use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use super::worktree_landing_refresh::refresh_landing_refs;
use super::worktree_reclaim_landed::{
ReclaimProof, landing_recheck, reclaim_landed_content, reclaim_landed_proof,
};
use super::worktree_reclaim::{
AgentStateProbe, BranchPrState, KeepList, LandedContentProbe, LiveClaims, NOT_INSPECTED_REASON,
PrIndex, ReclaimCandidate, ReclaimGate, ReclaimMode, ReclaimOutcome, ReclaimSurvey,
ReclaimVerdict, agent_ownership_blocks, classify_with_landed_content, measure_bytes_until,
session_ownership_blocks, tm_provisioned, unattributed_nested_blocks,
};
use super::worktree_reclaim_launch::launch_refusal;
use super::worktree_liveness::process_holding;
use super::worktree_reclaim_budget::{inspect_within, reclaim_index};
use super::worktree_reclaim_pr_match::{GhLandingProbe, PrResolution, resolve_with_index};
use super::worktree_reclaim_branch::{
OnceProof, cleanup_reclaimed_branch, deletion_proof, own_pr_gate,
};
use super::worktree_registry::{
ScannedWorktree, list_registered_worktrees, scan_registered_worktrees,
scan_registered_worktrees_in,
};
use super::worktree_removal_integrity::identity_refusal;
use super::worktree_safety::inspect_dirt;
use super::worktree_scope::WorktreeScope;
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct SurveyBudget {
pub measure: Option<Duration>,
pub classify: Option<Instant>,
}
pub(crate) const RECLAIM_MEASURE_BUDGET: Duration = Duration::from_secs(120);
impl SurveyBudget {
pub(crate) fn for_reclaim() -> Self {
Self {
measure: Some(RECLAIM_MEASURE_BUDGET),
classify: None,
}
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn survey_with_index(
repos_root: &Path,
in_use: &LiveClaims,
index_for: &dyn Fn(&Path) -> PrIndex,
agent_state: AgentStateProbe<'_>,
budget: SurveyBudget,
per_branch_fallback: bool,
keep_list: &KeepList,
adopted: &[PathBuf],
) -> ReclaimSurvey {
survey_with_landed_content(
repos_root,
in_use,
index_for,
agent_state,
budget,
per_branch_fallback,
keep_list,
adopted,
None,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn survey_with_landed_content(
repos_root: &Path,
in_use: &LiveClaims,
index_for: &dyn Fn(&Path) -> PrIndex,
agent_state: AgentStateProbe<'_>,
budget: SurveyBudget,
per_branch_fallback: bool,
keep_list: &KeepList,
adopted: &[PathBuf],
landed_content: LandedContentProbe<'_>,
) -> ReclaimSurvey {
survey_scanned(
scan_registered_worktrees(repos_root, adopted),
in_use,
index_for,
agent_state,
budget,
per_branch_fallback,
keep_list,
landed_content,
)
}
#[allow(clippy::too_many_arguments)]
fn survey_scanned(
scanned: Vec<ScannedWorktree>,
in_use: &LiveClaims,
index_for: &dyn Fn(&Path) -> PrIndex,
agent_state: AgentStateProbe<'_>,
budget: SurveyBudget,
per_branch_fallback: bool,
keep_list: &KeepList,
landed_content: LandedContentProbe<'_>,
) -> ReclaimSurvey {
let mut indexes: BTreeMap<PathBuf, PrIndex> = BTreeMap::new();
let mut candidates = Vec::new();
for scanned in scanned {
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(ReclaimGate::Deadline, NOT_INSPECTED_REASON),
});
continue;
}
let (pr, verdict) = inspect_within(&scanned.path, budget.classify, || {
let index = indexes
.entry(scanned.registry_root.clone())
.or_insert_with(|| index_for(&scanned.registry_root));
let PrResolution {
by_name,
landing: pr,
} = resolve_with_index(
&scanned.path,
&scanned.registry_root,
scanned.branch.as_deref(),
index,
per_branch_fallback,
&GhLandingProbe,
);
let claim = in_use.claim_state(&scanned.path);
if let Some(note) = claim.note() {
tracing::info!(path = %scanned.path.display(), "{note}");
}
let verdict = classify_with_landed_content(
&scanned.path,
scanned.admission,
&claim,
&pr,
&inspect_dirt,
agent_state,
&in_use.owners,
keep_list,
landed_content,
);
let verdict = own_pr_gate(&scanned.path, verdict, &by_name, landed_content);
(pr, verdict)
});
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);
}
}
}
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
&& let Some(why) = super::worktree_owner_gate::lock_liveness(path).refusal()
{
return Err(format!("git-locked since the survey — {why}"));
}
if record.prunable {
return Err("git reports the directory is already gone".into());
}
Ok(())
}
pub(crate) fn recheck_before_delete(
path: &Path,
keep_list_now: &KeepList,
in_use_now: Option<&LiveClaims>,
pr_now: &BranchPrState,
agent_state: AgentStateProbe<'_>,
landed_content: LandedContentProbe<'_>,
) -> Option<String> {
if let Some(kept) = keep_list_now.keeps(path) {
return Some(kept.detail());
}
let Some(in_use_now) = in_use_now else {
return Some("the live session set could not be re-read — refusing to delete".into());
};
let claim_now = in_use_now.claim_state(path);
if let Some(reason) = claim_now.refusal(true) {
return Some(reason);
}
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, &in_use_now.owners) {
return Some(reason);
}
if let Some(reason) = session_ownership_blocks(path, &in_use_now.owners) {
return Some(reason);
}
if let Some(reason) = unattributed_nested_blocks(path, &claim_now) {
return Some(reason);
}
landing_recheck(path, pr_now, landed_content)
}
pub(crate) struct FreshProbes<'a> {
pub in_use_now: &'a dyn Fn() -> Option<LiveClaims>,
pub index_for: &'a dyn Fn(&Path) -> PrIndex,
pub agent_state: AgentStateProbe<'a>,
pub keep_list: &'a dyn Fn() -> KeepList,
pub launched_from: &'a [PathBuf],
pub cwd_holder: &'a dyn Fn(&Path) -> Option<String>,
pub prove: &'a dyn Fn(&Path) -> ReclaimProof,
}
fn last_moment_refusal(path: &Path, claims: Option<&LiveClaims>) -> Option<String> {
let Some(claims) = claims else {
return Some(
"the live session set could not be re-read immediately before removal — refusing \
to delete"
.into(),
);
};
let claim = claims.claim_state(path);
claim
.refusal(true)
.or_else(|| session_ownership_blocks(path, &claims.owners))
.or_else(|| unattributed_nested_blocks(path, &claim))
}
fn refresh_repositories(scanned: &[ScannedWorktree]) {
let roots: BTreeSet<PathBuf> = scanned.iter().map(|s| s.registry_root.clone()).collect();
for root in roots {
if let Err(e) = refresh_landing_refs(&root) {
tracing::warn!(
root = %root.display(),
"worktree-reclaim: could not refresh this repository's landing refs, so \
gate 6 judges every candidate under it against possibly stale \
remote-tracking refs — the refusing direction (#7889): {e}"
);
}
}
}
fn log_decisions(survey: &ReclaimSurvey, mode: ReclaimMode) {
for candidate in &survey.candidates {
tracing::info!(
path = %candidate.path.display(),
branch = candidate.branch.as_deref().unwrap_or("(detached)"),
mode = ?mode,
"worktree-reclaim: {} (#8109)",
candidate.verdict.decision()
);
}
}
pub(crate) fn reclaim_scoped(
repos_root: &Path,
probes: &FreshProbes<'_>,
mode: ReclaimMode,
adopted: &[PathBuf],
scope: &WorktreeScope,
) -> ReclaimOutcome {
let (initial, mode) = match (probes.in_use_now)() {
Some(claims) => (claims, mode),
None => {
tracing::warn!(
"worktree-reclaim: the claim probe could not be read; surveying only, \
deleting nothing (#7965)"
);
(LiveClaims::default(), ReclaimMode::Report)
}
};
let scanned = scan_registered_worktrees_in(repos_root, adopted, scope);
if mode == ReclaimMode::Remove {
refresh_repositories(&scanned);
}
let survey = survey_scanned(
scanned,
&initial,
probes.index_for,
probes.agent_state,
SurveyBudget::for_mode(mode),
true,
&(probes.keep_list)(),
Some(&reclaim_landed_content),
);
log_decisions(&survey, mode);
let mut out = ReclaimOutcome {
removed: Vec::new(),
removed_bytes: 0,
refused_at_recheck: Vec::new(),
removal_failed: Vec::new(),
partially_removed: 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;
if let Some(reason) = launch_refusal(&path, probes.launched_from) {
tracing::warn!(
path = %path.display(),
"worktree-reclaim: spared a surveyed candidate — {reason}"
);
out.refused_at_recheck
.push(format!("{}: {reason}", path.display()));
continue;
}
let index = fresh_indexes
.entry(candidate.registry_root.clone())
.or_insert_with(|| (probes.index_for)(&candidate.registry_root));
let PrResolution {
by_name,
landing: pr_now,
} = resolve_with_index(
&path,
&candidate.registry_root,
candidate.branch.as_deref(),
index,
true,
&GhLandingProbe,
);
let in_use_now = (probes.in_use_now)();
let keep_list_now = (probes.keep_list)();
let once = OnceProof::new(&path, probes.prove);
let ask = |p: &Path| once.admission(p);
if let Some(reason) = recheck_before_delete(
&path,
&keep_list_now,
in_use_now.as_ref(),
&pr_now,
probes.agent_state,
Some(&ask),
) {
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 proof = match deletion_proof(&by_name, &once) {
Ok(proof) => proof,
Err(reason) => {
tracing::warn!(
path = %path.display(),
"worktree-reclaim: re-check refused a surveyed candidate — {reason}"
);
out.refused_at_recheck
.push(format!("{}: {reason}", path.display()));
continue;
}
};
let late_refusal: std::cell::Cell<Option<String>> = std::cell::Cell::new(None);
let outcome = super::decommission::remove_session_worktree_guarded(
&path,
&format!(
"merged-PR reclaim: every gate passed and the pre-delete re-check agreed \
({pr_now:?})"
),
&|| {
let refusal = last_moment_refusal(&path, (probes.in_use_now)().as_ref())
.or_else(|| (probes.cwd_holder)(&path))
.or_else(|| identity_refusal(&path))
.or_else(|| super::worktree_owner_gate::release_stale_lock(&path).err());
late_refusal.set(refusal.clone());
refusal
},
super::DirtyWorktreePolicy::Skip,
);
if let Some(reason) = late_refusal.take() {
tracing::warn!(
path = %path.display(),
"worktree-reclaim: the last claim re-read refused a surveyed candidate — \
{reason} (#7652)"
);
out.refused_at_recheck
.push(format!("{}: {reason}", path.display()));
continue;
}
if let super::decommission::WorktreeRemoval::PartiallyRemoved(reason) = &outcome {
tracing::warn!(path = %path.display(), "worktree-reclaim: {reason}");
out.partially_removed
.push(format!("{}: {reason}", path.display()));
continue;
}
if outcome.removed() && !path.exists() {
tracing::info!(
path = %path.display(),
branch = candidate.branch.as_deref().unwrap_or("(detached)"),
pr = match &pr_now {
BranchPrState::Merged { pr } => Some(*pr),
_ => None,
},
evidence = ?candidate.verdict,
bytes_freed = candidate.bytes,
"worktree-reclaim: reclaimed a merged-PR worktree (#2919, #7504)"
);
cleanup_reclaimed_branch(
&candidate.registry_root,
&path,
candidate.branch.as_deref(),
&proof,
);
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
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn reclaim_merged_pr_worktrees(
repos_root: &Path,
in_use_paths: &dyn Fn() -> Option<LiveClaims>,
agent_state: AgentStateProbe<'_>,
mode: ReclaimMode,
keep_list: &dyn Fn() -> KeepList,
adopted: &[PathBuf],
launched_from: &[PathBuf],
scope: &WorktreeScope,
) -> ReclaimOutcome {
reclaim_scoped(
repos_root,
&FreshProbes {
in_use_now: in_use_paths,
index_for: &reclaim_index,
agent_state,
keep_list,
launched_from,
cwd_holder: &process_holding,
prove: &reclaim_landed_proof,
},
mode,
adopted,
scope,
)
}
#[cfg(test)]
#[path = "worktree_reclaim_sweep_tests.rs"]
mod worktree_reclaim_sweep_tests;