use super::*;
use crate::session_manager::worktree_git_fixture::GitWorktreeFixture;
#[test]
fn dirty_policy_defaults_to_skip() {
assert_eq!(
DirtyWorktreePolicy::default(),
DirtyWorktreePolicy::Skip,
"the default dirty policy must never be ForceDiscard"
);
}
#[test]
fn inspect_dirt_clean_pushed_worktree_is_none() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("clean");
assert!(
inspect_dirt(&wt).is_none(),
"a clean, fully-pushed worktree must be reclaimable; got {:?}",
inspect_dirt(&wt)
);
}
#[test]
fn inspect_dirt_reports_modified_tracked_file() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("modified");
std::fs::write(wt.join("README.md"), "edited but never committed\n").unwrap();
let dirt = inspect_dirt(&wt).expect("a modified tracked file must read as dirty");
assert_eq!(dirt.dirty_files, 1, "reason was: {}", dirt.reason);
assert_eq!(dirt.unpushed_commits, 0);
assert_eq!(dirt.path, wt);
}
#[test]
fn inspect_dirt_reports_untracked_file() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("untracked");
std::fs::write(wt.join("notes.md"), "hand-written, never added\n").unwrap();
let dirt = inspect_dirt(&wt).expect("an untracked file must read as dirty");
assert_eq!(dirt.dirty_files, 1, "reason was: {}", dirt.reason);
}
#[test]
fn inspect_dirt_ignores_gitignored_files() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("ignored");
std::fs::write(wt.join(".gitignore"), "build-output/\n").unwrap();
std::fs::create_dir_all(wt.join("build-output")).unwrap();
std::fs::write(wt.join("build-output").join("artifact.bin"), "junk\n").unwrap();
let commit = std::process::Command::new("git")
.arg("-C")
.arg(&wt)
.args(["add", ".gitignore"])
.output()
.unwrap();
assert!(commit.status.success());
let commit = std::process::Command::new("git")
.arg("-C")
.arg(&wt)
.args(["commit", "-m", "ignore build output"])
.output()
.unwrap();
assert!(commit.status.success());
let dirt = inspect_dirt(&wt).expect("the new commit is unpushed, so this is dirty");
assert_eq!(
dirt.dirty_files, 0,
"ignored build output must not count as uncommitted work; reason: {}",
dirt.reason
);
assert_eq!(dirt.unpushed_commits, 1, "reason: {}", dirt.reason);
}
#[test]
fn inspect_dirt_excludes_own_sentinel() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("sentinel-only");
GitWorktreeFixture::stamp_reclaimable_sentinel(&wt);
assert!(
inspect_dirt(&wt).is_none(),
"trusty-mpm's own sentinel must not make a worktree dirty; got {:?}",
inspect_dirt(&wt)
);
}
#[test]
fn inspect_dirt_excludes_untracked_trusty_mpm_dir() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("scrollback");
std::fs::create_dir_all(wt.join(".trusty-mpm")).unwrap();
std::fs::write(wt.join(".trusty-mpm").join("scrollback.txt"), "pane dump\n").unwrap();
std::fs::write(
wt.join(".trusty-mpm").join("last-instructions.md"),
"brief\n",
)
.unwrap();
assert!(
inspect_dirt(&wt).is_none(),
"the daemon's own scrollback artefacts must not make a worktree dirty; got {:?}",
inspect_dirt(&wt)
);
}
#[test]
fn inspect_dirt_counts_wip_backup_under_trusty_mpm() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("wip-backup");
let wip = wt.join(".trusty-mpm").join("wip-backup-20260727-jira");
std::fs::create_dir_all(wip.join("crates").join("foo").join("src")).unwrap();
std::fs::write(
wip.join("crates").join("foo").join("src").join("main.rs"),
"fn main() { /* rescued, uncommitted */ }\n",
)
.unwrap();
std::fs::write(wt.join(".trusty-mpm").join("scrollback.txt"), "pane dump\n").unwrap();
let dirt = inspect_dirt(&wt).expect("rescued source under .trusty-mpm/ must read as dirty");
assert_eq!(
dirt.dirty_files, 1,
"the wip-backup tree must count and the scrollback must not; reason: {}",
dirt.reason
);
}
#[test]
fn inspect_dirt_counts_pause_snapshots_under_trusty_mpm() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("pause-snapshots");
std::fs::create_dir_all(wt.join(".trusty-mpm").join("sessions")).unwrap();
std::fs::write(
wt.join(".trusty-mpm").join("sessions").join("s-01.md"),
"# paused session\n",
)
.unwrap();
let dirt = inspect_dirt(&wt).expect("pause snapshots must read as dirty");
assert!(dirt.dirty_files >= 1, "reason: {}", dirt.reason);
}
#[test]
fn inspect_dirt_counts_gitignored_trusty_mpm_work() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("ignored-tm");
std::fs::write(wt.join(".gitignore"), ".trusty-mpm/*\n").unwrap();
GitWorktreeFixture::commit_all_and_push(&wt, "gitignore .trusty-mpm");
std::fs::create_dir_all(wt.join(".trusty-mpm").join("wip-backup-x")).unwrap();
std::fs::write(
wt.join(".trusty-mpm").join("wip-backup-x").join("main.rs"),
"fn main() {}\n",
)
.unwrap();
let plain = std::process::Command::new("git")
.arg("-C")
.arg(&wt)
.args(["status", "--porcelain"])
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&plain.stdout).trim().is_empty(),
"premise broken: plain porcelain should not see the gitignored subtree"
);
let dirt = inspect_dirt(&wt).expect("gitignored .trusty-mpm/ work must still read as dirty");
assert!(dirt.dirty_files >= 1, "reason: {}", dirt.reason);
}
#[test]
fn inspect_dirt_counts_tracked_trusty_mpm_edit() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("tracked-config");
std::fs::create_dir_all(wt.join(".trusty-mpm")).unwrap();
std::fs::write(wt.join(".trusty-mpm").join("INSTRUCTIONS.md"), "v1\n").unwrap();
for args in [
vec!["add", ".trusty-mpm/INSTRUCTIONS.md"],
vec!["commit", "-m", "track instructions"],
] {
let out = std::process::Command::new("git")
.arg("-C")
.arg(&wt)
.args(&args)
.output()
.unwrap();
assert!(out.status.success(), "{args:?}: {:?}", out);
}
std::fs::write(
wt.join(".trusty-mpm").join("INSTRUCTIONS.md"),
"v2 edited\n",
)
.unwrap();
let dirt = inspect_dirt(&wt).expect("a tracked edit is real work, wherever it lives");
assert_eq!(
dirt.dirty_files, 1,
"a tracked .trusty-mpm/ edit must still count; reason: {}",
dirt.reason
);
}
#[test]
fn inspect_dirt_counts_a_deleted_tracked_file_under_trusty_mpm() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("deleted-tm");
std::fs::create_dir_all(wt.join(".trusty-mpm")).unwrap();
std::fs::write(wt.join(".trusty-mpm").join("notes.md"), "v1\n").unwrap();
GitWorktreeFixture::commit_all_and_push(&wt, "track notes under .trusty-mpm");
std::fs::remove_dir_all(wt.join(".trusty-mpm")).unwrap();
assert!(
!wt.join(".trusty-mpm").exists(),
"premise broken: the whole directory should be gone"
);
let dirt =
inspect_dirt(&wt).expect("a deleted tracked file under .trusty-mpm/ must read as dirty");
assert_eq!(
dirt.dirty_files, 1,
"the deletion must be counted by pass 2; reason: {}",
dirt.reason
);
}
#[test]
fn inspect_dirt_reports_unpushed_commit() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("unpushed");
GitWorktreeFixture::commit_unpushed(&wt);
let dirt = inspect_dirt(&wt).expect("an unpushed commit must read as dirty");
assert_eq!(dirt.dirty_files, 0, "reason: {}", dirt.reason);
assert_eq!(dirt.unpushed_commits, 1, "reason: {}", dirt.reason);
}
#[test]
fn inspect_dirt_reports_nested_gitignored_worktree() {
let fx = GitWorktreeFixture::new();
let parent = fx.add_worktree("parent");
std::fs::write(parent.join(".gitignore"), ".claude/worktrees/\n").unwrap();
GitWorktreeFixture::commit_all_and_push(&parent, "ignore agent worktrees");
let nested = fx.add_nested_worktree(&parent, ".claude/worktrees", "agentA");
std::fs::write(nested.join("patch.rs"), "// agent work, never committed\n").unwrap();
let plain = std::process::Command::new("git")
.arg("-C")
.arg(&parent)
.args(["status", "--porcelain"])
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&plain.stdout).trim().is_empty(),
"premise broken: plain porcelain should not see the gitignored nested worktree"
);
let dirt = inspect_dirt(&parent)
.expect("a nested worktree holding uncommitted work must make the parent dirty");
assert!(
dirt.reason.contains("nested git worktree/repository"),
"unexpected reason: {}",
dirt.reason
);
}
#[test]
fn inspect_dirt_reports_unregistered_nested_repo_in_ignored_dir() {
let fx = GitWorktreeFixture::new();
let parent = fx.add_worktree("with-clone");
std::fs::write(parent.join(".gitignore"), "scratch/\n").unwrap();
GitWorktreeFixture::commit_all_and_push(&parent, "ignore scratch");
let inner = parent.join("scratch").join("side-project");
std::fs::create_dir_all(&inner).unwrap();
for args in [vec!["init", "--initial-branch=main"], vec!["status"]] {
let out = std::process::Command::new("git")
.arg("-C")
.arg(&inner)
.args(&args)
.output()
.unwrap();
assert!(out.status.success(), "{args:?}: {out:?}");
}
std::fs::write(inner.join("work.rs"), "// never committed anywhere\n").unwrap();
let dirt = inspect_dirt(&parent)
.expect("an unregistered nested repo holding work must make the parent dirty");
assert!(
dirt.reason.contains("nested git worktree/repository"),
"unexpected reason: {}",
dirt.reason
);
}
#[test]
fn inspect_dirt_allows_clean_nested_worktree() {
let fx = GitWorktreeFixture::new();
let parent = fx.add_worktree("clean-parent");
std::fs::write(parent.join(".gitignore"), ".claude/worktrees/\n").unwrap();
GitWorktreeFixture::commit_all_and_push(&parent, "ignore agent worktrees");
let nested = fx.add_nested_worktree(&parent, ".claude/worktrees", "agentClean");
std::fs::write(nested.join("done.rs"), "// finished agent work\n").unwrap();
GitWorktreeFixture::commit_all_and_push(&nested, "agent work, pushed");
assert!(
inspect_dirt(&parent).is_none(),
"a clean, pushed nested worktree must not pin the parent; got {:?}",
inspect_dirt(&parent)
);
}
#[test]
fn inspect_dirt_keeps_scanning_past_a_clean_nested_worktree() {
let fx = GitWorktreeFixture::new();
let parent = fx.add_worktree("two-nested");
std::fs::write(parent.join(".gitignore"), ".claude/worktrees/\n").unwrap();
GitWorktreeFixture::commit_all_and_push(&parent, "ignore agent worktrees");
let clean = fx.add_nested_worktree(&parent, ".claude/worktrees", "aaa-clean");
std::fs::write(clean.join("done.rs"), "// finished\n").unwrap();
GitWorktreeFixture::commit_all_and_push(&clean, "finished, pushed");
let dirty = fx.add_nested_worktree(&parent, ".claude/worktrees", "zzz-dirty");
std::fs::write(dirty.join("wip.rs"), "// in flight, never committed\n").unwrap();
let dirt = inspect_dirt(&parent)
.expect("a dirty nested worktree behind a clean one must still make the parent dirty");
assert!(
dirt.reason.contains("zzz-dirty"),
"the reason must name the nested worktree that is actually dirty; got: {}",
dirt.reason
);
}
#[test]
fn inspect_dirt_does_not_scan_disposable_build_dirs() {
let fx = GitWorktreeFixture::new();
let parent = fx.add_worktree("disposable");
std::fs::write(parent.join(".gitignore"), "target/\n").unwrap();
GitWorktreeFixture::commit_all_and_push(&parent, "ignore target");
let buried = parent.join("target").join("debug").join("checkout");
std::fs::create_dir_all(buried.join(".git")).unwrap();
std::fs::write(buried.join("thing.rs"), "// inside target/\n").unwrap();
assert!(
inspect_dirt(&parent).is_none(),
"target/ is deliberately not scanned; got {:?}",
inspect_dirt(&parent)
);
}
#[test]
fn inspect_dirt_survives_hostile_show_untracked_files_config() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("hostile-untracked");
std::fs::write(wt.join("notes.md"), "hand-written, never added\n").unwrap();
let out = std::process::Command::new("git")
.arg("-C")
.arg(&wt)
.args(["config", "status.showUntrackedFiles", "no"])
.output()
.unwrap();
assert!(out.status.success());
let plain = std::process::Command::new("git")
.arg("-C")
.arg(&wt)
.args(["status", "--porcelain"])
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&plain.stdout).trim().is_empty(),
"premise broken: the hostile config should have silenced plain porcelain"
);
let dirt = inspect_dirt(&wt).expect("a hostile config must not be able to hide untracked work");
assert_eq!(dirt.dirty_files, 1, "reason: {}", dirt.reason);
}
#[test]
fn inspect_dirt_survives_hostile_global_excludes_file() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("hostile-excludes");
let excludes = fx.repo.join("hostile-excludes.txt");
std::fs::write(&excludes, "secret-work.txt\n").unwrap();
std::fs::write(wt.join("secret-work.txt"), "REAL WORK\n").unwrap();
let out = std::process::Command::new("git")
.arg("-C")
.arg(&wt)
.args(["config", "core.excludesFile"])
.arg(&excludes)
.output()
.unwrap();
assert!(out.status.success());
let plain = std::process::Command::new("git")
.arg("-C")
.arg(&wt)
.args(["status", "--porcelain"])
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&plain.stdout).trim().is_empty(),
"premise broken: the excludes file should have hidden the work"
);
let dirt = inspect_dirt(&wt).expect("a hostile excludes file must not hide untracked work");
assert_eq!(dirt.dirty_files, 1, "reason: {}", dirt.reason);
}
#[test]
fn inspect_dirt_reports_unpushed_session_branch_when_head_moved() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("moved-head");
GitWorktreeFixture::commit_unpushed(&wt);
let out = std::process::Command::new("git")
.arg("-C")
.arg(&wt)
.args(["switch", "--detach", "origin/main"])
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
let dirt = inspect_dirt(&wt)
.expect("an unpushed commit on the branch decommission deletes must read as dirty");
assert_eq!(
dirt.unpushed_commits, 1,
"HEAD is pushed but session/moved-head is not; reason: {}",
dirt.reason
);
}
#[test]
fn inspect_dirt_reports_unpushed_bare_leaf_branch_when_head_moved() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("bare-branch");
for args in [
vec!["switch", "-c", "bare-branch"],
vec!["branch", "-D", "session/bare-branch"],
] {
let out = std::process::Command::new("git")
.arg("-C")
.arg(&wt)
.args(&args)
.output()
.unwrap();
assert!(out.status.success(), "{args:?}: {out:?}");
}
GitWorktreeFixture::commit_unpushed(&wt);
let out = std::process::Command::new("git")
.arg("-C")
.arg(&wt)
.args(["switch", "--detach", "origin/main"])
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
let dirt = inspect_dirt(&wt)
.expect("an unpushed commit on the bare leaf-named branch must read as dirty");
assert_eq!(
dirt.unpushed_commits, 1,
"HEAD is pushed but the bare `bare-branch` is not; reason: {}",
dirt.reason
);
}
#[test]
fn inspect_dirt_does_not_double_count_the_checked_out_session_branch() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("single-count");
GitWorktreeFixture::commit_unpushed(&wt);
let dirt = inspect_dirt(&wt).expect("an unpushed commit must read as dirty");
assert_eq!(
dirt.unpushed_commits, 1,
"the same commit must not be counted by both legs; reason: {}",
dirt.reason
);
}
#[test]
fn git_command_strips_repository_redirecting_env() {
let cmd = super::git_command(std::path::Path::new("/tmp"), &["status"]);
let removed: Vec<&str> = cmd
.get_envs()
.filter(|(_, v)| v.is_none())
.filter_map(|(k, _)| k.to_str())
.collect();
for key in [
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_CONFIG_COUNT",
] {
assert!(
removed.contains(&key),
"{key} must be removed from the child environment; removed: {removed:?}"
);
}
}
#[test]
fn git_command_pins_untracked_and_excludes_config() {
let cmd = super::git_command(std::path::Path::new("/tmp"), &["status"]);
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
for pin in [
"core.excludesFile=",
"status.showUntrackedFiles=normal",
"core.quotePath=false",
] {
assert!(
args.iter().any(|a| a == pin),
"`{pin}` must be pinned on every git call; args: {args:?}"
);
}
}
#[test]
fn inspect_dirt_treats_missing_path_as_dirty() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("does").join("not").join("exist");
let dirt = inspect_dirt(&missing).expect("an unexaminable path must read as dirty");
assert!(
dirt.reason.contains("unreadable") || dirt.reason.contains("not a git worktree"),
"unexpected reason: {}",
dirt.reason
);
}
#[test]
fn inspect_dirt_treats_non_worktree_with_files_as_dirty() {
let fx = GitWorktreeFixture::new();
let plain = fx.repo.join(".worktrees").join("not-a-worktree");
std::fs::create_dir_all(&plain).unwrap();
std::fs::write(plain.join("leftover.txt"), "who knows\n").unwrap();
let dirt = inspect_dirt(&plain).expect("a non-worktree dir holding files must read as dirty");
assert_eq!(dirt.dirty_files, 1, "reason: {}", dirt.reason);
assert!(
dirt.reason.contains("not a git worktree root"),
"unexpected reason: {}",
dirt.reason
);
}
#[test]
fn inspect_dirt_allows_empty_non_git_leftover() {
let tmp = tempfile::tempdir().unwrap();
let shell = tmp.path().join(".worktrees").join("empty-shell");
std::fs::create_dir_all(&shell).unwrap();
std::fs::write(
shell.join(super::super::decommission::WORKTREE_SENTINEL_FILE),
"{}",
)
.unwrap();
assert!(
inspect_dirt(&shell).is_none(),
"an empty leftover shell must stay reclaimable; got {:?}",
inspect_dirt(&shell)
);
}