use std::cell::RefCell;
use std::path::{Path, PathBuf};
use super::CleanupRequest;
use super::driver::{ClaimEnder, CmdOut, Gh, Git, Landing};
use super::plan::{
AGENT_BRANCH_PREFIX, MergeCommit, PrView, StepLine, StepStatus, agent_branches_at,
merge_refusal, parse_worktree_list, worktree_targets,
};
use super::registry::{CleanupRegistry, OpenedPr};
use super::{ClaimOwnership, CleanupReport, DirtProbe, HolderState, run_with};
async fn run<G: Gh, T: Git, C: ClaimEnder + ClaimOwnership>(
gh: &G,
git: &T,
claims: &C,
landing: &dyn Landing,
probe_dirt: DirtProbe<'_>,
req: &CleanupRequest,
) -> CleanupReport {
run_with(gh, git, claims, landing, probe_dirt, req, claims).await
}
use crate::session_manager::DirtyWorktree;
use crate::session_manager::worktree_reclaim::BranchPrState;
use crate::session_manager::worktree_reclaim_pr_match::worktree_reclaim_pr_match_tests::FakeProbe;
struct Scripted {
routes: Vec<(String, CmdOut)>,
seen: RefCell<Vec<String>>,
removed: RefCell<Vec<PathBuf>>,
}
impl Scripted {
fn new() -> Self {
Self {
routes: Vec::new(),
seen: RefCell::new(Vec::new()),
removed: RefCell::new(Vec::new()),
}
}
fn on(mut self, needle: &str, stdout: &str) -> Self {
self.routes.push((
needle.to_string(),
CmdOut {
success: true,
stdout: stdout.to_string(),
stderr: String::new(),
},
));
self
}
fn on_fail(mut self, needle: &str, stderr: &str) -> Self {
self.routes.push((
needle.to_string(),
CmdOut {
success: false,
stdout: String::new(),
stderr: stderr.to_string(),
},
));
self
}
fn answer(&self, joined: &str) -> anyhow::Result<CmdOut> {
self.seen.borrow_mut().push(joined.to_string());
for (needle, out) in &self.routes {
if joined.contains(needle.as_str()) {
return Ok(out.clone());
}
}
anyhow::bail!("Scripted: no route for `{joined}`")
}
fn calls(&self) -> Vec<String> {
self.seen.borrow().clone()
}
}
impl Gh for Scripted {
fn run(&self, args: &[String]) -> anyhow::Result<CmdOut> {
self.answer(&format!("gh {}", args.join(" ")))
}
}
impl Scripted {
fn head_of(&self, dir: &Path) -> anyhow::Result<CmdOut> {
let listing = self
.routes
.iter()
.find(|(needle, _)| needle.contains("worktree list"))
.map(|(_, out)| out.stdout.clone())
.unwrap_or_default();
let head = parse_worktree_list(&listing)
.into_iter()
.find(|e| e.path == dir)
.map(|e| e.head);
match head {
Some(h) => Ok(CmdOut {
success: true,
stdout: format!("{h}\n"),
stderr: String::new(),
}),
None => anyhow::bail!("Scripted: no worktree listed at {}", dir.display()),
}
}
}
impl Git for Scripted {
fn run(&self, dir: &Path, args: &[String]) -> anyhow::Result<CmdOut> {
let joined = format!("git {}", args.join(" "));
if args == ["rev-parse", "HEAD"] {
self.seen.borrow_mut().push(joined);
return self.head_of(dir);
}
if let [verb, flag, branch] = args
&& verb == "branch"
&& flag == "-D"
&& let Some(holder) = self.holder_of(branch)
{
self.seen.borrow_mut().push(joined);
return Ok(CmdOut {
success: false,
stdout: String::new(),
stderr: format!(
"error: cannot delete branch '{branch}' used by worktree at '{}'",
holder.display()
),
});
}
let out = self.answer(&joined)?;
if out.success
&& let [verb, sub, path] = args
&& verb == "worktree"
&& sub == "remove"
{
self.removed.borrow_mut().push(PathBuf::from(path));
}
Ok(out)
}
}
impl Scripted {
fn holder_of(&self, branch: &str) -> Option<PathBuf> {
let listing = self
.routes
.iter()
.find(|(needle, out)| needle.contains("worktree list") && out.success)
.map(|(_, out)| out.stdout.clone())
.unwrap_or_default();
let removed = self.removed.borrow();
parse_worktree_list(&listing)
.into_iter()
.find(|e| e.branch.as_deref() == Some(branch) && !removed.contains(&e.path))
.map(|e| e.path)
}
}
struct FakeClaims {
holders: Vec<String>,
ended: std::sync::Mutex<Vec<String>>,
live: Vec<String>,
refuse: Option<String>,
per_path: Vec<(PathBuf, Vec<String>)>,
claims_script: std::sync::Mutex<std::collections::VecDeque<Result<Vec<String>, String>>>,
gate_script: std::sync::Mutex<std::collections::VecDeque<Result<(), String>>>,
lock_refuse: Option<String>,
released: std::sync::Mutex<Vec<PathBuf>>,
}
impl FakeClaims {
fn none() -> Self {
Self::held(&[])
}
fn held_by(id: &str) -> Self {
Self::held(&[id])
}
fn held(ids: &[&str]) -> Self {
Self {
holders: ids.iter().map(|s| (*s).to_string()).collect(),
ended: std::sync::Mutex::new(Vec::new()),
live: Vec::new(),
refuse: None,
per_path: Vec::new(),
claims_script: Default::default(),
gate_script: Default::default(),
lock_refuse: None,
released: Default::default(),
}
}
fn claims_then(self, answers: Vec<Result<Vec<String>, String>>) -> Self {
*self.claims_script.lock().expect("claims script") = answers.into();
self
}
fn gate_then(self, answers: Vec<Result<(), String>>) -> Self {
*self.gate_script.lock().expect("gate script") = answers.into();
self
}
}
#[async_trait::async_trait]
impl ClaimEnder for FakeClaims {
async fn claims_on(&self, path: &Path) -> anyhow::Result<Vec<String>> {
if let Some(next) = self
.claims_script
.lock()
.expect("claims script")
.pop_front()
{
return next.map_err(|e| anyhow::anyhow!(e));
}
if self.per_path.is_empty() {
return Ok(self.holders.clone());
}
Ok(self
.per_path
.iter()
.find(|(p, _)| p == path)
.map(|(_, ids)| ids.clone())
.unwrap_or_default())
}
async fn end_claim(&self, id: &str) -> anyhow::Result<()> {
self.ended.lock().expect("claims lock").push(id.to_string());
Ok(())
}
}
#[async_trait::async_trait]
impl ClaimOwnership for FakeClaims {
async fn holder_state(&self, id: &str) -> HolderState {
if self.live.iter().any(|l| l == id) {
HolderState::Live
} else {
HolderState::Ended
}
}
async fn tree_gate(&self, _path: &Path) -> Result<(), String> {
if let Some(next) = self.gate_script.lock().expect("gate script").pop_front() {
return next;
}
self.refuse.clone().map_or(Ok(()), Err)
}
async fn release_stale_lock(&self, path: &Path) -> Result<(), String> {
self.released
.lock()
.expect("released lock")
.push(path.to_path_buf());
self.lock_refuse.clone().map_or(Ok(()), Err)
}
}
const HEAD_OID: &str = "abc1234def5678000000000000000000000000aa";
const BRANCH: &str = "feat/7275-post-merge-cleanup";
const TREE: &str = "/repo/.claude/worktrees/agent-aa11";
fn root() -> PathBuf {
PathBuf::from("/repo")
}
fn req(dry_run: bool) -> CleanupRequest {
CleanupRequest {
pr: 7275,
repo: Some("bobmatnyc/trusty-tools".to_string()),
repo_root: root(),
dry_run,
head_only: false,
}
}
fn merged_json() -> String {
format!(
"{{\"state\":\"MERGED\",\"headRefName\":\"{BRANCH}\",\"headRefOid\":\"{HEAD_OID}\",\
\"mergeCommit\":{{\"oid\":\"9999888877776666555544443333222211110000\"}}}}"
)
}
fn worktree_listing() -> String {
format!(
"worktree /repo\nHEAD 1111111111111111111111111111111111111111\nbranch refs/heads/main\n\n\
worktree {TREE}\nHEAD {HEAD_OID}\nbranch refs/heads/{BRANCH}\n\n"
)
}
fn branch_listing() -> String {
format!(
"main 1111111111111111111111111111111111111111\n\
{BRANCH} {HEAD_OID}\n\
{AGENT_BRANCH_PREFIX}aa11 {HEAD_OID}\n\
{AGENT_BRANCH_PREFIX}bb22 2222222222222222222222222222222222222222\n"
)
}
const ORIGIN_QUERY: &str = "config --get remote.origin.url";
const ORIGIN_URL: &str = "https://github.com/bobmatnyc/trusty-tools.git\n";
fn gh_merged() -> Scripted {
Scripted::new().on("gh pr view 7275", &merged_json())
}
fn git_full() -> Scripted {
Scripted::new()
.on(ORIGIN_QUERY, ORIGIN_URL)
.on(
"git ls-remote",
&format!("{HEAD_OID}\trefs/heads/{BRANCH}\n"),
)
.on("git push origin --delete", "")
.on("git worktree list", &worktree_listing())
.on("git worktree remove", "")
.on("git branch --format", &branch_listing())
.on("git branch -D", "")
.on("git worktree prune", "")
.on("git fetch --prune", "")
}
fn clean(_p: &Path) -> Option<DirtyWorktree> {
None
}
struct FakeLanding {
probe: FakeProbe,
}
impl FakeLanding {
fn nothing_merged() -> Self {
Self {
probe: FakeProbe::default(),
}
}
fn merged(branch: &str, pr: u64) -> Self {
Self {
probe: FakeProbe::default().with_head(branch, BranchPrState::Merged { pr }),
}
}
}
impl Landing for FakeLanding {
fn merged_pr(
&self,
repo_root: &Path,
worktree: &Path,
branch: Option<&str>,
exact: Option<u64>,
) -> Option<u64> {
super::driver::resolve_merged_pr(&self.probe, repo_root, worktree, branch, exact)
}
}
fn ahead_by(n: usize) -> impl Fn(&Path) -> Option<DirtyWorktree> {
move |p: &Path| {
Some(DirtyWorktree {
path: p.to_path_buf(),
reason: format!("0 uncommitted/untracked file(s), {n} unpushed commit(s)"),
dirty_files: 0,
unpushed_commits: n,
})
}
}
fn rendered(lines: &[StepLine]) -> String {
lines
.iter()
.map(StepLine::render)
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn merge_refusal_accepts_merged() {
let view = PrView {
state: "MERGED".to_string(),
head_ref_name: BRANCH.to_string(),
head_ref_oid: HEAD_OID.to_string(),
merge_commit: Some(MergeCommit {
oid: "deadbeef".to_string(),
}),
base_ref_name: "main".to_string(),
};
assert_eq!(merge_refusal(&view, 7275), None);
}
#[test]
fn merge_refusal_names_the_state() {
let view = PrView {
state: "OPEN".to_string(),
head_ref_name: BRANCH.to_string(),
..PrView::default()
};
let reason = merge_refusal(&view, 7275).expect("an OPEN PR must be refused");
assert!(
reason.contains("OPEN"),
"reason must name the state: {reason}"
);
assert!(reason.contains("7275"), "reason must name the PR: {reason}");
}
#[test]
fn merge_refusal_rejects_a_payload_with_no_head() {
let view = PrView {
state: "MERGED".to_string(),
..PrView::default()
};
assert!(
merge_refusal(&view, 7275).is_some(),
"MERGED with no head names nothing to clean up"
);
}
#[test]
fn parse_worktree_list_reads_branch_and_head() {
let entries = parse_worktree_list(&worktree_listing());
assert_eq!(entries.len(), 2, "{entries:?}");
assert_eq!(entries[1].path, PathBuf::from(TREE));
assert_eq!(entries[1].head, HEAD_OID);
assert_eq!(entries[1].branch.as_deref(), Some(BRANCH));
}
#[test]
fn parse_worktree_list_drops_the_bare_record() {
let entries = parse_worktree_list("worktree /repo/.git\nbare\n\n");
assert!(entries.is_empty(), "{entries:?}");
}
#[test]
fn parse_worktree_list_keeps_a_detached_worktree() {
let entries = parse_worktree_list(&format!("worktree {TREE}\nHEAD {HEAD_OID}\ndetached\n\n"));
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].branch, None);
}
#[test]
fn worktree_targets_matches_branch_and_detached_head() {
let listing = format!(
"worktree /repo\nHEAD 1111111111111111111111111111111111111111\nbranch refs/heads/main\n\n\
worktree /repo/wt-branch\nHEAD 3333333333333333333333333333333333333333\n\
branch refs/heads/{BRANCH}\n\n\
worktree /repo/wt-detached\nHEAD {HEAD_OID}\ndetached\n\n"
);
let entries = parse_worktree_list(&listing);
let hits = worktree_targets(&entries, BRANCH, HEAD_OID, &root());
let paths: Vec<_> = hits.iter().map(|e| e.path.clone()).collect();
assert_eq!(
paths,
vec![
PathBuf::from("/repo/wt-branch"),
PathBuf::from("/repo/wt-detached")
],
"both the branch match and the detached head match are targets"
);
}
#[test]
fn worktree_targets_never_returns_the_main_checkout() {
let listing = format!("worktree /repo\nHEAD {HEAD_OID}\nbranch refs/heads/{BRANCH}\n\n");
let entries = parse_worktree_list(&listing);
assert!(
worktree_targets(&entries, BRANCH, HEAD_OID, &root()).is_empty(),
"cleanup runs from the main checkout; it is never its own target"
);
}
#[test]
fn worktree_targets_ignores_an_empty_head_oid() {
let listing = format!("worktree {TREE}\nHEAD \ndetached\n\n");
let entries = parse_worktree_list(&listing);
assert!(
worktree_targets(&entries, "", "", &root()).is_empty(),
"an empty branch and OID must match nothing"
);
}
#[test]
fn agent_branches_at_matches_only_the_agent_prefix() {
let hits = agent_branches_at(&branch_listing(), HEAD_OID);
assert_eq!(hits, vec![format!("{AGENT_BRANCH_PREFIX}aa11")]);
}
#[test]
fn agent_branches_at_ignores_a_branch_that_moved_on() {
let listing = format!("{AGENT_BRANCH_PREFIX}bb22 2222222222222222222222222222222222222222\n");
assert!(
agent_branches_at(&listing, HEAD_OID).is_empty(),
"a tip that is not the merged head carries commits the PR never had"
);
}
#[test]
fn agent_branches_at_returns_nothing_for_an_empty_oid() {
assert!(agent_branches_at(&branch_listing(), " ").is_empty());
}
#[test]
fn step_line_renders_ok_and_failed() {
assert_eq!(StepLine::ok("pr", "fine").render(), "pr: ok — fine");
assert_eq!(StepLine::failed("pr", "nope").render(), "pr: FAILED — nope");
assert_eq!(StepLine::ok("pr", "fine").status, StepStatus::Ok);
}
#[tokio::test]
async fn cleanup_refuses_an_open_pr() {
let gh = Scripted::new().on(
"gh pr view 7275",
&format!("{{\"state\":\"OPEN\",\"headRefName\":\"{BRANCH}\"}}"),
);
let git = Scripted::new();
let claims = FakeClaims::none();
let report = run(
&gh,
&git,
&claims,
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
assert!(report.failed(), "{}", report.render());
assert_eq!(report.lines.len(), 1, "the refusal short-circuits the run");
assert!(report.lines[0].detail.contains("OPEN"));
assert!(
git.calls().is_empty(),
"no git command may run for an unmerged PR: {:?}",
git.calls()
);
}
#[tokio::test]
async fn cleanup_refuses_when_gh_view_fails() {
let gh = Scripted::new().on_fail("gh pr view 7275", "no pull requests found");
let git = Scripted::new();
let claims = FakeClaims::none();
let report = run(
&gh,
&git,
&claims,
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
assert!(report.failed());
assert!(report.lines[0].detail.contains("no pull requests found"));
assert!(git.calls().is_empty());
}
#[tokio::test]
async fn cleanup_clean_path_removes_everything() {
let gh = gh_merged();
let git = git_full();
let claims = FakeClaims::none();
let report = run(
&gh,
&git,
&claims,
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
assert!(!report.failed(), "{}", report.render());
let calls = git.calls();
let joined = calls.join("\n");
assert!(
joined.contains("git push origin --delete"),
"the remote branch must be deleted: {joined}"
);
assert!(
joined.contains(&format!("git worktree remove {TREE}")),
"the worktree must be removed: {joined}"
);
assert!(
!joined.contains("--force"),
"cleanup never forces a worktree removal: {joined}"
);
assert!(
joined.contains(&format!("git branch -D {BRANCH}")),
"the head branch must be deleted: {joined}"
);
assert!(
joined.contains(&format!("git branch -D {AGENT_BRANCH_PREFIX}aa11")),
"the agent branch at the merged head must be deleted: {joined}"
);
assert!(
!joined.contains(&format!("git branch -D {AGENT_BRANCH_PREFIX}bb22")),
"an agent branch that moved on must be left alone: {joined}"
);
assert!(joined.contains("git worktree prune"), "{joined}");
assert!(joined.contains("git fetch --prune origin"), "{joined}");
}
const OTHER_TREE: &str = "/repo/.claude/worktrees/agent-cc33";
fn git_two_trees() -> Scripted {
let listing = format!(
"{}worktree {OTHER_TREE}\nHEAD {HEAD_OID}\nbranch refs/heads/{AGENT_BRANCH_PREFIX}cc33\n\n",
worktree_listing()
);
let branches = format!("{}{AGENT_BRANCH_PREFIX}cc33 {HEAD_OID}\n", branch_listing());
Scripted::new()
.on(ORIGIN_QUERY, ORIGIN_URL)
.on("git ls-remote", "")
.on("git worktree list", &listing)
.on("git worktree remove", "")
.on("git branch --format", &branches)
.on("git branch -D", "")
.on("git worktree prune", "")
.on("git fetch --prune", "")
}
fn assert_other_tree_untouched(joined: &str) {
assert!(
!joined.contains(&format!("git worktree remove {OTHER_TREE}")),
"an unnamed worktree must never be removed by a head-only cleanup: {joined}"
);
assert!(
!joined.contains(&format!("git branch -D {AGENT_BRANCH_PREFIX}cc33")),
"an unnamed worktree's branch must never be deleted: {joined}"
);
assert!(
!joined.contains(&format!("git branch -D {AGENT_BRANCH_PREFIX}aa11")),
"a head-only cleanup deletes the head branch and nothing else: {joined}"
);
}
#[tokio::test]
async fn cleanup_8301_head_only_leaves_an_unnamed_tree_at_the_head_commit() {
let git = git_two_trees();
let req = CleanupRequest {
head_only: true,
..req(false)
};
let report = run(
&gh_merged(),
&git,
&FakeClaims::none(),
&FakeLanding::nothing_merged(),
&clean,
&req,
)
.await;
assert!(!report.failed(), "{}", report.render());
let joined = git.calls().join("\n");
assert!(
joined.contains(&format!("git worktree remove {TREE}")),
"the PR's own head worktree is still removed: {joined}"
);
assert!(
joined.contains(&format!("git branch -D {BRANCH}")),
"{joined}"
);
assert_other_tree_untouched(&joined);
assert!(
report.render().contains(&format!(
"left in place, not this PR's head worktree: {OTHER_TREE}"
)),
"{}",
report.render()
);
}
#[tokio::test]
async fn cleanup_8301_an_unreadable_head_tree_removes_nothing() {
let git = git_two_trees();
let unreadable = |p: &Path| {
Some(DirtyWorktree {
path: p.to_path_buf(),
reason: "`git status` could not be run".to_string(),
dirty_files: 0,
unpushed_commits: 0,
})
};
let req = CleanupRequest {
head_only: true,
..req(false)
};
let report = run(
&gh_merged(),
&git,
&FakeClaims::none(),
&FakeLanding::nothing_merged(),
&unreadable,
&req,
)
.await;
assert!(report.failed(), "an unreadable tree must fail the run");
let joined = git.calls().join("\n");
assert!(
!joined.contains("git worktree remove"),
"nothing may be removed when unsaved work cannot be ruled out: {joined}"
);
assert_other_tree_untouched(&joined);
}
#[tokio::test]
async fn cleanup_refuses_when_the_registry_repo_and_checkout_disagree() {
let gh = gh_merged();
let git = Scripted::new()
.on(
ORIGIN_QUERY,
"https://github.com/someone-else/other-repo.git\n",
)
.on("git ls-remote", "");
let claims = FakeClaims::none();
let report = run(
&gh,
&git,
&claims,
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
assert!(report.failed(), "{}", report.render());
let rendered = report.render();
assert!(rendered.contains("someone-else/other-repo"), "{rendered}");
assert!(rendered.contains("bobmatnyc/trusty-tools"), "{rendered}");
let joined = git.calls().join("\n");
assert!(
!joined.contains("push origin --delete") && !joined.contains("branch -D"),
"nothing destructive may run against a repository this cleanup is not for: {joined}"
);
}
#[tokio::test]
async fn cleanup_refuses_when_the_checkout_origin_cannot_be_read() {
let gh = gh_merged();
let git = Scripted::new()
.on_fail(ORIGIN_QUERY, "not a git repository")
.on("git ls-remote", "");
let claims = FakeClaims::none();
let report = run(
&gh,
&git,
&claims,
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
assert!(report.failed(), "{}", report.render());
assert!(
report.render().contains("could not be read"),
"{}",
report.render()
);
}
#[tokio::test]
async fn cleanup_refusal_never_quotes_the_origin_token() {
const TOKEN: &str = "ghp_9124CleanupSyntheticToken0000";
let origin = format!("https://octo:{TOKEN}@github.com/\n");
let gh = gh_merged();
let git = Scripted::new()
.on(ORIGIN_QUERY, &origin)
.on("git ls-remote", "");
let report = run(
&gh,
&git,
&FakeClaims::none(),
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
let rendered = report.render();
assert!(!rendered.contains(TOKEN), "the token reached the refusal");
assert!(report.failed(), "the cleanup must refuse");
assert!(
rendered.contains("https://***@github.com/") && rendered.contains("names no"),
"the refusal must name the redacted origin: {rendered}"
);
}
#[tokio::test]
async fn cleanup_clean_path_with_the_remote_branch_already_gone() {
let gh = gh_merged();
let git = Scripted::new()
.on(ORIGIN_QUERY, ORIGIN_URL)
.on("git ls-remote", "")
.on("git worktree list", &worktree_listing())
.on("git worktree remove", "")
.on("git branch --format", &branch_listing())
.on("git branch -D", "")
.on("git worktree prune", "")
.on("git fetch --prune", "");
let claims = FakeClaims::none();
let report = run(
&gh,
&git,
&claims,
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
assert!(!report.failed(), "{}", report.render());
assert!(
!git.calls()
.iter()
.any(|c| c.contains("push origin --delete")),
"an absent remote branch is a completed step, not a delete: {:?}",
git.calls()
);
assert!(
rendered(&report.lines).contains("already gone"),
"{}",
report.render()
);
}
#[tokio::test]
async fn cleanup_refuses_a_dirty_worktree() {
let gh = gh_merged();
let git = git_full();
let claims = FakeClaims::none();
let dirty = |p: &Path| {
Some(DirtyWorktree {
path: p.to_path_buf(),
reason: "3 uncommitted/untracked file(s), 1 unpushed commit(s)".to_string(),
dirty_files: 3,
unpushed_commits: 1,
})
};
let report = run(
&gh,
&git,
&claims,
&FakeLanding::nothing_merged(),
&dirty,
&req(false),
)
.await;
assert!(report.failed(), "{}", report.render());
assert!(
!git.calls().iter().any(|c| c.contains("worktree remove")),
"a dirty tree is never removed: {:?}",
git.calls()
);
assert!(
rendered(&report.lines).contains("unsaved work"),
"{}",
report.render()
);
}
#[tokio::test]
async fn cleanup_ends_a_session_claim_before_removing_the_worktree() {
let gh = gh_merged();
let git = git_full();
let claims = FakeClaims::held_by("tm-bobmatnyc-01");
let report = run(
&gh,
&git,
&claims,
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
assert!(!report.failed(), "{}", report.render());
assert_eq!(
claims.ended.lock().expect("claims lock").as_slice(),
["tm-bobmatnyc-01"],
"a merged PR makes the claim obsolete; cleanup ends it and proceeds"
);
assert!(
git.calls().iter().any(|c| c.contains("worktree remove")),
"the claim must not block the removal: {:?}",
git.calls()
);
}
#[tokio::test]
async fn cleanup_keeps_a_tree_another_live_session_claims() {
let gh = gh_merged();
let git = git_full();
let mut claims = FakeClaims::held(&["tm-caller-01", "tm-other-02"]);
claims.live = vec!["tm-other-02".to_string()];
let report = run(
&gh,
&git,
&claims,
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
assert!(report.failed(), "{}", report.render());
assert!(
report.render().contains("tm-other-02"),
"the kept tree names the live session: {}",
report.render()
);
assert!(
claims.ended.lock().expect("claims lock").is_empty(),
"no claim is ended when one holder is live and foreign"
);
assert!(
!git.calls().iter().any(|c| c.contains("worktree remove")),
"{:?}",
git.calls()
);
}
#[tokio::test]
async fn cleanup_keeps_a_tree_the_ownership_gate_refuses() {
let gh = gh_merged();
let git = git_full();
let mut claims = FakeClaims::held_by("tm-bobmatnyc-01");
claims.refuse = Some("its owner file names session Y, which is live".to_string());
let report = run(
&gh,
&git,
&claims,
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
assert!(report.failed(), "{}", report.render());
assert!(
report.render().contains("which is live"),
"{}",
report.render()
);
assert!(claims.ended.lock().expect("claims lock").is_empty());
assert!(
!git.calls().iter().any(|c| c.contains("worktree remove")),
"{:?}",
git.calls()
);
}
#[tokio::test]
async fn cleanup_default_ownership_gate_fails_closed() {
struct Bare;
#[async_trait::async_trait]
impl ClaimEnder for Bare {
async fn claims_on(&self, _path: &Path) -> anyhow::Result<Vec<String>> {
Ok(Vec::new())
}
async fn end_claim(&self, _id: &str) -> anyhow::Result<()> {
Ok(())
}
}
impl ClaimOwnership for Bare {}
let git = git_full();
let report = run(
&gh_merged(),
&git,
&Bare,
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
assert!(report.failed(), "{}", report.render());
assert!(!git.calls().iter().any(|c| c.contains("worktree remove")));
}
#[tokio::test]
async fn cleanup_8301_two_sessions_each_keep_their_own_tree() {
let git = git_two_trees();
let mut claims = FakeClaims::none();
claims.per_path = vec![
(PathBuf::from(TREE), vec!["tm-caller-01".to_string()]),
(PathBuf::from(OTHER_TREE), vec!["tm-other-02".to_string()]),
];
claims.live = vec!["tm-other-02".to_string()];
let report = run(
&gh_merged(),
&git,
&claims,
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
let joined = git.calls().join("\n");
assert!(
joined.contains(&format!("git worktree remove {TREE}")),
"the caller's own tree is removed: {joined}"
);
assert!(
!joined.contains(&format!("git worktree remove {OTHER_TREE}")),
"another live session's tree is never removed: {joined}"
);
assert_eq!(
claims.ended.lock().expect("claims lock").as_slice(),
["tm-caller-01"],
"only the caller's claim is ended"
);
let rendered = report.render();
assert!(
rendered.contains(&format!("{OTHER_TREE} kept")) && rendered.contains("tm-other-02"),
"the kept tree names its live owner: {rendered}"
);
}
#[tokio::test]
async fn cleanup_8301_rejudges_ownership_before_removing() {
let git = git_full().on("git worktree unlock", "");
let claims = FakeClaims::held_by("tm-bobmatnyc-01").gate_then(vec![
Ok(()),
Err("git still reports the harness's agent-lifetime lock".to_string()),
]);
let report = run(
&gh_merged(),
&git,
&claims,
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
assert!(report.failed(), "{}", report.render());
assert!(
report.render().contains("pre-removal check"),
"{}",
report.render()
);
let joined = git.calls().join("\n");
assert!(
!joined.contains("git worktree unlock"),
"a lock judged live now is never released: {joined}"
);
assert!(
claims.released.lock().expect("released lock").is_empty(),
"a refused tree's lock is never judged for release"
);
assert!(!joined.contains("git worktree remove"), "{joined}");
}
#[tokio::test]
async fn cleanup_8301_a_lock_judged_live_at_release_keeps_the_tree_and_its_claim() {
let git = git_full();
let mut claims = FakeClaims::held_by("tm-bobmatnyc-01");
claims.lock_refuse = Some("pid 4242 runs with the start time it recorded".to_string());
let report = run(
&gh_merged(),
&git,
&claims,
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
assert!(report.failed(), "{}", report.render());
assert!(
report.render().contains("judged again at release"),
"{}",
report.render()
);
assert!(claims.ended.lock().expect("claims lock").is_empty());
assert!(
!git.calls().iter().any(|c| c.contains("worktree remove")),
"{:?}",
git.calls()
);
}
#[tokio::test]
async fn cleanup_default_lock_release_fails_closed() {
struct NoLockJudge;
#[async_trait::async_trait]
impl ClaimEnder for NoLockJudge {
async fn claims_on(&self, _path: &Path) -> anyhow::Result<Vec<String>> {
Ok(Vec::new())
}
async fn end_claim(&self, _id: &str) -> anyhow::Result<()> {
Ok(())
}
}
#[async_trait::async_trait]
impl ClaimOwnership for NoLockJudge {
async fn tree_gate(&self, _path: &Path) -> Result<(), String> {
Ok(())
}
}
let git = git_full();
let report = run(
&gh_merged(),
&git,
&NoLockJudge,
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
assert!(report.failed(), "{}", report.render());
assert!(
report.render().contains("cannot judge the lock"),
"{}",
report.render()
);
assert!(!git.calls().iter().any(|c| c.contains("worktree remove")));
}
#[tokio::test]
async fn cleanup_8301_a_refused_pre_removal_check_ends_no_claim() {
let git = git_full();
let claims = FakeClaims::held_by("tm-bobmatnyc-01").gate_then(vec![
Ok(()),
Err("its owner file names session Y, which is live".to_string()),
]);
let report = run(
&gh_merged(),
&git,
&claims,
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
assert!(report.failed(), "{}", report.render());
assert!(
report.render().contains("pre-removal check"),
"{}",
report.render()
);
assert!(
claims.ended.lock().expect("claims lock").is_empty(),
"a kept tree keeps its claim"
);
assert!(
!git.calls().iter().any(|c| c.contains("worktree remove")),
"{:?}",
git.calls()
);
}
#[tokio::test]
async fn cleanup_8301_a_claim_taken_during_cleanup_keeps_the_tree() {
let git = git_full();
let mut claims =
FakeClaims::none().claims_then(vec![Ok(Vec::new()), Ok(vec!["tm-other-02".to_string()])]);
claims.live = vec!["tm-other-02".to_string()];
let report = run(
&gh_merged(),
&git,
&claims,
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
assert!(report.failed(), "{}", report.render());
assert!(
report.render().contains("tm-other-02"),
"{}",
report.render()
);
assert!(claims.ended.lock().expect("claims lock").is_empty());
assert!(
!git.calls().iter().any(|c| c.contains("worktree remove")),
"{:?}",
git.calls()
);
}
#[tokio::test]
async fn cleanup_8301_unreadable_claims_at_the_recheck_keep_the_tree() {
let git = git_full();
let claims = FakeClaims::none().claims_then(vec![
Ok(Vec::new()),
Err("the daemon stopped answering".to_string()),
]);
let report = run(
&gh_merged(),
&git,
&claims,
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
assert!(report.failed(), "{}", report.render());
assert!(
report.render().contains("could not be re-read"),
"{}",
report.render()
);
assert!(
!git.calls().iter().any(|c| c.contains("worktree remove")),
"{:?}",
git.calls()
);
}
#[tokio::test]
async fn cleanup_fails_the_worktree_step_when_claims_cannot_be_read() {
let gh = gh_merged();
let git = git_full();
let claims = super::UnavailableClaims::new("the daemon is not reachable");
let report = run(
&gh,
&git,
&claims,
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
assert!(report.failed(), "{}", report.render());
assert!(
!git.calls().iter().any(|c| c.contains("worktree remove")),
"an unanswerable claim question must not advance toward a delete: {:?}",
git.calls()
);
}
#[tokio::test]
async fn cleanup_dry_run_makes_no_mutating_call() {
let gh = gh_merged();
let git = git_full();
let claims = FakeClaims::held_by("tm-bobmatnyc-01");
let report = run(
&gh,
&git,
&claims,
&FakeLanding::nothing_merged(),
&clean,
&req(true),
)
.await;
assert!(!report.failed(), "{}", report.render());
for call in git.calls() {
assert!(
!(call.contains("push origin --delete")
|| call.contains("worktree remove")
|| call.contains("branch -D")
|| call.contains("worktree prune")
|| call.contains("fetch --prune")),
"--dry-run issued a mutating command: {call}"
);
}
assert!(
claims.ended.lock().expect("claims lock").is_empty(),
"--dry-run must not end a claim"
);
let text = report.render();
assert!(text.contains("would delete"), "{text}");
assert!(text.contains("would remove"), "{text}");
}
#[test]
fn strip_round_suffix_removes_only_a_numbered_round() {
use super::plan::strip_round_suffix;
assert_eq!(strip_round_suffix("fix/7232-thing-r2"), "fix/7232-thing");
assert_eq!(strip_round_suffix("fix/7232-thing-r13"), "fix/7232-thing");
assert_eq!(strip_round_suffix("fix/7232-thing"), "fix/7232-thing");
assert_eq!(
strip_round_suffix("feat/add-r-flag"),
"feat/add-r-flag",
"`-r` followed by letters is part of the name, not a round"
);
assert_eq!(
strip_round_suffix("-r2"),
"-r2",
"a bare suffix is the name"
);
}
#[test]
fn is_pr_branch_relates_round_siblings() {
use super::plan::is_pr_branch;
assert!(is_pr_branch("fix/7232-thing", "fix/7232-thing-r2"));
assert!(is_pr_branch("fix/7232-thing-r2", "fix/7232-thing"));
assert!(is_pr_branch("fix/7232-thing-r2", "fix/7232-thing-r3"));
assert!(is_pr_branch("fix/7232-thing", "fix/7232-thing"));
}
#[test]
fn is_pr_branch_rejects_an_unrelated_branch() {
use super::plan::is_pr_branch;
assert!(!is_pr_branch("fix/7232-thing", "fix/7233-other"));
assert!(!is_pr_branch("fix/7232-thing", "main"));
assert!(!is_pr_branch("", "fix/7232-thing"));
assert!(!is_pr_branch("fix/7232-thing", ""));
}
#[test]
fn worktree_targets_includes_a_round_sibling() {
let listing = format!(
"worktree /repo\nHEAD 1111111111111111111111111111111111111111\nbranch refs/heads/main\n\n\
worktree /repo/wt-r2\nHEAD 3333333333333333333333333333333333333333\n\
branch refs/heads/{BRANCH}-r2\n\n"
);
let entries = parse_worktree_list(&listing);
let hits = worktree_targets(&entries, BRANCH, HEAD_OID, &root());
assert_eq!(
hits.iter().map(|e| e.path.clone()).collect::<Vec<_>>(),
vec![PathBuf::from("/repo/wt-r2")],
"a `-r2` tree carries no merged PR of its own and nothing else reclaims it"
);
}
#[test]
fn pr_branches_lists_head_then_siblings() {
use super::plan::pr_branches;
let listing = format!("main 1111\n{BRANCH}-r2 2222\n{BRANCH} {HEAD_OID}\nfix/other-r2 3333\n");
assert_eq!(
pr_branches(&listing, BRANCH),
vec![BRANCH.to_string(), format!("{BRANCH}-r2")],
"the head is deleted first, then its siblings"
);
}
#[test]
fn pr_branches_ignores_an_unrelated_branch() {
use super::plan::pr_branches;
assert!(pr_branches("main 1111\nfix/other 2222\n", BRANCH).is_empty());
}
fn git_with_sibling(landed: bool) -> Scripted {
let listing = format!(
"worktree /repo\nHEAD 1111111111111111111111111111111111111111\nbranch refs/heads/main\n\n\
worktree {TREE}\nHEAD {HEAD_OID}\nbranch refs/heads/{BRANCH}\n\n\
worktree /repo/wt-r2\nHEAD 3333333333333333333333333333333333333333\n\
branch refs/heads/{BRANCH}-r2\n\n"
);
let branches = format!("main 1111\n{BRANCH} {HEAD_OID}\n{BRANCH}-r2 3333\n");
let s = Scripted::new()
.on(ORIGIN_QUERY, ORIGIN_URL)
.on("git ls-remote", "")
.on_fail("git merge-base --is-ancestor", "not an ancestor")
.on("git fetch origin", "")
.on("git merge-tree --write-tree", "aaaabbbbccccdddd\n")
.on("git worktree list", &listing)
.on("git worktree remove", "")
.on("git branch --format", &branches)
.on("git branch -D", "")
.on("git worktree prune", "")
.on("git fetch --prune", "");
if landed {
s.on("git diff --name-only", "")
} else {
s.on(
"git diff --name-only",
"crates/a/src/lib.rs\ncrates/a/src/x.rs\n",
)
}
}
#[tokio::test]
async fn cleanup_removes_a_round_sibling_whose_content_landed() {
let gh = gh_merged();
let git = git_with_sibling(true);
let claims = FakeClaims::none();
let report = run(
&gh,
&git,
&claims,
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
assert!(!report.failed(), "{}", report.render());
let joined = git.calls().join("\n");
assert!(
joined.contains("git worktree remove /repo/wt-r2"),
"the sibling tree must be removed: {joined}"
);
assert!(
joined.contains(&format!("git branch -D {BRANCH}-r2")),
"and its branch with it: {joined}"
);
assert!(!joined.contains("--force"), "still never forced: {joined}");
}
#[tokio::test]
async fn cleanup_refuses_a_sibling_whose_content_is_not_on_the_base() {
let gh = gh_merged();
let git = git_with_sibling(false);
let claims = FakeClaims::none();
let report = run(
&gh,
&git,
&claims,
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
assert!(report.failed(), "{}", report.render());
let text = report.render();
assert!(text.contains("crates/a/src/lib.rs"), "{text}");
assert!(text.contains("2 files"), "{text}");
assert!(
!git.calls()
.iter()
.any(|c| c.contains("worktree remove /repo/wt-r2")),
"the sibling must not be removed: {:?}",
git.calls()
);
}
#[tokio::test]
async fn cleanup_removes_a_round_one_branch_that_never_had_its_own_pr() {
let head_r2 = format!("{BRANCH}-r2");
let gh = Scripted::new().on(
"gh pr view 7275",
&format!(
"{{\"state\":\"MERGED\",\"headRefName\":\"{head_r2}\",\"headRefOid\":\"{HEAD_OID}\",\
\"baseRefName\":\"main\"}}"
),
);
let listing = format!(
"worktree /repo\nHEAD 1111\nbranch refs/heads/main\n\n\
worktree /repo/wt-r1\nHEAD 4444\nbranch refs/heads/{BRANCH}\n\n"
);
let git = Scripted::new()
.on(ORIGIN_QUERY, ORIGIN_URL)
.on("git ls-remote", "")
.on("git merge-base --is-ancestor", "")
.on("git worktree list", &listing)
.on("git worktree remove", "")
.on(
"git branch --format",
&format!("main 1111\n{BRANCH} 4444\n"),
)
.on("git branch -D", "")
.on("git worktree prune", "")
.on("git fetch --prune", "");
let claims = FakeClaims::none();
let report = run(
&gh,
&git,
&claims,
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
assert!(!report.failed(), "{}", report.render());
let joined = git.calls().join("\n");
assert!(
joined.contains("git worktree remove /repo/wt-r1"),
"the round-1 tree has no PR of its own and nothing else reclaims it: {joined}"
);
assert!(
joined.contains(&format!("git branch -D {BRANCH}")),
"and its branch with it: {joined}"
);
assert!(
!joined.contains("merge-tree"),
"an ancestor tip needs no merge test: {joined}"
);
}
const SIB_OID: &str = "5555555555555555555555555555555555555555";
fn git_squash(listing: &str, branches: &str, landed: bool) -> Scripted {
let s = Scripted::new()
.on(ORIGIN_QUERY, ORIGIN_URL)
.on("git ls-remote", "")
.on_fail("git merge-base --is-ancestor", "not an ancestor")
.on("git fetch origin", "")
.on("git merge-tree --write-tree", "aaaabbbbccccdddd\n")
.on("git worktree list", listing)
.on("git worktree remove", "")
.on("git branch --format", branches)
.on("git branch -D", "")
.on("git worktree prune", "")
.on("git fetch --prune", "");
if landed {
s.on("git diff --name-only", "")
} else {
s.on("git diff --name-only", "crates/a/src/lib.rs\n")
}
}
#[tokio::test]
async fn cleanup_removes_a_squash_merged_worktree() {
let gh = gh_merged();
let git = git_squash(&worktree_listing(), &branch_listing(), true);
let claims = FakeClaims::none();
let probe = ahead_by(2);
let report = run(
&gh,
&git,
&claims,
&FakeLanding::nothing_merged(),
&probe,
&req(false),
)
.await;
assert!(!report.failed(), "{}", report.render());
let joined = git.calls().join("\n");
assert!(
joined.contains(&format!("git worktree remove {TREE}")),
"a squash-merged tree must be removed, not refused for being ahead: {joined}"
);
assert!(
joined.contains("git merge-tree --write-tree origin/main"),
"the merge-tree no-op is what proves it landed: {joined}"
);
assert!(!joined.contains("--force"), "still never forced: {joined}");
assert!(
report.render().contains("landed"),
"the line must say the ahead-count was accounted for: {}",
report.render()
);
}
#[tokio::test]
async fn cleanup_refuses_a_squash_merged_tree_that_moved_on() {
let gh = gh_merged();
let git = git_squash(&worktree_listing(), &branch_listing(), false);
let claims = FakeClaims::none();
let probe = ahead_by(3);
let report = run(
&gh,
&git,
&claims,
&FakeLanding::nothing_merged(),
&probe,
&req(false),
)
.await;
assert!(report.failed(), "{}", report.render());
let text = report.render();
assert!(
text.contains("crates/a/src/lib.rs"),
"the refusal must name the divergence: {text}"
);
assert!(
text.contains("3 unpushed commit(s)"),
"and still report what the probe saw: {text}"
);
assert!(
!git.calls()
.iter()
.any(|c| c.contains(&format!("worktree remove {TREE}"))),
"a tree that moved on past the merge is never removed: {:?}",
git.calls()
);
}
#[tokio::test]
async fn cleanup_refuses_an_ahead_tree_with_no_merged_pr() {
let gh = gh_merged();
let listing = format!(
"worktree /repo\nHEAD 1111\nbranch refs/heads/main\n\n\
worktree {TREE}\nHEAD {SIB_OID}\nbranch refs/heads/{BRANCH}\n\n"
);
let git = git_squash(&listing, &format!("main 1111\n{BRANCH} {SIB_OID}\n"), true);
let claims = FakeClaims::none();
let probe = ahead_by(1);
let report = run(
&gh,
&git,
&claims,
&FakeLanding::nothing_merged(),
&probe,
&req(false),
)
.await;
assert!(report.failed(), "{}", report.render());
let text = report.render();
assert!(
text.contains("no MERGED pull request matches"),
"the refusal must name the missing evidence: {text}"
);
assert!(
!git.calls().iter().any(|c| c.contains("worktree remove")),
"no merged pull request means no removal: {:?}",
git.calls()
);
}
#[tokio::test]
async fn cleanup_removes_a_squash_merged_round_sibling() {
let gh = gh_merged();
let listing = format!(
"worktree /repo\nHEAD 1111\nbranch refs/heads/main\n\n\
worktree /repo/wt-r2\nHEAD {SIB_OID}\nbranch refs/heads/{BRANCH}-r2\n\n"
);
let branches = format!("main 1111\n{BRANCH}-r2 {SIB_OID}\n");
let git = git_squash(&listing, &branches, true);
let claims = FakeClaims::none();
let probe = ahead_by(1);
let report = run(
&gh,
&git,
&claims,
&FakeLanding::merged(BRANCH, 7275),
&probe,
&req(false),
)
.await;
assert!(!report.failed(), "{}", report.render());
assert!(
git.calls()
.iter()
.any(|c| c.contains("worktree remove /repo/wt-r2")),
"the stem's merged pull request vouches for the sibling: {:?}",
git.calls()
);
}
#[tokio::test]
async fn cleanup_refuses_when_the_merge_test_cannot_run() {
let gh = gh_merged();
let git = Scripted::new()
.on(ORIGIN_QUERY, ORIGIN_URL)
.on("git ls-remote", "")
.on_fail("git merge-base --is-ancestor", "not an ancestor")
.on("git fetch origin", "")
.on_fail("git merge-tree --write-tree", "fatal: not a valid object")
.on("git worktree list", &worktree_listing())
.on("git worktree remove", "")
.on("git branch --format", &branch_listing())
.on("git branch -D", "")
.on("git worktree prune", "")
.on("git fetch --prune", "");
let claims = FakeClaims::none();
let probe = ahead_by(1);
let report = run(
&gh,
&git,
&claims,
&FakeLanding::nothing_merged(),
&probe,
&req(false),
)
.await;
assert!(report.failed(), "{}", report.render());
assert!(
!git.calls().iter().any(|c| c.contains("worktree remove")),
"a failed merge test must not advance toward a delete: {:?}",
git.calls()
);
}
#[tokio::test]
async fn cleanup_refuses_an_unreadable_worktree() {
let gh = gh_merged();
let git = git_full();
let claims = FakeClaims::none();
let unreadable = |p: &Path| {
Some(DirtyWorktree {
path: p.to_path_buf(),
reason: "`git status` could not be run".to_string(),
dirty_files: 0,
unpushed_commits: 0,
})
};
let report = run(
&gh,
&git,
&claims,
&FakeLanding::nothing_merged(),
&unreadable,
&req(false),
)
.await;
assert!(report.failed(), "{}", report.render());
assert!(
report.render().contains("unsaved work"),
"an unreadable tree is dirty, not ahead: {}",
report.render()
);
assert!(
!git.calls().iter().any(|c| c.contains("worktree remove")),
"{:?}",
git.calls()
);
}
struct StaleBase {
inner: Scripted,
fetched: RefCell<bool>,
}
impl StaleBase {
fn new(inner: Scripted) -> Self {
Self {
inner,
fetched: RefCell::new(false),
}
}
}
impl Git for StaleBase {
fn run(&self, dir: &Path, args: &[String]) -> anyhow::Result<CmdOut> {
let joined = args.join(" ");
if joined.starts_with("fetch origin ") {
*self.fetched.borrow_mut() = true;
}
if joined.starts_with("diff --name-only") && !*self.fetched.borrow() {
self.inner.seen.borrow_mut().push(format!("git {joined}"));
return Ok(CmdOut {
success: true,
stdout: "crates/a/src/lib.rs\n".to_string(),
stderr: String::new(),
});
}
Git::run(&self.inner, dir, args)
}
}
#[tokio::test]
async fn cleanup_reclaims_a_tree_whose_base_ref_was_stale() {
let gh = gh_merged();
let git = StaleBase::new(git_squash(&worktree_listing(), &branch_listing(), true));
let claims = FakeClaims::none();
let probe = ahead_by(2);
let report = run(
&gh,
&git,
&claims,
&FakeLanding::nothing_merged(),
&probe,
&req(false),
)
.await;
assert!(!report.failed(), "{}", report.render());
let calls = git.inner.calls();
let joined = calls.join("\n");
assert!(
joined.contains("git fetch origin main"),
"the base ref must be refreshed before it is compared against: {joined}"
);
let fetch_at = calls
.iter()
.position(|c| c.contains("fetch origin main"))
.expect("the fetch must have run");
let diff_at = calls
.iter()
.position(|c| c.contains("diff --name-only"))
.expect("the merge comparison must have run");
assert!(
fetch_at < diff_at,
"the refresh must precede the comparison it feeds: {calls:?}"
);
assert!(
joined.contains(&format!("git worktree remove {TREE}")),
"one pass must reclaim it: {joined}"
);
}
#[tokio::test]
async fn cleanup_refuses_when_the_base_ref_cannot_be_refreshed() {
let gh = gh_merged();
let git = Scripted::new()
.on(ORIGIN_QUERY, ORIGIN_URL)
.on("git ls-remote", "")
.on_fail("git merge-base --is-ancestor", "not an ancestor")
.on_fail(
"git fetch origin",
"fatal: could not read from remote repository",
)
.on("git merge-tree --write-tree", "aaaabbbbccccdddd\n")
.on("git diff --name-only", "")
.on("git worktree list", &worktree_listing())
.on("git worktree remove", "")
.on("git branch --format", &branch_listing())
.on("git branch -D", "")
.on("git worktree prune", "")
.on("git fetch --prune", "");
let claims = FakeClaims::none();
let probe = ahead_by(1);
let report = run(
&gh,
&git,
&claims,
&FakeLanding::nothing_merged(),
&probe,
&req(false),
)
.await;
assert!(report.failed(), "{}", report.render());
let text = report.render();
assert!(
text.contains("could not read from remote repository"),
"the refusal must name what failed: {text}"
);
assert!(
!git.calls().iter().any(|c| c.contains("worktree remove")),
"a base ref that may predate the merge must not authorise a delete: {:?}",
git.calls()
);
}
fn dirt_then(later: Option<DirtyWorktree>) -> impl Fn(&Path) -> Option<DirtyWorktree> {
let calls = std::cell::Cell::new(0usize);
move |p: &Path| {
let n = calls.get();
calls.set(n + 1);
if n == 0 {
return None;
}
later.clone().map(|mut d| {
d.path = p.to_path_buf();
d
})
}
}
#[tokio::test]
async fn cleanup_refuses_a_tree_that_changed_while_cleanup_was_checking_it() {
let gh = gh_merged();
let git = git_full();
let claims = FakeClaims::held_by("tm-bobmatnyc-01");
let probe = dirt_then(Some(DirtyWorktree {
path: PathBuf::from(TREE),
reason: "1 uncommitted/untracked file(s), 0 unpushed commit(s)".to_string(),
dirty_files: 1,
unpushed_commits: 0,
}));
let report = run(
&gh,
&git,
&claims,
&FakeLanding::nothing_merged(),
&probe,
&req(false),
)
.await;
assert!(report.failed(), "{}", report.render());
let text = report.render();
assert!(
text.contains("changed while cleanup was checking it"),
"the refusal must say the reading went stale: {text}"
);
assert!(
text.contains("uncommitted/untracked files went from 0 to 1"),
"and name what changed: {text}"
);
assert!(
!git.calls().iter().any(|c| c.contains("worktree remove")),
"work written during the window is never deleted: {:?}",
git.calls()
);
assert!(claims.ended.lock().expect("claims lock").is_empty());
}
fn entry(pr: u64, cleaned: bool) -> OpenedPr {
OpenedPr {
pr,
repo: "bobmatnyc/trusty-tools".to_string(),
repo_root: root(),
opened_at: chrono::Utc::now(),
cleaned_at: cleaned.then(chrono::Utc::now),
scope: Default::default(),
}
}
#[test]
fn registry_round_trips_an_entry() {
let dir = tempfile::tempdir().expect("tempdir");
let reg = CleanupRegistry::under_root(dir.path());
reg.record_open(entry(7275, false)).expect("record");
let back = reg.entries();
assert_eq!(back.len(), 1);
assert_eq!(back[0].pr, 7275);
assert!(back[0].pending());
}
#[test]
fn registry_record_open_is_idempotent() {
let dir = tempfile::tempdir().expect("tempdir");
let reg = CleanupRegistry::under_root(dir.path());
reg.record_open(entry(7275, false)).expect("first");
reg.record_open(entry(7275, false)).expect("second");
assert_eq!(
reg.entries().len(),
1,
"recording the same PR twice would clean it twice"
);
}
#[test]
fn registry_pending_excludes_a_cleaned_entry() {
let dir = tempfile::tempdir().expect("tempdir");
let reg = CleanupRegistry::under_root(dir.path());
reg.record_open(entry(7275, false)).expect("pending");
reg.record_open(entry(7276, true)).expect("cleaned");
let pending: Vec<u64> = reg.pending().into_iter().map(|e| e.pr).collect();
assert_eq!(pending, vec![7275]);
}
#[test]
fn registry_mark_cleaned_stamps_the_entry() {
let dir = tempfile::tempdir().expect("tempdir");
let reg = CleanupRegistry::under_root(dir.path());
reg.record_open(entry(7275, false)).expect("record");
reg.mark_cleaned("bobmatnyc/trusty-tools", 7275, chrono::Utc::now())
.expect("stamp");
assert!(
reg.pending().is_empty(),
"a stamped entry never sweeps again"
);
}
#[test]
fn registry_mark_cleaned_ignores_an_unknown_pr() {
let dir = tempfile::tempdir().expect("tempdir");
let reg = CleanupRegistry::under_root(dir.path());
reg.mark_cleaned("bobmatnyc/trusty-tools", 999, chrono::Utc::now())
.expect("stamping an unknown PR is a no-op, not an error");
assert!(reg.entries().is_empty());
}
#[test]
fn registry_unreadable_file_reads_as_empty() {
let dir = tempfile::tempdir().expect("tempdir");
let reg = CleanupRegistry::under_root(dir.path());
std::fs::write(reg.path(), "{ this is not json").expect("write garbage");
assert!(
reg.entries().is_empty(),
"a corrupt registry makes the sweep idle, never guess"
);
}
#[test]
fn registry_concurrent_writers_lose_no_update() {
const N: u64 = 24;
for round in 0..4 {
let dir = tempfile::tempdir().expect("tempdir");
let reg = CleanupRegistry::under_root(dir.path());
for pr in 0..N {
reg.record_open(entry(pr, false)).expect("seed");
}
let barrier = std::sync::Arc::new(std::sync::Barrier::new(N as usize));
let handles: Vec<_> = (0..N)
.map(|pr| {
let (reg, barrier) = (reg.clone(), std::sync::Arc::clone(&barrier));
std::thread::spawn(move || {
barrier.wait();
if pr % 2 == 0 {
reg.mark_cleaned("bobmatnyc/trusty-tools", pr, chrono::Utc::now())
} else {
reg.record_scope(
"bobmatnyc/trusty-tools",
pr,
super::CleanupScope::Deferred,
)
.map(|_| ())
}
})
})
.collect();
for h in handles {
h.join().expect("join").expect("write");
}
let entries = reg.entries();
assert_eq!(entries.len(), N as usize, "round {round}");
for e in &entries {
if e.pr % 2 == 0 {
assert!(
e.cleaned_at.is_some(),
"round {round}: #{} lost its stamp",
e.pr
);
} else {
assert_eq!(
e.scope,
super::CleanupScope::Deferred,
"round {round}: #{} lost its deferral",
e.pr
);
}
}
}
}
#[test]
fn registry_record_scope_matches_the_repo_case_insensitively() {
let dir = tempfile::tempdir().expect("tempdir");
let reg = CleanupRegistry::under_root(dir.path());
reg.record_open(entry(7275, false)).expect("record");
let hit = reg
.record_scope(
"BobMatNYC/Trusty-Tools",
7275,
super::CleanupScope::Deferred,
)
.expect("write");
assert!(hit, "a differently cased slug names the same repository");
assert!(reg.pending().is_empty());
}
#[test]
fn registry_detects_a_rewrite_by_an_older_writer() {
let dir = tempfile::tempdir().expect("tempdir");
let reg = CleanupRegistry::under_root(dir.path());
reg.record_open(entry(7275, false)).expect("record");
assert!(
!reg.rewritten_by_older_writer(),
"a scope-aware write is not a downgrade"
);
std::fs::write(
reg.path(),
"{\"entries\":[{\"pr\":7275,\"repo\":\"bobmatnyc/trusty-tools\",\
\"repo_root\":\"/repo\",\"opened_at\":\"2026-09-23T00:00:00Z\"}]}",
)
.expect("older write");
assert!(reg.rewritten_by_older_writer());
reg.mark_cleaned("bobmatnyc/trusty-tools", 7275, chrono::Utc::now())
.expect("scope-aware write");
assert!(!reg.rewritten_by_older_writer());
}
#[test]
fn registry_write_refuses_to_replace_a_malformed_file() {
let dir = tempfile::tempdir().expect("tempdir");
let reg = CleanupRegistry::under_root(dir.path());
let garbage = b"{ this is not json".to_vec();
std::fs::write(reg.path(), &garbage).expect("write garbage");
let outcome = reg.record_scope(
"bobmatnyc/trusty-tools",
7275,
super::CleanupScope::Deferred,
);
assert!(outcome.is_err(), "a malformed registry must fail the write");
assert_eq!(
std::fs::read(reg.path()).expect("read back"),
garbage,
"the file must be left byte-for-byte"
);
}
#[test]
fn registry_an_old_file_alone_is_not_a_downgrade() {
let dir = tempfile::tempdir().expect("tempdir");
let reg = CleanupRegistry::under_root(dir.path());
std::fs::write(reg.path(), "{\"entries\":[]}").expect("old file");
assert!(!reg.rewritten_by_older_writer());
}
#[tokio::test]
async fn cleanup_clears_the_harness_marker_before_removing_the_tree() {
let tmp = tempfile::tempdir().expect("tempdir");
let tree = tmp.path().join("agent-aa11");
std::fs::create_dir_all(&tree).expect("tree");
let marker = tree.join(crate::session_manager::decommission::WORKTREE_SENTINEL_FILE);
std::fs::write(&marker, b"{}").expect("marker");
let kept = tree.join("notes.md");
std::fs::write(&kept, "left alone\n").expect("sibling");
let shown = tree.display().to_string();
let listing = format!(
"worktree /repo\nHEAD 1111111111111111111111111111111111111111\n\
branch refs/heads/main\n\n\
worktree {shown}\nHEAD {HEAD_OID}\nbranch refs/heads/{BRANCH}\n\n"
);
let git = Scripted::new()
.on(ORIGIN_QUERY, ORIGIN_URL)
.on(
"git ls-remote",
&format!("{HEAD_OID}\trefs/heads/{BRANCH}\n"),
)
.on("git push origin --delete", "")
.on("git worktree list", &listing)
.on("git worktree remove", "")
.on("git branch --format", &branch_listing())
.on("git branch -D", "")
.on("git worktree prune", "")
.on("git fetch --prune", "");
let report = run(
&gh_merged(),
&git,
&FakeClaims::none(),
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
assert!(!report.failed(), "{}", report.render());
assert!(
!marker.exists(),
"the harness marker must be cleared before the removal, or git refuses it"
);
assert!(
kept.exists(),
"clearing the marker must touch nothing else in the tree"
);
let joined = git.calls().join("\n");
assert!(
joined.contains(&format!("git worktree remove {shown}")),
"the tree must still be removed: {joined}"
);
assert!(
!joined.contains("--force"),
"clearing the marker is not licence to force: {joined}"
);
}
#[tokio::test]
async fn cleanup_restores_the_harness_marker_when_the_removal_fails() {
let tmp = tempfile::tempdir().expect("tempdir");
let tree = tmp.path().join("agent-aa11");
std::fs::create_dir_all(&tree).expect("tree");
let marker = tree.join(crate::session_manager::decommission::WORKTREE_SENTINEL_FILE);
let original = br#"{"owner_session_id":"s-1","created_at":"2026-09-11T00:00:00Z"}"#;
std::fs::write(&marker, original).expect("marker");
let shown = tree.display().to_string();
let listing = format!(
"worktree /repo\nHEAD 1111111111111111111111111111111111111111\n\
branch refs/heads/main\n\n\
worktree {shown}\nHEAD {HEAD_OID}\nbranch refs/heads/{BRANCH}\n\n"
);
let git = Scripted::new()
.on(ORIGIN_QUERY, ORIGIN_URL)
.on(
"git ls-remote",
&format!("{HEAD_OID}\trefs/heads/{BRANCH}\n"),
)
.on("git push origin --delete", "")
.on("git worktree list", &listing)
.on("git branch --format", &branch_listing())
.on("git branch -D", "")
.on("git worktree prune", "")
.on("git fetch --prune", "");
let report = run(
&gh_merged(),
&git,
&FakeClaims::none(),
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
assert!(
report.failed(),
"an unroutable removal must fail the step: {}",
report.render()
);
assert!(
marker.exists(),
"a removal that did not happen must leave the tree attributed"
);
assert_eq!(
std::fs::read(&marker).expect("read restored marker"),
original,
"the restore must write back the ORIGINAL bytes, not a re-serialised payload"
);
assert!(
!report.render().contains("could not be restored"),
"a successful restore must add no note: {}",
report.render()
);
}
#[tokio::test]
async fn a_dry_run_leaves_the_harness_marker_in_place() {
let tmp = tempfile::tempdir().expect("tempdir");
let tree = tmp.path().join("agent-aa11");
std::fs::create_dir_all(&tree).expect("tree");
let marker = tree.join(crate::session_manager::decommission::WORKTREE_SENTINEL_FILE);
std::fs::write(&marker, b"{}").expect("marker");
let shown = tree.display().to_string();
let listing = format!(
"worktree /repo\nHEAD 1111111111111111111111111111111111111111\n\
branch refs/heads/main\n\n\
worktree {shown}\nHEAD {HEAD_OID}\nbranch refs/heads/{BRANCH}\n\n"
);
let git = Scripted::new()
.on(ORIGIN_QUERY, ORIGIN_URL)
.on(
"git ls-remote",
&format!("{HEAD_OID}\trefs/heads/{BRANCH}\n"),
)
.on("git worktree list", &listing)
.on("git branch --format", &branch_listing());
let report = run(
&gh_merged(),
&git,
&FakeClaims::none(),
&FakeLanding::nothing_merged(),
&clean,
&req(true),
)
.await;
assert!(!report.failed(), "{}", report.render());
assert!(
marker.exists(),
"a dry run must leave the tree exactly as it found it"
);
}
fn git_listing(listing: &str) -> Scripted {
git_listing_with(listing, &branch_listing())
}
fn git_listing_with(listing: &str, branches: &str) -> Scripted {
Scripted::new()
.on(ORIGIN_QUERY, ORIGIN_URL)
.on("git ls-remote", "")
.on("git worktree list", listing)
.on("git worktree remove", "")
.on("git branch --format", branches)
.on("git branch -D", "")
.on("git worktree prune", "")
.on("git fetch --prune", "")
}
fn assert_holder_named(rendered: &str, holder: &str) {
assert!(
!rendered.contains("no worktree holds"),
"a tree holds {BRANCH}; the report must not say none does:\n{rendered}"
);
assert!(
rendered.contains(&format!(
"worktree: FAILED — {holder}: holds {BRANCH} and was kept"
)),
"the holder must be named with the reason it was kept:\n{rendered}"
);
assert!(
rendered.contains(&format!(
"run `git switch --detach` in it, then `git branch -D {BRANCH}`; or run \
`tm pr cleanup 7275` from the main checkout"
)),
"the recovery must name a switch that works in any worktree:\n{rendered}"
);
}
#[tokio::test]
async fn cleanup_8489_names_the_checkout_it_runs_from_when_that_holds_the_head() {
let git = git_listing(&worktree_listing());
let req = CleanupRequest {
repo_root: PathBuf::from(TREE),
..req(false)
};
let report = run(
&gh_merged(),
&git,
&FakeClaims::none(),
&FakeLanding::nothing_merged(),
&clean,
&req,
)
.await;
assert!(report.failed(), "{}", report.render());
assert_holder_named(&report.render(), TREE);
let joined = git.calls().join("\n");
assert!(
!joined.contains("git worktree remove"),
"the checkout cleanup runs in is never removed: {joined}"
);
}
#[tokio::test]
async fn cleanup_8489_an_empty_worktree_listing_is_inconclusive() {
let git = git_listing("");
let report = run(
&gh_merged(),
&git,
&FakeClaims::none(),
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
let rendered = report.render();
assert!(report.failed(), "{rendered}");
assert!(!rendered.contains("no worktree holds"), "{rendered}");
assert!(
rendered.contains("worktree: FAILED — `git worktree list --porcelain` named no worktree")
&& rendered.contains("inconclusive"),
"{rendered}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn cleanup_8489_a_symlinked_spelling_of_the_checkout_is_still_the_checkout() {
let tmp = tempfile::tempdir().expect("tempdir");
let real = tmp.path().join("agent-real");
std::fs::create_dir_all(&real).expect("tree");
let link = tmp.path().join("agent-link");
std::os::unix::fs::symlink(&real, &link).expect("symlink");
let shown = real.display().to_string();
let listing = format!(
"worktree /repo\nHEAD 1111111111111111111111111111111111111111\n\
branch refs/heads/main\n\n\
worktree {shown}\nHEAD {HEAD_OID}\nbranch refs/heads/{BRANCH}\n\n"
);
let git = git_listing(&listing);
let req = CleanupRequest {
repo_root: link,
..req(false)
};
let report = run(
&gh_merged(),
&git,
&FakeClaims::none(),
&FakeLanding::nothing_merged(),
&clean,
&req,
)
.await;
assert_holder_named(&report.render(), &shown);
let joined = git.calls().join("\n");
assert!(
!joined.contains("git worktree remove"),
"a symlinked spelling of the running checkout is still that checkout: {joined}"
);
}
const RUN_TREE: &str = "/repo/.claude/worktrees/agent-run";
fn deleted(git: &Scripted, branch: &str) -> bool {
let argv = format!("git branch -D {branch}");
git.calls().contains(&argv)
}
#[tokio::test]
async fn cleanup_8489_local_branch_skips_the_held_head_and_deletes_the_rest() {
let git = git_listing(&worktree_listing());
let req = CleanupRequest {
repo_root: PathBuf::from(TREE),
..req(false)
};
let report = run(
&gh_merged(),
&git,
&FakeClaims::none(),
&FakeLanding::nothing_merged(),
&clean,
&req,
)
.await;
let rendered = report.render();
let joined = git.calls().join("\n");
assert!(
!deleted(&git, BRANCH),
"a held branch is never attempted: {joined}"
);
assert!(
deleted(&git, &format!("{AGENT_BRANCH_PREFIX}aa11")),
"the unheld agent branch is still deleted: {joined}"
);
assert!(
rendered.contains(&format!(
"local-branch: FAILED — deleted {AGENT_BRANCH_PREFIX}aa11; kept {BRANCH}: the \
worktree at {TREE} has it checked out"
)),
"{rendered}"
);
}
#[tokio::test]
async fn cleanup_8489_a_running_checkout_on_an_agent_branch_at_the_head_is_kept() {
let agent = format!("{AGENT_BRANCH_PREFIX}run");
let listing = format!(
"worktree /repo\nHEAD 1111111111111111111111111111111111111111\n\
branch refs/heads/main\n\n\
worktree {RUN_TREE}\nHEAD {HEAD_OID}\nbranch refs/heads/{agent}\n\n"
);
let branches = format!(
"main 1111111111111111111111111111111111111111\n{BRANCH} {HEAD_OID}\n{agent} {HEAD_OID}\n"
);
let git = git_listing_with(&listing, &branches);
let req = CleanupRequest {
repo_root: PathBuf::from(RUN_TREE),
..req(false)
};
let report = run(
&gh_merged(),
&git,
&FakeClaims::none(),
&FakeLanding::nothing_merged(),
&clean,
&req,
)
.await;
let rendered = report.render();
assert!(!rendered.contains("no worktree holds"), "{rendered}");
assert!(
rendered.contains(&format!(
"worktree: FAILED — {RUN_TREE}: holds {agent} and was kept"
)),
"{rendered}"
);
assert!(!deleted(&git, &agent), "{}", git.calls().join("\n"));
assert!(deleted(&git, BRANCH), "{}", git.calls().join("\n"));
assert!(
rendered.contains(&format!("kept {agent}: the worktree at {RUN_TREE}")),
"{rendered}"
);
}
#[tokio::test]
async fn cleanup_8489_head_only_ignores_a_running_checkout_on_a_sibling() {
let listing = format!(
"worktree /repo\nHEAD 1111111111111111111111111111111111111111\n\
branch refs/heads/main\n\n\
worktree {TREE}\nHEAD {HEAD_OID}\nbranch refs/heads/{BRANCH}\n\n\
worktree {RUN_TREE}\nHEAD 3333333333333333333333333333333333333333\n\
branch refs/heads/{BRANCH}-r2\n\n"
);
let git = git_listing(&listing);
let req = CleanupRequest {
repo_root: PathBuf::from(RUN_TREE),
head_only: true,
..req(false)
};
let report = run(
&gh_merged(),
&git,
&FakeClaims::none(),
&FakeLanding::nothing_merged(),
&clean,
&req,
)
.await;
let rendered = report.render();
assert!(!report.failed(), "{rendered}");
let joined = git.calls().join("\n");
assert!(
joined.contains(&format!("git worktree remove {TREE}")),
"{joined}"
);
assert!(deleted(&git, BRANCH), "{joined}");
assert!(!joined.contains(&format!("{BRANCH}-r2")), "{joined}");
}
#[tokio::test]
async fn cleanup_8489_head_only_recovery_stays_in_scope() {
let git = git_listing(&worktree_listing());
let req = CleanupRequest {
repo_root: PathBuf::from(TREE),
head_only: true,
..req(false)
};
let report = run(
&gh_merged(),
&git,
&FakeClaims::none(),
&FakeLanding::nothing_merged(),
&clean,
&req,
)
.await;
let rendered = report.render();
assert!(
rendered.contains(&format!(
"run `git switch --detach` in it, then `git branch -D {BRANCH}` — this merge's \
cleanup reaches only {BRANCH}"
)),
"{rendered}"
);
assert!(!rendered.contains("tm pr cleanup"), "{rendered}");
assert!(!deleted(&git, BRANCH), "{}", git.calls().join("\n"));
}
#[tokio::test]
async fn cleanup_8489_a_failed_worktree_listing_deletes_no_branch() {
let git = Scripted::new()
.on(ORIGIN_QUERY, ORIGIN_URL)
.on("git ls-remote", "")
.on_fail("git worktree list", "fatal: not a git repository")
.on("git branch --format", &branch_listing())
.on("git branch -D", "")
.on("git worktree prune", "")
.on("git fetch --prune", "");
let report = run(
&gh_merged(),
&git,
&FakeClaims::none(),
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await;
let rendered = report.render();
assert!(report.failed(), "{rendered}");
assert!(!rendered.contains("no worktree holds"), "{rendered}");
assert!(
rendered.contains("worktree: FAILED — ")
&& rendered.contains("local-branch: FAILED — no branch deleted"),
"{rendered}"
);
let joined = git.calls().join("\n");
assert!(!joined.contains("git branch -D"), "{joined}");
}
fn git_kept_tip(relation: &[(&str, Option<&str>)]) -> Scripted {
let listing = format!(
"worktree /repo\nHEAD 1111111111111111111111111111111111111111\nbranch refs/heads/main\n\n\
worktree {TREE}\nHEAD {SIB_OID}\nbranch refs/heads/{BRANCH}\n\n"
);
let mut s = Scripted::new()
.on(ORIGIN_QUERY, ORIGIN_URL)
.on(
"git ls-remote",
&format!("{HEAD_OID}\trefs/heads/{BRANCH}\n"),
)
.on("git push origin --delete", "")
.on("git worktree list", &listing)
.on("git branch --format", &branch_listing())
.on("git worktree prune", "")
.on("git fetch --prune", "");
for (needle, stdout) in relation {
s = match stdout {
Some(out) => s.on(needle, out),
None => s.on_fail(needle, ""),
};
}
s
}
async fn kept_report(git: &Scripted) -> String {
let mut claims = FakeClaims::held_by("tm-bobmatnyc-01");
claims.refuse = Some("nothing proves that session ended".to_string());
run(
&gh_merged(),
git,
&claims,
&FakeLanding::nothing_merged(),
&clean,
&req(false),
)
.await
.render()
}
#[tokio::test]
async fn cleanup_8603_a_kept_tree_on_an_ancestor_says_every_commit_is_in_the_merge() {
let git = git_kept_tip(&[("git merge-base --is-ancestor", Some(""))]);
let rendered = kept_report(&git).await;
assert!(
rendered.contains(
"its tip 55555555 is an ancestor of the merged head abc1234d — every commit on it \
is in the merge"
),
"{rendered}"
);
assert!(!rendered.contains("WARNING"), "{rendered}");
let joined = git.calls().join("\n");
assert!(!joined.contains("worktree remove"), "{joined}");
}
#[tokio::test]
async fn cleanup_8603_a_kept_tree_ahead_of_the_head_warns_separately() {
let git = git_kept_tip(&[
("git merge-base --is-ancestor", None),
("git rev-list --count", Some("3\n")),
]);
let rendered = kept_report(&git).await;
assert!(
rendered.contains("its tip 55555555 has 3 commit(s) the merged head abc1234d"),
"{rendered}"
);
let warning = rendered
.lines()
.find(|l| l.contains("WARNING:"))
.unwrap_or_else(|| panic!("no separate warning line:\n{rendered}"));
assert!(
warning.contains(&format!("{TREE} holds 3 commit(s) beyond the merged head")),
"{warning}"
);
assert!(
!warning.contains("nothing proves"),
"the warning is its own line: {rendered}"
);
}
#[tokio::test]
async fn cleanup_8603_an_unreadable_relation_is_stated_as_unknown() {
let git = git_kept_tip(&[]).on_fail("git merge-base --is-ancestor", "fatal: bad object");
let rendered = kept_report(&git).await;
assert!(
rendered.contains(
"how its tip 55555555 relates to the merged head abc1234d could not be read: fatal: \
bad object"
),
"{rendered}"
);
assert!(
!rendered.contains("every commit on it is in the merge"),
"{rendered}"
);
}