use std::path::Path;
use super::reclaim_landed_content;
use crate::core::worktree_landed_content::LandedContent;
use crate::session_manager::worktree_git_fixture::GitWorktreeFixture;
use crate::session_manager::worktree_ownership::{AgentDelegationState, AgentWorktreeOwner};
use crate::session_manager::worktree_reclaim::{
BranchPrState, KeepList, LiveClaims, PrIndex, ReclaimVerdict,
};
use crate::session_manager::worktree_reclaim_sweep::{
SurveyBudget, recheck_before_delete, survey_with_landed_content,
};
fn no_agents(_: &AgentWorktreeOwner) -> AgentDelegationState {
AgentDelegationState::Unknown
}
fn unrelated_index(_: &Path) -> PrIndex {
PrIndex::from_json(
r#"[{"number": 72, "headRefName": "session/somebody-else", "state": "MERGED"}]"#,
400,
)
}
fn donor(name: &str) -> (GitWorktreeFixture, std::path::PathBuf) {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree(name);
fx.land_with_sibling_work(&wt, &format!("{name}.txt"), &format!("{name}-r2.txt"));
(fx, wt)
}
#[test]
fn worktree_7889_the_sweep_admits_a_real_donor_branch() {
let (fx, wt) = donor("donor-7889");
let s = survey_with_landed_content(
&fx.repos_root,
&LiveClaims::default(),
&unrelated_index,
&no_agents,
SurveyBudget::default(),
false,
&KeepList::default(),
&[],
Some(&reclaim_landed_content),
);
let found = s
.candidates
.iter()
.find(|c| c.path == wt)
.unwrap_or_else(|| panic!("survey missed {}", wt.display()));
assert!(
matches!(
found.verdict,
ReclaimVerdict::ReclaimableLandedContent { .. }
),
"a donor tree whose content is on origin/main must be reclaimable: {:?}",
found.verdict
);
}
#[test]
fn worktree_7889_the_sweep_probe_refuses_an_uncommitted_file() {
let (_fx, wt) = donor("donor-dirty-7889");
std::fs::write(wt.join("notes.md"), "never committed\n").expect("write untracked file");
let verdict = reclaim_landed_content(&wt);
assert!(
!verdict.admits(),
"an uncommitted file must refuse: {verdict:?}"
);
assert!(verdict.note().contains("uncommitted"), "{}", verdict.note());
let tmp = tempfile::tempdir().expect("tempdir");
assert!(!reclaim_landed_content(tmp.path()).admits());
}
#[test]
fn worktree_7889_the_recheck_admits_a_landed_tree_with_no_pull_request() {
let (_fx, wt) = donor("donor-recheck-7889");
assert_eq!(
recheck_before_delete(
&wt,
&KeepList::default(),
Some(&LiveClaims::default()),
&BranchPrState::NoPr,
&no_agents,
Some(&reclaim_landed_content),
),
None
);
}
#[test]
fn worktree_7889_the_recheck_refuses_a_tree_no_longer_landed() {
let (_fx, wt) = donor("donor-residue-7889");
GitWorktreeFixture::commit_unpushed(&wt);
let reason = recheck_before_delete(
&wt,
&KeepList::default(),
Some(&LiveClaims::default()),
&BranchPrState::NoPr,
&no_agents,
Some(&reclaim_landed_content),
)
.expect("residue must refuse");
assert!(reason.contains("landed-content"), "{reason}");
assert!(reason.contains("unpushed.txt"), "{reason}");
let unoffered = recheck_before_delete(
&wt,
&KeepList::default(),
Some(&LiveClaims::default()),
&BranchPrState::NoPr,
&no_agents,
None,
)
.expect("no probe offered must refuse");
assert!(unoffered.contains("no longer a merge"), "{unoffered}");
}
fn git(dir: &Path, args: &[&str]) {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("fixture: git could not be run");
assert!(
out.status.success(),
"fixture: `git {}` failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn worktree_7889_a_session_branch_commit_head_cannot_reach_refuses() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("moved-7889");
GitWorktreeFixture::commit_unpushed(&wt);
let orphan = std::process::Command::new("git")
.arg("-C")
.arg(&wt)
.args(["rev-parse", "HEAD"])
.output()
.expect("rev-parse");
let orphan = String::from_utf8_lossy(&orphan.stdout).trim().to_string();
git(&wt, &["switch", "-c", "donor-moved", "origin/main"]);
let s = survey_with_landed_content(
&fx.repos_root,
&LiveClaims::default(),
&unrelated_index,
&no_agents,
SurveyBudget::default(),
false,
&KeepList::default(),
&[],
Some(&reclaim_landed_content),
);
let found = s
.candidates
.iter()
.find(|c| c.path == wt)
.unwrap_or_else(|| panic!("survey missed {}", wt.display()));
assert!(
!found.verdict.is_reclaimable(),
"a commit only session/moved-7889 holds must refuse: {:?}",
found.verdict
);
let shown = format!("{:?}", found.verdict);
assert!(shown.contains("`session/moved-7889`"), "{shown}");
assert_eq!(orphan.len(), 40, "premise: a full sha: {orphan}");
assert!(shown.contains(&orphan), "the first unlanded sha: {shown}");
}
#[test]
fn worktree_7889_dirt_that_appears_during_the_admission_refuses() {
let landed = || -> crate::core::worktree_landed_content::LandingAdmission {
LandedContent::Landed {
base: "origin/main".into(),
base_sha: "0".repeat(40),
landed_at: None,
}
.into()
};
let (_fx, wt) = donor("donor-late-file-7889");
let late_file = super::reclaim_landed_content_with(&wt, &|p| {
std::fs::write(p.join("late.txt"), "written mid-admission\n").expect("write");
landed()
});
assert!(!late_file.admission.admits(), "{late_file:?}");
assert!(
late_file.admission.note().contains("appeared while"),
"{}",
late_file.admission.note()
);
let (_fx2, wt2) = donor("donor-late-commit-7889");
let late_commit = super::reclaim_landed_content_with(&wt2, &|p| {
GitWorktreeFixture::commit_unpushed(p);
landed()
});
assert!(!late_commit.admission.admits(), "{late_commit:?}");
assert!(
late_commit.admission.note().contains("HEAD moved"),
"{}",
late_commit.admission.note()
);
}
#[test]
fn worktree_7889_the_recheck_admits_a_merged_donor_with_commits_only_dirt() {
let (_fx, wt) = donor("donor-merged-7889");
let merged = BranchPrState::Merged { pr: 8328 };
assert_eq!(
recheck_before_delete(
&wt,
&KeepList::default(),
Some(&LiveClaims::default()),
&merged,
&no_agents,
Some(&reclaim_landed_content),
),
None
);
let unoffered = recheck_before_delete(
&wt,
&KeepList::default(),
Some(&LiveClaims::default()),
&merged,
&no_agents,
None,
)
.expect("no probe offered keeps the pre-#7889 refusal");
assert!(unoffered.contains("unpushed"), "{unoffered}");
}
fn published_elsewhere(fx: &GitWorktreeFixture, name: &str) -> std::path::PathBuf {
let wt = fx.add_worktree_at(&fx.repo.join(".claude").join("worktrees"), name);
std::fs::write(wt.join("published.txt"), "on origin\n").expect("write");
git(&wt, &["add", "published.txt"]);
git(&wt, &["commit", "-q", "-m", "published elsewhere"]);
let refspec = format!("HEAD:refs/heads/vc/{name}");
git(&wt, &["push", "-q", "origin", &refspec]);
git(&wt, &["fetch", "-q", "origin"]);
wt
}
#[test]
fn worktree_7771_published_needs_every_commit_on_some_origin_ref() {
use super::{landing_recheck, published_verdict};
use crate::session_manager::worktree_safety::inspect_dirt;
let fx = GitWorktreeFixture::new();
let wt = published_elsewhere(&fx, "published-elsewhere-7771");
assert!(
published_verdict(&wt, &inspect_dirt).is_some(),
"every commit is on origin/vc/published-elsewhere-7771"
);
assert_eq!(landing_recheck(&wt, &BranchPrState::NoPr, None), None);
GitWorktreeFixture::commit_unpushed(&wt);
assert!(
published_verdict(&wt, &|_| None).is_none(),
"one commit on origin and one local-only is not landed"
);
assert!(landing_recheck(&wt, &BranchPrState::NoPr, None).is_some());
}
#[test]
fn worktree_7771_a_failed_origin_count_refuses() {
use super::{landing_recheck, published_verdict};
use crate::core::worktree_removal_facts::LOCAL_ONLY_COMMITS_ARGS;
use crate::session_manager::worktree_safety::git_stdout;
let fx = GitWorktreeFixture::new();
let wt = published_elsewhere(&fx, "count-fails-7771");
assert!(published_verdict(&wt, &|_| None).is_some(), "precondition");
git(&wt, &["symbolic-ref", "HEAD", "refs/heads/unborn-7771"]);
assert!(
git_stdout(&wt, LOCAL_ONLY_COMMITS_ARGS).is_err(),
"the count must fail for this test to reach the error arm"
);
assert!(published_verdict(&wt, &|_| None).is_none());
assert!(landing_recheck(&wt, &BranchPrState::Unknown, None).is_some());
let plain = tempfile::tempdir().expect("tempdir");
assert!(published_verdict(plain.path(), &|_| None).is_none());
}
fn detached_donor(name: &str) -> (GitWorktreeFixture, std::path::PathBuf) {
let (fx, wt) = donor(name);
git(&wt, &["checkout", "-q", "--detach"]);
git(&wt, &["branch", "-q", "-D", &format!("session/{name}")]);
(fx, wt)
}
fn survey_verdict(
fx: &GitWorktreeFixture,
wt: &Path,
index: &dyn Fn(&Path) -> PrIndex,
probe: &dyn Fn(&Path) -> crate::core::worktree_landed_content::LandingAdmission,
) -> (BranchPrState, ReclaimVerdict) {
survey_verdict_with(fx, wt, index, probe, false)
}
fn survey_verdict_with(
fx: &GitWorktreeFixture,
wt: &Path,
index: &dyn Fn(&Path) -> PrIndex,
probe: &dyn Fn(&Path) -> crate::core::worktree_landed_content::LandingAdmission,
fallback: bool,
) -> (BranchPrState, ReclaimVerdict) {
let s = survey_with_landed_content(
&fx.repos_root,
&LiveClaims::default(),
index,
&no_agents,
SurveyBudget::default(),
fallback,
&KeepList::default(),
&[],
Some(probe),
);
let found = s
.candidates
.into_iter()
.find(|c| c.path == wt)
.unwrap_or_else(|| panic!("survey missed {}", wt.display()));
(found.pr, found.verdict)
}
#[test]
fn worktree_8721_the_sweep_admits_a_detached_head_whose_content_landed() {
let (fx, wt) = detached_donor("detached-8721");
let (pr, verdict) = survey_verdict(&fx, &wt, &unrelated_index, &reclaim_landed_content);
assert_eq!(pr, BranchPrState::Unknown, "fixture: detached");
assert!(
matches!(verdict, ReclaimVerdict::ReclaimableLandedContent { .. }),
"{verdict:?}"
);
}
#[test]
fn worktree_8721_the_sweep_refuses_a_detached_head_holding_residue() {
let (fx, wt) = detached_donor("detached-residue-8721");
GitWorktreeFixture::commit_unpushed(&wt);
let (_, verdict) = survey_verdict(&fx, &wt, &unrelated_index, &reclaim_landed_content);
assert!(!verdict.is_reclaimable(), "{verdict:?}");
let shown = format!("{verdict:?}");
assert!(shown.contains("landed-content"), "{shown}");
assert!(shown.contains("unpushed.txt"), "{shown}");
}
#[test]
fn worktree_8721_an_unknown_state_on_a_branch_is_still_refused() {
let (fx, wt) = donor("truncated-8721");
let truncated = |_: &Path| {
PrIndex::from_json(
r#"[{"number": 72, "headRefName": "session/somebody-else", "state": "MERGED"}]"#,
1,
)
};
let landed = |_: &Path| -> crate::core::worktree_landed_content::LandingAdmission {
LandedContent::Landed {
base: "origin/main".into(),
base_sha: "0".repeat(40),
landed_at: None,
}
.into()
};
let (pr, verdict) = survey_verdict(&fx, &wt, &truncated, &landed);
assert_eq!(pr, BranchPrState::Unknown, "fixture: truncated index");
assert!(!verdict.is_reclaimable(), "{verdict:?}");
}
#[test]
fn worktree_8721_the_recheck_admits_a_detached_head_whose_content_landed() {
let (_fx, wt) = detached_donor("detached-recheck-8721");
let recheck = |probe| {
let unknown = BranchPrState::Unknown;
let claims = LiveClaims::default();
recheck_before_delete(
&wt,
&KeepList::default(),
Some(&claims),
&unknown,
&no_agents,
probe,
)
};
assert_eq!(recheck(Some(&reclaim_landed_content)), None);
assert!(
recheck(None).is_some(),
"no probe offered keeps the refusal"
);
}
#[test]
fn worktree_8721_a_failed_commit_search_never_reclaims_a_detached_head() {
let (fx, wt) = detached_donor("search-failed-8721");
let (pr, verdict) =
survey_verdict_with(&fx, &wt, &unrelated_index, &reclaim_landed_content, true);
assert!(!verdict.is_reclaimable(), "{verdict:?}");
assert!(
matches!(pr, BranchPrState::LookupFailed { .. }),
"the search did not answer: {pr:?}"
);
assert!(
!format!("{verdict:?}").contains("landed-content"),
"{verdict:?}"
);
let claims = LiveClaims::default();
let recheck = |p: &BranchPrState| {
let offered: &dyn Fn(&Path) -> _ = &reclaim_landed_content;
recheck_before_delete(
&wt,
&KeepList::default(),
Some(&claims),
p,
&no_agents,
Some(offered),
)
};
assert!(recheck(&pr).is_some(), "the re-check refuses it too");
let published = published_elsewhere(&fx, "published-detached-8721");
git(&published, &["checkout", "-q", "--detach"]);
assert_eq!(super::landing_recheck(&published, &pr, None), None);
}