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);
let measured = merged
.get("reclaimable_measured")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
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}");
}
}
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(", "))
}
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();
for s in strings("spared_agent_owned") {
out.push(format!(" spared — a dispatched agent owns it: {s}"));
}
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}"));
}
for s in strings("blocked_reasons") {
out.push(format!(" {s}"));
}
out
}
fn is_gateway_url(url: &str) -> bool {
url.trim_end_matches('/')
.ends_with(trusty_mpm::core::discovery::GATEWAY_PATH)
}
pub(crate) fn prune_worktrees_url(url: &str, direct: &str) -> String {
let base = if is_gateway_url(url) { direct } else { url };
format!(
"{}/api/v1/sessions/managed/prune-worktrees",
base.trim_end_matches('/')
)
}
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())
}
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();
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))
}
fn prune_transport_error(e: reqwest::Error) -> 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."
)
}
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 endpoint =
prune_endpoint(client, url, || trusty_mpm::core::resolve_daemon_url(None)).await?;
let mut request = client.post(endpoint).json(&serde_json::json!({
"dry_run": dry_run,
"discard_dirty": discard_dirty,
"merged_prs": merged_prs,
"invoking_session": invoking_session,
}));
if merged_prs {
request = request.timeout(trusty_mpm::client::http_client::RECLAIM_SURVEY_REQUEST_TIMEOUT);
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.map_err(prune_transport_error)?;
let body: serde_json::Value = resp
.error_for_status()?
.json()
.await
.map_err(prune_transport_error)?;
let paths = body
.get("paths")
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default();
let mut printed = 0usize;
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)");
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}");
}
}
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;