use std::path::{Path, PathBuf};
use super::*;
use crate::session_manager::worktree_reclaim_gh::{
GhFailure, consults_the_keychain, gh_command, keychain_warning,
};
use crate::session_manager::worktree_git_fixture::{GitWorktreeFixture, deny_all};
use crate::session_manager::worktree_ownership::{AgentDelegationState, AgentWorktreeOwner};
use crate::session_manager::worktree_safety::inspect_dirt;
use crate::core::worktree_carried_by_pr::CarriedByPr;
use crate::core::worktree_landed_content::{LandedContent, LandingAdmission};
fn clean(_: &Path) -> Option<DirtyWorktree> {
None
}
fn dirty(path: &Path) -> Option<DirtyWorktree> {
Some(DirtyWorktree::new(path, "2 modified files", 2, 0))
}
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 no_agents(_: &AgentWorktreeOwner) -> AgentDelegationState {
AgentDelegationState::Unknown
}
fn agent_live(_: &AgentWorktreeOwner) -> AgentDelegationState {
AgentDelegationState::Live
}
fn agent_ended(_: &AgentWorktreeOwner) -> AgentDelegationState {
AgentDelegationState::Ended
}
fn classify_no_agent(
path: &Path,
admission: Admission,
claim: &ClaimState,
pr: &BranchPrState,
probe_dirt: &dyn Fn(&Path) -> Option<DirtyWorktree>,
) -> ReclaimVerdict {
classify(
path,
admission,
claim,
pr,
probe_dirt,
&no_agents,
&SessionOwners::default(),
&KeepList::default(),
)
}
fn claim(live: bool) -> ClaimState {
if live {
ClaimState::Foreign {
session: "tm-other-01".to_string(),
caller: Some("tm-client-03".to_string()),
}
} else {
ClaimState::Unclaimed
}
}
fn agent_store_worktree(fx: &GitWorktreeFixture, name: &str) -> PathBuf {
let path = fx.add_worktree_at(&fx.repo.join(".claude").join("worktrees"), name);
land(&path);
path
}
fn wt() -> PathBuf {
PathBuf::from("/tmp/.worktrees/worktree-2919")
}
fn reason(v: &ReclaimVerdict) -> String {
match v {
ReclaimVerdict::Blocked { reason, .. } | ReclaimVerdict::BlockedByAgent { reason, .. } => {
reason.clone()
}
ReclaimVerdict::Reclaimable { pr } => panic!("expected a refusal, got Reclaimable {pr}"),
ReclaimVerdict::ReclaimableLandedContent { base } => {
panic!("expected a refusal, got landed content on {base}")
}
}
}
fn classify_landed(
path: &Path,
probe_dirt: &dyn Fn(&Path) -> Option<DirtyWorktree>,
landed: LandedContent,
) -> ReclaimVerdict {
classify_admission(path, &BranchPrState::NoPr, probe_dirt, Some(landed.into()))
}
fn classify_admission(
path: &Path,
pr: &BranchPrState,
probe_dirt: &dyn Fn(&Path) -> Option<DirtyWorktree>,
admission: Option<LandingAdmission>,
) -> ReclaimVerdict {
let ask = |_: &Path| admission.clone().expect("probe asked only when offered");
classify_with_landed_content(
path,
Admission::Admitted,
&claim(false),
pr,
probe_dirt,
&no_agents,
&SessionOwners::default(),
&KeepList::default(),
admission
.as_ref()
.map(|_| &ask as &dyn Fn(&Path) -> LandingAdmission),
)
}
fn landed_on_main() -> LandedContent {
LandedContent::Landed {
base: "origin/main".to_string(),
base_sha: "7df1c383f0a1b2c3d4e5f60718293a4b5c6d7e8f".to_string(),
landed_at: None,
}
}
#[test]
fn classify_blocks_non_admitted_worktree() {
for admission in [
Admission::MainCheckout,
Admission::Bare,
Admission::Locked,
Admission::HarnessAgentLock,
Admission::Prunable,
Admission::Unresolvable,
Admission::OutsideProject,
Admission::OutsideReposRoot,
] {
let v = classify_no_agent(&wt(), admission, &claim(false), &merged(1), &clean);
assert!(
!v.is_reclaimable(),
"{admission:?} must never be reclaimable, even with a merged PR"
);
assert!(reason(&v).starts_with(admission.reason()), "{}", reason(&v));
}
}
#[test]
fn classify_blocks_live_session_workspace() {
let v = classify_no_agent(
&wt(),
Admission::Admitted,
&claim(true),
&merged(42),
&clean,
);
assert!(!v.is_reclaimable());
assert!(
reason(&v).contains("claims this workspace"),
"{}",
reason(&v)
);
}
#[test]
fn classify_names_the_foreign_session_that_blocked_a_candidate() {
let v = classify_no_agent(
&wt(),
Admission::Admitted,
&claim(true),
&merged(42),
&clean,
);
let reason = reason(&v);
assert!(
reason.contains("tm-other-01"),
"names the claimant: {reason}"
);
assert!(
reason.contains("tm-client-03"),
"names the caller: {reason}"
);
assert!(
reason.contains("not the calling session"),
"says the claim is not the caller's: {reason}"
);
}
#[test]
fn classify_allows_a_worktree_claimed_only_by_the_calling_session() {
let v = classify(
&wt(),
Admission::Admitted,
&ClaimState::CallerNested {
session: "tm-client-03".to_string(),
},
&merged(42),
&clean,
&no_agents,
&SessionOwners::default(),
&KeepList::default(),
);
assert_eq!(
v,
ReclaimVerdict::Reclaimable { pr: 42 },
"the caller's own claim must not block its own nested worktree"
);
}
#[test]
fn classify_blocks_the_callers_own_workspace() {
let v = classify(
&wt(),
Admission::Admitted,
&ClaimState::CallerWorkspace {
session: "tm-client-03".to_string(),
},
&merged(42),
&clean,
&no_agents,
&SessionOwners::default(),
&KeepList::default(),
);
assert!(!v.is_reclaimable());
assert!(reason(&v).contains("IS the caller"), "{}", reason(&v));
}
#[test]
fn classify_blocks_open_pr() {
let v = classify_no_agent(
&wt(),
Admission::Admitted,
&claim(false),
&BranchPrState::Open { pr: 9 },
&clean,
);
assert!(!v.is_reclaimable());
assert!(reason(&v).contains("#9"), "{}", reason(&v));
}
#[test]
fn classify_blocks_closed_unmerged_pr() {
let v = classify_no_agent(
&wt(),
Admission::Admitted,
&claim(false),
&BranchPrState::ClosedUnmerged { pr: 11 },
&clean,
);
assert!(!v.is_reclaimable());
assert!(reason(&v).contains("without merging"), "{}", reason(&v));
}
#[test]
fn classify_blocks_no_pr() {
let v = classify_no_agent(
&wt(),
Admission::Admitted,
&claim(false),
&BranchPrState::NoPr,
&clean,
);
assert!(!v.is_reclaimable());
assert!(reason(&v).contains("no pull request"), "{}", reason(&v));
}
#[test]
fn worktree_7889_classify_admits_a_landed_tree_with_no_pull_request() {
let v = classify_landed(&wt(), &clean, landed_on_main());
assert_eq!(
v,
ReclaimVerdict::ReclaimableLandedContent {
base: "origin/main".to_string()
},
"a tree holding no content the remote lacks must be reclaimable"
);
}
#[test]
fn worktree_7889_classify_refuses_a_tree_holding_residue() {
let v = classify_landed(
&wt(),
&clean,
LandedContent::Residual {
base: "origin/main".to_string(),
first_path: "crates/trusty-mpm/src/daemon/mod.rs".to_string(),
},
);
assert!(!v.is_reclaimable());
let r = reason(&v);
assert!(r.contains("landed-content"), "{r}");
assert!(r.contains("crates/trusty-mpm/src/daemon/mod.rs"), "{r}");
}
#[test]
fn worktree_7889_classify_refuses_when_the_admission_is_unavailable() {
let v = classify_landed(
&wt(),
&clean,
LandedContent::unavailable("`origin` could not be refreshed: host unreachable"),
);
assert!(!v.is_reclaimable());
let r = reason(&v);
assert!(r.contains("landed-content"), "{r}");
assert!(r.contains("could not be refreshed"), "{r}");
}
#[test]
fn worktree_7889_classify_refuses_a_dirty_tree_whose_content_is_landed() {
let v = classify_landed(&wt(), &dirty, landed_on_main());
assert!(!v.is_reclaimable());
assert!(reason(&v).contains("unsaved work"), "{}", reason(&v));
}
#[test]
fn worktree_7889_classify_admits_commits_only_dirt_when_landed() {
let commits_only = |p: &Path| Some(DirtyWorktree::new(p, "0 files, 3 unpushed", 0, 3));
let v = classify_landed(&wt(), &commits_only, landed_on_main());
assert!(v.is_reclaimable(), "commits the base already holds: {v:?}");
}
#[test]
fn worktree_7889_classify_refuses_files_beside_unpushed_commits() {
let both = |p: &Path| Some(DirtyWorktree::new(p, "1 file, 3 unpushed", 1, 3));
let v = classify_landed(&wt(), &both, landed_on_main());
assert!(!v.is_reclaimable());
assert!(reason(&v).contains("unsaved work"), "{}", reason(&v));
}
fn residual_then(carried: CarriedByPr) -> LandingAdmission {
LandingAdmission {
content: LandedContent::Residual {
base: "origin/main".to_string(),
first_path: "src/superseded.rs".to_string(),
},
carried: Some(carried),
}
}
fn donor_commits(p: &Path) -> Option<DirtyWorktree> {
Some(DirtyWorktree::new(p, "0 files, 2 unpushed", 0, 2))
}
#[test]
fn worktree_7889_classify_admits_a_tree_a_merged_pr_carried() {
let carried = residual_then(CarriedByPr::Carried {
pr: 8328,
pr_head: "2222222222222222222222222222222222222222".to_string(),
});
let v = classify_admission(&wt(), &BranchPrState::NoPr, &clean, Some(carried));
assert_eq!(v, ReclaimVerdict::Reclaimable { pr: 8328 });
}
#[test]
fn worktree_7889_a_merged_pr_with_commits_only_dirt_reaches_the_admission() {
let merged = BranchPrState::Merged { pr: 8328 };
let v = classify_admission(
&wt(),
&merged,
&donor_commits,
Some(landed_on_main().into()),
);
assert_eq!(v, ReclaimVerdict::Reclaimable { pr: 8328 });
let unoffered = classify_admission(&wt(), &merged, &donor_commits, None);
assert!(
reason(&unoffered).contains("unsaved work"),
"{}",
reason(&unoffered)
);
}
#[test]
fn worktree_7889_a_merged_pr_with_unlanded_commits_still_refuses() {
let neither = residual_then(CarriedByPr::Unavailable {
detail: "gh timed out".to_string(),
});
let v = classify_admission(
&wt(),
&BranchPrState::Merged { pr: 8328 },
&donor_commits,
Some(neither),
);
assert!(!v.is_reclaimable());
let r = reason(&v);
assert!(r.contains("2 unpushed"), "{r}");
assert!(r.contains("landed-content"), "{r}");
assert!(r.contains("src/superseded.rs"), "{r}");
assert!(r.contains("merged-pr-ancestry"), "{r}");
assert!(r.contains("gh timed out"), "{r}");
}
#[test]
fn classify_blocks_unknown_pr_state() {
let v = classify_no_agent(
&wt(),
Admission::Admitted,
&claim(false),
&BranchPrState::Unknown,
&clean,
);
assert!(!v.is_reclaimable());
assert!(
reason(&v).contains("could not be determined"),
"{}",
reason(&v)
);
}
#[test]
fn classify_blocks_dirty_worktree() {
let v = classify_no_agent(
&wt(),
Admission::Admitted,
&claim(false),
&merged(5),
&dirty,
);
assert!(!v.is_reclaimable());
assert!(reason(&v).contains("unsaved work"), "{}", reason(&v));
}
#[test]
fn classify_blocks_a_really_dirty_worktree() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("dirty-2919");
std::fs::write(path.join("scratch.rs"), "fn main() {}\n").expect("write untracked file");
let v = classify_no_agent(
&path,
Admission::Admitted,
&claim(false),
&merged(5),
&inspect_dirt,
);
assert!(
!v.is_reclaimable(),
"a real untracked file must block: {v:?}"
);
}
#[test]
fn classify_blocks_a_worktree_with_unpushed_commits() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("unpushed-2919");
GitWorktreeFixture::commit_unpushed(&path);
let v = classify_no_agent(
&path,
Admission::Admitted,
&claim(false),
&merged(6),
&inspect_dirt,
);
assert!(!v.is_reclaimable(), "an unpushed commit must block: {v:?}");
}
#[test]
fn classify_allows_clean_pushed_merged_worktree() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("clean-2919");
land(&path);
let v = classify_no_agent(
&path,
Admission::Admitted,
&claim(false),
&merged(77),
&inspect_dirt,
);
assert_eq!(v, ReclaimVerdict::Reclaimable { pr: 77 });
}
#[test]
fn classify_blocks_a_live_agents_worktree() {
let fx = GitWorktreeFixture::new();
let path = agent_store_worktree(&fx, "live-agent-5661");
GitWorktreeFixture::stamp_agent_sentinel(&path, "agent-a39a3a8181f33e597");
let v = classify(
&path,
Admission::Admitted,
&claim(false),
&merged(101),
&inspect_dirt,
&agent_live,
&SessionOwners::default(),
&KeepList::default(),
);
assert!(!v.is_reclaimable(), "a live agent's worktree: {v:?}");
assert!(reason(&v).contains("has not ended"), "{}", reason(&v));
assert!(
reason(&v).contains("agent-a39a3a8181f33e597"),
"the refusal must name the agent it is protecting: {}",
reason(&v)
);
}
#[test]
fn classify_blocks_an_agent_the_harness_still_holds_after_a_restart() {
let fx = GitWorktreeFixture::new();
let path = agent_store_worktree(&fx, "forgotten-agent-5661");
GitWorktreeFixture::stamp_agent_sentinel(&path, "agent-lost-to-a-restart");
fx.harness_lock_worktree(&path, "agent-lost-to-a-restart");
let v = classify(
&path,
Admission::Admitted,
&claim(false),
&merged(102),
&inspect_dirt,
&no_agents,
&SessionOwners::default(),
&KeepList::default(),
);
assert!(!v.is_reclaimable(), "{v:?}");
assert!(
reason(&v).contains("still reports the harness"),
"{}",
reason(&v)
);
}
#[test]
fn classify_records_an_agent_refusal_as_its_own_verdict_kind() {
let fx = GitWorktreeFixture::new();
for (name, probe, agent) in [
(
"live-agent-5829",
&agent_live as &dyn Fn(&AgentWorktreeOwner) -> AgentDelegationState,
"agent-still-writing",
),
(
"forgotten-agent-5829",
&no_agents,
"agent-lost-to-a-restart",
),
] {
let path = agent_store_worktree(&fx, name);
GitWorktreeFixture::stamp_agent_sentinel(&path, agent);
fx.harness_lock_worktree(&path, agent);
let v = classify(
&path,
Admission::Admitted,
&claim(false),
&merged(105),
&inspect_dirt,
probe,
&SessionOwners::default(),
&KeepList::default(),
);
assert!(
matches!(v, ReclaimVerdict::BlockedByAgent { .. }),
"an agent-ownership refusal must be its own verdict kind, got {v:?}"
);
assert!(reason(&v).contains(agent), "{}", reason(&v));
}
}
#[test]
fn classify_reserves_the_agent_verdict_for_agent_refusals() {
let dirty_tree = classify_no_agent(
&wt(),
Admission::Admitted,
&claim(false),
&merged(106),
&dirty as &dyn Fn(&Path) -> Option<DirtyWorktree>,
);
assert!(
matches!(dirty_tree, ReclaimVerdict::Blocked { .. }),
"a dirty tree is not an agent refusal: {dirty_tree:?}"
);
let open_pr = classify_no_agent(
&wt(),
Admission::Admitted,
&claim(false),
&BranchPrState::Open { pr: 107 },
&clean,
);
assert!(
matches!(open_pr, ReclaimVerdict::Blocked { .. }),
"an open PR is not an agent refusal: {open_pr:?}"
);
}
#[test]
fn survey_lists_an_agent_held_candidate_in_exactly_one_place() {
let fx = GitWorktreeFixture::new();
let harness_held = agent_store_worktree(&fx, "harness-lock-6507");
GitWorktreeFixture::stamp_agent_sentinel(&harness_held, "agent-under-a-harness-lock");
fx.harness_lock_worktree(&harness_held, "agent-under-a-harness-lock");
let harness_verdict = classify_no_agent(
&harness_held,
Admission::HarnessAgentLock,
&claim(false),
&merged(6507),
&clean,
);
assert!(
matches!(
harness_verdict,
ReclaimVerdict::BlockedByAgent {
gate: ReclaimGate::Admission,
..
}
),
"gate 1's harness lock must build the agent verdict: {harness_verdict:?}"
);
let registry_held = agent_store_worktree(&fx, "live-agent-6507");
GitWorktreeFixture::stamp_agent_sentinel(®istry_held, "agent-still-writing");
let registry_verdict = classify(
®istry_held,
Admission::Admitted,
&claim(false),
&merged(6508),
&inspect_dirt,
&agent_live,
&SessionOwners::default(),
&KeepList::default(),
);
assert!(
matches!(
registry_verdict,
ReclaimVerdict::BlockedByAgent {
gate: ReclaimGate::AgentOwnership,
..
}
),
"gate 4 must build the agent verdict: {registry_verdict:?}"
);
let dirty_tree = fx.add_worktree("dirty-6507");
let dirty_verdict = classify_no_agent(
&dirty_tree,
Admission::Admitted,
&claim(false),
&merged(6509),
&dirty,
);
assert!(
matches!(dirty_verdict, ReclaimVerdict::Blocked { .. }),
"the control must be an ordinary block: {dirty_verdict:?}"
);
let candidate = |path: &Path, verdict: ReclaimVerdict| ReclaimCandidate {
path: path.to_path_buf(),
branch: None,
registry_root: fx.repo.clone(),
bytes: Some(1),
pr: merged(6507),
verdict,
};
let survey = ReclaimSurvey::from_candidates(vec![
candidate(&harness_held, harness_verdict),
candidate(®istry_held, registry_verdict),
candidate(&dirty_tree, dirty_verdict),
]);
let lines_for = |list: &[String], path: &Path| {
let prefix = format!("{}: ", path.display());
list.iter().filter(|l| l.starts_with(&prefix)).count()
};
for held in [&harness_held, ®istry_held] {
assert_eq!(
lines_for(&survey.agent_owned, held),
1,
"an agent-held tree owes exactly one agent line; got {:?}",
survey.agent_owned
);
assert_eq!(
lines_for(&survey.blocked_reasons, held),
0,
"and it must not be disclosed a second time as an ordinary block; got {:?}",
survey.blocked_reasons
);
}
assert_eq!(
lines_for(&survey.blocked_reasons, &dirty_tree),
1,
"an ordinary refusal still owes its line; got {:?}",
survey.blocked_reasons
);
assert_eq!(
lines_for(&survey.agent_owned, &dirty_tree),
0,
"a dirty tree is nobody's agent skip; got {:?}",
survey.agent_owned
);
assert_eq!(
survey.agent_owned.len() + survey.blocked_reasons.len(),
survey.blocked,
"the two lists partition the blocked set: {:?} / {:?}",
survey.agent_owned,
survey.blocked_reasons
);
}
#[test]
fn classify_allows_a_finished_agents_merged_worktree() {
let fx = GitWorktreeFixture::new();
let path = agent_store_worktree(&fx, "finished-agent-5661");
let owner = GitWorktreeFixture::stamp_agent_sentinel(&path, "agent-that-finished");
let v = classify(
&path,
Admission::Admitted,
&claim(false),
&merged(103),
&inspect_dirt,
&agent_ended,
&GitWorktreeFixture::parent_ended(&owner),
&KeepList::default(),
);
assert_eq!(v, ReclaimVerdict::Reclaimable { pr: 103 });
}
#[test]
fn classify_blocks_an_agent_store_worktree_with_an_unreadable_sentinel() {
let fx = GitWorktreeFixture::new();
let garbled = agent_store_worktree(&fx, "garbled-sentinel-5661");
crate::session_manager::worktree_ownership_location::write_sentinel_bytes(
&garbled,
b"{not json",
)
.expect("write a malformed sentinel");
let v = classify(
&garbled,
Admission::Admitted,
&claim(false),
&merged(104),
&inspect_dirt,
&agent_ended,
&SessionOwners::default(),
&KeepList::default(),
);
assert!(!v.is_reclaimable(), "malformed sentinel: {v:?}");
assert!(reason(&v).contains("does not parse"), "{}", reason(&v));
let denied = agent_store_worktree(&fx, "denied-sentinel-5661");
let sentinel =
crate::session_manager::worktree_ownership_location::admin_sentinel_path(&denied)
.expect("an agent-store worktree has a git admin dir");
GitWorktreeFixture::stamp_agent_sentinel(&denied, "agent-behind-a-locked-door");
let _restore = deny_all(&sentinel);
let v = classify(
&denied,
Admission::Admitted,
&claim(false),
&merged(105),
&inspect_dirt,
&agent_ended,
&SessionOwners::default(),
&KeepList::default(),
);
assert!(!v.is_reclaimable(), "unreadable sentinel: {v:?}");
assert!(reason(&v).contains("cannot be read"), "{}", reason(&v));
}
#[test]
fn classify_leaves_a_session_owned_worktree_alone() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("session-owned-5661");
land(&path);
GitWorktreeFixture::stamp_reclaimable_sentinel(&path);
let v = classify(
&path,
Admission::Admitted,
&claim(false),
&merged(106),
&inspect_dirt,
&no_agents,
&GitWorktreeFixture::reclaimable_owner_gone(),
&KeepList::default(),
);
assert_eq!(v, ReclaimVerdict::Reclaimable { pr: 106 });
}
const ROWS: &str = r#"[
{"number": 1, "headRefName": "feat/merged", "state": "MERGED"},
{"number": 2, "headRefName": "feat/open", "state": "OPEN"},
{"number": 3, "headRefName": "feat/closed", "state": "CLOSED"}
]"#;
#[test]
fn pr_index_reads_merged_open_and_closed_rows() {
let idx = PrIndex::from_json(ROWS, 400);
assert_eq!(
idx.state_for(Some("feat/merged")),
BranchPrState::Merged { pr: 1 }
);
assert_eq!(
idx.state_for(Some("feat/open")),
BranchPrState::Open { pr: 2 }
);
assert_eq!(
idx.state_for(Some("feat/closed")),
BranchPrState::ClosedUnmerged { pr: 3 }
);
}
#[test]
fn pr_index_absent_branch_is_no_pr_when_complete() {
let idx = PrIndex::from_json(ROWS, 400);
assert_eq!(
idx.state_for(Some("feat/never-had-one")),
BranchPrState::NoPr
);
}
#[test]
fn pr_index_truncated_reply_makes_absent_branches_unknown() {
let idx = PrIndex::from_json(ROWS, 3);
assert_eq!(
idx.state_for(Some("feat/never-had-one")),
BranchPrState::Unknown
);
assert_eq!(
idx.state_for(Some("feat/merged")),
BranchPrState::Merged { pr: 1 }
);
}
const ROUND_ROWS: &str = r#"[
{"number": 11, "headRefName": "fix/7267-thing", "state": "MERGED"},
{"number": 12, "headRefName": "feat/other-r2", "state": "MERGED"}
]"#;
#[test]
fn pr_index_resolves_a_round_sibling_by_stem() {
let idx = PrIndex::from_json(ROUND_ROWS, 400);
assert_eq!(
idx.state_for(Some("fix/7267-thing-r3")),
BranchPrState::Merged { pr: 11 }
);
}
#[test]
fn pr_index_resolves_a_stem_branch_from_its_round_sibling() {
let idx = PrIndex::from_json(ROUND_ROWS, 400);
assert_eq!(
idx.state_for(Some("feat/other")),
BranchPrState::Merged { pr: 12 }
);
}
#[test]
fn pr_index_does_not_relate_an_unrelated_branch() {
let idx = PrIndex::from_json(ROUND_ROWS, 400);
assert_eq!(idx.state_for(Some("fix/7267-other")), BranchPrState::NoPr);
assert_eq!(
idx.state_for(Some("fix/7267-thing-rework")),
BranchPrState::NoPr
);
}
#[test]
fn pr_index_open_round_sibling_beats_a_merged_one() {
const ROWS_WITH_OPEN: &str = r#"[
{"number": 21, "headRefName": "fix/x", "state": "MERGED"},
{"number": 22, "headRefName": "fix/x-r2", "state": "OPEN"}
]"#;
let idx = PrIndex::from_json(ROWS_WITH_OPEN, 400);
assert_eq!(
idx.state_for(Some("fix/x-r5")),
BranchPrState::Open { pr: 22 }
);
}
#[test]
fn pr_index_reports_a_failed_lookup_with_its_reason() {
let idx = PrIndex::unavailable_because("`gh` exited 4: gh auth login".to_string());
assert_eq!(
idx.state_for(Some("anything")),
BranchPrState::LookupFailed {
reason: "`gh` exited 4: gh auth login".to_string()
}
);
}
#[test]
fn pr_index_malformed_json_is_unavailable() {
for junk in ["", "not json", "{}", "gh: not authenticated"] {
let idx = PrIndex::from_json(junk, 400);
assert!(
matches!(
idx.state_for(Some("feat/x")),
BranchPrState::LookupFailed { .. }
),
"{junk:?} must not yield a usable index"
);
}
}
#[test]
fn pr_index_detached_worktree_is_unknown() {
let idx = PrIndex::from_json(ROWS, 400);
assert_eq!(idx.state_for(None), BranchPrState::Unknown);
}
#[test]
fn pr_index_open_pr_beats_a_merged_one_on_the_same_branch() {
let rows = r#"[
{"number": 1, "headRefName": "feat/reused", "state": "MERGED"},
{"number": 2, "headRefName": "feat/reused", "state": "OPEN"}
]"#;
assert_eq!(
PrIndex::from_json(rows, 400).state_for(Some("feat/reused")),
BranchPrState::Open { pr: 2 }
);
let flipped = r#"[
{"number": 2, "headRefName": "feat/reused", "state": "OPEN"},
{"number": 1, "headRefName": "feat/reused", "state": "MERGED"}
]"#;
assert_eq!(
PrIndex::from_json(flipped, 400).state_for(Some("feat/reused")),
BranchPrState::Open { pr: 2 }
);
}
#[test]
fn pr_index_unrecognised_state_is_not_treated_as_merged() {
let rows = r#"[{"number": 1, "headRefName": "feat/x", "state": "DRAFT_SOMETHING"}]"#;
let idx = PrIndex::from_json(rows, 400);
assert_ne!(
idx.state_for(Some("feat/x")),
BranchPrState::Merged { pr: 1 }
);
}
#[test]
fn gh_command_strips_repository_redirecting_env() {
let cmd = gh_command(
Path::new("/tmp"),
&crate::core::gh_identity::GhEnv::default(),
);
let removed: Vec<&str> = cmd
.get_envs()
.filter(|(_, v)| v.is_none())
.filter_map(|(k, _)| k.to_str())
.collect();
for key in ["GIT_DIR", "GIT_WORK_TREE", "GH_REPO", "GH_HOST"] {
assert!(
removed.contains(&key),
"{key} must be stripped: {removed:?}"
);
}
}
#[test]
fn gh_command_applies_the_resolved_gh_env() {
let env = crate::core::gh_identity::resolve_gh_env(Some(
&crate::core::trusty_tools_config::GithubConfig {
config_dir: Some(PathBuf::from("/cfg/daemon")),
..Default::default()
},
))
.expect("ok");
let cmd = gh_command(Path::new("/tmp"), &env);
let value = cmd
.get_envs()
.find(|(k, _)| *k == "GH_CONFIG_DIR")
.and_then(|(_, v)| v)
.expect("GH_CONFIG_DIR must be set");
assert_eq!(value, "/cfg/daemon");
}
#[test]
fn gh_command_removes_an_inherited_token_when_a_config_dir_binds() {
let env = crate::core::gh_identity::resolve_gh_env(Some(
&crate::core::trusty_tools_config::GithubConfig {
config_dir: Some(PathBuf::from("/cfg/duetto")),
..Default::default()
},
))
.expect("ok");
let cmd = gh_command(Path::new("/tmp"), &env);
let removed: Vec<&str> = cmd
.get_envs()
.filter(|(_, v)| v.is_none())
.filter_map(|(k, _)| k.to_str())
.collect();
for key in [
"GH_TOKEN",
"GITHUB_TOKEN",
"GH_ENTERPRISE_TOKEN",
"GITHUB_ENTERPRISE_TOKEN",
"GH_USER",
] {
assert!(
removed.contains(&key),
"{key} must be removed when a config_dir binds: {removed:?}"
);
}
let value = cmd
.get_envs()
.find(|(k, _)| *k == "GH_CONFIG_DIR")
.and_then(|(_, v)| v)
.expect("GH_CONFIG_DIR must still be set");
assert_eq!(value, "/cfg/duetto");
}
#[test]
fn gh_command_keeps_an_inherited_token_when_nothing_binds() {
let cmd = gh_command(
Path::new("/tmp"),
&crate::core::gh_identity::GhEnv::default(),
);
let removed: Vec<&str> = cmd
.get_envs()
.filter(|(_, v)| v.is_none())
.filter_map(|(k, _)| k.to_str())
.collect();
assert!(
!removed.contains(&"GH_TOKEN"),
"an unbound spawn must inherit GH_TOKEN: {removed:?}"
);
}
#[test]
fn measure_bytes_counts_file_contents() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::write(tmp.path().join("a"), vec![0u8; 100]).expect("write a");
std::fs::create_dir(tmp.path().join("sub")).expect("mkdir");
std::fs::write(tmp.path().join("sub").join("b"), vec![0u8; 50]).expect("write b");
assert_eq!(measure_bytes_until(tmp.path(), None), Some(150));
}
#[test]
fn measure_bytes_of_missing_path_is_none() {
assert_eq!(
measure_bytes_until(Path::new("/nonexistent-2919"), None),
None
);
}
#[test]
fn measure_bytes_stops_at_an_expired_deadline() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::write(tmp.path().join("a"), vec![0u8; 100]).expect("write a");
let expired = std::time::Instant::now() - std::time::Duration::from_secs(1);
assert_eq!(
measure_bytes_until(tmp.path(), Some(expired)),
None,
"an expired deadline must abandon the walk, not measure it"
);
let ample = std::time::Instant::now() + std::time::Duration::from_secs(60);
assert_eq!(measure_bytes_until(tmp.path(), Some(ample)), Some(100));
}
#[test]
fn classify_blocks_a_worktree_trusty_mpm_does_not_own() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree_at(&fx.repo.join("elsewhere"), "unowned-2919");
let v = classify_no_agent(
&path,
Admission::Admitted,
&claim(false),
&merged(9),
&clean,
);
assert!(!v.is_reclaimable(), "{v:?}");
assert!(
reason(&v).contains("not a trusty-mpm-removable worktree"),
"{}",
reason(&v)
);
}
#[test]
fn the_remover_really_refuses_what_tm_provisioned_rejects() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree_at(&fx.repo.join("elsewhere"), "remover-refuses-2919");
assert!(
!tm_provisioned(&path),
"precondition: the classifier rejects this shape"
);
let removed = crate::session_manager::decommission::remove_session_worktree(
&path,
"test",
crate::session_manager::DirtyWorktreePolicy::Skip,
);
assert!(!removed.removed(), "the remover must refuse it too");
assert!(
path.exists(),
"and must leave the directory on disk โ which is exactly why \
classifying it Reclaimable produced a `removal_failed` report"
);
}
#[test]
fn tm_provisioned_matches_the_removers_own_predicate() {
let fx = GitWorktreeFixture::new();
let under_worktrees = fx.add_worktree("convention-2919");
assert!(
tm_provisioned(&under_worktrees),
"the `.worktrees/<name>` convention must be accepted"
);
let parked = fx.add_worktree_at(&fx.repo.join("elsewhere"), "sentinel-2919");
assert!(
!tm_provisioned(&parked),
"no sentinel, not under .worktrees/, not in the harness store"
);
GitWorktreeFixture::stamp_reclaimable_sentinel(&parked);
assert!(
tm_provisioned(&parked),
"an ownership sentinel must be accepted wherever the worktree is parked"
);
let agent_store = fx.add_worktree_at(
&fx.repo.join(".claude").join("worktrees"),
"agent-aa049179cd3e4e1bb",
);
assert!(
tm_provisioned(&agent_store),
"a `.claude/worktrees/agent-*` leaf must be accepted โ it is the dominant \
creation path in a tm-orchestrated session"
);
}
#[test]
fn worktree_7771_a_hand_made_tree_is_reclaimed() {
let fx = GitWorktreeFixture::new();
let path = agent_store_worktree(&fx, "semver-accept-7771");
let v = classify_no_agent(
&path,
Admission::Admitted,
&claim(false),
&merged(759),
&inspect_dirt,
);
assert_eq!(v, ReclaimVerdict::Reclaimable { pr: 759 });
}
#[test]
fn worktree_7771_a_hand_made_tree_with_an_unpushed_commit_is_kept() {
let fx = GitWorktreeFixture::new();
let path = agent_store_worktree(&fx, "handmade-7771unpushed");
std::fs::write(path.join("novel.txt"), "unsaved\n").expect("write");
for args in [
&["add", "novel.txt"][..],
&["commit", "-q", "-m", "novel"][..],
] {
let ok = std::process::Command::new("git")
.current_dir(&path)
.args(args)
.status()
.expect("git");
assert!(ok.success());
}
let v = classify_no_agent(
&path,
Admission::Admitted,
&claim(false),
&merged(759),
&inspect_dirt,
);
assert!(!v.is_reclaimable(), "{v:?}");
assert!(reason(&v).contains("unpushed"), "{}", reason(&v));
}
#[test]
fn worktree_7771_a_published_tree_with_no_pr_is_reclaimed() {
let fx = GitWorktreeFixture::new();
let path = agent_store_worktree(&fx, "published-7771");
let v = classify_no_agent(
&path,
Admission::Admitted,
&claim(false),
&BranchPrState::NoPr,
&inspect_dirt,
);
assert!(v.is_reclaimable(), "{v:?}");
let detached = agent_store_worktree(&fx, "published-7771-detached");
detach(&detached);
let v = classify_no_agent(
&detached,
Admission::Admitted,
&claim(false),
&BranchPrState::Unknown,
&inspect_dirt,
);
assert!(v.is_reclaimable(), "{v:?}");
}
fn detach(path: &Path) {
let ok = std::process::Command::new("git")
.current_dir(path)
.args(["checkout", "-q", "--detach"])
.status()
.expect("git");
assert!(ok.success());
}
#[test]
fn worktree_7771_a_commit_on_no_origin_ref_is_kept() {
let fx = GitWorktreeFixture::new();
let path = agent_store_worktree(&fx, "unpublished-7771");
let before = classify_no_agent(
&path,
Admission::Admitted,
&claim(false),
&BranchPrState::NoPr,
&inspect_dirt,
);
assert!(before.is_reclaimable(), "{before:?}");
GitWorktreeFixture::commit_unpushed(&path);
let v = classify_no_agent(
&path,
Admission::Admitted,
&claim(false),
&BranchPrState::NoPr,
&inspect_dirt,
);
assert!(!v.is_reclaimable(), "{v:?}");
}
#[test]
fn worktree_7771_a_published_tree_of_a_live_foreign_session_is_kept() {
use crate::session_manager::worktree_reclaim_claim::ClaimLiveness;
let fx = GitWorktreeFixture::new();
let path = agent_store_worktree(&fx, "published-foreign-7771");
let owner = GitWorktreeFixture::stamp_agent_sentinel(&path, "published-foreign-7771");
let parent = owner.parent_session_id.0.to_string();
let verdict = |liveness| {
classify(
&path,
Admission::Admitted,
&claim(false),
&BranchPrState::NoPr,
&inspect_dirt,
&no_agents,
&SessionOwners::observed([(parent.clone(), liveness)]),
&KeepList::default(),
)
};
let live = verdict(ClaimLiveness::Live);
assert!(!live.is_reclaimable(), "{live:?}");
assert!(reason(&live).contains("is live"), "{}", reason(&live));
assert!(verdict(ClaimLiveness::SessionGone).is_reclaimable());
}
#[test]
fn worktree_7771_a_detached_unknown_state_does_not_blame_gh() {
let fx = GitWorktreeFixture::new();
let path = agent_store_worktree(&fx, "detached-unpublished-7771");
detach(&path);
GitWorktreeFixture::commit_unpushed(&path);
let v = classify_no_agent(
&path,
Admission::Admitted,
&claim(false),
&BranchPrState::Unknown,
&clean,
);
assert!(!v.is_reclaimable(), "{v:?}");
assert!(reason(&v).contains("detached HEAD"), "{}", reason(&v));
assert!(!reason(&v).contains("gh"), "{}", reason(&v));
}
#[test]
fn worktree_7771_a_stale_harness_lock_is_released() {
let fx = GitWorktreeFixture::new();
let dead = agent_store_worktree(&fx, "agent-7771deadlock");
let mut child = std::process::Command::new("true").spawn().expect("spawn");
let gone = child.id();
child.wait().expect("reap");
fx.harness_lock_worktree_with_pid(&dead, "agent-7771deadlock", gone);
let reused = agent_store_worktree(&fx, "agent-7771reused");
fx.harness_lock_worktree_with_reason(
&reused,
&format!(
"claude agent agent-7771reused (pid {} start Mon Sep 1 20:33:51 2025)",
std::process::id()
),
);
for path in [&dead, &reused] {
let v = classify_no_agent(
path,
Admission::HarnessAgentLock,
&claim(false),
&merged(761),
&clean,
);
assert_eq!(
v,
ReclaimVerdict::Reclaimable { pr: 761 },
"{}",
path.display()
);
}
let live = agent_store_worktree(&fx, "agent-7771livelock");
fx.harness_lock_worktree(&live, "agent-7771livelock");
let v = classify_no_agent(
&live,
Admission::HarnessAgentLock,
&claim(false),
&merged(761),
&clean,
);
assert!(matches!(v, ReclaimVerdict::BlockedByAgent { .. }), "{v:?}");
}
#[test]
fn worktree_7771_the_owner_files_session_decides() {
use crate::session_manager::worktree_reclaim_claim::ClaimLiveness;
let fx = GitWorktreeFixture::new();
let mut verdicts = Vec::new();
for (name, liveness, caller) in [
("agent-7771other", ClaimLiveness::Live, false),
("agent-7771mine", ClaimLiveness::Live, true),
("agent-7771ended", ClaimLiveness::SessionGone, false),
] {
let path = agent_store_worktree(&fx, name);
let owner = GitWorktreeFixture::stamp_agent_sentinel(&path, name);
let claude = owner.parent_session_id.0.to_string();
let owners = SessionOwners::observed([("managed-x".to_string(), liveness)])
.with_aliases([(claude, "managed-x".to_string())])
.with_caller(caller.then(|| "managed-x".to_string()));
verdicts.push(classify(
&path,
Admission::Admitted,
&claim(false),
&merged(762),
&clean,
&no_agents,
&owners,
&KeepList::default(),
));
}
assert!(!verdicts[0].is_reclaimable(), "{:?}", verdicts[0]);
assert!(
reason(&verdicts[0]).contains("is live"),
"{}",
reason(&verdicts[0])
);
assert_eq!(verdicts[1], ReclaimVerdict::Reclaimable { pr: 762 });
assert_eq!(verdicts[2], ReclaimVerdict::Reclaimable { pr: 762 });
}
#[test]
fn classify_allows_a_merged_agent_tree_the_harness_released() {
let fx = GitWorktreeFixture::new();
let path = agent_store_worktree(&fx, "agent-6561released");
let owner = GitWorktreeFixture::stamp_agent_sentinel(&path, "agent-6561released");
let v = classify(
&path,
Admission::Admitted,
&claim(false),
&merged(759),
&clean,
&no_agents,
&GitWorktreeFixture::parent_ended(&owner),
&KeepList::default(),
);
assert_eq!(
v,
ReclaimVerdict::Reclaimable { pr: 759 },
"a released, attributed, merged, clean agent tree must be reclaimable"
);
}
#[test]
fn classify_blocks_an_agent_tree_git_cannot_be_asked_about() {
let fx = GitWorktreeFixture::new();
let path = agent_store_worktree(&fx, "agent-6561unaskable");
GitWorktreeFixture::stamp_agent_sentinel(&path, "agent-6561unaskable");
let _restore = deny_all(&path.join(".git"));
let v = classify_no_agent(
&path,
Admission::Admitted,
&claim(false),
&merged(759),
&clean,
);
assert!(!v.is_reclaimable(), "two silences are not an answer: {v:?}");
}
#[test]
fn classify_discloses_a_harness_locked_agent_tree_as_agent_owned() {
let v = classify_no_agent(
&wt(),
Admission::HarnessAgentLock,
&claim(false),
&merged(760),
&clean,
);
assert!(
matches!(v, ReclaimVerdict::BlockedByAgent { .. }),
"a harness lock must be disclosed as an agent refusal, not an operator one: {v:?}"
);
assert!(reason(&v).starts_with(Admission::HarnessAgentLock.reason()));
}
#[test]
fn a_dirty_agent_store_worktree_is_still_refused() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree_at(
&fx.repo.join(".claude").join("worktrees"),
"agent-6561dirty",
);
let v = classify_no_agent(
&path,
Admission::Admitted,
&claim(false),
&merged(759),
&dirty,
);
assert!(
!v.is_reclaimable(),
"unsaved work outranks a merged PR, in the harness store as anywhere: {v:?}"
);
}
#[test]
fn an_unreadable_agent_sentinel_still_blocks_an_agent_store_worktree() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree_at(
&fx.repo.join(".claude").join("worktrees"),
"agent-6561garbage",
);
crate::session_manager::worktree_ownership_location::write_sentinel_bytes(&path, b"{not json")
.expect("write garbage sentinel");
let v = classify_no_agent(
&path,
Admission::Admitted,
&claim(false),
&merged(759),
&clean,
);
assert!(!v.is_reclaimable(), "{v:?}");
assert!(
reason(&v).contains("does not parse"),
"the refusal names the unparsable owner file: {}",
reason(&v)
);
}
#[test]
fn pr_index_skips_fork_pull_requests() {
let rows = r#"[{"number": 1, "headRefName": "feat/x", "state": "MERGED",
"isCrossRepository": true}]"#;
let idx = PrIndex::from_json(rows, 400);
assert_eq!(
idx.state_for(Some("feat/x")),
BranchPrState::NoPr,
"a fork PR must not make a local branch look merged"
);
assert!(
!classify_no_agent(
&wt(),
Admission::Admitted,
&claim(false),
&idx.state_for(Some("feat/x")),
&clean
)
.is_reclaimable(),
"and it must not authorise a delete"
);
}
#[test]
fn pr_index_keeps_a_same_repo_pull_request() {
let rows = r#"[{"number": 1, "headRefName": "feat/x", "state": "MERGED",
"isCrossRepository": false}]"#;
assert_eq!(
PrIndex::from_json(rows, 400).state_for(Some("feat/x")),
BranchPrState::Merged { pr: 1 }
);
}
#[test]
fn run_with_timeout_captures_output() {
let mut cmd = std::process::Command::new("echo");
cmd.arg("hello");
let out = run_with_timeout(cmd, std::time::Duration::from_secs(5))
.expect("a fast command must complete");
assert_eq!(out.trim(), "hello");
}
#[test]
fn run_with_timeout_reports_the_exit_code_and_stderr() {
let mut cmd = std::process::Command::new("sh");
cmd.args(["-c", "echo 'gh auth login' >&2; exit 4"]);
let err = run_with_timeout(cmd, std::time::Duration::from_secs(5))
.expect_err("a non-zero exit must be an error")
.to_string();
assert!(err.contains("exited 4"), "{err}");
assert!(err.contains("gh auth login"), "{err}");
}
#[test]
fn run_with_timeout_reports_a_spawn_failure() {
let cmd = std::process::Command::new("definitely-not-a-real-binary-6561");
let err = run_with_timeout(cmd, std::time::Duration::from_secs(5))
.expect_err("an unspawnable command must be an error");
assert!(err.to_string().contains("could not be run"), "{err}");
assert!(
!err.timed_out(),
"a spawn failure is not a hang and must not count toward the backoff (#6867)"
);
}
#[test]
fn run_with_timeout_kills_a_hung_child() {
let mut cmd = std::process::Command::new("sleep");
cmd.arg("30");
let started = std::time::Instant::now();
let result = run_with_timeout(cmd, std::time::Duration::from_millis(300));
assert!(result.is_err(), "a timed-out child must report failure");
assert!(
started.elapsed() < std::time::Duration::from_secs(10),
"the timeout must actually fire; took {:?}",
started.elapsed()
);
}
#[test]
fn run_with_timeout_marks_a_timeout_as_timed_out() {
let mut cmd = std::process::Command::new("sleep");
cmd.arg("30");
let err = run_with_timeout(cmd, std::time::Duration::from_millis(200))
.expect_err("a hung child must fail");
assert!(err.timed_out(), "{err}");
assert!(err.to_string().contains("process group"), "{err}");
}
#[test]
fn run_with_timeout_kills_the_whole_process_group() {
let tmp = tempfile::tempdir().expect("tempdir");
let pidfile = tmp.path().join("grandchild.pid");
let script = format!(
"sleep 300 >/dev/null 2>&1 & echo $! > {}; sleep 300",
pidfile.display()
);
let mut cmd = std::process::Command::new("sh");
cmd.args(["-c", &script]);
let err = run_with_timeout(cmd, std::time::Duration::from_millis(500))
.expect_err("a hung child must time out");
assert!(err.timed_out(), "{err}");
let pid: i32 = std::fs::read_to_string(&pidfile)
.expect("the shell must have recorded its background child's pid")
.trim()
.parse()
.expect("a pid");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while alive(pid) && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(50));
}
let still_there = alive(pid);
if still_there {
unsafe { libc::kill(pid, libc::SIGKILL) };
}
assert!(
!still_there,
"the grandchild (pid {pid}) survived its parent's timeout โ the process group \
was not killed (#6867)"
);
}
#[cfg(unix)]
fn alive(pid: i32) -> bool {
unsafe { libc::kill(pid, 0) == 0 }
}
#[test]
fn gh_failure_displays_the_resolved_identity() {
let bare = GhFailure::new("`gh` exited 4: auth");
assert_eq!(bare.to_string(), "`gh` exited 4: auth");
assert!(
bare.identity().contains("never spawned"),
"{}",
bare.identity()
);
let attributed = GhFailure::new("`gh` exited 4: auth").with_identity("GH_CONFIG_DIR=/cfg");
assert_eq!(
attributed.to_string(),
"`gh` exited 4: auth (resolved gh identity: GH_CONFIG_DIR=/cfg)"
);
assert_eq!(attributed.identity(), "GH_CONFIG_DIR=/cfg");
}
#[test]
fn ambient_gh_env_is_reported_as_keychain_bound() {
assert!(consults_the_keychain(
&crate::core::gh_identity::GhEnv::default()
));
}
#[test]
fn a_config_dir_binding_avoids_the_keychain() {
let env = crate::core::gh_identity::resolve_gh_env(Some(
&crate::core::trusty_tools_config::GithubConfig {
config_dir: Some(PathBuf::from("/cfg/daemon")),
..Default::default()
},
))
.expect("ok");
assert!(!consults_the_keychain(&env));
}
#[test]
fn the_keychain_warning_names_both_bindings() {
let w = keychain_warning();
assert!(w.contains("token_env"), "{w}");
assert!(w.contains("config_dir"), "{w}");
assert!(w.contains("securityd"), "{w}");
}
#[test]
fn pr_state_for_branch_reports_a_failed_call_as_lookup_failed() {
let tmp = tempfile::tempdir().expect("tempdir");
let state = pr_state_for_branch(tmp.path(), "feat/whatever");
assert!(
matches!(state, BranchPrState::LookupFailed { .. }),
"a broken lookup must be distinguishable from an unanswerable one; got {state:?}"
);
}
#[test]
fn pr_index_resolves_a_squash_merged_pr_whose_head_branch_was_deleted() {
let reply = r#"[{"headRefName":"fix/6550-index-id-guard","isCrossRepository":false,
"number":6573,"state":"MERGED"}]"#;
let idx = PrIndex::from_json(reply, 50);
assert_eq!(
idx.state_for(Some("fix/6550-index-id-guard")),
BranchPrState::Merged { pr: 6573 }
);
}
#[test]
fn classify_blocks_a_failed_lookup_and_names_the_reason() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("lookup-failed-6561");
land(&path);
let verdict = classify(
&path,
Admission::Admitted,
&claim(false),
&BranchPrState::LookupFailed {
reason: "`gh` exited 4: gh auth login".to_string(),
},
&clean,
&no_agents,
&SessionOwners::default(),
&KeepList::default(),
);
assert!(!verdict.is_reclaimable());
let ReclaimVerdict::Blocked { reason, .. } = verdict else {
panic!("a failed lookup is an ordinary block, not an agent refusal");
};
assert!(reason.contains("lookup failed"), "{reason}");
assert!(reason.contains("gh auth login"), "{reason}");
}
#[test]
fn gh_command_passes_no_dash_c_flag() {
let cmd = gh_command(
Path::new("/tmp"),
&crate::core::gh_identity::GhEnv::default(),
);
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(
!args.iter().any(|a| a == "-C"),
"`gh` has no -C flag (verified against gh 2.96.0: `unknown shorthand \
flag: 'C' in -C`); argv was {args:?}"
);
assert!(
args.is_empty(),
"gh_command must contribute no positional args of its own; got {args:?}"
);
}
#[test]
fn gh_command_runs_in_the_requested_directory() {
let cmd = gh_command(
Path::new("/tmp"),
&crate::core::gh_identity::GhEnv::default(),
);
assert_eq!(cmd.get_current_dir(), Some(Path::new("/tmp")));
}
#[test]
fn pr_index_from_gh_reads_this_repository() {
const UPSTREAM: &str = "bobmatnyc/trusty-tools";
if which::which("gh").is_err() {
eprintln!("skipping: `gh` not on PATH");
return;
}
let repo_root = trusty_common::test_harness::test_repo_root().expect("resolve the checkout");
let mut probe = std::process::Command::new("gh");
probe.current_dir(&repo_root).args([
"repo",
"view",
"--json",
"nameWithOwner",
"-q",
".nameWithOwner",
]);
let resolved = match run_with_timeout(probe, std::time::Duration::from_secs(10)) {
Ok(out) => out.trim().to_string(),
Err(reason) => {
eprintln!("skipping: `gh` cannot resolve a repository here โ {reason}");
return;
}
};
if resolved != UPSTREAM {
eprintln!("skipping: resolved {resolved:?}, not the upstream {UPSTREAM:?}");
return;
}
let index = PrIndex::from_gh(&repo_root);
const ABSENT: &str = "trusty-tools-7038/no-such-branch";
if let BranchPrState::LookupFailed { reason } = index.state_for(Some(ABSENT)) {
eprintln!("skipping: `gh pr list` failed โ {reason}");
return;
}
assert!(
index.branch_count() > 0,
"a successful `gh pr list` against {UPSTREAM} must yield branches; an empty \
index here means the call failed and every branch will block โ which is \
exactly what the `gh -C` argv bug produced"
);
}
#[test]
fn classify_blocks_a_keep_listed_worktree() {
let keeps = KeepList::from_patterns(&["/tmp/.worktrees".to_string()]);
let v = classify(
&wt(),
Admission::Admitted,
&ClaimState::Unclaimed,
&merged(42),
&clean,
&no_agents,
&SessionOwners::default(),
&keeps,
);
assert!(
matches!(
&v,
ReclaimVerdict::Blocked {
gate: ReclaimGate::KeepList,
..
}
),
"a keep-listed worktree must be blocked at gate 0: {v:?}"
);
let reason = reason(&v);
assert!(
reason.contains("/tmp/.worktrees"),
"the refusal must name the operator's own entry: {reason}"
);
}
#[test]
fn classify_keep_list_outranks_a_merged_clean_worktree() {
let control = classify(
&wt(),
Admission::Admitted,
&ClaimState::Unclaimed,
&merged(42),
&clean,
&no_agents,
&SessionOwners::default(),
&KeepList::default(),
);
assert_eq!(
control,
ReclaimVerdict::Reclaimable { pr: 42 },
"CONTROL: without a keep-list this worktree must be reclaimable"
);
let kept = classify(
&wt(),
Admission::Admitted,
&ClaimState::Unclaimed,
&merged(42),
&clean,
&no_agents,
&SessionOwners::default(),
&KeepList::from_patterns(&["**/worktree-2919".to_string()]),
);
assert!(
!kept.is_reclaimable(),
"a keep-list glob must outrank a merged, clean, unclaimed worktree: {kept:?}"
);
}
#[test]
fn worktree_7652_an_unread_owner_map_refuses() {
let fx = GitWorktreeFixture::new();
let path = fx.add_worktree("session-owned-unread-7652");
land(&path);
GitWorktreeFixture::stamp_reclaimable_sentinel(&path);
let v = classify(
&path,
Admission::Admitted,
&claim(false),
&merged(107),
&inspect_dirt,
&no_agents,
&SessionOwners::default(),
&KeepList::default(),
);
assert!(
matches!(
v,
ReclaimVerdict::Blocked {
gate: ReclaimGate::SessionOwnership,
..
}
),
"{v:?}"
);
assert!(reason(&v).contains("was not read"), "{}", reason(&v));
}
#[test]
fn worktree_7652_a_held_harness_lock_outranks_an_ended_sentinel_session() {
let fx = GitWorktreeFixture::new();
let owners = GitWorktreeFixture::reclaimable_owner_gone();
let held = agent_store_worktree(&fx, "agent-7652held");
GitWorktreeFixture::stamp_reclaimable_sentinel(&held);
fx.harness_lock_worktree(&held, "agent-7652held");
let refusal = agent_ownership_blocks(&held, &no_agents, &owners)
.expect("a held harness lock must refuse although the sentinel's session ended");
assert!(refusal.contains("agent-lifetime lock"), "{refusal}");
let v = classify(
&held,
Admission::Admitted,
&claim(false),
&merged(7652),
&clean,
&agent_ended,
&owners,
&KeepList::default(),
);
assert!(
matches!(
v,
ReclaimVerdict::BlockedByAgent {
gate: ReclaimGate::AgentOwnership,
..
}
),
"{v:?}"
);
let released = agent_store_worktree(&fx, "agent-7652released");
GitWorktreeFixture::stamp_reclaimable_sentinel(&released);
assert_eq!(
agent_ownership_blocks(&released, &agent_ended, &owners),
None
);
}