use super::*;
#[test]
fn prune_orphaned_worktrees_spares_active() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("live-session");
let active: std::collections::HashSet<_> = [wt.clone()].into_iter().collect();
let orphans = find_orphaned_worktrees(&fx.repos_root, &active);
assert!(
orphans.is_empty(),
"live session must not be listed as orphan; got {orphans:?}"
);
}
#[test]
fn prune_orphaned_worktrees_fresh_active_set_blocks_deletion() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("session-xyz");
let empty_initial: std::collections::HashSet<std::path::PathBuf> =
std::collections::HashSet::new();
let candidates = find_orphaned_worktrees(&fx.repos_root, &empty_initial);
assert!(
candidates.contains(&wt),
"empty initial set must find the worktree as a candidate; got {candidates:?}"
);
let fresh: std::collections::HashSet<std::path::PathBuf> = [wt.clone()].into_iter().collect();
assert!(
find_orphaned_worktrees(&fx.repos_root, &fresh).is_empty(),
"a candidate in the fresh active set must not be proposed for deletion"
);
assert!(wt.exists(), "worktree must survive the TOCTOU check");
}
#[test]
fn prune_orphaned_worktrees_collects_orphan() {
let fx = GitWorktreeFixture::new();
let live = fx.add_worktree("live");
let dead = fx.add_worktree("dead");
let active: std::collections::HashSet<_> = [live.clone()].into_iter().collect();
let orphans = find_orphaned_worktrees(&fx.repos_root, &active);
assert_eq!(
orphans,
vec![dead],
"only the unclaimed worktree is an orphan"
);
}
#[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 fx = GitWorktreeFixture::new();
let session_id = super::super::record::ManagedSessionId::new();
let wt_path = fx.add_worktree(&session_id.to_string());
let mgr = super::super::manager::SessionManager::new(store_dir.path(), Arc::new(NoopDriver))
.await
.expect("SessionManager::new");
let canonical_wt = 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,
terminal_at: None,
};
mgr.store
.write()
.await
.upsert(record)
.await
.expect("upsert test record");
let outcome = mgr
.prune_orphaned_worktrees(&fx.repos_root, &[], false, DirtyWorktreePolicy::Skip)
.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_discovers_worktree_at_unwalked_location() {
let fx = GitWorktreeFixture::new();
let parked = fx.add_worktree_at(&fx.repo.join("agents").join("scratch"), "wt-1");
let empty: std::collections::HashSet<std::path::PathBuf> = std::collections::HashSet::new();
let orphans = find_orphaned_worktrees(&fx.repos_root, &empty);
assert!(
orphans.contains(&parked),
"a registered worktree must be found wherever it lives; got {orphans:?}"
);
}
#[test]
fn find_orphaned_worktrees_ignores_plain_directory() {
let fx = GitWorktreeFixture::new();
let fake = fx.repo.join(".worktrees").join("just-a-mkdir");
std::fs::create_dir_all(&fake).expect("mkdir");
let empty: std::collections::HashSet<std::path::PathBuf> = std::collections::HashSet::new();
let orphans = find_orphaned_worktrees(&fx.repos_root, &empty);
assert!(
!orphans.contains(&fake),
"a plain directory is not a registered worktree; got {orphans:?}"
);
}
#[tokio::test]
async fn prune_orphaned_worktrees_never_deletes_claude_native_worktree() {
let store_dir = tempfile::tempdir().unwrap();
let mgr = SessionManager::new(
store_dir.path(),
crate::session_manager::tests::FakeTmuxDriver::new(),
)
.await
.expect("manager");
let fx = GitWorktreeFixture::new();
let claude_wt = fx.add_worktree_at(
&fx.repo.join(".claude").join("worktrees"),
"agent-00112233445566778899aabbccddeeff00112233",
);
let outcome = mgr
.prune_orphaned_worktrees(&fx.repos_root, &[], false, DirtyWorktreePolicy::Skip)
.await
.expect("prune must not error");
assert!(
outcome.removed.is_empty(),
"a Claude-Code-native worktree must never be auto-deleted; got {:?}",
outcome.removed
);
assert!(
outcome.owner_unknown.contains(&claude_wt),
"expected {claude_wt:?} to be reported as owner-unknown; got {:?}",
outcome.owner_unknown
);
assert!(claude_wt.exists(), "worktree dir must survive the prune");
}
#[tokio::test]
async fn prune_orphaned_worktrees_skips_owner_unknown() {
let store_dir = tempfile::tempdir().unwrap();
let mgr = SessionManager::new(
store_dir.path(),
crate::session_manager::tests::FakeTmuxDriver::new(),
)
.await
.expect("manager");
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("legacy-no-sentinel");
let outcome = mgr
.prune_orphaned_worktrees(&fx.repos_root, &[], false, DirtyWorktreePolicy::Skip)
.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 mgr = SessionManager::new(
store_dir.path(),
crate::session_manager::tests::FakeTmuxDriver::new(),
)
.await
.expect("manager");
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("ownerless-gone");
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(&fx.repos_root, &[], false, DirtyWorktreePolicy::Skip)
.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_reclaims_owned_husk_at_an_unwalked_location() {
let store_dir = tempfile::tempdir().unwrap();
let mgr = SessionManager::new(
store_dir.path(),
crate::session_manager::tests::FakeTmuxDriver::new(),
)
.await
.expect("manager");
let fx = GitWorktreeFixture::new();
let husk = fx.add_worktree_at(&fx.repo.join("agents").join("scratch"), "husk");
assert!(
!husk.starts_with(fx.repo.join(".worktrees"))
&& !husk.starts_with(fx.repo.join(".claude"))
&& !husk.starts_with(fx.repo.join(".base")),
"test invariant: the husk must sit at a location none of the five \
removed shapes covered, or this test cannot prove the #4207 fix \
survived the #4224 narrowing; got {}",
husk.display()
);
GitWorktreeFixture::stamp_reclaimable_sentinel(&husk);
let outcome = mgr
.prune_orphaned_worktrees(&fx.repos_root, &[], false, DirtyWorktreePolicy::Skip)
.await
.expect("prune must not error");
assert!(
outcome.removed.iter().any(|p| p == &husk),
"a sentinel-bearing, ownerless worktree inside the project must be \
reclaimed wherever in the project it lives; got removed={:?} \
owner_unknown={:?} skipped_dirty={:?}",
outcome.removed,
outcome.owner_unknown,
outcome.skipped_dirty
);
assert!(!husk.exists(), "the reclaimed husk must be gone from disk");
}
#[tokio::test]
async fn prune_orphaned_worktrees_spares_recent_unregistered_owner() {
let store_dir = tempfile::tempdir().unwrap();
let mgr = SessionManager::new(
store_dir.path(),
crate::session_manager::tests::FakeTmuxDriver::new(),
)
.await
.expect("manager");
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("mid-creation-race");
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 empty: std::collections::HashSet<std::path::PathBuf> = std::collections::HashSet::new();
assert!(
find_orphaned_worktrees(&fx.repos_root, &empty).contains(&wt),
"test invariant: the worktree must reach the ownership gate"
);
let outcome = mgr
.prune_orphaned_worktrees(&fx.repos_root, &[], false, DirtyWorktreePolicy::Skip)
.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 mgr = SessionManager::new(
store_dir.path(),
crate::session_manager::tests::FakeTmuxDriver::new(),
)
.await
.expect("manager");
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("live-owner-elsewhere");
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 empty: std::collections::HashSet<std::path::PathBuf> = std::collections::HashSet::new();
assert!(
find_orphaned_worktrees(&fx.repos_root, &empty).contains(&wt),
"test invariant: the worktree must reach the ownership gate"
);
let outcome = mgr
.prune_orphaned_worktrees(&fx.repos_root, &[], false, DirtyWorktreePolicy::Skip)
.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 git_worktree_list_agrees_true_for_worktree_registered_to_parent_repo() {
let fx = GitWorktreeFixture::new();
fx.add_base_clone_worktree("owned-by-base");
let parent_registered = fx.add_worktree_at(
&fx.repo.join(".base").join(".worktrees"),
"registered-to-parent",
);
let grandparent = parent_registered
.parent()
.and_then(|p| p.parent())
.expect("grandparent");
assert!(
crate::session_manager::worktree_registry::registry_root_for(grandparent).is_some(),
"test invariant: the grandparent must itself be a repository, or the old \
rule would fall through its best-effort `true` and pass by accident"
);
assert!(
git_worktree_list_agrees(&parent_registered),
"a worktree registered to the PARENT repo but living under .base must be \
recognised — asking the grandparent (.base) disowns it"
);
}
#[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"
);
}
use crate::session_manager::worktree_git_fixture::GitWorktreeFixture;
async fn reclaimable_fixture(
name: &str,
) -> (
SessionManager,
tempfile::TempDir,
GitWorktreeFixture,
std::path::PathBuf,
) {
let store_dir = tempfile::tempdir().unwrap();
let mgr = SessionManager::new(
store_dir.path(),
crate::session_manager::tests::FakeTmuxDriver::new(),
)
.await
.expect("manager");
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree(name);
GitWorktreeFixture::stamp_reclaimable_sentinel(&wt);
(mgr, store_dir, fx, wt)
}
#[tokio::test]
async fn prune_orphaned_worktrees_reclaims_clean_pushed_worktree() {
let (mgr, _store, fx, wt) = reclaimable_fixture("clean-pushed").await;
let outcome = mgr
.prune_orphaned_worktrees(&fx.repos_root, &[], false, DirtyWorktreePolicy::Skip)
.await
.expect("prune must not error");
assert!(
outcome.skipped_dirty.is_empty(),
"a clean worktree must not be reported dirty; got {:?}",
outcome.skipped_dirty
);
assert!(
outcome.removed.iter().any(|p| p == &wt),
"a clean, fully-pushed, owner-known stale worktree must still be reclaimed; got {:?}",
outcome.removed
);
assert!(
!wt.exists(),
"the reclaimed worktree must be gone from disk"
);
}
#[tokio::test]
async fn prune_orphaned_worktrees_rechecks_dirt_immediately_before_removal() {
let (mgr, _store, fx, remover) = reclaimable_fixture("aaa-remover").await;
let victim = fx.add_worktree("zzz-victim");
GitWorktreeFixture::stamp_reclaimable_sentinel(&victim);
let preview = mgr
.prune_orphaned_worktrees(&fx.repos_root, &[], true, DirtyWorktreePolicy::Skip)
.await
.expect("dry run must not error");
assert!(
preview.skipped_dirty.is_empty() && preview.removed.len() == 2,
"premise broken: both candidates must scan CLEAN; got removed={:?} skipped={:?}",
preview.removed,
preview.skipped_dirty
);
let fired = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let watcher = {
let (remover, victim, fired) = (remover.clone(), victim.clone(), fired.clone());
std::thread::spawn(move || {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while remover.exists() {
if std::time::Instant::now() > deadline {
return;
}
std::thread::sleep(std::time::Duration::from_micros(200));
}
std::fs::write(victim.join("notes.md"), "written mid-sweep\n").unwrap();
fired.store(true, std::sync::atomic::Ordering::SeqCst);
})
};
let outcome = mgr
.prune_orphaned_worktrees(&fx.repos_root, &[], false, DirtyWorktreePolicy::Skip)
.await
.expect("prune must not error");
watcher.join().expect("watcher thread");
assert!(
fired.load(std::sync::atomic::Ordering::SeqCst),
"the watcher never observed the first removal, so nothing was tested"
);
assert!(
outcome.removed.iter().any(|p| p == &remover),
"the first candidate was clean throughout and must still be reclaimed; got {:?}",
outcome.removed
);
assert!(
!outcome.removed.iter().any(|p| p == &victim),
"a candidate dirtied AFTER the scan must NOT be removed; got {:?}",
outcome.removed
);
assert!(
victim.join("notes.md").exists(),
"the work written mid-sweep must still be on disk"
);
assert!(
outcome.skipped_dirty.iter().any(|d| d.path == victim),
"the mid-sweep skip must be REPORTED, not merely logged; got {:?}",
outcome.skipped_dirty
);
}
#[tokio::test]
async fn reap_aged_ephemeral_spares_a_worktree_holding_unsaved_work() {
let store_dir = tempfile::tempdir().unwrap();
let mgr = SessionManager::new(
store_dir.path(),
crate::session_manager::tests::FakeTmuxDriver::new(),
)
.await
.expect("manager");
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("aged-ephemeral");
std::fs::write(wt.join("rescued.rs"), "// never committed\n").unwrap();
let id = crate::session_manager::record::ManagedSessionId::new();
let record = crate::session_manager::record::SessionRecord {
id,
tmux_name: format!("tmpm-aged-{id}"),
cwd: fx.repo.clone(),
task: "aged".into(),
state: crate::session_manager::record::ManagedSessionState::Active,
created_at: chrono::Utc::now() - chrono::Duration::hours(2),
last_activity_at: None,
workspace_path: Some(wt.clone()),
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
ephemeral: true,
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,
terminal_at: None,
};
mgr.store.write().await.upsert(record).await.expect("seed");
let reaped = mgr
.reap_aged_ephemeral(chrono::Duration::hours(1))
.await
.expect("reap must not error");
assert_eq!(
reaped, 0,
"an aged ephemeral holding uncommitted work must not be auto-reaped"
);
assert!(
wt.join("rescued.rs").exists(),
"the uncommitted work must still be on disk"
);
assert_eq!(
mgr.get(&id).await.unwrap().state,
crate::session_manager::record::ManagedSessionState::Active,
"the record must be left for an operator, not silently decommissioned"
);
}
#[tokio::test]
async fn prune_orphaned_worktrees_skips_modified_tracked_file() {
let (mgr, _store, fx, wt) = reclaimable_fixture("modified").await;
std::fs::write(wt.join("README.md"), "uncommitted edit\n").unwrap();
let outcome = mgr
.prune_orphaned_worktrees(&fx.repos_root, &[], false, DirtyWorktreePolicy::Skip)
.await
.expect("prune must not error");
assert!(
outcome.removed.is_empty(),
"a worktree with modified tracked files must NOT be removed; got {:?}",
outcome.removed
);
let dirt = outcome
.skipped_dirty
.iter()
.find(|d| d.path == wt)
.expect("the skip must be reported in skipped_dirty, not just logged");
assert_eq!(dirt.dirty_files, 1, "reason: {}", dirt.reason);
assert!(wt.exists(), "the dirty worktree must survive on disk");
}
#[tokio::test]
async fn prune_orphaned_worktrees_skips_untracked_file() {
let (mgr, _store, fx, wt) = reclaimable_fixture("untracked").await;
std::fs::write(wt.join("scratch-notes.md"), "never added to git\n").unwrap();
let outcome = mgr
.prune_orphaned_worktrees(&fx.repos_root, &[], false, DirtyWorktreePolicy::Skip)
.await
.expect("prune must not error");
assert!(
outcome.removed.is_empty(),
"a worktree with untracked files must NOT be removed; got {:?}",
outcome.removed
);
assert!(
outcome.skipped_dirty.iter().any(|d| d.path == wt),
"the untracked-file skip must be reported; got {:?}",
outcome.skipped_dirty
);
assert!(wt.exists(), "the dirty worktree must survive on disk");
}
#[tokio::test]
async fn prune_orphaned_worktrees_skips_unpushed_commit() {
let (mgr, _store, fx, wt) = reclaimable_fixture("unpushed").await;
GitWorktreeFixture::commit_unpushed(&wt);
let outcome = mgr
.prune_orphaned_worktrees(&fx.repos_root, &[], false, DirtyWorktreePolicy::Skip)
.await
.expect("prune must not error");
assert!(
outcome.removed.is_empty(),
"a worktree with unpushed commits must NOT be removed; got {:?}",
outcome.removed
);
let dirt = outcome
.skipped_dirty
.iter()
.find(|d| d.path == wt)
.expect("the unpushed-commit skip must be reported");
assert_eq!(dirt.unpushed_commits, 1, "reason: {}", dirt.reason);
assert!(wt.exists(), "the worktree must survive on disk");
}
#[tokio::test]
async fn prune_orphaned_worktrees_skips_when_dirty_check_errors() {
let (mgr, _store, fx, wt) = reclaimable_fixture("broken-index").await;
let index = fx
.repo
.join(".git")
.join("worktrees")
.join("broken-index")
.join("index");
std::fs::remove_file(&index).expect("remove worktree index");
std::fs::create_dir(&index).expect("replace index with a directory");
let outcome = mgr
.prune_orphaned_worktrees(&fx.repos_root, &[], false, DirtyWorktreePolicy::Skip)
.await
.expect("prune must not error");
assert!(
outcome.removed.is_empty(),
"a candidate whose dirty-check errored must NOT be removed; got {:?}",
outcome.removed
);
let dirt = outcome
.skipped_dirty
.iter()
.find(|d| d.path == wt)
.expect("an errored dirty-check must be reported as a dirty skip");
assert!(
dirt.reason.contains("dirty-check failed"),
"unexpected reason: {}",
dirt.reason
);
assert!(
wt.exists(),
"the unexaminable worktree must survive on disk"
);
}
#[tokio::test]
async fn prune_orphaned_worktrees_force_discards_dirty() {
let (mgr, _store, fx, wt) = reclaimable_fixture("force-discard").await;
std::fs::write(wt.join("README.md"), "uncommitted edit\n").unwrap();
let outcome = mgr
.prune_orphaned_worktrees(
&fx.repos_root,
&[],
false,
DirtyWorktreePolicy::ForceDiscard,
)
.await
.expect("prune must not error");
assert!(
outcome.removed.iter().any(|p| p == &wt),
"the explicit force-discard opt-in must remove a dirty worktree; got {:?}",
outcome.removed
);
assert!(
outcome.skipped_dirty.is_empty(),
"force-discard reports nothing as skipped; got {:?}",
outcome.skipped_dirty
);
assert!(!wt.exists(), "the force-discarded worktree must be gone");
}
#[tokio::test]
async fn prune_orphaned_worktrees_default_policy_cannot_delete_dirty_work() {
let (mgr, _store, fx, wt) = reclaimable_fixture("default-policy").await;
std::fs::write(wt.join("precious.txt"), "hours of work\n").unwrap();
let outcome = mgr
.prune_orphaned_worktrees(&fx.repos_root, &[], false, DirtyWorktreePolicy::default())
.await
.expect("prune must not error");
assert!(
outcome.removed.is_empty(),
"the DEFAULT prune policy must never delete dirty work; got {:?}",
outcome.removed
);
assert!(
wt.exists(),
"the dirty worktree must survive the default sweep"
);
assert_eq!(
std::fs::read_to_string(wt.join("precious.txt")).unwrap(),
"hours of work\n",
"the untracked file's contents must be intact"
);
}
#[tokio::test]
async fn prune_orphaned_worktrees_dry_run_reports_dirty_skip() {
let (mgr, _store, fx, wt) = reclaimable_fixture("dry-run-dirty").await;
std::fs::write(wt.join("README.md"), "uncommitted edit\n").unwrap();
let outcome = mgr
.prune_orphaned_worktrees(&fx.repos_root, &[], true, DirtyWorktreePolicy::Skip)
.await
.expect("prune must not error");
assert!(
outcome.removed.is_empty(),
"dry-run must not list a dirty worktree as would-remove; got {:?}",
outcome.removed
);
assert!(
outcome.skipped_dirty.iter().any(|d| d.path == wt),
"dry-run must report the dirty skip; got {:?}",
outcome.skipped_dirty
);
assert!(wt.exists(), "dry-run must not touch anything");
}
#[tokio::test]
async fn phase2_fresh_snapshot_spares_a_record_the_caller_set_missed() {
use crate::session_manager::record::{ManagedSessionState, SessionRecord};
let (mgr, _store, fx, wt) = reclaimable_fixture("stale-caller-set").await;
let record = mgr
.create_with_id(
ManagedSessionId::new(),
"pinned by #4288".into(),
Some(wt.clone()),
None,
Some(wt.clone()),
None,
None,
crate::runtime::RuntimeKind::default(),
false,
false,
)
.await
.expect("seed session record");
let stopped = SessionRecord {
state: ManagedSessionState::Stopped,
..record
};
mgr.store
.write()
.await
.upsert(stopped)
.await
.expect("persist the Stopped record");
let control = mgr
.prune_orphaned_worktrees(&fx.repos_root, &[], true, DirtyWorktreePolicy::Skip)
.await
.expect("control sweep must not error");
assert!(
control.removed.contains(&wt),
"CONTROL: {} must reach Phase 2 as a reclaimable candidate, otherwise \
this test proves nothing; removed={:?} owner_unknown={:?} skipped_dirty={:?}",
wt.display(),
control.removed,
control.owner_unknown,
control.skipped_dirty
);
let outcome = mgr
.prune_orphaned_worktrees(&fx.repos_root, &[], false, DirtyWorktreePolicy::Skip)
.await
.expect("prune must not error");
assert!(
!outcome.removed.contains(&wt),
"the Phase 2 fresh snapshot must spare a worktree backed by a store \
record the caller's set missed; {} appeared in the removed set {:?}",
wt.display(),
outcome.removed
);
assert!(
wt.exists(),
"{} must still exist on disk after the real sweep",
wt.display()
);
}