1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
//! Make an adopted worktree usable by the next agent (#8318).
//!
//! Why: adoption (#6497) rewrote the ownership sentinel and nothing else, so the
//! tree kept its branch checked out and kept the dead agent's harness git lock.
//! Git refuses to check a branch out in a second worktree while any worktree
//! holds it, and a successor agent always runs in its own isolated tree — so the
//! successor could not reach the branch and restarted from a bare SHA instead.
//! What: two follow-up steps the adopt route runs after the sentinel write.
//! [`release_branch_if_clean`] detaches the tree's HEAD, which frees its branch,
//! but only when the tree provably holds no uncommitted, staged or untracked
//! work. [`clear_dead_agent_lock`] removes the harness lock only when it names a
//! pid the kernel reports does not exist.
//!
//! FAIL-CLOSED: a check that cannot complete detaches nothing and unlocks
//! nothing. Detaching never discards work — the branch keeps pointing at every
//! commit, and a detach at the same commit leaves the working tree untouched —
//! but a tree that could not be proven clean keeps its branch anyway, so the
//! operator sees the work before anyone else checks the branch out.
//! Test: `worktree_adopt_release_tests`.
use std::path::Path;
use serde::Serialize;
use super::worktree_adopt::LockEvidence;
use super::worktree_safety::{count_dirty_files, git_stdout, is_worktree_root};
/// What adoption did with the tree's checked-out branch (#8318).
///
/// Why: the CLI has to exit non-zero when the branch stays held, and it can
/// only do that if the daemon says which of the three outcomes happened.
/// What: serialized as `{"outcome": "released" | "already_detached" | "kept"}`
/// plus the branch name or the reason.
/// Test: `release_detaches_a_clean_trees_branch_and_frees_it`,
/// `release_keeps_a_dirty_trees_branch`,
/// `release_reports_an_already_detached_head`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub(crate) enum BranchRelease {
/// HEAD was detached; `branch` is now free for another worktree.
Released { branch: String },
/// HEAD was already detached, so no branch was held.
AlreadyDetached,
/// The branch stays checked out; `reason` says why.
Kept { reason: String },
}
/// What adoption did with the tree's harness git lock (#8318).
///
/// Test: `clear_dead_agent_lock_unlocks_a_dead_pids_tree`,
/// `clear_dead_agent_lock_leaves_a_running_pids_lock`,
/// `clear_dead_agent_lock_never_touches_an_operator_lock`,
/// `clear_dead_agent_lock_reports_an_unlock_that_fails`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub(crate) enum LockRelease {
/// The lock named dead `pid` and was removed.
Cleared { pid: u32 },
/// The lock names running `pid` and was left in place.
LeftRunning { pid: u32 },
/// The lock named dead `pid`, but `git worktree unlock` failed.
Failed { pid: u32, reason: String },
/// No harness agent lock with a pid the probe could answer for — no lock,
/// an operator lock, or an unanswerable probe. Nothing was touched.
NotCleared,
}
/// Detach `path`'s HEAD when the tree is provably clean (#8318).
///
/// Why: a detached HEAD is what lets `git worktree add <other> <branch>` or
/// `git switch <branch>` succeed in the successor's own tree. It is only safe
/// to do without asking when there is no work in the tree the operator has not
/// seen, so a dirty tree keeps its branch and the caller reports why.
/// What: five steps, each of which returns [`BranchRelease::Kept`] on failure:
/// the path must be its own worktree root and a LINKED worktree, never the
/// main checkout ([`is_linked_worktree`]); [`count_dirty_files`] must answer
/// zero (the ownership sentinel does not count); HEAD must resolve; and
/// `git switch --detach` must succeed. A HEAD that is already detached returns
/// [`BranchRelease::AlreadyDetached`].
/// Test: `release_detaches_a_clean_trees_branch_and_frees_it`,
/// `release_refuses_the_main_checkout`,
/// `release_keeps_a_dirty_trees_branch`,
/// `release_keeps_the_branch_when_the_clean_check_cannot_complete`,
/// `release_reports_an_already_detached_head`.
pub(crate) fn release_branch_if_clean(path: &Path) -> BranchRelease {
let kept = |reason: String| BranchRelease::Kept { reason };
// #8318: fail closed — an unanswered check keeps the branch.
match is_worktree_root(path) {
Ok(true) => {}
Ok(false) => {
return kept(format!(
"{} is not the root of a git worktree, so its cleanliness cannot be checked",
path.display()
));
}
Err(e) => return kept(format!("the clean check could not complete: {e}")),
}
// #8318: detaching the main checkout's HEAD strands whoever works there.
match is_linked_worktree(path) {
Ok(true) => {}
Ok(false) => {
return kept(format!(
"{} is the repository's main checkout, not a linked worktree; its branch is \
never released",
path.display()
));
}
Err(e) => {
return kept(format!(
"could not tell a linked worktree from the main one: {e}"
));
}
}
let dirty = match count_dirty_files(path) {
Ok(n) => n,
Err(e) => return kept(format!("the clean check could not complete: {e}")),
};
if dirty > 0 {
return kept(format!(
"the tree holds {dirty} uncommitted, staged or untracked file(s). Commit or discard \
them, then free the branch with `git -C {} switch --detach`",
path.display()
));
}
let head = match git_stdout(path, &["rev-parse", "--abbrev-ref", "HEAD"]) {
Ok(out) => out.trim().to_string(),
Err(e) => return kept(format!("could not read the checked-out branch: {e}")),
};
if head == "HEAD" {
return BranchRelease::AlreadyDetached;
}
match git_stdout(path, &["switch", "--detach", "--quiet"]) {
Ok(_) => BranchRelease::Released { branch: head },
Err(e) => kept(format!("detaching HEAD failed: {e}")),
}
}
/// Whether `path` is a LINKED worktree rather than the main checkout (#8318).
///
/// What: a linked worktree's `--git-dir` (`<common>/worktrees/<name>`) differs
/// from its `--git-common-dir`; the main checkout's two are the same directory.
/// Both are resolved against `path` and canonicalized before comparing, since
/// git may answer either relative to the cwd. Any failure is an `Err`.
/// Test: `release_refuses_the_main_checkout`.
fn is_linked_worktree(path: &Path) -> Result<bool, String> {
let out = git_stdout(path, &["rev-parse", "--git-dir", "--git-common-dir"])?;
let mut dirs = out.lines().map(|l| {
let p = path.join(l.trim());
std::fs::canonicalize(&p).map_err(|e| format!("cannot resolve {}: {e}", p.display()))
});
match (dirs.next(), dirs.next()) {
(Some(git_dir), Some(common)) => Ok(git_dir? != common?),
_ => Err(format!("`git rev-parse` named no git directory: {out:?}")),
}
}
/// Remove `path`'s harness git lock when it belongs to a dead agent (#8318).
///
/// Why: the harness writes `claude agent <id> (pid <n> …)` as the lock reason
/// and removes the lock when the agent ends. An agent that died without ending
/// leaves the lock behind, and git then refuses `worktree remove` and `prune`
/// on a tree nobody holds. Only the harness's own lock, naming a pid that
/// provably no longer exists, is stale; an operator lock or a running pid is a
/// live veto and stays.
/// What: `evidence` is the caller's fresh [`LockEvidence`] for `path`. Only
/// [`LockEvidence::HolderPidGone`] runs `git worktree unlock`.
/// Test: `clear_dead_agent_lock_unlocks_a_dead_pids_tree`,
/// `clear_dead_agent_lock_leaves_a_running_pids_lock`,
/// `clear_dead_agent_lock_never_touches_an_operator_lock`,
/// `clear_dead_agent_lock_reports_an_unlock_that_fails`.
pub(crate) fn clear_dead_agent_lock(path: &Path, evidence: LockEvidence) -> LockRelease {
match evidence {
LockEvidence::HolderPidGone(pid) => {
let target = path.to_string_lossy();
match git_stdout(path, &["worktree", "unlock", target.as_ref()]) {
Ok(_) => LockRelease::Cleared { pid },
Err(reason) => LockRelease::Failed { pid, reason },
}
}
LockEvidence::HolderPidRunning(pid) => LockRelease::LeftRunning { pid },
LockEvidence::Silent => LockRelease::NotCleared,
}
}
#[cfg(test)]
#[path = "worktree_adopt_release_tests.rs"]
mod worktree_adopt_release_tests;