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("partially_removed") {
out.push(format!(" PARTIALLY REMOVED: {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!("{}{PRUNE_WORKTREES_PATH}", base.trim_end_matches('/'))
}
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())
}
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))
}
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."
)
}
#[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<()> {
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,
"merged_prs": merged_prs,
"invoking_session": invoking_session,
"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 {
request =
request.timeout(trusty_mpm::client::http_client::RECLAIM_SURVEY_REQUEST_TIMEOUT);
}
async move {
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 {
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()
);
}
let wait = |fut| async move {
if merged_prs {
with_heartbeat(fut, SURVEY_HEARTBEAT, |elapsed| {
eprintln!("{}", heartbeat_line(elapsed));
})
.await
} else {
fut.await
}
};
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 {
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(())
}
const SURVEY_HEARTBEAT: std::time::Duration = std::time::Duration::from_secs(30);
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()),
}
}
}
fn heartbeat_line(elapsed: std::time::Duration) -> String {
format!(
"merged-PR pass: still waiting on the daemon — {}s elapsed (#8301)",
elapsed.as_secs()
)
}
#[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
}
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 {
for line in super::prune_preview::removed_lines(body) {
println!("{line}");
}
}
let mut printed = 0usize;
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)");
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}");
}
}
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}");
}
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;