Skip to main content

fallow_core/
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::path::{Path, PathBuf};
8use std::process::Command;
9
10use serde::Serialize;
11
12/// Number of seconds in one day.
13const SECS_PER_DAY: f64 = 86_400.0;
14
15/// Recency weight half-life in days. A commit from 90 days ago counts half
16/// as much as today's commit; 180 days ago counts 25%.
17const HALF_LIFE_DAYS: f64 = 90.0;
18
19/// Parsed duration for the `--since` flag.
20#[derive(Debug, Clone)]
21pub struct SinceDuration {
22    /// Value to pass to `git log --after` (e.g., `"6 months ago"` or `"2025-06-01"`).
23    pub git_after: String,
24    /// Human-readable display string (e.g., `"6 months"`).
25    pub display: String,
26}
27
28/// Churn trend indicator based on comparing recent vs older halves of the analysis period.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, bitcode::Encode, bitcode::Decode)]
30#[serde(rename_all = "snake_case")]
31pub enum ChurnTrend {
32    /// Recent half has >1.5× the commits of the older half.
33    Accelerating,
34    /// Churn is roughly stable between halves.
35    Stable,
36    /// Recent half has <0.67× the commits of the older half.
37    Cooling,
38}
39
40impl std::fmt::Display for ChurnTrend {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            Self::Accelerating => write!(f, "accelerating"),
44            Self::Stable => write!(f, "stable"),
45            Self::Cooling => write!(f, "cooling"),
46        }
47    }
48}
49
50/// Per-author commit aggregation for a single file.
51///
52/// Authors are interned via [`ChurnResult::author_pool`] indices to keep
53/// per-file maps small and the bitcode cache compact.
54#[derive(Debug, Clone, Copy)]
55pub struct AuthorContribution {
56    /// Total commits by this author touching this file in the analysis window.
57    pub commits: u32,
58    /// Recency-weighted commit sum (exponential decay, half-life 90 days).
59    pub weighted_commits: f64,
60    /// Earliest commit timestamp by this author (epoch seconds).
61    pub first_commit_ts: u64,
62    /// Latest commit timestamp by this author (epoch seconds).
63    pub last_commit_ts: u64,
64}
65
66/// Per-file churn data collected from git history.
67#[derive(Debug, Clone)]
68pub struct FileChurn {
69    /// Absolute file path.
70    pub path: PathBuf,
71    /// Total number of commits touching this file in the analysis window.
72    pub commits: u32,
73    /// Recency-weighted commit count (exponential decay, half-life 90 days).
74    pub weighted_commits: f64,
75    /// Total lines added across all commits.
76    pub lines_added: u32,
77    /// Total lines deleted across all commits.
78    pub lines_deleted: u32,
79    /// Churn trend: accelerating, stable, or cooling.
80    pub trend: ChurnTrend,
81    /// Per-author contributions keyed by interned author index.
82    /// Indices reference [`ChurnResult::author_pool`].
83    pub authors: FxHashMap<u32, AuthorContribution>,
84}
85
86/// Result of churn analysis.
87pub struct ChurnResult {
88    /// Per-file churn data, keyed by absolute path.
89    pub files: FxHashMap<PathBuf, FileChurn>,
90    /// Whether the repository is a shallow clone.
91    pub shallow_clone: bool,
92    /// Author email pool. Per-file [`AuthorContribution`] entries reference
93    /// authors by their index into this vector.
94    pub author_pool: Vec<String>,
95}
96
97/// Parse a `--since` value into a git-compatible duration.
98///
99/// Accepts:
100/// - Durations: `6m`, `6months`, `90d`, `90days`, `1y`, `1year`, `2w`, `2weeks`
101/// - ISO dates: `2025-06-01`
102///
103/// # Errors
104///
105/// Returns an error if the input is not a recognized duration format or ISO date,
106/// the numeric part is invalid, or the duration is zero.
107pub fn parse_since(input: &str) -> Result<SinceDuration, String> {
108    // Try ISO date first (YYYY-MM-DD)
109    if is_iso_date(input) {
110        return Ok(SinceDuration {
111            git_after: input.to_string(),
112            display: input.to_string(),
113        });
114    }
115
116    // Parse duration: number + unit
117    let (num_str, unit) = split_number_unit(input)?;
118    let num: u64 = num_str
119        .parse()
120        .map_err(|_| format!("invalid number in --since: {input}"))?;
121
122    if num == 0 {
123        return Err("--since duration must be greater than 0".to_string());
124    }
125
126    match unit {
127        "d" | "day" | "days" => {
128            let s = if num == 1 { "" } else { "s" };
129            Ok(SinceDuration {
130                git_after: format!("{num} day{s} ago"),
131                display: format!("{num} day{s}"),
132            })
133        }
134        "w" | "week" | "weeks" => {
135            let s = if num == 1 { "" } else { "s" };
136            Ok(SinceDuration {
137                git_after: format!("{num} week{s} ago"),
138                display: format!("{num} week{s}"),
139            })
140        }
141        "m" | "month" | "months" => {
142            let s = if num == 1 { "" } else { "s" };
143            Ok(SinceDuration {
144                git_after: format!("{num} month{s} ago"),
145                display: format!("{num} month{s}"),
146            })
147        }
148        "y" | "year" | "years" => {
149            let s = if num == 1 { "" } else { "s" };
150            Ok(SinceDuration {
151                git_after: format!("{num} year{s} ago"),
152                display: format!("{num} year{s}"),
153            })
154        }
155        _ => Err(format!(
156            "unknown duration unit '{unit}' in --since. Use d/w/m/y (e.g., 6m, 90d, 1y)"
157        )),
158    }
159}
160
161/// Analyze git churn for files in the given root directory.
162///
163/// Returns `None` if git is not available or the directory is not a git repository.
164pub fn analyze_churn(root: &Path, since: &SinceDuration) -> Option<ChurnResult> {
165    let shallow = is_shallow_clone(root);
166    let state = analyze_churn_events(root, since, None)?;
167    Some(build_churn_result(state, shallow))
168}
169
170/// Check if the repository is a shallow clone.
171#[must_use]
172pub fn is_shallow_clone(root: &Path) -> bool {
173    Command::new("git")
174        .args(["rev-parse", "--is-shallow-repository"])
175        .current_dir(root)
176        .output()
177        .is_ok_and(|o| {
178            String::from_utf8_lossy(&o.stdout)
179                .trim()
180                .eq_ignore_ascii_case("true")
181        })
182}
183
184/// Check if the directory is inside a git repository.
185#[must_use]
186pub fn is_git_repo(root: &Path) -> bool {
187    Command::new("git")
188        .args(["rev-parse", "--git-dir"])
189        .current_dir(root)
190        .stdout(std::process::Stdio::null())
191        .stderr(std::process::Stdio::null())
192        .status()
193        .is_ok_and(|s| s.success())
194}
195
196// ── Churn cache ──────────────────────────────────────────────────
197
198/// Maximum size of a churn cache file (64 MB). The incremental cache stores
199/// per-commit events, so it needs more headroom than the old aggregate rows.
200const MAX_CHURN_CACHE_SIZE: usize = 64 * 1024 * 1024;
201
202/// Cache schema version. Bump when the on-disk shape of [`ChurnCache`]
203/// changes so older payloads are rejected on load. Bumped to 3 when the cache
204/// switched from aggregate rows to per-commit events for incremental updates.
205const CHURN_CACHE_VERSION: u8 = 3;
206
207/// Serializable per-commit event for the disk cache.
208#[derive(Clone, bitcode::Encode, bitcode::Decode)]
209struct CachedCommitEvent {
210    timestamp: u64,
211    lines_added: u32,
212    lines_deleted: u32,
213    author_idx: Option<u32>,
214}
215
216/// Serializable per-file churn entry for the disk cache.
217#[derive(Clone, bitcode::Encode, bitcode::Decode)]
218struct CachedFileChurn {
219    path: String,
220    events: Vec<CachedCommitEvent>,
221}
222
223/// Cached churn data keyed by last indexed SHA and since string.
224#[derive(Clone, bitcode::Encode, bitcode::Decode)]
225struct ChurnCache {
226    /// Schema version; must equal [`CHURN_CACHE_VERSION`] to be accepted.
227    version: u8,
228    last_indexed_sha: String,
229    git_after: String,
230    files: Vec<CachedFileChurn>,
231    shallow_clone: bool,
232    /// Author email pool referenced by [`CachedCommitEvent::author_idx`].
233    author_pool: Vec<String>,
234}
235
236/// Per-file commit events retained in memory while building or updating churn.
237struct FileEvents {
238    events: Vec<CachedCommitEvent>,
239}
240
241/// Event-level churn state. Unlike [`ChurnResult`], this preserves commit
242/// timestamps so a cache can merge new commits and recompute trend/recency.
243struct ChurnEventState {
244    files: FxHashMap<PathBuf, FileEvents>,
245    author_pool: Vec<String>,
246}
247
248/// Get the full HEAD SHA for cache keying.
249fn get_head_sha(root: &Path) -> Option<String> {
250    Command::new("git")
251        .args(["rev-parse", "HEAD"])
252        .current_dir(root)
253        .output()
254        .ok()
255        .filter(|o| o.status.success())
256        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
257}
258
259/// Check whether `ancestor` is still reachable from `descendant`.
260fn is_ancestor(root: &Path, ancestor: &str, descendant: &str) -> bool {
261    Command::new("git")
262        .args(["merge-base", "--is-ancestor", ancestor, descendant])
263        .current_dir(root)
264        .status()
265        .is_ok_and(|s| s.success())
266}
267
268/// Try to load churn data from disk cache. Returns `None` on cache miss
269/// or version mismatch.
270fn load_churn_cache(cache_dir: &Path, git_after: &str) -> Option<ChurnCache> {
271    let cache_file = cache_dir.join("churn.bin");
272    let data = std::fs::read(&cache_file).ok()?;
273    if data.len() > MAX_CHURN_CACHE_SIZE {
274        return None;
275    }
276    let cache: ChurnCache = bitcode::decode(&data).ok()?;
277    if cache.version != CHURN_CACHE_VERSION || cache.git_after != git_after {
278        return None;
279    }
280    Some(cache)
281}
282
283/// Save churn data to disk cache.
284fn save_churn_cache(
285    cache_dir: &Path,
286    last_indexed_sha: &str,
287    git_after: &str,
288    state: &ChurnEventState,
289    shallow_clone: bool,
290) {
291    let files: Vec<CachedFileChurn> = state
292        .files
293        .iter()
294        .map(|f| CachedFileChurn {
295            path: f.0.to_string_lossy().to_string(),
296            events: f.1.events.clone(),
297        })
298        .collect();
299    let cache = ChurnCache {
300        version: CHURN_CACHE_VERSION,
301        last_indexed_sha: last_indexed_sha.to_string(),
302        git_after: git_after.to_string(),
303        files,
304        shallow_clone,
305        author_pool: state.author_pool.clone(),
306    };
307    let _ = std::fs::create_dir_all(cache_dir);
308    let data = bitcode::encode(&cache);
309    // Write to temp file then rename for atomic update (avoids partial reads by concurrent processes)
310    let tmp = cache_dir.join("churn.bin.tmp");
311    if std::fs::write(&tmp, data).is_ok() {
312        let _ = std::fs::rename(&tmp, cache_dir.join("churn.bin"));
313    }
314}
315
316/// Analyze churn with disk caching. Uses cached result when HEAD SHA and
317/// since duration match. If HEAD advanced from the cached SHA, runs an
318/// incremental `git log <cached>..HEAD --numstat` scan and merges it.
319///
320/// Returns `(ChurnResult, bool)` where the bool indicates whether reusable
321/// cache state was used.
322/// Returns `None` if git analysis fails.
323pub fn analyze_churn_cached(
324    root: &Path,
325    since: &SinceDuration,
326    cache_dir: &Path,
327    no_cache: bool,
328) -> Option<(ChurnResult, bool)> {
329    let head_sha = get_head_sha(root)?;
330
331    if !no_cache && let Some(cache) = load_churn_cache(cache_dir, &since.git_after) {
332        if cache.last_indexed_sha == head_sha {
333            let shallow_clone = cache.shallow_clone;
334            let state = cache.into_event_state();
335            return Some((build_churn_result(state, shallow_clone), true));
336        }
337
338        if is_ancestor(root, &cache.last_indexed_sha, &head_sha) {
339            let shallow_clone = is_shallow_clone(root);
340            let range = format!("{}..HEAD", cache.last_indexed_sha);
341            if let Some(delta) = analyze_churn_events(root, since, Some(&range)) {
342                let mut state = cache.into_event_state();
343                merge_churn_states(&mut state, delta);
344                save_churn_cache(
345                    cache_dir,
346                    &head_sha,
347                    &since.git_after,
348                    &state,
349                    shallow_clone,
350                );
351                return Some((build_churn_result(state, shallow_clone), true));
352            }
353        }
354    }
355
356    let shallow_clone = is_shallow_clone(root);
357    let state = analyze_churn_events(root, since, None)?;
358    if !no_cache {
359        save_churn_cache(
360            cache_dir,
361            &head_sha,
362            &since.git_after,
363            &state,
364            shallow_clone,
365        );
366    }
367
368    let result = build_churn_result(state, shallow_clone);
369    Some((result, false))
370}
371
372// ── Internal ──────────────────────────────────────────────────────
373
374impl ChurnCache {
375    fn into_event_state(self) -> ChurnEventState {
376        let files = self
377            .files
378            .into_iter()
379            .map(|entry| {
380                (
381                    PathBuf::from(entry.path),
382                    FileEvents {
383                        events: entry.events,
384                    },
385                )
386            })
387            .collect();
388        ChurnEventState {
389            files,
390            author_pool: self.author_pool,
391        }
392    }
393}
394
395/// Run `git log --numstat` and return event-level churn state.
396fn analyze_churn_events(
397    root: &Path,
398    since: &SinceDuration,
399    revision_range: Option<&str>,
400) -> Option<ChurnEventState> {
401    let mut command = Command::new("git");
402    command.arg("log");
403    if let Some(range) = revision_range {
404        command.arg(range);
405    }
406    command
407        .args([
408            "--numstat",
409            "--no-merges",
410            "--no-renames",
411            "--use-mailmap",
412            "--format=format:%at|%ae",
413            &format!("--after={}", since.git_after),
414        ])
415        .current_dir(root);
416
417    let output = match command.output() {
418        Ok(o) => o,
419        Err(e) => {
420            tracing::warn!("hotspot analysis skipped: failed to run git: {e}");
421            return None;
422        }
423    };
424
425    if !output.status.success() {
426        let stderr = String::from_utf8_lossy(&output.stderr);
427        tracing::warn!("hotspot analysis skipped: git log failed: {stderr}");
428        return None;
429    }
430
431    let stdout = String::from_utf8_lossy(&output.stdout);
432    Some(parse_git_log_events(&stdout, root))
433}
434
435/// Merge new churn events into cached event state.
436fn merge_churn_states(base: &mut ChurnEventState, delta: ChurnEventState) {
437    let mut base_author_index: FxHashMap<String, u32> = base
438        .author_pool
439        .iter()
440        .enumerate()
441        .filter_map(|(idx, email)| u32::try_from(idx).ok().map(|idx| (email.clone(), idx)))
442        .collect();
443
444    let mut author_mapping: FxHashMap<u32, u32> = FxHashMap::default();
445    for (old_idx, email) in delta.author_pool.into_iter().enumerate() {
446        let Ok(old_idx) = u32::try_from(old_idx) else {
447            continue;
448        };
449        let new_idx = intern_author(&email, &mut base.author_pool, &mut base_author_index);
450        author_mapping.insert(old_idx, new_idx);
451    }
452
453    for (path, mut file) in delta.files {
454        for event in &mut file.events {
455            event.author_idx = event
456                .author_idx
457                .and_then(|idx| author_mapping.get(&idx).copied());
458        }
459        base.files
460            .entry(path)
461            .and_modify(|existing| existing.events.append(&mut file.events))
462            .or_insert(file);
463    }
464}
465
466/// Parse `git log --numstat --format=format:%at|%ae` output into events.
467fn parse_git_log_events(stdout: &str, root: &Path) -> ChurnEventState {
468    let now_secs = std::time::SystemTime::now()
469        .duration_since(std::time::UNIX_EPOCH)
470        .unwrap_or_default()
471        .as_secs();
472
473    let mut files: FxHashMap<PathBuf, FileEvents> = FxHashMap::default();
474    let mut author_pool: Vec<String> = Vec::new();
475    let mut author_index: FxHashMap<String, u32> = FxHashMap::default();
476    let mut current_timestamp: Option<u64> = None;
477    let mut current_author_idx: Option<u32> = None;
478
479    for line in stdout.lines() {
480        let line = line.trim();
481        if line.is_empty() {
482            continue;
483        }
484
485        // Header lines have shape: "<ts>|<email>"
486        if let Some((ts_str, email)) = line.split_once('|')
487            && let Ok(ts) = ts_str.parse::<u64>()
488        {
489            current_timestamp = Some(ts);
490            current_author_idx = Some(intern_author(email, &mut author_pool, &mut author_index));
491            continue;
492        }
493
494        // Backwards-compat: bare timestamp (legacy format or test fixtures).
495        if let Ok(ts) = line.parse::<u64>() {
496            current_timestamp = Some(ts);
497            current_author_idx = None;
498            continue;
499        }
500
501        // Numstat line: "10\t5\tpath/to/file"
502        if let Some((added, deleted, path)) = parse_numstat_line(line) {
503            let abs_path = root.join(path);
504            let ts = current_timestamp.unwrap_or(now_secs);
505            files
506                .entry(abs_path)
507                .or_insert_with(|| FileEvents { events: Vec::new() })
508                .events
509                .push(CachedCommitEvent {
510                    timestamp: ts,
511                    lines_added: added,
512                    lines_deleted: deleted,
513                    author_idx: current_author_idx,
514                });
515        }
516    }
517
518    ChurnEventState { files, author_pool }
519}
520
521/// Convert event-level churn state into the public aggregate result.
522#[expect(
523    clippy::cast_possible_truncation,
524    reason = "commit count per file is bounded by git history depth"
525)]
526fn build_churn_result(state: ChurnEventState, shallow_clone: bool) -> ChurnResult {
527    let now_secs = std::time::SystemTime::now()
528        .duration_since(std::time::UNIX_EPOCH)
529        .unwrap_or_default()
530        .as_secs();
531
532    let files = state
533        .files
534        .into_iter()
535        .map(|(path, file)| {
536            let mut timestamps = Vec::with_capacity(file.events.len());
537            let mut weighted_commits = 0.0;
538            let mut lines_added = 0;
539            let mut lines_deleted = 0;
540            let mut authors: FxHashMap<u32, AuthorContribution> = FxHashMap::default();
541
542            for event in file.events {
543                timestamps.push(event.timestamp);
544                let age_days = (now_secs.saturating_sub(event.timestamp)) as f64 / SECS_PER_DAY;
545                let weight = 0.5_f64.powf(age_days / HALF_LIFE_DAYS);
546                weighted_commits += weight;
547                lines_added += event.lines_added;
548                lines_deleted += event.lines_deleted;
549
550                if let Some(idx) = event.author_idx {
551                    authors
552                        .entry(idx)
553                        .and_modify(|c| {
554                            c.commits += 1;
555                            c.weighted_commits += weight;
556                            c.first_commit_ts = c.first_commit_ts.min(event.timestamp);
557                            c.last_commit_ts = c.last_commit_ts.max(event.timestamp);
558                        })
559                        .or_insert(AuthorContribution {
560                            commits: 1,
561                            weighted_commits: weight,
562                            first_commit_ts: event.timestamp,
563                            last_commit_ts: event.timestamp,
564                        });
565                }
566            }
567
568            let commits = timestamps.len() as u32;
569            let trend = compute_trend(&timestamps);
570            // Round per-author weighted sums for cache stability.
571            for c in authors.values_mut() {
572                c.weighted_commits = (c.weighted_commits * 100.0).round() / 100.0;
573            }
574            let churn = FileChurn {
575                path: path.clone(),
576                commits,
577                weighted_commits: (weighted_commits * 100.0).round() / 100.0,
578                lines_added,
579                lines_deleted,
580                trend,
581                authors,
582            };
583            (path, churn)
584        })
585        .collect();
586
587    ChurnResult {
588        files,
589        shallow_clone,
590        author_pool: state.author_pool,
591    }
592}
593
594/// Parse `git log --numstat --format=format:%at|%ae` output.
595///
596/// Returns a per-file churn map plus the author email pool referenced by
597/// interned indices in [`FileChurn::authors`].
598#[cfg(test)]
599fn parse_git_log(stdout: &str, root: &Path) -> (FxHashMap<PathBuf, FileChurn>, Vec<String>) {
600    let result = build_churn_result(parse_git_log_events(stdout, root), false);
601    (result.files, result.author_pool)
602}
603
604/// Intern an author email into the pool, returning its stable index.
605fn intern_author(email: &str, pool: &mut Vec<String>, index: &mut FxHashMap<String, u32>) -> u32 {
606    if let Some(&idx) = index.get(email) {
607        return idx;
608    }
609    #[expect(
610        clippy::cast_possible_truncation,
611        reason = "author count is bounded by git history; u32 is far above any realistic ceiling"
612    )]
613    let idx = pool.len() as u32;
614    let owned = email.to_string();
615    index.insert(owned.clone(), idx);
616    pool.push(owned);
617    idx
618}
619
620/// Parse a single numstat line: `"10\t5\tpath/to/file.ts"`.
621/// Binary files show as `"-\t-\tpath"` — skip those.
622fn parse_numstat_line(line: &str) -> Option<(u32, u32, &str)> {
623    let mut parts = line.splitn(3, '\t');
624    let added_str = parts.next()?;
625    let deleted_str = parts.next()?;
626    let path = parts.next()?;
627
628    // Binary files show "-" for added/deleted — skip them
629    let added: u32 = added_str.parse().ok()?;
630    let deleted: u32 = deleted_str.parse().ok()?;
631
632    Some((added, deleted, path))
633}
634
635/// Compute churn trend by splitting commits into two temporal halves.
636///
637/// Finds the midpoint between the oldest and newest commit timestamps,
638/// then compares commit counts in each half:
639/// - Recent > 1.5× older → Accelerating
640/// - Recent < 0.67× older → Cooling
641/// - Otherwise → Stable
642fn compute_trend(timestamps: &[u64]) -> ChurnTrend {
643    if timestamps.len() < 2 {
644        return ChurnTrend::Stable;
645    }
646
647    let min_ts = timestamps.iter().copied().min().unwrap_or(0);
648    let max_ts = timestamps.iter().copied().max().unwrap_or(0);
649
650    if max_ts == min_ts {
651        return ChurnTrend::Stable;
652    }
653
654    let midpoint = min_ts + (max_ts - min_ts) / 2;
655    let recent = timestamps.iter().filter(|&&ts| ts > midpoint).count() as f64;
656    let older = timestamps.iter().filter(|&&ts| ts <= midpoint).count() as f64;
657
658    if older < 1.0 {
659        return ChurnTrend::Stable;
660    }
661
662    let ratio = recent / older;
663    if ratio > 1.5 {
664        ChurnTrend::Accelerating
665    } else if ratio < 0.67 {
666        ChurnTrend::Cooling
667    } else {
668        ChurnTrend::Stable
669    }
670}
671
672fn is_iso_date(input: &str) -> bool {
673    input.len() == 10
674        && input.as_bytes().get(4) == Some(&b'-')
675        && input.as_bytes().get(7) == Some(&b'-')
676        && input[..4].bytes().all(|b| b.is_ascii_digit())
677        && input[5..7].bytes().all(|b| b.is_ascii_digit())
678        && input[8..10].bytes().all(|b| b.is_ascii_digit())
679}
680
681fn split_number_unit(input: &str) -> Result<(&str, &str), String> {
682    let pos = input.find(|c: char| !c.is_ascii_digit()).ok_or_else(|| {
683        format!("--since requires a unit suffix (e.g., 6m, 90d, 1y), got: {input}")
684    })?;
685    if pos == 0 {
686        return Err(format!(
687            "--since must start with a number (e.g., 6m, 90d, 1y), got: {input}"
688        ));
689    }
690    Ok((&input[..pos], &input[pos..]))
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696
697    // ── parse_since ──────────────────────────────────────────────
698
699    #[test]
700    fn parse_since_months_short() {
701        let d = parse_since("6m").unwrap();
702        assert_eq!(d.git_after, "6 months ago");
703        assert_eq!(d.display, "6 months");
704    }
705
706    #[test]
707    fn parse_since_months_long() {
708        let d = parse_since("6months").unwrap();
709        assert_eq!(d.git_after, "6 months ago");
710        assert_eq!(d.display, "6 months");
711    }
712
713    #[test]
714    fn parse_since_days() {
715        let d = parse_since("90d").unwrap();
716        assert_eq!(d.git_after, "90 days ago");
717        assert_eq!(d.display, "90 days");
718    }
719
720    #[test]
721    fn parse_since_year_singular() {
722        let d = parse_since("1y").unwrap();
723        assert_eq!(d.git_after, "1 year ago");
724        assert_eq!(d.display, "1 year");
725    }
726
727    #[test]
728    fn parse_since_years_plural() {
729        let d = parse_since("2years").unwrap();
730        assert_eq!(d.git_after, "2 years ago");
731        assert_eq!(d.display, "2 years");
732    }
733
734    #[test]
735    fn parse_since_weeks() {
736        let d = parse_since("2w").unwrap();
737        assert_eq!(d.git_after, "2 weeks ago");
738        assert_eq!(d.display, "2 weeks");
739    }
740
741    #[test]
742    fn parse_since_iso_date() {
743        let d = parse_since("2025-06-01").unwrap();
744        assert_eq!(d.git_after, "2025-06-01");
745        assert_eq!(d.display, "2025-06-01");
746    }
747
748    #[test]
749    fn parse_since_month_singular() {
750        let d = parse_since("1month").unwrap();
751        assert_eq!(d.display, "1 month");
752    }
753
754    #[test]
755    fn parse_since_day_singular() {
756        let d = parse_since("1day").unwrap();
757        assert_eq!(d.display, "1 day");
758    }
759
760    #[test]
761    fn parse_since_zero_rejected() {
762        assert!(parse_since("0m").is_err());
763    }
764
765    #[test]
766    fn parse_since_no_unit_rejected() {
767        assert!(parse_since("90").is_err());
768    }
769
770    #[test]
771    fn parse_since_unknown_unit_rejected() {
772        assert!(parse_since("6x").is_err());
773    }
774
775    #[test]
776    fn parse_since_no_number_rejected() {
777        assert!(parse_since("months").is_err());
778    }
779
780    // ── parse_numstat_line ───────────────────────────────────────
781
782    #[test]
783    fn numstat_normal() {
784        let (a, d, p) = parse_numstat_line("10\t5\tsrc/file.ts").unwrap();
785        assert_eq!(a, 10);
786        assert_eq!(d, 5);
787        assert_eq!(p, "src/file.ts");
788    }
789
790    #[test]
791    fn numstat_binary_skipped() {
792        assert!(parse_numstat_line("-\t-\tsrc/image.png").is_none());
793    }
794
795    #[test]
796    fn numstat_zero_lines() {
797        let (a, d, p) = parse_numstat_line("0\t0\tsrc/empty.ts").unwrap();
798        assert_eq!(a, 0);
799        assert_eq!(d, 0);
800        assert_eq!(p, "src/empty.ts");
801    }
802
803    // ── compute_trend ────────────────────────────────────────────
804
805    #[test]
806    fn trend_empty_is_stable() {
807        assert_eq!(compute_trend(&[]), ChurnTrend::Stable);
808    }
809
810    #[test]
811    fn trend_single_commit_is_stable() {
812        assert_eq!(compute_trend(&[100]), ChurnTrend::Stable);
813    }
814
815    #[test]
816    fn trend_accelerating() {
817        // 2 old commits, 5 recent commits
818        let timestamps = vec![100, 200, 800, 850, 900, 950, 1000];
819        assert_eq!(compute_trend(&timestamps), ChurnTrend::Accelerating);
820    }
821
822    #[test]
823    fn trend_cooling() {
824        // 5 old commits, 2 recent commits
825        let timestamps = vec![100, 150, 200, 250, 300, 900, 1000];
826        assert_eq!(compute_trend(&timestamps), ChurnTrend::Cooling);
827    }
828
829    #[test]
830    fn trend_stable_even_distribution() {
831        // 3 old commits, 3 recent commits → ratio = 1.0 → stable
832        let timestamps = vec![100, 200, 300, 700, 800, 900];
833        assert_eq!(compute_trend(&timestamps), ChurnTrend::Stable);
834    }
835
836    #[test]
837    fn trend_same_timestamp_is_stable() {
838        let timestamps = vec![500, 500, 500];
839        assert_eq!(compute_trend(&timestamps), ChurnTrend::Stable);
840    }
841
842    // ── is_iso_date ──────────────────────────────────────────────
843
844    #[test]
845    fn iso_date_valid() {
846        assert!(is_iso_date("2025-06-01"));
847        assert!(is_iso_date("2025-12-31"));
848    }
849
850    #[test]
851    fn iso_date_with_time_rejected() {
852        // Only exact YYYY-MM-DD (10 chars) is accepted
853        assert!(!is_iso_date("2025-06-01T00:00:00"));
854    }
855
856    #[test]
857    fn iso_date_invalid() {
858        assert!(!is_iso_date("6months"));
859        assert!(!is_iso_date("2025"));
860        assert!(!is_iso_date("not-a-date"));
861        assert!(!is_iso_date("abcd-ef-gh"));
862    }
863
864    // ── Display ──────────────────────────────────────────────────
865
866    #[test]
867    fn trend_display() {
868        assert_eq!(ChurnTrend::Accelerating.to_string(), "accelerating");
869        assert_eq!(ChurnTrend::Stable.to_string(), "stable");
870        assert_eq!(ChurnTrend::Cooling.to_string(), "cooling");
871    }
872
873    // ── parse_git_log ───────────────────────────────────────────
874
875    #[test]
876    fn parse_git_log_single_commit() {
877        let root = Path::new("/project");
878        let output = "1700000000\n10\t5\tsrc/index.ts\n";
879        let (result, _) = parse_git_log(output, root);
880        assert_eq!(result.len(), 1);
881        let churn = &result[&PathBuf::from("/project/src/index.ts")];
882        assert_eq!(churn.commits, 1);
883        assert_eq!(churn.lines_added, 10);
884        assert_eq!(churn.lines_deleted, 5);
885    }
886
887    #[test]
888    fn parse_git_log_multiple_commits_same_file() {
889        let root = Path::new("/project");
890        let output = "1700000000\n10\t5\tsrc/index.ts\n\n1700100000\n3\t2\tsrc/index.ts\n";
891        let (result, _) = parse_git_log(output, root);
892        assert_eq!(result.len(), 1);
893        let churn = &result[&PathBuf::from("/project/src/index.ts")];
894        assert_eq!(churn.commits, 2);
895        assert_eq!(churn.lines_added, 13);
896        assert_eq!(churn.lines_deleted, 7);
897    }
898
899    #[test]
900    fn parse_git_log_multiple_files() {
901        let root = Path::new("/project");
902        let output = "1700000000\n10\t5\tsrc/a.ts\n3\t1\tsrc/b.ts\n";
903        let (result, _) = parse_git_log(output, root);
904        assert_eq!(result.len(), 2);
905        assert!(result.contains_key(&PathBuf::from("/project/src/a.ts")));
906        assert!(result.contains_key(&PathBuf::from("/project/src/b.ts")));
907    }
908
909    #[test]
910    fn parse_git_log_empty_output() {
911        let root = Path::new("/project");
912        let (result, _) = parse_git_log("", root);
913        assert!(result.is_empty());
914    }
915
916    #[test]
917    fn parse_git_log_skips_binary_files() {
918        let root = Path::new("/project");
919        let output = "1700000000\n-\t-\timage.png\n10\t5\tsrc/a.ts\n";
920        let (result, _) = parse_git_log(output, root);
921        assert_eq!(result.len(), 1);
922        assert!(!result.contains_key(&PathBuf::from("/project/image.png")));
923    }
924
925    #[test]
926    fn parse_git_log_weighted_commits_are_positive() {
927        let root = Path::new("/project");
928        // Use a timestamp near "now" to ensure weight doesn't decay to zero
929        let now_secs = std::time::SystemTime::now()
930            .duration_since(std::time::UNIX_EPOCH)
931            .unwrap()
932            .as_secs();
933        let output = format!("{now_secs}\n10\t5\tsrc/a.ts\n");
934        let (result, _) = parse_git_log(&output, root);
935        let churn = &result[&PathBuf::from("/project/src/a.ts")];
936        assert!(
937            churn.weighted_commits > 0.0,
938            "weighted_commits should be positive for recent commits"
939        );
940    }
941
942    // ── compute_trend edge cases ─────────────────────────────────
943
944    #[test]
945    fn trend_boundary_1_5x_ratio() {
946        // Exactly 1.5x ratio (3 recent : 2 old) → boundary between stable and accelerating
947        // midpoint = 100 + (1000-100)/2 = 550
948        // old: 100, 200 (2 timestamps <= 550)
949        // recent: 600, 800, 1000 (3 timestamps > 550)
950        // ratio = 3/2 = 1.5 — NOT > 1.5, so stable
951        let timestamps = vec![100, 200, 600, 800, 1000];
952        assert_eq!(compute_trend(&timestamps), ChurnTrend::Stable);
953    }
954
955    #[test]
956    fn trend_just_above_1_5x() {
957        // midpoint = 100 + (1000-100)/2 = 550
958        // old: 100 (1 timestamp <= 550)
959        // recent: 600, 800, 1000 (3 timestamps > 550)
960        // ratio = 3/1 = 3.0 → accelerating
961        let timestamps = vec![100, 600, 800, 1000];
962        assert_eq!(compute_trend(&timestamps), ChurnTrend::Accelerating);
963    }
964
965    #[test]
966    fn trend_boundary_0_67x_ratio() {
967        // Exactly 0.67x ratio → boundary between cooling and stable
968        // midpoint = 100 + (1000-100)/2 = 550
969        // old: 100, 200, 300 (3 timestamps <= 550)
970        // recent: 600, 1000 (2 timestamps > 550)
971        // ratio = 2/3 = 0.666... < 0.67 → cooling
972        let timestamps = vec![100, 200, 300, 600, 1000];
973        assert_eq!(compute_trend(&timestamps), ChurnTrend::Cooling);
974    }
975
976    #[test]
977    fn trend_two_timestamps_different() {
978        // Only 2 timestamps: midpoint = 100 + (200-100)/2 = 150
979        // old: 100 (1 timestamp <= 150)
980        // recent: 200 (1 timestamp > 150)
981        // ratio = 1/1 = 1.0 → stable
982        let timestamps = vec![100, 200];
983        assert_eq!(compute_trend(&timestamps), ChurnTrend::Stable);
984    }
985
986    // ── parse_since additional coverage ─────────────────────────
987
988    #[test]
989    fn parse_since_week_singular() {
990        let d = parse_since("1week").unwrap();
991        assert_eq!(d.git_after, "1 week ago");
992        assert_eq!(d.display, "1 week");
993    }
994
995    #[test]
996    fn parse_since_weeks_long() {
997        let d = parse_since("3weeks").unwrap();
998        assert_eq!(d.git_after, "3 weeks ago");
999        assert_eq!(d.display, "3 weeks");
1000    }
1001
1002    #[test]
1003    fn parse_since_days_long() {
1004        let d = parse_since("30days").unwrap();
1005        assert_eq!(d.git_after, "30 days ago");
1006        assert_eq!(d.display, "30 days");
1007    }
1008
1009    #[test]
1010    fn parse_since_year_long() {
1011        let d = parse_since("1year").unwrap();
1012        assert_eq!(d.git_after, "1 year ago");
1013        assert_eq!(d.display, "1 year");
1014    }
1015
1016    #[test]
1017    fn parse_since_overflow_number_rejected() {
1018        // Number too large for u64
1019        let result = parse_since("99999999999999999999d");
1020        assert!(result.is_err());
1021        let err = result.unwrap_err();
1022        assert!(err.contains("invalid number"));
1023    }
1024
1025    #[test]
1026    fn parse_since_zero_days_rejected() {
1027        assert!(parse_since("0d").is_err());
1028    }
1029
1030    #[test]
1031    fn parse_since_zero_weeks_rejected() {
1032        assert!(parse_since("0w").is_err());
1033    }
1034
1035    #[test]
1036    fn parse_since_zero_years_rejected() {
1037        assert!(parse_since("0y").is_err());
1038    }
1039
1040    // ── parse_numstat_line additional coverage ──────────────────
1041
1042    #[test]
1043    fn numstat_missing_path() {
1044        // Only two tab-separated fields, no path
1045        assert!(parse_numstat_line("10\t5").is_none());
1046    }
1047
1048    #[test]
1049    fn numstat_single_field() {
1050        assert!(parse_numstat_line("10").is_none());
1051    }
1052
1053    #[test]
1054    fn numstat_empty_string() {
1055        assert!(parse_numstat_line("").is_none());
1056    }
1057
1058    #[test]
1059    fn numstat_only_added_is_binary() {
1060        // Added is "-" but deleted is numeric
1061        assert!(parse_numstat_line("-\t5\tsrc/file.ts").is_none());
1062    }
1063
1064    #[test]
1065    fn numstat_only_deleted_is_binary() {
1066        // Added is numeric but deleted is "-"
1067        assert!(parse_numstat_line("10\t-\tsrc/file.ts").is_none());
1068    }
1069
1070    #[test]
1071    fn numstat_path_with_spaces() {
1072        let (a, d, p) = parse_numstat_line("3\t1\tpath with spaces/file.ts").unwrap();
1073        assert_eq!(a, 3);
1074        assert_eq!(d, 1);
1075        assert_eq!(p, "path with spaces/file.ts");
1076    }
1077
1078    #[test]
1079    fn numstat_large_numbers() {
1080        let (a, d, p) = parse_numstat_line("9999\t8888\tsrc/big.ts").unwrap();
1081        assert_eq!(a, 9999);
1082        assert_eq!(d, 8888);
1083        assert_eq!(p, "src/big.ts");
1084    }
1085
1086    // ── is_iso_date additional coverage ─────────────────────────
1087
1088    #[test]
1089    fn iso_date_wrong_separator_positions() {
1090        // Dashes in wrong positions
1091        assert!(!is_iso_date("20-25-0601"));
1092        assert!(!is_iso_date("202506-01-"));
1093    }
1094
1095    #[test]
1096    fn iso_date_too_short() {
1097        assert!(!is_iso_date("2025-06-0"));
1098    }
1099
1100    #[test]
1101    fn iso_date_letters_in_day() {
1102        assert!(!is_iso_date("2025-06-ab"));
1103    }
1104
1105    #[test]
1106    fn iso_date_letters_in_month() {
1107        assert!(!is_iso_date("2025-ab-01"));
1108    }
1109
1110    // ── split_number_unit additional coverage ───────────────────
1111
1112    #[test]
1113    fn split_number_unit_valid() {
1114        let (num, unit) = split_number_unit("42days").unwrap();
1115        assert_eq!(num, "42");
1116        assert_eq!(unit, "days");
1117    }
1118
1119    #[test]
1120    fn split_number_unit_single_digit() {
1121        let (num, unit) = split_number_unit("1m").unwrap();
1122        assert_eq!(num, "1");
1123        assert_eq!(unit, "m");
1124    }
1125
1126    #[test]
1127    fn split_number_unit_no_digits() {
1128        let err = split_number_unit("abc").unwrap_err();
1129        assert!(err.contains("must start with a number"));
1130    }
1131
1132    #[test]
1133    fn split_number_unit_no_unit() {
1134        let err = split_number_unit("123").unwrap_err();
1135        assert!(err.contains("requires a unit suffix"));
1136    }
1137
1138    // ── parse_git_log additional coverage ───────────────────────
1139
1140    #[test]
1141    fn parse_git_log_numstat_before_timestamp_uses_now() {
1142        let root = Path::new("/project");
1143        // No timestamp line before the numstat line
1144        let output = "10\t5\tsrc/no_ts.ts\n";
1145        let (result, _) = parse_git_log(output, root);
1146        assert_eq!(result.len(), 1);
1147        let churn = &result[&PathBuf::from("/project/src/no_ts.ts")];
1148        assert_eq!(churn.commits, 1);
1149        assert_eq!(churn.lines_added, 10);
1150        assert_eq!(churn.lines_deleted, 5);
1151        // Without a timestamp, it falls back to now_secs, so weight should be ~1.0
1152        assert!(
1153            churn.weighted_commits > 0.9,
1154            "weight should be near 1.0 when timestamp defaults to now"
1155        );
1156    }
1157
1158    #[test]
1159    fn parse_git_log_whitespace_lines_ignored() {
1160        let root = Path::new("/project");
1161        let output = "  \n1700000000\n  \n10\t5\tsrc/a.ts\n  \n";
1162        let (result, _) = parse_git_log(output, root);
1163        assert_eq!(result.len(), 1);
1164    }
1165
1166    #[test]
1167    fn parse_git_log_trend_is_computed_per_file() {
1168        let root = Path::new("/project");
1169        // Two commits far apart for one file, recent-heavy for another
1170        let output = "\
11711000\n5\t1\tsrc/old.ts\n\
11722000\n3\t1\tsrc/old.ts\n\
11731000\n1\t0\tsrc/hot.ts\n\
11741800\n1\t0\tsrc/hot.ts\n\
11751900\n1\t0\tsrc/hot.ts\n\
11761950\n1\t0\tsrc/hot.ts\n\
11772000\n1\t0\tsrc/hot.ts\n";
1178        let (result, _) = parse_git_log(output, root);
1179        let old = &result[&PathBuf::from("/project/src/old.ts")];
1180        let hot = &result[&PathBuf::from("/project/src/hot.ts")];
1181        assert_eq!(old.commits, 2);
1182        assert_eq!(hot.commits, 5);
1183        // hot.ts has 4 recent vs 1 old => accelerating
1184        assert_eq!(hot.trend, ChurnTrend::Accelerating);
1185    }
1186
1187    #[test]
1188    fn parse_git_log_weighted_decay_for_old_commits() {
1189        let root = Path::new("/project");
1190        let now = std::time::SystemTime::now()
1191            .duration_since(std::time::UNIX_EPOCH)
1192            .unwrap()
1193            .as_secs();
1194        // One commit from 180 days ago (two half-lives) should weigh ~0.25
1195        let old_ts = now - (180 * 86_400);
1196        let output = format!("{old_ts}\n10\t5\tsrc/old.ts\n");
1197        let (result, _) = parse_git_log(&output, root);
1198        let churn = &result[&PathBuf::from("/project/src/old.ts")];
1199        assert!(
1200            churn.weighted_commits < 0.5,
1201            "180-day-old commit should weigh ~0.25, got {}",
1202            churn.weighted_commits
1203        );
1204        assert!(
1205            churn.weighted_commits > 0.1,
1206            "180-day-old commit should weigh ~0.25, got {}",
1207            churn.weighted_commits
1208        );
1209    }
1210
1211    #[test]
1212    fn parse_git_log_path_stored_as_absolute() {
1213        let root = Path::new("/my/project");
1214        let output = "1700000000\n1\t0\tlib/utils.ts\n";
1215        let (result, _) = parse_git_log(output, root);
1216        let key = PathBuf::from("/my/project/lib/utils.ts");
1217        assert!(result.contains_key(&key));
1218        assert_eq!(result[&key].path, key);
1219    }
1220
1221    #[test]
1222    fn parse_git_log_weighted_commits_rounded() {
1223        let root = Path::new("/project");
1224        let now = std::time::SystemTime::now()
1225            .duration_since(std::time::UNIX_EPOCH)
1226            .unwrap()
1227            .as_secs();
1228        // A commit right now should weigh exactly 1.00
1229        let output = format!("{now}\n1\t0\tsrc/a.ts\n");
1230        let (result, _) = parse_git_log(&output, root);
1231        let churn = &result[&PathBuf::from("/project/src/a.ts")];
1232        // Weighted commits are rounded to 2 decimal places
1233        let decimals = format!("{:.2}", churn.weighted_commits);
1234        assert_eq!(
1235            churn.weighted_commits.to_string().len(),
1236            decimals.len().min(churn.weighted_commits.to_string().len()),
1237            "weighted_commits should be rounded to at most 2 decimal places"
1238        );
1239    }
1240
1241    // ── ChurnTrend serde ────────────────────────────────────────
1242
1243    #[test]
1244    fn trend_serde_serialization() {
1245        assert_eq!(
1246            serde_json::to_string(&ChurnTrend::Accelerating).unwrap(),
1247            "\"accelerating\""
1248        );
1249        assert_eq!(
1250            serde_json::to_string(&ChurnTrend::Stable).unwrap(),
1251            "\"stable\""
1252        );
1253        assert_eq!(
1254            serde_json::to_string(&ChurnTrend::Cooling).unwrap(),
1255            "\"cooling\""
1256        );
1257    }
1258
1259    // ── parse_git_log: author tracking ──────────────────────────
1260
1261    #[test]
1262    fn parse_git_log_extracts_author_email() {
1263        let root = Path::new("/project");
1264        let output = "1700000000|alice@example.com\n10\t5\tsrc/index.ts\n";
1265        let (result, pool) = parse_git_log(output, root);
1266        assert_eq!(pool, vec!["alice@example.com".to_string()]);
1267        let churn = &result[&PathBuf::from("/project/src/index.ts")];
1268        assert_eq!(churn.authors.len(), 1);
1269        let alice = &churn.authors[&0];
1270        assert_eq!(alice.commits, 1);
1271        assert_eq!(alice.first_commit_ts, 1_700_000_000);
1272        assert_eq!(alice.last_commit_ts, 1_700_000_000);
1273    }
1274
1275    #[test]
1276    fn parse_git_log_intern_dedupes_authors() {
1277        let root = Path::new("/project");
1278        let output = "\
12791700000000|alice@example.com
12801\t0\ta.ts
12811700100000|bob@example.com
12822\t1\tb.ts
12831700200000|alice@example.com
12843\t2\tc.ts
1285";
1286        let (_result, pool) = parse_git_log(output, root);
1287        assert_eq!(pool.len(), 2);
1288        assert!(pool.contains(&"alice@example.com".to_string()));
1289        assert!(pool.contains(&"bob@example.com".to_string()));
1290    }
1291
1292    #[test]
1293    fn parse_git_log_aggregates_per_author() {
1294        let root = Path::new("/project");
1295        // alice touches index.ts twice, bob once.
1296        let output = "\
12971700000000|alice@example.com
12981\t0\tsrc/index.ts
12991700100000|bob@example.com
13002\t0\tsrc/index.ts
13011700200000|alice@example.com
13021\t1\tsrc/index.ts
1303";
1304        let (result, pool) = parse_git_log(output, root);
1305        let churn = &result[&PathBuf::from("/project/src/index.ts")];
1306        assert_eq!(churn.commits, 3);
1307        assert_eq!(churn.authors.len(), 2);
1308
1309        let alice_idx =
1310            u32::try_from(pool.iter().position(|a| a == "alice@example.com").unwrap()).unwrap();
1311        let alice = &churn.authors[&alice_idx];
1312        assert_eq!(alice.commits, 2);
1313        assert_eq!(alice.first_commit_ts, 1_700_000_000);
1314        assert_eq!(alice.last_commit_ts, 1_700_200_000);
1315    }
1316
1317    #[test]
1318    fn parse_git_log_legacy_bare_timestamp_still_parses() {
1319        // Backwards-compat path: header has no `|email` suffix.
1320        let root = Path::new("/project");
1321        let output = "1700000000\n10\t5\tsrc/index.ts\n";
1322        let (result, pool) = parse_git_log(output, root);
1323        assert!(pool.is_empty());
1324        let churn = &result[&PathBuf::from("/project/src/index.ts")];
1325        assert_eq!(churn.commits, 1);
1326        assert!(churn.authors.is_empty());
1327    }
1328
1329    // ── intern_author ──────────────────────────────────────────
1330
1331    #[test]
1332    fn intern_author_returns_existing_index() {
1333        let mut pool = Vec::new();
1334        let mut index = FxHashMap::default();
1335        let i1 = intern_author("alice@x", &mut pool, &mut index);
1336        let i2 = intern_author("alice@x", &mut pool, &mut index);
1337        assert_eq!(i1, i2);
1338        assert_eq!(pool.len(), 1);
1339    }
1340
1341    #[test]
1342    fn intern_author_assigns_sequential_indices() {
1343        let mut pool = Vec::new();
1344        let mut index = FxHashMap::default();
1345        assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1346        assert_eq!(intern_author("bob@x", &mut pool, &mut index), 1);
1347        assert_eq!(intern_author("carol@x", &mut pool, &mut index), 2);
1348        assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1349    }
1350
1351    // ── incremental cache ───────────────────────────────────────
1352
1353    fn git(root: &Path, args: &[&str]) {
1354        let status = std::process::Command::new("git")
1355            .args(args)
1356            .current_dir(root)
1357            .status()
1358            .expect("run git");
1359        assert!(status.success(), "git {args:?} failed");
1360    }
1361
1362    fn write(root: &Path, path: &str, contents: &str) {
1363        let path = root.join(path);
1364        std::fs::create_dir_all(path.parent().expect("test path has parent")).unwrap();
1365        std::fs::write(path, contents).unwrap();
1366    }
1367
1368    #[test]
1369    fn cached_churn_merges_new_commits_after_head_advances() {
1370        let repo = tempfile::tempdir().expect("create repo");
1371        let root = repo.path();
1372        git(root, &["init"]);
1373        git(root, &["config", "user.email", "churn@example.test"]);
1374        git(root, &["config", "user.name", "Churn Test"]);
1375        git(root, &["config", "commit.gpgsign", "false"]);
1376
1377        write(root, "src/a.ts", "export const a = 1;\n");
1378        git(root, &["add", "."]);
1379        git(root, &["commit", "-m", "initial"]);
1380
1381        let since = parse_since("1y").unwrap();
1382        let cache = tempfile::tempdir().expect("create cache dir");
1383        let (cold, cold_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1384        assert!(!cold_hit);
1385        let file = root.join("src/a.ts");
1386        assert_eq!(cold.files[&file].commits, 1);
1387
1388        let (_warm, warm_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1389        assert!(warm_hit);
1390
1391        write(
1392            root,
1393            "src/a.ts",
1394            "export const a = 1;\nexport const b = 2;\n",
1395        );
1396        git(root, &["add", "."]);
1397        git(root, &["commit", "-m", "update a"]);
1398        let head = get_head_sha(root).unwrap();
1399
1400        let (incremental, incremental_hit) =
1401            analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1402        assert!(incremental_hit);
1403        assert_eq!(incremental.files[&file].commits, 2);
1404
1405        let cache = load_churn_cache(cache.path(), &since.git_after).unwrap();
1406        assert_eq!(cache.last_indexed_sha, head);
1407    }
1408}