use serde_json::Value;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
fn git(root: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.output()
.unwrap();
assert!(
output.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
stdout.strip_suffix('\n').unwrap_or(&stdout).to_owned()
}
struct Fixture {
_temp: tempfile::TempDir,
repo: PathBuf,
lane: PathBuf,
base: String,
}
impl Fixture {
fn new() -> Self {
Self::with_suffix("")
}
fn with_suffix(suffix: &str) -> Self {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join(format!("repo{suffix}"));
std::fs::create_dir(&repo).unwrap();
git(&repo, &["init", "-b", "v6.7.7"]);
git(&repo, &["config", "user.email", "fixture@example.invalid"]);
git(&repo, &["config", "user.name", "Fixture"]);
git(&repo, &["config", "gc.auto", "0"]);
git(&repo, &["config", "maintenance.auto", "false"]);
std::fs::write(repo.join("tracked"), "base\n").unwrap();
git(&repo, &["add", "tracked"]);
git(&repo, &["commit", "-m", "base"]);
let base = git(&repo, &["rev-parse", "HEAD"]);
let lane = temp.path().join(format!("lane{suffix}"));
git(
&repo,
&[
"worktree",
"add",
"-b",
"lane/508-custody.12",
lane.to_str().unwrap(),
],
);
Self {
_temp: temp,
repo,
lane,
base,
}
}
fn check(&self, root: &Path, extra: &[&str]) -> (Output, Value) {
self.check_with_env(root, extra, &[])
}
fn check_with_env(&self, root: &Path, extra: &[&str], env: &[(&str, &str)]) -> (Output, Value) {
let mut command = Command::new(env!("CARGO_BIN_EXE_shepherd"));
command
.args(["lane-check", "--root"])
.arg(root)
.arg("--worktree")
.arg(&self.lane)
.arg("--common-dir")
.arg(self.repo.join(".git"))
.args([
"--branch",
"refs/heads/lane/508-custody.12",
"--base-ref",
"refs/heads/v6.7.7",
"--base-sha",
&self.base,
])
.args(extra);
for (key, value) in env {
command.env(key, value);
}
let output = command.output().unwrap();
let report = serde_json::from_slice(&output.stdout).unwrap_or_else(|_| {
panic!(
"missing report: {}",
String::from_utf8_lossy(&output.stderr)
)
});
(output, report)
}
}
fn index_bytes(root: &Path) -> (PathBuf, Vec<u8>) {
let path = PathBuf::from(git(
root,
&["rev-parse", "--path-format=absolute", "--git-path", "index"],
));
let bytes = std::fs::read(&path).unwrap();
(path, bytes)
}
fn check_flagged_entries(f: &Fixture, target: &Path, indexes: &[&Path]) {
let mut accepted = Vec::new();
for (flags, tag) in [
(vec!["--assume-unchanged"], 'h'),
(vec!["--skip-worktree"], 'S'),
(vec!["--assume-unchanged", "--skip-worktree"], 's'),
] {
for edited in [false, true] {
git(
target,
&["update-index", "--no-assume-unchanged", "tracked"],
);
git(target, &["update-index", "--no-skip-worktree", "tracked"]);
let content = if edited { "precious edit\n" } else { "base\n" };
std::fs::write(target.join("tracked"), content).unwrap();
for flag in &flags {
git(target, &["update-index", flag, "tracked"]);
}
let entries = git(target, &["ls-files", "-v", "-z"]);
assert!(
entries
.split('\0')
.any(|entry| entry == format!("{tag} tracked")),
"{entries:?}"
);
assert!(
git(
&f.lane,
&["status", "--porcelain=v1", "-z", "--ignore-submodules=none"]
)
.is_empty()
);
let before: Vec<_> = indexes.iter().map(|path| index_bytes(path)).collect();
for extra in [&[][..], &["--require-clean"][..]] {
let (out, report) = f.check(&f.lane, extra);
if out.status.code() != Some(1)
|| report["ok"] != false
|| !report["dirty"].is_null()
|| !report["untracked"].is_null()
|| !report["findings"].to_string().contains("index flags")
{
accepted.push(format!(
"flags={flags:?} edited={edited} extra={extra:?}: {report}"
));
}
assert_eq!(report["ownership"], "not_assessed");
assert_eq!(git(target, &["ls-files", "-v", "-z"]), entries);
assert_eq!(
std::fs::read_to_string(target.join("tracked")).unwrap(),
content
);
for (path, bytes) in &before {
assert_eq!(
&std::fs::read(path).unwrap(),
bytes,
"index changed: {path:?}"
);
}
}
}
}
git(
target,
&["update-index", "--no-assume-unchanged", "tracked"],
);
git(target, &["update-index", "--no-skip-worktree", "tracked"]);
std::fs::write(target.join("tracked"), "base\n").unwrap();
let (out, report) = f.check(&f.lane, &["--require-clean"]);
assert!(out.status.success(), "unflagged clean rejected: {report}");
assert!(
accepted.is_empty(),
"unsupported flagged inspection accepted:\n{}",
accepted.join("\n")
);
}
#[test]
fn root_index_flags_fail_closed_without_changing_bits_index_or_content() {
let f = Fixture::new();
assert!(f.check(&f.lane, &["--require-clean"]).0.status.success());
check_flagged_entries(&f, &f.lane, &[&f.lane]);
}
#[test]
fn recursive_submodule_index_flags_fail_closed_and_preserve_every_index() {
let f = Fixture::new();
let module = Fixture::new();
let inner_source = Fixture::new();
let module_name = "mod ' $name";
let inner_name = "inner ; $name";
git(
&module.repo,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"add",
inner_source.repo.to_str().unwrap(),
inner_name,
],
);
git(&module.repo, &["commit", "-am", "inner module"]);
git(
&f.lane,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"add",
module.repo.to_str().unwrap(),
module_name,
],
);
git(&f.lane, &["commit", "-am", "outer module"]);
git(
&f.lane,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"update",
"--init",
"--recursive",
],
);
let outer = f.lane.join(module_name);
let inner = outer.join(inner_name);
assert!(f.check(&f.lane, &["--require-clean"]).0.status.success());
check_flagged_entries(&f, &inner, &[&f.lane, &outer, &inner]);
check_flagged_entries(&f, &outer, &[&f.lane, &outer, &inner]);
}
#[test]
fn deinitialized_gitlink_cannot_certify_cleanliness_with_or_without_precious_work() {
let f = Fixture::new();
let module = Fixture::new();
git(
&f.lane,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"add",
module.repo.to_str().unwrap(),
"mod",
],
);
git(&f.lane, &["commit", "-am", "module"]);
assert!(f.check(&f.lane, &["--require-clean"]).0.status.success());
git(&f.lane, &["submodule", "deinit", "-f", "mod"]);
let nested = f.lane.join("mod");
let (index, before) = index_bytes(&f.lane);
assert!(git(&f.lane, &["submodule", "status", "--recursive"]).starts_with('-'));
std::fs::remove_dir(&nested).unwrap();
for state in ["absent", "empty", "precious"] {
if state == "empty" {
std::fs::create_dir(&nested).unwrap();
}
if state == "precious" {
std::fs::write(nested.join("precious"), b"keep me\n").unwrap();
}
for extra in [&[][..], &["--require-clean"][..]] {
let (out, report) = f.check(&f.lane, extra);
assert_eq!(out.status.code(), Some(1), "{report}");
assert_eq!(report["ok"], false);
assert!(report["dirty"].is_null(), "{report}");
assert!(report["untracked"].is_null(), "{report}");
assert!(
report["findings"]
.to_string()
.contains("uninitialized submodule"),
"{report}"
);
assert_eq!(std::fs::read(&index).unwrap(), before);
if state == "precious" {
assert_eq!(
std::fs::read(nested.join("precious")).unwrap(),
b"keep me\n"
);
}
}
}
}
#[test]
fn nested_deinitialized_gitlink_with_precious_work_cannot_certify_cleanliness() {
let f = Fixture::new();
let module = Fixture::new();
let inner_source = Fixture::new();
git(
&module.repo,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"add",
inner_source.repo.to_str().unwrap(),
"inner",
],
);
git(&module.repo, &["commit", "-am", "inner module"]);
git(
&f.lane,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"add",
module.repo.to_str().unwrap(),
"mod",
],
);
git(&f.lane, &["commit", "-am", "outer module"]);
git(
&f.lane,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"update",
"--init",
"--recursive",
],
);
let outer = f.lane.join("mod");
assert!(f.check(&f.lane, &["--require-clean"]).0.status.success());
git(&outer, &["submodule", "deinit", "-f", "inner"]);
let precious = outer.join("inner/precious");
std::fs::write(&precious, b"keep me\n").unwrap();
let indexes = [index_bytes(&f.lane), index_bytes(&outer)];
assert!(
git(&f.lane, &["submodule", "status", "--recursive"])
.lines()
.any(|line| line.starts_with('-'))
);
for extra in [&[][..], &["--require-clean"][..]] {
let (out, report) = f.check(&f.lane, extra);
assert_eq!(out.status.code(), Some(1), "{report}");
assert_eq!(report["ok"], false);
assert!(report["dirty"].is_null(), "{report}");
assert!(
report["findings"]
.to_string()
.contains("uninitialized submodule"),
"{report}"
);
for (index, before) in &indexes {
assert_eq!(&std::fs::read(index).unwrap(), before);
}
assert_eq!(std::fs::read(&precious).unwrap(), b"keep me\n");
}
}
#[test]
#[cfg(unix)]
fn core_filemode_false_cannot_hide_root_executable_bit_change() {
use std::os::unix::fs::PermissionsExt;
let f = Fixture::new();
let tracked = f.lane.join("tracked");
git(&f.lane, &["config", "core.fileMode", "false"]);
assert!(f.check(&f.lane, &["--require-clean"]).0.status.success());
std::fs::set_permissions(&tracked, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(git(&f.lane, &["status", "--porcelain=v1"]).is_empty());
let (index, before) = index_bytes(&f.lane);
let (out, report) = f.check(&f.lane, &[]);
assert!(out.status.success(), "{report}");
assert_eq!(report["dirty"], true);
let (out, report) = f.check(&f.lane, &["--require-clean"]);
assert_eq!(out.status.code(), Some(1), "{report}");
assert_eq!(report["dirty"], true);
assert_eq!(std::fs::read(&index).unwrap(), before);
assert_eq!(std::fs::read(&tracked).unwrap(), b"base\n");
assert_eq!(
std::fs::metadata(&tracked).unwrap().permissions().mode() & 0o777,
0o755
);
assert_eq!(git(&f.lane, &["config", "core.fileMode"]), "false");
std::fs::set_permissions(&tracked, std::fs::Permissions::from_mode(0o644)).unwrap();
assert!(f.check(&f.lane, &["--require-clean"]).0.status.success());
}
#[test]
#[cfg(unix)]
fn core_filemode_false_cannot_hide_initialized_submodule_executable_bit_change() {
use std::os::unix::fs::PermissionsExt;
let f = Fixture::new();
let module = Fixture::new();
git(
&f.lane,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"add",
module.repo.to_str().unwrap(),
"mod",
],
);
git(&f.lane, &["commit", "-am", "module"]);
let nested = f.lane.join("mod");
let tracked = nested.join("tracked");
git(&nested, &["config", "core.fileMode", "false"]);
assert!(f.check(&f.lane, &["--require-clean"]).0.status.success());
std::fs::set_permissions(&tracked, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(
git(
&f.lane,
&["status", "--porcelain=v1", "--ignore-submodules=none"]
)
.is_empty()
);
let indexes = [index_bytes(&f.lane), index_bytes(&nested)];
let (out, report) = f.check(&f.lane, &[]);
assert!(out.status.success(), "{report}");
assert_eq!(report["dirty"], true);
let (out, report) = f.check(&f.lane, &["--require-clean"]);
assert_eq!(out.status.code(), Some(1), "{report}");
assert_eq!(report["dirty"], true);
for (index, before) in &indexes {
assert_eq!(&std::fs::read(index).unwrap(), before);
}
assert_eq!(std::fs::read(&tracked).unwrap(), b"base\n");
assert_eq!(
std::fs::metadata(&tracked).unwrap().permissions().mode() & 0o777,
0o755
);
assert_eq!(git(&nested, &["config", "core.fileMode"]), "false");
std::fs::set_permissions(&tracked, std::fs::Permissions::from_mode(0o644)).unwrap();
assert!(f.check(&f.lane, &["--require-clean"]).0.status.success());
}
#[test]
fn same_size_restored_mtime_edit_is_dirty_despite_stat_configuration() {
use std::time::{Duration, SystemTime};
let cases: [&[(&str, &str)]; 4] = [
&[("core.trustctime", "false")],
&[("core.checkStat", "minimal")],
&[("core.trustctime", "false"), ("core.checkStat", "minimal")],
&[],
];
for settings in cases {
for nested in [false, true] {
let f = Fixture::new();
let module = Fixture::new();
let target = if nested {
git(
&f.lane,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"add",
module.repo.to_str().unwrap(),
"mod",
],
);
git(&f.lane, &["commit", "-am", "module"]);
f.lane.join("mod")
} else {
f.lane.clone()
};
let tracked = target.join("tracked");
let old = SystemTime::now() - Duration::from_secs(86400);
std::fs::File::open(&tracked)
.unwrap()
.set_modified(old)
.unwrap();
for (setting, value) in settings {
git(&target, &["config", setting, value]);
}
git(&target, &["update-index", "--refresh"]);
let indexes = if nested {
vec![index_bytes(&f.lane), index_bytes(&target)]
} else {
vec![index_bytes(&f.lane)]
};
std::fs::write(&tracked, b"edit\n").unwrap();
std::fs::File::open(&tracked)
.unwrap()
.set_modified(old)
.unwrap();
let (out, report) = f.check(&f.lane, &[]);
assert!(
out.status.success(),
"{settings:?} nested={nested}: {report}"
);
assert_eq!(
report["dirty"], true,
"{settings:?} nested={nested}: {report}"
);
let (out, report) = f.check(&f.lane, &["--require-clean"]);
assert_eq!(
out.status.code(),
Some(1),
"{settings:?} nested={nested}: {report}"
);
assert_eq!(report["dirty"], true);
for (index, before) in &indexes {
assert_eq!(&std::fs::read(index).unwrap(), before);
}
assert_eq!(std::fs::read(&tracked).unwrap(), b"edit\n");
for (setting, value) in settings {
assert_eq!(git(&target, &["config", setting]), *value);
}
std::fs::write(&tracked, b"base\n").unwrap();
std::fs::File::open(&tracked)
.unwrap()
.set_modified(old)
.unwrap();
assert!(f.check(&f.lane, &["--require-clean"]).0.status.success());
}
}
}
#[test]
fn unchanged_normalized_file_and_symlink_are_clean_but_changed_link_target_is_dirty() {
let f = Fixture::new();
std::fs::write(f.lane.join(".gitattributes"), b"tracked text\n").unwrap();
std::fs::write(f.lane.join("tracked"), b"base\r\n").unwrap();
git(&f.lane, &["add", ".gitattributes", "tracked"]);
git(&f.lane, &["commit", "-m", "normalized file"]);
let objects = f.repo.join(".git/objects");
let before = object_snapshot(&objects);
assert!(f.check(&f.lane, &["--require-clean"]).0.status.success());
assert_eq!(
object_snapshot(&objects),
before,
"content hashing wrote Git objects"
);
#[cfg(unix)]
{
std::os::unix::fs::symlink("tracked", f.lane.join("link")).unwrap();
git(&f.lane, &["add", "link"]);
git(&f.lane, &["commit", "-m", "link"]);
let before = object_snapshot(&objects);
assert!(f.check(&f.lane, &["--require-clean"]).0.status.success());
assert_eq!(
object_snapshot(&objects),
before,
"symlink hashing wrote Git objects"
);
std::fs::remove_file(f.lane.join("link")).unwrap();
std::os::unix::fs::symlink("different", f.lane.join("link")).unwrap();
assert_eq!(f.check(&f.lane, &["--require-clean"]).1["dirty"], true);
}
}
#[test]
fn unsupported_tracked_file_type_fails_closed_and_preserves_precious_work() {
let f = Fixture::new();
let tracked = f.lane.join("tracked");
std::fs::remove_file(&tracked).unwrap();
std::fs::create_dir(&tracked).unwrap();
let precious = tracked.join("precious");
std::fs::write(&precious, b"keep me\n").unwrap();
let (index, before) = index_bytes(&f.lane);
for extra in [&[][..], &["--require-clean"][..]] {
let (out, report) = f.check(&f.lane, extra);
assert_eq!(out.status.code(), Some(1), "{report}");
assert!(report["dirty"].is_null(), "{report}");
assert!(report["untracked"].is_null(), "{report}");
assert!(
report["findings"].to_string().contains("tracked file type"),
"{report}"
);
assert_eq!(std::fs::read(&index).unwrap(), before);
assert_eq!(std::fs::read(&precious).unwrap(), b"keep me\n");
}
}
#[test]
#[cfg(unix)]
#[cfg_attr(
target_os = "macos",
ignore = "APFS rejects invalid-byte filenames; set TMPDIR to a byte-capable filesystem and run with --ignored"
)]
fn non_utf8_worktree_fails_closed_and_preserves_index_and_content() {
use std::os::unix::ffi::OsStringExt;
let mut f = Fixture::new();
let (index, before) = index_bytes(&f.lane);
let moved = f
._temp
.path()
.join(std::ffi::OsString::from_vec(b"lane-\xff".to_vec()));
let output = Command::new("git")
.arg("-C")
.arg(&f.repo)
.args(["worktree", "move"])
.arg(&f.lane)
.arg(&moved)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
f.lane = moved;
for extra in [&[][..], &["--require-clean"][..]] {
let (out, report) = f.check(&f.lane, extra);
assert_eq!(out.status.code(), Some(1), "{report}");
assert_eq!(report["ok"], false);
assert!(report["dirty"].is_null());
assert!(report["untracked"].is_null());
assert!(report["worktree"].is_null());
assert!(report["findings"].to_string().contains("not UTF-8"));
assert_eq!(std::fs::read(&index).unwrap(), before);
assert_eq!(std::fs::read(f.lane.join("tracked")).unwrap(), b"base\n");
}
}
#[test]
#[cfg(unix)]
#[cfg_attr(
target_os = "macos",
ignore = "APFS rejects invalid-byte filenames; set TMPDIR to a byte-capable filesystem and run with --ignored"
)]
fn non_utf8_untracked_name_fails_closed_and_preserves_precious_file() {
use std::os::unix::ffi::OsStringExt;
let f = Fixture::new();
let (index, before) = index_bytes(&f.lane);
let precious = f
.lane
.join(std::ffi::OsString::from_vec(b"precious-\xff".to_vec()));
std::fs::write(&precious, b"keep these bytes\n").unwrap();
for extra in [&[][..], &["--require-clean"][..]] {
let (out, report) = f.check(&f.lane, extra);
assert_eq!(out.status.code(), Some(1), "{report}");
assert_eq!(report["ok"], false);
assert!(report["dirty"].is_null());
assert!(report["untracked"].is_null());
assert!(report["findings"].to_string().contains("not UTF-8"));
assert_eq!(std::fs::read(&index).unwrap(), before);
assert_eq!(std::fs::read(&precious).unwrap(), b"keep these bytes\n");
assert_eq!(std::fs::read(f.lane.join("tracked")).unwrap(), b"base\n");
}
}
#[test]
fn nested_untracked_suppression_cannot_hide_precious_files() {
let f = Fixture::new();
let module = Fixture::new();
git(
&f.lane,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"add",
module.repo.to_str().unwrap(),
"mod",
],
);
git(&f.lane, &["commit", "-am", "module"]);
let nested = f.lane.join("mod");
git(&nested, &["config", "status.showUntrackedFiles", "no"]);
assert!(f.check(&f.lane, &["--require-clean"]).0.status.success());
std::fs::write(nested.join("precious"), "keep me\n").unwrap();
assert!(
git(
&f.lane,
&[
"status",
"--porcelain=v1",
"--ignore-submodules=none",
"--untracked-files=normal"
]
)
.is_empty()
);
let (out, report) = f.check(&f.lane, &["--require-clean"]);
assert!(
!out.status.success(),
"nested precious file hidden: {report}"
);
assert_eq!(report["dirty"], true);
assert_eq!(report["untracked"], false); assert!(f.check(&f.lane, &[]).0.status.success());
assert_eq!(
std::fs::read_to_string(nested.join("precious")).unwrap(),
"keep me\n"
);
assert_eq!(git(&nested, &["config", "status.showUntrackedFiles"]), "no");
}
#[test]
fn graft_overlays_neither_create_nor_remove_certified_ancestry() {
let f = Fixture::new();
git(&f.lane, &["switch", "--orphan", "lane/orphan"]);
git(&f.lane, &["commit", "--allow-empty", "-m", "orphan"]);
git(&f.lane, &["branch", "-D", "lane/508-custody.12"]);
git(&f.lane, &["branch", "-m", "lane/508-custody.12"]);
let head = git(&f.lane, &["rev-parse", "HEAD"]);
assert!(
!git(&f.lane, &["cat-file", "-p", &head])
.lines()
.any(|line| line.starts_with("parent "))
);
assert!(!f.check(&f.lane, &[]).0.status.success());
let graft_path = f.repo.join(".git/info/grafts");
let graft = format!("{head} {}\n", f.base);
std::fs::write(&graft_path, &graft).unwrap();
let (out, report) = f.check_with_env(
&f.lane,
&[],
&[("GIT_GRAFT_FILE", graft_path.to_str().unwrap())],
);
assert!(!out.status.success(), "grafted orphan accepted: {report}");
assert!(report["findings"].to_string().contains("not an ancestor"));
assert_eq!(std::fs::read_to_string(&graft_path).unwrap(), graft);
std::fs::remove_file(&graft_path).unwrap();
git(&f.lane, &["reset", "--hard", &f.base]);
git(
&f.lane,
&["commit", "--allow-empty", "-m", "real descendant"],
);
let head = git(&f.lane, &["rev-parse", "HEAD"]);
let graft = format!("{head}\n");
std::fs::write(&graft_path, &graft).unwrap();
let (out, report) = f.check(&f.lane, &["--require-clean"]);
assert!(
out.status.success(),
"real ancestry hidden by graft: {report}"
);
assert_eq!(std::fs::read_to_string(&graft_path).unwrap(), graft);
}
#[test]
#[cfg(unix)]
fn git_paths_preserve_trailing_whitespace_and_nul_record_boundaries() {
for suffix in [" ", "\t", "\n", " \t\n"] {
let f = Fixture::with_suffix(suffix);
let (out, report) = f.check(&f.lane, &["--require-clean"]);
assert!(out.status.success(), "suffix {suffix:?}: {report}");
assert_eq!(
report["worktree"],
f.lane.canonicalize().unwrap().to_str().unwrap()
);
assert_eq!(
report["common_dir"],
f.repo
.join(".git")
.canonicalize()
.unwrap()
.to_str()
.unwrap()
);
}
}
#[test]
fn filesystem_moved_worktree_requires_native_registration_repair() {
let mut f = Fixture::new();
let moved = f._temp.path().join("moved");
std::fs::rename(&f.lane, &moved).unwrap();
f.lane = moved;
let registration = git(&f.repo, &["worktree", "list", "--porcelain", "-z"]);
let (out, report) = f.check(&f.lane, &[]);
assert!(
!out.status.success(),
"stale registration accepted: {report}"
);
assert!(report["findings"].to_string().contains("registration"));
assert_eq!(
git(&f.repo, &["worktree", "list", "--porcelain", "-z"]),
registration
);
git(&f.repo, &["worktree", "repair", f.lane.to_str().unwrap()]);
let (out, report) = f.check(&f.lane, &["--require-clean"]);
assert!(
out.status.success(),
"repaired registration rejected: {report}"
);
}
fn object_snapshot(root: &Path) -> Vec<(PathBuf, Vec<u8>)> {
let mut files = Vec::new();
for entry in std::fs::read_dir(root).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
files.extend(object_snapshot(&path));
} else {
files.push((path.clone(), std::fs::read(path).unwrap()));
}
}
files.sort();
files
}
#[test]
fn promisor_missing_commit_fails_without_fetch_or_object_writes() {
let f = Fixture::new();
git(&f.lane, &["commit", "--allow-empty", "-m", "candidate"]);
let remote = f._temp.path().join("remote.git");
git(
&f.repo,
&[
"clone",
"--bare",
f.repo.to_str().unwrap(),
remote.to_str().unwrap(),
],
);
git(
&f.repo,
&["remote", "add", "origin", remote.to_str().unwrap()],
);
git(&f.repo, &["config", "remote.origin.promisor", "true"]);
git(
&f.repo,
&["config", "remote.origin.partialclonefilter", "blob:none"],
);
let (out, report) = f.check(&f.lane, &["--require-clean"]);
assert!(out.status.success(), "complete promisor rejected: {report}");
let objects = f.repo.join(".git/objects");
std::fs::remove_file(objects.join(&f.base[..2]).join(&f.base[2..])).unwrap();
let before = object_snapshot(&objects);
let (out, report) = f.check_with_env(
&f.lane,
&[],
&[("GIT_NO_LAZY_FETCH", "0"), ("GIT_TERMINAL_PROMPT", "1")],
);
assert_eq!(
object_snapshot(&objects),
before,
"inspection lazily fetched missing objects"
);
assert!(!out.status.success(), "missing commit accepted: {report}");
assert!(report["findings"].to_string().contains("failed"));
let missing = objects.join(&f.base[..2]).join(&f.base[2..]);
let remote_object = remote.join("objects").join(&f.base[..2]).join(&f.base[2..]);
std::fs::write(&missing, std::fs::read(remote_object).unwrap()).unwrap();
let (out, report) = f.check(&f.lane, &["--require-clean"]);
assert!(out.status.success(), "restored object rejected: {report}");
}
#[test]
fn clean_identity_and_qualified_branch_tag_are_unambiguous() {
let f = Fixture::new();
git(&f.repo, &["tag", "v6.7.7"]);
let (out, report) = f.check(&f.lane, &["--head", &f.base, "--require-clean"]);
assert!(out.status.success(), "{report}");
assert_eq!(report["branch"], "refs/heads/lane/508-custody.12");
assert_eq!(report["ownership"], "not_assessed");
assert_eq!(report["dirty"], false);
}
#[test]
fn wrong_branch_same_sha_and_detached_head_fail() {
let f = Fixture::new();
git(&f.lane, &["switch", "-c", "lane/other.1"]);
assert!(!f.check(&f.lane, &[]).0.status.success());
assert_eq!(git(&f.lane, &["rev-parse", "HEAD"]), f.base);
git(&f.lane, &["checkout", "--detach"]);
assert!(!f.check(&f.lane, &[]).0.status.success());
}
#[test]
fn project_root_selects_the_explicit_worktree_and_rejects_foreign_repositories() {
let mut f = Fixture::new();
std::fs::write(f.repo.join("tracked"), "project edit\n").unwrap();
std::fs::create_dir(f.repo.join("subdir")).unwrap();
for root in [&f.repo, &f.repo.join("subdir")] {
let (out, report) = f.check(root, &["--require-clean"]);
assert!(out.status.success(), "{report}");
assert_eq!(
report["worktree"],
f.lane.canonicalize().unwrap().to_str().unwrap()
);
assert_eq!(report["branch"], "refs/heads/lane/508-custody.12");
assert_eq!(report["dirty"], false);
}
std::fs::write(f.lane.join("tracked"), "lane edit\n").unwrap();
let (out, report) = f.check(&f.repo, &["--require-clean"]);
assert_eq!(out.status.code(), Some(1), "{report}");
assert_eq!(report["dirty"], true);
let other = Fixture::new();
let (out, report) = f.check(&other.repo, &[]);
assert_eq!(out.status.code(), Some(1), "{report}");
assert!(
report["findings"].to_string().contains("common directory"),
"{report}"
);
let original_repo = f.repo.clone();
f.repo = other.repo.clone(); let (out, report) = f.check(&original_repo, &[]);
assert_eq!(out.status.code(), Some(1), "{report}");
assert!(
report["findings"]
.to_string()
.contains("actual common directory differs"),
"{report}"
);
f.repo = original_repo;
std::fs::create_dir(f.lane.join("subdir")).unwrap();
f.lane = f.lane.join("subdir");
let (out, report) = f.check(&f.repo, &[]);
assert_eq!(out.status.code(), Some(1), "{report}");
assert!(
report["findings"].to_string().contains("worktree root"),
"{report}"
);
}
#[test]
fn dirty_untracked_is_reported_and_preserved() {
let f = Fixture::new();
std::fs::write(f.lane.join("tracked"), "modified\n").unwrap();
std::fs::write(f.lane.join("untracked"), "precious\n").unwrap();
let (out, report) = f.check(&f.lane, &[]);
assert!(out.status.success(), "{report}");
assert_eq!(report["dirty"], true);
assert_eq!(report["untracked"], true);
assert!(!f.check(&f.lane, &["--require-clean"]).0.status.success());
assert_eq!(
std::fs::read_to_string(f.lane.join("untracked")).unwrap(),
"precious\n"
);
assert_eq!(
std::fs::read_to_string(f.lane.join("tracked")).unwrap(),
"modified\n"
);
}
#[test]
fn changed_head_and_moved_base_invalidate_expected_identity() {
let f = Fixture::new();
git(&f.lane, &["commit", "--allow-empty", "-m", "candidate"]);
assert!(!f.check(&f.lane, &["--head", &f.base]).0.status.success());
assert!(f.check(&f.lane, &[]).0.status.success());
git(&f.repo, &["commit", "--allow-empty", "-m", "new base"]);
assert!(!f.check(&f.lane, &[]).0.status.success());
}
#[test]
fn relocated_and_duplicate_branch_worktrees_fail_stale_mapping() {
let mut f = Fixture::new();
let moved = f._temp.path().join("moved");
git(
&f.repo,
&[
"worktree",
"move",
f.lane.to_str().unwrap(),
moved.to_str().unwrap(),
],
);
assert!(!f.check(&moved, &[]).0.status.success());
f.lane = moved;
assert!(f.check(&f.lane, &[]).0.status.success());
let duplicate = f._temp.path().join("duplicate");
git(
&f.repo,
&[
"worktree",
"add",
"--force",
duplicate.to_str().unwrap(),
"lane/508-custody.12",
],
);
let (out, report) = f.check(&f.lane, &[]);
assert!(!out.status.success(), "{report}");
assert!(
report["findings"]
.to_string()
.contains("multiple worktrees")
);
}
#[test]
fn nonancestor_base_fails_even_when_ref_and_sha_agree() {
let mut f = Fixture::new();
git(&f.repo, &["commit", "--allow-empty", "-m", "base moved"]);
f.base = git(&f.repo, &["rev-parse", "HEAD"]);
let (out, report) = f.check(&f.lane, &[]);
assert!(!out.status.success());
assert!(report["findings"].to_string().contains("not an ancestor"));
}
#[test]
fn clean_check_includes_submodules_even_when_repository_ignores_them() {
let f = Fixture::new();
let module_source = f._temp.path().join("module-source");
std::fs::create_dir(&module_source).unwrap();
git(&module_source, &["init", "-b", "main"]);
git(
&module_source,
&["config", "user.email", "fixture@example.invalid"],
);
git(&module_source, &["config", "user.name", "Fixture"]);
std::fs::write(module_source.join("tracked"), "module base\n").unwrap();
git(&module_source, &["add", "tracked"]);
git(&module_source, &["commit", "-m", "module base"]);
git(
&f.lane,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"add",
module_source.to_str().unwrap(),
"mod",
],
);
git(
&f.lane,
&["config", "-f", ".gitmodules", "submodule.mod.ignore", "all"],
);
git(&f.lane, &["add", ".gitmodules", "mod"]);
git(&f.lane, &["commit", "-m", "ignored module"]);
git(&f.lane, &["config", "submodule.mod.ignore", "all"]);
assert!(f.check(&f.lane, &["--require-clean"]).0.status.success());
for untracked in [false, true] {
std::fs::write(
f.lane.join("mod/tracked"),
if untracked {
"module base\n"
} else {
"modified\n"
},
)
.unwrap();
if untracked {
std::fs::write(f.lane.join("mod/precious"), "keep me\n").unwrap();
}
assert!(
git(
&f.lane,
&["status", "--porcelain=v1", "--untracked-files=normal"]
)
.is_empty()
);
let (out, report) = f.check(&f.lane, &["--require-clean"]);
assert!(!out.status.success(), "ignored module accepted: {report}");
assert_eq!(report["dirty"], true);
assert!(report["findings"].to_string().contains("dirty"));
}
assert_eq!(
std::fs::read_to_string(f.lane.join("mod/precious")).unwrap(),
"keep me\n"
);
}
#[test]
fn nested_ignore_all_and_untracked_suppression_cannot_hide_precious_work() {
let f = Fixture::new();
let outer_source = Fixture::new();
let inner_source = Fixture::new();
git(
&outer_source.repo,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"add",
inner_source.repo.to_str().unwrap(),
"inner",
],
);
git(&outer_source.repo, &["commit", "-am", "inner module"]);
git(
&f.lane,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"add",
outer_source.repo.to_str().unwrap(),
"outer",
],
);
git(&f.lane, &["commit", "-am", "outer module"]);
git(
&f.lane,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"update",
"--init",
"--recursive",
],
);
let outer = f.lane.join("outer");
let inner = outer.join("inner");
git(&outer, &["config", "submodule.inner.ignore", "all"]);
git(&inner, &["config", "status.showUntrackedFiles", "no"]);
assert!(f.check(&f.lane, &["--require-clean"]).0.status.success());
let precious = inner.join("precious");
std::fs::write(&precious, b"keep me\n").unwrap();
assert!(
git(
&f.lane,
&["status", "--porcelain=v1", "--ignore-submodules=none"]
)
.is_empty()
);
assert_eq!(
git(
&outer,
&[
"-c",
"status.showUntrackedFiles=all",
"status",
"--porcelain=v1",
"--ignore-submodules=none"
]
),
" M inner"
);
let indexes = [
index_bytes(&f.lane),
index_bytes(&outer),
index_bytes(&inner),
];
let (out, report) = f.check(&f.lane, &[]);
assert!(out.status.success(), "{report}");
assert_eq!(report["dirty"], true);
let (out, report) = f.check(&f.lane, &["--require-clean"]);
assert_eq!(out.status.code(), Some(1), "{report}");
assert_eq!(report["dirty"], true);
assert_eq!(report["untracked"], false);
for (index, before) in &indexes {
assert_eq!(&std::fs::read(index).unwrap(), before);
}
assert_eq!(std::fs::read(&precious).unwrap(), b"keep me\n");
assert_eq!(git(&outer, &["config", "submodule.inner.ignore"]), "all");
assert_eq!(git(&inner, &["config", "status.showUntrackedFiles"]), "no");
}
#[test]
fn usage_errors_are_clap_stderr_without_a_json_report() {
for args in [
vec!["lane-check"],
vec!["lane-check", "--require-clean=invalid"],
] {
let output = Command::new(env!("CARGO_BIN_EXE_shepherd"))
.args(args)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2));
assert!(output.stdout.is_empty());
assert!(!output.stderr.is_empty());
}
}
fn large_inventory(root: &Path) -> Vec<PathBuf> {
let files: Vec<_> = (0..5000)
.map(|n| root.join(format!("file-{n:05}-{}", "x".repeat(210))))
.collect();
for file in &files {
std::fs::write(file, b"base\n").unwrap();
}
git(root, &["add", "."]);
git(root, &["commit", "-m", "large inventory"]);
assert!(git(root, &["ls-files", "-v", "--stage", "-z"]).len() > 1024 * 1024);
files
}
#[test]
fn large_clean_inventory_with_initialized_submodule_and_hooks_is_inspected_in_full() {
for nested in [false, true] {
let f = Fixture::new();
let module = Fixture::new();
git(
&f.lane,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"add",
module.repo.to_str().unwrap(),
"mod",
],
);
git(&f.lane, &["commit", "-am", "module"]);
let target = if nested {
f.lane.join("mod")
} else {
f.lane.clone()
};
git(
&target,
&["config", "user.email", "fixture@example.invalid"],
);
git(&target, &["config", "user.name", "Fixture"]);
let files = large_inventory(&target);
if nested {
git(&f.lane, &["commit", "-am", "large module"]);
}
git(&target, &["config", "core.hooksPath", "unused-hooks"]);
git(&target, &["config", "core.trustctime", "false"]);
git(&target, &["config", "core.checkStat", "minimal"]);
assert!(git(&f.lane, &["status", "--porcelain=v1", "-z"]).is_empty());
let indexes = [index_bytes(&f.lane), index_bytes(&f.lane.join("mod"))];
for extra in [&[][..], &["--require-clean"][..]] {
let (out, report) = f.check(&f.lane, extra);
assert!(out.status.success(), "nested={nested}: {report}");
assert_eq!(report["dirty"], false);
assert_eq!(report["untracked"], false);
}
let last = files.last().unwrap();
let modified = std::fs::metadata(last).unwrap().modified().unwrap();
std::fs::write(last, b"edit\n").unwrap();
std::fs::File::open(last)
.unwrap()
.set_modified(modified)
.unwrap();
let (out, report) = f.check(&f.lane, &["--require-clean"]);
assert_eq!(out.status.code(), Some(1), "nested={nested}: {report}");
assert_eq!(report["dirty"], true);
for (index, before) in &indexes {
assert_eq!(&std::fs::read(index).unwrap(), before);
}
assert_eq!(git(&target, &["config", "core.hooksPath"]), "unused-hooks");
git(
&target,
&[
"update-index",
"--assume-unchanged",
last.file_name().unwrap().to_str().unwrap(),
],
);
let (out, report) = f.check(&f.lane, &[]);
assert_eq!(out.status.code(), Some(1), "nested={nested}: {report}");
assert!(report["dirty"].is_null());
assert!(
report["findings"].to_string().contains("index flags"),
"{report}"
);
}
}
#[test]
fn large_status_inventory_reports_dirty_without_truncation() {
let f = Fixture::new();
let files = large_inventory(&f.lane);
for file in files {
std::fs::write(file, b"modified\n").unwrap();
}
std::fs::write(f.lane.join("zz-untracked"), b"precious\n").unwrap();
assert!(git(&f.lane, &["status", "--porcelain=v1", "-z"]).len() > 1024 * 1024);
let (index, before) = index_bytes(&f.lane);
let (out, report) = f.check(&f.lane, &[]);
assert!(out.status.success(), "{report}");
assert_eq!(report["dirty"], true);
assert_eq!(report["untracked"], true);
let (out, report) = f.check(&f.lane, &["--require-clean"]);
assert_eq!(out.status.code(), Some(1), "{report}");
assert_eq!(report["dirty"], true);
assert_eq!(std::fs::read(&index).unwrap(), before);
assert_eq!(
std::fs::read(f.lane.join("zz-untracked")).unwrap(),
b"precious\n"
);
}