#![allow(
clippy::expect_used,
reason = "integration tests use expect to keep fixture setup concise"
)]
use std::path::{Path, PathBuf};
use std::process::Command;
use fallow_engine::churn::{ChurnResult, ChurnWindowUnit, SinceDuration, analyze_churn_cached};
const WINDOW_DAYS: u64 = 90;
const SECS_PER_DAY: u64 = 86_400;
const BASE_EPOCH: u64 = 1_700_000_000;
fn window() -> SinceDuration {
SinceDuration::relative(WINDOW_DAYS, ChurnWindowUnit::Days, "90 days")
}
fn git(root: &Path, args: &[&str], epoch: Option<u64>) {
let mut command = Command::new("git");
command.current_dir(root).args(args);
if let Some(epoch) = epoch {
let stamp = format!("{epoch} +0000");
command
.env("GIT_AUTHOR_DATE", &stamp)
.env("GIT_COMMITTER_DATE", &stamp);
}
let status = command.status().expect("run git");
assert!(status.success(), "git {args:?} failed");
}
fn init_repo(root: &Path) {
git(root, &["init", "--quiet", "--initial-branch=main"], None);
git(root, &["config", "user.name", "Churn Fixture"], None);
git(root, &["config", "user.email", "fixture@example.com"], None);
git(root, &["config", "commit.gpgsign", "false"], None);
}
fn commit_file(root: &Path, path: &str, contents: &str, epoch: u64) {
let file = root.join(path);
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent).expect("create source directory");
}
std::fs::write(&file, contents).expect("write source file");
git(root, &["add", path], None);
git(root, &["commit", "--quiet", "-m", path], Some(epoch));
}
fn churn(root: &Path, cache_dir: &Path, no_cache: bool) -> (ChurnResult, bool) {
analyze_churn_cached(root, &window(), cache_dir, no_cache).expect("churn analysis")
}
fn changed_files(result: &ChurnResult, root: &Path) -> Vec<String> {
let mut paths: Vec<String> = result
.files
.keys()
.map(|path| {
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
})
.collect();
paths.sort();
paths
}
fn churn_rows(result: &ChurnResult, root: &Path) -> Vec<(String, u32, u64, u64)> {
let mut rows: Vec<(String, u32, u64, u64)> = result
.files
.iter()
.map(|(path, file)| {
(
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/"),
file.commits,
u64::from(file.lines_added),
u64::from(file.lines_deleted),
)
})
.collect();
rows.sort();
rows
}
struct Fixture {
_dir: tempfile::TempDir,
root: PathBuf,
cache: PathBuf,
}
impl Fixture {
fn new() -> Self {
let dir = tempfile::tempdir().expect("churn fixture tempdir");
let root = dir.path().canonicalize().expect("canonical fixture root");
let cache = root.join(".fallow-cache");
init_repo(&root);
Self {
_dir: dir,
root,
cache,
}
}
}
#[test]
fn warm_cache_drops_commits_the_window_no_longer_covers() {
let fixture = Fixture::new();
let root = &fixture.root;
commit_file(root, "src/old.ts", "export const old = 1;\n", BASE_EPOCH);
commit_file(
root,
"src/mid.ts",
"export const mid = 1;\n",
BASE_EPOCH + 10 * SECS_PER_DAY,
);
let (warm_seed, seeded_from_cache) = churn(root, &fixture.cache, false);
assert!(!seeded_from_cache, "first run must be a cold build");
assert_eq!(
changed_files(&warm_seed, root),
vec!["src/mid.ts".to_owned(), "src/old.ts".to_owned()],
"both commits are inside the window at the seeding commit"
);
commit_file(
root,
"src/new.ts",
"export const fresh = 1;\n",
BASE_EPOCH + 200 * SECS_PER_DAY,
);
let (warm, from_cache) = churn(root, &fixture.cache, false);
assert!(from_cache, "second run must reuse the seeded cache");
let cold_cache = root.join(".fallow-cold-cache");
let (cold, _) = churn(root, &cold_cache, true);
assert_eq!(
changed_files(&cold, root),
vec!["src/new.ts".to_owned()],
"a cold build only sees the commit inside the 90 day window"
);
assert_eq!(
churn_rows(&warm, root),
churn_rows(&cold, root),
"a warm churn cache must produce the same rows as a cold build"
);
assert_eq!(
warm.shallow_clone, cold.shallow_clone,
"shallow-clone detection must survive the cache path"
);
assert_eq!(
warm.clock.epoch_secs(),
cold.clock.epoch_secs(),
"both runs resolve the same clock from the same HEAD"
);
}
#[test]
fn cache_prune_and_git_after_agree_on_the_cutoff_second() {
let fixture = Fixture::new();
let root = &fixture.root;
let edge_epoch = BASE_EPOCH;
let head_epoch = edge_epoch + WINDOW_DAYS * SECS_PER_DAY;
commit_file(
root,
"src/before-edge.ts",
"export const before = 1;\n",
edge_epoch - 1,
);
commit_file(root, "src/edge.ts", "export const edge = 1;\n", edge_epoch);
commit_file(
root,
"src/seed.ts",
"export const seed = 1;\n",
edge_epoch + 10 * SECS_PER_DAY,
);
let (seeded, seeded_from_cache) = churn(root, &fixture.cache, false);
assert!(!seeded_from_cache, "first run must be a cold build");
assert!(
changed_files(&seeded, root).contains(&"src/before-edge.ts".to_owned()),
"both boundary commits must reach the cache"
);
commit_file(root, "src/head.ts", "export const head = 1;\n", head_epoch);
let (warm, from_cache) = churn(root, &fixture.cache, false);
assert!(
from_cache,
"advancing HEAD must extend the cache, not miss it"
);
let cold_cache = root.join(".fallow-cold-cache");
let (cold, _) = churn(root, &cold_cache, true);
assert_eq!(
changed_files(&cold, root),
vec![
"src/edge.ts".to_owned(),
"src/head.ts".to_owned(),
"src/seed.ts".to_owned(),
],
"the cutoff second itself is inside the window, the second before it is not"
);
assert_eq!(
churn_rows(&warm, root),
churn_rows(&cold, root),
"the cache prune must use the same boundary as git --after"
);
}