use super::*;
#[test]
fn prune_orphaned_worktrees_spares_active() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let wt = root
.join("owner")
.join("repo")
.join(".worktrees")
.join("live-session");
std::fs::create_dir_all(&wt).unwrap();
let active: std::collections::HashSet<_> =
vec![std::fs::canonicalize(&wt).unwrap_or_else(|_| wt.clone())]
.into_iter()
.collect();
let orphans = find_orphaned_worktrees(root, &active);
assert!(
orphans.is_empty(),
"live session must not be listed as orphan"
);
}
#[test]
fn prune_orphaned_worktrees_fresh_active_set_blocks_deletion() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let wt = root
.join("owner")
.join("repo")
.join(".worktrees")
.join("session-xyz");
std::fs::create_dir_all(&wt).unwrap();
let empty_initial: std::collections::HashSet<std::path::PathBuf> =
std::collections::HashSet::new();
let candidates = find_orphaned_worktrees(root, &empty_initial);
assert!(
candidates.contains(&wt),
"empty initial set must find the dir as a candidate"
);
let canonical = std::fs::canonicalize(&wt).unwrap_or_else(|_| wt.clone());
let fresh: std::collections::HashSet<std::path::PathBuf> =
[canonical.clone()].into_iter().collect();
assert!(
fresh.contains(&canonical),
"fresh active set must contain the canonicalized worktree path"
);
assert!(wt.exists(), "worktree must survive the TOCTOU check");
}
#[test]
fn prune_orphaned_worktrees_collects_orphan() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let wt1 = root
.join("owner")
.join("repo")
.join(".worktrees")
.join("live");
let wt2 = root
.join("owner")
.join("repo")
.join(".worktrees")
.join("dead");
std::fs::create_dir_all(&wt1).unwrap();
std::fs::create_dir_all(&wt2).unwrap();
let active: std::collections::HashSet<_> =
vec![std::fs::canonicalize(&wt1).unwrap_or_else(|_| wt1.clone())]
.into_iter()
.collect();
let orphans = find_orphaned_worktrees(root, &active);
assert_eq!(orphans.len(), 1);
assert!(
orphans.contains(&wt2),
"expected {wt2:?} to be the orphan, got {orphans:?}"
);
}
#[tokio::test]
async fn prune_orphaned_worktrees_store_snapshot_blocks_deletion() {
use std::path::PathBuf;
use std::sync::Arc;
struct NoopDriver;
impl super::super::manager::ManagedTmuxDriver for NoopDriver {
fn create_session(
&self,
_: &str,
_: &str,
) -> Result<(), super::super::manager::ManagedError> {
Ok(())
}
fn kill_session(&self, _: &str) -> Result<(), super::super::manager::ManagedError> {
Ok(())
}
fn send_line(&self, _: &str, _: &str) -> Result<(), super::super::manager::ManagedError> {
Ok(())
}
fn capture(
&self,
_: &str,
_: usize,
) -> Result<String, super::super::manager::ManagedError> {
Ok(String::new())
}
fn list_sessions(&self) -> Result<Vec<String>, super::super::manager::ManagedError> {
Ok(Vec::new())
}
}
let store_dir = tempfile::tempdir().unwrap();
let repos_tmp = tempfile::tempdir().unwrap();
let session_id = super::super::record::ManagedSessionId::new();
let wt_path = repos_tmp
.path()
.join("owner")
.join("repo")
.join(".worktrees")
.join(session_id.to_string());
std::fs::create_dir_all(&wt_path).expect("create worktree dir");
let mgr = super::super::manager::SessionManager::new(store_dir.path(), Arc::new(NoopDriver))
.await
.expect("SessionManager::new");
let canonical_wt = std::fs::canonicalize(&wt_path).unwrap_or_else(|_| wt_path.clone());
let record = super::super::record::SessionRecord {
id: session_id,
tmux_name: "test-toctou".into(),
cwd: PathBuf::from("/tmp"),
task: "toctou test".into(),
state: super::super::record::ManagedSessionState::Active,
created_at: chrono::Utc::now(),
last_activity_at: None,
workspace_path: Some(canonical_wt),
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
ephemeral: false,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
deliverable_id: None,
pane_id: None,
injection_status: Default::default(),
worktree_owner: None,
};
mgr.store
.write()
.await
.upsert(record)
.await
.expect("upsert test record");
let outcome = mgr
.prune_orphaned_worktrees(repos_tmp.path(), &[], false)
.await
.expect("prune must not error");
assert!(
outcome.removed.is_empty(),
"worktree backed by a live store record must NOT be removed; got: {:?}",
outcome.removed
);
assert!(wt_path.exists(), "worktree dir must survive the prune");
}
#[test]
fn find_orphaned_worktrees_covers_base_worktrees_shape() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let base_wt = root
.join("owner")
.join("repo")
.join(".base")
.join(".worktrees")
.join("session-abc");
std::fs::create_dir_all(&base_wt).unwrap();
let empty_active: std::collections::HashSet<std::path::PathBuf> =
std::collections::HashSet::new();
let orphans = find_orphaned_worktrees(root, &empty_active);
assert!(
orphans.contains(&base_wt),
"the .base/.worktrees shape must be discovered as a candidate; got {orphans:?}"
);
}
#[tokio::test]
async fn prune_orphaned_worktrees_skips_owner_unknown() {
let store_dir = tempfile::tempdir().unwrap();
let repos = tempfile::tempdir().unwrap();
let mgr = SessionManager::new(
store_dir.path(),
crate::session_manager::tests::FakeTmuxDriver::new(),
)
.await
.expect("manager");
let wt = repos
.path()
.join("owner")
.join("repo")
.join(".worktrees")
.join("legacy-no-sentinel");
std::fs::create_dir_all(&wt).unwrap();
let outcome = mgr
.prune_orphaned_worktrees(repos.path(), &[], false)
.await
.expect("prune must not error");
assert!(
outcome.removed.is_empty(),
"an owner-unknown candidate must never be auto-deleted; got {:?}",
outcome.removed
);
assert!(
outcome.owner_unknown.iter().any(|p| p == &wt),
"an owner-unknown candidate must be counted for doctor surfacing; got {:?}",
outcome.owner_unknown
);
assert!(
wt.exists(),
"the untouched legacy worktree must survive on disk"
);
}
fn aged_sentinel_bytes(
owner: ManagedSessionId,
created_at: chrono::DateTime<chrono::Utc>,
) -> Vec<u8> {
serde_json::to_vec(
&crate::session_manager::worktree_ownership::WorktreeSentinel {
owner_session_id: owner,
created_at,
},
)
.expect("serialize aged sentinel")
}
#[tokio::test]
async fn prune_orphaned_worktrees_reclaims_terminal_owner() {
let store_dir = tempfile::tempdir().unwrap();
let repos = tempfile::tempdir().unwrap();
let mgr = SessionManager::new(
store_dir.path(),
crate::session_manager::tests::FakeTmuxDriver::new(),
)
.await
.expect("manager");
let wt = repos
.path()
.join("owner")
.join("repo")
.join(".worktrees")
.join("ownerless-gone");
std::fs::create_dir_all(&wt).unwrap();
let never_registered_owner = ManagedSessionId::new();
let aged = chrono::Utc::now()
- crate::session_manager::worktree_ownership::OWNERLESS_GRACE
- chrono::Duration::minutes(1);
std::fs::write(
wt.join(crate::session_manager::decommission::WORKTREE_SENTINEL_FILE),
aged_sentinel_bytes(never_registered_owner, aged),
)
.expect("write sentinel");
let outcome = mgr
.prune_orphaned_worktrees(repos.path(), &[], false)
.await
.expect("prune must not error");
assert!(
outcome.removed.iter().any(|p| p == &wt),
"a candidate whose owner has no resolvable record AND whose sentinel \
is aged past the grace window must be reclaimed; got {:?}",
outcome.removed
);
assert!(
!wt.exists(),
"the reclaimed worktree must be removed from disk"
);
}
#[tokio::test]
async fn prune_orphaned_worktrees_spares_recent_unregistered_owner() {
let store_dir = tempfile::tempdir().unwrap();
let repos = tempfile::tempdir().unwrap();
let mgr = SessionManager::new(
store_dir.path(),
crate::session_manager::tests::FakeTmuxDriver::new(),
)
.await
.expect("manager");
let wt = repos
.path()
.join("owner")
.join("repo")
.join(".worktrees")
.join("mid-creation-race");
std::fs::create_dir_all(&wt).unwrap();
let not_yet_persisted_owner = ManagedSessionId::new();
std::fs::write(
wt.join(crate::session_manager::decommission::WORKTREE_SENTINEL_FILE),
crate::session_manager::worktree_ownership::sentinel_payload_bytes(not_yet_persisted_owner),
)
.expect("write sentinel");
let outcome = mgr
.prune_orphaned_worktrees(repos.path(), &[], false)
.await
.expect("prune must not error");
assert!(
!outcome.removed.iter().any(|p| p == &wt),
"a candidate whose owner is absent but whose sentinel is fresh must \
NEVER be reclaimed (mid-creation race, #3649); got {:?}",
outcome.removed
);
assert!(
wt.exists(),
"the mid-creation-race worktree must survive on disk"
);
}
#[tokio::test]
async fn prune_orphaned_worktrees_spares_live_owner() {
let store_dir = tempfile::tempdir().unwrap();
let repos = tempfile::tempdir().unwrap();
let mgr = SessionManager::new(
store_dir.path(),
crate::session_manager::tests::FakeTmuxDriver::new(),
)
.await
.expect("manager");
let wt = repos
.path()
.join("owner")
.join("repo")
.join(".worktrees")
.join("live-owner-elsewhere");
std::fs::create_dir_all(&wt).unwrap();
let owner_id = ManagedSessionId::new();
let owner_record = mgr
.create_with_id(
owner_id,
"task".into(),
None,
None,
None,
None,
None,
crate::runtime::RuntimeKind::default(),
false,
false,
)
.await
.expect("create owner record");
assert_eq!(owner_record.state, ManagedSessionState::Provisioning);
std::fs::write(
wt.join(crate::session_manager::decommission::WORKTREE_SENTINEL_FILE),
crate::session_manager::worktree_ownership::sentinel_payload_bytes(owner_id),
)
.expect("write sentinel");
let outcome = mgr
.prune_orphaned_worktrees(repos.path(), &[], false)
.await
.expect("prune must not error");
assert!(
!outcome.removed.iter().any(|p| p == &wt),
"a candidate whose owner is live must NEVER be reclaimed; got {:?}",
outcome.removed
);
assert!(wt.exists(), "the live-owned worktree must survive on disk");
}
#[test]
fn git_worktree_list_agrees_true_for_real_worktree() {
let base_dir = tempfile::tempdir().unwrap();
let base = base_dir.path();
let init_ok = std::process::Command::new("git")
.arg("init")
.current_dir(base)
.status()
.map(|s| s.success())
.unwrap_or(false);
if !init_ok {
eprintln!("git_worktree_list_agrees_true_for_real_worktree: git unavailable, skipping");
return;
}
let _ = std::process::Command::new("git")
.args([
"-C",
base.to_str().unwrap(),
"config",
"user.email",
"ci@test.invalid",
])
.status();
let _ = std::process::Command::new("git")
.args(["-C", base.to_str().unwrap(), "config", "user.name", "CI"])
.status();
let commit_ok = std::process::Command::new("git")
.args([
"-C",
base.to_str().unwrap(),
"commit",
"--allow-empty",
"-m",
"init",
])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !commit_ok {
eprintln!("git_worktree_list_agrees_true_for_real_worktree: commit failed, skipping");
return;
}
let wt_dir = base.join(".worktrees");
std::fs::create_dir_all(&wt_dir).unwrap();
let wt_path = wt_dir.join("agree-test");
let add_ok = std::process::Command::new("git")
.args([
"-C",
base.to_str().unwrap(),
"worktree",
"add",
"-b",
"session/agree-test",
])
.arg(&wt_path)
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(add_ok, "git worktree add must succeed in this test fixture");
assert!(
git_worktree_list_agrees(&wt_path),
"a genuinely registered git worktree must agree"
);
}
#[test]
fn git_worktree_list_agrees_false_for_untracked_dir() {
let base_dir = tempfile::tempdir().unwrap();
let base = base_dir.path();
let init_ok = std::process::Command::new("git")
.arg("init")
.current_dir(base)
.status()
.map(|s| s.success())
.unwrap_or(false);
if !init_ok {
eprintln!("git_worktree_list_agrees_false_for_untracked_dir: git unavailable, skipping");
return;
}
let untracked = base.join(".worktrees").join("never-added-to-git");
std::fs::create_dir_all(&untracked).unwrap();
assert!(
!git_worktree_list_agrees(&untracked),
"a directory git never registered as a worktree must disagree"
);
}
#[test]
fn canonicalize_streak_escalates_at_threshold() {
let mut streaks = CanonicalizeFailureStreaks::default();
let path = std::path::Path::new("/some/vanished/workspace");
for expected in 1..CANONICALIZE_FAILURE_STREAK_THRESHOLD {
let streak = streaks.record_failure(path);
assert_eq!(streak, expected, "streak must increment by 1 per failure");
assert!(
streak < CANONICALIZE_FAILURE_STREAK_THRESHOLD,
"must not have reached the escalation threshold yet"
);
}
let final_streak = streaks.record_failure(path);
assert_eq!(final_streak, CANONICALIZE_FAILURE_STREAK_THRESHOLD);
assert!(
final_streak >= CANONICALIZE_FAILURE_STREAK_THRESHOLD,
"the Nth consecutive failure must cross the escalation threshold"
);
}
#[test]
fn canonicalize_streak_resets_on_success() {
let mut streaks = CanonicalizeFailureStreaks::default();
let path_a = std::path::Path::new("/workspace/a");
let path_b = std::path::Path::new("/workspace/b");
streaks.record_failure(path_a);
streaks.record_failure(path_a);
streaks.record_failure(path_a);
streaks.record_failure(path_b);
streaks.record_failure(path_b);
streaks.record_success(path_a);
assert_eq!(
streaks.record_failure(path_a),
1,
"streak must restart at 1 after a success resets it"
);
assert_eq!(
streaks.record_failure(path_b),
3,
"an unrelated path's streak must be untouched by another path's reset"
);
}
#[test]
fn canonicalize_streak_evicts_paths_no_longer_active() {
let mut streaks = CanonicalizeFailureStreaks::default();
let still_active = std::path::PathBuf::from("/workspace/still-active");
let left_active = std::path::PathBuf::from("/workspace/decommissioned");
streaks.record_failure(&still_active);
streaks.record_failure(&still_active);
streaks.record_failure(&left_active);
streaks.record_failure(&left_active);
streaks.record_failure(&left_active);
let active: std::collections::HashSet<std::path::PathBuf> =
[still_active.clone()].into_iter().collect();
streaks.retain_active(&active);
assert_eq!(
streaks.record_failure(&left_active),
1,
"a path evicted by retain_active must restart its streak at 1, proving \
the old count was removed rather than merely capped"
);
assert_eq!(
streaks.record_failure(&still_active),
3,
"a path still present in the active set must be untouched by eviction"
);
}