Skip to main content

fallow_engine/
churn.rs

1//! Git churn analysis for hotspot detection.
2//!
3//! Shells out to `git log` to collect per-file change history, then computes
4//! recency-weighted churn scores and trend indicators.
5
6use rustc_hash::FxHashMap;
7use std::io::Read as _;
8use std::path::{Component, Path, PathBuf};
9use std::process::{Command, Output};
10use std::sync::OnceLock;
11
12use serde::Deserialize;
13
14use crate::changed_files::git_path_from_bytes;
15
16pub use fallow_types::churn::ChurnTrend;
17
18/// Function pointer signature used by `set_spawn_hook` to intercept the
19/// `git log --numstat` subprocess. Lets the CLI route long-running git
20/// log calls through its `ScopedChild` registry so SIGINT / SIGTERM
21/// reap the subprocess instead of leaving it running after the parent
22/// exits. See `crates/cli/src/signal/` and issue #477.
23pub type ChurnSpawnHook = fn(&mut Command) -> std::io::Result<Output>;
24
25static SPAWN_HOOK: OnceLock<ChurnSpawnHook> = OnceLock::new();
26
27fn git_command(cwd: &Path) -> Command {
28    crate::git_env::git_command(cwd)
29}
30
31/// Install a spawn-hook that wraps the `git log` subprocess. Idempotent;
32/// subsequent calls are no-ops. Called once from the CLI's `main()` to
33/// route through the signal registry; defaults to `Command::output`
34/// when not set so the function-pointer indirection stays free for tests
35/// and embedders that don't care.
36pub fn set_spawn_hook(hook: ChurnSpawnHook) {
37    let _ = SPAWN_HOOK.set(hook);
38}
39
40fn spawn_output(command: &mut Command) -> std::io::Result<Output> {
41    if let Some(hook) = SPAWN_HOOK.get() {
42        hook(command)
43    } else {
44        command.output()
45    }
46}
47
48/// Number of seconds in one day.
49const SECS_PER_DAY: f64 = 86_400.0;
50
51/// Recency weight half-life in days. A commit from 90 days ago counts half
52/// as much as today's commit; 180 days ago counts 25%.
53const HALF_LIFE_DAYS: f64 = 90.0;
54
55/// Schema discriminator a `--churn-file` document must declare.
56const CHURN_FILE_SCHEMA: &str = "fallow-churn/v1";
57
58/// Upper bound on imported churn events. A file past this size is a sign of a
59/// pathological export (whole-history dump of a giant monorepo) rather than a
60/// useful hotspot window; parsing is rejected so we never allocate unbounded
61/// state from a single untrusted file. Mirrors the diff parser's
62/// `MAX_ADDED_LINES` guard in the CLI.
63const MAX_CHURN_EVENTS: usize = 5_000_000;
64
65/// Upper bound on the serialized churn import before JSON deserialization.
66const MAX_CHURN_FILE_BYTES: usize = 256 * 1024 * 1024;
67
68/// Reject an imported `timestamp` more than this many seconds in the future
69/// (one year). A unix-seconds commit time is never legitimately this far ahead
70/// even with clock skew, so a value past it is almost always a millisecond
71/// timestamp (~52000 years out) or corruption. Caught loudly because the
72/// recency decay uses `saturating_sub`, so a future timestamp would otherwise
73/// clamp to age 0, give every commit full weight, and silently collapse the
74/// recency signal that distinguishes recent from old churn.
75const MAX_FUTURE_TIMESTAMP_SECS: u64 = 365 * 24 * 60 * 60;
76
77/// Unit of a relative churn window.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ChurnWindowUnit {
80    /// Fixed 24-hour days.
81    Days,
82    /// Fixed 7-day weeks.
83    Weeks,
84    /// Calendar months in UTC.
85    Months,
86    /// Calendar years in UTC.
87    Years,
88}
89
90impl ChurnWindowUnit {
91    /// Single-letter token used in the churn cache key.
92    const fn token(self) -> char {
93        match self {
94            Self::Days => 'd',
95            Self::Weeks => 'w',
96            Self::Months => 'm',
97            Self::Years => 'y',
98        }
99    }
100}
101
102/// The span of history churn analysis covers.
103///
104/// A relative window is stored as its duration rather than as the wall-clock
105/// string git resolves, so the same token always keys the same cache entry
106/// while the cutoff it resolves to moves with the run clock.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum ChurnWindow {
109    /// A span measured back from the run clock.
110    Relative {
111        /// How many `unit`s of history to cover.
112        count: u64,
113        /// The unit `count` is measured in.
114        unit: ChurnWindowUnit,
115    },
116    /// An absolute ISO `YYYY-MM-DD` date, read as UTC midnight.
117    Date(String),
118    /// No window: imported churn covers whatever the exporter selected.
119    Imported,
120}
121
122impl ChurnWindow {
123    /// Stable cache key for this window. Absolute for a date, the duration
124    /// token (`"1y"`, `"90d"`) for a relative span.
125    #[must_use]
126    pub fn cache_token(&self) -> String {
127        match self {
128            Self::Relative { count, unit } => format!("{count}{}", unit.token()),
129            Self::Date(date) => date.clone(),
130            Self::Imported => String::new(),
131        }
132    }
133
134    /// The oldest commit timestamp this window includes, resolved against
135    /// `clock`. `None` for imported churn, which has no cutoff to apply.
136    #[must_use]
137    pub fn cutoff_secs(&self, clock: &crate::clock::AnalysisClock) -> Option<u64> {
138        match self {
139            Self::Relative { count, unit } => Some(match unit {
140                ChurnWindowUnit::Days => clock.minus_days(*count),
141                ChurnWindowUnit::Weeks => clock.minus_days(count.saturating_mul(7)),
142                ChurnWindowUnit::Months => clock.minus_months(*count),
143                ChurnWindowUnit::Years => clock.minus_years(*count),
144            }),
145            Self::Date(date) => crate::clock::utc_midnight_epoch(date),
146            Self::Imported => None,
147        }
148    }
149}
150
151/// Parsed `--since` window plus the label reports print for it.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct SinceDuration {
154    /// History span to analyze.
155    pub window: ChurnWindow,
156    /// Human-readable display string (e.g., `"6 months"`).
157    pub display: String,
158}
159
160impl SinceDuration {
161    /// A relative window of `count` `unit`s, labelled for report headers.
162    #[must_use]
163    pub fn relative(count: u64, unit: ChurnWindowUnit, display: impl Into<String>) -> Self {
164        Self {
165            window: ChurnWindow::Relative { count, unit },
166            display: display.into(),
167        }
168    }
169}
170
171/// Per-author commit aggregation for a single file.
172///
173/// Authors are interned via [`ChurnResult::author_pool`] indices to keep
174/// per-file maps small and the bitcode cache compact.
175#[derive(Debug, Clone, Copy, PartialEq)]
176pub struct AuthorContribution {
177    /// Total commits by this author touching this file in the analysis window.
178    pub commits: u32,
179    /// Recency-weighted commit sum (exponential decay, half-life 90 days).
180    pub weighted_commits: f64,
181    /// Earliest commit timestamp by this author (epoch seconds).
182    pub first_commit_ts: u64,
183    /// Latest commit timestamp by this author (epoch seconds).
184    pub last_commit_ts: u64,
185}
186
187/// Per-file churn data collected from git history.
188#[derive(Debug, Clone)]
189pub struct FileChurn {
190    /// Absolute file path.
191    pub path: PathBuf,
192    /// Total number of commits touching this file in the analysis window.
193    pub commits: u32,
194    /// Recency-weighted commit count (exponential decay, half-life 90 days).
195    pub weighted_commits: f64,
196    /// Total lines added across all commits.
197    pub lines_added: u32,
198    /// Total lines deleted across all commits.
199    pub lines_deleted: u32,
200    /// Churn trend: accelerating, stable, or cooling.
201    pub trend: ChurnTrend,
202    /// Per-author contributions keyed by interned author index.
203    /// Indices reference [`ChurnResult::author_pool`].
204    pub authors: FxHashMap<u32, AuthorContribution>,
205}
206
207/// Result of churn analysis.
208#[derive(Debug, Clone)]
209pub struct ChurnResult {
210    /// Per-file churn data, keyed by absolute path.
211    pub files: FxHashMap<PathBuf, FileChurn>,
212    /// Whether the repository is a shallow clone.
213    pub shallow_clone: bool,
214    /// Author email pool. Per-file [`AuthorContribution`] entries reference
215    /// authors by their index into this vector.
216    pub author_pool: Vec<String>,
217    /// The instant recency weighting and staleness were measured against.
218    /// Ownership and routing reuse it so every churn-derived number in one run
219    /// agrees on "now".
220    pub clock: crate::clock::AnalysisClock,
221    /// Bytes of `git log` output that this run read. Zero when the churn
222    /// cache or a churn file supplied all the history.
223    pub git_log_bytes: u64,
224    /// Every commit in the history window with the files it changed, sorted
225    /// by committer timestamp and then by id. The history window can be
226    /// wider than the window of [`files`](Self::files), see
227    /// [`analyze_churn_cached_with_history`]. Imported events without a
228    /// `commit` id are not in this list.
229    pub commits: Vec<ChurnCommit>,
230}
231
232/// One commit in the churn history window and the files it changed.
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct ChurnCommit {
235    /// Commit id (`%H`), or the opaque `commit` value of an imported event.
236    pub sha: String,
237    /// Committer timestamp (epoch seconds).
238    pub committed_at: u64,
239    /// Changed files, sorted by path.
240    pub files: Vec<ChurnCommitFile>,
241}
242
243/// One file that a [`ChurnCommit`] changed.
244#[derive(Debug, Clone, PartialEq, Eq)]
245pub struct ChurnCommitFile {
246    /// Absolute file path.
247    pub path: PathBuf,
248    /// Lines added plus lines deleted in this file in this commit.
249    pub lines_changed: u32,
250}
251
252/// Parse a `--since` value into a git-compatible duration.
253///
254/// Accepts:
255/// - Durations: `6m`, `6months`, `90d`, `90days`, `1y`, `1year`, `2w`, `2weeks`
256/// - ISO dates: `2025-06-01`
257///
258/// # Errors
259///
260/// Returns an error if the input is not a recognized duration format or ISO date,
261/// the numeric part is invalid, or the duration is zero.
262pub fn parse_since(input: &str) -> Result<SinceDuration, String> {
263    if is_iso_date(input) {
264        return Ok(SinceDuration {
265            window: ChurnWindow::Date(input.to_string()),
266            display: input.to_string(),
267        });
268    }
269
270    let (num_str, unit) = split_number_unit(input)?;
271    let num: u64 = num_str
272        .parse()
273        .map_err(|_| format!("invalid number in --since: {input}"))?;
274
275    if num == 0 {
276        return Err("--since duration must be greater than 0".to_string());
277    }
278
279    let (unit, label) = match unit {
280        "d" | "day" | "days" => (ChurnWindowUnit::Days, "day"),
281        "w" | "week" | "weeks" => (ChurnWindowUnit::Weeks, "week"),
282        "m" | "month" | "months" => (ChurnWindowUnit::Months, "month"),
283        "y" | "year" | "years" => (ChurnWindowUnit::Years, "year"),
284        _ => {
285            return Err(format!(
286                "unknown duration unit '{unit}' in --since. Use d/w/m/y (e.g., 6m, 90d, 1y)"
287            ));
288        }
289    };
290    let plural = if num == 1 { "" } else { "s" };
291    Ok(SinceDuration::relative(
292        num,
293        unit,
294        format!("{num} {label}{plural}"),
295    ))
296}
297
298/// Analyze git churn for files in the given root directory.
299///
300/// Returns `None` if git is not available or the directory is not a git repository.
301pub fn analyze_churn(root: &Path, since: &SinceDuration) -> Option<ChurnResult> {
302    let clock = crate::clock::AnalysisClock::for_repo(root);
303    let shallow = is_shallow_clone(root);
304    let state = analyze_churn_events(root, since, None, &clock)?;
305    Some(build_churn_result(state, shallow, clock))
306}
307
308/// A `fallow-churn/v1` import document: a normalized, VCS-agnostic stand-in for
309/// `git log --numstat` output. Unknown fields are ignored (no
310/// `deny_unknown_fields`) so wrappers may carry extra metadata.
311#[derive(Debug, Deserialize)]
312struct ChurnFileDoc {
313    schema: String,
314    #[serde(default)]
315    events: Vec<ChurnFileEvent>,
316}
317
318/// One per-(commit, file) change event, the natural shape of a `<vcs> log
319/// --numstat` row. The optional `commit` id groups events into commits, which
320/// change coupling and change dispersion need. An event without it still
321/// feeds per-file churn.
322#[derive(Debug, Deserialize)]
323struct ChurnFileEvent {
324    /// Opaque commit id. Events with the same id belong to one commit.
325    #[serde(default)]
326    commit: Option<String>,
327    /// Repo-root-relative, forward-slash path. Joined to `root`.
328    path: String,
329    /// Commit time, unix SECONDS UTC (not milliseconds).
330    timestamp: u64,
331    /// Opaque author identity (email recommended); absent contributes no
332    /// ownership signal. fallow does NOT apply mailmap to imported authors.
333    #[serde(default)]
334    author: Option<String>,
335    /// Lines added in this file in this commit.
336    added: u32,
337    /// Lines deleted in this file in this commit.
338    deleted: u32,
339}
340
341/// Build churn data from a normalized `fallow-churn/v1` JSON import instead of
342/// `git log`. Lets projects on a non-git VCS (Yandex Arc, Mercurial, Perforce)
343/// feed change history into hotspot / ownership / bus-factor analysis: a small
344/// wrapper translates the VCS log into the contract and fallow runs all the
345/// usual recency-weighting, trend, and ownership logic on the imported events.
346///
347/// `root` is the project root that relative event paths are joined to (matching
348/// how the git path joins numstat paths), so the churn keys line up with the
349/// analyzed files. Returns a human-readable error (the CLI maps it to exit code
350/// 2) on an oversized or missing file, malformed JSON, wrong `schema`, an
351/// invalid repo-relative event path, a far-future timestamp, line totals above
352/// `u32::MAX`, or an event count past `MAX_CHURN_EVENTS`. An empty `events`
353/// array is valid (no hotspots), not an error. Never runs `git`.
354pub(crate) fn analyze_churn_from_file(path: &Path, root: &Path) -> Result<ChurnResult, String> {
355    let raw = read_churn_file_with_limit(path, MAX_CHURN_FILE_BYTES)?;
356    let doc: ChurnFileDoc = serde_json::from_str(&raw)
357        .map_err(|e| format!("failed to parse churn file {}: {e}", path.display()))?;
358    if doc.schema != CHURN_FILE_SCHEMA {
359        return Err(format!(
360            "churn file {} declares schema \"{}\", expected \"{CHURN_FILE_SCHEMA}\"",
361            path.display(),
362            doc.schema
363        ));
364    }
365    if doc.events.len() > MAX_CHURN_EVENTS {
366        return Err(format!(
367            "churn file {} has {} events, exceeding the {MAX_CHURN_EVENTS} limit",
368            path.display(),
369            doc.events.len()
370        ));
371    }
372
373    let state = churn_event_state_from_doc(&doc, path, root)?;
374    Ok(build_churn_result(
375        state,
376        false,
377        crate::clock::AnalysisClock::for_repo(root),
378    ))
379}
380
381fn read_churn_file_with_limit(path: &Path, limit: usize) -> Result<String, String> {
382    let file = std::fs::File::open(path)
383        .map_err(|e| format!("failed to read churn file {}: {e}", path.display()))?;
384    let mut bytes = Vec::new();
385    file.take(limit as u64 + 1)
386        .read_to_end(&mut bytes)
387        .map_err(|e| format!("failed to read churn file {}: {e}", path.display()))?;
388    if bytes.len() > limit {
389        return Err(format!(
390            "churn file {} is at least {} bytes, exceeding the {limit} byte limit",
391            path.display(),
392            bytes.len()
393        ));
394    }
395    String::from_utf8(bytes)
396        .map_err(|e| format!("failed to read churn file {} as UTF-8: {e}", path.display()))
397}
398
399/// Validate and fold a parsed `fallow-churn/v1` document into event state.
400///
401/// Rejects invalid paths, far-future (likely millisecond) timestamps, and line
402/// totals outside the public `u32` contract. Interns authors into the pool
403/// exactly as the git-log path does.
404fn churn_event_state_from_doc(
405    doc: &ChurnFileDoc,
406    path: &Path,
407    root: &Path,
408) -> Result<ChurnEventState, String> {
409    let mut builder = ChurnFileImportBuilder::new(path, root, churn_file_future_limit());
410
411    for event in &doc.events {
412        builder.push_event(event)?;
413    }
414
415    Ok(builder.finish())
416}
417
418/// The ceiling above which an imported event timestamp is rejected as
419/// implausible (almost always seconds-versus-milliseconds confusion).
420///
421/// This deliberately reads the wall clock rather than the run's
422/// [`crate::clock::AnalysisClock`]. The limit gates *acceptance* of an import,
423/// never a scored value, so it cannot move `weighted_commits` or `stale_days`
424/// the way a wall-clock "now" in the scoring path would. Pinning it to the run
425/// clock would instead make the gate reject real data: the run clock is HEAD's
426/// committer timestamp, so analyzing an older checkout while importing churn
427/// that covers today would drop every recent event. Wall-clock drift here is
428/// also one-directional, since a timestamp accepted today stays accepted on
429/// every later run.
430fn churn_file_future_limit() -> u64 {
431    let now_secs = std::time::SystemTime::now()
432        .duration_since(std::time::UNIX_EPOCH)
433        .unwrap_or_default()
434        .as_secs();
435    now_secs.saturating_add(MAX_FUTURE_TIMESTAMP_SECS)
436}
437
438struct ChurnFileImportBuilder<'a> {
439    path: &'a Path,
440    root: &'a Path,
441    future_limit: u64,
442    files: FxHashMap<PathBuf, FileEvents>,
443    totals: FxHashMap<PathBuf, (u64, u64)>,
444    author_pool: Vec<String>,
445    author_index: FxHashMap<String, u32>,
446    commit_pool: Vec<String>,
447    commit_index: FxHashMap<String, u32>,
448}
449
450impl<'a> ChurnFileImportBuilder<'a> {
451    fn new(path: &'a Path, root: &'a Path, future_limit: u64) -> Self {
452        Self {
453            path,
454            root,
455            future_limit,
456            files: FxHashMap::default(),
457            totals: FxHashMap::default(),
458            author_pool: Vec::new(),
459            author_index: FxHashMap::default(),
460            commit_pool: Vec::new(),
461            commit_index: FxHashMap::default(),
462        }
463    }
464
465    fn push_event(&mut self, event: &ChurnFileEvent) -> Result<(), String> {
466        let rel = normalize_churn_event_path(self.path, &event.path)?;
467        validate_churn_event_timestamp(self.path, event.timestamp, self.future_limit, &rel)?;
468
469        let abs_path = self.root.join(&rel);
470        let totals = self.totals.entry(abs_path.clone()).or_default();
471        totals.0 += u64::from(event.added);
472        totals.1 += u64::from(event.deleted);
473        if totals.0 > u64::from(u32::MAX) || totals.1 > u64::from(u32::MAX) {
474            return Err(format!(
475                "churn file {} has line totals for \"{rel}\" exceeding the u32 limit \
476                 (added {}, deleted {})",
477                self.path.display(),
478                totals.0,
479                totals.1
480            ));
481        }
482        let author_idx = self.intern_author(event.author.as_deref());
483        let commit_idx = event
484            .commit
485            .as_deref()
486            .map(str::trim)
487            .filter(|commit| !commit.is_empty())
488            .map(|commit| intern_author(commit, &mut self.commit_pool, &mut self.commit_index));
489        self.files
490            .entry(abs_path)
491            .or_insert_with(|| FileEvents { events: Vec::new() })
492            .events
493            .push(CachedCommitEvent {
494                timestamp: event.timestamp,
495                committed_at: event.timestamp,
496                lines_added: event.added,
497                lines_deleted: event.deleted,
498                author_idx,
499                commit_idx,
500            });
501        Ok(())
502    }
503
504    fn intern_author(&mut self, author: Option<&str>) -> Option<u32> {
505        author
506            .map(str::trim)
507            .filter(|email| !email.is_empty())
508            .map(|email| intern_author(email, &mut self.author_pool, &mut self.author_index))
509    }
510
511    fn finish(self) -> ChurnEventState {
512        ChurnEventState {
513            files: self.files,
514            author_pool: self.author_pool,
515            commit_pool: self.commit_pool,
516            git_log_bytes: 0,
517        }
518    }
519}
520
521fn normalize_churn_event_path(path: &Path, event_path: &str) -> Result<String, String> {
522    let normalized = event_path.replace('\\', "/");
523    let rel = normalized.trim();
524    if rel.is_empty() {
525        return Err(format!(
526            "churn file {} has an event with an empty path",
527            path.display()
528        ));
529    }
530    let bytes = rel.as_bytes();
531    let has_drive_prefix = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':';
532    let has_empty_component = rel.split('/').any(str::is_empty);
533    let components_are_normal = Path::new(rel)
534        .components()
535        .all(|component| matches!(component, Component::Normal(_)));
536    if rel.starts_with('/') || has_drive_prefix || has_empty_component || !components_are_normal {
537        return Err(format!(
538            "churn file {} has an invalid repo-relative event path \"{event_path}\"",
539            path.display()
540        ));
541    }
542    Ok(rel.to_string())
543}
544
545fn validate_churn_event_timestamp(
546    path: &Path,
547    timestamp: u64,
548    future_limit: u64,
549    rel: &str,
550) -> Result<(), String> {
551    if timestamp <= future_limit {
552        return Ok(());
553    }
554
555    Err(format!(
556        "churn file {} has event timestamp {} for \"{rel}\" more than a year in the \
557         future; timestamps must be unix SECONDS (not milliseconds), UTC",
558        path.display(),
559        timestamp
560    ))
561}
562
563/// Check if the repository is a shallow clone.
564#[must_use]
565fn is_shallow_clone(root: &Path) -> bool {
566    let mut command = git_command(root);
567    command
568        .args(["rev-parse", "--is-shallow-repository"])
569        .current_dir(root);
570    command.output().is_ok_and(|o| {
571        String::from_utf8_lossy(&o.stdout)
572            .trim()
573            .eq_ignore_ascii_case("true")
574    })
575}
576
577/// Check if the directory is inside a git repository.
578#[must_use]
579pub fn is_git_repo(root: &Path) -> bool {
580    let mut command = git_command(root);
581    command.args(["rev-parse", "--git-dir"]).current_dir(root);
582    command.output().is_ok_and(|output| output.status.success())
583}
584
585/// Maximum size of a churn cache file (64 MB). The incremental cache stores
586/// per-commit events, so it needs more headroom than the old aggregate rows.
587const MAX_CHURN_CACHE_SIZE: usize = 64 * 1024 * 1024;
588
589/// Cache schema version. Bump when the on-disk shape of [`ChurnCache`]
590/// changes so older payloads are rejected on load. Version 5 stores paths in
591/// their platform-native byte representation instead of lossy UTF-8 strings.
592/// Version 6 keys on the window token instead of a wall-clock-resolved git
593/// date string and stores each event's committer timestamp so a warm load can
594/// prune to the same cutoff `git log --after` applies on a cold run.
595/// Version 7 scopes the log to the project root, so entries written from a
596/// subdirectory root with toplevel-relative paths are rejected.
597/// Version 8 stores the commit id of each event, so change coupling can
598/// rebuild the file set of each commit from the cache.
599const CHURN_CACHE_VERSION: u8 = 8;
600
601/// Serializable per-commit event for the disk cache.
602#[derive(Clone, bitcode::Encode, bitcode::Decode)]
603struct CachedCommitEvent {
604    timestamp: u64,
605    /// Committer timestamp. `git log --after` filters on this, while recency
606    /// weighting stays on the author timestamp, so a rebased commit lands in
607    /// the same window warm and cold without its age changing.
608    committed_at: u64,
609    lines_added: u32,
610    lines_deleted: u32,
611    author_idx: Option<u32>,
612    /// Index into the commit pool. `None` for an imported event without a
613    /// commit id.
614    commit_idx: Option<u32>,
615}
616
617/// Serializable per-file churn entry for the disk cache.
618#[derive(Clone, bitcode::Encode, bitcode::Decode)]
619struct CachedFileChurn {
620    path: Vec<u8>,
621    events: Vec<CachedCommitEvent>,
622}
623
624/// Cached churn data keyed by last indexed SHA and since string.
625#[derive(Clone, bitcode::Encode, bitcode::Decode)]
626struct ChurnCache {
627    /// Schema version; must equal [`CHURN_CACHE_VERSION`] to be accepted.
628    version: u8,
629    last_indexed_sha: String,
630    /// [`ChurnWindow::cache_token`] of the window this entry was built for.
631    window_token: String,
632    files: Vec<CachedFileChurn>,
633    shallow_clone: bool,
634    /// Author email pool referenced by [`CachedCommitEvent::author_idx`].
635    author_pool: Vec<String>,
636    /// Commit id pool referenced by [`CachedCommitEvent::commit_idx`].
637    commit_pool: Vec<String>,
638}
639
640/// Per-file commit events retained in memory while building or updating churn.
641struct FileEvents {
642    events: Vec<CachedCommitEvent>,
643}
644
645/// Event-level churn state. Unlike [`ChurnResult`], this preserves commit
646/// timestamps so a cache can merge new commits and recompute trend/recency.
647struct ChurnEventState {
648    files: FxHashMap<PathBuf, FileEvents>,
649    author_pool: Vec<String>,
650    /// Commit id pool referenced by [`CachedCommitEvent::commit_idx`].
651    commit_pool: Vec<String>,
652    /// Bytes of `git log` output that built this state in this run. Zero for
653    /// state restored from the churn cache or read from a churn file.
654    git_log_bytes: u64,
655}
656
657/// Get the full HEAD SHA for cache keying.
658fn get_head_sha(root: &Path) -> Option<String> {
659    crate::repo_refs::head_sha(root).ok().flatten()
660}
661
662/// Check whether `ancestor` is still reachable from `descendant`.
663fn is_ancestor(root: &Path, ancestor: &str, descendant: &str) -> bool {
664    let mut command = git_command(root);
665    command
666        .args(["merge-base", "--is-ancestor", ancestor, descendant])
667        .current_dir(root);
668    command.status().is_ok_and(|s| s.success())
669}
670
671/// Read the churn cache file. Returns `None` when the file is missing, too
672/// large, unreadable, or written by another cache version.
673fn read_churn_cache(cache_dir: &Path) -> Option<ChurnCache> {
674    let cache_file = cache_dir.join("churn.bin");
675    let data = std::fs::read(&cache_file).ok()?;
676    if data.len() > MAX_CHURN_CACHE_SIZE {
677        return None;
678    }
679    let cache: ChurnCache = bitcode::decode(&data).ok()?;
680    (cache.version == CHURN_CACHE_VERSION).then_some(cache)
681}
682
683/// Try to load churn data from disk cache. Returns `None` on cache miss
684/// or version mismatch.
685#[cfg(test)]
686fn load_churn_cache(cache_dir: &Path, window_token: &str) -> Option<ChurnCache> {
687    read_churn_cache(cache_dir).filter(|cache| cache.window_token == window_token)
688}
689
690/// True when `outer` includes every commit that `inner` includes at `clock`.
691fn window_covers(
692    outer: &ChurnWindow,
693    inner: &ChurnWindow,
694    clock: &crate::clock::AnalysisClock,
695) -> bool {
696    if outer == inner {
697        return true;
698    }
699    match (outer.cutoff_secs(clock), inner.cutoff_secs(clock)) {
700        (Some(outer_cutoff), Some(inner_cutoff)) => outer_cutoff <= inner_cutoff,
701        _ => false,
702    }
703}
704
705/// Load a cache entry whose window covers `window`, plus the window that the
706/// entry was built for.
707///
708/// One cache file serves several windows. Health reads a 12 month history
709/// while target churn reads 6 months. A narrower request prunes the wider
710/// entry to its own cutoff, which gives the same events as a cold
711/// `git log --after`, so the commands do not overwrite each other's entry.
712fn load_covering_churn_cache(
713    cache_dir: &Path,
714    window: &ChurnWindow,
715    clock: &crate::clock::AnalysisClock,
716) -> Option<(ChurnCache, SinceDuration)> {
717    let cache = read_churn_cache(cache_dir)?;
718    let cached = parse_since(&cache.window_token).ok()?;
719    window_covers(&cached.window, window, clock).then_some((cache, cached))
720}
721
722/// Save churn data to disk cache.
723fn save_churn_cache(
724    cache_dir: &Path,
725    last_indexed_sha: &str,
726    window_token: &str,
727    state: &ChurnEventState,
728    shallow_clone: bool,
729) {
730    let files: Vec<CachedFileChurn> = state
731        .files
732        .iter()
733        .map(|f| CachedFileChurn {
734            path: path_to_cache_bytes(f.0),
735            events: f.1.events.clone(),
736        })
737        .collect();
738    let cache = ChurnCache {
739        version: CHURN_CACHE_VERSION,
740        last_indexed_sha: last_indexed_sha.to_string(),
741        window_token: window_token.to_string(),
742        files,
743        shallow_clone,
744        author_pool: state.author_pool.clone(),
745        commit_pool: state.commit_pool.clone(),
746    };
747    let _ = std::fs::create_dir_all(cache_dir);
748    let data = bitcode::encode(&cache);
749    let tmp = cache_dir.join("churn.bin.tmp");
750    if std::fs::write(&tmp, data).is_ok() {
751        let _ = std::fs::rename(&tmp, cache_dir.join("churn.bin"));
752    }
753}
754
755/// Analyze churn with disk caching. Uses cached result when HEAD SHA and
756/// since duration match. If HEAD advanced from the cached SHA, runs an
757/// incremental `git log <cached>..HEAD --numstat` scan and merges it.
758///
759/// Returns `(ChurnResult, bool)` where the bool indicates whether reusable
760/// cache state was used.
761/// Returns `None` if git analysis fails.
762pub fn analyze_churn_cached(
763    root: &Path,
764    since: &SinceDuration,
765    cache_dir: &Path,
766    no_cache: bool,
767) -> Option<(ChurnResult, bool)> {
768    analyze_churn_cached_with_history(root, since, since, cache_dir, no_cache)
769}
770
771/// Analyze churn with disk caching over two windows.
772///
773/// [`ChurnResult::files`] covers `since`, which is the hotspot window.
774/// [`ChurnResult::commits`] covers `history`, which change coupling and
775/// change dispersion read. One `git log` pass over the wider window feeds
776/// both. When `since` is the wider window, it is used for both.
777///
778/// Returns `None` if git analysis fails.
779pub fn analyze_churn_cached_with_history(
780    root: &Path,
781    since: &SinceDuration,
782    history: &SinceDuration,
783    cache_dir: &Path,
784    no_cache: bool,
785) -> Option<(ChurnResult, bool)> {
786    let head_sha = get_head_sha(root)?;
787    let clock = crate::clock::AnalysisClock::for_repo(root);
788    let history = if window_covers(&history.window, &since.window, &clock) {
789        history
790    } else {
791        since
792    };
793    let files_cutoff = since.window.cutoff_secs(&clock);
794
795    if !no_cache
796        && let Some((state, shallow_clone)) =
797            try_reuse_churn_cache(root, history, cache_dir, &head_sha, &clock)
798    {
799        return Some((
800            build_windowed_churn_result(state, files_cutoff, shallow_clone, clock),
801            true,
802        ));
803    }
804
805    let (state, shallow_clone) =
806        analyze_fresh_churn(root, history, cache_dir, no_cache, &head_sha, &clock)?;
807    Some((
808        build_windowed_churn_result(state, files_cutoff, shallow_clone, clock),
809        false,
810    ))
811}
812
813fn try_reuse_churn_cache(
814    root: &Path,
815    since: &SinceDuration,
816    cache_dir: &Path,
817    head_sha: &str,
818    clock: &crate::clock::AnalysisClock,
819) -> Option<(ChurnEventState, bool)> {
820    let (cache, cached_since) = load_covering_churn_cache(cache_dir, &since.window, clock)?;
821    let cutoff = since.window.cutoff_secs(clock);
822    if cache.last_indexed_sha == head_sha {
823        let shallow_clone = cache.shallow_clone;
824        return Some((cache.into_event_state(cutoff), shallow_clone));
825    }
826
827    if !is_ancestor(root, &cache.last_indexed_sha, head_sha) {
828        return None;
829    }
830
831    // Extend the entry over its own window, so a narrower request does not
832    // shrink the entry that a wider request saved.
833    let (mut state, shallow_clone) =
834        extend_churn_cache(root, &cached_since, cache_dir, head_sha, cache, clock)?;
835    state.retain_window(cutoff);
836    Some((state, shallow_clone))
837}
838
839fn extend_churn_cache(
840    root: &Path,
841    since: &SinceDuration,
842    cache_dir: &Path,
843    head_sha: &str,
844    cache: ChurnCache,
845    clock: &crate::clock::AnalysisClock,
846) -> Option<(ChurnEventState, bool)> {
847    let shallow_clone = is_shallow_clone(root);
848    let range = format!("{}..HEAD", cache.last_indexed_sha);
849    let delta = analyze_churn_events(root, since, Some(&range), clock)?;
850    let mut state = cache.into_event_state(since.window.cutoff_secs(clock));
851    merge_churn_states(&mut state, delta);
852    save_churn_cache(
853        cache_dir,
854        head_sha,
855        &since.window.cache_token(),
856        &state,
857        shallow_clone,
858    );
859    Some((state, shallow_clone))
860}
861
862fn analyze_fresh_churn(
863    root: &Path,
864    since: &SinceDuration,
865    cache_dir: &Path,
866    no_cache: bool,
867    head_sha: &str,
868    clock: &crate::clock::AnalysisClock,
869) -> Option<(ChurnEventState, bool)> {
870    let shallow_clone = is_shallow_clone(root);
871    let state = analyze_churn_events(root, since, None, clock)?;
872    if !no_cache {
873        save_churn_cache(
874            cache_dir,
875            head_sha,
876            &since.window.cache_token(),
877            &state,
878            shallow_clone,
879        );
880    }
881
882    Some((state, shallow_clone))
883}
884
885impl ChurnCache {
886    /// Rehydrate the cached events, dropping everything the current window no
887    /// longer covers.
888    ///
889    /// The cache only ever appends, so an entry minted months ago still holds
890    /// commits a cold `git log --after` would exclude today. Without this prune
891    /// a warm run reports more history than a cold run over the same commit,
892    /// which is exactly the cache-transparency invariant fallow asserts
893    /// elsewhere.
894    fn into_event_state(self, cutoff_secs: Option<u64>) -> ChurnEventState {
895        let files = self
896            .files
897            .into_iter()
898            .filter_map(|entry| {
899                let path = path_from_cache_bytes(&entry.path)?;
900                Some((
901                    path,
902                    FileEvents {
903                        events: entry.events,
904                    },
905                ))
906            })
907            .collect();
908        let mut state = ChurnEventState {
909            files,
910            author_pool: self.author_pool,
911            commit_pool: self.commit_pool,
912            git_log_bytes: 0,
913        };
914        state.retain_window(cutoff_secs);
915        state
916    }
917}
918
919impl ChurnEventState {
920    /// Drop every event older than `cutoff_secs` (committer time), and every
921    /// file that has no event left. `None` keeps everything.
922    fn retain_window(&mut self, cutoff_secs: Option<u64>) {
923        let Some(cutoff) = cutoff_secs else {
924            return;
925        };
926        self.files.retain(|_, file| {
927            file.events.retain(|event| event.committed_at >= cutoff);
928            !file.events.is_empty()
929        });
930    }
931}
932
933#[cfg(unix)]
934fn path_to_cache_bytes(path: &Path) -> Vec<u8> {
935    use std::os::unix::ffi::OsStrExt;
936
937    path.as_os_str().as_bytes().to_vec()
938}
939
940#[cfg(unix)]
941#[allow(
942    clippy::unnecessary_wraps,
943    reason = "Windows rejects truncated UTF-16 bytes through this shared fallible contract"
944)]
945fn path_from_cache_bytes(path: &[u8]) -> Option<PathBuf> {
946    use std::ffi::OsStr;
947    use std::os::unix::ffi::OsStrExt;
948
949    Some(PathBuf::from(OsStr::from_bytes(path)))
950}
951
952#[cfg(windows)]
953fn path_to_cache_bytes(path: &Path) -> Vec<u8> {
954    use std::os::windows::ffi::OsStrExt;
955
956    path.as_os_str()
957        .encode_wide()
958        .flat_map(u16::to_le_bytes)
959        .collect()
960}
961
962#[cfg(windows)]
963fn path_from_cache_bytes(path: &[u8]) -> Option<PathBuf> {
964    use std::ffi::OsString;
965    use std::os::windows::ffi::OsStringExt;
966
967    let (chunks, remainder) = path.as_chunks::<2>();
968    if !remainder.is_empty() {
969        return None;
970    }
971    let wide: Vec<u16> = chunks
972        .iter()
973        .map(|&pair| u16::from_le_bytes(pair))
974        .collect();
975    Some(PathBuf::from(OsString::from_wide(&wide)))
976}
977
978/// Run `git log --numstat` and return event-level churn state.
979///
980/// The window is passed as an absolute `--after=@<epoch>` resolved against the
981/// run clock rather than as a phrase git re-resolves against the wall clock, so
982/// two runs over one commit see the same window boundary.
983fn analyze_churn_events(
984    root: &Path,
985    since: &SinceDuration,
986    revision_range: Option<&str>,
987    clock: &crate::clock::AnalysisClock,
988) -> Option<ChurnEventState> {
989    let mut command = git_command(root);
990    command.arg("log");
991    if let Some(range) = revision_range {
992        command.arg(range);
993    }
994    command
995        .args([
996            "--numstat",
997            "--no-merges",
998            "--no-renames",
999            "--use-mailmap",
1000            "-z",
1001            "--format=format:%at|%ct|%H|%ae%x00",
1002        ])
1003        .current_dir(root);
1004    if let Some(cutoff) = since.window.cutoff_secs(clock) {
1005        command.arg(format!("--after=@{cutoff}"));
1006    }
1007    // numstat paths are relative to the git toplevel. When the project root
1008    // is a subdirectory, `--relative` rewrites them relative to the root, and
1009    // the `.` pathspec skips commits and files outside it. Both are no-ops at
1010    // the toplevel.
1011    command.args(["--relative", "--", "."]);
1012
1013    let output = match spawn_output(&mut command) {
1014        Ok(o) => o,
1015        Err(e) => {
1016            tracing::warn!("hotspot analysis skipped: failed to run git: {e}");
1017            return None;
1018        }
1019    };
1020
1021    if !output.status.success() {
1022        let stderr = String::from_utf8_lossy(&output.stderr);
1023        tracing::warn!("hotspot analysis skipped: git log failed: {stderr}");
1024        return None;
1025    }
1026
1027    let mut state = parse_git_log_events_z(&output.stdout, root, clock.epoch_secs());
1028    state.git_log_bytes = output.stdout.len() as u64;
1029    Some(state)
1030}
1031
1032/// Intern every value of `delta` into `base` and map each old `delta` index to
1033/// its index in `base`.
1034fn remap_pool(base: &mut Vec<String>, delta: Vec<String>) -> FxHashMap<u32, u32> {
1035    let mut base_index: FxHashMap<String, u32> = base
1036        .iter()
1037        .enumerate()
1038        .filter_map(|(idx, value)| u32::try_from(idx).ok().map(|idx| (value.clone(), idx)))
1039        .collect();
1040    let mut mapping: FxHashMap<u32, u32> = FxHashMap::default();
1041    for (old_idx, value) in delta.into_iter().enumerate() {
1042        let Ok(old_idx) = u32::try_from(old_idx) else {
1043            continue;
1044        };
1045        mapping.insert(old_idx, intern_author(&value, base, &mut base_index));
1046    }
1047    mapping
1048}
1049
1050/// Merge new churn events into cached event state.
1051fn merge_churn_states(base: &mut ChurnEventState, delta: ChurnEventState) {
1052    base.git_log_bytes += delta.git_log_bytes;
1053    let author_mapping = remap_pool(&mut base.author_pool, delta.author_pool);
1054    let commit_mapping = remap_pool(&mut base.commit_pool, delta.commit_pool);
1055
1056    for (path, mut file) in delta.files {
1057        for event in &mut file.events {
1058            event.author_idx = event
1059                .author_idx
1060                .and_then(|idx| author_mapping.get(&idx).copied());
1061            event.commit_idx = event
1062                .commit_idx
1063                .and_then(|idx| commit_mapping.get(&idx).copied());
1064        }
1065        base.files
1066            .entry(path)
1067            .and_modify(|existing| existing.events.append(&mut file.events))
1068            .or_insert(file);
1069    }
1070}
1071
1072/// `now_secs` is the run clock's epoch, not the wall clock: it is the fallback
1073/// timestamp for a numstat record that arrives before any commit header, so
1074/// truncated or malformed git output still scores against the pinned instant.
1075fn parse_git_log_events_z(stdout: &[u8], root: &Path, now_secs: u64) -> ChurnEventState {
1076    let mut parser = GitLogEventParser::new(root, now_secs);
1077    for record in stdout.split(|byte| *byte == 0) {
1078        let record = record.strip_prefix(b"\n").unwrap_or(record);
1079        if record.is_empty() {
1080            continue;
1081        }
1082        if record.contains(&b'\t') {
1083            parser.record_numstat_bytes(record);
1084        } else {
1085            parser.consume_header(&String::from_utf8_lossy(record));
1086        }
1087    }
1088    parser.finish()
1089}
1090
1091struct GitLogEventParser<'a> {
1092    root: &'a Path,
1093    now_secs: u64,
1094    files: FxHashMap<PathBuf, FileEvents>,
1095    author_pool: Vec<String>,
1096    author_index: FxHashMap<String, u32>,
1097    commit_pool: Vec<String>,
1098    commit_index: FxHashMap<String, u32>,
1099    current_timestamp: Option<u64>,
1100    current_committed_at: Option<u64>,
1101    current_author_idx: Option<u32>,
1102    current_commit_idx: Option<u32>,
1103}
1104
1105impl<'a> GitLogEventParser<'a> {
1106    fn new(root: &'a Path, now_secs: u64) -> Self {
1107        Self {
1108            root,
1109            now_secs,
1110            files: FxHashMap::default(),
1111            author_pool: Vec::new(),
1112            author_index: FxHashMap::default(),
1113            commit_pool: Vec::new(),
1114            commit_index: FxHashMap::default(),
1115            current_timestamp: None,
1116            current_committed_at: None,
1117            current_author_idx: None,
1118            current_commit_idx: None,
1119        }
1120    }
1121
1122    /// Numstat rows always hold a tab, so a tab-free record is a commit header,
1123    /// a legacy bare timestamp, or noise.
1124    fn consume_header(&mut self, line: &str) {
1125        let line = line.trim();
1126        if line.is_empty() || self.record_commit_header(line) {
1127            return;
1128        }
1129        self.record_legacy_timestamp(line);
1130    }
1131
1132    /// Parse a `%at|%ct|%H|%ae` commit header. The older `%at|%ct|%ae` and
1133    /// `%at|%ae` headers are still accepted so a fixture or an embedder pinned
1134    /// to an older format keeps parsing. A header without `%H` gives events
1135    /// without a commit id, and a missing committer timestamp falls back to the
1136    /// author one.
1137    fn record_commit_header(&mut self, line: &str) -> bool {
1138        let Some((ts_str, rest)) = line.split_once('|') else {
1139            return false;
1140        };
1141        let Ok(ts) = ts_str.parse::<u64>() else {
1142            return false;
1143        };
1144        let (committed_at, rest) = match rest.split_once('|') {
1145            Some((committer_str, rest)) => (committer_str.parse::<u64>().unwrap_or(ts), rest),
1146            None => (ts, rest),
1147        };
1148        let (sha, email) = match rest.split_once('|') {
1149            Some((sha, email)) if is_commit_id(sha) => (Some(sha), email),
1150            _ => (None, rest),
1151        };
1152
1153        self.current_timestamp = Some(ts);
1154        self.current_committed_at = Some(committed_at);
1155        self.current_author_idx = Some(intern_author(
1156            email,
1157            &mut self.author_pool,
1158            &mut self.author_index,
1159        ));
1160        self.current_commit_idx =
1161            sha.map(|sha| intern_author(sha, &mut self.commit_pool, &mut self.commit_index));
1162        true
1163    }
1164
1165    fn record_legacy_timestamp(&mut self, line: &str) -> bool {
1166        let Ok(ts) = line.parse::<u64>() else {
1167            return false;
1168        };
1169
1170        self.current_timestamp = Some(ts);
1171        self.current_committed_at = Some(ts);
1172        self.current_author_idx = None;
1173        self.current_commit_idx = None;
1174        true
1175    }
1176
1177    fn record_numstat_bytes(&mut self, record: &[u8]) {
1178        let Some(first_tab) = record.iter().position(|byte| *byte == b'\t') else {
1179            return;
1180        };
1181        let Some(second_tab_offset) = record[first_tab + 1..]
1182            .iter()
1183            .position(|byte| *byte == b'\t')
1184        else {
1185            return;
1186        };
1187        let second_tab = first_tab + 1 + second_tab_offset;
1188        let Some(added) = std::str::from_utf8(&record[..first_tab])
1189            .ok()
1190            .and_then(|value| value.parse().ok())
1191        else {
1192            return;
1193        };
1194        let Some(deleted) = std::str::from_utf8(&record[first_tab + 1..second_tab])
1195            .ok()
1196            .and_then(|value| value.parse().ok())
1197        else {
1198            return;
1199        };
1200        let path = git_path_from_bytes(&record[second_tab + 1..]);
1201        self.record_numstat_path(added, deleted, path);
1202    }
1203
1204    fn record_numstat_path(&mut self, added: u32, deleted: u32, path: PathBuf) {
1205        let ts = self.current_timestamp.unwrap_or(self.now_secs);
1206        self.files
1207            .entry(self.root.join(path))
1208            .or_insert_with(|| FileEvents { events: Vec::new() })
1209            .events
1210            .push(CachedCommitEvent {
1211                timestamp: ts,
1212                committed_at: self.current_committed_at.unwrap_or(ts),
1213                lines_added: added,
1214                lines_deleted: deleted,
1215                author_idx: self.current_author_idx,
1216                commit_idx: self.current_commit_idx,
1217            });
1218    }
1219
1220    fn finish(self) -> ChurnEventState {
1221        ChurnEventState {
1222            files: self.files,
1223            author_pool: self.author_pool,
1224            commit_pool: self.commit_pool,
1225            git_log_bytes: 0,
1226        }
1227    }
1228}
1229
1230/// True for a full SHA-1 (40) or SHA-256 (64) hexadecimal commit id.
1231fn is_commit_id(value: &str) -> bool {
1232    matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
1233}
1234
1235/// Aggregate one file's raw commit events into a [`FileChurn`], applying
1236/// recency weighting, trend detection, and per-author accumulation.
1237#[expect(
1238    clippy::cast_possible_truncation,
1239    reason = "commit count per file is bounded by git history depth"
1240)]
1241fn aggregate_file_churn(path: PathBuf, file: FileEvents, now_secs: u64) -> FileChurn {
1242    let mut timestamps = Vec::with_capacity(file.events.len());
1243    let mut weighted_commits = 0.0;
1244    let mut lines_added = 0_u32;
1245    let mut lines_deleted = 0_u32;
1246    let mut authors: FxHashMap<u32, AuthorContribution> = FxHashMap::default();
1247
1248    for event in file.events {
1249        timestamps.push(event.timestamp);
1250        let age_days = (now_secs.saturating_sub(event.timestamp)) as f64 / SECS_PER_DAY;
1251        let weight = 0.5_f64.powf(age_days / HALF_LIFE_DAYS);
1252        weighted_commits += weight;
1253        lines_added = lines_added.saturating_add(event.lines_added);
1254        lines_deleted = lines_deleted.saturating_add(event.lines_deleted);
1255        accumulate_author(&mut authors, event.author_idx, weight, event.timestamp);
1256    }
1257
1258    let commits = timestamps.len() as u32;
1259    let trend = compute_trend(&timestamps);
1260    for c in authors.values_mut() {
1261        c.weighted_commits = (c.weighted_commits * 100.0).round() / 100.0;
1262    }
1263    FileChurn {
1264        path,
1265        commits,
1266        weighted_commits: (weighted_commits * 100.0).round() / 100.0,
1267        lines_added,
1268        lines_deleted,
1269        trend,
1270        authors,
1271    }
1272}
1273
1274/// Fold a single commit's author contribution into the per-author map.
1275fn accumulate_author(
1276    authors: &mut FxHashMap<u32, AuthorContribution>,
1277    author_idx: Option<u32>,
1278    weight: f64,
1279    timestamp: u64,
1280) {
1281    let Some(idx) = author_idx else {
1282        return;
1283    };
1284    authors
1285        .entry(idx)
1286        .and_modify(|c| {
1287            c.commits += 1;
1288            c.weighted_commits += weight;
1289            c.first_commit_ts = c.first_commit_ts.min(timestamp);
1290            c.last_commit_ts = c.last_commit_ts.max(timestamp);
1291        })
1292        .or_insert(AuthorContribution {
1293            commits: 1,
1294            weighted_commits: weight,
1295            first_commit_ts: timestamp,
1296            last_commit_ts: timestamp,
1297        });
1298}
1299
1300/// Convert event-level churn state into the public aggregate result.
1301///
1302/// Recency weighting is measured against `clock`, not the system clock, so the
1303/// same commit yields the same `weighted_commits` on every run.
1304fn build_churn_result(
1305    state: ChurnEventState,
1306    shallow_clone: bool,
1307    clock: crate::clock::AnalysisClock,
1308) -> ChurnResult {
1309    build_windowed_churn_result(state, None, shallow_clone, clock)
1310}
1311
1312/// Like [`build_churn_result`], but the per-file aggregates only count events
1313/// at or after `files_cutoff`. The commit list keeps the full state.
1314fn build_windowed_churn_result(
1315    mut state: ChurnEventState,
1316    files_cutoff: Option<u64>,
1317    shallow_clone: bool,
1318    clock: crate::clock::AnalysisClock,
1319) -> ChurnResult {
1320    let commits = collect_churn_commits(&state);
1321    state.retain_window(files_cutoff);
1322    let now_secs = clock.epoch_secs();
1323
1324    let files = state
1325        .files
1326        .into_iter()
1327        .map(|(path, file)| {
1328            let churn = aggregate_file_churn(path.clone(), file, now_secs);
1329            (path, churn)
1330        })
1331        .collect();
1332
1333    ChurnResult {
1334        files,
1335        shallow_clone,
1336        author_pool: state.author_pool,
1337        clock,
1338        git_log_bytes: state.git_log_bytes,
1339        commits,
1340    }
1341}
1342
1343/// Group the events of `state` by commit id. Each commit lists its files
1344/// sorted by path, and the commits are sorted by committer time, then id.
1345fn collect_churn_commits(state: &ChurnEventState) -> Vec<ChurnCommit> {
1346    let mut by_idx: FxHashMap<u32, ChurnCommit> = FxHashMap::default();
1347    for (path, file) in &state.files {
1348        for event in &file.events {
1349            let Some(idx) = event.commit_idx else {
1350                continue;
1351            };
1352            let Some(sha) = state.commit_pool.get(idx as usize) else {
1353                continue;
1354            };
1355            by_idx
1356                .entry(idx)
1357                .or_insert_with(|| ChurnCommit {
1358                    sha: sha.clone(),
1359                    committed_at: event.committed_at,
1360                    files: Vec::new(),
1361                })
1362                .files
1363                .push(ChurnCommitFile {
1364                    path: path.clone(),
1365                    lines_changed: event.lines_added.saturating_add(event.lines_deleted),
1366                });
1367        }
1368    }
1369    let mut commits: Vec<ChurnCommit> = by_idx
1370        .into_values()
1371        .map(|mut commit| {
1372            commit.files.sort_by(|a, b| a.path.cmp(&b.path));
1373            commit.files.dedup_by(|later, kept| {
1374                if later.path != kept.path {
1375                    return false;
1376                }
1377                kept.lines_changed = kept.lines_changed.saturating_add(later.lines_changed);
1378                true
1379            });
1380            commit
1381        })
1382        .collect();
1383    commits.sort_by(|a, b| {
1384        a.committed_at
1385            .cmp(&b.committed_at)
1386            .then_with(|| a.sha.cmp(&b.sha))
1387    });
1388    commits
1389}
1390
1391/// Intern an author email into the pool, returning its stable index.
1392fn intern_author(email: &str, pool: &mut Vec<String>, index: &mut FxHashMap<String, u32>) -> u32 {
1393    if let Some(&idx) = index.get(email) {
1394        return idx;
1395    }
1396    #[expect(
1397        clippy::cast_possible_truncation,
1398        reason = "author count is bounded by git history; u32 is far above any realistic ceiling"
1399    )]
1400    let idx = pool.len() as u32;
1401    let owned = email.to_string();
1402    index.insert(owned.clone(), idx);
1403    pool.push(owned);
1404    idx
1405}
1406
1407/// Compute churn trend by splitting commits into two temporal halves.
1408///
1409/// Finds the midpoint between the oldest and newest commit timestamps,
1410/// then compares commit counts in each half:
1411/// - Recent > 1.5× older → Accelerating
1412/// - Recent < 0.67× older → Cooling
1413/// - Otherwise → Stable
1414fn compute_trend(timestamps: &[u64]) -> ChurnTrend {
1415    if timestamps.len() < 2 {
1416        return ChurnTrend::Stable;
1417    }
1418
1419    let min_ts = timestamps.iter().copied().min().unwrap_or(0);
1420    let max_ts = timestamps.iter().copied().max().unwrap_or(0);
1421
1422    if max_ts == min_ts {
1423        return ChurnTrend::Stable;
1424    }
1425
1426    let midpoint = min_ts + (max_ts - min_ts) / 2;
1427    let recent = timestamps.iter().filter(|&&ts| ts > midpoint).count() as f64;
1428    let older = timestamps.iter().filter(|&&ts| ts <= midpoint).count() as f64;
1429
1430    if older < 1.0 {
1431        return ChurnTrend::Stable;
1432    }
1433
1434    let ratio = recent / older;
1435    if ratio > 1.5 {
1436        ChurnTrend::Accelerating
1437    } else if ratio < 0.67 {
1438        ChurnTrend::Cooling
1439    } else {
1440        ChurnTrend::Stable
1441    }
1442}
1443
1444fn is_iso_date(input: &str) -> bool {
1445    input.len() == 10
1446        && input.as_bytes().get(4) == Some(&b'-')
1447        && input.as_bytes().get(7) == Some(&b'-')
1448        && input[..4].bytes().all(|b| b.is_ascii_digit())
1449        && input[5..7].bytes().all(|b| b.is_ascii_digit())
1450        && input[8..10].bytes().all(|b| b.is_ascii_digit())
1451}
1452
1453fn split_number_unit(input: &str) -> Result<(&str, &str), String> {
1454    let pos = input.find(|c: char| !c.is_ascii_digit()).ok_or_else(|| {
1455        format!("--since requires a unit suffix (e.g., 6m, 90d, 1y), got: {input}")
1456    })?;
1457    if pos == 0 {
1458        return Err(format!(
1459            "--since must start with a number (e.g., 6m, 90d, 1y), got: {input}"
1460        ));
1461    }
1462    Ok((&input[..pos], &input[pos..]))
1463}
1464
1465#[cfg(test)]
1466mod tests {
1467    use super::*;
1468
1469    #[test]
1470    fn parse_since_months_short() {
1471        let d = parse_since("6m").unwrap();
1472        assert_eq!(
1473            d.window,
1474            ChurnWindow::Relative {
1475                count: 6,
1476                unit: ChurnWindowUnit::Months
1477            }
1478        );
1479        assert_eq!(d.display, "6 months");
1480    }
1481
1482    #[test]
1483    fn parse_since_months_long() {
1484        let d = parse_since("6months").unwrap();
1485        assert_eq!(
1486            d.window,
1487            ChurnWindow::Relative {
1488                count: 6,
1489                unit: ChurnWindowUnit::Months
1490            }
1491        );
1492        assert_eq!(d.display, "6 months");
1493    }
1494
1495    #[test]
1496    fn parse_since_days() {
1497        let d = parse_since("90d").unwrap();
1498        assert_eq!(
1499            d.window,
1500            ChurnWindow::Relative {
1501                count: 90,
1502                unit: ChurnWindowUnit::Days
1503            }
1504        );
1505        assert_eq!(d.display, "90 days");
1506    }
1507
1508    #[test]
1509    fn parse_since_year_singular() {
1510        let d = parse_since("1y").unwrap();
1511        assert_eq!(
1512            d.window,
1513            ChurnWindow::Relative {
1514                count: 1,
1515                unit: ChurnWindowUnit::Years
1516            }
1517        );
1518        assert_eq!(d.display, "1 year");
1519    }
1520
1521    #[test]
1522    fn parse_since_years_plural() {
1523        let d = parse_since("2years").unwrap();
1524        assert_eq!(
1525            d.window,
1526            ChurnWindow::Relative {
1527                count: 2,
1528                unit: ChurnWindowUnit::Years
1529            }
1530        );
1531        assert_eq!(d.display, "2 years");
1532    }
1533
1534    #[test]
1535    fn parse_since_weeks() {
1536        let d = parse_since("2w").unwrap();
1537        assert_eq!(
1538            d.window,
1539            ChurnWindow::Relative {
1540                count: 2,
1541                unit: ChurnWindowUnit::Weeks
1542            }
1543        );
1544        assert_eq!(d.display, "2 weeks");
1545    }
1546
1547    #[test]
1548    fn parse_since_iso_date() {
1549        let d = parse_since("2025-06-01").unwrap();
1550        assert_eq!(d.window, ChurnWindow::Date("2025-06-01".to_string()));
1551        assert_eq!(d.display, "2025-06-01");
1552    }
1553
1554    #[test]
1555    fn parse_since_month_singular() {
1556        let d = parse_since("1month").unwrap();
1557        assert_eq!(d.display, "1 month");
1558    }
1559
1560    #[test]
1561    fn parse_since_day_singular() {
1562        let d = parse_since("1day").unwrap();
1563        assert_eq!(d.display, "1 day");
1564    }
1565
1566    #[test]
1567    fn parse_since_zero_rejected() {
1568        assert!(parse_since("0m").is_err());
1569    }
1570
1571    #[test]
1572    fn parse_since_no_unit_rejected() {
1573        assert!(parse_since("90").is_err());
1574    }
1575
1576    #[test]
1577    fn parse_since_unknown_unit_rejected() {
1578        assert!(parse_since("6x").is_err());
1579    }
1580
1581    #[test]
1582    fn parse_since_no_number_rejected() {
1583        assert!(parse_since("months").is_err());
1584    }
1585
1586    #[test]
1587    fn trend_empty_is_stable() {
1588        assert_eq!(compute_trend(&[]), ChurnTrend::Stable);
1589    }
1590
1591    #[test]
1592    fn trend_single_commit_is_stable() {
1593        assert_eq!(compute_trend(&[100]), ChurnTrend::Stable);
1594    }
1595
1596    #[test]
1597    fn trend_accelerating() {
1598        let timestamps = vec![100, 200, 800, 850, 900, 950, 1000];
1599        assert_eq!(compute_trend(&timestamps), ChurnTrend::Accelerating);
1600    }
1601
1602    #[test]
1603    fn trend_cooling() {
1604        let timestamps = vec![100, 150, 200, 250, 300, 900, 1000];
1605        assert_eq!(compute_trend(&timestamps), ChurnTrend::Cooling);
1606    }
1607
1608    #[test]
1609    fn trend_stable_even_distribution() {
1610        let timestamps = vec![100, 200, 300, 700, 800, 900];
1611        assert_eq!(compute_trend(&timestamps), ChurnTrend::Stable);
1612    }
1613
1614    #[test]
1615    fn trend_same_timestamp_is_stable() {
1616        let timestamps = vec![500, 500, 500];
1617        assert_eq!(compute_trend(&timestamps), ChurnTrend::Stable);
1618    }
1619
1620    #[test]
1621    fn iso_date_valid() {
1622        assert!(is_iso_date("2025-06-01"));
1623        assert!(is_iso_date("2025-12-31"));
1624    }
1625
1626    #[test]
1627    fn iso_date_with_time_rejected() {
1628        assert!(!is_iso_date("2025-06-01T00:00:00"));
1629    }
1630
1631    #[test]
1632    fn iso_date_invalid() {
1633        assert!(!is_iso_date("6months"));
1634        assert!(!is_iso_date("2025"));
1635        assert!(!is_iso_date("not-a-date"));
1636        assert!(!is_iso_date("abcd-ef-gh"));
1637    }
1638
1639    #[test]
1640    fn trend_display() {
1641        assert_eq!(ChurnTrend::Accelerating.to_string(), "accelerating");
1642        assert_eq!(ChurnTrend::Stable.to_string(), "stable");
1643        assert_eq!(ChurnTrend::Cooling.to_string(), "cooling");
1644    }
1645
1646    #[test]
1647    fn trend_boundary_1_5x_ratio() {
1648        let timestamps = vec![100, 200, 600, 800, 1000];
1649        assert_eq!(compute_trend(&timestamps), ChurnTrend::Stable);
1650    }
1651
1652    #[test]
1653    fn trend_just_above_1_5x() {
1654        let timestamps = vec![100, 600, 800, 1000];
1655        assert_eq!(compute_trend(&timestamps), ChurnTrend::Accelerating);
1656    }
1657
1658    #[test]
1659    fn trend_boundary_0_67x_ratio() {
1660        let timestamps = vec![100, 200, 300, 600, 1000];
1661        assert_eq!(compute_trend(&timestamps), ChurnTrend::Cooling);
1662    }
1663
1664    #[test]
1665    fn trend_two_timestamps_different() {
1666        let timestamps = vec![100, 200];
1667        assert_eq!(compute_trend(&timestamps), ChurnTrend::Stable);
1668    }
1669
1670    #[test]
1671    fn parse_since_week_singular() {
1672        let d = parse_since("1week").unwrap();
1673        assert_eq!(
1674            d.window,
1675            ChurnWindow::Relative {
1676                count: 1,
1677                unit: ChurnWindowUnit::Weeks
1678            }
1679        );
1680        assert_eq!(d.display, "1 week");
1681    }
1682
1683    #[test]
1684    fn parse_since_weeks_long() {
1685        let d = parse_since("3weeks").unwrap();
1686        assert_eq!(
1687            d.window,
1688            ChurnWindow::Relative {
1689                count: 3,
1690                unit: ChurnWindowUnit::Weeks
1691            }
1692        );
1693        assert_eq!(d.display, "3 weeks");
1694    }
1695
1696    #[test]
1697    fn parse_since_days_long() {
1698        let d = parse_since("30days").unwrap();
1699        assert_eq!(
1700            d.window,
1701            ChurnWindow::Relative {
1702                count: 30,
1703                unit: ChurnWindowUnit::Days
1704            }
1705        );
1706        assert_eq!(d.display, "30 days");
1707    }
1708
1709    #[test]
1710    fn parse_since_year_long() {
1711        let d = parse_since("1year").unwrap();
1712        assert_eq!(
1713            d.window,
1714            ChurnWindow::Relative {
1715                count: 1,
1716                unit: ChurnWindowUnit::Years
1717            }
1718        );
1719        assert_eq!(d.display, "1 year");
1720    }
1721
1722    #[test]
1723    fn parse_since_overflow_number_rejected() {
1724        let result = parse_since("99999999999999999999d");
1725        assert!(result.is_err());
1726        let err = result.unwrap_err();
1727        assert!(err.contains("invalid number"));
1728    }
1729
1730    #[test]
1731    fn parse_since_zero_days_rejected() {
1732        assert!(parse_since("0d").is_err());
1733    }
1734
1735    #[test]
1736    fn parse_since_zero_weeks_rejected() {
1737        assert!(parse_since("0w").is_err());
1738    }
1739
1740    #[test]
1741    fn parse_since_zero_years_rejected() {
1742        assert!(parse_since("0y").is_err());
1743    }
1744
1745    #[test]
1746    fn iso_date_wrong_separator_positions() {
1747        assert!(!is_iso_date("20-25-0601"));
1748        assert!(!is_iso_date("202506-01-"));
1749    }
1750
1751    #[test]
1752    fn iso_date_too_short() {
1753        assert!(!is_iso_date("2025-06-0"));
1754    }
1755
1756    #[test]
1757    fn iso_date_letters_in_day() {
1758        assert!(!is_iso_date("2025-06-ab"));
1759    }
1760
1761    #[test]
1762    fn iso_date_letters_in_month() {
1763        assert!(!is_iso_date("2025-ab-01"));
1764    }
1765
1766    #[test]
1767    fn split_number_unit_valid() {
1768        let (num, unit) = split_number_unit("42days").unwrap();
1769        assert_eq!(num, "42");
1770        assert_eq!(unit, "days");
1771    }
1772
1773    #[test]
1774    fn split_number_unit_single_digit() {
1775        let (num, unit) = split_number_unit("1m").unwrap();
1776        assert_eq!(num, "1");
1777        assert_eq!(unit, "m");
1778    }
1779
1780    #[test]
1781    fn split_number_unit_no_digits() {
1782        let err = split_number_unit("abc").unwrap_err();
1783        assert!(err.contains("must start with a number"));
1784    }
1785
1786    #[test]
1787    fn split_number_unit_no_unit() {
1788        let err = split_number_unit("123").unwrap_err();
1789        assert!(err.contains("requires a unit suffix"));
1790    }
1791
1792    #[test]
1793    fn trend_serde_serialization() {
1794        assert_eq!(
1795            serde_json::to_string(&ChurnTrend::Accelerating).unwrap(),
1796            "\"accelerating\""
1797        );
1798        assert_eq!(
1799            serde_json::to_string(&ChurnTrend::Stable).unwrap(),
1800            "\"stable\""
1801        );
1802        assert_eq!(
1803            serde_json::to_string(&ChurnTrend::Cooling).unwrap(),
1804            "\"cooling\""
1805        );
1806    }
1807
1808    #[test]
1809    fn intern_author_returns_existing_index() {
1810        let mut pool = Vec::new();
1811        let mut index = FxHashMap::default();
1812        let i1 = intern_author("alice@x", &mut pool, &mut index);
1813        let i2 = intern_author("alice@x", &mut pool, &mut index);
1814        assert_eq!(i1, i2);
1815        assert_eq!(pool.len(), 1);
1816    }
1817
1818    #[test]
1819    fn intern_author_assigns_sequential_indices() {
1820        let mut pool = Vec::new();
1821        let mut index = FxHashMap::default();
1822        assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1823        assert_eq!(intern_author("bob@x", &mut pool, &mut index), 1);
1824        assert_eq!(intern_author("carol@x", &mut pool, &mut index), 2);
1825        assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1826    }
1827
1828    fn git(root: &Path, args: &[&str]) {
1829        let status = super::git_command(root)
1830            .args(args)
1831            .current_dir(root)
1832            .status()
1833            .expect("run git");
1834        assert!(status.success(), "git {args:?} failed");
1835    }
1836
1837    fn write(root: &Path, path: &str, contents: &str) {
1838        let path = root.join(path);
1839        std::fs::create_dir_all(path.parent().expect("test path has parent")).unwrap();
1840        std::fs::write(path, contents).unwrap();
1841    }
1842
1843    #[cfg(unix)]
1844    #[test]
1845    fn churn_preserves_special_filenames() {
1846        let repo = tempfile::tempdir().expect("create repo");
1847        let root = repo.path();
1848        git(root, &["init", "--quiet"]);
1849        git(root, &["config", "user.email", "churn@example.test"]);
1850        git(root, &["config", "user.name", "Churn Test"]);
1851        git(root, &["config", "commit.gpgsign", "false"]);
1852
1853        let special_files = [
1854            "src/line\nbreak.ts",
1855            "src/space name.ts",
1856            "src/quote\"name.ts",
1857            "src/back\\slash.ts",
1858            "src/unicode-λ.ts",
1859        ]
1860        .map(|path| root.join(path));
1861        std::fs::create_dir_all(root.join("src")).expect("source dir");
1862        for special in &special_files {
1863            std::fs::write(special, "export const value = 1;\n").expect("special fixture");
1864        }
1865        git(root, &["add", "."]);
1866        git(root, &["commit", "--quiet", "-m", "initial"]);
1867
1868        let since = parse_since("1y").expect("valid duration");
1869        let churn = analyze_churn(root, &since).expect("churn result");
1870        for special in special_files {
1871            assert!(
1872                churn.files.contains_key(&special),
1873                "missing {special:?}: {:?}",
1874                churn.files.keys()
1875            );
1876        }
1877    }
1878
1879    #[cfg(unix)]
1880    #[test]
1881    fn churn_cache_preserves_non_utf8_filenames() {
1882        use std::ffi::OsString;
1883        use std::os::unix::ffi::OsStringExt;
1884
1885        let invalid_path = PathBuf::from(OsString::from_vec(b"src/non-utf8-\xff.ts".to_vec()));
1886        let mut files = FxHashMap::default();
1887        files.insert(
1888            invalid_path.clone(),
1889            FileEvents {
1890                events: vec![CachedCommitEvent {
1891                    timestamp: 1,
1892                    committed_at: 1,
1893                    lines_added: 2,
1894                    lines_deleted: 1,
1895                    author_idx: None,
1896                    commit_idx: None,
1897                }],
1898            },
1899        );
1900        let state = ChurnEventState {
1901            files,
1902            author_pool: Vec::new(),
1903            commit_pool: Vec::new(),
1904            git_log_bytes: 0,
1905        };
1906        let cache_dir = tempfile::tempdir().expect("cache directory");
1907        save_churn_cache(cache_dir.path(), "abc123", "1y", &state, false);
1908        let warm = load_churn_cache(cache_dir.path(), "1y")
1909            .expect("warm churn cache")
1910            .into_event_state(None);
1911
1912        assert!(warm.files.contains_key(&invalid_path));
1913    }
1914
1915    #[test]
1916    fn churn_cache_rejects_pre_lossless_path_encoding_version() {
1917        let cache_dir = tempfile::tempdir().expect("cache directory");
1918        let cache = ChurnCache {
1919            version: 4,
1920            last_indexed_sha: "abc123".to_string(),
1921            window_token: "1y".to_string(),
1922            files: vec![CachedFileChurn {
1923                path: br#"/project/\"src/line\\nbreak.ts\""#.to_vec(),
1924                events: Vec::new(),
1925            }],
1926            shallow_clone: false,
1927            author_pool: Vec::new(),
1928            commit_pool: Vec::new(),
1929        };
1930        std::fs::write(cache_dir.path().join("churn.bin"), bitcode::encode(&cache))
1931            .expect("cache fixture");
1932
1933        assert!(load_churn_cache(cache_dir.path(), "1y").is_none());
1934    }
1935
1936    #[test]
1937    fn churn_cache_rejects_entries_without_commit_ids() {
1938        let cache_dir = tempfile::tempdir().expect("cache directory");
1939        let cache = ChurnCache {
1940            version: 7,
1941            last_indexed_sha: "abc123".to_string(),
1942            window_token: "1y".to_string(),
1943            files: Vec::new(),
1944            shallow_clone: false,
1945            author_pool: Vec::new(),
1946            commit_pool: Vec::new(),
1947        };
1948        std::fs::write(cache_dir.path().join("churn.bin"), bitcode::encode(&cache))
1949            .expect("cache fixture");
1950
1951        assert!(load_churn_cache(cache_dir.path(), "1y").is_none());
1952    }
1953
1954    #[test]
1955    fn commit_header_with_sha_records_the_commit_id() {
1956        let sha = "0123456789abcdef0123456789abcdef01234567";
1957        let header = format!("1700000000|1700000001|{sha}|alice@corp");
1958        let state = parse_git_log_events_z(
1959            &git_log_z(&[(header.as_str(), &["1\t2\tsrc/a.ts", "3\t0\tsrc/b.ts"])]),
1960            Path::new("/project"),
1961            1_700_000_100,
1962        );
1963        let result = build_churn_result(
1964            state,
1965            false,
1966            crate::clock::AnalysisClock::pinned(1_700_000_100),
1967        );
1968
1969        assert_eq!(result.author_pool, vec!["alice@corp".to_string()]);
1970        assert_eq!(result.commits.len(), 1);
1971        let commit = &result.commits[0];
1972        assert_eq!(commit.sha, sha);
1973        assert_eq!(commit.committed_at, 1_700_000_001);
1974        let files: Vec<(PathBuf, u32)> = commit
1975            .files
1976            .iter()
1977            .map(|file| (file.path.clone(), file.lines_changed))
1978            .collect();
1979        assert_eq!(
1980            files,
1981            vec![
1982                (PathBuf::from("/project/src/a.ts"), 3),
1983                (PathBuf::from("/project/src/b.ts"), 3),
1984            ]
1985        );
1986    }
1987
1988    #[test]
1989    fn commit_header_without_sha_keeps_the_email_and_no_commit() {
1990        let state = parse_git_log_events_z(
1991            &git_log_z(&[("1700000000|1700000000|alice@corp", &["1\t2\tsrc/a.ts"])]),
1992            Path::new("/project"),
1993            1_700_000_100,
1994        );
1995        let result = build_churn_result(
1996            state,
1997            false,
1998            crate::clock::AnalysisClock::pinned(1_700_000_100),
1999        );
2000
2001        assert_eq!(result.author_pool, vec!["alice@corp".to_string()]);
2002        assert!(result.commits.is_empty());
2003        assert_eq!(result.files.len(), 1);
2004    }
2005
2006    #[test]
2007    fn churn_file_commit_field_groups_events_into_commits() {
2008        let dir = tempfile::tempdir().unwrap();
2009        let path = write_churn_file(
2010            dir.path(),
2011            r#"{
2012              "schema": "fallow-churn/v1",
2013              "events": [
2014                { "commit": "c1", "path": "src/a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 },
2015                { "commit": "c1", "path": "src/b.ts", "timestamp": 1700000000, "added": 2, "deleted": 1 },
2016                { "commit": "c2", "path": "src/a.ts", "timestamp": 1700100000, "added": 4, "deleted": 0 },
2017                { "path": "src/c.ts", "timestamp": 1700100000, "added": 1, "deleted": 0 }
2018              ]
2019            }"#,
2020        );
2021        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2022
2023        let commits: Vec<(String, usize)> = result
2024            .commits
2025            .iter()
2026            .map(|commit| (commit.sha.clone(), commit.files.len()))
2027            .collect();
2028        assert_eq!(commits, vec![("c1".to_string(), 2), ("c2".to_string(), 1)]);
2029        assert_eq!(
2030            result.files.len(),
2031            3,
2032            "an event without commit still counts"
2033        );
2034    }
2035
2036    #[test]
2037    fn cached_churn_merges_new_commits_after_head_advances() {
2038        let repo = tempfile::tempdir().expect("create repo");
2039        let root = repo.path();
2040        git(root, &["init"]);
2041        git(root, &["config", "user.email", "churn@example.test"]);
2042        git(root, &["config", "user.name", "Churn Test"]);
2043        git(root, &["config", "commit.gpgsign", "false"]);
2044
2045        write(root, "src/a.ts", "export const a = 1;\n");
2046        git(root, &["add", "."]);
2047        git(root, &["commit", "-m", "initial"]);
2048
2049        let since = parse_since("1y").unwrap();
2050        let cache = tempfile::tempdir().expect("create cache dir");
2051        let (cold, cold_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
2052        assert!(!cold_hit);
2053        let file = root.join("src/a.ts");
2054        assert_eq!(cold.files[&file].commits, 1);
2055
2056        let (_warm, warm_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
2057        assert!(warm_hit);
2058
2059        write(
2060            root,
2061            "src/a.ts",
2062            "export const a = 1;\nexport const b = 2;\n",
2063        );
2064        git(root, &["add", "."]);
2065        git(root, &["commit", "-m", "update a"]);
2066        let head = get_head_sha(root).unwrap();
2067
2068        let (incremental, incremental_hit) =
2069            analyze_churn_cached(root, &since, cache.path(), false).unwrap();
2070        assert!(incremental_hit);
2071        assert_eq!(incremental.files[&file].commits, 2);
2072
2073        let cache = load_churn_cache(cache.path(), &since.window.cache_token()).unwrap();
2074        assert_eq!(cache.last_indexed_sha, head);
2075    }
2076
2077    fn write_churn_file(dir: &std::path::Path, contents: &str) -> PathBuf {
2078        let path = dir.join("churn.json");
2079        std::fs::write(&path, contents).unwrap();
2080        path
2081    }
2082
2083    #[test]
2084    fn churn_file_happy_path() {
2085        let dir = tempfile::tempdir().unwrap();
2086        let root = Path::new("/project");
2087        let path = write_churn_file(
2088            dir.path(),
2089            r#"{
2090              "schema": "fallow-churn/v1",
2091              "events": [
2092                { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 10, "deleted": 5 },
2093                { "path": "src/a.ts", "timestamp": 1700100000, "author": "bob@corp", "added": 3, "deleted": 2 }
2094              ]
2095            }"#,
2096        );
2097        let result = analyze_churn_from_file(&path, root).unwrap();
2098        let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
2099        assert_eq!(churn.commits, 2);
2100        assert_eq!(churn.lines_added, 13);
2101        assert_eq!(churn.lines_deleted, 7);
2102        assert_eq!(churn.authors.len(), 2);
2103        assert!(result.author_pool.contains(&"alice@corp".to_string()));
2104        assert!(result.author_pool.contains(&"bob@corp".to_string()));
2105        assert!(!result.shallow_clone);
2106    }
2107
2108    #[test]
2109    fn churn_file_empty_events_is_valid() {
2110        let dir = tempfile::tempdir().unwrap();
2111        let path = write_churn_file(
2112            dir.path(),
2113            r#"{ "schema": "fallow-churn/v1", "events": [] }"#,
2114        );
2115        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2116        assert!(result.files.is_empty());
2117        assert!(result.author_pool.is_empty());
2118    }
2119
2120    #[test]
2121    fn churn_file_missing_events_key_is_valid() {
2122        let dir = tempfile::tempdir().unwrap();
2123        let path = write_churn_file(dir.path(), r#"{ "schema": "fallow-churn/v1" }"#);
2124        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2125        assert!(result.files.is_empty());
2126    }
2127
2128    #[test]
2129    fn churn_file_bad_schema_rejected() {
2130        let dir = tempfile::tempdir().unwrap();
2131        let path = write_churn_file(
2132            dir.path(),
2133            r#"{ "schema": "fallow-churn/v2", "events": [] }"#,
2134        );
2135        let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
2136        assert!(err.contains("expected \"fallow-churn/v1\""), "{err}");
2137    }
2138
2139    #[test]
2140    fn churn_file_malformed_json_rejected() {
2141        let dir = tempfile::tempdir().unwrap();
2142        let path = write_churn_file(dir.path(), "{ not json");
2143        assert!(analyze_churn_from_file(&path, Path::new("/project")).is_err());
2144    }
2145
2146    #[test]
2147    fn churn_file_missing_file_rejected() {
2148        let err = analyze_churn_from_file(Path::new("/no/such/churn.json"), Path::new("/project"))
2149            .unwrap_err();
2150        assert!(err.contains("failed to read churn file"), "{err}");
2151    }
2152
2153    #[test]
2154    fn churn_file_reader_accepts_exact_limit() {
2155        let dir = tempfile::tempdir().unwrap();
2156        let path = write_churn_file(dir.path(), "12345678");
2157        assert_eq!(read_churn_file_with_limit(&path, 8).unwrap(), "12345678");
2158    }
2159
2160    #[test]
2161    fn churn_file_reader_rejects_limit_plus_one() {
2162        let dir = tempfile::tempdir().unwrap();
2163        let path = write_churn_file(dir.path(), "123456789");
2164        let err = read_churn_file_with_limit(&path, 8).unwrap_err();
2165        assert!(err.contains("at least 9 bytes"), "{err}");
2166        assert!(err.contains("8 byte limit"), "{err}");
2167    }
2168
2169    #[test]
2170    fn churn_file_empty_path_rejected() {
2171        let dir = tempfile::tempdir().unwrap();
2172        let path = write_churn_file(
2173            dir.path(),
2174            r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "  ", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
2175        );
2176        let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
2177        assert!(err.contains("empty path"), "{err}");
2178    }
2179
2180    #[test]
2181    fn churn_file_rejects_non_relative_paths() {
2182        let invalid = [
2183            "/tmp/a.ts",
2184            r"C:\tmp\a.ts",
2185            "../a.ts",
2186            "src/../../a.ts",
2187            "./src/a.ts",
2188            "//server/share/a.ts",
2189        ];
2190        for event_path in invalid {
2191            let dir = tempfile::tempdir().unwrap();
2192            let body = format!(
2193                r#"{{ "schema": "fallow-churn/v1", "events": [ {{ "path": {event_path:?}, "timestamp": 1700000000, "added": 1, "deleted": 0 }} ] }}"#
2194            );
2195            let path = write_churn_file(dir.path(), &body);
2196            let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
2197            assert!(err.contains(event_path), "{event_path}: {err}");
2198            assert!(err.contains("repo-relative"), "{event_path}: {err}");
2199        }
2200    }
2201
2202    #[test]
2203    fn churn_file_accepts_unicode_and_spaces_in_path_components() {
2204        let dir = tempfile::tempdir().unwrap();
2205        let path = write_churn_file(
2206            dir.path(),
2207            r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/ruimte map/naïef.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
2208        );
2209        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2210        assert!(
2211            result
2212                .files
2213                .contains_key(&PathBuf::from("/project/src/ruimte map/naïef.ts"))
2214        );
2215    }
2216
2217    #[test]
2218    fn churn_file_millisecond_timestamp_rejected() {
2219        let dir = tempfile::tempdir().unwrap();
2220        // 1700000000000 is milliseconds; ~52000 years in the future as seconds.
2221        let path = write_churn_file(
2222            dir.path(),
2223            r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000000, "added": 1, "deleted": 0 } ] }"#,
2224        );
2225        let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
2226        assert!(err.contains("milliseconds"), "{err}");
2227    }
2228
2229    #[test]
2230    fn churn_file_missing_author_contributes_no_signal() {
2231        let dir = tempfile::tempdir().unwrap();
2232        let path = write_churn_file(
2233            dir.path(),
2234            r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
2235        );
2236        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2237        let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
2238        assert_eq!(churn.commits, 1);
2239        assert!(churn.authors.is_empty());
2240        assert!(result.author_pool.is_empty());
2241    }
2242
2243    #[test]
2244    fn churn_file_empty_author_string_treated_as_absent() {
2245        let dir = tempfile::tempdir().unwrap();
2246        let path = write_churn_file(
2247            dir.path(),
2248            r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "author": "  ", "added": 1, "deleted": 0 } ] }"#,
2249        );
2250        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2251        assert!(result.author_pool.is_empty());
2252    }
2253
2254    #[test]
2255    fn churn_file_unknown_fields_ignored() {
2256        // Extra keys (including the reserved `commit`) are accepted and ignored,
2257        // so a wrapper carrying extra metadata stays forward-compatible.
2258        let dir = tempfile::tempdir().unwrap();
2259        let path = write_churn_file(
2260            dir.path(),
2261            r#"{ "schema": "fallow-churn/v1", "extra": true, "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 1, "deleted": 0, "commit": "abc123", "tz": "+0200" } ] }"#,
2262        );
2263        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2264        assert_eq!(result.files[&PathBuf::from("/project/src/a.ts")].commits, 1);
2265    }
2266
2267    #[test]
2268    fn churn_file_backslash_paths_normalized() {
2269        let dir = tempfile::tempdir().unwrap();
2270        let path = write_churn_file(
2271            dir.path(),
2272            r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src\\a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
2273        );
2274        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2275        assert!(
2276            result
2277                .files
2278                .contains_key(&PathBuf::from("/project/src/a.ts"))
2279        );
2280    }
2281
2282    #[test]
2283    fn churn_file_rejects_line_totals_above_u32() {
2284        let dir = tempfile::tempdir().unwrap();
2285        let path = write_churn_file(
2286            dir.path(),
2287            r#"{ "schema": "fallow-churn/v1", "events": [
2288                { "path": "src/a.ts", "timestamp": 1700000000, "added": 4294967295, "deleted": 0 },
2289                { "path": "src/a.ts", "timestamp": 1700000001, "added": 1, "deleted": 0 }
2290            ] }"#,
2291        );
2292        let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
2293        assert!(err.contains("exceeding the u32 limit"), "{err}");
2294        assert!(err.contains("src/a.ts"), "{err}");
2295    }
2296
2297    #[test]
2298    fn churn_file_accepts_line_totals_at_u32_max() {
2299        let dir = tempfile::tempdir().unwrap();
2300        let path = write_churn_file(
2301            dir.path(),
2302            r#"{ "schema": "fallow-churn/v1", "events": [
2303                { "path": "src/a.ts", "timestamp": 1700000000, "added": 4294967294, "deleted": 4294967295 },
2304                { "path": "src/a.ts", "timestamp": 1700000001, "added": 1, "deleted": 0 }
2305            ] }"#,
2306        );
2307        let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2308        let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
2309        assert_eq!(churn.lines_added, u32::MAX);
2310        assert_eq!(churn.lines_deleted, u32::MAX);
2311    }
2312
2313    /// A numstat row that arrives before any commit header (truncated or
2314    /// malformed git output) must fall back to the run clock, so the recorded
2315    /// timestamp, and therefore the file's weighted commits and staleness, is
2316    /// the same on every run over one commit instead of moving with wall time.
2317    #[test]
2318    fn headerless_numstat_falls_back_to_the_run_clock() {
2319        let root = Path::new("/project");
2320        let pinned = 1_700_000_000;
2321
2322        let state = parse_git_log_events_z(b"10\t5\tsrc/a.ts", root, pinned);
2323
2324        let events = &state.files[&PathBuf::from("/project/src/a.ts")].events;
2325        assert_eq!(events.len(), 1);
2326        assert_eq!(events[0].timestamp, pinned);
2327        assert_eq!(events[0].committed_at, pinned);
2328    }
2329
2330    /// The same truncated output parsed against two different run clocks must
2331    /// disagree only by those clocks: nothing in the fallback path may consult
2332    /// the system clock.
2333    #[test]
2334    fn headerless_numstat_tracks_only_the_supplied_clock() {
2335        let root = Path::new("/project");
2336        let record: &[u8] = b"1\t0\tsrc/a.ts";
2337
2338        let early = parse_git_log_events_z(record, root, 1_600_000_000);
2339        let late = parse_git_log_events_z(record, root, 1_700_000_000);
2340
2341        let key = PathBuf::from("/project/src/a.ts");
2342        assert_eq!(early.files[&key].events[0].timestamp, 1_600_000_000);
2343        assert_eq!(late.files[&key].events[0].timestamp, 1_700_000_000);
2344    }
2345
2346    /// A commit header still wins over the fallback: the clock only fills a gap.
2347    #[test]
2348    fn commit_header_timestamp_beats_the_run_clock_fallback() {
2349        let root = Path::new("/project");
2350        let record = b"1700000000|1700000500|dev@example.com\x002\t1\tsrc/a.ts";
2351
2352        let state = parse_git_log_events_z(record, root, 1_234_567_890);
2353
2354        let events = &state.files[&PathBuf::from("/project/src/a.ts")].events;
2355        assert_eq!(events[0].timestamp, 1_700_000_000);
2356        assert_eq!(events[0].committed_at, 1_700_000_500);
2357    }
2358
2359    /// The run clock for the ported `git log -z` scenarios below.
2360    const NOW: u64 = 1_750_000_000;
2361    const HEADER: &str = "1700000000|1700000000|dev@example.com";
2362
2363    /// Build the bytes that `git log --numstat -z --format=format:%at|%ct|%H|%ae%x00`
2364    /// prints: each commit is `header NUL LF`, then one NUL-terminated numstat
2365    /// record per file, and consecutive commits are split by one more NUL.
2366    fn git_log_z(commits: &[(&str, &[&str])]) -> Vec<u8> {
2367        let mut out = Vec::new();
2368        for (index, (header, rows)) in commits.iter().enumerate() {
2369            if index > 0 {
2370                out.push(0);
2371            }
2372            out.extend_from_slice(header.as_bytes());
2373            out.extend_from_slice(b"\0\n");
2374            for row in *rows {
2375                out.extend_from_slice(row.as_bytes());
2376                out.push(0);
2377            }
2378        }
2379        out
2380    }
2381
2382    fn churn_from_git_log_z(stdout: &[u8], root: &Path, now_secs: u64) -> ChurnResult {
2383        build_churn_result(
2384            parse_git_log_events_z(stdout, root, now_secs),
2385            false,
2386            crate::clock::AnalysisClock::pinned(now_secs),
2387        )
2388    }
2389
2390    fn churn_from_commits(commits: &[(&str, &[&str])]) -> ChurnResult {
2391        churn_from_git_log_z(&git_log_z(commits), Path::new("/project"), NOW)
2392    }
2393
2394    #[test]
2395    fn git_log_z_fixture_matches_real_git_output() {
2396        assert_eq!(
2397            git_log_z(&[
2398                ("1|2|a@b", &["1\t0\ta.ts"]),
2399                ("3|4|a@b", &["1\t0\ta.ts", "1\t0\tb c.ts"])
2400            ]),
2401            b"1|2|a@b\0\n1\t0\ta.ts\0\x003|4|a@b\0\n1\t0\ta.ts\x001\t0\tb c.ts\0".to_vec()
2402        );
2403    }
2404
2405    #[test]
2406    fn numstat_records_parse_counts_and_skip_binary_or_malformed_rows() {
2407        let root = Path::new("/project");
2408        type Expected = Option<(u32, u32, &'static str)>;
2409        let cases: &[(&str, Expected)] = &[
2410            ("10\t5\tsrc/file.ts", Some((10, 5, "src/file.ts"))),
2411            ("0\t0\tsrc/empty.ts", Some((0, 0, "src/empty.ts"))),
2412            (
2413                "3\t1\tpath with spaces/file.ts",
2414                Some((3, 1, "path with spaces/file.ts")),
2415            ),
2416            ("1\t0\tweird\tname.ts", Some((1, 0, "weird\tname.ts"))),
2417            (
2418                "4294967295\t8888\tsrc/big.ts",
2419                Some((u32::MAX, 8888, "src/big.ts")),
2420            ),
2421            ("4294967296\t0\tsrc/overflow.ts", None),
2422            ("-\t-\tsrc/image.png", None),
2423            ("-\t5\tsrc/file.ts", None),
2424            ("10\t-\tsrc/file.ts", None),
2425            ("10\t5", None),
2426        ];
2427        for (row, expected) in cases {
2428            let state = parse_git_log_events_z(&git_log_z(&[(HEADER, &[row])]), root, NOW);
2429            let Some((added, deleted, path)) = expected else {
2430                assert!(state.files.is_empty(), "{row:?} must not record churn");
2431                continue;
2432            };
2433            assert_eq!(state.files.len(), 1, "{row:?}");
2434            let events = &state.files[&root.join(path)].events;
2435            assert_eq!(events.len(), 1, "{row:?}");
2436            assert_eq!(events[0].lines_added, *added, "{row:?}");
2437            assert_eq!(events[0].lines_deleted, *deleted, "{row:?}");
2438        }
2439    }
2440
2441    #[test]
2442    fn git_log_z_aggregates_commits_and_lines_per_file() {
2443        let result = churn_from_commits(&[
2444            (
2445                HEADER,
2446                &["10\t5\tsrc/a.ts", "3\t1\tsrc/b.ts", "-\t-\timage.png"],
2447            ),
2448            ("1700100000|1700100000|dev@example.com", &["3\t2\tsrc/a.ts"]),
2449        ]);
2450        assert_eq!(result.files.len(), 2);
2451        let a = &result.files[&PathBuf::from("/project/src/a.ts")];
2452        assert_eq!((a.commits, a.lines_added, a.lines_deleted), (2, 13, 7));
2453        let b = &result.files[&PathBuf::from("/project/src/b.ts")];
2454        assert_eq!((b.commits, b.lines_added, b.lines_deleted), (1, 3, 1));
2455        assert!(
2456            !result
2457                .files
2458                .contains_key(&PathBuf::from("/project/image.png"))
2459        );
2460    }
2461
2462    #[test]
2463    fn git_log_z_empty_or_blank_output_records_nothing() {
2464        assert!(
2465            churn_from_git_log_z(b"", Path::new("/project"), NOW)
2466                .files
2467                .is_empty()
2468        );
2469        assert!(
2470            churn_from_git_log_z(b"\0\0\n\0  \0", Path::new("/project"), NOW)
2471                .files
2472                .is_empty()
2473        );
2474    }
2475
2476    #[test]
2477    fn git_log_z_blank_records_between_commits_are_ignored() {
2478        let stdout = b"  \0\n1700000000|1700000000|dev@example.com\0\n  \0\n10\t5\tsrc/a.ts\0\0";
2479        let result = churn_from_git_log_z(stdout, Path::new("/project"), NOW);
2480        assert_eq!(result.files.len(), 1);
2481        assert_eq!(result.files[&PathBuf::from("/project/src/a.ts")].commits, 1);
2482    }
2483
2484    #[test]
2485    fn git_log_z_paths_are_joined_to_the_root() {
2486        let root = Path::new("/my/project");
2487        let result =
2488            churn_from_git_log_z(&git_log_z(&[(HEADER, &["1\t0\tlib/utils.ts"])]), root, NOW);
2489        let key = PathBuf::from("/my/project/lib/utils.ts");
2490        assert_eq!(result.files[&key].path, key);
2491    }
2492
2493    #[test]
2494    fn git_log_z_weights_commits_by_age_against_the_run_clock() {
2495        let day = 86_400;
2496        let now = format!("{NOW}|{NOW}|dev@example.com");
2497        let half_life = format!("{0}|{0}|dev@example.com", NOW - 45 * day);
2498        let two_half_lives = format!("{0}|{0}|dev@example.com", NOW - 180 * day);
2499        let result = churn_from_commits(&[
2500            (&now, &["1\t0\tsrc/fresh.ts"]),
2501            (&half_life, &["1\t0\tsrc/half.ts"]),
2502            (&two_half_lives, &["1\t0\tsrc/old.ts"]),
2503        ]);
2504        let weight = |name: &str| {
2505            result.files[&PathBuf::from(format!("/project/src/{name}"))].weighted_commits
2506        };
2507        assert!((weight("fresh.ts") - 1.0).abs() < f64::EPSILON);
2508        // 0.5^(45/90) = 0.7071..., rounded to two decimals.
2509        assert!((weight("half.ts") - 0.71).abs() < f64::EPSILON);
2510        assert!((weight("old.ts") - 0.25).abs() < f64::EPSILON);
2511    }
2512
2513    #[test]
2514    fn git_log_z_headerless_numstat_weighs_as_a_commit_at_the_run_clock() {
2515        let result = churn_from_git_log_z(b"10\t5\tsrc/no_ts.ts\0", Path::new("/project"), NOW);
2516        let churn = &result.files[&PathBuf::from("/project/src/no_ts.ts")];
2517        assert_eq!(
2518            (churn.commits, churn.lines_added, churn.lines_deleted),
2519            (1, 10, 5)
2520        );
2521        assert!((churn.weighted_commits - 1.0).abs() < f64::EPSILON);
2522    }
2523
2524    #[test]
2525    fn git_log_z_trend_is_computed_per_file() {
2526        let result = churn_from_commits(&[
2527            (
2528                "1000|1000|dev@example.com",
2529                &["5\t1\tsrc/old.ts", "1\t0\tsrc/hot.ts"],
2530            ),
2531            ("1800|1800|dev@example.com", &["1\t0\tsrc/hot.ts"]),
2532            ("1900|1900|dev@example.com", &["1\t0\tsrc/hot.ts"]),
2533            ("1950|1950|dev@example.com", &["1\t0\tsrc/hot.ts"]),
2534            (
2535                "2000|2000|dev@example.com",
2536                &["3\t1\tsrc/old.ts", "1\t0\tsrc/hot.ts"],
2537            ),
2538        ]);
2539        let old = &result.files[&PathBuf::from("/project/src/old.ts")];
2540        let hot = &result.files[&PathBuf::from("/project/src/hot.ts")];
2541        assert_eq!(old.commits, 2);
2542        assert_eq!(old.trend, ChurnTrend::Stable);
2543        assert_eq!(hot.commits, 5);
2544        assert_eq!(hot.trend, ChurnTrend::Accelerating);
2545    }
2546
2547    #[test]
2548    fn git_log_z_interns_authors_and_aggregates_per_author() {
2549        let result = churn_from_commits(&[
2550            (
2551                "1700000000|1700000000|alice@example.com",
2552                &["1\t0\tsrc/index.ts"],
2553            ),
2554            (
2555                "1700100000|1700100000|bob@example.com",
2556                &["2\t0\tsrc/index.ts"],
2557            ),
2558            (
2559                "1700200000|1700200000|alice@example.com",
2560                &["1\t1\tsrc/index.ts"],
2561            ),
2562        ]);
2563        assert_eq!(
2564            result.author_pool,
2565            vec![
2566                "alice@example.com".to_string(),
2567                "bob@example.com".to_string()
2568            ]
2569        );
2570        let churn = &result.files[&PathBuf::from("/project/src/index.ts")];
2571        assert_eq!(churn.commits, 3);
2572        assert_eq!(churn.authors.len(), 2);
2573        let alice = &churn.authors[&0];
2574        assert_eq!(alice.commits, 2);
2575        assert_eq!(alice.first_commit_ts, 1_700_000_000);
2576        assert_eq!(alice.last_commit_ts, 1_700_200_000);
2577        let bob = &churn.authors[&1];
2578        assert_eq!(bob.commits, 1);
2579        assert_eq!(bob.first_commit_ts, 1_700_100_000);
2580    }
2581
2582    #[test]
2583    fn git_log_z_legacy_headers_still_parse() {
2584        let result = churn_from_commits(&[
2585            ("1700000000", &["10\t5\tsrc/bare.ts"]),
2586            ("1700100000|alice@example.com", &["1\t0\tsrc/two_field.ts"]),
2587        ]);
2588        assert_eq!(result.author_pool, vec!["alice@example.com".to_string()]);
2589        let bare = &result.files[&PathBuf::from("/project/src/bare.ts")];
2590        assert_eq!(bare.commits, 1);
2591        assert!(bare.authors.is_empty());
2592        let two_field = &result.files[&PathBuf::from("/project/src/two_field.ts")];
2593        assert_eq!(two_field.authors[&0].first_commit_ts, 1_700_100_000);
2594    }
2595
2596    #[test]
2597    fn churn_file_matches_git_log_z_parse() {
2598        // The same events fed via git numstat and via the JSON import must
2599        // produce identical aggregate churn: the import reuses
2600        // build_churn_result, so only the SOURCE differs.
2601        let dir = tempfile::tempdir().unwrap();
2602        let root = Path::new("/project");
2603        let path = write_churn_file(
2604            dir.path(),
2605            r#"{
2606              "schema": "fallow-churn/v1",
2607              "events": [
2608                { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 10, "deleted": 5 },
2609                { "path": "src/b.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 3, "deleted": 1 },
2610                { "path": "src/a.ts", "timestamp": 1700100000, "author": "bob@corp", "added": 3, "deleted": 2 }
2611              ]
2612            }"#,
2613        );
2614        let imported = analyze_churn_from_file(&path, root).unwrap();
2615        let git = churn_from_git_log_z(
2616            &git_log_z(&[
2617                (
2618                    "1700000000|1700000000|alice@corp",
2619                    &["10\t5\tsrc/a.ts", "3\t1\tsrc/b.ts"],
2620                ),
2621                ("1700100000|1700100000|bob@corp", &["3\t2\tsrc/a.ts"]),
2622            ]),
2623            root,
2624            imported.clock.epoch_secs(),
2625        );
2626
2627        assert_eq!(
2628            git.author_pool, imported.author_pool,
2629            "author pools diverge"
2630        );
2631        assert_eq!(git.files.len(), imported.files.len());
2632        for (file, git_churn) in &git.files {
2633            let imp = &imported.files[file];
2634            assert_eq!(git_churn.commits, imp.commits, "commits for {file:?}");
2635            assert_eq!(git_churn.lines_added, imp.lines_added, "added for {file:?}");
2636            assert_eq!(
2637                git_churn.lines_deleted, imp.lines_deleted,
2638                "deleted for {file:?}"
2639            );
2640            assert_eq!(git_churn.trend, imp.trend, "trend for {file:?}");
2641            assert_eq!(git_churn.authors, imp.authors, "authors for {file:?}");
2642            assert!(
2643                (git_churn.weighted_commits - imp.weighted_commits).abs() < f64::EPSILON,
2644                "weighted_commits for {file:?}: {} vs {}",
2645                git_churn.weighted_commits,
2646                imp.weighted_commits
2647            );
2648        }
2649    }
2650}