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 worktree_remove_command_strips_repository_redirecting_env() {
let cmd = super::worktree_remove_command(
std::path::Path::new("/r"),
std::path::Path::new("/r/.claude/worktrees/agent-x"),
);
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_COMMON_DIR",
"GIT_CONFIG_COUNT",
] {
assert!(
removed.contains(&key),
"{key} must be removed from the removal's child environment; removed: {removed:?}"
);
}
}
#[test]
fn worktree_remove_command_ends_in_the_target_path() {
let target = std::path::Path::new("/r/.claude/worktrees/agent-x");
let cmd = super::worktree_remove_command(std::path::Path::new("/r"), target);
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(
args.ends_with(&[
"worktree".to_string(),
"remove".to_string(),
"--force".to_string(),
target.display().to_string(),
]),
"the removal must end in `worktree remove --force <path>`; args: {args:?}"
);
assert!(
args.windows(2).any(|w| w[0] == "-C" && w[1] == "/r"),
"the repository root must still be named by `-C`; args: {args:?}"
);
}
#[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)
);
}
fn git_must(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.unwrap_or_else(|e| panic!("`git {}` could not be run: {e}", args.join(" ")));
assert!(
out.status.success(),
"`git {}` failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn worktree_7889_a_stale_origin_main_no_longer_spares_a_squash_merged_branch() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("agent-a2e6bc07b");
fx.land_on_the_remote_only(&wt, "landed.rs");
let dirt = inspect_dirt(&wt).expect("the stale-ref state must reproduce the refusal");
assert_eq!(
dirt.unpushed_commits, 1,
"the fixture must leave exactly the one commit gate 6 miscounted: {dirt:?}"
);
crate::session_manager::worktree_landing_refresh::refresh_landing_refs(&wt)
.expect("the fixture's origin is reachable");
assert!(
inspect_dirt(&wt).is_none(),
"once the landing ref is current the squash clears the branch; got {:?}",
inspect_dirt(&wt)
);
}
#[test]
fn inspect_dirt_clears_a_squash_merged_branch_whose_upstream_was_pruned() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("squashed");
fx.squash_merge_to_origin(&wt, "landed.rs");
let reachability = git_stdout(&wt, &["rev-list", "--count", "HEAD", "--not", "--remotes"])
.expect("rev-list must run");
assert_eq!(
reachability.trim(),
"1",
"the fixture must reproduce the pruned-upstream shape this bug needs"
);
assert!(
inspect_dirt(&wt).is_none(),
"a squash-merged branch holds no unsaved work; got {:?}",
inspect_dirt(&wt)
);
}
#[test]
fn inspect_dirt_clears_a_two_commit_squash_merge() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("squashed-two");
fx.squash_merge_commits_to_origin(&wt, &["first.rs", "second.rs"]);
let reachability = git_stdout(&wt, &["rev-list", "--count", "HEAD", "--not", "--remotes"])
.expect("rev-list must run");
assert_eq!(
reachability.trim(),
"2",
"the fixture must reproduce a divergence of more than one commit"
);
let cherry =
git_stdout(&wt, &["cherry", "refs/remotes/origin/main", "HEAD"]).expect("cherry must run");
assert!(
!cherry.contains("- "),
"the per-commit comparison must find NO match — that is the whole bug; got {cherry}"
);
assert!(
inspect_dirt(&wt).is_none(),
"a branch whose two commits squash-merged as one holds no unsaved work; got {:?}",
inspect_dirt(&wt)
);
}
#[test]
fn inspect_dirt_reports_a_commit_added_after_a_two_commit_squash_merge() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("squashed-two-plus");
fx.squash_merge_commits_to_origin(&wt, &["first.rs", "second.rs"]);
std::fs::write(wt.join("never-landed.rs"), "work that exists only here\n").unwrap();
git_must(&wt, &["add", "never-landed.rs"]);
git_must(&wt, &["commit", "-m", "feat: never pushed anywhere"]);
let dirt = inspect_dirt(&wt).expect("a commit added after the squash must still read as dirty");
assert_eq!(
dirt.unpushed_commits, 3,
"an aggregate that no longer matches discounts nothing; reason was: {}",
dirt.reason
);
}
#[test]
fn inspect_dirt_keeps_the_raw_count_when_there_is_no_merge_base() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("orphan-6507");
git_must(&wt, &["checkout", "--orphan", "unrelated-6507"]);
git_must(&wt, &["rm", "-rf", "."]);
std::fs::write(wt.join("only-here.rs"), "work that shares no ancestor\n").unwrap();
git_must(&wt, &["add", "only-here.rs"]);
git_must(&wt, &["commit", "-m", "feat: unrelated root commit"]);
assert!(
git_stdout(&wt, &["merge-base", "refs/remotes/origin/main", "HEAD"]).is_err(),
"the fixture must reproduce a history with no merge base"
);
let dirt = inspect_dirt(&wt).expect("an unrelated root commit is unsaved work");
assert_eq!(
dirt.unpushed_commits, 1,
"an unanswerable comparison discounts nothing; reason was: {}",
dirt.reason
);
}
#[test]
fn inspect_dirt_keeps_the_raw_count_when_the_divergence_has_no_diff() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("netzero-6507");
std::fs::write(wt.join("ephemeral.rs"), "added, then taken away\n").unwrap();
git_must(&wt, &["add", "ephemeral.rs"]);
git_must(&wt, &["commit", "-m", "feat: add a file"]);
git_must(&wt, &["rm", "ephemeral.rs"]);
git_must(&wt, &["commit", "-m", "fix: remove it again"]);
let names = git_stdout(
&wt,
&["diff", "--name-only", "refs/remotes/origin/main", "HEAD"],
)
.expect("diff must run");
assert!(
names.trim().is_empty(),
"the divergence must cancel out; got {names}"
);
let dirt = inspect_dirt(&wt).expect("two commits that landed nowhere are unsaved work");
assert_eq!(
dirt.unpushed_commits, 2,
"an empty aggregate proves nothing landed; reason was: {}",
dirt.reason
);
}
#[test]
fn inspect_dirt_keeps_the_raw_count_when_the_squash_falls_outside_the_window() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("windowed-6507");
fx.squash_merge_commits_to_origin(&wt, &["first.rs", "second.rs"]);
for i in 0..25 {
std::fs::write(fx.repo.join("first.rs"), format!("later edit {i}\n")).unwrap();
git_must(&fx.repo, &["add", "first.rs"]);
git_must(
&fx.repo,
&["commit", "-m", &format!("chore: later edit {i}")],
);
}
git_must(&fx.repo, &["push", "origin", "main"]);
git_must(&fx.repo, &["fetch", "--prune", "origin"]);
let dirt =
inspect_dirt(&wt).expect("a squash the window cannot reach is not proof the work landed");
assert_eq!(
dirt.unpushed_commits, 2,
"an exhausted window discounts nothing; reason was: {}",
dirt.reason
);
}
#[test]
fn inspect_dirt_still_reports_a_genuinely_unpushed_commit_beside_a_squashed_one() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("mixed");
fx.squash_merge_to_origin(&wt, "landed.rs");
std::fs::write(wt.join("never-landed.rs"), "work that exists only here\n").unwrap();
git_must(&wt, &["add", "never-landed.rs"]);
git_must(&wt, &["commit", "-m", "feat: never pushed anywhere"]);
let dirt = inspect_dirt(&wt).expect("an unlanded commit must still read as dirty");
assert_eq!(
dirt.unpushed_commits, 1,
"only the squash-merged commit may be discounted; reason was: {}",
dirt.reason
);
}
#[test]
fn inspect_dirt_discounts_only_the_commits_the_query_counted() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("two-refs");
fx.land_patch_and_keep_the_remote_branch(&wt, "landed.rs", "landed-elsewhere");
std::fs::write(wt.join("only-here.rs"), "exists nowhere else\n").unwrap();
git_must(&wt, &["add", "only-here.rs"]);
git_must(&wt, &["commit", "-m", "feat: exists nowhere else"]);
let counted = git_stdout(&wt, &["rev-list", "--count", "HEAD", "--not", "--remotes"])
.expect("rev-list must run");
assert_eq!(
counted.trim(),
"1",
"the fixture must count only the commit that landed nowhere"
);
let cherry = git_stdout(&wt, &["cherry", "refs/remotes/origin/main", "HEAD"])
.expect("git cherry must run");
assert_eq!(
cherry.lines().filter(|l| l.starts_with("- ")).count(),
1,
"the cherry range must also hold a landed commit the rev-list never counted: {cherry}"
);
let dirt = inspect_dirt(&wt).expect("the second commit is unsaved work");
assert_eq!(
dirt.unpushed_commits, 1,
"a landed commit outside the counted set must not discount one inside it; reason was: {}",
dirt.reason
);
}
#[test]
fn inspect_dirt_discounts_nothing_without_a_remote_landing_branch() {
let tmp = tempfile::tempdir().unwrap();
let repo = std::fs::canonicalize(tmp.path()).unwrap().join("solo");
std::fs::create_dir_all(&repo).unwrap();
git_must(&repo, &["init", "--initial-branch=main"]);
git_must(&repo, &["config", "user.email", "ci@test.invalid"]);
git_must(&repo, &["config", "user.name", "CI"]);
git_must(&repo, &["config", "commit.gpgsign", "false"]);
std::fs::write(repo.join("only.rs"), "local\n").unwrap();
git_must(&repo, &["add", "only.rs"]);
git_must(&repo, &["commit", "-m", "local only"]);
assert!(
landing_bases(&repo).is_empty(),
"a repository with no remotes offers no landing base"
);
let dirt = inspect_dirt(&repo).expect("a commit on no remote is unsaved work");
assert_eq!(dirt.unpushed_commits, 1, "reason was: {}", dirt.reason);
}
fn git_try(dir: &Path, args: &[&str]) -> (bool, String) {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.unwrap_or_else(|e| panic!("`git {}` could not be run: {e}", args.join(" ")));
(
out.status.success(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
#[test]
fn a_worktree_holding_only_the_harness_marker_is_removable_by_plain_git() {
use crate::core::worktree_removal_facts::{GitAndGhProbe, WorktreeRemovalProbe};
use crate::session_manager::worktree_ownership::take_worktree_sentinel;
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("marker-only");
GitWorktreeFixture::stamp_reclaimable_sentinel(&wt);
assert!(
inspect_dirt(&wt).is_none(),
"the harness marker is not unsaved work; got {:?}",
inspect_dirt(&wt)
);
assert_eq!(
GitAndGhProbe
.dirty_entries(&wt)
.expect("status must be readable"),
0,
"both reclaim paths must read the marker-only tree as clean"
);
let (removed, stderr) = git_try(&fx.repo, &["worktree", "remove", wt.to_str().unwrap()]);
assert!(
!removed && stderr.contains("untracked files"),
"git itself counts the untracked marker — if this ever stops being true \
the clearing step below is no longer needed; stderr: {stderr}"
);
let taken = take_worktree_sentinel(&wt).expect("the harness marker must be clearable");
assert!(
taken.is_some_and(|b| !b.is_empty()),
"taking the marker must hand back the bytes a failed removal would restore"
);
let (removed, stderr) = git_try(&fx.repo, &["worktree", "remove", wt.to_str().unwrap()]);
assert!(
removed,
"clearing the marker must let a NON-forced removal succeed; stderr: {stderr}"
);
}
#[test]
fn clearing_the_marker_leaves_every_other_file_alone() {
use crate::core::worktree_removal_facts::{GitAndGhProbe, WorktreeRemovalProbe};
use crate::session_manager::worktree_ownership::{
restore_worktree_sentinel, take_worktree_sentinel,
};
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("marker-plus-work");
GitWorktreeFixture::stamp_reclaimable_sentinel(&wt);
std::fs::write(wt.join("README.md"), "edited\n").expect("modify tracked file");
let untracked = wt.join("scratch.rs");
std::fs::write(&untracked, "fn main() {}\n").expect("write untracked source");
let dirt = inspect_dirt(&wt).expect("real work beside the marker is still unsaved work");
assert_eq!(dirt.dirty_files, 2, "reason was: {}", dirt.reason);
assert_eq!(
GitAndGhProbe
.dirty_entries(&wt)
.expect("status must be readable"),
2,
"the removal guard must count the same two files"
);
let taken = take_worktree_sentinel(&wt)
.expect("clearing must succeed")
.expect("the marker was there, so its bytes come back");
assert!(
untracked.exists() && wt.join("README.md").exists(),
"clearing the marker must remove the marker and nothing else"
);
assert!(
take_worktree_sentinel(&wt)
.expect("a second take must be a no-op")
.is_none()
);
restore_worktree_sentinel(&wt, &taken).expect("restore must succeed");
assert_eq!(
std::fs::read(wt.join(super::super::decommission::WORKTREE_SENTINEL_FILE))
.expect("read restored marker"),
taken,
"the restore must be byte-faithful"
);
assert_eq!(
GitAndGhProbe
.dirty_entries(&wt)
.expect("status must be readable"),
2,
"a restored marker must not become work"
);
}
#[test]
fn local_only_commits_counts_only_what_no_origin_ref_has() {
use crate::core::worktree_removal_facts::{GitAndGhProbe, WorktreeRemovalProbe};
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("landed-history");
assert_eq!(
GitAndGhProbe
.local_only_commits(&wt)
.expect("a fresh worktree must be countable"),
0,
"a worktree branched off a pushed tip holds no commit origin lacks"
);
GitWorktreeFixture::commit_unpushed(&wt);
assert_eq!(
GitAndGhProbe
.local_only_commits(&wt)
.expect("a committed worktree must be countable"),
1,
"a commit on no origin ref is exactly what must keep denying"
);
std::fs::write(wt.join("landed.txt"), "landed\n").expect("write a file to land");
GitWorktreeFixture::commit_all_and_push(&wt, "land it");
assert_eq!(
GitAndGhProbe
.local_only_commits(&wt)
.expect("a pushed worktree must be countable"),
0,
"once the commits are on origin the tree is the only place for nothing"
);
}
#[test]
fn local_only_commits_reprunes_a_branch_deleted_behind_this_worktrees_back() {
use crate::core::worktree_removal_facts::{GitAndGhProbe, WorktreeRemovalProbe};
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("deleted-upstream");
std::fs::write(wt.join("work.txt"), "real work\n").expect("write work");
GitWorktreeFixture::commit_all_and_push(&wt, "work nobody else has");
assert_eq!(
GitAndGhProbe
.local_only_commits(&wt)
.expect("a pushed worktree must be countable"),
0,
"the pushed branch vouches for the commit while it exists"
);
let origin = git_stdout(&wt, &["remote", "get-url", "origin"]).expect("origin url");
let clone = fx.repos_root.join("second-clone");
let (ok, stderr) = git_try(
&fx.repos_root,
&["clone", "-q", origin.trim(), clone.to_str().expect("utf8")],
);
assert!(ok, "the second clone must succeed; stderr: {stderr}");
let (ok, stderr) = git_try(
&clone,
&[
"push",
"-q",
"origin",
"--delete",
"session/deleted-upstream",
],
);
assert!(ok, "the remote delete must succeed; stderr: {stderr}");
assert_eq!(
GitAndGhProbe
.local_only_commits(&wt)
.expect("the count must still be answerable"),
1,
"a ref the remote no longer has must stop vouching — this worktree now holds the \
only copy of that commit"
);
}
#[test]
fn local_only_commits_cannot_be_answered_when_origin_is_unreachable() {
use crate::core::worktree_removal_facts::{GitAndGhProbe, WorktreeRemovalProbe};
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree("dead-origin");
let gone = fx.repos_root.join("no-such-remote.git");
let (ok, stderr) = git_try(
&wt,
&["remote", "set-url", "origin", gone.to_str().expect("utf8")],
);
assert!(
ok,
"pointing origin at nothing must succeed; stderr: {stderr}"
);
let answer = GitAndGhProbe.local_only_commits(&wt);
let err = answer.expect_err("an unreachable origin must not produce a trusted count");
assert!(
err.contains("could not be refreshed"),
"the failure must name the refresh, not a count: {err}"
);
}