use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
pub(crate) use super::worktree_reclaim_landed::LandedContentProbe;
use super::worktree_reclaim_landed::{
merged_pr_verdict, no_pr_verdict, published_route_applies, published_verdict,
unknown_pr_verdict,
};
use super::worktree_reclaim_gh::{
GH_TIMEOUT, PR_JSON_FIELDS, gh_pr_list_command, resolve_daemon_gh_env, run_with_timeout,
};
use super::worktree_reclaim_gh_gate as gh_gate;
pub(crate) use super::worktree_keep_list::KeepList;
use super::worktree_registry::Admission;
use super::worktree_repo_slug::repo_slug_for;
use super::worktree_safety::DirtyWorktree;
pub(crate) use super::worktree_reclaim_verdict::{ReclaimGate, ReclaimVerdict};
pub(crate) use super::worktree_reclaim_claim::{
ClaimLiveness, ClaimState, LiveClaims, WorkspaceClaim,
};
pub(crate) use super::worktree_reclaim_ownership::{
AgentStateProbe, SessionOwners, agent_ownership_blocks, session_ownership_blocks,
unattributed_nested_blocks,
};
pub(crate) const PR_INDEX_LIMIT: usize = 400;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum BranchPrState {
Merged {
pr: u64,
},
Open {
pr: u64,
},
ClosedUnmerged {
pr: u64,
},
NoPr,
Unknown,
LookupFailed {
reason: String,
},
}
impl BranchPrState {
fn block_rank(&self) -> u8 {
match self {
Self::Open { .. } => 3,
Self::ClosedUnmerged { .. } => 2,
Self::Merged { .. } => 1,
Self::NoPr | Self::Unknown | Self::LookupFailed { .. } => 0,
}
}
}
#[derive(Debug, Deserialize)]
struct PrRow {
number: u64,
#[serde(rename = "headRefName")]
head_ref_name: String,
state: String,
#[serde(default, rename = "isCrossRepository")]
is_cross_repository: bool,
}
#[derive(Debug, Default)]
pub(crate) struct PrIndex {
by_branch: BTreeMap<String, BranchPrState>,
complete: bool,
failure: Option<String>,
}
impl PrIndex {
pub(crate) fn unavailable_because(reason: String) -> Self {
Self {
by_branch: BTreeMap::new(),
complete: false,
failure: Some(reason),
}
}
pub(crate) fn from_json(stdout: &str, limit: usize) -> Self {
let Ok(rows) = serde_json::from_str::<Vec<PrRow>>(stdout) else {
return Self::unavailable_because(
"`gh` printed output that is not the pull-request JSON".to_string(),
);
};
let complete = rows.len() < limit;
let mut by_branch: BTreeMap<String, BranchPrState> = BTreeMap::new();
for row in rows {
if row.is_cross_repository {
continue;
}
let state = match row.state.as_str() {
"MERGED" => BranchPrState::Merged { pr: row.number },
"OPEN" => BranchPrState::Open { pr: row.number },
"CLOSED" => BranchPrState::ClosedUnmerged { pr: row.number },
_ => continue,
};
let entry = by_branch.entry(row.head_ref_name).or_insert(state.clone());
if state.block_rank() > entry.block_rank() {
*entry = state;
}
}
Self {
by_branch,
complete,
failure: None,
}
}
pub(crate) fn from_gh(registry_root: &Path) -> Self {
Self::from_gh_within(registry_root, GH_TIMEOUT)
}
pub(crate) fn from_gh_within(registry_root: &Path, timeout: Duration) -> Self {
Self::from_gh_listing(registry_root, PR_INDEX_LIMIT, timeout)
}
pub(crate) fn from_gh_listing(registry_root: &Path, limit: usize, timeout: Duration) -> Self {
let repo = match repo_slug_for(registry_root) {
Ok(repo) => repo,
Err(reason) => {
tracing::warn!(
root = %registry_root.display(),
reason = %reason,
"worktree-reclaim: the repository for this root could not be \
established — every branch under it will block (#7057)"
);
return Self::unavailable_because(reason);
}
};
let outcome = gh_gate::shared().poll(registry_root, &format!("index:{limit}"), || {
let gh_env = resolve_daemon_gh_env(registry_root, &repo)?;
let identity = gh_env.describe();
let mut cmd = gh_pr_list_command(registry_root, &gh_env, &repo);
cmd.args(["--state", "all", "--limit"])
.arg(limit.to_string())
.args(["--json", PR_JSON_FIELDS]);
run_with_timeout(cmd, timeout).map_err(|f| f.with_identity(identity))
});
match outcome {
Ok(stdout) => {
let index = Self::from_json(&stdout, limit);
tracing::debug!(
root = %registry_root.display(),
repo = %repo,
branches = index.branch_count(),
complete = index.is_complete(),
"worktree-reclaim: built pull-request index (#2919)"
);
index
}
Err(failure) => {
tracing::warn!(
root = %registry_root.display(),
repo = %repo,
reason = %failure,
identity = %failure.identity(),
"worktree-reclaim: the pull-request lookup failed — every branch \
will block, and the survey reports this reason (#6561, #6623)"
);
Self::unavailable_because(format!("{failure} (repository searched: {repo})"))
}
}
}
pub(crate) fn is_complete(&self) -> bool {
self.complete
}
pub(crate) fn branch_count(&self) -> usize {
self.by_branch.len()
}
pub(crate) fn state_for(&self, branch: Option<&str>) -> BranchPrState {
let Some(branch) = branch else {
return BranchPrState::Unknown;
};
if let Some(state) = self.by_branch.get(branch) {
return state.clone();
}
if let Some(state) = self.related_state(branch) {
return state;
}
if self.complete {
return BranchPrState::NoPr;
}
match &self.failure {
Some(reason) => BranchPrState::LookupFailed {
reason: reason.clone(),
},
None => BranchPrState::Unknown,
}
}
fn related_state(&self, branch: &str) -> Option<BranchPrState> {
let stem = crate::core::pr_cleanup::plan::strip_round_suffix(branch);
self.by_branch
.iter()
.filter(|(name, _)| {
name.as_str() != branch
&& crate::core::pr_cleanup::plan::strip_round_suffix(name) == stem
})
.map(|(_, state)| state)
.max_by_key(|state| state.block_rank())
.cloned()
}
}
pub(crate) fn pr_state_for_branch(registry_root: &Path, branch: &str) -> BranchPrState {
pr_state_for_branch_within(registry_root, branch, GH_TIMEOUT)
}
pub(crate) fn pr_state_for_branch_within(
registry_root: &Path,
branch: &str,
timeout: Duration,
) -> BranchPrState {
const PER_BRANCH_LIMIT: usize = 50;
let repo = match repo_slug_for(registry_root) {
Ok(repo) => repo,
Err(reason) => {
tracing::warn!(
root = %registry_root.display(),
branch = %branch,
reason = %reason,
"worktree-reclaim: the repository for this root could not be \
established — this branch will block (#7057)"
);
return BranchPrState::LookupFailed { reason };
}
};
let outcome = gh_gate::shared().poll(registry_root, &format!("head:{branch}"), || {
let gh_env = resolve_daemon_gh_env(registry_root, &repo)?;
let identity = gh_env.describe();
let mut cmd = gh_pr_list_command(registry_root, &gh_env, &repo);
cmd.args(["--head", branch, "--state", "all", "--limit"])
.arg(PER_BRANCH_LIMIT.to_string())
.args(["--json", PR_JSON_FIELDS]);
run_with_timeout(cmd, timeout).map_err(|f| f.with_identity(identity))
});
match outcome {
Ok(stdout) => PrIndex::from_json(&stdout, PER_BRANCH_LIMIT).state_for(Some(branch)),
Err(failure) => {
tracing::warn!(
root = %registry_root.display(),
repo = %repo,
branch = %branch,
reason = %failure,
identity = %failure.identity(),
"worktree-reclaim: the per-branch pull-request lookup failed — this \
branch will block, and the survey reports this reason (#6507)"
);
BranchPrState::LookupFailed {
reason: format!("{failure} (repository searched: {repo})"),
}
}
}
}
pub(crate) fn tm_provisioned(path: &Path) -> bool {
super::decommission::removal_permitted(path)
}
pub(crate) const NOT_INSPECTED_REASON: &str = "survey deadline reached before inspection";
#[allow(clippy::too_many_arguments)]
pub(crate) fn classify(
path: &Path,
admission: Admission,
claim: &ClaimState,
pr: &BranchPrState,
probe_dirt: &dyn Fn(&Path) -> Option<DirtyWorktree>,
agent_state: AgentStateProbe<'_>,
owners: &SessionOwners,
keep_list: &KeepList,
) -> ReclaimVerdict {
classify_with_landed_content(
path,
admission,
claim,
pr,
probe_dirt,
agent_state,
owners,
keep_list,
None,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn classify_with_landed_content(
path: &Path,
admission: Admission,
claim: &ClaimState,
pr: &BranchPrState,
probe_dirt: &dyn Fn(&Path) -> Option<DirtyWorktree>,
agent_state: AgentStateProbe<'_>,
owners: &SessionOwners,
keep_list: &KeepList,
landed_content: LandedContentProbe<'_>,
) -> ReclaimVerdict {
if let Some(kept) = keep_list.keeps(path) {
return ReclaimVerdict::blocked(
ReclaimGate::KeepList,
format!("{} (#6927)", kept.detail()),
);
}
let stale_lock = admission == Admission::HarnessAgentLock
&& match super::worktree_owner_gate::lock_liveness(path).refusal() {
Some(why) => {
let reason = format!("{} — {why}", admission.reason());
return ReclaimVerdict::blocked_by_agent(ReclaimGate::Admission, reason);
}
None => true,
};
if admission != Admission::Admitted && !stale_lock {
return ReclaimVerdict::blocked(ReclaimGate::Admission, admission.reason());
}
if let Some(reason) = claim.refusal(false) {
return ReclaimVerdict::blocked(ReclaimGate::Liveness, reason);
}
if !tm_provisioned(path) {
return ReclaimVerdict::blocked(
ReclaimGate::Removability,
"not a trusty-mpm-removable worktree — no ownership sentinel, not under \
`.worktrees/`, and not in the harness `.claude/worktrees/` store, so \
`prune-worktrees` cannot remove it",
);
}
if let Some(reason) = agent_ownership_blocks(path, agent_state, owners) {
return ReclaimVerdict::blocked_by_agent(ReclaimGate::AgentOwnership, reason);
}
if let Some(reason) = session_ownership_blocks(path, owners) {
return ReclaimVerdict::blocked(ReclaimGate::SessionOwnership, reason);
}
if let Some(reason) = unattributed_nested_blocks(path, claim) {
return ReclaimVerdict::blocked(ReclaimGate::Liveness, reason);
}
if published_route_applies(path, pr)
&& let Some(verdict) = published_verdict(path, probe_dirt)
{
return verdict;
}
let merged_pr = match pr {
BranchPrState::Merged { pr } => *pr,
BranchPrState::Open { pr } => {
return ReclaimVerdict::blocked(
ReclaimGate::PrState,
format!("PR #{pr} is still open"),
);
}
BranchPrState::ClosedUnmerged { pr } => {
return ReclaimVerdict::blocked(
ReclaimGate::PrState,
format!("PR #{pr} was closed without merging"),
);
}
BranchPrState::NoPr => {
return no_pr_verdict(path, probe_dirt, landed_content);
}
BranchPrState::Unknown => {
return unknown_pr_verdict(path, probe_dirt, landed_content);
}
BranchPrState::LookupFailed { reason } => {
return ReclaimVerdict::blocked(
ReclaimGate::PrState,
format!("the pull-request lookup failed: {reason}"),
);
}
};
merged_pr_verdict(path, merged_pr, probe_dirt, landed_content)
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct ReclaimCandidate {
pub path: PathBuf,
pub branch: Option<String>,
#[serde(skip)]
pub registry_root: PathBuf,
pub bytes: Option<u64>,
pub pr: BranchPrState,
pub verdict: ReclaimVerdict,
}
#[derive(Debug, Clone, Default, Serialize)]
pub(crate) struct ReclaimSurvey {
pub candidates: Vec<ReclaimCandidate>,
pub total_bytes: u64,
pub reclaimable_bytes: u64,
pub reclaimable: usize,
pub reclaimable_measured: usize,
pub blocked: usize,
pub unmeasured: usize,
pub pr_state_unknown: usize,
pub agent_owned: Vec<String>,
pub not_inspected: usize,
pub lookup_failed: usize,
pub lookup_failure: Option<String>,
pub blocked_reasons: Vec<String>,
}
impl ReclaimSurvey {
pub(crate) fn from_candidates(candidates: Vec<ReclaimCandidate>) -> Self {
let mut out = Self {
total_bytes: 0,
reclaimable_bytes: 0,
reclaimable: 0,
reclaimable_measured: 0,
not_inspected: 0,
blocked: 0,
unmeasured: 0,
pr_state_unknown: 0,
lookup_failed: 0,
lookup_failure: None,
agent_owned: Vec::new(),
blocked_reasons: Vec::new(),
candidates: Vec::new(),
};
for c in &candidates {
if let ReclaimVerdict::Blocked { gate, reason } = &c.verdict {
out.blocked_reasons.push(format!(
"{}: blocked at {}: {reason}",
c.path.display(),
gate.label()
));
}
match c.bytes {
Some(b) => {
out.total_bytes = out.total_bytes.saturating_add(b);
if c.verdict.is_reclaimable() {
out.reclaimable_bytes = out.reclaimable_bytes.saturating_add(b);
}
}
None => out.unmeasured += 1,
}
if c.verdict.is_reclaimable() {
out.reclaimable += 1;
if c.bytes.is_some() {
out.reclaimable_measured += 1;
}
} else {
out.blocked += 1;
}
match &c.pr {
BranchPrState::Unknown => out.pr_state_unknown += 1,
BranchPrState::LookupFailed { reason } => {
out.lookup_failed += 1;
out.lookup_failure.get_or_insert_with(|| reason.clone());
}
_ => {}
}
if let ReclaimVerdict::BlockedByAgent { reason, .. } = &c.verdict {
out.agent_owned
.push(format!("{}: {reason}", c.path.display()));
}
if matches!(
&c.verdict,
ReclaimVerdict::Blocked {
gate: ReclaimGate::Deadline,
..
}
) {
out.not_inspected += 1;
}
}
out.candidates = candidates;
out
}
}
const DEADLINE_CHECK_INTERVAL: usize = 4096;
pub(crate) fn measure_bytes_until(path: &Path, deadline: Option<Instant>) -> Option<u64> {
if !path.is_dir() {
return None;
}
let expired = |d: Option<Instant>| d.is_some_and(|d| Instant::now() >= d);
if expired(deadline) {
return None;
}
let mut total: u64 = 0;
for (seen, entry) in walkdir::WalkDir::new(path)
.follow_links(false)
.into_iter()
.filter_map(Result::ok)
.enumerate()
{
if seen % DEADLINE_CHECK_INTERVAL == 0 && seen > 0 && expired(deadline) {
return None;
}
if entry.file_type().is_file()
&& let Ok(md) = entry.metadata()
{
total = total.saturating_add(md.len());
}
}
Some(total)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum ReclaimMode {
#[default]
Report,
Remove,
}
#[derive(Debug, Clone, Default, Serialize)]
pub(crate) struct ReclaimOutcome {
pub survey: ReclaimSurvey,
pub removed: Vec<PathBuf>,
pub removed_bytes: u64,
pub refused_at_recheck: Vec<String>,
pub removal_failed: Vec<String>,
pub partially_removed: Vec<String>,
}
#[cfg(test)]
#[path = "worktree_reclaim_tests.rs"]
mod worktree_reclaim_tests;