trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
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
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
//! 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}"));
    }
    // #8782: git failed after deleting content; what is left needs review.
    for s in strings("partially_removed") {
        out.push(format!("  PARTIALLY REMOVED: {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
}

/// Is `url` the trusty-console gateway base (`http://<console>/api/mpm`)? (#8347)
fn is_gateway_url(url: &str) -> bool {
    url.trim_end_matches('/')
        .ends_with(trusty_mpm::core::discovery::GATEWAY_PATH)
}

/// The prune-worktrees endpoint, addressed to the daemon itself (#8347).
///
/// Why: `tm` resolves its base URL gateway-first, so with the console running
/// the request went to `http://<console>/api/mpm/api/v1/…`. The console proxy
/// cuts every forwarded call at its 30 s client timeout, and a merged-PR survey
/// runs for minutes, so the proxy answered 502 for a healthy daemon.
/// What: joins `/api/v1/sessions/managed/prune-worktrees` onto `direct` when
/// `url` is the gateway base, else onto `url`, with any trailing `/` removed.
/// The result always carries exactly one `/api/` prefix.
/// Test: `prune_worktrees_url_bypasses_the_gateway_prefix`.
pub(crate) fn prune_worktrees_url(url: &str, direct: &str) -> String {
    let base = if is_gateway_url(url) { direct } else { url };
    format!("{}{PRUNE_WORKTREES_PATH}", base.trim_end_matches('/'))
}

/// Whether `url`'s host is this machine: `localhost` or a loopback IP (#8347).
/// #6288: also `socket_dispatch`'s test for an explicit `--url`.
pub(crate) fn is_loopback_url(url: &str) -> bool {
    let Ok(parsed) = reqwest::Url::parse(url) else {
        return false;
    };
    let Some(host) = parsed.host_str() else {
        return false;
    };
    host.eq_ignore_ascii_case("localhost")
        || host
            .trim_start_matches('[')
            .trim_end_matches(']')
            .parse::<std::net::IpAddr>()
            .is_ok_and(|ip| ip.is_loopback())
}

/// The prune-worktrees endpoint this invocation may POST to (#8347).
///
/// Why: the console gateway cuts a long survey at its 30 s proxy timeout, so a
/// gateway base must be bypassed. But the bypass resolves the LOCAL daemon, and
/// a gateway on another host fronts a different daemon: redirecting there would
/// run a destructive prune against the wrong fleet, which #1737 forbids.
/// What: a non-gateway `url` is used as given. A gateway on a loopback host is
/// replaced by `direct()` (lock file, then default), which must answer
/// `GET /health` before anything is posted. A gateway on any other host is an
/// error that names the daemon URL to pass instead.
/// Test: `prune_endpoint_refuses_a_remote_gateway`,
/// `prune_endpoint_resolves_a_loopback_gateway_to_the_daemon`,
/// `prune_endpoint_errors_when_the_direct_daemon_does_not_answer`.
pub(crate) async fn prune_endpoint(
    client: &reqwest::Client,
    url: &str,
    direct: impl FnOnce() -> String,
) -> anyhow::Result<String> {
    if !is_gateway_url(url) {
        return Ok(prune_worktrees_url(url, url));
    }
    anyhow::ensure!(
        is_loopback_url(url),
        "prune-worktrees will not run through the console gateway at {url}: it cannot \
         address that host's daemon directly, and it will not retarget the local one \
         (#8347, #1737). Pass the daemon's own URL with --url or TRUSTY_MPM_URL."
    );
    let direct = direct();
    // #8347: probe the daemon before a destructive POST is addressed to it.
    let direct = trusty_mpm::core::resolve_daemon_url_probing(client, Some(&direct))
        .await
        .map_err(|e| anyhow::anyhow!("prune-worktrees bypasses the console gateway: {e}"))?;
    Ok(prune_worktrees_url(url, &direct))
}

/// Turn a prune-worktrees transport failure into the operator's error (#7884).
///
/// Why: a timed-out sweep printed reqwest's bare "error sending request for
/// url", which does not say the daemon went silent, or that removals may have
/// happened before the client gave up.
/// What: a timeout becomes an error naming the timeout, stating that nothing
/// is reported as removed, and naming the dry run that shows what remains.
/// Every other error passes through unchanged.
/// Test: `prune_worktrees_reports_a_timeout_as_an_error`.
/// The route both transports address for a prune-worktrees pass.
const PRUNE_WORKTREES_PATH: &str = "/api/v1/sessions/managed/prune-worktrees";

fn prune_transport_error(e: trusty_mpm::client::DaemonCallError) -> anyhow::Error {
    if !e.is_timeout() {
        return e.into();
    }
    anyhow::anyhow!(
        "prune-worktrees timed out waiting for the daemon (#7884): {e}. Nothing is reported \
         as removed, but the daemon may still be sweeping — re-run without `--force` to see \
         what remains."
    )
}

/// `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.
/// `project_root` (#8782) is the checkout this run is scoped to, or `None` for
/// `--all-projects`. Every run POSTs a preview first and prints it; `--force`
/// then POSTs once more carrying each pass's previewed paths as
/// `only_orphan_paths` / `only_merged_paths`, so neither pass removes anything
/// the preview did not list for it, and the rows the preview marked as
/// discarding unsaved work as `only_discard_paths`. Each reply must echo the
/// scope — a daemon that predates #8782 ignores it and runs daemon-global —
/// and a `--force` run also needs every allowlist key echoed.
/// 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`; the #8782 two-step by
/// `force_sends_only_the_previewed_paths`,
/// `force_sends_nothing_after_a_reply_without_the_scope_echo`,
/// `force_with_all_projects_sends_nothing_after_a_reply_without_the_scope_echo`,
/// `force_sends_nothing_after_a_preview_without_the_allowlist_keys`.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn session_prune_worktrees(
    daemon: &trusty_mpm::client::DaemonClient,
    dry_run: bool,
    discard_dirty: bool,
    merged_prs: bool,
    invoking_session: Option<String>,
    project_root: Option<String>,
) -> anyhow::Result<()> {
    // #8347: a loopback gateway is bypassed for the local daemon; a remote one
    // is refused rather than silently retargeted (#1737).
    // #6288: a socket client never goes through the gateway, so only an HTTP
    // client's base is checked.
    let daemon = match daemon.socket_path() {
        Some(_) => daemon.clone(),
        None => {
            let endpoint = prune_endpoint(daemon.http(), daemon.base_url(), || {
                trusty_mpm::core::resolve_daemon_url(None)
            })
            .await?;
            let base = endpoint.trim_end_matches(PRUNE_WORKTREES_PATH);
            trusty_mpm::client::DaemonClient::with_client(daemon.http().clone(), base)
        }
    };
    let post = |dry_run: bool, planned: Option<&super::prune_preview::PlannedPaths>| {
        let mut request = daemon.post(PRUNE_WORKTREES_PATH).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,
            // #8782: the project this run is bounded to, and on `--force` the
            // preview's own paths.
            "project_root": project_root,
            "only_orphan_paths": planned.map(|p| &p.orphan),
            "only_merged_paths": planned.map(|p| &p.merged),
            "only_discard_paths": planned.map(|p| &p.discard),
        }));
        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);
        }
        async move {
            // #7884: a timeout is a named error, never a generic transport line.
            let resp = request.send().await.map_err(prune_transport_error)?;
            let body: serde_json::Value = resp.error_for_status()?.json().await?;
            anyhow::Ok(body)
        }
    };
    if merged_prs {
        // 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.
        // #8301: the preview is now bounded, and a heartbeat proves it is alive.
        eprintln!(
            "merged-PR pass: surveying the registered worktrees in scope (classify, then \
             byte-walk) — the daemon stops classifying after 10 minutes and reports what it did \
             not reach as not inspected (kept); a heartbeat prints every {}s (#5830, #8301)",
            SURVEY_HEARTBEAT.as_secs()
        );
    }
    // #8301: a merged-PR request is the long one, so it carries the heartbeat.
    let wait = |fut| async move {
        if merged_prs {
            with_heartbeat(fut, SURVEY_HEARTBEAT, |elapsed| {
                eprintln!("{}", heartbeat_line(elapsed));
            })
            .await
        } else {
            fut.await
        }
    };
    // #8782: every run previews first. A `--force` run needs the echo too: a
    // daemon without it would ignore the allowlists as well as the scope.
    let preview = wait(post(true, None)).await?;
    let project = project_root.as_deref();
    super::prune_preview::check_scope_echo(&preview, project, project.is_some() || !dry_run)?;
    if !dry_run {
        // #8782: a daemon that drops an allowlist key would run that bound
        // unrestricted, so it gets no removal request.
        super::prune_preview::check_allowlist_echo(&preview, None)?;
    }
    for line in super::prune_preview::preview_lines(&preview) {
        println!("{line}");
    }
    if dry_run {
        print_prune_reply(&preview, true, merged_prs);
        return Ok(());
    }
    let planned = super::prune_preview::planned_paths(&preview);
    if planned.is_empty() {
        println!("--force: the preview listed nothing to remove, so nothing was removed (#8782)");
        print_prune_reply(&preview, true, merged_prs);
        return Ok(());
    }
    let body = wait(post(false, Some(&planned))).await?;
    super::prune_preview::check_scope_echo(&body, project, true)?;
    super::prune_preview::check_allowlist_echo(&body, Some(&planned))?;
    print_prune_reply(&body, false, merged_prs);
    Ok(())
}

/// How often a merged-PR request prints that it is still waiting (#8301).
const SURVEY_HEARTBEAT: std::time::Duration = std::time::Duration::from_secs(30);

/// Await `fut`, calling `beat` with the elapsed time every `every` (#8301).
///
/// Why: the daemon answers a merged-PR survey in one reply, minutes later. A
/// preview that printed one line and then nothing for 25 minutes could not be
/// told from a wedged one, and was killed.
/// What: the first beat comes one `every` after the call starts, then one per
/// `every`, until `fut` resolves; its output is returned unchanged.
/// Test: `worktree_8301_the_heartbeat_beats_until_the_reply_arrives`.
pub(crate) async fn with_heartbeat<F: std::future::Future>(
    fut: F,
    every: std::time::Duration,
    mut beat: impl FnMut(std::time::Duration),
) -> F::Output {
    let started = tokio::time::Instant::now();
    let mut ticks = tokio::time::interval_at(started + every, every);
    tokio::pin!(fut);
    loop {
        tokio::select! {
            out = &mut fut => return out,
            _ = ticks.tick() => beat(started.elapsed()),
        }
    }
}

/// The heartbeat a waiting merged-PR request prints (#8301).
fn heartbeat_line(elapsed: std::time::Duration) -> String {
    format!(
        "merged-PR pass: still waiting on the daemon — {}s elapsed (#8301)",
        elapsed.as_secs()
    )
}

/// `tm session prune-worktrees`, scoped from the directory it runs in (#8782).
///
/// Why: outside a repository there is no project to scope to, and falling back
/// to every project would turn a mistyped directory into a daemon-global sweep.
/// What: `--all-projects` sends no project root; otherwise the checkout owning
/// `cwd`, and outside a repository an error before any request is sent. Then
/// [`session_prune_worktrees`], with `--force` as "not a dry run".
/// Test: `prune_worktrees_outside_a_repository_posts_nothing`.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn prune_worktrees_from(
    daemon: &trusty_mpm::client::DaemonClient,
    cwd: &std::path::Path,
    force: bool,
    discard_dirty: bool,
    merged_prs: bool,
    all_projects: bool,
    invoking_session: Option<String>,
) -> anyhow::Result<()> {
    let project_root = if all_projects {
        None
    } else {
        Some(super::prune_preview::project_root_from(cwd)?)
    };
    session_prune_worktrees(
        daemon,
        !force,
        discard_dirty,
        merged_prs,
        invoking_session,
        project_root,
    )
    .await
}

/// Print one prune-worktrees reply: removed paths, dirty skips, merged-PR pass.
///
/// Why: split from [`session_prune_worktrees`] by #8782, which prints a preview
/// reply and a removal reply through the same renderer.
/// What: under `dry_run` the orphan paths are not repeated — the preview has
/// listed them — and only the count is printed. A removal reply prints each
/// orphan row with its reason, so every discard of unsaved work is named.
/// Test: exercised by `prune_worktrees_sends_the_invoking_session`;
/// the removal lines by `removed_lines_name_every_discard`.
fn print_prune_reply(body: &serde_json::Value, dry_run: bool, merged_prs: bool) {
    let paths = body
        .get("paths")
        .and_then(serde_json::Value::as_array)
        .cloned()
        .unwrap_or_default();
    if !dry_run {
        // #8782: the reason names any unsaved work the removal discarded.
        for line in super::prune_preview::removed_lines(body) {
            println!("{line}");
        }
    }
    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 p.is_string() {
            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}");
        }
    }

    // #8782: git failed after deleting content — neither removed nor kept.
    for entry in body
        .get("partially_removed")
        .and_then(serde_json::Value::as_array)
        .into_iter()
        .flatten()
        .filter_map(serde_json::Value::as_str)
    {
        eprintln!("PARTIALLY REMOVED: {entry}");
    }

    // #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);
    }
}

#[cfg(test)]
#[path = "managed_merged_prs_tests.rs"]
mod managed_merged_prs_tests;