use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use super::worktree_reclaim::{
AgentStateProbe, BranchPrState, NOT_INSPECTED_REASON, PrIndex, ReclaimCandidate, ReclaimMode,
ReclaimOutcome, ReclaimSurvey, ReclaimVerdict, agent_ownership_blocks, classify, is_live,
measure_bytes_until, pr_state_for_branch, tm_provisioned,
};
use super::worktree_registry::{list_registered_worktrees, scan_registered_worktrees};
use super::worktree_safety::inspect_dirt;
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct SurveyBudget {
pub measure: Option<Duration>,
pub classify: Option<Instant>,
}
impl SurveyBudget {
pub(crate) fn unbounded() -> Self {
Self {
measure: None,
classify: None,
}
}
}
pub(crate) fn survey_with_index(
repos_root: &Path,
in_use: &[PathBuf],
index_for: &dyn Fn(&Path) -> PrIndex,
agent_state: AgentStateProbe<'_>,
budget: SurveyBudget,
per_branch_fallback: bool,
) -> ReclaimSurvey {
let mut indexes: BTreeMap<PathBuf, PrIndex> = BTreeMap::new();
let mut candidates = Vec::new();
for scanned in scan_registered_worktrees(repos_root) {
if budget.classify.is_some_and(|d| Instant::now() >= d) {
candidates.push(ReclaimCandidate {
path: scanned.path,
branch: scanned.branch,
registry_root: scanned.registry_root,
bytes: None,
pr: BranchPrState::Unknown,
verdict: ReclaimVerdict::blocked(NOT_INSPECTED_REASON),
});
continue;
}
let index = indexes
.entry(scanned.registry_root.clone())
.or_insert_with(|| index_for(&scanned.registry_root));
let mut pr = index.state_for(scanned.branch.as_deref());
if per_branch_fallback
&& pr == BranchPrState::Unknown
&& !index.is_complete()
&& let Some(branch) = scanned.branch.as_deref()
{
pr = pr_state_for_branch(&scanned.registry_root, branch);
}
let verdict = classify(
&scanned.path,
scanned.admission,
is_live(&scanned.path, in_use),
&pr,
&inspect_dirt,
agent_state,
);
candidates.push(ReclaimCandidate {
bytes: None,
path: scanned.path,
branch: scanned.branch,
registry_root: scanned.registry_root,
pr,
verdict,
});
}
let measure_deadline = budget.measure.map(|d| Instant::now() + d);
measure_reclaimable_first(&mut candidates, measure_deadline);
ReclaimSurvey::from_candidates(candidates)
}
fn measure_reclaimable_first(candidates: &mut [ReclaimCandidate], deadline: Option<Instant>) {
for want_reclaimable in [true, false] {
for c in candidates
.iter_mut()
.filter(|c| c.verdict.is_reclaimable() == want_reclaimable)
{
c.bytes = measure_bytes_until(&c.path, deadline);
}
}
}
pub(crate) fn survey(
repos_root: &Path,
in_use: &[PathBuf],
agent_state: AgentStateProbe<'_>,
budget: SurveyBudget,
per_branch_fallback: bool,
) -> ReclaimSurvey {
survey_with_index(
repos_root,
in_use,
&PrIndex::from_gh,
agent_state,
budget,
per_branch_fallback,
)
}
fn git_still_permits(path: &Path) -> Result<(), String> {
let Some(registered) = list_registered_worktrees(path) else {
return Err("git could not be queried for this path".into());
};
let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let found = registered
.into_iter()
.find(|w| std::fs::canonicalize(&w.path).unwrap_or_else(|_| w.path.clone()) == canonical);
let Some(record) = found else {
return Err("git no longer lists this path as a worktree".into());
};
if record.is_main {
return Err("git now reports this as the repository's main checkout".into());
}
if record.bare {
return Err("git now reports this as a bare repository".into());
}
if record.locked {
return Err("git-locked by the operator since the survey".into());
}
if record.prunable {
return Err("git reports the directory is already gone".into());
}
Ok(())
}
pub(crate) fn recheck_before_delete(
path: &Path,
in_use_now: Option<&[PathBuf]>,
pr_now: &BranchPrState,
agent_state: AgentStateProbe<'_>,
) -> Option<String> {
let Some(in_use_now) = in_use_now else {
return Some("the live session set could not be re-read — refusing to delete".into());
};
if is_live(path, in_use_now) {
return Some("a session claims this workspace now".into());
}
if let Err(reason) = git_still_permits(path) {
return Some(reason);
}
if !tm_provisioned(path) {
return Some("no longer carries a trusty-mpm ownership marker".into());
}
if let Some(reason) = agent_ownership_blocks(path, agent_state) {
return Some(reason);
}
if !matches!(pr_now, BranchPrState::Merged { .. }) {
return Some(format!(
"pull-request state is no longer a merge ({pr_now:?})"
));
}
if let Some(dirt) = inspect_dirt(path) {
return Some(format!("holds unsaved work: {}", dirt.reason));
}
None
}
pub(crate) struct FreshProbes<'a> {
pub in_use_now: &'a dyn Fn() -> Option<Vec<PathBuf>>,
pub index_for: &'a dyn Fn(&Path) -> PrIndex,
pub agent_state: AgentStateProbe<'a>,
}
pub(crate) fn reclaim_with_probes(
repos_root: &Path,
probes: &FreshProbes<'_>,
mode: ReclaimMode,
) -> ReclaimOutcome {
let initial = (probes.in_use_now)().unwrap_or_default();
let survey = survey_with_index(
repos_root,
&initial,
probes.index_for,
probes.agent_state,
SurveyBudget::unbounded(),
true,
);
let mut out = ReclaimOutcome {
removed: Vec::new(),
removed_bytes: 0,
refused_at_recheck: Vec::new(),
removal_failed: Vec::new(),
survey,
};
if mode == ReclaimMode::Report {
return out;
}
let approved: Vec<ReclaimCandidate> = out
.survey
.candidates
.iter()
.filter(|c| c.verdict.is_reclaimable())
.cloned()
.collect();
let mut fresh_indexes: BTreeMap<PathBuf, PrIndex> = BTreeMap::new();
for candidate in approved {
let path = candidate.path;
let index = fresh_indexes
.entry(candidate.registry_root.clone())
.or_insert_with(|| (probes.index_for)(&candidate.registry_root));
let mut pr_now = index.state_for(candidate.branch.as_deref());
if pr_now == BranchPrState::Unknown
&& !index.is_complete()
&& let Some(branch) = candidate.branch.as_deref()
{
pr_now = pr_state_for_branch(&candidate.registry_root, branch);
}
let in_use_now = (probes.in_use_now)();
if let Some(reason) =
recheck_before_delete(&path, in_use_now.as_deref(), &pr_now, probes.agent_state)
{
tracing::warn!(
path = %path.display(),
"worktree-reclaim: re-check refused a surveyed candidate — {reason} (#2919)"
);
out.refused_at_recheck
.push(format!("{}: {reason}", path.display()));
continue;
}
let outcome = super::decommission::remove_session_worktree(&path);
if outcome.removed() && !path.exists() {
tracing::info!(
path = %path.display(),
"worktree-reclaim: reclaimed a merged-PR worktree (#2919)"
);
out.removed_bytes = out
.removed_bytes
.saturating_add(candidate.bytes.unwrap_or(0));
out.removed.push(path);
} else {
let reason = outcome
.reason()
.unwrap_or("the directory is still present after a reported removal");
tracing::warn!(
path = %path.display(),
"worktree-reclaim: worktree kept — {reason}"
);
out.removal_failed
.push(format!("{}: {reason}", path.display()));
}
}
out
}
pub(crate) fn reclaim_merged_pr_worktrees(
repos_root: &Path,
in_use_paths: &dyn Fn() -> Option<Vec<PathBuf>>,
agent_state: AgentStateProbe<'_>,
mode: ReclaimMode,
) -> ReclaimOutcome {
reclaim_with_probes(
repos_root,
&FreshProbes {
in_use_now: in_use_paths,
index_for: &PrIndex::from_gh,
agent_state,
},
mode,
)
}
#[cfg(test)]
#[path = "worktree_reclaim_sweep_tests.rs"]
mod worktree_reclaim_sweep_tests;