use std::path::{Path, PathBuf};
use super::*;
use crate::session_manager::worktree_git_fixture::GitWorktreeFixture;
use crate::session_manager::worktree_safety::inspect_dirt;
fn git_ok(dir: &Path, args: &[&str]) {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.unwrap_or_else(|e| panic!("fixture: `git {}` could not be run: {e}", args.join(" ")));
assert!(
out.status.success(),
"fixture: `git {}` failed in {}: {}",
args.join(" "),
dir.display(),
String::from_utf8_lossy(&out.stderr)
);
}
fn seed_self_contained_clone(fx: &GitWorktreeFixture, parent: &Path) -> PathBuf {
let remote = fx.repos_root.join("inner-remote.git");
std::fs::create_dir_all(&remote).unwrap();
git_ok(&remote, &["init", "--bare", "--initial-branch=main"]);
let clone = parent.join("scratch").join("work");
std::fs::create_dir_all(&clone).unwrap();
git_ok(&clone, &["init", "--initial-branch=main"]);
git_ok(&clone, &["config", "user.email", "ci@test.invalid"]);
git_ok(&clone, &["config", "user.name", "CI"]);
git_ok(&clone, &["config", "commit.gpgsign", "false"]);
std::fs::write(clone.join("a.txt"), "base\n").unwrap();
git_ok(&clone, &["add", "a.txt"]);
git_ok(&clone, &["commit", "-m", "base"]);
git_ok(
&clone,
&["remote", "add", "origin", remote.to_str().unwrap()],
);
git_ok(&clone, &["push", "origin", "main"]);
git_ok(&clone, &["fetch", "origin"]);
git_ok(&clone, &["branch", "--set-upstream-to=origin/main", "main"]);
clone
}
fn seed_parent(fx: &GitWorktreeFixture, name: &str) -> PathBuf {
let parent = fx.add_worktree(name);
std::fs::write(parent.join(".gitignore"), "scratch/\n.claude/worktrees/\n").unwrap();
GitWorktreeFixture::commit_all_and_push(&parent, "ignore scratch and agent worktrees");
parent
}
#[test]
fn inspect_dirt_reports_self_contained_clone_with_work_on_another_branch() {
let fx = GitWorktreeFixture::new();
let parent = seed_parent(&fx, "clone-other-branch");
let clone = seed_self_contained_clone(&fx, &parent);
git_ok(&clone, &["checkout", "-b", "feature"]);
std::fs::write(clone.join("only-here.txt"), "the only copy\n").unwrap();
git_ok(&clone, &["add", "only-here.txt"]);
git_ok(&clone, &["commit", "-m", "work that only exists here"]);
git_ok(&clone, &["checkout", "main"]);
let status = std::process::Command::new("git")
.arg("-C")
.arg(&clone)
.args(["status", "--porcelain"])
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&status.stdout).trim().is_empty(),
"premise broken: the clone's working tree should be clean"
);
let head = std::process::Command::new("git")
.arg("-C")
.arg(&clone)
.args(["rev-list", "--count", "origin/main..HEAD"])
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&head.stdout).trim(),
"0",
"premise broken: the clone's HEAD should be fully pushed"
);
let dirt = inspect_dirt(&parent)
.expect("a commit living only on another local branch of a nested clone must be seen");
assert!(
dirt.reason.contains("self-contained clone"),
"the reason must say which loss model applied; got: {}",
dirt.reason
);
assert!(
dirt.unpushed_commits >= 1,
"the unreachable commit must be counted; got: {}",
dirt.reason
);
}
#[test]
fn inspect_dirt_reports_self_contained_clone_holding_only_a_stash() {
let fx = GitWorktreeFixture::new();
let parent = seed_parent(&fx, "clone-stash");
let clone = seed_self_contained_clone(&fx, &parent);
std::fs::write(clone.join("a.txt"), "stash me\n").unwrap();
git_ok(&clone, &["stash"]);
let status = std::process::Command::new("git")
.arg("-C")
.arg(&clone)
.args(["status", "--porcelain"])
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&status.stdout).trim().is_empty(),
"premise broken: `git stash` should have left a clean tree"
);
let dirt = inspect_dirt(&parent).expect("a nested clone's stash dies with it and must be seen");
assert!(
dirt.reason.contains("stash"),
"the reason must name the stash; got: {}",
dirt.reason
);
}
#[test]
fn inspect_dirt_reports_remote_less_self_contained_clone() {
let fx = GitWorktreeFixture::new();
let parent = seed_parent(&fx, "clone-no-remote");
let clone = parent.join("scratch").join("orphan");
std::fs::create_dir_all(&clone).unwrap();
git_ok(&clone, &["init", "--initial-branch=main"]);
git_ok(&clone, &["config", "user.email", "ci@test.invalid"]);
git_ok(&clone, &["config", "user.name", "CI"]);
git_ok(&clone, &["config", "commit.gpgsign", "false"]);
std::fs::write(clone.join("solo.txt"), "never pushed anywhere\n").unwrap();
git_ok(&clone, &["add", "solo.txt"]);
git_ok(&clone, &["commit", "-m", "solo"]);
let dirt = inspect_dirt(&parent).expect("a remote-less nested clone must read as dirty");
assert!(
dirt.reason.contains("self-contained clone"),
"unexpected reason: {}",
dirt.reason
);
}
#[test]
fn inspect_dirt_allows_fully_pushed_self_contained_clone() {
let fx = GitWorktreeFixture::new();
let parent = seed_parent(&fx, "clone-clean");
seed_self_contained_clone(&fx, &parent);
assert!(
inspect_dirt(&parent).is_none(),
"a fully-pushed nested clone must not pin the parent; got {:?}",
inspect_dirt(&parent)
);
}
#[test]
fn object_store_dies_with_is_false_for_a_registered_worktree() {
let fx = GitWorktreeFixture::new();
let parent = seed_parent(&fx, "discriminator-registered");
let nested = fx.add_nested_worktree(&parent, ".claude/worktrees", "agentA");
let canonical = std::fs::canonicalize(&parent).unwrap();
assert_eq!(
object_store_dies_with(&nested, &canonical),
Ok(false),
"a registered worktree's store lives outside the candidate and survives it"
);
}
#[test]
fn object_store_dies_with_is_true_for_a_self_contained_clone() {
let fx = GitWorktreeFixture::new();
let parent = seed_parent(&fx, "discriminator-clone");
let clone = seed_self_contained_clone(&fx, &parent);
let canonical = std::fs::canonicalize(&parent).unwrap();
assert_eq!(
object_store_dies_with(&clone, &canonical),
Ok(true),
"a self-contained clone's store lives inside the candidate and dies with it"
);
}
fn seed_bare_repo_with_only_copy(fx: &GitWorktreeFixture, parent: &Path, rel: &str) -> PathBuf {
let bare = parent.join(rel);
std::fs::create_dir_all(&bare).unwrap();
git_ok(&bare, &["init", "--bare", "--initial-branch=main"]);
let src = fx.repos_root.join("bare-source");
std::fs::create_dir_all(&src).unwrap();
git_ok(&src, &["init", "--initial-branch=main"]);
git_ok(&src, &["config", "user.email", "ci@test.invalid"]);
git_ok(&src, &["config", "user.name", "CI"]);
git_ok(&src, &["config", "commit.gpgsign", "false"]);
std::fs::write(src.join("salvage.txt"), "the only copy\n").unwrap();
git_ok(&src, &["add", "salvage.txt"]);
git_ok(&src, &["commit", "-m", "salvaged work"]);
git_ok(&src, &["push", bare.to_str().unwrap(), "main"]);
std::fs::remove_dir_all(&src).unwrap();
bare
}
#[test]
fn inspect_dirt_reports_nested_bare_repo_holding_the_only_copy() {
let fx = GitWorktreeFixture::new();
let parent = seed_parent(&fx, "bare-only-copy");
let bare = seed_bare_repo_with_only_copy(&fx, &parent, "scratch/salvage.git");
assert!(
!bare.join(".git").exists(),
"premise broken: a bare repo must not have a .git entry"
);
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: the bare repo sits under a gitignored path"
);
let dirt = inspect_dirt(&parent)
.expect("a nested bare repo holding the only copy of a commit must be seen");
assert!(
dirt.reason.contains("bare repository"),
"the reason must name the loss model that applied; got: {}",
dirt.reason
);
assert!(
dirt.unpushed_commits >= 1,
"the unreachable commit must be counted; got: {}",
dirt.reason
);
}
#[test]
fn inspect_dirt_allows_an_empty_nested_bare_repo() {
let fx = GitWorktreeFixture::new();
let parent = seed_parent(&fx, "bare-empty");
let bare = parent.join("scratch").join("empty.git");
std::fs::create_dir_all(&bare).unwrap();
git_ok(&bare, &["init", "--bare", "--initial-branch=main"]);
assert!(
inspect_dirt(&parent).is_none(),
"an empty bare repo holds nothing and must not pin the parent; got {:?}",
inspect_dirt(&parent)
);
}
#[test]
fn scan_does_not_descend_into_a_bare_repos_object_store() {
let fx = GitWorktreeFixture::new();
let parent = seed_parent(&fx, "bare-no-descend");
let bare = parent.join("scratch").join("packed.git");
std::fs::create_dir_all(&bare).unwrap();
git_ok(&bare, &["init", "--bare", "--initial-branch=main"]);
std::fs::write(bare.join("objects").join("tripwire.bak"), "unreachable\n").unwrap();
assert!(
inspect_dirt(&parent).is_none(),
"the walk must stop at the bare repo root, so nothing inside objects/ is \
reachable; got {:?}",
inspect_dirt(&parent)
);
}
#[test]
fn inspect_dirt_reports_high_value_gitignored_env_file() {
let fx = GitWorktreeFixture::new();
let parent = fx.add_worktree("env-local");
std::fs::write(parent.join(".gitignore"), ".env*\n").unwrap();
GitWorktreeFixture::commit_all_and_push(&parent, "ignore env files");
std::fs::write(parent.join(".env.local"), "API_KEY=not-in-git\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: .env.local should be invisible to plain porcelain"
);
let dirt = inspect_dirt(&parent).expect("a gitignored .env.local must read as dirty");
assert!(
dirt.reason.contains(".env.local"),
"the reason must name the file at risk; got: {}",
dirt.reason
);
assert_eq!(dirt.dirty_files, 1, "reason: {}", dirt.reason);
}
#[test]
fn inspect_dirt_reports_high_value_gitignored_bak_in_a_subdirectory() {
let fx = GitWorktreeFixture::new();
let parent = fx.add_worktree("bak-nested");
std::fs::write(parent.join(".gitignore"), "scratch/\n").unwrap();
GitWorktreeFixture::commit_all_and_push(&parent, "ignore scratch");
std::fs::create_dir_all(parent.join("scratch").join("deep")).unwrap();
std::fs::write(
parent
.join("scratch")
.join("deep")
.join("settings.json.bak"),
"the pre-edit copy\n",
)
.unwrap();
let dirt = inspect_dirt(&parent).expect("a gitignored *.bak must read as dirty");
assert!(
dirt.reason.contains("settings.json.bak"),
"the reason must name the file at risk; got: {}",
dirt.reason
);
}
#[test]
fn inspect_dirt_ignores_high_value_names_inside_disposable_build_dirs() {
let fx = GitWorktreeFixture::new();
let parent = fx.add_worktree("disposable-env");
std::fs::write(parent.join(".gitignore"), "target/\nnode_modules/\n").unwrap();
GitWorktreeFixture::commit_all_and_push(&parent, "ignore build output");
std::fs::create_dir_all(parent.join("target").join("debug")).unwrap();
std::fs::write(parent.join("target").join(".env"), "BUILD=1\n").unwrap();
std::fs::write(
parent.join("target").join("debug").join("incremental.bak"),
"regenerable\n",
)
.unwrap();
std::fs::create_dir_all(parent.join("node_modules")).unwrap();
std::fs::write(parent.join("node_modules").join("x.orig"), "vendored\n").unwrap();
assert!(
inspect_dirt(&parent).is_none(),
"a candidate whose only ignored content is build output must stay \
reclaimable; got {:?}",
inspect_dirt(&parent)
);
}
#[test]
fn object_store_dies_with_errors_on_a_non_repository() {
let tmp = tempfile::tempdir().unwrap();
let plain = tmp.path().join("not-a-repo");
std::fs::create_dir_all(&plain).unwrap();
assert!(
object_store_dies_with(&plain, tmp.path()).is_err(),
"a directory git cannot speak for must not yield a loss-model verdict"
);
}