Skip to main content

WorktreeManager

Struct WorktreeManager 

Source
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>

Source

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
§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?;
Source

pub fn repo_root(&self) -> &Path

Returns the repository root this manager was constructed with.

Source

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.

Source

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.

Source

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
§Examples
let handle = mgr.create("agent-42").await?;
println!("Worktree at {:?} on branch {}", handle.path, handle.branch_name);
Source

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?;
Source

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());
Source

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());
}
Source

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?;
Source

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));
Source

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());
Source

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);
}
Source

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);
}

Auto Trait Implementations§

§

impl<R> !Freeze for WorktreeManager<R>

§

impl<R> !RefUnwindSafe for WorktreeManager<R>

§

impl<R> Send for WorktreeManager<R>

§

impl<R> Sync for WorktreeManager<R>

§

impl<R> Unpin for WorktreeManager<R>
where R: Unpin,

§

impl<R> UnsafeUnpin for WorktreeManager<R>
where R: UnsafeUnpin,

§

impl<R> UnwindSafe for WorktreeManager<R>
where R: UnwindSafe,

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T> Instrument for T

Source§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided Span, returning an Instrumented wrapper. Read more
Source§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
Source§

impl<T> WithSubscriber for T

Source§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a WithDispatch wrapper. Read more
Source§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a WithDispatch wrapper. Read more