zeph_worktree/handle.rs
1// SPDX-License-Identifier: MIT
2//! [`WorktreeHandle`] — a live record of a managed git worktree.
3
4use std::{path::PathBuf, time::SystemTime};
5
6/// Sentinel used for [`WorktreeHandle::branch_name`] when a worktree discovered
7/// via [`WorktreeManager::reconcile`][crate::WorktreeManager::reconcile] is on a
8/// detached `HEAD` rather than a branch (`git worktree list --porcelain` emits a
9/// `detached` line instead of `branch refs/heads/<name>` for these entries).
10///
11/// The embedded space makes this an invalid git ref name: `git
12/// check-ref-format` rejects any ref component containing a space (see
13/// `git help check-ref-format` — disallowed characters include space, `~`,
14/// `^`, `:`, `?`, `*`, `[`, `\`). `reconcile()` only ever populates
15/// `branch_name` from a `branch refs/heads/<name>` porcelain line, and git
16/// itself refuses to create a ref containing a space in the first place — so
17/// no real branch, including one on a worktree foreign to zeph (i.e. not
18/// created via [`WorktreeManager::create`][crate::WorktreeManager::create],
19/// which further restricts the subagent-id component to
20/// `^[A-Za-z0-9._-]+$`), can ever equal this sentinel. An earlier version of
21/// this constant, `"(detached)"`, lacked this property: parentheses are
22/// valid in git ref names, so a real branch literally named `(detached)`
23/// would have been indistinguishable from a detached-HEAD worktree, causing
24/// [`WorktreeManager::remove`][crate::WorktreeManager::remove] to silently
25/// skip pruning it (#5936 review finding).
26pub const DETACHED_BRANCH_SENTINEL: &str = "(detached HEAD)";
27
28/// Sentinel used for [`WorktreeHandle::branch_name`] when a worktree discovered
29/// via [`WorktreeManager::reconcile`][crate::WorktreeManager::reconcile] is the
30/// main worktree of a bare repository (`git worktree list --porcelain` emits a
31/// `bare` line, with no `HEAD` or `branch`/`detached` line at all, for these
32/// entries).
33///
34/// Distinct from [`DETACHED_BRANCH_SENTINEL`] so operators and logs can tell
35/// "no branch because this is a bare repo" apart from "no branch because HEAD
36/// is detached" (#6052). Like [`DETACHED_BRANCH_SENTINEL`], the embedded space
37/// makes this an invalid git ref name, so it can never collide with a real
38/// branch.
39pub const BARE_WORKTREE_SENTINEL: &str = "(bare repository)";
40
41/// A live record of a git worktree that [`WorktreeManager`][crate::WorktreeManager]
42/// has created for a subagent.
43///
44/// Handles are stored in-memory for the duration of the session. They are not
45/// persisted to disk; on restart, [`WorktreeManager::reconcile`][crate::WorktreeManager::reconcile]
46/// re-discovers handles from the git worktree registry.
47#[derive(Debug, Clone)]
48pub struct WorktreeHandle {
49 /// Absolute path on disk where the worktree was checked out.
50 pub path: PathBuf,
51 /// The git branch name created for this worktree.
52 ///
53 /// For worktrees discovered by [`WorktreeManager::reconcile`][crate::WorktreeManager::reconcile]
54 /// that are on a detached `HEAD`, this is [`DETACHED_BRANCH_SENTINEL`] rather
55 /// than an actual branch name.
56 pub branch_name: String,
57 /// The resolved base ref used to create the branch.
58 ///
59 /// `"HEAD"` for [`WorktreeBaseRef::Head`][zeph_config::WorktreeBaseRef::Head],
60 /// `"origin/{branch}"` for [`WorktreeBaseRef::Fresh`][zeph_config::WorktreeBaseRef::Fresh].
61 pub base_ref_resolved: String,
62 /// The subagent identifier that this worktree was created for.
63 pub subagent_id: String,
64 /// Wall-clock time when the worktree was created.
65 pub created_at: SystemTime,
66}
67
68/// A worktree discovered by [`WorktreeManager::reconcile`][crate::WorktreeManager::reconcile]
69/// that is not present in the current process's own in-memory session state.
70///
71/// `prunable_reason` is git's own verdict from `git worktree list --porcelain`,
72/// which git derives by checking whether the worktree's directory and `.git`
73/// gitdir-link still exist and are valid — not by asking whether *this*
74/// process happens to recognise the path. This is the only staleness signal
75/// safe to force-remove on: a worktree created by a *different*, concurrently
76/// running zeph session updates the same on-disk git worktree registry that
77/// `reconcile` reads, so it is invisible to this process's `self.handles`
78/// (documented as session-local, in-RAM-only) yet fully intact on disk, and
79/// git will never mark it `prunable` while that's true.
80#[derive(Debug, Clone)]
81pub struct StaleWorktree {
82 /// The discovered worktree. `branch_name` is [`DETACHED_BRANCH_SENTINEL`]
83 /// for detached-HEAD worktrees and [`BARE_WORKTREE_SENTINEL`] for a bare
84 /// repository's main worktree, matching `reconcile`'s existing behavior.
85 pub handle: WorktreeHandle,
86 /// `Some(reason)` when git's own porcelain output includes a `prunable`
87 /// line for this worktree (directory or gitdir-link gone) — `None` means
88 /// the directory is intact and this worktree MUST NOT be force-removed
89 /// without an explicit operator override.
90 pub prunable_reason: Option<String>,
91}
92
93impl StaleWorktree {
94 /// True only when git itself has determined this worktree's directory or
95 /// `.git` link is gone/broken — the one condition under which
96 /// force-removal cannot discard live, uncommitted work, regardless of
97 /// which process created the worktree.
98 #[must_use]
99 pub fn is_safe_to_force_remove(&self) -> bool {
100 self.prunable_reason.is_some()
101 }
102}