use std::collections::HashSet;
use std::path::{Path, PathBuf};
use tracing::{error, info, warn};
use super::super::decommission::WorktreeRemoval;
use super::super::git_ceiling::with_git_ceiling;
use super::super::record::SessionRecord;
use super::super::worktree_liveness::process_holding;
use super::super::worktree_removal_integrity::identity_refusal;
use super::super::worktree_safety::{
DirtVerdict, DirtyWorktree, DirtyWorktreePolicy, dirt_verdict,
};
use super::super::worktree_scope::WorktreeScope;
pub(super) const CANONICALIZE_FAILURE_STREAK_THRESHOLD: u32 = 10;
#[derive(Debug, Default)]
pub(super) struct CanonicalizeFailureStreaks {
counts: std::collections::HashMap<PathBuf, u32>,
}
impl CanonicalizeFailureStreaks {
pub(super) fn record_failure(&mut self, path: &Path) -> u32 {
let count = self.counts.entry(path.to_path_buf()).or_insert(0);
*count += 1;
*count
}
pub(super) fn record_success(&mut self, path: &Path) {
self.counts.remove(path);
}
pub(super) fn retain_active(&mut self, active: &HashSet<PathBuf>) {
self.counts.retain(|path, _| active.contains(path));
}
}
static CANONICALIZE_FAILURE_STREAKS: std::sync::OnceLock<
std::sync::Mutex<CanonicalizeFailureStreaks>,
> = std::sync::OnceLock::new();
fn canonicalize_failure_streaks() -> std::sync::MutexGuard<'static, CanonicalizeFailureStreaks> {
CANONICALIZE_FAILURE_STREAKS
.get_or_init(|| std::sync::Mutex::new(CanonicalizeFailureStreaks::default()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub(super) fn fresh_in_use(records: Vec<SessionRecord>) -> HashSet<PathBuf> {
let mut set = HashSet::new();
let mut checked_paths: HashSet<PathBuf> = HashSet::new();
for r in records {
let session_id = r.id;
let Some(p) = r.workspace_path else {
continue;
};
checked_paths.insert(p.clone());
if let Ok(c) = std::fs::canonicalize(&p) {
set.insert(c);
canonicalize_failure_streaks().record_success(&p);
} else {
let streak = canonicalize_failure_streaks().record_failure(&p);
if streak >= CANONICALIZE_FAILURE_STREAK_THRESHOLD {
error!(
session = %session_id,
path = %p.display(),
streak,
"prune-worktrees: active session path has failed to \
canonicalize for {streak} consecutive real-sweep \
observations — sustained failure, investigate before \
a stop/reap silently reconstitutes this workspace \
root (#3715)"
);
} else {
warn!(
path = %p.display(),
"prune-worktrees: active session path failed to canonicalize; \
using raw path as protective fallback (#1845 F3)"
);
}
}
set.insert(p);
}
canonicalize_failure_streaks().retain_active(&checked_paths);
set
}
pub(super) fn allowed_dirt_verdict(
candidate: &Path,
policy: DirtyWorktreePolicy,
phase: &'static str,
scope: &WorktreeScope,
) -> DirtVerdict {
match dirt_verdict(candidate, policy, phase) {
DirtVerdict::Discards(dirt) if !scope.may_discard(candidate) => {
warn!(
path = %candidate.display(), phase,
"prune-worktrees: unsaved work the preview did not name — keeping it (#8782)"
);
DirtVerdict::Blocks(DirtyWorktree {
reason: format!(
"{} — the preview reported no unsaved work here, so --discard-dirty does \
not cover it; kept (#8782)",
dirt.reason
),
..dirt
})
}
verdict => verdict,
}
}
#[derive(Debug)]
pub(super) enum CandidateRemoval {
Removed(Option<DirtyWorktree>),
Kept(Option<DirtyWorktree>),
PartiallyRemoved(String),
}
pub(super) async fn remove_candidate(
candidate: &Path,
fresh_in_use: &HashSet<PathBuf>,
policy: DirtyWorktreePolicy,
scope: &WorktreeScope,
ceiling: std::time::Duration,
) -> CandidateRemoval {
remove_candidate_with(
candidate,
fresh_in_use,
policy,
scope,
ceiling,
process_holding,
)
.await
}
pub(super) async fn remove_candidate_with(
candidate: &Path,
fresh_in_use: &HashSet<PathBuf>,
policy: DirtyWorktreePolicy,
scope: &WorktreeScope,
ceiling: std::time::Duration,
cwd_holder: fn(&Path) -> Option<String>,
) -> CandidateRemoval {
if !candidate.exists() {
return CandidateRemoval::Kept(None);
}
if fresh_in_use.contains(candidate) {
info!(
path = %candidate.display(),
"prune-worktrees: skipping — active session appeared after initial snapshot"
);
return CandidateRemoval::Kept(None);
}
let verdict = with_git_ceiling(ceiling, || {
allowed_dirt_verdict(candidate, policy, "pre-removal", scope)
});
let discard = match verdict {
DirtVerdict::Blocks(dirt) => return CandidateRemoval::Kept(Some(dirt)),
DirtVerdict::Discards(dirt) => Some(dirt),
DirtVerdict::Clean => None,
};
info!(path = %candidate.display(), "prune-worktrees: removing orphaned worktree");
let owned = candidate.to_path_buf();
let outcome = tokio::task::spawn_blocking(move || {
with_git_ceiling(ceiling, || {
super::super::decommission::remove_session_worktree_guarded(
&owned,
"prune-worktrees orphan sweep: no live session claims this worktree",
&|| identity_refusal(&owned).or_else(|| cwd_holder(&owned)),
policy,
)
})
})
.await
.unwrap_or_else(|e| {
error!("prune-worktrees: spawn_blocking panicked during removal: {e}");
WorktreeRemoval::Kept(format!("the removal task panicked: {e}"))
});
if let WorktreeRemoval::PartiallyRemoved(report) = outcome {
warn!(path = %candidate.display(), "prune-worktrees: {report}");
return CandidateRemoval::PartiallyRemoved(report);
}
if let Some(reason) = outcome.reason() {
warn!(path = %candidate.display(), "prune-worktrees: worktree kept — {reason}");
}
if outcome.removed() {
CandidateRemoval::Removed(discard)
} else {
CandidateRemoval::Kept(None)
}
}