use std::path::{Path, PathBuf};
use super::super::record::{ManagedSessionId, ManagedSessionState};
use super::super::tests::{make_manager, seed_record};
use super::PruneFilter;
fn session_worktree(root: &Path, leaf: &str) -> PathBuf {
let wt = root.join(".worktrees").join(leaf);
std::fs::create_dir_all(&wt).expect("create worktree dir");
std::fs::write(
wt.join(super::super::decommission::WORKTREE_SENTINEL_FILE),
b"",
)
.expect("write sentinel");
wt
}
async fn slots_held(mgr: &super::SessionManager) -> Vec<u32> {
mgr.numbered_snapshot(&mgr.list().await)
.await
.into_iter()
.map(|s| s.slot)
.collect()
}
#[tokio::test]
async fn prune_compaction_releases_the_slot() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let tombstone = ManagedSessionId::new();
seed_record(
&mgr,
&dir,
tombstone,
ManagedSessionState::Decommissioned,
false,
)
.await;
let keeper = mgr
.create("keeper".into(), None, None, None, None, None)
.await
.expect("create keeper");
let before = mgr.numbered_snapshot(&mgr.list().await).await;
let find = |rows: &[super::super::slots::NumberedSlot], id: ManagedSessionId| {
rows.iter()
.find(|s| s.record.as_ref().map(|r| r.id) == Some(id))
.map(|s| s.slot)
};
let tombstone_slot = find(&before, tombstone).expect("tombstone observed");
let keeper_slot = find(&before, keeper.id).expect("keeper observed");
let outcome = mgr
.prune_managed(PruneFilter::Decommissioned, false, false, None)
.await
.expect("prune decommissioned");
assert_eq!(outcome.count(), 1, "the tombstone is the only target");
let after = mgr.numbered_snapshot(&mgr.list().await).await;
assert!(
after.iter().all(|s| s.slot != tombstone_slot),
"the compacted record's slot must leave the listing entirely, not linger \
as a `-- deleted --` row; still held: {:?}",
after.iter().map(|s| s.slot).collect::<Vec<_>>()
);
assert_eq!(
find(&after, keeper.id),
Some(keeper_slot),
"a surviving session's NUM never shifts"
);
let fresh = mgr
.create("fresh".into(), None, None, None, None, None)
.await
.expect("create fresh");
let reused = mgr.numbered_snapshot(&mgr.list().await).await;
assert_eq!(
find(&reused, fresh.id),
Some(tombstone_slot),
"the released slot is reusable"
);
let mut slots: Vec<u32> = reused.iter().map(|s| s.slot).collect();
let total = slots.len();
slots.sort_unstable();
slots.dedup();
assert_eq!(slots.len(), total, "no duplicate NUM after reallocation");
}
#[tokio::test]
async fn prune_compaction_keeps_the_slot_when_the_worktree_is_still_on_disk() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let tombstone = ManagedSessionId::new();
seed_record(
&mgr,
&dir,
tombstone,
ManagedSessionState::Decommissioned,
false,
)
.await;
let wt = session_worktree(dir.path(), &tombstone.to_string());
let mut record = mgr.get(&tombstone).await.expect("seeded record");
record.workspace_path = Some(wt.clone());
mgr.store
.write()
.await
.upsert(record)
.await
.expect("repoint workspace_path");
let before = mgr.numbered_snapshot(&mgr.list().await).await;
let tombstone_slot = before
.iter()
.find(|s| s.record.as_ref().map(|r| r.id) == Some(tombstone))
.expect("tombstone observed")
.slot;
let outcome = mgr
.prune_managed(PruneFilter::Decommissioned, false, false, None)
.await
.expect("prune decommissioned");
assert_eq!(outcome.count(), 1, "the record is still compacted");
assert!(
matches!(
mgr.get(&tombstone).await,
Err(super::super::manager::ManagedError::SessionNotFound(_))
),
"compaction still removes the record from the store"
);
let after = mgr.numbered_snapshot(&mgr.list().await).await;
let row = after
.iter()
.find(|s| s.slot == tombstone_slot)
.expect("the protected slot is STILL held — no number is handed out");
assert!(
row.record.is_none(),
"its record is gone, so the row renders as a `-- deleted --` tombstone"
);
assert!(wt.is_dir(), "prune must not remove the worktree directory");
assert!(
wt.join(super::super::decommission::WORKTREE_SENTINEL_FILE)
.is_file(),
"prune must not remove the ownership sentinel"
);
}
#[tokio::test]
async fn prune_keeps_the_record_and_slot_of_a_session_reactivated_after_the_snapshot() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let id = ManagedSessionId::new();
seed_record(&mgr, &dir, id, ManagedSessionState::Decommissioned, false).await;
let stale = mgr.get(&id).await.expect("seeded record");
let before = mgr.numbered_snapshot(&mgr.list().await).await;
let slot = before
.iter()
.find(|s| s.record.as_ref().map(|r| r.id) == Some(id))
.expect("tombstone observed")
.slot;
mgr.mark_reactivated(&id)
.await
.expect("reactivate in place");
let names = super::super::decommission::worktree_dir_names();
let compacted = mgr
.compact_and_release_slot(&stale, &names)
.await
.expect("compaction");
assert!(
!compacted,
"a reactivated session is not compacted, so prune must not report it as removed"
);
let live = mgr
.get(&id)
.await
.expect("a live, just-reactivated session's record must survive compaction");
assert_eq!(
live.state,
ManagedSessionState::Active,
"the surviving record is the reactivated body, not a resurrected tombstone"
);
let after = mgr.numbered_snapshot(&mgr.list().await).await;
assert!(
after.iter().any(|s| s.slot == slot),
"a reactivated session's number must not be handed back for reuse; \
still held: {:?}",
after.iter().map(|s| s.slot).collect::<Vec<_>>()
);
let row = after
.iter()
.find(|s| s.slot == slot)
.expect("slot still held");
assert_eq!(
row.record.as_ref().map(|r| r.id),
Some(id),
"the held slot must render its live record, not a `-- deleted --` phantom"
);
}
#[tokio::test]
async fn prune_dry_run_releases_no_slot() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let tombstone = ManagedSessionId::new();
seed_record(
&mgr,
&dir,
tombstone,
ManagedSessionState::Decommissioned,
false,
)
.await;
let before = slots_held(&mgr).await;
mgr.prune_managed(PruneFilter::Decommissioned, true, false, None)
.await
.expect("dry-run prune");
assert_eq!(slots_held(&mgr).await, before, "dry-run holds every slot");
assert!(
mgr.get(&tombstone).await.is_ok(),
"dry-run leaves the record in the store"
);
}