use std::fmt;
use chrono::{Duration, Utc};
use tracing::{error, info, warn};
use super::driver::ManagedTmuxDriver;
use super::manager::{ManagedError, SessionManager};
use super::record::{ManagedSessionId, ManagedSessionState, SessionRecord};
pub const MAX_EPHEMERAL_AGE_HOURS: i64 = 24;
const CANONICALIZE_FAILURE_STREAK_THRESHOLD: u32 = 10;
#[derive(Debug, Default)]
struct CanonicalizeFailureStreaks {
counts: std::collections::HashMap<std::path::PathBuf, u32>,
}
impl CanonicalizeFailureStreaks {
fn record_failure(&mut self, path: &std::path::Path) -> u32 {
let count = self.counts.entry(path.to_path_buf()).or_insert(0);
*count += 1;
*count
}
fn record_success(&mut self, path: &std::path::Path) {
self.counts.remove(path);
}
fn retain_active(&mut self, active: &std::collections::HashSet<std::path::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() -> &'static std::sync::Mutex<CanonicalizeFailureStreaks> {
CANONICALIZE_FAILURE_STREAKS
.get_or_init(|| std::sync::Mutex::new(CanonicalizeFailureStreaks::default()))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PruneFilter {
Ephemeral,
Stopped,
Decommissioned,
Deleted,
All,
}
impl PruneFilter {
pub fn parse(raw: &str) -> Result<Self, String> {
match raw.trim().to_ascii_lowercase().as_str() {
"ephemeral" => Ok(Self::Ephemeral),
"stopped" => Ok(Self::Stopped),
"decommissioned" => Ok(Self::Decommissioned),
"deleted" => Ok(Self::Deleted),
"all" => Ok(Self::All),
other => Err(format!(
"unknown prune filter `{other}` (expected: ephemeral | stopped | decommissioned | deleted | all)"
)),
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Ephemeral => "ephemeral",
Self::Stopped => "stopped",
Self::Decommissioned => "decommissioned",
Self::Deleted => "deleted",
Self::All => "all",
}
}
}
impl fmt::Display for PruneFilter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PruneAction {
Decommissioned,
Removed,
}
impl PruneAction {
pub fn as_str(&self) -> &'static str {
match self {
Self::Decommissioned => "decommissioned",
Self::Removed => "removed",
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PrunedSession {
pub id: String,
pub tmux_name: String,
pub state: String,
pub action: PruneAction,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PruneOutcome {
pub dry_run: bool,
pub filter: String,
pub sessions: Vec<PrunedSession>,
}
impl PruneOutcome {
pub fn count(&self) -> usize {
self.sessions.len()
}
}
pub(super) fn is_running(record: &SessionRecord, tmux: &dyn ManagedTmuxDriver) -> bool {
tmux.session_exists(&record.tmux_name)
}
fn matches_filter(record: &SessionRecord, filter: PruneFilter) -> bool {
match filter {
PruneFilter::Ephemeral => {
record.ephemeral && record.state != ManagedSessionState::Decommissioned
}
PruneFilter::Stopped => record.state == ManagedSessionState::Stopped,
PruneFilter::Decommissioned => record.state == ManagedSessionState::Decommissioned,
PruneFilter::Deleted => record.state == ManagedSessionState::Deleted,
PruneFilter::All => true,
}
}
pub(crate) fn find_orphaned_worktrees(
repos_root: &std::path::Path,
active_set: &std::collections::HashSet<std::path::PathBuf>,
) -> Vec<std::path::PathBuf> {
let mut orphans = Vec::new();
let Ok(owner_entries) = std::fs::read_dir(repos_root) else {
return orphans;
};
for owner_entry in owner_entries.flatten() {
let owner_path = owner_entry.path();
if !owner_path.is_dir() {
continue;
}
let Ok(repo_entries) = std::fs::read_dir(&owner_path) else {
continue;
};
for repo_entry in repo_entries.flatten() {
let repo_path = repo_entry.path();
scan_worktree_shape(&repo_path.join(".worktrees"), active_set, &mut orphans);
scan_worktree_shape(
&repo_path.join(".base").join(".worktrees"),
active_set,
&mut orphans,
);
}
}
orphans
}
fn scan_worktree_shape(
wt_dir: &std::path::Path,
active_set: &std::collections::HashSet<std::path::PathBuf>,
orphans: &mut Vec<std::path::PathBuf>,
) {
if !wt_dir.is_dir() {
return;
}
let Ok(wt_entries) = std::fs::read_dir(wt_dir) else {
return;
};
for wt_entry in wt_entries.flatten() {
let wt_path = wt_entry.path();
if !wt_path.is_dir() {
continue;
}
let canonical_wt = match std::fs::canonicalize(&wt_path) {
Ok(c) => c,
Err(_) => continue,
};
if !active_set.contains(&canonical_wt) {
orphans.push(wt_path);
}
}
}
#[derive(Debug, Clone, Default)]
pub struct OrphanSweepOutcome {
pub removed: Vec<std::path::PathBuf>,
pub owner_unknown: Vec<std::path::PathBuf>,
}
fn git_worktree_list_agrees(candidate: &std::path::Path) -> bool {
let Some(repo_root) = candidate.parent().and_then(|p| p.parent()) else {
return true;
};
let out = std::process::Command::new("git")
.arg("-C")
.arg(repo_root)
.args(["worktree", "list", "--porcelain"])
.output();
let Ok(out) = out else {
return true; };
if !out.status.success() {
return true;
}
let stdout = String::from_utf8_lossy(&out.stdout);
let canonical_candidate =
std::fs::canonicalize(candidate).unwrap_or_else(|_| candidate.to_path_buf());
stdout
.lines()
.filter_map(|l| l.strip_prefix("worktree "))
.any(|p| {
let pb = std::path::PathBuf::from(p);
std::fs::canonicalize(&pb).unwrap_or(pb) == canonical_candidate
})
}
impl SessionManager {
pub async fn decommission_all_ephemeral(&self) -> Result<usize, ManagedError> {
let outcome = self
.prune_managed(PruneFilter::Ephemeral, false, true, None)
.await?;
Ok(outcome.count())
}
pub async fn prune_managed(
&self,
filter: PruneFilter,
dry_run: bool,
include_active: bool,
caller: Option<ManagedSessionId>,
) -> Result<PruneOutcome, ManagedError> {
self.store.write().await.reload_if_changed().await?;
let all = self.store.read().await.cached_all();
let tmux = self.tmux.as_ref();
let targets: Vec<SessionRecord> = all
.into_iter()
.filter(|r| matches_filter(r, filter))
.filter(|r| include_active || !is_running(r, tmux))
.collect();
let mut sessions = Vec::with_capacity(targets.len());
for record in targets {
let is_tombstone = matches!(
record.state,
ManagedSessionState::Decommissioned | ManagedSessionState::Deleted
);
let action = if is_tombstone {
PruneAction::Removed
} else {
PruneAction::Decommissioned
};
if !dry_run {
if is_tombstone {
if let Err(e) = self.store.write().await.remove(&record.id).await {
warn!(id = %record.id, "prune: compaction remove failed: {e}; skipping");
continue;
}
} else if let Err(e) = self.decommission(&record.id, caller).await {
warn!(id = %record.id, "prune: decommission failed: {e}; skipping");
continue;
}
}
sessions.push(PrunedSession {
id: record.id.to_string(),
tmux_name: record.tmux_name.clone(),
state: record.state.to_string(),
action,
});
}
if dry_run {
info!(
filter = %filter,
count = sessions.len(),
"prune (dry-run): reporting candidates, no mutation"
);
} else {
info!(
filter = %filter,
count = sessions.len(),
"prune: applied teardown/compaction"
);
}
Ok(PruneOutcome {
dry_run,
filter: filter.as_str().to_string(),
sessions,
})
}
pub async fn compact_record(&self, id: &ManagedSessionId) -> Result<(), ManagedError> {
self.store.write().await.remove(id).await?;
Ok(())
}
pub async fn prune_orphaned_worktrees(
&self,
repos_root: &std::path::Path,
active_workspace_paths: &[std::path::PathBuf],
dry_run: bool,
) -> Result<OrphanSweepOutcome, anyhow::Error> {
use super::decommission::remove_session_worktree;
use super::worktree_ownership::SentinelOwner;
use std::collections::HashSet;
let repos_root = repos_root.to_path_buf();
let initial_active: HashSet<std::path::PathBuf> = active_workspace_paths
.iter()
.map(|p| std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
.collect();
let candidates = tokio::task::spawn_blocking({
let initial_active = initial_active.clone();
move || find_orphaned_worktrees(&repos_root, &initial_active)
})
.await
.map_err(|e| anyhow::anyhow!("prune-worktrees: orphan scan panicked: {e}"))?;
let mut owner_unknown = Vec::new();
let mut reclaimable = Vec::new();
for candidate in candidates {
match super::worktree_ownership::read_sentinel_owner(&candidate) {
SentinelOwner::Unknown => {
info!(
path = %candidate.display(),
"prune-worktrees: owner-unknown sentinel — never auto-deleting; \
run `tm doctor` or inspect manually (#3649)"
);
owner_unknown.push(candidate);
}
SentinelOwner::Known(owner, created_at) => {
if !self.resolve_ownerless_with_grace(owner, created_at).await {
info!(
path = %candidate.display(),
owner = %owner,
"prune-worktrees: owner is still live/resumable, or the sentinel \
is too young to rule out a creation race — skipping (#3649)"
);
continue;
}
if !git_worktree_list_agrees(&candidate) {
warn!(
path = %candidate.display(),
"prune-worktrees: git worktree list disagrees this path is a \
worktree — skipping conservatively (#3649)"
);
continue;
}
reclaimable.push(candidate);
}
}
}
if dry_run {
for p in &reclaimable {
info!(path = %p.display(), "prune-worktrees (dry-run): would remove orphaned worktree");
}
return Ok(OrphanSweepOutcome {
removed: reclaimable,
owner_unknown,
});
}
let fresh_active: HashSet<std::path::PathBuf> = {
let mut set = HashSet::new();
let mut checked_paths: HashSet<std::path::PathBuf> = HashSet::new();
for r in self.store.read().await.cached_all() {
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()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.record_success(&p);
} else {
let streak = canonicalize_failure_streaks()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.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()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.retain_active(&checked_paths);
set
};
let mut removed = Vec::new();
for candidate in reclaimable {
let canonical_candidate = match std::fs::canonicalize(&candidate) {
Ok(c) => c,
Err(_) => {
warn!(
path = %candidate.display(),
"prune-worktrees: skipping candidate — canonicalize failed"
);
continue;
}
};
if fresh_active.contains(&canonical_candidate) || fresh_active.contains(&candidate) {
info!(
path = %candidate.display(),
"prune-worktrees: skipping — active session appeared after initial snapshot"
);
continue;
}
info!(path = %candidate.display(), "prune-worktrees: removing orphaned worktree");
let candidate_clone = candidate.clone();
let removed_ok =
tokio::task::spawn_blocking(move || remove_session_worktree(&candidate_clone))
.await
.unwrap_or_else(|e| {
tracing::error!(
"prune-worktrees: spawn_blocking panicked during removal: {e}"
);
false
});
if removed_ok {
removed.push(candidate);
}
}
Ok(OrphanSweepOutcome {
removed,
owner_unknown,
})
}
pub async fn reap_orphaned_worktrees(
&self,
repos_root: &std::path::Path,
) -> Result<OrphanSweepOutcome, anyhow::Error> {
let active: Vec<std::path::PathBuf> = self
.list()
.await
.into_iter()
.filter_map(|r| r.workspace_path)
.collect();
self.prune_orphaned_worktrees(repos_root, &active, false)
.await
}
pub async fn reap_aged_ephemeral(&self, max_age: Duration) -> Result<usize, ManagedError> {
let cutoff = Utc::now() - max_age;
self.store.write().await.reload_if_changed().await?;
let all = self.store.read().await.cached_all();
let stale: Vec<SessionRecord> = all
.into_iter()
.filter(|r| {
r.ephemeral
&& r.state != ManagedSessionState::Decommissioned
&& r.created_at < cutoff
})
.collect();
let mut reaped = 0usize;
for record in stale {
match self.decommission(&record.id, None).await {
Ok(_) => {
reaped += 1;
info!(
id = %record.id,
name = %record.tmux_name,
created_at = %record.created_at.to_rfc3339(),
"auto-reap: decommissioned stale ephemeral session"
);
}
Err(e) => {
warn!(id = %record.id, "auto-reap: decommission failed: {e}; skipping");
}
}
}
Ok(reaped)
}
}
#[cfg(test)]
#[path = "prune_orphan_tests.rs"]
mod orphan_tests;