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
188
189
190
191
192
193
194
195
196
197
198
//! The audit lines every worktree removal writes around its deletion (#7885).
//!
//! Why: on 2026-09-14 two merged worktrees — `agent-a9826013bc7683c2b` and
//! `agent-a1afc1489a1adbf97`, both PR #7858 — lost their entire tracked
//! `crates/` subtree (6652 `D` entries, no `M`, no `??`), and no daemon log
//! named either path or either session. Several code paths can remove a session
//! workspace, each logged differently or not at all, so the mechanism could not
//! be identified even after the fact. An audit line that exists only on the
//! success arm of one of those paths is not an audit trail: the one that
//! deleted these left no record at all.
//!
//! What: [`audited_removal`], called by every route that removes a worktree or
//! a session workspace:
//! [`remove_session_worktree`](super::decommission::remove_session_worktree),
//! which the merged-PR reclaim, the orphan sweep and session decommission reach,
//! including its raw `remove_dir_all` fallback; decommission's owned-workspace
//! removal; and the agent-worktree reaper (#7885 critic round). It resolves a
//! [`RemovalAudit`] from the path
//! itself (resolved path, branch, owning session or agent from the
//! `.trusty-mpm-worktree` sentinel, and the caller's reason), writes an ATTEMPT
//! line before the removal runs, and an OUTCOME line after it returns. Both go
//! to `tracing` at INFO, where prune already logs (no second sink).
//!
//! **Attempt, then outcome (#7885 critic round).** The single line used to read
//! "removing …" and was written before a removal that can still be refused —
//! a git lock, a stale pointer, a spawn failure — so a refusal read as a
//! deletion. The attempt line stays BEFORE the removal, because a removal that
//! dies part-way (the incident's shape) still has to be on record; the outcome
//! line says whether the directory is actually gone.
//!
//! **Best-effort, and never a gate.** Every field falls back to a placeholder
//! rather than failing, because an audit that can refuse a removal is a new
//! failure mode on a path whose whole job is to finish. It reads; it never
//! writes and never decides.
//! Test: `worktree_removal_audit_tests`.
use std::path::{Path, PathBuf};
use tracing::info;
use super::decommission::WorktreeRemoval;
use super::worktree_ownership::{SentinelOwner, read_sentinel_owner};
use super::worktree_safety::git_stdout;
/// What a removal is about to delete, and on whose say-so (#7885).
///
/// Why: a struct rather than four `tracing` fields at each call site, so every
/// removal route is guaranteed to record the same four facts — the divergence
/// between routes is exactly what made the observed deletion untraceable.
/// What: the four fields #7885's closure conditions name. Built by
/// [`Self::resolve`], rendered by [`Self::line`] and [`Self::outcome_line`].
/// Test: `worktree_7885_an_audit_names_the_path_branch_session_and_reason`,
/// `an_audit_for_an_unreadable_path_still_names_the_path_and_reason`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RemovalAudit {
/// The directory about to be removed, canonicalized where possible.
pub path: PathBuf,
/// The branch the worktree has checked out.
pub branch: String,
/// The session or agent the ownership sentinel names.
pub session: String,
/// Which code path asked for the removal, and why.
pub reason: String,
}
/// Rendered when git reports no branch name, or cannot be asked.
const NO_BRANCH: &str = "(none)";
/// Rendered when the ownership sentinel names nobody.
const NO_OWNER: &str = "(unowned)";
impl RemovalAudit {
/// Read the four facts off `path` itself (#7885).
///
/// Why: resolved from the path rather than passed in, so a caller cannot
/// omit or mis-state them, and so the line describes what is ACTUALLY on
/// disk at the moment of deletion rather than what a minutes-old survey
/// believed.
/// What: the canonical path (raw spelling when canonicalization fails, which
/// is itself worth recording), `git rev-parse --abbrev-ref HEAD`, and the
/// `.trusty-mpm-worktree` sentinel's owner. Every read falls back to a
/// placeholder; none can fail.
/// Test: `worktree_7885_an_audit_names_the_path_branch_session_and_reason`.
pub(crate) fn resolve(path: &Path, reason: impl Into<String>) -> Self {
let branch = git_stdout(path, &["rev-parse", "--abbrev-ref", "HEAD"])
.map(|s| s.trim().to_string())
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| NO_BRANCH.to_string());
let session = match read_sentinel_owner(path) {
SentinelOwner::Known(id, _) => format!("session {id}"),
SentinelOwner::Agent(owner, _) => format!("agent {}", owner.agent_id),
SentinelOwner::Unknown => NO_OWNER.to_string(),
};
Self {
path: std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()),
branch,
session,
reason: reason.into(),
}
}
/// The ATTEMPT line, written before the removal runs.
///
/// #7885 critic round: worded as an attempt. It precedes a removal that can
/// still be refused, so "removing …" read as a completed deletion.
/// Test: `worktree_7885_an_audit_names_the_path_branch_session_and_reason`,
/// `worktree_7885_a_refused_removal_is_never_audited_as_a_deletion`.
pub(crate) fn line(&self) -> String {
format!(
"worktree-removal: attempting removal of {} (branch {}, owner {}) — {} (#7885)",
self.path.display(),
self.branch,
self.session,
self.reason
)
}
/// The OUTCOME line, written after the removal returns (#7885 critic round).
///
/// Why: the attempt line alone over-reports deletions to anyone counting
/// them. This line reads `removed` only when the remover reported success
/// AND the directory is gone; otherwise it reads `kept` and quotes why.
/// What: `still_on_disk` is observed by the caller after the removal, so a
/// reported success that left the directory behind is not called a removal.
/// Test: `worktree_7885_a_refused_removal_is_never_audited_as_a_deletion`,
/// `worktree_7885_a_completed_removal_is_audited_as_removed`.
pub(super) fn outcome_line(&self, outcome: &WorktreeRemoval, still_on_disk: bool) -> String {
let (verb, detail) = match (outcome.reason(), still_on_disk) {
// #8782: git failed after deleting content — never "kept".
(Some(why), _) if matches!(outcome, WorktreeRemoval::PartiallyRemoved(_)) => (
"partially removed",
format!("{why} (asked for: {})", self.reason),
),
(None, false) => ("removed", self.reason.clone()),
(None, true) => (
"kept",
format!(
"the remover reported success but the directory is still on disk (asked \
for: {})",
self.reason
),
),
(Some(why), _) => (
"kept",
format!(
"the removal did not complete: {why} (asked for: {})",
self.reason
),
),
};
format!(
"worktree-removal: {verb} {} (branch {}, owner {}) — {detail} (#7885)",
self.path.display(),
self.branch,
self.session,
)
}
}
/// Run `remove` between an attempt line and an outcome line (#7885).
///
/// Why: the removal path has exactly one place this belongs. Taking the removal
/// as a closure means no future edit can write the attempt without the outcome,
/// or read the facts after the directory they describe is gone.
/// What: resolves [`RemovalAudit`] from `path`, logs [`RemovalAudit::line`],
/// runs `remove`, logs [`RemovalAudit::outcome_line`] against whether `path`
/// still exists, and returns the removal's own result unchanged.
/// Test: `worktree_7885_a_removal_emits_one_audit_line_before_deleting`,
/// `worktree_7885_a_refused_removal_is_never_audited_as_a_deletion`,
/// `worktree_7885_a_completed_removal_is_audited_as_removed`.
pub(crate) fn audited_removal(
path: &Path,
reason: &str,
remove: impl FnOnce() -> WorktreeRemoval,
) -> WorktreeRemoval {
let audit = RemovalAudit::resolve(path, reason);
info!(
path = %audit.path.display(),
branch = %audit.branch,
session = %audit.session,
reason = %audit.reason,
"{}",
audit.line()
);
let outcome = remove();
info!(
path = %audit.path.display(),
removed = outcome.removed() && !path.exists(),
"{}",
audit.outcome_line(&outcome, path.exists())
);
outcome
}
#[cfg(test)]
#[path = "worktree_removal_audit_tests.rs"]
mod worktree_removal_audit_tests;