1use anyhow::{Context, Result, anyhow};
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12use super::{DiffVerifier, WorktreeManager};
13
14#[derive(Debug, Clone)]
18pub struct ReconcileResult {
19 pub approved: bool,
21 pub merged: bool,
23 pub issues: Vec<String>,
25 pub reasoning: String,
27}
28
29pub struct WorktreeReconciler {
37 workspace_root: PathBuf,
38 main_branch: String,
39}
40
41impl WorktreeReconciler {
42 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 pub fn branch_name(worktree_name: &str) -> String {
58 format!("loop/{worktree_name}")
59 }
60
61 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 pub fn git_diff(&self, base: &str, head: &str) -> Result<String> {
86 self.run_git_diff(base, head, &[])
87 }
88
89 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 pub fn git_diff_stat(&self, base: &str, head: &str) -> Result<String> {
101 self.run_git_diff(base, head, &["--stat"])
102 }
103
104 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 pub fn git_merge(&self, branch: &str) -> Result<()> {
124 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 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 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 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 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 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 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 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 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 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 self.git_merge(&branch).context("Failed to merge approved branch")?;
266
267 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#[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}