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}