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
//! 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);
println!(
"merged-PR pass: {candidates} worktree(s) reclaimable; {bytes} byte(s) \
across {measured} of {candidates} measured (#2919)"
);
} else {
let bytes = merged
.get("removed_bytes")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
println!(
"merged-PR pass: reclaimed {} worktree(s), {bytes} byte(s) (#2919)",
reclaimed.len()
);
}
for line in diagnostic_lines(merged) {
eprintln!("{line}");
}
}
/// 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}"));
}
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.
/// 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`.
pub(crate) async fn session_prune_worktrees(
client: &reqwest::Client,
url: &str,
dry_run: bool,
discard_dirty: bool,
merged_prs: bool,
) -> 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,
}));
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;