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}