use std::path::{Path, PathBuf};
use super::*;
use crate::session_manager::decommission::WORKTREE_SENTINEL_FILE;
use crate::session_manager::worktree_git_fixture::{GitWorktreeFixture, deny_all};
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");
std::fs::write(wt.join(WORKTREE_SENTINEL_FILE), b"").expect("write sentinel");
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_in_project_worktree(&ManagedSessionId::new(), 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 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}");
}
#[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");
let verdict = {
let _restore = deny_all(&wt.join(".git"));
remove(&wt, ProvisioningDirt::Discard).await
};
assert!(!verdict.removed, "an unanswered check must keep the tree");
assert!(wt.join("CLAUDE.md").exists());
assert!(verdict.kept_reason.is_some());
}
fn untracked_gitignore_tree(fx: &GitWorktreeFixture, name: &str) -> PathBuf {
let wt = fx.add_worktree(name);
std::fs::write(wt.join(WORKTREE_SENTINEL_FILE), b"").expect("write sentinel");
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");
}
#[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"));
}