use std::cell::RefCell;
use std::path::{Path, PathBuf};
use super::*;
use crate::session_manager::worktree_reclaim::classify;
use crate::session_manager::worktree_git_fixture::GitWorktreeFixture;
use crate::session_manager::worktree_ownership::{AgentDelegationState, AgentWorktreeOwner};
use crate::session_manager::worktree_reclaim_claim::{ClaimLiveness, WorkspaceClaim};
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 nobody() -> LiveClaims {
LiveClaims::default()
}
fn no_keeps() -> KeepList {
KeepList::default()
}
#[test]
fn worktree_7652_an_unattributed_tree_under_a_live_foreign_claim_is_refused() {
use crate::session_manager::decommission::WORKTREE_SENTINEL_FILE;
use crate::session_manager::worktree_reclaim_claim::ClaimState;
use crate::session_manager::worktree_registry::Admission;
let fx = GitWorktreeFixture::new();
let claims = LiveClaims {
claims: vec![WorkspaceClaim::with_liveness(
"tm-project-root-7652",
&fx.repo,
ClaimLiveness::Live,
)],
..LiveClaims::default()
};
let valid = crate::session_manager::worktree_ownership::sentinel_payload_bytes(
crate::session_manager::record::ManagedSessionId::new(),
);
let rows: [(&str, Option<Vec<u8>>, bool); 4] = [
("absent", None, false),
("empty", Some(Vec::new()), false),
("garbage", Some(b"{not json".to_vec()), false),
("unreadable", Some(valid), true),
];
for (label, bytes, deny) in rows {
let wt = fx.add_worktree(&format!("unattributed-{label}-7652"));
land(&wt);
let sentinel = wt.join(WORKTREE_SENTINEL_FILE);
if let Some(bytes) = bytes {
std::fs::write(&sentinel, bytes).expect("write sentinel");
}
let _restore =
deny.then(|| crate::session_manager::worktree_git_fixture::deny_all(&sentinel));
let claim = claims.claim_state(&wt);
assert!(
matches!(claim, ClaimState::ForeignNested { .. }),
"{label}: premise — gate 2 sees a foreign project-root claim: {claim:?}"
);
let v = classify(
&wt,
Admission::Admitted,
&claim,
&merged(7652),
&inspect_dirt,
&no_agents,
&claims.owners,
&no_keeps(),
);
assert!(!v.is_reclaimable(), "{label}: survey classified {v:?}");
let reason = recheck_before_delete(
&wt,
&no_keeps(),
Some(&claims),
&merged(7652),
&no_agents,
None,
)
.unwrap_or_else(|| panic!("{label}: the pre-delete re-check permitted the removal"));
assert!(reason.contains("nothing attributes"), "{label}: {reason}");
}
}
#[test]
fn worktree_7652_gate_4c_survives_a_caller_claim_on_the_same_checkout() {
use crate::session_manager::worktree_reclaim_claim::ClaimState;
use crate::session_manager::worktree_registry::Admission;
let fx = GitWorktreeFixture::new();
let claims = LiveClaims {
claims: vec![
WorkspaceClaim::with_liveness("tm-caller-7652", &fx.repo, ClaimLiveness::Live),
WorkspaceClaim::with_liveness("tm-foreign-7652", &fx.repo, ClaimLiveness::Live),
],
caller: Some("tm-caller-7652".to_string()),
..LiveClaims::default()
};
let wt = fx.add_worktree("caller-and-foreign-7652");
land(&wt);
let claim = claims.claim_state(&wt);
assert!(
matches!(claim, ClaimState::ForeignNested { .. }),
"premise — the caller's own claim must not hide the foreign one: {claim:?}"
);
let v = classify(
&wt,
Admission::Admitted,
&claim,
&merged(7652),
&inspect_dirt,
&no_agents,
&claims.owners,
&no_keeps(),
);
assert!(!v.is_reclaimable(), "the survey classified {v:?}");
let reason = recheck_before_delete(
&wt,
&no_keeps(),
Some(&claims),
&merged(7652),
&no_agents,
None,
)
.expect("the pre-delete re-check permitted the removal");
assert!(reason.contains("nothing attributes"), "{reason}");
assert!(reason.contains("tm-foreign-7652"), "{reason}");
}
fn no_agents(_: &AgentWorktreeOwner) -> AgentDelegationState {
AgentDelegationState::Unknown
}
fn agent_live(_: &AgentWorktreeOwner) -> AgentDelegationState {
AgentDelegationState::Live
}
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, &no_keeps(), None, &merged(1), &no_agents, None)
.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,
&no_keeps(),
Some(&LiveClaims::foreign(vec![WorkspaceClaim::new(
"tm-other-01",
&path,
)])),
&merged(1),
&no_agents,
None,
)
.expect("a claimed worktree must refuse");
assert!(reason.contains("claims this workspace now"), "{reason}");
assert!(
reason.contains("tm-other-01"),
"names the claimant: {reason}"
);
}
#[test]
fn recheck_permits_a_worktree_claimed_only_by_the_calling_session() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("caller-claimed-6806");
land(&path);
let claims = LiveClaims {
claims: vec![WorkspaceClaim::new("tm-client-03", &fx.repo)],
caller: Some("tm-client-03".to_string()),
owners: Default::default(),
};
assert_eq!(
recheck_before_delete(
&path,
&no_keeps(),
Some(&claims),
&merged(1),
&no_agents,
None
),
None,
"the caller's own claim on the enclosing workspace must not refuse"
);
}
#[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,
&no_keeps(),
Some(&nobody()),
&merged(1),
&no_agents,
None
)
.is_none(),
"precondition: the worktree is reclaimable before the lock"
);
fx.lock_worktree(&path);
let reason = recheck_before_delete(
&path,
&no_keeps(),
Some(&nobody()),
&merged(1),
&no_agents,
None,
)
.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,
&no_keeps(),
Some(&nobody()),
&merged(1),
&no_agents,
None,
)
.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(),
&no_keeps(),
Some(&nobody()),
&merged(1),
&no_agents,
None,
)
.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("elsewhere");
let path = fx.add_worktree_at(&parent, "unowned-2919");
let reason = recheck_before_delete(
&path,
&no_keeps(),
Some(&nobody()),
&merged(1),
&no_agents,
None,
)
.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);
GitWorktreeFixture::commit_unpushed(&path);
for state in [
BranchPrState::Open { pr: 2 },
BranchPrState::ClosedUnmerged { pr: 2 },
BranchPrState::NoPr,
BranchPrState::Unknown,
] {
let reason = recheck_before_delete(
&path,
&no_keeps(),
Some(&nobody()),
&state,
&no_agents,
None,
)
.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,
&no_keeps(),
Some(&nobody()),
&merged(1),
&no_agents,
None
)
.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,
&no_keeps(),
Some(&nobody()),
&merged(1),
&no_agents,
None,
)
.expect("a dirtied worktree must refuse");
assert!(reason.contains("unsaved work"), "{reason}");
}
#[test]
fn recheck_refuses_a_worktree_an_agent_claimed_after_the_survey() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("agent-race-5661");
land(&path);
assert!(
recheck_before_delete(
&path,
&no_keeps(),
Some(&nobody()),
&merged(1),
&no_agents,
None
)
.is_none(),
"precondition: permitted before the agent claims it"
);
GitWorktreeFixture::stamp_agent_sentinel(&path, "agent-arrived-mid-sweep");
let reason = recheck_before_delete(
&path,
&no_keeps(),
Some(&nobody()),
&merged(1),
&agent_live,
None,
)
.expect("a tree an agent claimed mid-sweep must refuse");
assert!(reason.contains("agent-arrived-mid-sweep"), "{reason}");
}
#[test]
fn reclaim_remove_mode_spares_a_live_agents_merged_worktree() {
let fx = GitWorktreeFixture::new();
let parent = fx.repo.join(".claude").join("worktrees");
let path = fx.add_worktree_at(&parent, "live-agent-sweep-5661");
land(&path);
GitWorktreeFixture::stamp_agent_sentinel(&path, "agent-still-working");
let out = reclaim_scoped(
&fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &no_keeps,
agent_state: &agent_live,
in_use_now: &|| Some(nobody()),
index_for: &|_: &Path| merged_index("wt/live-agent-sweep-5661", 5661),
},
ReclaimMode::Remove,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
assert_eq!(
out.survey.reclaimable, 0,
"a live agent's worktree must never be advertised: {out:?}"
);
assert!(
out.removed.is_empty(),
"removed a live agent's tree: {out:?}"
);
assert!(path.exists(), "and the directory must still be there");
}
#[test]
fn survey_discloses_a_live_agents_spared_worktree() {
let fx = GitWorktreeFixture::new();
let parent = fx.repo.join(".claude").join("worktrees");
let path = fx.add_worktree_at(&parent, "spared-agent-5829");
land(&path);
GitWorktreeFixture::stamp_agent_sentinel(&path, "agent-mid-task-5829");
let out = reclaim_scoped(
&fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &no_keeps,
agent_state: &agent_live,
in_use_now: &|| Some(nobody()),
index_for: &|_: &Path| merged_index("wt/spared-agent-5829", 5829),
},
ReclaimMode::Remove,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
assert!(
out.removed.is_empty(),
"removed a live agent's tree: {out:?}"
);
assert!(path.exists(), "the directory must still be there");
let disclosed = out.survey.agent_owned.join("\n");
assert_eq!(
out.survey.agent_owned.len(),
1,
"the spared worktree must be reported exactly once: {disclosed}"
);
assert!(
disclosed.contains("spared-agent-5829"),
"the disclosure must name the worktree: {disclosed}"
);
assert!(
disclosed.contains("agent-mid-task-5829"),
"and the agent it was spared for: {disclosed}"
);
}
#[test]
fn survey_discloses_nothing_when_no_agent_was_spared() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("no-agent-5829");
land(&path);
let out = reclaim_scoped(
&fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &no_keeps,
agent_state: &no_agents,
in_use_now: &|| Some(nobody()),
index_for: &|_: &Path| merged_index("session/no-agent-5829", 5830),
},
ReclaimMode::Remove,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
assert!(
out.survey.agent_owned.is_empty(),
"nothing was agent-owned: {out:?}"
);
assert_eq!(
out.removed.len(),
1,
"the sweep must still reclaim: {out:?}"
);
assert!(!path.exists(), "and the directory must be gone");
}
#[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,
&no_keeps(),
Some(&nobody()),
&merged(1),
&no_agents,
None
),
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,
&nobody(),
&|_: &Path| merged_index("session/survey-2919", 21),
&no_agents,
SurveyBudget::default(),
false,
&KeepList::default(),
&[],
);
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_reclaims_a_round_sibling_of_a_merged_pr() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("survey-7267-r2");
land(&path);
let s = survey_with_index(
&fx.repos_root,
&nobody(),
&|_: &Path| merged_index("session/survey-7267", 71),
&no_agents,
SurveyBudget::default(),
false,
&KeepList::default(),
&[],
);
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: 71 });
}
#[test]
fn survey_still_blocks_a_branch_no_merged_pr_relates_to() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("survey-7267-none");
land(&path);
GitWorktreeFixture::commit_unpushed(&path);
let s = survey_with_index(
&fx.repos_root,
&nobody(),
&|_: &Path| merged_index("session/somebody-else", 72),
&no_agents,
SurveyBudget::default(),
false,
&KeepList::default(),
&[],
);
let found = s
.candidates
.iter()
.find(|c| c.path == path)
.unwrap_or_else(|| panic!("survey missed {}", path.display()));
assert!(
!found.verdict.is_reclaimable(),
"an unrelated merged PR must not vouch for this tree: {:?}",
found.verdict
);
}
#[test]
fn survey_reclaims_a_worktree_claimed_only_by_the_calling_session() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("caller-survey-6806");
land(&path);
let claims = LiveClaims {
claims: vec![WorkspaceClaim::new("tm-client-03", &fx.repo)],
caller: Some("tm-client-03".to_string()),
owners: Default::default(),
};
let s = survey_with_index(
&fx.repos_root,
&claims,
&|_: &Path| merged_index("session/caller-survey-6806", 61),
&no_agents,
SurveyBudget::default(),
false,
&KeepList::default(),
&[],
);
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: 61 });
}
#[test]
fn survey_still_blocks_a_worktree_a_foreign_session_claims() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("foreign-survey-6806");
land(&path);
let claims = LiveClaims {
claims: vec![WorkspaceClaim::new("tm-other-01", &path)],
caller: Some("tm-client-03".to_string()),
owners: Default::default(),
};
let s = survey_with_index(
&fx.repos_root,
&claims,
&|_: &Path| merged_index("session/foreign-survey-6806", 62),
&no_agents,
SurveyBudget::default(),
false,
&KeepList::default(),
&[],
);
let found = s
.candidates
.iter()
.find(|c| c.path == path)
.unwrap_or_else(|| panic!("survey missed {}", path.display()));
let ReclaimVerdict::Blocked { gate, reason } = &found.verdict else {
panic!("expected a gate-2 refusal, got {:?}", found.verdict);
};
assert_eq!(*gate, ReclaimGate::Liveness);
assert!(
reason.contains("tm-other-01"),
"names the claimant: {reason}"
);
assert!(
reason.contains("not the calling session"),
"denies it is the caller: {reason}"
);
assert!(
s.blocked_reasons
.iter()
.any(|r| r.contains("tm-other-01") && r.contains("gate 2 (liveness)")),
"{:?}",
s.blocked_reasons
);
}
#[test]
fn survey_reclaims_a_worktree_whose_two_commits_squash_merged_as_one() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("squash-6507");
fx.squash_merge_commits_to_origin(&path, &["first.rs", "second.rs"]);
let s = survey_with_index(
&fx.repos_root,
&nobody(),
&|_: &Path| merged_index("session/squash-6507", 6705),
&no_agents,
SurveyBudget::default(),
false,
&KeepList::default(),
&[],
);
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: 6705 },
"a squash-merged branch whose upstream is pruned holds nothing unsaved; \
blocked_reasons was {:?}",
s.blocked_reasons
);
}
#[test]
fn survey_names_the_gate_that_blocked_each_candidate() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("blocked-6507");
land(&path);
let s = survey_with_index(
&fx.repos_root,
&nobody(),
&|_: &Path| PrIndex::unavailable_because("gh exited 4: gh auth login".to_string()),
&no_agents,
SurveyBudget::default(),
false,
&KeepList::default(),
&[],
);
let line = s
.blocked_reasons
.iter()
.find(|l| l.starts_with(&format!("{}: ", path.display())))
.unwrap_or_else(|| {
panic!(
"every non-reclaimable candidate owes a line; got {:?}",
s.blocked_reasons
)
});
assert_eq!(
line,
&format!(
"{}: blocked at gate 5 (pull-request state): the pull-request lookup failed: \
gh exited 4: gh auth login",
path.display()
)
);
assert_eq!(
s.blocked_reasons.len() + s.agent_owned.len(),
s.blocked,
"the disclosed lines and the blocked count are folded from one pass and \
may never disagree"
);
}
#[test]
fn survey_excludes_a_worktree_trusty_mpm_cannot_remove() {
let fx = GitWorktreeFixture::new();
let parent = fx.repo.join("elsewhere");
let path = fx.add_worktree_at(&parent, "harness-2919");
let s = survey_with_index(
&fx.repos_root,
&nobody(),
&|_: &Path| merged_index("wt/harness-2919", 55),
&no_agents,
SurveyBudget::default(),
false,
&KeepList::default(),
&[],
);
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 worktree_7771_survey_offers_an_unattributed_agent_store_worktree() {
let fx = GitWorktreeFixture::new();
let parent = fx.repo.join(".claude").join("worktrees");
let path = fx.add_worktree_at(&parent, "agent-6561");
land(&path);
let s = survey_with_index(
&fx.repos_root,
&nobody(),
&|_: &Path| merged_index("wt/agent-6561", 759),
&no_agents,
SurveyBudget::default(),
false,
&KeepList::default(),
&[],
);
let found = s
.candidates
.iter()
.find(|c| c.path == path)
.unwrap_or_else(|| panic!("survey missed {}", path.display()));
assert!(
found.verdict.is_reclaimable(),
"a merged, clean, unlocked tree with no owner file is reclaimable: {:?}",
found.verdict
);
assert_eq!(s.reclaimable, 1);
}
#[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,
&nobody(),
&|_: &Path| merged_index("session/deadline-2919", 40),
&no_agents,
SurveyBudget {
measure: Some(std::time::Duration::ZERO),
classify: Some(std::time::Instant::now() - std::time::Duration::from_secs(1)),
},
false,
&KeepList::default(),
&[],
);
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,
&nobody(),
&|_: &Path| merged_index("session/measure-2919", 41),
&no_agents,
SurveyBudget {
measure: Some(std::time::Duration::ZERO),
classify: None,
},
false,
&KeepList::default(),
&[],
);
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_scoped(
&fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &no_keeps,
agent_state: &no_agents,
in_use_now: &|| Some(nobody()),
index_for: &|_: &Path| merged_index("session/report-2919", 30),
},
ReclaimMode::Report,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
assert_eq!(out.survey.reclaimable, 1, "it IS reclaimable…");
assert!(out.removed.is_empty(), "…but Report mode removed it");
assert!(path.exists());
}
#[test]
fn a_dead_sessions_claim_does_not_block_the_dry_run() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("dead-claim-7232");
land(&path);
let dead = LiveClaims::foreign(vec![WorkspaceClaim::with_liveness(
"51786c9c-478f-5b3f-83a7-cbe63e3d5958",
&fx.repo,
ClaimLiveness::SessionGone,
)]);
let out = reclaim_scoped(
&fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &no_keeps,
agent_state: &no_agents,
in_use_now: &|| Some(dead.clone()),
index_for: &|_: &Path| merged_index("session/dead-claim-7232", 32),
},
ReclaimMode::Report,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
assert_eq!(
out.survey.reclaimable, 1,
"a tombstoned session's claim must not block reclaim: {:?}",
out.survey.blocked_reasons
);
assert!(out.removed.is_empty(), "Report mode still removes nothing");
assert!(path.exists());
}
#[test]
fn a_live_sessions_claim_still_blocks_the_dry_run() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("live-claim-7232");
land(&path);
let live = LiveClaims::foreign(vec![WorkspaceClaim::with_liveness(
"tm-other-01",
&path,
ClaimLiveness::Live,
)]);
let out = reclaim_scoped(
&fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &no_keeps,
agent_state: &no_agents,
in_use_now: &|| Some(live.clone()),
index_for: &|_: &Path| merged_index("session/live-claim-7232", 33),
},
ReclaimMode::Report,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
assert_eq!(out.survey.reclaimable, 0);
assert!(
out.survey
.blocked_reasons
.iter()
.any(|r| r.contains("tm-other-01")),
"{:?}",
out.survey.blocked_reasons
);
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(nobody())
} else {
Some(LiveClaims::foreign(vec![WorkspaceClaim::new(
"tm-other-01",
claimed.clone(),
)]))
}
};
let out = reclaim_scoped(
&fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &no_keeps,
agent_state: &no_agents,
in_use_now: &in_use_now,
index_for: &|_: &Path| merged_index("session/claim-race-2919", 31),
},
ReclaimMode::Remove,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
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(nobody())
};
let out = reclaim_scoped(
&fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &no_keeps,
agent_state: &no_agents,
in_use_now: &in_use_now,
index_for: &|_: &Path| merged_index("session/dirt-race-2919", 32),
},
ReclaimMode::Remove,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
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(nobody())
};
let out = reclaim_scoped(
&fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &no_keeps,
agent_state: &no_agents,
in_use_now: &in_use_now,
index_for: &|_: &Path| merged_index("session/lock-race-2919", 33),
},
ReclaimMode::Remove,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
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_scoped(
&fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &no_keeps,
agent_state: &no_agents,
in_use_now: &|| Some(nobody()),
index_for: &index,
},
ReclaimMode::Remove,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
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(nobody()) } else { None }
};
let out = reclaim_scoped(
&fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &no_keeps,
agent_state: &no_agents,
in_use_now: &in_use_now,
index_for: &|_: &Path| merged_index("session/unreadable-race-2919", 36),
},
ReclaimMode::Remove,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
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 recheck_refuses_a_worktree_keep_listed_after_the_survey() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("recheck-keep-6927");
land(&path);
assert!(
recheck_before_delete(
&path,
&no_keeps(),
Some(&nobody()),
&merged(1),
&no_agents,
None
)
.is_none(),
"the fixture must otherwise be deletable, or this test proves nothing"
);
let keeps = KeepList::from_patterns(&[path.to_string_lossy().to_string()]);
let reason =
recheck_before_delete(&path, &keeps, Some(&nobody()), &merged(1), &no_agents, None)
.expect("a keep-listed worktree must be refused at the delete");
assert!(
reason.contains("keep-list"),
"the refusal must name the operator's own decision: {reason}"
);
}
#[test]
fn recheck_refuses_when_the_keep_list_cannot_be_read() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("recheck-unreadable-6927");
land(&path);
let broken = KeepList::unreadable("config YAML error at /x/config.yaml: bad");
let reason = recheck_before_delete(
&path,
&broken,
Some(&nobody()),
&merged(1),
&no_agents,
None,
)
.expect("an unreadable keep-list must refuse the delete");
assert!(
reason.contains("could not be read"),
"the refusal must say the config is broken: {reason}"
);
}
#[test]
fn reclaim_remove_mode_refuses_a_worktree_keep_listed_after_the_survey() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("reclaim-keep-6927");
land(&path);
let reads = RefCell::new(0usize);
let keep_list = || {
let mut n = reads.borrow_mut();
*n += 1;
if *n == 1 {
KeepList::default()
} else {
KeepList::from_patterns(&[path.to_string_lossy().to_string()])
}
};
let out = reclaim_scoped(
&fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &keep_list,
agent_state: &no_agents,
in_use_now: &|| Some(nobody()),
index_for: &|_: &Path| merged_index("session/reclaim-keep-6927", 41),
},
ReclaimMode::Remove,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
assert_eq!(
out.survey.reclaimable, 1,
"the survey must approve it, or the delete loop never runs: {out:?}"
);
assert!(
out.removed.is_empty(),
"deleted a keep-listed worktree: {out:?}"
);
assert!(path.exists(), "the directory must still be there");
assert_eq!(out.refused_at_recheck.len(), 1, "{out:?}");
assert!(
out.refused_at_recheck[0].contains("keep-list"),
"the refusal must name the keep-list: {:?}",
out.refused_at_recheck
);
assert!(
*reads.borrow() >= 2,
"the delete loop must RE-READ the keep-list, not reuse the survey's answer"
);
}
#[test]
fn a_malformed_config_refuses_to_reclaim_a_merged_clean_worktree() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("keep-config-6927");
land(&path);
let home = tempfile::tempdir().expect("tempdir");
let config = trusty_common::crate_config::crate_config_path_at(
home.path(),
crate::core::trusty_tools_config::CRATE_NAME,
);
std::fs::create_dir_all(config.parent().expect("parent")).expect("mkdir");
std::fs::write(
&config,
format!(
"disk:\n keep_list:\n - {}\nauto_resume: not-a-boolean\n",
path.display()
),
)
.expect("write config");
let keep_list = || crate::core::trusty_tools_config::load_disk_keep_list_at(home.path());
let out = reclaim_scoped(
&fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &keep_list,
agent_state: &no_agents,
in_use_now: &|| Some(nobody()),
index_for: &|_: &Path| merged_index("session/keep-config-6927", 43),
},
ReclaimMode::Remove,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
assert!(
path.exists(),
"a vetoed worktree was deleted because its keep-list would not parse: {out:?}"
);
assert!(out.removed.is_empty(), "{out:?}");
assert_eq!(
out.survey.reclaimable, 0,
"an unreadable keep-list must make nothing reclaimable: {out:?}"
);
let blocked = format!("{:?}", out.survey.blocked_reasons);
assert!(
blocked.contains("could not be read"),
"the operator must be told their config is what stopped the sweep: {blocked}"
);
}
#[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_scoped(
&fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &no_keeps,
agent_state: &no_agents,
in_use_now: &|| Some(nobody()),
index_for: &|_: &Path| merged_index("session/reclaim-2919", 37),
},
ReclaimMode::Remove,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
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 a_merged_pr_path_replaced_by_a_symlink_is_not_removed() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("symlinked-8782");
land(&path);
let other = fx.add_worktree("other-8782");
std::fs::write(other.join("work.rs"), "// unsaved\n").expect("write");
let entries = |dir: &Path| -> Vec<std::ffi::OsString> {
let mut names: Vec<_> = std::fs::read_dir(dir)
.map(|d| d.flatten().map(|e| e.file_name()).collect())
.unwrap_or_default();
names.sort();
names
};
let before = entries(&other);
let calls = RefCell::new(0usize);
let in_use_now = || {
let mut n = calls.borrow_mut();
*n += 1;
if *n == 3 {
std::fs::remove_dir_all(&path).expect("remove the scanned tree");
let pruned = std::process::Command::new("git")
.arg("-C")
.arg(&fx.repo)
.args(["worktree", "prune"])
.status()
.expect("run git worktree prune");
assert!(pruned.success(), "git worktree prune: {pruned}");
std::os::unix::fs::symlink(&other, &path).expect("symlink");
}
Some(nobody())
};
let out = reclaim_scoped(
&fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &no_keeps,
agent_state: &no_agents,
in_use_now: &in_use_now,
index_for: &|_: &Path| merged_index("session/symlinked-8782", 38),
},
ReclaimMode::Remove,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
assert_eq!(*calls.borrow(), 3, "the swap must land in the guard's read");
assert_eq!(entries(&other), before, "the symlink's target was touched");
assert!(out.removed.is_empty(), "{out:?}");
assert!(
out.refused_at_recheck
.iter()
.any(|r| r.contains("no longer resolves to the scanned worktree")),
"{out:?}"
);
}
#[test]
fn reclaim_remove_mode_spares_a_worktree_a_process_was_launched_from() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("launched-from-7504");
land(&path);
let inside = vec![path.join("crates")];
std::fs::create_dir_all(&inside[0]).expect("create the launch directory");
let out = reclaim_scoped(
&fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &inside,
cwd_holder: &|_| None,
keep_list: &no_keeps,
agent_state: &no_agents,
in_use_now: &|| Some(nobody()),
index_for: &|_: &Path| merged_index("session/launched-from-7504", 7504),
},
ReclaimMode::Remove,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
assert!(
path.exists(),
"the daemon deleted the directory it was launched from: {out:?}"
);
assert!(out.removed.is_empty(), "outcome: {out:?}");
let refused = format!("{:?}", out.refused_at_recheck);
assert!(
refused.contains("launched from inside"),
"the refusal must be REPORTED, not silent: {refused}"
);
}
struct KillOnDrop(std::process::Child);
impl Drop for KillOnDrop {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[test]
fn reclaim_remove_mode_spares_a_worktree_an_unregistered_process_stands_in() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("live-cwd-7540");
land(&path);
let inside = path.join("crates");
std::fs::create_dir_all(&inside).expect("create the process's cwd");
let child = KillOnDrop(
std::process::Command::new("sleep")
.arg("120")
.current_dir(&inside)
.spawn()
.expect("spawn an unregistered process inside the worktree"),
);
let out = reclaim_scoped(
&fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &crate::session_manager::worktree_liveness::process_holding,
keep_list: &no_keeps,
agent_state: &no_agents,
in_use_now: &|| Some(nobody()),
index_for: &|_: &Path| merged_index("session/live-cwd-7540", 7540),
},
ReclaimMode::Remove,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
assert!(
path.exists(),
"the sweep deleted a tree a live process stands in: {out:?}"
);
assert!(out.removed.is_empty(), "outcome: {out:?}");
let refused = format!("{:?}", out.refused_at_recheck);
let probe_unavailable = [
"could not run `lsof` to check for live processes:",
"`lsof` timed out after ",
]
.iter()
.any(|prefix| refused.contains(prefix))
|| (refused.contains("`lsof` exited ")
&& refused.contains(" while checking for live processes:"));
assert!(
probe_unavailable || refused.contains(&format!("pid {} ", child.0.id())),
"the refusal must name the unregistered process: {refused}"
);
}
#[test]
fn recheck_refuses_the_repositorys_main_checkout() {
let fx = GitWorktreeFixture::new();
let reason = recheck_before_delete(
&fx.repo,
&no_keeps(),
Some(&nobody()),
&merged(7505),
&no_agents,
None,
)
.unwrap_or_else(|| panic!("{} is the main checkout and must refuse", fx.repo.display()));
assert!(
reason.contains("main checkout"),
"the refusal must name what it protected: {reason}"
);
}
#[test]
fn reclaim_leaves_classification_unbounded() {
assert!(SurveyBudget::for_reclaim().classify.is_none());
}
#[test]
fn worktree_7884_the_reclaim_pass_bounds_its_measurement_phase() {
let budget = SurveyBudget::for_reclaim();
let measure = budget
.measure
.expect("the destructive path must not walk bytes without a deadline");
assert_eq!(measure, super::RECLAIM_MEASURE_BUDGET);
assert!(
measure <= std::time::Duration::from_secs(300),
"the bound must be well inside the client's own request timeout, or it \
is not a bound the operator ever sees: {measure:?}"
);
}
#[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_with_index(
&root,
&nobody(),
&PrIndex::from_gh,
&no_agents,
budget,
true,
&KeepList::default(),
&[],
);
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,
&nobody(),
&|_: &Path| PrIndex::from_json(rows, 400),
&no_agents,
SurveyBudget::default(),
false,
&KeepList::default(),
&[],
);
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,
&nobody(),
&|_: &Path| PrIndex::from_json(rows, 400),
&no_agents,
SurveyBudget {
measure: Some(std::time::Duration::ZERO),
classify: None,
},
false,
&KeepList::default(),
&[],
);
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,
&nobody(),
&|_: &Path| PrIndex::from_json(rows, 400),
&no_agents,
SurveyBudget::default(),
false,
&KeepList::default(),
&[],
);
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,
&nobody(),
&|_: &Path| PrIndex::from_json(rows, 400),
&no_agents,
SurveyBudget {
measure: Some(std::time::Duration::ZERO),
classify: None,
},
false,
&KeepList::default(),
&[],
);
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_refuses_a_git_locked_worktree() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("locked-refusal-4732");
land(&path);
std::fs::write(path.join("precious.txt"), "operator work\n").expect("write precious file");
fx.lock_worktree(&path);
let outcome = crate::session_manager::decommission::remove_session_worktree(
&path,
"test",
crate::session_manager::DirtyWorktreePolicy::Skip,
);
assert!(
path.exists() && path.join("precious.txt").exists(),
"a locked worktree must survive — git declined, and declining is a \
refusal, not a failure to work around"
);
assert!(
!outcome.removed(),
"and the refusal must be reported as NOT removed: {outcome:?}"
);
assert!(
outcome.reason().is_some(),
"with a reason the caller can surface: {outcome:?}"
);
}
#[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,
&nobody(),
&|_: &Path| merged_index("session/cause-split-2919", 80),
&no_agents,
SurveyBudget {
measure: None,
classify: Some(std::time::Instant::now() - std::time::Duration::from_secs(1)),
},
false,
&KeepList::default(),
&[],
);
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,
&nobody(),
&|_: &Path| PrIndex::from_json(r#"[]"#, 0),
&no_agents,
SurveyBudget::default(),
false,
&KeepList::default(),
&[],
);
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"
);
assert_eq!(
unresolved.lookup_failed, 0,
"a truncated index is not a broken one (#6561)"
);
}
#[test]
fn survey_counts_a_failed_lookup_apart_from_an_unknown_state() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("lookup-broke-6561");
land(&path);
let out = survey_with_index(
&fx.repos_root,
&nobody(),
&|_: &Path| {
PrIndex::unavailable_because(
"`gh` exited 4: To get started with GitHub CLI, please run: gh auth login"
.to_string(),
)
},
&no_agents,
SurveyBudget::default(),
false,
&KeepList::default(),
&[],
);
assert!(out.lookup_failed > 0, "the failure must be counted");
assert_eq!(
out.pr_state_unknown, 0,
"a broken lookup must not be filed as an indeterminate answer"
);
assert!(
out.lookup_failure
.as_deref()
.is_some_and(|r| r.contains("gh auth login")),
"the survey must keep `gh`'s own reason; got {:?}",
out.lookup_failure
);
assert_eq!(
out.reclaimable, 0,
"a failed lookup still blocks (ADR-0045)"
);
}
fn released_agent_worktree(fx: &GitWorktreeFixture, name: &str) -> PathBuf {
let path = fx.add_worktree_at(&fx.repo.join(".claude").join("worktrees"), name);
land(&path);
let owner = crate::session_manager::worktree_ownership::AgentWorktreeOwner {
agent_id: name.to_string(),
delegation_id: crate::core::agent::DelegationId::new(),
parent_session_id: crate::core::session::SessionId(ENDED_PARENT),
};
crate::session_manager::worktree_ownership::write_agent_sentinel(&path, owner)
.expect("write agent sentinel");
path
}
const ENDED_PARENT: uuid::Uuid = uuid::Uuid::from_u128(0x7771_0000_0000_0000_0000_0000_0000_0001);
fn parent_ended_claims() -> LiveClaims {
LiveClaims {
owners: crate::session_manager::worktree_reclaim_ownership::SessionOwners::observed([(
ENDED_PARENT.to_string(),
crate::session_manager::worktree_reclaim_claim::ClaimLiveness::SessionGone,
)]),
..LiveClaims::default()
}
}
fn restarted_registry(_: &AgentWorktreeOwner) -> AgentDelegationState {
AgentDelegationState::Unknown
}
#[test]
fn survey_offers_a_merged_agent_worktree_the_harness_released() {
let fx = GitWorktreeFixture::new();
let path = released_agent_worktree(&fx, "agent-6561e2e");
let out = reclaim_scoped(
&fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &no_keeps,
agent_state: &restarted_registry,
in_use_now: &|| Some(parent_ended_claims()),
index_for: &|_: &Path| merged_index("wt/agent-6561e2e", 6561),
},
ReclaimMode::Report,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
let found = out
.survey
.candidates
.iter()
.find(|c| c.path == path)
.unwrap_or_else(|| panic!("survey missed {}", path.display()));
assert_eq!(found.verdict, ReclaimVerdict::Reclaimable { pr: 6561 });
assert_eq!(out.survey.reclaimable, 1, "outcome: {out:?}");
assert!(
out.removed.is_empty() && path.exists(),
"a dry run must delete nothing"
);
}
#[test]
fn reclaim_reclaims_a_merged_agent_worktree_the_harness_released() {
let fx = GitWorktreeFixture::new();
let path = released_agent_worktree(&fx, "agent-6561reclaim");
let out = reclaim_scoped(
&fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &no_keeps,
agent_state: &restarted_registry,
in_use_now: &|| Some(parent_ended_claims()),
index_for: &|_: &Path| merged_index("wt/agent-6561reclaim", 6562),
},
ReclaimMode::Remove,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
assert_eq!(out.removed, vec![path.clone()], "outcome: {out:?}");
assert!(!path.exists(), "the directory must be gone");
assert!(out.removal_failed.is_empty(), "outcome: {out:?}");
}
#[test]
fn reclaim_never_offers_an_agent_worktree_whose_pr_is_open() {
let fx = GitWorktreeFixture::new();
let path = released_agent_worktree(&fx, "agent-6561open");
let out = reclaim_scoped(
&fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &no_keeps,
agent_state: &restarted_registry,
in_use_now: &|| Some(parent_ended_claims()),
index_for: &|_: &Path| open_index("wt/agent-6561open", 6563),
},
ReclaimMode::Remove,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
assert_eq!(out.survey.reclaimable, 0, "outcome: {out:?}");
assert!(out.removed.is_empty() && path.exists());
let found = out
.survey
.candidates
.iter()
.find(|c| c.path == path)
.expect("listed");
assert_eq!(
found.verdict,
ReclaimVerdict::blocked(ReclaimGate::PrState, "PR #6563 is still open"),
"the refusal must be the landing-evidence gate's"
);
}
#[test]
fn reclaim_never_offers_a_dirty_agent_worktree() {
let fx = GitWorktreeFixture::new();
let path = released_agent_worktree(&fx, "agent-6561dirty");
std::fs::write(path.join("in-flight.rs"), "// unsaved\n").expect("write unsaved file");
let out = reclaim_scoped(
&fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &no_keeps,
agent_state: &restarted_registry,
in_use_now: &|| Some(parent_ended_claims()),
index_for: &|_: &Path| merged_index("wt/agent-6561dirty", 6564),
},
ReclaimMode::Remove,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
assert_eq!(out.survey.reclaimable, 0, "outcome: {out:?}");
assert!(out.removed.is_empty() && path.exists());
let found = out
.survey
.candidates
.iter()
.find(|c| c.path == path)
.expect("listed");
assert!(
matches!(&found.verdict, ReclaimVerdict::Blocked { reason, .. }
if reason.contains("holds unsaved work")),
"the refusal must be the unsaved-work gate's: {:?}",
found.verdict
);
}
#[test]
fn survey_discloses_a_harness_locked_agent_worktree() {
let fx = GitWorktreeFixture::new();
let path = released_agent_worktree(&fx, "agent-6561locked");
fx.harness_lock_worktree(&path, "agent-6561locked");
let out = reclaim_scoped(
&fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &no_keeps,
agent_state: &restarted_registry,
in_use_now: &|| Some(nobody()),
index_for: &|_: &Path| merged_index("wt/agent-6561locked", 6565),
},
ReclaimMode::Remove,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
assert!(out.removed.is_empty() && path.exists(), "outcome: {out:?}");
let disclosed = out.survey.agent_owned.join("\n");
assert_eq!(
out.survey.agent_owned.len(),
1,
"the spared worktree must be reported exactly once: {disclosed}"
);
assert!(
disclosed.contains("agent-6561locked"),
"the disclosure must name the worktree: {disclosed}"
);
}
fn git_ok_7057(dir: &Path, args: &[&str]) {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.unwrap_or_else(|e| panic!("fixture: `git {}` could not be run: {e}", args.join(" ")));
assert!(
out.status.success(),
"fixture: `git {}` failed in {}: {}",
args.join(" "),
dir.display(),
String::from_utf8_lossy(&out.stderr)
);
}
fn second_project(repos_root: &Path, owner: &str, name: &str, branch: &str) -> (PathBuf, PathBuf) {
let outside = repos_root
.parent()
.expect("repos root has a parent")
.join(format!("{name}-remote.git"));
let repo = repos_root.join(owner).join(name);
std::fs::create_dir_all(&outside).expect("fixture: remote dir");
std::fs::create_dir_all(&repo).expect("fixture: repo dir");
git_ok_7057(&outside, &["init", "--bare", "--initial-branch=main"]);
git_ok_7057(&repo, &["init", "--initial-branch=main"]);
for (k, v) in [
("user.email", "ci@test.invalid"),
("user.name", "CI"),
("commit.gpgsign", "false"),
] {
git_ok_7057(&repo, &["config", k, v]);
}
std::fs::write(repo.join("README.md"), "base\n").expect("fixture: README");
git_ok_7057(&repo, &["add", "README.md"]);
git_ok_7057(&repo, &["commit", "-m", "base"]);
git_ok_7057(
&repo,
&[
"remote",
"add",
"origin",
outside.to_str().expect("utf8 remote"),
],
);
git_ok_7057(&repo, &["push", "origin", "main"]);
git_ok_7057(&repo, &["fetch", "origin"]);
let wt = repo.join(".worktrees").join(name);
git_ok_7057(
&repo,
&[
"worktree",
"add",
"-b",
branch,
wt.to_str().expect("utf8 worktree path"),
],
);
for (k, v) in [
("user.email", "ci@test.invalid"),
("user.name", "CI"),
("commit.gpgsign", "false"),
] {
git_ok_7057(&wt, &["config", k, v]);
}
land(&wt);
(repo, wt)
}
fn set_origin_7057(repo: &Path, url: &str) {
git_ok_7057(repo, &["remote", "set-url", "origin", url]);
}
#[test]
fn prune_resolves_each_projects_repo_from_its_own_origin_7057() {
use crate::core::gh_identity::GhEnv;
use crate::session_manager::worktree_reclaim_gh::gh_pr_list_command;
use crate::session_manager::worktree_repo_slug::repo_slug_for;
const A_URL: &str = "https://github.com/1m-consulting/adaptive-crm.git";
const A_SLUG: &str = "1m-consulting/adaptive-crm";
const B_URL: &str = "git@github.com:hotstats/hotstats-product-poc.git";
const B_SLUG: &str = "hotstats/hotstats-product-poc";
const B_BRANCH: &str = "feat/demo-seed-fixtures";
let fx = GitWorktreeFixture::new();
let a_wt = fx.add_worktree("adaptive-7057");
land(&a_wt);
GitWorktreeFixture::commit_unpushed(&a_wt);
let (b_repo, b_wt) =
second_project(&fx.repos_root, "hotstats", "hotstats-product-poc", B_BRANCH);
set_origin_7057(&fx.repo, A_URL);
set_origin_7057(&b_repo, B_URL);
let seen: RefCell<Vec<(PathBuf, String)>> = RefCell::new(Vec::new());
let index_for = |root: &Path| -> PrIndex {
let slug = repo_slug_for(root).expect("each project's origin resolves");
let cmd = gh_pr_list_command(root, &GhEnv::default(), &slug);
let argv: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
let at = argv
.iter()
.position(|a| a == "--repo")
.expect("every pull-request lookup names its repository (#7057)");
let named = argv[at + 1].clone();
seen.borrow_mut().push((root.to_path_buf(), named.clone()));
if named == B_SLUG {
merged_index(B_BRANCH, 61)
} else {
PrIndex::from_json("[]", 400)
}
};
let out = reclaim_scoped(
&fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &no_keeps,
agent_state: &no_agents,
in_use_now: &|| Some(nobody()),
index_for: &index_for,
},
ReclaimMode::Report,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
let seen = seen.into_inner();
let slug_for = |root: &Path| -> Option<String> {
seen.iter().find(|(r, _)| r == root).map(|(_, s)| s.clone())
};
let a_root = std::fs::canonicalize(&fx.repo).expect("canonical a");
let b_root = std::fs::canonicalize(&b_repo).expect("canonical b");
assert_eq!(
slug_for(&a_root).as_deref(),
Some(A_SLUG),
"project A's lookup must name its own repository; saw {seen:?}"
);
assert_eq!(
slug_for(&b_root).as_deref(),
Some(B_SLUG),
"project B's lookup must name its own repository; saw {seen:?}"
);
let reclaimable: Vec<&PathBuf> = out
.survey
.candidates
.iter()
.filter(|c| c.verdict.is_reclaimable())
.map(|c| &c.path)
.collect();
assert_eq!(
reclaimable,
vec![&b_wt],
"only the worktree whose branch merged in {B_SLUG} is reclaimable; outcome: {out:?}"
);
assert!(
!reclaimable.contains(&&a_wt),
"project A's worktree has no merged pull request in its own repository"
);
}
#[test]
fn worktree_8109_every_surveyed_worktree_gets_one_decision_line() {
use tracing_subscriber::layer::SubscriberExt;
let fx = GitWorktreeFixture::new();
let granted = fx.add_worktree("decide-8109-merged");
let refused = fx.add_worktree("decide-8109-nopr");
land(&granted);
GitWorktreeFixture::commit_unpushed(&refused);
crate::test_support::enable_event_capture();
let buffer = trusty_common::log_buffer::LogBuffer::new(256);
let subscriber = tracing_subscriber::registry().with(
trusty_common::log_buffer::LogBufferLayer::new(buffer.clone()),
);
let out = tracing::subscriber::with_default(subscriber, || {
reclaim_scoped(
&fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &no_keeps,
agent_state: &no_agents,
in_use_now: &|| Some(nobody()),
index_for: &|_: &Path| merged_index("session/decide-8109-merged", 41),
},
ReclaimMode::Report,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
)
});
let lines = buffer.tail(256);
for candidate in &out.survey.candidates {
let field = format!(" path={} branch=", candidate.path.display());
let matching: Vec<&String> = lines
.iter()
.filter(|l| l.contains("(#8109)") && l.contains(&field))
.collect();
assert_eq!(matching.len(), 1, "one line for {field}: {lines:#?}");
let decision = candidate.verdict.decision();
assert!(matching[0].contains(&decision), "{decision}: {matching:?}");
}
let decision_of = |name: &str| {
out.survey
.candidates
.iter()
.find(|c| c.path.ends_with(name))
.map(|c| c.verdict.decision())
.unwrap_or_else(|| panic!("{name} surveyed: {:?}", out.survey.candidates))
};
assert_eq!(
decision_of("decide-8109-merged"),
"reclaimable — landing evidence is PR #41"
);
let refusal = decision_of("decide-8109-nopr");
assert!(refusal.starts_with("refused at gate"), "{refusal}");
assert!(
granted.exists() && refused.exists(),
"Report mode removes nothing"
);
}