use super::*;
use std::fs;
fn init_git_repo() -> tempfile::TempDir {
let repo = tempfile::TempDir::new().unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(repo.path())
.args(["init"])
.output()
.unwrap();
repo
}
#[test]
fn detect_changed_files_empty_repo_returns_none() {
let repo = init_git_repo();
let git = crate::git_util::resolve_git_binary();
if !git.is_file() {
return; }
let canonical = repo.path().canonicalize().unwrap();
let result = detect_changed_files(&canonical, &git, None).unwrap();
assert!(result.paths.is_empty());
assert!(result.budget_exceeded.is_none());
}
#[test]
fn detect_changed_files_measures_detection_time() {
let repo = init_git_repo();
let git = crate::git_util::resolve_git_binary();
if !git.is_file() {
return;
}
let canonical = repo.path().canonicalize().unwrap();
let result = detect_changed_files(&canonical, &git, None).unwrap();
assert!(
result.detect_elapsed_ms < 30_000,
"detect_elapsed_ms should be a real (small) measurement, got {}",
result.detect_elapsed_ms
);
let bounded = detect_changed_files(&canonical, &git, Some(0)).unwrap();
assert!(bounded.budget_exceeded.is_some());
assert!(bounded.detect_elapsed_ms < 30_000);
}
#[test]
fn detect_changed_files_finds_untracked_file() {
let repo = init_git_repo();
let git = crate::git_util::resolve_git_binary();
if !git.is_file() {
return;
}
fs::write(repo.path().join("hello.rs"), "fn hello() {}\n").unwrap();
let canonical = repo.path().canonicalize().unwrap();
let result = detect_changed_files(&canonical, &git, None).unwrap();
assert!(
result.paths.contains(std::path::Path::new("hello.rs")),
"untracked file should be detected, got: {:?}",
result.paths
);
}
#[test]
fn detect_changed_files_budget_exceeded_bails_early() {
let repo = init_git_repo();
let git = crate::git_util::resolve_git_binary();
if !git.is_file() {
return;
}
for i in 0..20 {
fs::write(repo.path().join(format!("file_{i}.rs")), "// original\n").unwrap();
}
std::process::Command::new(&git)
.arg("-C")
.arg(repo.path())
.args(["add", "-A"])
.output()
.unwrap();
std::process::Command::new(&git)
.arg("-C")
.arg(repo.path())
.args(["commit", "-m", "initial", "--no-gpg-sign"])
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test")
.output()
.unwrap();
for i in 0..20 {
fs::write(repo.path().join(format!("file_{i}.rs")), "// modified\n").unwrap();
}
let canonical = repo.path().canonicalize().unwrap();
let result = detect_changed_files(&canonical, &git, Some(0)).unwrap();
assert!(
result.budget_exceeded.is_some(),
"budget of 0ms should trigger BudgetExceeded"
);
assert!(
result.paths.is_empty(),
"budget=0 must perform no git work; got {:?}",
result.paths
);
}
#[test]
fn detect_changed_files_dedupes_path_reported_by_two_git_commands() {
let repo = init_git_repo();
let git = crate::git_util::resolve_git_binary();
if !git.is_file() {
return;
}
fs::write(repo.path().join("dup.rs"), "orig\n").unwrap();
std::process::Command::new(&git)
.arg("-C")
.arg(repo.path())
.args(["add", "-A"])
.output()
.unwrap();
std::process::Command::new(&git)
.arg("-C")
.arg(repo.path())
.args(["commit", "-m", "initial", "--no-gpg-sign"])
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test")
.output()
.unwrap();
let rm_status = std::process::Command::new(&git)
.arg("-C")
.arg(repo.path())
.args(["rm", "--cached", "-q", "dup.rs"])
.status()
.unwrap();
assert!(rm_status.success(), "git rm --cached must succeed");
let diff_head = std::process::Command::new(&git)
.arg("-C")
.arg(repo.path())
.args(["diff", "-z", "--name-only", "HEAD"])
.output()
.unwrap();
let ls_others = std::process::Command::new(&git)
.arg("-C")
.arg(repo.path())
.args(["ls-files", "-z", "--others", "--exclude-standard"])
.output()
.unwrap();
assert!(
!diff_head.stdout.is_empty(),
"premise check: `git diff HEAD` must report dup.rs"
);
assert!(
!ls_others.stdout.is_empty(),
"premise check: `git ls-files --others` must report dup.rs"
);
let canonical = repo.path().canonicalize().unwrap();
let result = detect_changed_files(&canonical, &git, None).unwrap();
assert_eq!(
result.paths.len(),
1,
"path reported by two git commands must collapse to one entry, got: {:?}",
result.paths
);
assert!(result.paths.contains(std::path::Path::new("dup.rs")));
let limits = UpdateLimits {
max_files: Some(1),
budget_ms: None,
};
assert!(
result.paths.len() <= limits.max_files.unwrap(),
"deduped change set must fit under max_files=1, got {} paths",
result.paths.len()
);
}
#[test]
fn parse_nul_paths_splits_correctly() {
let input = b"src/main.rs\0src/lib.rs\0tests/test.rs\0";
let paths = parse_nul_paths(input);
assert_eq!(paths.len(), 3);
assert_eq!(paths[0], PathBuf::from("src/main.rs"));
assert_eq!(paths[1], PathBuf::from("src/lib.rs"));
assert_eq!(paths[2], PathBuf::from("tests/test.rs"));
}
#[test]
fn parse_nul_paths_filters_unsafe_paths() {
let input = b"src/main.rs\0../../etc/passwd\0foo/bar.rs\0";
let paths = parse_nul_paths(input);
assert_eq!(paths.len(), 2);
assert_eq!(paths[0], PathBuf::from("src/main.rs"));
assert_eq!(paths[1], PathBuf::from("foo/bar.rs"));
}
#[test]
fn parse_nul_paths_handles_empty_input() {
let paths = parse_nul_paths(b"");
assert!(paths.is_empty());
}
#[test]
fn parse_nul_paths_handles_single_entry_no_trailing_nul() {
let paths = parse_nul_paths(b"only_file.rs");
assert_eq!(paths.len(), 1);
assert_eq!(paths[0], PathBuf::from("only_file.rs"));
}
#[test]
fn change_set_budget_exceeded_is_none_on_full_detection() {
let cs = ChangeSet {
paths: HashSet::new(),
budget_exceeded: None,
detect_elapsed_ms: 0,
};
assert!(cs.budget_exceeded.is_none());
}
#[test]
fn update_outcome_budget_exceeded_has_nonzero_estimate() {
let outcome = UpdateOutcome::BudgetExceeded {
files_behind_estimate: 5,
detect_elapsed_ms: 42,
};
match outcome {
UpdateOutcome::BudgetExceeded {
files_behind_estimate: n,
..
} => assert!(n > 0),
_ => panic!("expected BudgetExceeded"),
}
}
#[test]
fn update_outcome_detect_elapsed_ms_reads_every_variant() {
assert_eq!(
UpdateOutcome::Updated {
files: 1,
skipped: 0,
detect_elapsed_ms: 10,
}
.detect_elapsed_ms(),
10
);
assert_eq!(
UpdateOutcome::NoChanges {
detect_elapsed_ms: 11
}
.detect_elapsed_ms(),
11
);
assert_eq!(
UpdateOutcome::BudgetExceeded {
files_behind_estimate: 1,
detect_elapsed_ms: 12,
}
.detect_elapsed_ms(),
12
);
assert_eq!(
UpdateOutcome::TooManyFiles {
files_behind: 1,
detect_elapsed_ms: 13,
}
.detect_elapsed_ms(),
13
);
}
#[test]
fn fsmonitor_tip_not_printed_below_half_budget() {
let repo = init_git_repo();
let git = crate::git_util::resolve_git_binary();
if !git.is_file() {
return;
}
let canonical = repo.path().canonicalize().unwrap();
let index_dir = tempfile::TempDir::new().unwrap();
maybe_print_fsmonitor_tip(&canonical, &git, index_dir.path(), 40, 100);
assert!(
!index_dir.path().join(FSMONITOR_TIP_STAMP).exists(),
"stamp file must not be written below half the budget"
);
}
#[test]
fn fsmonitor_tip_zero_budget_never_fires() {
let repo = init_git_repo();
let git = crate::git_util::resolve_git_binary();
if !git.is_file() {
return;
}
let canonical = repo.path().canonicalize().unwrap();
let index_dir = tempfile::TempDir::new().unwrap();
maybe_print_fsmonitor_tip(&canonical, &git, index_dir.path(), 0, 0);
assert!(!index_dir.path().join(FSMONITOR_TIP_STAMP).exists());
}
#[test]
fn fsmonitor_tip_prints_once_and_stamps_when_fsmonitor_unset() {
let repo = init_git_repo();
let git = crate::git_util::resolve_git_binary();
if !git.is_file() {
return;
}
let canonical = repo.path().canonicalize().unwrap();
let index_dir = tempfile::TempDir::new().unwrap();
assert!(!is_fsmonitor_enabled(&canonical, &git));
maybe_print_fsmonitor_tip(&canonical, &git, index_dir.path(), 60, 100);
let stamp = index_dir.path().join(FSMONITOR_TIP_STAMP);
assert!(stamp.exists(), "stamp file must be written on first fire");
maybe_print_fsmonitor_tip(&canonical, &git, index_dir.path(), 60, 100);
assert!(stamp.exists());
}
#[test]
fn enable_fsmonitor_sets_config_and_is_then_detected() {
let repo = init_git_repo();
let git = crate::git_util::resolve_git_binary();
if !git.is_file() {
return;
}
let canonical = repo.path().canonicalize().unwrap();
assert!(!is_fsmonitor_enabled(&canonical, &git));
assert!(
enable_fsmonitor(&canonical, &git),
"git config should succeed"
);
assert!(is_fsmonitor_enabled(&canonical, &git));
let output = std::process::Command::new(&git)
.arg("-C")
.arg(&canonical)
.args(["config", "--get", "core.fsmonitor"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "true");
}
#[test]
fn enable_fsmonitor_returns_false_outside_git_repo() {
let git = crate::git_util::resolve_git_binary();
if !git.is_file() {
return;
}
let non_repo = tempfile::TempDir::new().unwrap();
let canonical = non_repo.path().canonicalize().unwrap();
assert!(!enable_fsmonitor(&canonical, &git));
}
#[test]
fn fsmonitor_tip_never_sets_fsmonitor_config() {
let repo = init_git_repo();
let git = crate::git_util::resolve_git_binary();
if !git.is_file() {
return;
}
let canonical = repo.path().canonicalize().unwrap();
let index_dir = tempfile::TempDir::new().unwrap();
assert!(!is_fsmonitor_enabled(&canonical, &git));
for _ in 0..5 {
maybe_print_fsmonitor_tip(&canonical, &git, index_dir.path(), 90, 100);
assert!(
!is_fsmonitor_enabled(&canonical, &git),
"the tip path must never set core.fsmonitor on its own"
);
}
let output = std::process::Command::new(&git)
.arg("-C")
.arg(repo.path())
.args(["config", "--get", "core.fsmonitor"])
.output()
.unwrap();
assert!(
!output.status.success(),
"core.fsmonitor must remain unset after repeated tip calls"
);
}
#[test]
fn fsmonitor_tip_never_fires_when_core_fsmonitor_already_true() {
let repo = init_git_repo();
let git = crate::git_util::resolve_git_binary();
if !git.is_file() {
return;
}
let canonical = repo.path().canonicalize().unwrap();
std::process::Command::new(&git)
.arg("-C")
.arg(repo.path())
.args(["config", "core.fsmonitor", "true"])
.output()
.unwrap();
assert!(is_fsmonitor_enabled(&canonical, &git));
let index_dir = tempfile::TempDir::new().unwrap();
maybe_print_fsmonitor_tip(&canonical, &git, index_dir.path(), 60, 100);
assert!(
!index_dir.path().join(FSMONITOR_TIP_STAMP).exists(),
"stamp file must not be written when core.fsmonitor is already true"
);
}
#[test]
fn detect_changed_files_drains_output_larger_than_pipe_buffer() {
let repo = init_git_repo();
let git = crate::git_util::resolve_git_binary();
if !git.is_file() {
return;
}
const N: usize = 2000;
for i in 0..N {
fs::write(
repo.path()
.join(format!("some_reasonably_long_source_file_name_{i:05}.rs")),
"// x\n",
)
.unwrap();
}
let canonical = repo.path().canonicalize().unwrap();
let result = detect_changed_files(&canonical, &git, Some(30_000)).unwrap();
assert_eq!(
result.paths.len(),
N,
"all untracked files must be detected once stdout is drained; \
budget_exceeded={:?}",
result.budget_exceeded
);
assert!(result.budget_exceeded.is_none());
}
#[cfg(unix)]
fn write_fake_git(dir: &std::path::Path, script: &str) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = dir.join("fake-git");
fs::write(&path, script).unwrap();
let mut perms = fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&path, perms).unwrap();
path
}
#[test]
#[cfg(unix)]
fn run_git_bounded_classifies_deadline_kill_as_partial() {
let dir = tempfile::TempDir::new().unwrap();
let fake = write_fake_git(
dir.path(),
"#!/bin/sh\nprintf 'seen.rs\\0'\nsleep 5\nprintf 'never.rs\\0'\n",
);
let deadline = Some(std::time::Instant::now() + std::time::Duration::from_millis(200));
match run_git_bounded(&fake, dir.path(), &["ignored"], deadline).unwrap() {
GitOutput::Partial(buf) => assert_eq!(buf, b"seen.rs\0"),
other => panic!("expected Partial, got {other:?}"),
}
}
#[test]
#[cfg(unix)]
fn detect_changed_files_reports_budget_exceeded_when_last_command_is_killed() {
let dir = tempfile::TempDir::new().unwrap();
let fake = write_fake_git(
dir.path(),
"#!/bin/sh\ncase \"$*\" in\n *ls-files*) printf 'untracked.rs\\0'; sleep 5 ;;\n *) exit 0 ;;\nesac\n",
);
let result = detect_changed_files(dir.path(), &fake, Some(500)).unwrap();
assert_eq!(
result.budget_exceeded,
Some(1),
"a kill on the final command must report exhaustion with the partial count"
);
assert!(result.paths.contains(std::path::Path::new("untracked.rs")));
}