Skip to main content

zeph_worktree/
manager.rs

1// SPDX-License-Identifier: MIT
2//! [`WorktreeManager`] — lifecycle management for per-subagent git worktrees.
3
4use std::{
5    path::{Path, PathBuf},
6    process::Output,
7    time::{Instant, SystemTime},
8};
9
10use tracing::instrument;
11use zeph_config::{WorktreeBaseRef, WorktreeConfig};
12
13use crate::{
14    error::WorktreeError,
15    git_runner::GitRunner,
16    handle::{BARE_WORKTREE_SENTINEL, DETACHED_BRANCH_SENTINEL, StaleWorktree, WorktreeHandle},
17    sanitize::{canonicalize_root, validate_branch_component},
18    usage::{QuotaStatus, WorktreeDiskUsage},
19};
20
21/// Manages the full lifecycle of per-subagent git worktrees.
22///
23/// `WorktreeManager` is parameterised over a [`GitRunner`] so that unit tests
24/// can inject a `FakeGitRunner` (defined in the test module) without touching
25/// the file system.  Production code uses
26/// [`DefaultWorktreeManager`][crate::DefaultWorktreeManager].
27///
28/// ## Concurrency
29///
30/// The internal handle list is guarded by a [`std::sync::Mutex`].  Most
31/// methods acquire this lock for the minimum necessary duration — they
32/// never hold it across an `.await` on an external resource.
33///
34/// [`create`][Self::create] is the exception: its quota-check-through-
35/// registration sequence is additionally guarded end-to-end by a
36/// [`tokio::sync::Mutex`] (`admission_lock`), held across every `.await` in
37/// that sequence. This makes admission to `max_worktrees` safe against
38/// concurrent in-process `create()` calls without relying on caller-side
39/// locking — see `create`'s `# Concurrency` section for details.
40///
41/// ## TODO
42///
43/// TODO(critic D1): concurrent per-agent cwd isolation requires child-process
44/// bgIsolation or full `ToolExecutor` cwd-threading; in-process MVP is
45/// concurrency-1 only.
46pub struct WorktreeManager<R: GitRunner> {
47    /// Canonical absolute path to the repository root.
48    repo_root: PathBuf,
49    /// Canonicalised, validated worktree root — computed once in [`Self::new`]
50    /// and reused by [`Self::create`] on every call, since `config.root` and
51    /// `repo_root` never change for the lifetime of the manager.
52    worktree_root: PathBuf,
53    /// Resolved config for this manager instance.
54    config: WorktreeConfig,
55    /// Abstraction over `git` invocations (swapped for fakes in tests).
56    runner: R,
57    /// In-memory list of live worktree handles for the current session.
58    handles: std::sync::Mutex<Vec<WorktreeHandle>>,
59    /// Last computed disk usage, populated by [`Self::disk_usage`]. Read cheaply
60    /// (without a filesystem walk) via [`Self::cached_disk_usage`]. `None` until
61    /// the first `disk_usage()` call.
62    usage_cache: parking_lot::Mutex<Option<(Instant, WorktreeDiskUsage)>>,
63    /// Serialises [`Self::create`]'s quota-check-through-registration
64    /// sequence so concurrent in-process calls cannot both observe the same
65    /// pre-admission count and both proceed past the `max_worktrees` check.
66    /// Held across `.await` points for that entire sequence, so this must be
67    /// a [`tokio::sync::Mutex`] rather than [`std::sync::Mutex`] — see
68    /// `create`'s `# Concurrency` section.
69    admission_lock: tokio::sync::Mutex<()>,
70}
71
72impl<R: GitRunner> WorktreeManager<R> {
73    /// Creates a new manager, validating the repository root and canonicalising
74    /// the worktree root directory.
75    ///
76    /// The worktree root directory is created if it does not yet exist.  The
77    /// underlying filesystem calls (`create_dir_all`, `canonicalize`) are
78    /// offloaded to `tokio::task::spawn_blocking` so the async executor is
79    /// never stalled.
80    ///
81    /// # Errors
82    ///
83    /// - [`WorktreeError::RootOutsideRepo`] if the configured root escapes the
84    ///   repository.
85    /// - [`WorktreeError::Io`] for filesystem errors.
86    ///
87    /// # Examples
88    ///
89    /// ```no_run
90    /// use std::path::PathBuf;
91    /// use zeph_config::WorktreeConfig;
92    /// use zeph_worktree::{DefaultWorktreeManager, git_runner::DefaultGitRunner};
93    ///
94    /// # async fn example() -> Result<(), zeph_worktree::WorktreeError> {
95    /// let mgr = DefaultWorktreeManager::new(
96    ///     PathBuf::from("/path/to/repo"),
97    ///     WorktreeConfig::default(),
98    ///     DefaultGitRunner::new(),
99    /// ).await?;
100    /// # Ok(())
101    /// # }
102    /// ```
103    pub async fn new(
104        repo_root: PathBuf,
105        config: WorktreeConfig,
106        runner: R,
107    ) -> Result<Self, WorktreeError> {
108        // Validate the root now so bootstrap fails fast rather than at first spawn,
109        // and cache the result for reuse by `create()` on every subsequent call.
110        // Offload blocking I/O (create_dir_all + canonicalize) to a dedicated thread.
111        let root = PathBuf::from(&config.root);
112        let repo = repo_root.clone();
113        let worktree_root = tokio::task::spawn_blocking(move || canonicalize_root(&root, &repo))
114            .await
115            .map_err(|e| WorktreeError::Io(std::io::Error::other(e)))??;
116
117        Ok(Self {
118            repo_root,
119            worktree_root,
120            config,
121            runner,
122            handles: std::sync::Mutex::new(Vec::new()),
123            usage_cache: parking_lot::Mutex::new(None),
124            admission_lock: tokio::sync::Mutex::new(()),
125        })
126    }
127
128    /// Returns the repository root this manager was constructed with.
129    #[must_use]
130    pub fn repo_root(&self) -> &Path {
131        &self.repo_root
132    }
133
134    /// Returns the [`WorktreeConfig`] this manager was constructed with.
135    ///
136    /// Exposed so callers that only hold the manager (not the original config, e.g.
137    /// `/worktree list` in `zeph-core`) can read `max_worktrees`/`disk_quota_mb` for
138    /// quota-status formatting without threading the config separately.
139    #[must_use]
140    pub fn config(&self) -> &WorktreeConfig {
141        &self.config
142    }
143
144    /// Whether [`remove`][Self::remove] should also delete the branch, per the
145    /// `WorktreeConfig::prune_branch_on_remove` this manager was constructed with.
146    ///
147    /// Exposed so callers that only hold the manager (not the original config, e.g.
148    /// `/worktree clean` in `zeph-core`) don't need to thread the config separately.
149    #[must_use]
150    pub fn prune_branch_on_remove(&self) -> bool {
151        self.config.prune_branch_on_remove
152    }
153
154    /// Creates a new worktree for `subagent_id` according to the configured
155    /// `base_ref` strategy.
156    ///
157    /// The branch name is `"{branch_prefix}{subagent_id}"`.  The path on disk is
158    /// `"{root}/{subagent_id}"`.
159    ///
160    /// ## Admission cap
161    ///
162    /// When `config.max_worktrees` is `Some(max)`, this call first counts all
163    /// git-registered secondary worktrees under `root` — via
164    /// [`reconcile`][Self::reconcile] (stale/foreign entries) plus
165    /// [`list`][Self::list] (this session's own) — and fails with
166    /// [`WorktreeError::QuotaExceeded`] if that count is already `>= max`. The
167    /// count includes worktrees created by *other*, concurrently running zeph
168    /// sessions over the same `root`, since they consume the same disk budget
169    /// `max_worktrees` is meant to bound — but, per [`reconcile`][Self::reconcile]'s
170    /// "Scope" section, excludes worktrees created by unrelated tooling elsewhere in the
171    /// repository (e.g. `EnterWorktree`, a manual `git worktree add` outside `root`), which
172    /// do not consume this budget and must not count against it (#6257). This is a
173    /// best-effort **soft** cap across *processes*: the count-then-`git worktree add`
174    /// sequence is not atomic across separate zeph sessions, so two concurrent `create()`
175    /// calls in different processes can both pass the check and briefly push the total
176    /// above `max`. No cross-process locking is used to close that gap — it is out of
177    /// scope for the size of this feature. Within a single process, admission is a
178    /// **hard** guarantee — see `# Concurrency` below.
179    ///
180    /// ## Concurrency
181    ///
182    /// The quota-check-through-registration sequence (the count read, the
183    /// `max` comparison, `git worktree add`, and the final push onto the
184    /// in-memory handle list) is serialised end-to-end by an internal
185    /// `tokio::sync::Mutex`, held across every `.await` in that span. Two
186    /// concurrent in-process `create()` calls on the same `WorktreeManager`
187    /// can therefore never both observe the same pre-admission count and
188    /// both proceed past the `max_worktrees` check — the second call's count
189    /// read always reflects the first call's completed registration. Callers
190    /// do not need to replicate external locking for quota-safety purposes;
191    /// any locking they hold (e.g. `zeph-subagent`'s `cwd_lock`) exists for
192    /// unrelated invariants and is not load-bearing for `max_worktrees`
193    /// enforcement.
194    ///
195    /// ## TODO
196    ///
197    /// TODO(critic D2): head worktree does not include parent uncommitted changes
198    /// by design; revisit if users need stash-based propagation.
199    ///
200    /// # Errors
201    ///
202    /// - [`WorktreeError::InvalidBranchName`] when `subagent_id` fails validation.
203    /// - [`WorktreeError::QuotaExceeded`] when `config.max_worktrees` would be
204    ///   reached or exceeded (see "Admission cap" above).
205    /// - [`WorktreeError::PathExists`] when the worktree path already exists.
206    /// - [`WorktreeError::BaseRefUnresolved`] when `base_ref = Fresh` and the
207    ///   default branch cannot be resolved.
208    /// - [`WorktreeError::GitCommand`] for any `git` failure.
209    ///
210    /// # Examples
211    ///
212    /// ```no_run
213    /// # async fn example(mgr: zeph_worktree::DefaultWorktreeManager) -> Result<(), zeph_worktree::WorktreeError> {
214    /// let handle = mgr.create("agent-42").await?;
215    /// println!("Worktree at {:?} on branch {}", handle.path, handle.branch_name);
216    /// # Ok(())
217    /// # }
218    /// ```
219    // `err(level = WARN)` logs every `Err` return (including `QuotaExceeded`, whose
220    // `Display` already carries `current`/`max`) at `warn` with the `subagent_id` span
221    // field attached — previously this failed silently with zero log evidence (#6257).
222    #[instrument(
223        name = "worktree.create",
224        skip(self),
225        fields(subagent_id = %subagent_id),
226        err(level = tracing::Level::WARN)
227    )]
228    pub async fn create(&self, subagent_id: &str) -> Result<WorktreeHandle, WorktreeError> {
229        validate_branch_component(subagent_id)?;
230
231        // Serialises the quota-check-through-registration sequence below so
232        // two concurrent in-process `create()` calls can never both observe
233        // the same pre-admission count and both proceed past the
234        // `max_worktrees` check (see `# Concurrency` above).
235        let _admission_guard = self.admission_lock.lock().await;
236
237        if let Some(max) = self.config.max_worktrees {
238            let current = self.reconcile().await?.len() + self.list().len();
239            if current >= max {
240                return Err(WorktreeError::QuotaExceeded { current, max });
241            }
242        }
243
244        let branch_name = format!("{}{}", self.config.branch_prefix, subagent_id);
245        let path = self.worktree_root.join(subagent_id);
246
247        if path.exists() {
248            return Err(WorktreeError::PathExists(path));
249        }
250
251        // Head and any future non-exhaustive variants branch from local HEAD.
252        let (base_ref_resolved, commitish) = if let WorktreeBaseRef::Fresh = &self.config.base_ref {
253            let branch = self.resolve_default_branch().await?;
254            self.fetch_origin(&branch).await?;
255            self.verify_commitish(&format!("origin/{branch}")).await?;
256            let resolved = format!("origin/{branch}");
257            (resolved.clone(), resolved)
258        } else {
259            self.check_dirty_tree().await;
260            ("HEAD".to_string(), "HEAD".to_string())
261        };
262
263        let path_str = path.to_string_lossy();
264        self.git_worktree_add(&branch_name, &path_str, &commitish)
265            .await?;
266
267        let handle = WorktreeHandle {
268            path,
269            branch_name,
270            base_ref_resolved,
271            subagent_id: subagent_id.to_string(),
272            created_at: SystemTime::now(),
273        };
274
275        self.handles
276            .lock()
277            .unwrap_or_else(std::sync::PoisonError::into_inner)
278            .push(handle.clone());
279        Ok(handle)
280    }
281
282    /// Removes the worktree identified by `handle`.
283    ///
284    /// If `prune_branch` is `true`, also deletes the git branch after removing
285    /// the worktree directory.
286    ///
287    /// The in-memory handle is dropped as soon as the worktree directory has
288    /// been removed from disk, regardless of whether the subsequent branch
289    /// prune succeeds. This keeps [`list`][Self::list] from ever reporting a
290    /// path that no longer exists on disk, even if the branch prune step
291    /// fails below.
292    ///
293    /// This issues a single `git worktree remove --force`, which bypasses
294    /// git's "refuse to remove a dirty working tree" guard but — deliberately
295    /// — does *not* override an explicit `git worktree lock`; git demands a
296    /// second `-f` (`remove -f -f`) for that. Callers deciding *whether* to
297    /// call `remove` on a worktree not created by this session (e.g.
298    /// `zeph worktree clean`) MUST gate on
299    /// [`StaleWorktree::is_safe_to_force_remove`][crate::StaleWorktree::is_safe_to_force_remove]
300    /// or an explicit operator override first — `remove` itself performs no
301    /// such check (#6055).
302    ///
303    /// # Errors
304    ///
305    /// Returns [`WorktreeError::GitCommand`] if either git command fails. If
306    /// the `op` field is `"branch -D"`, the worktree itself was already
307    /// removed and the handle already dropped — only the branch delete
308    /// failed.
309    ///
310    /// # Examples
311    ///
312    /// ```no_run
313    /// # async fn example(mgr: zeph_worktree::DefaultWorktreeManager, handle: zeph_worktree::WorktreeHandle) -> Result<(), zeph_worktree::WorktreeError> {
314    /// mgr.remove(&handle, false).await?;
315    /// # Ok(())
316    /// # }
317    /// ```
318    #[instrument(name = "worktree.remove", skip(self), fields(branch = %handle.branch_name))]
319    pub async fn remove(
320        &self,
321        handle: &WorktreeHandle,
322        prune_branch: bool,
323    ) -> Result<(), WorktreeError> {
324        let path_str = handle.path.to_string_lossy().to_string();
325
326        let out = self
327            .runner
328            .run(
329                &["worktree", "remove", "--force", "--", &path_str],
330                &self.repo_root,
331            )
332            .await?;
333        check_git_status(&out, "worktree remove")?;
334
335        // The worktree directory is gone from disk now — drop the in-memory
336        // handle unconditionally so a subsequent branch-prune failure below
337        // never leaves `self.handles` pointing at a nonexistent path.
338        self.handles
339            .lock()
340            .unwrap_or_else(std::sync::PoisonError::into_inner)
341            .retain(|h| h.path != handle.path);
342
343        if prune_branch {
344            if handle.branch_name == DETACHED_BRANCH_SENTINEL {
345                // A detached-HEAD worktree (see `reconcile`) has no branch to
346                // prune; `git branch -D` would just fail against the sentinel.
347                tracing::debug!(
348                    path = %handle.path.display(),
349                    "skipping branch prune for detached-HEAD worktree"
350                );
351            } else {
352                let branch = &handle.branch_name;
353                let out = self
354                    .runner
355                    .run(&["branch", "-D", "--", branch], &self.repo_root)
356                    .await?;
357                check_git_status(&out, "branch -D")?;
358            }
359        }
360
361        Ok(())
362    }
363
364    /// Returns a snapshot of the in-memory handle list for the current session.
365    ///
366    /// This list only contains worktrees created in the current process.  To
367    /// discover worktrees that exist in the git registry but not in memory (e.g.
368    /// after a crash), use [`reconcile`][Self::reconcile].
369    ///
370    /// # Examples
371    ///
372    /// ```no_run
373    /// # fn example(mgr: &zeph_worktree::DefaultWorktreeManager) {
374    /// let handles = mgr.list();
375    /// println!("{} active worktrees", handles.len());
376    /// # }
377    /// ```
378    pub fn list(&self) -> Vec<WorktreeHandle> {
379        self.handles
380            .lock()
381            .unwrap_or_else(std::sync::PoisonError::into_inner)
382            .clone()
383    }
384
385    /// Reads the git worktree registry and returns [`StaleWorktree`] entries for
386    /// worktrees that exist on disk but are not in the current session's
387    /// in-memory list.
388    ///
389    /// This is called by the `zeph worktree list` and `zeph worktree clean`
390    /// CLI subcommands (`handle_worktree_command` in `src/commands/worktree.rs`)
391    /// to recover from a previous crash that left stale worktrees behind. There
392    /// is no startup caller — worktrees are only reconciled on-demand via these
393    /// subcommands. Each entry carries
394    /// git's own `prunable` verdict (see [`StaleWorktree::is_safe_to_force_remove`])
395    /// so callers can distinguish a worktree whose directory is already gone
396    /// from one that is merely untracked by *this* process — the latter may
397    /// belong to another, concurrently running session and MUST NOT be
398    /// force-removed without an explicit operator override (#6055).
399    ///
400    /// ## Scope
401    ///
402    /// Only entries whose path falls under this manager's canonicalised
403    /// `worktree_root` (`config.root`, resolved at construction) are returned. `git
404    /// worktree list --porcelain` reports *every* worktree registered against the
405    /// repository, including ones created by unrelated tooling — e.g. the `EnterWorktree`
406    /// developer tool, or a manual `git worktree add` — under a completely different
407    /// directory. Those are not managed by this subsystem: `zeph worktree clean` must
408    /// never remove them, and [`create`][Self::create]'s `max_worktrees` admission count
409    /// must not be inflated by them (#6257). A worktree created by *another*,
410    /// concurrently running zeph session that shares the same `config.root` still counts
411    /// — it is under `worktree_root` and is legitimately this subsystem's responsibility,
412    /// even though it is untracked by *this* process's in-memory handle list.
413    ///
414    /// # Errors
415    ///
416    /// Returns [`WorktreeError::GitCommand`] if `git worktree list` fails.
417    ///
418    /// # Examples
419    ///
420    /// ```no_run
421    /// # async fn example(mgr: &zeph_worktree::DefaultWorktreeManager) -> Result<(), zeph_worktree::WorktreeError> {
422    /// let stale = mgr.reconcile().await?;
423    /// for s in &stale {
424    ///     println!("stale worktree: {:?} (safe to force-remove: {})", s.handle.path, s.is_safe_to_force_remove());
425    /// }
426    /// # Ok(())
427    /// # }
428    /// ```
429    #[instrument(name = "worktree.reconcile", skip(self))]
430    pub async fn reconcile(&self) -> Result<Vec<StaleWorktree>, WorktreeError> {
431        let out = self
432            .runner
433            .run(&["worktree", "list", "--porcelain"], &self.repo_root)
434            .await?;
435        check_git_status(&out, "worktree list")?;
436
437        let output_str = String::from_utf8_lossy(&out.stdout);
438        let raw_entries = parse_worktree_list_porcelain(&output_str);
439
440        let session_paths: std::collections::HashSet<PathBuf> = self
441            .handles
442            .lock()
443            .unwrap_or_else(std::sync::PoisonError::into_inner)
444            .iter()
445            .map(|h| h.path.clone())
446            .collect();
447
448        let stale = raw_entries
449            .into_iter()
450            .filter(|e| !session_paths.contains(&e.path))
451            // Skip the main worktree (repo_root itself).
452            .filter(|e| e.path != self.repo_root)
453            // Skip worktrees not managed by this subsystem (see "Scope" above, #6257).
454            .filter(|e| e.path.starts_with(&self.worktree_root))
455            .map(|e| {
456                let branch_name = match (e.branch, e.is_bare) {
457                    (Some(branch), _) => branch,
458                    (None, true) => BARE_WORKTREE_SENTINEL.to_string(),
459                    (None, false) => DETACHED_BRANCH_SENTINEL.to_string(),
460                };
461                StaleWorktree {
462                    handle: WorktreeHandle {
463                        path: e.path,
464                        branch_name,
465                        base_ref_resolved: String::new(),
466                        subagent_id: String::new(),
467                        created_at: SystemTime::UNIX_EPOCH,
468                    },
469                    prunable_reason: e.prunable_reason,
470                }
471            })
472            .collect();
473
474        Ok(stale)
475    }
476
477    /// Runs `git worktree prune` to clear stale administrative entries from
478    /// the git worktree registry (e.g. left behind when a worktree directory
479    /// was deleted directly instead of via [`remove`][Self::remove]).
480    ///
481    /// Per FR-CLEANUP-04, this SHALL be called by `zeph worktree clean` after
482    /// [`reconcile`][Self::reconcile]'s stale entries have been removed via
483    /// `git worktree remove --force`.
484    ///
485    /// # Errors
486    ///
487    /// Returns [`WorktreeError::GitCommand`] if `git worktree prune` fails.
488    ///
489    /// # Examples
490    ///
491    /// ```no_run
492    /// # async fn example(mgr: &zeph_worktree::DefaultWorktreeManager) -> Result<(), zeph_worktree::WorktreeError> {
493    /// mgr.prune().await?;
494    /// # Ok(())
495    /// # }
496    /// ```
497    #[instrument(name = "worktree.prune", skip(self), err)]
498    pub async fn prune(&self) -> Result<(), WorktreeError> {
499        let out = self
500            .runner
501            .run(&["worktree", "prune"], &self.repo_root)
502            .await?;
503        check_git_status(&out, "worktree prune")?;
504        Ok(())
505    }
506
507    /// Runs the full `worktree clean` pipeline: [`reconcile`][Self::reconcile], then
508    /// [`remove`][Self::remove] each stale entry that is either `prunable` or covered by
509    /// `force`, then [`prune`][Self::prune] the registry.
510    ///
511    /// Shared by the CLI (`zeph worktree clean`, `src/commands/worktree.rs`) and the
512    /// agent-side `/worktree clean` slash command (`crates/zeph-core/src/agent/
513    /// worktree_commands.rs`) so their removed/skipped/errored counts and per-entry
514    /// warnings cannot silently diverge — this exact divergence (a discarded `prune()`
515    /// failure on one call site) was caught in review during #6141 (#6142).
516    ///
517    /// `force_hint` is substituted into the skip-warning for a non-`force` run advising
518    /// the operator how to override it (e.g. `` `zeph worktree clean --force` `` for the
519    /// CLI, `` `/worktree clean --force` `` for the slash command) — the only piece of
520    /// UX text that legitimately differs between the two surfaces.
521    ///
522    /// This is a thin wrapper around an internal `clean_from_stale` helper —
523    /// [`sweep`][Self::sweep] calls that helper directly with an already-fetched
524    /// `stale` list so a single `sweep()` tick only ever issues one `reconcile()`
525    /// subprocess call (#6205).
526    ///
527    /// # Errors
528    ///
529    /// Returns [`WorktreeError::GitCommand`] only if the initial [`reconcile`][Self::reconcile] call
530    /// fails — nothing has been removed yet, so there is no partial outcome to lose.
531    /// Per-entry removal failures and a final prune failure are both recorded in the
532    /// returned [`CleanOutcome`] instead of aborting.
533    ///
534    /// # Examples
535    ///
536    /// ```no_run
537    /// # async fn example(mgr: &zeph_worktree::DefaultWorktreeManager) -> Result<(), zeph_worktree::WorktreeError> {
538    /// let outcome = mgr.clean(false, false, "`zeph worktree clean --force`").await?;
539    /// println!("{}", zeph_worktree::format_clean_summary(&outcome));
540    /// # Ok(())
541    /// # }
542    /// ```
543    pub async fn clean(
544        &self,
545        force: bool,
546        prune_branch_on_remove: bool,
547        force_hint: &str,
548    ) -> Result<CleanOutcome, WorktreeError> {
549        let stale = self.reconcile().await?;
550        let (outcome, _remaining) = self
551            .clean_from_stale(stale, force, prune_branch_on_remove, force_hint)
552            .await;
553        Ok(outcome)
554    }
555
556    /// Removal-and-prune half of the `clean` pipeline, operating on an already-fetched
557    /// `stale` list rather than calling [`reconcile`][Self::reconcile] itself.
558    ///
559    /// For each entry, removes it via [`remove`][Self::remove] when it is either
560    /// `prunable` or covered by `force`; otherwise leaves it in place with a skip
561    /// warning. Always runs [`prune`][Self::prune] afterward (FR-CLEANUP-04), recording
562    /// a failure as a warning rather than aborting.
563    ///
564    /// Returns the [`CleanOutcome`] alongside the "remaining" stale entries — the
565    /// entries from `stale` that were *not* successfully removed (skipped or
566    /// errored) — so a caller like [`sweep`][Self::sweep] can derive a post-clean
567    /// worktree count purely in-memory, without a second `reconcile()` subprocess call.
568    async fn clean_from_stale(
569        &self,
570        stale: Vec<StaleWorktree>,
571        force: bool,
572        prune_branch_on_remove: bool,
573        force_hint: &str,
574    ) -> (CleanOutcome, Vec<StaleWorktree>) {
575        let mut outcome = CleanOutcome::default();
576        let mut remaining = Vec::new();
577        for stale_wt in stale {
578            if !force && !stale_wt.is_safe_to_force_remove() {
579                outcome.warnings.push(format!(
580                    "warning: skipping {} — directory exists and git does not report it as \
581                     prunable; it may be in active use by another zeph session. \
582                     Re-run with {force_hint} if you are certain it is abandoned.",
583                    stale_wt.handle.path.display()
584                ));
585                outcome.skipped += 1;
586                remaining.push(stale_wt);
587                continue;
588            }
589            if let Err(e) = self.remove(&stale_wt.handle, prune_branch_on_remove).await {
590                outcome.warnings.push(format!(
591                    "warning: failed to remove {}: {e}",
592                    stale_wt.handle.path.display()
593                ));
594                outcome.errored += 1;
595                remaining.push(stale_wt);
596            } else {
597                outcome.removed += 1;
598            }
599        }
600        // FR-CLEANUP-04: clear any remaining stale administrative entries
601        // (e.g. worktrees deleted outside Zeph) from the git registry.
602        if let Err(e) = self.prune().await {
603            outcome
604                .warnings
605                .push(format!("warning: failed to prune worktree registry: {e}"));
606        }
607        (outcome, remaining)
608    }
609
610    /// Computes total and per-worktree disk usage across every worktree under
611    /// `root` — both this session's own ([`list`][Self::list]) and any discovered
612    /// via [`reconcile`][Self::reconcile] (stale/foreign entries).
613    ///
614    /// The recursive filesystem walk runs on [`tokio::task::spawn_blocking`], so
615    /// it never stalls the async executor — but it is still an O(files-under-root)
616    /// operation that can be slow against multi-gigabyte `target/` directories.
617    /// Callers on a hot or interactive path should prefer
618    /// [`cached_disk_usage`][Self::cached_disk_usage] and only call this method
619    /// from a deliberate, infrequent trigger (a [`sweep`][Self::sweep] tick or an
620    /// explicit CLI invocation) — **never** from [`create`][Self::create].
621    ///
622    /// The reported total is a sum of logical file sizes
623    /// (`std::fs::Metadata::len`), not on-disk block usage — content shared via
624    /// hardlinks across worktrees (e.g. zeph-session blobs) can be double-counted.
625    /// Treat the result as an approximation suitable for a soft warn threshold.
626    ///
627    /// On success, the result is stored so a subsequent
628    /// [`cached_disk_usage`][Self::cached_disk_usage] call can read it without
629    /// re-walking the filesystem.
630    ///
631    /// This is a thin wrapper around an internal `disk_usage_from_paths` helper —
632    /// [`sweep`][Self::sweep] calls that helper directly with the stale paths left
633    /// over from its own internal `clean_from_stale` call, so a single `sweep()` tick
634    /// only ever issues one `reconcile()` subprocess call (#6205).
635    ///
636    /// # Errors
637    ///
638    /// Returns [`WorktreeError::GitCommand`] if the underlying
639    /// [`reconcile`][Self::reconcile] call fails, or [`WorktreeError::Io`] if the
640    /// blocking walk task itself panics or is cancelled.
641    ///
642    /// # Examples
643    ///
644    /// ```no_run
645    /// # async fn example(mgr: &zeph_worktree::DefaultWorktreeManager) -> Result<(), zeph_worktree::WorktreeError> {
646    /// let usage = mgr.disk_usage().await?;
647    /// println!("total: {} bytes across {} worktree(s)", usage.total_bytes, usage.per_worktree.len());
648    /// # Ok(())
649    /// # }
650    /// ```
651    #[instrument(name = "worktree.disk_usage", skip(self))]
652    pub async fn disk_usage(&self) -> Result<WorktreeDiskUsage, WorktreeError> {
653        let paths: Vec<PathBuf> = self
654            .reconcile()
655            .await?
656            .into_iter()
657            .map(|stale| stale.handle.path)
658            .collect();
659        self.disk_usage_from_paths(paths).await
660    }
661
662    /// Filesystem-walk half of [`disk_usage`][Self::disk_usage], operating on an
663    /// already-fetched list of stale worktree paths rather than calling
664    /// [`reconcile`][Self::reconcile] itself.
665    ///
666    /// `paths` is merged with this session's own [`list`][Self::list] paths before the
667    /// walk, matching [`disk_usage`][Self::disk_usage]'s behavior exactly.
668    async fn disk_usage_from_paths(
669        &self,
670        mut paths: Vec<PathBuf>,
671    ) -> Result<WorktreeDiskUsage, WorktreeError> {
672        paths.extend(self.list().into_iter().map(|h| h.path));
673
674        let usage = tokio::task::spawn_blocking(move || walk_worktree_sizes(&paths))
675            .await
676            .map_err(|e| WorktreeError::Io(std::io::Error::other(e)))?;
677
678        *self.usage_cache.lock() = Some((Instant::now(), usage.clone()));
679        Ok(usage)
680    }
681
682    /// Returns the disk usage computed by the most recent
683    /// [`disk_usage`][Self::disk_usage] call, without performing a filesystem
684    /// walk. Returns `None` if `disk_usage()` has never been called on this
685    /// manager instance.
686    ///
687    /// # Examples
688    ///
689    /// ```no_run
690    /// # fn example(mgr: &zeph_worktree::DefaultWorktreeManager) {
691    /// if let Some(usage) = mgr.cached_disk_usage() {
692    ///     println!("last known total: {} bytes", usage.total_bytes);
693    /// }
694    /// # }
695    /// ```
696    #[must_use]
697    pub fn cached_disk_usage(&self) -> Option<WorktreeDiskUsage> {
698        self.usage_cache
699            .lock()
700            .as_ref()
701            .map(|(_, usage)| usage.clone())
702    }
703
704    /// Runs one reconcile-and-quota sweep: a single [`reconcile`][Self::reconcile]
705    /// call feeds prunable-only auto-reclaim (the same removal-and-prune pipeline as
706    /// `zeph worktree clean`'s `clean(force=false, ..)`), then the resulting
707    /// post-clean worktree count is evaluated against `config.max_worktrees` and,
708    /// when `config.disk_quota_mb` is set, against a disk-usage walk.
709    ///
710    /// Unlike calling [`clean`][Self::clean] and [`disk_usage`][Self::disk_usage]
711    /// directly (which each perform their own `reconcile()`), `sweep()` fetches the
712    /// stale worktree list once and threads it through the internal
713    /// `clean_from_stale` and `disk_usage_from_paths` helpers — the "remaining" stale
714    /// entries `clean_from_stale` returns (i.e. `stale` minus what it just removed) are
715    /// exactly the post-clean stale state, computed in-memory rather than by
716    /// re-invoking `git worktree list --porcelain` (#6205). This makes every `sweep()`
717    /// tick issue exactly one `reconcile()` subprocess call instead of three.
718    ///
719    /// Never force-removes an intact worktree — reclamation only removes entries git
720    /// itself reports as `prunable` (spec-063 INV-5/INV-6). An over-quota state with
721    /// only intact worktrees is reported via [`QuotaStatus::is_over_quota`], never
722    /// resolved by deleting anything.
723    ///
724    /// The disk-usage walk is skipped entirely (and [`QuotaStatus::total_bytes`]
725    /// is left at `0` with [`QuotaStatus::disk_quota_bytes`] as `None`) when
726    /// `config.disk_quota_mb` is unset, avoiding the filesystem walk's cost when
727    /// there is no threshold to evaluate it against.
728    ///
729    /// ## Concurrency note
730    ///
731    /// `count` and the disk-usage figures both derive from the single
732    /// [`reconcile`][Self::reconcile] snapshot taken at the start of this call, not from
733    /// re-querying git afterward. `WorktreeManager` is typically shared (e.g. `Arc`'d
734    /// between a subagent spawn/teardown path and a periodic sweep loop), so a
735    /// worktree registry mutation that lands *during* this call (a concurrent
736    /// [`create`][Self::create]/[`remove`][Self::remove] from another task) will not be
737    /// reflected in this tick's result — the next `sweep()` tick picks it up instead.
738    /// This is a narrower staleness window than calling [`clean`][Self::clean] and
739    /// [`disk_usage`][Self::disk_usage] separately (each would re-snapshot git at its own
740    /// call time), but it does not weaken any safety invariant: reclamation still never
741    /// force-removes an intact worktree (see below), and [`disk_usage`][Self::disk_usage]
742    /// is already documented as an approximation suitable only for a soft warn threshold.
743    ///
744    /// # Errors
745    ///
746    /// Returns [`WorktreeError::GitCommand`] if the initial
747    /// [`reconcile`][Self::reconcile] call fails, or [`WorktreeError::Io`] from the
748    /// disk-usage walk when disk accounting is enabled.
749    ///
750    /// # Examples
751    ///
752    /// ```no_run
753    /// # async fn example(mgr: &zeph_worktree::DefaultWorktreeManager) -> Result<(), zeph_worktree::WorktreeError> {
754    /// let status = mgr.sweep().await?;
755    /// if status.is_over_quota() {
756    ///     eprintln!("worktrees over quota: {}/{:?}", status.count, status.max_worktrees);
757    /// }
758    /// # Ok(())
759    /// # }
760    /// ```
761    #[instrument(name = "worktree.sweep", skip(self))]
762    pub async fn sweep(&self) -> Result<QuotaStatus, WorktreeError> {
763        let stale = self.reconcile().await?;
764        let (outcome, remaining_stale) = self
765            .clean_from_stale(
766                stale,
767                false,
768                self.config.prune_branch_on_remove,
769                "`zeph worktree clean --force`",
770            )
771            .await;
772
773        let count = remaining_stale.len() + self.list().len();
774        let max_worktrees = self.config.max_worktrees;
775        let over_count = max_worktrees.is_some_and(|max| count >= max);
776
777        let (total_bytes, disk_quota_bytes, over_disk) =
778            if let Some(quota_mb) = self.config.disk_quota_mb {
779                let paths: Vec<PathBuf> = remaining_stale
780                    .into_iter()
781                    .map(|stale| stale.handle.path)
782                    .collect();
783                let usage = self.disk_usage_from_paths(paths).await?;
784                let quota_bytes = quota_mb.saturating_mul(1_048_576);
785                let over_disk = usage.total_bytes >= quota_bytes;
786                (usage.total_bytes, Some(quota_bytes), over_disk)
787            } else {
788                (0, None, false)
789            };
790
791        Ok(QuotaStatus {
792            count,
793            max_worktrees,
794            total_bytes,
795            disk_quota_bytes,
796            reclaimed: outcome.removed,
797            over_count,
798            over_disk,
799        })
800    }
801}
802
803/// Recursively sums regular-file sizes under each path in `paths`.
804///
805/// Runs on a blocking thread (see [`WorktreeManager::disk_usage`]). Entries that
806/// cannot be read (permission errors, races with concurrent removal) are silently
807/// skipped rather than failing the whole walk — a best-effort accounting is more
808/// useful than an aborted one for a soft warn threshold.
809fn walk_worktree_sizes(paths: &[PathBuf]) -> WorktreeDiskUsage {
810    let mut per_worktree = Vec::with_capacity(paths.len());
811    let mut total_bytes: u64 = 0;
812
813    for path in paths {
814        let mut size: u64 = 0;
815        for entry in walkdir::WalkDir::new(path)
816            .into_iter()
817            .filter_map(Result::ok)
818        {
819            if entry.file_type().is_file()
820                && let Ok(metadata) = entry.metadata()
821            {
822                size = size.saturating_add(metadata.len());
823            }
824        }
825        total_bytes = total_bytes.saturating_add(size);
826        per_worktree.push((path.clone(), size));
827    }
828
829    WorktreeDiskUsage {
830        total_bytes,
831        per_worktree,
832    }
833}
834
835/// Outcome of a [`WorktreeManager::clean`] pass.
836///
837/// Every stale entry `reconcile()` discovers is accounted for in exactly one of
838/// `removed`, `skipped`, or `errored` — the three always sum to the number of stale
839/// entries processed, so a caller can never silently undercount what happened.
840#[derive(Debug, Default, Clone, PartialEq, Eq)]
841pub struct CleanOutcome {
842    /// Number of stale entries successfully removed.
843    pub removed: usize,
844    /// Number of stale entries left in place because they were not `prunable` and
845    /// `force` was not passed.
846    pub skipped: usize,
847    /// Number of stale entries whose removal was attempted but the underlying
848    /// `git worktree remove` call itself failed (e.g. a locked worktree).
849    pub errored: usize,
850    /// One warning line per skipped, errored, or prune-failure event, in encounter order.
851    pub warnings: Vec<String>,
852}
853
854/// Formats a [`CleanOutcome`]'s counts into the standard one-line summary shared by
855/// both the CLI and agent-side `/worktree clean` output.
856#[must_use]
857pub fn format_clean_summary(outcome: &CleanOutcome) -> String {
858    format!(
859        "Removed {} stale worktree(s), skipped {} in-use candidate(s), {} error(s).",
860        outcome.removed, outcome.skipped, outcome.errored
861    )
862}
863
864/// Intermediate result of parsing one `git worktree list --porcelain` block,
865/// before [`reconcile`][WorktreeManager::reconcile] resolves it into a
866/// [`StaleWorktree`].
867///
868/// Kept private to this module: it exists only so the parser doesn't have to
869/// build a full [`WorktreeHandle`] (with placeholder `base_ref_resolved` /
870/// `subagent_id` / `created_at`) before the `branch_name` fallback (detached
871/// vs. bare vs. real branch) and the `prunable` verdict are both known.
872struct RawWorktreeEntry {
873    /// Absolute path from the `worktree <path>` line.
874    path: PathBuf,
875    /// `Some(name)` from a `branch refs/heads/<name>` line; `None` for a
876    /// `detached` or `bare` block.
877    branch: Option<String>,
878    /// `true` if this block's line was `bare` (the main worktree of a bare
879    /// repository) rather than `branch`/`detached`.
880    is_bare: bool,
881    /// `Some(reason)` from a `prunable <reason>` line — git's own signal that
882    /// this worktree's directory or `.git` gitdir-link is gone/broken.
883    prunable_reason: Option<String>,
884}
885
886/// Parses the output of `git worktree list --porcelain` into [`RawWorktreeEntry`]s.
887///
888/// Each worktree block in the porcelain output looks like one of:
889/// ```text
890/// worktree /path/to/worktree
891/// HEAD deadbeef...
892/// branch refs/heads/branch-name
893///
894/// ```
895/// for a worktree on a detached `HEAD`:
896/// ```text
897/// worktree /path/to/worktree
898/// HEAD deadbeef...
899/// detached
900///
901/// ```
902/// for the main worktree of a bare repository (#6052 — no `HEAD` line at all):
903/// ```text
904/// worktree /path/to/bare.git
905/// bare
906///
907/// ```
908/// or, when the directory/gitdir-link is gone or broken, with an extra line
909/// regardless of the block's other contents:
910/// ```text
911/// prunable gitdir file points to non-existent location
912/// ```
913/// A block is flushed as soon as its `worktree <path>` line is seen (i.e. when
914/// the *next* block starts, or at end of output) — regardless of whether a
915/// `branch` line was present. Detached-HEAD and bare blocks are never silently
916/// dropped (#5936, #6052).
917fn parse_worktree_list_porcelain(output: &str) -> Vec<RawWorktreeEntry> {
918    let mut result = Vec::new();
919    let mut path: Option<PathBuf> = None;
920    let mut branch: Option<String> = None;
921    let mut is_bare = false;
922    let mut prunable_reason: Option<String> = None;
923
924    for line in output.lines() {
925        if let Some(p) = line.strip_prefix("worktree ") {
926            if let Some(entry) =
927                flush_worktree_block(path.take(), branch.take(), is_bare, prunable_reason.take())
928            {
929                result.push(entry);
930            }
931            is_bare = false;
932            path = Some(PathBuf::from(p));
933        } else if let Some(b) = line.strip_prefix("branch refs/heads/") {
934            branch = Some(b.to_string());
935        } else if let Some(reason) = line.strip_prefix("prunable ") {
936            prunable_reason = Some(reason.to_string());
937        } else if line == "bare" {
938            is_bare = true;
939        }
940        // "locked ..." lines need no new handling here — a locked worktree is
941        // already protected by git's own single-`--force` refusal to remove
942        // it (see `WorktreeManager::remove`'s doc comment); "detached" lines
943        // need no explicit match either, since the `branch = None, is_bare =
944        // false` fallback already resolves to `DETACHED_BRANCH_SENTINEL`.
945    }
946
947    if let Some(entry) = flush_worktree_block(path, branch, is_bare, prunable_reason) {
948        result.push(entry);
949    }
950
951    result
952}
953
954/// Builds a [`RawWorktreeEntry`] from one parsed porcelain block, if a
955/// `worktree <path>` line was seen.
956fn flush_worktree_block(
957    path: Option<PathBuf>,
958    branch: Option<String>,
959    is_bare: bool,
960    prunable_reason: Option<String>,
961) -> Option<RawWorktreeEntry> {
962    path.map(|wt_path| RawWorktreeEntry {
963        path: wt_path,
964        branch,
965        is_bare,
966        prunable_reason,
967    })
968}
969
970// --- Internal helpers -------------------------------------------------------
971
972impl<R: GitRunner> WorktreeManager<R> {
973    /// Emits a warning if the working tree has uncommitted changes.
974    #[instrument(name = "worktree.dirty_check", skip(self))]
975    async fn check_dirty_tree(&self) {
976        match self
977            .runner
978            .run(&["status", "--porcelain"], &self.repo_root)
979            .await
980        {
981            Ok(out) if !out.stdout.is_empty() => {
982                tracing::warn!(
983                    "creating a head worktree on a dirty working tree; \
984                     uncommitted changes will NOT be visible in the worktree"
985                );
986            }
987            _ => {}
988        }
989    }
990
991    /// Resolves the default branch name from config or via `git symbolic-ref`.
992    #[instrument(name = "worktree.resolve_branch", skip(self))]
993    async fn resolve_default_branch(&self) -> Result<String, WorktreeError> {
994        if !self.config.default_branch.is_empty() {
995            return Ok(self.config.default_branch.clone());
996        }
997
998        let out = self
999            .runner
1000            .run(
1001                &["symbolic-ref", "refs/remotes/origin/HEAD"],
1002                &self.repo_root,
1003            )
1004            .await?;
1005
1006        if out.status.success() {
1007            let raw = String::from_utf8_lossy(&out.stdout);
1008            let trimmed = raw.trim();
1009            if let Some(branch) = trimmed.strip_prefix("refs/remotes/origin/") {
1010                return Ok(branch.to_string());
1011            }
1012        }
1013
1014        Err(WorktreeError::BaseRefUnresolved {
1015            attempted: "symbolic-ref refs/remotes/origin/HEAD".to_string(),
1016        })
1017    }
1018
1019    /// Runs `git fetch origin {branch}`.
1020    #[instrument(name = "worktree.fetch", skip(self), fields(branch = %branch))]
1021    async fn fetch_origin(&self, branch: &str) -> Result<(), WorktreeError> {
1022        let out = self
1023            .runner
1024            .run(&["fetch", "origin", "--", branch], &self.repo_root)
1025            .await?;
1026        check_git_status(&out, "fetch")?;
1027
1028        Ok(())
1029    }
1030
1031    /// Runs `git rev-parse --verify {commitish}` to confirm it is resolvable.
1032    #[instrument(name = "worktree.verify_commitish", skip(self), err)]
1033    async fn verify_commitish(&self, commitish: &str) -> Result<(), WorktreeError> {
1034        let out = self
1035            .runner
1036            .run(&["rev-parse", "--verify", "--", commitish], &self.repo_root)
1037            .await?;
1038        check_git_status(&out, &format!("rev-parse --verify {commitish}"))?;
1039
1040        Ok(())
1041    }
1042
1043    /// Runs `git worktree add -b {branch} -- {path} {commitish}`.
1044    #[instrument(name = "worktree.git_worktree_add", skip(self), err)]
1045    async fn git_worktree_add(
1046        &self,
1047        branch: &str,
1048        path: &str,
1049        commitish: &str,
1050    ) -> Result<(), WorktreeError> {
1051        let out = self
1052            .runner
1053            .run(
1054                &["worktree", "add", "-b", branch, "--", path, commitish],
1055                &self.repo_root,
1056            )
1057            .await?;
1058        check_git_status(&out, "worktree add")?;
1059
1060        Ok(())
1061    }
1062}
1063
1064/// Checks a git command's exit status, returning [`WorktreeError::GitCommand`]
1065/// with `op` as the operation label if the command failed.
1066///
1067/// Raw stderr is logged at `DEBUG` level here — per [`WorktreeError`]'s
1068/// contract, it must never be surfaced directly to the user.
1069fn check_git_status(out: &Output, op: &str) -> Result<(), WorktreeError> {
1070    if !out.status.success() {
1071        let stderr = String::from_utf8_lossy(&out.stderr).to_string();
1072        tracing::debug!(op, %stderr, "git command failed");
1073        return Err(WorktreeError::GitCommand {
1074            op: op.to_string(),
1075            stderr,
1076        });
1077    }
1078    Ok(())
1079}
1080
1081/// Probes that `git` is available and at a sufficient version, and that
1082/// `repo_root` is inside a git repository.
1083///
1084/// Must be called during bootstrap when `worktree.enabled = true`.  Both checks
1085/// are skipped when worktrees are disabled.
1086///
1087/// # Errors
1088///
1089/// - [`WorktreeError::NotAGitRepo`] if `repo_root` is not inside a git repo.
1090/// - [`WorktreeError::GitCommand`] if `git` is not on `PATH` or is too old.
1091///
1092/// # Examples
1093///
1094/// ```no_run
1095/// use std::path::Path;
1096/// use zeph_worktree::{git_runner::DefaultGitRunner, manager::probe_capabilities};
1097///
1098/// # async fn example() -> Result<(), zeph_worktree::WorktreeError> {
1099/// let runner = DefaultGitRunner::new();
1100/// probe_capabilities(&runner, Path::new("/path/to/repo")).await?;
1101/// # Ok(())
1102/// # }
1103/// ```
1104#[instrument(name = "worktree.probe_capabilities", skip(runner), err)]
1105pub async fn probe_capabilities<R: GitRunner>(
1106    runner: &R,
1107    repo_root: &Path,
1108) -> Result<(), WorktreeError> {
1109    // 1. git --version → parse, require >= 2.5
1110    let out = runner.run(&["--version"], repo_root).await?;
1111    if !out.status.success() {
1112        return Err(WorktreeError::GitCommand {
1113            op: "--version".to_string(),
1114            stderr: String::from_utf8_lossy(&out.stderr).to_string(),
1115        });
1116    }
1117
1118    let version_output = String::from_utf8_lossy(&out.stdout);
1119    if let Some(version) = parse_git_version(&version_output)
1120        && version < (2, 5)
1121    {
1122        return Err(WorktreeError::GitCommand {
1123            op: "--version".to_string(),
1124            stderr: format!(
1125                "git \u{2265} 2.5 is required for worktree support (found: {}.{}). \
1126                 Upgrade git or set `worktree.enabled = false`.",
1127                version.0, version.1
1128            ),
1129        });
1130    }
1131
1132    // 2. git rev-parse --is-inside-work-tree
1133    let out = runner
1134        .run(&["rev-parse", "--is-inside-work-tree"], repo_root)
1135        .await?;
1136
1137    if !out.status.success() {
1138        return Err(WorktreeError::NotAGitRepo);
1139    }
1140
1141    Ok(())
1142}
1143
1144/// Parses `(major, minor)` from `git version X.Y.Z`.
1145fn parse_git_version(output: &str) -> Option<(u32, u32)> {
1146    let version_str = output.trim().strip_prefix("git version ")?;
1147    let mut parts = version_str.split('.');
1148    let major: u32 = parts.next()?.parse().ok()?;
1149    let minor: u32 = parts.next()?.parse().ok()?;
1150    Some((major, minor))
1151}
1152
1153#[cfg(test)]
1154mod tests {
1155    use super::*;
1156    use crate::git_runner::FakeGitRunner;
1157    use std::assert_matches;
1158    use std::sync::Arc;
1159    use zeph_config::WorktreeConfig;
1160
1161    fn test_config() -> WorktreeConfig {
1162        WorktreeConfig {
1163            enabled: true,
1164            root: "worktrees".to_string(),
1165            branch_prefix: "agent/".to_string(),
1166            ..WorktreeConfig::default()
1167        }
1168    }
1169
1170    fn make_repo() -> tempfile::TempDir {
1171        let dir = tempfile::tempdir().unwrap();
1172        // Create .git dir so canonicalize_root works
1173        std::fs::create_dir_all(dir.path().join(".git")).unwrap();
1174        dir
1175    }
1176
1177    /// Canonicalises `dir`'s path the same way `WorktreeManager::new` canonicalises
1178    /// `worktree_root`. Required so synthetic `git worktree list --porcelain` fixtures
1179    /// match `reconcile()`'s `worktree_root`-scoped filter (#6257) — `TempDir::path()` is
1180    /// not canonical on macOS (`/var` is a symlink to `/private/var`).
1181    fn canon_repo(dir: &tempfile::TempDir) -> PathBuf {
1182        dir.path().canonicalize().unwrap()
1183    }
1184
1185    async fn make_manager(
1186        dir: &tempfile::TempDir,
1187        runner: FakeGitRunner,
1188    ) -> WorktreeManager<FakeGitRunner> {
1189        WorktreeManager::new(dir.path().to_path_buf(), test_config(), runner)
1190            .await
1191            .unwrap()
1192    }
1193
1194    // --- probe_capabilities ---
1195
1196    #[tokio::test]
1197    async fn probe_succeeds_on_valid_git() {
1198        let dir = make_repo();
1199        let runner = FakeGitRunner::new();
1200        // Response for --version
1201        runner.push_ok(b"git version 2.43.0\n" as &[u8]);
1202        // Response for rev-parse --is-inside-work-tree
1203        runner.push_ok(b"true\n" as &[u8]);
1204        probe_capabilities(&runner, dir.path()).await.unwrap();
1205
1206        let calls = runner.calls.lock().unwrap();
1207        // Both calls must use -- separator or be safe flag-only
1208        assert!(calls[0].0.contains(&"--version".to_string()));
1209        assert!(calls[1].0.contains(&"--is-inside-work-tree".to_string()));
1210    }
1211
1212    #[tokio::test]
1213    async fn probe_rejects_old_git() {
1214        let dir = make_repo();
1215        let runner = FakeGitRunner::new();
1216        runner.push_ok(b"git version 2.4.0\n" as &[u8]);
1217        let err = probe_capabilities(&runner, dir.path()).await.unwrap_err();
1218        assert_matches!(err, WorktreeError::GitCommand { .. });
1219    }
1220
1221    #[tokio::test]
1222    async fn probe_rejects_non_repo() {
1223        let dir = make_repo();
1224        let runner = FakeGitRunner::new();
1225        runner.push_ok(b"git version 2.44.0\n" as &[u8]);
1226        runner.push_err(b"not a git repo\n" as &[u8]);
1227        let err = probe_capabilities(&runner, dir.path()).await.unwrap_err();
1228        assert_matches!(err, WorktreeError::NotAGitRepo);
1229    }
1230
1231    // --- config accessors ---
1232
1233    #[tokio::test]
1234    async fn prune_branch_on_remove_reflects_constructor_config() {
1235        let dir = make_repo();
1236        let runner = FakeGitRunner::new();
1237        let config = WorktreeConfig {
1238            prune_branch_on_remove: true,
1239            ..test_config()
1240        };
1241        let mgr = WorktreeManager::new(dir.path().to_path_buf(), config, runner)
1242            .await
1243            .unwrap();
1244        assert!(mgr.prune_branch_on_remove());
1245    }
1246
1247    #[tokio::test]
1248    async fn prune_branch_on_remove_defaults_to_false() {
1249        let dir = make_repo();
1250        let runner = FakeGitRunner::new();
1251        let mgr = make_manager(&dir, runner).await;
1252        assert!(!mgr.prune_branch_on_remove());
1253    }
1254
1255    // --- create (Head mode) ---
1256
1257    #[tokio::test]
1258    async fn create_head_mode_passes_double_dash() {
1259        let dir = make_repo();
1260        let runner = FakeGitRunner::new();
1261        // status --porcelain (dirty-tree check) → clean
1262        runner.push_ok(b"" as &[u8]);
1263        // worktree add → success
1264        runner.push_ok(b"" as &[u8]);
1265
1266        let mgr = make_manager(&dir, runner).await;
1267
1268        // The path doesn't actually get created since FakeGitRunner doesn't
1269        // invoke git, so we just verify the call args.
1270        // We use Arc to verify calls post-creation.
1271        // First, get the runner reference before `create` consumes it via mgr.
1272        // Access via mgr field is private; instead we check that create returns
1273        // an error or success and verify the CALLS via the FakeGitRunner we built
1274        // the manager from. Since mgr owns runner we need a shared ref.
1275        //
1276        // Workaround: wrap FakeGitRunner in Arc<FakeGitRunner> by implementing
1277        // GitRunner for Arc<FakeGitRunner> — but for now just assert success
1278        // by checking that the manager was constructed and create didn't panic.
1279        let result = mgr.create("agent-42").await;
1280        // May fail because the worktree path doesn't actually get created by fake,
1281        // but the branch sanitisation and git calls should have been issued.
1282        // We accept both Ok and GitCommand errors (the latter means git "ran").
1283        match result {
1284            Ok(_) | Err(WorktreeError::GitCommand { .. }) => {}
1285            Err(e) => panic!("unexpected error: {e}"),
1286        }
1287    }
1288
1289    /// Regression test for #5940: `create()` must reuse the `worktree_root` cached on
1290    /// `self` at construction time rather than recomputing it on every call. Every
1291    /// other `create()` test in this module calls `create()` at most once per manager,
1292    /// so none of them would fail if `create()` accidentally recomputed a stale or
1293    /// diverged root each time — only calling `create()` twice on the same manager and
1294    /// checking both handles resolve under the identical cached parent actually
1295    /// exercises the caching behavior.
1296    #[tokio::test]
1297    async fn create_reuses_cached_worktree_root_across_calls() {
1298        let dir = make_repo();
1299        let runner = FakeGitRunner::new();
1300        // First create(): status --porcelain (dirty check), then worktree add.
1301        runner.push_ok(b"" as &[u8]);
1302        runner.push_ok(b"" as &[u8]);
1303        // Second create(): status --porcelain, then worktree add.
1304        runner.push_ok(b"" as &[u8]);
1305        runner.push_ok(b"" as &[u8]);
1306
1307        let mgr = make_manager(&dir, runner).await;
1308        let cached_root = mgr.worktree_root.clone();
1309
1310        let handle_a = mgr.create("agent-a").await.unwrap();
1311        let handle_b = mgr.create("agent-b").await.unwrap();
1312
1313        assert_eq!(handle_a.path.parent(), Some(cached_root.as_path()));
1314        assert_eq!(handle_b.path.parent(), Some(cached_root.as_path()));
1315    }
1316
1317    #[tokio::test]
1318    async fn create_rejects_invalid_branch_component() {
1319        let dir = make_repo();
1320        let runner = FakeGitRunner::new();
1321        let mgr = make_manager(&dir, runner).await;
1322        let err = mgr.create("../escape").await.unwrap_err();
1323        assert_matches!(err, WorktreeError::InvalidBranchName(_));
1324    }
1325
1326    #[tokio::test]
1327    async fn create_rejects_leading_dash() {
1328        let dir = make_repo();
1329        let runner = FakeGitRunner::new();
1330        let mgr = make_manager(&dir, runner).await;
1331        let err = mgr.create("-bad-id").await.unwrap_err();
1332        assert_matches!(err, WorktreeError::InvalidBranchName(_));
1333    }
1334
1335    // --- create (Fresh mode) ---
1336
1337    #[tokio::test]
1338    async fn create_fresh_resolves_default_branch_from_config() {
1339        let dir = make_repo();
1340        let runner = FakeGitRunner::new();
1341        // fetch origin -- main
1342        runner.push_ok(b"" as &[u8]);
1343        // rev-parse --verify -- origin/main
1344        runner.push_ok(b"deadbeef\n" as &[u8]);
1345        // worktree add
1346        runner.push_ok(b"" as &[u8]);
1347
1348        let config = WorktreeConfig {
1349            enabled: true,
1350            base_ref: zeph_config::WorktreeBaseRef::Fresh,
1351            default_branch: "main".to_string(),
1352            root: "worktrees".to_string(),
1353            branch_prefix: "agent/".to_string(),
1354            ..WorktreeConfig::default()
1355        };
1356        let mgr = WorktreeManager::new(dir.path().to_path_buf(), config, runner)
1357            .await
1358            .unwrap();
1359
1360        let result = mgr.create("agent-fresh").await;
1361        match result {
1362            Ok(_) | Err(WorktreeError::GitCommand { .. }) => {}
1363            Err(e) => panic!("unexpected error: {e}"),
1364        }
1365    }
1366
1367    #[tokio::test]
1368    async fn create_fresh_fails_when_fetch_fails() {
1369        let dir = make_repo();
1370        let runner = FakeGitRunner::new();
1371        // fetch fails
1372        runner.push_err(b"network error\n" as &[u8]);
1373
1374        let config = WorktreeConfig {
1375            enabled: true,
1376            base_ref: zeph_config::WorktreeBaseRef::Fresh,
1377            default_branch: "main".to_string(),
1378            root: "worktrees".to_string(),
1379            branch_prefix: "agent/".to_string(),
1380            ..WorktreeConfig::default()
1381        };
1382        let mgr = WorktreeManager::new(dir.path().to_path_buf(), config, runner)
1383            .await
1384            .unwrap();
1385        let err = mgr.create("agent-fresh").await.unwrap_err();
1386        assert_matches!(err, WorktreeError::GitCommand { .. });
1387    }
1388
1389    #[tokio::test]
1390    async fn create_fresh_fails_when_symbolic_ref_unset() {
1391        let dir = make_repo();
1392        let runner = FakeGitRunner::new();
1393        // symbolic-ref fails (empty default_branch)
1394        runner.push_err(b"symbolic-ref: not a ref\n" as &[u8]);
1395
1396        let config = WorktreeConfig {
1397            enabled: true,
1398            base_ref: zeph_config::WorktreeBaseRef::Fresh,
1399            default_branch: String::new(), // empty → trigger symbolic-ref
1400            root: "worktrees".to_string(),
1401            branch_prefix: "agent/".to_string(),
1402            ..WorktreeConfig::default()
1403        };
1404        let mgr = WorktreeManager::new(dir.path().to_path_buf(), config, runner)
1405            .await
1406            .unwrap();
1407        let err = mgr.create("agent-fresh").await.unwrap_err();
1408        assert_matches!(err, WorktreeError::BaseRefUnresolved { .. });
1409    }
1410
1411    // --- remove ---
1412
1413    #[tokio::test]
1414    async fn remove_without_branch_prune() {
1415        let dir = make_repo();
1416        let runner = FakeGitRunner::new();
1417        // worktree remove → success
1418        runner.push_ok(b"" as &[u8]);
1419
1420        let mgr = make_manager(&dir, runner).await;
1421        let handle = WorktreeHandle {
1422            path: dir.path().join("worktrees/agent-99"),
1423            branch_name: "agent/agent-99".to_string(),
1424            base_ref_resolved: "HEAD".to_string(),
1425            subagent_id: "agent-99".to_string(),
1426            created_at: SystemTime::now(),
1427        };
1428
1429        mgr.remove(&handle, false).await.unwrap();
1430    }
1431
1432    /// Regression test for #5936: a detached-HEAD handle (`branch_name` ==
1433    /// [`DETACHED_BRANCH_SENTINEL`]) has no real branch to prune. `remove` must
1434    /// not issue a `git branch -D` call for it even when `prune_branch = true` —
1435    /// only the single `worktree remove` response is queued, so an unwanted
1436    /// second git call would panic the `FakeGitRunner` on an empty queue.
1437    #[tokio::test]
1438    async fn remove_skips_branch_prune_for_detached_head() {
1439        let dir = make_repo();
1440        let runner = FakeGitRunner::new();
1441        // worktree remove → success (only response queued)
1442        runner.push_ok(b"" as &[u8]);
1443
1444        let mgr = make_manager(&dir, runner).await;
1445        let handle = WorktreeHandle {
1446            path: dir.path().join("worktrees/detached-1"),
1447            branch_name: DETACHED_BRANCH_SENTINEL.to_string(),
1448            base_ref_resolved: String::new(),
1449            subagent_id: String::new(),
1450            created_at: SystemTime::now(),
1451        };
1452
1453        mgr.remove(&handle, true).await.unwrap();
1454    }
1455
1456    #[tokio::test]
1457    async fn remove_with_branch_prune_issues_two_git_calls() {
1458        let dir = make_repo();
1459        let runner = FakeGitRunner::new();
1460        // worktree remove
1461        runner.push_ok(b"" as &[u8]);
1462        // branch -D
1463        runner.push_ok(b"" as &[u8]);
1464
1465        let mgr = make_manager(&dir, runner).await;
1466        let handle = WorktreeHandle {
1467            path: dir.path().join("worktrees/agent-99"),
1468            branch_name: "agent/agent-99".to_string(),
1469            base_ref_resolved: "HEAD".to_string(),
1470            subagent_id: "agent-99".to_string(),
1471            created_at: SystemTime::now(),
1472        };
1473
1474        mgr.remove(&handle, true).await.unwrap();
1475    }
1476
1477    /// Regression test for #5397: `git worktree remove` succeeds but the
1478    /// subsequent `git branch -D` fails. The in-memory handle must already be
1479    /// gone from [`list`][WorktreeManager::list] once the worktree directory
1480    /// removal succeeded, regardless of the branch-prune outcome.
1481    #[tokio::test]
1482    async fn remove_drops_handle_even_when_branch_prune_fails() {
1483        let dir = make_repo();
1484        let runner = FakeGitRunner::new();
1485        // worktree remove → success
1486        runner.push_ok(b"" as &[u8]);
1487        // branch -D → failure (e.g. branch not fully merged)
1488        runner.push_err(b"error: branch 'agent/agent-99' not fully merged\n" as &[u8]);
1489
1490        let mgr = make_manager(&dir, runner).await;
1491        let handle = WorktreeHandle {
1492            path: dir.path().join("worktrees/agent-99"),
1493            branch_name: "agent/agent-99".to_string(),
1494            base_ref_resolved: "HEAD".to_string(),
1495            subagent_id: "agent-99".to_string(),
1496            created_at: SystemTime::now(),
1497        };
1498
1499        // Seed the in-memory handle list directly, bypassing `create()` — the
1500        // `tests` module is a descendant of the manager's module so it can
1501        // reach the private `handles` field.
1502        mgr.handles.lock().unwrap().push(handle.clone());
1503        assert_eq!(mgr.list().len(), 1, "precondition: handle is tracked");
1504
1505        let err = mgr.remove(&handle, true).await.unwrap_err();
1506        assert_matches!(
1507            err,
1508            WorktreeError::GitCommand { ref op, .. } if op == "branch -D"
1509        );
1510
1511        // The stale-handle bug (#5397) would leave this list non-empty even
1512        // though the worktree directory was already removed from disk.
1513        assert!(
1514            mgr.list().is_empty(),
1515            "handle must be dropped once `worktree remove` succeeded, \
1516             independent of the branch -D outcome"
1517        );
1518    }
1519
1520    // --- reconcile ---
1521
1522    #[tokio::test]
1523    async fn reconcile_parses_porcelain_output() {
1524        let dir = make_repo();
1525        let runner = FakeGitRunner::new();
1526        let porcelain = format!(
1527            "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\nworktree {0}/worktrees/agent-1\nHEAD def456\nbranch refs/heads/agent/agent-1\n\n",
1528            canon_repo(&dir).display()
1529        );
1530        runner.push_ok(porcelain.into_bytes());
1531
1532        let mgr = make_manager(&dir, runner).await;
1533        let stale = mgr.reconcile().await.unwrap();
1534        // The main worktree (repo_root) is filtered out; only agent worktrees remain.
1535        assert_eq!(stale.len(), 1);
1536        assert_eq!(stale[0].handle.branch_name, "agent/agent-1");
1537    }
1538
1539    /// Regression test for #5936: a `detached` line (instead of `branch
1540    /// refs/heads/<name>`) must not cause the block to be silently dropped.
1541    #[tokio::test]
1542    async fn reconcile_includes_detached_head_worktree() {
1543        let dir = make_repo();
1544        let runner = FakeGitRunner::new();
1545        let porcelain = format!(
1546            "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\nworktree {0}/worktrees/detached-1\nHEAD def456\ndetached\n\n",
1547            canon_repo(&dir).display()
1548        );
1549        runner.push_ok(porcelain.into_bytes());
1550
1551        let mgr = make_manager(&dir, runner).await;
1552        let stale = mgr.reconcile().await.unwrap();
1553        assert_eq!(stale.len(), 1);
1554        assert_eq!(stale[0].handle.branch_name, DETACHED_BRANCH_SENTINEL);
1555        assert_eq!(
1556            stale[0].handle.path,
1557            canon_repo(&dir).join("worktrees/detached-1")
1558        );
1559    }
1560
1561    /// A detached-HEAD block that is also the *last* block in the porcelain
1562    /// output (no trailing `worktree` line to trigger the flush) must still be
1563    /// included — exercises the end-of-output flush path specifically.
1564    #[test]
1565    fn parse_worktree_list_porcelain_flushes_trailing_detached_block() {
1566        let output = "worktree /repo\nHEAD abc123\ndetached\n";
1567        let result = parse_worktree_list_porcelain(output);
1568        assert_eq!(result.len(), 1);
1569        assert_eq!(result[0].path, PathBuf::from("/repo"));
1570        assert_eq!(result[0].branch, None);
1571        assert!(!result[0].is_bare);
1572    }
1573
1574    /// Two secondary detached-HEAD worktrees back to back (no `branch` line
1575    /// anywhere between them) must both be flushed independently, not merged
1576    /// into one block or have the first one dropped when the second
1577    /// `worktree` line triggers its flush.
1578    #[test]
1579    fn parse_worktree_list_porcelain_flushes_consecutive_detached_blocks() {
1580        let output = "worktree /repo/wt-a\nHEAD aaa111\ndetached\n\nworktree /repo/wt-b\nHEAD bbb222\ndetached\n\n";
1581        let result = parse_worktree_list_porcelain(output);
1582        assert_eq!(result.len(), 2);
1583        assert_eq!(result[0].path, PathBuf::from("/repo/wt-a"));
1584        assert_eq!(result[0].branch, None);
1585        assert_eq!(result[1].path, PathBuf::from("/repo/wt-b"));
1586        assert_eq!(result[1].branch, None);
1587    }
1588
1589    /// Regression test for #5936: a repo where *every* worktree — including
1590    /// the main one — is on a detached `HEAD` (e.g. a shallow CI checkout)
1591    /// has no `branch refs/heads/` line anywhere in the porcelain output.
1592    /// `reconcile` must still parse without panicking; the main worktree is
1593    /// filtered out by path (not by branch), so the only surviving entry is
1594    /// the secondary detached worktree.
1595    #[tokio::test]
1596    async fn reconcile_repo_with_only_detached_head_worktrees() {
1597        let dir = make_repo();
1598        let runner = FakeGitRunner::new();
1599        let porcelain = format!(
1600            "worktree {0}\nHEAD abc123\ndetached\n\nworktree {0}/worktrees/detached-2\nHEAD def456\ndetached\n\n",
1601            canon_repo(&dir).display()
1602        );
1603        runner.push_ok(porcelain.into_bytes());
1604
1605        let mgr = make_manager(&dir, runner).await;
1606        let stale = mgr.reconcile().await.unwrap();
1607        assert_eq!(stale.len(), 1, "main worktree filtered out by path only");
1608        assert_eq!(stale[0].handle.branch_name, DETACHED_BRANCH_SENTINEL);
1609        assert_eq!(
1610            stale[0].handle.path,
1611            canon_repo(&dir).join("worktrees/detached-2")
1612        );
1613    }
1614
1615    /// Regression test for #6052: `git worktree list --porcelain` emits a
1616    /// `bare` line (not `branch` or `detached`) for the main worktree of a
1617    /// bare repository:
1618    /// ```text
1619    /// worktree /path/to/bare.git
1620    /// bare
1621    ///
1622    /// ```
1623    /// (confirmed against real `git worktree list --porcelain` output, git
1624    /// 2.50.1 — no `HEAD` line is emitted for bare worktrees at all). The
1625    /// parser must capture this as `is_bare = true` rather than falling
1626    /// through to the detached-HEAD fallback.
1627    #[test]
1628    fn parse_worktree_list_porcelain_marks_bare_worktree_as_bare() {
1629        let output = "worktree /path/to/bare.git\nbare\n\n";
1630        let result = parse_worktree_list_porcelain(output);
1631        assert_eq!(result.len(), 1);
1632        assert_eq!(result[0].path, PathBuf::from("/path/to/bare.git"));
1633        assert_eq!(result[0].branch, None);
1634        assert!(result[0].is_bare);
1635    }
1636
1637    /// End-to-end regression test for #6052: `reconcile()` must label a bare
1638    /// worktree with [`BARE_WORKTREE_SENTINEL`], not
1639    /// [`DETACHED_BRANCH_SENTINEL`] — the two are semantically distinct and
1640    /// must not collide.
1641    #[tokio::test]
1642    async fn reconcile_distinguishes_bare_from_detached_worktree() {
1643        let dir = make_repo();
1644        let runner = FakeGitRunner::new();
1645        let porcelain = format!(
1646            "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\nworktree {0}/worktrees/bare-1\nbare\n\nworktree {0}/worktrees/detached-1\nHEAD def456\ndetached\n\n",
1647            canon_repo(&dir).display()
1648        );
1649        runner.push_ok(porcelain.into_bytes());
1650
1651        let mgr = make_manager(&dir, runner).await;
1652        let stale = mgr.reconcile().await.unwrap();
1653        assert_eq!(stale.len(), 2);
1654        assert_eq!(stale[0].handle.branch_name, BARE_WORKTREE_SENTINEL);
1655        assert_eq!(stale[1].handle.branch_name, DETACHED_BRANCH_SENTINEL);
1656        assert_ne!(
1657            BARE_WORKTREE_SENTINEL, DETACHED_BRANCH_SENTINEL,
1658            "bare and detached sentinels must be distinct markers"
1659        );
1660    }
1661
1662    /// Regression test for #6055: a worktree whose directory is intact (no
1663    /// `prunable` line in the porcelain output) must report
1664    /// `prunable_reason == None` and `is_safe_to_force_remove() == false` —
1665    /// this is the condition under which `clean` must skip-and-warn instead
1666    /// of force-removing, since the worktree may belong to another,
1667    /// concurrently running session.
1668    #[tokio::test]
1669    async fn reconcile_marks_directory_intact_worktree_as_not_prunable() {
1670        let dir = make_repo();
1671        let runner = FakeGitRunner::new();
1672        let porcelain = format!(
1673            "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\nworktree {0}/worktrees/agent-1\nHEAD def456\nbranch refs/heads/agent/agent-1\n\n",
1674            canon_repo(&dir).display()
1675        );
1676        runner.push_ok(porcelain.into_bytes());
1677
1678        let mgr = make_manager(&dir, runner).await;
1679        let stale = mgr.reconcile().await.unwrap();
1680        assert_eq!(stale.len(), 1);
1681        assert_eq!(stale[0].prunable_reason, None);
1682        assert!(!stale[0].is_safe_to_force_remove());
1683    }
1684
1685    /// Regression test for #6055: when git's porcelain output includes a
1686    /// `prunable <reason>` line for a worktree, `reconcile()` must surface the
1687    /// exact reason text and `is_safe_to_force_remove()` must be `true` — this
1688    /// is the one condition under which `clean` may force-remove by default.
1689    #[tokio::test]
1690    async fn reconcile_captures_prunable_reason_text() {
1691        let dir = make_repo();
1692        let runner = FakeGitRunner::new();
1693        let porcelain = format!(
1694            "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\nworktree {0}/worktrees/gone\nHEAD def456\nbranch refs/heads/agent/gone\nprunable gitdir file points to non-existent location\n\n",
1695            canon_repo(&dir).display()
1696        );
1697        runner.push_ok(porcelain.into_bytes());
1698
1699        let mgr = make_manager(&dir, runner).await;
1700        let stale = mgr.reconcile().await.unwrap();
1701        assert_eq!(stale.len(), 1);
1702        assert_eq!(
1703            stale[0].prunable_reason,
1704            Some("gitdir file points to non-existent location".to_string())
1705        );
1706        assert!(stale[0].is_safe_to_force_remove());
1707    }
1708
1709    /// Regression test for #6257: `reconcile()` must exclude a git-registered worktree
1710    /// whose path falls outside this subsystem's canonicalised `worktree_root` — e.g. one
1711    /// created by the `EnterWorktree` developer tool or a manual `git worktree add`
1712    /// elsewhere in the repository. Only the managed worktree under `worktree_root` may
1713    /// appear in the stale list.
1714    #[tokio::test]
1715    async fn reconcile_excludes_worktree_outside_worktree_root() {
1716        let dir = make_repo();
1717        let foreign = tempfile::tempdir().unwrap();
1718        let foreign_path = foreign.path().canonicalize().unwrap();
1719        let runner = FakeGitRunner::new();
1720        let porcelain = format!(
1721            "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\n\
1722             worktree {0}/worktrees/agent-1\nHEAD def456\nbranch refs/heads/agent/agent-1\n\n\
1723             worktree {1}\nHEAD ghi789\nbranch refs/heads/enter-worktree/fix-123\n\n",
1724            canon_repo(&dir).display(),
1725            foreign_path.display()
1726        );
1727        runner.push_ok(porcelain.into_bytes());
1728
1729        let mgr = make_manager(&dir, runner).await;
1730        let stale = mgr.reconcile().await.unwrap();
1731
1732        assert_eq!(
1733            stale.len(),
1734            1,
1735            "the foreign worktree outside worktree_root must be excluded: {stale:?}"
1736        );
1737        assert_eq!(stale[0].handle.branch_name, "agent/agent-1");
1738        assert_eq!(
1739            stale[0].handle.path,
1740            canon_repo(&dir).join("worktrees/agent-1")
1741        );
1742    }
1743
1744    // --- prune ---
1745
1746    /// Regression test for #5937: `WorktreeManager::prune` must issue exactly
1747    /// `git worktree prune` — the command `zeph worktree clean` is required to
1748    /// run (FR-CLEANUP-04) after removing stale entries.
1749    #[tokio::test]
1750    async fn prune_issues_worktree_prune() {
1751        let dir = make_repo();
1752        let runner = Arc::new(FakeGitRunner::new());
1753        runner.push_ok(b"" as &[u8]);
1754
1755        let mgr =
1756            WorktreeManager::new(dir.path().to_path_buf(), test_config(), Arc::clone(&runner))
1757                .await
1758                .unwrap();
1759
1760        mgr.prune().await.unwrap();
1761
1762        let calls = runner.calls.lock().unwrap();
1763        assert_eq!(calls.len(), 1);
1764        assert_eq!(
1765            calls[0].0,
1766            vec!["worktree".to_string(), "prune".to_string()]
1767        );
1768    }
1769
1770    #[tokio::test]
1771    async fn prune_propagates_git_failure() {
1772        let dir = make_repo();
1773        let runner = FakeGitRunner::new();
1774        runner.push_err(b"fatal: not a working tree\n" as &[u8]);
1775
1776        let mgr = make_manager(&dir, runner).await;
1777        let err = mgr.prune().await.unwrap_err();
1778        assert_matches!(err, WorktreeError::GitCommand { ref op, .. } if op == "worktree prune");
1779    }
1780
1781    // --- clean (#6142: shared by CLI + agent-side /worktree clean) ---
1782
1783    /// Mixed prunable/non-prunable stale list, `force = false`: the prunable entry is
1784    /// removed, the non-prunable one is skipped and left alone — the standard case
1785    /// both the CLI and agent-side callers depend on.
1786    #[tokio::test]
1787    async fn clean_removes_prunable_and_skips_non_prunable_without_force() {
1788        let dir = make_repo();
1789        let runner = FakeGitRunner::new();
1790        let porcelain = format!(
1791            "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\n\
1792             worktree {0}/worktrees/prunable-1\nHEAD def456\nbranch refs/heads/agent/prunable-1\n\
1793             prunable gitdir file points to non-existent location\n\n\
1794             worktree {0}/worktrees/in-use-1\nHEAD ghi789\nbranch refs/heads/agent/in-use-1\n\n",
1795            canon_repo(&dir).display()
1796        );
1797        runner.push_ok(porcelain.into_bytes()); // reconcile: worktree list --porcelain
1798        runner.push_ok(b"" as &[u8]); // remove prunable-1: worktree remove --force
1799        runner.push_ok(b"" as &[u8]); // final: worktree prune
1800
1801        let mgr = make_manager(&dir, runner).await;
1802        let outcome = mgr.clean(false, false, "`--force`").await.unwrap();
1803
1804        assert_eq!(outcome.removed, 1);
1805        assert_eq!(outcome.skipped, 1);
1806        assert_eq!(outcome.errored, 0);
1807        assert_eq!(
1808            outcome.warnings.len(),
1809            1,
1810            "only the skip warning: {:?}",
1811            outcome.warnings
1812        );
1813        assert!(outcome.warnings[0].contains("in-use-1"));
1814        assert!(outcome.warnings[0].contains("`--force`"));
1815    }
1816
1817    /// `force = true` bypasses the non-prunable skip-gate: the same entry that would be
1818    /// skipped without `--force` is now attempted and removed.
1819    #[tokio::test]
1820    async fn clean_with_force_removes_non_prunable_entries_too() {
1821        let dir = make_repo();
1822        let runner = FakeGitRunner::new();
1823        let porcelain = format!(
1824            "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\n\
1825             worktree {0}/worktrees/in-use-1\nHEAD ghi789\nbranch refs/heads/agent/in-use-1\n\n",
1826            canon_repo(&dir).display()
1827        );
1828        runner.push_ok(porcelain.into_bytes());
1829        runner.push_ok(b"" as &[u8]); // remove in-use-1 (force bypasses the skip-gate)
1830        runner.push_ok(b"" as &[u8]); // final prune
1831
1832        let mgr = make_manager(&dir, runner).await;
1833        let outcome = mgr.clean(true, false, "`--force`").await.unwrap();
1834
1835        assert_eq!(outcome.removed, 1);
1836        assert_eq!(outcome.skipped, 0);
1837        assert_eq!(outcome.errored, 0);
1838    }
1839
1840    /// Regression test for #6077/#6142 (critic M2): a stale entry whose `remove()` call
1841    /// itself fails (e.g. a locked worktree under `--force`) must be counted as
1842    /// `errored`, not silently folded into `removed` or `skipped`.
1843    #[tokio::test]
1844    async fn clean_counts_errored_when_remove_fails() {
1845        let dir = make_repo();
1846        let runner = FakeGitRunner::new();
1847        let porcelain = format!(
1848            "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\n\
1849             worktree {0}/worktrees/locked-1\nHEAD def456\nbranch refs/heads/agent/locked-1\n\
1850             prunable gitdir file points to non-existent location\n\n",
1851            canon_repo(&dir).display()
1852        );
1853        runner.push_ok(porcelain.into_bytes());
1854        runner.push_err(b"error: unable to remove worktree: it is locked\n" as &[u8]);
1855        runner.push_ok(b"" as &[u8]); // final prune still runs
1856
1857        let mgr = make_manager(&dir, runner).await;
1858        let outcome = mgr.clean(false, false, "`--force`").await.unwrap();
1859
1860        assert_eq!(outcome.removed, 0);
1861        assert_eq!(outcome.skipped, 0);
1862        assert_eq!(outcome.errored, 1);
1863        assert_eq!(outcome.warnings.len(), 1);
1864        assert!(outcome.warnings[0].contains("failed to remove"));
1865    }
1866
1867    /// Regression test for the exact divergence caught in #6141's review: a failure of
1868    /// the final `prune()` step must be recorded as a warning, not discard an
1869    /// otherwise-successful removal count or turn the whole call into an `Err`.
1870    #[tokio::test]
1871    async fn clean_records_prune_failure_without_discarding_removed_count() {
1872        let dir = make_repo();
1873        let runner = FakeGitRunner::new();
1874        let porcelain = format!(
1875            "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\n\
1876             worktree {0}/worktrees/prunable-1\nHEAD def456\nbranch refs/heads/agent/prunable-1\n\
1877             prunable gitdir file points to non-existent location\n\n",
1878            canon_repo(&dir).display()
1879        );
1880        runner.push_ok(porcelain.into_bytes());
1881        runner.push_ok(b"" as &[u8]); // remove succeeds
1882        runner.push_err(b"fatal: not a working tree\n" as &[u8]); // prune fails
1883
1884        let mgr = make_manager(&dir, runner).await;
1885        let outcome = mgr
1886            .clean(false, false, "`--force`")
1887            .await
1888            .expect("a prune failure must not turn clean() into an Err");
1889
1890        assert_eq!(
1891            outcome.removed, 1,
1892            "the successful removal must not be discarded by a later prune failure"
1893        );
1894        assert!(
1895            outcome
1896                .warnings
1897                .iter()
1898                .any(|w| w.contains("failed to prune worktree registry")),
1899            "got: {:?}",
1900            outcome.warnings
1901        );
1902    }
1903
1904    #[test]
1905    fn format_clean_summary_all_zero() {
1906        assert_eq!(
1907            format_clean_summary(&CleanOutcome::default()),
1908            "Removed 0 stale worktree(s), skipped 0 in-use candidate(s), 0 error(s)."
1909        );
1910    }
1911
1912    #[test]
1913    fn format_clean_summary_includes_all_three_counts() {
1914        let outcome = CleanOutcome {
1915            removed: 2,
1916            skipped: 1,
1917            errored: 3,
1918            warnings: Vec::new(),
1919        };
1920        let msg = format_clean_summary(&outcome);
1921        assert!(msg.contains("Removed 2"), "got: {msg}");
1922        assert!(msg.contains("skipped 1"), "got: {msg}");
1923        assert!(msg.contains("3 error(s)"), "got: {msg}");
1924    }
1925
1926    // --- parse_git_version ---
1927
1928    #[test]
1929    fn parse_version_standard() {
1930        assert_eq!(parse_git_version("git version 2.43.0"), Some((2, 43)));
1931    }
1932
1933    #[test]
1934    fn parse_version_old() {
1935        assert_eq!(parse_git_version("git version 2.4.1"), Some((2, 4)));
1936    }
1937
1938    #[test]
1939    fn parse_version_invalid() {
1940        assert_eq!(parse_git_version("not git output"), None);
1941    }
1942
1943    // --- double-dash invariant ---
1944
1945    #[tokio::test]
1946    async fn remove_uses_double_dash_separator() {
1947        let dir = make_repo();
1948        let runner = Arc::new(FakeGitRunner::new());
1949        runner.push_ok(b"" as &[u8]);
1950
1951        // Use Arc<FakeGitRunner> as the runner.
1952        let mgr =
1953            WorktreeManager::new(dir.path().to_path_buf(), test_config(), Arc::clone(&runner))
1954                .await
1955                .unwrap();
1956
1957        let handle = WorktreeHandle {
1958            path: dir.path().join("worktrees/x"),
1959            branch_name: "agent/x".to_string(),
1960            base_ref_resolved: "HEAD".to_string(),
1961            subagent_id: "x".to_string(),
1962            created_at: SystemTime::now(),
1963        };
1964
1965        let _ = mgr.remove(&handle, false).await;
1966        let calls = runner.calls.lock().unwrap();
1967        // The first call must contain "--" separator before path
1968        let has_sep = calls[0].0.iter().any(|a| a == "--");
1969        assert!(
1970            has_sep,
1971            "expected '--' separator in git args: {:?}",
1972            calls[0].0
1973        );
1974    }
1975
1976    /// MINOR-4: dirty-tree warning path — `create()` returns `Ok` even on a dirty tree.
1977    ///
1978    /// `check_dirty_tree` emits `tracing::warn!` but does not fail the operation.
1979    /// This test verifies the code path is exercised without panic and that the manager
1980    /// still proceeds past the dirty-tree check.
1981    #[tokio::test]
1982    async fn create_head_mode_proceeds_on_dirty_tree() {
1983        let dir = make_repo();
1984        let runner = FakeGitRunner::new();
1985        // status --porcelain → non-empty (dirty tree)
1986        runner.push_ok(b" M some-file.txt\n" as &[u8]);
1987        // worktree add → error (fake git can't create the path on disk)
1988        // This is fine — we only verify dirty-tree check doesn't abort early.
1989        runner.push_err(b"fake error\n" as &[u8]);
1990
1991        let mgr = make_manager(&dir, runner).await;
1992        let result = mgr.create("dirty-agent").await;
1993        // Result is an error because the fake runner returns an error for `worktree add`,
1994        // but we reached that point — meaning check_dirty_tree did NOT abort.
1995        assert!(
1996            matches!(result, Err(WorktreeError::GitCommand { .. })),
1997            "expected GitCommand error from fake runner, not an early abort: {result:?}"
1998        );
1999    }
2000
2001    // --- max_worktrees admission cap ---
2002
2003    #[tokio::test]
2004    async fn create_succeeds_when_under_max_worktrees_cap() {
2005        let dir = make_repo();
2006        let runner = FakeGitRunner::new();
2007        let porcelain = format!(
2008            "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\n",
2009            dir.path().display()
2010        );
2011        runner.push_ok(porcelain.into_bytes()); // reconcile for quota check
2012        runner.push_ok(b"" as &[u8]); // status --porcelain (dirty check)
2013        runner.push_ok(b"" as &[u8]); // worktree add
2014
2015        let config = WorktreeConfig {
2016            max_worktrees: Some(5),
2017            ..test_config()
2018        };
2019        let mgr = WorktreeManager::new(dir.path().to_path_buf(), config, runner)
2020            .await
2021            .unwrap();
2022        assert!(mgr.create("agent-a").await.is_ok());
2023    }
2024
2025    /// Regression test for #5924: `create()` must refuse admission once the
2026    /// git-registered secondary worktree count reaches `max_worktrees`, without
2027    /// issuing any further git calls (only `FakeGitRunner`'s queued reconcile
2028    /// response is consumed for the rejected call).
2029    #[tokio::test]
2030    async fn create_fails_with_quota_exceeded_when_max_worktrees_reached() {
2031        let dir = make_repo();
2032        let runner = FakeGitRunner::new();
2033        let porcelain = format!(
2034            "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\n",
2035            dir.path().display()
2036        );
2037        // First create(): reconcile (quota check), status --porcelain, worktree add.
2038        runner.push_ok(porcelain.clone().into_bytes());
2039        runner.push_ok(b"" as &[u8]);
2040        runner.push_ok(b"" as &[u8]);
2041        // Second create(): reconcile (quota check) only — QuotaExceeded short-circuits
2042        // before any further git call.
2043        runner.push_ok(porcelain.into_bytes());
2044
2045        let config = WorktreeConfig {
2046            max_worktrees: Some(1),
2047            ..test_config()
2048        };
2049        let mgr = WorktreeManager::new(dir.path().to_path_buf(), config, runner)
2050            .await
2051            .unwrap();
2052
2053        mgr.create("agent-a").await.unwrap();
2054
2055        let err = mgr.create("agent-b").await.unwrap_err();
2056        assert_matches!(err, WorktreeError::QuotaExceeded { current: 1, max: 1 });
2057    }
2058
2059    /// Regression test for #6257: `create()`'s admission quota must not count a
2060    /// git-registered worktree that lives outside this subsystem's `worktree_root` (e.g.
2061    /// a sibling directory created by the `EnterWorktree` developer tool). With
2062    /// `max_worktrees: Some(1)` and only a foreign worktree registered (no managed
2063    /// worktree yet), `create()` must still be admitted — before the fix, the foreign
2064    /// entry would have inflated the count and spuriously tripped `QuotaExceeded`.
2065    #[tokio::test]
2066    async fn create_admission_ignores_worktree_outside_worktree_root() {
2067        let dir = make_repo();
2068        let foreign = tempfile::tempdir().unwrap();
2069        let foreign_path = foreign.path().canonicalize().unwrap();
2070        let runner = FakeGitRunner::new();
2071        let porcelain = format!(
2072            "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\n\
2073             worktree {1}\nHEAD ghi789\nbranch refs/heads/enter-worktree/fix-123\n\n",
2074            canon_repo(&dir).display(),
2075            foreign_path.display()
2076        );
2077        // reconcile (quota check) — only the foreign worktree is registered.
2078        runner.push_ok(porcelain.into_bytes());
2079        // status --porcelain (dirty check)
2080        runner.push_ok(b"" as &[u8]);
2081        // worktree add
2082        runner.push_ok(b"" as &[u8]);
2083
2084        let config = WorktreeConfig {
2085            max_worktrees: Some(1),
2086            ..test_config()
2087        };
2088        let mgr = WorktreeManager::new(dir.path().to_path_buf(), config, runner)
2089            .await
2090            .unwrap();
2091
2092        let result = mgr.create("agent-a").await;
2093        assert!(
2094            result.is_ok(),
2095            "admission must not be blocked by a foreign worktree outside worktree_root: {result:?}"
2096        );
2097    }
2098
2099    /// Regression test for #6257: `create()` must log a `WARN`-level event when it fails
2100    /// (via `#[instrument(err(level = WARN))]`) — previously a `QuotaExceeded` failure had
2101    /// zero log evidence, which is how the failure turned into a silent zombie task with no
2102    /// diagnostic trail.
2103    #[tokio::test]
2104    #[tracing_test::traced_test]
2105    async fn create_logs_warn_on_quota_exceeded() {
2106        let dir = make_repo();
2107        let runner = FakeGitRunner::new();
2108        let porcelain = format!(
2109            "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\n",
2110            dir.path().display()
2111        );
2112        runner.push_ok(porcelain.into_bytes());
2113
2114        let config = WorktreeConfig {
2115            max_worktrees: Some(0),
2116            ..test_config()
2117        };
2118        let mgr = WorktreeManager::new(dir.path().to_path_buf(), config, runner)
2119            .await
2120            .unwrap();
2121
2122        let err = mgr.create("agent-a").await.unwrap_err();
2123        assert_matches!(err, WorktreeError::QuotaExceeded { current: 0, max: 0 });
2124
2125        assert!(
2126            logs_contain("worktree.create"),
2127            "expected the worktree.create span to be logged"
2128        );
2129        assert!(
2130            logs_contain("worktree limit reached"),
2131            "expected the QuotaExceeded error text to be logged at WARN via err(level = WARN)"
2132        );
2133    }
2134
2135    /// Test-only [`GitRunner`] wrapper that sleeps before delegating a
2136    /// `worktree list --porcelain` call (the git call `reconcile` issues as
2137    /// part of `create`'s quota check). This widens the check-then-act window
2138    /// enough for a multi-threaded runtime to reliably interleave concurrent
2139    /// `create()` calls — without it, `FakeGitRunner::run` never actually
2140    /// suspends, so two tasks racing through `create()` would just run
2141    /// sequentially to completion and the test would prove nothing.
2142    struct DelayedListRunner {
2143        inner: Arc<FakeGitRunner>,
2144        delay: std::time::Duration,
2145    }
2146
2147    impl GitRunner for DelayedListRunner {
2148        async fn run(&self, args: &[&str], cwd: &Path) -> Result<Output, WorktreeError> {
2149            if args.first() == Some(&"worktree") && args.get(1) == Some(&"list") {
2150                tokio::time::sleep(self.delay).await;
2151            }
2152            self.inner.run(args, cwd).await
2153        }
2154    }
2155
2156    /// Regression test for #6250: two or more in-process `create()` calls
2157    /// racing on a `WorktreeManager` configured with `max_worktrees = 1` must
2158    /// never both observe the same pre-admission count and both proceed past
2159    /// the quota check. Spawns `CONCURRENCY` tasks that all start as close to
2160    /// simultaneously as possible (via a barrier) on a real multi-thread
2161    /// runtime, with the quota-check git call artificially delayed to widen
2162    /// the TOCTOU window `admission_lock` closes. Every `run()` response is
2163    /// an empty-stdout success — `reconcile` parses `""` as zero stale
2164    /// entries, `check_dirty_tree` ignores its result entirely, and
2165    /// `git_worktree_add` only needs a zero exit code — so the response
2166    /// content is agnostic to which task ends up winning the race; only the
2167    /// call *count* (3 for the winner, 1 for each loser) matters, and that
2168    /// count is fixed regardless of interleaving order.
2169    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
2170    async fn create_admission_is_race_free_under_concurrent_calls() {
2171        const CONCURRENCY: usize = 8;
2172
2173        let dir = make_repo();
2174        let inner = Arc::new(FakeGitRunner::new());
2175        for _ in 0..CONCURRENCY * 3 {
2176            inner.push_ok(b"" as &[u8]);
2177        }
2178        let runner = DelayedListRunner {
2179            inner,
2180            delay: std::time::Duration::from_millis(20),
2181        };
2182
2183        let config = WorktreeConfig {
2184            max_worktrees: Some(1),
2185            ..test_config()
2186        };
2187        let mgr = Arc::new(
2188            WorktreeManager::new(dir.path().to_path_buf(), config, runner)
2189                .await
2190                .unwrap(),
2191        );
2192
2193        let barrier = Arc::new(tokio::sync::Barrier::new(CONCURRENCY));
2194        let mut tasks = Vec::with_capacity(CONCURRENCY);
2195        for i in 0..CONCURRENCY {
2196            let mgr = Arc::clone(&mgr);
2197            let barrier = Arc::clone(&barrier);
2198            tasks.push(tokio::spawn(async move {
2199                barrier.wait().await;
2200                mgr.create(&format!("agent-{i}")).await
2201            }));
2202        }
2203
2204        let mut ok_count = 0;
2205        let mut quota_exceeded_count = 0;
2206        for task in tasks {
2207            match task.await.unwrap() {
2208                Ok(_) => ok_count += 1,
2209                Err(WorktreeError::QuotaExceeded { max: 1, .. }) => quota_exceeded_count += 1,
2210                Err(e) => panic!("unexpected error from concurrent create(): {e}"),
2211            }
2212        }
2213
2214        assert_eq!(
2215            ok_count, 1,
2216            "exactly one concurrent create() must be admitted under max_worktrees=1"
2217        );
2218        assert_eq!(quota_exceeded_count, CONCURRENCY - 1);
2219    }
2220
2221    // --- disk_usage / cached_disk_usage ---
2222
2223    #[tokio::test]
2224    async fn disk_usage_returns_zero_for_no_worktrees() {
2225        let dir = make_repo();
2226        let runner = FakeGitRunner::new();
2227        let porcelain = format!(
2228            "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\n",
2229            dir.path().display()
2230        );
2231        runner.push_ok(porcelain.into_bytes());
2232
2233        let mgr = make_manager(&dir, runner).await;
2234        let usage = mgr.disk_usage().await.unwrap();
2235        assert_eq!(usage.total_bytes, 0);
2236        assert!(usage.per_worktree.is_empty());
2237    }
2238
2239    #[tokio::test]
2240    async fn disk_usage_sums_file_sizes_under_registered_worktree() {
2241        let dir = make_repo();
2242        let runner = FakeGitRunner::new();
2243        let porcelain = format!(
2244            "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\n",
2245            dir.path().display()
2246        );
2247        runner.push_ok(porcelain.into_bytes());
2248
2249        let mgr = make_manager(&dir, runner).await;
2250
2251        let wt_path = dir.path().join("worktrees/agent-1");
2252        std::fs::create_dir_all(&wt_path).unwrap();
2253        std::fs::write(wt_path.join("file.txt"), b"hello world").unwrap();
2254
2255        mgr.handles.lock().unwrap().push(WorktreeHandle {
2256            path: wt_path.clone(),
2257            branch_name: "agent/agent-1".to_string(),
2258            base_ref_resolved: "HEAD".to_string(),
2259            subagent_id: "agent-1".to_string(),
2260            created_at: SystemTime::now(),
2261        });
2262
2263        let usage = mgr.disk_usage().await.unwrap();
2264        assert_eq!(usage.total_bytes, 11);
2265        assert_eq!(usage.per_worktree.len(), 1);
2266        assert_eq!(usage.per_worktree[0].0, wt_path);
2267        assert_eq!(usage.per_worktree[0].1, 11);
2268    }
2269
2270    #[tokio::test]
2271    async fn cached_disk_usage_none_before_first_call() {
2272        let dir = make_repo();
2273        let runner = FakeGitRunner::new();
2274        let mgr = make_manager(&dir, runner).await;
2275        assert!(mgr.cached_disk_usage().is_none());
2276    }
2277
2278    #[tokio::test]
2279    async fn cached_disk_usage_populated_after_disk_usage_call() {
2280        let dir = make_repo();
2281        let runner = FakeGitRunner::new();
2282        let porcelain = format!(
2283            "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\n",
2284            dir.path().display()
2285        );
2286        runner.push_ok(porcelain.into_bytes());
2287        let mgr = make_manager(&dir, runner).await;
2288
2289        assert!(mgr.cached_disk_usage().is_none());
2290        mgr.disk_usage().await.unwrap();
2291        assert!(mgr.cached_disk_usage().is_some());
2292    }
2293
2294    // --- sweep ---
2295
2296    /// Regression test for #5924: `sweep()` reclaims only `prunable` entries (via the
2297    /// same `clean(force=false, ..)` pipeline as `zeph worktree clean`) and, with no
2298    /// `disk_quota_mb` configured, never performs the filesystem walk.
2299    #[tokio::test]
2300    async fn sweep_reclaims_prunable_and_reports_count_without_disk_check() {
2301        let dir = make_repo();
2302        let runner = FakeGitRunner::new();
2303        let porcelain_with_prunable = format!(
2304            "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\n\
2305             worktree {0}/worktrees/prunable-1\nHEAD def456\nbranch refs/heads/agent/prunable-1\n\
2306             prunable gitdir file points to non-existent location\n\n",
2307            canon_repo(&dir).display()
2308        );
2309        // sweep()'s single reconcile() call, shared by the clean and count steps.
2310        runner.push_ok(porcelain_with_prunable.into_bytes());
2311        // clean_from_stale(): remove prunable-1
2312        runner.push_ok(b"" as &[u8]);
2313        // clean_from_stale(): prune
2314        runner.push_ok(b"" as &[u8]);
2315
2316        let mgr = make_manager(&dir, runner).await;
2317        let status = mgr.sweep().await.unwrap();
2318
2319        assert_eq!(status.reclaimed, 1);
2320        assert_eq!(status.count, 0);
2321        assert_eq!(status.max_worktrees, None);
2322        assert!(!status.over_count);
2323        assert_eq!(status.total_bytes, 0);
2324        assert_eq!(status.disk_quota_bytes, None);
2325        assert!(!status.over_disk);
2326        assert!(!status.is_over_quota());
2327    }
2328
2329    /// Regression test for #5924 (critic M1): `sweep()` must evaluate `disk_quota_mb`
2330    /// when configured — this is what makes the quota knob non-inert even when
2331    /// `auto_reconcile_secs = 0` (periodic sweep disabled) and only the startup sweep
2332    /// runs `sweep()` once.
2333    #[tokio::test]
2334    async fn sweep_evaluates_disk_quota_when_configured() {
2335        let dir = make_repo();
2336        let runner = FakeGitRunner::new();
2337        let porcelain = format!(
2338            "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\n",
2339            dir.path().display()
2340        );
2341        // sweep()'s single reconcile() call (no prunable entries — nothing removed).
2342        runner.push_ok(porcelain.into_bytes());
2343        // clean_from_stale(): prune
2344        runner.push_ok(b"" as &[u8]);
2345
2346        let config = WorktreeConfig {
2347            disk_quota_mb: Some(1),
2348            ..test_config()
2349        };
2350        let mgr = WorktreeManager::new(dir.path().to_path_buf(), config, runner)
2351            .await
2352            .unwrap();
2353
2354        let wt_path = dir.path().join("worktrees/agent-1");
2355        std::fs::create_dir_all(&wt_path).unwrap();
2356        std::fs::write(wt_path.join("big.bin"), vec![0u8; 2 * 1024 * 1024]).unwrap();
2357
2358        mgr.handles.lock().unwrap().push(WorktreeHandle {
2359            path: wt_path.clone(),
2360            branch_name: "agent/agent-1".to_string(),
2361            base_ref_resolved: "HEAD".to_string(),
2362            subagent_id: "agent-1".to_string(),
2363            created_at: SystemTime::now(),
2364        });
2365
2366        let status = mgr.sweep().await.unwrap();
2367        assert_eq!(status.disk_quota_bytes, Some(1_048_576));
2368        assert!(status.total_bytes >= 2 * 1024 * 1024);
2369        assert!(status.over_disk);
2370        assert!(status.is_over_quota());
2371    }
2372
2373    /// Regression test (tester gap G1): `sweep()`'s `over_count = true` branch — the only
2374    /// user-visible signal when an operator lowers `max_worktrees` below the existing worktree
2375    /// count (which does not evict anything, only blocks new admissions).
2376    #[tokio::test]
2377    async fn sweep_reports_over_count_when_max_worktrees_exceeded() {
2378        let dir = make_repo();
2379        let runner = FakeGitRunner::new();
2380        let porcelain = format!(
2381            "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\n\
2382             worktree {0}/worktrees/agent-1\nHEAD def456\nbranch refs/heads/agent/agent-1\n\n\
2383             worktree {0}/worktrees/agent-2\nHEAD ghi789\nbranch refs/heads/agent/agent-2\n\n",
2384            canon_repo(&dir).display()
2385        );
2386        // sweep()'s single reconcile() call (no prunable entries — both skipped, nothing removed).
2387        runner.push_ok(porcelain.into_bytes());
2388        // clean_from_stale(): prune
2389        runner.push_ok(b"" as &[u8]);
2390
2391        let config = WorktreeConfig {
2392            max_worktrees: Some(1),
2393            ..test_config()
2394        };
2395        let mgr = WorktreeManager::new(dir.path().to_path_buf(), config, runner)
2396            .await
2397            .unwrap();
2398
2399        let status = mgr.sweep().await.unwrap();
2400        assert_eq!(status.count, 2);
2401        assert_eq!(status.max_worktrees, Some(1));
2402        assert!(status.over_count);
2403        assert!(status.is_over_quota());
2404        assert_eq!(
2405            status.reclaimed, 0,
2406            "over_count must never trigger removal of intact worktrees"
2407        );
2408    }
2409
2410    /// Regression test (tester gap G2): `max_worktrees` and `disk_quota_mb` set together must
2411    /// report `over_count`/`over_disk` independently — one breached, the other not.
2412    #[tokio::test]
2413    async fn sweep_reports_independent_over_count_and_over_disk_flags() {
2414        let dir = make_repo();
2415        let runner = FakeGitRunner::new();
2416        let porcelain = format!(
2417            "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\n",
2418            dir.path().display()
2419        );
2420        // sweep()'s single reconcile() call (no prunable entries — nothing removed).
2421        runner.push_ok(porcelain.into_bytes());
2422        // clean_from_stale(): prune
2423        runner.push_ok(b"" as &[u8]);
2424
2425        let config = WorktreeConfig {
2426            max_worktrees: Some(5),
2427            disk_quota_mb: Some(1),
2428            ..test_config()
2429        };
2430        let mgr = WorktreeManager::new(dir.path().to_path_buf(), config, runner)
2431            .await
2432            .unwrap();
2433
2434        let wt_path = dir.path().join("worktrees/agent-1");
2435        std::fs::create_dir_all(&wt_path).unwrap();
2436        std::fs::write(wt_path.join("big.bin"), vec![0u8; 2 * 1024 * 1024]).unwrap();
2437
2438        mgr.handles.lock().unwrap().push(WorktreeHandle {
2439            path: wt_path.clone(),
2440            branch_name: "agent/agent-1".to_string(),
2441            base_ref_resolved: "HEAD".to_string(),
2442            subagent_id: "agent-1".to_string(),
2443            created_at: SystemTime::now(),
2444        });
2445
2446        let status = mgr.sweep().await.unwrap();
2447        assert_eq!(status.count, 1, "only the registered handle counts");
2448        assert!(
2449            !status.over_count,
2450            "count (1) is under max_worktrees (5) — must not be flagged over"
2451        );
2452        assert!(status.over_disk, "disk usage over quota must be flagged");
2453        assert!(status.is_over_quota());
2454    }
2455
2456    /// Regression test for #6205 (review finding #1 / critic finding #4): a single
2457    /// `sweep()` tick with a MIX of removed + skipped + errored stale entries must
2458    /// retain in `remaining_stale` exactly the skipped and errored ones, excluding only
2459    /// the successfully-removed one. Every other sweep test has stale entries that are
2460    /// either all-removed or all-skipped, so none of them can distinguish "`remaining`
2461    /// correctly excludes removed entries" from "`remaining` happens to equal all stale
2462    /// entries" as a coincidence of the fixtures used — this test is the one that can.
2463    ///
2464    /// Asserts both `status.count` (derived from `remaining_stale.len()`) and
2465    /// `status.total_bytes` (derived from the paths handed to `disk_usage_from_paths`,
2466    /// which come from the same `remaining_stale`): each worktree directory is seeded
2467    /// with a distinct file size, so if the removed entry's path leaked into
2468    /// `remaining_stale` the total would be wrong by exactly its size (100 bytes).
2469    #[tokio::test]
2470    async fn sweep_retains_only_skipped_and_errored_entries_when_outcomes_are_mixed() {
2471        let dir = make_repo();
2472        let runner = FakeGitRunner::new();
2473        let porcelain = format!(
2474            "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\n\
2475             worktree {0}/worktrees/removed-1\nHEAD def456\nbranch refs/heads/agent/removed-1\n\
2476             prunable gitdir file points to non-existent location\n\n\
2477             worktree {0}/worktrees/skipped-1\nHEAD ghi789\nbranch refs/heads/agent/skipped-1\n\n\
2478             worktree {0}/worktrees/errored-1\nHEAD jkl012\nbranch refs/heads/agent/errored-1\n\
2479             prunable gitdir file points to non-existent location\n\n",
2480            canon_repo(&dir).display()
2481        );
2482        // sweep()'s single reconcile() call, covering all 3 stale entries.
2483        runner.push_ok(porcelain.into_bytes());
2484        // clean_from_stale(): remove removed-1 -> succeeds.
2485        runner.push_ok(b"" as &[u8]);
2486        // clean_from_stale(): remove errored-1 -> fails (e.g. a locked worktree).
2487        runner.push_err(b"error: unable to remove worktree: it is locked\n" as &[u8]);
2488        // clean_from_stale(): prune.
2489        runner.push_ok(b"" as &[u8]);
2490
2491        let config = WorktreeConfig {
2492            disk_quota_mb: Some(1),
2493            ..test_config()
2494        };
2495        let mgr = WorktreeManager::new(dir.path().to_path_buf(), config, runner)
2496            .await
2497            .unwrap();
2498
2499        // Seed all 3 directories with distinct sizes so a leaked/missing path in
2500        // `remaining_stale` shows up as a wrong `total_bytes`, not just a wrong count.
2501        for (name, size) in [("removed-1", 100), ("skipped-1", 10), ("errored-1", 1)] {
2502            let wt_path = dir.path().join("worktrees").join(name);
2503            std::fs::create_dir_all(&wt_path).unwrap();
2504            std::fs::write(wt_path.join("file.bin"), vec![0u8; size]).unwrap();
2505        }
2506
2507        let status = mgr.sweep().await.unwrap();
2508
2509        assert_eq!(
2510            status.reclaimed, 1,
2511            "only removed-1 was successfully removed"
2512        );
2513        assert_eq!(
2514            status.count, 2,
2515            "count must reflect exactly the skipped + errored entries, not the removed one"
2516        );
2517        assert_eq!(
2518            status.total_bytes, 11,
2519            "disk usage must walk exactly skipped-1 (10 bytes) + errored-1 (1 byte); \
2520             100 would mean removed-1 leaked into remaining_stale, 1 or 10 alone would mean \
2521             one of skipped/errored was dropped"
2522        );
2523    }
2524
2525    /// Regression test for #6205: before the dedup fix, a `sweep()` tick with
2526    /// `disk_quota_mb` configured issued `worktree list --porcelain` three times
2527    /// (once inside `clean()`, once directly in `sweep()` for the post-clean count,
2528    /// once inside `disk_usage()`) — all three reflect the same git state, so the
2529    /// last two were pure duplicates. `sweep()` must now issue exactly one.
2530    #[tokio::test]
2531    async fn sweep_issues_exactly_one_reconcile_call_per_tick() {
2532        let dir = make_repo();
2533        let runner = Arc::new(FakeGitRunner::new());
2534        let porcelain = format!(
2535            "worktree {0}\nHEAD abc123\nbranch refs/heads/main\n\n\
2536             worktree {0}/worktrees/prunable-1\nHEAD def456\nbranch refs/heads/agent/prunable-1\n\
2537             prunable gitdir file points to non-existent location\n\n",
2538            dir.path().display()
2539        );
2540        // The single reconcile() call shared by the clean and disk-usage halves.
2541        runner.push_ok(porcelain.into_bytes());
2542        // clean_from_stale(): remove prunable-1
2543        runner.push_ok(b"" as &[u8]);
2544        // clean_from_stale(): prune
2545        runner.push_ok(b"" as &[u8]);
2546
2547        let config = WorktreeConfig {
2548            disk_quota_mb: Some(1),
2549            ..test_config()
2550        };
2551        let mgr = WorktreeManager::new(dir.path().to_path_buf(), config, Arc::clone(&runner))
2552            .await
2553            .unwrap();
2554
2555        mgr.sweep().await.unwrap();
2556
2557        let calls = runner.calls.lock().unwrap();
2558        let reconcile_calls = calls
2559            .iter()
2560            .filter(|(args, _)| {
2561                args == &vec![
2562                    "worktree".to_string(),
2563                    "list".to_string(),
2564                    "--porcelain".to_string(),
2565                ]
2566            })
2567            .count();
2568        assert_eq!(
2569            reconcile_calls, 1,
2570            "sweep() must call `worktree list --porcelain` exactly once per tick, got calls: {calls:?}"
2571        );
2572    }
2573}