Skip to main content

vtcode_core/git/
reconciler.rs

1//! Worktree reconciliation: diff, verify, merge.
2//!
3//! After a worktree-isolated subagent finishes, the `WorktreeReconciler`
4//! captures the branch diff, asks a [`DiffVerifier`]
5//! to approve or reject it, and merges approved changes back into the main
6//! branch.
7
8use anyhow::{Context, Result, anyhow};
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12use super::{DiffVerifier, WorktreeManager};
13
14// ─── ReconcileResult ────────────────────────────────────────────────────────
15
16/// Outcome of a reconcile cycle.
17#[derive(Debug, Clone)]
18pub struct ReconcileResult {
19    /// Whether the verifier approved the change.
20    pub approved: bool,
21    /// Whether the branch was successfully merged into main.
22    pub merged: bool,
23    /// Concrete issues identified by the verifier (empty if approved).
24    pub issues: Vec<String>,
25    /// Free-text reasoning from the verifier or error description.
26    pub reasoning: String,
27}
28
29// ─── WorktreeReconciler ─────────────────────────────────────────────────────
30
31/// Orchestrates the diff → verify → merge cycle for a completed worktree.
32///
33/// The reconciler is internal infrastructure and uses `Command::new("git")`
34/// directly (consistent with `WorktreeManager`), bypassing the tool safety
35/// layer.
36pub struct WorktreeReconciler {
37    workspace_root: PathBuf,
38    main_branch: String,
39}
40
41impl WorktreeReconciler {
42    /// Create a reconciler for the given workspace.
43    ///
44    /// `main_branch` defaults to `"main"` if empty.
45    pub fn new(workspace_root: impl Into<PathBuf>, main_branch: &str) -> Self {
46        Self {
47            workspace_root: workspace_root.into(),
48            main_branch: if main_branch.is_empty() {
49                "main".to_string()
50            } else {
51                main_branch.to_string()
52            },
53        }
54    }
55
56    /// The branch name for a given worktree name (convention: `loop/{name}`).
57    pub fn branch_name(worktree_name: &str) -> String {
58        format!("loop/{worktree_name}")
59    }
60
61    // ── Git helpers ──────────────────────────────────────────────────────
62
63    /// Run a `git diff` variant with extra args and return stdout.
64    fn run_git_diff(&self, base: &str, head: &str, extra_args: &[&str]) -> Result<String> {
65        let range = format!("{base}...{head}");
66        let mut args = vec!["diff"];
67        args.extend_from_slice(extra_args);
68        args.push(&range);
69
70        let output = Command::new("git")
71            .args(&args)
72            .current_dir(&self.workspace_root)
73            .output()
74            .context("Failed to run git diff")?;
75
76        if !output.status.success() {
77            let stderr = String::from_utf8_lossy(&output.stderr);
78            return Err(anyhow!("git diff failed: {}", stderr.trim()));
79        }
80
81        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
82    }
83
84    /// Run `git diff {base}...{head}` and return the unified diff text.
85    pub fn git_diff(&self, base: &str, head: &str) -> Result<String> {
86        self.run_git_diff(base, head, &[])
87    }
88
89    /// Run `git diff --name-only {base}...{head}` and return changed file paths.
90    pub fn git_diff_name_only(&self, base: &str, head: &str) -> Result<Vec<PathBuf>> {
91        let stdout = self.run_git_diff(base, head, &["--name-only"])?;
92        Ok(stdout
93            .lines()
94            .filter(|l| !l.trim().is_empty())
95            .map(|l| PathBuf::from(l.trim()))
96            .collect())
97    }
98
99    /// Run `git diff --stat {base}...{head}` and return the summary text.
100    pub fn git_diff_stat(&self, base: &str, head: &str) -> Result<String> {
101        self.run_git_diff(base, head, &["--stat"])
102    }
103
104    /// Check whether a branch has any commits ahead of the base.
105    pub fn branch_has_commits(&self, base: &str, head: &str) -> Result<bool> {
106        let range = format!("{base}..{head}");
107        let output = Command::new("git")
108            .args(["log", "--oneline", &range])
109            .current_dir(&self.workspace_root)
110            .output()
111            .context("Failed to run git log")?;
112
113        if !output.status.success() {
114            let stderr = String::from_utf8_lossy(&output.stderr);
115            return Err(anyhow!("git log failed: {}", stderr.trim()));
116        }
117
118        Ok(!output.stdout.is_empty())
119    }
120
121    /// Merge a branch into the workspace. Prefers `--ff-only`; falls back
122    /// to a standard merge if fast-forward is not possible.
123    pub fn git_merge(&self, branch: &str) -> Result<()> {
124        // Try fast-forward first.
125        let ff_output = Command::new("git")
126            .args(["merge", "--ff-only", branch])
127            .current_dir(&self.workspace_root)
128            .output()
129            .context("Failed to run git merge --ff-only")?;
130
131        if ff_output.status.success() {
132            return Ok(());
133        }
134
135        // Fall back to standard merge.
136        let output = Command::new("git")
137            .args(["merge", branch])
138            .current_dir(&self.workspace_root)
139            .output()
140            .context("Failed to run git merge")?;
141
142        if !output.status.success() {
143            // Abort the failed merge to leave the repo clean.
144            let abort_output = Command::new("git")
145                .args(["merge", "--abort"])
146                .current_dir(&self.workspace_root)
147                .output();
148            match abort_output {
149                Ok(o) if !o.status.success() => {
150                    tracing::warn!(
151                        stderr = %String::from_utf8_lossy(&o.stderr),
152                        "git merge --abort failed; repo may be in a dirty state"
153                    );
154                }
155                Err(e) => {
156                    tracing::warn!(error = %e, "Failed to spawn git merge --abort");
157                }
158                _ => {}
159            }
160            let stderr = String::from_utf8_lossy(&output.stderr);
161            return Err(anyhow!("git merge failed: {}", stderr.trim()));
162        }
163
164        Ok(())
165    }
166
167    /// Delete a local branch (must be fully merged).
168    pub fn git_branch_delete(&self, branch: &str) -> Result<()> {
169        let output = Command::new("git")
170            .args(["branch", "-d", branch])
171            .current_dir(&self.workspace_root)
172            .output()
173            .context("Failed to run git branch -d")?;
174
175        if !output.status.success() {
176            let stderr = String::from_utf8_lossy(&output.stderr);
177            return Err(anyhow!("git branch -d failed: {}", stderr.trim()));
178        }
179
180        Ok(())
181    }
182
183    /// Check whether a worktree working directory is clean (no uncommitted changes).
184    pub fn is_worktree_clean(&self, worktree_path: &Path) -> Result<bool> {
185        let output = Command::new("git")
186            .args(["status", "--porcelain"])
187            .current_dir(worktree_path)
188            .output()
189            .context("Failed to run git status")?;
190
191        if !output.status.success() {
192            let stderr = String::from_utf8_lossy(&output.stderr);
193            return Err(anyhow!("git status failed: {}", stderr.trim()));
194        }
195
196        Ok(output.stdout.is_empty())
197    }
198
199    // ── Core reconcile ───────────────────────────────────────────────────
200
201    /// Reconcile a completed worktree: diff → verify → merge → cleanup.
202    ///
203    /// 1. Verify the worktree is clean.
204    /// 2. Check the branch has commits ahead of main.
205    /// 3. Capture the diff and changed files.
206    /// 4. Ask `verifier` to approve or reject the diff.
207    /// 5. If approved, merge the branch and clean up the worktree.
208    /// 6. If rejected, return the result without merging.
209    ///
210    /// `verifier` receives `(diff_text, changed_files)` and returns a verdict.
211    /// This decouples the reconciler from `SubagentController` to keep the git
212    /// module free of subagent dependencies.
213    pub fn reconcile(
214        &self,
215        worktree_name: &str,
216        worktree_path: &Path,
217        verifier: &(dyn DiffVerifier + Send + Sync),
218    ) -> Result<ReconcileResult> {
219        let branch = Self::branch_name(worktree_name);
220
221        // 1. Pre-flight: worktree must be clean.
222        if !self.is_worktree_clean(worktree_path)? {
223            return Ok(ReconcileResult {
224                approved: false,
225                merged: false,
226                issues: vec!["Worktree has uncommitted changes".to_string()],
227                reasoning: "Subagent did not commit all changes before completing.".to_string(),
228            });
229        }
230
231        // 2. Check for commits.
232        if !self.branch_has_commits(&self.main_branch, &branch)? {
233            return Ok(ReconcileResult {
234                approved: true,
235                merged: false,
236                issues: Vec::new(),
237                reasoning: "No commits on branch; nothing to merge.".to_string(),
238            });
239        }
240
241        // 3. Capture diff.
242        let diff_text = self.git_diff(&self.main_branch, &branch)?;
243        let changed_files = self.git_diff_name_only(&self.main_branch, &branch)?;
244
245        if diff_text.trim().is_empty() {
246            return Ok(ReconcileResult {
247                approved: true,
248                merged: false,
249                issues: Vec::new(),
250                reasoning: "Empty diff; nothing to merge.".to_string(),
251            });
252        }
253
254        // 4. Verify.
255        let verdict = verifier.verify(&diff_text, &changed_files)?;
256        let approved = verdict.approved;
257        let issues = verdict.issues;
258        let reasoning = verdict.reasoning;
259
260        if !approved {
261            return Ok(ReconcileResult { approved: false, merged: false, issues, reasoning });
262        }
263
264        // 5. Merge.
265        self.git_merge(&branch).context("Failed to merge approved branch")?;
266
267        // 6. Cleanup: delete branch and remove worktree.
268        if let Err(e) = self.git_branch_delete(&branch) {
269            tracing::warn!(error = %e, branch = %branch, "Failed to delete branch after merge");
270        }
271        let wm = WorktreeManager::new(&self.workspace_root);
272        if let Err(e) = wm.remove(worktree_name) {
273            tracing::warn!(error = %e, worktree = %worktree_name, "Failed to remove worktree after merge");
274        }
275
276        Ok(ReconcileResult { approved: true, merged: true, issues, reasoning })
277    }
278}
279
280// ─── Tests ──────────────────────────────────────────────────────────────────
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn branch_name_format() {
288        assert_eq!(WorktreeReconciler::branch_name("my-loop"), "loop/my-loop");
289        assert_eq!(WorktreeReconciler::branch_name("test_1"), "loop/test_1");
290    }
291
292    #[test]
293    fn reconciler_defaults_main_branch() {
294        let r = WorktreeReconciler::new("/tmp/ws", "");
295        assert_eq!(r.main_branch, "main");
296    }
297
298    #[test]
299    fn reconciler_custom_main_branch() {
300        let r = WorktreeReconciler::new("/tmp/ws", "develop");
301        assert_eq!(r.main_branch, "develop");
302    }
303}