1use anyhow::{Context, Result};
7use directories::ProjectDirs;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use tracing::{debug, info, warn};
11use uuid::Uuid;
12
13#[derive(Debug, Clone)]
15#[allow(dead_code)]
16pub struct WorktreeInfo {
17 pub id: String,
19 pub path: PathBuf,
21 pub branch: String,
23 pub repo_path: PathBuf,
25 pub parent_branch: String,
27}
28
29pub struct WorktreeManager {
31 base_dir: PathBuf,
33 repo_path: PathBuf,
35}
36
37impl WorktreeManager {
38 pub fn new(repo_path: impl AsRef<Path>) -> Result<Self> {
40 let repo_path = repo_path.as_ref().to_path_buf();
41
42 let repo_name = repo_path
44 .file_name()
45 .map(|n| n.to_string_lossy().to_string())
46 .unwrap_or_else(|| "unknown".to_string());
47
48 let base_dir = ProjectDirs::from("com", "codetether", "codetether-agent")
49 .map(|p| p.data_dir().to_path_buf())
50 .unwrap_or_else(|| PathBuf::from("/tmp/.codetether"))
51 .join("worktrees")
52 .join(&repo_name);
53
54 std::fs::create_dir_all(&base_dir)?;
55
56 Ok(Self {
57 base_dir,
58 repo_path,
59 })
60 }
61
62 pub fn create(&self, task_slug: &str) -> Result<WorktreeInfo> {
64 let id = format!("{}-{}", task_slug, &Uuid::new_v4().to_string()[..8]);
65 let branch = format!("codetether/subagent-{}", id);
66 let worktree_path = self.base_dir.join(&id);
67
68 let parent_branch = self.current_branch()?;
70
71 info!(
72 worktree_id = %id,
73 branch = %branch,
74 path = %worktree_path.display(),
75 parent_branch = %parent_branch,
76 "Creating worktree"
77 );
78
79 let output = Command::new("git")
81 .args([
82 "worktree",
83 "add",
84 "-b",
85 &branch,
86 worktree_path.to_str().unwrap(),
87 "HEAD",
88 ])
89 .current_dir(&self.repo_path)
90 .output()
91 .context("Failed to run git worktree add")?;
92
93 if !output.status.success() {
94 let stderr = String::from_utf8_lossy(&output.stderr);
95 return Err(anyhow::anyhow!("git worktree add failed: {}", stderr));
96 }
97
98 debug!(
99 worktree_id = %id,
100 "Worktree created successfully"
101 );
102
103 Ok(WorktreeInfo {
104 id,
105 path: worktree_path,
106 branch,
107 repo_path: self.repo_path.clone(),
108 parent_branch,
109 })
110 }
111
112 pub fn inject_workspace_stub(&self, worktree_path: &Path) -> Result<()> {
117 let cargo_toml = worktree_path.join("Cargo.toml");
118 if !cargo_toml.exists() {
119 return Ok(()); }
121
122 let content = std::fs::read_to_string(&cargo_toml).context("Failed to read Cargo.toml")?;
123
124 if content.contains("[workspace]") {
126 return Ok(());
127 }
128
129 let new_content = format!("[workspace]\n\n{}", content);
131 std::fs::write(&cargo_toml, new_content)
132 .context("Failed to write Cargo.toml with workspace stub")?;
133
134 info!(
135 cargo_toml = %cargo_toml.display(),
136 "Injected [workspace] stub for hermetic isolation"
137 );
138
139 Ok(())
140 }
141
142 fn current_branch(&self) -> Result<String> {
144 let output = Command::new("git")
145 .args(["rev-parse", "--abbrev-ref", "HEAD"])
146 .current_dir(&self.repo_path)
147 .output()
148 .context("Failed to get current branch")?;
149
150 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
151 }
152
153 pub fn merge(&self, worktree: &WorktreeInfo) -> Result<MergeResult> {
155 info!(
156 worktree_id = %worktree.id,
157 branch = %worktree.branch,
158 target = %worktree.parent_branch,
159 "Merging worktree changes"
160 );
161
162 let diff_output = Command::new("git")
164 .args([
165 "diff",
166 "--stat",
167 &format!("{}..{}", worktree.parent_branch, worktree.branch),
168 ])
169 .current_dir(&self.repo_path)
170 .output()?;
171
172 let diff_stat = String::from_utf8_lossy(&diff_output.stdout).to_string();
173
174 let files_changed = diff_stat.lines().filter(|l| l.contains('|')).count();
176
177 if files_changed == 0 {
179 warn!(
180 worktree_id = %worktree.id,
181 "Sub-agent made NO file changes - lazy or stuck?"
182 );
183 } else {
184 let full_diff = Command::new("git")
186 .args([
187 "diff",
188 &format!("{}..{}", worktree.parent_branch, worktree.branch),
189 ])
190 .current_dir(&self.repo_path)
191 .output()?;
192 let diff_content = String::from_utf8_lossy(&full_diff.stdout);
193
194 let lazy_indicators = [
196 ("TODO", diff_content.matches("TODO").count()),
197 ("FIXME", diff_content.matches("FIXME").count()),
198 (
199 "unimplemented!",
200 diff_content.matches("unimplemented!").count(),
201 ),
202 ("todo!", diff_content.matches("todo!").count()),
203 ("panic!", diff_content.matches("panic!").count()),
204 ];
205
206 let lazy_count: usize = lazy_indicators.iter().map(|(_, c)| c).sum();
207 if lazy_count > 0 {
208 let lazy_details: Vec<String> = lazy_indicators
209 .iter()
210 .filter(|(_, c)| *c > 0)
211 .map(|(name, c)| format!("{}:{}", name, c))
212 .collect();
213 warn!(
214 worktree_id = %worktree.id,
215 lazy_markers = %lazy_details.join(", "),
216 "Sub-agent left lazy markers - review carefully!"
217 );
218 }
219
220 info!(
221 worktree_id = %worktree.id,
222 files_changed = files_changed,
223 diff_summary = %diff_stat.trim(),
224 "Sub-agent changes to merge"
225 );
226 }
227
228 let merge_output = Command::new("git")
230 .args([
231 "merge",
232 "--no-ff",
233 "-m",
234 &format!("Merge subagent worktree: {}", worktree.id),
235 &worktree.branch,
236 ])
237 .current_dir(&self.repo_path)
238 .output()
239 .context("Failed to run git merge")?;
240
241 if merge_output.status.success() {
242 info!(
243 worktree_id = %worktree.id,
244 files_changed = files_changed,
245 "Merge successful"
246 );
247
248 Ok(MergeResult {
249 success: true,
250 conflicts: Vec::new(),
251 conflict_diffs: Vec::new(),
252 files_changed,
253 summary: format!(
254 "Merged {} files from subagent {}",
255 files_changed, worktree.id
256 ),
257 aborted: false,
258 })
259 } else {
260 let stderr = String::from_utf8_lossy(&merge_output.stderr).to_string();
261 let stdout = String::from_utf8_lossy(&merge_output.stdout).to_string();
262
263 let conflicts: Vec<String> =
265 if stderr.contains("CONFLICT") || stdout.contains("CONFLICT") {
266 let status = Command::new("git")
268 .args(["diff", "--name-only", "--diff-filter=U"])
269 .current_dir(&self.repo_path)
270 .output()?;
271
272 String::from_utf8_lossy(&status.stdout)
273 .lines()
274 .map(|s| s.to_string())
275 .collect()
276 } else {
277 Vec::new()
278 };
279
280 if stderr.contains("Already up to date") || stdout.contains("Already up to date") {
282 warn!(
283 worktree_id = %worktree.id,
284 "Merge says 'Already up to date' - sub-agent may have made no commits"
285 );
286 } else if conflicts.is_empty() {
287 warn!(
289 worktree_id = %worktree.id,
290 stderr = %stderr.trim(),
291 stdout = %stdout.trim(),
292 "Merge failed for unknown reason (not conflicts)"
293 );
294 } else {
295 warn!(
296 worktree_id = %worktree.id,
297 conflicts = ?conflicts,
298 "Merge had conflicts - sub-agent's changes conflict with main"
299 );
300 }
301
302 let conflict_diffs: Vec<(String, String)> = if !conflicts.is_empty() {
304 conflicts
305 .iter()
306 .filter_map(|file| {
307 let output = Command::new("git")
308 .args(["diff", file])
309 .current_dir(&self.repo_path)
310 .output()
311 .ok()?;
312 let diff = String::from_utf8_lossy(&output.stdout).to_string();
313 if diff.is_empty() {
314 None
315 } else {
316 Some((file.clone(), diff))
317 }
318 })
319 .collect()
320 } else {
321 Vec::new()
322 };
323
324 let aborted = if conflicts.is_empty() {
327 let _ = Command::new("git")
328 .args(["merge", "--abort"])
329 .current_dir(&self.repo_path)
330 .output();
331 true
332 } else {
333 info!(
335 worktree_id = %worktree.id,
336 num_conflicts = conflicts.len(),
337 "Leaving merge in conflicted state for resolution"
338 );
339 false
340 };
341
342 Ok(MergeResult {
343 success: false,
344 conflicts,
345 conflict_diffs,
346 files_changed: 0,
347 summary: format!("Merge failed: {}", stderr),
348 aborted,
349 })
350 }
351 }
352
353 pub fn complete_merge(
355 &self,
356 worktree: &WorktreeInfo,
357 commit_message: &str,
358 ) -> Result<MergeResult> {
359 info!(
360 worktree_id = %worktree.id,
361 "Completing merge after conflict resolution"
362 );
363
364 let add_output = Command::new("git")
366 .args(["add", "-A"])
367 .current_dir(&self.repo_path)
368 .output()
369 .context("Failed to stage resolved files")?;
370
371 if !add_output.status.success() {
372 let stderr = String::from_utf8_lossy(&add_output.stderr);
373 warn!(error = %stderr, "Failed to stage resolved files");
374 }
375
376 let commit_output = Command::new("git")
378 .args(["commit", "-m", commit_message])
379 .current_dir(&self.repo_path)
380 .output()
381 .context("Failed to complete merge commit")?;
382
383 if commit_output.status.success() {
384 let stat_output = Command::new("git")
386 .args(["diff", "--stat", "HEAD~1", "HEAD"])
387 .current_dir(&self.repo_path)
388 .output()?;
389 let diff_stat = String::from_utf8_lossy(&stat_output.stdout);
390 let files_changed = diff_stat.lines().filter(|l| l.contains('|')).count();
391
392 info!(
393 worktree_id = %worktree.id,
394 files_changed = files_changed,
395 "Merge completed after conflict resolution"
396 );
397
398 Ok(MergeResult {
399 success: true,
400 conflicts: Vec::new(),
401 conflict_diffs: Vec::new(),
402 files_changed,
403 summary: format!("Merge completed after resolving conflicts"),
404 aborted: false,
405 })
406 } else {
407 let stderr = String::from_utf8_lossy(&commit_output.stderr).to_string();
408 warn!(error = %stderr, "Failed to complete merge commit");
409
410 Ok(MergeResult {
411 success: false,
412 conflicts: Vec::new(),
413 conflict_diffs: Vec::new(),
414 files_changed: 0,
415 summary: format!("Failed to complete merge: {}", stderr),
416 aborted: false,
417 })
418 }
419 }
420
421 pub fn abort_merge(&self) -> Result<()> {
423 info!("Aborting merge in progress");
424 let _ = Command::new("git")
425 .args(["merge", "--abort"])
426 .current_dir(&self.repo_path)
427 .output();
428 Ok(())
429 }
430
431 pub fn is_merging(&self) -> bool {
433 self.repo_path.join(".git/MERGE_HEAD").exists()
434 }
435
436 pub fn cleanup(&self, worktree: &WorktreeInfo) -> Result<()> {
438 info!(
439 worktree_id = %worktree.id,
440 path = %worktree.path.display(),
441 "Cleaning up worktree"
442 );
443
444 let output = Command::new("git")
446 .args([
447 "worktree",
448 "remove",
449 "--force",
450 worktree.path.to_str().unwrap(),
451 ])
452 .current_dir(&self.repo_path)
453 .output();
454
455 if let Err(e) = output {
456 warn!(error = %e, "Failed to remove worktree via git");
457 let _ = std::fs::remove_dir_all(&worktree.path);
459 }
460
461 let _ = Command::new("git")
463 .args(["branch", "-D", &worktree.branch])
464 .current_dir(&self.repo_path)
465 .output();
466
467 debug!(
468 worktree_id = %worktree.id,
469 "Worktree cleanup complete"
470 );
471
472 Ok(())
473 }
474
475 #[allow(dead_code)]
477 pub fn list(&self) -> Result<Vec<WorktreeInfo>> {
478 let output = Command::new("git")
479 .args(["worktree", "list", "--porcelain"])
480 .current_dir(&self.repo_path)
481 .output()
482 .context("Failed to list worktrees")?;
483
484 let stdout = String::from_utf8_lossy(&output.stdout);
485 let mut worktrees = Vec::new();
486
487 let mut current_path: Option<PathBuf> = None;
488 let mut current_branch: Option<String> = None;
489
490 for line in stdout.lines() {
491 if let Some(path) = line.strip_prefix("worktree ") {
492 current_path = Some(PathBuf::from(path));
493 } else if let Some(branch) = line.strip_prefix("branch refs/heads/") {
494 current_branch = Some(branch.to_string());
495 } else if line.is_empty() {
496 if let (Some(path), Some(branch)) = (current_path.take(), current_branch.take()) {
497 if branch.starts_with("codetether/subagent-") {
499 let id = branch
500 .strip_prefix("codetether/subagent-")
501 .unwrap_or(&branch)
502 .to_string();
503
504 worktrees.push(WorktreeInfo {
505 id,
506 path,
507 branch,
508 repo_path: self.repo_path.clone(),
509 parent_branch: String::new(), });
511 }
512 }
513 }
514 }
515
516 Ok(worktrees)
517 }
518
519 pub fn cleanup_all(&self) -> Result<usize> {
521 let worktrees = self.list()?;
522 let count = worktrees.len();
523
524 for wt in worktrees {
525 if let Err(e) = self.cleanup(&wt) {
526 warn!(worktree_id = %wt.id, error = %e, "Failed to cleanup worktree");
527 }
528 }
529
530 let _ = Command::new("git")
532 .args(["worktree", "prune"])
533 .current_dir(&self.repo_path)
534 .output();
535
536 let orphaned = self.cleanup_orphaned_branches()?;
538
539 Ok(count + orphaned)
540 }
541
542 pub fn cleanup_orphaned_branches(&self) -> Result<usize> {
544 let output = Command::new("git")
546 .args(["branch", "--list", "codetether/subagent-*"])
547 .current_dir(&self.repo_path)
548 .output()
549 .context("Failed to list branches")?;
550
551 let stdout = String::from_utf8_lossy(&output.stdout);
552 let branches: Vec<&str> = stdout
553 .lines()
554 .map(|l| l.trim().trim_start_matches("* "))
555 .filter(|l| !l.is_empty())
556 .collect();
557
558 let active_worktrees = self.list()?;
560 let active_branches: std::collections::HashSet<&str> = active_worktrees
561 .iter()
562 .map(|wt| wt.branch.as_str())
563 .collect();
564
565 let mut deleted = 0;
566 for branch in branches {
567 if !active_branches.contains(branch) {
568 info!(branch = %branch, "Deleting orphaned subagent branch");
569 let result = Command::new("git")
570 .args(["branch", "-D", branch])
571 .current_dir(&self.repo_path)
572 .output();
573
574 match result {
575 Ok(output) if output.status.success() => {
576 deleted += 1;
577 }
578 Ok(output) => {
579 let stderr = String::from_utf8_lossy(&output.stderr);
580 warn!(branch = %branch, error = %stderr, "Failed to delete orphaned branch");
581 }
582 Err(e) => {
583 warn!(branch = %branch, error = %e, "Failed to run git branch -D");
584 }
585 }
586 }
587 }
588
589 if deleted > 0 {
590 info!(count = deleted, "Cleaned up orphaned subagent branches");
591 }
592
593 Ok(deleted)
594 }
595}
596
597#[derive(Debug, Clone)]
599pub struct MergeResult {
600 pub success: bool,
601 pub conflicts: Vec<String>,
602 pub conflict_diffs: Vec<(String, String)>,
604 pub files_changed: usize,
605 pub summary: String,
606 pub aborted: bool,
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613 use tempfile::TempDir;
614
615 fn setup_test_repo() -> Result<(TempDir, PathBuf)> {
616 let temp = TempDir::new()?;
617 let repo_path = temp.path().to_path_buf();
618
619 Command::new("git")
621 .args(["init"])
622 .current_dir(&repo_path)
623 .output()?;
624
625 Command::new("git")
626 .args(["config", "user.email", "test@test.com"])
627 .current_dir(&repo_path)
628 .output()?;
629
630 Command::new("git")
631 .args(["config", "user.name", "Test"])
632 .current_dir(&repo_path)
633 .output()?;
634
635 std::fs::write(repo_path.join("README.md"), "# Test")?;
637 Command::new("git")
638 .args(["add", "."])
639 .current_dir(&repo_path)
640 .output()?;
641 Command::new("git")
642 .args(["commit", "-m", "Initial commit"])
643 .current_dir(&repo_path)
644 .output()?;
645
646 Ok((temp, repo_path))
647 }
648
649 #[test]
650 fn test_create_worktree() -> Result<()> {
651 let (_temp, repo_path) = setup_test_repo()?;
652 let manager = WorktreeManager::new(&repo_path)?;
653
654 let wt = manager.create("test-task")?;
655
656 assert!(wt.path.exists());
657 assert!(wt.branch.starts_with("codetether/subagent-test-task-"));
658
659 manager.cleanup(&wt)?;
661 assert!(!wt.path.exists());
662
663 Ok(())
664 }
665}