use std::path::{Path, PathBuf};
use super::*;
use crate::core::agent::DelegationId;
use crate::core::session::SessionId;
use crate::session_manager::decommission::WORKTREE_SENTINEL_FILE;
use crate::session_manager::provisioning_ledger;
use crate::session_manager::worktree_git_fixture::{GitWorktreeFixture, deny_all};
use crate::session_manager::worktree_ownership::{
AgentWorktreeOwner, sentinel_payload_bytes, write_agent_sentinel,
};
use crate::session_manager::worktree_ownership_location::{
admin_sentinel_path, write_sentinel_bytes,
};
fn mark_owned(wt: &Path) {
write_sentinel_bytes(wt, b"").expect("write the admin-dir marker");
}
fn admin_marker(wt: &Path) -> PathBuf {
admin_sentinel_path(wt).expect("the admin dir resolves")
}
fn provisioned_tree(fx: &GitWorktreeFixture, name: &str) -> PathBuf {
let wt = fx.add_worktree(name);
std::fs::write(wt.join(".gitignore"), "target/\n").expect("write .gitignore");
GitWorktreeFixture::commit_all_and_push(&wt, "track .gitignore");
mark_owned(&wt);
let before = provisioning_ledger::snapshot(&wt);
crate::core::scaffold_gitignore::append_legacy_block(&wt);
std::fs::create_dir_all(wt.join(".claude")).expect("mkdir .claude");
std::fs::write(wt.join(".claude/settings.json"), "{}\n").expect("write settings");
std::fs::write(wt.join(".claude/settings.json.bak"), "{}\n").expect("write settings bak");
std::fs::write(wt.join("CLAUDE.md"), "# tm\n").expect("write CLAUDE.md");
assert!(
provisioning_ledger::record(&wt, &before).expect("record the ledger"),
"premise: the worktree has an admin dir for the ledger"
);
wt
}
fn ledger_file(wt: &Path) -> PathBuf {
admin_marker(wt)
.parent()
.expect("the marker has a parent")
.join(provisioning_ledger::LEDGER_NAME)
}
const SETTINGS_SNAPSHOT: &str = ".claude/settings.json.20260926T140608Z.bak";
const TASK: &str = "Fix the bug\n";
fn task_bearing_tree(fx: &GitWorktreeFixture, name: &str) -> PathBuf {
let wt = provisioned_tree(fx, name);
std::fs::write(wt.join("TASK.md"), TASK).expect("write TASK.md");
std::fs::write(wt.join(SETTINGS_SNAPSHOT), "{}\n").expect("write the snapshot");
wt
}
fn count_and_list(reason: &str) -> (usize, Vec<String>) {
let (head, _) = reason
.split_once(" uncommitted/untracked file(s)")
.expect("the reason states a file count");
let count = head
.rsplit(|c: char| !c.is_ascii_digit())
.next()
.and_then(|n| n.parse().ok())
.expect("a numeric count");
let listed = reason
.split_once("unpushed commit(s): ")
.and_then(|(_, rest)| rest.split_once(')'))
.map(|(list, _)| list.split(", ").map(str::to_string).collect())
.unwrap_or_default();
(count, listed)
}
async fn remove(wt: &Path, policy: ProvisioningDirt) -> WorkspaceVerdict {
remove_as(&ManagedSessionId::new(), wt, policy).await
}
async fn remove_as(id: &ManagedSessionId, wt: &Path, policy: ProvisioningDirt) -> WorkspaceVerdict {
remove_in_project_worktree(id, Some(TASK), wt, policy).await
}
#[test]
fn provisioning_entry_matches_only_the_four_paths_in_provisioning_states() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-entry-7660");
let is = |line: &str| is_provisioning_entry(&wt, None, line);
assert!(is(" M .gitignore"));
assert!(is("?? .claude/settings.json"));
assert!(is("?? .claude/settings.json.bak"));
assert!(is("?? CLAUDE.md"));
assert!(!is("M .gitignore"), "a staged change is not provisioning");
assert!(!is(" D CLAUDE.md"), "a deletion is not provisioning");
assert!(
!is(" M CLAUDE.md"),
"an edit to a tracked CLAUDE.md is not provisioning"
);
assert!(
!is(" M .claude/settings.json"),
"an edit to a tracked settings file is not provisioning"
);
assert!(!is("?? .claude/"), "a collapsed directory hides its files");
assert!(!is("?? src/main.rs"));
}
#[tokio::test]
async fn force_decommission_keeps_an_edited_tracked_claude_md() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("decom-force-claude-md-7660");
std::fs::write(wt.join("CLAUDE.md"), "# project rules\n").expect("write CLAUDE.md");
GitWorktreeFixture::commit_all_and_push(&wt, "track CLAUDE.md");
std::fs::write(wt.join("CLAUDE.md"), "# project rules\n- a new rule\n")
.expect("edit CLAUDE.md");
let verdict = remove(&wt, ProvisioningDirt::Discard).await;
assert!(
!verdict.removed,
"an edited tracked CLAUDE.md must keep the tree"
);
assert_eq!(
std::fs::read_to_string(wt.join("CLAUDE.md")).expect("read CLAUDE.md"),
"# project rules\n- a new rule\n",
"the edit must survive"
);
assert!(verdict.kept_reason.is_some());
}
#[tokio::test]
async fn force_decommission_keeps_a_gitignore_with_a_non_provisioning_line() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-force-gitignore-7660");
let mut body = std::fs::read_to_string(wt.join(".gitignore")).expect("read .gitignore");
body.push_str("secrets.env\n");
std::fs::write(wt.join(".gitignore"), &body).expect("append a user line");
let verdict = remove(&wt, ProvisioningDirt::Discard).await;
assert!(
!verdict.removed,
"a user's .gitignore line must keep the tree"
);
assert!(
std::fs::read_to_string(wt.join(".gitignore"))
.expect("read .gitignore")
.contains("secrets.env"),
"the user's line must survive"
);
}
#[tokio::test]
async fn decommission_reports_why_it_kept_a_provisioned_worktree() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-refuse-7660");
let verdict = remove(&wt, ProvisioningDirt::Refuse).await;
assert!(!verdict.removed && wt.exists());
let reason = verdict.kept_reason.expect("a kept workspace must say why");
assert!(reason.contains("--force"), "reason: {reason}");
}
#[tokio::test]
async fn decommission_refusal_names_the_untracked_claude_files() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-refuse-names-8540");
let verdict = remove(&wt, ProvisioningDirt::Refuse).await;
assert!(!verdict.removed && wt.exists());
let reason = verdict.kept_reason.expect("a kept workspace must say why");
for entry in ["?? CLAUDE.md", "?? .claude/settings.json"] {
assert!(reason.contains(entry), "reason: {reason}");
}
assert!(reason.contains("unedited CLAUDE.md"), "reason: {reason}");
assert!(!reason.contains("WARNING"), "reason: {reason}");
}
#[test]
fn provisioning_entry_excuses_claude_files_only_as_the_ledger_recorded_them() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-entry-ledger-8540");
let is = |line: &str| is_provisioning_entry(&wt, None, line);
for rel in [
"CLAUDE.md",
".claude/settings.json",
".claude/settings.json.bak",
] {
let line = format!("?? {rel}");
assert!(is(&line), "premise: tm's own {rel} is excused");
let mut body = std::fs::read(wt.join(rel)).expect("read");
body.extend_from_slice(b"# a user's note\n");
std::fs::write(wt.join(rel), &body).expect("edit");
assert!(!is(&line), "an edited {rel} is not provisioning");
}
}
#[test]
fn provisioning_entry_refuses_a_claude_md_that_is_not_a_regular_file() {
use std::os::unix::ffi::OsStrExt as _;
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-entry-fifo-8540");
let claude_md = wt.join("CLAUDE.md");
std::fs::rename(&claude_md, wt.join("tm-wrote.md")).expect("move the bytes aside");
std::os::unix::fs::symlink("tm-wrote.md", &claude_md).expect("symlink CLAUDE.md");
assert!(
!is_provisioning_entry(&wt, None, "?? CLAUDE.md"),
"a symlink is not the file tm wrote"
);
std::fs::remove_file(&claude_md).expect("drop the symlink");
let c = std::ffi::CString::new(claude_md.as_os_str().as_bytes()).expect("no NUL");
assert_eq!(unsafe { libc::mkfifo(c.as_ptr(), 0o600) }, 0, "mkfifo");
let (tx, rx) = std::sync::mpsc::channel();
let probe = wt.clone();
std::thread::spawn(move || {
let _ = tx.send(is_provisioning_entry(&probe, None, "?? CLAUDE.md"));
});
let answer = rx.recv_timeout(std::time::Duration::from_secs(10));
if answer.is_err() {
let _ = std::fs::OpenOptions::new().write(true).open(&claude_md);
}
assert_eq!(answer, Ok(false), "a FIFO is refused without being opened");
}
#[tokio::test]
async fn force_decommission_keeps_notes_appended_to_an_untracked_claude_md() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-force-claude-notes-8540");
let edited = "# tm\n- always run the gate twice\n";
std::fs::write(wt.join("CLAUDE.md"), edited).expect("append a note");
let verdict = remove(&wt, ProvisioningDirt::Discard).await;
assert!(!verdict.removed, "an edited CLAUDE.md must keep the tree");
assert_eq!(
std::fs::read_to_string(wt.join("CLAUDE.md")).expect("CLAUDE.md survives"),
edited,
"the note survives untouched"
);
let reason = verdict.kept_reason.expect("a kept tree must say why");
assert_eq!(
count_and_list(&reason),
(1, vec!["?? CLAUDE.md".to_string()]),
"reason: {reason}"
);
assert!(
reason.contains(
"CLAUDE.md is not recorded in tm's provisioning ledger, or differs from what it \
recorded, or the ledger could not be read, so --force kept the worktree and \
deleted nothing"
),
"reason: {reason}"
);
}
#[tokio::test]
async fn force_decommission_keeps_claude_md_when_the_ledger_cannot_vouch_for_it() {
let fx = GitWorktreeFixture::new();
for case in ["missing", "corrupt", "unrecorded", "unreadable"] {
let wt = provisioned_tree(&fx, &format!("decom-force-ledger-{case}-8540"));
let claude_md = wt.join("CLAUDE.md");
let verdict = match case {
"missing" => {
std::fs::remove_file(ledger_file(&wt)).expect("drop the ledger");
remove(&wt, ProvisioningDirt::Discard).await
}
"corrupt" => {
std::fs::write(ledger_file(&wt), b"{not json").expect("corrupt the ledger");
remove(&wt, ProvisioningDirt::Discard).await
}
"unrecorded" => {
let bytes = std::fs::read(ledger_file(&wt)).expect("read the ledger");
let mut ledger: serde_json::Value =
serde_json::from_slice(&bytes).expect("parse the ledger");
let files = ledger["files"].as_object_mut().expect("a files map");
assert!(
files.remove("CLAUDE.md").is_some(),
"premise: it was recorded"
);
std::fs::write(ledger_file(&wt), ledger.to_string()).expect("rewrite");
remove(&wt, ProvisioningDirt::Discard).await
}
_ => {
let _restore = deny_all(&claude_md);
remove(&wt, ProvisioningDirt::Discard).await
}
};
assert!(!verdict.removed, "{case}: the tree must be kept");
assert_eq!(
std::fs::read_to_string(&claude_md).expect("CLAUDE.md survives"),
"# tm\n",
"{case}"
);
let reason = verdict.kept_reason.expect("a kept tree must say why");
assert!(reason.contains("?? CLAUDE.md"), "{case}: {reason}");
assert!(reason.contains("deleted nothing"), "{case}: {reason}");
}
}
#[tokio::test]
async fn force_decommission_removes_a_provisioning_only_worktree() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-force-7660");
let verdict = remove(&wt, ProvisioningDirt::Discard).await;
assert!(verdict.removed, "kept: {:?}", verdict.kept_reason);
assert!(!wt.exists(), "the workspace directory must be gone");
}
#[test]
fn provisioning_entry_excuses_task_md_and_settings_snapshots_only_when_untracked() {
let fx = GitWorktreeFixture::new();
let wt = task_bearing_tree(&fx, "decom-entry-8688");
let is = |line: &str| is_provisioning_entry(&wt, Some(TASK), line);
assert!(is("?? TASK.md"));
assert!(is(&format!("?? {SETTINGS_SNAPSHOT}")));
assert!(is("?? .claude/settings.json.20260926T140608Z-1.bak"));
assert!(!is(" M TASK.md"), "an edit to a tracked TASK.md is work");
assert!(!is("?? docs/TASK.md"));
assert!(!is("?? .claude/settings.json.mine.bak"));
assert!(!is("?? .claude/settings.local.json.20260926T140608Z.bak"));
assert!(!is("?? settings.json.20260926T140608Z.bak"));
assert!(!is("?? .claude/sub/settings.json.20260926T140608Z.bak"));
assert!(!is(&format!(" M {SETTINGS_SNAPSHOT}")));
}
#[test]
fn provisioning_entry_excuses_task_md_only_when_it_equals_the_session_task() {
let fx = GitWorktreeFixture::new();
let wt = task_bearing_tree(&fx, "decom-entry-task-8688");
let task_md = wt.join("TASK.md");
let is = |task: Option<&str>| is_provisioning_entry(&wt, task, "?? TASK.md");
assert!(is(Some(TASK)));
assert!(!is(None), "no record vouches for it");
assert!(!is(Some("")), "a task-less record wrote no TASK.md");
assert!(!is(Some("Fix the bug")), "one byte short is not tm's write");
assert!(!is(Some("Another task\n")));
{
let _restore = deny_all(&task_md);
assert!(!is(Some(TASK)), "an unreadable TASK.md is not excused");
}
std::fs::remove_file(&task_md).expect("drop TASK.md");
std::fs::write(wt.join("brief.txt"), TASK).expect("write the link target");
std::os::unix::fs::symlink("brief.txt", &task_md).expect("symlink TASK.md");
assert!(!is(Some(TASK)), "a symlink is not the file tm wrote");
}
#[tokio::test]
async fn force_decommission_removes_a_worktree_holding_task_md_and_a_settings_snapshot() {
let fx = GitWorktreeFixture::new();
let wt = task_bearing_tree(&fx, "decom-force-task-8688");
let verdict = remove(&wt, ProvisioningDirt::Discard).await;
assert!(verdict.removed, "kept: {:?}", verdict.kept_reason);
assert!(!wt.exists(), "the workspace directory must be gone");
}
#[tokio::test]
async fn force_decommission_keeps_a_task_md_edited_after_spawn() {
let fx = GitWorktreeFixture::new();
let wt = task_bearing_tree(&fx, "decom-force-task-edit-8688");
let edited = format!("{TASK}\n## Notes\n- the cause is the stale cache\n");
std::fs::write(wt.join("TASK.md"), &edited).expect("edit TASK.md");
let verdict = remove(&wt, ProvisioningDirt::Discard).await;
assert!(!verdict.removed, "an edited TASK.md must keep the worktree");
let body = std::fs::read_to_string(wt.join("TASK.md")).expect("TASK.md survives");
assert_eq!(body, edited, "the notes survive untouched");
let reason = verdict.kept_reason.expect("reason");
assert_eq!(
count_and_list(&reason),
(1, vec!["?? TASK.md".to_string()]),
"reason: {reason}"
);
}
#[tokio::test]
async fn force_decommission_keeps_a_task_md_without_a_session_task() {
let fx = GitWorktreeFixture::new();
for (name, task) in [("none", None), ("empty", Some(""))] {
let wt = task_bearing_tree(&fx, &format!("decom-force-task-{name}-8688"));
let verdict = remove_in_project_worktree(
&ManagedSessionId::new(),
task,
&wt,
ProvisioningDirt::Discard,
)
.await;
assert!(!verdict.removed && wt.join("TASK.md").exists(), "{name}");
let reason = verdict.kept_reason.expect("reason");
assert_eq!(
count_and_list(&reason),
(1, vec!["?? TASK.md".to_string()]),
"{name}: {reason}"
);
}
}
fn errored(task: &str, msg: &str) -> String {
format!("{task} [error: {msg}]")
}
async fn force_with_task(wt: &Path, task: &str) -> WorkspaceVerdict {
remove_in_project_worktree(
&ManagedSessionId::new(),
Some(task),
wt,
ProvisioningDirt::Discard,
)
.await
}
#[tokio::test]
async fn force_decommission_removes_a_once_errored_session_s_unedited_task_md() {
let fx = GitWorktreeFixture::new();
let wt = task_bearing_tree(&fx, "decom-force-errored-8688");
let verdict = force_with_task(&wt, &errored(TASK, "launch spec [tm-x] unreadable")).await;
assert!(verdict.removed, "kept: {:?}", verdict.kept_reason);
assert!(!wt.exists(), "the workspace directory must be gone");
}
#[tokio::test]
async fn force_decommission_keeps_a_once_errored_session_s_edited_task_md() {
let fx = GitWorktreeFixture::new();
let wt = task_bearing_tree(&fx, "decom-force-errored-edit-8688");
let edited = format!("{TASK}\n## Notes\n- the spawn failed on a stale spec\n");
std::fs::write(wt.join("TASK.md"), &edited).expect("edit TASK.md");
let verdict = force_with_task(&wt, &errored(TASK, "boom")).await;
assert!(!verdict.removed, "an edited TASK.md must keep the worktree");
let body = std::fs::read_to_string(wt.join("TASK.md")).expect("TASK.md survives");
assert_eq!(body, edited, "the notes survive untouched");
let reason = verdict.kept_reason.expect("reason");
assert_eq!(
count_and_list(&reason),
(1, vec!["?? TASK.md".to_string()]),
"reason: {reason}"
);
}
#[tokio::test]
async fn force_decommission_keeps_a_repeatedly_errored_session_s_task_md() {
use crate::session_manager::setters::strip_error_notes;
let fx = GitWorktreeFixture::new();
let noted = "Fix the parser [error: x]\n";
let straddle = "Fix the bug [error:";
let rows: [(&str, String, String); 5] = [
(
"strip-twice",
errored(&errored(TASK, "a"), "b"),
TASK.into(),
),
("own-marker", errored(noted, "boom"), noted.into()),
(
"strip-own-marker",
errored(noted, "boom"),
strip_error_notes(&errored(noted, "boom")),
),
(
"first-marker",
errored(noted, "boom"),
"Fix the parser".into(),
),
("straddle", errored(straddle, "boom"), "Fix the bug".into()),
];
let mut removed = Vec::new();
for (name, task, task_md) in rows {
let wt = provisioned_tree(&fx, &format!("decom-force-errored-{name}-8688"));
std::fs::write(wt.join("TASK.md"), &task_md).expect("write TASK.md");
let verdict = force_with_task(&wt, &task).await;
let Some(reason) = verdict.kept_reason.filter(|_| !verdict.removed) else {
removed.push(name);
continue;
};
assert_eq!(
std::fs::read_to_string(wt.join("TASK.md")).expect("TASK.md survives"),
task_md,
"{name}"
);
assert_eq!(
count_and_list(&reason),
(1, vec!["?? TASK.md".to_string()]),
"{name}: {reason}"
);
}
assert!(
removed.is_empty(),
"TASK.md must keep the tree: {removed:?}"
);
}
#[tokio::test]
async fn force_decommission_keeps_user_work_beside_task_md() {
let fx = GitWorktreeFixture::new();
let wt = task_bearing_tree(&fx, "decom-force-task-work-8688");
std::fs::write(wt.join("notes.rs"), "// unsaved\n").expect("write user work");
let verdict = remove(&wt, ProvisioningDirt::Discard).await;
assert!(!verdict.removed && wt.join("notes.rs").exists());
assert!(wt.join("TASK.md").exists(), "nothing is removed");
let reason = verdict.kept_reason.expect("reason");
assert_eq!(
count_and_list(&reason),
(1, vec!["?? notes.rs".to_string()]),
"reason: {reason}"
);
}
#[tokio::test]
async fn decommission_refusal_count_matches_the_entries_it_lists() {
let fx = GitWorktreeFixture::new();
let wt = task_bearing_tree(&fx, "decom-refuse-count-8688");
let verdict = remove(&wt, ProvisioningDirt::Refuse).await;
assert!(!verdict.removed && wt.exists());
let reason = verdict.kept_reason.expect("a kept workspace must say why");
let (count, listed) = count_and_list(&reason);
assert_eq!(count, listed.len(), "reason: {reason}");
assert_eq!(count, 6, "reason: {reason}");
}
#[tokio::test]
async fn force_decommission_still_refuses_user_work() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-force-work-7660");
std::fs::write(wt.join("notes.rs"), "// unsaved\n").expect("write user work");
let verdict = remove(&wt, ProvisioningDirt::Discard).await;
assert!(!verdict.removed && wt.join("notes.rs").exists());
let reason = verdict.kept_reason.expect("reason");
assert!(reason.contains("never discards"), "reason: {reason}");
assert!(reason.contains("?? notes.rs"), "reason: {reason}");
assert!(!reason.contains("?? CLAUDE.md"), "reason: {reason}");
}
#[tokio::test]
async fn force_decommission_still_refuses_unpushed_commits() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-force-unpushed-7660");
GitWorktreeFixture::commit_unpushed(&wt);
let verdict = remove(&wt, ProvisioningDirt::Discard).await;
assert!(!verdict.removed && wt.exists());
assert!(verdict.kept_reason.is_some());
}
#[tokio::test]
async fn force_decommission_removes_nothing_when_the_dirty_check_cannot_complete() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-force-unreadable-7660");
assert_eq!(
force_blocker(&wt, &ManagedSessionId::new()),
None,
"premise: the blocker passes"
);
let index = fx
.repo
.join(".git/worktrees/decom-force-unreadable-7660/index");
let verdict = {
let _restore = deny_all(&index);
remove(&wt, ProvisioningDirt::Discard).await
};
assert!(!verdict.removed, "an unanswered check must keep the tree");
assert!(wt.join("CLAUDE.md").exists());
let reason = verdict.kept_reason.expect("a kept tree must say why");
assert!(reason.contains("dirty-check failed"), "reason: {reason}");
assert!(!reason.contains("--force declined"), "reason: {reason}");
}
#[tokio::test]
async fn force_decommission_honours_an_admin_dir_marker() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-admin-marker-7660");
assert!(
admin_marker(&wt).is_file(),
"premise: the admin marker exists"
);
assert!(
!wt.join(WORKTREE_SENTINEL_FILE).exists(),
"premise: no legacy marker"
);
let verdict = remove(&wt, ProvisioningDirt::Discard).await;
assert!(verdict.removed, "kept: {:?}", verdict.kept_reason);
assert!(!wt.exists());
}
#[tokio::test]
async fn force_decommission_honours_a_legacy_in_tree_marker() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-legacy-marker-7660");
std::fs::remove_file(admin_marker(&wt)).expect("drop the admin marker");
std::fs::write(wt.join(WORKTREE_SENTINEL_FILE), b"").expect("write legacy marker");
let verdict = remove(&wt, ProvisioningDirt::Discard).await;
assert!(verdict.removed, "kept: {:?}", verdict.kept_reason);
assert!(!wt.exists());
}
#[tokio::test]
async fn force_decommission_keeps_a_worktree_whose_marker_is_a_symlink() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-marker-symlink-7660");
let marker = admin_marker(&wt);
let target = fx.repo.join("elsewhere-7660");
std::fs::write(&target, b"").expect("write symlink target");
std::fs::remove_file(&marker).expect("drop the admin marker");
std::os::unix::fs::symlink(&target, &marker).expect("symlink the marker");
let reason = kept_under_force(&wt).await;
assert!(reason.contains("not a regular file"), "reason: {reason}");
}
#[tokio::test]
async fn force_decommission_is_never_stricter_than_plain_on_a_clean_tree() {
let fx = GitWorktreeFixture::new();
for (name, policy) in [
("decom-clean-plain-7660", ProvisioningDirt::Refuse),
("decom-clean-force-7660", ProvisioningDirt::Discard),
] {
let wt = fx.add_worktree(name);
let verdict = remove(&wt, policy).await;
assert!(
verdict.removed,
"{policy:?} kept: {:?}",
verdict.kept_reason
);
assert!(!wt.exists(), "{policy:?}: the tree must be gone");
}
}
#[tokio::test]
async fn decommission_keeps_a_worktree_a_live_process_stands_in_9444() {
let fx = GitWorktreeFixture::new();
for (name, policy) in [
("decom-live-plain-9444", ProvisioningDirt::Refuse),
("decom-live-force-9444", ProvisioningDirt::Discard),
] {
let wt = fx.add_worktree(name);
let holder = crate::session_manager::worktree_git_fixture::StandingProcess::in_dir(&wt);
let verdict = remove(&wt, policy).await;
assert!(
!verdict.removed,
"{policy:?} removed a tree a process stands in"
);
assert!(wt.exists(), "{policy:?}: the tree must stay");
let reason = verdict.kept_reason.unwrap_or_default();
assert!(holder.named_in(&reason), "{policy:?}: {reason}");
}
}
#[tokio::test]
async fn decommission_keeps_a_worktree_when_the_holder_probe_fails_9444() {
let fx = GitWorktreeFixture::new();
for (name, policy) in [
("decom-probe-plain-9444", ProvisioningDirt::Refuse),
("decom-probe-force-9444", ProvisioningDirt::Discard),
] {
let wt = fx.add_worktree(name);
let verdict = remove_in_project_worktree_with(
&ManagedSessionId::new(),
Some(TASK),
&wt,
policy,
crate::session_manager::worktree_git_fixture::probe_cannot_answer,
)
.await;
assert!(
!verdict.removed,
"{policy:?} removed on an unanswered probe"
);
assert!(wt.exists(), "{policy:?}: the tree must stay");
let reason = verdict.kept_reason.unwrap_or_default();
assert!(
reason.contains("could not run `lsof`"),
"{policy:?}: {reason}"
);
}
}
fn untracked_gitignore_tree(fx: &GitWorktreeFixture, name: &str) -> PathBuf {
let wt = fx.add_worktree(name);
mark_owned(&wt);
let before = provisioning_ledger::snapshot(&wt);
crate::core::scaffold_gitignore::append_legacy_block(&wt);
std::fs::create_dir_all(wt.join(".claude")).expect("mkdir .claude");
std::fs::write(wt.join(".claude/settings.json"), "{}\n").expect("write settings");
std::fs::write(wt.join("CLAUDE.md"), "# tm\n").expect("write CLAUDE.md");
provisioning_ledger::record(&wt, &before).expect("record the ledger");
wt
}
#[tokio::test]
async fn force_decommission_removes_a_tree_with_an_untracked_scaffold_gitignore() {
let fx = GitWorktreeFixture::new();
let wt = untracked_gitignore_tree(&fx, "decom-untracked-gi-7660");
let verdict = remove(&wt, ProvisioningDirt::Discard).await;
assert!(verdict.removed, "kept: {:?}", verdict.kept_reason);
assert!(!wt.exists());
}
#[tokio::test]
async fn force_decommission_keeps_an_untracked_gitignore_with_a_user_line() {
let fx = GitWorktreeFixture::new();
let wt = untracked_gitignore_tree(&fx, "decom-untracked-gi-user-7660");
let mut body = std::fs::read_to_string(wt.join(".gitignore")).expect("read .gitignore");
body.push_str("secrets.env\n");
std::fs::write(wt.join(".gitignore"), &body).expect("append a user line");
let verdict = remove(&wt, ProvisioningDirt::Discard).await;
assert!(
!verdict.removed,
"a user's .gitignore line must keep the tree"
);
assert!(wt.join(".gitignore").exists());
}
#[tokio::test]
async fn force_decommission_keeps_the_tree_when_the_gitignore_diff_cannot_be_read() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-corrupt-index-7660");
let index = fx
.repo
.join(".git/worktrees/decom-corrupt-index-7660/index");
assert!(index.exists(), "premise: the worktree's index lives here");
std::fs::write(&index, b"not an index").expect("corrupt the index");
assert!(
!is_provisioning_entry(&wt, None, " M .gitignore"),
"an unreadable diff must never excuse .gitignore"
);
let verdict = remove(&wt, ProvisioningDirt::Discard).await;
assert!(!verdict.removed && wt.exists(), "the tree must be kept");
let reason = verdict.kept_reason.expect("a kept tree must say why");
assert!(reason.contains("dirty-check failed"), "reason: {reason}");
}
#[test]
fn gitignore_body_line_shaped_like_a_header_is_not_excused() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-header-shaped-7660");
assert!(
is_provisioning_entry(&wt, None, " M .gitignore"),
"premise: the scaffold block alone is excused"
);
let mut body = std::fs::read_to_string(wt.join(".gitignore")).expect("read .gitignore");
body.push_str("++ secrets.env\n");
std::fs::write(wt.join(".gitignore"), &body).expect("append a header-shaped line");
assert!(!is_provisioning_entry(&wt, None, " M .gitignore"));
}
async fn kept_under_force(wt: &Path) -> String {
let verdict = remove(wt, ProvisioningDirt::Discard).await;
assert!(!verdict.removed, "the tree must be kept");
assert!(wt.exists(), "the tree must still be on disk");
verdict.kept_reason.expect("a kept tree must say why")
}
#[tokio::test]
async fn force_decommission_keeps_a_worktree_without_the_sentinel() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-no-sentinel-7660");
std::fs::remove_file(admin_marker(&wt)).expect("drop the marker");
let reason = kept_under_force(&wt).await;
assert!(reason.contains("no ownership sentinel"), "reason: {reason}");
}
#[tokio::test]
async fn force_decommission_keeps_a_worktree_another_session_owns() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-foreign-owner-7660");
let other = ManagedSessionId::new();
write_sentinel_bytes(&wt, &sentinel_payload_bytes(other)).expect("write the owner marker");
let verdict = remove_as(&ManagedSessionId::new(), &wt, ProvisioningDirt::Discard).await;
assert!(
!verdict.removed && wt.exists(),
"another session's tree must stay"
);
let reason = verdict.kept_reason.expect("a kept tree must say why");
assert!(reason.contains("--force declined"), "reason: {reason}");
assert!(reason.contains(&other.to_string()), "reason: {reason}");
}
#[tokio::test]
async fn force_decommission_keeps_a_worktree_an_agent_owns() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-agent-owner-7660");
let agent = AgentWorktreeOwner {
agent_id: "agent-synthetic-7660".to_string(),
delegation_id: DelegationId::new(),
parent_session_id: SessionId::new(),
};
write_agent_sentinel(&wt, agent).expect("write the agent marker");
let reason = kept_under_force(&wt).await;
assert!(reason.contains("--force declined"), "reason: {reason}");
assert!(reason.contains("agent-synthetic-7660"), "reason: {reason}");
}
#[tokio::test]
async fn force_decommission_removes_a_worktree_its_own_session_owns() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-own-owner-7660");
let me = ManagedSessionId::new();
write_sentinel_bytes(&wt, &sentinel_payload_bytes(me)).expect("write the owner marker");
let verdict = remove_as(&me, &wt, ProvisioningDirt::Discard).await;
assert!(verdict.removed, "kept: {:?}", verdict.kept_reason);
assert!(!wt.exists());
}
#[tokio::test]
async fn force_decommission_keeps_a_worktree_whose_sentinel_cannot_be_read() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-sentinel-unreadable-7660");
let reason = {
let _restore = deny_all(&wt);
kept_under_force(&wt).await
};
assert!(
reason.contains("sentinel could not be read"),
"reason: {reason}"
);
}
#[tokio::test]
async fn force_decommission_keeps_a_main_checkout_under_the_worktrees_dir() {
let fx = GitWorktreeFixture::new();
let main = fx.repo.join(".worktrees").join("standalone-7660");
std::fs::create_dir_all(&main).expect("mkdir standalone");
let init = std::process::Command::new("git")
.arg("-C")
.arg(&main)
.args(["init", "--initial-branch=main"])
.output()
.expect("run git init");
assert!(init.status.success(), "git init failed");
mark_owned(&main);
std::fs::write(main.join("CLAUDE.md"), "# tm\n").expect("write CLAUDE.md");
let reason = kept_under_force(&main).await;
assert!(reason.contains("main checkout"), "reason: {reason}");
assert!(main.join("CLAUDE.md").exists());
}
#[tokio::test]
async fn force_decommission_keeps_a_worktree_git_cannot_resolve() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-unresolvable-7660");
std::fs::write(wt.join(".git"), "gitdir: /nonexistent/7660\n").expect("break .git");
let reason = kept_under_force(&wt).await;
assert!(
reason.contains("cannot prove tm created it"),
"reason: {reason}"
);
assert!(reason.contains("does not resolve"), "reason: {reason}");
}
#[tokio::test]
async fn force_decommission_keeps_a_worktree_whose_marker_is_corrupt() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-marker-corrupt-7660");
std::fs::write(admin_marker(&wt), b"not a marker").expect("corrupt the marker");
let reason = kept_under_force(&wt).await;
assert!(reason.contains("corrupt"), "reason: {reason}");
}
#[tokio::test]
async fn force_decommission_keeps_a_locked_worktree() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-locked-7660");
fx.lock_worktree(&wt);
let reason = kept_under_force(&wt).await;
assert!(reason.contains("--force declined"), "reason: {reason}");
assert!(reason.contains("locked"), "reason: {reason}");
}
#[test]
fn force_blocker_refuses_a_directory_that_is_not_a_worktree_root() {
let fx = GitWorktreeFixture::new();
let plain = fx.repo.join(".worktrees").join("plain-7660");
std::fs::create_dir_all(&plain).expect("mkdir plain");
std::fs::write(plain.join(WORKTREE_SENTINEL_FILE), b"").expect("write legacy marker");
let blocker =
force_blocker(&plain, &ManagedSessionId::new()).expect("a non-root directory must block");
assert!(blocker.contains("not a worktree root"), "{blocker}");
}
#[test]
fn lock_blocker_fails_closed_when_the_lock_state_cannot_be_read() {
let dir = tempfile::tempdir().expect("tempdir");
let git_dir = dir.path().join("worktrees").join("wt");
std::fs::create_dir_all(&git_dir).expect("mkdir git dir");
assert_eq!(lock_blocker(&git_dir), None, "premise: no lock file");
let blocker = {
let _restore = deny_all(&git_dir);
lock_blocker(&git_dir)
};
let blocker = blocker.expect("an unreadable lock state must block");
assert!(
blocker.contains("lock state could not be read"),
"{blocker}"
);
}
#[tokio::test]
async fn decommission_of_an_absent_worktree_reports_nothing_kept() {
let fx = GitWorktreeFixture::new();
let absent = fx.repo.join(".worktrees").join("gone-7660");
for policy in [ProvisioningDirt::Refuse, ProvisioningDirt::Discard] {
let verdict = remove(&absent, policy).await;
assert!(verdict.kept_reason.is_none(), "{:?}", verdict.kept_reason);
}
}
#[test]
fn unowned_kept_reason_names_a_main_checkout() {
let fx = GitWorktreeFixture::new();
let plain = unowned_kept_reason(&fx.repo, ProvisioningDirt::Refuse).expect("kept");
assert!(plain.contains("main checkout"), "{plain}");
assert!(!plain.contains("--force"), "{plain}");
assert!(!plain.contains('\n'), "one line: {plain}");
let forced = unowned_kept_reason(&fx.repo, ProvisioningDirt::Discard).expect("kept");
assert!(forced.contains("--force does not apply"), "{forced}");
let absent = fx.repo.join("no-such-dir-7660");
assert_eq!(
unowned_kept_reason(&absent, ProvisioningDirt::Discard),
None
);
}
#[test]
fn unowned_kept_reason_names_a_foreign_worktree_and_a_plain_directory() {
let fx = GitWorktreeFixture::new();
let foreign = fx.add_worktree("foreign-7660");
let plain = tempfile::tempdir().expect("tempdir");
for (ws, kind) in [
(foreign.as_path(), "a git worktree tm did not create"),
(plain.path(), "a local-path or adopted directory"),
] {
let reason = unowned_kept_reason(ws, ProvisioningDirt::Refuse).expect("kept");
assert!(reason.contains(kind), "{reason}");
assert!(reason.starts_with("kept by design"), "{reason}");
}
}