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 fn auto_commit_worktree(&self, worktree: &WorktreeInfo) -> Result<bool> {
159 let status = Command::new("git")
161 .args(["status", "--porcelain"])
162 .current_dir(&worktree.path)
163 .output()
164 .context("Failed to check worktree status")?;
165
166 let status_output = String::from_utf8_lossy(&status.stdout);
167 if status_output.trim().is_empty() {
168 debug!(
169 worktree_id = %worktree.id,
170 "No uncommitted changes in worktree"
171 );
172 return Ok(false);
173 }
174
175 let changed_files = status_output.lines().count();
176 info!(
177 worktree_id = %worktree.id,
178 changed_files = changed_files,
179 "Auto-committing sub-agent changes in worktree"
180 );
181
182 let add_output = Command::new("git")
184 .args(["add", "-A"])
185 .current_dir(&worktree.path)
186 .output()
187 .context("Failed to stage worktree changes")?;
188
189 if !add_output.status.success() {
190 let stderr = String::from_utf8_lossy(&add_output.stderr);
191 warn!(
192 worktree_id = %worktree.id,
193 error = %stderr,
194 "git add -A failed in worktree"
195 );
196 return Err(anyhow::anyhow!("Failed to stage changes: {}", stderr));
197 }
198
199 let commit_msg = format!("subagent({}): automated work", worktree.id);
201 let commit_output = Command::new("git")
202 .args(["commit", "-m", &commit_msg])
203 .current_dir(&worktree.path)
204 .output()
205 .context("Failed to commit worktree changes")?;
206
207 if !commit_output.status.success() {
208 let stderr = String::from_utf8_lossy(&commit_output.stderr);
209 if stderr.contains("nothing to commit") {
211 debug!(worktree_id = %worktree.id, "Nothing to commit after staging");
212 return Ok(false);
213 }
214 warn!(
215 worktree_id = %worktree.id,
216 error = %stderr,
217 "git commit failed in worktree"
218 );
219 return Err(anyhow::anyhow!("Failed to commit changes: {}", stderr));
220 }
221
222 info!(
223 worktree_id = %worktree.id,
224 changed_files = changed_files,
225 "Auto-committed sub-agent changes"
226 );
227 Ok(true)
228 }
229
230 pub fn merge(&self, worktree: &WorktreeInfo) -> Result<MergeResult> {
232 info!(
233 worktree_id = %worktree.id,
234 branch = %worktree.branch,
235 target = %worktree.parent_branch,
236 "Merging worktree changes"
237 );
238
239 match self.auto_commit_worktree(worktree) {
241 Ok(committed) => {
242 if committed {
243 info!(worktree_id = %worktree.id, "Auto-committed sub-agent changes before merge");
244 }
245 }
246 Err(e) => {
247 warn!(
248 worktree_id = %worktree.id,
249 error = %e,
250 "Failed to auto-commit worktree changes — merge may show nothing"
251 );
252 }
253 }
254
255 if self.is_merging() {
257 warn!(
258 worktree_id = %worktree.id,
259 "Merge already in progress - cannot start new merge"
260 );
261 return Ok(MergeResult {
262 success: false,
263 conflicts: Vec::new(),
264 conflict_diffs: Vec::new(),
265 files_changed: 0,
266 summary: "Merge already in progress".to_string(),
267 aborted: false,
268 });
269 }
270
271 let diff_output = Command::new("git")
273 .args([
274 "diff",
275 "--stat",
276 &format!("{}..{}", worktree.parent_branch, worktree.branch),
277 ])
278 .current_dir(&self.repo_path)
279 .output()?;
280
281 let diff_stat = String::from_utf8_lossy(&diff_output.stdout).to_string();
282
283 let files_changed = diff_stat.lines().filter(|l| l.contains('|')).count();
285
286 if files_changed == 0 {
288 warn!(
289 worktree_id = %worktree.id,
290 "Sub-agent made NO file changes - lazy or stuck?"
291 );
292 } else {
293 let full_diff = Command::new("git")
295 .args([
296 "diff",
297 &format!("{}..{}", worktree.parent_branch, worktree.branch),
298 ])
299 .current_dir(&self.repo_path)
300 .output()?;
301 let diff_content = String::from_utf8_lossy(&full_diff.stdout);
302
303 let lazy_indicators = [
305 ("TODO", diff_content.matches("TODO").count()),
306 ("FIXME", diff_content.matches("FIXME").count()),
307 (
308 "unimplemented!",
309 diff_content.matches("unimplemented!").count(),
310 ),
311 ("todo!", diff_content.matches("todo!").count()),
312 ("panic!", diff_content.matches("panic!").count()),
313 ];
314
315 let lazy_count: usize = lazy_indicators.iter().map(|(_, c)| c).sum();
316 if lazy_count > 0 {
317 let lazy_details: Vec<String> = lazy_indicators
318 .iter()
319 .filter(|(_, c)| *c > 0)
320 .map(|(name, c)| format!("{}:{}", name, c))
321 .collect();
322 warn!(
323 worktree_id = %worktree.id,
324 lazy_markers = %lazy_details.join(", "),
325 "Sub-agent left lazy markers - review carefully!"
326 );
327 }
328
329 info!(
330 worktree_id = %worktree.id,
331 files_changed = files_changed,
332 diff_summary = %diff_stat.trim(),
333 "Sub-agent changes to merge"
334 );
335 }
336
337 let merge_output = Command::new("git")
339 .args([
340 "merge",
341 "--no-ff",
342 "-m",
343 &format!("Merge subagent worktree: {}", worktree.id),
344 &worktree.branch,
345 ])
346 .current_dir(&self.repo_path)
347 .output()
348 .context("Failed to run git merge")?;
349
350 if merge_output.status.success() {
351 info!(
352 worktree_id = %worktree.id,
353 files_changed = files_changed,
354 "Merge successful"
355 );
356
357 Ok(MergeResult {
358 success: true,
359 conflicts: Vec::new(),
360 conflict_diffs: Vec::new(),
361 files_changed,
362 summary: format!(
363 "Merged {} files from subagent {}",
364 files_changed, worktree.id
365 ),
366 aborted: false,
367 })
368 } else {
369 let stderr = String::from_utf8_lossy(&merge_output.stderr).to_string();
370 let stdout = String::from_utf8_lossy(&merge_output.stdout).to_string();
371
372 let conflicts: Vec<String> =
374 if stderr.contains("CONFLICT") || stdout.contains("CONFLICT") {
375 let status = Command::new("git")
377 .args(["diff", "--name-only", "--diff-filter=U"])
378 .current_dir(&self.repo_path)
379 .output()?;
380
381 String::from_utf8_lossy(&status.stdout)
382 .lines()
383 .map(|s| s.to_string())
384 .collect()
385 } else {
386 Vec::new()
387 };
388
389 if stderr.contains("Already up to date") || stdout.contains("Already up to date") {
391 warn!(
392 worktree_id = %worktree.id,
393 "Merge says 'Already up to date' - sub-agent may have made no commits"
394 );
395 } else if conflicts.is_empty() {
396 warn!(
398 worktree_id = %worktree.id,
399 stderr = %stderr.trim(),
400 stdout = %stdout.trim(),
401 "Merge failed for unknown reason (not conflicts)"
402 );
403 } else {
404 warn!(
405 worktree_id = %worktree.id,
406 conflicts = ?conflicts,
407 "Merge had conflicts - sub-agent's changes conflict with main"
408 );
409 }
410
411 let conflict_diffs: Vec<(String, String)> = if !conflicts.is_empty() {
413 conflicts
414 .iter()
415 .filter_map(|file| {
416 let output = Command::new("git")
417 .args(["diff", file])
418 .current_dir(&self.repo_path)
419 .output()
420 .ok()?;
421 let diff = String::from_utf8_lossy(&output.stdout).to_string();
422 if diff.is_empty() {
423 None
424 } else {
425 Some((file.clone(), diff))
426 }
427 })
428 .collect()
429 } else {
430 Vec::new()
431 };
432
433 let aborted = if conflicts.is_empty() {
436 let _ = Command::new("git")
437 .args(["merge", "--abort"])
438 .current_dir(&self.repo_path)
439 .output();
440 true
441 } else {
442 info!(
444 worktree_id = %worktree.id,
445 num_conflicts = conflicts.len(),
446 "Leaving merge in conflicted state for resolution"
447 );
448 false
449 };
450
451 Ok(MergeResult {
452 success: false,
453 conflicts,
454 conflict_diffs,
455 files_changed: 0,
456 summary: format!("Merge failed: {}", stderr),
457 aborted,
458 })
459 }
460 }
461
462 pub fn complete_merge(
464 &self,
465 worktree: &WorktreeInfo,
466 commit_message: &str,
467 ) -> Result<MergeResult> {
468 info!(
469 worktree_id = %worktree.id,
470 "Completing merge after conflict resolution"
471 );
472
473 let add_output = Command::new("git")
475 .args(["add", "-A"])
476 .current_dir(&self.repo_path)
477 .output()
478 .context("Failed to stage resolved files")?;
479
480 if !add_output.status.success() {
481 let stderr = String::from_utf8_lossy(&add_output.stderr);
482 warn!(error = %stderr, "Failed to stage resolved files");
483 }
484
485 let commit_output = Command::new("git")
487 .args(["commit", "-m", commit_message])
488 .current_dir(&self.repo_path)
489 .output()
490 .context("Failed to complete merge commit")?;
491
492 if commit_output.status.success() {
493 let stat_output = Command::new("git")
495 .args(["diff", "--stat", "HEAD~1", "HEAD"])
496 .current_dir(&self.repo_path)
497 .output()?;
498 let diff_stat = String::from_utf8_lossy(&stat_output.stdout);
499 let files_changed = diff_stat.lines().filter(|l| l.contains('|')).count();
500
501 info!(
502 worktree_id = %worktree.id,
503 files_changed = files_changed,
504 "Merge completed after conflict resolution"
505 );
506
507 Ok(MergeResult {
508 success: true,
509 conflicts: Vec::new(),
510 conflict_diffs: Vec::new(),
511 files_changed,
512 summary: format!("Merge completed after resolving conflicts"),
513 aborted: false,
514 })
515 } else {
516 let stderr = String::from_utf8_lossy(&commit_output.stderr).to_string();
517 warn!(error = %stderr, "Failed to complete merge commit");
518
519 Ok(MergeResult {
520 success: false,
521 conflicts: Vec::new(),
522 conflict_diffs: Vec::new(),
523 files_changed: 0,
524 summary: format!("Failed to complete merge: {}", stderr),
525 aborted: false,
526 })
527 }
528 }
529
530 pub fn abort_merge(&self) -> Result<()> {
532 info!("Aborting merge in progress");
533 let _ = Command::new("git")
534 .args(["merge", "--abort"])
535 .current_dir(&self.repo_path)
536 .output();
537 Ok(())
538 }
539
540 pub fn is_merging(&self) -> bool {
542 self.repo_path.join(".git/MERGE_HEAD").exists()
543 }
544
545 pub fn cleanup(&self, worktree: &WorktreeInfo) -> Result<()> {
547 info!(
548 worktree_id = %worktree.id,
549 path = %worktree.path.display(),
550 "Cleaning up worktree"
551 );
552
553 if self.is_merging() {
555 warn!(
556 worktree_id = %worktree.id,
557 "Aborted merge detected during cleanup - aborting merge state"
558 );
559 let _ = self.abort_merge();
560 }
561
562 let output = Command::new("git")
564 .args([
565 "worktree",
566 "remove",
567 "--force",
568 worktree.path.to_str().unwrap(),
569 ])
570 .current_dir(&self.repo_path)
571 .output();
572
573 if let Err(e) = output {
574 warn!(error = %e, "Failed to remove worktree via git");
575 let _ = std::fs::remove_dir_all(&worktree.path);
577 }
578
579 let _ = Command::new("git")
581 .args(["branch", "-D", &worktree.branch])
582 .current_dir(&self.repo_path)
583 .output();
584
585 debug!(
586 worktree_id = %worktree.id,
587 "Worktree cleanup complete"
588 );
589
590 Ok(())
591 }
592
593 #[allow(dead_code)]
595 pub fn list(&self) -> Result<Vec<WorktreeInfo>> {
596 let output = Command::new("git")
597 .args(["worktree", "list", "--porcelain"])
598 .current_dir(&self.repo_path)
599 .output()
600 .context("Failed to list worktrees")?;
601
602 let stdout = String::from_utf8_lossy(&output.stdout);
603 let mut worktrees = Vec::new();
604
605 let mut current_path: Option<PathBuf> = None;
606 let mut current_branch: Option<String> = None;
607
608 for line in stdout.lines() {
609 if let Some(path) = line.strip_prefix("worktree ") {
610 current_path = Some(PathBuf::from(path));
611 } else if let Some(branch) = line.strip_prefix("branch refs/heads/") {
612 current_branch = Some(branch.to_string());
613 } else if line.is_empty() {
614 if let (Some(path), Some(branch)) = (current_path.take(), current_branch.take()) {
615 if branch.starts_with("codetether/subagent-") {
617 let id = branch
618 .strip_prefix("codetether/subagent-")
619 .unwrap_or(&branch)
620 .to_string();
621
622 worktrees.push(WorktreeInfo {
623 id,
624 path,
625 branch,
626 repo_path: self.repo_path.clone(),
627 parent_branch: String::new(), });
629 }
630 }
631 }
632 }
633
634 Ok(worktrees)
635 }
636
637 pub fn cleanup_all(&self) -> Result<usize> {
639 let worktrees = self.list()?;
640 let count = worktrees.len();
641
642 for wt in worktrees {
643 if let Err(e) = self.cleanup(&wt) {
644 warn!(worktree_id = %wt.id, error = %e, "Failed to cleanup worktree");
645 }
646 }
647
648 let _ = Command::new("git")
650 .args(["worktree", "prune"])
651 .current_dir(&self.repo_path)
652 .output();
653
654 let orphaned = self.cleanup_orphaned_branches()?;
656
657 Ok(count + orphaned)
658 }
659
660 pub fn cleanup_orphaned_branches(&self) -> Result<usize> {
662 let output = Command::new("git")
664 .args(["branch", "--list", "codetether/subagent-*"])
665 .current_dir(&self.repo_path)
666 .output()
667 .context("Failed to list branches")?;
668
669 let stdout = String::from_utf8_lossy(&output.stdout);
670 let branches: Vec<&str> = stdout
671 .lines()
672 .map(|l| l.trim().trim_start_matches("* "))
673 .filter(|l| !l.is_empty())
674 .collect();
675
676 let active_worktrees = self.list()?;
678 let active_branches: std::collections::HashSet<&str> = active_worktrees
679 .iter()
680 .map(|wt| wt.branch.as_str())
681 .collect();
682
683 let mut deleted = 0;
684 for branch in branches {
685 if !active_branches.contains(branch) {
686 info!(branch = %branch, "Deleting orphaned subagent branch");
687 let result = Command::new("git")
688 .args(["branch", "-D", branch])
689 .current_dir(&self.repo_path)
690 .output();
691
692 match result {
693 Ok(output) if output.status.success() => {
694 deleted += 1;
695 }
696 Ok(output) => {
697 let stderr = String::from_utf8_lossy(&output.stderr);
698 warn!(branch = %branch, error = %stderr, "Failed to delete orphaned branch");
699 }
700 Err(e) => {
701 warn!(branch = %branch, error = %e, "Failed to run git branch -D");
702 }
703 }
704 }
705 }
706
707 if deleted > 0 {
708 info!(count = deleted, "Cleaned up orphaned subagent branches");
709 }
710
711 Ok(deleted)
712 }
713}
714
715#[derive(Debug, Clone)]
717pub struct MergeResult {
718 pub success: bool,
719 pub conflicts: Vec<String>,
720 pub conflict_diffs: Vec<(String, String)>,
722 pub files_changed: usize,
723 pub summary: String,
724 pub aborted: bool,
726}
727
728#[cfg(test)]
729mod tests {
730 use super::*;
731 use tempfile::TempDir;
732
733 fn setup_test_repo() -> Result<(TempDir, PathBuf)> {
734 let temp = TempDir::new()?;
735 let repo_path = temp.path().to_path_buf();
736
737 Command::new("git")
739 .args(["init"])
740 .current_dir(&repo_path)
741 .output()?;
742
743 Command::new("git")
744 .args(["config", "user.email", "test@test.com"])
745 .current_dir(&repo_path)
746 .output()?;
747
748 Command::new("git")
749 .args(["config", "user.name", "Test"])
750 .current_dir(&repo_path)
751 .output()?;
752
753 std::fs::write(repo_path.join("README.md"), "# Test")?;
755 Command::new("git")
756 .args(["add", "."])
757 .current_dir(&repo_path)
758 .output()?;
759 Command::new("git")
760 .args(["commit", "-m", "Initial commit"])
761 .current_dir(&repo_path)
762 .output()?;
763
764 Ok((temp, repo_path))
765 }
766
767 #[test]
768 fn test_create_worktree() -> Result<()> {
769 let (_temp, repo_path) = setup_test_repo()?;
770 let manager = WorktreeManager::new(&repo_path)?;
771
772 let wt = manager.create("test-task")?;
773
774 assert!(wt.path.exists());
775 assert!(wt.branch.starts_with("codetether/subagent-test-task-"));
776
777 manager.cleanup(&wt)?;
779 assert!(!wt.path.exists());
780
781 Ok(())
782 }
783}