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    /// Merge a worktree's changes back to the parent branch
154    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        // First, get diff stats before merge
163        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        // Count changed files
175        let files_changed = diff_stat.lines().filter(|l| l.contains('|')).count();
176
177        // Log what the sub-agent actually did (trust but verify)
178        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            // Get the actual diff to check for lazy patterns
185            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            // Check for lazy patterns
195            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        // Attempt the merge
229        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            // Check for conflicts
264            let conflicts: Vec<String> =
265                if stderr.contains("CONFLICT") || stdout.contains("CONFLICT") {
266                    // Get list of conflicted files
267                    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            // Better logging for different failure modes
281            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                // Failed but no conflicts - log what happened
288                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            // Get conflict diffs if there are actual conflicts
303            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            // Only abort if no real conflicts (for non-conflict failures)
325            // Keep merge state for conflict resolution
326            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                // Don't abort - leave in conflicted state for resolver
334                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    /// Complete a merge after conflicts have been resolved
354    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        // Stage all resolved files
365        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        // Complete the merge commit
377        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            // Get files changed from commit
385            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    /// Abort a merge in progress
422    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    /// Check if there's a merge in progress
432    pub fn is_merging(&self) -> bool {
433        self.repo_path.join(".git/MERGE_HEAD").exists()
434    }
435
436    /// Clean up a worktree after use
437    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        // Remove the worktree
445        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            // Fallback: just delete the directory
458            let _ = std::fs::remove_dir_all(&worktree.path);
459        }
460
461        // Delete the branch
462        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    /// List all active worktrees
476    #[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                    // Only include our subagent worktrees
498                    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(), // Unknown for listed worktrees
510                        });
511                    }
512                }
513            }
514        }
515
516        Ok(worktrees)
517    }
518
519    /// Clean up all orphaned worktrees
520    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        // Also prune any stale worktree references
531        let _ = Command::new("git")
532            .args(["worktree", "prune"])
533            .current_dir(&self.repo_path)
534            .output();
535
536        // Clean up orphaned branches (branches that exist but worktrees don't)
537        let orphaned = self.cleanup_orphaned_branches()?;
538
539        Ok(count + orphaned)
540    }
541
542    /// Clean up orphaned subagent branches (branches with no corresponding worktree)
543    pub fn cleanup_orphaned_branches(&self) -> Result<usize> {
544        // List all branches matching our pattern
545        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        // Get active worktrees
559        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/// Result of merging a worktree back
598#[derive(Debug, Clone)]
599pub struct MergeResult {
600    pub success: bool,
601    pub conflicts: Vec<String>,
602    /// Diffs for conflicting files (file path -> diff content)
603    pub conflict_diffs: Vec<(String, String)>,
604    pub files_changed: usize,
605    pub summary: String,
606    /// Whether merge was aborted (false = still in conflicted state)
607    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        // Initialize git repo
620        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        // Create initial commit
636        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        // Cleanup
660        manager.cleanup(&wt)?;
661        assert!(!wt.path.exists());
662
663        Ok(())
664    }
665}