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        // Check if there's already a merge in progress
163        if self.is_merging() {
164            warn!(
165                worktree_id = %worktree.id,
166                "Merge already in progress - cannot start new merge"
167            );
168            return Ok(MergeResult {
169                success: false,
170                conflicts: Vec::new(),
171                conflict_diffs: Vec::new(),
172                files_changed: 0,
173                summary: "Merge already in progress".to_string(),
174                aborted: false,
175            });
176        }
177
178        // First, get diff stats before merge
179        let diff_output = Command::new("git")
180            .args([
181                "diff",
182                "--stat",
183                &format!("{}..{}", worktree.parent_branch, worktree.branch),
184            ])
185            .current_dir(&self.repo_path)
186            .output()?;
187
188        let diff_stat = String::from_utf8_lossy(&diff_output.stdout).to_string();
189
190        // Count changed files
191        let files_changed = diff_stat.lines().filter(|l| l.contains('|')).count();
192
193        // Log what the sub-agent actually did (trust but verify)
194        if files_changed == 0 {
195            warn!(
196                worktree_id = %worktree.id,
197                "Sub-agent made NO file changes - lazy or stuck?"
198            );
199        } else {
200            // Get the actual diff to check for lazy patterns
201            let full_diff = Command::new("git")
202                .args([
203                    "diff",
204                    &format!("{}..{}", worktree.parent_branch, worktree.branch),
205                ])
206                .current_dir(&self.repo_path)
207                .output()?;
208            let diff_content = String::from_utf8_lossy(&full_diff.stdout);
209
210            // Check for lazy patterns
211            let lazy_indicators = [
212                ("TODO", diff_content.matches("TODO").count()),
213                ("FIXME", diff_content.matches("FIXME").count()),
214                (
215                    "unimplemented!",
216                    diff_content.matches("unimplemented!").count(),
217                ),
218                ("todo!", diff_content.matches("todo!").count()),
219                ("panic!", diff_content.matches("panic!").count()),
220            ];
221
222            let lazy_count: usize = lazy_indicators.iter().map(|(_, c)| c).sum();
223            if lazy_count > 0 {
224                let lazy_details: Vec<String> = lazy_indicators
225                    .iter()
226                    .filter(|(_, c)| *c > 0)
227                    .map(|(name, c)| format!("{}:{}", name, c))
228                    .collect();
229                warn!(
230                    worktree_id = %worktree.id,
231                    lazy_markers = %lazy_details.join(", "),
232                    "Sub-agent left lazy markers - review carefully!"
233                );
234            }
235
236            info!(
237                worktree_id = %worktree.id,
238                files_changed = files_changed,
239                diff_summary = %diff_stat.trim(),
240                "Sub-agent changes to merge"
241            );
242        }
243
244        // Attempt the merge
245        let merge_output = Command::new("git")
246            .args([
247                "merge",
248                "--no-ff",
249                "-m",
250                &format!("Merge subagent worktree: {}", worktree.id),
251                &worktree.branch,
252            ])
253            .current_dir(&self.repo_path)
254            .output()
255            .context("Failed to run git merge")?;
256
257        if merge_output.status.success() {
258            info!(
259                worktree_id = %worktree.id,
260                files_changed = files_changed,
261                "Merge successful"
262            );
263
264            Ok(MergeResult {
265                success: true,
266                conflicts: Vec::new(),
267                conflict_diffs: Vec::new(),
268                files_changed,
269                summary: format!(
270                    "Merged {} files from subagent {}",
271                    files_changed, worktree.id
272                ),
273                aborted: false,
274            })
275        } else {
276            let stderr = String::from_utf8_lossy(&merge_output.stderr).to_string();
277            let stdout = String::from_utf8_lossy(&merge_output.stdout).to_string();
278
279            // Check for conflicts
280            let conflicts: Vec<String> =
281                if stderr.contains("CONFLICT") || stdout.contains("CONFLICT") {
282                    // Get list of conflicted files
283                    let status = Command::new("git")
284                        .args(["diff", "--name-only", "--diff-filter=U"])
285                        .current_dir(&self.repo_path)
286                        .output()?;
287
288                    String::from_utf8_lossy(&status.stdout)
289                        .lines()
290                        .map(|s| s.to_string())
291                        .collect()
292                } else {
293                    Vec::new()
294                };
295
296            // Better logging for different failure modes
297            if stderr.contains("Already up to date") || stdout.contains("Already up to date") {
298                warn!(
299                    worktree_id = %worktree.id,
300                    "Merge says 'Already up to date' - sub-agent may have made no commits"
301                );
302            } else if conflicts.is_empty() {
303                // Failed but no conflicts - log what happened
304                warn!(
305                    worktree_id = %worktree.id,
306                    stderr = %stderr.trim(),
307                    stdout = %stdout.trim(),
308                    "Merge failed for unknown reason (not conflicts)"
309                );
310            } else {
311                warn!(
312                    worktree_id = %worktree.id,
313                    conflicts = ?conflicts,
314                    "Merge had conflicts - sub-agent's changes conflict with main"
315                );
316            }
317
318            // Get conflict diffs if there are actual conflicts
319            let conflict_diffs: Vec<(String, String)> = if !conflicts.is_empty() {
320                conflicts
321                    .iter()
322                    .filter_map(|file| {
323                        let output = Command::new("git")
324                            .args(["diff", file])
325                            .current_dir(&self.repo_path)
326                            .output()
327                            .ok()?;
328                        let diff = String::from_utf8_lossy(&output.stdout).to_string();
329                        if diff.is_empty() {
330                            None
331                        } else {
332                            Some((file.clone(), diff))
333                        }
334                    })
335                    .collect()
336            } else {
337                Vec::new()
338            };
339
340            // Only abort if no real conflicts (for non-conflict failures)
341            // Keep merge state for conflict resolution
342            let aborted = if conflicts.is_empty() {
343                let _ = Command::new("git")
344                    .args(["merge", "--abort"])
345                    .current_dir(&self.repo_path)
346                    .output();
347                true
348            } else {
349                // Don't abort - leave in conflicted state for resolver
350                info!(
351                    worktree_id = %worktree.id,
352                    num_conflicts = conflicts.len(),
353                    "Leaving merge in conflicted state for resolution"
354                );
355                false
356            };
357
358            Ok(MergeResult {
359                success: false,
360                conflicts,
361                conflict_diffs,
362                files_changed: 0,
363                summary: format!("Merge failed: {}", stderr),
364                aborted,
365            })
366        }
367    }
368
369    /// Complete a merge after conflicts have been resolved
370    pub fn complete_merge(
371        &self,
372        worktree: &WorktreeInfo,
373        commit_message: &str,
374    ) -> Result<MergeResult> {
375        info!(
376            worktree_id = %worktree.id,
377            "Completing merge after conflict resolution"
378        );
379
380        // Stage all resolved files
381        let add_output = Command::new("git")
382            .args(["add", "-A"])
383            .current_dir(&self.repo_path)
384            .output()
385            .context("Failed to stage resolved files")?;
386
387        if !add_output.status.success() {
388            let stderr = String::from_utf8_lossy(&add_output.stderr);
389            warn!(error = %stderr, "Failed to stage resolved files");
390        }
391
392        // Complete the merge commit
393        let commit_output = Command::new("git")
394            .args(["commit", "-m", commit_message])
395            .current_dir(&self.repo_path)
396            .output()
397            .context("Failed to complete merge commit")?;
398
399        if commit_output.status.success() {
400            // Get files changed from commit
401            let stat_output = Command::new("git")
402                .args(["diff", "--stat", "HEAD~1", "HEAD"])
403                .current_dir(&self.repo_path)
404                .output()?;
405            let diff_stat = String::from_utf8_lossy(&stat_output.stdout);
406            let files_changed = diff_stat.lines().filter(|l| l.contains('|')).count();
407
408            info!(
409                worktree_id = %worktree.id,
410                files_changed = files_changed,
411                "Merge completed after conflict resolution"
412            );
413
414            Ok(MergeResult {
415                success: true,
416                conflicts: Vec::new(),
417                conflict_diffs: Vec::new(),
418                files_changed,
419                summary: format!("Merge completed after resolving conflicts"),
420                aborted: false,
421            })
422        } else {
423            let stderr = String::from_utf8_lossy(&commit_output.stderr).to_string();
424            warn!(error = %stderr, "Failed to complete merge commit");
425
426            Ok(MergeResult {
427                success: false,
428                conflicts: Vec::new(),
429                conflict_diffs: Vec::new(),
430                files_changed: 0,
431                summary: format!("Failed to complete merge: {}", stderr),
432                aborted: false,
433            })
434        }
435    }
436
437    /// Abort a merge in progress
438    pub fn abort_merge(&self) -> Result<()> {
439        info!("Aborting merge in progress");
440        let _ = Command::new("git")
441            .args(["merge", "--abort"])
442            .current_dir(&self.repo_path)
443            .output();
444        Ok(())
445    }
446
447    /// Check if there's a merge in progress
448    pub fn is_merging(&self) -> bool {
449        self.repo_path.join(".git/MERGE_HEAD").exists()
450    }
451
452    /// Clean up a worktree after use
453    pub fn cleanup(&self, worktree: &WorktreeInfo) -> Result<()> {
454        info!(
455            worktree_id = %worktree.id,
456            path = %worktree.path.display(),
457            "Cleaning up worktree"
458        );
459
460        // Check if there's an aborted merge and clean it up first
461        if self.is_merging() {
462            warn!(
463                worktree_id = %worktree.id,
464                "Aborted merge detected during cleanup - aborting merge state"
465            );
466            let _ = self.abort_merge();
467        }
468
469        // Remove the worktree
470        let output = Command::new("git")
471            .args([
472                "worktree",
473                "remove",
474                "--force",
475                worktree.path.to_str().unwrap(),
476            ])
477            .current_dir(&self.repo_path)
478            .output();
479
480        if let Err(e) = output {
481            warn!(error = %e, "Failed to remove worktree via git");
482            // Fallback: just delete the directory
483            let _ = std::fs::remove_dir_all(&worktree.path);
484        }
485
486        // Delete the branch
487        let _ = Command::new("git")
488            .args(["branch", "-D", &worktree.branch])
489            .current_dir(&self.repo_path)
490            .output();
491
492        debug!(
493            worktree_id = %worktree.id,
494            "Worktree cleanup complete"
495        );
496
497        Ok(())
498    }
499
500    /// List all active worktrees
501    #[allow(dead_code)]
502    pub fn list(&self) -> Result<Vec<WorktreeInfo>> {
503        let output = Command::new("git")
504            .args(["worktree", "list", "--porcelain"])
505            .current_dir(&self.repo_path)
506            .output()
507            .context("Failed to list worktrees")?;
508
509        let stdout = String::from_utf8_lossy(&output.stdout);
510        let mut worktrees = Vec::new();
511
512        let mut current_path: Option<PathBuf> = None;
513        let mut current_branch: Option<String> = None;
514
515        for line in stdout.lines() {
516            if let Some(path) = line.strip_prefix("worktree ") {
517                current_path = Some(PathBuf::from(path));
518            } else if let Some(branch) = line.strip_prefix("branch refs/heads/") {
519                current_branch = Some(branch.to_string());
520            } else if line.is_empty() {
521                if let (Some(path), Some(branch)) = (current_path.take(), current_branch.take()) {
522                    // Only include our subagent worktrees
523                    if branch.starts_with("codetether/subagent-") {
524                        let id = branch
525                            .strip_prefix("codetether/subagent-")
526                            .unwrap_or(&branch)
527                            .to_string();
528
529                        worktrees.push(WorktreeInfo {
530                            id,
531                            path,
532                            branch,
533                            repo_path: self.repo_path.clone(),
534                            parent_branch: String::new(), // Unknown for listed worktrees
535                        });
536                    }
537                }
538            }
539        }
540
541        Ok(worktrees)
542    }
543
544    /// Clean up all orphaned worktrees
545    pub fn cleanup_all(&self) -> Result<usize> {
546        let worktrees = self.list()?;
547        let count = worktrees.len();
548
549        for wt in worktrees {
550            if let Err(e) = self.cleanup(&wt) {
551                warn!(worktree_id = %wt.id, error = %e, "Failed to cleanup worktree");
552            }
553        }
554
555        // Also prune any stale worktree references
556        let _ = Command::new("git")
557            .args(["worktree", "prune"])
558            .current_dir(&self.repo_path)
559            .output();
560
561        // Clean up orphaned branches (branches that exist but worktrees don't)
562        let orphaned = self.cleanup_orphaned_branches()?;
563
564        Ok(count + orphaned)
565    }
566
567    /// Clean up orphaned subagent branches (branches with no corresponding worktree)
568    pub fn cleanup_orphaned_branches(&self) -> Result<usize> {
569        // List all branches matching our pattern
570        let output = Command::new("git")
571            .args(["branch", "--list", "codetether/subagent-*"])
572            .current_dir(&self.repo_path)
573            .output()
574            .context("Failed to list branches")?;
575
576        let stdout = String::from_utf8_lossy(&output.stdout);
577        let branches: Vec<&str> = stdout
578            .lines()
579            .map(|l| l.trim().trim_start_matches("* "))
580            .filter(|l| !l.is_empty())
581            .collect();
582
583        // Get active worktrees
584        let active_worktrees = self.list()?;
585        let active_branches: std::collections::HashSet<&str> = active_worktrees
586            .iter()
587            .map(|wt| wt.branch.as_str())
588            .collect();
589
590        let mut deleted = 0;
591        for branch in branches {
592            if !active_branches.contains(branch) {
593                info!(branch = %branch, "Deleting orphaned subagent branch");
594                let result = Command::new("git")
595                    .args(["branch", "-D", branch])
596                    .current_dir(&self.repo_path)
597                    .output();
598
599                match result {
600                    Ok(output) if output.status.success() => {
601                        deleted += 1;
602                    }
603                    Ok(output) => {
604                        let stderr = String::from_utf8_lossy(&output.stderr);
605                        warn!(branch = %branch, error = %stderr, "Failed to delete orphaned branch");
606                    }
607                    Err(e) => {
608                        warn!(branch = %branch, error = %e, "Failed to run git branch -D");
609                    }
610                }
611            }
612        }
613
614        if deleted > 0 {
615            info!(count = deleted, "Cleaned up orphaned subagent branches");
616        }
617
618        Ok(deleted)
619    }
620}
621
622/// Result of merging a worktree back
623#[derive(Debug, Clone)]
624pub struct MergeResult {
625    pub success: bool,
626    pub conflicts: Vec<String>,
627    /// Diffs for conflicting files (file path -> diff content)
628    pub conflict_diffs: Vec<(String, String)>,
629    pub files_changed: usize,
630    pub summary: String,
631    /// Whether merge was aborted (false = still in conflicted state)
632    pub aborted: bool,
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638    use tempfile::TempDir;
639
640    fn setup_test_repo() -> Result<(TempDir, PathBuf)> {
641        let temp = TempDir::new()?;
642        let repo_path = temp.path().to_path_buf();
643
644        // Initialize git repo
645        Command::new("git")
646            .args(["init"])
647            .current_dir(&repo_path)
648            .output()?;
649
650        Command::new("git")
651            .args(["config", "user.email", "test@test.com"])
652            .current_dir(&repo_path)
653            .output()?;
654
655        Command::new("git")
656            .args(["config", "user.name", "Test"])
657            .current_dir(&repo_path)
658            .output()?;
659
660        // Create initial commit
661        std::fs::write(repo_path.join("README.md"), "# Test")?;
662        Command::new("git")
663            .args(["add", "."])
664            .current_dir(&repo_path)
665            .output()?;
666        Command::new("git")
667            .args(["commit", "-m", "Initial commit"])
668            .current_dir(&repo_path)
669            .output()?;
670
671        Ok((temp, repo_path))
672    }
673
674    #[test]
675    fn test_create_worktree() -> Result<()> {
676        let (_temp, repo_path) = setup_test_repo()?;
677        let manager = WorktreeManager::new(&repo_path)?;
678
679        let wt = manager.create("test-task")?;
680
681        assert!(wt.path.exists());
682        assert!(wt.branch.starts_with("codetether/subagent-test-task-"));
683
684        // Cleanup
685        manager.cleanup(&wt)?;
686        assert!(!wt.path.exists());
687
688        Ok(())
689    }
690}