Skip to main content

git_internal/internal/object/
patchset.rs

1//! AI PatchSet Definition
2//!
3//! A `PatchSet` represents a proposed set of code changes (diffs) generated
4//! by an agent during a [`Run`](super::run::Run). It is the atomic unit of
5//! code modification in the AI workflow — every change the agent wants to
6//! make to the repository is packaged as a PatchSet.
7//!
8//! # Relationships
9//!
10//! ```text
11//! Run ──patchsets──▶ [PatchSet₀, PatchSet₁, ...]
12//!                        │
13//!                        └──run──▶ Run  (back-reference)
14//! ```
15//!
16//! - **Run** (bidirectional): `Run.patchsets` holds the forward reference
17//!   (chronological generation history), `PatchSet.run` is the back-reference.
18//!
19//! # Lifecycle
20//!
21//! ```text
22//!   ┌──────────┐   agent produces diff   ┌──────────┐
23//!   │ (created)│ ───────────────────────▶ │ Proposed │
24//!   └──────────┘                          └────┬─────┘
25//!                                              │
26//!                          ┌───────────────────┼───────────────────┐
27//!                          │ validation/review  │                   │
28//!                          ▼ passes             ▼ fails             │
29//!                     ┌─────────┐          ┌──────────┐            │
30//!                     │ Applied │          │ Rejected │            │
31//!                     └─────────┘          └────┬─────┘            │
32//!                                               │                  │
33//!                                               ▼                  │
34//!                                  agent generates new PatchSet     │
35//!                                  appended to Run.patchsets        │
36//! ```
37//!
38//! 1. **Creation**: The orchestrator calls `PatchSet::new()`, which sets
39//!    `apply_status` to `Proposed`. At this point `artifact` is `None`
40//!    and `touched` is empty.
41//! 2. **Diff generation**: The agent produces a diff against `commit`
42//!    (the baseline Git commit). It sets `artifact` to point to the
43//!    stored diff content, populates `touched` with a file-level
44//!    summary, writes a `rationale`, and records the `format`.
45//! 3. **Review / validation**: The orchestrator or a human reviewer
46//!    inspects the PatchSet. Automated checks (tests, linting) may run.
47//! 4. **Applied**: If the diff passes, the orchestrator commits it to
48//!    the repository and transitions `apply_status` to `Applied`.
49//! 5. **Rejected**: If the diff fails validation or is rejected by a
50//!    reviewer, `apply_status` becomes `Rejected`. The agent may then
51//!    generate a new PatchSet appended to `Run.patchsets`.
52//!
53//! # Ordering
54//!
55//! PatchSet ordering is determined by position in `Run.patchsets`. If a
56//! PatchSet is rejected, the agent generates a new PatchSet and appends
57//! it to the Vec. The last entry is always the most recent attempt.
58//!
59//! # Content
60//!
61//! The actual diff content is stored as an [`ArtifactRef`] (via the
62//! `artifact` field), while [`TouchedFile`] (via the `touched` field)
63//! provides a lightweight file-level summary for UI and indexing.
64//! The `format` field indicates how to parse the artifact content
65//! (unified diff or git diff). The `rationale` field carries the
66//! agent's explanation of what was changed and why.
67
68use std::fmt;
69
70use serde::{Deserialize, Serialize};
71use uuid::Uuid;
72
73use crate::{
74    errors::GitError,
75    hash::ObjectHash,
76    internal::object::{
77        ObjectTrait,
78        integrity::IntegrityHash,
79        types::{ActorRef, ArtifactRef, Header, ObjectType},
80    },
81};
82
83/// Patch application status.
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85#[serde(rename_all = "snake_case")]
86pub enum ApplyStatus {
87    /// Patch is generated but not yet applied to the repo.
88    Proposed,
89    /// Patch has been applied (committed) to the repo.
90    Applied,
91    /// Patch was rejected by validation or user.
92    Rejected,
93}
94
95impl ApplyStatus {
96    pub fn as_str(&self) -> &'static str {
97        match self {
98            ApplyStatus::Proposed => "proposed",
99            ApplyStatus::Applied => "applied",
100            ApplyStatus::Rejected => "rejected",
101        }
102    }
103}
104
105impl fmt::Display for ApplyStatus {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        write!(f, "{}", self.as_str())
108    }
109}
110
111/// Diff format for patch content.
112#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
113#[serde(rename_all = "snake_case")]
114pub enum DiffFormat {
115    /// Standard unified diff format.
116    UnifiedDiff,
117    /// Git-specific diff format (with binary support etc).
118    GitDiff,
119}
120
121/// Type of change for a file.
122#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
123#[serde(rename_all = "snake_case")]
124pub enum ChangeType {
125    Add,
126    Modify,
127    Delete,
128    Rename,
129    Copy,
130}
131
132/// Touched file summary in a patchset.
133///
134/// Provides a quick overview of what files are modified without parsing the full diff.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct TouchedFile {
137    pub path: String,
138    pub change_type: ChangeType,
139    pub lines_added: u32,
140    pub lines_deleted: u32,
141}
142
143impl TouchedFile {
144    pub fn new(
145        path: impl Into<String>,
146        change_type: ChangeType,
147        lines_added: u32,
148        lines_deleted: u32,
149    ) -> Result<Self, String> {
150        let path = path.into();
151        if path.trim().is_empty() {
152            return Err("path cannot be empty".to_string());
153        }
154        Ok(Self {
155            path,
156            change_type,
157            lines_added,
158            lines_deleted,
159        })
160    }
161}
162
163/// PatchSet object containing a candidate diff.
164///
165/// Ordering between PatchSets is determined by their position in
166/// [`Run.patchsets`](super::run::Run). The PatchSet itself does not
167/// carry a generation number or supersession list.
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct PatchSet {
170    /// Common header (object ID, type, timestamps, creator, etc.).
171    #[serde(flatten)]
172    header: Header,
173    /// The [`Run`](super::run::Run) that generated this PatchSet.
174    /// `Run.patchsets` holds the forward reference and ordering.
175    run: Uuid,
176    /// Git commit hash the diff is based on.
177    commit: IntegrityHash,
178    /// Diff format used for the patch content (e.g. unified diff, git diff).
179    ///
180    /// Determines how the diff stored in `artifact` should be parsed.
181    /// `UnifiedDiff` is the standard format produced by `diff -u`;
182    /// `GitDiff` extends it with binary file support, rename detection,
183    /// and mode-change headers. The orchestrator sets this at creation
184    /// time based on the tool that generated the diff.
185    #[serde(alias = "diff_format")]
186    format: DiffFormat,
187    /// Reference to the actual diff content in object storage.
188    ///
189    /// Points to an [`ArtifactRef`] whose payload contains the full
190    /// diff text (or binary patch) in the encoding described by `format`.
191    /// `None` while the diff is still being generated; set once the
192    /// agent finishes producing the patch. Consumers fetch the artifact,
193    /// then interpret it according to `format`.
194    #[serde(alias = "diff_artifact")]
195    artifact: Option<ArtifactRef>,
196    /// Lightweight summary of files modified in this PatchSet.
197    ///
198    /// Each [`TouchedFile`] records a path, change type (add/modify/
199    /// delete/rename/copy), and line-count deltas. This allows UIs and
200    /// indexing pipelines to display a file-level overview without
201    /// downloading or parsing the full diff artifact. The list is
202    /// populated incrementally as the agent produces changes and should
203    /// be consistent with the actual diff content.
204    #[serde(default, alias = "touched_files")]
205    touched: Vec<TouchedFile>,
206    /// Human-readable explanation of the changes in this PatchSet.
207    ///
208    /// Serves a role analogous to a commit message or PR description,
209    /// bridging the gap between the high-level goal (Task/Plan) and
210    /// the raw diff (artifact).
211    ///
212    /// **Primary author**: the agent executing the Run. After producing
213    /// the diff, the agent summarises **what was changed and why** and
214    /// writes it here. A human reviewer may later overwrite or refine
215    /// the text via `set_rationale()` if the agent's explanation is
216    /// insufficient.
217    ///
218    /// When a Run produces multiple PatchSets (successive attempts),
219    /// each rationale captures the reasoning behind that specific
220    /// attempt, e.g.:
221    ///
222    /// - PatchSet₀: "Replaced session auth with JWT — breaks backward compat"
223    /// - PatchSet₁: "Gradual migration: accept both auth schemes"
224    ///
225    /// `None` only when the PatchSet is still being generated or the
226    /// agent did not provide an explanation. Reviewers should treat a
227    /// missing rationale as a signal to inspect the diff more carefully.
228    rationale: Option<String>,
229    /// Current application status of this PatchSet.
230    ///
231    /// Tracks whether the diff has been applied to the repository:
232    ///
233    /// - **`Proposed`** (initial): The diff has been generated but not
234    ///   yet committed. The orchestrator or a human reviewer can inspect
235    ///   the artifact, run validation, and decide whether to apply.
236    /// - **`Applied`**: The diff has been committed to the repository.
237    ///   Once applied, the PatchSet is immutable — further changes
238    ///   require a new PatchSet in the same Run.
239    /// - **`Rejected`**: The diff was rejected by automated validation
240    ///   (e.g. tests failed) or by a human reviewer. The agent may
241    ///   generate a new PatchSet appended to `Run.patchsets` to retry.
242    ///
243    /// Transitions: `Proposed → Applied` or `Proposed → Rejected`.
244    /// No other transitions are valid.
245    apply_status: ApplyStatus,
246}
247
248impl PatchSet {
249    /// Create a new patchset object.
250    pub fn new(created_by: ActorRef, run: Uuid, commit: impl AsRef<str>) -> Result<Self, String> {
251        let commit = commit.as_ref().parse()?;
252        Ok(Self {
253            header: Header::new(ObjectType::PatchSet, created_by)?,
254            run,
255            commit,
256            format: DiffFormat::UnifiedDiff,
257            artifact: None,
258            touched: Vec::new(),
259            rationale: None,
260            apply_status: ApplyStatus::Proposed,
261        })
262    }
263
264    pub fn header(&self) -> &Header {
265        &self.header
266    }
267
268    pub fn run(&self) -> Uuid {
269        self.run
270    }
271
272    pub fn commit(&self) -> &IntegrityHash {
273        &self.commit
274    }
275
276    pub fn format(&self) -> &DiffFormat {
277        &self.format
278    }
279
280    pub fn artifact(&self) -> Option<&ArtifactRef> {
281        self.artifact.as_ref()
282    }
283
284    pub fn touched(&self) -> &[TouchedFile] {
285        &self.touched
286    }
287
288    pub fn rationale(&self) -> Option<&str> {
289        self.rationale.as_deref()
290    }
291
292    pub fn apply_status(&self) -> &ApplyStatus {
293        &self.apply_status
294    }
295
296    pub fn set_artifact(&mut self, artifact: Option<ArtifactRef>) {
297        self.artifact = artifact;
298    }
299
300    pub fn add_touched(&mut self, file: TouchedFile) {
301        self.touched.push(file);
302    }
303
304    pub fn set_rationale(&mut self, rationale: Option<String>) {
305        self.rationale = rationale;
306    }
307
308    pub fn set_apply_status(&mut self, apply_status: ApplyStatus) {
309        self.apply_status = apply_status;
310    }
311}
312
313impl fmt::Display for PatchSet {
314    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315        write!(f, "PatchSet: {}", self.header.object_id())
316    }
317}
318
319impl ObjectTrait for PatchSet {
320    fn from_bytes(data: &[u8], _hash: ObjectHash) -> Result<Self, GitError>
321    where
322        Self: Sized,
323    {
324        serde_json::from_slice(data).map_err(|e| GitError::InvalidObjectInfo(e.to_string()))
325    }
326
327    fn get_type(&self) -> ObjectType {
328        ObjectType::PatchSet
329    }
330
331    fn get_size(&self) -> usize {
332        match serde_json::to_vec(self) {
333            Ok(v) => v.len(),
334            Err(e) => {
335                tracing::warn!("failed to compute PatchSet size: {}", e);
336                0
337            }
338        }
339    }
340
341    fn to_data(&self) -> Result<Vec<u8>, GitError> {
342        serde_json::to_vec(self).map_err(|e| GitError::InvalidObjectInfo(e.to_string()))
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    fn test_hash_hex() -> String {
351        IntegrityHash::compute(b"ai-process-test").to_hex()
352    }
353
354    #[test]
355    fn test_patchset_creation() {
356        let actor = ActorRef::agent("test-agent").expect("actor");
357        let run = Uuid::from_u128(0x1);
358        let base_hash = test_hash_hex();
359
360        let patchset = PatchSet::new(actor, run, &base_hash).expect("patchset");
361
362        assert_eq!(patchset.header().object_type(), &ObjectType::PatchSet);
363        assert_eq!(patchset.run(), run);
364        assert_eq!(patchset.format(), &DiffFormat::UnifiedDiff);
365        assert_eq!(patchset.apply_status(), &ApplyStatus::Proposed);
366        assert!(patchset.touched().is_empty());
367    }
368}