Skip to main content

forge_agent/env/
worktree.rs

1//! Git-worktree helpers for the worker spawn and despawn paths:
2//! detecting a repo, reading a worktree's branch, deciding whether a
3//! worktree is safe to remove, removing it, and reaping the branch
4//! behind it.
5//!
6//! Synchronous throughout because each answer is one fast git
7//! subprocess and the callers are already on a tokio task.
8
9use std::collections::HashMap;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12use std::process::Command;
13use std::sync::OnceLock;
14
15use super::git_command;
16
17/// Check whether `path` is inside a git repository. Shells out to
18/// `git rev-parse --git-dir`. Exit 0 -> true; anything else -> false.
19///
20/// Handles every git-recognised shape (normal repo, submodule,
21/// worktree-in-worktree, detached HEAD, bare repo) by reading git's
22/// own answer rather than re-implementing detection. Non-existent
23/// paths return false because git itself errors on them.
24pub fn is_git_repo(path: &Path) -> bool {
25    git_command::command("git")
26        .args(["rev-parse", "--git-dir"])
27        .current_dir(path)
28        .output()
29        .is_ok_and(|out| out.status.success())
30}
31
32/// The tree a worker spawned at `cwd` runs in: the repo root when the
33/// path is inside a repository, the path itself otherwise. Both are
34/// canonical, because the `claude` CLI derives the session JSONL's
35/// directory from the canonical path - measured: a worktree under
36/// `/tmp` writes its transcript under `/private/tmp` - so a record
37/// holding the alias would name a directory the CLI never wrote to.
38///
39/// `Err` when the path is not there to canonicalize or is not a
40/// directory, so a typo is refused where it was typed rather than
41/// surfacing as claude's own cwd failure at spawn.
42pub fn worker_root(cwd: &Path) -> Result<std::path::PathBuf, String> {
43    let resolved = std::fs::canonicalize(cwd)
44        .map_err(|error| format!("{} is not a directory: {error}", cwd.display()))?;
45    if !resolved.is_dir() {
46        return Err(format!("{} is not a directory", cwd.display()));
47    }
48    let Some(toplevel) = git_in_repo(&resolved, &["rev-parse", "--show-toplevel"]) else {
49        return Ok(resolved);
50    };
51    Ok(std::fs::canonicalize(toplevel.trim()).unwrap_or(resolved))
52}
53
54/// The checkout root the `claude` CLI forks a worker's `--worktree` under:
55/// the repository's main working tree, resolved from the first entry of
56/// `git worktree list --porcelain` - the main checkout when the project is
57/// itself a linked worktree, the repo root for a subdirectory. Measured on
58/// 2.1.280: the CLI resolves its cwd to that root before composing
59/// `<root>/.claude/worktrees/<label>`, so a project rooted in a linked
60/// worktree (or in a repo subdirectory) otherwise names a directory the
61/// CLI never creates (#2460). A path that is not a repository is its own
62/// base.
63///
64/// A repository kept in a separate git dir is NOT a supported worker
65/// layout: git names its admin dir (not a checkout) as the first worktree
66/// entry, and the CLI's own base for that shape is unverified, so forge
67/// follows git's answer rather than guessing a checkout it cannot derive.
68///
69/// Memoised per root: callers include render paths that ask per row per
70/// frame, and a checkout's canonical root does not move under a live
71/// forge. A git that could not answer is NOT memoised - a transient
72/// failure must not pin the path this exists to correct - and is reported
73/// once per root.
74pub fn worktree_base(root: &Path) -> PathBuf {
75    static BASES: OnceLock<parking_lot::Mutex<HashMap<PathBuf, PathBuf>>> = OnceLock::new();
76    let cache = BASES.get_or_init(|| parking_lot::Mutex::new(HashMap::new()));
77    if let Some(base) = cache.lock().get(root).cloned() {
78        return base;
79    }
80    // A path that is not there cannot be a repository: answer without a
81    // git call, so a caller on a render path (a stale row's gone tree)
82    // does not spawn one per frame.
83    if !root.is_dir() {
84        return root.to_path_buf();
85    }
86    let Some(base) = resolve_worktree_base(root) else {
87        // Not a repository is an answer; a repository git would not read
88        // is not. Only the second is worth reporting, and neither is
89        // cached: the next call re-asks.
90        if is_git_repo(root) && first_unresolved(root) {
91            report_unresolved_root(root);
92        }
93        return root.to_path_buf();
94    };
95    cache.lock().insert(root.to_path_buf(), base.clone());
96    base
97}
98
99/// True the first time this process reports `root` as unresolved, so the
100/// warning is one line per root rather than one per render-path call.
101fn first_unresolved(root: &Path) -> bool {
102    static UNRESOLVED: OnceLock<parking_lot::Mutex<std::collections::HashSet<PathBuf>>> =
103        OnceLock::new();
104    UNRESOLVED.get_or_init(Default::default).lock().insert(root.to_path_buf())
105}
106
107/// The one-line report for a root git would not name, so a log grep for
108/// `worktree_base_unresolved` finds the path that composed the wrong way.
109fn report_unresolved_root(root: &Path) {
110    tracing::warn!(
111        target: "forge_agent::env::worktree",
112        event_name = "worktree_base_unresolved",
113        repo = %root.display(),
114        "git would not name this checkout's root, so worker paths compose from the project path \
115         until it can; a linked-worktree project records the wrong directory meanwhile",
116    );
117}
118
119/// [`worktree_base`] without the memo; `None` when git could not answer.
120///
121/// The first entry of `git worktree list --porcelain` is the main working
122/// tree: the main checkout for a linked worktree, the repo root from a
123/// subdirectory. A repo kept in a separate git dir is not a supported
124/// layout - git names its admin dir there, and the CLI's base for it is
125/// unverified - so forge follows git rather than guessing.
126fn resolve_worktree_base(root: &Path) -> Option<PathBuf> {
127    let listing = git_in_repo(root, &["worktree", "list", "--porcelain"])?;
128    let first = listing.lines().find_map(|line| line.strip_prefix("worktree "))?;
129    std::fs::canonicalize(first).ok()
130}
131
132/// The branch checked out at `path`, via `git rev-parse --abbrev-ref
133/// HEAD`. `None` on detached HEAD, a git error, or non-utf8 output.
134/// Read before a worktree is removed so its per-branch review threads
135/// clean up under the same `(project, branch)` key the overlay saved
136/// them with.
137pub fn worktree_branch(path: &Path) -> Option<String> {
138    let branch = git_in_repo(path, &["rev-parse", "--abbrev-ref", "HEAD"])?.trim().to_owned();
139    if branch.is_empty() || branch == "HEAD" { None } else { Some(branch) }
140}
141
142/// Errors from [`remove_worktree`].
143#[derive(Debug, thiserror::Error)]
144pub enum WorktreeError {
145    /// The worktree path has no parent directory, so there is no
146    /// in-repo location to run `git worktree remove` from.
147    #[error("worktree path {0} has no parent directory to run git from")]
148    NoParent(String),
149    /// `git worktree remove` could not be spawned at all.
150    #[error("failed to spawn git worktree remove: {0}")]
151    GitSpawn(String),
152    /// `git worktree remove` ran but exited non-zero (e.g. a dirty
153    /// worktree without `--force`, or a transient git error). Carries
154    /// git's stderr.
155    #[error("git worktree remove failed: {0}")]
156    RemoveFailed(String),
157    /// `git worktree add` ran but exited non-zero. Carries git's stderr.
158    #[error("git worktree add failed: {0}")]
159    CreateFailed(String),
160    /// [`remove_orphaned_worktree_dir`] was handed a path that is not a
161    /// direct child of the repo's `.claude/worktrees` root.
162    #[error("refusing to remove {path}: not a worktree under {root}")]
163    OutsideWorktreesRoot { path: String, root: String },
164    /// [`remove_orphaned_worktree_dir`] was handed something that is not
165    /// a plain directory (a file, a symlink, a missing path), so there
166    /// is nothing safe to delete.
167    #[error("refusing to remove {0}: not a directory")]
168    NotAPlainDirectory(String),
169    /// git still lists the path as a worktree, live or by the stale path
170    /// a repo move leaves, so it is not a leftover: git may be holding
171    /// uncommitted work in it. Also returned when the registration cannot
172    /// be read - refusing is the safe direction.
173    #[error("refusing to remove {0}: git still lists it as a worktree")]
174    StillRegistered(String),
175    /// [`remove_orphaned_worktree_dir`] was handed a directory whose
176    /// `.git` entry says it belongs to something else - a directory (a
177    /// checkout of its own, never a worktree tail), a link naming a live
178    /// foreign repo, or a stale link with no removal this repo recorded.
179    /// Not this repo's leftover, so not this repo's to delete.
180    #[error("refusing to remove {path}: {detail}")]
181    ForeignLeftover { path: String, detail: String },
182    /// The registration git lists at the worktree path cannot be reused:
183    /// its gitdir link names a path the repo has left and `git worktree
184    /// repair` could not rewrite it, or it carries a live foreign link
185    /// that repair would adopt. Either way the worktree is not this
186    /// spawn's to use.
187    #[error("refusing to reuse {path}: {detail}")]
188    RegistrationUnusable { path: String, detail: String },
189    /// The leftover directory itself could not be deleted from disk.
190    #[error("failed to delete the leftover worktree directory {path}: {error}")]
191    LeftoverUndeletable { path: String, error: String },
192}
193
194/// Why a worktree is not safe to remove, or `None` when it is clean.
195///
196/// "Dirty" means either uncommitted/untracked changes
197/// (`git status --porcelain` non-empty) OR commits reachable from HEAD
198/// and from no remote-tracking ref (`git rev-list --count HEAD --not
199/// --remotes` > 0) - reachability, not the branch's upstream, which a
200/// squash merge leaves permanently ahead. A branch whose configured
201/// upstream ref has been deleted was pushed and its remote branch is
202/// gone, which is how a squash land reads once the branch is pruned. A
203/// repo with no remote-tracking refs stays not-unpushed. The despawn
204/// path calls this BEFORE any teardown so a dirty worker is blocked
205/// without being killed.
206pub fn worktree_dirty_reason(path: &Path) -> Option<String> {
207    if let Some(reason) = worktree_uncommitted_reason(path) {
208        return Some(reason);
209    }
210
211    if let Some(ahead) = unpushed_commit_count(path)
212        && ahead > 0
213    {
214        let plural = if ahead == 1 { "" } else { "s" };
215        return Some(format!("{ahead} unpushed commit{plural}"));
216    }
217
218    None
219}
220
221/// Uncommitted or untracked changes, or `None` when the tree is
222/// porcelain-clean.
223///
224/// The subagent reap gates on this alone: committed work is safe there
225/// because the branch outlives the tree and [`reap_worktree_branch`]
226/// keeps one holding unique commits. Uncommitted work exists only in
227/// the tree, so a status that cannot be read blocks.
228fn worktree_uncommitted_reason(path: &Path) -> Option<String> {
229    match git_command::command("git").args(["status", "--porcelain"]).current_dir(path).output() {
230        Ok(out) if out.status.success() => {
231            if !out.stdout.is_empty() {
232                return Some("uncommitted or untracked changes".to_owned());
233            }
234            None
235        }
236        // Couldn't read the status - block rather than risk discarding
237        // work we failed to inspect.
238        _ => Some("could not determine the worktree's git status".to_owned()),
239    }
240}
241
242/// Commits HEAD reaches that no remote-tracking ref does, with a branch
243/// whose remote branch was deleted counted as pushed. `None` when the
244/// repo holds no remote-tracking refs to compare against, or when the
245/// query fails - both leave the verdict clean.
246///
247/// A failed query does not block, unlike a failed status: commits live
248/// on the branch, which the despawn's own reap keeps when it holds
249/// unique commits, while uncommitted work would go with the tree.
250fn unpushed_commit_count(path: &Path) -> Option<u64> {
251    let has_remote_refs = git_command::command("git")
252        .args(["for-each-ref", "--count=1", "--format=%(refname)", "refs/remotes/"])
253        .current_dir(path)
254        .output()
255        .is_ok_and(|out| out.status.success() && !out.stdout.is_empty());
256    if !has_remote_refs {
257        return None;
258    }
259    let out = git_command::command("git")
260        .args(["rev-list", "--count", "HEAD", "--not", "--remotes"])
261        .current_dir(path)
262        .output()
263        .ok()?;
264    if !out.status.success() {
265        return None;
266    }
267    let count = String::from_utf8_lossy(&out.stdout).trim().parse().ok()?;
268    if count == 0 || upstream_ref_deleted(path) { Some(0) } else { Some(count) }
269}
270
271/// Whether the branch's configured upstream names a remote ref that no
272/// longer exists.
273///
274/// `push -u` writes the upstream only after a successful push, so the
275/// branch was on that remote and its remote branch has since been
276/// deleted - the shape a squash-merged PR leaves once the branch is
277/// pruned. Neither reachability nor the upstream can see a squash by
278/// sha, and by then the remote branch is normally gone.
279///
280/// This trusts `-u`: an upstream configured by hand against a ref that
281/// was never fetched reads as deleted. Proceeding still loses nothing,
282/// because the despawn's branch reap keeps a local branch holding
283/// commits no other ref reaches, and reports it.
284fn upstream_ref_deleted(path: &Path) -> bool {
285    let Some(branch) = worktree_branch(path) else {
286        return false;
287    };
288    let Some(remote) = git_config_value(path, &format!("branch.{branch}.remote")) else {
289        return false;
290    };
291    // A `.` remote tracks a local branch, which is not a push.
292    if remote == "." {
293        return false;
294    }
295    let Some(tracked) = git_config_value(path, &format!("branch.{branch}.merge"))
296        .and_then(|merge| merge.strip_prefix("refs/heads/").map(str::to_owned))
297    else {
298        return false;
299    };
300    let refname = format!("refs/remotes/{remote}/{tracked}");
301    // `--quiet` exits 1 for a ref that is missing and 128 for a repo it
302    // cannot read, so only 1 counts as deleted.
303    git_command::command("git")
304        .args(["rev-parse", "--verify", "--quiet", &refname])
305        .current_dir(path)
306        .output()
307        .is_ok_and(|out| out.status.code() == Some(1))
308}
309
310/// One `git config --get` value, `None` when unset or unreadable.
311fn git_config_value(path: &Path, key: &str) -> Option<String> {
312    let out = git_command::command("git")
313        .args(["config", "--get", key])
314        .current_dir(path)
315        .output()
316        .ok()?;
317    if !out.status.success() {
318        return None;
319    }
320    let value = String::from_utf8_lossy(&out.stdout).trim().to_owned();
321    if value.is_empty() { None } else { Some(value) }
322}
323
324/// Remove the git worktree at `path` via `git worktree remove`. Run
325/// from the worktree's PARENT (which lives inside the main worktree
326/// for claude's `<repo>/.claude/worktrees/<label>` layout), since git
327/// refuses to remove the worktree it is invoked from. `force` adds
328/// `--force`, removing even a dirty worktree (the despawn path only
329/// reaches this on a verified-clean worktree, or when the caller
330/// explicitly passed `force`).
331///
332/// Without `--force`, git itself refuses a worktree with
333/// uncommitted/untracked changes, and also one holding initialized
334/// submodules however clean it is - git-worktree(1) puts both cases
335/// under `--force`. The unpushed-commits guard lives in
336/// [`worktree_dirty_reason`] (git's own check does not cover unpushed).
337pub fn remove_worktree(path: &Path, force: bool) -> Result<(), WorktreeError> {
338    let parent =
339        path.parent().ok_or_else(|| WorktreeError::NoParent(path.display().to_string()))?;
340    // The worktree carries the claude session's own lock, which git
341    // won't remove even with --force; unlock first, ignoring the result
342    // since a not-locked worktree errors harmlessly.
343    let _ = git_command::command("git")
344        .arg("worktree")
345        .arg("unlock")
346        .arg(path)
347        .current_dir(parent)
348        .output();
349    let mut cmd = git_command::command("git");
350    cmd.arg("worktree").arg("remove");
351    if force {
352        cmd.arg("--force");
353    }
354    cmd.arg(path).current_dir(parent);
355    let output = cmd.output().map_err(|e| WorktreeError::GitSpawn(e.to_string()))?;
356    if output.status.success() {
357        Ok(())
358    } else {
359        Err(WorktreeError::RemoveFailed(String::from_utf8_lossy(&output.stderr).trim().to_owned()))
360    }
361}
362
363/// Delete the directory a failed [`remove_worktree`] left behind, once
364/// git no longer lists it as a worktree.
365///
366/// `git worktree remove` deregisters the path before it walks the tree,
367/// so a delete that fails part-way - a build still writing
368/// into `target/` while the removal runs, which surfaces as "Directory
369/// not empty" - leaves the directory with no registration at all: the
370/// tree can no longer be read by git, and every later removal refuses it
371/// as "not a working tree". The branch, which is where the work actually
372/// lives, is untouched by any of this.
373///
374/// Only the directory is left to delete, and only when the path is
375/// exactly `<repo>/.claude/worktrees/<name>`, git no longer lists it, and
376/// the `.git` entry (or, where that entry cannot prove ownership, this
377/// repo's own removal record) agrees it is ours; anything else refuses
378/// rather than guesses. A still-registered worktree is not a leftover -
379/// git may be holding uncommitted work in it, and `git worktree remove`
380/// is the only path allowed to delete one.
381///
382/// **What the delete trusts, stated plainly:** it is unconditional on the
383/// directory's contents, so the evidence must prove both halves - that the
384/// directory is this repo's, and that a removal this repo admitted left it
385/// behind. A `.git` link naming this repo's common git dir proves the
386/// first while that git dir still exists, and that shape clears as it
387/// always has (a leftover from an older forge carries no record). Once the
388/// git dir is gone (the repo was moved) or there is no `.git` at all, the
389/// link is a path shape that a moved or deleted foreign worktree can match
390/// just as well, so the second half is the record
391/// [`remove_owned_worktree`] writes under the git common dir before a
392/// removal starts: it travels with the repo, `worktree prune` does not
393/// touch it, and a foreign worktree's label is not in it. Where the link
394/// cannot prove ownership, a leftover with no such record is refused - a
395/// pruned live worktree after a move is one, and is kept - while one whose
396/// link still names this repo's common dir clears, as it always has. The
397/// blast radius when the record is wrong is uncommitted/untracked files in
398/// a directory that is no longer a worktree; committed work lives on the
399/// branch, which this never touches.
400pub fn remove_orphaned_worktree_dir(repo: &Path, path: &Path) -> Result<(), WorktreeError> {
401    let root = repo.join(".claude/worktrees");
402    // The gate is lexical, not a real-path guarantee: `parent` and
403    // `file_name` are compared as path components and nothing here
404    // resolves symlinks, so a symlinked `.claude/worktrees` root would
405    // aim the delete at that link's target - forge's own single-user
406    // namespace. `file_name` is None for a path ending in `..`, which
407    // would otherwise pass the parent test and aim the delete at
408    // `.claude`.
409    if path.parent() != Some(root.as_path()) || path.file_name().is_none() {
410        return Err(WorktreeError::OutsideWorktreesRoot {
411            path: path.display().to_string(),
412            root: root.display().to_string(),
413        });
414    }
415    // `symlink_metadata`, so a symlink at the path is refused rather
416    // than followed.
417    match std::fs::symlink_metadata(path) {
418        Ok(meta) if meta.is_dir() => {}
419        _ => return Err(WorktreeError::NotAPlainDirectory(path.display().to_string())),
420    }
421    if worktree_registered(repo, path) {
422        return Err(WorktreeError::StillRegistered(path.display().to_string()));
423    }
424    if let Some(detail) = foreign_leftover_detail(repo, path) {
425        return Err(WorktreeError::ForeignLeftover { path: path.display().to_string(), detail });
426    }
427    std::fs::remove_dir_all(path).map_err(|error| WorktreeError::LeftoverUndeletable {
428        path: path.display().to_string(),
429        error: error.to_string(),
430    })?;
431    if let Some(label) = path.file_name().and_then(std::ffi::OsStr::to_str) {
432        record_worktree_state(repo, label, path, WorktreeRecord::Cleared);
433    }
434    Ok(())
435}
436
437/// Why the directory at `path` is not this repo's leftover, when its
438/// `.git` entry says so - `None` when it is ours to delete.
439///
440/// A link naming a git dir that still exists is the evidence the guard has
441/// always accepted: when that git dir is this repo's git COMMON dir - the
442/// one `git rev-parse --git-common-dir` answers, so a project checked out
443/// as a linked worktree or a submodule matches its own too - the directory
444/// is this repo's, record or no record. A leftover from an older forge
445/// therefore still clears.
446///
447/// A link whose git dir is GONE cannot prove ownership: the repo may have
448/// been moved, but a moved or deleted FOREIGN worktree parked under our
449/// root produces the same shape, and so does a live worktree of ours whose
450/// registration was pruned. The same is true of no `.git` at all (a write
451/// recreated the path after the walk). There the evidence is a removal
452/// this repo ADMITTED, recorded by [`remove_owned_worktree`] in the git
453/// common dir before git ran: it travels with the repo (`worktree prune`
454/// does not touch it) and exists only for a removal this repo started and
455/// never finished.
456///
457/// A `.git` DIRECTORY is a checkout of its own - a worktree's link is
458/// always a file - and a `.git` that cannot be read as a link is refused
459/// as well, so refusing is the safe direction wherever the evidence is not
460/// ours. The link is READ, never resolved: a deregistration has already
461/// removed the worktree admin dir it names.
462fn foreign_leftover_detail(repo: &Path, path: &Path) -> Option<String> {
463    match std::fs::symlink_metadata(path.join(".git")) {
464        Ok(meta) if meta.is_dir() => {
465            return Some(
466                "its .git is a directory, so it is a checkout of its own, not a worktree this \
467                 repo forked"
468                    .to_owned(),
469            );
470        }
471        // No `.git` at all: the recreated-path shape, ours when a removal
472        // of this worktree was admitted and never completed. An empty
473        // directory holds nothing to lose and the fresh-spawn obstacle
474        // treats it as free, so the clear must too.
475        Err(_) => {
476            let empty = std::fs::read_dir(path).is_ok_and(|mut entries| entries.next().is_none());
477            return (!empty && !worktree_removal_admitted(repo, path)).then(|| {
478                "it holds no .git and this repo recorded no removal of it; remove the directory \
479                 by hand if it is yours, then spawn again"
480                    .to_owned()
481            });
482        }
483        Ok(_) => {}
484    }
485    let Ok(link) = std::fs::read_to_string(path.join(".git")) else {
486        return Some("its .git file could not be read".to_owned());
487    };
488    let Some(named) = link.trim().strip_prefix("gitdir:").map(str::trim) else {
489        return Some("its .git file does not name a gitdir".to_owned());
490    };
491    let named = Path::new(named);
492    let named = if named.is_absolute() { named.to_path_buf() } else { path.join(named) };
493    if named.parent().and_then(Path::parent).is_some_and(Path::exists) {
494        // A live git dir: ours or not, and nothing else to weigh.
495        return live_foreign_git_dir(repo, path);
496    }
497    (!worktree_removal_admitted(repo, path)).then(|| {
498        format!(
499            "its .git link points at '{}', outside this repo's worktree admin dir, and this repo \
500             recorded no removal of this worktree; remove the directory by hand if it is yours, \
501             then spawn again",
502            named.display()
503        )
504    })
505}
506
507/// Why the `.git` link at `path` names a LIVE git dir that is not this
508/// repo's, when it does; `None` when it names this repo's common dir or
509/// the git dir is gone. A stale or absent target is not proof of foreign
510/// ownership on its own, so callers weigh it separately.
511fn live_foreign_git_dir(repo: &Path, path: &Path) -> Option<String> {
512    let link = std::fs::read_to_string(path.join(".git")).ok()?;
513    let named = link.trim().strip_prefix("gitdir:").map(str::trim)?;
514    let named = Path::new(named);
515    let named = if named.is_absolute() { named.to_path_buf() } else { path.join(named) };
516    let git_dir = named.parent().and_then(Path::parent)?;
517    if !git_dir.exists() {
518        return None;
519    }
520    // Canonicalised on both sides, because the spellings can disagree on a
521    // symlinked prefix.
522    let ours = matches!(
523        (git_common_dir(repo), std::fs::canonicalize(git_dir).ok()),
524        (Some(ours), Some(named)) if ours == named
525    );
526    (!ours).then(|| {
527        format!(
528            "its .git link points at '{}', outside this repo's worktree admin dir",
529            named.display()
530        )
531    })
532}
533
534/// Where the worktree at `path` stands with git's registrations.
535#[derive(Debug, Clone, Copy, PartialEq, Eq)]
536enum WorktreeRegistration {
537    /// git lists it, and the listed path resolves to this directory.
538    Live,
539    /// git lists it by a path that no longer resolves, and the listing's
540    /// tail still names this worktree - the shape a repo move leaves.
541    Stale,
542    /// git lists no registration for this path.
543    Absent,
544}
545
546/// The registration [`worktree_registration`] finds, or `None` when git
547/// could not be asked. The clear treats that as listed (the refusing
548/// direction); ensure reuses the path as it stands.
549fn worktree_registration(repo: &Path, path: &Path) -> Option<WorktreeRegistration> {
550    // Canonicalised on both sides: the listed path and the caller's can
551    // disagree on a symlinked prefix (`/tmp` vs `/private/tmp` on macOS)
552    // while naming the same directory.
553    let ours = std::fs::canonicalize(path).ok()?;
554    let out = scrubbed_git(repo, &["worktree", "list", "--porcelain"]).output().ok()?;
555    if !out.status.success() {
556        return None;
557    }
558    let mut found = WorktreeRegistration::Absent;
559    for listed in String::from_utf8_lossy(&out.stdout)
560        .lines()
561        .filter_map(|line| line.strip_prefix("worktree "))
562    {
563        let resolved = std::fs::canonicalize(listed);
564        if resolved.as_ref().is_ok_and(|resolved| *resolved == ours) {
565            return Some(WorktreeRegistration::Live);
566        }
567        // A listed path that resolves and names somewhere else is a
568        // different worktree: `cp -a` keeps a link that still resolves at
569        // the original path, so the stale fallback must not run on a path
570        // that resolves at all.
571        if resolved.is_err() && moved_registration_names(listed, &ours) {
572            found = WorktreeRegistration::Stale;
573        }
574    }
575    Some(found)
576}
577
578/// Whether git still lists `path` as one of `repo`'s worktrees, live or
579/// stale. A repo git cannot read answers `true`, the refusing direction.
580fn worktree_registered(repo: &Path, path: &Path) -> bool {
581    !matches!(worktree_registration(repo, path), Some(WorktreeRegistration::Absent))
582}
583
584/// Whether a listed worktree path that no longer resolves still names the
585/// worktree at `ours`. A repo moved or renamed leaves its registrations
586/// carrying the old absolute path, while the tail below the repo root -
587/// `.claude/worktrees/<label>` - is intact. The listing is this repo's
588/// own, so a matching tail is this repo's registration, not a foreign
589/// one.
590fn moved_registration_names(listed: &str, ours: &Path) -> bool {
591    let Some(label) = ours.file_name() else {
592        return false;
593    };
594    Path::new(listed).ends_with(Path::new(".claude").join("worktrees").join(label))
595}
596
597/// `git worktree repair <path>`: git's own remedy for a registration and
598/// link that name the path the repo has left. Only a stale registration
599/// needs it; reusing one unrepaired leaves every git command in the
600/// worktree failing.
601fn repair_worktree(repo: &Path, path: &Path) -> Result<(), WorktreeError> {
602    let output =
603        scrubbed_git(repo, &["worktree", "repair"]).arg(path).output().map_err(|error| {
604            WorktreeError::RegistrationUnusable {
605                path: path.display().to_string(),
606                detail: format!("git worktree repair could not be spawned: {error}"),
607            }
608        })?;
609    if output.status.success() {
610        Ok(())
611    } else {
612        Err(WorktreeError::RegistrationUnusable {
613            path: path.display().to_string(),
614            detail: format!(
615                "git worktree repair failed: {}",
616                String::from_utf8_lossy(&output.stderr).trim()
617            ),
618        })
619    }
620}
621
622/// What forge recorded about a worktree it owns, in the repo's git common
623/// dir so the record moves with the repo.
624#[derive(Debug, Clone, Copy, PartialEq, Eq)]
625enum WorktreeRecord {
626    /// forge created the worktree and has not started removing it.
627    Owned,
628    /// A removal this repo admitted started and never reported completion.
629    Removing,
630    /// The removal completed; there is nothing left to clear.
631    Cleared,
632}
633
634impl WorktreeRecord {
635    fn as_str(self) -> &'static str {
636        match self {
637            Self::Owned => "owned",
638            Self::Removing => "removing",
639            Self::Cleared => "cleared",
640        }
641    }
642}
643
644const WORKTREE_RECORDS: &str = "forge-worktrees";
645
646/// The repo's git common dir, resolved and canonicalised. A project
647/// checked out as a linked worktree or a submodule answers with the dir
648/// its gitdir lives under, not `<repo>/.git`.
649fn git_common_dir(repo: &Path) -> Option<std::path::PathBuf> {
650    let raw = git_in_repo(repo, &["rev-parse", "--git-common-dir"])?;
651    let raw = raw.trim();
652    if raw.is_empty() {
653        return None;
654    }
655    let dir = Path::new(raw);
656    let dir = if dir.is_absolute() { dir.to_path_buf() } else { repo.join(dir) };
657    std::fs::canonicalize(dir).ok()
658}
659
660fn worktree_record_path(repo: &Path) -> Option<std::path::PathBuf> {
661    git_common_dir(repo).map(|dir| dir.join(WORKTREE_RECORDS))
662}
663
664/// Append one state for `label` in this checkout. Append-only with last
665/// line winning: a rewrite would race another spawn's record in the same
666/// process, and the file is a handful of bytes per worker lifecycle. The
667/// checkout root is stored with the path because the common dir can be
668/// shared by several checkouts.
669fn record_worktree_state(repo: &Path, label: &str, path: &Path, state: WorktreeRecord) {
670    let Some(file) = worktree_record_path(repo) else {
671        tracing::warn!(
672            target: "forge_agent::env::worktree",
673            event_name = "worktree_record_unavailable",
674            repo = %repo.display(),
675            label = %label,
676            "the repo's git common dir could not be read, so this worktree's ownership is not \
677             recorded and a leftover it leaves may be refused rather than cleared",
678        );
679        return;
680    };
681    let line = format!("{}\t{label}\t{}\t{}\n", state.as_str(), repo.display(), path.display());
682    let written = std::fs::OpenOptions::new()
683        .create(true)
684        .append(true)
685        .open(&file)
686        .and_then(|mut file| file.write_all(line.as_bytes()));
687    if let Err(err) = written {
688        tracing::warn!(
689            target: "forge_agent::env::worktree",
690            event_name = "worktree_record_unwritable",
691            repo = %repo.display(),
692            label = %label,
693            record = %file.display(),
694            %err,
695            "this worktree's ownership could not be recorded, so a leftover it leaves may be \
696             refused rather than cleared",
697        );
698    }
699}
700
701/// One record, as [`recorded_worktree`] reads it.
702struct RecordedWorktree {
703    state: WorktreeRecord,
704    /// The checkout root the record was written from.
705    root: std::path::PathBuf,
706    /// The worktree path as it stood when the state was written.
707    path: std::path::PathBuf,
708}
709
710/// The record that speaks for the worktree at `path`, when one can be
711/// read.
712///
713/// The file lives in the git common dir, which a project that is itself a
714/// linked worktree SHARES with the main checkout, so neither a label nor a
715/// path alone identifies a worktree across projects. A record whose path
716/// still exists speaks for this worktree only when it resolves to it; a
717/// record whose path is gone is the one a repo move left behind, and it
718/// speaks only for a checkout root that is also gone - a live root that
719/// differs is another checkout's. A matching live path is preferred over
720/// a stale one, so another checkout's `cleared` cannot retract this
721/// checkout's still-standing admission.
722fn recorded_worktree(repo: &Path, path: &Path) -> Option<RecordedWorktree> {
723    let label = path.file_name().and_then(std::ffi::OsStr::to_str)?;
724    let text = std::fs::read_to_string(worktree_record_path(repo)?).ok()?;
725    let mut live = None;
726    let mut stale = None;
727    for line in text.lines() {
728        let mut fields = line.splitn(4, '\t');
729        let Some(recorded_state) = fields.next().and_then(|field| match field {
730            "owned" => Some(WorktreeRecord::Owned),
731            "removing" => Some(WorktreeRecord::Removing),
732            "cleared" => Some(WorktreeRecord::Cleared),
733            _ => None,
734        }) else {
735            continue;
736        };
737        if fields.next() != Some(label) {
738            continue;
739        }
740        let Some(recorded_root) = fields.next() else {
741            continue;
742        };
743        let Some(recorded_path) = fields.next() else {
744            continue;
745        };
746        let recorded = RecordedWorktree {
747            state: recorded_state,
748            root: std::path::PathBuf::from(recorded_root),
749            path: std::path::PathBuf::from(recorded_path),
750        };
751        if recorded.path.exists() {
752            if recorded_names(&recorded.path, path) {
753                live = Some(recorded);
754            }
755        } else if recorded_root_is_ours(&recorded.root, repo) {
756            stale = Some(recorded);
757        }
758    }
759    live.or(stale)
760}
761
762/// Whether a record written by the checkout at `recorded` can speak for
763/// `repo`: a root that no longer exists is the one a repo move left,
764/// while a live root must be this checkout.
765fn recorded_root_is_ours(recorded: &Path, repo: &Path) -> bool {
766    if !recorded.exists() {
767        return true;
768    }
769    matches!(
770        (std::fs::canonicalize(recorded), std::fs::canonicalize(repo)),
771        (Ok(recorded), Ok(repo)) if recorded == repo
772    )
773}
774
775/// Whether a recorded path that still exists names the worktree at
776/// `current`.
777fn recorded_names(recorded: &Path, current: &Path) -> bool {
778    matches!(
779        (std::fs::canonicalize(recorded), std::fs::canonicalize(current)),
780        (Ok(recorded), Ok(current)) if recorded == current
781    )
782}
783
784/// Whether an admitted removal is on record for this worktree and never
785/// reported complete. Where the link cannot prove ownership, this record
786/// is what the orphan clear deletes on; a live link naming this repo's
787/// common dir clears without it.
788fn worktree_removal_admitted(repo: &Path, path: &Path) -> bool {
789    recorded_worktree(repo, path).is_some_and(|record| record.state == WorktreeRecord::Removing)
790}
791
792/// Record that forge created the worktree at `path`.
793fn record_worktree_owned(repo: &Path, label: &str, path: &Path) {
794    record_worktree_state(repo, label, path, WorktreeRecord::Owned);
795}
796
797/// Record that the worktree at `path` is gone. Retracts any earlier
798/// admission for the label, so a directory that later takes its place is
799/// not cleared on it.
800pub fn record_worktree_gone(repo: &Path, label: &str, path: &Path) {
801    record_worktree_state(repo, label, path, WorktreeRecord::Cleared);
802}
803
804/// Remove a worktree whose removal this repo admitted - a despawn, or a
805/// refused spawn's rollback - recording the admission before git runs.
806/// A removal that dies part-way leaves the record behind, which is what
807/// lets [`remove_orphaned_worktree_dir`] prove the leftover is ours after
808/// a repo move; a completed removal clears it.
809///
810/// A path that is already gone admits nothing: a `removing` record for it
811/// would outlive the directory it described and could later authorize
812/// deleting a new worktree that reused the label, so `cleared` is written
813/// instead.
814pub fn remove_owned_worktree(
815    repo: &Path,
816    label: &str,
817    path: &Path,
818    force: bool,
819) -> Result<(), WorktreeError> {
820    let admission = if path.is_dir() { WorktreeRecord::Removing } else { WorktreeRecord::Cleared };
821    record_worktree_state(repo, label, path, admission);
822    let outcome = remove_worktree(path, force);
823    if outcome.is_ok() {
824        record_worktree_state(repo, label, path, WorktreeRecord::Cleared);
825    }
826    outcome
827}
828
829/// What [`ensure_worker_worktree`] had to do, so a caller rolling back
830/// a refused spawn knows what it may delete: the worktree only when it
831/// was absent, the branch only when this call minted it.
832#[derive(Debug, Clone, Copy, PartialEq, Eq)]
833pub enum WorktreeEnsure {
834    /// `path` already stood and was reused; a stale registration was
835    /// repaired first when the repo had been moved.
836    AlreadyPresent,
837    /// `path` was created and an existing `worktree-<label>` branch was
838    /// attached to it. The branch predates the call and may hold the
839    /// worker's only copy of its commits.
840    AttachedExistingBranch,
841    /// `path` was created and `worktree-<label>` was minted at claude's
842    /// fresh base: `origin/<default-branch>`, or the local HEAD when the
843    /// repo names no usable origin base.
844    CreatedWithNewBranch,
845}
846
847/// Recreate the worker worktree at `path` when a despawn removed it, so
848/// a resumed worker can run with its worktree as cwd again - the
849/// transcript lives under the worktree's storage key, so `claude
850/// --resume` cannot find it from any other directory. Anchored on the
851/// branch: attach the surviving `worktree-<label>` when despawn kept
852/// it, otherwise recreate the branch from `origin/<default-branch>` - the
853/// base claude's own `--worktree <label>` uses under its default
854/// `worktree.baseRef` of `fresh`.
855///
856/// **`worktree.baseRef = "head"` is not honoured.** forge always cuts from
857/// the origin base and falls back to the local HEAD only when the repo names
858/// no `origin/HEAD`, or names one that no longer resolves.
859///
860/// A `path` that exists is judged by its REGISTRATION, not its presence:
861/// a worktree git still lists is reused, while a leftover directory git
862/// no longer tracks - a despawn's removal failed part-way, and git
863/// deregisters a path before it walks the tree - is cleared deliberately
864/// before the add below, because `worktree add` refuses a path that
865/// exists and a resume that died on git's refusal surfaced as the
866/// transport's message rather than git's (#1767). An out-of-band delete
867/// still defeats this (git's stale registration makes `worktree add`
868/// fail with git's message, surfaced here).
869///
870/// A registration git lists by the path the repo has left (a move) is
871/// repaired with `git worktree repair` before it is reused: without that,
872/// `AlreadyPresent` would hand the resume a directory where every git
873/// command exits 128. A repair git refuses is a refusal here.
874///
875/// **What the clearing trusts, stated plainly:** that this state is the
876/// tail of a removal the despawn admitted (its dirty verdict, or an
877/// explicit force) - nothing in this call re-reads the tree. The record
878/// [`remove_owned_worktree`] wrote before that removal is what proves it
879/// where the link cannot, so a pruned live worktree after a move is
880/// refused and kept. Committed work lives on the branch, which is never
881/// touched here; what could be lost if the record were wrong is
882/// uncommitted/untracked files in a directory that is no longer a
883/// worktree.
884pub fn ensure_worker_worktree(
885    repo: &Path,
886    label: &str,
887    path: &Path,
888) -> Result<WorktreeEnsure, WorktreeError> {
889    if path.exists() {
890        match worktree_registration(repo, path) {
891            Some(WorktreeRegistration::Absent) => remove_orphaned_worktree_dir(repo, path)?,
892            Some(WorktreeRegistration::Stale) => {
893                // `git worktree repair` rewrites the directory's `.git`
894                // link, so a live foreign link here is refused rather
895                // than adopted.
896                if let Some(detail) = live_foreign_git_dir(repo, path) {
897                    return Err(WorktreeError::RegistrationUnusable {
898                        path: path.display().to_string(),
899                        detail: format!("{detail}; git worktree repair would adopt it"),
900                    });
901                }
902                repair_worktree(repo, path)?;
903                record_worktree_owned(repo, label, path);
904                return Ok(WorktreeEnsure::AlreadyPresent);
905            }
906            // A live registration retracts any stale admission: a removal
907            // that reported failure over a worktree still alive and
908            // registered must not authorize deleting it later.
909            Some(WorktreeRegistration::Live) => {
910                record_worktree_owned(repo, label, path);
911                return Ok(WorktreeEnsure::AlreadyPresent);
912            }
913            // git could not be asked: reuse as it stands.
914            None => return Ok(WorktreeEnsure::AlreadyPresent),
915        }
916    }
917    let branch = format!("worktree-{label}");
918    let mut cmd = git_command::command("git");
919    cmd.arg("worktree").arg("add").arg("-q");
920    let outcome = if branch_ref_exists(repo, &branch) {
921        cmd.arg(path).arg(&branch);
922        WorktreeEnsure::AttachedExistingBranch
923    } else {
924        // **The base claude's own `--worktree` uses.** Its
925        // `worktree.baseRef` defaults to `fresh`, which cuts from
926        // `origin/<default-branch>`; a resume that recreates the branch at
927        // the local HEAD starts the worker from a commit its launcher would
928        // not have chosen whenever HEAD is ahead (#1594). A repo that names
929        // no remote default has no origin base and keeps the local HEAD.
930        cmd.arg("-b").arg(&branch).arg(path);
931        if let Some(base) = origin_base(repo) {
932            cmd.arg(base);
933        }
934        WorktreeEnsure::CreatedWithNewBranch
935    };
936    cmd.current_dir(repo);
937    let output = cmd.output().map_err(|e| WorktreeError::GitSpawn(e.to_string()))?;
938    if output.status.success() {
939        record_worktree_owned(repo, label, path);
940        Ok(outcome)
941    } else {
942        Err(WorktreeError::CreateFailed(git_error(&output)))
943    }
944}
945
946/// Undo the work [`ensure_worker_worktree`] did for a spawn that was
947/// then refused, so the refusal strands neither worktree nor branch.
948/// The worktree goes only when ensure created it, the branch only when
949/// ensure minted it - an `AttachedExistingBranch` keeps the branch,
950/// which predates the call and may hold the worker's only copy of its
951/// commits. Returns git's complaint when something had to stay behind;
952/// the spawn is already refused, so a failure is a warning, not an
953/// error.
954pub fn discard_worker_worktree(
955    repo: &Path,
956    label: &str,
957    path: &Path,
958    ensured: WorktreeEnsure,
959) -> Option<String> {
960    if matches!(ensured, WorktreeEnsure::AlreadyPresent) {
961        return None;
962    }
963    if let Err(err) = remove_owned_worktree(repo, label, path, true) {
964        return Some(err.to_string());
965    }
966    if !matches!(ensured, WorktreeEnsure::CreatedWithNewBranch) {
967        return None;
968    }
969    let branch = format!("worktree-{label}");
970    match reap_worktree_branch(repo, &branch) {
971        BranchReapOutcome::Reaped | BranchReapOutcome::NotPresent => None,
972        other => Some(format!("branch '{branch}' left behind: {other:?}")),
973    }
974}
975
976/// What git refuses to create a worker's worktree for at `path`, when there
977/// is something: the `worktree-<label>` branch already checked out in
978/// another worktree, a path taken by something that is not this worktree,
979/// or a registration git will not reuse.
980///
981/// The worktree is claude's to create - it is handed `--worktree <label>` -
982/// and a creation that does nothing still leaves the session started, so a
983/// worker can read live with nowhere to work. Asking git the question that
984/// creation asks lets the caller refuse that spawn instead, and leaves who
985/// creates the worktree unchanged.
986///
987/// `None` when git cannot be asked: this exists to catch a silent failure,
988/// not to block a spawn on a probe that never answered.
989///
990/// The label is taken raw, the way forge's worktree paths take it
991/// everywhere else. claude sanitises the name before deriving the path and
992/// the branch, so a label carrying `/` is a divergence that predates this
993/// check rather than one it widens.
994pub fn worktree_creation_obstacle(repo: &Path, label: &str, path: &Path) -> Option<String> {
995    let branch = format!("worktree-{label}");
996    let mut command = git_command::command("git");
997    command.arg("-C").arg(repo).args(["worktree", "list", "--porcelain"]);
998    // Both ways of failing to ask git leave nothing else to tell the two
999    // apart from a repo with no obstacle, and that is the one branch where
1000    // this check silently does nothing.
1001    let output = match command.output() {
1002        Ok(output) => output,
1003        Err(err) => {
1004            tracing::warn!(
1005                target: "forge_agent::env::worktree",
1006                event_name = "worktree_obstacle_unavailable",
1007                repo = %repo.display(),
1008                label = %label,
1009                %err,
1010                "git could not be asked whether this worker's worktree can be created, so this \
1011                 spawn is not refused here and may start without a worktree",
1012            );
1013            return None;
1014        }
1015    };
1016    if !output.status.success() {
1017        tracing::warn!(
1018            target: "forge_agent::env::worktree",
1019            event_name = "worktree_obstacle_unreadable",
1020            repo = %repo.display(),
1021            label = %label,
1022            status = %output.status,
1023            stderr = %String::from_utf8_lossy(&output.stderr).trim(),
1024            "git would not list this repo's worktrees, so this spawn is not refused here and \
1025             may start without a worktree",
1026        );
1027        return None;
1028    }
1029
1030    let listing = String::from_utf8_lossy(&output.stdout);
1031    obstacle_from_listing(&listed_worktrees(&listing), repo, &branch, path)
1032}
1033
1034/// One `git worktree list --porcelain` entry.
1035struct ListedWorktree {
1036    /// The path git printed, already canonical while it exists.
1037    at: String,
1038    /// The ref this worktree holds, when it holds a branch.
1039    branch: Option<String>,
1040}
1041
1042/// The entries of a porcelain listing: one block each, a `worktree <path>`
1043/// header followed by the `branch` line when the entry holds one.
1044fn listed_worktrees(listing: &str) -> Vec<ListedWorktree> {
1045    let mut entries: Vec<ListedWorktree> = Vec::new();
1046    for line in listing.lines() {
1047        if let Some(at) = line.strip_prefix("worktree ") {
1048            entries.push(ListedWorktree { at: at.to_owned(), branch: None });
1049        } else if let Some(found) = line.strip_prefix("branch ")
1050            && let Some(entry) = entries.last_mut()
1051        {
1052            entry.branch = Some(found.to_owned());
1053        }
1054    }
1055    entries
1056}
1057
1058/// The obstacle the listing describes: the branch held at another path, or
1059/// what git has registered at `path` and will not reuse.
1060///
1061/// The second decision reads the worktree's own gitdir LINK, not git's
1062/// `prunable` line: a LOCKED entry carries no such line on any git version
1063/// (and claude locks every worktree it creates), while a git older than
1064/// 2.36 carries none for anything - so the line cannot tell a registration
1065/// whose gitdir is gone from one whose gitdir is there, which is the half
1066/// the link answers. [`worktree_gitdir_resolves`] then asks whether that
1067/// gitdir is one git can actually reuse, so a present-but-unusable
1068/// registration is refused here rather than left for the add.
1069fn obstacle_from_listing(
1070    entries: &[ListedWorktree],
1071    repo: &Path,
1072    branch: &str,
1073    path: &Path,
1074) -> Option<String> {
1075    let wanted = format!("refs/heads/{branch}");
1076    if let Some(at) = entries.iter().find(|e| e.branch.as_deref() == Some(&wanted)).map(|e| &e.at)
1077        && !same_path(at, path)
1078    {
1079        return Some(format!(
1080            "the branch '{branch}' is already checked out in the worktree at {at}, so this \
1081             worker's worktree cannot be created; remove that worktree, or spawn under a \
1082             different label"
1083        ));
1084    }
1085    match entries.iter().find(|e| same_path(&e.at, path)) {
1086        // There, and usable: the worktree is reused as it stands. A locked
1087        // one included - claude locks every worktree it creates.
1088        Some(_) if worktree_gitdir_resolves(path) => None,
1089        Some(_) => Some(format!(
1090            "the worktree registration at {} cannot be reused - its .git link is not the plain \
1091             file git writes, the gitdir it names is gone or belongs to another repository, or \
1092             its HEAD does not resolve - so this worker's worktree cannot be created there; \
1093             unlock it with `git worktree unlock {}` (which errors harmlessly when it is not \
1094             locked), delete that directory if it is still there, then `git worktree prune` in \
1095             {} and spawn again",
1096            path.display(),
1097            path.display(),
1098            repo.display(),
1099        )),
1100        None if path_is_taken(path) => Some(format!(
1101            "{} already exists and is not an empty directory, so this worker's worktree cannot \
1102             be created there; remove it, or spawn under a different label",
1103            path.display()
1104        )),
1105        None => None,
1106    }
1107}
1108
1109/// Whether the worktree registered at `path` still points at a gitdir git
1110/// can reuse.
1111///
1112/// git reads several facts when it decides to reuse or refuse a worktree, so
1113/// the link alone is not the question: the `.git` entry must be the plain
1114/// link file git writes (not a symlink standing in for one), the gitdir it
1115/// names must be there, that gitdir's own `gitdir` back-link must name THIS
1116/// worktree's `.git` entry, and its HEAD must resolve. Each of those states
1117/// is one git's own reuse probe returns null for, and the add then refuses -
1118/// the silent failure this check exists to catch (#1593).
1119fn worktree_gitdir_resolves(path: &Path) -> bool {
1120    let Ok(link_meta) = std::fs::symlink_metadata(path.join(".git")) else {
1121        return false;
1122    };
1123    if !link_meta.is_file() {
1124        return false;
1125    }
1126    let Ok(link) = std::fs::read_to_string(path.join(".git")) else {
1127        return false;
1128    };
1129    let Some(gitdir) = link.trim().strip_prefix("gitdir:") else {
1130        return false;
1131    };
1132    let gitdir = Path::new(gitdir.trim());
1133    let gitdir = if gitdir.is_absolute() { gitdir.to_path_buf() } else { path.join(gitdir) };
1134    if !gitdir.is_dir() {
1135        return false;
1136    }
1137    let Ok(back) = std::fs::read_to_string(gitdir.join("gitdir")) else {
1138        return false;
1139    };
1140    let back = Path::new(back.trim());
1141    let back = if back.is_absolute() { back.to_path_buf() } else { gitdir.join(back) };
1142    // git writes the back link as the worktree's `.git` entry itself, so
1143    // that file is what must canonicalize to this worktree's.
1144    let (Ok(back), Ok(here)) =
1145        (std::fs::canonicalize(back), std::fs::canonicalize(path.join(".git")))
1146    else {
1147        return false;
1148    };
1149    if back != here {
1150        return false;
1151    }
1152    git_command::command("git")
1153        .arg("--git-dir")
1154        .arg(&gitdir)
1155        .args(["rev-parse", "--verify", "--quiet", "HEAD"])
1156        .output()
1157        .is_ok_and(|out| out.status.success())
1158}
1159
1160/// Why the worktree a git worker's spawn put it in is not usable, or `None`
1161/// when it is there and git can read it.
1162///
1163/// The spawn-time check runs before creation; this is the read after it. A
1164/// tree that went away before the session connected otherwise leaves the
1165/// worker reading live with nowhere to work (#1593).
1166pub fn worktree_missing_reason(path: &Path) -> Option<String> {
1167    if !path.is_dir() {
1168        return Some(format!("its worktree {} is gone", path.display()));
1169    }
1170    if !worktree_gitdir_resolves(path) {
1171        return Some(format!(
1172            "its worktree {} is not a git worktree git can reuse",
1173            path.display()
1174        ));
1175    }
1176    None
1177}
1178
1179/// Whether something at `path` is in the way of `git worktree add`, which
1180/// takes a path that is absent or an empty directory and refuses anything
1181/// else: a file, and any directory holding an entry, both exit 128
1182/// ("already exists"). A dotfile counts as an entry - measured.
1183fn path_is_taken(path: &Path) -> bool {
1184    match std::fs::read_dir(path) {
1185        Ok(mut entries) => entries.next().is_some(),
1186        // Not a directory at all, or one that cannot be read: git takes
1187        // neither. `exists` separates those from a free path EXCEPT for a
1188        // broken symlink, which it follows to nothing - the link itself is
1189        // what git refuses, so `symlink_metadata` is asked too.
1190        Err(_) => path.exists() || path.symlink_metadata().is_ok(),
1191    }
1192}
1193
1194/// Whether a path git printed and a path forge composed name the same
1195/// directory. Each side canonicalizes through its parent with the name
1196/// re-attached, so a symlinked spelling of a repo root (a macOS tempdir
1197/// under `/private`, a linked checkout) cannot read as a different
1198/// worktree. That matters most for a path that is gone, which is exactly
1199/// where a stranded registration lives and nothing exists to canonicalize.
1200fn same_path(listed: &str, composed: &Path) -> bool {
1201    let listed = Path::new(listed);
1202    match (spelled_from_parent(listed), spelled_from_parent(composed)) {
1203        (Some(listed), Some(composed)) => listed == composed,
1204        _ => listed == composed,
1205    }
1206}
1207
1208/// `path` with its parent canonicalized and its own name re-attached: the
1209/// spelling a path keeps once it exists, for one that does not.
1210fn spelled_from_parent(path: &Path) -> Option<std::path::PathBuf> {
1211    let parent = std::fs::canonicalize(path.parent()?).ok()?;
1212    Some(parent.join(path.file_name()?))
1213}
1214
1215/// Outcome of [`reap_worktree_branch`]. `Kept`, `KeptOnError` and
1216/// `DeleteFailed` each leave a branch behind and the caller warns
1217/// differently for each; `NotPresent` warns about nothing.
1218#[derive(Debug, PartialEq, Eq)]
1219pub enum BranchReapOutcome {
1220    /// Deleted. No commit became unreachable.
1221    Reaped,
1222    /// No branch by that name, so nothing to do.
1223    NotPresent,
1224    /// `count` commits are reachable from this branch and nothing else, so
1225    /// deleting it would strand them. `tip` is the short sha.
1226    Kept { count: u64, tip: String },
1227    /// The check could not be completed, so whether the branch holds
1228    /// unique commits is unknown.
1229    KeptOnError { reason: String },
1230    /// The check passed and only the delete failed.
1231    DeleteFailed { reason: String },
1232}
1233
1234/// Delete `branch` from the repo at `repo` if no commit would become
1235/// unreachable, i.e. every commit reachable from it is also reachable
1236/// from another ref or from a worktree's HEAD.
1237///
1238/// Asks about reachability, never merged-ness: nothing here consults
1239/// whether the branch landed, so a squash merge cannot mislead it. Any
1240/// failure keeps the branch.
1241pub fn reap_worktree_branch(repo: &Path, branch: &str) -> BranchReapOutcome {
1242    let refname = format!("refs/heads/{branch}");
1243
1244    // Existence plus the tip in one call. `--quiet` exits 1 for a ref that
1245    // is missing or broken and 128 for a repo it cannot read, so "no such
1246    // branch" never absorbs "no such repo".
1247    let tip = match git_command::command("git")
1248        .args(["rev-parse", "--short", "--verify", "--quiet", &refname])
1249        .current_dir(repo)
1250        .output()
1251    {
1252        Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
1253        Ok(out) if out.status.code() == Some(1) => return BranchReapOutcome::NotPresent,
1254        Ok(out) => return BranchReapOutcome::KeptOnError { reason: git_error(&out) },
1255        Err(err) => return BranchReapOutcome::KeptOnError { reason: err.to_string() },
1256    };
1257
1258    // `--exclude` filters what the NEXT `--all` considers, so it has to
1259    // precede it; placed after, git ignores it and every branch reads as
1260    // holding nothing.
1261    let exclude = format!("--exclude={refname}");
1262    let count = match git_command::command("git")
1263        .args(["rev-list", "--count", &refname, "--not", &exclude, "--all"])
1264        .current_dir(repo)
1265        .output()
1266    {
1267        Ok(out) if out.status.success() => {
1268            match String::from_utf8_lossy(&out.stdout).trim().parse::<u64>() {
1269                Ok(count) => count,
1270                Err(err) => {
1271                    return BranchReapOutcome::KeptOnError {
1272                        reason: format!("unreadable commit count: {err}"),
1273                    };
1274                }
1275            }
1276        }
1277        Ok(out) => return BranchReapOutcome::KeptOnError { reason: git_error(&out) },
1278        Err(err) => return BranchReapOutcome::KeptOnError { reason: err.to_string() },
1279    };
1280    if count > 0 {
1281        return BranchReapOutcome::Kept { count, tip };
1282    }
1283
1284    // `-D`, not `-d`: the count above IS the safety decision. `-d` would
1285    // re-derive merged-ness from history shape and refuses a pristine
1286    // branch whenever HEAD predates the commit it was created from.
1287    match git_command::command("git").args(["branch", "-D", branch]).current_dir(repo).output() {
1288        Ok(out) if out.status.success() => BranchReapOutcome::Reaped,
1289        Ok(out) => BranchReapOutcome::DeleteFailed { reason: git_error(&out) },
1290        Err(err) => BranchReapOutcome::DeleteFailed { reason: err.to_string() },
1291    }
1292}
1293
1294/// Whether `branch` resolves at `repo`, as a local head or as any
1295/// remote-tracking ref. A repo git cannot read answers `true`, so a
1296/// caller cleaning up on absence never discards state it failed to
1297/// inspect.
1298///
1299/// Membership in [`repo_branch_names`] rather than a targeted
1300/// `for-each-ref` pattern, so both callers share one parser. A pattern
1301/// cannot answer this safely on a delete path: `refs/remotes/*/<branch>`
1302/// has a `*` that does not cross `/`, so a remote named `gh/upstream`
1303/// puts its refs where the pattern cannot see them, and `a[b`, a leading
1304/// `-` or a space match nothing. Every one of those exits 0 with empty
1305/// stdout, and empty is what triggers the delete.
1306///
1307/// Remote-tracking refs count because [`reap_worktree_branch`] deletes a
1308/// local head whose commits are reachable from one, and a branch that
1309/// survives on a remote is still open for review.
1310pub fn branch_ref_exists(repo: &Path, branch: &str) -> bool {
1311    repo_branch_names(repo).is_none_or(|repo| repo.names.contains(branch))
1312}
1313
1314/// The ref claude's `--worktree` cuts a fresh branch from:
1315/// `origin/<default-branch>` as the repo's `origin/HEAD` names it.
1316///
1317/// `None` when the repo names no remote default, or when the symref names a
1318/// ref the remote no longer carries: a start point git cannot resolve would
1319/// refuse the whole resume, so the caller falls back to the local HEAD.
1320fn origin_base(repo: &Path) -> Option<String> {
1321    let name = git_in_repo(repo, &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"])?
1322        .trim()
1323        .to_owned();
1324    if name.is_empty() {
1325        return None;
1326    }
1327    git_in_repo(repo, &["rev-parse", "--verify", "--quiet", &name]).map(|_| name)
1328}
1329
1330/// Run `git -C repo <args>` with the ambient repo-location env scrubbed,
1331/// returning stdout on exit 0.
1332///
1333/// `GIT_DIR` / `GIT_WORK_TREE` / `GIT_COMMON_DIR` override `-C` outright,
1334/// so without the scrub a forge launched from a git hook, a `git rebase
1335/// --exec`, or any shell that exported them answers every question about
1336/// every repo from one foreign repo - at exit 0, with nothing in the
1337/// output to say so.
1338fn git_in_repo(repo: &Path, args: &[&str]) -> Option<String> {
1339    let out = scrubbed_git(repo, args).output().ok()?;
1340    out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned())
1341}
1342
1343/// The command [`git_in_repo`] runs, built separately so a test can
1344/// assert the scrub is on it.
1345fn scrubbed_git(repo: &Path, args: &[&str]) -> Command {
1346    let mut command = git_command::command("git");
1347    command.arg("-C").arg(repo).args(args);
1348    command
1349}
1350
1351/// What a repo knows about its branches, in two quantities that must not
1352/// be confused for each other.
1353///
1354/// [`Self::names`] deliberately over-includes and [`Self::ref_count`] is
1355/// exact. A caller sparing on membership wants the first; a caller
1356/// judging whether the repo can be trusted to answer wants the second.
1357/// They were one value once, and the over-inclusion silently inflated
1358/// the population: each slash in a branch name adds a phantom suffix, so
1359/// `release/2.0/rc1` reads as three names against two refs and a shallow
1360/// clone looks well-stocked. One slash does not show it - two names, two
1361/// refs.
1362pub struct RepoBranches {
1363    /// Every name a stored branch might legitimately match. Includes
1364    /// every `/`-suffix of a remote-tracking ref's tail, so it can only
1365    /// spare a branch, never condemn one - which is why nothing is
1366    /// dropped from it on a name test. NOT a population.
1367    pub names: std::collections::HashSet<String>,
1368    /// Branch refs the repo actually holds - local heads plus
1369    /// remote-tracking refs, excluding symrefs, which point at another
1370    /// ref in the same listing rather than being branches of their own.
1371    /// Independent of how any branch is named, which is the whole point:
1372    /// a count derived from names moves when a branch is renamed.
1373    pub ref_count: usize,
1374}
1375
1376/// Every branch name the repo rooted exactly at `repo` knows, plus how
1377/// many branch refs it actually holds. `None` when `repo` is not itself
1378/// a work-tree root or git could not be asked.
1379///
1380/// One listing, then exact string membership. That is the difference
1381/// between this and asking [`branch_ref_exists`] per branch: a
1382/// `for-each-ref` *pattern* matches path prefixes, so a stored `fix`
1383/// matches `fix/anything`, an empty name matches every head, and a name
1384/// carrying `[`, a leading `-` or a space matches nothing and exits 0 -
1385/// indistinguishable from a branch that is genuinely gone. A caller that
1386/// deletes on absence cannot tell those apart; a set cannot produce them.
1387///
1388/// A remote-tracking ref is `refs/remotes/<remote>/<branch>`, and
1389/// `<remote>` may itself contain slashes - `git remote add gh/upstream`
1390/// is accepted - so where one segment ends and the other begins is not
1391/// recoverable from the refname. Every `/`-suffix of the tail joins
1392/// `names` rather than guessing: `gh/upstream/feat/pushed` contributes
1393/// `upstream/feat/pushed`, `feat/pushed` and `pushed`. That can only add
1394/// names, so its failure is to spare a branch whose name happens to be a
1395/// suffix of another ref's path - never to delete a live one, which
1396/// splitting once does. `ref_count` counts refs and is untouched by it.
1397///
1398/// The `--show-toplevel` equality check is what keeps discovery from
1399/// walking up: git answers happily from an ancestor repo when `repo` is
1400/// merely nested inside one. It is only meaningful because the helper
1401/// above scrubs the env first - with `GIT_DIR` set, `--show-toplevel`
1402/// reports the working directory and the check passes while the refs
1403/// come from elsewhere.
1404pub fn repo_branch_names(repo: &Path) -> Option<RepoBranches> {
1405    let toplevel = git_in_repo(repo, &["rev-parse", "--show-toplevel"])?;
1406    let toplevel = std::fs::canonicalize(toplevel.trim()).ok()?;
1407    if toplevel != std::fs::canonicalize(repo).ok()? {
1408        return None;
1409    }
1410    let listing = git_in_repo(
1411        repo,
1412        &["for-each-ref", "--format=%(refname) %(symref)", "refs/heads", "refs/remotes"],
1413    )?;
1414    let mut names = std::collections::HashSet::new();
1415    let mut ref_count = 0;
1416    for line in listing.lines() {
1417        // A ref name cannot contain a space, so the first one separates
1418        // it from `%(symref)`, which is empty for everything but a symref.
1419        let (refname, symref) = line.split_once(' ').unwrap_or((line, ""));
1420        // `origin/HEAD` points at another ref in this same listing, so
1421        // counting it would make every clone look one branch richer and
1422        // its target's name is already here. Tested by symref rather than
1423        // by the name: `release/HEAD` is a legal branch, and skipping it
1424        // on the name would drop a real branch out of `names` - which
1425        // deletes it.
1426        if !symref.trim().is_empty() {
1427            continue;
1428        }
1429        if let Some(head) = refname.strip_prefix("refs/heads/") {
1430            ref_count += 1;
1431            names.insert(head.to_owned());
1432        } else if let Some(tail) = refname.strip_prefix("refs/remotes/") {
1433            ref_count += 1;
1434            let mut rest = tail;
1435            while let Some((_, suffix)) = rest.split_once('/') {
1436                names.insert(suffix.to_owned());
1437                rest = suffix;
1438            }
1439        }
1440    }
1441    names.remove("");
1442    Some(RepoBranches { names, ref_count })
1443}
1444
1445/// git's stderr, or its exit status when it said nothing.
1446fn git_error(out: &std::process::Output) -> String {
1447    let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
1448    if stderr.is_empty() { out.status.to_string() } else { stderr }
1449}
1450
1451/// A completed `isolation: "worktree"` subagent's managed worktree, as
1452/// the Agent tool reports it on completion. Produced only by
1453/// [`completed_agent_worktree`], which vouches for every field.
1454#[derive(Debug)]
1455pub struct AgentWorktree {
1456    /// The worktree directory, `<repo>/.claude/worktrees/agent-<hex>`.
1457    pub path: std::path::PathBuf,
1458    /// The repo the worktree was cut from; where branch reaping runs.
1459    pub repo_root: std::path::PathBuf,
1460    /// The CLI's conventional branch (`worktree-<slug>`), from the tool
1461    /// response or derived from the directory name.
1462    pub branch: String,
1463}
1464
1465/// Outcome of [`reap_agent_worktree`].
1466#[derive(Debug)]
1467pub enum AgentWorktreeReap {
1468    /// Removed. Carries the conventional branch's reap outcome.
1469    Reaped { branch: BranchReapOutcome },
1470    /// Uncommitted or untracked changes: the CLI hands a changed tree
1471    /// back to the caller deliberately, so the reap refuses too.
1472    KeptDirty { reason: String },
1473    /// Detached HEAD holding commits reachable from no ref: removal
1474    /// would strand them, because the post-removal branch reap sees refs
1475    /// only and never a linked worktree's HEAD. `tip` is the full sha.
1476    KeptUniqueCommit { tip: String },
1477    /// Already gone - the CLI's own auto-clean, or an earlier reap.
1478    Absent,
1479    /// Git does not vouch for the path being a worktree.
1480    NotAWorktree,
1481    /// `git worktree remove` failed; the directory survives.
1482    RemoveFailed { reason: String },
1483}
1484
1485/// Whether this hook payload names an isolation:"worktree" Agent call -
1486/// the population whose completion either names a managed worktree or
1487/// proves the CLI's tool-response contract drifted.
1488pub fn is_isolated_agent_call(tool_name: &str, tool_input: &serde_json::Value) -> bool {
1489    (tool_name == "Agent" || tool_name == "Task")
1490        && tool_input.get("isolation").and_then(serde_json::Value::as_str) == Some("worktree")
1491}
1492
1493/// The agent worktree a completed Agent-tool call left behind, from the
1494/// PostToolUse hook payload.
1495///
1496/// Gated four ways because the answer aims a deletion: only the Agent
1497/// tool (either spelling), only calls that ran with
1498/// `isolation: "worktree"`, only a structured `worktreePath` field, and
1499/// only paths with the CLI's managed layout
1500/// `<repo>/.claude/worktrees/agent-<...>`. `None` when any gate fails.
1501pub fn completed_agent_worktree(
1502    tool_name: &str,
1503    tool_input: &serde_json::Value,
1504    tool_response: &serde_json::Value,
1505) -> Option<AgentWorktree> {
1506    if !is_isolated_agent_call(tool_name, tool_input) {
1507        return None;
1508    }
1509    let path: std::path::PathBuf = find_string_field(tool_response, "worktreePath", 0)?.into();
1510    if !path.is_absolute() {
1511        return None;
1512    }
1513    let name = path.file_name()?.to_str()?;
1514    if !name.starts_with("agent-") {
1515        return None;
1516    }
1517    let worktrees = path.parent()?;
1518    if worktrees.file_name()? != std::ffi::OsStr::new("worktrees") {
1519        return None;
1520    }
1521    let dot_claude = worktrees.parent()?;
1522    if dot_claude.file_name()? != std::ffi::OsStr::new(".claude") {
1523        return None;
1524    }
1525    let branch = find_string_field(tool_response, "worktreeBranch", 0)
1526        .unwrap_or_else(|| format!("worktree-{name}"));
1527    let repo_root = dot_claude.parent()?.to_path_buf();
1528    Some(AgentWorktree { path, repo_root, branch })
1529}
1530
1531/// First string value under `key` in a pre-order walk. The walk is
1532/// depth-capped at 6: the captured shape is flat and top-level, so a
1533/// deeper field is treated as absent (surfaced by the caller's
1534/// parse-miss log, never guessed at). String content is never searched,
1535/// so a result text quoting `worktreePath:` cannot aim the reap.
1536fn find_string_field(value: &serde_json::Value, key: &str, depth: u8) -> Option<String> {
1537    if depth > 6 {
1538        return None;
1539    }
1540    match value {
1541        serde_json::Value::Object(map) => {
1542            for (k, v) in map {
1543                if k == key
1544                    && let Some(s) = v.as_str()
1545                {
1546                    return Some(s.to_owned());
1547                }
1548            }
1549            map.values().find_map(|v| find_string_field(v, key, depth + 1))
1550        }
1551        serde_json::Value::Array(items) => {
1552            items.iter().find_map(|v| find_string_field(v, key, depth + 1))
1553        }
1554        _ => None,
1555    }
1556}
1557
1558/// The full sha of a detached worktree HEAD holding commits that only
1559/// the worktree itself reaches, or `None` when removal cannot strand
1560/// anything. An attached branch is judged after removal by
1561/// [`reap_worktree_branch`]; a detached HEAD must be judged before.
1562///
1563/// The survivors are refs plus every OTHER worktree's HEAD. `--all`
1564/// cannot serve here: it includes this worktree's own HEAD, against
1565/// which nothing ever reads unique, so the guard would never fire. Any
1566/// failure to verify returns a tip, keeping the tree.
1567fn detached_unique_tip(repo_root: &Path, path: &Path) -> Option<String> {
1568    if worktree_branch(path).is_some() {
1569        return None;
1570    }
1571    let tip = match git_in_repo(path, &["rev-parse", "HEAD"]) {
1572        Some(sha) => sha.trim().to_owned(),
1573        None => return Some(String::from("(unreadable HEAD)")),
1574    };
1575    if tip.is_empty() {
1576        return Some(String::from("(unreadable HEAD)"));
1577    }
1578    let survivors = survivor_tips(repo_root, path)?;
1579    let exclusions: Vec<String> = survivors.iter().map(|sha| format!("^{sha}")).collect();
1580    let mut cmd = git_command::command("git");
1581    cmd.arg("-C")
1582        .arg(repo_root)
1583        .args(["rev-list", "--count", &tip, "--stdin"])
1584        .stdin(std::process::Stdio::piped())
1585        .stdout(std::process::Stdio::piped())
1586        .stderr(std::process::Stdio::piped());
1587    let Ok(mut child) = cmd.spawn() else {
1588        return Some(tip);
1589    };
1590    if let Some(mut stdin) = child.stdin.take() {
1591        use std::io::Write;
1592        let _ = stdin.write_all(exclusions.join("\n").as_bytes());
1593        let _ = stdin.write_all(b"\n");
1594    }
1595    let unique = match child.wait_with_output() {
1596        Ok(out) if out.status.success() => {
1597            String::from_utf8_lossy(&out.stdout).trim().parse::<u64>().ok()
1598        }
1599        _ => None,
1600    };
1601    match unique {
1602        Some(0) => None,
1603        _ => Some(tip),
1604    }
1605}
1606
1607/// Every tip that would survive `worktree_path`'s removal: all refs plus
1608/// the HEAD of each other worktree. `None` when git cannot be asked,
1609/// which the caller treats as keep.
1610fn survivor_tips(repo_root: &Path, worktree_path: &Path) -> Option<Vec<String>> {
1611    let mut tips: Vec<String> =
1612        git_in_repo(repo_root, &["for-each-ref", "--format=%(objectname)"])?
1613            .lines()
1614            .filter(|l| !l.trim().is_empty())
1615            .map(|l| l.trim().to_owned())
1616            .collect();
1617    let listing = git_in_repo(repo_root, &["worktree", "list", "--porcelain"])?;
1618    let here = std::fs::canonicalize(worktree_path).ok();
1619    let mut current: Option<(String, Option<String>)> = None;
1620    let flush = |current: &mut Option<(String, Option<String>)>, tips: &mut Vec<String>| {
1621        if let Some((path, Some(sha))) = current.take() {
1622            let same = std::fs::canonicalize(std::path::Path::new(&path))
1623                .ok()
1624                .zip(here.clone())
1625                .is_some_and(|(a, b)| a == b)
1626                || path == worktree_path.to_string_lossy();
1627            if !same {
1628                tips.push(sha);
1629            }
1630        }
1631    };
1632    for line in listing.lines() {
1633        if let Some(path) = line.strip_prefix("worktree ") {
1634            flush(&mut current, &mut tips);
1635            current = Some((path.to_owned(), None));
1636        } else if let Some(sha) = line.strip_prefix("HEAD ") {
1637            if let Some((_, head)) = current.as_mut() {
1638                *head = Some(sha.to_owned());
1639            }
1640        } else if line.is_empty() {
1641            flush(&mut current, &mut tips);
1642        }
1643    }
1644    flush(&mut current, &mut tips);
1645    Some(tips)
1646}
1647
1648/// Managed agent worktrees under `<base>/.claude/worktrees/` untouched
1649/// for at least `age`, for the reconciliation sweep that runs at every
1650/// session spawn: it reaps what a one-shot failure or a missed
1651/// completion event left behind. A lock whose reason carries a live pid
1652/// (the CLI stamps one on a live isolation agent's tree) keeps the tree
1653/// out; a dead pid - a crashed CLI's leftover, which git never prunes on
1654/// its own - is reapable. Locks without a parsable pid are not ours to
1655/// judge and skip. `Ok(vec![])` when the worktrees dir is absent; other
1656/// read errors propagate, and a repo git cannot answer about sweeps as
1657/// empty - the fail-closed direction for a deleter. Feed the result to
1658/// [`reap_agent_worktree`].
1659pub fn stale_agent_worktrees(
1660    repo_root: &Path,
1661    age: std::time::Duration,
1662) -> std::io::Result<Vec<AgentWorktree>> {
1663    // The CLI's own root, which is the main checkout for a project that is
1664    // itself a linked worktree (#2460); the project path lists a directory
1665    // the CLI never fills.
1666    let worktrees_dir = worktree_base(repo_root).join(".claude").join("worktrees");
1667    let entries = match std::fs::read_dir(&worktrees_dir) {
1668        Ok(entries) => entries,
1669        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1670        Err(err) => return Err(err),
1671    };
1672    let Some(locks) = locked_worktrees(repo_root) else {
1673        return Ok(Vec::new());
1674    };
1675    let mut stale = Vec::new();
1676    for entry in entries.flatten() {
1677        let name = entry.file_name();
1678        let Some(name) = name.to_str() else { continue };
1679        if !name.starts_with("agent-") {
1680            continue;
1681        }
1682        let Ok(meta) = entry.metadata() else { continue };
1683        if !meta.is_dir() {
1684            continue;
1685        }
1686        let untouched = meta.modified().ok().and_then(|m| m.elapsed().ok());
1687        if untouched.is_none_or(|tree_age| tree_age < age) {
1688            continue;
1689        }
1690        let path = entry.path();
1691        let real = std::fs::canonicalize(&path);
1692        let lock = locks
1693            .iter()
1694            .find(|lock| lock.path == path || real.as_ref().is_ok_and(|real| lock.path == *real));
1695        match lock {
1696            // Live owner, or a lock we cannot judge: keep the tree.
1697            Some(lock) if lock_pid_is_live(lock.pid) => continue,
1698            _ => {}
1699        }
1700        stale.push(AgentWorktree {
1701            path,
1702            repo_root: repo_root.to_path_buf(),
1703            branch: format!("worktree-{name}"),
1704        });
1705    }
1706    Ok(stale)
1707}
1708
1709/// Whether a lock blocks the sweep: a parsable pid whose process is
1710/// alive blocks, and so does one we cannot judge (no pid, or liveness
1711/// unanswerable); only a provably dead pid releases the tree.
1712fn lock_pid_is_live(pid: Option<u32>) -> bool {
1713    match pid {
1714        Some(pid) => process_is_live(pid),
1715        None => true,
1716    }
1717}
1718
1719/// Whether `pid` names a live process. Unanswerable counts as live: a
1720/// liveness question we cannot ask never deletes a tree.
1721fn process_is_live(pid: u32) -> bool {
1722    let Ok(out) = Command::new("ps").args(["-p", &pid.to_string(), "-o", "pid="]).output() else {
1723        return true;
1724    };
1725    out.status.success() && !out.stdout.is_empty()
1726}
1727
1728/// Whether `path`'s lock, re-read now, belongs to a live process. The
1729/// sweep re-runs this immediately before each removal so a lock taken
1730/// after the listing still stops that reap. Unlocked or dead-owner
1731/// trees answer false; unreadable answers true.
1732pub fn agent_worktree_lock_is_live(repo_root: &Path, path: &Path) -> bool {
1733    let Ok(real) = std::fs::canonicalize(path) else {
1734        return true;
1735    };
1736    let Some(locks) = locked_worktrees(repo_root) else {
1737        return true;
1738    };
1739    match locks.into_iter().find(|lock| lock.path == real) {
1740        Some(lock) => lock_pid_is_live(lock.pid),
1741        None => false,
1742    }
1743}
1744
1745/// A worktree lock: the canonicalized path plus the pid the CLI stamps
1746/// into its reason, when it carries one.
1747struct WorktreeLock {
1748    path: std::path::PathBuf,
1749    pid: Option<u32>,
1750}
1751
1752/// This repo's worktrees git reports as locked, canonicalized, with the
1753/// pid parsed from the reason when there is one. `None` when git cannot
1754/// be asked - the caller sweeps as empty rather than judge without the
1755/// lock facts.
1756fn locked_worktrees(repo_root: &Path) -> Option<Vec<WorktreeLock>> {
1757    let listing = git_in_repo(repo_root, &["worktree", "list", "--porcelain"])?;
1758    let mut locks = Vec::new();
1759    let mut current: Option<String> = None;
1760    let mut reason: Option<String> = None;
1761    let flush = |current: &mut Option<String>,
1762                 reason: &mut Option<String>,
1763                 locks: &mut Vec<WorktreeLock>| {
1764        if let (Some(path), Some(reason)) = (current.take(), reason.take())
1765            && let Ok(real) = std::fs::canonicalize(std::path::Path::new(&path))
1766        {
1767            locks.push(WorktreeLock { path: real, pid: parse_lock_pid(&reason) });
1768        }
1769    };
1770    for line in listing.lines() {
1771        if let Some(path) = line.strip_prefix("worktree ") {
1772            flush(&mut current, &mut reason, &mut locks);
1773            current = Some(path.to_owned());
1774        } else if let Some(reason_text) = line.strip_prefix("locked") {
1775            reason = Some(reason_text.trim().to_owned());
1776        } else if line.is_empty() {
1777            flush(&mut current, &mut reason, &mut locks);
1778        }
1779    }
1780    flush(&mut current, &mut reason, &mut locks);
1781    Some(locks)
1782}
1783
1784/// The pid in a CLI lock reason, `claude agent agent-<hex> (pid 33497
1785/// start ...)`. `None` for a bare `locked` line or any foreign reason.
1786fn parse_lock_pid(reason: &str) -> Option<u32> {
1787    let digits = reason.split("pid ").nth(1)?;
1788    let digits: String = digits.chars().take_while(char::is_ascii_digit).collect();
1789    digits.parse().ok()
1790}
1791
1792/// Remove a completed subagent's managed worktree, then reap the
1793/// conventional branch behind it. Refuses anything but a porcelain-clean
1794/// tree: the measured leak is clean trees whose gitignored target/ the
1795/// CLI's auto-clean does not count, while uncommitted edits are a tree
1796/// the CLI handed back deliberately. Only `branch` is ever aimed at -
1797/// a branch the subagent checked out itself is left to git's normal
1798/// lifecycle, the same rule the worker despawn applies.
1799pub fn reap_agent_worktree(agent_worktree: &AgentWorktree) -> AgentWorktreeReap {
1800    let path = &agent_worktree.path;
1801    if !path.exists() {
1802        return AgentWorktreeReap::Absent;
1803    }
1804    if !is_git_repo(path) {
1805        return AgentWorktreeReap::NotAWorktree;
1806    }
1807    if let Some(reason) = worktree_uncommitted_reason(path) {
1808        return AgentWorktreeReap::KeptDirty { reason };
1809    }
1810    if let Some(tip) = detached_unique_tip(&agent_worktree.repo_root, path) {
1811        return AgentWorktreeReap::KeptUniqueCommit { tip };
1812    }
1813    // `git worktree remove` refuses a worktree holding initialized
1814    // submodules however clean it is (git-worktree(1)), so the unforced
1815    // form cannot serve here. The porcelain gate above authorizes
1816    // `--force` and is then the only dirtiness gate on this path: the hook
1817    // runs once the subagent has returned, and the sweep lists only trees
1818    // whose top-level dir has gone untouched for an hour and whose lock
1819    // has no live pid - neither of which excludes a process the subagent
1820    // left running behind it.
1821    match remove_worktree(path, true) {
1822        Ok(()) => AgentWorktreeReap::Reaped {
1823            branch: reap_worktree_branch(&agent_worktree.repo_root, &agent_worktree.branch),
1824        },
1825        Err(err) => AgentWorktreeReap::RemoveFailed { reason: err.to_string() },
1826    }
1827}
1828
1829#[cfg(test)]
1830mod tests {
1831    use super::*;
1832    use std::fs;
1833    use tempfile::tempdir;
1834
1835    #[test]
1836    fn returns_false_for_empty_dir() {
1837        let dir = tempdir().expect("tempdir");
1838        assert!(!is_git_repo(dir.path()));
1839    }
1840
1841    #[test]
1842    fn returns_true_for_initialised_repo() {
1843        let dir = tempdir().expect("tempdir");
1844        std::process::Command::new("git")
1845            .args(["init", "-q"])
1846            .current_dir(dir.path())
1847            .status()
1848            .expect("git init");
1849        assert!(is_git_repo(dir.path()));
1850    }
1851
1852    #[test]
1853    fn returns_false_for_nonexistent_path() {
1854        assert!(!is_git_repo(Path::new("/nonexistent/path/that/does/not/exist")));
1855    }
1856
1857    #[test]
1858    fn returns_true_for_subdir_of_repo() {
1859        let dir = tempdir().expect("tempdir");
1860        std::process::Command::new("git")
1861            .args(["init", "-q"])
1862            .current_dir(dir.path())
1863            .status()
1864            .expect("git init");
1865        let sub = dir.path().join("sub");
1866        fs::create_dir(&sub).expect("mkdir");
1867        assert!(is_git_repo(&sub));
1868    }
1869
1870    /// A spawn's tree is the repo root of wherever it points: a
1871    /// subdirectory must land the worker at the root, because that is
1872    /// where claude's `--worktree` forks from.
1873    #[test]
1874    fn worker_root_resolves_a_subdirectory_to_the_repo_root() {
1875        let repo = init_repo_with_commit();
1876        let nested = repo.path().join("crates/deep");
1877        fs::create_dir_all(&nested).expect("nested dir");
1878
1879        let root = worker_root(&nested).expect("resolves");
1880
1881        assert_eq!(
1882            root,
1883            fs::canonicalize(repo.path()).expect("canonical repo"),
1884            "a subdirectory must resolve to the repo root, not itself",
1885        );
1886    }
1887
1888    /// A path outside any repo is its own tree, resolved: the worker runs
1889    /// there without a worktree.
1890    #[test]
1891    fn worker_root_keeps_a_path_outside_a_repo_as_itself() {
1892        let dir = tempdir().expect("tempdir");
1893
1894        let root = worker_root(dir.path()).expect("resolves");
1895
1896        assert_eq!(
1897            root,
1898            fs::canonicalize(dir.path()).expect("canonical dir"),
1899            "a non-repo directory is its own root",
1900        );
1901    }
1902
1903    /// The record stores the canonical root on purpose: the claude CLI
1904    /// derives the session JSONL's directory from the canonical path
1905    /// (measured: `/tmp` resolves to `/private/tmp`), so an alias in the
1906    /// record would compose a directory the CLI never wrote to.
1907    #[test]
1908    fn worker_root_canonicalizes_the_path_it_returns() {
1909        let dir = tempdir().expect("tempdir");
1910        let alias = dir.path().join("alias");
1911        fs::create_dir_all(&alias).expect("alias dir");
1912        std::os::unix::fs::symlink(&alias, dir.path().join("link")).expect("symlink");
1913
1914        let root = worker_root(&dir.path().join("link")).expect("resolves");
1915
1916        assert_eq!(
1917            root,
1918            fs::canonicalize(&alias).expect("canonical"),
1919            "the resolved root must carry no symlink component",
1920        );
1921    }
1922
1923    /// `--worktree <label>` composes `<canonical root>/.claude/worktrees/
1924    /// <label>`, and the canonical root of a linked worktree is the MAIN
1925    /// checkout, not the worktree path (#2460). A path inside a repo
1926    /// resolves to its toplevel the same way; a path that is not a
1927    /// repository is its own base, and a git that could not answer is
1928    /// re-asked rather than pinned.
1929    #[test]
1930    fn worktree_base_is_the_cli_canonical_root() {
1931        let repo = init_repo_with_commit();
1932        let main = fs::canonicalize(repo.path()).expect("canonical repo");
1933        let linked = main.join("linked");
1934        run_git(&main, &["worktree", "add", "-q", "-b", "linked", linked.to_str().expect("utf8")]);
1935        let linked_nested = linked.join("crates/deep");
1936        fs::create_dir_all(&linked_nested).expect("nested dir");
1937        let main_nested = main.join("crates/deep");
1938        fs::create_dir_all(&main_nested).expect("nested dir");
1939
1940        assert_eq!(
1941            worktree_base(&linked),
1942            main,
1943            "a project that is a linked worktree forks its workers' worktrees under the main \
1944             checkout",
1945        );
1946        assert_eq!(
1947            worktree_base(&linked_nested),
1948            main,
1949            "a path inside a linked worktree resolves to the main checkout",
1950        );
1951        assert_eq!(
1952            worktree_base(&main_nested),
1953            main,
1954            "a subdirectory of a normal checkout resolves to its toplevel, as the CLI does",
1955        );
1956        assert_eq!(worktree_base(&main), main, "the main checkout is its own base");
1957
1958        // A declared project path can reach the main checkout through a
1959        // symlink; the CLI composes from the canonical spelling, so the
1960        // answer must not depend on which spelling the config used.
1961        let holder = tempdir().expect("alias holder");
1962        let alias = holder.path().join("project");
1963        std::os::unix::fs::symlink(&main, &alias).expect("symlink");
1964        assert_eq!(worktree_base(&alias), main, "a symlinked spelling resolves to the same root");
1965
1966        // A linked worktree of a repo kept in a separate git dir. This is
1967        // the unsupported layout: see the assertion below.
1968        let separate = tempdir().expect("separate holder");
1969        let checkout = separate.path().join("checkout");
1970        let store = separate.path().join("store.git");
1971        run_git(
1972            separate.path(),
1973            &[
1974                "init",
1975                "-q",
1976                "--separate-git-dir",
1977                store.to_str().expect("utf8"),
1978                checkout.to_str().expect("utf8"),
1979            ],
1980        );
1981        run_git(&checkout, &["config", "user.email", "t@example.com"]);
1982        run_git(&checkout, &["config", "user.name", "Test"]);
1983        run_git(&checkout, &["config", "commit.gpgsign", "false"]);
1984        fs::write(checkout.join("README.md"), "seed").expect("write seed");
1985        run_git(&checkout, &["add", "."]);
1986        run_git(&checkout, &["commit", "-q", "-m", "init"]);
1987        let separate_linked = checkout.join("linked");
1988        run_git(
1989            &checkout,
1990            &["worktree", "add", "-q", "-b", "sep-linked", separate_linked.to_str().expect("utf8")],
1991        );
1992        // A repo kept in a separate git dir is NOT a supported worker
1993        // layout: git names the ADMIN dir as its main worktree here, and
1994        // the CLI's own base for this shape is unverified. forge follows
1995        // git's answer rather than guessing a checkout it cannot derive.
1996        assert_eq!(
1997            worktree_base(&separate_linked),
1998            fs::canonicalize(&store).expect("canonical store"),
1999            "a separate git dir's base is the admin dir git names, and the layout is unsupported",
2000        );
2001
2002        // A failed resolution is re-asked: with its link gone the worktree
2003        // is not a repository, and once the link is back the same root
2004        // answers with the main checkout rather than the pinned fallback.
2005        // The path sits outside the main checkout so the lifted link
2006        // leaves it in no repository at all.
2007        let flaky_holder = tempdir().expect("flaky holder");
2008        let flaky = flaky_holder.path().join("flaky");
2009        run_git(&main, &["worktree", "add", "-q", "-b", "flaky", flaky.to_str().expect("utf8")]);
2010        let link = flaky.join(".git");
2011        let held = flaky_holder.path().join("flaky-link");
2012        fs::rename(&link, &held).expect("lift the worktree link");
2013        assert_eq!(worktree_base(&flaky), flaky, "a path git cannot answer for is its own base");
2014        fs::rename(&held, &link).expect("restore the worktree link");
2015        assert_eq!(
2016            worktree_base(&flaky),
2017            main,
2018            "the failed resolution was not memoised, so the next call answers with the main \
2019             checkout",
2020        );
2021
2022        let outside = tempdir().expect("outside");
2023        assert_eq!(
2024            worktree_base(outside.path()),
2025            outside.path(),
2026            "a path outside any repo is its own base",
2027        );
2028        let missing = outside.path().join("not-there");
2029        assert_eq!(
2030            worktree_base(&missing),
2031            missing,
2032            "a path that is not there answers without a git call and is its own base",
2033        );
2034    }
2035
2036    /// The unresolved-root warning is once per root, not once per call:
2037    /// callers include render paths that ask for the same row every frame.
2038    #[test]
2039    fn an_unresolved_root_is_reported_once() {
2040        let holder = tempdir().expect("holder");
2041        let first = holder.path().join("one");
2042        let second = holder.path().join("two");
2043
2044        assert!(first_unresolved(&first), "the first report of a root goes through");
2045        assert!(!first_unresolved(&first), "a repeat for the same root is suppressed");
2046        assert!(first_unresolved(&second), "a different root reports on its own");
2047    }
2048
2049    /// The warning line carries the event name a log grep uses and the
2050    /// root it is about.
2051    #[test]
2052    fn the_unresolved_root_warning_names_the_event_and_the_root() {
2053        #[derive(Clone)]
2054        struct Capture(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
2055        impl std::io::Write for Capture {
2056            fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
2057                self.0.lock().expect("capture lock").extend_from_slice(buf);
2058                Ok(buf.len())
2059            }
2060            fn flush(&mut self) -> std::io::Result<()> {
2061                Ok(())
2062            }
2063        }
2064        impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Capture {
2065            type Writer = Capture;
2066            fn make_writer(&'a self) -> Self::Writer {
2067                self.clone()
2068            }
2069        }
2070
2071        let buffer = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2072        let subscriber = tracing_subscriber::fmt()
2073            .with_max_level(tracing::Level::WARN)
2074            .with_writer(Capture(std::sync::Arc::clone(&buffer)))
2075            .finish();
2076        let root = std::path::Path::new("/nowhere/at/all");
2077        tracing::subscriber::with_default(subscriber, || report_unresolved_root(root));
2078
2079        let text =
2080            String::from_utf8(buffer.lock().expect("capture lock").clone()).expect("utf8 log");
2081        assert!(text.contains("worktree_base_unresolved"), "the event name is logged: {text}");
2082        assert!(text.contains("/nowhere/at/all"), "and the root it is about: {text}");
2083    }
2084
2085    /// A directory that is not there is a refusal, not a silent pass: the
2086    /// spawn would otherwise fail later with claude's own cwd error, far
2087    /// from the caller that mistyped the path.
2088    #[test]
2089    fn worker_root_refuses_a_directory_that_is_not_there() {
2090        let dir = tempdir().expect("tempdir");
2091        let missing = dir.path().join("not-there");
2092
2093        let error = worker_root(&missing).expect_err("a missing directory is refused");
2094
2095        assert!(
2096            error.contains("not-there") && error.contains("not a directory"),
2097            "the refusal names the path and what is wrong with it: {error}",
2098        );
2099    }
2100
2101    fn run_git(dir: &Path, args: &[&str]) {
2102        let status = std::process::Command::new("git")
2103            .args(args)
2104            .current_dir(dir)
2105            .status()
2106            .expect("spawn git");
2107        assert!(status.success(), "git {args:?} failed in {dir:?}");
2108    }
2109
2110    /// A repo with one commit. Never names a default branch - CI's git
2111    /// 2.25.1 predates `init -b` and defaults to `master`.
2112    fn init_repo_with_commit() -> tempfile::TempDir {
2113        let dir = tempdir().expect("tempdir");
2114        init_repo_at(dir.path());
2115        dir
2116    }
2117
2118    fn init_repo_at(path: &Path) {
2119        run_git(path, &["init", "-q"]);
2120        run_git(path, &["config", "user.email", "t@example.com"]);
2121        run_git(path, &["config", "user.name", "Test"]);
2122        run_git(path, &["config", "commit.gpgsign", "false"]);
2123        fs::write(path.join("README.md"), "seed").expect("write seed");
2124        run_git(path, &["add", "."]);
2125        run_git(path, &["commit", "-q", "-m", "init"]);
2126    }
2127
2128    /// Init a repo with one commit and add a worktree at `<repo>/wt`.
2129    /// Returns `(repo_tempdir, worktree_path)`.
2130    fn init_repo_with_worktree() -> (tempfile::TempDir, std::path::PathBuf) {
2131        let dir = init_repo_with_commit();
2132        let wt = dir.path().join("wt");
2133        run_git(dir.path(), &["worktree", "add", "-q", wt.to_str().expect("utf8 path")]);
2134        (dir, wt)
2135    }
2136
2137    #[test]
2138    fn repo_branch_names_lists_local_heads_and_remote_tracking_refs() {
2139        let dir = init_repo_with_commit();
2140        run_git(dir.path(), &["branch", "feat/live"]);
2141        run_git(dir.path(), &["update-ref", "refs/remotes/origin/feat/pushed", "HEAD"]);
2142        // A remote whose own name carries no slash, and one branch under a
2143        // nested path, so the two-segment split is exercised both ways.
2144        run_git(dir.path(), &["update-ref", "refs/remotes/upstream/main", "HEAD"]);
2145
2146        let names = repo_branch_names(dir.path()).expect("a repo root answers").names;
2147        assert!(names.contains("feat/live"), "local heads are listed: {names:?}");
2148        assert!(names.contains("feat/pushed"), "remote-tracking refs count: {names:?}");
2149        assert!(names.contains("main") || names.contains("master"), "{names:?}");
2150        assert!(
2151            !names.iter().any(|n| n.starts_with("refs/")),
2152            "names are branch names, not refnames: {names:?}",
2153        );
2154    }
2155
2156    /// The branch this returns is what the despawn delete is keyed on,
2157    /// so an unscrubbed read hands the wrong name to a correctly-scrubbed
2158    /// check: absent in the real repo, and its review state deleted. A
2159    /// scrubbed check beside an unscrubbed read is the asymmetry that
2160    /// produced the original defect.
2161    #[test]
2162    fn worktree_branch_is_scrubbed_like_every_other_repo_question() {
2163        let foreign = init_repo_with_commit();
2164        run_git(foreign.path(), &["checkout", "-q", "-b", "only-in-foreign"]);
2165        let host = init_repo_with_commit();
2166        run_git(host.path(), &["checkout", "-q", "-b", "the-real-branch"]);
2167
2168        let command = scrubbed_git(host.path(), &["rev-parse", "--abbrev-ref", "HEAD"]);
2169        let removed: Vec<&str> = command
2170            .get_envs()
2171            .filter(|(_, value)| value.is_none())
2172            .filter_map(|(key, _)| key.to_str())
2173            .collect();
2174        assert!(removed.contains(&"GIT_DIR"), "the read goes through the scrub: {removed:?}");
2175        assert_eq!(worktree_branch(host.path()).as_deref(), Some("the-real-branch"));
2176        let _ = foreign;
2177    }
2178
2179    /// `release/HEAD` is a legal branch name - `git branch release/HEAD`
2180    /// succeeds - so excluding `origin/HEAD` by its last segment drops a
2181    /// real branch out of the alive set, and absent is what deletes. The
2182    /// symref is what distinguishes them: a clone's `origin/HEAD` carries
2183    /// one, a branch never does.
2184    #[test]
2185    fn a_branch_legally_named_head_is_not_mistaken_for_the_symref() {
2186        let dir = init_repo_with_commit();
2187        run_git(dir.path(), &["update-ref", "refs/remotes/origin/release/HEAD", "HEAD"]);
2188        run_git(dir.path(), &["update-ref", "refs/remotes/origin/feat/a", "HEAD"]);
2189        // What a clone leaves: a real symref, not a plain ref.
2190        run_git(
2191            dir.path(),
2192            &["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/feat/a"],
2193        );
2194
2195        let repo = repo_branch_names(dir.path()).expect("a repo root answers");
2196        assert!(
2197            repo.names.contains("release/HEAD"),
2198            "a branch that happens to end in HEAD is still a branch: {:?}",
2199            repo.names,
2200        );
2201        assert!(branch_ref_exists(dir.path(), "release/HEAD"), "and the delete gate agrees");
2202        // The symref itself is excluded from the population, its target
2203        // having been counted already.
2204        assert_eq!(repo.ref_count, 3, "local head + two remote branches: {:?}", repo.names);
2205    }
2206
2207    /// A remote name may contain slashes - `git remote add gh/upstream`
2208    /// is accepted - so splitting the tail once leaves the remote's own
2209    /// tail on the front of the branch, and the live branch reads as
2210    /// absent. That is the only hazard in this area that points at
2211    /// DELETING state for a branch that still exists; every other one
2212    /// spares wrongly.
2213    #[test]
2214    fn a_slash_bearing_remote_still_yields_the_real_branch_name() {
2215        let dir = init_repo_with_commit();
2216        run_git(dir.path(), &["update-ref", "refs/remotes/gh/upstream/feat/pushed", "HEAD"]);
2217        run_git(dir.path(), &["update-ref", "refs/remotes/origin/feat/plain", "HEAD"]);
2218
2219        let names = repo_branch_names(dir.path()).expect("a repo root answers").names;
2220        assert!(
2221            names.contains("feat/pushed"),
2222            "the branch behind a slash-bearing remote must not read as gone: {names:?}",
2223        );
2224        assert!(names.contains("feat/plain"), "the ordinary case still works: {names:?}");
2225        // Over-sparing is the deliberate direction: intermediate suffixes
2226        // join the alive set, which can only spare, never delete.
2227        assert!(names.contains("upstream/feat/pushed"), "{names:?}");
2228        assert!(names.contains("pushed"), "{names:?}");
2229    }
2230
2231    /// Discovery walks up. Asking about a directory that merely sits
2232    /// inside a repo gets the ancestor's refs at exit 0, which for a
2233    /// caller deleting on absence means judging one project's rows
2234    /// against another repo's branches.
2235    #[test]
2236    fn repo_branch_names_refuses_a_directory_nested_inside_a_repo() {
2237        let dir = init_repo_with_commit();
2238        let nested = dir.path().join("crates");
2239        fs::create_dir_all(&nested).expect("mkdir");
2240        assert!(
2241            repo_branch_names(&nested).is_none(),
2242            "only the work-tree root answers for its own refs",
2243        );
2244        assert!(repo_branch_names(dir.path()).is_some(), "the root itself still answers");
2245    }
2246
2247    #[test]
2248    fn repo_branch_names_is_none_outside_a_repo() {
2249        let dir = tempdir().expect("tempdir");
2250        assert!(repo_branch_names(dir.path()).is_none());
2251    }
2252
2253    /// Pins the DEFENCE rather than only the hazard. The inherited case
2254    /// cannot be exercised in-process - the crate forbids `unsafe_code`,
2255    /// so a test cannot set `GIT_DIR` on itself - but `env_remove` beating
2256    /// an explicit `env` on the same `Command` is the same precedence that
2257    /// defeats inheritance, and it is what `git_in_repo` relies on.
2258    #[test]
2259    fn env_remove_beats_a_git_dir_set_on_the_same_command() {
2260        let host = init_repo_with_commit();
2261        run_git(host.path(), &["branch", "only-in-host"]);
2262        let foreign = init_repo_with_commit();
2263        run_git(foreign.path(), &["branch", "only-in-foreign"]);
2264
2265        let out = Command::new("git")
2266            .arg("-C")
2267            .arg(host.path())
2268            .args(["for-each-ref", "--format=%(refname)", "refs/heads"])
2269            .env("GIT_DIR", foreign.path().join(".git"))
2270            .env_remove("GIT_DIR")
2271            .output()
2272            .expect("git runs");
2273        let listing = String::from_utf8_lossy(&out.stdout);
2274
2275        assert!(out.status.success());
2276        assert!(
2277            listing.contains("only-in-host"),
2278            "the scrubbed command reads its -C repo: {listing:?}"
2279        );
2280        assert!(
2281            !listing.contains("only-in-foreign"),
2282            "and not the one GIT_DIR pointed at: {listing:?}",
2283        );
2284    }
2285
2286    /// Pins the scrub at its CALL SITE. The precedence test above covers
2287    /// `env_remove` winning; this covers `git_in_repo` actually applying
2288    /// it, which nothing else does - all three calls can be deleted with
2289    /// the rest of the suite green.
2290    #[test]
2291    fn the_git_helper_scrubs_every_repo_location_variable() {
2292        let dir = tempdir().expect("tempdir");
2293        let command = scrubbed_git(dir.path(), &["rev-parse", "--show-toplevel"]);
2294        let removed: Vec<&str> = command
2295            .get_envs()
2296            .filter(|(_, value)| value.is_none())
2297            .filter_map(|(key, _)| key.to_str())
2298            .collect();
2299        for key in ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR"] {
2300            assert!(removed.contains(&key), "{key} is not scrubbed: {removed:?}");
2301        }
2302    }
2303
2304    /// The hazard the scrub exists for: `-C` does not win against an
2305    /// inherited `GIT_DIR`, so an unscrubbed call answers about whatever
2306    /// repo the environment names.
2307    #[test]
2308    fn an_inherited_git_dir_beats_dash_c() {
2309        let foreign = init_repo_with_commit();
2310        run_git(foreign.path(), &["branch", "only-in-foreign"]);
2311        let elsewhere = tempdir().expect("tempdir");
2312
2313        let out = Command::new("git")
2314            .env("GIT_DIR", foreign.path().join(".git"))
2315            .arg("-C")
2316            .arg(elsewhere.path())
2317            .args(["for-each-ref", "--format=%(refname)", "refs/heads"])
2318            .output()
2319            .expect("git runs");
2320        let listing = String::from_utf8_lossy(&out.stdout);
2321
2322        assert!(out.status.success(), "the wrong answer arrives as a success");
2323        assert!(
2324            listing.contains("only-in-foreign"),
2325            "GIT_DIR overrides -C, so this reports the foreign repo's refs: {listing:?}",
2326        );
2327    }
2328
2329    #[test]
2330    fn clean_worktree_reports_not_dirty() {
2331        let (_dir, wt) = init_repo_with_worktree();
2332        assert_eq!(worktree_dirty_reason(&wt), None);
2333    }
2334
2335    #[test]
2336    fn untracked_file_reports_dirty() {
2337        let (_dir, wt) = init_repo_with_worktree();
2338        fs::write(wt.join("scratch.txt"), "uncommitted").expect("write scratch");
2339        assert!(worktree_dirty_reason(&wt).is_some());
2340    }
2341
2342    #[test]
2343    fn unpushed_commit_reports_dirty() {
2344        let (_dir, wt) = init_repo_with_worktree();
2345        // Establish an upstream via a bare remote, then commit locally
2346        // so HEAD is one ahead of @{upstream}. The working tree stays
2347        // clean (the change is committed), isolating the unpushed path.
2348        let remote = tempdir().expect("remote tempdir");
2349        run_git(remote.path(), &["init", "-q", "--bare"]);
2350        run_git(&wt, &["remote", "add", "origin", remote.path().to_str().expect("utf8 path")]);
2351        run_git(&wt, &["push", "-q", "-u", "origin", "HEAD"]);
2352        fs::write(wt.join("ahead.txt"), "local only").expect("write ahead");
2353        run_git(&wt, &["add", "."]);
2354        run_git(&wt, &["commit", "-q", "-m", "ahead of upstream"]);
2355        let reason = worktree_dirty_reason(&wt).expect("unpushed commit reports dirty");
2356        assert!(reason.contains("unpushed"), "reason names unpushed commits: {reason:?}");
2357    }
2358
2359    /// The measured wind-down (#2486): a worker branch cut from main
2360    /// tracks `origin/main`, its head is pushed to a remote branch, and
2361    /// the content landed on main as a squash commit. The upstream
2362    /// comparison reads ahead forever because the squash has a different
2363    /// sha, and refused a clean despawn.
2364    ///
2365    /// The head goes to a second remote, `upstream`: reachability is
2366    /// from ANY remote ref, not from `origin` alone.
2367    #[test]
2368    fn a_squash_merged_branch_head_reports_clean() {
2369        let (dir, wt) = init_repo_with_worktree();
2370        let remote = tempdir().expect("remote tempdir");
2371        run_git(remote.path(), &["init", "-q", "--bare"]);
2372        run_git(
2373            dir.path(),
2374            &["remote", "add", "origin", remote.path().to_str().expect("utf8 path")],
2375        );
2376        let fork = tempdir().expect("fork tempdir");
2377        run_git(fork.path(), &["init", "-q", "--bare"]);
2378        run_git(
2379            dir.path(),
2380            &["remote", "add", "upstream", fork.path().to_str().expect("utf8 path")],
2381        );
2382        let main = git_stdout(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]);
2383        run_git(dir.path(), &["push", "-q", "-u", "origin", "HEAD"]);
2384
2385        // The worker branch: cut from main, tracking it, head pushed to
2386        // its own remote ref the way the PR's branch is.
2387        run_git(&wt, &["branch", "--set-upstream-to", &format!("origin/{main}")]);
2388        fs::write(wt.join("work.txt"), "the PR's content").expect("write work");
2389        run_git(&wt, &["add", "."]);
2390        run_git(&wt, &["commit", "-q", "-m", "the PR's commit"]);
2391        run_git(&wt, &["push", "-q", "upstream", "HEAD:refs/heads/wt"]);
2392
2393        // The squash onto main: same content, a new sha.
2394        run_git(dir.path(), &["merge", "-q", "--squash", "wt"]);
2395        run_git(dir.path(), &["commit", "-q", "-m", "squash-merge wt"]);
2396        run_git(dir.path(), &["push", "-q", "origin", "HEAD"]);
2397
2398        assert_eq!(
2399            git_stdout(&wt, &["rev-list", "--count", "@{upstream}..HEAD"]),
2400            "1",
2401            "the fixture reproduces the trap: the upstream comparison still reads ahead",
2402        );
2403        let reaching = git_stdout(&wt, &["branch", "-r", "--contains", "HEAD"]);
2404        assert!(
2405            reaching.contains("upstream/wt"),
2406            "the head is on a remote ref, and not origin's: {reaching:?}",
2407        );
2408        assert!(!reaching.contains("origin/wt"), "origin alone cannot see it: {reaching:?}");
2409        assert_eq!(worktree_dirty_reason(&wt), None);
2410    }
2411
2412    /// The landing after the remote branch is gone: the worker pushed
2413    /// with `-u`, the PR squash-merged three commits (one of them
2414    /// empty), the head branch was deleted on the remote and a prune
2415    /// dropped its tracking ref. No remote ref reaches HEAD and no
2416    /// patch-id matches a multi-commit squash, so only the deleted
2417    /// upstream says the branch landed.
2418    #[test]
2419    fn a_squash_merged_branch_survives_its_remote_branch_being_pruned() {
2420        let (dir, wt) = init_repo_with_worktree();
2421        let remote = tempdir().expect("remote tempdir");
2422        run_git(remote.path(), &["init", "-q", "--bare"]);
2423        run_git(
2424            dir.path(),
2425            &["remote", "add", "origin", remote.path().to_str().expect("utf8 path")],
2426        );
2427        run_git(dir.path(), &["push", "-q", "-u", "origin", "HEAD"]);
2428
2429        // The worker pushes with `-u`, so its upstream is its own branch.
2430        fs::write(wt.join("one.txt"), "first").expect("write one");
2431        run_git(&wt, &["add", "."]);
2432        run_git(&wt, &["commit", "-q", "-m", "first"]);
2433        fs::write(wt.join("two.txt"), "second").expect("write two");
2434        run_git(&wt, &["add", "."]);
2435        run_git(&wt, &["commit", "-q", "-m", "second"]);
2436        run_git(&wt, &["commit", "-q", "--allow-empty", "-m", "empty"]);
2437        run_git(&wt, &["push", "-q", "-u", "origin", "HEAD:refs/heads/wt"]);
2438
2439        run_git(dir.path(), &["merge", "-q", "--squash", "wt"]);
2440        run_git(dir.path(), &["commit", "-q", "-m", "squash-merge wt"]);
2441        run_git(dir.path(), &["push", "-q", "origin", "HEAD"]);
2442
2443        run_git(dir.path(), &["push", "-q", "origin", "--delete", "wt"]);
2444        run_git(dir.path(), &["fetch", "-q", "--prune", "origin"]);
2445
2446        assert_eq!(
2447            git_stdout(&wt, &["rev-list", "--count", "HEAD", "--not", "--remotes"]),
2448            "3",
2449            "no remote ref reaches the branch's commits any more",
2450        );
2451        assert_eq!(worktree_dirty_reason(&wt), None);
2452    }
2453
2454    /// Reachability is from ANY remote ref. The head sits on a fork
2455    /// remote (`upstream`) and nothing has merged it yet, so only a
2456    /// query that asks about every remote sees it as pushed.
2457    #[test]
2458    fn a_head_pushed_to_any_remote_counts_as_pushed() {
2459        let (dir, wt) = init_repo_with_worktree();
2460        let remote = tempdir().expect("remote tempdir");
2461        run_git(remote.path(), &["init", "-q", "--bare"]);
2462        run_git(
2463            dir.path(),
2464            &["remote", "add", "origin", remote.path().to_str().expect("utf8 path")],
2465        );
2466        let fork = tempdir().expect("fork tempdir");
2467        run_git(fork.path(), &["init", "-q", "--bare"]);
2468        run_git(
2469            dir.path(),
2470            &["remote", "add", "upstream", fork.path().to_str().expect("utf8 path")],
2471        );
2472        run_git(dir.path(), &["push", "-q", "-u", "origin", "HEAD"]);
2473
2474        fs::write(wt.join("work.txt"), "pushed to the fork").expect("write work");
2475        run_git(&wt, &["add", "."]);
2476        run_git(&wt, &["commit", "-q", "-m", "on the fork only"]);
2477        run_git(&wt, &["push", "-q", "upstream", "HEAD:refs/heads/wt"]);
2478
2479        assert!(
2480            !git_stdout(&wt, &["branch", "-r", "--contains", "HEAD"]).contains("origin/"),
2481            "nothing on origin reaches the head",
2482        );
2483        assert_eq!(worktree_dirty_reason(&wt), None);
2484    }
2485
2486    /// The other side: a commit no remote ref reaches is unpushed however
2487    /// the branch is configured. A branch with no upstream used to be
2488    /// skipped entirely, so the close went through without naming it.
2489    #[test]
2490    fn a_commit_no_remote_ref_reaches_reports_dirty() {
2491        let (dir, wt) = init_repo_with_worktree();
2492        let remote = tempdir().expect("remote tempdir");
2493        run_git(remote.path(), &["init", "-q", "--bare"]);
2494        run_git(
2495            dir.path(),
2496            &["remote", "add", "origin", remote.path().to_str().expect("utf8 path")],
2497        );
2498        run_git(dir.path(), &["push", "-q", "-u", "origin", "HEAD"]);
2499
2500        // No upstream on the worker branch, and the commit is pushed
2501        // nowhere: reachability from a remote ref is the whole question.
2502        fs::write(wt.join("work.txt"), "local only").expect("write work");
2503        run_git(&wt, &["add", "."]);
2504        run_git(&wt, &["commit", "-q", "-m", "never pushed"]);
2505
2506        assert_eq!(
2507            worktree_dirty_reason(&wt),
2508            Some("1 unpushed commit".to_owned()),
2509            "the named reason counts the commits no remote ref reaches",
2510        );
2511    }
2512
2513    #[test]
2514    fn worktree_branch_reports_checked_out_branch() {
2515        let (dir, wt) = init_repo_with_worktree();
2516        // The main worktree is on its init branch; the added worktree
2517        // gets its own detached-or-named branch. Name one explicitly.
2518        run_git(&wt, &["checkout", "-q", "-b", "review-loop/feat"]);
2519        assert_eq!(worktree_branch(&wt).as_deref(), Some("review-loop/feat"));
2520        assert!(worktree_branch(dir.path()).is_some(), "main worktree also reports a branch");
2521    }
2522
2523    #[test]
2524    fn worktree_branch_none_for_non_repo() {
2525        let dir = tempdir().expect("tempdir");
2526        assert!(worktree_branch(dir.path()).is_none());
2527    }
2528
2529    #[test]
2530    fn remove_worktree_removes_clean() {
2531        let (_dir, wt) = init_repo_with_worktree();
2532        assert!(wt.exists());
2533        remove_worktree(&wt, false).expect("clean worktree removes");
2534        assert!(!wt.exists(), "worktree dir gone after remove");
2535    }
2536
2537    #[test]
2538    fn remove_worktree_removes_locked_clean() {
2539        let (dir, wt) = init_repo_with_worktree();
2540        run_git(dir.path(), &["worktree", "lock", wt.to_str().expect("utf8 path")]);
2541        assert!(wt.exists());
2542        remove_worktree(&wt, false).expect("locked clean worktree removes");
2543        assert!(!wt.exists(), "locked worktree dir gone after remove");
2544    }
2545
2546    #[test]
2547    fn remove_worktree_errors_on_dirty_without_force() {
2548        let (_dir, wt) = init_repo_with_worktree();
2549        fs::write(wt.join("scratch.txt"), "uncommitted").expect("write scratch");
2550        run_git(&wt, &["add", "."]);
2551        assert!(remove_worktree(&wt, false).is_err(), "dirty worktree blocks removal");
2552        assert!(wt.exists(), "worktree survives a blocked removal");
2553    }
2554
2555    #[test]
2556    fn remove_worktree_force_removes_dirty() {
2557        let (_dir, wt) = init_repo_with_worktree();
2558        fs::write(wt.join("scratch.txt"), "uncommitted").expect("write scratch");
2559        run_git(&wt, &["add", "."]);
2560        remove_worktree(&wt, true).expect("force removes a dirty worktree");
2561        assert!(!wt.exists(), "worktree dir gone after force remove");
2562    }
2563
2564    /// The leftover state a failed delete leaves: the path deregistered,
2565    /// the directory still on disk with the build tree in it. `git
2566    /// worktree remove` refuses that state for good, and the fallback
2567    /// finishes the job on the one path it was handed.
2568    #[test]
2569    fn orphan_removal_removes_a_deregistered_leftover_directory() {
2570        let (dir, wt, _branch) = init_repo_with_worker_worktree("leftover");
2571        fs::create_dir_all(wt.join("target").join("debug")).expect("target dir");
2572        fs::write(wt.join("target").join("debug").join("obj.o"), "x").expect("object file");
2573        // The despawn records the admitted removal before git runs.
2574        record_worktree_state(dir.path(), "leftover", &wt, WorktreeRecord::Removing);
2575        fs::remove_dir_all(dir.path().join(".git").join("worktrees").join("leftover"))
2576            .expect("deregister the worktree");
2577        // Fixture precondition, asserted rather than assumed so a git
2578        // that stops refusing fails here instead of passing without the
2579        // fallback.
2580        assert!(
2581            remove_worktree(&wt, true).is_err(),
2582            "fixture precondition: git refuses a deregistered worktree"
2583        );
2584        assert!(wt.exists(), "and the refusal leaves the directory on disk");
2585
2586        remove_orphaned_worktree_dir(dir.path(), &wt).expect("the leftover directory removes");
2587
2588        assert!(!wt.exists(), "the leftover directory is gone");
2589    }
2590
2591    /// A registered worktree is not a leftover: git may be holding
2592    /// uncommitted work in it, and `git worktree remove` (with its own
2593    /// clean check) stays the only path allowed to delete one.
2594    #[test]
2595    fn orphan_removal_refuses_a_registered_worktree() {
2596        let (dir, wt, _branch) = init_repo_with_worker_worktree("registered");
2597
2598        let err = remove_orphaned_worktree_dir(dir.path(), &wt).expect_err("refused");
2599
2600        assert!(
2601            matches!(err, WorktreeError::StillRegistered(_)),
2602            "the refusal names the registration: {err:?}"
2603        );
2604        assert!(wt.exists(), "the registered worktree is untouched");
2605        assert!(worktree_registered(dir.path(), &wt), "and git still lists it");
2606    }
2607
2608    /// Anything that is not a direct child of the worktrees root is
2609    /// refused rather than guessed at - the main checkout above all.
2610    #[test]
2611    fn orphan_removal_refuses_paths_outside_the_worktrees_root() {
2612        let (dir, wt, _branch) = init_repo_with_worker_worktree("elsewhere");
2613        fs::create_dir_all(dir.path().join("some-dir")).expect("unrelated dir");
2614        let root = wt.parent().expect("worktrees root").to_path_buf();
2615        let targets =
2616            [dir.path().to_path_buf(), root.clone(), root.join(".."), dir.path().join("some-dir")];
2617        for target in targets {
2618            let err = remove_orphaned_worktree_dir(dir.path(), &target).expect_err("refused");
2619            assert!(
2620                matches!(err, WorktreeError::OutsideWorktreesRoot { .. }),
2621                "the refusal is about the path, not the caller: {err:?} for {target:?}"
2622            );
2623        }
2624        assert!(dir.path().join("some-dir").is_dir(), "nothing outside the root was touched");
2625        assert!(root.is_dir(), "the worktrees root stands");
2626        assert!(wt.exists(), "the worktree it does hold stands");
2627    }
2628
2629    /// A symlink or a plain file where the directory should be is
2630    /// refused, not followed or deleted.
2631    #[test]
2632    fn orphan_removal_refuses_a_non_directory() {
2633        let (dir, wt, _branch) = init_repo_with_worker_worktree("shape");
2634        std::fs::remove_dir_all(dir.path().join(".git").join("worktrees").join("shape"))
2635            .expect("deregister the worktree");
2636        std::fs::remove_dir_all(&wt).expect("clear the worktree dir");
2637        let elsewhere = dir.path().join("precious");
2638        fs::create_dir_all(&elsewhere).expect("precious dir");
2639        fs::write(elsewhere.join("keep.txt"), "keep").expect("keep file");
2640        std::os::unix::fs::symlink(&elsewhere, &wt).expect("symlink at the worktree path");
2641
2642        let err = remove_orphaned_worktree_dir(dir.path(), &wt).expect_err("refused");
2643
2644        assert!(
2645            matches!(err, WorktreeError::NotAPlainDirectory(_)),
2646            "a symlink is not a directory to delete: {err:?}"
2647        );
2648        assert!(
2649            elsewhere.join("keep.txt").exists(),
2650            "the symlink's target was not deleted through the link"
2651        );
2652
2653        fs::remove_file(&wt).expect("clear the symlink");
2654        fs::write(&wt, "not a directory").expect("file at the path");
2655        let err = remove_orphaned_worktree_dir(dir.path(), &wt).expect_err("refused");
2656        assert!(
2657            matches!(err, WorktreeError::NotAPlainDirectory(_)),
2658            "a file is not a directory to delete: {err:?}"
2659        );
2660        assert!(wt.is_file(), "the file was not deleted");
2661    }
2662
2663    /// A `.git` DIRECTORY at the path is a checkout of its own, never the
2664    /// tail of a worktree removal - a worktree's link is a file - so the
2665    /// clear must refuse it rather than delete someone's repository.
2666    #[test]
2667    fn orphan_removal_refuses_a_checkout_of_its_own() {
2668        let dir = init_repo_with_commit();
2669        let wt = dir.path().join(".claude").join("worktrees").join("lbl");
2670        fs::create_dir_all(wt.join(".git")).expect("a .git directory");
2671        fs::write(wt.join("real-work.txt"), "someone's repository").expect("write");
2672
2673        let err =
2674            remove_orphaned_worktree_dir(dir.path(), &wt).expect_err("a foreign checkout refuses");
2675
2676        assert!(matches!(err, WorktreeError::ForeignLeftover { .. }), "{err:?}");
2677        assert!(wt.join("real-work.txt").exists(), "nothing was deleted");
2678    }
2679
2680    /// A `.git` link naming ANOTHER repo's admin dir is someone else's
2681    /// worktree parked at our path: not this repo's leftover, so not this
2682    /// repo's to delete. The comparison must resolve both spellings (a
2683    /// tempdir root is a symlink on macOS), which the same-repo case in
2684    /// `orphan_removal_removes_a_deregistered_leftover_directory` pins
2685    /// from the other side.
2686    #[test]
2687    fn orphan_removal_refuses_another_repos_worktree_link() {
2688        let dir = init_repo_with_commit();
2689        let other = init_repo_with_commit();
2690        let wt = dir.path().join(".claude").join("worktrees").join("lbl");
2691        fs::create_dir_all(&wt).expect("mkdir");
2692        fs::write(
2693            wt.join(".git"),
2694            format!(
2695                "gitdir: {}\n",
2696                other.path().join(".git").join("worktrees").join("lbl").display()
2697            ),
2698        )
2699        .expect("write the foreign link");
2700
2701        let err =
2702            remove_orphaned_worktree_dir(dir.path(), &wt).expect_err("a foreign link refuses");
2703
2704        assert!(matches!(err, WorktreeError::ForeignLeftover { .. }), "{err:?}");
2705        assert!(wt.exists(), "the directory was left alone");
2706    }
2707
2708    /// A repo moved or renamed after a failed despawn keeps its leftover's
2709    /// `.git` link pointing at the path the repo left. The removal record
2710    /// travels with the repo's `.git`, so it is what proves the leftover
2711    /// is ours - otherwise the resumed worker dies on git's already-exists,
2712    /// the #1767 symptom, for the moved-repo edge (#2344).
2713    #[test]
2714    fn orphan_removal_clears_its_leftover_after_the_repo_moved() {
2715        let parent = tempdir().expect("parent");
2716        let before = parent.path().join("before");
2717        fs::create_dir_all(&before).expect("mkdir");
2718        init_repo_at(&before);
2719        add_worker_worktree(&before, "lbl");
2720        let wt = before.join(".claude").join("worktrees").join("lbl");
2721        record_worktree_owned(&before, "lbl", &wt);
2722        // The despawn records the admitted removal before git runs.
2723        record_worktree_state(&before, "lbl", &wt, WorktreeRecord::Removing);
2724        deregister_worktree(&before, "lbl");
2725        let after = parent.path().join("after");
2726        fs::rename(&before, &after).expect("move the repo");
2727        let wt = after.join(".claude").join("worktrees").join("lbl");
2728        let link = fs::read_to_string(wt.join(".git")).expect("leftover link");
2729        let named = link.trim().strip_prefix("gitdir:").expect("a gitdir link").trim();
2730        assert!(
2731            !Path::new(named).exists(),
2732            "fixture precondition: the link names the path the repo left behind: {named}",
2733        );
2734        assert_eq!(
2735            recorded_worktree(&after, &wt).map(|record| record.state),
2736            Some(WorktreeRecord::Removing),
2737            "the removal record moved with the repo",
2738        );
2739
2740        remove_orphaned_worktree_dir(&after, &wt).expect("its own leftover clears after a move");
2741
2742        assert!(!wt.exists(), "the leftover directory is gone");
2743        assert_eq!(
2744            recorded_worktree(&after, &wt).map(|record| record.state),
2745            Some(WorktreeRecord::Cleared),
2746            "the clear leaves no authorization behind for the next directory at this label",
2747        );
2748    }
2749
2750    /// Our own worktree after a repo move whose registration was pruned -
2751    /// `git worktree prune` is normal cleanup - leaves the same stale link
2752    /// a failed despawn does, with uncommitted files still inside. No
2753    /// removal was admitted, so the clear must refuse and keep the
2754    /// directory rather than delete it before the add (#2344).
2755    #[test]
2756    fn orphan_removal_refuses_a_moved_worktree_without_a_removal_record() {
2757        let parent = tempdir().expect("parent");
2758        let before = parent.path().join("before");
2759        fs::create_dir_all(&before).expect("mkdir");
2760        init_repo_at(&before);
2761        add_worker_worktree(&before, "lbl");
2762        let after = parent.path().join("after");
2763        fs::rename(&before, &after).expect("move the repo");
2764        let wt = after.join(".claude").join("worktrees").join("lbl");
2765        // The pruned registration: git no longer lists the worktree, and
2766        // the directory keeps the file nobody committed.
2767        deregister_worktree(&after, "lbl");
2768        fs::write(wt.join("uncommitted.txt"), "work in progress").expect("write work");
2769
2770        let err = remove_orphaned_worktree_dir(&after, &wt)
2771            .expect_err("a live worktree with no admitted removal refuses");
2772
2773        assert!(matches!(err, WorktreeError::ForeignLeftover { .. }), "{err:?}");
2774        assert!(wt.join("uncommitted.txt").exists(), "the uncommitted work was not deleted");
2775    }
2776
2777    /// A foreign repo's worktree parked under our root whose repo was
2778    /// moved or deleted produces the same stale link shape as our own
2779    /// moved leftover. Its label is not in this repo's removal record, so
2780    /// it is refused no matter what the link says (#2344).
2781    #[test]
2782    fn orphan_removal_refuses_a_moved_foreign_worktree_link() {
2783        let dir = init_repo_with_commit();
2784        let foreign_parent = tempdir().expect("foreign parent");
2785        let foreign = foreign_parent.path().join("foreign");
2786        fs::create_dir_all(&foreign).expect("mkdir");
2787        init_repo_at(&foreign);
2788        let wt = dir.path().join(".claude").join("worktrees").join("lbl");
2789        fs::create_dir_all(&wt).expect("mkdir");
2790        fs::write(
2791            wt.join(".git"),
2792            format!("gitdir: {}\n", foreign.join(".git").join("worktrees").join("lbl").display()),
2793        )
2794        .expect("write the foreign link");
2795        fs::rename(&foreign, foreign_parent.path().join("gone")).expect("move the foreign repo");
2796
2797        let err = remove_orphaned_worktree_dir(dir.path(), &wt)
2798            .expect_err("a moved foreign link refuses");
2799
2800        assert!(matches!(err, WorktreeError::ForeignLeftover { .. }), "{err:?}");
2801        assert!(wt.exists(), "the directory was left alone");
2802    }
2803
2804    /// A stale link naming a DIFFERENT worktree than the directory it sits
2805    /// in is not this directory's removal tail. With no record for this
2806    /// label, it refuses rather than trusting the path shape (#2344).
2807    #[test]
2808    fn orphan_removal_refuses_a_stale_link_naming_another_worktree() {
2809        let dir = init_repo_with_commit();
2810        let foreign_parent = tempdir().expect("foreign parent");
2811        let foreign = foreign_parent.path().join("foreign");
2812        fs::create_dir_all(&foreign).expect("mkdir");
2813        init_repo_at(&foreign);
2814        let wt = dir.path().join(".claude").join("worktrees").join("lbl");
2815        fs::create_dir_all(&wt).expect("mkdir");
2816        fs::write(
2817            wt.join(".git"),
2818            format!("gitdir: {}\n", foreign.join(".git").join("worktrees").join("other").display()),
2819        )
2820        .expect("write the foreign link");
2821        fs::rename(&foreign, foreign_parent.path().join("gone")).expect("move the foreign repo");
2822
2823        let err = remove_orphaned_worktree_dir(dir.path(), &wt)
2824            .expect_err("a stale link to another worktree refuses");
2825
2826        assert!(matches!(err, WorktreeError::ForeignLeftover { .. }), "{err:?}");
2827        assert!(wt.exists(), "the directory was left alone");
2828    }
2829
2830    /// Only an admitted removal authorizes the clear. A live `owned`
2831    /// record and a spent `cleared` one are both the latest state for the
2832    /// label and both must leave the directory in place; a `.is_some()`
2833    /// predicate would delete a live worktree (#2344).
2834    #[test]
2835    fn orphan_removal_refuses_a_moved_worktree_recorded_owned_or_cleared() {
2836        for state in [WorktreeRecord::Owned, WorktreeRecord::Cleared] {
2837            let parent = tempdir().expect("parent");
2838            let before = parent.path().join("before");
2839            fs::create_dir_all(&before).expect("mkdir");
2840            init_repo_at(&before);
2841            add_worker_worktree(&before, "lbl");
2842            let wt = before.join(".claude").join("worktrees").join("lbl");
2843            record_worktree_state(&before, "lbl", &wt, state);
2844            deregister_worktree(&before, "lbl");
2845            let after = parent.path().join("after");
2846            fs::rename(&before, &after).expect("move the repo");
2847            let wt = after.join(".claude").join("worktrees").join("lbl");
2848            fs::write(wt.join("uncommitted.txt"), "work in progress").expect("write work");
2849
2850            let err = remove_orphaned_worktree_dir(&after, &wt)
2851                .expect_err("only an admitted removal authorizes the clear");
2852
2853            assert!(matches!(err, WorktreeError::ForeignLeftover { .. }), "{state:?}: {err:?}");
2854            assert!(wt.exists(), "{state:?}: the directory was left alone");
2855        }
2856    }
2857
2858    /// The record parser scopes by label: an admission for `a` must not
2859    /// authorize deleting a leftover named `b` (#2344).
2860    #[test]
2861    fn orphan_removal_refuses_a_leftover_whose_label_has_no_admission() {
2862        let parent = tempdir().expect("parent");
2863        let before = parent.path().join("before");
2864        fs::create_dir_all(&before).expect("mkdir");
2865        init_repo_at(&before);
2866        add_worker_worktree(&before, "b");
2867        record_worktree_state(
2868            &before,
2869            "a",
2870            &before.join(".claude").join("worktrees").join("a"),
2871            WorktreeRecord::Removing,
2872        );
2873        deregister_worktree(&before, "b");
2874        let after = parent.path().join("after");
2875        fs::rename(&before, &after).expect("move the repo");
2876        let wt = after.join(".claude").join("worktrees").join("b");
2877
2878        let err = remove_orphaned_worktree_dir(&after, &wt)
2879            .expect_err("another label's admission does not authorize the clear");
2880
2881        assert!(matches!(err, WorktreeError::ForeignLeftover { .. }), "{err:?}");
2882        assert!(wt.exists(), "the directory was left alone");
2883    }
2884
2885    /// Two checkouts can share a git common dir (a project that is itself
2886    /// a linked worktree keeps its git dir in the main repo), so the same
2887    /// record file holds both projects' labels. A record whose path still
2888    /// exists speaks only for the checkout it names; another checkout's
2889    /// admission must not authorize this one's clear, and its later
2890    /// `cleared` must not retract this one's live admission (#2344).
2891    #[test]
2892    fn another_checkouts_record_does_not_speak_for_this_one() {
2893        let dir = init_repo_with_commit();
2894        let left = dir.path().join("left");
2895        let right = dir.path().join("right");
2896        run_git(
2897            dir.path(),
2898            &["worktree", "add", "-q", "-b", "left", left.to_str().expect("utf8 path")],
2899        );
2900        run_git(
2901            dir.path(),
2902            &["worktree", "add", "-q", "-b", "right", right.to_str().expect("utf8 path")],
2903        );
2904        let left_wt = left.join(".claude").join("worktrees").join("w");
2905        run_git(
2906            &left,
2907            &["worktree", "add", "-q", "-b", "worktree-w", left_wt.to_str().expect("utf8 path")],
2908        );
2909        record_worktree_state(&left, "w", &left_wt, WorktreeRecord::Removing);
2910        // The right checkout's leftover carries the same label and no
2911        // record of its own; the left checkout's admission names a path
2912        // that still exists and is not this one.
2913        let right_wt = right.join(".claude").join("worktrees").join("w");
2914        fs::create_dir_all(&right_wt).expect("mkdir");
2915        fs::write(right_wt.join("uncommitted.txt"), "right's work").expect("write");
2916
2917        let err = remove_orphaned_worktree_dir(&right, &right_wt)
2918            .expect_err("the left checkout's admission does not speak for the right");
2919
2920        assert!(matches!(err, WorktreeError::ForeignLeftover { .. }), "{err:?}");
2921        assert!(right_wt.join("uncommitted.txt").exists(), "right's work was not deleted");
2922
2923        // A spent record from the right checkout, its path now gone, does
2924        // not retract the left's still-standing admission: a matching
2925        // live path is preferred over a stale one.
2926        record_worktree_state(&right, "w", &right_wt.join("gone"), WorktreeRecord::Cleared);
2927        assert_eq!(
2928            recorded_worktree(&left, &left_wt).map(|record| record.state),
2929            Some(WorktreeRecord::Removing),
2930            "the left checkout's live admission still speaks for its own worktree",
2931        );
2932
2933        // A stale-path record from the left checkout - its path gone
2934        // while its checkout root is alive - is still not the right
2935        // checkout's.
2936        record_worktree_state(&left, "w", &left.join("gone-wt"), WorktreeRecord::Removing);
2937        let err = remove_orphaned_worktree_dir(&right, &right_wt)
2938            .expect_err("a stale record from a live other checkout does not speak for the right");
2939        assert!(matches!(err, WorktreeError::ForeignLeftover { .. }), "{err:?}");
2940        assert!(right_wt.join("uncommitted.txt").exists(), "right's work was not deleted");
2941    }
2942
2943    /// The whole mechanism end to end: `remove_owned_worktree` records the
2944    /// admission before git runs, a removal that dies part-way leaves the
2945    /// directory and the record, and after a repo move `ensure` clears the
2946    /// leftover on that record and recreates the worktree.
2947    #[test]
2948    fn remove_owned_worktree_leaves_the_record_a_failed_removal_needs() {
2949        use std::os::unix::fs::PermissionsExt;
2950
2951        let parent = tempdir().expect("parent");
2952        let before = parent.path().join("before");
2953        fs::create_dir_all(&before).expect("mkdir");
2954        init_repo_at(&before);
2955        let branch = add_worker_worktree(&before, "lbl");
2956        let wt = before.join(".claude").join("worktrees").join("lbl");
2957        let blocked = wt.join("blocked");
2958        fs::create_dir_all(&blocked).expect("blocked dir");
2959        fs::write(blocked.join("file"), "x").expect("file");
2960        fs::set_permissions(&blocked, fs::Permissions::from_mode(0o555)).expect("chmod");
2961        let err = remove_owned_worktree(&before, "lbl", &wt, true)
2962            .expect_err("the removal dies on the unwritable directory");
2963        assert!(wt.exists(), "the directory survives the failed removal: {err:?}");
2964        assert_eq!(
2965            recorded_worktree(&before, &wt).map(|record| record.state),
2966            Some(WorktreeRecord::Removing),
2967            "the admission stands after the removal died",
2968        );
2969
2970        fs::set_permissions(&blocked, fs::Permissions::from_mode(0o755)).expect("chmod back");
2971        let after = parent.path().join("after");
2972        fs::rename(&before, &after).expect("move the repo");
2973        let wt = after.join(".claude").join("worktrees").join("lbl");
2974
2975        let outcome = ensure_worker_worktree(&after, "lbl", &wt)
2976            .expect("the moved leftover clears on the record");
2977
2978        assert_eq!(outcome, WorktreeEnsure::AttachedExistingBranch, "the branch is reattached");
2979        assert_eq!(worktree_branch(&wt).as_deref(), Some(branch.as_str()));
2980        assert!(!wt.join("blocked").exists(), "the leftover's dead tree is gone");
2981    }
2982
2983    /// A completed removal clears its record, so nothing is left behind to
2984    /// authorize a later clear.
2985    #[test]
2986    fn a_completed_removal_clears_the_record() {
2987        let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
2988
2989        remove_owned_worktree(dir.path(), "lbl", &wt, true).expect("the worktree removes");
2990
2991        assert_eq!(
2992            recorded_worktree(dir.path(), &wt).map(|record| record.state),
2993            Some(WorktreeRecord::Cleared),
2994            "a completed removal leaves nothing to clear",
2995        );
2996    }
2997
2998    /// A forced removal of a path that is already gone admits nothing. If
2999    /// it did, the admission would outlive its directory and later
3000    /// authorize deleting a new worktree that reused the label after a
3001    /// repo move (#2344).
3002    #[test]
3003    fn a_forced_removal_of_a_gone_worktree_admits_nothing() {
3004        let parent = tempdir().expect("parent");
3005        let before = parent.path().join("before");
3006        fs::create_dir_all(&before).expect("mkdir");
3007        init_repo_at(&before);
3008        let branch = add_worker_worktree(&before, "lbl");
3009        let wt = before.join(".claude").join("worktrees").join("lbl");
3010        // The live worker's directory is gone before the forced despawn,
3011        // and the removal itself fails - git cannot even be spawned from
3012        // the missing parent - so only the admission record can outlive
3013        // the call.
3014        fs::remove_dir_all(wt.parent().expect("worktrees root")).expect("the worktree is gone");
3015        remove_owned_worktree(&before, "lbl", &wt, true)
3016            .expect_err("git cannot run from a missing parent");
3017        assert_eq!(
3018            recorded_worktree(&before, &wt).map(|record| record.state),
3019            Some(WorktreeRecord::Cleared),
3020            "a gone path admits nothing",
3021        );
3022        // The label is reused: any stale registration is pruned, a new
3023        // worktree takes the path, and it holds a file nobody committed.
3024        if before.join(".git").join("worktrees").join("lbl").exists() {
3025            deregister_worktree(&before, "lbl");
3026        }
3027        run_git(&before, &["worktree", "add", "-q", wt.to_str().expect("utf8 path"), &branch]);
3028        fs::write(wt.join("uncommitted.txt"), "new worker's work").expect("write work");
3029        deregister_worktree(&before, "lbl");
3030        let after = parent.path().join("after");
3031        fs::rename(&before, &after).expect("move the repo");
3032        let wt = after.join(".claude").join("worktrees").join("lbl");
3033
3034        let err = ensure_worker_worktree(&after, "lbl", &wt)
3035            .expect_err("the spent admission does not authorize deleting the new worktree");
3036
3037        assert!(matches!(err, WorktreeError::ForeignLeftover { .. }), "{err:?}");
3038        assert!(wt.join("uncommitted.txt").exists(), "the new worker's work is intact");
3039    }
3040
3041    /// A directory with no `.git` is decided by the removal record: no
3042    /// admission refuses (with the directory intact), an admitted removal
3043    /// clears. An empty directory is the one exception - it holds nothing
3044    /// to lose and the fresh-spawn obstacle treats it as free.
3045    #[test]
3046    fn orphan_removal_decides_a_no_git_directory_by_its_record() {
3047        let dir = init_repo_with_commit();
3048        let wt = dir.path().join(".claude").join("worktrees").join("lbl");
3049        fs::create_dir_all(&wt).expect("mkdir");
3050        fs::write(wt.join("uncommitted.txt"), "work").expect("write");
3051
3052        let err = remove_orphaned_worktree_dir(dir.path(), &wt)
3053            .expect_err("no .git and no admission refuses");
3054        assert!(matches!(err, WorktreeError::ForeignLeftover { .. }), "{err:?}");
3055        assert!(wt.join("uncommitted.txt").exists(), "the directory was left alone");
3056
3057        record_worktree_state(dir.path(), "lbl", &wt, WorktreeRecord::Removing);
3058        remove_orphaned_worktree_dir(dir.path(), &wt).expect("an admitted removal clears it");
3059        assert!(!wt.exists(), "the leftover directory is gone");
3060    }
3061
3062    #[test]
3063    fn orphan_removal_clears_an_empty_directory_without_a_record() {
3064        let dir = init_repo_with_commit();
3065        let wt = dir.path().join(".claude").join("worktrees").join("lbl");
3066        fs::create_dir_all(&wt).expect("mkdir");
3067
3068        remove_orphaned_worktree_dir(dir.path(), &wt)
3069            .expect("an empty directory holds nothing to lose");
3070
3071        assert!(!wt.exists(), "the empty directory is gone");
3072    }
3073
3074    /// A stale registration whose directory carries a LIVE foreign link is
3075    /// not repaired: `git worktree repair` would rewrite the link and adopt
3076    /// the directory into this repo.
3077    #[test]
3078    fn ensure_does_not_repair_a_registration_over_a_foreign_link() {
3079        let other = init_repo_with_commit();
3080        let parent = tempdir().expect("parent");
3081        let before = parent.path().join("before");
3082        fs::create_dir_all(&before).expect("mkdir");
3083        init_repo_at(&before);
3084        add_worker_worktree(&before, "lbl");
3085        let wt = before.join(".claude").join("worktrees").join("lbl");
3086        let foreign = format!(
3087            "gitdir: {}\n",
3088            other.path().join(".git").join("worktrees").join("lbl").display()
3089        );
3090        fs::write(wt.join(".git"), &foreign).expect("write the foreign link");
3091        let after = parent.path().join("after");
3092        fs::rename(&before, &after).expect("move the repo");
3093        let wt = after.join(".claude").join("worktrees").join("lbl");
3094
3095        let err = ensure_worker_worktree(&after, "lbl", &wt)
3096            .expect_err("a repair over a live foreign link refuses");
3097
3098        assert!(matches!(err, WorktreeError::RegistrationUnusable { .. }), "{err:?}");
3099        assert_eq!(
3100            fs::read_to_string(wt.join(".git")).expect("the link"),
3101            foreign,
3102            "the foreign link was not rewritten",
3103        );
3104    }
3105
3106    /// A repair git refuses is a refusal, not `AlreadyPresent` over a
3107    /// git-dead directory.
3108    #[test]
3109    fn ensure_refuses_a_moved_registration_git_cannot_repair() {
3110        use std::os::unix::fs::PermissionsExt;
3111
3112        let parent = tempdir().expect("parent");
3113        let before = parent.path().join("before");
3114        fs::create_dir_all(&before).expect("mkdir");
3115        init_repo_at(&before);
3116        add_worker_worktree(&before, "lbl");
3117        let after = parent.path().join("after");
3118        fs::rename(&before, &after).expect("move the repo");
3119        let wt = after.join(".claude").join("worktrees").join("lbl");
3120        // The admin dir's `gitdir` file is what repair rewrites, so making
3121        // it read-only makes the repair itself fail.
3122        let gitdir = after.join(".git").join("worktrees").join("lbl").join("gitdir");
3123        fs::set_permissions(&gitdir, fs::Permissions::from_mode(0o444)).expect("chmod the gitdir");
3124
3125        let err = ensure_worker_worktree(&after, "lbl", &wt)
3126            .expect_err("a repair git refuses is a refusal");
3127
3128        assert!(matches!(err, WorktreeError::RegistrationUnusable { .. }), "{err:?}");
3129        fs::set_permissions(&gitdir, fs::Permissions::from_mode(0o644)).expect("chmod back");
3130    }
3131
3132    /// A removal that reported failure over a worktree which is still
3133    /// alive and registered must not authorize deleting it later. `ensure`
3134    /// seeing the live registration retracts the admission, so after a
3135    /// move and a prune the directory is refused rather than cleared on
3136    /// the old admission (#2344).
3137    #[test]
3138    fn ensure_retracts_a_stale_admission_when_the_worktree_is_alive() {
3139        let parent = tempdir().expect("parent");
3140        let before = parent.path().join("before");
3141        fs::create_dir_all(&before).expect("mkdir");
3142        init_repo_at(&before);
3143        add_worker_worktree(&before, "lbl");
3144        let wt = before.join(".claude").join("worktrees").join("lbl");
3145        // A despawn's removal was admitted, then reported failure over a
3146        // worktree git still lists.
3147        record_worktree_state(&before, "lbl", &wt, WorktreeRecord::Removing);
3148        ensure_worker_worktree(&before, "lbl", &wt).expect("the live worktree is reused");
3149        assert_eq!(
3150            recorded_worktree(&before, &wt).map(|record| record.state),
3151            Some(WorktreeRecord::Owned),
3152            "the live observation retracts the admission",
3153        );
3154        // Later: the repo moves, the registration is pruned, and the file
3155        // nobody committed is still there.
3156        let after = parent.path().join("after");
3157        fs::rename(&before, &after).expect("move the repo");
3158        let wt = after.join(".claude").join("worktrees").join("lbl");
3159        deregister_worktree(&after, "lbl");
3160        fs::write(wt.join("uncommitted.txt"), "work in progress").expect("write work");
3161
3162        let err = ensure_worker_worktree(&after, "lbl", &wt)
3163            .expect_err("the retracted admission does not authorize the clear");
3164
3165        assert!(matches!(err, WorktreeError::ForeignLeftover { .. }), "{err:?}");
3166        assert!(wt.exists(), "the directory was left alone");
3167    }
3168
3169    /// A leftover from an older forge carries no record at all. Its link
3170    /// names this repo's live git dir, which proves ownership, so it still
3171    /// clears as the guard always did (#2344 heals forward).
3172    #[test]
3173    fn orphan_removal_clears_a_leftover_with_a_live_link_and_no_record() {
3174        let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3175        deregister_worktree(dir.path(), "lbl");
3176
3177        remove_orphaned_worktree_dir(dir.path(), &wt).expect("a live link still proves ownership");
3178
3179        assert!(!wt.exists(), "the leftover directory is gone");
3180    }
3181
3182    /// A repo moved while a worktree registration survives: git lists the
3183    /// worktree by the old absolute path, so the registration is stale and
3184    /// every git command inside the worktree fails. `ensure` must repair
3185    /// the link before reusing it, not answer `AlreadyPresent` over a
3186    /// git-dead directory (#2344).
3187    #[test]
3188    fn ensure_reuses_a_moved_registered_worktree_after_repairing_it() {
3189        let parent = tempdir().expect("parent");
3190        let before = parent.path().join("before");
3191        fs::create_dir_all(&before).expect("mkdir");
3192        init_repo_at(&before);
3193        add_worker_worktree(&before, "lbl");
3194        let after = parent.path().join("after");
3195        fs::rename(&before, &after).expect("move the repo");
3196        let wt = after.join(".claude").join("worktrees").join("lbl");
3197        assert!(
3198            !worktree_runs_git(&wt),
3199            "fixture precondition: the moved worktree cannot run git before the repair",
3200        );
3201
3202        let outcome =
3203            ensure_worker_worktree(&after, "lbl", &wt).expect("the moved registration repairs");
3204
3205        assert_eq!(
3206            outcome,
3207            WorktreeEnsure::AlreadyPresent,
3208            "the worktree is reused, not recreated"
3209        );
3210        assert!(worktree_runs_git(&wt), "git commands work in the worktree after the repair");
3211        assert_eq!(
3212            worktree_registration(&after, &wt),
3213            Some(WorktreeRegistration::Live),
3214            "the listing now resolves at the new path",
3215        );
3216        assert_eq!(
3217            recorded_worktree(&after, &wt).map(|record| record.state),
3218            Some(WorktreeRecord::Owned),
3219            "the repaired worktree is recorded as this checkout's and not being removed",
3220        );
3221        assert!(wt.exists(), "the worktree stands");
3222    }
3223
3224    fn worktree_runs_git(wt: &Path) -> bool {
3225        std::process::Command::new("git")
3226            .args(["rev-parse", "--show-toplevel"])
3227            .current_dir(wt)
3228            .output()
3229            .is_ok_and(|out| out.status.success())
3230    }
3231
3232    fn copy_tree(from: &Path, to: &Path) {
3233        let status = std::process::Command::new("cp")
3234            .arg("-a")
3235            .arg(from)
3236            .arg(to)
3237            .status()
3238            .expect("spawn cp");
3239        assert!(status.success(), "cp -a {from:?} {to:?}");
3240    }
3241
3242    /// `cp -a` of a repo copies the worktree admin dirs too, and their
3243    /// `gitdir` files still resolve at the ORIGINAL repo's worktree. The
3244    /// copy must not read that as its own stale registration: the stale
3245    /// fallback runs only when the listed path no longer resolves.
3246    #[test]
3247    fn a_copied_repo_does_not_claim_the_original_worktree_registration() {
3248        let dir = init_repo_with_commit();
3249        add_worker_worktree(dir.path(), "lbl");
3250        let holder = tempdir().expect("holder");
3251        let clone = holder.path().join("clone");
3252        copy_tree(dir.path(), &clone);
3253        let clone_wt = clone.join(".claude").join("worktrees").join("lbl");
3254
3255        assert!(
3256            matches!(worktree_registration(&clone, &clone_wt), Some(WorktreeRegistration::Absent)),
3257            "the copied registration names the original worktree, which still resolves",
3258        );
3259        assert!(!worktree_registered(&clone, &clone_wt), "and is not this copy's registration");
3260    }
3261
3262    /// A project that is itself a linked worktree keeps its git dir in the
3263    /// main repo's common dir, and a worker worktree created inside it is
3264    /// registered there too. Ownership must be compared against
3265    /// `git rev-parse --git-common-dir`, not `<repo>/.git`, or such a
3266    /// project never matches its own worktree's link.
3267    #[test]
3268    fn orphan_removal_clears_a_leftover_in_a_project_that_is_a_linked_worktree() {
3269        let dir = init_repo_with_commit();
3270        let linked = dir.path().join("linked");
3271        run_git(
3272            dir.path(),
3273            &["worktree", "add", "-q", "-b", "linked", linked.to_str().expect("utf8 path")],
3274        );
3275        let wt = linked.join(".claude").join("worktrees").join("lbl");
3276        run_git(
3277            &linked,
3278            &["worktree", "add", "-q", "-b", "worktree-lbl", wt.to_str().expect("utf8 path")],
3279        );
3280        record_worktree_state(&linked, "lbl", &wt, WorktreeRecord::Removing);
3281        fs::remove_dir_all(dir.path().join(".git").join("worktrees").join("lbl"))
3282            .expect("deregister the worktree under the common dir");
3283
3284        remove_orphaned_worktree_dir(&linked, &wt)
3285            .expect("its own leftover clears under a linked worktree");
3286
3287        assert!(!wt.exists(), "the leftover directory is gone");
3288    }
3289
3290    fn branch_exists(repo: &Path, branch: &str) -> bool {
3291        let out = std::process::Command::new("git")
3292            .args(["rev-parse", "--verify", "--quiet", &format!("refs/heads/{branch}")])
3293            .current_dir(repo)
3294            .output()
3295            .expect("spawn git");
3296        match out.status.code() {
3297            Some(0) => true,
3298            Some(1) => false,
3299            other => panic!("git rev-parse in {repo:?} exited {other:?}, so absence is unproven"),
3300        }
3301    }
3302
3303    /// Init a repo with one commit plus a claude-shaped worker worktree:
3304    /// `worktree add -b worktree-<label>` at `.claude/worktrees/<label>`.
3305    /// The worktree is left in place; tests that reap remove it first,
3306    /// matching the despawn order.
3307    fn init_repo_with_worker_worktree(
3308        label: &str,
3309    ) -> (tempfile::TempDir, std::path::PathBuf, String) {
3310        let dir = init_repo_with_commit();
3311        let branch = add_worker_worktree(dir.path(), label);
3312        let wt = dir.path().join(".claude").join("worktrees").join(label);
3313        (dir, wt, branch)
3314    }
3315
3316    /// `worktree add -b worktree-<label>` off the current HEAD: the branch
3317    /// claude's `--worktree <label>` names, though not the base it would
3318    /// use. Returns the branch name.
3319    fn add_worker_worktree(repo: &Path, label: &str) -> String {
3320        let branch = format!("worktree-{label}");
3321        let wt = repo.join(".claude").join("worktrees").join(label);
3322        run_git(repo, &["worktree", "add", "-q", "-b", &branch, wt.to_str().expect("utf8 path")]);
3323        branch
3324    }
3325
3326    /// The gitdir a worktree's `.git` link names.
3327    fn worktree_gitdir(wt: &Path) -> PathBuf {
3328        let link = fs::read_to_string(wt.join(".git")).expect("the worktree's link");
3329        let gitdir = link.trim().strip_prefix("gitdir:").expect("a gitdir link").trim();
3330        PathBuf::from(gitdir)
3331    }
3332
3333    fn git_stdout(repo: &Path, args: &[&str]) -> String {
3334        let out = std::process::Command::new("git")
3335            .args(args)
3336            .current_dir(repo)
3337            .output()
3338            .expect("spawn git");
3339        assert!(out.status.success(), "git {args:?} failed in {repo:?}");
3340        String::from_utf8_lossy(&out.stdout).trim().to_owned()
3341    }
3342
3343    fn drop_worktree(repo: &Path, wt: &Path) {
3344        run_git(repo, &["worktree", "remove", "--force", wt.to_str().expect("utf8 path")]);
3345    }
3346
3347    /// The shape a despawn's failed removal leaves: the directory survives
3348    /// with no registration at all, because git deregisters a worktree
3349    /// before it walks the tree. `git worktree remove` cannot reach this
3350    /// state itself (it deletes the directory too), so the registration's
3351    /// admin dir is what the fixture drops.
3352    fn deregister_worktree(repo: &Path, name: &str) {
3353        fs::remove_dir_all(repo.join(".git").join("worktrees").join(name))
3354            .expect("drop the registration");
3355    }
3356
3357    #[test]
3358    fn reaps_a_pristine_worker_branch() {
3359        let (dir, wt, branch) = init_repo_with_worker_worktree("lbl");
3360        drop_worktree(dir.path(), &wt);
3361        assert_eq!(reap_worktree_branch(dir.path(), &branch), BranchReapOutcome::Reaped);
3362        assert!(!branch_exists(dir.path(), &branch), "the ref is actually gone");
3363    }
3364
3365    /// The reachability premise itself: a branch whose commits survive only
3366    /// on a remote-tracking ref is still reapable, because deleting the
3367    /// local ref strands nothing.
3368    #[test]
3369    fn reaps_a_branch_whose_commits_live_on_a_remote_ref() {
3370        let (dir, wt, branch) = init_repo_with_worker_worktree("lbl");
3371        let remote = tempdir().expect("remote tempdir");
3372        run_git(remote.path(), &["init", "-q", "--bare"]);
3373        run_git(&wt, &["remote", "add", "origin", remote.path().to_str().expect("utf8 path")]);
3374        fs::write(wt.join("work.txt"), "pushed work").expect("write work");
3375        run_git(&wt, &["add", "."]);
3376        run_git(&wt, &["commit", "-q", "-m", "real work"]);
3377        run_git(&wt, &["push", "-q", "-u", "origin", &branch]);
3378        drop_worktree(dir.path(), &wt);
3379
3380        assert_eq!(reap_worktree_branch(dir.path(), &branch), BranchReapOutcome::Reaped);
3381        assert!(!branch_exists(dir.path(), &branch), "the local ref goes, the commits do not");
3382    }
3383
3384    #[test]
3385    fn keeps_a_branch_holding_unique_commits() {
3386        let (dir, wt, branch) = init_repo_with_worker_worktree("lbl");
3387        fs::write(wt.join("work.txt"), "a worker committed here").expect("write work");
3388        run_git(&wt, &["add", "."]);
3389        run_git(&wt, &["commit", "-q", "-m", "real work"]);
3390        drop_worktree(dir.path(), &wt);
3391
3392        let outcome = reap_worktree_branch(dir.path(), &branch);
3393        let BranchReapOutcome::Kept { count, tip } = outcome else {
3394            panic!("a branch with its own commit is kept, got {outcome:?}");
3395        };
3396        assert_eq!(count, 1, "the one commit reachable from no other ref");
3397        assert_eq!(
3398            tip,
3399            git_stdout(dir.path(), &["rev-parse", "--short", &branch]),
3400            "the warning names the BRANCH tip, so 'git log <tip>' lands in the right history",
3401        );
3402        assert!(branch_exists(dir.path(), &branch), "the branch survives");
3403    }
3404
3405    /// The squash trap: the content landed on the default branch but the
3406    /// shape did not, so `git cherry` reports nothing unique here and a
3407    /// patch-id gate would delete it.
3408    #[test]
3409    fn keeps_a_squash_merged_branch() {
3410        let (dir, wt, branch) = init_repo_with_worker_worktree("lbl");
3411        fs::write(wt.join("work.txt"), "a worker committed here").expect("write work");
3412        run_git(&wt, &["add", "."]);
3413        run_git(&wt, &["commit", "-q", "-m", "real work"]);
3414        run_git(dir.path(), &["merge", "-q", "--squash", &branch]);
3415        run_git(dir.path(), &["commit", "-q", "-m", "squashed the worker's work"]);
3416        drop_worktree(dir.path(), &wt);
3417
3418        assert!(
3419            git_stdout(dir.path(), &["cherry", "HEAD", &branch]).starts_with("- "),
3420            "fixture precondition: the squash is patch-identical, so a patch-id gate reaps it",
3421        );
3422        assert!(
3423            matches!(reap_worktree_branch(dir.path(), &branch), BranchReapOutcome::Kept { .. }),
3424            "a squash-merged branch still holds its own commit object",
3425        );
3426        assert!(branch_exists(dir.path(), &branch));
3427    }
3428
3429    /// `git branch -d` refuses this exact shape - pristine branch, tip not
3430    /// an ancestor of HEAD - so the reap must not delegate to git's
3431    /// merged-ness inference. The precondition is asserted so the fixture
3432    /// cannot stop exercising the shape silently.
3433    #[test]
3434    fn reaps_a_pristine_branch_whose_tip_is_not_an_ancestor_of_head() {
3435        let dir = init_repo_with_commit();
3436        // The operator's own branch, parked at the first commit, then the
3437        // worker's worktree branches off a newer one.
3438        run_git(dir.path(), &["branch", "operator-work"]);
3439        fs::write(dir.path().join("README.md"), "advanced").expect("write advance");
3440        run_git(dir.path(), &["commit", "-qam", "advance"]);
3441        let branch = add_worker_worktree(dir.path(), "lbl");
3442        drop_worktree(dir.path(), &dir.path().join(".claude").join("worktrees").join("lbl"));
3443        run_git(dir.path(), &["checkout", "-q", "operator-work"]);
3444
3445        let not_ancestor = std::process::Command::new("git")
3446            .args(["merge-base", "--is-ancestor", &branch, "HEAD"])
3447            .current_dir(dir.path())
3448            .status()
3449            .expect("spawn git");
3450        assert!(!not_ancestor.success(), "fixture precondition: the tip is ahead of HEAD");
3451
3452        assert_eq!(reap_worktree_branch(dir.path(), &branch), BranchReapOutcome::Reaped);
3453        assert!(!branch_exists(dir.path(), &branch));
3454    }
3455
3456    #[test]
3457    fn missing_branch_is_a_noop() {
3458        let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3459        drop_worktree(dir.path(), &wt);
3460        assert_eq!(
3461            reap_worktree_branch(dir.path(), "worktree-never-existed"),
3462            BranchReapOutcome::NotPresent
3463        );
3464    }
3465
3466    #[test]
3467    fn ensure_worker_worktree_leaves_an_existing_worktree_alone() {
3468        let (dir, wt, branch) = init_repo_with_worker_worktree("lbl");
3469        let outcome = ensure_worker_worktree(dir.path(), "lbl", &wt).expect("no-op");
3470        assert_eq!(outcome, WorktreeEnsure::AlreadyPresent, "nothing was touched");
3471        assert!(wt.exists());
3472        assert_eq!(worktree_branch(&wt).as_deref(), Some(branch.as_str()));
3473    }
3474
3475    /// A dirty existing worktree is still a no-op: the uncommitted file
3476    /// and the branch must survive ensure, so a force-recreate mutation
3477    /// that wipes the checkout cannot pass.
3478    #[test]
3479    fn ensure_worker_worktree_never_touches_a_dirty_existing_worktree() {
3480        let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3481        fs::write(wt.join("uncommitted.txt"), "in flight").expect("write uncommitted");
3482        let outcome = ensure_worker_worktree(dir.path(), "lbl", &wt).expect("no-op");
3483        assert_eq!(outcome, WorktreeEnsure::AlreadyPresent);
3484        assert!(wt.join("uncommitted.txt").exists(), "the uncommitted work survives ensure");
3485    }
3486
3487    /// The despawn-kept-branch shape: the worktree was removed but the
3488    /// branch survives (it held unreachable commits). Reattaching it must
3489    /// bring the branch's content back with it, or the resumed worker
3490    /// returns to an empty checkout.
3491    #[test]
3492    fn ensure_worker_worktree_reattaches_a_surviving_branch() {
3493        let (dir, wt, branch) = init_repo_with_worker_worktree("lbl");
3494        fs::write(wt.join("work.txt"), "a worker committed here").expect("write work");
3495        run_git(&wt, &["add", "."]);
3496        run_git(&wt, &["commit", "-q", "-m", "real work"]);
3497        drop_worktree(dir.path(), &wt);
3498        assert!(!wt.exists(), "fixture precondition: the worktree is gone");
3499        assert!(branch_ref_exists(dir.path(), &branch), "fixture precondition: branch kept");
3500
3501        let outcome = ensure_worker_worktree(dir.path(), "lbl", &wt).expect("reattach succeeds");
3502        assert_eq!(
3503            outcome,
3504            WorktreeEnsure::AttachedExistingBranch,
3505            "the branch predates the call, so a rollback must spare it"
3506        );
3507        assert!(wt.exists(), "the worktree exists again");
3508        assert_eq!(worktree_branch(&wt).as_deref(), Some(branch.as_str()));
3509        assert!(wt.join("work.txt").exists(), "reattaching the branch restores its content");
3510    }
3511
3512    /// The incident (#1767): a despawn's removal failed part-way, leaving
3513    /// the directory with NO registration - a late write into the dir
3514    /// makes `git worktree remove` fail after git has already deregistered
3515    /// the path - and `ensure` then answered `AlreadyPresent` over it, so
3516    /// the resumed child ran `--worktree` and git refused the occupied
3517    /// path before the initialize handshake. The leftover must be cleared
3518    /// deliberately and the worktree recreated.
3519    #[test]
3520    fn ensure_worker_worktree_clears_a_leftover_dir_git_no_longer_tracks() {
3521        let (dir, wt, branch) = init_repo_with_worker_worktree("lbl");
3522        // The despawn records the admitted removal before git runs.
3523        record_worktree_state(dir.path(), "lbl", &wt, WorktreeRecord::Removing);
3524        deregister_worktree(dir.path(), "lbl");
3525        fs::write(wt.join("late-write.txt"), "wrote while dying").expect("late write");
3526        assert!(wt.exists(), "fixture precondition: the directory is still there");
3527        assert!(
3528            !worktree_registered(dir.path(), &wt),
3529            "fixture precondition: git no longer lists the path",
3530        );
3531
3532        let outcome = ensure_worker_worktree(dir.path(), "lbl", &wt).expect("the leftover clears");
3533
3534        assert_eq!(
3535            outcome,
3536            WorktreeEnsure::AttachedExistingBranch,
3537            "the branch predates the call and may hold real work, so it is reused",
3538        );
3539        assert!(wt.exists(), "the worktree stands again");
3540        assert_eq!(worktree_branch(&wt).as_deref(), Some(branch.as_str()));
3541        assert!(!wt.join("late-write.txt").exists(), "the leftover's death-throes write is gone");
3542        assert_eq!(
3543            recorded_worktree(dir.path(), &wt).map(|record| record.state),
3544            Some(WorktreeRecord::Owned),
3545            "a created worktree is recorded as this checkout's",
3546        );
3547    }
3548
3549    /// A path that is not a plain directory cannot be cleared as a
3550    /// leftover, and answering `AlreadyPresent` over it is what let the
3551    /// resumed child die behind the transport's message. The refusal names
3552    /// the path instead of pretending the worktree is there.
3553    #[test]
3554    fn ensure_worker_worktree_refuses_a_leftover_that_is_not_a_directory() {
3555        let dir = init_repo_with_commit();
3556        let wt = dir.path().join(".claude").join("worktrees").join("lbl");
3557        fs::create_dir_all(wt.parent().expect("worktrees dir")).expect("mkdir worktrees");
3558        fs::write(&wt, "a file where the worktree goes").expect("write a file at the path");
3559
3560        let err = ensure_worker_worktree(dir.path(), "lbl", &wt).expect_err("a file is refused");
3561
3562        assert!(
3563            matches!(err, WorktreeError::NotAPlainDirectory(ref at) if at.ends_with("lbl")),
3564            "the refusal names the occupied path: {err:?}",
3565        );
3566    }
3567
3568    /// The despawn-reaped-branch shape: nothing on the branch was
3569    /// unreachable, so despawn deleted it. A repo with no remote default
3570    /// has no origin base to cut from, so the branch is recreated at the
3571    /// local HEAD - the fallback [`ensure_worker_worktree`] documents.
3572    #[test]
3573    fn ensure_worker_worktree_recreates_the_branch_when_reaped() {
3574        let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3575        drop_worktree(dir.path(), &wt);
3576        assert_eq!(
3577            reap_worktree_branch(dir.path(), "worktree-lbl"),
3578            BranchReapOutcome::Reaped,
3579            "fixture precondition: despawn reaped the branch"
3580        );
3581        assert!(!wt.exists());
3582
3583        let outcome = ensure_worker_worktree(dir.path(), "lbl", &wt).expect("recreate succeeds");
3584        assert_eq!(outcome, WorktreeEnsure::CreatedWithNewBranch);
3585        assert!(wt.exists());
3586        assert_eq!(worktree_branch(&wt).as_deref(), Some("worktree-lbl"));
3587        assert_eq!(
3588            git_stdout(dir.path(), &["rev-parse", "worktree-lbl"]),
3589            git_stdout(dir.path(), &["rev-parse", "HEAD"]),
3590            "the recreated branch starts at the repo's current HEAD"
3591        );
3592    }
3593
3594    /// claude's own `--worktree` cuts a fresh branch from
3595    /// `origin/<default-branch>` (`worktree.baseRef` defaults to `fresh`),
3596    /// so a resume that recreates the branch must start there too. Cutting
3597    /// at the local HEAD starts a resumed worker from a commit its
3598    /// launcher would not have chosen whenever HEAD is ahead (#1594).
3599    #[test]
3600    fn ensure_worker_worktree_cuts_a_recreated_branch_from_the_origin_base() {
3601        let dir = init_repo_with_commit();
3602        let origin = git_stdout(dir.path(), &["rev-parse", "HEAD"]);
3603        // The remote's default branch sits where the clone found it...
3604        run_git(dir.path(), &["update-ref", "refs/remotes/origin/main", &origin]);
3605        run_git(
3606            dir.path(),
3607            &["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"],
3608        );
3609        // ...and local HEAD moves on ahead of it.
3610        fs::write(dir.path().join("README.md"), "local work").expect("write advance");
3611        run_git(dir.path(), &["commit", "-qam", "local ahead"]);
3612        let local = git_stdout(dir.path(), &["rev-parse", "HEAD"]);
3613        assert_ne!(origin, local, "fixture precondition: local HEAD is ahead of the origin base");
3614
3615        let wt = dir.path().join(".claude").join("worktrees").join("lbl");
3616        let outcome = ensure_worker_worktree(dir.path(), "lbl", &wt).expect("recreate succeeds");
3617
3618        assert_eq!(outcome, WorktreeEnsure::CreatedWithNewBranch);
3619        assert_eq!(
3620            git_stdout(dir.path(), &["rev-parse", "worktree-lbl"]),
3621            origin,
3622            "the new branch starts at origin/HEAD's commit, the base claude's own path uses",
3623        );
3624    }
3625
3626    /// An `origin/HEAD` naming a ref the remote no longer carries is not a
3627    /// base git can resolve: the recreate falls back to the local HEAD
3628    /// rather than refusing the whole resume.
3629    #[test]
3630    fn ensure_worker_worktree_falls_back_when_origin_head_dangles() {
3631        let dir = init_repo_with_commit();
3632        let local = git_stdout(dir.path(), &["rev-parse", "HEAD"]);
3633        run_git(
3634            dir.path(),
3635            &["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/gone"],
3636        );
3637        let wt = dir.path().join(".claude").join("worktrees").join("lbl");
3638
3639        let outcome = ensure_worker_worktree(dir.path(), "lbl", &wt).expect("recreate succeeds");
3640
3641        assert_eq!(outcome, WorktreeEnsure::CreatedWithNewBranch);
3642        assert_eq!(
3643            git_stdout(dir.path(), &["rev-parse", "worktree-lbl"]),
3644            local,
3645            "the local HEAD is the fallback when the origin base does not resolve",
3646        );
3647    }
3648
3649    /// The branch is checked out in a second worktree, so `git worktree
3650    /// add` at the label path cannot attach it. ensure reports the
3651    /// failure with git's stderr instead of pretending to succeed.
3652    #[test]
3653    fn ensure_worker_worktree_fails_when_the_branch_is_checked_out_elsewhere() {
3654        let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3655        drop_worktree(dir.path(), &wt);
3656        let second = dir.path().join("elsewhere");
3657        run_git(
3658            dir.path(),
3659            &["worktree", "add", "-q", second.to_str().expect("utf8"), "worktree-lbl"],
3660        );
3661
3662        let err = ensure_worker_worktree(dir.path(), "lbl", &wt).expect_err("attach is refused");
3663        assert!(
3664            matches!(err, WorktreeError::CreateFailed(ref reason) if reason.contains("worktree-lbl")),
3665            "the error carries git's stderr naming the branch: {err:?}"
3666        );
3667        assert!(!wt.exists(), "the failed add leaves no worktree behind");
3668    }
3669
3670    /// The measured silent case: claude is handed `--worktree lbl`, the
3671    /// branch is checked out elsewhere, the creation does nothing - and the
3672    /// session starts anyway. The obstacle is what lets the spawn refuse it.
3673    #[test]
3674    fn obstacle_names_a_branch_checked_out_elsewhere() {
3675        let repo = init_repo_with_commit();
3676        let elsewhere = repo.path().join("elsewhere");
3677        run_git(
3678            repo.path(),
3679            &["worktree", "add", "-q", "-b", "worktree-lbl", elsewhere.to_str().expect("utf8")],
3680        );
3681        let wt = repo.path().join(".claude").join("worktrees").join("lbl");
3682
3683        let obstacle = worktree_creation_obstacle(repo.path(), "lbl", &wt)
3684            .expect("the branch held elsewhere is an obstacle");
3685
3686        assert!(
3687            obstacle.contains("worktree-lbl") && obstacle.contains("already checked out"),
3688            "the obstacle names the branch and what holds it: {obstacle}"
3689        );
3690    }
3691
3692    /// The worker's own worktree is where a fresh re-spawn lands, and the
3693    /// worktree there is reused as it stands.
3694    #[test]
3695    fn obstacle_is_none_for_the_workers_own_worktree() {
3696        let repo = init_repo_with_commit();
3697        let wt = repo.path().join(".claude").join("worktrees").join("lbl");
3698        fs::create_dir_all(wt.parent().expect("worktrees dir")).expect("mkdir worktrees");
3699        run_git(
3700            repo.path(),
3701            &["worktree", "add", "-q", "-b", "worktree-lbl", wt.to_str().expect("utf8")],
3702        );
3703
3704        assert_eq!(
3705            worktree_creation_obstacle(repo.path(), "lbl", &wt),
3706            None,
3707            "a worktree registered at the worker's own path is not an obstacle",
3708        );
3709    }
3710
3711    #[test]
3712    fn obstacle_is_none_where_the_worktree_can_be_created() {
3713        let repo = init_repo_with_commit();
3714        let wt = repo.path().join(".claude").join("worktrees").join("lbl");
3715
3716        assert_eq!(worktree_creation_obstacle(repo.path(), "lbl", &wt), None);
3717    }
3718
3719    /// git takes a path that is absent or an EMPTY directory, so an empty
3720    /// one is not in its way. Refusing it would strand the label: a `mkdir`
3721    /// or a cleanup that empties the directory would block every spawn of
3722    /// it until someone removed the directory by hand.
3723    #[test]
3724    fn obstacle_is_none_for_an_empty_directory() {
3725        let repo = init_repo_with_commit();
3726        let wt = repo.path().join(".claude").join("worktrees").join("lbl");
3727        fs::create_dir_all(&wt).expect("empty worktrees dir");
3728
3729        assert_eq!(
3730            worktree_creation_obstacle(repo.path(), "lbl", &wt),
3731            None,
3732            "an empty directory is a path git will take",
3733        );
3734    }
3735
3736    /// claude locks every worktree it creates and git will not prune a
3737    /// locked registration, so the remedy has to clear the lock first:
3738    /// measured, `worktree prune` alone exits 0 having done nothing and
3739    /// the same refusal comes back byte-identical.
3740    #[test]
3741    fn obstacle_remedy_unlocks_before_pruning() {
3742        let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3743        run_git(dir.path(), &["worktree", "lock", wt.to_str().expect("utf8 path")]);
3744        fs::remove_dir_all(&wt).expect("delete the worktree directory");
3745
3746        let obstacle = worktree_creation_obstacle(dir.path(), "lbl", &wt)
3747            .expect("a locked registration with no directory is an obstacle");
3748
3749        let unlock = obstacle.find("worktree unlock").expect("the remedy clears the lock");
3750        let prune = obstacle.find("worktree prune").expect("and then clears the registration");
3751        assert!(
3752            unlock < prune,
3753            "the order is the remedy, not the pair of names: unlock comes first in {obstacle}"
3754        );
3755    }
3756
3757    #[test]
3758    fn obstacle_names_a_path_taken_by_something_else() {
3759        let repo = init_repo_with_commit();
3760        let wt = repo.path().join(".claude").join("worktrees").join("lbl");
3761        fs::create_dir_all(&wt).expect("stray dir");
3762        fs::write(wt.join("notes.txt"), "hand-made").expect("stray file");
3763
3764        let obstacle = worktree_creation_obstacle(repo.path(), "lbl", &wt)
3765            .expect("a stray directory at the path is an obstacle");
3766
3767        assert!(
3768            obstacle.contains("is not an empty directory"),
3769            "the obstacle names what is in the way: {obstacle}"
3770        );
3771    }
3772
3773    /// A listing with nothing but paths and branches: no `prunable` line, as
3774    /// a git older than 2.36 prints for every stranded entry, and no `locked`
3775    /// marker either. Written against the listing rather than against git,
3776    /// since no git on this machine prints that older shape: an entry at the
3777    /// worker's path it cannot reuse would otherwise go unrefused, which is
3778    /// #1303 returning. The predicate reads no marker lines, so what the
3779    /// fixture pins is that they are not needed.
3780    #[test]
3781    fn obstacle_refuses_an_entry_whose_listing_says_nothing_about_it() {
3782        let repo = Path::new("/repo");
3783        let path = Path::new("/repo/.claude/worktrees/lbl");
3784        let listing = "worktree /repo\nbranch refs/heads/main\n\n\
3785                       worktree /repo/.claude/worktrees/lbl\nbranch refs/heads/worktree-lbl\n";
3786        assert!(
3787            !listing.contains("prunable") && !listing.contains("locked"),
3788            "fixture precondition: the listing carries neither marker, the older git shape",
3789        );
3790        let entries = listed_worktrees(listing);
3791
3792        let obstacle = obstacle_from_listing(&entries, repo, "worktree-lbl", path)
3793            .expect("a registration whose gitdir cannot resolve is an obstacle");
3794
3795        assert!(
3796            obstacle.contains("registration at"),
3797            "the obstacle names the registration that cannot be reused: {obstacle}"
3798        );
3799    }
3800
3801    /// **A gitdir that exists is not a usable worktree gitdir.** The
3802    /// spawn-time check read only whether the linked directory was there,
3803    /// so four states git's own reuse probe refuses - a link that is a
3804    /// symlink, a gitdir with no HEAD, a HEAD naming a deleted branch, and
3805    /// a link into another repository - all read as reusable and the spawn
3806    /// started with nowhere to work (#1593).
3807    #[test]
3808    fn obstacle_refuses_a_registration_whose_gitdir_git_cannot_reuse() {
3809        // A symlink standing in for the link file is not what git writes.
3810        let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3811        let real = wt.join("real-link");
3812        fs::rename(wt.join(".git"), &real).expect("move the link aside");
3813        std::os::unix::fs::symlink(&real, wt.join(".git")).expect("symlink the link");
3814        assert!(
3815            worktree_creation_obstacle(dir.path(), "lbl", &wt).is_some(),
3816            "a symlinked .git link is not a registration git reuses",
3817        );
3818
3819        // A gitdir whose HEAD is gone.
3820        let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3821        let gitdir = worktree_gitdir(&wt);
3822        fs::remove_file(gitdir.join("HEAD")).expect("remove HEAD");
3823        assert!(
3824            worktree_creation_obstacle(dir.path(), "lbl", &wt).is_some(),
3825            "a gitdir with no HEAD cannot be reused",
3826        );
3827
3828        // A HEAD naming a branch the repo deleted.
3829        let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3830        let gitdir = worktree_gitdir(&wt);
3831        fs::write(gitdir.join("HEAD"), "ref: refs/heads/gone\n").expect("point HEAD at nothing");
3832        assert!(
3833            worktree_creation_obstacle(dir.path(), "lbl", &wt).is_some(),
3834            "a HEAD naming a deleted branch cannot be reused",
3835        );
3836
3837        // A link pointing into another repository: its gitdir's own back
3838        // link names that repository's worktree, not this one.
3839        let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3840        let (_other, other_wt, _other_branch) = init_repo_with_worker_worktree("lbl");
3841        let other_gitdir = worktree_gitdir(&other_wt);
3842        fs::write(wt.join(".git"), format!("gitdir: {}\n", other_gitdir.display()))
3843            .expect("point at another repository");
3844        assert!(
3845            worktree_creation_obstacle(dir.path(), "lbl", &wt).is_some(),
3846            "another repository's worktree gitdir is not this worktree's",
3847        );
3848    }
3849
3850    /// A worktree whose `.git` link file is gone while its directory stands,
3851    /// LOCKED - which is claude's own shape, since it locks every worktree it
3852    /// creates. git refuses to add a worktree there (exit 128), and a locked
3853    /// entry carries no `prunable` line to say so, which is why the state is
3854    /// read from the link itself rather than from that flag.
3855    #[test]
3856    fn obstacle_refuses_a_locked_worktree_whose_gitdir_link_is_broken() {
3857        let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3858        run_git(dir.path(), &["worktree", "lock", wt.to_str().expect("utf8 path")]);
3859        fs::remove_file(wt.join(".git")).expect("delete the worktree's gitdir link");
3860        assert!(wt.is_dir(), "fixture precondition: the directory itself stands");
3861
3862        let obstacle = worktree_creation_obstacle(dir.path(), "lbl", &wt)
3863            .expect("a worktree git will not reuse is an obstacle");
3864
3865        assert!(
3866            obstacle.contains("cannot be reused"),
3867            "the obstacle names the registration git will not reuse: {obstacle}"
3868        );
3869    }
3870
3871    /// A dangling symlink at the path is a path git refuses (`already
3872    /// exists`, exit 128 - measured), and one `exists()` cannot see: it
3873    /// follows the link and answers false. Admitting it starts the worker
3874    /// with no worktree, which is the bug this check exists for.
3875    #[test]
3876    fn obstacle_refuses_a_dangling_symlink_at_the_path() {
3877        let repo = init_repo_with_commit();
3878        let wt = repo.path().join(".claude").join("worktrees").join("lbl");
3879        fs::create_dir_all(wt.parent().expect("worktrees dir")).expect("mkdir worktrees");
3880        std::os::unix::fs::symlink(repo.path().join("nowhere"), &wt).expect("dangling symlink");
3881
3882        let obstacle = worktree_creation_obstacle(repo.path(), "lbl", &wt)
3883            .expect("a symlink git refuses is an obstacle");
3884
3885        assert!(
3886            obstacle.contains("is not an empty directory"),
3887            "the obstacle names what is in the way: {obstacle}"
3888        );
3889    }
3890
3891    #[test]
3892    fn discard_worker_worktree_removes_a_minted_worktree_and_branch() {
3893        let (dir, wt, branch) = init_repo_with_worker_worktree("lbl");
3894        drop_worktree(dir.path(), &wt);
3895        assert_eq!(
3896            reap_worktree_branch(dir.path(), &branch),
3897            BranchReapOutcome::Reaped,
3898            "fixture precondition: the branch is gone, so ensure must mint it"
3899        );
3900        let outcome = ensure_worker_worktree(dir.path(), "lbl", &wt).expect("recreate succeeds");
3901        assert_eq!(outcome, WorktreeEnsure::CreatedWithNewBranch);
3902
3903        let warning = discard_worker_worktree(dir.path(), "lbl", &wt, outcome);
3904        assert!(warning.is_none(), "a clean discard warns about nothing: {warning:?}");
3905        assert!(!wt.exists(), "the minted worktree is gone");
3906        assert!(!branch_exists(dir.path(), &branch), "the minted branch is reaped");
3907    }
3908
3909    /// The attach case is the data-loss guard: the branch predates the
3910    /// ensure and may hold the worker's only copy of its commits, so a
3911    /// rollback removes the worktree and spares the branch.
3912    #[test]
3913    fn discard_worker_worktree_keeps_a_pre_existing_branch() {
3914        let (dir, wt, branch) = init_repo_with_worker_worktree("lbl");
3915        fs::write(wt.join("work.txt"), "a worker committed here").expect("write work");
3916        run_git(&wt, &["add", "."]);
3917        run_git(&wt, &["commit", "-q", "-m", "real work"]);
3918        drop_worktree(dir.path(), &wt);
3919        let outcome = ensure_worker_worktree(dir.path(), "lbl", &wt).expect("reattach succeeds");
3920        assert_eq!(outcome, WorktreeEnsure::AttachedExistingBranch);
3921
3922        let warning = discard_worker_worktree(dir.path(), "lbl", &wt, outcome);
3923        assert!(warning.is_none(), "a clean discard warns about nothing: {warning:?}");
3924        assert!(!wt.exists(), "the worktree ensure created is gone");
3925        assert!(branch_exists(dir.path(), &branch), "the pre-existing branch survives");
3926        assert_eq!(
3927            git_stdout(dir.path(), &["rev-list", "--count", &branch]),
3928            "2",
3929            "the branch's two commits (seed + the worker's) are all still reachable"
3930        );
3931    }
3932
3933    #[test]
3934    fn discard_worker_worktree_is_a_noop_when_ensure_touched_nothing() {
3935        let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3936        let warning =
3937            discard_worker_worktree(dir.path(), "lbl", &wt, WorktreeEnsure::AlreadyPresent);
3938        assert!(warning.is_none());
3939        assert!(wt.exists(), "an already-present worktree is left alone");
3940    }
3941
3942    #[test]
3943    fn a_refused_delete_is_not_reported_as_a_verification_failure() {
3944        let (dir, _wt, branch) = init_repo_with_worker_worktree("lbl");
3945        let outcome = reap_worktree_branch(dir.path(), &branch);
3946        let BranchReapOutcome::DeleteFailed { reason } = outcome else {
3947            panic!("a refused delete reports DeleteFailed, got {outcome:?}");
3948        };
3949        assert!(!reason.is_empty(), "the warning can name git's complaint");
3950        assert!(branch_exists(dir.path(), &branch), "the branch survives a refused delete");
3951    }
3952
3953    #[test]
3954    fn keeps_on_error_when_the_path_is_not_a_repo() {
3955        let dir = tempdir().expect("tempdir");
3956        assert!(
3957            matches!(
3958                reap_worktree_branch(dir.path(), "worktree-lbl"),
3959                BranchReapOutcome::KeptOnError { .. }
3960            ),
3961            "a repo we cannot read is never treated as a branch that does not exist",
3962        );
3963    }
3964
3965    #[test]
3966    fn branch_ref_exists_separates_present_from_absent() {
3967        let (dir, _wt, branch) = init_repo_with_worker_worktree("lbl");
3968        assert!(branch_ref_exists(dir.path(), &branch));
3969        assert!(!branch_ref_exists(dir.path(), "never-created"));
3970    }
3971
3972    /// The reap counts a remote-tracking ref as reachability and so deletes
3973    /// a pushed local head. Existence has to count the same ref or the two
3974    /// disagree about a branch that is still open for review.
3975    #[test]
3976    fn branch_ref_exists_counts_a_branch_that_only_survives_on_a_remote() {
3977        let (dir, wt, branch) = init_repo_with_worker_worktree("lbl");
3978        let remote = tempdir().expect("remote tempdir");
3979        run_git(remote.path(), &["init", "-q", "--bare"]);
3980        run_git(&wt, &["remote", "add", "origin", remote.path().to_str().expect("utf8 path")]);
3981        fs::write(wt.join("work.txt"), "pushed work").expect("write work");
3982        run_git(&wt, &["add", "."]);
3983        run_git(&wt, &["commit", "-q", "-m", "real work"]);
3984        run_git(&wt, &["push", "-q", "-u", "origin", &branch]);
3985        drop_worktree(dir.path(), &wt);
3986        assert_eq!(reap_worktree_branch(dir.path(), &branch), BranchReapOutcome::Reaped);
3987
3988        assert!(!branch_exists(dir.path(), &branch), "the local head really is gone");
3989        assert!(
3990            branch_ref_exists(dir.path(), &branch),
3991            "a branch alive only on a remote still exists for review purposes",
3992        );
3993    }
3994
3995    /// A slashed name must not let the remote-ref glob over-match.
3996    #[test]
3997    fn branch_ref_exists_handles_a_slashed_branch_name() {
3998        let (dir, wt, _branch) = init_repo_with_worker_worktree("lbl");
3999        let remote = tempdir().expect("remote tempdir");
4000        run_git(remote.path(), &["init", "-q", "--bare"]);
4001        run_git(&wt, &["remote", "add", "origin", remote.path().to_str().expect("utf8 path")]);
4002        run_git(&wt, &["checkout", "-q", "-b", "fix/deep/name"]);
4003        run_git(&wt, &["push", "-q", "-u", "origin", "fix/deep/name"]);
4004        run_git(&wt, &["checkout", "-q", "-"]);
4005        run_git(dir.path(), &["branch", "-D", "fix/deep/name"]);
4006
4007        assert!(branch_ref_exists(dir.path(), "fix/deep/name"));
4008        assert!(!branch_ref_exists(dir.path(), "fix/deep"));
4009    }
4010
4011    /// The existing coverage slashes the BRANCH name; this slashes the
4012    /// REMOTE, which is what the old `refs/remotes/*/<branch>` pattern
4013    /// could not see - `*` does not cross `/`. Empty stdout, exit 0, and
4014    /// on the despawn path empty is what deletes.
4015    #[test]
4016    fn branch_ref_exists_sees_a_branch_behind_a_slash_bearing_remote() {
4017        let dir = init_repo_with_commit();
4018        run_git(dir.path(), &["update-ref", "refs/remotes/gh/upstream/feat/pushed", "HEAD"]);
4019        assert!(
4020            branch_ref_exists(dir.path(), "feat/pushed"),
4021            "a branch surviving only on a slash-named remote is still present",
4022        );
4023        assert!(!branch_ref_exists(dir.path(), "feat/never-existed"));
4024    }
4025
4026    /// A project path nested inside a repo cannot be judged, so it reads
4027    /// as present. Sharing the parser brings the work-tree-root check
4028    /// with it: previously this answered from the ancestor's refs and
4029    /// could delete on them.
4030    #[test]
4031    fn branch_ref_exists_reads_a_nested_path_as_present() {
4032        let dir = init_repo_with_commit();
4033        let nested = dir.path().join("crates");
4034        fs::create_dir_all(&nested).expect("mkdir");
4035        assert!(branch_ref_exists(&nested, "anything-at-all"));
4036    }
4037
4038    #[test]
4039    fn branch_ref_exists_reads_an_unreadable_repo_as_present() {
4040        let dir = tempdir().expect("tempdir");
4041        assert!(
4042            branch_ref_exists(dir.path(), "worktree-lbl"),
4043            "a repo we cannot read must not read as a branch that is gone",
4044        );
4045    }
4046
4047    /// The PostToolUse tool_response of a real isolation:"worktree" Agent
4048    /// call that left an uncommitted file, captured on CLI 2.1.220
4049    /// (absolute path redacted). The worktree fields are flat and
4050    /// top-level; a completed call that changed nothing carries none of
4051    /// them, and an async launch carries none either.
4052    fn kept_worktree_response(path: &str, branch: &str) -> serde_json::Value {
4053        serde_json::json!({
4054            "status": "completed",
4055            "prompt": "Create a file named kept.txt in the repo root containing the word hello. Do not commit it. When finished, reply with just the word done.",
4056            "agentId": "ac437073a41a2149d",
4057            "agentType": "general-purpose",
4058            "content": [{ "type": "text", "text": "done" }],
4059            "resolvedModel": "glm-5.3-flash",
4060            "totalDurationMs": 111_649,
4061            "totalTokens": 35201,
4062            "totalToolUseCount": 8,
4063            "usage": {
4064                "input_tokens": 190,
4065                "cache_creation_input_tokens": 0,
4066                "cache_read_input_tokens": 35008,
4067                "output_tokens": 3,
4068                "server_tool_use": { "web_search_requests": 0, "web_fetch_requests": 0 },
4069                "service_tier": "standard",
4070            },
4071            "toolStats": { "editFileCount": 1, "linesAdded": 2, "linesRemoved": 0 },
4072            "worktreePath": path,
4073            "worktreeBranch": branch,
4074        })
4075    }
4076
4077    /// The tool_response of a real async isolation:"worktree" launch,
4078    /// captured on CLI 2.1.220. It names no worktree: the subagent is
4079    /// about to run in it, so a parse that found a path here would reap a
4080    /// live agent's tree.
4081    fn async_launch_response(output_file: &str) -> serde_json::Value {
4082        serde_json::json!({
4083            "isAsync": true,
4084            "status": "async_launched",
4085            "agentId": "a01ffe965157f5add",
4086            "description": "capture async",
4087            "resolvedModel": "glm-5.3-flash",
4088            "prompt": "sleep then done",
4089            "outputFile": output_file,
4090            "canReadOutputFile": true,
4091        })
4092    }
4093
4094    fn agent_input() -> serde_json::Value {
4095        serde_json::json!({ "isolation": "worktree", "prompt": "review it" })
4096    }
4097
4098    /// The CLI auto-cleans a no-changes worktree and names no path in the
4099    /// response; that population must parse as None, never as a miss that
4100    /// guesses.
4101    #[test]
4102    fn completed_agent_worktree_ignores_a_completed_response_without_a_worktree() {
4103        let mut response = kept_worktree_response("/repo/.claude/worktrees/agent-abc123", "w");
4104        response.as_object_mut().expect("object").remove("worktreePath");
4105        response.as_object_mut().expect("object").remove("worktreeBranch");
4106        assert!(completed_agent_worktree("Agent", &agent_input(), &response).is_none());
4107    }
4108
4109    /// An async launch's worktree is about to be a live agent's cwd; the
4110    /// response naming no path is what keeps the reap off it.
4111    #[test]
4112    fn completed_agent_worktree_never_reaps_an_async_launch() {
4113        assert!(
4114            completed_agent_worktree(
4115                "Agent",
4116                &agent_input(),
4117                &async_launch_response("/tmp/tasks/a01ffe965157f5add.output")
4118            )
4119            .is_none()
4120        );
4121    }
4122
4123    #[test]
4124    fn is_isolated_agent_call_tracks_the_gate_population() {
4125        assert!(is_isolated_agent_call("Agent", &agent_input()));
4126        assert!(is_isolated_agent_call("Task", &agent_input()));
4127        assert!(!is_isolated_agent_call("EnterWorktree", &agent_input()));
4128        assert!(!is_isolated_agent_call("Agent", &serde_json::json!({ "prompt": "plain" })));
4129    }
4130
4131    #[test]
4132    fn completed_agent_worktree_parses_the_managed_path_and_branch() {
4133        let wt = completed_agent_worktree(
4134            "Agent",
4135            &agent_input(),
4136            &kept_worktree_response(
4137                "/repo/.claude/worktrees/agent-a83052083ca02b1ce",
4138                "worktree-agent-a83052083ca02b1ce",
4139            ),
4140        )
4141        .expect("an isolated Agent completion yields its worktree");
4142        assert_eq!(wt.path, Path::new("/repo/.claude/worktrees/agent-a83052083ca02b1ce"));
4143        assert_eq!(wt.repo_root, Path::new("/repo"));
4144        assert_eq!(wt.branch, "worktree-agent-a83052083ca02b1ce");
4145    }
4146
4147    #[test]
4148    fn completed_agent_worktree_derives_the_branch_when_the_response_omits_it() {
4149        let response = serde_json::json!({
4150            "worktreePath": "/repo/.claude/worktrees/agent-abc123",
4151        });
4152        let wt = completed_agent_worktree("Agent", &agent_input(), &response)
4153            .expect("a path-only response still yields the worktree");
4154        assert_eq!(wt.branch, "worktree-agent-abc123", "the CLI's worktree-<slug> convention");
4155    }
4156
4157    /// Older CLIs name the tool `Task`; both spellings gate the reap.
4158    #[test]
4159    fn completed_agent_worktree_accepts_the_task_spelling() {
4160        let wt = completed_agent_worktree(
4161            "Task",
4162            &agent_input(),
4163            &kept_worktree_response(
4164                "/repo/.claude/worktrees/agent-abc123",
4165                "worktree-agent-abc123",
4166            ),
4167        );
4168        assert!(wt.is_some(), "Task is the pre-rename Agent tool name");
4169    }
4170
4171    /// A completed Agent tool result text quotes the worktreePath template;
4172    /// only a structured field may aim the reap.
4173    #[test]
4174    fn completed_agent_worktree_ignores_the_path_when_it_only_appears_in_text() {
4175        let response = serde_json::json!({
4176            "content": [{ "type": "text", "text": "worktreePath: /repo/.claude/worktrees/agent-abc123" }],
4177        });
4178        assert!(completed_agent_worktree("Agent", &agent_input(), &response).is_none());
4179    }
4180
4181    #[test]
4182    fn completed_agent_worktree_requires_the_isolation_input() {
4183        let plain = serde_json::json!({ "prompt": "review it" });
4184        assert!(
4185            completed_agent_worktree(
4186                "Agent",
4187                &plain,
4188                &kept_worktree_response(
4189                    "/repo/.claude/worktrees/agent-abc123",
4190                    "worktree-agent-abc123"
4191                ),
4192            )
4193            .is_none(),
4194            "a non-isolated Agent call never names a managed worktree",
4195        );
4196    }
4197
4198    /// EnterWorktree's own response also carries worktree fields; the
4199    /// tool-name gate is what keeps it out of the reap.
4200    #[test]
4201    fn completed_agent_worktree_rejects_other_tool_names() {
4202        assert!(
4203            completed_agent_worktree(
4204                "EnterWorktree",
4205                &agent_input(),
4206                &kept_worktree_response(
4207                    "/repo/.claude/worktrees/agent-abc123",
4208                    "worktree-agent-abc123"
4209                ),
4210            )
4211            .is_none()
4212        );
4213    }
4214
4215    #[test]
4216    fn completed_agent_worktree_rejects_paths_outside_the_managed_layout() {
4217        for path in [
4218            "/repo/other/agent-abc123",
4219            "/repo/worktrees/agent-abc123",
4220            "/repo/.claude/worktrees/worker-abc123",
4221            ".claude/worktrees/agent-abc123",
4222        ] {
4223            assert!(
4224                completed_agent_worktree(
4225                    "Agent",
4226                    &agent_input(),
4227                    &kept_worktree_response(path, "worktree-agent-abc123"),
4228                )
4229                .is_none(),
4230                "{path} is not a managed agent worktree",
4231            );
4232        }
4233    }
4234
4235    /// The reap counts on the parser's layout guard for its repo root,
4236    /// so the response walk must find the field at a shallow nesting
4237    /// (depth cap 6); a deeper shape degrades to the parse-miss log.
4238    #[test]
4239    fn completed_agent_worktree_walks_nested_response_objects() {
4240        let response = serde_json::json!({
4241            "result": { "worktreePath": "/repo/.claude/worktrees/agent-abc123" },
4242        });
4243        let wt = completed_agent_worktree("Agent", &agent_input(), &response)
4244            .expect("a nested worktreePath is still a structured field");
4245        assert_eq!(wt.repo_root, Path::new("/repo"));
4246    }
4247
4248    fn init_repo_with_agent_worktree(hex: &str) -> (tempfile::TempDir, std::path::PathBuf, String) {
4249        let dir = init_repo_with_commit();
4250        let branch = add_worker_worktree(dir.path(), &format!("agent-{hex}"));
4251        let wt = dir.path().join(".claude").join("worktrees").join(format!("agent-{hex}"));
4252        (dir, wt, branch)
4253    }
4254
4255    fn agent_worktree_for(dir: &Path, hex: &str, branch: &str) -> AgentWorktree {
4256        AgentWorktree {
4257            path: dir.join(".claude").join("worktrees").join(format!("agent-{hex}")),
4258            repo_root: dir.to_path_buf(),
4259            branch: branch.to_owned(),
4260        }
4261    }
4262
4263    /// The agent fixture whose tree holds an initialized submodule: the
4264    /// repo gains the submodule and the tree's branch follows it, then
4265    /// `git submodule update --init` runs in the tree - what a subagent
4266    /// does - which is what leaves
4267    /// `<repo>/.git/worktrees/agent-<hex>/modules` behind.
4268    fn init_repo_with_submodule_agent_worktree(
4269        hex: &str,
4270    ) -> (tempfile::TempDir, std::path::PathBuf, String) {
4271        let (dir, wt, branch) = init_repo_with_agent_worktree(hex);
4272        let source = tempdir().expect("submodule source tempdir");
4273        run_git(source.path(), &["init", "-q"]);
4274        run_git(source.path(), &["config", "commit.gpgsign", "false"]);
4275        fs::write(source.path().join("sub.txt"), "submodule seed").expect("write seed");
4276        run_git(source.path(), &["add", "."]);
4277        run_git(
4278            source.path(),
4279            &[
4280                "-c",
4281                "user.email=t@example.com",
4282                "-c",
4283                "user.name=Test",
4284                "commit",
4285                "-q",
4286                "-m",
4287                "sub",
4288            ],
4289        );
4290        run_git(
4291            dir.path(),
4292            &[
4293                "-c",
4294                "protocol.file.allow=always",
4295                "submodule",
4296                "add",
4297                source.path().to_str().expect("utf8 path"),
4298                "contracts/sub",
4299            ],
4300        );
4301        run_git(dir.path(), &["commit", "-q", "-m", "add submodule"]);
4302        // The tree is cut from the pre-submodule commit, so its branch has
4303        // to take the repo's HEAD before the submodule exists to init.
4304        run_git(&wt, &["reset", "--hard", &git_stdout(dir.path(), &["rev-parse", "HEAD"])]);
4305        run_git(&wt, &["-c", "protocol.file.allow=always", "submodule", "update", "--init"]);
4306        (dir, wt, branch)
4307    }
4308
4309    /// The shape `git worktree remove` refuses however clean the tree is:
4310    /// a subagent that ran `git submodule update --init` in its own
4311    /// worktree, which the reap must still remove. Left unfixed the tree
4312    /// survives the completion hook AND every spawn-time sweep, so it
4313    /// leaks instead of being retried away.
4314    #[test]
4315    fn reaps_a_clean_agent_worktree_holding_initialized_submodules() {
4316        let (dir, wt, branch) = init_repo_with_submodule_agent_worktree("abc123");
4317        // Fixture precondition, and the reason the test exists: git will
4318        // not remove this tree unforced. Asserted rather than assumed so a
4319        // git that stops refusing fails here instead of passing without
4320        // exercising the fix.
4321        assert!(
4322            dir.path().join(".git").join("worktrees").join("agent-abc123").join("modules").is_dir(),
4323            "fixture precondition: the tree's gitdir holds the submodule's modules dir",
4324        );
4325        let refused = std::process::Command::new("git")
4326            .args(["worktree", "remove", wt.to_str().expect("utf8 path")])
4327            .current_dir(wt.parent().expect("wt parent"))
4328            .output()
4329            .expect("spawn git");
4330        assert!(!refused.status.success(), "fixture precondition: git refuses this tree unforced");
4331        assert!(wt.exists(), "and the refusal leaves it on disk");
4332
4333        let outcome = reap_agent_worktree(&agent_worktree_for(dir.path(), "abc123", &branch));
4334
4335        assert!(
4336            matches!(outcome, AgentWorktreeReap::Reaped { .. }),
4337            "a clean tree git refuses by shape is reaped, not leaked: {outcome:?}"
4338        );
4339        assert!(!wt.exists(), "the directory is gone");
4340        assert!(!branch_exists(dir.path(), &branch), "the conventional branch goes with it");
4341    }
4342
4343    #[test]
4344    fn reaps_a_clean_agent_worktree_and_its_conventional_branch() {
4345        let (dir, wt, branch) = init_repo_with_agent_worktree("abc123");
4346        let agent_wt = agent_worktree_for(dir.path(), "abc123", &branch);
4347
4348        let outcome = reap_agent_worktree(&agent_wt);
4349
4350        assert!(
4351            matches!(outcome, AgentWorktreeReap::Reaped { .. }),
4352            "a clean completed agent worktree is reaped, got {outcome:?}"
4353        );
4354        assert!(!wt.exists(), "the directory is gone");
4355        assert!(!branch_exists(dir.path(), &branch), "the conventional branch is gone");
4356    }
4357
4358    /// target/ and friends are ignored, so the measured leak is
4359    /// porcelain-clean; a non-empty porcelain means the subagent left real
4360    /// edits and the CLI handed the tree back deliberately.
4361    #[test]
4362    fn keeps_an_agent_worktree_holding_uncommitted_changes() {
4363        let (dir, wt, branch) = init_repo_with_agent_worktree("abc123");
4364        fs::write(wt.join("notes.md"), "uncommitted").expect("write scratch");
4365        let agent_wt = agent_worktree_for(dir.path(), "abc123", &branch);
4366
4367        let outcome = reap_agent_worktree(&agent_wt);
4368
4369        let AgentWorktreeReap::KeptDirty { reason } = outcome else {
4370            panic!("a dirty agent worktree is kept, got {outcome:?}");
4371        };
4372        assert!(!reason.is_empty());
4373        assert!(wt.exists(), "the directory survives");
4374    }
4375
4376    /// The same committed-branch shape in a repo that HAS remote refs.
4377    /// The subagent never pushes, so the despawn's unpushed check would
4378    /// call this dirty; the reap gates on the uncommitted tree alone,
4379    /// because the branch reap below is what protects committed work.
4380    /// A tree kept here is re-listed by every sweep.
4381    #[test]
4382    fn removes_a_committed_agent_worktree_in_a_repo_with_remote_refs() {
4383        let (dir, wt, branch) = init_repo_with_agent_worktree("abc123");
4384        run_git(dir.path(), &["update-ref", "refs/remotes/origin/main", "HEAD"]);
4385        fs::write(wt.join("work.txt"), "committed by the subagent").expect("write work");
4386        run_git(&wt, &["add", "."]);
4387        run_git(&wt, &["commit", "-q", "-m", "real work"]);
4388        let agent_wt = agent_worktree_for(dir.path(), "abc123", &branch);
4389
4390        let outcome = reap_agent_worktree(&agent_wt);
4391
4392        let AgentWorktreeReap::Reaped { branch: branch_outcome } = outcome else {
4393            panic!("a committed, unpushed branch does not keep the tree, got {outcome:?}");
4394        };
4395        assert!(!wt.exists(), "the directory is gone");
4396        assert!(
4397            matches!(branch_outcome, BranchReapOutcome::Kept { .. }),
4398            "the branch keeps the commit no ref reaches, got {branch_outcome:?}"
4399        );
4400    }
4401
4402    /// A subagent that committed its work to the conventional branch leaves
4403    /// a clean tree; the reachability check keeps the branch.
4404    #[test]
4405    fn removes_a_clean_worktree_but_keeps_a_branch_holding_unique_commits() {
4406        let (dir, wt, branch) = init_repo_with_agent_worktree("abc123");
4407        fs::write(wt.join("work.txt"), "committed by the subagent").expect("write work");
4408        run_git(&wt, &["add", "."]);
4409        run_git(&wt, &["commit", "-q", "-m", "real work"]);
4410        let agent_wt = agent_worktree_for(dir.path(), "abc123", &branch);
4411
4412        let outcome = reap_agent_worktree(&agent_wt);
4413
4414        let AgentWorktreeReap::Reaped { branch: branch_outcome } = outcome else {
4415            panic!("a clean tree is removed, got {outcome:?}");
4416        };
4417        assert!(!wt.exists(), "the directory is gone");
4418        assert!(
4419            matches!(branch_outcome, BranchReapOutcome::Kept { .. }),
4420            "the branch holds a unique commit, got {branch_outcome:?}"
4421        );
4422        assert!(branch_exists(dir.path(), &branch));
4423    }
4424
4425    /// A subagent may check out its own branch inside the worktree (the
4426    /// leaked review-971 shape). The reap aims at the conventional branch
4427    /// only, never at whatever was checked out.
4428    #[test]
4429    fn never_aims_at_a_branch_the_subagent_checked_out_itself() {
4430        let (dir, wt, branch) = init_repo_with_agent_worktree("abc123");
4431        run_git(&wt, &["checkout", "-q", "-b", "review-971"]);
4432        let agent_wt = agent_worktree_for(dir.path(), "abc123", &branch);
4433
4434        let outcome = reap_agent_worktree(&agent_wt);
4435
4436        assert!(
4437            matches!(outcome, AgentWorktreeReap::Reaped { .. }),
4438            "the tree is clean, got {outcome:?}"
4439        );
4440        assert!(!wt.exists());
4441        assert!(!branch_exists(dir.path(), &branch), "the conventional branch goes");
4442        assert!(
4443            branch_exists(dir.path(), "review-971"),
4444            "the subagent's own branch is not the reap's target",
4445        );
4446    }
4447
4448    /// A detached worktree HEAD is invisible to the post-removal branch
4449    /// reap (`--all` reads refs, not linked worktree HEADs), so commits
4450    /// there would strand on removal. No upstream exists to trip the
4451    /// unpushed check, and the tree reads porcelain-clean - exactly the
4452    /// shape the reap otherwise removes.
4453    #[test]
4454    fn keeps_a_detached_agent_worktree_whose_commits_no_ref_reaches() {
4455        let dir = init_repo_with_commit();
4456        let wt = dir.path().join(".claude").join("worktrees").join("agent-abc123");
4457        std::fs::create_dir_all(wt.parent().expect("parent")).expect("mkdir");
4458        run_git(
4459            dir.path(),
4460            &["worktree", "add", "-q", "--detach", wt.to_str().expect("utf8 path")],
4461        );
4462        fs::write(wt.join("work.txt"), "committed while detached").expect("write work");
4463        run_git(&wt, &["add", "."]);
4464        run_git(&wt, &["commit", "-q", "-m", "detached work"]);
4465        let agent_wt = agent_worktree_for(dir.path(), "abc123", "worktree-agent-abc123");
4466
4467        let outcome = reap_agent_worktree(&agent_wt);
4468
4469        let AgentWorktreeReap::KeptUniqueCommit { tip } = outcome else {
4470            panic!("a detached HEAD holding unique commits keeps the tree, got {outcome:?}");
4471        };
4472        assert_eq!(
4473            tip,
4474            git_stdout(&wt, &["rev-parse", "HEAD"]),
4475            "the warning names the full tip, so the commit stays findable"
4476        );
4477        assert!(wt.exists(), "the tree survives, and the commit with it");
4478    }
4479
4480    /// The second leaked worktree sat on a detached HEAD: no branch to
4481    /// reap, removal must still work.
4482    #[test]
4483    fn removes_a_detached_head_agent_worktree() {
4484        let dir = init_repo_with_commit();
4485        let wt = dir.path().join(".claude").join("worktrees").join("agent-abc123");
4486        std::fs::create_dir_all(wt.parent().expect("parent")).expect("mkdir");
4487        run_git(
4488            dir.path(),
4489            &["worktree", "add", "-q", "--detach", wt.to_str().expect("utf8 path")],
4490        );
4491        let agent_wt = agent_worktree_for(dir.path(), "abc123", "worktree-agent-abc123");
4492
4493        let outcome = reap_agent_worktree(&agent_wt);
4494
4495        assert!(
4496            matches!(outcome, AgentWorktreeReap::Reaped { .. }),
4497            "a detached clean worktree is reaped, got {outcome:?}"
4498        );
4499        assert!(!wt.exists());
4500        assert!(
4501            !branch_exists(dir.path(), "worktree-agent-abc123"),
4502            "the conventional branch never existed here"
4503        );
4504    }
4505
4506    #[test]
4507    fn reports_absent_when_the_worktree_is_already_gone() {
4508        let dir = init_repo_with_commit();
4509        let agent_wt = agent_worktree_for(dir.path(), "abc123", "worktree-agent-abc123");
4510
4511        assert!(
4512            matches!(reap_agent_worktree(&agent_wt), AgentWorktreeReap::Absent),
4513            "the CLI's own auto-clean may have removed it first"
4514        );
4515    }
4516
4517    #[test]
4518    fn reports_not_a_worktree_for_a_plain_directory() {
4519        let dir = tempdir().expect("tempdir");
4520        let fake = dir.path().join(".claude").join("worktrees").join("agent-abc123");
4521        std::fs::create_dir_all(&fake).expect("mkdir");
4522        let agent_wt = AgentWorktree {
4523            path: fake,
4524            repo_root: dir.path().to_path_buf(),
4525            branch: "worktree-agent-abc123".to_owned(),
4526        };
4527
4528        assert!(
4529            matches!(reap_agent_worktree(&agent_wt), AgentWorktreeReap::NotAWorktree),
4530            "a directory git does not vouch for is never removed",
4531        );
4532    }
4533
4534    #[test]
4535    fn stale_agent_worktrees_lists_a_tree_once_its_age_elapses() {
4536        let (dir, wt, branch) = init_repo_with_agent_worktree("abc123");
4537
4538        let listed =
4539            stale_agent_worktrees(dir.path(), std::time::Duration::ZERO).expect("listing works");
4540        assert_eq!(listed.len(), 1, "a fresh tree is stale at age zero: {listed:?}");
4541        // The listing composes from the canonical base, as the CLI does.
4542        assert_eq!(
4543            fs::canonicalize(&listed[0].path).expect("canonical listed tree"),
4544            fs::canonicalize(&wt).expect("canonical worktree"),
4545        );
4546        assert_eq!(listed[0].branch, branch);
4547        assert_eq!(listed[0].repo_root, dir.path());
4548
4549        assert!(
4550            stale_agent_worktrees(dir.path(), std::time::Duration::from_secs(3600))
4551                .expect("listing works")
4552                .is_empty(),
4553            "a tree younger than the age is not listed"
4554        );
4555    }
4556
4557    /// A project that is itself a linked worktree: the CLI's agent
4558    /// worktrees live under the MAIN checkout, and the sweep must list
4559    /// there or every tree it never sees leaks (#2460).
4560    #[test]
4561    fn stale_agent_worktrees_reads_the_main_checkout_for_a_linked_project() {
4562        let repo = init_repo_with_commit();
4563        let main = fs::canonicalize(repo.path()).expect("canonical main");
4564        let linked = main.join("linked");
4565        run_git(&main, &["worktree", "add", "-q", "-b", "linked", linked.to_str().expect("utf8")]);
4566        let branch = add_worker_worktree(&main, "agent-abc123");
4567
4568        let listed = stale_agent_worktrees(&linked, std::time::Duration::ZERO).expect("listing");
4569        assert_eq!(
4570            listed.len(),
4571            1,
4572            "the tree under the main checkout is seen from the linked project: {listed:?}",
4573        );
4574        assert_eq!(
4575            fs::canonicalize(&listed[0].path).expect("canonical listed tree"),
4576            main.join(".claude").join("worktrees").join("agent-abc123"),
4577        );
4578        assert_eq!(listed[0].branch, branch);
4579    }
4580
4581    /// A pid whose process has already exited, for dead-owner fixtures.
4582    fn dead_child_pid() -> u32 {
4583        let mut child = std::process::Command::new("true").spawn().expect("spawn true");
4584        let pid = child.id();
4585        child.wait().expect("wait true");
4586        pid
4587    }
4588
4589    /// A lock means a live CLI process owns the tree; the sweep must not
4590    /// list it, because the reap's removal unlocks first.
4591    #[test]
4592    fn stale_agent_worktrees_never_lists_a_locked_tree() {
4593        let (dir, wt, _branch) = init_repo_with_agent_worktree("abc123");
4594        run_git(dir.path(), &["worktree", "lock", wt.to_str().expect("utf8 path")]);
4595
4596        assert!(
4597            stale_agent_worktrees(dir.path(), std::time::Duration::ZERO)
4598                .expect("listing works")
4599                .is_empty(),
4600            "a locked tree stays out of the sweep"
4601        );
4602    }
4603
4604    /// The lock the CLI stamps on a live isolation agent's tree: a pid
4605    /// this process can prove alive. Measured live on CLI 2.1.220.
4606    #[test]
4607    fn stale_agent_worktrees_skips_a_live_pid_lock() {
4608        let (dir, wt, _branch) = init_repo_with_agent_worktree("abc123");
4609        let reason = format!("claude agent agent-abc123 (pid {} start now)", std::process::id());
4610        run_git(
4611            dir.path(),
4612            &["worktree", "lock", "--reason", &reason, wt.to_str().expect("utf8 path")],
4613        );
4614
4615        assert!(
4616            stale_agent_worktrees(dir.path(), std::time::Duration::ZERO)
4617                .expect("listing works")
4618                .is_empty(),
4619            "a live owner keeps its tree out of the sweep"
4620        );
4621    }
4622
4623    /// A crashed CLI leaves its pid behind; a dead pid makes the tree
4624    /// reapable again instead of leaking forever (git never prunes
4625    /// locked worktrees on its own).
4626    #[test]
4627    fn stale_agent_worktrees_lists_a_dead_pid_lock() {
4628        let (dir, wt, _branch) = init_repo_with_agent_worktree("abc123");
4629        let exited = std::process::Command::new("true")
4630            .spawn()
4631            .expect("spawn true")
4632            .wait()
4633            .expect("wait true");
4634        assert!(exited.success());
4635        let dead = dead_child_pid();
4636        let reason = format!("claude agent agent-abc123 (pid {dead} start now)");
4637        run_git(
4638            dir.path(),
4639            &["worktree", "lock", "--reason", &reason, wt.to_str().expect("utf8 path")],
4640        );
4641
4642        let listed =
4643            stale_agent_worktrees(dir.path(), std::time::Duration::ZERO).expect("listing works");
4644        assert_eq!(listed.len(), 1, "a dead owner's leftover is reapable: {listed:?}");
4645    }
4646
4647    /// A lock reason without a pid is one we did not write; its
4648    /// lifecycle is not ours to judge, so it skips.
4649    #[test]
4650    fn stale_agent_worktrees_skips_a_lock_without_a_parsable_pid() {
4651        let (dir, wt, _branch) = init_repo_with_agent_worktree("abc123");
4652        run_git(
4653            dir.path(),
4654            &["worktree", "lock", "--reason", "held by hand", wt.to_str().expect("utf8 path")],
4655        );
4656
4657        assert!(
4658            stale_agent_worktrees(dir.path(), std::time::Duration::ZERO)
4659                .expect("listing works")
4660                .is_empty()
4661        );
4662    }
4663
4664    /// The gate the sweep re-runs immediately before each removal, so a
4665    /// lock taken after the listing still stops that reap.
4666    #[test]
4667    fn agent_worktree_lock_is_live_distinguishes_the_three_states() {
4668        let (dir, wt_locked, _b) = init_repo_with_agent_worktree("locked1");
4669        let reason = format!("claude agent agent-locked1 (pid {} start now)", std::process::id());
4670        run_git(
4671            dir.path(),
4672            &["worktree", "lock", "--reason", &reason, wt_locked.to_str().expect("utf8 path")],
4673        );
4674        let (_d2, wt_dead, _b2) = {
4675            let (dir2, wt2, b2) = init_repo_with_agent_worktree("dead1");
4676            let dead = dead_child_pid();
4677            let reason = format!("claude agent agent-dead1 (pid {dead} start now)");
4678            run_git(
4679                dir2.path(),
4680                &["worktree", "lock", "--reason", &reason, wt2.to_str().expect("utf8 path")],
4681            );
4682            (dir2, wt2, b2)
4683        };
4684        let (_d3, wt_unlocked, _b3) = init_repo_with_agent_worktree("free1");
4685
4686        assert!(agent_worktree_lock_is_live(dir.path(), &wt_locked), "a live owner's lock is live");
4687        assert!(!agent_worktree_lock_is_live(dir.path(), &wt_dead), "a dead owner's lock is not");
4688        assert!(
4689            !agent_worktree_lock_is_live(dir.path(), &wt_unlocked),
4690            "an unlocked tree has nothing live",
4691        );
4692    }
4693
4694    #[test]
4695    fn stale_agent_worktrees_ignores_non_agent_names_and_plain_files() {
4696        let (dir, _wt, _branch) = init_repo_with_agent_worktree("abc123");
4697        add_worker_worktree(dir.path(), "worker-z");
4698        let scratch = dir.path().join(".claude").join("worktrees").join("agent-plain-file");
4699        fs::write(&scratch, "a file, not a tree").expect("write scratch");
4700
4701        let listed =
4702            stale_agent_worktrees(dir.path(), std::time::Duration::ZERO).expect("listing works");
4703        assert_eq!(listed.len(), 1, "only the agent-prefixed dir is listed: {listed:?}");
4704        assert!(listed[0].path.ends_with("agent-abc123"), "{:?}", listed[0].path);
4705    }
4706
4707    /// A worktrees dir that cannot be read propagates the error - the
4708    /// sweep must not read an unreadable listing as an empty one.
4709    #[cfg(unix)]
4710    #[test]
4711    fn stale_agent_worktrees_propagates_an_unreadable_worktrees_dir() {
4712        use std::os::unix::fs::PermissionsExt;
4713
4714        let dir = init_repo_with_commit();
4715        let worktrees = dir.path().join(".claude").join("worktrees");
4716        std::fs::create_dir_all(&worktrees).expect("mkdir");
4717        std::fs::set_permissions(&worktrees, std::fs::Permissions::from_mode(0o000))
4718            .expect("chmod");
4719
4720        let outcome = stale_agent_worktrees(dir.path(), std::time::Duration::ZERO);
4721        std::fs::set_permissions(&worktrees, std::fs::Permissions::from_mode(0o755))
4722            .expect("restore chmod");
4723        assert!(
4724            outcome.is_err(),
4725            "an unreadable dir is an error, never an empty sweep: {outcome:?}"
4726        );
4727    }
4728
4729    #[test]
4730    fn stale_agent_worktrees_is_empty_outside_a_repo() {
4731        let dir = tempdir().expect("tempdir");
4732        assert!(
4733            stale_agent_worktrees(dir.path(), std::time::Duration::ZERO)
4734                .expect("listing works")
4735                .is_empty()
4736        );
4737    }
4738}