Skip to main content

codetether_agent/
worktree.rs

1//! Git worktree management for isolated agent execution
2//!
3//! Provides worktree isolation for parallel agent tasks.
4
5use crate::provenance::{ExecutionOrigin, ExecutionProvenance, git_commit_with_provenance};
6use anyhow::{Context, Result, anyhow};
7use std::path::{Path, PathBuf};
8use tokio::sync::Mutex;
9
10/// Worktree information
11#[derive(Debug, Clone)]
12pub struct WorktreeInfo {
13    /// Worktree name/identifier
14    pub name: String,
15    /// Path to the worktree
16    pub path: PathBuf,
17    /// Branch name
18    pub branch: String,
19    /// Whether this worktree is active
20    #[allow(dead_code)]
21    pub active: bool,
22}
23
24/// Worktree manager for creating and managing isolated git worktrees
25#[derive(Debug)]
26pub struct WorktreeManager {
27    /// Base directory for worktrees
28    base_dir: PathBuf,
29    /// Path to the main repository
30    repo_path: PathBuf,
31    /// Active worktrees
32    worktrees: Mutex<Vec<WorktreeInfo>>,
33    /// Whether git object integrity was already verified for this manager
34    integrity_checked: Mutex<bool>,
35}
36
37/// Merge result
38#[derive(Debug, Clone)]
39pub struct MergeResult {
40    pub success: bool,
41    pub aborted: bool,
42    pub conflicts: Vec<String>,
43    pub conflict_diffs: Vec<(String, String)>,
44    pub files_changed: usize,
45    pub summary: String,
46}
47
48impl WorktreeManager {
49    /// Create a new worktree manager
50    pub fn new(base_dir: impl Into<PathBuf>) -> Self {
51        Self {
52            base_dir: base_dir.into(),
53            repo_path: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
54            worktrees: Mutex::new(Vec::new()),
55            integrity_checked: Mutex::new(false),
56        }
57    }
58
59    /// Create a worktree manager with explicit repo path
60    #[allow(dead_code)]
61    pub fn with_repo(base_dir: impl Into<PathBuf>, repo_path: impl Into<PathBuf>) -> Self {
62        Self {
63            base_dir: base_dir.into(),
64            repo_path: repo_path.into(),
65            worktrees: Mutex::new(Vec::new()),
66            integrity_checked: Mutex::new(false),
67        }
68    }
69
70    /// Create a new worktree for a task
71    ///
72    /// This creates an actual git worktree using `git worktree add`,
73    /// creating a new branch if it doesn't exist.
74    pub async fn create(&self, name: &str) -> Result<WorktreeInfo> {
75        self.ensure_repo_integrity_once().await?;
76        Self::validate_worktree_name(name)?;
77        let worktree_path = self.base_dir.join(name);
78        let branch_name = format!("codetether/{}", name);
79
80        // Ensure base directory exists
81        tokio::fs::create_dir_all(&self.base_dir)
82            .await
83            .with_context(|| {
84                format!(
85                    "Failed to create base directory: {}",
86                    self.base_dir.display()
87                )
88            })?;
89
90        // Run git worktree add
91        let output = tokio::process::Command::new("git")
92            .args(["worktree", "add", "-b", &branch_name])
93            .arg(&worktree_path)
94            .current_dir(&self.repo_path)
95            .output()
96            .await
97            .context("Failed to execute git worktree add")?;
98
99        if !output.status.success() {
100            // Branch might already exist, try without -b
101            let output2 = tokio::process::Command::new("git")
102                .args(["worktree", "add"])
103                .arg(&worktree_path)
104                .arg(&branch_name)
105                .current_dir(&self.repo_path)
106                .output()
107                .await
108                .context("Failed to execute git worktree add (fallback)")?;
109
110            if !output2.status.success() {
111                return Err(anyhow!(
112                    "Failed to create git worktree '{}': {}",
113                    name,
114                    String::from_utf8_lossy(&output2.stderr)
115                ));
116            }
117        }
118
119        let info = WorktreeInfo {
120            name: name.to_string(),
121            path: worktree_path.clone(),
122            branch: branch_name,
123            active: true,
124        };
125
126        let mut worktrees = self.worktrees.lock().await;
127        worktrees.push(info.clone());
128
129        tracing::info!(worktree = %name, path = %worktree_path.display(), "Created git worktree");
130        Ok(info)
131    }
132
133    /// Verify repository object integrity before worktree operations.
134    ///
135    /// If corruption is detected, a best-effort repair is attempted automatically.
136    pub async fn ensure_repo_integrity(&self) -> Result<()> {
137        let first_check = self.run_repo_fsck().await?;
138        if first_check.status.success() {
139            return Ok(());
140        }
141
142        let first_output = Self::combined_output(&first_check.stdout, &first_check.stderr);
143        if !Self::looks_like_object_corruption(&first_output) {
144            return Err(anyhow!(
145                "Git repository preflight failed: {}",
146                Self::summarize_git_output(&first_output)
147            ));
148        }
149
150        tracing::warn!(
151            repo_path = %self.repo_path.display(),
152            issue = %Self::summarize_git_output(&first_output),
153            "Detected git object corruption; attempting automatic repair"
154        );
155        self.try_auto_repair().await;
156
157        let second_check = self.run_repo_fsck().await?;
158        if second_check.status.success() {
159            tracing::info!(
160                repo_path = %self.repo_path.display(),
161                "Git repository integrity restored after automatic repair"
162            );
163            return Ok(());
164        }
165
166        let second_output = Self::combined_output(&second_check.stdout, &second_check.stderr);
167        Err(Self::integrity_error_message(
168            &self.repo_path,
169            &second_output,
170        ))
171    }
172
173    /// Get information about a worktree
174    #[allow(dead_code)]
175    pub async fn get(&self, name: &str) -> Option<WorktreeInfo> {
176        let worktrees = self.worktrees.lock().await;
177        worktrees.iter().find(|w| w.name == name).cloned()
178    }
179
180    /// List all worktrees
181    pub async fn list(&self) -> Vec<WorktreeInfo> {
182        self.worktrees.lock().await.clone()
183    }
184
185    /// Clean up a specific worktree
186    pub async fn cleanup(&self, name: &str) -> Result<()> {
187        // Clone info but keep tracking until IO succeeds
188        let info = {
189            let worktrees = self.worktrees.lock().await;
190            match worktrees.iter().find(|w| w.name == name) {
191                Some(w) => w.clone(),
192                None => return Ok(()),
193            }
194        };
195        let branch = info.branch.clone();
196
197        // Run git worktree remove (no lock held)
198        let output = tokio::process::Command::new("git")
199            .args(["worktree", "remove", "--force"])
200            .arg(&info.path)
201            .current_dir(&self.repo_path)
202            .output()
203            .await;
204
205        match output {
206            Ok(o) if o.status.success() => {
207                tracing::info!(worktree = %name, "Removed git worktree");
208            }
209            Ok(o) => {
210                tracing::warn!(
211                    worktree = %name,
212                    error = %String::from_utf8_lossy(&o.stderr),
213                    "Git worktree remove failed, falling back to directory removal"
214                );
215                if let Err(e) = tokio::fs::remove_dir_all(&info.path).await {
216                    tracing::warn!(worktree = %name, error = %e, "Failed to remove worktree directory");
217                }
218            }
219            Err(e) => {
220                tracing::warn!(worktree = %name, error = %e, "Failed to execute git worktree remove");
221                if let Err(e) = tokio::fs::remove_dir_all(&info.path).await {
222                    tracing::warn!(worktree = %name, error = %e, "Failed to remove worktree directory");
223                }
224            }
225        }
226
227        // Delete the branch so it doesn't leak
228        Self::delete_branch(&self.repo_path, &branch, None).await;
229        Ok(())
230    }
231
232    /// Merge a worktree branch back into the current branch
233    ///
234    /// This performs an actual git merge operation and handles conflicts.
235    pub async fn merge(&self, name: &str) -> Result<MergeResult> {
236        let worktrees = self.worktrees.lock().await;
237        let info = worktrees
238            .iter()
239            .find(|w| w.name == name)
240            .ok_or_else(|| anyhow!("Worktree not found: {}", name))?;
241
242        let branch = info.branch.clone();
243        drop(worktrees); // Release lock before git operations
244
245        // Pre-merge cleanup: reset any leftover unmerged index entries from
246        // previous failed merges.  Without this, `git merge` refuses to start
247        // ("Merging is not possible because you have unmerged files.").
248        let umg = std::process::Command::new("git")
249            .args(["diff", "--name-only", "--diff-filter=U"])
250            .current_dir(&self.repo_path)
251            .output()
252            .context("Failed to check for unmerged files")?;
253        if !String::from_utf8_lossy(&umg.stdout).trim().is_empty() {
254            tracing::warn!("Resetting unmerged index entries before merge");
255            // Abort any lingering merge state first
256            let _ = std::process::Command::new("git")
257                .args(["merge", "--abort"])
258                .current_dir(&self.repo_path)
259                .output();
260            // Hard-reset the index to HEAD to clear unmerged entries
261            let _ = std::process::Command::new("git")
262                .args(["reset", "HEAD", "--"])
263                .current_dir(&self.repo_path)
264                .output();
265            // Restore working tree files to match index
266            let _ = std::process::Command::new("git")
267                .args(["checkout", "--", "."])
268                .current_dir(&self.repo_path)
269                .output();
270        }
271
272        // Stash any uncommitted changes before merging to avoid conflicts
273        let dirty = std::process::Command::new("git")
274            .args(["diff", "--quiet"])
275            .current_dir(&self.repo_path)
276            .output();
277        let has_dirty = dirty.is_err() || !dirty.unwrap().status.success();
278
279        if has_dirty {
280            tracing::info!("Stashing dirty working tree before merge");
281            let stash_out = std::process::Command::new("git")
282                .args(["stash", "--include-untracked"])
283                .current_dir(&self.repo_path)
284                .output()
285                .context("Failed to execute git stash")?;
286            if !stash_out.status.success() {
287                tracing::warn!(
288                    "Stash failed (may be no changes): {}",
289                    String::from_utf8_lossy(&stash_out.stderr)
290                );
291            }
292        }
293
294        tracing::info!(worktree = %name, branch = %branch, "Starting git merge");
295
296        // Stage the merge but stamp the final merge commit ourselves for provenance.
297        // First attempt: normal merge.
298        let mut output = tokio::process::Command::new("git")
299            .args(["merge", "--no-ff", "--no-commit", &branch])
300            .current_dir(&self.repo_path)
301            .output()
302            .await
303            .context("Failed to execute git merge")?;
304
305        let stdout = String::from_utf8_lossy(&output.stdout);
306        let stderr = String::from_utf8_lossy(&output.stderr);
307
308        // If the merge has conflicts, automatically resolve them by accepting
309        // the incoming (theirs) version.  This avoids blocking the autonomous
310        // loop on manual conflict resolution.
311        if !output.status.success() && (stderr.contains("CONFLICT") || stdout.contains("CONFLICT"))
312        {
313            tracing::warn!(
314                worktree = %name,
315                "Merge has conflicts — auto-resolving with -X theirs"
316            );
317            // Abort the conflicted merge
318            let _ = tokio::process::Command::new("git")
319                .args(["merge", "--abort"])
320                .current_dir(&self.repo_path)
321                .output()
322                .await;
323
324            // Retry with -X theirs to auto-resolve
325            output = tokio::process::Command::new("git")
326                .args(["merge", "--no-ff", "--no-commit", "-X", "theirs", &branch])
327                .current_dir(&self.repo_path)
328                .output()
329                .await
330                .context("Failed to execute git merge -X theirs")?;
331        }
332
333        let stdout = String::from_utf8_lossy(&output.stdout);
334        let stderr = String::from_utf8_lossy(&output.stderr);
335
336        if output.status.success() {
337            let commit_msg = format!("Merge branch '{}' into current branch", branch);
338            let provenance =
339                ExecutionProvenance::for_operation("worktree", ExecutionOrigin::LocalCli);
340            let commit_output =
341                git_commit_with_provenance(&self.repo_path, &commit_msg, Some(&provenance)).await?;
342            if !commit_output.status.success() {
343                let commit_stderr = String::from_utf8_lossy(&commit_output.stderr);
344                let _ = Self::stash_pop(&self.repo_path);
345                return Err(anyhow!("Git merge commit failed: {}", commit_stderr));
346            }
347            tracing::info!(worktree = %name, branch = %branch, "Git merge successful");
348
349            // Pop stash after successful merge
350            let _ = Self::stash_pop(&self.repo_path);
351
352            // Get files changed count
353            let files_changed = self.count_merge_files_changed().await.unwrap_or(0);
354
355            Ok(MergeResult {
356                success: true,
357                aborted: false,
358                conflicts: vec![],
359                conflict_diffs: vec![],
360                files_changed,
361                summary: commit_msg,
362            })
363        } else {
364            // Abort merge and restore stash
365            let _ = tokio::process::Command::new("git")
366                .args(["merge", "--abort"])
367                .current_dir(&self.repo_path)
368                .output()
369                .await;
370            let _ = Self::stash_pop(&self.repo_path);
371
372            // Check for conflicts
373            if stderr.contains("CONFLICT") || stdout.contains("CONFLICT") {
374                tracing::warn!(worktree = %name, "Merge has conflicts");
375
376                let conflicts = self.get_conflict_list().await?;
377                let conflict_diffs = self.get_conflict_diffs().await?;
378
379                Ok(MergeResult {
380                    success: false,
381                    aborted: false,
382                    conflicts,
383                    conflict_diffs,
384                    files_changed: 0,
385                    summary: "Merge has conflicts that need resolution".to_string(),
386                })
387            } else {
388                Err(anyhow!("Git merge failed: {}", stderr))
389            }
390        }
391    }
392
393    /// Complete a merge after conflicts are resolved
394    ///
395    /// This commits the merge after the user has resolved conflicts.
396    pub async fn complete_merge(&self, name: &str, commit_msg: &str) -> Result<MergeResult> {
397        let worktrees = self.worktrees.lock().await;
398        let info = worktrees
399            .iter()
400            .find(|w| w.name == name)
401            .ok_or_else(|| anyhow!("Worktree not found: {}", name))?;
402
403        let branch = info.branch.clone();
404        drop(worktrees);
405
406        // Check if we're in a merge state
407        let merge_head = self.merge_head_path().await?;
408        let in_merge = tokio::fs::try_exists(&merge_head).await.unwrap_or(false);
409
410        if !in_merge {
411            return Err(anyhow!("Not in a merge state. Use merge() first."));
412        }
413
414        // Commit the merge
415        let provenance = ExecutionProvenance::for_operation("worktree", ExecutionOrigin::LocalCli);
416        let output =
417            git_commit_with_provenance(&self.repo_path, commit_msg, Some(&provenance)).await?;
418
419        if output.status.success() {
420            tracing::info!(worktree = %name, branch = %branch, "Merge completed");
421
422            let files_changed = self.count_merge_files_changed().await.unwrap_or(0);
423
424            Ok(MergeResult {
425                success: true,
426                aborted: false,
427                conflicts: vec![],
428                conflict_diffs: vec![],
429                files_changed,
430                summary: format!("Merge completed: {}", commit_msg),
431            })
432        } else {
433            let stderr = String::from_utf8_lossy(&output.stderr);
434            Err(anyhow!("Failed to complete merge: {}", stderr))
435        }
436    }
437
438    /// Abort an in-progress merge
439    pub async fn abort_merge(&self, name: &str) -> Result<()> {
440        let worktrees = self.worktrees.lock().await;
441        if !worktrees.iter().any(|w| w.name == name) {
442            return Err(anyhow!("Worktree not found: {}", name));
443        }
444        drop(worktrees);
445
446        // Check if we're in a merge state
447        let merge_head = self.merge_head_path().await?;
448        let in_merge = tokio::fs::try_exists(&merge_head).await.unwrap_or(false);
449
450        if !in_merge {
451            tracing::warn!("Not in a merge state, nothing to abort");
452            return Ok(());
453        }
454
455        let output = tokio::process::Command::new("git")
456            .args(["merge", "--abort"])
457            .current_dir(&self.repo_path)
458            .output()
459            .await
460            .context("Failed to execute git merge --abort")?;
461
462        if output.status.success() {
463            tracing::info!("Merge aborted");
464            Ok(())
465        } else {
466            let stderr = String::from_utf8_lossy(&output.stderr);
467            Err(anyhow!("Failed to abort merge: {}", stderr))
468        }
469    }
470
471    fn validate_worktree_name(name: &str) -> Result<()> {
472        if name.is_empty() {
473            return Err(anyhow!("Worktree name cannot be empty"));
474        }
475        if name
476            .chars()
477            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
478        {
479            return Ok(());
480        }
481        Err(anyhow!(
482            "Invalid worktree name '{}'. Only alphanumeric characters, '-' and '_' are allowed.",
483            name
484        ))
485    }
486
487    async fn ensure_repo_integrity_once(&self) -> Result<()> {
488        let mut checked = self.integrity_checked.lock().await;
489        if *checked {
490            return Ok(());
491        }
492        self.ensure_repo_integrity().await?;
493        *checked = true;
494        Ok(())
495    }
496
497    async fn run_repo_fsck(&self) -> Result<std::process::Output> {
498        tokio::process::Command::new("git")
499            .args(["fsck", "--full", "--no-dangling"])
500            .current_dir(&self.repo_path)
501            .output()
502            .await
503            .context("Failed to execute git fsck --full --no-dangling")
504    }
505
506    async fn try_auto_repair(&self) {
507        self.run_repair_step(["fetch", "--all", "--prune", "--tags"])
508            .await;
509        self.run_repair_step(["worktree", "prune"]).await;
510        self.run_repair_step(["gc", "--prune=now"]).await;
511    }
512
513    async fn run_repair_step<const N: usize>(&self, args: [&str; N]) {
514        match tokio::process::Command::new("git")
515            .args(args)
516            .current_dir(&self.repo_path)
517            .output()
518            .await
519        {
520            Ok(output) if output.status.success() => {
521                tracing::info!(
522                    repo_path = %self.repo_path.display(),
523                    command = %format!("git {}", args.join(" ")),
524                    "Git repair step succeeded"
525                );
526            }
527            Ok(output) => {
528                tracing::warn!(
529                    repo_path = %self.repo_path.display(),
530                    command = %format!("git {}", args.join(" ")),
531                    error = %Self::summarize_git_output(&Self::combined_output(
532                        &output.stdout,
533                        &output.stderr
534                    )),
535                    "Git repair step failed"
536                );
537            }
538            Err(error) => {
539                tracing::warn!(
540                    repo_path = %self.repo_path.display(),
541                    command = %format!("git {}", args.join(" ")),
542                    error = %error,
543                    "Failed to execute git repair step"
544                );
545            }
546        }
547    }
548
549    fn integrity_error_message(repo_path: &Path, fsck_output: &str) -> anyhow::Error {
550        let summary = Self::summarize_git_output(fsck_output);
551        anyhow!(
552            "Git object database is corrupted in '{}': {}\n\
553Automatic repair was attempted but repository integrity is still broken.\n\
554Recovery steps:\n\
5551. Backup local changes: git diff > /tmp/codetether-recovery.patch\n\
5562. Attempt object recovery: git fetch --all --prune --tags && git fsck --full\n\
5573. If corruption remains, create a fresh clone and re-apply the patch.",
558            repo_path.display(),
559            summary
560        )
561    }
562
563    fn combined_output(stdout: &[u8], stderr: &[u8]) -> String {
564        let left = String::from_utf8_lossy(stdout);
565        let right = String::from_utf8_lossy(stderr);
566        format!("{left}\n{right}")
567    }
568
569    fn looks_like_object_corruption(output: &str) -> bool {
570        let lower = output.to_ascii_lowercase();
571        [
572            "missing blob",
573            "missing tree",
574            "missing commit",
575            "bad object",
576            "unable to read",
577            "object file",
578            "hash mismatch",
579            "broken link from",
580            "corrupt",
581            "invalid sha1 pointer",
582            "fatal: loose object",
583            "failed to parse commit",
584        ]
585        .iter()
586        .any(|needle| lower.contains(needle))
587    }
588
589    fn summarize_git_output(output: &str) -> String {
590        output
591            .lines()
592            .map(str::trim)
593            .find(|line| !line.is_empty())
594            .map(|line| line.chars().take(220).collect::<String>())
595            .unwrap_or_else(|| "git command reported no details".to_string())
596    }
597
598    async fn merge_head_path(&self) -> Result<PathBuf> {
599        let output = tokio::process::Command::new("git")
600            .args(["rev-parse", "--git-path", "MERGE_HEAD"])
601            .current_dir(&self.repo_path)
602            .output()
603            .await
604            .context("Failed to determine git merge metadata path")?;
605
606        if !output.status.success() {
607            return Err(anyhow!(
608                "Failed to resolve merge metadata path: {}",
609                String::from_utf8_lossy(&output.stderr).trim()
610            ));
611        }
612
613        let merge_head = String::from_utf8_lossy(&output.stdout).trim().to_string();
614        if merge_head.is_empty() {
615            return Err(anyhow!("Git returned an empty MERGE_HEAD path"));
616        }
617
618        let path = PathBuf::from(&merge_head);
619        if path.is_absolute() {
620            Ok(path)
621        } else {
622            Ok(self.repo_path.join(path))
623        }
624    }
625
626    /// Clean up all worktrees
627    pub async fn cleanup_all(&self) -> Result<usize> {
628        // Clone list so tracking persists until IO succeeds
629        let infos: Vec<WorktreeInfo> = {
630            let worktrees = self.worktrees.lock().await;
631            worktrees.clone()
632        };
633        let count = infos.len();
634
635        for info in &infos {
636            // Try git worktree remove first
637            let _ = tokio::process::Command::new("git")
638                .args(["worktree", "remove", "--force"])
639                .arg(&info.path)
640                .current_dir(&self.repo_path)
641                .output()
642                .await;
643
644            // Fallback to directory removal
645            if let Err(e) = tokio::fs::remove_dir_all(&info.path).await {
646                tracing::warn!(worktree = %info.name, error = %e, "Failed to remove worktree directory");
647            }
648        }
649
650        // Delete all branches so they don't leak
651        for info in &infos {
652            Self::delete_branch(&self.repo_path, &info.branch, None).await;
653        }
654
655        tracing::info!(count, "Cleaned up all worktrees");
656        Ok(count)
657    }
658
659    /// Inject workspace stub for Cargo workspace isolation
660    pub fn inject_workspace_stub(&self, _worktree_path: &Path) -> Result<()> {
661        // Placeholder: In a real implementation, this would prepend [workspace] to Cargo.toml
662        Ok(())
663    }
664
665    /// Delete a local branch and optionally its remote tracking ref (best-effort).
666    ///
667    /// Logs but does not propagate failures. Remote deletion is skipped when
668    /// `remote` is `None` or when the push fails.
669    async fn delete_branch(repo_path: &Path, branch: &str, remote: Option<&str>) {
670        // Local branch deletion
671        let out = tokio::process::Command::new("git")
672            .args(["branch", "-D", branch])
673            .current_dir(repo_path)
674            .output()
675            .await;
676        match out {
677            Ok(o) if o.status.success() => {
678                tracing::info!(branch, "Deleted worktree branch");
679            }
680            Ok(o) => {
681                let err = String::from_utf8_lossy(&o.stderr);
682                tracing::debug!(branch, error = %err, "Branch delete skipped");
683            }
684            Err(e) => {
685                tracing::debug!(branch, error = %e, "Branch delete failed");
686            }
687        }
688        // Remote branch deletion (best-effort)
689        if let Some(remote_name) = remote {
690            let out = tokio::process::Command::new("git")
691                .args(["push", remote_name, "--delete", branch])
692                .current_dir(repo_path)
693                .output()
694                .await;
695            match out {
696                Ok(o) if o.status.success() => {
697                    tracing::info!(
698                        branch,
699                        remote = remote_name,
700                        "Deleted remote worktree branch"
701                    );
702                }
703                Ok(o) => {
704                    let err = String::from_utf8_lossy(&o.stderr);
705                    tracing::debug!(
706                        branch,
707                        remote = remote_name,
708                        error = %err,
709                        "Remote branch delete skipped"
710                    );
711                }
712                Err(e) => {
713                    tracing::debug!(
714                        branch,
715                        remote = remote_name,
716                        error = %e,
717                        "Remote branch delete failed"
718                    );
719                }
720            }
721        }
722    }
723
724    /// Get list of conflicting files during a merge
725    async fn get_conflict_list(&self) -> Result<Vec<String>> {
726        let output = tokio::process::Command::new("git")
727            .args(["diff", "--name-only", "--diff-filter=U"])
728            .current_dir(&self.repo_path)
729            .output()
730            .await
731            .context("Failed to get conflict list")?;
732
733        let conflicts = String::from_utf8_lossy(&output.stdout)
734            .lines()
735            .map(String::from)
736            .filter(|s| !s.is_empty())
737            .collect();
738
739        Ok(conflicts)
740    }
741
742    /// Get diffs for conflicting files
743    async fn get_conflict_diffs(&self) -> Result<Vec<(String, String)>> {
744        let conflicts = self.get_conflict_list().await?;
745        let mut diffs = Vec::new();
746
747        for file in conflicts {
748            let output = tokio::process::Command::new("git")
749                .args(["diff", &file])
750                .current_dir(&self.repo_path)
751                .output()
752                .await;
753
754            if let Ok(o) = output {
755                let diff = String::from_utf8_lossy(&o.stdout).to_string();
756                diffs.push((file, diff));
757            }
758        }
759
760        Ok(diffs)
761    }
762
763    /// Count files changed in the last merge
764    async fn count_merge_files_changed(&self) -> Result<usize> {
765        let output = tokio::process::Command::new("git")
766            .args(["diff", "--name-only", "HEAD~1", "HEAD"])
767            .current_dir(&self.repo_path)
768            .output()
769            .await
770            .context("Failed to count changed files")?;
771
772        let count = String::from_utf8_lossy(&output.stdout)
773            .lines()
774            .filter(|s| !s.is_empty())
775            .count();
776
777        Ok(count)
778    }
779
780    /// Pop the most recent stash after a merge completes or fails.
781    fn stash_pop(repo_path: &Path) -> Result<()> {
782        let output = std::process::Command::new("git")
783            .args(["stash", "pop"])
784            .current_dir(repo_path)
785            .output()
786            .context("Failed to execute git stash pop")?;
787        if !output.status.success() {
788            tracing::warn!(
789                "stash pop failed (may be empty stash): {}",
790                String::from_utf8_lossy(&output.stderr)
791            );
792        }
793        Ok(())
794    }
795}
796
797#[cfg(test)]
798mod tests {
799    use super::WorktreeManager;
800
801    #[test]
802    fn corruption_detection_matches_missing_blob() {
803        let output = "error: missing blob 1234abcd";
804        assert!(WorktreeManager::looks_like_object_corruption(output));
805    }
806
807    #[test]
808    fn corruption_detection_ignores_non_corruption_errors() {
809        let output = "fatal: not a git repository";
810        assert!(!WorktreeManager::looks_like_object_corruption(output));
811    }
812
813    #[test]
814    fn summarize_output_uses_first_non_empty_line() {
815        let output = "\n\nfatal: bad object HEAD\nmore";
816        assert_eq!(
817            WorktreeManager::summarize_git_output(output),
818            "fatal: bad object HEAD"
819        );
820    }
821}
822
823impl Default for WorktreeManager {
824    fn default() -> Self {
825        Self::new("/tmp/codetether-worktrees")
826    }
827}