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
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
//! Operator-facing rendering for the merged-PR reclaim pass (#2919).
//!
//! Why: split out of `managed.rs` to keep that file under the 500-SLOC
//! production cap. It is also a genuinely separate concern: the merged-PR
//! pass's refusals have a different shape from the orphan sweep's — a worktree
//! spared here because its pull request is still OPEN is not a dirty-tree skip
//! and must not be filed as one.
//! What: [`print_merged_pr_pass`], which reads the `merged_prs` object the
//! prune-worktrees route returns and prints reclaimed paths, the byte total,
//! and every re-check refusal.
//! Test: `merged_pr_pass_prints_nothing_for_a_null_body`,
//! `merged_pr_pass_reports_reclaimed_paths_and_bytes`,
//! `merged_pr_pass_surfaces_recheck_refusals`.
/// Render the `merged_prs` half of a prune-worktrees reply (#2919).
///
/// Why: a reclaim pass that deleted directories and said nothing is
/// indistinguishable from one that did nothing — the post-mortem's eighth
/// constraint ("report real before/after numbers … not merely a claim") exists
/// because that has happened here before.
/// What: prints one line per reclaimed path to stdout, then a summary naming
/// either the candidate count (dry run) or the reclaimed count and bytes, then
/// [`diagnostic_lines`] to stderr. A `null`/absent object prints nothing —
/// that is the shape the route returns when the pass was not requested.
/// Test: `merged_pr_pass_prints_nothing_for_a_null_body`,
/// `merged_pr_pass_reports_reclaimed_paths_and_bytes`,
/// `merged_pr_pass_surfaces_recheck_refusals`,
/// `merged_pr_pass_surfaces_an_agent_owned_skip`.
pub(crate) fn print_merged_pr_pass(merged: Option<&serde_json::Value>, dry_run: bool) {
let Some(merged) = merged.filter(|m| !m.is_null()) else {
return;
};
if let Some(err) = merged.get("error").and_then(serde_json::Value::as_str) {
eprintln!("merged-PR pass failed: {err} — nothing was reclaimed (#2919)");
return;
}
let array = |key: &str| {
merged
.get(key)
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default()
};
let reclaimed = array("removed");
for p in &reclaimed {
if let Some(s) = p.as_str() {
println!("{s}");
}
}
if dry_run {
let candidates = merged
.get("reclaimable")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let bytes = merged
.get("reclaimable_bytes")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
// #2919: the byte figure is a sum over the MEASURED subset, so it never
// appears without its measured-of-total qualifier — a single 17.8 GiB
// worktree can eat the whole measurement budget and leave the rest of
// the reclaimable set uncounted.
let measured = merged
.get("reclaimable_measured")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
// #6561: the classification clause rides on the SAME line as the
// reclaimable count, so "0 reclaimable" can never be read without
// whether anything was actually classified.
let unclassified = classification_clause(merged);
println!(
"merged-PR pass: {candidates} worktree(s) reclaimable; {bytes} byte(s) \
across {measured} of {candidates} measured{unclassified} (#2919)"
);
} else {
let bytes = merged
.get("removed_bytes")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let unclassified = classification_clause(merged);
println!(
"merged-PR pass: reclaimed {} worktree(s), {bytes} byte(s){unclassified} (#2919)",
reclaimed.len()
);
}
for line in diagnostic_lines(merged) {
eprintln!("{line}");
}
}
/// What the pass could NOT classify, as a clause for the summary line (#6561).
///
/// Why: a reclaimable count of zero has two causes that read identically —
/// nothing had landed, or nothing could be looked up. Live on 2026-09-02 the
/// second held for all 261 registered worktrees (the daemon inherits neither
/// `GH_TOKEN` nor `GH_CONFIG_DIR`, so `gh pr list` exited 4) and the pass
/// printed `0 worktree(s) reclaimable; 0 byte(s) across 0 of 0 measured` with
/// no hint of it. These three counts are what tell them apart, so they share
/// the reclaimable count's line rather than sitting in a stderr diagnostic the
/// operator may not have kept.
/// What: `"; could not classify: …"` naming any non-zero
/// `lookup_failed` (with the reason `gh` gave), `pr_state_unknown`, and
/// `not_inspected`. All three zero — the healthy case — returns an empty
/// string, so a clean run's line is unchanged.
/// Test: `merged_pr_pass_names_a_failed_lookup_beside_the_reclaimable_count`,
/// `merged_pr_pass_adds_no_clause_when_everything_classified`.
fn classification_clause(merged: &serde_json::Value) -> String {
let count = |key: &str| {
merged
.get(key)
.and_then(serde_json::Value::as_u64)
.unwrap_or(0)
};
let (failed, unknown, skipped) = (
count("lookup_failed"),
count("pr_state_unknown"),
count("not_inspected"),
);
let mut parts = Vec::new();
if failed > 0 {
let reason = merged
.get("lookup_failure")
.and_then(serde_json::Value::as_str)
.unwrap_or("no reason given");
parts.push(format!("{failed} pull-request lookup(s) FAILED ({reason})"));
}
if unknown > 0 {
parts.push(format!("{unknown} pull-request state(s) indeterminate"));
}
if skipped > 0 {
parts.push(format!("{skipped} not inspected before the deadline"));
}
if parts.is_empty() {
return String::new();
}
format!("; could not classify: {}", parts.join(", "))
}
/// Every stderr diagnostic the merged-PR pass owes the operator (#2919, #5829).
///
/// Why: pure so the lines can be ASSERTED rather than merely executed. The
/// previous renderer printed straight to stderr, so its tests could only prove
/// it did not panic — which is why `spared_agent_owned` being dropped entirely
/// was invisible to them.
/// What: three families, each `" <label>: <path>: <reason>"`. A worktree spared
/// because a dispatched agent owns it comes FIRST: it is the only one naming a
/// tree that is still being written to, and #5829 exists because the sweep
/// stayed silent about it. Then the re-check near-misses, then removals that
/// failed. An absent or non-array key contributes nothing.
/// Test: `merged_pr_pass_surfaces_an_agent_owned_skip`,
/// `merged_pr_pass_surfaces_recheck_refusals`.
fn diagnostic_lines(merged: &serde_json::Value) -> Vec<String> {
let strings = |key: &str| -> Vec<String> {
merged
.get(key)
.and_then(serde_json::Value::as_array)
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
};
let mut out = Vec::new();
// #5829: a live agent's tree was spared. The operator has to see this — it
// is the difference between "nothing was reclaimable" and "something was
// deliberately protected, and here is who for".
for s in strings("spared_agent_owned") {
out.push(format!(" spared — a dispatched agent owns it: {s}"));
}
// A candidate the survey approved but the fresh re-check refused is a
// near-miss: the workspace changed underneath the pass.
for s in strings("refused_at_recheck") {
out.push(format!(" refused at re-check: {s}"));
}
for s in strings("removal_failed") {
out.push(format!(" removal FAILED (still on disk): {s}"));
}
// #6507: every candidate the survey refused, naming the gate. The three
// families above disclose only what happened AFTER classification, so a
// worktree refused during classification by any gate but 4 appeared
// nowhere — which is why one merged, clean worktree read as simply absent
// from the report for three verification passes running.
for s in strings("blocked_reasons") {
out.push(format!(" {s}"));
}
out
}
/// `tm session prune --worktrees [--dry-run]` — remove orphaned per-session worktrees (#1840).
///
/// Why: sessions decommissioned before Fix 1a (#1840), or where
/// `git worktree remove` failed, leave stale `.worktrees/<session-id>/`
/// directories on disk. This verb lets operators clean them up safely — only
/// dirs without a corresponding active session are ever removed.
/// What: POSTs `/api/v1/sessions/managed/prune-worktrees` with
/// `{ dry_run, discard_dirty }`; prints one path per removed (or would-remove)
/// directory and a summary count, then prints every worktree the #4091
/// dirty-tree gate refused to touch (path + reason) to stderr so a skip is
/// never silent — a skipped worktree still holds work the operator needs to
/// deal with by hand.
///
/// #6806: the request carries `invoking_session`, so the merged-PR pass's
/// liveness gate can tell the caller's own claim on a worktree from another
/// session's. Without it every claim reads as foreign and a session blocks its
/// own trees. It is a PARAMETER rather than an inline `$TM_MANAGED_SESSION_ID`
/// read so this function performs no process-global env work — the call site in
/// `commands::session` resolves the id.
/// Test: HTTP path covered by integration test; CLI parse by
/// `cli_parses_session_prune_worktrees` and
/// `cli_prune_worktrees_discard_dirty_is_opt_in`; the #5830 timeout override by
/// `merged_pr_request_outlives_the_default_client_timeout`; the caller id by
/// `prune_worktrees_sends_the_invoking_session`.
pub(crate) async fn session_prune_worktrees(
client: &reqwest::Client,
url: &str,
dry_run: bool,
discard_dirty: bool,
merged_prs: bool,
invoking_session: Option<String>,
) -> anyhow::Result<()> {
let mut request = client
.post(format!("{url}/api/v1/sessions/managed/prune-worktrees"))
.json(&serde_json::json!({
"dry_run": dry_run,
"discard_dirty": discard_dirty,
// #2919: the merged-PR reclaim pass, off unless explicitly asked for.
"merged_prs": merged_prs,
// #6806: the daemon occupies no pane and cannot discover who is
// asking, so the caller names itself. Absent outside a managed
// session, which leaves every claim foreign — the pre-#6806 gate.
"invoking_session": invoking_session,
}));
if merged_prs {
// #5830: the merged-PR survey runs synchronously in the handler and
// takes minutes, so the client's 10s default aborted every invocation.
request = request.timeout(trusty_mpm::client::http_client::RECLAIM_SURVEY_REQUEST_TIMEOUT);
// The wait is long and the daemon streams nothing, so say what is
// happening — an operator with no output cannot tell a running survey
// from a wedged one.
eprintln!(
"merged-PR pass: surveying every registered worktree (classify, then byte-walk) \
— this takes minutes on a large workspace and prints nothing until it \
finishes (#5830)"
);
}
let resp = request.send().await?;
let body: serde_json::Value = resp.error_for_status()?.json().await?;
let paths = body
.get("paths")
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default();
let mut printed = 0usize;
// Item 6 (#1845): non-string entries in the `paths` array are unexpected
// (the server controls the format) but must not crash the CLI. Warn to
// stderr so the operator is aware, rather than silently dropping the entry.
for p in &paths {
if let Some(s) = p.as_str() {
println!("{s}");
printed += 1;
} else {
eprintln!("warning: prune-worktrees: unexpected non-string path entry: {p}");
}
}
let verb = if dry_run { "would remove" } else { "removed" };
println!("{verb} {printed} orphaned worktree dir(s)");
// #4091: surface every dirty-skip. These are worktrees that WOULD have
// been reclaimed but still hold unsaved work; staying quiet about them is
// how the sweep silently loses work.
let skipped = body
.get("skipped_dirty")
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default();
if !skipped.is_empty() {
eprintln!(
"skipped {} worktree(s) holding uncommitted or unpushed work (#4091) — \
review them by hand; `--discard-dirty` would destroy this work:",
skipped.len()
);
for entry in &skipped {
let path = entry
.get("path")
.and_then(serde_json::Value::as_str)
.unwrap_or("<unknown path>");
let reason = entry
.get("reason")
.and_then(serde_json::Value::as_str)
.unwrap_or("<no reason reported>");
eprintln!(" {path}: {reason}");
}
}
// #2919: the merged-PR pass reports separately, because its refusals have a
// different shape — a worktree can be spared here for being on an OPEN PR,
// which is not a dirty-tree skip and must not be filed as one.
if merged_prs {
print_merged_pr_pass(body.get("merged_prs"), dry_run);
}
Ok(())
}
#[cfg(test)]
#[path = "managed_merged_prs_tests.rs"]
mod managed_merged_prs_tests;