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::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);
crate::core::scaffold_gitignore::ensure_scaffold_gitignored(&wt).expect("scaffold .gitignore");
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");
wt
}
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, 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, 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_warns_force_discards_untracked_claude_md_edits() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-refuse-warn-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.ends_with(
"unpushed commits. WARNING: --force deletes the untracked .claude/settings.json \
and CLAUDE.md with the worktree, including any edits made to them; copy out \
anything you added there first"
),
"reason: {reason}"
);
}
#[tokio::test]
async fn decommission_refusal_warning_names_a_single_untracked_file() {
let fx = GitWorktreeFixture::new();
let wt = provisioned_tree(&fx, "decom-refuse-warn-one-7660");
std::fs::remove_file(wt.join(".claude/settings.json")).expect("drop settings.json");
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.ends_with(
"unpushed commits. WARNING: --force deletes the untracked CLAUDE.md with the \
worktree, including any edits made to it; copy out anything you added there first"
),
"reason: {reason}"
);
}
#[tokio::test]
async fn decommission_refusal_omits_the_warning_without_untracked_claude_files() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("decom-refuse-no-warn-7660");
mark_owned(&wt);
std::fs::write(wt.join("notes.rs"), "// unsaved\n").expect("write user work");
let verdict = remove(&wt, ProvisioningDirt::Refuse).await;
assert!(!verdict.removed && wt.join("notes.rs").exists());
let reason = verdict.kept_reason.expect("a kept workspace must say why");
assert!(reason.contains("?? notes.rs"), "reason: {reason}");
assert!(!reason.contains("WARNING"), "reason: {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");
}
#[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");
}
}
fn untracked_gitignore_tree(fx: &GitWorktreeFixture, name: &str) -> PathBuf {
let wt = fx.add_worktree(name);
mark_owned(&wt);
crate::core::scaffold_gitignore::ensure_scaffold_gitignored(&wt).expect("scaffold .gitignore");
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");
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, " 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, " 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, " 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}");
}
}