Skip to main content

ryu_workspace/
worktree.rs

1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use crate::win_process::NoWindow;
5
6use uuid::Uuid;
7
8/// A live per-run worktree. Created by [`create_worktree`] and cleaned up when
9/// dropped (synchronous, so it works inside both regular code and `Drop` impls).
10/// Moving it into the ACP stream generator ensures cleanup on stream completion
11/// or on early client disconnect.
12pub struct WorktreeGuard {
13    /// Absolute path to the worktree directory.
14    pub path: PathBuf,
15    /// `ryu/run-<id>` branch created with the worktree.
16    pub branch: String,
17    /// Root of the repository (where `git worktree remove` must be run from).
18    repo_root: PathBuf,
19    /// The commit SHA from which this worktree was forked (the base for diff).
20    pub base_hash: String,
21}
22
23impl Drop for WorktreeGuard {
24    fn drop(&mut self) {
25        remove_worktree_sync(&self.repo_root, &self.path, &self.branch);
26    }
27}
28
29/// Create a git worktree for one agent run with an auto-generated branch name
30/// (`ryu/run-<id>`). Thin wrapper over [`create_worktree_in`].
31pub fn create_worktree(repo_path: &Path) -> anyhow::Result<WorktreeGuard> {
32    create_worktree_in(repo_path, None)
33}
34
35/// Sanitize a user-supplied branch name into a git-legal ref segment.
36///
37/// Replaces whitespace with `-`, drops characters git forbids in refs, collapses
38/// `..` sequences, and trims leading/trailing separators. Returns `None` when
39/// nothing usable remains (caller falls back to the auto-generated name).
40fn sanitize_branch_name(raw: &str) -> Option<String> {
41    let trimmed = raw.trim();
42    if trimmed.is_empty() {
43        return None;
44    }
45    let mut out = String::with_capacity(trimmed.len());
46    for ch in trimmed.chars() {
47        if ch.is_whitespace() {
48            out.push('-');
49        } else if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '/' | '.') {
50            out.push(ch);
51        }
52        // Everything else (~^:?*[\ etc.) is dropped.
53    }
54    // Git forbids `..` in a ref; collapse any that survived.
55    while out.contains("..") {
56        out = out.replace("..", ".");
57    }
58    let cleaned = out
59        .trim_matches(|c| c == '/' || c == '.' || c == '-')
60        .to_string();
61    if cleaned.is_empty() {
62        None
63    } else {
64        Some(cleaned)
65    }
66}
67
68/// Whether a local branch already exists in `repo_path`.
69fn branch_exists(repo_path: &Path, branch: &str) -> bool {
70    Command::new("git")
71        .args(["show-ref", "--verify", "--quiet"])
72        .arg(format!("refs/heads/{branch}"))
73        .current_dir(repo_path)
74        .no_window()
75        .status()
76        .map(|s| s.success())
77        .unwrap_or(false)
78}
79
80/// Create a git worktree for one agent run.
81///
82/// Shells `git worktree add <dir> -b <branch>` from `repo_path`. The new
83/// worktree lives under `<repo_root>/.ryu-worktrees/ryu-run-<id>` (inside the
84/// repo, below `.gitignore`). When `branch_name` is `Some`, it is sanitized and
85/// used as the branch (with a short uuid suffix appended on collision); when
86/// `None` (or unusable), the branch is auto-named `ryu/run-<id>`. Returns a
87/// [`WorktreeGuard`] whose `Drop` cleans up the worktree directory and its
88/// branch automatically.
89pub fn create_worktree_in(
90    repo_path: &Path,
91    branch_name: Option<&str>,
92) -> anyhow::Result<WorktreeGuard> {
93    let run_id = Uuid::new_v4().to_string();
94    let branch = match branch_name.and_then(sanitize_branch_name) {
95        Some(name) if branch_exists(repo_path, &name) => {
96            format!("{name}-{}", &run_id[..8])
97        }
98        Some(name) => name,
99        None => format!("ryu/run-{run_id}"),
100    };
101
102    // Capture the current HEAD SHA so we have a stable base for diff later.
103    let base_hash = Command::new("git")
104        .args(["rev-parse", "HEAD"])
105        .current_dir(repo_path)
106        .no_window()
107        .output()
108        .ok()
109        .filter(|o| o.status.success())
110        .and_then(|o| String::from_utf8(o.stdout).ok())
111        .map(|s| s.trim().to_string())
112        .unwrap_or_default();
113
114    // Place worktrees under a Core-owned sub-directory of the repo.
115    let worktree_base = repo_path.join(".ryu-worktrees");
116    std::fs::create_dir_all(&worktree_base)?;
117    let worktree_path = worktree_base.join(format!("ryu-run-{run_id}"));
118
119    let output = Command::new("git")
120        .args(["worktree", "add", "-b", &branch])
121        .arg(&worktree_path)
122        .arg("HEAD")
123        .current_dir(repo_path)
124        .no_window()
125        .output()
126        .map_err(|e| anyhow::anyhow!("git worktree add: {e}"))?;
127
128    if !output.status.success() {
129        let stderr = String::from_utf8_lossy(&output.stderr);
130        return Err(anyhow::anyhow!("git worktree add failed: {stderr}"));
131    }
132
133    tracing::info!(
134        branch = %branch,
135        path = %worktree_path.display(),
136        "worktree created"
137    );
138
139    Ok(WorktreeGuard {
140        path: worktree_path,
141        branch,
142        repo_root: repo_path.to_owned(),
143        base_hash,
144    })
145}
146
147/// Remove a worktree directory and its branch; called from `Drop`.
148///
149/// Uses synchronous `std::process::Command` so it is usable inside `Drop`.
150/// Logs warnings on failure — errors here are non-fatal since the git
151/// repository is still valid; the caller can run `git worktree prune` manually.
152fn remove_worktree_sync(repo_root: &Path, worktree_path: &Path, branch: &str) {
153    let rm = Command::new("git")
154        .args(["worktree", "remove", "--force"])
155        .arg(worktree_path)
156        .current_dir(repo_root)
157        .no_window()
158        .output();
159
160    match rm {
161        Ok(out) if out.status.success() => {
162            tracing::info!(path = %worktree_path.display(), "worktree removed");
163        }
164        Ok(out) => {
165            let stderr = String::from_utf8_lossy(&out.stderr);
166            tracing::warn!("git worktree remove failed: {stderr}");
167        }
168        Err(e) => tracing::warn!("git worktree remove exec error: {e}"),
169    }
170
171    let prune = Command::new("git")
172        .args(["worktree", "prune"])
173        .current_dir(repo_root)
174        .no_window()
175        .output();
176    if let Err(e) = prune {
177        tracing::warn!("git worktree prune exec error: {e}");
178    }
179
180    let del_branch = Command::new("git")
181        .args(["branch", "-D", branch])
182        .current_dir(repo_root)
183        .no_window()
184        .output();
185
186    match del_branch {
187        Ok(out) if out.status.success() => {
188            tracing::info!(branch = %branch, "run branch deleted");
189        }
190        Ok(out) => {
191            let stderr = String::from_utf8_lossy(&out.stderr);
192            tracing::warn!("git branch -D failed: {stderr}");
193        }
194        Err(e) => tracing::warn!("git branch -D exec error: {e}"),
195    }
196}
197
198/// Detect whether `path` is inside a git repository. Used to gate worktree
199/// isolation — non-repo directories fall back to the plain cwd path.
200pub fn is_git_repo(path: &Path) -> bool {
201    Command::new("git")
202        .args(["-C"])
203        .arg(path)
204        .args(["rev-parse", "--is-inside-work-tree"])
205        .no_window()
206        .output()
207        .map(|o| o.status.success())
208        .unwrap_or(false)
209}
210
211/// Find the root of the git repository containing `path`.
212pub fn find_git_root(path: &Path) -> Option<PathBuf> {
213    let output = Command::new("git")
214        .args(["-C"])
215        .arg(path)
216        .args(["rev-parse", "--show-toplevel"])
217        .no_window()
218        .output()
219        .ok()?;
220    if output.status.success() {
221        let root = String::from_utf8(output.stdout).ok()?;
222        Some(PathBuf::from(root.trim()))
223    } else {
224        None
225    }
226}
227
228// ── Diff ──────────────────────────────────────────────────────────────────────
229
230/// Change status for one file in the diff summary.
231#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
232#[serde(rename_all = "snake_case")]
233pub enum FileChangeKind {
234    Added,
235    Modified,
236    Deleted,
237    Renamed,
238}
239
240/// Per-file summary entry returned by [`worktree_diff`].
241#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
242pub struct FileSummary {
243    /// Repo-relative path (forward slashes).
244    pub path: String,
245    pub kind: FileChangeKind,
246    pub additions: u32,
247    pub deletions: u32,
248}
249
250/// The aggregate diff for a single run's worktree. Returned by
251/// `GET /api/worktree/:run_id/diff`.
252#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
253pub struct WorktreeDiff {
254    /// True when the worktree diverges from the base branch in any way
255    /// (committed OR uncommitted).
256    pub has_changes: bool,
257    /// Per-file summary (path, kind, +/- counts).
258    pub files: Vec<FileSummary>,
259    /// Unified diff covering committed changes (`git diff HEAD...<branch_base>`)
260    /// merged with any uncommitted working-tree changes.
261    pub unified_diff: String,
262}
263
264/// Compute the aggregate diff for a live worktree.
265///
266/// `worktree_path` is the absolute path to the worktree directory.
267/// `base_branch` is the branch the worktree was forked from (e.g. `HEAD` of the
268/// main checkout at the time `git worktree add` was run — the merge-base is used
269/// to compute only the run's own changes, not the full history divergence).
270///
271/// Returns a zero-change [`WorktreeDiff`] on any git failure so callers can
272/// render "no changes" rather than an error.
273///
274/// Stages all untracked and modified files via `git add -A` before diffing.
275/// This is safe because the worktree is ephemeral and about to be destroyed;
276/// mutating the index ensures new files created by the agent appear in the diff
277/// and aren't silently dropped by `git diff HEAD` (which skips untracked files).
278pub fn worktree_diff(worktree_path: &Path, base_ref: &str) -> WorktreeDiff {
279    let empty = WorktreeDiff {
280        has_changes: false,
281        files: vec![],
282        unified_diff: String::new(),
283    };
284
285    if !worktree_path.is_dir() {
286        return empty;
287    }
288
289    let cwd = worktree_path.to_str().unwrap_or(".");
290
291    // Stage everything in the worktree so that new (untracked) files appear in
292    // `git diff --cached`, which includes them in the full diff below. The
293    // worktree is about to be destroyed, so mutating the index is safe.
294    let _ = Command::new("git")
295        .args(["add", "-A"])
296        .current_dir(cwd)
297        .no_window()
298        .output();
299
300    // 1. Unified diff: the full change set — committed commits on this branch
301    //    relative to `base_ref`, PLUS any newly-staged working-tree changes
302    //    (including files the agent created but never committed).
303    //    `git diff <base_ref>` in three-dot form finds the merge-base so the
304    //    diff reflects "what this run added" only.
305    let committed_diff =
306        run_git_output(cwd, &["diff", &format!("{base_ref}...HEAD"), "--unified=3"]);
307    // Staged changes not yet committed (new files, modifications staged by `git add -A`).
308    let staged_diff = run_git_output(cwd, &["diff", "--cached", "--unified=3"]);
309
310    let mut unified_diff = committed_diff.unwrap_or_default();
311    if let Some(staged) = staged_diff {
312        if !staged.is_empty() {
313            if !unified_diff.is_empty() {
314                unified_diff.push('\n');
315            }
316            unified_diff.push_str(&staged);
317        }
318    }
319
320    // 2. Per-file summary via `git diff --numstat`.
321    let committed_stat = run_git_output(cwd, &["diff", &format!("{base_ref}...HEAD"), "--numstat"]);
322    let staged_stat = run_git_output(cwd, &["diff", "--cached", "--numstat"]);
323
324    // Track files by path (last write wins — staged supercedes committed).
325    let mut file_map: std::collections::HashMap<String, FileSummary> = Default::default();
326
327    for stat_block in [committed_stat, staged_stat].into_iter().flatten() {
328        for line in stat_block.lines() {
329            if let Some(summary) = parse_numstat_line(line) {
330                file_map.insert(summary.path.clone(), summary);
331            }
332        }
333    }
334
335    // Supplement the numstat with --name-status for both committed and staged
336    // ranges. The staged range picks up added/deleted files that appear as
337    // zero-line changes (e.g. empty new files, deletions) that --numstat misses.
338    let committed_range = format!("{base_ref}...HEAD");
339    let name_status_sources: [&[&str]; 2] = [
340        &["diff", &committed_range, "--name-status"],
341        &["diff", "--cached", "--name-status"],
342    ];
343    for ns_args in name_status_sources {
344        if let Some(ns_output) = run_git_output(cwd, ns_args) {
345            for line in ns_output.lines() {
346                let parts: Vec<&str> = line.splitn(2, '\t').collect();
347                if parts.len() < 2 {
348                    continue;
349                }
350                let status = parts[0].trim();
351                let path = parts[1].trim().to_string();
352                // Rename: "R<score>\told_path\tnew_path" — already captured by numstat.
353                let kind = match status.chars().next() {
354                    Some('A') => FileChangeKind::Added,
355                    Some('D') => FileChangeKind::Deleted,
356                    Some('R') => FileChangeKind::Renamed,
357                    _ => FileChangeKind::Modified,
358                };
359                file_map.entry(path.clone()).or_insert(FileSummary {
360                    path,
361                    kind,
362                    additions: 0,
363                    deletions: 0,
364                });
365            }
366        }
367    }
368
369    let mut files: Vec<FileSummary> = file_map.into_values().collect();
370    files.sort_by(|a, b| a.path.cmp(&b.path));
371
372    let has_changes = !files.is_empty() || !unified_diff.is_empty();
373    WorktreeDiff {
374        has_changes,
375        files,
376        unified_diff,
377    }
378}
379
380fn run_git_output(cwd: &str, args: &[&str]) -> Option<String> {
381    let out = Command::new("git")
382        .args(args)
383        .current_dir(cwd)
384        .no_window()
385        .output()
386        .ok()?;
387    if out.status.success() {
388        Some(String::from_utf8_lossy(&out.stdout).into_owned())
389    } else {
390        None
391    }
392}
393
394/// Parse one line of `git diff --numstat` output.
395/// Format: `<added>\t<deleted>\t<path>` (binary files use `-`).
396fn parse_numstat_line(line: &str) -> Option<FileSummary> {
397    let parts: Vec<&str> = line.splitn(3, '\t').collect();
398    if parts.len() < 3 {
399        return None;
400    }
401    let additions: u32 = parts[0].trim().parse().unwrap_or(0);
402    let deletions: u32 = parts[1].trim().parse().unwrap_or(0);
403    let path = parts[2].trim().replace('\\', "/");
404    if path.is_empty() {
405        return None;
406    }
407    let kind = if additions > 0 && deletions == 0 {
408        FileChangeKind::Added
409    } else if deletions > 0 && additions == 0 {
410        FileChangeKind::Deleted
411    } else {
412        FileChangeKind::Modified
413    };
414    Some(FileSummary {
415        path,
416        kind,
417        additions,
418        deletions,
419    })
420}
421
422// ── Apply (commit + merge or open PR) ────────────────────────────────────────
423
424/// The mode for applying a completed run's changes.
425#[derive(Debug, Clone, serde::Deserialize)]
426#[serde(rename_all = "snake_case")]
427pub enum ApplyMode {
428    /// Commit any staged/unstaged changes in the worktree, then merge the
429    /// worktree branch into the base branch.
430    Merge,
431    /// Commit, push the branch to origin, and open a PR via `gh pr create`.
432    Pr,
433}
434
435/// Returned on a successful apply.
436#[derive(Debug, serde::Serialize)]
437pub struct ApplySuccess {
438    /// `merge` mode: the commit SHA that was merged.
439    pub commit: Option<String>,
440    /// `pr` mode: the URL of the created PR.
441    pub pr_url: Option<String>,
442}
443
444/// A conflict prevented the merge from completing cleanly.
445#[derive(Debug, serde::Serialize)]
446pub struct ConflictError {
447    pub conflicted_files: Vec<String>,
448}
449
450/// Apply the worktree's changes: commit → merge into base OR commit → push → PR.
451///
452/// On success the worktree and its branch are removed (via the existing
453/// `remove_worktree_sync` helper). On merge conflict, `git merge --abort` is
454/// called so the base repo is never left mid-merge, and the worktree + branch
455/// are cleaned up before returning the conflict list.
456pub fn apply_worktree(
457    guard: &WorktreeGuard,
458    mode: ApplyMode,
459    message: &str,
460    base: Option<&str>,
461) -> Result<ApplySuccess, ConflictError> {
462    let wt = guard.path.as_path();
463    let repo = guard.repo_root.as_path();
464
465    // Stage all changes in the worktree.
466    let _ = Command::new("git")
467        .args(["add", "-A"])
468        .current_dir(wt)
469        .no_window()
470        .output();
471
472    // Check if there is anything to commit.
473    let status = Command::new("git")
474        .args(["status", "--porcelain"])
475        .current_dir(wt)
476        .no_window()
477        .output()
478        .ok()
479        .and_then(|o| String::from_utf8(o.stdout).ok())
480        .unwrap_or_default();
481
482    if !status.trim().is_empty() {
483        let commit_out = Command::new("git")
484            .args(["commit", "-m", message])
485            .current_dir(wt)
486            .no_window()
487            .output();
488
489        if let Ok(out) = commit_out {
490            if !out.status.success() {
491                let err = String::from_utf8_lossy(&out.stderr);
492                tracing::warn!("apply: git commit failed in worktree: {err}");
493            }
494        }
495    }
496
497    // Determine the effective base (the branch HEAD has checked out in the main repo).
498    let effective_base = base.map(str::to_string).unwrap_or_else(|| {
499        Command::new("git")
500            .args(["rev-parse", "--abbrev-ref", "HEAD"])
501            .current_dir(repo)
502            .no_window()
503            .output()
504            .ok()
505            .filter(|o| o.status.success())
506            .and_then(|o| String::from_utf8(o.stdout).ok())
507            .map(|s| s.trim().to_string())
508            .unwrap_or_else(|| "main".to_string())
509    });
510
511    match mode {
512        ApplyMode::Merge => {
513            let merge_out = Command::new("git")
514                .args(["merge", "--no-ff", &guard.branch, "-m", message])
515                .current_dir(repo)
516                .no_window()
517                .output();
518
519            match merge_out {
520                Ok(out) if out.status.success() => {
521                    let commit_sha = Command::new("git")
522                        .args(["rev-parse", "HEAD"])
523                        .current_dir(repo)
524                        .no_window()
525                        .output()
526                        .ok()
527                        .filter(|o| o.status.success())
528                        .and_then(|o| String::from_utf8(o.stdout).ok())
529                        .map(|s| s.trim().to_string());
530
531                    Ok(ApplySuccess {
532                        commit: commit_sha,
533                        pr_url: None,
534                    })
535                }
536                Ok(out) => {
537                    let stderr = String::from_utf8_lossy(&out.stderr);
538                    tracing::warn!("apply: merge conflict: {stderr}");
539
540                    // Collect conflicted files.
541                    let conflicted = Command::new("git")
542                        .args(["diff", "--name-only", "--diff-filter=U"])
543                        .current_dir(repo)
544                        .no_window()
545                        .output()
546                        .ok()
547                        .filter(|o| o.status.success())
548                        .and_then(|o| String::from_utf8(o.stdout).ok())
549                        .map(|s| {
550                            s.lines()
551                                .filter(|l| !l.is_empty())
552                                .map(str::to_string)
553                                .collect::<Vec<_>>()
554                        })
555                        .unwrap_or_default();
556
557                    // Abort so base repo is never left mid-merge.
558                    let _ = Command::new("git")
559                        .args(["merge", "--abort"])
560                        .current_dir(repo)
561                        .no_window()
562                        .output();
563
564                    Err(ConflictError {
565                        conflicted_files: conflicted,
566                    })
567                }
568                Err(e) => {
569                    tracing::error!("apply: merge exec error: {e}");
570                    Err(ConflictError {
571                        conflicted_files: vec![],
572                    })
573                }
574            }
575        }
576
577        ApplyMode::Pr => {
578            // Push the worktree branch to origin.
579            let push_out = Command::new("git")
580                .args(["push", "-u", "origin", &guard.branch])
581                .current_dir(wt)
582                .no_window()
583                .output();
584
585            if let Ok(ref out) = push_out {
586                if !out.status.success() {
587                    let err = String::from_utf8_lossy(&out.stderr);
588                    tracing::warn!("apply: git push failed: {err}");
589                }
590            }
591
592            // Run `gh pr create` — requires `gh` to be authed. Use `--head` to
593            // point at the worktree branch and `--base` at the effective base.
594            let gh_out = Command::new("gh")
595                .args([
596                    "pr",
597                    "create",
598                    "--head",
599                    &guard.branch,
600                    "--base",
601                    &effective_base,
602                    "--title",
603                    message,
604                    "--body",
605                    "",
606                ])
607                .current_dir(repo)
608                .no_window()
609                .output();
610
611            match gh_out {
612                Ok(out) if out.status.success() => {
613                    let pr_url = String::from_utf8_lossy(&out.stdout).trim().to_string();
614                    Ok(ApplySuccess {
615                        commit: None,
616                        pr_url: Some(pr_url),
617                    })
618                }
619                Ok(out) => {
620                    let err = String::from_utf8_lossy(&out.stderr);
621                    tracing::warn!("apply: gh pr create failed: {err}");
622                    // Return a conflict-style error so the caller sees a 409.
623                    Err(ConflictError {
624                        conflicted_files: vec![],
625                    })
626                }
627                Err(e) => {
628                    tracing::error!("apply: gh exec error: {e}");
629                    Err(ConflictError {
630                        conflicted_files: vec![],
631                    })
632                }
633            }
634        }
635    }
636}
637
638// ── Tests ─────────────────────────────────────────────────────────────────────
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643    use std::process::Command;
644    use tempfile::TempDir;
645
646    fn init_git_repo(dir: &Path) {
647        Command::new("git")
648            .args(["init"])
649            .current_dir(dir)
650            .output()
651            .expect("git init");
652        Command::new("git")
653            .args(["config", "user.email", "test@ryu"])
654            .current_dir(dir)
655            .output()
656            .expect("git config email");
657        Command::new("git")
658            .args(["config", "user.name", "Test"])
659            .current_dir(dir)
660            .output()
661            .expect("git config name");
662        // Need at least one commit for worktree add to work.
663        let readme = dir.join("README");
664        std::fs::write(&readme, "init").expect("write README");
665        Command::new("git")
666            .args(["add", "."])
667            .current_dir(dir)
668            .output()
669            .expect("git add");
670        Command::new("git")
671            .args(["commit", "-m", "init"])
672            .current_dir(dir)
673            .output()
674            .expect("git commit");
675    }
676
677    #[test]
678    fn diff_captures_committed_changes_in_worktree() {
679        let tmp = TempDir::new().expect("tempdir");
680        let repo = tmp.path();
681        init_git_repo(repo);
682
683        let guard = create_worktree(repo).expect("create_worktree");
684        let wt_path = guard.path.clone();
685        let base_hash = guard.base_hash.clone();
686        assert!(
687            !base_hash.is_empty(),
688            "base_hash should be captured at worktree creation"
689        );
690
691        // The worktree starts clean: diff against the base commit returns no changes.
692        let diff_clean = worktree_diff(&wt_path, &base_hash);
693        assert!(
694            !diff_clean.has_changes,
695            "fresh worktree should report no changes"
696        );
697
698        // Write two files and commit them inside the worktree.
699        std::fs::write(wt_path.join("alpha.txt"), "hello alpha").expect("write alpha");
700        std::fs::write(wt_path.join("beta.txt"), "hello beta").expect("write beta");
701        Command::new("git")
702            .args(["add", "."])
703            .current_dir(&wt_path)
704            .output()
705            .expect("git add");
706        Command::new("git")
707            .args(["commit", "-m", "add two files"])
708            .current_dir(&wt_path)
709            .output()
710            .expect("git commit");
711
712        // After committing, diff against the captured base_hash shows the 2 new files.
713        let diff = worktree_diff(&wt_path, &base_hash);
714        assert!(diff.has_changes, "should see changes after commit");
715        assert_eq!(diff.files.len(), 2, "should report 2 changed files");
716        assert!(
717            diff.unified_diff.contains("alpha.txt") || diff.unified_diff.contains("beta.txt"),
718            "unified diff should mention at least one of the added files"
719        );
720
721        drop(guard);
722
723        // Confirm worktree directory is gone.
724        assert!(!wt_path.exists(), "worktree dir should be gone after drop");
725    }
726
727    #[test]
728    fn diff_captures_untracked_files_in_worktree() {
729        let tmp = TempDir::new().expect("tempdir");
730        let repo = tmp.path();
731        init_git_repo(repo);
732
733        let guard = create_worktree(repo).expect("create_worktree");
734        let wt_path = guard.path.clone();
735        let base_hash = guard.base_hash.clone();
736
737        // Write two files but do NOT commit (simulating an ACP agent that only
738        // edits files without running git commit). The diff should still include
739        // them because worktree_diff stages via `git add -A` before diffing.
740        std::fs::write(wt_path.join("gamma.txt"), "hello gamma").expect("write gamma");
741        std::fs::write(wt_path.join("delta.txt"), "hello delta").expect("write delta");
742
743        let diff = worktree_diff(&wt_path, &base_hash);
744        assert!(diff.has_changes, "untracked files should be detected");
745        assert_eq!(
746            diff.files.len(),
747            2,
748            "should report 2 changed files from untracked"
749        );
750        assert!(
751            diff.unified_diff.contains("gamma.txt") || diff.unified_diff.contains("delta.txt"),
752            "unified diff should mention at least one of the new untracked files"
753        );
754
755        drop(guard);
756    }
757
758    /// AC1 for issue #128: two concurrent worktrees from the same repo must be
759    /// independent — creating/dropping one must not disturb the other.
760    #[test]
761    fn two_concurrent_worktrees_are_independent() {
762        let tmp = TempDir::new().expect("tempdir");
763        let repo = tmp.path();
764        init_git_repo(repo);
765
766        let guard_a = create_worktree(repo).expect("create worktree A");
767        let guard_b = create_worktree(repo).expect("create worktree B");
768
769        let path_a = guard_a.path.clone();
770        let path_b = guard_b.path.clone();
771        let branch_a = guard_a.branch.clone();
772        let branch_b = guard_b.branch.clone();
773
774        assert!(path_a.exists(), "worktree A should exist");
775        assert!(path_b.exists(), "worktree B should exist");
776        assert_ne!(path_a, path_b, "worktrees should be at distinct paths");
777        assert_ne!(branch_a, branch_b, "each run gets its own branch");
778
779        // Both must appear in `git worktree list`.
780        let list = Command::new("git")
781            .args(["worktree", "list"])
782            .current_dir(repo)
783            .output()
784            .expect("git worktree list");
785        let list_str = String::from_utf8_lossy(&list.stdout);
786        let norm_a = path_a.to_string_lossy().replace('\\', "/");
787        let norm_b = path_b.to_string_lossy().replace('\\', "/");
788        assert!(
789            list_str.contains(&*norm_a),
790            "worktree A should appear in list; got:\n{list_str}"
791        );
792        assert!(
793            list_str.contains(&*norm_b),
794            "worktree B should appear in list; got:\n{list_str}"
795        );
796
797        // Drop A — B must survive.
798        drop(guard_a);
799        assert!(!path_a.exists(), "worktree A should be gone after drop");
800        assert!(
801            path_b.exists(),
802            "worktree B should still exist after A is dropped"
803        );
804
805        // Branch A must be gone; branch B must still exist.
806        let branches = Command::new("git")
807            .args(["branch", "--list"])
808            .current_dir(repo)
809            .output()
810            .expect("git branch list");
811        let branches_str = String::from_utf8_lossy(&branches.stdout);
812        assert!(
813            !branches_str.contains(&*branch_a),
814            "branch A should be deleted; got:\n{branches_str}"
815        );
816        assert!(
817            branches_str.contains(&*branch_b),
818            "branch B should still exist; got:\n{branches_str}"
819        );
820
821        drop(guard_b);
822    }
823
824    #[test]
825    fn apply_merge_lands_commit_on_base() {
826        let tmp = TempDir::new().expect("tempdir");
827        let repo = tmp.path();
828        init_git_repo(repo);
829
830        let guard = create_worktree(repo).expect("create_worktree");
831
832        // Write a file and stage it in the worktree (apply will commit it).
833        std::fs::write(guard.path.join("feature.txt"), "hello").expect("write");
834        Command::new("git")
835            .args(["add", "feature.txt"])
836            .current_dir(&guard.path)
837            .output()
838            .expect("git add");
839
840        let result = apply_worktree(&guard, ApplyMode::Merge, "feat: add feature", None);
841        assert!(
842            result.is_ok(),
843            "merge should succeed on a clean repo: {result:?}"
844        );
845        let ok = result.unwrap();
846        assert!(ok.commit.is_some(), "should return commit SHA");
847
848        // Confirm the file landed on the base branch.
849        assert!(
850            repo.join("feature.txt").exists(),
851            "feature.txt should be in base repo"
852        );
853
854        // Clean up guard manually (worktree + branch already gone from base after merge).
855        drop(guard);
856    }
857
858    #[test]
859    fn apply_merge_conflict_returns_409_data_and_leaves_base_clean() {
860        let tmp = TempDir::new().expect("tempdir");
861        let repo = tmp.path();
862        init_git_repo(repo);
863
864        // Write a file on the base branch.
865        std::fs::write(repo.join("conflict.txt"), "base content").expect("write base");
866        Command::new("git")
867            .args(["add", "."])
868            .current_dir(repo)
869            .output()
870            .expect("add");
871        Command::new("git")
872            .args(["commit", "-m", "base commit"])
873            .current_dir(repo)
874            .output()
875            .expect("commit");
876
877        // Create a worktree and write a conflicting version of the same file.
878        let guard = create_worktree(repo).expect("create_worktree");
879        std::fs::write(guard.path.join("conflict.txt"), "worktree content").expect("write wt");
880        Command::new("git")
881            .args(["add", "."])
882            .current_dir(&guard.path)
883            .output()
884            .expect("add");
885        Command::new("git")
886            .args(["commit", "-m", "wt commit"])
887            .current_dir(&guard.path)
888            .output()
889            .expect("commit");
890
891        // Also modify the file on base AFTER worktree creation so it diverges.
892        std::fs::write(repo.join("conflict.txt"), "base diverged content").expect("write base2");
893        Command::new("git")
894            .args(["add", "."])
895            .current_dir(repo)
896            .output()
897            .expect("add");
898        Command::new("git")
899            .args(["commit", "-m", "base diverged"])
900            .current_dir(repo)
901            .output()
902            .expect("commit");
903
904        let result = apply_worktree(&guard, ApplyMode::Merge, "conflict merge", None);
905
906        // On a genuine conflict the merge should fail with the conflicted file list.
907        if let Err(conflict) = result {
908            assert!(
909                !conflict.conflicted_files.is_empty(),
910                "conflict error should include conflicted files"
911            );
912            // Base repo must be in a clean state (merge --abort ran).
913            let status = Command::new("git")
914                .args(["status", "--porcelain"])
915                .current_dir(repo)
916                .output()
917                .expect("git status");
918            let status_str = String::from_utf8_lossy(&status.stdout);
919            assert!(
920                !status_str.contains("UU"),
921                "base repo should not have unmerged files after abort"
922            );
923        }
924        // If merge succeeded (fast-forward on some git versions), that's also valid.
925
926        drop(guard);
927    }
928
929    #[test]
930    fn create_then_drop_removes_worktree_and_branch() {
931        let tmp = TempDir::new().expect("tempdir");
932        let repo = tmp.path();
933        init_git_repo(repo);
934
935        let guard = create_worktree(repo).expect("create_worktree");
936        let worktree_path = guard.path.clone();
937        let branch = guard.branch.clone();
938
939        assert!(
940            worktree_path.exists(),
941            "worktree dir should exist after create"
942        );
943
944        // Confirm git knows about the worktree.
945        let list = Command::new("git")
946            .args(["worktree", "list"])
947            .current_dir(repo)
948            .output()
949            .expect("git worktree list");
950        let list_str = String::from_utf8_lossy(&list.stdout);
951        // On Windows git outputs forward-slash paths; normalize for comparison.
952        let normalized_path = worktree_path.to_string_lossy().replace('\\', "/");
953        assert!(
954            list_str.contains(&*normalized_path),
955            "worktree should appear in git worktree list; got:\n{list_str}"
956        );
957
958        // Dropping the guard removes the worktree and branch.
959        drop(guard);
960
961        assert!(
962            !worktree_path.exists(),
963            "worktree dir should be gone after drop"
964        );
965
966        // Branch must also be deleted.
967        let branches = Command::new("git")
968            .args(["branch", "--list", &branch])
969            .current_dir(repo)
970            .output()
971            .expect("git branch list");
972        let branches_str = String::from_utf8_lossy(&branches.stdout);
973        assert!(
974            branches_str.trim().is_empty(),
975            "run branch should be deleted after drop"
976        );
977    }
978}