use std::path::{Path, PathBuf};
use crate::core::doctor::{CheckStatus, DoctorCheck};
use crate::session_manager::worktree_reclaim::ReclaimSurvey;
use crate::session_manager::worktree_reclaim_sweep::{SurveyBudget, survey};
const SURVEY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
const SURVEY_TIMEOUT_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
fn human_bytes(n: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut value = n as f64;
let mut unit = 0usize;
while value >= 1024.0 && unit + 1 < UNITS.len() {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{n} B")
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
fn build_worktree_disk_check(survey: &ReclaimSurvey) -> DoctorCheck {
let total = survey.candidates.len();
if total == 0 {
return DoctorCheck::new(
"worktree_disk",
CheckStatus::Ok,
"no git-registered worktrees under the managed workspace root",
);
}
let measured = format!(
"{} across {total} worktree(s)",
human_bytes(survey.total_bytes)
);
let undercount = if survey.unmeasured > 0 {
format!(
" — an UNDERCOUNT: {} worktree(s) went unmeasured inside this probe's {}s budget; \
`tm session prune-worktrees --merged-prs` surveys with no deadline for exact figures",
survey.unmeasured,
SURVEY_TIMEOUT.as_secs()
)
} else {
String::new()
};
let lookup_unknown = survey.pr_state_unknown.saturating_sub(survey.not_inspected);
let mut clauses = String::new();
if survey.not_inspected > 0 {
clauses.push_str(&format!(
"; {} worktree(s) were NOT INSPECTED before this probe's {}s classify budget \
expired — that is a budget limit, not a `gh` problem",
survey.not_inspected,
SURVEY_TIMEOUT.as_secs()
));
}
if lookup_unknown > 0 {
clauses.push_str(&format!(
"; {lookup_unknown} worktree(s) were inspected but their pull-request state did \
not resolve — a detached HEAD, a repository this `gh` account cannot see, or a \
branch older than the most recent {} pull requests this probe reads; \
`tm session prune-worktrees --merged-prs` resolves that last case \
branch-by-branch",
crate::session_manager::worktree_reclaim::PR_INDEX_LIMIT
));
}
let window = clauses;
if survey.pr_state_unknown == total {
return DoctorCheck::new(
"worktree_disk",
CheckStatus::Unknown,
format!(
"{measured}{undercount}; pull-request state resolved for NONE of them, \
so nothing could be classified reclaimable — check that `gh` is \
installed and authenticated (`gh auth status`) (#2919)"
),
);
}
if survey.reclaimable > 0 {
return DoctorCheck::new(
"worktree_disk",
CheckStatus::Warn,
format!(
"{measured}{undercount}; {} across {} of {} worktree(s) measured sits on \
branches whose pull request already merged and holds no uncommitted or \
unpushed work — reclaim with `tm session prune-worktrees \
--merged-prs`{window} (#2919)",
human_bytes(survey.reclaimable_bytes),
survey.reclaimable_measured,
survey.reclaimable
),
);
}
if survey.unmeasured > 0 {
return DoctorCheck::new(
"worktree_disk",
CheckStatus::Unknown,
format!(
"{measured}{undercount}{window}; total disk use is therefore UNDETERMINED (#2919)"
),
);
}
DoctorCheck::new(
"worktree_disk",
CheckStatus::Ok,
format!("{measured}{undercount}{window}; none currently reclaimable"),
)
}
pub(super) async fn check_worktree_disk(
repos_root: Option<&Path>,
active_workspace_paths: &[PathBuf],
) -> DoctorCheck {
let Some(root) = repos_root else {
return DoctorCheck::new(
"worktree_disk",
CheckStatus::Ok,
"no managed workspace root configured — worktree disk accounting skipped",
);
};
if !root.exists() {
return DoctorCheck::new(
"worktree_disk",
CheckStatus::Ok,
format!(
"{} does not exist — no worktrees to account for",
root.display()
),
);
}
let root = root.to_path_buf();
let active: Vec<PathBuf> = active_workspace_paths.to_vec();
let deadline = std::time::Instant::now() + SURVEY_TIMEOUT;
let budget = SurveyBudget {
measure: Some(SURVEY_TIMEOUT),
classify: Some(deadline),
};
let joined = tokio::time::timeout(
SURVEY_TIMEOUT + SURVEY_TIMEOUT + SURVEY_TIMEOUT_GRACE,
tokio::task::spawn_blocking(move || survey(&root, &active, budget, false)),
)
.await;
match joined {
Ok(Ok(result)) => build_worktree_disk_check(&result),
Ok(Err(e)) => {
tracing::error!("doctor: worktree disk survey panicked: {e}");
DoctorCheck::new(
"worktree_disk",
CheckStatus::Unknown,
format!("worktree disk survey panicked ({e}) — disk state undetermined"),
)
}
Err(_) => DoctorCheck::new(
"worktree_disk",
CheckStatus::Unknown,
format!(
"worktree disk survey exceeded its {}s ceiling ({}s classify + {}s \
measure + {}s subprocess grace) — disk state undetermined (#2919)",
(SURVEY_TIMEOUT + SURVEY_TIMEOUT + SURVEY_TIMEOUT_GRACE).as_secs(),
SURVEY_TIMEOUT.as_secs(),
SURVEY_TIMEOUT.as_secs(),
SURVEY_TIMEOUT_GRACE.as_secs()
),
),
}
}
#[cfg(test)]
#[path = "doctor_worktree_disk_tests.rs"]
mod doctor_worktree_disk_tests;