Skip to main content

codetether_agent/worktree/
mod.rs

1//! Git worktree management for subagent isolation
2//!
3//! Each sub-agent gets its own git worktree to work in isolation,
4//! preventing file conflicts and enabling parallel execution.
5
6use 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/// Information about a created worktree
14#[derive(Debug, Clone)]
15#[allow(dead_code)]
16pub struct WorktreeInfo {
17    /// Unique identifier for this worktree
18    pub id: String,
19    /// Path to the worktree directory
20    pub path: PathBuf,
21    /// Branch name for this worktree
22    pub branch: String,
23    /// The original repository path
24    pub repo_path: PathBuf,
25    /// Parent branch this was created from
26    pub parent_branch: String,
27}
28
29/// Manages git worktrees for sub-agent isolation
30pub struct WorktreeManager {
31    /// Base directory for worktrees
32    base_dir: PathBuf,
33    /// The main repository path
34    repo_path: PathBuf,
35}
36
37impl WorktreeManager {
38    /// Create a new worktree manager
39    pub fn new(repo_path: impl AsRef<Path>) -> Result<Self> {
40        let repo_path = repo_path.as_ref().to_path_buf();
41
42        // Default worktree base: ~/.local/share/codetether/worktrees/{repo-name}
43        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    /// Create a new worktree for a sub-agent
63    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        // Get current branch
69        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        // Create the worktree with a new branch
80        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    /// Inject a `[workspace]` stub into the worktree's Cargo.toml to make it hermetically sealed
113    ///
114    /// This prevents Cargo from treating the worktree as part of the parent workspace,
115    /// which would cause "current package believes it's in a workspace when it's not" errors.
116    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(()); // No Cargo.toml, nothing to do
120        }
121
122        let content = std::fs::read_to_string(&cargo_toml).context("Failed to read Cargo.toml")?;
123
124        // Check if already has [workspace]
125        if content.contains("[workspace]") {
126            return Ok(());
127        }
128
129        // Prepend [workspace] stub to make this package standalone
130        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    /// Get the current branch name
143    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    /// Auto-commit any uncommitted changes in a worktree
154    ///
155    /// Sub-agents modify files via tools (write, edit, bash) but never git-commit.
156    /// This stages and commits those changes so the branch actually advances
157    /// before we attempt to merge it.
158    fn auto_commit_worktree(&self, worktree: &WorktreeInfo) -> Result<bool> {
159        // Check for uncommitted changes
160        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        // Stage all changes
183        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        // Commit
200        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            // "nothing to commit" is OK — race between status and add
210            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    /// Merge a worktree's changes back to the parent branch
231    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        // Auto-commit any uncommitted changes the sub-agent left behind
240        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        // Check if there's already a merge in progress
256        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        // First, get diff stats before merge
272        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        // Count changed files
284        let files_changed = diff_stat.lines().filter(|l| l.contains('|')).count();
285
286        // Log what the sub-agent actually did (trust but verify)
287        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            // Get the actual diff to check for lazy patterns
294            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            // Check for lazy patterns
304            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        // Attempt the merge
338        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            // Check for conflicts
373            let conflicts: Vec<String> =
374                if stderr.contains("CONFLICT") || stdout.contains("CONFLICT") {
375                    // Get list of conflicted files
376                    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            // Better logging for different failure modes
390            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                // Failed but no conflicts - log what happened
397                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            // Get conflict diffs if there are actual conflicts
412            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            // Only abort if no real conflicts (for non-conflict failures)
434            // Keep merge state for conflict resolution
435            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                // Don't abort - leave in conflicted state for resolver
443                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    /// Complete a merge after conflicts have been resolved
463    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        // Stage all resolved files
474        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        // Complete the merge commit
486        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            // Get files changed from commit
494            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    /// Abort a merge in progress
531    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    /// Check if there's a merge in progress
541    pub fn is_merging(&self) -> bool {
542        self.repo_path.join(".git/MERGE_HEAD").exists()
543    }
544
545    /// Clean up a worktree after use
546    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        // Check if there's an aborted merge and clean it up first
554        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        // Remove the worktree
563        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            // Fallback: just delete the directory
576            let _ = std::fs::remove_dir_all(&worktree.path);
577        }
578
579        // Delete the branch
580        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    /// List all active worktrees
594    #[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                    // Only include our subagent worktrees
616                    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(), // Unknown for listed worktrees
628                        });
629                    }
630                }
631            }
632        }
633
634        Ok(worktrees)
635    }
636
637    /// Clean up all orphaned worktrees
638    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        // Also prune any stale worktree references
649        let _ = Command::new("git")
650            .args(["worktree", "prune"])
651            .current_dir(&self.repo_path)
652            .output();
653
654        // Clean up orphaned branches (branches that exist but worktrees don't)
655        let orphaned = self.cleanup_orphaned_branches()?;
656
657        Ok(count + orphaned)
658    }
659
660    /// Clean up orphaned subagent branches (branches with no corresponding worktree)
661    pub fn cleanup_orphaned_branches(&self) -> Result<usize> {
662        // List all branches matching our pattern
663        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        // Get active worktrees
677        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/// Result of merging a worktree back
716#[derive(Debug, Clone)]
717pub struct MergeResult {
718    pub success: bool,
719    pub conflicts: Vec<String>,
720    /// Diffs for conflicting files (file path -> diff content)
721    pub conflict_diffs: Vec<(String, String)>,
722    pub files_changed: usize,
723    pub summary: String,
724    /// Whether merge was aborted (false = still in conflicted state)
725    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        // Initialize git repo
738        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        // Create initial commit
754        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        // Cleanup
778        manager.cleanup(&wt)?;
779        assert!(!wt.path.exists());
780
781        Ok(())
782    }
783}