use std::cell::RefCell;
use std::path::Path;
use super::*;
use crate::session_manager::worktree_git_fixture::GitWorktreeFixture;
fn land(path: &Path) {
std::fs::write(path.join("landed.rs"), "// landed\n").expect("write landed file");
GitWorktreeFixture::commit_all_and_push(path, "landed");
}
fn merged(pr: u64) -> BranchPrState {
BranchPrState::Merged { pr }
}
fn merged_index(branch: &str, pr: u64) -> PrIndex {
PrIndex::from_json(
&format!(r#"[{{"number": {pr}, "headRefName": "{branch}", "state": "MERGED"}}]"#),
400,
)
}
fn open_index(branch: &str, pr: u64) -> PrIndex {
PrIndex::from_json(
&format!(r#"[{{"number": {pr}, "headRefName": "{branch}", "state": "OPEN"}}]"#),
400,
)
}
#[test]
fn recheck_refuses_when_the_live_set_cannot_be_read() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("unreadable-2919");
land(&path);
let reason =
recheck_before_delete(&path, None, &merged(1)).expect("an unreadable live set must refuse");
assert!(reason.contains("could not be re-read"), "{reason}");
}
#[test]
fn recheck_refuses_a_worktree_a_session_claims_now() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("claimed-now-2919");
land(&path);
let reason = recheck_before_delete(&path, Some(std::slice::from_ref(&path)), &merged(1))
.expect("a claimed worktree must refuse");
assert!(reason.contains("claims this workspace now"), "{reason}");
}
#[test]
fn recheck_refuses_a_worktree_locked_after_the_survey() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("locked-2919");
land(&path);
assert!(
recheck_before_delete(&path, Some(&[]), &merged(1)).is_none(),
"precondition: the worktree is reclaimable before the lock"
);
fx.lock_worktree(&path);
let reason =
recheck_before_delete(&path, Some(&[]), &merged(1)).expect("a locked worktree must refuse");
assert!(reason.contains("git-locked"), "{reason}");
}
#[test]
fn recheck_refuses_a_path_git_no_longer_lists() {
let fx = GitWorktreeFixture::new();
let plain = fx.repo.join("not-a-worktree");
std::fs::create_dir_all(&plain).expect("mkdir");
let reason =
recheck_before_delete(&plain, Some(&[]), &merged(1)).expect("an unlisted path must refuse");
assert!(reason.contains("no longer lists"), "{reason}");
}
#[test]
fn recheck_refuses_when_git_cannot_be_queried() {
let tmp = tempfile::tempdir().expect("tempdir");
let reason = recheck_before_delete(tmp.path(), Some(&[]), &merged(1))
.expect("an unqueryable path must refuse");
assert!(reason.contains("could not be queried"), "{reason}");
}
#[test]
fn recheck_refuses_a_worktree_that_lost_its_ownership_marker() {
let fx = GitWorktreeFixture::new();
let parent = fx.repo.join(".claude").join("worktrees");
let path = fx.add_worktree_at(&parent, "unowned-2919");
let reason = recheck_before_delete(&path, Some(&[]), &merged(1))
.expect("an unowned worktree must refuse");
assert!(reason.contains("ownership marker"), "{reason}");
}
#[test]
fn recheck_refuses_when_the_pr_is_no_longer_merged() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("reopened-2919");
land(&path);
for state in [
BranchPrState::Open { pr: 2 },
BranchPrState::ClosedUnmerged { pr: 2 },
BranchPrState::NoPr,
BranchPrState::Unknown,
] {
let reason = recheck_before_delete(&path, Some(&[]), &state)
.unwrap_or_else(|| panic!("{state:?} must refuse"));
assert!(reason.contains("no longer a merge"), "{reason}");
}
}
#[test]
fn recheck_refuses_a_worktree_dirtied_after_the_survey() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("dirtied-2919");
land(&path);
assert!(
recheck_before_delete(&path, Some(&[]), &merged(1)).is_none(),
"precondition: clean before the write"
);
std::fs::write(path.join("appeared.rs"), "fn main() {}\n").expect("write");
let reason = recheck_before_delete(&path, Some(&[]), &merged(1))
.expect("a dirtied worktree must refuse");
assert!(reason.contains("unsaved work"), "{reason}");
}
#[test]
fn recheck_permits_a_clean_merged_owned_worktree() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("permitted-2919");
land(&path);
assert_eq!(recheck_before_delete(&path, Some(&[]), &merged(1)), None);
}
#[test]
fn survey_reports_a_merged_worktree_as_reclaimable() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("survey-2919");
land(&path);
let s = survey_with_index(
&fx.repos_root,
&[],
&|_: &Path| merged_index("session/survey-2919", 21),
SurveyBudget::default(),
false,
);
let found = s
.candidates
.iter()
.find(|c| c.path == path)
.unwrap_or_else(|| panic!("survey missed {}", path.display()));
assert_eq!(found.verdict, ReclaimVerdict::Reclaimable { pr: 21 });
assert_eq!(s.reclaimable, 1);
assert!(s.total_bytes > 0);
}
#[test]
fn survey_excludes_a_worktree_trusty_mpm_cannot_remove() {
let fx = GitWorktreeFixture::new();
let parent = fx.repo.join(".claude").join("worktrees");
let path = fx.add_worktree_at(&parent, "harness-2919");
let s = survey_with_index(
&fx.repos_root,
&[],
&|_: &Path| merged_index("wt/harness-2919", 55),
SurveyBudget::default(),
false,
);
let found = s
.candidates
.iter()
.find(|c| c.path == path)
.expect("listed");
assert!(
!found.verdict.is_reclaimable(),
"a worktree the remover would refuse must not be advertised: {:?}",
found.verdict
);
assert_eq!(
s.reclaimable_bytes, 0,
"its bytes must not be counted as reclaimable"
);
}
#[test]
fn survey_past_its_classify_deadline_reclaims_nothing() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("deadline-2919");
land(&path);
let s = survey_with_index(
&fx.repos_root,
&[],
&|_: &Path| merged_index("session/deadline-2919", 40),
SurveyBudget {
measure: Some(std::time::Duration::ZERO),
classify: Some(std::time::Instant::now() - std::time::Duration::from_secs(1)),
},
false,
);
assert!(!s.candidates.is_empty(), "candidates must still be listed");
assert_eq!(s.reclaimable, 0, "an out-of-time survey approves nothing");
assert_eq!(s.blocked, s.candidates.len());
}
#[test]
fn survey_past_its_measure_deadline_still_classifies() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("measure-2919");
land(&path);
let s = survey_with_index(
&fx.repos_root,
&[],
&|_: &Path| merged_index("session/measure-2919", 41),
SurveyBudget {
measure: Some(std::time::Duration::ZERO),
classify: None,
},
false,
);
let found = s
.candidates
.iter()
.find(|c| c.path == path)
.expect("listed");
assert!(found.verdict.is_reclaimable(), "{:?}", found.verdict);
assert_eq!(found.bytes, None, "bytes must be unmeasured");
assert!(s.unmeasured > 0, "and disclosed as unmeasured");
}
#[test]
fn reclaim_report_mode_removes_nothing() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("report-2919");
land(&path);
let out = reclaim_with_probes(
&fx.repos_root,
&FreshProbes {
in_use_now: &|| Some(Vec::new()),
index_for: &|_: &Path| merged_index("session/report-2919", 30),
},
ReclaimMode::Report,
);
assert_eq!(out.survey.reclaimable, 1, "it IS reclaimable…");
assert!(out.removed.is_empty(), "…but Report mode removed it");
assert!(path.exists());
}
#[test]
fn reclaim_remove_mode_refuses_a_worktree_claimed_after_the_survey() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("claim-race-2919");
land(&path);
let calls = RefCell::new(0usize);
let claimed = path.clone();
let in_use_now = || {
let mut n = calls.borrow_mut();
*n += 1;
if *n == 1 {
Some(Vec::new())
} else {
Some(vec![claimed.clone()])
}
};
let out = reclaim_with_probes(
&fx.repos_root,
&FreshProbes {
in_use_now: &in_use_now,
index_for: &|_: &Path| merged_index("session/claim-race-2919", 31),
},
ReclaimMode::Remove,
);
assert_eq!(out.survey.reclaimable, 1, "must reach the delete loop");
assert!(
out.removed.is_empty(),
"removed a claimed worktree: {out:?}"
);
assert_eq!(out.refused_at_recheck.len(), 1);
assert!(path.exists());
}
#[test]
fn reclaim_remove_mode_refuses_a_worktree_dirtied_after_the_survey() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("dirt-race-2919");
land(&path);
let calls = RefCell::new(0usize);
let to_dirty = path.clone();
let in_use_now = || {
let mut n = calls.borrow_mut();
*n += 1;
if *n > 1 {
std::fs::write(to_dirty.join("rescued.rs"), "fn main() {}\n").expect("write");
}
Some(Vec::new())
};
let out = reclaim_with_probes(
&fx.repos_root,
&FreshProbes {
in_use_now: &in_use_now,
index_for: &|_: &Path| merged_index("session/dirt-race-2919", 32),
},
ReclaimMode::Remove,
);
assert_eq!(out.survey.reclaimable, 1, "must reach the delete loop");
assert!(out.removed.is_empty(), "destroyed new work: {out:?}");
assert!(path.join("rescued.rs").exists(), "the work must survive");
}
#[test]
fn reclaim_remove_mode_refuses_a_worktree_locked_after_the_survey() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("lock-race-2919");
land(&path);
let calls = RefCell::new(0usize);
let locked_path = path.clone();
let repo = fx.repo.clone();
let in_use_now = || {
let mut n = calls.borrow_mut();
*n += 1;
if *n > 1 {
let _ = std::process::Command::new("git")
.arg("-C")
.arg(&repo)
.args(["worktree", "lock"])
.arg(&locked_path)
.output();
}
Some(Vec::new())
};
let out = reclaim_with_probes(
&fx.repos_root,
&FreshProbes {
in_use_now: &in_use_now,
index_for: &|_: &Path| merged_index("session/lock-race-2919", 33),
},
ReclaimMode::Remove,
);
assert_eq!(out.survey.reclaimable, 1, "must reach the delete loop");
assert!(out.removed.is_empty(), "deleted a locked worktree: {out:?}");
assert!(path.exists());
}
#[test]
fn reclaim_remove_mode_refuses_when_the_pr_reopens_after_the_survey() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("pr-race-2919");
land(&path);
let index_calls = RefCell::new(0usize);
let index = |_: &Path| {
let mut n = index_calls.borrow_mut();
*n += 1;
if *n == 1 {
merged_index("session/pr-race-2919", 34)
} else {
open_index("session/pr-race-2919", 35)
}
};
let out = reclaim_with_probes(
&fx.repos_root,
&FreshProbes {
in_use_now: &|| Some(Vec::new()),
index_for: &index,
},
ReclaimMode::Remove,
);
assert_eq!(out.survey.reclaimable, 1, "must reach the delete loop");
assert!(out.removed.is_empty(), "deleted a reopened branch: {out:?}");
assert!(path.exists());
}
#[test]
fn reclaim_remove_mode_refuses_when_the_live_set_cannot_be_read() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("unreadable-race-2919");
land(&path);
let calls = RefCell::new(0usize);
let in_use_now = || {
let mut n = calls.borrow_mut();
*n += 1;
if *n == 1 { Some(Vec::new()) } else { None }
};
let out = reclaim_with_probes(
&fx.repos_root,
&FreshProbes {
in_use_now: &in_use_now,
index_for: &|_: &Path| merged_index("session/unreadable-race-2919", 36),
},
ReclaimMode::Remove,
);
assert_eq!(out.survey.reclaimable, 1, "must reach the delete loop");
assert!(out.removed.is_empty(), "deleted on an unknown live set");
assert!(path.exists());
}
#[test]
fn reclaim_remove_mode_reclaims_a_clean_merged_worktree() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("reclaim-2919");
land(&path);
let out = reclaim_with_probes(
&fx.repos_root,
&FreshProbes {
in_use_now: &|| Some(Vec::new()),
index_for: &|_: &Path| merged_index("session/reclaim-2919", 37),
},
ReclaimMode::Remove,
);
assert_eq!(out.removed, vec![path.clone()], "outcome: {out:?}");
assert!(!path.exists(), "the directory must be gone");
assert!(out.removed_bytes > 0);
assert!(out.removal_failed.is_empty());
}
#[test]
fn reclaim_uses_an_unbounded_budget() {
let b = SurveyBudget::unbounded();
assert!(b.measure.is_none() && b.classify.is_none());
}
#[test]
fn e2e_survey_against_a_real_store() {
let Ok(root) = std::env::var("TM_2919_E2E_ROOT") else {
eprintln!("skipping: set TM_2919_E2E_ROOT to run the end-to-end probe");
return;
};
let root = std::path::PathBuf::from(root);
let started = std::time::Instant::now();
let budget = SurveyBudget {
measure: Some(std::time::Duration::from_secs(20)),
classify: None,
};
let s = survey(&root, &[], budget, true);
println!("--- #2919 e2e survey of {} ---", root.display());
println!("elapsed = {:?}", started.elapsed());
println!("candidates = {}", s.candidates.len());
println!("reclaimable = {}", s.reclaimable);
println!(
"reclaimable_bytes = {} (across {} of {} measured)",
s.reclaimable_bytes, s.reclaimable_measured, s.reclaimable
);
println!("total_bytes = {}", s.total_bytes);
println!(
"pr_state_unknown = {} (of which not_inspected = {}, lookup-unresolved = {})",
s.pr_state_unknown,
s.not_inspected,
s.pr_state_unknown.saturating_sub(s.not_inspected)
);
println!("unmeasured = {}", s.unmeasured);
for c in s.candidates.iter().filter(|c| c.verdict.is_reclaimable()) {
println!(
"RECLAIMABLE {} branch={:?} pr={:?} bytes={:?}",
c.path.display(),
c.branch,
c.pr,
c.bytes
);
}
assert!(!s.candidates.is_empty(), "the store must hold worktrees");
assert!(
s.pr_state_unknown < s.candidates.len(),
"pull-request state resolved for NONE of {} worktrees — this is exactly \
the signature of the `gh -C` argv bug, where every call failed and every \
branch blocked",
s.candidates.len()
);
}
#[test]
fn survey_measures_reclaimable_worktrees_before_blocked_ones() {
let fx = GitWorktreeFixture::new();
let reclaimable = fx.add_worktree("measure-order-reclaimable-2919");
land(&reclaimable);
let blocked = fx.add_worktree("measure-order-blocked-2919");
land(&blocked);
let rows = r#"[
{"number": 60, "headRefName": "session/measure-order-reclaimable-2919", "state": "MERGED"},
{"number": 61, "headRefName": "session/measure-order-blocked-2919", "state": "OPEN"}
]"#;
let s = survey_with_index(
&fx.repos_root,
&[],
&|_: &Path| PrIndex::from_json(rows, 400),
SurveyBudget::default(),
false,
);
let r = s
.candidates
.iter()
.find(|c| c.path == reclaimable)
.expect("listed");
assert!(r.verdict.is_reclaimable(), "{:?}", r.verdict);
assert!(
r.bytes.is_some() && s.reclaimable_bytes > 0,
"the reclaimable worktree must carry real bytes, got {:?} / total {}",
r.bytes,
s.reclaimable_bytes
);
let starved = survey_with_index(
&fx.repos_root,
&[],
&|_: &Path| PrIndex::from_json(rows, 400),
SurveyBudget {
measure: Some(std::time::Duration::ZERO),
classify: None,
},
false,
);
assert_eq!(starved.reclaimable_bytes, 0);
assert!(starved.unmeasured > 0, "and it is disclosed as unmeasured");
}
#[test]
fn survey_discloses_a_partially_measured_reclaimable_set() {
let fx = GitWorktreeFixture::new();
for n in ["partial-a-2919", "partial-b-2919"] {
let p = fx.add_worktree(n);
land(&p);
}
let rows = r#"[
{"number": 70, "headRefName": "session/partial-a-2919", "state": "MERGED"},
{"number": 71, "headRefName": "session/partial-b-2919", "state": "MERGED"}
]"#;
let full = survey_with_index(
&fx.repos_root,
&[],
&|_: &Path| PrIndex::from_json(rows, 400),
SurveyBudget::default(),
false,
);
assert_eq!(full.reclaimable, 2);
assert_eq!(
full.reclaimable_measured, 2,
"an unbounded survey must measure the whole reclaimable set"
);
let starved = survey_with_index(
&fx.repos_root,
&[],
&|_: &Path| PrIndex::from_json(rows, 400),
SurveyBudget {
measure: Some(std::time::Duration::ZERO),
classify: None,
},
false,
);
assert_eq!(starved.reclaimable, 2, "classification is unaffected");
assert!(
starved.reclaimable_measured < starved.reclaimable,
"a starved measurement pass must report fewer measured than reclaimable"
);
}
#[test]
fn remove_session_worktree_reports_success_when_the_fallback_removes_it() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("fallback-removal-2919");
land(&path);
fx.lock_worktree(&path);
let reported = crate::session_manager::decommission::remove_session_worktree(&path);
assert!(
!path.exists(),
"precondition: the fallback really did remove the directory"
);
assert!(
reported,
"a worktree that is genuinely gone must be reported as removed, not as \
`removal_failed` — the reclaim loop's `remove_session_worktree(..) && \
!path.exists()` depends on this"
);
}
#[test]
fn survey_separates_deadline_skips_from_lookup_failures() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("cause-split-2919");
land(&path);
let skipped = survey_with_index(
&fx.repos_root,
&[],
&|_: &Path| merged_index("session/cause-split-2919", 80),
SurveyBudget {
measure: None,
classify: Some(std::time::Instant::now() - std::time::Duration::from_secs(1)),
},
false,
);
assert!(
skipped.not_inspected > 0,
"the deadline skip must be counted"
);
assert_eq!(
skipped.not_inspected, skipped.pr_state_unknown,
"a deadline skip must not also read as a lookup failure"
);
let unresolved = survey_with_index(
&fx.repos_root,
&[],
&|_: &Path| PrIndex::unavailable(),
SurveyBudget::default(),
false,
);
assert_eq!(
unresolved.not_inspected, 0,
"nothing was skipped — the survey ran to completion"
);
assert!(
unresolved.pr_state_unknown > 0,
"but the lookup resolved nothing"
);
}