use super::*;
use std::fmt::Write as _;
#[test]
fn audit_review_benchmark_reuses_production_assembly_without_ambient_io() {
let temp = tempfile::TempDir::new().expect("temp dir should be created");
let root = temp.path().join("audit-review-benchmark");
fs::create_dir_all(root.join("src/changed")).expect("fixture directory should be created");
fs::create_dir_all(root.join("src/consumers")).expect("fixture directory should be created");
fs::write(
root.join("package.json"),
r#"{"name":"audit-review-benchmark","type":"module","main":"src/index.ts"}"#,
)
.expect("package fixture should be written");
let mut changed_files = Vec::new();
let mut index_source = String::new();
for index in 0..8 {
let relative = format!("src/changed/module{index}.ts");
let changed = root.join(&relative);
fs::write(
&changed,
format!("export const used{index} = {index};\nexport const unused{index} = {index};\n"),
)
.expect("changed fixture should be written");
fs::write(
root.join(format!("src/consumers/consumer{index}.ts")),
format!(
"import {{ used{index} }} from \"../changed/module{index}\";\nexport const result{index} = used{index};\n"
),
)
.expect("consumer fixture should be written");
write!(
&mut index_source,
"import {{ result{index} }} from \"./consumers/consumer{index}\";\nconsole.log(result{index});\n"
)
.expect("entry fixture should be formatted");
changed_files.push(changed);
}
fs::write(root.join("src/index.ts"), index_source).expect("entry fixture should be written");
let mut corpus = create_audit_review_benchmark_corpus(&root, &changed_files, 2)
.expect("benchmark corpus should be created");
fs::remove_dir_all(&root).expect("fixture should be removable before the timed path");
let result = benchmark_audit_review_brief_many_changed_files_json(&mut corpus)
.expect("benchmark assembly should not read the removed fixture");
assert_eq!(result.introduced_count, 4);
assert_eq!(result.inherited_count, 4);
assert_eq!(result.public_api_added_count, 8);
assert_eq!(result.decision_count, 4);
assert!(result.rendered_bytes > 0);
let repeated = benchmark_audit_review_brief_many_changed_files_json(&mut corpus)
.expect("reused benchmark corpus should preserve production behavior");
assert_eq!(repeated, result);
}
#[test]
fn completeness_gate_uses_effective_metadata_requirement() {
let meta = fallow_types::envelope::TypeAwareMeta {
required_completeness: Some(
fallow_types::semantic::SemanticCompletenessRequirement::Complete,
),
queries: vec![fallow_types::semantic::SemanticQuerySummary {
query_id: 0,
capability: fallow_types::semantic::SemanticCapability::SymbolUse,
assertion: "candidate usage refinement".to_string(),
status: fallow_types::semantic::SemanticCompleteness::Partial,
reason_code: None,
total_evidence_count: 0,
truncated: false,
omissions: Vec::new(),
actions: Vec::new(),
}],
..Default::default()
};
assert!(type_aware_meta_completeness_failed(Some(&meta)));
}
use crate::base_worktree::{
CacheMaxAgeSource, SweepDecision, SweepDisposition, SweepMode, SweepPass, SweepSizes,
canonical_root_hash, legacy_reusable_audit_worktree_path, reclaim_orphan_cache_entry,
remove_reusable_audit_caches, resolve_cache_max_age_with_source, sweep_old_reusable_caches_in,
sweep_reusable_caches_with_report,
};
use std::{fs, process::Command};
fn git(dir: &std::path::Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.output()
.expect("git command failed");
assert!(
output.status.success(),
"git {:?} failed\nstdout:\n{}\nstderr:\n{}",
args,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn audit_worktree_helpers_filter_to_fallow_temp_prefix() {
let temp = std::env::temp_dir();
let audit_path = temp.join("fallow-audit-base-123-456");
let reusable_path = temp.join("fallow-audit-base-cache-abcd-1234");
let canonical_audit_path = temp
.canonicalize()
.unwrap_or_else(|_| temp.clone())
.join("fallow-audit-base-456-789");
let unrelated_temp = temp.join("other-worktree");
let output = format!(
"worktree /repo\nHEAD abc\n\nworktree {}\nHEAD def\n\nworktree {}\nHEAD ghi\n\nworktree {}\nHEAD jkl\n",
audit_path.display(),
unrelated_temp.display(),
reusable_path.display()
);
assert_eq!(
parse_worktree_list(&output),
vec![audit_path, reusable_path.clone()]
);
assert!(is_fallow_audit_worktree_path(&canonical_audit_path));
assert!(is_reusable_audit_worktree_path(&reusable_path));
assert_eq!(audit_worktree_pid("fallow-audit-base-123-456"), Some(123));
assert_eq!(
audit_worktree_pid("fallow-audit-base-cache-abcd-1234"),
None
);
assert_eq!(audit_worktree_pid("not-fallow-audit-base-123"), None);
}
/// Initialize a throwaway git repo with a single commit and return its root.
/// Used by the worktree-lifecycle tests below as a parent repo that can host
/// `git worktree add` invocations.
fn init_throwaway_repo(parent: &std::path::Path, name: &str) -> PathBuf {
let root = parent.join(name);
fs::create_dir_all(&root).expect("repo root should be created");
fs::write(root.join("README.md"), "seed\n").expect("seed file should be written");
git(&root, &["init", "-b", "main"]);
git(&root, &["add", "."]);
git(
&root,
&["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
);
root
}
/// Add a tracked file and commit it; return the new HEAD SHA.
fn commit_file(repo: &std::path::Path, name: &str, body: &str) -> String {
fs::write(repo.join(name), body).expect("file should be written");
git(repo, &["add", "."]);
git(repo, &["-c", "commit.gpgsign=false", "commit", "-m", name]);
git_rev_parse(repo, "HEAD").expect("HEAD should resolve")
}
#[test]
fn parse_audit_base_override_trims_and_rejects_empty() {
assert_eq!(parse_audit_base_override(None), None);
assert_eq!(parse_audit_base_override(Some(String::new())), None);
assert_eq!(parse_audit_base_override(Some(" ".to_string())), None);
assert_eq!(
parse_audit_base_override(Some(" origin/main ".to_string())),
Some("origin/main".to_string())
);
}
fn worktree_is_registered_with_git(repo_root: &std::path::Path, worktree_path: &Path) -> bool {
list_audit_worktrees(repo_root)
.is_some_and(|paths| paths.iter().any(|p| paths_equal(p, worktree_path)))
}
/// Count fallow-audit worktree entries visible in `git worktree list`. The
/// #1815 fix keeps this at zero even while a base-snapshot cache exists on
/// disk, so any fallow-owned entry here is a regression.
fn registered_fallow_worktree_count(repo_root: &std::path::Path) -> usize {
list_audit_worktrees(repo_root).map_or(0, |paths| paths.len())
}
/// True when `path` appears in the raw `git worktree list --porcelain` output,
/// regardless of naming. Unlike `worktree_is_registered_with_git` this does
/// not filter to fallow-owned entries, so it can assert a user's own worktree
/// stays registered.
fn git_worktree_list_contains(repo_root: &std::path::Path, path: &Path) -> bool {
let output = Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(repo_root)
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.output()
.expect("git worktree list should run");
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.strip_prefix("worktree "))
.any(|listed| paths_equal(&PathBuf::from(listed), path))
}
#[test]
fn worktree_cleanup_guard_runs_on_drop() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo");
let worktree_path = tmp.path().join("fallow-audit-base-1234-5678");
git(
&repo,
&[
"worktree",
"add",
"--detach",
"--quiet",
worktree_path.to_str().expect("path is utf-8"),
"HEAD",
],
);
assert!(worktree_path.is_dir());
assert!(worktree_is_registered_with_git(&repo, &worktree_path));
{
let _guard = WorktreeCleanupGuard::new(&repo, &worktree_path);
}
assert!(
!worktree_path.exists(),
"guard Drop should remove the worktree directory",
);
assert!(
!worktree_is_registered_with_git(&repo, &worktree_path),
"guard Drop should remove the git worktree registration",
);
}
#[test]
fn worktree_cleanup_guard_defused_skips_drop() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo");
let worktree_path = tmp.path().join("fallow-audit-base-1234-5679");
git(
&repo,
&[
"worktree",
"add",
"--detach",
"--quiet",
worktree_path.to_str().expect("path is utf-8"),
"HEAD",
],
);
assert!(worktree_path.is_dir());
{
let mut guard = WorktreeCleanupGuard::new(&repo, &worktree_path);
guard.defuse();
guard.defuse();
}
assert!(
worktree_path.is_dir(),
"defused guard must not remove the worktree on drop",
);
assert!(
worktree_is_registered_with_git(&repo, &worktree_path),
"defused guard must not unregister the worktree from git",
);
remove_audit_worktree(&repo, &worktree_path);
let _ = fs::remove_dir_all(&worktree_path);
}
#[test]
fn audit_orphan_sweep_removes_dead_pid_worktree() {
const DEAD_PID: u32 = 99_999_999;
assert!(!process_is_alive(DEAD_PID));
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo");
// Under tmp.path(), NOT std::env::temp_dir(): a fabricated dead-pid dir in
// the shared temp dir is legitimate prey for any concurrent sweep (a
// parallel test or a spawned fallow binary), which races the asserts below.
let worktree_path = tmp.path().join(format!(
"fallow-audit-base-{}-{}",
DEAD_PID,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock should be after epoch")
.as_nanos()
));
git(
&repo,
&[
"worktree",
"add",
"--detach",
"--quiet",
worktree_path.to_str().expect("path is utf-8"),
"HEAD",
],
);
assert!(worktree_path.is_dir());
assert!(worktree_is_registered_with_git(&repo, &worktree_path));
sweep_orphan_audit_worktrees_in(&repo, tmp.path());
assert!(
!worktree_path.exists(),
"sweep should remove worktree owned by a dead PID",
);
assert!(
!worktree_is_registered_with_git(&repo, &worktree_path),
"sweep should unregister worktree owned by a dead PID",
);
}
#[test]
fn audit_orphan_sweep_keeps_live_pid_worktree() {
let live_pid = std::process::id();
assert!(process_is_alive(live_pid));
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo");
let worktree_path = tmp.path().join(format!(
"fallow-audit-base-{}-{}",
live_pid,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock should be after epoch")
.as_nanos()
));
git(
&repo,
&[
"worktree",
"add",
"--detach",
"--quiet",
worktree_path.to_str().expect("path is utf-8"),
"HEAD",
],
);
sweep_orphan_audit_worktrees_in(&repo, tmp.path());
assert!(
worktree_path.is_dir(),
"sweep must not remove worktree owned by a live PID",
);
assert!(
worktree_is_registered_with_git(&repo, &worktree_path),
"sweep must not unregister worktree owned by a live PID",
);
remove_audit_worktree(&repo, &worktree_path);
let _ = fs::remove_dir_all(&worktree_path);
}
/// Deterministic fake base SHA for GC tests. Only the first 16 chars form the
/// cache-path suffix; the repo hash (derived from each test's unique tempdir)
/// keeps parallel tests from colliding, so the SHA value can be shared.
const TEST_BASE_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
/// Materialize an UNREGISTERED reusable cache directory at the path
/// `reuse_or_create` computes for `repo`/[`TEST_BASE_SHA`], mirroring the
/// post-#1815 on-disk layout (a directory, an invalid `.git` stub, and a
/// `.sha` sidecar). Returns the cache path.
fn create_unregistered_reusable_cache(repo: &Path) -> PathBuf {
let path = reusable_audit_worktree_path(repo);
fs::create_dir_all(&path).expect("reusable cache dir should be created");
fs::write(path.join(".git"), "gitdir: fallow-audit-unregistered\n")
.expect("stub .git should be written");
fs::write(
reusable_worktree_sha_path(&path),
format!("{TEST_BASE_SHA}\n"),
)
.expect(".sha sidecar should be written");
path
}
/// Materialize an unregistered reusable cache for `repo` under `scan_root`
/// instead of the shared temp dir. The cross-repo GC pass only sees entries
/// under its injected scan root, so foreign-entry fixtures built here are
/// visible ONLY to this test's own sweep, never to a concurrently running
/// test's or developer process's sweep (and vice versa).
fn create_unregistered_foreign_cache(repo: &Path, scan_root: &Path) -> PathBuf {
let name = reusable_audit_worktree_path(repo)
.file_name()
.expect("cache path should have a file name")
.to_os_string();
let path = scan_root.join(name);
fs::create_dir_all(&path).expect("reusable cache dir should be created");
fs::write(path.join(".git"), "gitdir: fallow-audit-unregistered\n")
.expect("stub .git should be written");
fs::write(
reusable_worktree_sha_path(&path),
format!("{TEST_BASE_SHA}\n"),
)
.expect(".sha sidecar should be written");
path
}
/// Register a worktree with the parent repo at `path` checked out at HEAD.
/// Simulates a pre-#1815 cache entry (left registered) for the legacy
/// migration / GC deregistration tests.
fn register_reusable_worktree(repo: &Path, path: &Path) {
git(
repo,
&[
"worktree",
"add",
"--detach",
"--quiet",
path.to_str().expect("path is utf-8"),
"HEAD",
],
);
}
fn write_sidecar_with_age(path: &Path, age: Duration) {
let sidecar = reusable_worktree_last_used_path(path);
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&sidecar)
.expect("sidecar should open");
let when = SystemTime::now()
.checked_sub(age)
.expect("backdated time should fit in SystemTime");
file.set_modified(when)
.expect("set_modified should succeed");
}
/// Tear down a reusable worktree (git registration + dir + sidecars + lock)
/// regardless of which of those the test created. Idempotent.
fn cleanup_reusable_worktree(repo: &Path, path: &Path) {
remove_audit_worktree(repo, path);
let _ = fs::remove_dir_all(path);
let _ = fs::remove_file(reusable_worktree_last_used_path(path));
let _ = fs::remove_file(reusable_worktree_sha_path(path));
let _ = fs::remove_file(reusable_worktree_lock_path(path));
}
#[test]
fn reusable_cache_identity_is_canonical_root_owned_and_sha_independent() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-root-identity");
let first_root = repo.join("packages/first");
let sibling_root = repo.join("packages/second");
fs::create_dir_all(&first_root).expect("first analysis root should be created");
fs::create_dir_all(&sibling_root).expect("sibling analysis root should be created");
let alias = first_root.join("../first");
let first_path = reusable_audit_worktree_path(&first_root);
// Reconstruct the expected identity through the SAME hashing helper the
// production path uses. Recomputing it here with `Path::canonicalize` +
// `to_string_lossy` bytes diverges on Windows, where std canonicalize keeps
// the `\\?\` verbatim prefix (production strips it via dunce) and the path
// identity is hashed as UTF-16LE bytes, not UTF-8.
let expected_hash = canonical_root_hash(&first_root);
let expected_repo_hash = canonical_root_hash(&repo);
let expected_name =
format!("fallow-audit-base-cache-{expected_repo_hash:016x}-root-{expected_hash:016x}");
assert_eq!(
first_path.file_name().and_then(|name| name.to_str()),
Some(expected_name.as_str()),
"identity must be the canonical requested root hash with no SHA suffix",
);
assert_eq!(
first_path,
reusable_audit_worktree_path(&alias),
"canonical aliases must share one cache identity",
);
assert_ne!(
first_path,
reusable_audit_worktree_path(&sibling_root),
"sibling analysis roots inside one git repo must stay isolated",
);
}
#[cfg(unix)]
#[test]
fn reusable_cache_identity_distinguishes_non_utf8_roots() {
use std::os::unix::ffi::OsStringExt as _;
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-non-utf8-root-identity");
let first = repo.join(std::ffi::OsString::from_vec(vec![0xff]));
let second = repo.join(std::ffi::OsString::from_vec(vec![0xfe]));
assert_eq!(first.to_string_lossy(), second.to_string_lossy());
assert_ne!(
reusable_audit_worktree_path(&first),
reusable_audit_worktree_path(&second),
"distinct raw paths must not collide through lossy UTF-8 conversion",
);
}
#[test]
fn repo_root_removal_finds_cache_for_a_deleted_nested_root() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-remove-deleted-nested-root");
let nested_root = repo.join("packages/deleted");
fs::create_dir_all(&nested_root).expect("nested root should be created");
let cache_path = reusable_audit_worktree_path(&nested_root);
fs::create_dir_all(&cache_path).expect("nested cache should be created");
fs::write(reusable_worktree_sha_path(&cache_path), TEST_BASE_SHA)
.expect("nested cache sidecar should be created");
fs::remove_dir_all(&nested_root).expect("nested root should be deleted");
let report = remove_reusable_audit_caches(&repo, false)
.expect("repo-root cleanup should enumerate nested caches");
assert_eq!(report.removed, 1);
assert!(!cache_path.exists());
assert!(!reusable_worktree_sha_path(&cache_path).exists());
let _ = fs::remove_file(reusable_worktree_lock_path(&cache_path));
}
#[test]
fn reusable_cache_gc_removes_old_entry_with_backdated_sidecar() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-gc-remove");
let worktree_path = create_unregistered_reusable_cache(&repo);
write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
sweep_old_reusable_caches_in(&repo, Some(Duration::from_hours(30 * 24)), true, tmp.path());
assert!(
!worktree_path.exists(),
"sweep should remove worktree dir whose sidecar is older than the threshold",
);
assert!(
!reusable_worktree_last_used_path(&worktree_path).exists(),
"sweep should remove the sidecar `.last-used` file alongside the worktree",
);
assert!(
!reusable_worktree_sha_path(&worktree_path).exists(),
"sweep should remove the `.sha` sidecar alongside the worktree",
);
assert_eq!(
registered_fallow_worktree_count(&repo),
0,
"an unregistered cache must never appear in git worktree list",
);
cleanup_reusable_worktree(&repo, &worktree_path);
}
#[test]
fn reusable_cache_gc_keeps_fresh_entry() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-gc-keep");
let worktree_path = create_unregistered_reusable_cache(&repo);
write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
sweep_old_reusable_caches_in(&repo, Some(Duration::from_hours(30 * 24)), true, tmp.path());
assert!(
worktree_path.is_dir(),
"sweep must not remove a worktree whose sidecar is fresher than the threshold",
);
cleanup_reusable_worktree(&repo, &worktree_path);
}
#[test]
fn reusable_cache_gc_skips_locked_entry() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-gc-locked");
let worktree_path = create_unregistered_reusable_cache(&repo);
// Lock BEFORE aging the sidecar: the fixture lives in the shared temp
// dir, and an aged ownerless entry is fair game for any concurrent
// process's cross-repo GC pass in the window before the lock is held.
let lock = ReusableWorktreeLock::try_acquire(&worktree_path, "test")
.expect("test should acquire the lock first");
write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
sweep_old_reusable_caches_in(&repo, Some(Duration::from_hours(30 * 24)), true, tmp.path());
assert!(
worktree_path.is_dir(),
"sweep must skip a locked entry even when its sidecar is stale",
);
drop(lock);
cleanup_reusable_worktree(&repo, &worktree_path);
}
#[test]
fn reusable_cache_gc_grace_when_sidecar_absent() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-gc-grace");
// Bare directory with no `.last-used` sidecar, modelling a pre-upgrade
// warm cache the sweep sees for the first time.
let worktree_path = reusable_audit_worktree_path(&repo);
fs::create_dir_all(&worktree_path).expect("cache dir should be created");
let sidecar = reusable_worktree_last_used_path(&worktree_path);
assert!(
!sidecar.exists(),
"test pre-condition: sidecar should not exist",
);
sweep_old_reusable_caches_in(&repo, Some(Duration::from_hours(30 * 24)), true, tmp.path());
assert!(
worktree_path.is_dir(),
"pre-upgrade grace: sidecar-absent entries must NOT be removed on first encounter",
);
assert!(
sidecar.exists(),
"pre-upgrade grace: sidecar must be seeded so the next run can age from real last-used",
);
let mtime = std::fs::metadata(&sidecar)
.and_then(|m| m.modified())
.expect("seeded sidecar should have a readable mtime");
let age = SystemTime::now()
.duration_since(mtime)
.unwrap_or(Duration::ZERO);
assert!(
age < Duration::from_mins(1),
"seeded sidecar mtime should be near `now()`, got age {age:?}",
);
cleanup_reusable_worktree(&repo, &worktree_path);
}
#[test]
fn reusable_cache_gc_reclaims_sidecar_orphan_when_dir_missing() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-gc-orphan");
let worktree_path = create_unregistered_reusable_cache(&repo);
// Fresh sidecar: the age branch alone would KEEP this entry, so a
// successful reclaim proves the dir-missing branch drove it.
write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
let last_used = reusable_worktree_last_used_path(&worktree_path);
let sha = reusable_worktree_sha_path(&worktree_path);
// Simulate an external temp-reaper: delete only the worktree directory,
// leaving its sidecars behind.
fs::remove_dir_all(&worktree_path).expect("test should remove the cache dir");
assert!(
!worktree_path.exists(),
"test pre-condition: cache dir should be gone",
);
assert!(
last_used.exists() && sha.exists(),
"test pre-condition: sidecars survive a dir-only reaper",
);
sweep_old_reusable_caches_in(&repo, Some(Duration::from_hours(30 * 24)), true, tmp.path());
assert!(
!last_used.exists(),
"sweep should remove the stale `.last-used` sidecar for a reclaimed orphan",
);
assert!(
!sha.exists(),
"sweep should remove the stale `.sha` sidecar for a reclaimed orphan",
);
cleanup_reusable_worktree(&repo, &worktree_path);
}
#[test]
fn reusable_cache_gc_reclaims_sidecar_orphan_even_when_age_gc_disabled() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-gc-orphan-nogc");
let worktree_path = create_unregistered_reusable_cache(&repo);
write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
let last_used = reusable_worktree_last_used_path(&worktree_path);
let sha = reusable_worktree_sha_path(&worktree_path);
fs::remove_dir_all(&worktree_path).expect("test should remove the cache dir");
assert!(
last_used.exists() && sha.exists(),
"test pre-condition: sidecars survive a dir-only reaper",
);
// `None` = age-based GC disabled (`cacheMaxAgeDays = 0`). Sidecar-orphan
// reclaim must still run so dead sidecars do not accumulate forever.
sweep_old_reusable_caches_in(&repo, None, true, tmp.path());
assert!(
!last_used.exists(),
"orphan reclaim must run even when age-based GC is disabled",
);
assert!(
!sha.exists(),
"sweep should remove the stale `.sha` sidecar even when age-based GC is disabled",
);
cleanup_reusable_worktree(&repo, &worktree_path);
}
#[test]
fn reusable_cache_gc_preserves_lock_file_after_removal() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-gc-lockfile");
let worktree_path = create_unregistered_reusable_cache(&repo);
write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
let lock_path = reusable_worktree_lock_path(&worktree_path);
drop(
ReusableWorktreeLock::try_acquire(&worktree_path, "test")
.expect("test should acquire the lock"),
);
assert!(
lock_path.exists(),
"test pre-condition: lock file should exist before sweep",
);
sweep_old_reusable_caches_in(&repo, Some(Duration::from_hours(30 * 24)), true, tmp.path());
assert!(
!worktree_path.exists(),
"sweep should still remove the worktree directory",
);
assert!(
lock_path.exists(),
"sweep MUST NOT delete the `.lock` file (lock-lifecycle invariant)",
);
let _ = fs::remove_file(&lock_path);
cleanup_reusable_worktree(&repo, &worktree_path);
}
#[test]
fn reusable_cache_gc_keeps_foreign_entry_with_live_owner() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-gc-scope-self");
let other_repo = init_throwaway_repo(tmp.path(), "repo-gc-scope-other");
// An aged-out cache belonging to a DIFFERENT repo whose root still
// exists. `repo`'s cross-repo pass must leave it to `other_repo`'s own
// sweep (which would otherwise let one repo defeat another's
// `cacheMaxAgeDays = 0`).
let other_path = create_unregistered_foreign_cache(&other_repo, tmp.path());
record_last_used(&other_path, &other_repo);
write_sidecar_with_age(&other_path, Duration::from_hours(31 * 24));
sweep_old_reusable_caches_in(&repo, Some(Duration::from_hours(30 * 24)), true, tmp.path());
assert!(
other_path.is_dir(),
"a repo's sweep must not reclaim another repo's aged cache entry while its owner root exists",
);
cleanup_reusable_worktree(&other_repo, &other_path);
}
#[test]
fn reusable_cache_gc_reclaims_abandoned_foreign_entry() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-gc-abandoned-self");
let other_repo = init_throwaway_repo(tmp.path(), "repo-gc-abandoned-other");
let other_path = create_unregistered_foreign_cache(&other_repo, tmp.path());
record_last_used(&other_path, &other_repo);
write_sidecar_with_age(&other_path, Duration::from_hours(31 * 24));
// Delete the owning repo: nothing will ever sweep this hash again, which
// is exactly the multi-worktree / deleted-repo leak from issue #2169.
fs::remove_dir_all(&other_repo).expect("owner repo should be removable");
sweep_old_reusable_caches_in(&repo, Some(Duration::from_hours(30 * 24)), true, tmp.path());
assert!(
!other_path.exists(),
"an aged cache whose owner root is gone must be reclaimed by any repo's sweep",
);
assert!(
!reusable_worktree_last_used_path(&other_path).exists(),
"the abandoned entry's `.last-used` sidecar must be reclaimed with it",
);
assert!(
!reusable_worktree_sha_path(&other_path).exists(),
"the abandoned entry's `.sha` sidecar must be reclaimed with it",
);
cleanup_reusable_worktree(&repo, &other_path);
}
/// `--max-age-days 0` / `cacheMaxAgeDays = 0` documents that it still reclaims
/// entries whose recorded owner root is gone. Such an entry is abandoned:
/// nothing will ever sweep it under its own hash again, so letting the
/// switched-off age gate keep it stranded it on disk forever.
#[test]
fn reusable_cache_gc_reclaims_abandoned_foreign_entry_with_age_gc_disabled() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-gc-abandoned-nogc-self");
let other_repo = init_throwaway_repo(tmp.path(), "repo-gc-abandoned-nogc-other");
let other_path = create_unregistered_foreign_cache(&other_repo, tmp.path());
record_last_used(&other_path, &other_repo);
fs::remove_dir_all(&other_repo).expect("owner repo should be removable");
sweep_old_reusable_caches_in(&repo, None, true, tmp.path());
assert!(
!other_path.exists(),
"age-gc-disabled must still reclaim an entry whose owner root is gone",
);
assert!(
!reusable_worktree_last_used_path(&other_path).exists(),
"the abandoned entry's `.last-used` sidecar must be reclaimed with it",
);
cleanup_reusable_worktree(&repo, &other_path);
}
/// The `0` escape hatch must not become a cross-repo delete: a foreign entry
/// whose owner root is still on disk stays under that repo's own policy.
#[test]
fn reusable_cache_gc_keeps_live_owner_foreign_entry_with_age_gc_disabled() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-gc-live-nogc-self");
let other_repo = init_throwaway_repo(tmp.path(), "repo-gc-live-nogc-other");
let other_path = create_unregistered_foreign_cache(&other_repo, tmp.path());
record_last_used(&other_path, &other_repo);
sweep_old_reusable_caches_in(&repo, None, true, tmp.path());
assert!(
other_path.is_dir(),
"age-gc-disabled must not reclaim a foreign entry whose owner root still exists",
);
cleanup_reusable_worktree(&other_repo, &other_path);
}
#[test]
fn reusable_cache_gc_reclaims_aged_foreign_entry_without_recorded_owner() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-gc-ownerless-self");
let other_repo = init_throwaway_repo(tmp.path(), "repo-gc-ownerless-other");
// Pre-#2169 sidecars are empty (mtime-only), so the owner is unknown.
// Aged ownerless entries must still age out: they are indistinguishable
// from abandoned ones and are the reporter's 19-month accumulation.
let other_path = create_unregistered_foreign_cache(&other_repo, tmp.path());
write_sidecar_with_age(&other_path, Duration::from_hours(31 * 24));
sweep_old_reusable_caches_in(&repo, Some(Duration::from_hours(30 * 24)), true, tmp.path());
assert!(
!other_path.exists(),
"an aged cache with no recorded owner must be reclaimed by the cross-repo pass",
);
cleanup_reusable_worktree(&other_repo, &other_path);
}
#[test]
fn reusable_cache_gc_grace_seeds_foreign_entry_without_sidecar() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-gc-foreign-grace-self");
let other_repo = init_throwaway_repo(tmp.path(), "repo-gc-foreign-grace-other");
let other_path = create_unregistered_foreign_cache(&other_repo, tmp.path());
let sidecar = reusable_worktree_last_used_path(&other_path);
assert!(
!sidecar.exists(),
"test pre-condition: sidecar should not exist",
);
sweep_old_reusable_caches_in(&repo, Some(Duration::from_hours(30 * 24)), true, tmp.path());
assert!(
other_path.is_dir(),
"a sidecar-less foreign entry must get the pre-upgrade grace, not removal",
);
assert!(
sidecar.exists(),
"the cross-repo pass must seed a sidecar so the entry can age from now on",
);
cleanup_reusable_worktree(&other_repo, &other_path);
}
#[test]
fn sweep_report_classifies_aged_owned_entry() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-report-aged");
let worktree_path = create_unregistered_reusable_cache(&repo);
// Recording the owner keeps a concurrent external cross-repo sweep away
// from this aged shared-temp fixture while the test runs.
record_last_used(&worktree_path, &repo);
write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
let report = sweep_reusable_caches_with_report(
&repo,
Some(Duration::from_hours(30 * 24)),
tmp.path(),
SweepMode::Apply,
SweepSizes::Skip,
);
let entry = report
.entries
.iter()
.find(|entry| entry.path == worktree_path)
.expect("sweep report should list the aged entry");
assert_eq!(entry.disposition, SweepDisposition::ReclaimedAged);
assert_eq!(entry.pass, SweepPass::Owned);
assert_eq!(entry.disposition.decision(), SweepDecision::Removed);
assert_eq!(entry.disposition.reason(), "aged-out");
assert_eq!(entry.age_days, Some(31));
assert_eq!(
entry.size_bytes, None,
"the per-audit sweep must never measure sizes",
);
assert_eq!(report.removed, 1);
assert!(!worktree_path.exists());
cleanup_reusable_worktree(&repo, &worktree_path);
}
#[test]
fn sweep_report_dry_run_previews_aged_entry_without_mutation() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-report-dryrun");
let worktree_path = create_unregistered_reusable_cache(&repo);
record_last_used(&worktree_path, &repo);
write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
let lock_path = reusable_worktree_lock_path(&worktree_path);
let _ = fs::remove_file(&lock_path);
let sidecar = reusable_worktree_last_used_path(&worktree_path);
let sidecar_mtime_before = fs::metadata(&sidecar)
.and_then(|metadata| metadata.modified())
.expect("sidecar mtime should be readable");
let report = sweep_reusable_caches_with_report(
&repo,
Some(Duration::from_hours(30 * 24)),
tmp.path(),
SweepMode::DryRun,
SweepSizes::Measure,
);
let entry = report
.entries
.iter()
.find(|entry| entry.path == worktree_path)
.expect("dry-run report should list the aged entry");
assert_eq!(entry.disposition, SweepDisposition::ReclaimedAged);
assert!(
entry.size_bytes.is_some_and(|size| size > 0),
"prune-style dry run should measure the entry size",
);
assert!(
worktree_path.is_dir(),
"dry run must not remove the entry directory",
);
assert!(
!lock_path.exists(),
"dry run must not create a `.lock` sidecar",
);
let sidecar_mtime_after = fs::metadata(&sidecar)
.and_then(|metadata| metadata.modified())
.expect("sidecar mtime should stay readable");
assert_eq!(
sidecar_mtime_before, sidecar_mtime_after,
"dry run must not touch the `.last-used` sidecar",
);
cleanup_reusable_worktree(&repo, &worktree_path);
}
#[test]
fn sweep_report_dry_run_lists_legacy_registered_without_deregistering() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-report-legacy-dryrun");
let cache_path = reusable_audit_worktree_path(&repo);
register_reusable_worktree(&repo, &cache_path);
let report = sweep_reusable_caches_with_report(
&repo,
Some(Duration::from_hours(30 * 24)),
tmp.path(),
SweepMode::DryRun,
SweepSizes::Skip,
);
assert!(
report.entries.iter().any(|entry| {
paths_equal(&entry.path, &cache_path)
&& entry.disposition == SweepDisposition::KeptLegacyDeregistered
}),
"dry run must report the still-registered current path as deregistered-but-kept",
);
assert!(
worktree_is_registered_with_git(&repo, &cache_path),
"dry run must not deregister the legacy entry",
);
assert!(
!reusable_worktree_lock_path(&cache_path).exists(),
"dry run must not create a `.lock` sidecar for the legacy pass",
);
assert!(
!reusable_worktree_last_used_path(&cache_path).exists(),
"dry run must not seed a grace sidecar",
);
cleanup_reusable_worktree(&repo, &cache_path);
}
/// Pre-#1815 upgrade edge: a mixed-version registration at the CURRENT cache
/// path is only deregistered and stays warm on disk, so it must report as
/// kept (`legacy-deregistered`), while a released SHA-keyed registration is
/// genuinely removed and stays a `legacy-registered` reclaim.
#[test]
fn sweep_report_apply_keeps_deregistered_current_path_and_removes_released_sha() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-report-legacy-apply");
let current_path = reusable_audit_worktree_path(&repo);
let released_path = legacy_reusable_audit_worktree_path(&repo, TEST_BASE_SHA)
.expect("legacy SHA path should resolve");
register_reusable_worktree(&repo, ¤t_path);
register_reusable_worktree(&repo, &released_path);
let report = sweep_reusable_caches_with_report(
&repo,
Some(Duration::from_hours(30 * 24)),
tmp.path(),
SweepMode::Apply,
SweepSizes::Measure,
);
// Match by entry NAME: the report echoes the canonical paths git prints,
// while a removed entry can no longer be canonicalized for `paths_equal`.
let find = |path: &Path| {
let name = path.file_name().expect("cache path should have a name");
report
.entries
.iter()
.find(|entry| entry.path.file_name() == Some(name))
.unwrap_or_else(|| panic!("entry for {} should be reported", path.display()))
};
let current = find(¤t_path);
assert_eq!(
current.disposition,
SweepDisposition::KeptLegacyDeregistered
);
assert_eq!(current.disposition.decision(), SweepDecision::Kept);
assert_eq!(current.disposition.reason(), "legacy-deregistered");
assert!(
current.size_bytes.is_some_and(|size| size > 0),
"the kept-warm entry still reports what it occupies",
);
let released = find(&released_path);
assert_eq!(
released.disposition,
SweepDisposition::ReclaimedLegacyRegistered
);
assert_eq!(released.disposition.decision(), SweepDecision::Removed);
assert!(
current_path.is_dir(),
"the current-path cache must stay warm on disk",
);
assert!(
!worktree_is_registered_with_git(&repo, ¤t_path),
"the current-path cache must be deregistered from git",
);
assert!(
!released_path.is_dir(),
"the released SHA-keyed cache must be removed",
);
assert_eq!(
report.removed, 0,
"neither legacy outcome feeds the per-audit stderr summary counter",
);
cleanup_reusable_worktree(&repo, ¤t_path);
cleanup_reusable_worktree(&repo, &released_path);
}
#[test]
fn sweep_report_counts_bare_lock_sidecar_as_lock_only() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-report-lock-only");
let cache_path = tmp
.path()
.join("fallow-audit-base-cache-0123456789abcdef-root-fedcba9876543210");
let lock_path = reusable_worktree_lock_path(&cache_path);
fs::write(&lock_path, "").expect("bare lock sidecar should be written");
let report = sweep_reusable_caches_with_report(
&repo,
Some(Duration::from_hours(30 * 24)),
tmp.path(),
SweepMode::Apply,
SweepSizes::Skip,
);
let entry = report
.entries
.iter()
.find(|entry| entry.path == cache_path)
.expect("a bare lock sidecar must fold back into a reported candidate");
assert_eq!(entry.disposition, SweepDisposition::KeptLockOnly);
assert_eq!(entry.pass, SweepPass::Foreign);
assert_eq!(entry.disposition.reason(), "lock-only");
assert_eq!(report.removed, 0);
assert!(
lock_path.exists(),
"the `.lock` sidecar must never be deleted",
);
}
#[test]
fn reclaim_orphan_entry_reports_recreated_directory_under_lock() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-report-recreated");
// The caller observed `!path.exists()`; the directory existing again at
// the lock-guarded re-check is exactly the concurrent-rebuild race.
let worktree_path = create_unregistered_reusable_cache(&repo);
assert_eq!(
reclaim_orphan_cache_entry(&repo, &worktree_path),
SweepDisposition::KeptRecreated,
);
assert!(
worktree_path.is_dir(),
"a recreated directory must be preserved",
);
cleanup_reusable_worktree(&repo, &worktree_path);
}
#[cfg(unix)]
#[test]
fn foreign_owner_behind_dangling_symlink_is_dead() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-owner-dangling-self");
let other_repo = init_throwaway_repo(tmp.path(), "repo-owner-dangling-other");
let other_path = create_unregistered_foreign_cache(&other_repo, tmp.path());
let dangling_owner = tmp.path().join("dangling-owner");
std::os::unix::fs::symlink(tmp.path().join("missing-target"), &dangling_owner)
.expect("dangling symlink should be created");
record_last_used(&other_path, &dangling_owner);
write_sidecar_with_age(&other_path, Duration::from_hours(31 * 24));
let report = sweep_reusable_caches_with_report(
&repo,
Some(Duration::from_hours(30 * 24)),
tmp.path(),
SweepMode::Apply,
SweepSizes::Skip,
);
let entry = report
.entries
.iter()
.find(|entry| entry.path == other_path)
.expect("report should list the foreign entry");
assert_eq!(
entry.disposition,
SweepDisposition::ReclaimedAged,
"a dangling-symlink owner root must classify dead, matching Path::exists()",
);
assert!(!other_path.exists());
cleanup_reusable_worktree(&other_repo, &other_path);
}
#[cfg(unix)]
#[test]
fn foreign_owner_with_unprobeable_root_is_kept_unverifiable() {
use std::os::unix::fs::PermissionsExt as _;
if rustix::process::geteuid().as_raw() == 0 {
// chmod 000 does not stop root from probing; the scenario cannot be
// reproduced.
return;
}
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-owner-unverifiable-self");
let other_repo = init_throwaway_repo(tmp.path(), "repo-owner-unverifiable-other");
let other_path = create_unregistered_foreign_cache(&other_repo, tmp.path());
let guarded_parent = tmp.path().join("guarded");
let owner = guarded_parent.join("repo");
fs::create_dir_all(&owner).expect("guarded owner root should be created");
record_last_used(&other_path, &owner);
write_sidecar_with_age(&other_path, Duration::from_hours(31 * 24));
fs::set_permissions(&guarded_parent, fs::Permissions::from_mode(0o000))
.expect("guarded parent should become unreadable");
let report = sweep_reusable_caches_with_report(
&repo,
Some(Duration::from_hours(30 * 24)),
tmp.path(),
SweepMode::Apply,
SweepSizes::Skip,
);
fs::set_permissions(&guarded_parent, fs::Permissions::from_mode(0o755))
.expect("guarded parent should be restored");
let entry = report
.entries
.iter()
.find(|entry| entry.path == other_path)
.expect("report should list the foreign entry");
assert_eq!(
entry.disposition,
SweepDisposition::KeptOwnerUnverifiable(std::io::ErrorKind::PermissionDenied),
"a probe error must keep the entry instead of reading as a dead owner",
);
assert!(
other_path.is_dir(),
"an unverifiable owner must never lose its cache entry",
);
cleanup_reusable_worktree(&other_repo, &other_path);
}
#[test]
fn cache_max_age_flag_wins_and_reports_source() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let flagged = resolve_cache_max_age_with_source(tmp.path(), None, false, Some(0));
assert_eq!(flagged.max_age, None);
assert_eq!(flagged.days, 0);
assert_eq!(flagged.source, CacheMaxAgeSource::Flag);
assert_eq!(flagged.source.as_str(), "flag");
if std::env::var("FALLOW_AUDIT_CACHE_MAX_AGE_DAYS").is_err() {
let defaulted = resolve_cache_max_age_with_source(tmp.path(), None, false, None);
assert_eq!(defaulted.days, 30);
assert_eq!(defaulted.source, CacheMaxAgeSource::Default);
assert!(defaulted.max_age.is_some());
}
}
#[test]
fn reuse_or_create_stamps_sidecar_on_fresh_create() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-fresh-create-stamp");
let base_sha = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
let worktree = BaseWorktree::reuse_or_create(&repo, &base_sha)
.expect("fresh reuse_or_create should succeed on a clean repo");
let cache_path = worktree.path().to_path_buf();
let sidecar = reusable_worktree_last_used_path(&cache_path);
assert!(
sidecar.exists(),
"fresh-create must write the sidecar so age is measured from now",
);
let initial_age = std::fs::metadata(&sidecar)
.and_then(|m| m.modified())
.ok()
.and_then(|mtime| SystemTime::now().duration_since(mtime).ok())
.expect("sidecar mtime should be readable and not in the future");
assert!(
initial_age < Duration::from_mins(1),
"fresh-create sidecar mtime should be near now(), got age {initial_age:?}",
);
assert_eq!(
fs::read_to_string(reusable_worktree_sha_path(&cache_path))
.expect(".sha sidecar should exist")
.trim(),
base_sha,
"fresh-create must record the base SHA in the `.sha` sidecar",
);
assert!(
cache_path.join(".git").is_file(),
"fresh-create must leave a `.git` stub file (deregistered, gitignore parity)",
);
assert_eq!(
registered_fallow_worktree_count(&repo),
0,
"fresh-create must not register the cache worktree with git",
);
drop(worktree);
cleanup_reusable_worktree(&repo, &cache_path);
}
/// Headline regression for issue #1815: a persistent-cache base worktree must
/// not register with git while its cache dir exists on disk, and a second
/// create with the same base SHA must reuse the checkout (a marker survives)
/// WITHOUT ever re-registering.
#[test]
fn base_worktree_reusable_create_leaves_no_registered_entry() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-1815-headline");
let base_sha = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
let first = BaseWorktree::create(&repo, "HEAD", Some(&base_sha))
.expect("persistent base worktree should be created");
let cache_path = first.path().to_path_buf();
assert!(cache_path.is_dir(), "cache directory should exist on disk");
assert!(
cache_path.join(".git").is_file(),
"unregistered cache must keep a `.git` stub file for gitignore parity",
);
assert_eq!(
registered_fallow_worktree_count(&repo),
0,
"a persistent-cache audit must leave zero fallow entries in git worktree list",
);
let marker = cache_path.join(".fallow-reuse-marker");
fs::write(&marker, "keep").expect("marker should be written");
drop(first);
let second = BaseWorktree::create(&repo, "HEAD", Some(&base_sha))
.expect("second create should reuse the cache");
assert_eq!(
second.path(),
cache_path,
"same base SHA should reuse the same cache dir",
);
assert!(
marker.is_file(),
"reuse must preserve the checkout (marker file survives) without a rebuild",
);
assert_eq!(
registered_fallow_worktree_count(&repo),
0,
"reuse must not re-register the cache worktree",
);
drop(second);
cleanup_reusable_worktree(&repo, &cache_path);
}
/// The non-reusable pid-named path must also stay unregistered, and its Drop
/// must remove the directory without a git subprocess.
#[test]
fn base_worktree_non_reusable_create_leaves_no_registered_entry() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-1815-nonreusable");
let worktree = BaseWorktree::create(&repo, "HEAD", None)
.expect("non-reusable base worktree should be created");
let path = worktree.path().to_path_buf();
assert!(path.is_dir());
assert!(
!is_reusable_audit_worktree_path(&path),
"None base_sha must select the pid-named non-reusable path",
);
assert!(
path.join(".git").is_file(),
"non-reusable cache must keep a `.git` stub file",
);
assert_eq!(
registered_fallow_worktree_count(&repo),
0,
"non-reusable create must not register with git",
);
drop(worktree);
assert!(
!path.exists(),
"Drop should remove the non-reusable worktree directory",
);
assert_eq!(registered_fallow_worktree_count(&repo), 0);
}
/// Readiness keys off the `.sha` sidecar: a matching SHA reuses the checkout;
/// a mismatched or missing `.sha` forces a rebuild.
#[test]
fn reusable_cache_reuses_on_sha_match_rebuilds_on_mismatch_or_missing() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-sha-readiness");
let base_sha = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
let first = BaseWorktree::reuse_or_create(&repo, &base_sha)
.expect("fresh reusable worktree should be created");
let cache_path = first.path().to_path_buf();
let sha_path = reusable_worktree_sha_path(&cache_path);
let marker = cache_path.join(".fallow-reuse-marker");
fs::write(&marker, "keep").expect("marker should be written");
drop(first);
// Matching .sha reuses (marker survives).
let reused =
BaseWorktree::reuse_or_create(&repo, &base_sha).expect("matching .sha should reuse");
assert_eq!(reused.path(), cache_path);
assert!(marker.is_file(), "matching .sha must reuse without rebuild");
drop(reused);
// Mismatched .sha -> rebuild wipes the marker and rewrites the sidecar.
fs::write(&sha_path, "ffffffffffffffff\n").expect("sha should be overwritable");
let rebuilt =
BaseWorktree::reuse_or_create(&repo, &base_sha).expect("mismatched .sha should rebuild");
assert_eq!(rebuilt.path(), cache_path);
assert!(
!marker.exists(),
"mismatched .sha must force a rebuild (marker wiped)",
);
assert_eq!(
fs::read_to_string(&sha_path).expect("sha rewritten").trim(),
base_sha,
"rebuild rewrites the .sha to the current base SHA",
);
fs::write(&marker, "keep2").expect("marker rewrite");
drop(rebuilt);
// Missing .sha -> rebuild wipes the marker.
fs::remove_file(&sha_path).expect("sha removable");
let rebuilt2 =
BaseWorktree::reuse_or_create(&repo, &base_sha).expect("missing .sha should rebuild");
assert!(!marker.exists(), "missing .sha must force a rebuild");
drop(rebuilt2);
cleanup_reusable_worktree(&repo, &cache_path);
}
#[test]
fn reusable_cache_rebuilds_same_path_for_a_new_sha_and_publishes_new_content() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-root-cache-rebuild");
let first_sha = git_rev_parse(&repo, "HEAD").expect("initial HEAD should resolve");
let first = BaseWorktree::reuse_or_create(&repo, &first_sha)
.expect("first reusable worktree should materialize");
let cache_path = first.path().to_path_buf();
assert_eq!(
fs::read_to_string(cache_path.join("README.md")).expect("base content should read"),
"seed\n",
);
drop(first);
let second_sha = commit_file(&repo, "README.md", "second snapshot\n");
let second = BaseWorktree::reuse_or_create(&repo, &second_sha)
.expect("second reusable worktree should rebuild");
assert_eq!(second.path(), cache_path, "both SHAs must share one path");
assert_eq!(
fs::read_to_string(cache_path.join("README.md")).expect("rebuilt content should read"),
"second snapshot\n",
);
assert_eq!(
fs::read_to_string(reusable_worktree_sha_path(&cache_path))
.expect("readiness should exist")
.trim(),
second_sha,
"readiness must publish the full replacement SHA",
);
drop(second);
cleanup_reusable_worktree(&repo, &cache_path);
}
#[test]
fn failed_reusable_cache_rebuild_leaves_no_matching_readiness() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-failed-cache-rebuild");
let base_sha = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
let initial = BaseWorktree::reuse_or_create(&repo, &base_sha)
.expect("initial reusable worktree should materialize");
let cache_path = initial.path().to_path_buf();
let sha_path = reusable_worktree_sha_path(&cache_path);
drop(initial);
let missing_sha = "0000000000000000000000000000000000000000";
assert!(
BaseWorktree::reuse_or_create(&repo, missing_sha).is_none(),
"a missing git object must fail materialization",
);
assert!(
!sha_path.exists(),
"failed rebuild must not leave readiness for the old or requested SHA",
);
cleanup_reusable_worktree(&repo, &cache_path);
}
#[test]
fn retained_reusable_lock_makes_remove_skip_then_succeed_and_preserve_lock() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-remove-retained-lock");
let base_sha = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
let worktree = BaseWorktree::reuse_or_create(&repo, &base_sha)
.expect("reusable worktree should materialize");
let cache_path = worktree.path().to_path_buf();
let lock_path = reusable_worktree_lock_path(&cache_path);
let contended = remove_reusable_audit_caches(&repo, false)
.expect("contended removal should report the skipped cache");
assert_eq!(contended.skipped, 1);
assert!(cache_path.is_dir(), "held cache must be skipped");
assert!(
reusable_worktree_sha_path(&cache_path).is_file(),
"held cache readiness must remain",
);
drop(worktree);
let removed =
remove_reusable_audit_caches(&repo, false).expect("unlocked removal should succeed");
assert_eq!(removed.removed, 1);
assert!(
!cache_path.exists(),
"unlocked cache directory must be removed"
);
assert!(!reusable_worktree_sha_path(&cache_path).exists());
assert!(!reusable_worktree_last_used_path(&cache_path).exists());
assert!(
lock_path.exists(),
"cache removal must preserve the lock file"
);
fs::remove_file(lock_path).expect("test lock file should be removable");
}
#[test]
fn dry_run_removal_does_not_create_lock_sidecars() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-dry-run-no-lock");
let cache_path = reusable_audit_worktree_path(&repo);
fs::create_dir_all(&cache_path).expect("cache directory should be created");
fs::write(
reusable_worktree_sha_path(&cache_path),
format!("{TEST_BASE_SHA}\n"),
)
.expect("readiness sidecar should be written");
fs::write(reusable_worktree_last_used_path(&cache_path), "")
.expect("last-used sidecar should be written");
let lock_path = reusable_worktree_lock_path(&cache_path);
assert!(
!lock_path.exists(),
"precondition: entry has no lock sidecar yet"
);
let report = remove_reusable_audit_caches(&repo, true).expect("dry-run removal should succeed");
assert!(report.dry_run, "report must reflect dry-run mode");
assert_eq!(report.found, 1, "the entry must be counted as found");
assert_eq!(report.removed, 0, "dry-run must remove nothing");
assert_eq!(report.skipped, 0, "dry-run must not report contention");
assert!(
!lock_path.exists(),
"dry-run must not create a .lock sidecar (documented as filesystem-inert)",
);
assert!(
cache_path.is_dir(),
"dry-run must preserve the cache directory"
);
cleanup_reusable_worktree(&repo, &cache_path);
}
#[test]
fn removal_on_repo_without_caches_reports_zero() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-remove-empty");
let report = remove_reusable_audit_caches(&repo, false)
.expect("removal on an empty repo should succeed");
assert_eq!(report.found, 0, "no caches means nothing found");
assert_eq!(report.removed, 0, "no caches means nothing removed");
assert_eq!(report.skipped, 0, "no caches means nothing skipped");
}
#[cfg(unix)]
#[test]
fn removal_surfaces_filesystem_errors() {
use std::os::unix::fs::PermissionsExt as _;
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-remove-eacces");
let cache_path = reusable_audit_worktree_path(&repo);
fs::create_dir_all(&cache_path).expect("cache directory should be created");
fs::write(cache_path.join("child"), "blocked\n").expect("inner file should be written");
// r-x only: the directory can be read and traversed, but its entries cannot
// be unlinked, so remove_dir_all fails with a permission error.
fs::set_permissions(&cache_path, fs::Permissions::from_mode(0o500))
.expect("cache mode should be set");
let result = remove_reusable_audit_caches(&repo, false);
// Restore write permission before asserting, so a failed assertion still
// leaves the TempDir removable.
fs::set_permissions(&cache_path, fs::Permissions::from_mode(0o700))
.expect("cache mode should be restored");
assert!(
result.is_err(),
"a filesystem error during removal must surface as Err (maps to exit 2)",
);
cleanup_reusable_worktree(&repo, &cache_path);
}
#[test]
fn cache_removal_does_not_follow_untrusted_gitdir_outside_repo_admin() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-remove-untrusted-gitdir");
let cache_path = reusable_audit_worktree_path(&repo);
fs::create_dir_all(&cache_path).expect("cache directory should be created");
let victim = tmp.path().join("fallow-audit-base-victim");
fs::create_dir_all(&victim).expect("victim directory should be created");
fs::write(victim.join("sentinel"), "keep\n").expect("sentinel should be written");
fs::write(
cache_path.join(".git"),
format!("gitdir: {}\n", victim.display()),
)
.expect("untrusted gitdir pointer should be written");
assert!(
unregister_worktree(&repo, &cache_path).is_err(),
"direct deregistration must reject the unverified pointer",
);
assert!(
fs::read_to_string(cache_path.join(".git"))
.expect("untrusted pointer should remain readable")
.contains(victim.to_string_lossy().as_ref()),
"failed verification must not overwrite the pointer and hide a live admin entry",
);
remove_reusable_audit_caches(&repo, false).expect("cache removal should succeed safely");
assert!(
victim.join("sentinel").is_file(),
"cleanup must not delete a cache-controlled path outside the repo admin directory",
);
cleanup_reusable_worktree(&repo, &cache_path);
}
#[cfg(unix)]
#[test]
fn reusable_cache_rejects_world_readable_preseed() {
use std::os::unix::fs::PermissionsExt as _;
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-reject-public-preseed");
let base_sha = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
let cache_path = reusable_audit_worktree_path(&repo);
fs::create_dir_all(&cache_path).expect("preseeded cache directory should be created");
fs::set_permissions(&cache_path, fs::Permissions::from_mode(0o755))
.expect("preseeded cache mode should be set");
fs::write(
reusable_worktree_sha_path(&cache_path),
format!("{base_sha}\n"),
)
.expect("preseeded readiness should be written");
fs::write(cache_path.join("POISONED"), "untrusted\n").expect("poison marker should be written");
let worktree = BaseWorktree::reuse_or_create(&repo, &base_sha)
.expect("unsafe preseed should be rebuilt safely");
assert!(
!worktree.path().join("POISONED").exists(),
"a world-readable preseed must not be trusted as a cache hit",
);
let mode = fs::metadata(worktree.path())
.expect("rebuilt cache metadata should read")
.permissions()
.mode();
assert_eq!(
mode & 0o077,
0,
"materialized cache contents must be private to the current user",
);
drop(worktree);
cleanup_reusable_worktree(&repo, &cache_path);
}
#[test]
fn root_removal_reclaims_legacy_sha_siblings_without_touching_unrelated_root() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-remove-legacy");
let unrelated_repo = init_throwaway_repo(tmp.path(), "repo-remove-unrelated");
let second_sha = "fedcba9876543210fedcba9876543210fedcba98";
let first = legacy_reusable_audit_worktree_path(&repo, TEST_BASE_SHA)
.expect("legacy path should resolve");
let second = legacy_reusable_audit_worktree_path(&repo, second_sha)
.expect("second legacy path should resolve");
let unrelated = legacy_reusable_audit_worktree_path(&unrelated_repo, TEST_BASE_SHA)
.expect("unrelated legacy path should resolve");
for path in [&first, &second, &unrelated] {
fs::create_dir_all(path).expect("legacy cache directory should be created");
fs::write(
reusable_worktree_sha_path(path),
format!("{TEST_BASE_SHA}\n"),
)
.expect("legacy readiness should be written");
fs::write(reusable_worktree_last_used_path(path), "").expect("last-used should be written");
}
let held_lock =
ReusableWorktreeLock::try_acquire(&first, "test").expect("first legacy lock acquired");
let first_lock_path = reusable_worktree_lock_path(&first);
let second_lock_path = reusable_worktree_lock_path(&second);
let contended = remove_reusable_audit_caches(&repo, false)
.expect("legacy removal should report only contention");
assert_eq!(contended.skipped, 1);
assert!(first.is_dir(), "contended legacy sibling must remain");
assert!(
!second.exists(),
"uncontended legacy sibling must be removed"
);
assert!(
second_lock_path.exists(),
"legacy lock file must be preserved"
);
assert!(
unrelated.is_dir(),
"unrelated root cache must remain untouched"
);
drop(held_lock);
remove_reusable_audit_caches(&repo, false).expect("remaining legacy sibling should remove");
assert!(!first.exists());
assert!(!reusable_worktree_sha_path(&first).exists());
assert!(!reusable_worktree_last_used_path(&first).exists());
assert!(
first_lock_path.exists(),
"held legacy lock file must be preserved"
);
assert!(
unrelated.is_dir(),
"unrelated root must still remain untouched"
);
cleanup_reusable_worktree(&repo, &first);
cleanup_reusable_worktree(&repo, &second);
cleanup_reusable_worktree(&unrelated_repo, &unrelated);
}
/// A cache hit whose `.git` stub was lost out-of-band must be repaired, so
/// gitignore parity holds on the next base pass.
#[test]
fn reusable_cache_stub_git_repaired_on_cache_hit() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-stub-repair");
let base_sha = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
let first =
BaseWorktree::reuse_or_create(&repo, &base_sha).expect("fresh create should succeed");
let cache_path = first.path().to_path_buf();
let git_stub = cache_path.join(".git");
assert!(git_stub.is_file(), "fresh create writes a .git stub");
drop(first);
fs::remove_file(&git_stub).expect("stub removable");
let reused = BaseWorktree::reuse_or_create(&repo, &base_sha).expect("cache hit should succeed");
assert_eq!(reused.path(), cache_path);
assert!(
git_stub.is_file(),
"cache hit must repair the missing .git stub for gitignore parity",
);
assert_eq!(registered_fallow_worktree_count(&repo), 0);
drop(reused);
cleanup_reusable_worktree(&repo, &cache_path);
}
/// The load-bearing gitignore-parity guard (issue #1815 risk #1): a
/// tracked-but-gitignored file is materialized on the snapshot but must be
/// EXCLUDED from discovery, exactly as in a real worktree. Without the `.git`
/// stub the `ignore` crate's require_git default would stop applying
/// `.gitignore` and analyze the file on base but not head.
#[test]
fn base_snapshot_discovery_honors_gitignore_via_stub_git() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let root = tmp.path().join("repo");
fs::create_dir_all(root.join("src")).expect("src dir should be created");
fs::write(root.join(".gitignore"), "secret.ts\n").expect("gitignore should be written");
fs::write(root.join("src/index.ts"), "export const a = 1;\n").expect("index should be written");
fs::write(root.join("src/secret.ts"), "export const secret = 2;\n")
.expect("secret should be written");
git(&root, &["init", "-b", "main"]);
// Force-add the gitignored file so it is TRACKED yet gitignored.
git(&root, &["add", "-f", "."]);
git(
&root,
&["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
);
let base_sha = git_rev_parse(&root, "HEAD").expect("HEAD should resolve");
let worktree = BaseWorktree::create(&root, "HEAD", Some(&base_sha))
.expect("base worktree should be created");
let cache_path = worktree.path().to_path_buf();
assert!(
cache_path.join("src/secret.ts").is_file(),
"raw materialization writes the full committed tree, including gitignored tracked files",
);
let project = fallow_engine::project_config::config_for_project(&cache_path, None)
.expect("base config should resolve");
let (files, _) =
fallow_engine::discover::discover_files_and_config_candidates(&project.config, &[]);
let names: Vec<String> = files
.iter()
.filter_map(|file| {
file.path
.file_name()
.and_then(|name| name.to_str())
.map(str::to_owned)
})
.collect();
assert!(
names.iter().any(|name| name == "index.ts"),
"normal tracked source must be discovered on the base snapshot",
);
assert!(
!names.iter().any(|name| name == "secret.ts"),
"gitignored tracked file must be EXCLUDED from base discovery (require_git parity)",
);
drop(worktree);
cleanup_reusable_worktree(&root, &cache_path);
}
/// `unregister_worktree` targets only the admin entry its own gitfile points
/// at, so a user's unrelated worktree is never collaterally deregistered.
#[test]
fn unregister_worktree_targets_only_its_own_admin_entry() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-unregister-targeted");
let fallow_path = reusable_audit_worktree_path(&repo);
register_reusable_worktree(&repo, &fallow_path);
let user_path = tmp.path().join("user-feature-worktree");
git(
&repo,
&[
"worktree",
"add",
"--detach",
"--quiet",
user_path.to_str().expect("path is utf-8"),
"HEAD",
],
);
assert!(worktree_is_registered_with_git(&repo, &fallow_path));
assert!(git_worktree_list_contains(&repo, &user_path));
unregister_worktree(&repo, &fallow_path).expect("fallow worktree should deregister");
assert!(
!worktree_is_registered_with_git(&repo, &fallow_path),
"the fallow worktree must be deregistered",
);
assert!(
fallow_path.is_dir() && fallow_path.join(".git").is_file(),
"unregister keeps the directory and writes a stub .git",
);
assert!(
git_worktree_list_contains(&repo, &user_path),
"a user's unrelated worktree must NOT be deregistered",
);
let _ = fs::remove_dir_all(&fallow_path);
remove_audit_worktree(&repo, &user_path);
let _ = fs::remove_dir_all(&user_path);
}
/// A mixed-version current-path cache left registered must be deregistered on
/// the first reuse, with the warm checkout preserved and `.sha` seeded.
#[test]
fn registered_current_cache_is_deregistered_and_reused_warm() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-legacy-migrate");
let base_sha = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
let cache_path = reusable_audit_worktree_path(&repo);
// Materialize via the engine to simulate another version registering the
// current root-owned path before this process acquires it.
fallow_engine::repo_refs::create_detached_base_worktree(&repo, &cache_path, &base_sha)
.expect("legacy worktree should materialize");
assert!(worktree_is_registered_with_git(&repo, &cache_path));
assert!(
!reusable_worktree_sha_path(&cache_path).exists(),
"pre-condition: a legacy entry has no .sha sidecar",
);
let marker = cache_path.join(".fallow-reuse-marker");
fs::write(&marker, "warm").expect("marker should be written");
let migrated = BaseWorktree::reuse_or_create(&repo, &base_sha)
.expect("legacy cache should migrate and reuse");
assert_eq!(migrated.path(), cache_path);
assert!(
marker.is_file(),
"a warm legacy cache must be reused, not rebuilt",
);
assert!(
!worktree_is_registered_with_git(&repo, &cache_path),
"legacy cache must be deregistered on migration",
);
assert_eq!(
fs::read_to_string(reusable_worktree_sha_path(&cache_path))
.expect(".sha should be seeded")
.trim(),
base_sha,
".sha sidecar seeded from the legacy entry's HEAD",
);
drop(migrated);
cleanup_reusable_worktree(&repo, &cache_path);
}
#[test]
fn released_sha_keyed_registered_cache_is_deregistered_and_removed() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-released-legacy-cleanup");
let base_sha = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
let legacy_path = legacy_reusable_audit_worktree_path(&repo, &base_sha)
.expect("released legacy path should resolve");
fallow_engine::repo_refs::create_detached_base_worktree(&repo, &legacy_path, &base_sha)
.expect("released legacy cache should materialize");
assert!(worktree_is_registered_with_git(&repo, &legacy_path));
sweep_old_reusable_caches_in(&repo, None, true, tmp.path());
assert!(
!legacy_path.exists(),
"obsolete legacy contents should be removed"
);
assert!(
!worktree_is_registered_with_git(&repo, &legacy_path),
"released SHA-keyed cache must be removed from git worktree state",
);
assert!(!reusable_worktree_sha_path(&legacy_path).exists());
cleanup_reusable_worktree(&repo, &legacy_path);
}
/// An unregistered dead-pid worktree directory (a crash in the transient
/// registration window) is swept by the temp-dir prefix scan.
#[test]
fn orphan_sweep_removes_unregistered_dead_pid_dir() {
const DEAD_PID: u32 = 99_999_998;
assert!(!process_is_alive(DEAD_PID));
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "repo-unreg-orphan");
let dir = tmp.path().join(format!(
"fallow-audit-base-{DEAD_PID}-{}-0",
SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock should be after epoch")
.as_nanos()
));
fs::create_dir_all(&dir).expect("orphan dir should be created");
fs::write(dir.join(".git"), "gitdir: fallow-audit-unregistered\n").expect("stub should write");
sweep_orphan_audit_worktrees_in(&repo, tmp.path());
assert!(
!dir.exists(),
"an unregistered dead-pid worktree dir must be swept",
);
}
#[test]
fn days_to_duration_zero_disables() {
assert!(days_to_duration(0).is_none());
assert_eq!(days_to_duration(1), Some(Duration::from_hours(24)));
assert_eq!(days_to_duration(30), Some(Duration::from_hours(30 * 24)));
}
#[test]
fn reusable_worktree_last_used_path_lives_next_to_cache_dir() {
let cache_dir = std::env::temp_dir().join("fallow-audit-base-cache-abcd-1234");
let sidecar = reusable_worktree_last_used_path(&cache_dir);
assert_eq!(sidecar.parent(), cache_dir.parent());
assert_eq!(
sidecar.file_name().and_then(|s| s.to_str()),
Some("fallow-audit-base-cache-abcd-1234.last-used"),
);
}
#[test]
fn touch_last_used_creates_sidecar_if_missing() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let cache_dir = tmp.path().join("fallow-audit-base-cache-touchtest-0000");
fs::create_dir(&cache_dir).expect("cache dir should be created");
let sidecar = reusable_worktree_last_used_path(&cache_dir);
assert!(!sidecar.exists(), "sidecar should not exist before touch");
touch_last_used(&cache_dir);
assert!(sidecar.exists(), "touch should create the sidecar");
let mtime = fs::metadata(&sidecar)
.and_then(|m| m.modified())
.expect("sidecar should have an mtime");
let age = SystemTime::now()
.duration_since(mtime)
.unwrap_or(Duration::ZERO);
assert!(
age < Duration::from_mins(1),
"touched sidecar should be near `now()`",
);
}
#[test]
fn reusable_worktree_lock_excludes_concurrent_acquires() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let reusable = tmp.path().join("fallow-audit-base-cache-deadbeef-0000");
let lock_path = reusable_worktree_lock_path(&reusable);
let first = ReusableWorktreeLock::try_acquire(&reusable, "test")
.expect("first acquire on a fresh path should succeed");
assert!(
ReusableWorktreeLock::try_acquire(&reusable, "test").is_none(),
"second acquire must fail while the first is held",
);
drop(first);
assert!(
lock_path.exists(),
"lock file must persist after drop (only the kernel lock is released)",
);
}
#[test]
fn audit_base_worktree_reuses_current_node_modules_context() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let root = tmp.path();
fs::create_dir_all(root.join("src")).expect("src dir should be created");
fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
.expect("gitignore should be written");
fs::write(
root.join("package.json"),
r#"{"name":"audit-rn-alias","main":"src/index.ts","dependencies":{"@react-native/typescript-config":"1.0.0"}}"#,
)
.expect("package.json should be written");
fs::write(
root.join("tsconfig.json"),
r#"{"extends":"./node_modules/@react-native/typescript-config/tsconfig.json","compilerOptions":{"baseUrl":".","paths":{"@/*":["src/*"]}},"include":["src"]}"#,
)
.expect("tsconfig should be written");
fs::write(
root.join("src/index.ts"),
"import { used } from '@/feature';\nconsole.log(used);\n",
)
.expect("index should be written");
fs::write(root.join("src/feature.ts"), "export const used = 1;\n")
.expect("feature should be written");
git(root, &["init", "-b", "main"]);
git(root, &["add", "."]);
git(
root,
&["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
);
let rn_config = root.join("node_modules/@react-native/typescript-config");
fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
fs::write(
rn_config.join("tsconfig.json"),
r#"{"compilerOptions":{"jsx":"react-native","moduleResolution":"bundler"}}"#,
)
.expect("node_modules tsconfig should be written");
let worktree =
BaseWorktree::create(root, "HEAD", None).expect("base worktree should be created");
assert!(
worktree.path().join("node_modules").is_dir(),
"base worktree should reuse ignored node_modules from the current checkout"
);
assert!(
worktree
.path()
.join("node_modules/@react-native/typescript-config/tsconfig.json")
.is_file(),
"base worktree should preserve tsconfig extends targets installed in node_modules"
);
}
#[cfg(unix)]
#[test]
fn audit_base_worktree_uses_no_checkout_engine_materializer() {
use std::os::unix::fs::PermissionsExt as _;
let temp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(temp.path(), "repo-no-checkout");
let sentinel = temp.path().join("post-checkout-ran");
let hook = repo.join(".git/hooks/post-checkout");
fs::write(
&hook,
format!("#!/bin/sh\nprintf ran > '{}'\n", sentinel.display()),
)
.expect("post-checkout hook should be written");
let mut permissions = fs::metadata(&hook)
.expect("hook metadata should be readable")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&hook, permissions).expect("hook should be executable");
let worktree =
BaseWorktree::create(&repo, "HEAD", None).expect("base worktree should be created");
assert!(worktree.path().join("README.md").is_file());
assert!(
!sentinel.exists(),
"CLI base worktree creation must not execute checkout hooks"
);
}
/// Confirms `materialize_base_dependency_context` symlinks the Nuxt
/// `.nuxt/` generated dir from the host checkout into the audit base
/// worktree. Without this, root `tsconfig.json` `references` entries
/// pointing into `.nuxt/tsconfig.app.json` break in the base pass and
/// emit "Nuxt project missing .nuxt/tsconfig.json" plus "tsconfig chain
/// not fully loaded" warnings. The function is exercised directly here
/// rather than
/// through `BaseWorktree::create` to avoid the `git worktree add`
/// concurrency-flakiness the worktree-level integration tests already
/// exhibit.
#[test]
fn materialize_base_dependency_context_symlinks_nuxt_generated_dir() {
let host = tempfile::TempDir::new().expect("host tempdir should be created");
let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
let dot_nuxt = host.path().join(".nuxt");
fs::create_dir_all(&dot_nuxt).expect(".nuxt dir should be created");
fs::write(dot_nuxt.join("tsconfig.json"), r#"{"compilerOptions":{}}"#)
.expect(".nuxt/tsconfig.json should be written");
fs::write(
dot_nuxt.join("tsconfig.app.json"),
r#"{"compilerOptions":{}}"#,
)
.expect(".nuxt/tsconfig.app.json should be written");
materialize_base_dependency_context(host.path(), worktree.path());
let mirrored = worktree.path().join(".nuxt");
assert!(
mirrored.is_dir(),
"base worktree should reuse the ignored .nuxt dir from the host checkout"
);
let link_meta = fs::symlink_metadata(&mirrored)
.expect(".nuxt entry should exist as a symlink in the worktree");
assert!(
link_meta.file_type().is_symlink(),
"base worktree's .nuxt should be a symlink to the host checkout"
);
assert!(
mirrored.join("tsconfig.json").is_file(),
"base worktree should expose .nuxt/tsconfig.json so the Nuxt meta-framework \
prerequisite check stays quiet"
);
assert!(
mirrored.join("tsconfig.app.json").is_file(),
"base worktree should expose .nuxt/tsconfig.app.json so root tsconfig references \
resolve without falling back to resolver-less resolution"
);
}
/// Confirms the same symlink treatment for Astro's `.astro/` generated
/// types directory, which is gitignored by default and would otherwise
/// trip the "Astro project missing .astro/" prerequisite check on the
/// base pass.
#[test]
fn materialize_base_dependency_context_symlinks_astro_generated_dir() {
let host = tempfile::TempDir::new().expect("host tempdir should be created");
let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
let dot_astro = host.path().join(".astro");
fs::create_dir_all(&dot_astro).expect(".astro dir should be created");
fs::write(dot_astro.join("types.d.ts"), "// generated types\n")
.expect(".astro/types.d.ts should be written");
materialize_base_dependency_context(host.path(), worktree.path());
let mirrored = worktree.path().join(".astro");
assert!(
mirrored.is_dir(),
"base worktree should reuse the ignored .astro dir from the host checkout"
);
assert!(
mirrored.join("types.d.ts").is_file(),
"base worktree should expose generated Astro types so the Astro meta-framework \
prerequisite check stays quiet"
);
}
/// Confirms the symlink step is a no-op when the host checkout has no
/// meta-framework output. We must not fabricate a dangling `.nuxt`
/// symlink: the Nuxt prerequisite check would then pass on the base pass
/// while the actual `.nuxt/tsconfig.json` still doesn't exist, hiding a
/// real "run `nuxt prepare`" warning on the HEAD pass behind a
/// process-wide dedupe key.
#[test]
fn materialize_base_dependency_context_skips_when_host_lacks_meta_framework_dir() {
let host = tempfile::TempDir::new().expect("host tempdir should be created");
let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
materialize_base_dependency_context(host.path(), worktree.path());
assert!(
!worktree.path().join(".nuxt").exists(),
"base worktree should not fabricate a .nuxt symlink when the host has no .nuxt dir"
);
assert!(
!worktree.path().join(".astro").exists(),
"base worktree should not fabricate a .astro symlink when the host has no .astro dir"
);
assert!(
!worktree.path().join("node_modules").exists(),
"base worktree should not fabricate a node_modules symlink when the host has none"
);
}
/// Confirms each entry in `MATERIALIZED_CONTEXT_DIRS` is independent: a
/// missing host `.nuxt/` must not prevent `node_modules` from being
/// symlinked when only one of the two is present on the host.
#[test]
fn materialize_base_dependency_context_handles_each_dir_independently() {
let host = tempfile::TempDir::new().expect("host tempdir should be created");
let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
fs::create_dir_all(host.path().join("node_modules"))
.expect("host node_modules should be created");
materialize_base_dependency_context(host.path(), worktree.path());
assert!(
worktree.path().join("node_modules").is_dir(),
"node_modules should still be symlinked even when host has no .nuxt or .astro"
);
assert!(
!worktree.path().join(".nuxt").exists(),
"missing host .nuxt should leave the worktree slot empty"
);
}
/// Confirms a real (non-symlink) generated dir already present in the base
/// worktree is preserved, not clobbered by a host symlink. A base commit
/// that genuinely tracks `.nuxt/` is base-shaped and authoritative; the
/// host-symlink shortcut only fills the gap when the worktree slot is
/// empty (or a stale dangling symlink), so the `destination.is_dir()`
/// early-continue must keep the worktree's own contents.
#[test]
fn materialize_base_dependency_context_preserves_real_worktree_dir() {
let host = tempfile::TempDir::new().expect("host tempdir should be created");
let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
let host_nuxt = host.path().join(".nuxt");
fs::create_dir_all(&host_nuxt).expect("host .nuxt dir should be created");
fs::write(host_nuxt.join("tsconfig.json"), r#"{"_source":"host"}"#)
.expect("host .nuxt/tsconfig.json should be written");
let worktree_nuxt = worktree.path().join(".nuxt");
fs::create_dir_all(&worktree_nuxt).expect("worktree .nuxt dir should be created");
fs::write(worktree_nuxt.join("tsconfig.json"), r#"{"_source":"base"}"#)
.expect("worktree .nuxt/tsconfig.json should be written");
materialize_base_dependency_context(host.path(), worktree.path());
let link_meta = fs::symlink_metadata(&worktree_nuxt)
.expect(".nuxt entry should still exist in the worktree");
assert!(
!link_meta.file_type().is_symlink(),
"a real base-tracked .nuxt dir must not be replaced by a host symlink"
);
let contents =
fs::read_to_string(worktree_nuxt.join("tsconfig.json")).expect("tsconfig should read");
assert!(
contents.contains("base"),
"base worktree's own .nuxt contents must survive, not be overwritten by the host's"
);
}
#[test]
fn audit_reusable_base_worktree_refreshes_current_node_modules_context() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let root = tmp.path();
fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
.expect("gitignore should be written");
fs::write(root.join("package.json"), r#"{"name":"audit-reusable"}"#)
.expect("package.json should be written");
git(root, &["init", "-b", "main"]);
git(root, &["add", "."]);
git(
root,
&["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
);
let rn_config = root.join("node_modules/@react-native/typescript-config");
fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
fs::write(rn_config.join("tsconfig.json"), "{}")
.expect("node_modules tsconfig should be written");
let base_sha = git_rev_parse(root, "HEAD").expect("HEAD should resolve");
let first = BaseWorktree::reuse_or_create(root, &base_sha)
.expect("persistent base worktree should be created");
let worktree_path = first.path().to_path_buf();
assert!(
worktree_path.join("node_modules").is_dir(),
"initial persistent worktree should receive node_modules context"
);
remove_node_modules_context(&worktree_path);
assert!(
!worktree_path.join("node_modules").exists(),
"test setup should remove the dependency context from the reusable worktree"
);
drop(first);
let reused = BaseWorktree::reuse_or_create(root, &base_sha)
.expect("ready persistent base worktree should be reused");
assert_eq!(reused.path(), worktree_path.as_path());
assert!(
reused.path().join("node_modules").is_dir(),
"ready persistent worktree should refresh missing node_modules context"
);
cleanup_reusable_worktree(root, reused.path());
}
fn remove_node_modules_context(worktree_path: &Path) {
let path = worktree_path.join("node_modules");
let Ok(metadata) = fs::symlink_metadata(&path) else {
return;
};
if metadata.file_type().is_symlink() {
#[cfg(unix)]
let _ = fs::remove_file(path);
#[cfg(windows)]
let _ = fs::remove_dir(&path).or_else(|_| fs::remove_file(&path));
} else {
let _ = fs::remove_dir_all(path);
}
}
fn empty_snapshot_with_type_aware(
identity: Option<fallow_types::semantic::SemanticAnalysisIdentity>,
gap_signature: Vec<String>,
) -> AuditKeySnapshot {
AuditKeySnapshot {
branching: rustc_hash::FxHashMap::default(),
type_aware_identity: identity,
type_aware_gap_signature: gap_signature,
syntactic_dead_code: None,
dead_code: FxHashSet::default(),
health: FxHashSet::default(),
styling: FxHashSet::default(),
dupes: FxHashSet::default(),
boundary_edges: FxHashSet::default(),
cycles: FxHashSet::default(),
public_api: FxHashSet::default(),
}
}
fn identity_with_hash(
project_config_hash: &str,
) -> fallow_types::semantic::SemanticAnalysisIdentity {
fallow_types::semantic::SemanticAnalysisIdentity {
project_config_hash: project_config_hash.to_string(),
..Default::default()
}
}
fn meta_with_identity(
identity: fallow_types::semantic::SemanticAnalysisIdentity,
) -> fallow_types::envelope::TypeAwareMeta {
fallow_types::envelope::TypeAwareMeta {
identity: Some(identity),
..Default::default()
}
}
#[test]
fn degrade_reason_absent_for_fully_syntactic_comparison() {
let base = empty_snapshot_with_type_aware(None, Vec::new());
assert_eq!(
type_aware_attribution_degrade_reason(Some(&base), None),
None
);
assert_eq!(type_aware_attribution_degrade_reason(None, None), None);
}
/// Regression test for #2102: a side without a semantic identity made no
/// semantic claims (it legitimately ran no semantic queries), so the other
/// side's identity cannot conflict with it and the comparison must not
/// degrade.
#[test]
fn degrade_reason_absent_when_only_one_side_has_type_aware_identity() {
let base = empty_snapshot_with_type_aware(Some(identity_with_hash("hash-a")), Vec::new());
assert_eq!(
type_aware_attribution_degrade_reason(Some(&base), None),
None
);
let base = empty_snapshot_with_type_aware(None, Vec::new());
let head = meta_with_identity(identity_with_hash("hash-a"));
assert_eq!(
type_aware_attribution_degrade_reason(Some(&base), Some(&head)),
None
);
}
/// Regression test for #2102: the deferred project-config hash (a side that
/// needed no semantic queries) is compatible with any concrete hash, so a
/// diff that adds a file (deferred base, concrete head) must not degrade.
#[test]
fn degrade_reason_absent_for_deferred_vs_concrete_project_config_hash() {
let deferred = identity_with_hash(fallow_types::semantic::DEFERRED_PROJECT_CONFIG_HASH);
let concrete = identity_with_hash("sha256:concrete");
let base = empty_snapshot_with_type_aware(Some(deferred.clone()), Vec::new());
let head = meta_with_identity(concrete.clone());
assert_eq!(
type_aware_attribution_degrade_reason(Some(&base), Some(&head)),
None
);
let base = empty_snapshot_with_type_aware(Some(concrete), Vec::new());
let head = meta_with_identity(deferred);
assert_eq!(
type_aware_attribution_degrade_reason(Some(&base), Some(&head)),
None
);
}
#[test]
fn degrade_reason_when_semantic_identities_are_incompatible() {
let base = empty_snapshot_with_type_aware(Some(identity_with_hash("hash-a")), Vec::new());
let head = meta_with_identity(identity_with_hash("hash-b"));
assert_eq!(
type_aware_attribution_degrade_reason(Some(&base), Some(&head)),
Some("their semantic analysis identities are incompatible")
);
}
#[test]
fn degrade_reason_absent_when_identities_and_gap_signatures_match() {
let base = empty_snapshot_with_type_aware(Some(identity_with_hash("hash-a")), Vec::new());
let head = meta_with_identity(identity_with_hash("hash-a"));
assert_eq!(
type_aware_attribution_degrade_reason(Some(&base), Some(&head)),
None
);
}
#[test]
fn degrade_reason_when_gap_signatures_differ() {
let base = empty_snapshot_with_type_aware(Some(identity_with_hash("hash-a")), Vec::new());
let mut head = meta_with_identity(identity_with_hash("hash-a"));
head.queries = vec![fallow_types::semantic::SemanticQuerySummary {
query_id: 0,
capability: fallow_types::semantic::SemanticCapability::SymbolUse,
assertion: "candidate usage refinement".to_string(),
status: fallow_types::semantic::SemanticCompleteness::Partial,
reason_code: None,
total_evidence_count: 0,
truncated: false,
omissions: Vec::new(),
actions: Vec::new(),
}];
assert_eq!(
type_aware_attribution_degrade_reason(Some(&base), Some(&head)),
Some("their incomplete semantic query reasons or omissions differ")
);
}
#[test]
fn audit_base_snapshot_cache_payload_roundtrips_sets() {
let key = AuditBaseSnapshotCacheKey {
hash: 42,
base_sha: "abc123".to_string(),
};
let snapshot = AuditKeySnapshot {
branching: [
("src/z.ts".to_string(), branching_totals(7, 3, 9)),
("src/a.ts".to_string(), branching_totals(2, 1, 3)),
]
.into_iter()
.collect(),
type_aware_identity: Some(identity_with_hash("hash-roundtrip")),
type_aware_gap_signature: vec!["SymbolUse:None:".to_string()],
syntactic_dead_code: Some(
["syn:a".to_string(), "syn:b".to_string()]
.into_iter()
.collect(),
),
dead_code: ["dead:a".to_string(), "dead:b".to_string()]
.into_iter()
.collect(),
health: std::iter::once("health:a".to_string()).collect(),
styling: std::iter::once("styling:a".to_string()).collect(),
dupes: ["dupe:a".to_string(), "dupe:b".to_string()]
.into_iter()
.collect(),
boundary_edges: std::iter::once("ui->-db".to_string()).collect(),
cycles: std::iter::once("a.ts|b.ts".to_string()).collect(),
public_api: std::iter::once("src/index.ts::foo".to_string()).collect(),
};
let cached = cached_from_snapshot(&key, &snapshot).expect("snapshot should serialize");
assert_eq!(cached.version, AUDIT_BASE_SNAPSHOT_CACHE_VERSION);
assert_eq!(cached.key_hash, key.hash);
assert_eq!(cached.base_sha, key.base_sha);
assert_eq!(cached.dead_code, vec!["dead:a", "dead:b"]);
assert_eq!(
cached.syntactic_dead_code,
Some(vec!["syn:a".to_string(), "syn:b".to_string()])
);
assert_eq!(
cached
.branching
.iter()
.map(|row| row.path.as_str())
.collect::<Vec<_>>(),
vec!["src/a.ts", "src/z.ts"],
"the cached payload is sorted, so identical input encodes to identical bytes"
);
let decoded = snapshot_from_cached(cached).expect("cached snapshot should decode");
assert_eq!(
decoded.branching, snapshot.branching,
"branching totals survive the cache round trip"
);
assert_eq!(decoded.type_aware_identity, snapshot.type_aware_identity);
assert_eq!(
decoded.type_aware_gap_signature,
snapshot.type_aware_gap_signature
);
assert_eq!(decoded.syntactic_dead_code, snapshot.syntactic_dead_code);
assert_eq!(decoded.dead_code, snapshot.dead_code);
assert_eq!(decoded.health, snapshot.health);
assert_eq!(decoded.styling, snapshot.styling);
assert_eq!(decoded.dupes, snapshot.dupes);
assert_eq!(decoded.boundary_edges, snapshot.boundary_edges);
assert_eq!(decoded.cycles, snapshot.cycles);
assert_eq!(decoded.public_api, snapshot.public_api);
}
#[test]
fn audit_base_snapshot_cache_dir_writes_gitignore() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let cache_root = tmp.path().join(".custom-fallow-cache");
let cache_dir = audit_base_snapshot_cache_dir(&cache_root);
ensure_audit_base_snapshot_cache_dir(&cache_dir).expect("cache dir should be created");
assert_eq!(
fs::read_to_string(cache_dir.join(".gitignore")).expect("gitignore should read"),
"*\n"
);
}
#[test]
fn audit_base_snapshot_cache_dir_sweeps_lower_versions() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let cache_root = tmp.path().join(".custom-fallow-cache");
let cache_parent = cache_root.join("cache");
let stale = cache_parent.join(format!(
"audit-base-v{}",
AUDIT_BASE_SNAPSHOT_CACHE_VERSION - 1
));
fs::create_dir_all(&stale).expect("stale dir should be created");
fs::write(stale.join("deadbeef.bin"), b"stale").expect("stale payload should write");
let newer = cache_parent.join(format!(
"audit-base-v{}",
AUDIT_BASE_SNAPSHOT_CACHE_VERSION + 1
));
fs::create_dir_all(&newer).expect("newer dir should be created");
let unrelated = cache_parent.join("resolve");
fs::create_dir_all(&unrelated).expect("unrelated dir should be created");
let cache_dir = audit_base_snapshot_cache_dir(&cache_root);
ensure_audit_base_snapshot_cache_dir(&cache_dir).expect("cache dir should be created");
assert!(
!stale.exists(),
"lower-versioned snapshot cache directories should be swept"
);
assert!(
newer.exists(),
"an older binary must not delete a newer version's cache"
);
assert!(unrelated.exists(), "unrelated cache dirs must be untouched");
assert!(cache_dir.exists());
}
#[test]
fn audit_base_snapshot_cache_roundtrips_from_disk() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let config_path = None;
let cache_root = tmp.path().join(".custom-fallow-cache");
let opts = AuditOptions {
root: tmp.path(),
cache_dir: &cache_root,
config_path: &config_path,
output: OutputFormat::Json,
json_style: crate::json_style::JsonStyle::Compact,
no_cache: false,
threads: 1,
quiet: true,
allow_remote_extends: false,
changed_since: Some("HEAD"),
production: false,
production_dead_code: None,
production_health: None,
production_dupes: None,
workspace: None,
changed_workspaces: None,
explain: false,
explain_skipped: false,
performance: false,
group_by: None,
dead_code_baseline: None,
health_baseline: None,
dupes_baseline: None,
health_baseline_mode: fallow_engine::baseline::HealthBaselineMode::default(),
fail_on_stale_baseline: false,
max_crap: None,
coverage: None,
coverage_root: None,
gate: AuditGate::NewOnly,
include_entry_exports: false,
css: false,
css_deep: false,
runtime_coverage: None,
min_invocations_hot: 100,
brief: false,
max_decisions: 4,
walkthrough_guide: false,
walkthrough: false,
mark_viewed: &[],
show_cleared: false,
walkthrough_file: None,
show_deprioritized: false,
scope: None,
};
let key = AuditBaseSnapshotCacheKey {
hash: 0xfeed,
base_sha: "abc123".to_string(),
};
let snapshot = AuditKeySnapshot {
branching: rustc_hash::FxHashMap::default(),
type_aware_identity: None,
type_aware_gap_signature: Vec::new(),
syntactic_dead_code: None,
dead_code: std::iter::once("dead:a".to_string()).collect(),
health: std::iter::once("health:a".to_string()).collect(),
styling: std::iter::once("styling:a".to_string()).collect(),
dupes: std::iter::once("dupe:a".to_string()).collect(),
boundary_edges: FxHashSet::default(),
cycles: FxHashSet::default(),
public_api: FxHashSet::default(),
};
save_cached_base_snapshot(&opts, &key, &snapshot);
assert!(
audit_base_snapshot_cache_file(&cache_root, &key).exists(),
"snapshot should be saved below the configured cache directory"
);
let loaded = load_cached_base_snapshot(&opts, &key).expect("snapshot should load");
assert_eq!(loaded.dead_code, snapshot.dead_code);
assert_eq!(loaded.health, snapshot.health);
assert_eq!(loaded.dupes, snapshot.dupes);
}
#[test]
fn audit_base_snapshot_cache_rejects_mismatched_key() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let config_path = None;
let cache_root = tmp.path().join(".custom-fallow-cache");
let opts = AuditOptions {
root: tmp.path(),
cache_dir: &cache_root,
config_path: &config_path,
output: OutputFormat::Json,
json_style: crate::json_style::JsonStyle::Compact,
no_cache: false,
threads: 1,
quiet: true,
allow_remote_extends: false,
changed_since: Some("HEAD"),
production: false,
production_dead_code: None,
production_health: None,
production_dupes: None,
workspace: None,
changed_workspaces: None,
explain: false,
explain_skipped: false,
performance: false,
group_by: None,
dead_code_baseline: None,
health_baseline: None,
dupes_baseline: None,
health_baseline_mode: fallow_engine::baseline::HealthBaselineMode::default(),
fail_on_stale_baseline: false,
max_crap: None,
coverage: None,
coverage_root: None,
gate: AuditGate::NewOnly,
include_entry_exports: false,
css: false,
css_deep: false,
runtime_coverage: None,
min_invocations_hot: 100,
brief: false,
max_decisions: 4,
walkthrough_guide: false,
walkthrough: false,
mark_viewed: &[],
show_cleared: false,
walkthrough_file: None,
show_deprioritized: false,
scope: None,
};
let key = AuditBaseSnapshotCacheKey {
hash: 0xbeef,
base_sha: "head".to_string(),
};
let cached = CachedAuditKeySnapshot {
branching: Vec::new(),
version: AUDIT_BASE_SNAPSHOT_CACHE_VERSION,
cli_version: env!("CARGO_PKG_VERSION").to_string(),
key_hash: key.hash,
base_sha: "other".to_string(),
type_aware_identity: None,
type_aware_gap_signature: Vec::new(),
syntactic_dead_code: None,
dead_code: vec!["dead:a".to_string()],
health: vec![],
styling: vec![],
dupes: vec![],
boundary_edges: vec![],
cycles: vec![],
public_api: vec![],
};
let cache_dir = audit_base_snapshot_cache_dir(&cache_root);
ensure_audit_base_snapshot_cache_dir(&cache_dir).expect("cache dir should be created");
fs::write(
audit_base_snapshot_cache_file(&cache_root, &key),
bitcode::encode(&cached),
)
.expect("cache file should be written");
assert!(load_cached_base_snapshot(&opts, &key).is_none());
}
#[test]
fn audit_base_snapshot_cache_key_includes_extended_config() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let root = tmp.path();
fs::write(
root.join(".fallowrc.json"),
r#"{"extends":"base.json","entry":["src/index.ts"]}"#,
)
.expect("config should be written");
fs::write(
root.join("base.json"),
r#"{"rules":{"unused-exports":"off"}}"#,
)
.expect("base config should be written");
let config_path = None;
let cache_root = root.join(".fallow");
let opts = AuditOptions {
root,
cache_dir: &cache_root,
config_path: &config_path,
output: OutputFormat::Json,
json_style: crate::json_style::JsonStyle::Compact,
no_cache: false,
threads: 1,
quiet: true,
allow_remote_extends: false,
changed_since: Some("HEAD"),
production: false,
production_dead_code: None,
production_health: None,
production_dupes: None,
workspace: None,
changed_workspaces: None,
explain: false,
explain_skipped: false,
performance: false,
group_by: None,
dead_code_baseline: None,
health_baseline: None,
dupes_baseline: None,
health_baseline_mode: fallow_engine::baseline::HealthBaselineMode::default(),
fail_on_stale_baseline: false,
max_crap: None,
coverage: None,
coverage_root: None,
gate: AuditGate::NewOnly,
include_entry_exports: false,
css: false,
css_deep: false,
runtime_coverage: None,
min_invocations_hot: 100,
brief: false,
max_decisions: 4,
walkthrough_guide: false,
walkthrough: false,
mark_viewed: &[],
show_cleared: false,
walkthrough_file: None,
show_deprioritized: false,
scope: None,
};
let first = config_file_fingerprint(&opts).expect("fingerprint should be computed");
fs::write(
root.join("base.json"),
r#"{"rules":{"unused-exports":"error"}}"#,
)
.expect("base config should be updated");
let second = config_file_fingerprint(&opts).expect("fingerprint should be recomputed");
assert_ne!(
first.resolved_hash, second.resolved_hash,
"extended config changes must invalidate cached base snapshots"
);
}
#[test]
fn audit_gate_all_skips_base_snapshot() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let root = tmp.path();
fs::create_dir_all(root.join("src")).expect("src dir should be created");
fs::write(
root.join("package.json"),
r#"{"name":"audit-gate-all","main":"src/index.ts"}"#,
)
.expect("package.json should be written");
fs::write(root.join("src/index.ts"), "export const legacy = 1;\n")
.expect("index should be written");
git(root, &["init", "-b", "main"]);
git(root, &["add", "."]);
git(
root,
&["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
);
let mut changed_module = "export const legacy = 1;\nexport const changed = 2;\n".to_string();
for _ in 0..500 {
changed_module.push_str("// expanded working-tree change\n");
}
fs::write(root.join("src/index.ts"), changed_module).expect("changed module should be written");
let config_path = None;
let cache_root = root.join(".fallow");
let opts = AuditOptions {
root,
cache_dir: &cache_root,
config_path: &config_path,
output: OutputFormat::Json,
json_style: crate::json_style::JsonStyle::Compact,
no_cache: true,
threads: 1,
quiet: true,
allow_remote_extends: false,
changed_since: Some("HEAD"),
production: false,
production_dead_code: None,
production_health: None,
production_dupes: None,
workspace: None,
changed_workspaces: None,
explain: false,
explain_skipped: false,
performance: false,
group_by: None,
dead_code_baseline: None,
health_baseline: None,
dupes_baseline: None,
health_baseline_mode: fallow_engine::baseline::HealthBaselineMode::default(),
fail_on_stale_baseline: false,
max_crap: None,
coverage: None,
coverage_root: None,
gate: AuditGate::All,
include_entry_exports: false,
css: false,
css_deep: false,
runtime_coverage: None,
min_invocations_hot: 100,
brief: true,
max_decisions: 4,
walkthrough_guide: false,
walkthrough: false,
mark_viewed: &[],
show_cleared: false,
walkthrough_file: None,
show_deprioritized: false,
scope: None,
};
let result = execute_audit(&opts).expect("audit should execute");
assert!(result.base_snapshot.is_none());
assert_eq!(result.attribution.gate, AuditGate::All);
assert_eq!(result.attribution.dead_code_introduced, 0);
assert_eq!(result.attribution.dead_code_inherited, 0);
let deltas = result.review_deltas.clone().unwrap_or_default();
assert!(deltas.boundary_introduced.is_empty());
assert!(deltas.cycle_introduced.is_empty());
assert!(deltas.public_api_added.is_empty());
let brief = crate::audit_brief::build_brief_output(&result);
assert_eq!(brief.graph_facts.exports_added, 0);
assert_eq!(brief.graph_facts.api_width_delta, 0);
assert!(brief.deltas.public_api_added.is_empty());
assert_eq!(brief.triage.hunks, Some(1));
assert!(brief.triage.net_lines.is_some_and(|lines| lines >= 500));
assert_eq!(brief.triage.risk_class, fallow_output::RiskClass::High);
assert!(brief.decisions.decisions.iter().all(|decision| {
decision.category != fallow_output::DecisionCategory::PublicApiContract
}));
}
#[test]
fn audit_gate_new_only_skips_base_snapshot_for_docs_only_diff() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let root = tmp.path();
fs::create_dir_all(root.join("src")).expect("src dir should be created");
fs::write(
root.join("package.json"),
r#"{"name":"audit-docs-only","main":"src/index.ts"}"#,
)
.expect("package.json should be written");
fs::write(
root.join(".fallowrc.json"),
r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
)
.expect("config should be written");
let duplicated = "export function same(input: number): number {\n const doubled = input * 2;\n const shifted = doubled + 1;\n return shifted;\n}\n";
fs::write(root.join("src/index.ts"), duplicated).expect("index should be written");
fs::write(root.join("src/copy.ts"), duplicated).expect("copy should be written");
fs::write(root.join("README.md"), "before\n").expect("readme should be written");
git(root, &["init", "-b", "main"]);
git(root, &["add", "."]);
git(
root,
&["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
);
fs::write(root.join("README.md"), "after\n").expect("readme should be modified");
fs::create_dir_all(root.join(".fallow/cache/dupes-tokens-v2"))
.expect("cache dir should be created");
fs::write(
root.join(".fallow/cache/dupes-tokens-v2/cache.bin"),
b"cache",
)
.expect("cache artifact should be written");
let before_worktrees = audit_worktree_names(root);
let config_path = None;
let cache_root = root.join(".fallow");
let opts = AuditOptions {
root,
cache_dir: &cache_root,
config_path: &config_path,
output: OutputFormat::Json,
json_style: crate::json_style::JsonStyle::Compact,
no_cache: true,
threads: 1,
quiet: true,
allow_remote_extends: false,
changed_since: Some("HEAD"),
production: false,
production_dead_code: None,
production_health: None,
production_dupes: None,
workspace: None,
changed_workspaces: None,
explain: false,
explain_skipped: false,
performance: true,
group_by: None,
dead_code_baseline: None,
health_baseline: None,
dupes_baseline: None,
health_baseline_mode: fallow_engine::baseline::HealthBaselineMode::default(),
fail_on_stale_baseline: false,
max_crap: None,
coverage: None,
coverage_root: None,
gate: AuditGate::NewOnly,
include_entry_exports: false,
css: false,
css_deep: false,
runtime_coverage: None,
min_invocations_hot: 100,
brief: false,
max_decisions: 4,
walkthrough_guide: false,
walkthrough: false,
mark_viewed: &[],
show_cleared: false,
walkthrough_file: None,
show_deprioritized: false,
scope: None,
};
let result = execute_audit(&opts).expect("audit should execute");
assert_eq!(result.verdict, AuditVerdict::Pass);
assert_eq!(result.changed_files_count, 2);
assert!(result.base_snapshot_skipped);
assert!(result.base_snapshot.is_some());
let after_worktrees = audit_worktree_names(root);
assert_eq!(
before_worktrees, after_worktrees,
"base snapshot skip must not create a temporary base worktree"
);
}
fn audit_worktree_names(repo_root: &Path) -> Vec<String> {
let mut names: Vec<String> = list_audit_worktrees(repo_root)
.unwrap_or_default()
.into_iter()
.filter_map(|path| {
path.file_name()
.and_then(|name| name.to_str())
.map(str::to_owned)
})
.collect();
names.sort();
names
}
#[test]
fn audit_reuses_dead_code_parse_for_health_when_production_matches() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let root = tmp.path();
fs::create_dir_all(root.join("src")).expect("src dir should be created");
fs::write(
root.join("package.json"),
r#"{"name":"audit-shared-parse","main":"src/index.ts"}"#,
)
.expect("package.json should be written");
fs::write(
root.join("src/index.ts"),
"import { used } from './used';\nused();\n",
)
.expect("index should be written");
fs::write(
root.join("src/used.ts"),
"export function used() {\n return 1;\n}\n",
)
.expect("used module should be written");
git(root, &["init", "-b", "main"]);
git(root, &["add", "."]);
git(
root,
&["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
);
fs::write(
root.join("src/used.ts"),
"export function used() {\n return 1;\n}\nexport function changed() {\n return 2;\n}\n",
)
.expect("changed module should be written");
let config_path = None;
let cache_root = root.join(".fallow");
let opts = AuditOptions {
root,
cache_dir: &cache_root,
config_path: &config_path,
output: OutputFormat::Json,
json_style: crate::json_style::JsonStyle::Compact,
no_cache: true,
threads: 1,
quiet: true,
allow_remote_extends: false,
changed_since: Some("HEAD"),
production: false,
production_dead_code: None,
production_health: None,
production_dupes: None,
workspace: None,
changed_workspaces: None,
explain: false,
explain_skipped: false,
performance: true,
group_by: None,
dead_code_baseline: None,
health_baseline: None,
dupes_baseline: None,
health_baseline_mode: fallow_engine::baseline::HealthBaselineMode::default(),
fail_on_stale_baseline: false,
max_crap: None,
coverage: None,
coverage_root: None,
gate: AuditGate::NewOnly,
include_entry_exports: false,
css: false,
css_deep: false,
runtime_coverage: None,
min_invocations_hot: 100,
brief: false,
max_decisions: 4,
walkthrough_guide: false,
walkthrough: false,
mark_viewed: &[],
show_cleared: false,
walkthrough_file: None,
show_deprioritized: false,
scope: None,
};
let result = execute_audit(&opts).expect("audit should execute");
let health = result.health.expect("health should run for changed files");
let timings = health.timings.expect("performance timings should be kept");
assert!(timings.discover_ms.abs() < f64::EPSILON);
assert!(timings.parse_ms.abs() < f64::EPSILON);
assert!(
result.dupes.is_some(),
"dupes should run when changed files exist"
);
}
/// Issue #2164: a clone-removal refactor may re-shape a clone group (here the
/// shared helper is extracted, leaving the surrounding scaffolding as a new,
/// smaller group with a new attribution key) without writing any duplicated
/// line. The new-only gate must classify that group as inherited, not
/// introduced.
#[test]
fn audit_new_only_does_not_gate_reshaped_clone_group_without_added_lines() {
let helper = "export function eq(xs: string[], ys: string[]): boolean {\n if (xs.length !== ys.length) {\n return false;\n }\n for (let index = 0; index < xs.length; index += 1) {\n if (xs[index] !== ys[index]) {\n return false;\n }\n }\n return true;\n}\n";
let scaffolding = "export function selfTest(): boolean {\n const alpha = ['alpha', 'beta', 'gamma'];\n const beta = ['alpha', 'beta', 'gamma'];\n const gamma = ['delta', 'epsilon', 'zeta'];\n const first = eq(alpha, beta);\n const second = eq(alpha, gamma);\n const third = eq(beta, gamma);\n const outcomes = [first, !second, !third];\n const labels = ['same', 'differs', 'differs'];\n const combined = outcomes.map((outcome, index) => `${labels[index]}:${outcome}`);\n return combined.length === outcomes.length && outcomes.every((outcome) => outcome === true);\n}\n";
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let root = &tmp
.path()
.canonicalize()
.expect("temp dir should canonicalize");
fs::create_dir_all(root.join("src")).expect("src dir should be created");
fs::write(
root.join("package.json"),
r#"{"name":"audit-dupes-reshape","main":"src/index.ts"}"#,
)
.expect("package.json should be written");
fs::write(
root.join("src/index.ts"),
"import { selfTest as a } from './a';\nimport { selfTest as b } from './b';\na();\nb();\n",
)
.expect("index should be written");
let base_module = format!("{helper}{scaffolding}");
fs::write(root.join("src/a.ts"), &base_module).expect("a should be written");
fs::write(root.join("src/b.ts"), &base_module).expect("b should be written");
git(root, &["init", "-b", "main"]);
git(root, &["add", "."]);
git(
root,
&["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
);
// The refactor: extract the identical helper to a shared lib. The
// scaffolding lines are untouched in both files.
let refactored = format!("import {{ eq }} from './lib';\n{scaffolding}");
fs::write(root.join("src/a.ts"), &refactored).expect("a should be rewritten");
fs::write(root.join("src/b.ts"), &refactored).expect("b should be rewritten");
fs::write(root.join("src/lib.ts"), helper).expect("lib should be written");
let config_path = None;
let cache_root = root.join(".fallow");
let opts = AuditOptions {
root,
cache_dir: &cache_root,
config_path: &config_path,
output: OutputFormat::Json,
json_style: crate::json_style::JsonStyle::Compact,
no_cache: true,
threads: 1,
quiet: true,
allow_remote_extends: false,
changed_since: Some("HEAD"),
production: false,
production_dead_code: None,
production_health: None,
production_dupes: None,
workspace: None,
changed_workspaces: None,
explain: false,
explain_skipped: false,
performance: false,
group_by: None,
dead_code_baseline: None,
health_baseline: None,
dupes_baseline: None,
health_baseline_mode: fallow_engine::baseline::HealthBaselineMode::default(),
fail_on_stale_baseline: false,
max_crap: None,
coverage: None,
coverage_root: None,
gate: AuditGate::NewOnly,
include_entry_exports: false,
css: false,
css_deep: false,
runtime_coverage: None,
min_invocations_hot: 100,
brief: false,
max_decisions: 4,
walkthrough_guide: false,
walkthrough: false,
mark_viewed: &[],
show_cleared: false,
walkthrough_file: None,
show_deprioritized: false,
scope: None,
};
let result = execute_audit(&opts).expect("audit should execute");
let dupes = result.dupes.as_ref().expect("dupes should run");
assert!(
!dupes.report.clone_groups.is_empty(),
"the scaffolding clone group should still be reported"
);
assert!(
!result.base_snapshot_skipped,
"base snapshot should be computed for attribution"
);
assert_eq!(
result.attribution.duplication_introduced, 0,
"a re-shaped clone group with no added lines must not gate as introduced"
);
assert!(
result.attribution.duplication_inherited >= 1,
"the pre-existing duplication should be reported as inherited"
);
assert_reshaped_demotion_observability(&result);
}
/// Issue #2220: each demotion diff-source state renders its user-facing
/// label. `Shared` carries the source label recorded by the shared diff
/// cache (`shared_diff_source_label()`) verbatim; the base ref only feeds
/// the worktree fallback.
#[test]
fn dupe_demotion_diff_source_labels_cover_every_state() {
assert_eq!(
DupeDemotionDiffSource::Shared("--diff-file pr.diff".to_string()).label("main"),
"--diff-file pr.diff",
);
assert_eq!(
DupeDemotionDiffSource::Worktree.label("origin/main"),
"merge-base worktree diff vs origin/main",
);
assert_eq!(
DupeDemotionDiffSource::Skipped.label("main"),
"skipped: no diff available",
);
}
/// Issue #2220 assertions for the re-shaped-clone fixture: the demotion is
/// recorded and surfaced on the wire, but stays observability-only.
fn assert_reshaped_demotion_observability(result: &AuditResult) {
let comparison = result.comparison.as_ref().expect("comparison retained");
assert!(
comparison.dupes.demoted_count() >= 1,
"the re-shaped group must be recorded as demoted"
);
assert!(
comparison.dupes.demoted_count() <= comparison.dupes.inherited_count(),
"demoted groups stay counted as inherited"
);
assert!(
matches!(
result.dupe_demotion_diff_source,
Some(DupeDemotionDiffSource::Worktree)
),
"without an opt-in shared diff the merge-base worktree diff decides"
);
assert_eq!(
result.verdict,
AuditVerdict::Pass,
"demotion observability must not change the verdict; attribution={:?}",
result.attribution,
);
assert_eq!(
crate::audit::print_audit_result(result, true, false),
std::process::ExitCode::SUCCESS,
"demotion observability must not change the exit code"
);
let json = super::output::build_audit_json_output(result).expect("audit json should build");
assert_eq!(json["attribution"]["duplication_demoted"], 1);
let groups = json["duplication"]["clone_groups"]
.as_array()
.expect("clone groups array");
let demoted: Vec<&serde_json::Value> = groups
.iter()
.filter(|group| group.get("demotion_reason").is_some())
.collect();
assert_eq!(demoted.len(), 1, "exactly one group carries the marker");
assert_eq!(demoted[0]["demotion_reason"], "no-added-lines");
assert_eq!(demoted[0]["introduced"], false);
}
/// Repo whose working tree pastes a whole module into a new file on top of a
/// committed base, with a duplication threshold so the pasted clone can fail
/// the gate.
fn pasted_clone_audit_repo() -> tempfile::TempDir {
let helper = "export function eq(xs: string[], ys: string[]): boolean {\n if (xs.length !== ys.length) {\n return false;\n }\n for (let index = 0; index < xs.length; index += 1) {\n if (xs[index] !== ys[index]) {\n return false;\n }\n }\n return true;\n}\n";
let scaffolding = "export function selfTest(): boolean {\n const alpha = ['alpha', 'beta', 'gamma'];\n const beta = ['alpha', 'beta', 'gamma'];\n const gamma = ['delta', 'epsilon', 'zeta'];\n const first = eq(alpha, beta);\n const second = eq(alpha, gamma);\n const third = eq(beta, gamma);\n const outcomes = [first, !second, !third];\n const labels = ['same', 'differs', 'differs'];\n const combined = outcomes.map((outcome, index) => `${labels[index]}:${outcome}`);\n return combined.length === outcomes.length && outcomes.every((outcome) => outcome === true);\n}\n";
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let root = &tmp
.path()
.canonicalize()
.expect("temp dir should canonicalize");
fs::create_dir_all(root.join("src")).expect("src dir should be created");
fs::write(
root.join("package.json"),
r#"{"name":"audit-dupes-paste","main":"src/index.ts"}"#,
)
.expect("package.json should be written");
fs::write(
root.join(".fallowrc.json"),
r#"{"duplicates":{"threshold":1.0}}"#,
)
.expect("config should be written");
fs::write(
root.join("src/index.ts"),
"import { selfTest as a } from './a';\na();\n",
)
.expect("index should be written");
let module = format!("{helper}{scaffolding}");
fs::write(root.join("src/a.ts"), &module).expect("a should be written");
git(root, &["init", "-b", "main"]);
git(root, &["add", "."]);
git(
root,
&["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
);
// The paste: copy the whole module into a new file. Every line of the new
// clone instance is an added line, so the group must keep gating.
fs::write(root.join("src/b.ts"), &module).expect("b should be written");
fs::write(
root.join("src/index.ts"),
"import { selfTest as a } from './a';\nimport { selfTest as b } from './b';\na();\nb();\n",
)
.expect("index should be rewritten");
tmp
}
/// Strict-side pin for the issue #2164 fix: the touched-instance guard must
/// only demote re-shaped groups. A genuinely pasted clone (whole module copied
/// into a new file) contains added lines, so it must still gate as introduced
/// and fail the new-only gate.
#[test]
fn audit_new_only_still_gates_pasted_clone_as_introduced() {
let tmp = pasted_clone_audit_repo();
let root_buf = tmp
.path()
.canonicalize()
.expect("temp root should canonicalize");
let root = root_buf.as_path();
let config_path = None;
let cache_root = root.join(".fallow");
let opts = AuditOptions {
root,
cache_dir: &cache_root,
config_path: &config_path,
output: OutputFormat::Json,
json_style: crate::json_style::JsonStyle::Compact,
no_cache: true,
threads: 1,
quiet: true,
allow_remote_extends: false,
changed_since: Some("HEAD"),
production: false,
production_dead_code: None,
production_health: None,
production_dupes: None,
workspace: None,
changed_workspaces: None,
explain: false,
explain_skipped: false,
performance: false,
group_by: None,
dead_code_baseline: None,
health_baseline: None,
dupes_baseline: None,
health_baseline_mode: fallow_engine::baseline::HealthBaselineMode::default(),
fail_on_stale_baseline: false,
max_crap: None,
coverage: None,
coverage_root: None,
gate: AuditGate::NewOnly,
include_entry_exports: false,
css: false,
css_deep: false,
runtime_coverage: None,
min_invocations_hot: 100,
brief: false,
max_decisions: 4,
walkthrough_guide: false,
walkthrough: false,
mark_viewed: &[],
show_cleared: false,
walkthrough_file: None,
show_deprioritized: false,
scope: None,
};
let result = execute_audit(&opts).expect("audit should execute");
let dupes = result.dupes.as_ref().expect("dupes should run");
assert!(
!dupes.report.clone_groups.is_empty(),
"the pasted clone group should be reported"
);
assert!(
!result.base_snapshot_skipped,
"base snapshot should be computed for attribution"
);
assert!(
result.attribution.duplication_introduced >= 1,
"a pasted clone contains added lines and must gate as introduced"
);
assert_eq!(
result.verdict,
AuditVerdict::Fail,
"introduced duplication above the threshold must fail the new-only gate"
);
// Issue #2220: no demotion happened, so nothing may carry the marker and
// the verdict/exit code stay exactly as pinned above.
let comparison = result.comparison.as_ref().expect("comparison retained");
assert_eq!(comparison.dupes.demoted_count(), 0);
assert_eq!(
crate::audit::print_audit_result(&result, true, false),
std::process::ExitCode::from(1),
"a genuinely pasted clone must keep failing with exit code 1"
);
let json = super::output::build_audit_json_output(&result).expect("audit json should build");
assert_eq!(json["attribution"]["duplication_demoted"], 0);
assert!(
json["duplication"]["clone_groups"]
.as_array()
.expect("clone groups array")
.iter()
.all(|group| group.get("demotion_reason").is_none()),
"non-demoted groups must not carry a demotion_reason key"
);
}
#[test]
fn audit_dupes_falls_back_to_own_discovery_when_health_off() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let root = tmp.path();
fs::create_dir_all(root.join("src")).expect("src dir should be created");
fs::write(
root.join("package.json"),
r#"{"name":"audit-dupes-fallback","main":"src/index.ts"}"#,
)
.expect("package.json should be written");
fs::write(
root.join("src/index.ts"),
"import { used } from './used';\nused();\n",
)
.expect("index should be written");
fs::write(
root.join("src/used.ts"),
"export function used() {\n return 1;\n}\n",
)
.expect("used module should be written");
git(root, &["init", "-b", "main"]);
git(root, &["add", "."]);
git(
root,
&["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
);
fs::write(
root.join("src/used.ts"),
"export function used() {\n return 1;\n}\nexport function changed() {\n return 2;\n}\n",
)
.expect("changed module should be written");
let config_path = None;
let cache_root = root.join(".fallow");
let opts = AuditOptions {
root,
cache_dir: &cache_root,
config_path: &config_path,
output: OutputFormat::Json,
json_style: crate::json_style::JsonStyle::Compact,
no_cache: true,
threads: 1,
quiet: true,
allow_remote_extends: false,
changed_since: Some("HEAD"),
production: false,
production_dead_code: Some(true),
production_health: Some(false),
production_dupes: Some(false),
workspace: None,
changed_workspaces: None,
explain: false,
explain_skipped: false,
performance: true,
group_by: None,
dead_code_baseline: None,
health_baseline: None,
dupes_baseline: None,
health_baseline_mode: fallow_engine::baseline::HealthBaselineMode::default(),
fail_on_stale_baseline: false,
max_crap: None,
coverage: None,
coverage_root: None,
gate: AuditGate::NewOnly,
include_entry_exports: false,
css: false,
css_deep: false,
runtime_coverage: None,
min_invocations_hot: 100,
brief: false,
max_decisions: 4,
walkthrough_guide: false,
walkthrough: false,
mark_viewed: &[],
show_cleared: false,
walkthrough_file: None,
show_deprioritized: false,
scope: None,
};
let result = execute_audit(&opts).expect("audit should execute");
assert!(result.dupes.is_some(), "dupes should still run");
}
#[cfg(unix)]
#[test]
fn remap_focus_files_does_not_canonicalize_through_symlinks() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let real = tmp.path().join("real");
let link = tmp.path().join("link");
fs::create_dir_all(&real).expect("real dir");
std::os::unix::fs::symlink(&real, &link).expect("symlink");
let canonical = link.canonicalize().expect("canonicalize symlink");
assert_ne!(link, canonical, "symlink should not equal its target");
let from_root = PathBuf::from("/repo");
let mut focus = FxHashSet::default();
focus.insert(from_root.join("src/foo.ts"));
let remapped = remap_focus_files(&focus, &from_root, &link)
.expect("remap should succeed for in-prefix files");
let expected = link.join("src/foo.ts");
assert!(
remapped.contains(&expected),
"remapped paths must keep the un-canonical to_root prefix; got {remapped:?}, expected entry {expected:?}"
);
}
#[test]
fn remap_focus_files_skips_paths_outside_from_root() {
let from_root = PathBuf::from("/repo/apps/web");
let to_root = PathBuf::from("/wt/apps/web");
let mut focus = FxHashSet::default();
focus.insert(PathBuf::from("/repo/apps/web/src/in.ts"));
focus.insert(PathBuf::from("/repo/services/api/src/out.ts"));
let remapped =
remap_focus_files(&focus, &from_root, &to_root).expect("partial map should succeed");
assert_eq!(remapped.len(), 1);
assert!(remapped.contains(&PathBuf::from("/wt/apps/web/src/in.ts")));
}
#[test]
fn remap_focus_files_returns_none_when_no_paths_map() {
let from_root = PathBuf::from("/repo/apps/web");
let to_root = PathBuf::from("/wt/apps/web");
let mut focus = FxHashSet::default();
focus.insert(PathBuf::from("/elsewhere/foo.ts"));
let remapped = remap_focus_files(&focus, &from_root, &to_root);
assert!(
remapped.is_none(),
"remap should return None when no paths can be mapped, falling caller back to full corpus"
);
}
/// The changed-file set is built from `git rev-parse --show-toplevel`, whose
/// spelling can differ from the caller's canonicalized root (Windows 8.3
/// components and drive-letter case; a symlinked root on unix). Those paths must
/// still map: an unmappable set leaves the base snapshot unfiltered, and
/// filtering it against the resulting empty set erased every base finding, so
/// each inherited head finding looked introduced under the new-only gate.
#[cfg(unix)]
#[test]
fn remap_focus_files_maps_paths_spelled_against_an_uncanonical_root() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let real = tmp.path().join("real");
let link = tmp.path().join("link");
fs::create_dir_all(real.join("src")).expect("real src dir");
fs::write(real.join("src/foo.ts"), "export const foo = 1;\n").expect("source file");
std::os::unix::fs::symlink(&real, &link).expect("symlink");
let mut focus = FxHashSet::default();
focus.insert(real.join("src/foo.ts"));
let to_root = PathBuf::from("/wt");
let remapped = remap_focus_files(&focus, &link, &to_root)
.expect("paths spelled against an uncanonical root must still map");
assert!(
remapped.contains(&to_root.join("src/foo.ts")),
"expected the focus path to map through the canonicalized root; got {remapped:?}"
);
}
#[test]
fn remap_cache_dir_moves_project_local_cache_to_base_worktree() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let current_root = tmp.path().join("repo");
let base_root = tmp.path().join("fallow-base");
let cache_dir = current_root.join(".cache").join("fallow");
let remapped = remap_cache_dir_for_base_worktree(¤t_root, &base_root, &cache_dir);
assert_eq!(remapped, base_root.join(".cache").join("fallow"));
}
#[test]
fn remap_cache_dir_keeps_external_absolute_cache_shared() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let current_root = tmp.path().join("repo");
let base_root = tmp.path().join("fallow-base");
let cache_dir = tmp.path().join("shared").join("fallow-cache");
let remapped = remap_cache_dir_for_base_worktree(¤t_root, &base_root, &cache_dir);
assert_eq!(remapped, cache_dir);
}
fn inherited_duplicate_audit_repo() -> tempfile::TempDir {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let root = tmp
.path()
.canonicalize()
.expect("temp root should canonicalize");
fs::create_dir_all(root.join("src")).expect("src dir should be created");
fs::write(
root.join("package.json"),
r#"{"name":"audit-newonly-inherit","main":"src/changed.ts"}"#,
)
.expect("package.json should be written");
fs::write(
root.join(".fallowrc.json"),
r#"{"duplicates":{"minTokens":10,"minLines":3,"mode":"strict"}}"#,
)
.expect("config should be written");
let dup_block = "export function processItems(input: number[]): number[] {\n const doubled = input.map((value) => value * 2);\n const filtered = doubled.filter((value) => value > 0);\n const summed = filtered.reduce((acc, value) => acc + value, 0);\n const shifted = summed + 10;\n const scaled = shifted * 3;\n const rounded = Math.round(scaled / 7);\n return [rounded, scaled, summed];\n}\n";
fs::write(root.join("src/changed.ts"), dup_block).expect("changed should be written");
fs::write(root.join("src/peer.ts"), dup_block).expect("peer should be written");
git(&root, &["init", "-b", "main"]);
git(&root, &["add", "."]);
git(
&root,
&["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
);
fs::write(
root.join("src/changed.ts"),
format!("{dup_block}// touched\n"),
)
.expect("changed file should be modified");
git(&root, &["add", "."]);
git(
&root,
&["-c", "commit.gpgsign=false", "commit", "-m", "touch"],
);
tmp
}
#[test]
fn audit_gate_new_only_inherits_pre_existing_duplicates_in_focused_files() {
let tmp = inherited_duplicate_audit_repo();
let root_buf = tmp
.path()
.canonicalize()
.expect("temp root should canonicalize");
let root = root_buf.as_path();
let config_path = None;
let cache_root = root.join(".fallow");
let opts = AuditOptions {
root,
cache_dir: &cache_root,
config_path: &config_path,
output: OutputFormat::Json,
json_style: crate::json_style::JsonStyle::Compact,
no_cache: true,
threads: 1,
quiet: true,
allow_remote_extends: false,
changed_since: Some("HEAD~1"),
production: false,
production_dead_code: None,
production_health: None,
production_dupes: None,
workspace: None,
changed_workspaces: None,
explain: false,
explain_skipped: false,
performance: false,
group_by: None,
dead_code_baseline: None,
health_baseline: None,
dupes_baseline: None,
health_baseline_mode: fallow_engine::baseline::HealthBaselineMode::default(),
fail_on_stale_baseline: false,
max_crap: None,
coverage: None,
coverage_root: None,
gate: AuditGate::NewOnly,
include_entry_exports: false,
css: false,
css_deep: false,
runtime_coverage: None,
min_invocations_hot: 100,
brief: false,
max_decisions: 4,
walkthrough_guide: false,
walkthrough: false,
mark_viewed: &[],
show_cleared: false,
walkthrough_file: None,
show_deprioritized: false,
scope: None,
};
let result = execute_audit(&opts).expect("audit should execute");
assert!(
result.base_snapshot_skipped,
"comment-only JS/TS diffs should reuse current keys as the base snapshot"
);
let dupes_report = &result.dupes.as_ref().expect("dupes should run").report;
assert!(
!dupes_report.clone_groups.is_empty(),
"current run should detect the pre-existing duplicate"
);
assert_eq!(
result.attribution.duplication_introduced, 0,
"pre-existing duplicate must not be classified as introduced; \
attribution = {:?}",
result.attribution
);
assert!(
result.attribution.duplication_inherited > 0,
"pre-existing duplicate must be classified as inherited; \
attribution = {:?}",
result.attribution
);
}
#[test]
#[expect(
clippy::too_many_lines,
reason = "test fixture; linear setup/assert, length is not a maintainability concern"
)]
fn audit_base_preserves_tsconfig_paths_when_extends_is_in_untracked_node_modules() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let root = tmp.path();
fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
.expect("node_modules config dir should be created");
fs::write(root.join(".gitignore"), "node_modules/\n").expect("gitignore should be written");
fs::write(
root.join("package.json"),
r#"{
"name": "audit-react-native-tsconfig-base",
"private": true,
"main": "src/App.tsx",
"dependencies": {
"react-native": "0.80.0"
}
}"#,
)
.expect("package.json should be written");
fs::write(
root.join("tsconfig.json"),
r#"{
"extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"]
}"#,
)
.expect("tsconfig should be written");
fs::write(
root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
)
.expect("react native tsconfig should be written");
fs::write(
root.join("src/App.tsx"),
r#"import { homeTitle } from "@/screens/Home";
export function App() {
return homeTitle;
}
"#,
)
.expect("app should be written");
fs::write(
root.join("src/screens/Home.ts"),
r#"export const homeTitle = "home";
"#,
)
.expect("home should be written");
git(root, &["init", "-b", "main"]);
git(root, &["add", "."]);
git(
root,
&["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
);
fs::write(
root.join("src/App.tsx"),
r#"import { homeTitle } from "@/screens/Home";
export function App() {
return homeTitle.toUpperCase();
}
"#,
)
.expect("app should be modified");
let config_path = None;
let cache_root = root.join(".fallow");
let opts = AuditOptions {
root,
cache_dir: &cache_root,
config_path: &config_path,
output: OutputFormat::Json,
json_style: crate::json_style::JsonStyle::Compact,
no_cache: true,
threads: 1,
quiet: true,
allow_remote_extends: false,
changed_since: Some("HEAD"),
production: false,
production_dead_code: None,
production_health: None,
production_dupes: None,
workspace: None,
changed_workspaces: None,
explain: false,
explain_skipped: false,
performance: false,
group_by: None,
dead_code_baseline: None,
health_baseline: None,
dupes_baseline: None,
health_baseline_mode: fallow_engine::baseline::HealthBaselineMode::default(),
fail_on_stale_baseline: false,
max_crap: None,
coverage: None,
coverage_root: None,
gate: AuditGate::NewOnly,
include_entry_exports: false,
css: false,
css_deep: false,
runtime_coverage: None,
min_invocations_hot: 100,
brief: false,
max_decisions: 4,
walkthrough_guide: false,
walkthrough: false,
mark_viewed: &[],
show_cleared: false,
walkthrough_file: None,
show_deprioritized: false,
scope: None,
};
let result = execute_audit(&opts).expect("audit should execute");
assert!(
!result.base_snapshot_skipped,
"source diffs should run a real base snapshot"
);
let base = result
.base_snapshot
.as_ref()
.expect("base snapshot should run");
assert!(
!base
.dead_code
.contains("unresolved-import:src/App.tsx:@/screens/Home"),
"base audit must keep local @/* tsconfig aliases when extends points into ignored node_modules: {:?}",
base.dead_code
);
assert!(
!base.dead_code.contains("unused-file:src/screens/Home.ts"),
"alias target should stay reachable in the base worktree: {:?}",
base.dead_code
);
let check = result.check.as_ref().expect("dead-code audit should run");
assert!(
check.results.unresolved_imports.is_empty(),
"HEAD audit should also resolve @/* aliases: {:?}",
check.results.unresolved_imports
);
}
#[test]
#[expect(
clippy::too_many_lines,
reason = "test fixture; linear setup/assert, length is not a maintainability concern"
)]
fn audit_base_preserves_subdirectory_root_resolution() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = tmp.path().join("repo");
let root = repo.join("apps/mobile");
fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
.expect("node_modules config dir should be created");
fs::write(repo.join(".gitignore"), "apps/mobile/node_modules/\n")
.expect("gitignore should be written");
fs::write(
root.join("package.json"),
r#"{
"name": "audit-subdir-react-native-tsconfig-base",
"private": true,
"main": "src/App.tsx",
"dependencies": {
"react-native": "0.80.0"
}
}"#,
)
.expect("package.json should be written");
fs::write(
root.join("tsconfig.json"),
r#"{
"extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"]
}"#,
)
.expect("tsconfig should be written");
fs::write(
root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
)
.expect("react native tsconfig should be written");
fs::write(
root.join("src/App.tsx"),
r#"import { homeTitle } from "@/screens/Home";
export function App() {
return homeTitle;
}
"#,
)
.expect("app should be written");
fs::write(
root.join("src/screens/Home.ts"),
r#"export const homeTitle = "home";
"#,
)
.expect("home should be written");
git(&repo, &["init", "-b", "main"]);
git(&repo, &["add", "."]);
git(
&repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
);
fs::write(
root.join("src/App.tsx"),
r#"import { homeTitle } from "@/screens/Home";
export function App() {
return homeTitle.toUpperCase();
}
"#,
)
.expect("app should be modified");
let config_path = None;
let cache_root = root.join(".fallow");
let opts = AuditOptions {
root: &root,
cache_dir: &cache_root,
config_path: &config_path,
output: OutputFormat::Json,
json_style: crate::json_style::JsonStyle::Compact,
no_cache: true,
threads: 1,
quiet: true,
allow_remote_extends: false,
changed_since: Some("HEAD"),
production: false,
production_dead_code: None,
production_health: None,
production_dupes: None,
workspace: None,
changed_workspaces: None,
explain: false,
explain_skipped: false,
performance: false,
group_by: None,
dead_code_baseline: None,
health_baseline: None,
dupes_baseline: None,
health_baseline_mode: fallow_engine::baseline::HealthBaselineMode::default(),
fail_on_stale_baseline: false,
max_crap: None,
coverage: None,
coverage_root: None,
gate: AuditGate::NewOnly,
include_entry_exports: false,
css: false,
css_deep: false,
runtime_coverage: None,
min_invocations_hot: 100,
brief: false,
max_decisions: 4,
walkthrough_guide: false,
walkthrough: false,
mark_viewed: &[],
show_cleared: false,
walkthrough_file: None,
show_deprioritized: false,
scope: None,
};
let result = execute_audit(&opts).expect("audit should execute");
assert!(
!result.base_snapshot_skipped,
"source diffs should run a real base snapshot"
);
let base = result
.base_snapshot
.as_ref()
.expect("base snapshot should run");
assert!(
!base
.dead_code
.contains("unresolved-import:src/App.tsx:@/screens/Home"),
"base audit should analyze from the app subdirectory, not the repo root: {:?}",
base.dead_code
);
assert!(
!base.dead_code.contains("unused-file:src/screens/Home.ts"),
"subdirectory base audit should keep alias targets reachable: {:?}",
base.dead_code
);
}
#[test]
fn audit_base_uses_new_explicit_config_without_hard_failure() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let root = tmp.path();
fs::create_dir_all(root.join("src")).expect("src dir should be created");
fs::write(
root.join("package.json"),
r#"{"name":"audit-new-config","main":"src/index.ts"}"#,
)
.expect("package.json should be written");
fs::write(root.join("src/index.ts"), "export const used = 1;\n")
.expect("index should be written");
git(root, &["init", "-b", "main"]);
git(root, &["add", "."]);
git(
root,
&["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
);
let explicit_config = root.join(".fallowrc.json");
fs::write(&explicit_config, r#"{"rules":{"unused-files":"error"}}"#)
.expect("new config should be written");
fs::write(root.join("src/index.ts"), "export const used = 2;\n")
.expect("index should be modified");
let config_path = Some(explicit_config);
let cache_root = root.join(".fallow");
let opts = AuditOptions {
root,
cache_dir: &cache_root,
config_path: &config_path,
output: OutputFormat::Json,
json_style: crate::json_style::JsonStyle::Compact,
no_cache: true,
threads: 1,
quiet: true,
allow_remote_extends: false,
changed_since: Some("HEAD"),
production: false,
production_dead_code: None,
production_health: None,
production_dupes: None,
workspace: None,
changed_workspaces: None,
explain: false,
explain_skipped: false,
performance: false,
group_by: None,
dead_code_baseline: None,
health_baseline: None,
dupes_baseline: None,
health_baseline_mode: fallow_engine::baseline::HealthBaselineMode::default(),
fail_on_stale_baseline: false,
max_crap: None,
coverage: None,
coverage_root: None,
gate: AuditGate::NewOnly,
include_entry_exports: false,
css: false,
css_deep: false,
runtime_coverage: None,
min_invocations_hot: 100,
brief: false,
max_decisions: 4,
walkthrough_guide: false,
walkthrough: false,
mark_viewed: &[],
show_cleared: false,
walkthrough_file: None,
show_deprioritized: false,
scope: None,
};
let result = execute_audit(&opts).expect("audit should execute with a new explicit config");
assert!(
result.base_snapshot.is_some(),
"base snapshot should use the current explicit config even when the base commit lacks it"
);
}
#[test]
fn audit_base_uses_current_discovered_config_for_attribution() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let root = tmp.path();
fs::create_dir_all(root.join("src")).expect("src dir should be created");
fs::write(
root.join("package.json"),
r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
)
.expect("package.json should be written");
fs::write(
root.join(".fallowrc.json"),
r#"{"rules":{"unused-dependencies":"off"}}"#,
)
.expect("base config should be written");
fs::write(root.join("src/index.ts"), "export const used = 1;\n")
.expect("index should be written");
git(root, &["init", "-b", "main"]);
git(root, &["add", "."]);
git(
root,
&["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
);
fs::write(
root.join(".fallowrc.json"),
r#"{"rules":{"unused-dependencies":"error"}}"#,
)
.expect("current config should be written");
fs::write(
root.join("package.json"),
r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
)
.expect("package.json should be touched");
let config_path = None;
let cache_root = root.join(".fallow");
let opts = AuditOptions {
root,
cache_dir: &cache_root,
config_path: &config_path,
output: OutputFormat::Json,
json_style: crate::json_style::JsonStyle::Compact,
no_cache: true,
threads: 1,
quiet: true,
allow_remote_extends: false,
changed_since: Some("HEAD"),
production: false,
production_dead_code: None,
production_health: None,
production_dupes: None,
workspace: None,
changed_workspaces: None,
explain: false,
explain_skipped: false,
performance: false,
group_by: None,
dead_code_baseline: None,
health_baseline: None,
dupes_baseline: None,
health_baseline_mode: fallow_engine::baseline::HealthBaselineMode::default(),
fail_on_stale_baseline: false,
max_crap: None,
coverage: None,
coverage_root: None,
gate: AuditGate::NewOnly,
include_entry_exports: false,
css: false,
css_deep: false,
runtime_coverage: None,
min_invocations_hot: 100,
brief: false,
max_decisions: 4,
walkthrough_guide: false,
walkthrough: false,
mark_viewed: &[],
show_cleared: false,
walkthrough_file: None,
show_deprioritized: false,
scope: None,
};
let result = execute_audit(&opts).expect("audit should execute");
assert_eq!(
result.attribution.dead_code_introduced, 0,
"enabling a rule should not make pre-existing changed-file findings look introduced: {:?}",
result.attribution
);
assert!(
result.attribution.dead_code_inherited > 0,
"pre-existing changed-file findings should be classified as inherited: {:?}",
result.attribution
);
}
#[test]
#[expect(
clippy::too_many_lines,
reason = "test fixture; linear setup/assert, length is not a maintainability concern"
)]
fn audit_base_current_config_attribution_survives_cache_hit() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let root = tmp.path();
fs::create_dir_all(root.join("src")).expect("src dir should be created");
fs::write(
root.join("package.json"),
r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
)
.expect("package.json should be written");
fs::write(
root.join(".fallowrc.json"),
r#"{"rules":{"unused-dependencies":"off"}}"#,
)
.expect("base config should be written");
fs::write(root.join("src/index.ts"), "export const used = 1;\n")
.expect("index should be written");
git(root, &["init", "-b", "main"]);
git(root, &["add", "."]);
git(
root,
&["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
);
fs::write(
root.join(".fallowrc.json"),
r#"{"rules":{"unused-dependencies":"error"}}"#,
)
.expect("current config should be written");
fs::write(
root.join("package.json"),
r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
)
.expect("package.json should be touched");
let cache_root = root.join(".fallow");
let opts = AuditOptions {
root,
cache_dir: &cache_root,
config_path: &None,
output: OutputFormat::Json,
json_style: crate::json_style::JsonStyle::Compact,
no_cache: false,
threads: 1,
quiet: true,
allow_remote_extends: false,
changed_since: Some("HEAD"),
production: false,
production_dead_code: None,
production_health: None,
production_dupes: None,
workspace: None,
changed_workspaces: None,
explain: false,
explain_skipped: false,
performance: false,
group_by: None,
dead_code_baseline: None,
health_baseline: None,
dupes_baseline: None,
health_baseline_mode: fallow_engine::baseline::HealthBaselineMode::default(),
fail_on_stale_baseline: false,
max_crap: None,
coverage: None,
coverage_root: None,
gate: AuditGate::NewOnly,
include_entry_exports: false,
css: false,
css_deep: false,
runtime_coverage: None,
min_invocations_hot: 100,
brief: false,
max_decisions: 4,
walkthrough_guide: false,
walkthrough: false,
mark_viewed: &[],
show_cleared: false,
walkthrough_file: None,
show_deprioritized: false,
scope: None,
};
let first = execute_audit(&opts).expect("first audit should execute");
assert_eq!(
first.attribution.dead_code_introduced, 0,
"first audit should classify pre-existing findings as inherited: {:?}",
first.attribution
);
let changed_files = fallow_engine::changed_files::get_changed_files(root, "HEAD")
.expect("changed files should resolve");
let key = audit_base_snapshot_cache_key(&opts, "HEAD", &changed_files)
.expect("cache key should compute")
.expect("cache key should exist");
assert!(
load_cached_base_snapshot(&opts, &key).is_some(),
"first audit should store a reusable base snapshot"
);
let second = execute_audit(&opts).expect("second audit should execute");
assert_eq!(
second.attribution.dead_code_introduced, 0,
"cache hit should keep current-config attribution stable: {:?}",
second.attribution
);
assert!(
second.attribution.dead_code_inherited > 0,
"cache hit should preserve inherited base findings: {:?}",
second.attribution
);
}
#[test]
fn audit_dupes_only_materializes_groups_touching_changed_files() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let root_path = tmp
.path()
.canonicalize()
.expect("temp root should canonicalize");
let root = root_path.as_path();
fs::create_dir_all(root.join("src")).expect("src dir should be created");
fs::write(
root.join("package.json"),
r#"{"name":"audit-dupes-focus","main":"src/changed.ts"}"#,
)
.expect("package.json should be written");
fs::write(
root.join(".fallowrc.json"),
r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
)
.expect("config should be written");
let focused_code = "export function focused(input: number): number {\n const doubled = input * 2;\n const shifted = doubled + 10;\n return shifted / 2;\n}\n";
let untouched_code = "export function untouched(input: string): string {\n const lowered = input.toLowerCase();\n const padded = lowered.padStart(10, \"x\");\n return padded.slice(0, 8);\n}\n";
fs::write(root.join("src/changed.ts"), focused_code).expect("changed should be written");
fs::write(root.join("src/focused-copy.ts"), focused_code)
.expect("focused copy should be written");
fs::write(root.join("src/untouched-a.ts"), untouched_code)
.expect("untouched a should be written");
fs::write(root.join("src/untouched-b.ts"), untouched_code)
.expect("untouched b should be written");
git(root, &["init", "-b", "main"]);
git(root, &["add", "."]);
git(
root,
&["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
);
fs::write(
root.join("src/changed.ts"),
format!("{focused_code}export const changedMarker = true;\n"),
)
.expect("changed file should be modified");
let config_path = None;
let cache_root = root.join(".fallow");
let opts = AuditOptions {
root,
cache_dir: &cache_root,
config_path: &config_path,
output: OutputFormat::Json,
json_style: crate::json_style::JsonStyle::Compact,
no_cache: true,
threads: 1,
quiet: true,
allow_remote_extends: false,
changed_since: Some("HEAD"),
production: false,
production_dead_code: None,
production_health: None,
production_dupes: None,
workspace: None,
changed_workspaces: None,
explain: false,
explain_skipped: false,
performance: false,
group_by: None,
dead_code_baseline: None,
health_baseline: None,
dupes_baseline: None,
health_baseline_mode: fallow_engine::baseline::HealthBaselineMode::default(),
fail_on_stale_baseline: false,
max_crap: None,
coverage: None,
coverage_root: None,
gate: AuditGate::All,
include_entry_exports: false,
css: false,
css_deep: false,
runtime_coverage: None,
min_invocations_hot: 100,
brief: false,
max_decisions: 4,
walkthrough_guide: false,
walkthrough: false,
mark_viewed: &[],
show_cleared: false,
walkthrough_file: None,
show_deprioritized: false,
scope: None,
};
let result = execute_audit(&opts).expect("audit should execute");
let dupes = result.dupes.expect("dupes should run");
let changed_path = root.join("src/changed.ts");
assert!(
!dupes.report.clone_groups.is_empty(),
"changed file should still match unchanged duplicate code"
);
assert!(dupes.report.clone_groups.iter().all(|group| {
group
.instances
.iter()
.any(|instance| instance.file == changed_path)
}));
}
// Unit tests for js_ts_tokens_equivalent, is_analysis_input, is_non_behavioral_doc.
#[test]
fn tokens_equivalent_whitespace_only() {
// Reformatting (indentation, blank lines) must not change token identity.
let a = "export const x = 1;\nexport const y = 2;\n";
let b = "export const x = 1;\n\n\nexport const y = 2;\n";
assert!(
js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
"whitespace-only change must be treated as equivalent"
);
}
#[test]
fn tokens_equivalent_comment_only_change() {
// Comments do not produce tokens; adding or removing a comment should be
// treated as equivalent by the tokenizer.
let a = "export const x = 1;\n";
let b = "// note\nexport const x = 1;\n";
assert!(
js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
"comment-only change must be treated as equivalent (comments emit no tokens)"
);
}
#[test]
fn tokens_equivalent_identifier_rename_is_not_equivalent() {
// Identifier carries its text payload; a rename must not be reusable.
let a = "export const a = 1;\n";
let b = "export const b = 1;\n";
assert!(
!js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
"identifier rename must be treated as non-equivalent"
);
}
#[test]
fn tokens_equivalent_string_literal_change_is_not_equivalent() {
// StringLiteral carries its text payload; a changed import path must not be reusable.
let a = r#"import x from "./a";"#;
let b = r#"import x from "./b";"#;
assert!(
!js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
"string-literal change must be treated as non-equivalent"
);
}
#[test]
fn tokens_equivalent_fallow_ignore_marker_forces_false() {
// The guard fires before tokenization; even identical content containing the
// marker must return false so suppression changes are never skipped.
let code = "// fallow-ignore-next-line unused-exports\nexport const x = 1;\n";
assert!(
!js_ts_tokens_equivalent(Path::new("a.ts"), code, code),
"fallow-ignore marker in either side must force false"
);
}
#[test]
fn tokens_equivalent_non_js_extension_is_false() {
// The extension check fires before tokenization; CSS content cannot be reused.
let a = ".foo { color: red; }\n";
let b = ".foo {\n color: red;\n}\n";
assert!(
!js_ts_tokens_equivalent(Path::new("styles.css"), a, b),
"non-JS/TS extension must always return false"
);
}
/// KNOWN SOUNDNESS GAP: `TokenKind::TemplateLiteral` carries no payload
/// (see `crates/engine/src/duplication_detector/token_types.rs`), so a change to the
/// content of a template literal is invisible to the tokenizer and is
/// treated as equivalent. This is safe for most template strings but
/// unsound for dynamic `import(\`...\`)` patterns where the quasi prefix
/// feeds module-resolution pattern edges. This test pins the current
/// behavior. A follow-up fix should give `TemplateLiteral` a payload to
/// close the gap.
#[test]
fn tokens_equivalent_template_literal_content_change_is_equivalent_known_gap() {
let a = "const p = import(`./pages/${x}`);\n";
let b = "const p = import(`./views/${x}`);\n";
// KNOWN GAP: changing the quasi string of a template literal is NOT
// detected as a behavioral change because TokenKind::TemplateLiteral
// has no payload. Expected: true (equivalent), which is incorrect for
// dynamic-import prefixes but documents the current reality.
assert!(
js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
"template-literal content change is CURRENTLY treated as equivalent (known gap)"
);
}
/// Companion to the template-literal gap test: a regex-literal content
/// change is also invisible to the tokenizer.
#[test]
fn tokens_equivalent_regex_literal_content_change_is_equivalent_known_gap() {
let a = "const re = /^foo/;\n";
let b = "const re = /^bar/;\n";
// KNOWN GAP: TokenKind::RegExpLiteral has no payload.
assert!(
js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
"regex-literal content change is CURRENTLY treated as equivalent (known gap)"
);
}
#[test]
fn analysis_input_and_doc_classification() {
// Analysis inputs: JS/TS variants and component formats are behavioral.
assert!(is_analysis_input(Path::new("src/app.ts")));
assert!(is_analysis_input(Path::new("src/app.tsx")));
assert!(is_analysis_input(Path::new("src/app.js")));
assert!(is_analysis_input(Path::new("src/app.jsx")));
assert!(is_analysis_input(Path::new("src/app.mts")));
assert!(is_analysis_input(Path::new("src/app.vue")));
assert!(is_analysis_input(Path::new("src/styles.css")));
// Non-analysis inputs.
assert!(!is_analysis_input(Path::new("README.md")));
assert!(!is_analysis_input(Path::new("package.json")));
assert!(!is_analysis_input(Path::new("image.png")));
// Non-behavioral docs.
assert!(is_non_behavioral_doc(Path::new("README.md")));
assert!(is_non_behavioral_doc(Path::new("CHANGELOG.txt")));
assert!(is_non_behavioral_doc(Path::new("docs/guide.rst")));
assert!(is_non_behavioral_doc(Path::new("docs/guide.adoc")));
// .json is neither an analysis input nor a non-behavioral doc, so the
// predicate treats it as behavioral (can_reuse returns false for it).
assert!(!is_analysis_input(Path::new("package.json")));
assert!(!is_non_behavioral_doc(Path::new("package.json")));
}
/// The weakening scan must never conflate a head-read FAILURE with removed
/// content: a file deleted at head genuinely scans against empty head content,
/// a net-new file (missing at base) scans against an empty base, and head
/// bytes that are not valid UTF-8 are read lossily instead of collapsing to an
/// empty string that fabricates an `it( removed` signal.
#[test]
fn weakening_scan_distinguishes_deleted_head_from_unreadable_content() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "weakening-head-reads");
let repo = dunce::canonicalize(&repo).expect("repo root should canonicalize");
let tests = "it('a', () => {});\nit('b', () => {});\n";
fs::write(repo.join("deleted.test.ts"), tests).expect("fixture write");
fs::write(repo.join("lossy.test.ts"), tests).expect("fixture write");
git(&repo, &["add", "."]);
git(
&repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "tests"],
);
fs::remove_file(repo.join("deleted.test.ts")).expect("fixture delete");
let mut lossy = tests.as_bytes().to_vec();
lossy.extend_from_slice(b"// ");
lossy.extend_from_slice(&[0xFF, 0xFE, b'\n']);
fs::write(repo.join("lossy.test.ts"), lossy).expect("fixture write");
fs::write(repo.join("added.test.ts"), "it('new', () => {});\n").expect("fixture write");
let changed: FxHashSet<PathBuf> = [
repo.join("deleted.test.ts"),
repo.join("lossy.test.ts"),
repo.join("added.test.ts"),
]
.into_iter()
.collect();
let signals = compute_weakening_signals(&repo, "HEAD", &changed);
assert!(
signals
.iter()
.any(|signal| signal.file == "deleted.test.ts"
&& signal.evidence.contains("it( removed")),
"a file deleted at head scans against empty head content: {signals:?}"
);
assert!(
!signals.iter().any(|signal| signal.file == "lossy.test.ts"),
"invalid UTF-8 head bytes are read lossily, never fabricated as removed tests: {signals:?}"
);
assert!(
!signals.iter().any(|signal| signal.file == "added.test.ts"),
"a net-new file scans against an empty base without fabricated signals: {signals:?}"
);
}
/// A head file that exists but cannot be read (permission denied) is skipped
/// entirely; scanning it as empty content would fabricate a removed-tests
/// signal for every test the file still contains.
#[cfg(unix)]
#[test]
fn weakening_scan_skips_permission_denied_head_file() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "weakening-unreadable-head");
let repo = dunce::canonicalize(&repo).expect("repo root should canonicalize");
fs::write(
repo.join("guarded.test.ts"),
"it('a', () => {});\nit('b', () => {});\n",
)
.expect("fixture write");
git(&repo, &["add", "."]);
git(
&repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "tests"],
);
fs::set_permissions(
repo.join("guarded.test.ts"),
fs::Permissions::from_mode(0o000),
)
.expect("permissions should be set");
let changed: FxHashSet<PathBuf> = std::iter::once(repo.join("guarded.test.ts")).collect();
let signals = compute_weakening_signals(&repo, "HEAD", &changed);
assert!(
signals.is_empty(),
"an unreadable head file is skipped, not scanned as removed content: {signals:?}"
);
}
/// [`BaseFileReader`] distinguishes a missing object (the file is new since
/// base) from a broken request pipe: a pipe failure must surface as
/// [`BaseRead::Error`], never as [`BaseRead::Missing`], so callers stop the
/// scan instead of treating every remaining file as empty at base.
#[test]
fn base_file_reader_distinguishes_missing_from_pipe_error() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let repo = init_throwaway_repo(tmp.path(), "base-read-modes");
let mut reader = BaseFileReader::spawn(&repo).expect("reader should spawn");
assert!(
matches!(
reader.read("HEAD", Path::new("README.md")),
BaseRead::Content(content) if content == "seed\n"
),
"a committed file reads back as content"
);
assert!(
matches!(
reader.read("HEAD", Path::new("absent.ts")),
BaseRead::Missing
),
"an object absent at base is Missing, not an error"
);
assert!(
matches!(reader.read("HEAD", Path::new("a\nb.ts")), BaseRead::Error),
"a newline path cannot be requested over the batch protocol"
);
reader.stdin.take();
assert!(
matches!(reader.read("HEAD", Path::new("README.md")), BaseRead::Error),
"a severed request pipe is an Error, never Missing or empty content"
);
}
fn branching_totals(
branch_points: u32,
functions: u32,
peak: u16,
) -> fallow_types::extract::FileBranching {
fallow_types::extract::FileBranching {
branch_points,
functions,
peak_cyclomatic: peak,
cognitive: branch_points,
cognitive_nesting_weight: 0,
has_module_unit: false,
has_synthetic_units: false,
}
}
fn snapshot_with_branching(
entries: &[(&str, fallow_types::extract::FileBranching)],
) -> AuditKeySnapshot {
AuditKeySnapshot {
branching: entries
.iter()
.map(|(path, totals)| ((*path).to_string(), *totals))
.collect(),
type_aware_identity: None,
type_aware_gap_signature: Vec::new(),
syntactic_dead_code: None,
dead_code: FxHashSet::default(),
health: FxHashSet::default(),
styling: FxHashSet::default(),
dupes: FxHashSet::default(),
boundary_edges: FxHashSet::default(),
cycles: FxHashSet::default(),
public_api: FxHashSet::default(),
}
}
#[test]
fn renaming_a_file_moves_its_branching_totals_onto_the_head_path() {
// Without this the base entry keeps the old path, the head entry looks
// like a new file, and a pure rename reads as branching arriving.
let root = std::path::Path::new("/repo");
let mut snapshot = snapshot_with_branching(&[("src/old.ts", branching_totals(11, 4, 6))]);
let renames = vec![fallow_engine::changed_files::RenamedFile {
from: root.join("src/old.ts"),
to: root.join("src/new.ts"),
}];
remap_base_snapshot_for_renames(&mut snapshot, &renames, root);
assert!(!snapshot.branching.contains_key("src/old.ts"));
assert_eq!(
snapshot.branching.get("src/new.ts").copied(),
Some(branching_totals(11, 4, 6))
);
}
#[test]
fn a_file_untouched_by_a_rename_keeps_its_branching_key() {
let root = std::path::Path::new("/repo");
let mut snapshot = snapshot_with_branching(&[
("src/old.ts", branching_totals(11, 4, 6)),
("src/other.ts", branching_totals(3, 2, 4)),
]);
let renames = vec![fallow_engine::changed_files::RenamedFile {
from: root.join("src/old.ts"),
to: root.join("src/new.ts"),
}];
remap_base_snapshot_for_renames(&mut snapshot, &renames, root);
assert_eq!(
snapshot.branching.get("src/other.ts").copied(),
Some(branching_totals(3, 2, 4))
);
assert_eq!(snapshot.branching.len(), 2);
}