pub struct WorktreeManager<R: GitRunner> { /* private fields */ }Expand description
Manages the full lifecycle of per-subagent git worktrees.
WorktreeManager is parameterised over a GitRunner so that unit tests
can inject a FakeGitRunner (defined in the test module) without touching
the file system. Production code uses
DefaultWorktreeManager.
§Concurrency
The internal handle list is guarded by a std::sync::Mutex. Most
methods acquire this lock for the minimum necessary duration — they
never hold it across an .await on an external resource.
create is the exception: its quota-check-through-
registration sequence is additionally guarded end-to-end by a
tokio::sync::Mutex (admission_lock), held across every .await in
that sequence. This makes admission to max_worktrees safe against
concurrent in-process create() calls without relying on caller-side
locking — see create’s # Concurrency section for details.
§TODO
TODO(critic D1): concurrent per-agent cwd isolation requires child-process
bgIsolation or full ToolExecutor cwd-threading; in-process MVP is
concurrency-1 only.
Implementations§
Source§impl<R: GitRunner> WorktreeManager<R>
impl<R: GitRunner> WorktreeManager<R>
Sourcepub async fn new(
repo_root: PathBuf,
config: WorktreeConfig,
runner: R,
) -> Result<Self, WorktreeError>
pub async fn new( repo_root: PathBuf, config: WorktreeConfig, runner: R, ) -> Result<Self, WorktreeError>
Creates a new manager, validating the repository root and canonicalising the worktree root directory.
The worktree root directory is created if it does not yet exist. The
underlying filesystem calls (create_dir_all, canonicalize) are
offloaded to tokio::task::spawn_blocking so the async executor is
never stalled.
§Errors
WorktreeError::RootOutsideRepoif the configured root escapes the repository.WorktreeError::Iofor filesystem errors.
§Examples
use std::path::PathBuf;
use zeph_config::WorktreeConfig;
use zeph_worktree::{DefaultWorktreeManager, git_runner::DefaultGitRunner};
let mgr = DefaultWorktreeManager::new(
PathBuf::from("/path/to/repo"),
WorktreeConfig::default(),
DefaultGitRunner::new(),
).await?;Sourcepub fn repo_root(&self) -> &Path
pub fn repo_root(&self) -> &Path
Returns the repository root this manager was constructed with.
Sourcepub fn config(&self) -> &WorktreeConfig
pub fn config(&self) -> &WorktreeConfig
Returns the WorktreeConfig this manager was constructed with.
Exposed so callers that only hold the manager (not the original config, e.g.
/worktree list in zeph-core) can read max_worktrees/disk_quota_mb for
quota-status formatting without threading the config separately.
Sourcepub fn prune_branch_on_remove(&self) -> bool
pub fn prune_branch_on_remove(&self) -> bool
Whether remove should also delete the branch, per the
WorktreeConfig::prune_branch_on_remove this manager was constructed with.
Exposed so callers that only hold the manager (not the original config, e.g.
/worktree clean in zeph-core) don’t need to thread the config separately.
Sourcepub async fn create(
&self,
subagent_id: &str,
) -> Result<WorktreeHandle, WorktreeError>
pub async fn create( &self, subagent_id: &str, ) -> Result<WorktreeHandle, WorktreeError>
Creates a new worktree for subagent_id according to the configured
base_ref strategy.
The branch name is "{branch_prefix}{subagent_id}". The path on disk is
"{root}/{subagent_id}".
§Admission cap
When config.max_worktrees is Some(max), this call first counts all
git-registered secondary worktrees under root — via
reconcile (stale/foreign entries) plus
list (this session’s own) — and fails with
WorktreeError::QuotaExceeded if that count is already >= max. The
count includes worktrees created by other, concurrently running zeph
sessions over the same root, since they consume the same disk budget
max_worktrees is meant to bound — but, per reconcile’s
“Scope” section, excludes worktrees created by unrelated tooling elsewhere in the
repository (e.g. EnterWorktree, a manual git worktree add outside root), which
do not consume this budget and must not count against it (#6257). This is a
best-effort soft cap across processes: the count-then-git worktree add
sequence is not atomic across separate zeph sessions, so two concurrent create()
calls in different processes can both pass the check and briefly push the total
above max. No cross-process locking is used to close that gap — it is out of
scope for the size of this feature. Within a single process, admission is a
hard guarantee — see # Concurrency below.
§Concurrency
The quota-check-through-registration sequence (the count read, the
max comparison, git worktree add, and the final push onto the
in-memory handle list) is serialised end-to-end by an internal
tokio::sync::Mutex, held across every .await in that span. Two
concurrent in-process create() calls on the same WorktreeManager
can therefore never both observe the same pre-admission count and
both proceed past the max_worktrees check — the second call’s count
read always reflects the first call’s completed registration. Callers
do not need to replicate external locking for quota-safety purposes;
any locking they hold (e.g. zeph-subagent’s cwd_lock) exists for
unrelated invariants and is not load-bearing for max_worktrees
enforcement.
§TODO
TODO(critic D2): head worktree does not include parent uncommitted changes by design; revisit if users need stash-based propagation.
§Errors
WorktreeError::InvalidBranchNamewhensubagent_idfails validation.WorktreeError::QuotaExceededwhenconfig.max_worktreeswould be reached or exceeded (see “Admission cap” above).WorktreeError::PathExistswhen the worktree path already exists.WorktreeError::BaseRefUnresolvedwhenbase_ref = Freshand the default branch cannot be resolved.WorktreeError::GitCommandfor anygitfailure.
§Examples
let handle = mgr.create("agent-42").await?;
println!("Worktree at {:?} on branch {}", handle.path, handle.branch_name);Sourcepub async fn remove(
&self,
handle: &WorktreeHandle,
prune_branch: bool,
) -> Result<(), WorktreeError>
pub async fn remove( &self, handle: &WorktreeHandle, prune_branch: bool, ) -> Result<(), WorktreeError>
Removes the worktree identified by handle.
If prune_branch is true, also deletes the git branch after removing
the worktree directory.
The in-memory handle is dropped as soon as the worktree directory has
been removed from disk, regardless of whether the subsequent branch
prune succeeds. This keeps list from ever reporting a
path that no longer exists on disk, even if the branch prune step
fails below.
This issues a single git worktree remove --force, which bypasses
git’s “refuse to remove a dirty working tree” guard but — deliberately
— does not override an explicit git worktree lock; git demands a
second -f (remove -f -f) for that. Callers deciding whether to
call remove on a worktree not created by this session (e.g.
zeph worktree clean) MUST gate on
StaleWorktree::is_safe_to_force_remove
or an explicit operator override first — remove itself performs no
such check (#6055).
§Errors
Returns WorktreeError::GitCommand if either git command fails. If
the op field is "branch -D", the worktree itself was already
removed and the handle already dropped — only the branch delete
failed.
§Examples
mgr.remove(&handle, false).await?;Sourcepub fn list(&self) -> Vec<WorktreeHandle>
pub fn list(&self) -> Vec<WorktreeHandle>
Returns a snapshot of the in-memory handle list for the current session.
This list only contains worktrees created in the current process. To
discover worktrees that exist in the git registry but not in memory (e.g.
after a crash), use reconcile.
§Examples
let handles = mgr.list();
println!("{} active worktrees", handles.len());Sourcepub async fn reconcile(&self) -> Result<Vec<StaleWorktree>, WorktreeError>
pub async fn reconcile(&self) -> Result<Vec<StaleWorktree>, WorktreeError>
Reads the git worktree registry and returns StaleWorktree entries for
worktrees that exist on disk but are not in the current session’s
in-memory list.
This is called by the zeph worktree list and zeph worktree clean
CLI subcommands (handle_worktree_command in src/commands/worktree.rs)
to recover from a previous crash that left stale worktrees behind. There
is no startup caller — worktrees are only reconciled on-demand via these
subcommands. Each entry carries
git’s own prunable verdict (see StaleWorktree::is_safe_to_force_remove)
so callers can distinguish a worktree whose directory is already gone
from one that is merely untracked by this process — the latter may
belong to another, concurrently running session and MUST NOT be
force-removed without an explicit operator override (#6055).
§Scope
Only entries whose path falls under this manager’s canonicalised
worktree_root (config.root, resolved at construction) are returned. git worktree list --porcelain reports every worktree registered against the
repository, including ones created by unrelated tooling — e.g. the EnterWorktree
developer tool, or a manual git worktree add — under a completely different
directory. Those are not managed by this subsystem: zeph worktree clean must
never remove them, and create’s max_worktrees admission count
must not be inflated by them (#6257). A worktree created by another,
concurrently running zeph session that shares the same config.root still counts
— it is under worktree_root and is legitimately this subsystem’s responsibility,
even though it is untracked by this process’s in-memory handle list.
§Errors
Returns WorktreeError::GitCommand if git worktree list fails.
§Examples
let stale = mgr.reconcile().await?;
for s in &stale {
println!("stale worktree: {:?} (safe to force-remove: {})", s.handle.path, s.is_safe_to_force_remove());
}Sourcepub async fn prune(&self) -> Result<(), WorktreeError>
pub async fn prune(&self) -> Result<(), WorktreeError>
Runs git worktree prune to clear stale administrative entries from
the git worktree registry (e.g. left behind when a worktree directory
was deleted directly instead of via remove).
Per FR-CLEANUP-04, this SHALL be called by zeph worktree clean after
reconcile’s stale entries have been removed via
git worktree remove --force.
§Errors
Returns WorktreeError::GitCommand if git worktree prune fails.
§Examples
mgr.prune().await?;Sourcepub async fn clean(
&self,
force: bool,
prune_branch_on_remove: bool,
force_hint: &str,
) -> Result<CleanOutcome, WorktreeError>
pub async fn clean( &self, force: bool, prune_branch_on_remove: bool, force_hint: &str, ) -> Result<CleanOutcome, WorktreeError>
Runs the full worktree clean pipeline: reconcile, then
remove each stale entry that is either prunable or covered by
force, then prune the registry.
Shared by the CLI (zeph worktree clean, src/commands/worktree.rs) and the
agent-side /worktree clean slash command (crates/zeph-core/src/agent/ worktree_commands.rs) so their removed/skipped/errored counts and per-entry
warnings cannot silently diverge — this exact divergence (a discarded prune()
failure on one call site) was caught in review during #6141 (#6142).
force_hint is substituted into the skip-warning for a non-force run advising
the operator how to override it (e.g. `zeph worktree clean --force` for the
CLI, `/worktree clean --force` for the slash command) — the only piece of
UX text that legitimately differs between the two surfaces.
This is a thin wrapper around an internal clean_from_stale helper —
sweep calls that helper directly with an already-fetched
stale list so a single sweep() tick only ever issues one reconcile()
subprocess call (#6205).
§Errors
Returns WorktreeError::GitCommand only if the initial reconcile call
fails — nothing has been removed yet, so there is no partial outcome to lose.
Per-entry removal failures and a final prune failure are both recorded in the
returned CleanOutcome instead of aborting.
§Examples
let outcome = mgr.clean(false, false, "`zeph worktree clean --force`").await?;
println!("{}", zeph_worktree::format_clean_summary(&outcome));Sourcepub async fn disk_usage(&self) -> Result<WorktreeDiskUsage, WorktreeError>
pub async fn disk_usage(&self) -> Result<WorktreeDiskUsage, WorktreeError>
Computes total and per-worktree disk usage across every worktree under
root — both this session’s own (list) and any discovered
via reconcile (stale/foreign entries).
The recursive filesystem walk runs on tokio::task::spawn_blocking, so
it never stalls the async executor — but it is still an O(files-under-root)
operation that can be slow against multi-gigabyte target/ directories.
Callers on a hot or interactive path should prefer
cached_disk_usage and only call this method
from a deliberate, infrequent trigger (a sweep tick or an
explicit CLI invocation) — never from create.
The reported total is a sum of logical file sizes
(std::fs::Metadata::len), not on-disk block usage — content shared via
hardlinks across worktrees (e.g. zeph-session blobs) can be double-counted.
Treat the result as an approximation suitable for a soft warn threshold.
On success, the result is stored so a subsequent
cached_disk_usage call can read it without
re-walking the filesystem.
This is a thin wrapper around an internal disk_usage_from_paths helper —
sweep calls that helper directly with the stale paths left
over from its own internal clean_from_stale call, so a single sweep() tick
only ever issues one reconcile() subprocess call (#6205).
§Errors
Returns WorktreeError::GitCommand if the underlying
reconcile call fails, or WorktreeError::Io if the
blocking walk task itself panics or is cancelled.
§Examples
let usage = mgr.disk_usage().await?;
println!("total: {} bytes across {} worktree(s)", usage.total_bytes, usage.per_worktree.len());Sourcepub fn cached_disk_usage(&self) -> Option<WorktreeDiskUsage>
pub fn cached_disk_usage(&self) -> Option<WorktreeDiskUsage>
Returns the disk usage computed by the most recent
disk_usage call, without performing a filesystem
walk. Returns None if disk_usage() has never been called on this
manager instance.
§Examples
if let Some(usage) = mgr.cached_disk_usage() {
println!("last known total: {} bytes", usage.total_bytes);
}Sourcepub async fn sweep(&self) -> Result<QuotaStatus, WorktreeError>
pub async fn sweep(&self) -> Result<QuotaStatus, WorktreeError>
Runs one reconcile-and-quota sweep: a single reconcile
call feeds prunable-only auto-reclaim (the same removal-and-prune pipeline as
zeph worktree clean’s clean(force=false, ..)), then the resulting
post-clean worktree count is evaluated against config.max_worktrees and,
when config.disk_quota_mb is set, against a disk-usage walk.
Unlike calling clean and disk_usage
directly (which each perform their own reconcile()), sweep() fetches the
stale worktree list once and threads it through the internal
clean_from_stale and disk_usage_from_paths helpers — the “remaining” stale
entries clean_from_stale returns (i.e. stale minus what it just removed) are
exactly the post-clean stale state, computed in-memory rather than by
re-invoking git worktree list --porcelain (#6205). This makes every sweep()
tick issue exactly one reconcile() subprocess call instead of three.
Never force-removes an intact worktree — reclamation only removes entries git
itself reports as prunable (spec-063 INV-5/INV-6). An over-quota state with
only intact worktrees is reported via QuotaStatus::is_over_quota, never
resolved by deleting anything.
The disk-usage walk is skipped entirely (and QuotaStatus::total_bytes
is left at 0 with QuotaStatus::disk_quota_bytes as None) when
config.disk_quota_mb is unset, avoiding the filesystem walk’s cost when
there is no threshold to evaluate it against.
§Concurrency note
count and the disk-usage figures both derive from the single
reconcile snapshot taken at the start of this call, not from
re-querying git afterward. WorktreeManager is typically shared (e.g. Arc’d
between a subagent spawn/teardown path and a periodic sweep loop), so a
worktree registry mutation that lands during this call (a concurrent
create/remove from another task) will not be
reflected in this tick’s result — the next sweep() tick picks it up instead.
This is a narrower staleness window than calling clean and
disk_usage separately (each would re-snapshot git at its own
call time), but it does not weaken any safety invariant: reclamation still never
force-removes an intact worktree (see below), and disk_usage
is already documented as an approximation suitable only for a soft warn threshold.
§Errors
Returns WorktreeError::GitCommand if the initial
reconcile call fails, or WorktreeError::Io from the
disk-usage walk when disk accounting is enabled.
§Examples
let status = mgr.sweep().await?;
if status.is_over_quota() {
eprintln!("worktrees over quota: {}/{:?}", status.count, status.max_worktrees);
}