use std::path::{Path, PathBuf};
use trusty_mpm::core::dispatch_isolation::permitted_in_shared_checkout;
use trusty_mpm::core::project_aliases::{is_sibling_worktree_path, is_worktree_path};
use super::main_checkout::git_verb_target_dir_with_tail;
use super::worktree_remove_rechecks::{
CHECK_DISPATCH_IDENTITY, CHECK_WORKTREE_SCOPE, recheck_deny,
};
use super::{PathEnv, resolve_target_path, shell_groups, unresolved_target};
pub(crate) const WORKTREE_REMOVE_DENY_REASON: &str = "Worktree removal is PM-executed (#5791, owner ruling 2026-08-19): an agent never removes a \
worktree, its own included. Instead, hand it back: report the worktree path and its merged \
PR to the PM (or to `version-control`), then stop. A sweep over other worktrees is never \
the fallback for one refused removal. `rm -rf` on the worktree directory is not the \
workaround either — it destroys unsaved work and leaves a stale registry entry git still \
believes in. `git worktree list` and `git worktree prune` are not blocked, and SendMessage \
is never blocked — use it to report the path back. One role is exempt and it is not this \
one: ADR-0057 lets a dispatched `version-control` agent remove a tree the guard can prove \
is a harness worktree, clean, merged on GitHub, and held by nobody else.";
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct DispatchIdentity<'a> {
pub(crate) agent_id: Option<&'a str>,
pub(crate) agent_type: Option<&'a str>,
}
impl<'a> DispatchIdentity<'a> {
pub(crate) fn from_payload(payload: &'a serde_json::Value) -> Self {
let field = |key: &str| {
payload
.get(key)
.and_then(serde_json::Value::as_str)
.filter(|s| !s.is_empty())
};
Self {
agent_id: field("agent_id"),
agent_type: field("agent_type"),
}
}
fn is_permitted_remover(self) -> bool {
self.agent_id.is_some() && self.agent_type.is_some_and(permitted_in_shared_checkout)
}
fn claims_the_name_without_a_dispatch(self) -> bool {
self.agent_id.is_none() && self.agent_type.is_some_and(permitted_in_shared_checkout)
}
}
pub(crate) enum WorktreeRemoveVerdict {
Allow,
Deny(String),
ReCheck {
target: PathBuf,
},
}
pub(crate) fn evaluate_worktree_remove_command(
command: &str,
caller_is_subagent: bool,
identity: DispatchIdentity<'_>,
cwd: &Path,
) -> WorktreeRemoveVerdict {
if !caller_is_subagent {
return WorktreeRemoveVerdict::Allow;
}
if let Some(reason) = shell_groups::unplaced_git_verb_reason(command, is_worktree_remove) {
return WorktreeRemoveVerdict::Deny(reason);
}
let Some((target_dir, tail)) = worktree_remove_segment(command, cwd) else {
return WorktreeRemoveVerdict::Allow;
};
if identity.claims_the_name_without_a_dispatch() {
return WorktreeRemoveVerdict::Deny(recheck_deny(
CHECK_DISPATCH_IDENTITY,
&target_dir,
"the payload names `version-control` but carries no `agent_id`, which the hooks \
contract stamps only inside a subagent call — a top-level session launched with \
`--agent version-control` carries the same `agent_type` and inherits nothing from \
this grant.",
));
}
if !identity.is_permitted_remover() {
return WorktreeRemoveVerdict::Deny(WORKTREE_REMOVE_DENY_REASON.to_string());
}
let Some((token, target)) = removal_target_path(&tail, &target_dir) else {
return WorktreeRemoveVerdict::Deny(recheck_deny(
CHECK_WORKTREE_SCOPE,
&target_dir,
"the command names no removable path this guard can resolve, so it cannot establish \
what would be deleted.",
));
};
if let Some(unresolved) = unresolved_target(&target) {
let expansion = unresolved.token;
return WorktreeRemoveVerdict::Deny(recheck_deny(
CHECK_WORKTREE_SCOPE,
Path::new(&token),
&format!(
"the path still carries the unresolved shell expansion `{expansion}` — the guard \
expands `$TMPDIR`, `$TMP`, `$HOME` and `$PWD`, and a leading `~` only when \
`$HOME` is set, so it cannot establish which directory would be deleted, and \
every later re-check would probe a path that does not exist. Re-run the removal \
with the worktree path written out in full."
),
));
}
let sibling = is_sibling_worktree_path(&target) && target.join(".git").is_file();
if !is_worktree_path(&target) && !sibling {
return WorktreeRemoveVerdict::Deny(recheck_deny(
CHECK_WORKTREE_SCOPE,
&target,
"the target is not under a harness worktree root (`.claude/worktrees/`, \
`.worktrees/`, or a linked worktree in a `<repo>-worktrees/` sibling), and the \
grant reaches no other directory.",
));
}
WorktreeRemoveVerdict::ReCheck { target }
}
fn worktree_remove_segment(command: &str, cwd: &Path) -> Option<(PathBuf, Vec<String>)> {
git_verb_target_dir_with_tail(command, cwd, &PathEnv::from_process(), is_worktree_remove)
.map(|(_, dir, tail)| (dir, tail))
}
fn is_worktree_remove(verb: &str, tail: &[String]) -> bool {
verb == "worktree" && tail.first().map(String::as_str) == Some("remove")
}
fn removal_target_path(tail: &[String], base: &Path) -> Option<(String, PathBuf)> {
let arg = tail
.iter()
.skip(1)
.find(|t| !t.starts_with('-') && !t.is_empty())?;
Some((
arg.clone(),
resolve_target_path(arg, base, &PathEnv::from_process()),
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::pm_guard_bash::worktree_remove_rechecks::{
CHECK_CLEAN_TREE, CHECK_LOCAL_ONLY_COMMITS, CHECK_MERGED_PULL_REQUEST, CHECK_SOLE_OWNER,
CHECK_UNPUSHED_COMMITS, evaluate_removal_rechecks,
};
use trusty_mpm::core::worktree_carried_by_pr::{
CarriedByPr, MERGED_PR_ANCESTRY_CHECK as CHECK_MERGED_PR_ANCESTRY,
};
use trusty_mpm::core::worktree_landed_content::{
LANDED_CONTENT_CHECK as CHECK_LANDED_CONTENT, LandedContent, LandingAdmission,
};
use trusty_mpm::core::worktree_landed_history::ContentOnBase;
use trusty_mpm::core::worktree_removal_facts::{
MergedPrLookup, UpstreamComparison, WorktreeRemovalProbe,
};
fn engineer() -> DispatchIdentity<'static> {
DispatchIdentity {
agent_id: Some("agent-abc123"),
agent_type: Some("rust-engineer"),
}
}
fn version_control() -> DispatchIdentity<'static> {
DispatchIdentity {
agent_id: Some("agent-abc123"),
agent_type: Some("version-control"),
}
}
fn deny_reason(v: WorktreeRemoveVerdict) -> String {
match v {
WorktreeRemoveVerdict::Deny(r) => r,
WorktreeRemoveVerdict::Allow => panic!("expected a deny, got Allow"),
WorktreeRemoveVerdict::ReCheck { target } => {
panic!("expected a deny, got ReCheck({})", target.display())
}
}
}
fn recheck_target(v: WorktreeRemoveVerdict) -> PathBuf {
match v {
WorktreeRemoveVerdict::ReCheck { target } => target,
WorktreeRemoveVerdict::Allow => panic!("expected ReCheck, got Allow"),
WorktreeRemoveVerdict::Deny(r) => panic!("expected ReCheck, got Deny: {r}"),
}
}
fn is_allow(v: &WorktreeRemoveVerdict) -> bool {
matches!(v, WorktreeRemoveVerdict::Allow)
}
const WT: &str = "/repo/.claude/worktrees/agent-x";
struct FakeProbe {
dirty: Result<usize, String>,
unpushed: Result<UpstreamComparison, String>,
branch: Result<String, String>,
merged: Result<MergedPrLookup, String>,
merged_related: Option<Result<MergedPrLookup, String>>,
on_base: Result<ContentOnBase, String>,
local_only: Result<usize, String>,
head_sha: Result<String, String>,
commit_pr: Result<MergedPrLookup, String>,
asked_base: std::cell::RefCell<Option<String>>,
landed: LandedContent,
carried: Option<CarriedByPr>,
asked_fresh: std::cell::Cell<Option<bool>>,
nested: Result<Option<String>, String>,
after_merge: Result<Vec<String>, String>,
asked_after_merge: std::cell::Cell<bool>,
}
fn lookup(count: usize) -> MergedPrLookup {
lookup_on(count, if count == 0 { "" } else { "main" })
}
fn lookup_on(count: usize, base: &str) -> MergedPrLookup {
MergedPrLookup::new(count, FAKE_REPO, base).with_head_sha(if count == 0 {
""
} else {
MERGED_PR_HEAD
})
}
fn commit_lookup(count: usize) -> MergedPrLookup {
MergedPrLookup::new(count, FAKE_REPO, "").with_head_sha(if count == 0 {
""
} else {
WORKTREE_HEAD
})
}
const WORKTREE_HEAD: &str = "02a83032d1f0b4c9e7a6d5c4b3a291807f6e5d4c";
const MERGED_PR_HEAD: &str = "9c8699fe0a1b2c3d4e5f60718293a4b5c6d7e8f9";
const FAKE_REPO: &str = "1m-consulting/adaptive-crm";
fn tip_landed() -> ContentOnBase {
ContentOnBase::Landed { at: None }
}
fn residual() -> ContentOnBase {
ContentOnBase::Residual {
paths: vec!["src/lib.rs".into()],
searched: 0,
candidates: 0,
}
}
impl FakeProbe {
fn reclaimable() -> Self {
Self {
dirty: Ok(0),
unpushed: Ok(UpstreamComparison::Ahead(0)),
branch: Ok("feat/thing".to_string()),
merged: Ok(lookup(1)),
merged_related: None,
on_base: Ok(residual()),
local_only: Ok(1),
head_sha: Ok(WORKTREE_HEAD.to_string()),
commit_pr: Ok(commit_lookup(0)),
asked_base: std::cell::RefCell::new(None),
landed: LandedContent::unavailable("no landed-content answer was fabricated"),
carried: None,
asked_fresh: std::cell::Cell::new(None),
nested: Ok(None),
after_merge: Ok(Vec::new()),
asked_after_merge: std::cell::Cell::new(false),
}
}
fn round_sibling() -> Self {
Self {
branch: Ok("feat/thing-r2".to_string()),
merged: Ok(lookup(0)),
merged_related: Some(Ok(lookup(1))),
on_base: Ok(tip_landed()),
..Self::upstream_deleted()
}
}
fn upstream_deleted() -> Self {
Self {
unpushed: Ok(UpstreamComparison::NoUpstream),
..Self::reclaimable()
}
}
}
impl WorktreeRemovalProbe for FakeProbe {
fn dirty_entries(&self, _dir: &Path) -> Result<usize, String> {
self.dirty.clone()
}
fn unpushed_commits(&self, _dir: &Path) -> Result<UpstreamComparison, String> {
self.unpushed.clone()
}
fn branch(&self, _dir: &Path) -> Result<String, String> {
self.branch.clone()
}
fn local_only_commits(&self, _dir: &Path) -> Result<usize, String> {
self.local_only.clone()
}
fn commits_after_merged_head(
&self,
_dir: &Path,
_pr_head: &str,
) -> Result<Vec<String>, String> {
self.asked_after_merge.set(true);
self.after_merge.clone()
}
fn head_sha(&self, _dir: &Path) -> Result<String, String> {
self.head_sha.clone()
}
fn merged_pull_request_for_commit(
&self,
_dir: &Path,
_sha: &str,
) -> Result<MergedPrLookup, String> {
self.commit_pr.clone()
}
fn merged_pull_requests(
&self,
_dir: &Path,
branch: &str,
) -> Result<MergedPrLookup, String> {
if self.branch.as_deref() == Ok(branch) {
return self.merged.clone();
}
self.merged_related.clone().unwrap_or_else(|| Ok(lookup(0)))
}
fn content_on_base(&self, _dir: &Path, base_ref: &str) -> Result<ContentOnBase, String> {
*self.asked_base.borrow_mut() = Some(base_ref.to_string());
self.on_base.clone()
}
fn landing_admission(&self, _dir: &Path) -> LandingAdmission {
self.asked_fresh.set(Some(false));
LandingAdmission {
content: self.landed.clone(),
carried: self.carried.clone(),
}
}
fn landing_admission_on_fetched_refs(&self, dir: &Path) -> LandingAdmission {
let answer = self.landing_admission(dir);
self.asked_fresh.set(Some(true));
answer
}
fn nested_dirt(&self, _dir: &Path) -> Result<Option<String>, String> {
self.nested.clone()
}
}
const NESTED: &str = "nested git worktree/repository `scratch/side-project` holds unsaved \
work that `git status` on this directory cannot see: 1 unpushed commit";
#[test]
fn worktree_7889_nested_dirt_denies_a_landed_grant() {
let probe = FakeProbe {
nested: Ok(Some(NESTED.to_string())),
..donor_branch_landed()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("nested work must deny the landed-content grant");
assert!(reason.contains(CHECK_CLEAN_TREE), "{reason}");
assert!(reason.contains("scratch/side-project"), "{reason}");
}
#[test]
fn worktree_7889_nested_dirt_denies_a_merged_pr_grant() {
let merged = FakeProbe::upstream_deleted();
assert_eq!(
evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &merged),
None,
"premise: this merged, clean tree is granted"
);
let probe = FakeProbe {
nested: Ok(Some(NESTED.to_string())),
..FakeProbe::upstream_deleted()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("nested work must deny the merged-PR grant");
assert!(reason.contains("scratch/side-project"), "{reason}");
}
#[test]
fn worktree_7889_an_unanswerable_nested_scan_denies() {
let probe = FakeProbe {
nested: Err("nested-repository scan failed: permission denied".to_string()),
..donor_branch_landed()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("an unanswerable scan must deny");
assert!(reason.contains("permission denied"), "{reason}");
}
#[test]
fn worktree_7889_the_admission_reuses_the_local_only_fetch_only_when_it_succeeded() {
let fresh = donor_branch_landed();
assert_eq!(
evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &fresh),
None
);
assert_eq!(fresh.asked_fresh.get(), Some(true), "one fetch, reused");
let stale = FakeProbe {
local_only: Err("`git fetch --prune origin` did not finish within 3s".to_string()),
..donor_branch_landed()
};
assert_eq!(
evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &stale),
None
);
assert_eq!(
stale.asked_fresh.get(),
Some(false),
"a failed #7914 fetch must never be trusted as a refresh"
);
}
fn donor_branch_landed() -> FakeProbe {
FakeProbe {
branch: Ok("fix/8351-bridge-session-recovery-critic-r1".to_string()),
merged: Ok(lookup(0)),
merged_related: None,
local_only: Ok(3),
landed: LandedContent::Landed {
base: "origin/main".to_string(),
base_sha: "7df1c383f0a1b2c3d4e5f60718293a4b5c6d7e8f".to_string(),
landed_at: None,
},
..FakeProbe::upstream_deleted()
}
}
#[test]
fn worktree_7889_a_landed_tree_with_no_merged_pr_is_reclaimable() {
assert_eq!(
evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &donor_branch_landed()),
None,
"a clean tree holding no content the remote lacks must be reclaimable"
);
}
#[test]
fn worktree_7889_a_head_carried_by_a_merged_pr_is_reclaimable() {
let probe = FakeProbe {
landed: LandedContent::Residual {
base: "origin/main".to_string(),
first_path: "crates/trusty-mpm/src/daemon/mod.rs".to_string(),
},
carried: Some(CarriedByPr::Carried {
pr: 8328,
pr_head: "2222222222222222222222222222222222222222".to_string(),
}),
..donor_branch_landed()
};
assert_eq!(
evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe),
None,
"every commit here was inside what PR #8328 merged"
);
}
#[test]
fn worktree_7889_both_routes_failing_denies_and_names_each() {
let probe = FakeProbe {
landed: LandedContent::Residual {
base: "origin/main".to_string(),
first_path: "crates/trusty-mpm/src/daemon/mod.rs".to_string(),
},
carried: Some(CarriedByPr::Unavailable {
detail: "the MERGED pull request search did not answer: gh timed out".to_string(),
}),
..donor_branch_landed()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("neither route admitting must deny removal");
assert!(reason.contains(CHECK_LANDED_CONTENT), "{reason}");
assert!(reason.contains(CHECK_MERGED_PR_ANCESTRY), "{reason}");
assert!(
reason.contains("crates/trusty-mpm/src/daemon/mod.rs"),
"{reason}"
);
assert!(reason.contains("gh timed out"), "{reason}");
}
#[test]
fn worktree_7889_a_residual_path_denies_and_names_it() {
let probe = FakeProbe {
landed: LandedContent::Residual {
base: "origin/main".to_string(),
first_path: "crates/trusty-mpm/src/daemon/mod.rs".to_string(),
},
..donor_branch_landed()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("work the base does not hold must deny removal");
assert!(reason.contains(CHECK_MERGED_PULL_REQUEST), "{reason}");
assert!(reason.contains(CHECK_LANDED_CONTENT), "{reason}");
assert!(
reason.contains("crates/trusty-mpm/src/daemon/mod.rs"),
"the deny must name the first residual path: {reason}"
);
}
#[test]
fn worktree_7889_an_unestablished_landed_content_answer_never_grants() {
let probe = FakeProbe {
landed: LandedContent::unavailable(
"`origin` could not be refreshed, so the remote-tracking refs cannot be \
trusted: `git fetch --prune origin` did not finish within 3s",
),
..donor_branch_landed()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("an unestablished admission must never grant");
assert!(reason.contains(CHECK_LANDED_CONTENT), "{reason}");
assert!(
reason.contains("could not be refreshed"),
"the deny must quote what failed: {reason}"
);
}
#[test]
fn worktree_7889_a_dirty_tree_denies_even_when_its_content_is_landed() {
let probe = FakeProbe {
dirty: Ok(3),
..donor_branch_landed()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("unsaved work must deny removal");
assert!(reason.contains(CHECK_CLEAN_TREE), "{reason}");
assert!(reason.contains('3'), "{reason}");
}
#[test]
fn worktree_7889_a_live_owner_denies_even_when_its_content_is_landed() {
let owners = ["agent-acb948e25b1025822".to_string()];
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&owners), &donor_branch_landed())
.expect("a live owner must deny removal");
assert!(reason.contains(CHECK_SOLE_OWNER), "{reason}");
assert!(reason.contains("agent-acb948e25b1025822"), "{reason}");
}
#[test]
fn worktree_7889_an_unanswerable_lookup_never_reaches_the_admission() {
let probe = FakeProbe {
merged: Err(format!(
"gh timed out after 20s (repository searched: {FAKE_REPO})"
)),
..donor_branch_landed()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("an unanswerable lookup must deny removal");
assert!(reason.contains(CHECK_MERGED_PULL_REQUEST), "{reason}");
assert!(reason.contains("timed out"), "{reason}");
assert!(
!reason.contains(CHECK_LANDED_CONTENT),
"the admission must not be reached from an unestablished fact: {reason}"
);
}
#[test]
fn a_round_sibling_whose_related_pr_merged_is_reclaimable() {
assert!(
evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &FakeProbe::round_sibling())
.is_none(),
"a related MERGED pull request plus content on its base IS the landing \
evidence the merged-PR question stands in for"
);
}
#[test]
fn an_empty_merge_tree_without_any_merged_pr_still_denies() {
let probe = FakeProbe {
branch: Ok("feat/never-pushed".to_string()),
merged: Ok(lookup(0)),
merged_related: None,
on_base: Ok(tip_landed()),
..FakeProbe::upstream_deleted()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("the merged-PR route's merge-tree answer alone is not landing evidence");
assert!(reason.contains(CHECK_MERGED_PULL_REQUEST), "{reason}");
assert!(
reason.contains(CHECK_LANDED_CONTENT),
"the deny must name the admission that did not establish landing: {reason}"
);
assert_eq!(
*probe.asked_base.borrow(),
None,
"the merge-tree question must not be asked without a merged pull request"
);
}
#[test]
fn a_related_merged_pr_with_a_non_empty_merge_tree_denies_with_the_residue() {
let probe = FakeProbe {
on_base: Ok(residual()),
..FakeProbe::round_sibling()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("work the merge did not carry must deny");
assert!(reason.contains(CHECK_MERGED_PULL_REQUEST), "{reason}");
assert!(reason.contains("would still change files"), "{reason}");
assert!(reason.contains("diff --name-only"), "{reason}");
assert!(reason.contains("Probe result:"), "{reason}");
assert!(reason.contains("`src/lib.rs`"), "{reason}");
}
#[test]
fn the_merge_tree_is_judged_against_the_merged_prs_own_base() {
let probe = FakeProbe {
merged_related: Some(Ok(lookup_on(1, "release/2.0"))),
..FakeProbe::round_sibling()
};
assert!(
evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe).is_none(),
"content on the PR's own base is still landing evidence"
);
assert_eq!(
probe.asked_base.borrow().as_deref(),
Some("origin/release/2.0"),
"the merge-tree question must use the pull request's base, not origin/HEAD"
);
}
#[test]
fn a_merged_pr_with_no_base_ref_denies() {
let probe = FakeProbe {
merged_related: Some(Ok(lookup_on(1, ""))),
..FakeProbe::round_sibling()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("a base that cannot be established denies");
assert!(reason.contains("named no base branch"), "{reason}");
assert!(
probe.asked_base.borrow().is_none(),
"the merge-tree question must not be asked without a base"
);
}
#[test]
fn a_stale_upstream_no_longer_refuses_a_merged_tree() {
let probe = FakeProbe {
unpushed: Ok(UpstreamComparison::Ahead(1)),
on_base: Ok(tip_landed()),
..FakeProbe::reclaimable()
};
assert!(
evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe).is_none(),
"a stale tracking ref is not unpushed work when the content is on the base"
);
}
#[test]
fn a_stale_upstream_still_denies_when_work_is_not_on_the_base() {
let probe = FakeProbe {
unpushed: Ok(UpstreamComparison::Ahead(1)),
on_base: Ok(residual()),
..FakeProbe::reclaimable()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("real unpushed work must deny");
assert!(reason.contains(CHECK_UNPUSHED_COMMITS), "{reason}");
assert!(reason.contains("on no remote"), "{reason}");
assert!(reason.contains("diff --name-only"), "{reason}");
}
#[test]
fn a_sibling_whose_content_cannot_be_checked_denies() {
let probe = FakeProbe {
on_base: Err("git could not be run".to_string()),
..FakeProbe::round_sibling()
};
assert!(
evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe).is_some(),
"undeterminable is not absent"
);
}
#[test]
fn content_landed_at_an_earlier_base_commit_allows() {
let probe = FakeProbe {
on_base: Ok(ContentOnBase::Landed {
at: Some(MERGED_PR_HEAD.to_string()),
}),
..FakeProbe::round_sibling()
};
assert_eq!(
evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe),
None,
"a history hit is landing evidence"
);
assert!(probe.asked_base.borrow().is_some(), "the probe was asked");
}
#[test]
fn a_conflicted_merge_with_no_landing_commit_denies() {
let probe = FakeProbe {
on_base: Ok(ContentOnBase::Conflicted {
paths: vec!["src/main.rs".into()],
searched: 3,
candidates: 3,
}),
..FakeProbe::round_sibling()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("a conflict with no landing commit must deny");
assert!(reason.contains(CHECK_MERGED_PULL_REQUEST), "{reason}");
assert!(reason.contains("conflicts in 1 file(s)"), "{reason}");
assert!(reason.contains("`src/main.rs`"), "{reason}");
assert!(reason.contains("none of the 3 commit(s)"), "{reason}");
}
#[test]
fn an_undone_landing_denies_and_names_what_was_taken_back() {
let probe = FakeProbe {
on_base: Ok(ContentOnBase::Undone {
at: MERGED_PR_HEAD.to_string(),
paths: vec!["added.txt".into()],
searched: 1,
candidates: 1,
}),
..FakeProbe::round_sibling()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("an undone landing must deny");
assert!(reason.contains(CHECK_MERGED_PULL_REQUEST), "{reason}");
assert!(
reason.contains("no longer holds all of what landed"),
"{reason}"
);
assert!(reason.contains("`added.txt`"), "{reason}");
assert!(!reason.contains("would still change files"), "{reason}");
}
#[test]
fn an_exhausted_candidate_cap_denies_and_says_it_was_truncated() {
let probe = FakeProbe {
on_base: Ok(ContentOnBase::Conflicted {
paths: vec!["src/main.rs".into()],
searched: 24,
candidates: 40,
}),
..FakeProbe::round_sibling()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("a truncated search must deny");
assert!(reason.contains(CHECK_MERGED_PULL_REQUEST), "{reason}");
assert!(reason.contains("the oldest 24 of 40 commit(s)"), "{reason}");
assert!(
reason.contains("the newer 16 were not searched"),
"{reason}"
);
}
#[test]
fn denies_worktree_remove_from_a_subagent() {
let reason = deny_reason(evaluate_worktree_remove_command(
"git worktree remove --force .claude/worktrees/agent-x",
true,
engineer(),
Path::new("/repo"),
));
assert!(reason.contains("#5791"), "{reason}");
assert!(reason.contains("hand it back"), "{reason}");
}
#[test]
fn allows_worktree_remove_from_the_pm() {
assert!(is_allow(&evaluate_worktree_remove_command(
"git worktree remove .claude/worktrees/baseline-1",
false,
DispatchIdentity::default(),
Path::new("/repo"),
)));
}
#[test]
fn allows_non_remove_worktree_subcommands() {
for command in [
"git worktree list",
"git worktree prune",
"git worktree lock .claude/worktrees/agent-x",
"git status",
"",
] {
assert!(
is_allow(&evaluate_worktree_remove_command(
command,
true,
engineer(),
Path::new("/repo"),
)),
"expected allow for: {command}"
);
assert!(
is_allow(&evaluate_worktree_remove_command(
command,
true,
version_control(),
Path::new("/repo"),
)),
"expected allow for version-control: {command}"
);
}
}
#[test]
fn denies_a_remove_hidden_in_a_composed_command() {
for command in [
"cargo test -p trusty-mpm && git worktree remove /some/tree",
"git -C /repo worktree remove --force /repo/.claude/worktrees/agent-x",
"true; git worktree remove wt",
] {
assert_eq!(
deny_reason(evaluate_worktree_remove_command(
command,
true,
engineer(),
Path::new("/repo"),
)),
WORKTREE_REMOVE_DENY_REASON,
"expected deny for: {command}"
);
}
}
#[test]
fn denies_a_remove_behind_an_eval_it_cannot_place() {
for (command, identity) in [
(
"command -p eval \"cd /elsewhere\"; git worktree remove --force \
.claude/worktrees/agent-x",
version_control(),
),
(
"command eval \"cd /elsewhere\"; git worktree remove .claude/worktrees/agent-x",
engineer(),
),
] {
let reason = deny_reason(evaluate_worktree_remove_command(
command,
true,
identity,
Path::new("/repo"),
));
assert!(reason.contains("cannot place it"), "{command}: {reason}");
}
let target = recheck_target(evaluate_worktree_remove_command(
"cd /repo && git worktree remove --force .claude/worktrees/agent-x",
true,
version_control(),
Path::new("/elsewhere"),
));
assert_eq!(target, PathBuf::from(WT));
}
#[test]
fn denies_a_remove_behind_an_unknown_git_global_option() {
let reason = deny_reason(evaluate_worktree_remove_command(
"git --shallow-file x worktree remove /repo/.claude/worktrees/agent-x",
true,
engineer(),
Path::new("/repo"),
));
assert_eq!(reason, WORKTREE_REMOVE_DENY_REASON);
}
#[test]
fn resolves_the_removal_target_against_a_dash_c_directory() {
let target = recheck_target(evaluate_worktree_remove_command(
"git -C /repo worktree remove --force .claude/worktrees/agent-x",
true,
version_control(),
Path::new("/elsewhere"),
));
assert_eq!(target, PathBuf::from(WT));
}
#[test]
fn denies_a_removal_whose_path_carries_an_unexpanded_variable() {
let reason = deny_reason(evaluate_worktree_remove_command(
"git -C $MAIN worktree remove $MAIN/.claude/worktrees/agent-x",
true,
version_control(),
Path::new("/repo"),
));
assert!(reason.contains(CHECK_WORKTREE_SCOPE), "{reason}");
assert!(reason.contains("$MAIN"), "{reason}");
assert!(reason.contains("unresolved shell expansion"), "{reason}");
assert!(
!reason.contains("$MAIN/$MAIN"),
"the doubled join must never reach the message: {reason}"
);
assert!(
!reason.contains(CHECK_CLEAN_TREE),
"an unresolvable path is not a dirty tree: {reason}"
);
}
#[test]
fn denies_a_removal_whose_dash_c_carries_an_unexpanded_variable() {
let reason = deny_reason(evaluate_worktree_remove_command(
"git -C ${MAIN}/sub worktree remove ../.claude/worktrees/agent-x",
true,
version_control(),
Path::new("/repo"),
));
assert!(reason.contains(CHECK_WORKTREE_SCOPE), "{reason}");
assert!(reason.contains("${MAIN}"), "{reason}");
}
#[test]
fn allows_a_removal_whose_path_uses_an_expanded_variable() {
let target = recheck_target(evaluate_worktree_remove_command(
"git worktree remove --force $PWD/.claude/worktrees/agent-x",
true,
version_control(),
Path::new("/repo"),
));
assert_eq!(target, PathBuf::from(WT));
}
#[test]
fn allows_worktree_remove_from_version_control_on_clean_merged_unowned_tree() {
let target = recheck_target(evaluate_worktree_remove_command(
"git worktree remove --force .claude/worktrees/agent-x",
true,
version_control(),
Path::new("/repo"),
));
assert_eq!(target, PathBuf::from(WT));
assert_eq!(
evaluate_removal_rechecks(&target, Ok(&[]), &FakeProbe::reclaimable()),
None,
"a clean, pushed, merged, unowned tree must pass every re-check"
);
}
#[test]
fn denies_worktree_remove_from_version_control_when_tree_dirty() {
let probe = FakeProbe {
dirty: Ok(3),
..FakeProbe::reclaimable()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("a dirty tree must deny removal");
assert!(reason.contains(CHECK_CLEAN_TREE), "{reason}");
assert!(reason.contains('3'), "{reason}");
}
#[test]
fn denies_worktree_remove_from_version_control_when_commits_are_unpushed() {
let probe = FakeProbe {
unpushed: Ok(UpstreamComparison::Ahead(2)),
..FakeProbe::reclaimable()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("an unpushed commit must deny removal");
assert!(reason.contains(CHECK_UNPUSHED_COMMITS), "{reason}");
assert!(reason.contains('2'), "{reason}");
}
#[test]
fn denies_worktree_remove_from_version_control_when_no_merged_pr() {
let probe = FakeProbe {
merged: Ok(lookup(0)),
..FakeProbe::reclaimable()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("an unmerged branch must deny removal");
assert!(reason.contains(CHECK_MERGED_PULL_REQUEST), "{reason}");
assert!(reason.contains("feat/thing"), "{reason}");
assert!(!reason.contains("--force"), "{reason}");
}
#[test]
fn deny_names_the_repository_the_merged_pr_lookup_searched() {
let probe = FakeProbe {
merged: Ok(lookup(0)),
..FakeProbe::reclaimable()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("an unmerged branch must deny removal");
assert!(
reason.contains(FAKE_REPO),
"the deny must name the repository searched: {reason}"
);
assert!(reason.contains("origin"), "{reason}");
}
#[test]
fn denies_worktree_remove_from_version_control_when_another_agent_holds_lock() {
let owners = vec!["rust-engineer".to_string()];
let reason =
evaluate_removal_rechecks(Path::new(WT), Ok(&owners), &FakeProbe::reclaimable())
.expect("a tree another live agent holds must deny removal");
assert!(reason.contains(CHECK_SOLE_OWNER), "{reason}");
assert!(reason.contains("rust-engineer"), "{reason}");
}
#[test]
fn denies_worktree_remove_from_version_control_when_the_owner_query_fails() {
let reason = evaluate_removal_rechecks(
Path::new(WT),
Err("nothing is listening at http://127.0.0.1:1"),
&FakeProbe::reclaimable(),
)
.expect("an unanswered owner query must deny removal");
assert!(reason.contains(CHECK_SOLE_OWNER), "{reason}");
assert!(reason.contains("nothing is listening"), "{reason}");
}
#[test]
fn denies_worktree_remove_when_agent_type_claims_version_control_without_agent_id() {
let spoof = DispatchIdentity {
agent_id: None,
agent_type: Some("version-control"),
};
let reason = deny_reason(evaluate_worktree_remove_command(
"git worktree remove --force .claude/worktrees/agent-x",
true,
spoof,
Path::new("/repo"),
));
assert!(reason.contains(CHECK_DISPATCH_IDENTITY), "{reason}");
assert!(reason.contains("agent_id"), "{reason}");
}
#[test]
fn denies_version_control_a_target_outside_a_harness_worktree() {
let reason = deny_reason(evaluate_worktree_remove_command(
"git worktree remove --force /repo/some/other/dir",
true,
version_control(),
Path::new("/repo"),
));
assert!(reason.contains(CHECK_WORKTREE_SCOPE), "{reason}");
}
#[test]
fn version_control_reaches_the_rechecks_for_a_sibling_layout_worktree() {
let tmp = tempfile::tempdir().expect("tempdir");
let linked = tmp.path().join("proj-worktrees").join("agent-a1");
std::fs::create_dir_all(&linked).expect("mkdir linked");
std::fs::write(
linked.join(".git"),
"gitdir: /proj/.git/worktrees/agent-a1\n",
)
.expect("write .git file");
let command = format!("git worktree remove {}", linked.display());
let target = recheck_target(evaluate_worktree_remove_command(
&command,
true,
version_control(),
tmp.path(),
));
assert_eq!(target, linked);
let main = tmp.path().join("old-worktrees").join("proj");
std::fs::create_dir_all(main.join(".git")).expect("mkdir main .git");
let command = format!("git worktree remove {}", main.display());
let reason = deny_reason(evaluate_worktree_remove_command(
&command,
true,
version_control(),
tmp.path(),
));
assert!(reason.contains(CHECK_WORKTREE_SCOPE), "{reason}");
}
#[test]
fn denies_version_control_when_a_fact_cannot_be_established() {
let probe = FakeProbe {
unpushed: Err("`git rev-parse --verify HEAD` failed: not a git repository".to_string()),
..FakeProbe::reclaimable()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("an unestablished fact must deny removal");
assert!(reason.contains(CHECK_UNPUSHED_COMMITS), "{reason}");
assert!(reason.contains("not a git repository"), "{reason}");
}
#[test]
fn a_merged_pr_clears_a_worktree_whose_upstream_is_gone() {
assert_eq!(
evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &FakeProbe::upstream_deleted()),
None,
"a clean tree whose branch has a MERGED pull request must be removable even \
though the merge deleted its upstream"
);
assert!(
probe_asked_after_merge(&FakeProbe::upstream_deleted()),
"#8665: the own-PR grant now asks what HEAD holds past the merged head"
);
}
fn probe_asked_after_merge(probe: &FakeProbe) -> bool {
let _ = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), probe);
probe.asked_after_merge.get()
}
#[test]
fn worktree_8665_an_unanswerable_post_merge_list_defers_to_the_content_probe() {
let unanswerable = || Err("`git rev-list` failed: bad revision".to_string());
let probe = FakeProbe {
after_merge: unanswerable(),
..FakeProbe::upstream_deleted()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("an unanswerable post-merge list must not grant over residue");
assert!(reason.contains(CHECK_MERGED_PULL_REQUEST), "{reason}");
assert!(reason.contains("bad revision"), "{reason}");
assert!(reason.contains("could not be established"), "{reason}");
let landed = FakeProbe {
after_merge: unanswerable(),
on_base: Ok(tip_landed()),
..FakeProbe::upstream_deleted()
};
assert_eq!(
evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &landed),
None,
"content proven on the base admits whatever the post-merge list said"
);
}
#[test]
fn worktree_8665_a_merged_pr_with_no_head_sha_never_admits_residue() {
let probe = FakeProbe {
merged: Ok(MergedPrLookup::new(1, FAKE_REPO, "main")),
on_base: Ok(residual()),
..FakeProbe::upstream_deleted()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("a merged pull request with no head must not vouch for residue");
assert!(!probe.asked_after_merge.get(), "{reason}");
assert!(reason.contains(CHECK_MERGED_PULL_REQUEST), "{reason}");
assert!(reason.contains("named no head commit"), "{reason}");
}
#[test]
fn worktree_8665_stale_origin_refs_are_never_asked() {
let probe = FakeProbe {
local_only: Err("`origin` could not be refreshed: timed out".to_string()),
..FakeProbe::upstream_deleted()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("stale refs must not vouch for post-merge commits");
assert!(!probe.asked_after_merge.get(), "{reason}");
assert!(reason.contains("was not refreshed"), "{reason}");
}
#[test]
fn no_upstream_and_no_merged_pr_denies_and_names_the_branch() {
let probe = FakeProbe {
merged: Ok(lookup(0)),
..FakeProbe::upstream_deleted()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("no upstream and no merged pull request must deny removal");
assert!(reason.contains(CHECK_MERGED_PULL_REQUEST), "{reason}");
assert!(reason.contains("feat/thing"), "{reason}");
assert!(
reason.contains("tracks no upstream"),
"the deny must say the upstream is gone too: {reason}"
);
}
#[test]
fn no_upstream_and_an_unanswerable_merged_pr_lookup_denies() {
let probe = FakeProbe {
merged: Err(format!(
"gh timed out after 20s (repository searched: {FAKE_REPO})"
)),
..FakeProbe::upstream_deleted()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("an unanswerable merged-PR lookup must deny removal");
assert!(reason.contains(CHECK_MERGED_PULL_REQUEST), "{reason}");
assert!(reason.contains("feat/thing"), "{reason}");
assert!(reason.contains(WT), "{reason}");
assert!(reason.contains(FAKE_REPO), "{reason}");
}
#[test]
fn a_dirty_tree_denies_even_when_merged_with_no_upstream() {
let probe = FakeProbe {
dirty: Ok(4),
..FakeProbe::upstream_deleted()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("unsaved work must deny removal");
assert!(reason.contains(CHECK_CLEAN_TREE), "{reason}");
assert!(reason.contains('4'), "{reason}");
}
fn no_pr_fully_landed() -> FakeProbe {
FakeProbe {
branch: Ok("fix/7965-sweep-stall-salvage".to_string()),
merged: Ok(lookup(0)),
merged_related: None,
on_base: Ok(residual()),
local_only: Ok(0),
..FakeProbe::upstream_deleted()
}
}
#[test]
fn a_clean_tree_whose_commits_are_all_on_origin_needs_no_pull_request() {
assert_eq!(
evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &no_pr_fully_landed()),
None,
"a tree that is the only place for nothing cannot lose anything to a removal"
);
}
#[test]
fn a_detached_head_holding_no_local_only_commit_is_reclaimable() {
let probe = FakeProbe {
branch: Err("HEAD is detached — the worktree has no branch to look a \
pull request up by"
.to_string()),
..no_pr_fully_landed()
};
assert_eq!(
evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe),
None,
"a detached HEAD whose commits are all on origin holds nothing to lose"
);
}
#[test]
fn a_commit_no_origin_ref_has_still_denies_without_a_merged_pr() {
let probe = FakeProbe {
local_only: Ok(1),
on_base: Ok(tip_landed()),
..no_pr_fully_landed()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("a commit that exists only here must deny removal");
assert!(reason.contains(CHECK_MERGED_PULL_REQUEST), "{reason}");
assert!(reason.contains(CHECK_LOCAL_ONLY_COMMITS), "{reason}");
assert!(
reason.contains("only place they exist"),
"the deny must name the work at risk: {reason}"
);
}
#[test]
fn a_dirty_tree_denies_even_when_no_commit_is_local_only() {
let probe = FakeProbe {
dirty: Ok(2),
..no_pr_fully_landed()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("unsaved work must deny removal");
assert!(reason.contains(CHECK_CLEAN_TREE), "{reason}");
assert!(reason.contains('2'), "{reason}");
}
#[test]
fn a_live_owner_denies_even_when_no_commit_is_local_only() {
let owners = ["agent-a5fc62fa0f4e5ba3d".to_string()];
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&owners), &no_pr_fully_landed())
.expect("a live owner must deny removal");
assert!(reason.contains(CHECK_SOLE_OWNER), "{reason}");
assert!(reason.contains("agent-a5fc62fa0f4e5ba3d"), "{reason}");
}
#[test]
fn an_unanswerable_local_only_count_never_admits() {
let probe = FakeProbe {
local_only: Err("`git rev-list` exited 128: not a git repository".to_string()),
..no_pr_fully_landed()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("an unestablished admission must never grant");
assert!(reason.contains(CHECK_MERGED_PULL_REQUEST), "{reason}");
assert!(reason.contains("exited 128"), "{reason}");
assert!(
reason.contains("could not be established"),
"the deny must separate an unanswerable count from a real commit: {reason}"
);
}
fn own_pr_merged_this_head() -> FakeProbe {
FakeProbe {
unpushed: Ok(UpstreamComparison::Ahead(4)),
head_sha: Ok(MERGED_PR_HEAD.to_string()),
..FakeProbe::reclaimable()
}
}
#[test]
fn a_head_sha_matching_the_merged_prs_own_head_grants_despite_a_stale_upstream() {
assert_eq!(
evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &own_pr_merged_this_head()),
None,
"a worktree whose HEAD IS the merged pull request's head holds nothing that \
merge did not carry"
);
}
#[test]
fn a_head_that_is_not_the_merged_prs_head_still_denies_when_ahead() {
let probe = FakeProbe {
head_sha: Ok(WORKTREE_HEAD.to_string()),
..own_pr_merged_this_head()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("work ahead of the upstream on another commit must still deny");
assert!(reason.contains(CHECK_UNPUSHED_COMMITS), "{reason}");
assert!(reason.contains("would still change files"), "{reason}");
}
#[test]
fn an_unanswerable_head_sha_never_grants_an_ahead_worktree() {
let probe = FakeProbe {
head_sha: Err("`git rev-parse HEAD` exited 128: ambiguous argument".to_string()),
..own_pr_merged_this_head()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("an unresolvable HEAD must never grant on the head-sha route");
assert!(reason.contains(CHECK_UNPUSHED_COMMITS), "{reason}");
}
#[test]
fn a_merged_pr_carrying_no_head_sha_never_grants_an_ahead_worktree() {
let probe = FakeProbe {
merged: Ok(MergedPrLookup::new(1, FAKE_REPO, "main")),
head_sha: Ok(String::new()),
..own_pr_merged_this_head()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("two unknown head commits are not a match");
assert!(reason.contains(CHECK_UNPUSHED_COMMITS), "{reason}");
}
fn detached_on_a_merged_pr_head() -> FakeProbe {
FakeProbe {
branch: Err("HEAD is detached — the worktree has no branch to look a \
pull request up by"
.to_string()),
commit_pr: Ok(commit_lookup(1)),
..FakeProbe::upstream_deleted()
}
}
#[test]
fn a_detached_head_that_is_a_merged_prs_own_head_is_reclaimable() {
assert_eq!(
evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &detached_on_a_merged_pr_head()),
None,
"a commit a merged pull request was opened from is landing evidence whether or \
not a branch name still points at it"
);
}
#[test]
fn a_detached_head_no_merged_pr_carries_still_denies() {
let probe = FakeProbe {
commit_pr: Ok(commit_lookup(0)),
..detached_on_a_merged_pr_head()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("a detached commit nothing landed must deny removal");
assert!(reason.contains(CHECK_MERGED_PULL_REQUEST), "{reason}");
assert!(reason.contains(WORKTREE_HEAD), "{reason}");
assert!(reason.contains(FAKE_REPO), "{reason}");
assert!(!reason.contains("--force"), "{reason}");
}
#[test]
fn a_detached_head_matched_to_a_pull_request_with_another_head_denies() {
let probe = FakeProbe {
commit_pr: Ok(MergedPrLookup::new(1, FAKE_REPO, "").with_head_sha(MERGED_PR_HEAD)),
..detached_on_a_merged_pr_head()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("a pull request opened from another commit is not this tree's evidence");
assert!(reason.contains(CHECK_MERGED_PULL_REQUEST), "{reason}");
assert!(reason.contains(MERGED_PR_HEAD), "{reason}");
assert!(
reason.contains("merely mentions a commit"),
"the deny must say why the reported pull request did not count: {reason}"
);
}
#[test]
fn a_detached_head_matched_to_a_pull_request_with_no_head_denies() {
let probe = FakeProbe {
commit_pr: Ok(MergedPrLookup::new(1, FAKE_REPO, "")),
..detached_on_a_merged_pr_head()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("a pull request with no head commit cannot vouch for this tree");
assert!(reason.contains(CHECK_MERGED_PULL_REQUEST), "{reason}");
assert!(reason.contains("named no head commit"), "{reason}");
}
#[test]
fn an_unanswerable_commit_search_denies_a_detached_head() {
let probe = FakeProbe {
commit_pr: Err("`gh pr list` timed out after 10s".to_string()),
..detached_on_a_merged_pr_head()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("an unanswered commit search must never grant");
assert!(reason.contains(CHECK_MERGED_PULL_REQUEST), "{reason}");
assert!(reason.contains("timed out"), "{reason}");
}
#[test]
fn an_unresolvable_head_sha_denies_a_detached_head() {
let probe = FakeProbe {
head_sha: Err("`git rev-parse HEAD` exited 128: unknown revision".to_string()),
..detached_on_a_merged_pr_head()
};
let reason = evaluate_removal_rechecks(Path::new(WT), Ok(&[]), &probe)
.expect("an unresolvable HEAD on a detached tree must deny");
assert!(reason.contains(CHECK_MERGED_PULL_REQUEST), "{reason}");
assert!(reason.contains("unknown revision"), "{reason}");
assert!(reason.contains("HEAD is detached"), "{reason}");
}
#[test]
fn reads_the_dispatch_identity_off_a_payload() {
let payload = serde_json::json!({
"agent_id": "agent-abc123",
"agent_type": "version-control",
});
assert!(DispatchIdentity::from_payload(&payload).is_permitted_remover());
let empty = serde_json::json!({ "agent_id": "", "agent_type": "version-control" });
assert!(!DispatchIdentity::from_payload(&empty).is_permitted_remover());
}
}