1use 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
18pub type ChurnSpawnHook = fn(&mut Command) -> std::io::Result<Output>;
24
25static SPAWN_HOOK: OnceLock<ChurnSpawnHook> = OnceLock::new();
26
27fn git_command() -> Command {
28 crate::git_env::git_command()
29}
30
31pub 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
48const SECS_PER_DAY: f64 = 86_400.0;
50
51const HALF_LIFE_DAYS: f64 = 90.0;
54
55const CHURN_FILE_SCHEMA: &str = "fallow-churn/v1";
57
58const MAX_CHURN_EVENTS: usize = 5_000_000;
64
65const MAX_CHURN_FILE_BYTES: usize = 256 * 1024 * 1024;
67
68const MAX_FUTURE_TIMESTAMP_SECS: u64 = 365 * 24 * 60 * 60;
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ChurnWindowUnit {
80 Days,
82 Weeks,
84 Months,
86 Years,
88}
89
90impl ChurnWindowUnit {
91 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#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum ChurnWindow {
109 Relative {
111 count: u64,
113 unit: ChurnWindowUnit,
115 },
116 Date(String),
118 Imported,
120}
121
122impl ChurnWindow {
123 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct SinceDuration {
154 pub window: ChurnWindow,
156 pub display: String,
158}
159
160impl SinceDuration {
161 #[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#[derive(Debug, Clone, Copy, PartialEq)]
176pub struct AuthorContribution {
177 pub commits: u32,
179 pub weighted_commits: f64,
181 pub first_commit_ts: u64,
183 pub last_commit_ts: u64,
185}
186
187#[derive(Debug, Clone)]
189pub struct FileChurn {
190 pub path: PathBuf,
192 pub commits: u32,
194 pub weighted_commits: f64,
196 pub lines_added: u32,
198 pub lines_deleted: u32,
200 pub trend: ChurnTrend,
202 pub authors: FxHashMap<u32, AuthorContribution>,
205}
206
207#[derive(Debug, Clone)]
209pub struct ChurnResult {
210 pub files: FxHashMap<PathBuf, FileChurn>,
212 pub shallow_clone: bool,
214 pub author_pool: Vec<String>,
217 pub clock: crate::clock::AnalysisClock,
221 pub git_log_bytes: u64,
224}
225
226pub fn parse_since(input: &str) -> Result<SinceDuration, String> {
237 if is_iso_date(input) {
238 return Ok(SinceDuration {
239 window: ChurnWindow::Date(input.to_string()),
240 display: input.to_string(),
241 });
242 }
243
244 let (num_str, unit) = split_number_unit(input)?;
245 let num: u64 = num_str
246 .parse()
247 .map_err(|_| format!("invalid number in --since: {input}"))?;
248
249 if num == 0 {
250 return Err("--since duration must be greater than 0".to_string());
251 }
252
253 let (unit, label) = match unit {
254 "d" | "day" | "days" => (ChurnWindowUnit::Days, "day"),
255 "w" | "week" | "weeks" => (ChurnWindowUnit::Weeks, "week"),
256 "m" | "month" | "months" => (ChurnWindowUnit::Months, "month"),
257 "y" | "year" | "years" => (ChurnWindowUnit::Years, "year"),
258 _ => {
259 return Err(format!(
260 "unknown duration unit '{unit}' in --since. Use d/w/m/y (e.g., 6m, 90d, 1y)"
261 ));
262 }
263 };
264 let plural = if num == 1 { "" } else { "s" };
265 Ok(SinceDuration::relative(
266 num,
267 unit,
268 format!("{num} {label}{plural}"),
269 ))
270}
271
272pub fn analyze_churn(root: &Path, since: &SinceDuration) -> Option<ChurnResult> {
276 let clock = crate::clock::AnalysisClock::for_repo(root);
277 let shallow = is_shallow_clone(root);
278 let state = analyze_churn_events(root, since, None, &clock)?;
279 Some(build_churn_result(state, shallow, clock))
280}
281
282#[derive(Debug, Deserialize)]
288struct ChurnFileDoc {
289 schema: String,
290 #[serde(default)]
291 events: Vec<ChurnFileEvent>,
292}
293
294#[derive(Debug, Deserialize)]
299struct ChurnFileEvent {
300 path: String,
302 timestamp: u64,
304 #[serde(default)]
307 author: Option<String>,
308 added: u32,
310 deleted: u32,
312}
313
314pub(crate) fn analyze_churn_from_file(path: &Path, root: &Path) -> Result<ChurnResult, String> {
328 let raw = read_churn_file_with_limit(path, MAX_CHURN_FILE_BYTES)?;
329 let doc: ChurnFileDoc = serde_json::from_str(&raw)
330 .map_err(|e| format!("failed to parse churn file {}: {e}", path.display()))?;
331 if doc.schema != CHURN_FILE_SCHEMA {
332 return Err(format!(
333 "churn file {} declares schema \"{}\", expected \"{CHURN_FILE_SCHEMA}\"",
334 path.display(),
335 doc.schema
336 ));
337 }
338 if doc.events.len() > MAX_CHURN_EVENTS {
339 return Err(format!(
340 "churn file {} has {} events, exceeding the {MAX_CHURN_EVENTS} limit",
341 path.display(),
342 doc.events.len()
343 ));
344 }
345
346 let state = churn_event_state_from_doc(&doc, path, root)?;
347 Ok(build_churn_result(
348 state,
349 false,
350 crate::clock::AnalysisClock::for_repo(root),
351 ))
352}
353
354fn read_churn_file_with_limit(path: &Path, limit: usize) -> Result<String, String> {
355 let file = std::fs::File::open(path)
356 .map_err(|e| format!("failed to read churn file {}: {e}", path.display()))?;
357 let mut bytes = Vec::new();
358 file.take(limit as u64 + 1)
359 .read_to_end(&mut bytes)
360 .map_err(|e| format!("failed to read churn file {}: {e}", path.display()))?;
361 if bytes.len() > limit {
362 return Err(format!(
363 "churn file {} is at least {} bytes, exceeding the {limit} byte limit",
364 path.display(),
365 bytes.len()
366 ));
367 }
368 String::from_utf8(bytes)
369 .map_err(|e| format!("failed to read churn file {} as UTF-8: {e}", path.display()))
370}
371
372fn churn_event_state_from_doc(
378 doc: &ChurnFileDoc,
379 path: &Path,
380 root: &Path,
381) -> Result<ChurnEventState, String> {
382 let mut builder = ChurnFileImportBuilder::new(path, root, churn_file_future_limit());
383
384 for event in &doc.events {
385 builder.push_event(event)?;
386 }
387
388 Ok(builder.finish())
389}
390
391fn churn_file_future_limit() -> u64 {
404 let now_secs = std::time::SystemTime::now()
405 .duration_since(std::time::UNIX_EPOCH)
406 .unwrap_or_default()
407 .as_secs();
408 now_secs.saturating_add(MAX_FUTURE_TIMESTAMP_SECS)
409}
410
411struct ChurnFileImportBuilder<'a> {
412 path: &'a Path,
413 root: &'a Path,
414 future_limit: u64,
415 files: FxHashMap<PathBuf, FileEvents>,
416 totals: FxHashMap<PathBuf, (u64, u64)>,
417 author_pool: Vec<String>,
418 author_index: FxHashMap<String, u32>,
419}
420
421impl<'a> ChurnFileImportBuilder<'a> {
422 fn new(path: &'a Path, root: &'a Path, future_limit: u64) -> Self {
423 Self {
424 path,
425 root,
426 future_limit,
427 files: FxHashMap::default(),
428 totals: FxHashMap::default(),
429 author_pool: Vec::new(),
430 author_index: FxHashMap::default(),
431 }
432 }
433
434 fn push_event(&mut self, event: &ChurnFileEvent) -> Result<(), String> {
435 let rel = normalize_churn_event_path(self.path, &event.path)?;
436 validate_churn_event_timestamp(self.path, event.timestamp, self.future_limit, &rel)?;
437
438 let abs_path = self.root.join(&rel);
439 let totals = self.totals.entry(abs_path.clone()).or_default();
440 totals.0 += u64::from(event.added);
441 totals.1 += u64::from(event.deleted);
442 if totals.0 > u64::from(u32::MAX) || totals.1 > u64::from(u32::MAX) {
443 return Err(format!(
444 "churn file {} has line totals for \"{rel}\" exceeding the u32 limit \
445 (added {}, deleted {})",
446 self.path.display(),
447 totals.0,
448 totals.1
449 ));
450 }
451 let author_idx = self.intern_author(event.author.as_deref());
452 self.files
453 .entry(abs_path)
454 .or_insert_with(|| FileEvents { events: Vec::new() })
455 .events
456 .push(CachedCommitEvent {
457 timestamp: event.timestamp,
458 committed_at: event.timestamp,
459 lines_added: event.added,
460 lines_deleted: event.deleted,
461 author_idx,
462 });
463 Ok(())
464 }
465
466 fn intern_author(&mut self, author: Option<&str>) -> Option<u32> {
467 author
468 .map(str::trim)
469 .filter(|email| !email.is_empty())
470 .map(|email| intern_author(email, &mut self.author_pool, &mut self.author_index))
471 }
472
473 fn finish(self) -> ChurnEventState {
474 ChurnEventState {
475 files: self.files,
476 author_pool: self.author_pool,
477 git_log_bytes: 0,
478 }
479 }
480}
481
482fn normalize_churn_event_path(path: &Path, event_path: &str) -> Result<String, String> {
483 let normalized = event_path.replace('\\', "/");
484 let rel = normalized.trim();
485 if rel.is_empty() {
486 return Err(format!(
487 "churn file {} has an event with an empty path",
488 path.display()
489 ));
490 }
491 let bytes = rel.as_bytes();
492 let has_drive_prefix = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':';
493 let has_empty_component = rel.split('/').any(str::is_empty);
494 let components_are_normal = Path::new(rel)
495 .components()
496 .all(|component| matches!(component, Component::Normal(_)));
497 if rel.starts_with('/') || has_drive_prefix || has_empty_component || !components_are_normal {
498 return Err(format!(
499 "churn file {} has an invalid repo-relative event path \"{event_path}\"",
500 path.display()
501 ));
502 }
503 Ok(rel.to_string())
504}
505
506fn validate_churn_event_timestamp(
507 path: &Path,
508 timestamp: u64,
509 future_limit: u64,
510 rel: &str,
511) -> Result<(), String> {
512 if timestamp <= future_limit {
513 return Ok(());
514 }
515
516 Err(format!(
517 "churn file {} has event timestamp {} for \"{rel}\" more than a year in the \
518 future; timestamps must be unix SECONDS (not milliseconds), UTC",
519 path.display(),
520 timestamp
521 ))
522}
523
524#[must_use]
526fn is_shallow_clone(root: &Path) -> bool {
527 let mut command = git_command();
528 command
529 .args(["rev-parse", "--is-shallow-repository"])
530 .current_dir(root);
531 command.output().is_ok_and(|o| {
532 String::from_utf8_lossy(&o.stdout)
533 .trim()
534 .eq_ignore_ascii_case("true")
535 })
536}
537
538#[must_use]
540pub fn is_git_repo(root: &Path) -> bool {
541 let mut command = git_command();
542 command
543 .args(["rev-parse", "--git-dir"])
544 .current_dir(root)
545 .stdout(std::process::Stdio::null())
546 .stderr(std::process::Stdio::null());
547 command.status().is_ok_and(|s| s.success())
548}
549
550const MAX_CHURN_CACHE_SIZE: usize = 64 * 1024 * 1024;
553
554const CHURN_CACHE_VERSION: u8 = 7;
563
564#[derive(Clone, bitcode::Encode, bitcode::Decode)]
566struct CachedCommitEvent {
567 timestamp: u64,
568 committed_at: u64,
572 lines_added: u32,
573 lines_deleted: u32,
574 author_idx: Option<u32>,
575}
576
577#[derive(Clone, bitcode::Encode, bitcode::Decode)]
579struct CachedFileChurn {
580 path: Vec<u8>,
581 events: Vec<CachedCommitEvent>,
582}
583
584#[derive(Clone, bitcode::Encode, bitcode::Decode)]
586struct ChurnCache {
587 version: u8,
589 last_indexed_sha: String,
590 window_token: String,
592 files: Vec<CachedFileChurn>,
593 shallow_clone: bool,
594 author_pool: Vec<String>,
596}
597
598struct FileEvents {
600 events: Vec<CachedCommitEvent>,
601}
602
603struct ChurnEventState {
606 files: FxHashMap<PathBuf, FileEvents>,
607 author_pool: Vec<String>,
608 git_log_bytes: u64,
611}
612
613fn get_head_sha(root: &Path) -> Option<String> {
615 crate::repo_refs::head_sha(root).ok().flatten()
616}
617
618fn is_ancestor(root: &Path, ancestor: &str, descendant: &str) -> bool {
620 let mut command = git_command();
621 command
622 .args(["merge-base", "--is-ancestor", ancestor, descendant])
623 .current_dir(root);
624 command.status().is_ok_and(|s| s.success())
625}
626
627fn load_churn_cache(cache_dir: &Path, window_token: &str) -> Option<ChurnCache> {
630 let cache_file = cache_dir.join("churn.bin");
631 let data = std::fs::read(&cache_file).ok()?;
632 if data.len() > MAX_CHURN_CACHE_SIZE {
633 return None;
634 }
635 let cache: ChurnCache = bitcode::decode(&data).ok()?;
636 if cache.version != CHURN_CACHE_VERSION || cache.window_token != window_token {
637 return None;
638 }
639 Some(cache)
640}
641
642fn save_churn_cache(
644 cache_dir: &Path,
645 last_indexed_sha: &str,
646 window_token: &str,
647 state: &ChurnEventState,
648 shallow_clone: bool,
649) {
650 let files: Vec<CachedFileChurn> = state
651 .files
652 .iter()
653 .map(|f| CachedFileChurn {
654 path: path_to_cache_bytes(f.0),
655 events: f.1.events.clone(),
656 })
657 .collect();
658 let cache = ChurnCache {
659 version: CHURN_CACHE_VERSION,
660 last_indexed_sha: last_indexed_sha.to_string(),
661 window_token: window_token.to_string(),
662 files,
663 shallow_clone,
664 author_pool: state.author_pool.clone(),
665 };
666 let _ = std::fs::create_dir_all(cache_dir);
667 let data = bitcode::encode(&cache);
668 let tmp = cache_dir.join("churn.bin.tmp");
669 if std::fs::write(&tmp, data).is_ok() {
670 let _ = std::fs::rename(&tmp, cache_dir.join("churn.bin"));
671 }
672}
673
674pub fn analyze_churn_cached(
682 root: &Path,
683 since: &SinceDuration,
684 cache_dir: &Path,
685 no_cache: bool,
686) -> Option<(ChurnResult, bool)> {
687 let head_sha = get_head_sha(root)?;
688 let clock = crate::clock::AnalysisClock::for_repo(root);
689
690 if !no_cache
691 && let Some(result) = try_reuse_churn_cache(root, since, cache_dir, &head_sha, &clock)
692 {
693 return Some((result, true));
694 }
695
696 analyze_fresh_churn(root, since, cache_dir, no_cache, &head_sha, &clock)
697 .map(|result| (result, false))
698}
699
700fn try_reuse_churn_cache(
701 root: &Path,
702 since: &SinceDuration,
703 cache_dir: &Path,
704 head_sha: &str,
705 clock: &crate::clock::AnalysisClock,
706) -> Option<ChurnResult> {
707 let cache = load_churn_cache(cache_dir, &since.window.cache_token())?;
708 let cutoff = since.window.cutoff_secs(clock);
709 if cache.last_indexed_sha == head_sha {
710 let shallow_clone = cache.shallow_clone;
711 return Some(build_churn_result(
712 cache.into_event_state(cutoff),
713 shallow_clone,
714 *clock,
715 ));
716 }
717
718 if !is_ancestor(root, &cache.last_indexed_sha, head_sha) {
719 return None;
720 }
721
722 extend_churn_cache(root, since, cache_dir, head_sha, cache, clock)
723}
724
725fn extend_churn_cache(
726 root: &Path,
727 since: &SinceDuration,
728 cache_dir: &Path,
729 head_sha: &str,
730 cache: ChurnCache,
731 clock: &crate::clock::AnalysisClock,
732) -> Option<ChurnResult> {
733 let shallow_clone = is_shallow_clone(root);
734 let range = format!("{}..HEAD", cache.last_indexed_sha);
735 let delta = analyze_churn_events(root, since, Some(&range), clock)?;
736 let mut state = cache.into_event_state(since.window.cutoff_secs(clock));
737 merge_churn_states(&mut state, delta);
738 save_churn_cache(
739 cache_dir,
740 head_sha,
741 &since.window.cache_token(),
742 &state,
743 shallow_clone,
744 );
745 Some(build_churn_result(state, shallow_clone, *clock))
746}
747
748fn analyze_fresh_churn(
749 root: &Path,
750 since: &SinceDuration,
751 cache_dir: &Path,
752 no_cache: bool,
753 head_sha: &str,
754 clock: &crate::clock::AnalysisClock,
755) -> Option<ChurnResult> {
756 let shallow_clone = is_shallow_clone(root);
757 let state = analyze_churn_events(root, since, None, clock)?;
758 if !no_cache {
759 save_churn_cache(
760 cache_dir,
761 head_sha,
762 &since.window.cache_token(),
763 &state,
764 shallow_clone,
765 );
766 }
767
768 Some(build_churn_result(state, shallow_clone, *clock))
769}
770
771impl ChurnCache {
772 fn into_event_state(self, cutoff_secs: Option<u64>) -> ChurnEventState {
781 let files = self
782 .files
783 .into_iter()
784 .filter_map(|entry| {
785 let path = path_from_cache_bytes(&entry.path)?;
786 let mut events = entry.events;
787 if let Some(cutoff) = cutoff_secs {
788 events.retain(|event| event.committed_at >= cutoff);
789 }
790 (!events.is_empty()).then_some((path, FileEvents { events }))
791 })
792 .collect();
793 ChurnEventState {
794 files,
795 author_pool: self.author_pool,
796 git_log_bytes: 0,
797 }
798 }
799}
800
801#[cfg(unix)]
802fn path_to_cache_bytes(path: &Path) -> Vec<u8> {
803 use std::os::unix::ffi::OsStrExt;
804
805 path.as_os_str().as_bytes().to_vec()
806}
807
808#[cfg(unix)]
809#[allow(
810 clippy::unnecessary_wraps,
811 reason = "Windows rejects truncated UTF-16 bytes through this shared fallible contract"
812)]
813fn path_from_cache_bytes(path: &[u8]) -> Option<PathBuf> {
814 use std::ffi::OsStr;
815 use std::os::unix::ffi::OsStrExt;
816
817 Some(PathBuf::from(OsStr::from_bytes(path)))
818}
819
820#[cfg(windows)]
821fn path_to_cache_bytes(path: &Path) -> Vec<u8> {
822 use std::os::windows::ffi::OsStrExt;
823
824 path.as_os_str()
825 .encode_wide()
826 .flat_map(u16::to_le_bytes)
827 .collect()
828}
829
830#[cfg(windows)]
831fn path_from_cache_bytes(path: &[u8]) -> Option<PathBuf> {
832 use std::ffi::OsString;
833 use std::os::windows::ffi::OsStringExt;
834
835 let chunks = path.chunks_exact(2);
836 if !chunks.remainder().is_empty() {
837 return None;
838 }
839 let wide: Vec<u16> = chunks
840 .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
841 .collect();
842 Some(PathBuf::from(OsString::from_wide(&wide)))
843}
844
845fn analyze_churn_events(
851 root: &Path,
852 since: &SinceDuration,
853 revision_range: Option<&str>,
854 clock: &crate::clock::AnalysisClock,
855) -> Option<ChurnEventState> {
856 let mut command = git_command();
857 command.arg("log");
858 if let Some(range) = revision_range {
859 command.arg(range);
860 }
861 command
862 .args([
863 "--numstat",
864 "--no-merges",
865 "--no-renames",
866 "--use-mailmap",
867 "-z",
868 "--format=format:%at|%ct|%ae%x00",
869 ])
870 .current_dir(root);
871 if let Some(cutoff) = since.window.cutoff_secs(clock) {
872 command.arg(format!("--after=@{cutoff}"));
873 }
874 command.args(["--relative", "--", "."]);
879
880 let output = match spawn_output(&mut command) {
881 Ok(o) => o,
882 Err(e) => {
883 tracing::warn!("hotspot analysis skipped: failed to run git: {e}");
884 return None;
885 }
886 };
887
888 if !output.status.success() {
889 let stderr = String::from_utf8_lossy(&output.stderr);
890 tracing::warn!("hotspot analysis skipped: git log failed: {stderr}");
891 return None;
892 }
893
894 let mut state = parse_git_log_events_z(&output.stdout, root, clock.epoch_secs());
895 state.git_log_bytes = output.stdout.len() as u64;
896 Some(state)
897}
898
899fn merge_churn_states(base: &mut ChurnEventState, delta: ChurnEventState) {
901 base.git_log_bytes += delta.git_log_bytes;
902 let mut base_author_index: FxHashMap<String, u32> = base
903 .author_pool
904 .iter()
905 .enumerate()
906 .filter_map(|(idx, email)| u32::try_from(idx).ok().map(|idx| (email.clone(), idx)))
907 .collect();
908
909 let mut author_mapping: FxHashMap<u32, u32> = FxHashMap::default();
910 for (old_idx, email) in delta.author_pool.into_iter().enumerate() {
911 let Ok(old_idx) = u32::try_from(old_idx) else {
912 continue;
913 };
914 let new_idx = intern_author(&email, &mut base.author_pool, &mut base_author_index);
915 author_mapping.insert(old_idx, new_idx);
916 }
917
918 for (path, mut file) in delta.files {
919 for event in &mut file.events {
920 event.author_idx = event
921 .author_idx
922 .and_then(|idx| author_mapping.get(&idx).copied());
923 }
924 base.files
925 .entry(path)
926 .and_modify(|existing| existing.events.append(&mut file.events))
927 .or_insert(file);
928 }
929}
930
931fn parse_git_log_events_z(stdout: &[u8], root: &Path, now_secs: u64) -> ChurnEventState {
935 let mut parser = GitLogEventParser::new(root, now_secs);
936 for record in stdout.split(|byte| *byte == 0) {
937 let record = record.strip_prefix(b"\n").unwrap_or(record);
938 if record.is_empty() {
939 continue;
940 }
941 if record.contains(&b'\t') {
942 parser.record_numstat_bytes(record);
943 } else {
944 parser.consume_header(&String::from_utf8_lossy(record));
945 }
946 }
947 parser.finish()
948}
949
950struct GitLogEventParser<'a> {
951 root: &'a Path,
952 now_secs: u64,
953 files: FxHashMap<PathBuf, FileEvents>,
954 author_pool: Vec<String>,
955 author_index: FxHashMap<String, u32>,
956 current_timestamp: Option<u64>,
957 current_committed_at: Option<u64>,
958 current_author_idx: Option<u32>,
959}
960
961impl<'a> GitLogEventParser<'a> {
962 fn new(root: &'a Path, now_secs: u64) -> Self {
963 Self {
964 root,
965 now_secs,
966 files: FxHashMap::default(),
967 author_pool: Vec::new(),
968 author_index: FxHashMap::default(),
969 current_timestamp: None,
970 current_committed_at: None,
971 current_author_idx: None,
972 }
973 }
974
975 fn consume_header(&mut self, line: &str) {
978 let line = line.trim();
979 if line.is_empty() || self.record_commit_header(line) {
980 return;
981 }
982 self.record_legacy_timestamp(line);
983 }
984
985 fn record_commit_header(&mut self, line: &str) -> bool {
989 let Some((ts_str, rest)) = line.split_once('|') else {
990 return false;
991 };
992 let Ok(ts) = ts_str.parse::<u64>() else {
993 return false;
994 };
995 let (committed_at, email) = match rest.split_once('|') {
996 Some((committer_str, email)) => (committer_str.parse::<u64>().unwrap_or(ts), email),
997 None => (ts, rest),
998 };
999
1000 self.current_timestamp = Some(ts);
1001 self.current_committed_at = Some(committed_at);
1002 self.current_author_idx = Some(intern_author(
1003 email,
1004 &mut self.author_pool,
1005 &mut self.author_index,
1006 ));
1007 true
1008 }
1009
1010 fn record_legacy_timestamp(&mut self, line: &str) -> bool {
1011 let Ok(ts) = line.parse::<u64>() else {
1012 return false;
1013 };
1014
1015 self.current_timestamp = Some(ts);
1016 self.current_committed_at = Some(ts);
1017 self.current_author_idx = None;
1018 true
1019 }
1020
1021 fn record_numstat_bytes(&mut self, record: &[u8]) {
1022 let Some(first_tab) = record.iter().position(|byte| *byte == b'\t') else {
1023 return;
1024 };
1025 let Some(second_tab_offset) = record[first_tab + 1..]
1026 .iter()
1027 .position(|byte| *byte == b'\t')
1028 else {
1029 return;
1030 };
1031 let second_tab = first_tab + 1 + second_tab_offset;
1032 let Some(added) = std::str::from_utf8(&record[..first_tab])
1033 .ok()
1034 .and_then(|value| value.parse().ok())
1035 else {
1036 return;
1037 };
1038 let Some(deleted) = std::str::from_utf8(&record[first_tab + 1..second_tab])
1039 .ok()
1040 .and_then(|value| value.parse().ok())
1041 else {
1042 return;
1043 };
1044 let path = git_path_from_bytes(&record[second_tab + 1..]);
1045 self.record_numstat_path(added, deleted, path);
1046 }
1047
1048 fn record_numstat_path(&mut self, added: u32, deleted: u32, path: PathBuf) {
1049 let ts = self.current_timestamp.unwrap_or(self.now_secs);
1050 self.files
1051 .entry(self.root.join(path))
1052 .or_insert_with(|| FileEvents { events: Vec::new() })
1053 .events
1054 .push(CachedCommitEvent {
1055 timestamp: ts,
1056 committed_at: self.current_committed_at.unwrap_or(ts),
1057 lines_added: added,
1058 lines_deleted: deleted,
1059 author_idx: self.current_author_idx,
1060 });
1061 }
1062
1063 fn finish(self) -> ChurnEventState {
1064 ChurnEventState {
1065 files: self.files,
1066 author_pool: self.author_pool,
1067 git_log_bytes: 0,
1068 }
1069 }
1070}
1071
1072#[expect(
1075 clippy::cast_possible_truncation,
1076 reason = "commit count per file is bounded by git history depth"
1077)]
1078fn aggregate_file_churn(path: PathBuf, file: FileEvents, now_secs: u64) -> FileChurn {
1079 let mut timestamps = Vec::with_capacity(file.events.len());
1080 let mut weighted_commits = 0.0;
1081 let mut lines_added = 0_u32;
1082 let mut lines_deleted = 0_u32;
1083 let mut authors: FxHashMap<u32, AuthorContribution> = FxHashMap::default();
1084
1085 for event in file.events {
1086 timestamps.push(event.timestamp);
1087 let age_days = (now_secs.saturating_sub(event.timestamp)) as f64 / SECS_PER_DAY;
1088 let weight = 0.5_f64.powf(age_days / HALF_LIFE_DAYS);
1089 weighted_commits += weight;
1090 lines_added = lines_added.saturating_add(event.lines_added);
1091 lines_deleted = lines_deleted.saturating_add(event.lines_deleted);
1092 accumulate_author(&mut authors, event.author_idx, weight, event.timestamp);
1093 }
1094
1095 let commits = timestamps.len() as u32;
1096 let trend = compute_trend(×tamps);
1097 for c in authors.values_mut() {
1098 c.weighted_commits = (c.weighted_commits * 100.0).round() / 100.0;
1099 }
1100 FileChurn {
1101 path,
1102 commits,
1103 weighted_commits: (weighted_commits * 100.0).round() / 100.0,
1104 lines_added,
1105 lines_deleted,
1106 trend,
1107 authors,
1108 }
1109}
1110
1111fn accumulate_author(
1113 authors: &mut FxHashMap<u32, AuthorContribution>,
1114 author_idx: Option<u32>,
1115 weight: f64,
1116 timestamp: u64,
1117) {
1118 let Some(idx) = author_idx else {
1119 return;
1120 };
1121 authors
1122 .entry(idx)
1123 .and_modify(|c| {
1124 c.commits += 1;
1125 c.weighted_commits += weight;
1126 c.first_commit_ts = c.first_commit_ts.min(timestamp);
1127 c.last_commit_ts = c.last_commit_ts.max(timestamp);
1128 })
1129 .or_insert(AuthorContribution {
1130 commits: 1,
1131 weighted_commits: weight,
1132 first_commit_ts: timestamp,
1133 last_commit_ts: timestamp,
1134 });
1135}
1136
1137fn build_churn_result(
1142 state: ChurnEventState,
1143 shallow_clone: bool,
1144 clock: crate::clock::AnalysisClock,
1145) -> ChurnResult {
1146 let now_secs = clock.epoch_secs();
1147
1148 let files = state
1149 .files
1150 .into_iter()
1151 .map(|(path, file)| {
1152 let churn = aggregate_file_churn(path.clone(), file, now_secs);
1153 (path, churn)
1154 })
1155 .collect();
1156
1157 ChurnResult {
1158 files,
1159 shallow_clone,
1160 author_pool: state.author_pool,
1161 clock,
1162 git_log_bytes: state.git_log_bytes,
1163 }
1164}
1165
1166fn intern_author(email: &str, pool: &mut Vec<String>, index: &mut FxHashMap<String, u32>) -> u32 {
1168 if let Some(&idx) = index.get(email) {
1169 return idx;
1170 }
1171 #[expect(
1172 clippy::cast_possible_truncation,
1173 reason = "author count is bounded by git history; u32 is far above any realistic ceiling"
1174 )]
1175 let idx = pool.len() as u32;
1176 let owned = email.to_string();
1177 index.insert(owned.clone(), idx);
1178 pool.push(owned);
1179 idx
1180}
1181
1182fn compute_trend(timestamps: &[u64]) -> ChurnTrend {
1190 if timestamps.len() < 2 {
1191 return ChurnTrend::Stable;
1192 }
1193
1194 let min_ts = timestamps.iter().copied().min().unwrap_or(0);
1195 let max_ts = timestamps.iter().copied().max().unwrap_or(0);
1196
1197 if max_ts == min_ts {
1198 return ChurnTrend::Stable;
1199 }
1200
1201 let midpoint = min_ts + (max_ts - min_ts) / 2;
1202 let recent = timestamps.iter().filter(|&&ts| ts > midpoint).count() as f64;
1203 let older = timestamps.iter().filter(|&&ts| ts <= midpoint).count() as f64;
1204
1205 if older < 1.0 {
1206 return ChurnTrend::Stable;
1207 }
1208
1209 let ratio = recent / older;
1210 if ratio > 1.5 {
1211 ChurnTrend::Accelerating
1212 } else if ratio < 0.67 {
1213 ChurnTrend::Cooling
1214 } else {
1215 ChurnTrend::Stable
1216 }
1217}
1218
1219fn is_iso_date(input: &str) -> bool {
1220 input.len() == 10
1221 && input.as_bytes().get(4) == Some(&b'-')
1222 && input.as_bytes().get(7) == Some(&b'-')
1223 && input[..4].bytes().all(|b| b.is_ascii_digit())
1224 && input[5..7].bytes().all(|b| b.is_ascii_digit())
1225 && input[8..10].bytes().all(|b| b.is_ascii_digit())
1226}
1227
1228fn split_number_unit(input: &str) -> Result<(&str, &str), String> {
1229 let pos = input.find(|c: char| !c.is_ascii_digit()).ok_or_else(|| {
1230 format!("--since requires a unit suffix (e.g., 6m, 90d, 1y), got: {input}")
1231 })?;
1232 if pos == 0 {
1233 return Err(format!(
1234 "--since must start with a number (e.g., 6m, 90d, 1y), got: {input}"
1235 ));
1236 }
1237 Ok((&input[..pos], &input[pos..]))
1238}
1239
1240#[cfg(test)]
1241mod tests {
1242 use super::*;
1243
1244 #[test]
1245 fn parse_since_months_short() {
1246 let d = parse_since("6m").unwrap();
1247 assert_eq!(
1248 d.window,
1249 ChurnWindow::Relative {
1250 count: 6,
1251 unit: ChurnWindowUnit::Months
1252 }
1253 );
1254 assert_eq!(d.display, "6 months");
1255 }
1256
1257 #[test]
1258 fn parse_since_months_long() {
1259 let d = parse_since("6months").unwrap();
1260 assert_eq!(
1261 d.window,
1262 ChurnWindow::Relative {
1263 count: 6,
1264 unit: ChurnWindowUnit::Months
1265 }
1266 );
1267 assert_eq!(d.display, "6 months");
1268 }
1269
1270 #[test]
1271 fn parse_since_days() {
1272 let d = parse_since("90d").unwrap();
1273 assert_eq!(
1274 d.window,
1275 ChurnWindow::Relative {
1276 count: 90,
1277 unit: ChurnWindowUnit::Days
1278 }
1279 );
1280 assert_eq!(d.display, "90 days");
1281 }
1282
1283 #[test]
1284 fn parse_since_year_singular() {
1285 let d = parse_since("1y").unwrap();
1286 assert_eq!(
1287 d.window,
1288 ChurnWindow::Relative {
1289 count: 1,
1290 unit: ChurnWindowUnit::Years
1291 }
1292 );
1293 assert_eq!(d.display, "1 year");
1294 }
1295
1296 #[test]
1297 fn parse_since_years_plural() {
1298 let d = parse_since("2years").unwrap();
1299 assert_eq!(
1300 d.window,
1301 ChurnWindow::Relative {
1302 count: 2,
1303 unit: ChurnWindowUnit::Years
1304 }
1305 );
1306 assert_eq!(d.display, "2 years");
1307 }
1308
1309 #[test]
1310 fn parse_since_weeks() {
1311 let d = parse_since("2w").unwrap();
1312 assert_eq!(
1313 d.window,
1314 ChurnWindow::Relative {
1315 count: 2,
1316 unit: ChurnWindowUnit::Weeks
1317 }
1318 );
1319 assert_eq!(d.display, "2 weeks");
1320 }
1321
1322 #[test]
1323 fn parse_since_iso_date() {
1324 let d = parse_since("2025-06-01").unwrap();
1325 assert_eq!(d.window, ChurnWindow::Date("2025-06-01".to_string()));
1326 assert_eq!(d.display, "2025-06-01");
1327 }
1328
1329 #[test]
1330 fn parse_since_month_singular() {
1331 let d = parse_since("1month").unwrap();
1332 assert_eq!(d.display, "1 month");
1333 }
1334
1335 #[test]
1336 fn parse_since_day_singular() {
1337 let d = parse_since("1day").unwrap();
1338 assert_eq!(d.display, "1 day");
1339 }
1340
1341 #[test]
1342 fn parse_since_zero_rejected() {
1343 assert!(parse_since("0m").is_err());
1344 }
1345
1346 #[test]
1347 fn parse_since_no_unit_rejected() {
1348 assert!(parse_since("90").is_err());
1349 }
1350
1351 #[test]
1352 fn parse_since_unknown_unit_rejected() {
1353 assert!(parse_since("6x").is_err());
1354 }
1355
1356 #[test]
1357 fn parse_since_no_number_rejected() {
1358 assert!(parse_since("months").is_err());
1359 }
1360
1361 #[test]
1362 fn trend_empty_is_stable() {
1363 assert_eq!(compute_trend(&[]), ChurnTrend::Stable);
1364 }
1365
1366 #[test]
1367 fn trend_single_commit_is_stable() {
1368 assert_eq!(compute_trend(&[100]), ChurnTrend::Stable);
1369 }
1370
1371 #[test]
1372 fn trend_accelerating() {
1373 let timestamps = vec![100, 200, 800, 850, 900, 950, 1000];
1374 assert_eq!(compute_trend(×tamps), ChurnTrend::Accelerating);
1375 }
1376
1377 #[test]
1378 fn trend_cooling() {
1379 let timestamps = vec![100, 150, 200, 250, 300, 900, 1000];
1380 assert_eq!(compute_trend(×tamps), ChurnTrend::Cooling);
1381 }
1382
1383 #[test]
1384 fn trend_stable_even_distribution() {
1385 let timestamps = vec![100, 200, 300, 700, 800, 900];
1386 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1387 }
1388
1389 #[test]
1390 fn trend_same_timestamp_is_stable() {
1391 let timestamps = vec![500, 500, 500];
1392 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1393 }
1394
1395 #[test]
1396 fn iso_date_valid() {
1397 assert!(is_iso_date("2025-06-01"));
1398 assert!(is_iso_date("2025-12-31"));
1399 }
1400
1401 #[test]
1402 fn iso_date_with_time_rejected() {
1403 assert!(!is_iso_date("2025-06-01T00:00:00"));
1404 }
1405
1406 #[test]
1407 fn iso_date_invalid() {
1408 assert!(!is_iso_date("6months"));
1409 assert!(!is_iso_date("2025"));
1410 assert!(!is_iso_date("not-a-date"));
1411 assert!(!is_iso_date("abcd-ef-gh"));
1412 }
1413
1414 #[test]
1415 fn trend_display() {
1416 assert_eq!(ChurnTrend::Accelerating.to_string(), "accelerating");
1417 assert_eq!(ChurnTrend::Stable.to_string(), "stable");
1418 assert_eq!(ChurnTrend::Cooling.to_string(), "cooling");
1419 }
1420
1421 #[test]
1422 fn trend_boundary_1_5x_ratio() {
1423 let timestamps = vec![100, 200, 600, 800, 1000];
1424 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1425 }
1426
1427 #[test]
1428 fn trend_just_above_1_5x() {
1429 let timestamps = vec![100, 600, 800, 1000];
1430 assert_eq!(compute_trend(×tamps), ChurnTrend::Accelerating);
1431 }
1432
1433 #[test]
1434 fn trend_boundary_0_67x_ratio() {
1435 let timestamps = vec![100, 200, 300, 600, 1000];
1436 assert_eq!(compute_trend(×tamps), ChurnTrend::Cooling);
1437 }
1438
1439 #[test]
1440 fn trend_two_timestamps_different() {
1441 let timestamps = vec![100, 200];
1442 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1443 }
1444
1445 #[test]
1446 fn parse_since_week_singular() {
1447 let d = parse_since("1week").unwrap();
1448 assert_eq!(
1449 d.window,
1450 ChurnWindow::Relative {
1451 count: 1,
1452 unit: ChurnWindowUnit::Weeks
1453 }
1454 );
1455 assert_eq!(d.display, "1 week");
1456 }
1457
1458 #[test]
1459 fn parse_since_weeks_long() {
1460 let d = parse_since("3weeks").unwrap();
1461 assert_eq!(
1462 d.window,
1463 ChurnWindow::Relative {
1464 count: 3,
1465 unit: ChurnWindowUnit::Weeks
1466 }
1467 );
1468 assert_eq!(d.display, "3 weeks");
1469 }
1470
1471 #[test]
1472 fn parse_since_days_long() {
1473 let d = parse_since("30days").unwrap();
1474 assert_eq!(
1475 d.window,
1476 ChurnWindow::Relative {
1477 count: 30,
1478 unit: ChurnWindowUnit::Days
1479 }
1480 );
1481 assert_eq!(d.display, "30 days");
1482 }
1483
1484 #[test]
1485 fn parse_since_year_long() {
1486 let d = parse_since("1year").unwrap();
1487 assert_eq!(
1488 d.window,
1489 ChurnWindow::Relative {
1490 count: 1,
1491 unit: ChurnWindowUnit::Years
1492 }
1493 );
1494 assert_eq!(d.display, "1 year");
1495 }
1496
1497 #[test]
1498 fn parse_since_overflow_number_rejected() {
1499 let result = parse_since("99999999999999999999d");
1500 assert!(result.is_err());
1501 let err = result.unwrap_err();
1502 assert!(err.contains("invalid number"));
1503 }
1504
1505 #[test]
1506 fn parse_since_zero_days_rejected() {
1507 assert!(parse_since("0d").is_err());
1508 }
1509
1510 #[test]
1511 fn parse_since_zero_weeks_rejected() {
1512 assert!(parse_since("0w").is_err());
1513 }
1514
1515 #[test]
1516 fn parse_since_zero_years_rejected() {
1517 assert!(parse_since("0y").is_err());
1518 }
1519
1520 #[test]
1521 fn iso_date_wrong_separator_positions() {
1522 assert!(!is_iso_date("20-25-0601"));
1523 assert!(!is_iso_date("202506-01-"));
1524 }
1525
1526 #[test]
1527 fn iso_date_too_short() {
1528 assert!(!is_iso_date("2025-06-0"));
1529 }
1530
1531 #[test]
1532 fn iso_date_letters_in_day() {
1533 assert!(!is_iso_date("2025-06-ab"));
1534 }
1535
1536 #[test]
1537 fn iso_date_letters_in_month() {
1538 assert!(!is_iso_date("2025-ab-01"));
1539 }
1540
1541 #[test]
1542 fn split_number_unit_valid() {
1543 let (num, unit) = split_number_unit("42days").unwrap();
1544 assert_eq!(num, "42");
1545 assert_eq!(unit, "days");
1546 }
1547
1548 #[test]
1549 fn split_number_unit_single_digit() {
1550 let (num, unit) = split_number_unit("1m").unwrap();
1551 assert_eq!(num, "1");
1552 assert_eq!(unit, "m");
1553 }
1554
1555 #[test]
1556 fn split_number_unit_no_digits() {
1557 let err = split_number_unit("abc").unwrap_err();
1558 assert!(err.contains("must start with a number"));
1559 }
1560
1561 #[test]
1562 fn split_number_unit_no_unit() {
1563 let err = split_number_unit("123").unwrap_err();
1564 assert!(err.contains("requires a unit suffix"));
1565 }
1566
1567 #[test]
1568 fn trend_serde_serialization() {
1569 assert_eq!(
1570 serde_json::to_string(&ChurnTrend::Accelerating).unwrap(),
1571 "\"accelerating\""
1572 );
1573 assert_eq!(
1574 serde_json::to_string(&ChurnTrend::Stable).unwrap(),
1575 "\"stable\""
1576 );
1577 assert_eq!(
1578 serde_json::to_string(&ChurnTrend::Cooling).unwrap(),
1579 "\"cooling\""
1580 );
1581 }
1582
1583 #[test]
1584 fn intern_author_returns_existing_index() {
1585 let mut pool = Vec::new();
1586 let mut index = FxHashMap::default();
1587 let i1 = intern_author("alice@x", &mut pool, &mut index);
1588 let i2 = intern_author("alice@x", &mut pool, &mut index);
1589 assert_eq!(i1, i2);
1590 assert_eq!(pool.len(), 1);
1591 }
1592
1593 #[test]
1594 fn intern_author_assigns_sequential_indices() {
1595 let mut pool = Vec::new();
1596 let mut index = FxHashMap::default();
1597 assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1598 assert_eq!(intern_author("bob@x", &mut pool, &mut index), 1);
1599 assert_eq!(intern_author("carol@x", &mut pool, &mut index), 2);
1600 assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1601 }
1602
1603 fn git(root: &Path, args: &[&str]) {
1604 let status = super::git_command()
1605 .args(args)
1606 .current_dir(root)
1607 .status()
1608 .expect("run git");
1609 assert!(status.success(), "git {args:?} failed");
1610 }
1611
1612 fn write(root: &Path, path: &str, contents: &str) {
1613 let path = root.join(path);
1614 std::fs::create_dir_all(path.parent().expect("test path has parent")).unwrap();
1615 std::fs::write(path, contents).unwrap();
1616 }
1617
1618 #[cfg(unix)]
1619 #[test]
1620 fn churn_preserves_special_filenames() {
1621 let repo = tempfile::tempdir().expect("create repo");
1622 let root = repo.path();
1623 git(root, &["init", "--quiet"]);
1624 git(root, &["config", "user.email", "churn@example.test"]);
1625 git(root, &["config", "user.name", "Churn Test"]);
1626 git(root, &["config", "commit.gpgsign", "false"]);
1627
1628 let special_files = [
1629 "src/line\nbreak.ts",
1630 "src/space name.ts",
1631 "src/quote\"name.ts",
1632 "src/back\\slash.ts",
1633 "src/unicode-λ.ts",
1634 ]
1635 .map(|path| root.join(path));
1636 std::fs::create_dir_all(root.join("src")).expect("source dir");
1637 for special in &special_files {
1638 std::fs::write(special, "export const value = 1;\n").expect("special fixture");
1639 }
1640 git(root, &["add", "."]);
1641 git(root, &["commit", "--quiet", "-m", "initial"]);
1642
1643 let since = parse_since("1y").expect("valid duration");
1644 let churn = analyze_churn(root, &since).expect("churn result");
1645 for special in special_files {
1646 assert!(
1647 churn.files.contains_key(&special),
1648 "missing {special:?}: {:?}",
1649 churn.files.keys()
1650 );
1651 }
1652 }
1653
1654 #[cfg(unix)]
1655 #[test]
1656 fn churn_cache_preserves_non_utf8_filenames() {
1657 use std::ffi::OsString;
1658 use std::os::unix::ffi::OsStringExt;
1659
1660 let invalid_path = PathBuf::from(OsString::from_vec(b"src/non-utf8-\xff.ts".to_vec()));
1661 let mut files = FxHashMap::default();
1662 files.insert(
1663 invalid_path.clone(),
1664 FileEvents {
1665 events: vec![CachedCommitEvent {
1666 timestamp: 1,
1667 committed_at: 1,
1668 lines_added: 2,
1669 lines_deleted: 1,
1670 author_idx: None,
1671 }],
1672 },
1673 );
1674 let state = ChurnEventState {
1675 files,
1676 author_pool: Vec::new(),
1677 git_log_bytes: 0,
1678 };
1679 let cache_dir = tempfile::tempdir().expect("cache directory");
1680 save_churn_cache(cache_dir.path(), "abc123", "1y", &state, false);
1681 let warm = load_churn_cache(cache_dir.path(), "1y")
1682 .expect("warm churn cache")
1683 .into_event_state(None);
1684
1685 assert!(warm.files.contains_key(&invalid_path));
1686 }
1687
1688 #[test]
1689 fn churn_cache_rejects_pre_lossless_path_encoding_version() {
1690 let cache_dir = tempfile::tempdir().expect("cache directory");
1691 let cache = ChurnCache {
1692 version: 4,
1693 last_indexed_sha: "abc123".to_string(),
1694 window_token: "1y".to_string(),
1695 files: vec![CachedFileChurn {
1696 path: br#"/project/\"src/line\\nbreak.ts\""#.to_vec(),
1697 events: Vec::new(),
1698 }],
1699 shallow_clone: false,
1700 author_pool: Vec::new(),
1701 };
1702 std::fs::write(cache_dir.path().join("churn.bin"), bitcode::encode(&cache))
1703 .expect("cache fixture");
1704
1705 assert!(load_churn_cache(cache_dir.path(), "1y").is_none());
1706 }
1707
1708 #[test]
1709 fn cached_churn_merges_new_commits_after_head_advances() {
1710 let repo = tempfile::tempdir().expect("create repo");
1711 let root = repo.path();
1712 git(root, &["init"]);
1713 git(root, &["config", "user.email", "churn@example.test"]);
1714 git(root, &["config", "user.name", "Churn Test"]);
1715 git(root, &["config", "commit.gpgsign", "false"]);
1716
1717 write(root, "src/a.ts", "export const a = 1;\n");
1718 git(root, &["add", "."]);
1719 git(root, &["commit", "-m", "initial"]);
1720
1721 let since = parse_since("1y").unwrap();
1722 let cache = tempfile::tempdir().expect("create cache dir");
1723 let (cold, cold_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1724 assert!(!cold_hit);
1725 let file = root.join("src/a.ts");
1726 assert_eq!(cold.files[&file].commits, 1);
1727
1728 let (_warm, warm_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1729 assert!(warm_hit);
1730
1731 write(
1732 root,
1733 "src/a.ts",
1734 "export const a = 1;\nexport const b = 2;\n",
1735 );
1736 git(root, &["add", "."]);
1737 git(root, &["commit", "-m", "update a"]);
1738 let head = get_head_sha(root).unwrap();
1739
1740 let (incremental, incremental_hit) =
1741 analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1742 assert!(incremental_hit);
1743 assert_eq!(incremental.files[&file].commits, 2);
1744
1745 let cache = load_churn_cache(cache.path(), &since.window.cache_token()).unwrap();
1746 assert_eq!(cache.last_indexed_sha, head);
1747 }
1748
1749 fn write_churn_file(dir: &std::path::Path, contents: &str) -> PathBuf {
1750 let path = dir.join("churn.json");
1751 std::fs::write(&path, contents).unwrap();
1752 path
1753 }
1754
1755 #[test]
1756 fn churn_file_happy_path() {
1757 let dir = tempfile::tempdir().unwrap();
1758 let root = Path::new("/project");
1759 let path = write_churn_file(
1760 dir.path(),
1761 r#"{
1762 "schema": "fallow-churn/v1",
1763 "events": [
1764 { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 10, "deleted": 5 },
1765 { "path": "src/a.ts", "timestamp": 1700100000, "author": "bob@corp", "added": 3, "deleted": 2 }
1766 ]
1767 }"#,
1768 );
1769 let result = analyze_churn_from_file(&path, root).unwrap();
1770 let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
1771 assert_eq!(churn.commits, 2);
1772 assert_eq!(churn.lines_added, 13);
1773 assert_eq!(churn.lines_deleted, 7);
1774 assert_eq!(churn.authors.len(), 2);
1775 assert!(result.author_pool.contains(&"alice@corp".to_string()));
1776 assert!(result.author_pool.contains(&"bob@corp".to_string()));
1777 assert!(!result.shallow_clone);
1778 }
1779
1780 #[test]
1781 fn churn_file_empty_events_is_valid() {
1782 let dir = tempfile::tempdir().unwrap();
1783 let path = write_churn_file(
1784 dir.path(),
1785 r#"{ "schema": "fallow-churn/v1", "events": [] }"#,
1786 );
1787 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1788 assert!(result.files.is_empty());
1789 assert!(result.author_pool.is_empty());
1790 }
1791
1792 #[test]
1793 fn churn_file_missing_events_key_is_valid() {
1794 let dir = tempfile::tempdir().unwrap();
1795 let path = write_churn_file(dir.path(), r#"{ "schema": "fallow-churn/v1" }"#);
1796 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1797 assert!(result.files.is_empty());
1798 }
1799
1800 #[test]
1801 fn churn_file_bad_schema_rejected() {
1802 let dir = tempfile::tempdir().unwrap();
1803 let path = write_churn_file(
1804 dir.path(),
1805 r#"{ "schema": "fallow-churn/v2", "events": [] }"#,
1806 );
1807 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1808 assert!(err.contains("expected \"fallow-churn/v1\""), "{err}");
1809 }
1810
1811 #[test]
1812 fn churn_file_malformed_json_rejected() {
1813 let dir = tempfile::tempdir().unwrap();
1814 let path = write_churn_file(dir.path(), "{ not json");
1815 assert!(analyze_churn_from_file(&path, Path::new("/project")).is_err());
1816 }
1817
1818 #[test]
1819 fn churn_file_missing_file_rejected() {
1820 let err = analyze_churn_from_file(Path::new("/no/such/churn.json"), Path::new("/project"))
1821 .unwrap_err();
1822 assert!(err.contains("failed to read churn file"), "{err}");
1823 }
1824
1825 #[test]
1826 fn churn_file_reader_accepts_exact_limit() {
1827 let dir = tempfile::tempdir().unwrap();
1828 let path = write_churn_file(dir.path(), "12345678");
1829 assert_eq!(read_churn_file_with_limit(&path, 8).unwrap(), "12345678");
1830 }
1831
1832 #[test]
1833 fn churn_file_reader_rejects_limit_plus_one() {
1834 let dir = tempfile::tempdir().unwrap();
1835 let path = write_churn_file(dir.path(), "123456789");
1836 let err = read_churn_file_with_limit(&path, 8).unwrap_err();
1837 assert!(err.contains("at least 9 bytes"), "{err}");
1838 assert!(err.contains("8 byte limit"), "{err}");
1839 }
1840
1841 #[test]
1842 fn churn_file_empty_path_rejected() {
1843 let dir = tempfile::tempdir().unwrap();
1844 let path = write_churn_file(
1845 dir.path(),
1846 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": " ", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
1847 );
1848 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1849 assert!(err.contains("empty path"), "{err}");
1850 }
1851
1852 #[test]
1853 fn churn_file_rejects_non_relative_paths() {
1854 let invalid = [
1855 "/tmp/a.ts",
1856 r"C:\tmp\a.ts",
1857 "../a.ts",
1858 "src/../../a.ts",
1859 "./src/a.ts",
1860 "//server/share/a.ts",
1861 ];
1862 for event_path in invalid {
1863 let dir = tempfile::tempdir().unwrap();
1864 let body = format!(
1865 r#"{{ "schema": "fallow-churn/v1", "events": [ {{ "path": {event_path:?}, "timestamp": 1700000000, "added": 1, "deleted": 0 }} ] }}"#
1866 );
1867 let path = write_churn_file(dir.path(), &body);
1868 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1869 assert!(err.contains(event_path), "{event_path}: {err}");
1870 assert!(err.contains("repo-relative"), "{event_path}: {err}");
1871 }
1872 }
1873
1874 #[test]
1875 fn churn_file_accepts_unicode_and_spaces_in_path_components() {
1876 let dir = tempfile::tempdir().unwrap();
1877 let path = write_churn_file(
1878 dir.path(),
1879 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/ruimte map/naïef.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
1880 );
1881 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1882 assert!(
1883 result
1884 .files
1885 .contains_key(&PathBuf::from("/project/src/ruimte map/naïef.ts"))
1886 );
1887 }
1888
1889 #[test]
1890 fn churn_file_millisecond_timestamp_rejected() {
1891 let dir = tempfile::tempdir().unwrap();
1892 let path = write_churn_file(
1894 dir.path(),
1895 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000000, "added": 1, "deleted": 0 } ] }"#,
1896 );
1897 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1898 assert!(err.contains("milliseconds"), "{err}");
1899 }
1900
1901 #[test]
1902 fn churn_file_missing_author_contributes_no_signal() {
1903 let dir = tempfile::tempdir().unwrap();
1904 let path = write_churn_file(
1905 dir.path(),
1906 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
1907 );
1908 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1909 let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
1910 assert_eq!(churn.commits, 1);
1911 assert!(churn.authors.is_empty());
1912 assert!(result.author_pool.is_empty());
1913 }
1914
1915 #[test]
1916 fn churn_file_empty_author_string_treated_as_absent() {
1917 let dir = tempfile::tempdir().unwrap();
1918 let path = write_churn_file(
1919 dir.path(),
1920 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "author": " ", "added": 1, "deleted": 0 } ] }"#,
1921 );
1922 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1923 assert!(result.author_pool.is_empty());
1924 }
1925
1926 #[test]
1927 fn churn_file_unknown_fields_ignored() {
1928 let dir = tempfile::tempdir().unwrap();
1931 let path = write_churn_file(
1932 dir.path(),
1933 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" } ] }"#,
1934 );
1935 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1936 assert_eq!(result.files[&PathBuf::from("/project/src/a.ts")].commits, 1);
1937 }
1938
1939 #[test]
1940 fn churn_file_backslash_paths_normalized() {
1941 let dir = tempfile::tempdir().unwrap();
1942 let path = write_churn_file(
1943 dir.path(),
1944 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src\\a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
1945 );
1946 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1947 assert!(
1948 result
1949 .files
1950 .contains_key(&PathBuf::from("/project/src/a.ts"))
1951 );
1952 }
1953
1954 #[test]
1955 fn churn_file_rejects_line_totals_above_u32() {
1956 let dir = tempfile::tempdir().unwrap();
1957 let path = write_churn_file(
1958 dir.path(),
1959 r#"{ "schema": "fallow-churn/v1", "events": [
1960 { "path": "src/a.ts", "timestamp": 1700000000, "added": 4294967295, "deleted": 0 },
1961 { "path": "src/a.ts", "timestamp": 1700000001, "added": 1, "deleted": 0 }
1962 ] }"#,
1963 );
1964 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1965 assert!(err.contains("exceeding the u32 limit"), "{err}");
1966 assert!(err.contains("src/a.ts"), "{err}");
1967 }
1968
1969 #[test]
1970 fn churn_file_accepts_line_totals_at_u32_max() {
1971 let dir = tempfile::tempdir().unwrap();
1972 let path = write_churn_file(
1973 dir.path(),
1974 r#"{ "schema": "fallow-churn/v1", "events": [
1975 { "path": "src/a.ts", "timestamp": 1700000000, "added": 4294967294, "deleted": 4294967295 },
1976 { "path": "src/a.ts", "timestamp": 1700000001, "added": 1, "deleted": 0 }
1977 ] }"#,
1978 );
1979 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1980 let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
1981 assert_eq!(churn.lines_added, u32::MAX);
1982 assert_eq!(churn.lines_deleted, u32::MAX);
1983 }
1984
1985 #[test]
1990 fn headerless_numstat_falls_back_to_the_run_clock() {
1991 let root = Path::new("/project");
1992 let pinned = 1_700_000_000;
1993
1994 let state = parse_git_log_events_z(b"10\t5\tsrc/a.ts", root, pinned);
1995
1996 let events = &state.files[&PathBuf::from("/project/src/a.ts")].events;
1997 assert_eq!(events.len(), 1);
1998 assert_eq!(events[0].timestamp, pinned);
1999 assert_eq!(events[0].committed_at, pinned);
2000 }
2001
2002 #[test]
2006 fn headerless_numstat_tracks_only_the_supplied_clock() {
2007 let root = Path::new("/project");
2008 let record: &[u8] = b"1\t0\tsrc/a.ts";
2009
2010 let early = parse_git_log_events_z(record, root, 1_600_000_000);
2011 let late = parse_git_log_events_z(record, root, 1_700_000_000);
2012
2013 let key = PathBuf::from("/project/src/a.ts");
2014 assert_eq!(early.files[&key].events[0].timestamp, 1_600_000_000);
2015 assert_eq!(late.files[&key].events[0].timestamp, 1_700_000_000);
2016 }
2017
2018 #[test]
2020 fn commit_header_timestamp_beats_the_run_clock_fallback() {
2021 let root = Path::new("/project");
2022 let record = b"1700000000|1700000500|dev@example.com\x002\t1\tsrc/a.ts";
2023
2024 let state = parse_git_log_events_z(record, root, 1_234_567_890);
2025
2026 let events = &state.files[&PathBuf::from("/project/src/a.ts")].events;
2027 assert_eq!(events[0].timestamp, 1_700_000_000);
2028 assert_eq!(events[0].committed_at, 1_700_000_500);
2029 }
2030
2031 const NOW: u64 = 1_750_000_000;
2033 const HEADER: &str = "1700000000|1700000000|dev@example.com";
2034
2035 fn git_log_z(commits: &[(&str, &[&str])]) -> Vec<u8> {
2039 let mut out = Vec::new();
2040 for (index, (header, rows)) in commits.iter().enumerate() {
2041 if index > 0 {
2042 out.push(0);
2043 }
2044 out.extend_from_slice(header.as_bytes());
2045 out.extend_from_slice(b"\0\n");
2046 for row in *rows {
2047 out.extend_from_slice(row.as_bytes());
2048 out.push(0);
2049 }
2050 }
2051 out
2052 }
2053
2054 fn churn_from_git_log_z(stdout: &[u8], root: &Path, now_secs: u64) -> ChurnResult {
2055 build_churn_result(
2056 parse_git_log_events_z(stdout, root, now_secs),
2057 false,
2058 crate::clock::AnalysisClock::pinned(now_secs),
2059 )
2060 }
2061
2062 fn churn_from_commits(commits: &[(&str, &[&str])]) -> ChurnResult {
2063 churn_from_git_log_z(&git_log_z(commits), Path::new("/project"), NOW)
2064 }
2065
2066 #[test]
2067 fn git_log_z_fixture_matches_real_git_output() {
2068 assert_eq!(
2069 git_log_z(&[
2070 ("1|2|a@b", &["1\t0\ta.ts"]),
2071 ("3|4|a@b", &["1\t0\ta.ts", "1\t0\tb c.ts"])
2072 ]),
2073 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()
2074 );
2075 }
2076
2077 #[test]
2078 fn numstat_records_parse_counts_and_skip_binary_or_malformed_rows() {
2079 let root = Path::new("/project");
2080 type Expected = Option<(u32, u32, &'static str)>;
2081 let cases: &[(&str, Expected)] = &[
2082 ("10\t5\tsrc/file.ts", Some((10, 5, "src/file.ts"))),
2083 ("0\t0\tsrc/empty.ts", Some((0, 0, "src/empty.ts"))),
2084 (
2085 "3\t1\tpath with spaces/file.ts",
2086 Some((3, 1, "path with spaces/file.ts")),
2087 ),
2088 ("1\t0\tweird\tname.ts", Some((1, 0, "weird\tname.ts"))),
2089 (
2090 "4294967295\t8888\tsrc/big.ts",
2091 Some((u32::MAX, 8888, "src/big.ts")),
2092 ),
2093 ("4294967296\t0\tsrc/overflow.ts", None),
2094 ("-\t-\tsrc/image.png", None),
2095 ("-\t5\tsrc/file.ts", None),
2096 ("10\t-\tsrc/file.ts", None),
2097 ("10\t5", None),
2098 ];
2099 for (row, expected) in cases {
2100 let state = parse_git_log_events_z(&git_log_z(&[(HEADER, &[row])]), root, NOW);
2101 let Some((added, deleted, path)) = expected else {
2102 assert!(state.files.is_empty(), "{row:?} must not record churn");
2103 continue;
2104 };
2105 assert_eq!(state.files.len(), 1, "{row:?}");
2106 let events = &state.files[&root.join(path)].events;
2107 assert_eq!(events.len(), 1, "{row:?}");
2108 assert_eq!(events[0].lines_added, *added, "{row:?}");
2109 assert_eq!(events[0].lines_deleted, *deleted, "{row:?}");
2110 }
2111 }
2112
2113 #[test]
2114 fn git_log_z_aggregates_commits_and_lines_per_file() {
2115 let result = churn_from_commits(&[
2116 (
2117 HEADER,
2118 &["10\t5\tsrc/a.ts", "3\t1\tsrc/b.ts", "-\t-\timage.png"],
2119 ),
2120 ("1700100000|1700100000|dev@example.com", &["3\t2\tsrc/a.ts"]),
2121 ]);
2122 assert_eq!(result.files.len(), 2);
2123 let a = &result.files[&PathBuf::from("/project/src/a.ts")];
2124 assert_eq!((a.commits, a.lines_added, a.lines_deleted), (2, 13, 7));
2125 let b = &result.files[&PathBuf::from("/project/src/b.ts")];
2126 assert_eq!((b.commits, b.lines_added, b.lines_deleted), (1, 3, 1));
2127 assert!(
2128 !result
2129 .files
2130 .contains_key(&PathBuf::from("/project/image.png"))
2131 );
2132 }
2133
2134 #[test]
2135 fn git_log_z_empty_or_blank_output_records_nothing() {
2136 assert!(
2137 churn_from_git_log_z(b"", Path::new("/project"), NOW)
2138 .files
2139 .is_empty()
2140 );
2141 assert!(
2142 churn_from_git_log_z(b"\0\0\n\0 \0", Path::new("/project"), NOW)
2143 .files
2144 .is_empty()
2145 );
2146 }
2147
2148 #[test]
2149 fn git_log_z_blank_records_between_commits_are_ignored() {
2150 let stdout = b" \0\n1700000000|1700000000|dev@example.com\0\n \0\n10\t5\tsrc/a.ts\0\0";
2151 let result = churn_from_git_log_z(stdout, Path::new("/project"), NOW);
2152 assert_eq!(result.files.len(), 1);
2153 assert_eq!(result.files[&PathBuf::from("/project/src/a.ts")].commits, 1);
2154 }
2155
2156 #[test]
2157 fn git_log_z_paths_are_joined_to_the_root() {
2158 let root = Path::new("/my/project");
2159 let result =
2160 churn_from_git_log_z(&git_log_z(&[(HEADER, &["1\t0\tlib/utils.ts"])]), root, NOW);
2161 let key = PathBuf::from("/my/project/lib/utils.ts");
2162 assert_eq!(result.files[&key].path, key);
2163 }
2164
2165 #[test]
2166 fn git_log_z_weights_commits_by_age_against_the_run_clock() {
2167 let day = 86_400;
2168 let now = format!("{NOW}|{NOW}|dev@example.com");
2169 let half_life = format!("{0}|{0}|dev@example.com", NOW - 45 * day);
2170 let two_half_lives = format!("{0}|{0}|dev@example.com", NOW - 180 * day);
2171 let result = churn_from_commits(&[
2172 (&now, &["1\t0\tsrc/fresh.ts"]),
2173 (&half_life, &["1\t0\tsrc/half.ts"]),
2174 (&two_half_lives, &["1\t0\tsrc/old.ts"]),
2175 ]);
2176 let weight = |name: &str| {
2177 result.files[&PathBuf::from(format!("/project/src/{name}"))].weighted_commits
2178 };
2179 assert!((weight("fresh.ts") - 1.0).abs() < f64::EPSILON);
2180 assert!((weight("half.ts") - 0.71).abs() < f64::EPSILON);
2182 assert!((weight("old.ts") - 0.25).abs() < f64::EPSILON);
2183 }
2184
2185 #[test]
2186 fn git_log_z_headerless_numstat_weighs_as_a_commit_at_the_run_clock() {
2187 let result = churn_from_git_log_z(b"10\t5\tsrc/no_ts.ts\0", Path::new("/project"), NOW);
2188 let churn = &result.files[&PathBuf::from("/project/src/no_ts.ts")];
2189 assert_eq!(
2190 (churn.commits, churn.lines_added, churn.lines_deleted),
2191 (1, 10, 5)
2192 );
2193 assert!((churn.weighted_commits - 1.0).abs() < f64::EPSILON);
2194 }
2195
2196 #[test]
2197 fn git_log_z_trend_is_computed_per_file() {
2198 let result = churn_from_commits(&[
2199 (
2200 "1000|1000|dev@example.com",
2201 &["5\t1\tsrc/old.ts", "1\t0\tsrc/hot.ts"],
2202 ),
2203 ("1800|1800|dev@example.com", &["1\t0\tsrc/hot.ts"]),
2204 ("1900|1900|dev@example.com", &["1\t0\tsrc/hot.ts"]),
2205 ("1950|1950|dev@example.com", &["1\t0\tsrc/hot.ts"]),
2206 (
2207 "2000|2000|dev@example.com",
2208 &["3\t1\tsrc/old.ts", "1\t0\tsrc/hot.ts"],
2209 ),
2210 ]);
2211 let old = &result.files[&PathBuf::from("/project/src/old.ts")];
2212 let hot = &result.files[&PathBuf::from("/project/src/hot.ts")];
2213 assert_eq!(old.commits, 2);
2214 assert_eq!(old.trend, ChurnTrend::Stable);
2215 assert_eq!(hot.commits, 5);
2216 assert_eq!(hot.trend, ChurnTrend::Accelerating);
2217 }
2218
2219 #[test]
2220 fn git_log_z_interns_authors_and_aggregates_per_author() {
2221 let result = churn_from_commits(&[
2222 (
2223 "1700000000|1700000000|alice@example.com",
2224 &["1\t0\tsrc/index.ts"],
2225 ),
2226 (
2227 "1700100000|1700100000|bob@example.com",
2228 &["2\t0\tsrc/index.ts"],
2229 ),
2230 (
2231 "1700200000|1700200000|alice@example.com",
2232 &["1\t1\tsrc/index.ts"],
2233 ),
2234 ]);
2235 assert_eq!(
2236 result.author_pool,
2237 vec![
2238 "alice@example.com".to_string(),
2239 "bob@example.com".to_string()
2240 ]
2241 );
2242 let churn = &result.files[&PathBuf::from("/project/src/index.ts")];
2243 assert_eq!(churn.commits, 3);
2244 assert_eq!(churn.authors.len(), 2);
2245 let alice = &churn.authors[&0];
2246 assert_eq!(alice.commits, 2);
2247 assert_eq!(alice.first_commit_ts, 1_700_000_000);
2248 assert_eq!(alice.last_commit_ts, 1_700_200_000);
2249 let bob = &churn.authors[&1];
2250 assert_eq!(bob.commits, 1);
2251 assert_eq!(bob.first_commit_ts, 1_700_100_000);
2252 }
2253
2254 #[test]
2255 fn git_log_z_legacy_headers_still_parse() {
2256 let result = churn_from_commits(&[
2257 ("1700000000", &["10\t5\tsrc/bare.ts"]),
2258 ("1700100000|alice@example.com", &["1\t0\tsrc/two_field.ts"]),
2259 ]);
2260 assert_eq!(result.author_pool, vec!["alice@example.com".to_string()]);
2261 let bare = &result.files[&PathBuf::from("/project/src/bare.ts")];
2262 assert_eq!(bare.commits, 1);
2263 assert!(bare.authors.is_empty());
2264 let two_field = &result.files[&PathBuf::from("/project/src/two_field.ts")];
2265 assert_eq!(two_field.authors[&0].first_commit_ts, 1_700_100_000);
2266 }
2267
2268 #[test]
2269 fn churn_file_matches_git_log_z_parse() {
2270 let dir = tempfile::tempdir().unwrap();
2274 let root = Path::new("/project");
2275 let path = write_churn_file(
2276 dir.path(),
2277 r#"{
2278 "schema": "fallow-churn/v1",
2279 "events": [
2280 { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 10, "deleted": 5 },
2281 { "path": "src/b.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 3, "deleted": 1 },
2282 { "path": "src/a.ts", "timestamp": 1700100000, "author": "bob@corp", "added": 3, "deleted": 2 }
2283 ]
2284 }"#,
2285 );
2286 let imported = analyze_churn_from_file(&path, root).unwrap();
2287 let git = churn_from_git_log_z(
2288 &git_log_z(&[
2289 (
2290 "1700000000|1700000000|alice@corp",
2291 &["10\t5\tsrc/a.ts", "3\t1\tsrc/b.ts"],
2292 ),
2293 ("1700100000|1700100000|bob@corp", &["3\t2\tsrc/a.ts"]),
2294 ]),
2295 root,
2296 imported.clock.epoch_secs(),
2297 );
2298
2299 assert_eq!(
2300 git.author_pool, imported.author_pool,
2301 "author pools diverge"
2302 );
2303 assert_eq!(git.files.len(), imported.files.len());
2304 for (file, git_churn) in &git.files {
2305 let imp = &imported.files[file];
2306 assert_eq!(git_churn.commits, imp.commits, "commits for {file:?}");
2307 assert_eq!(git_churn.lines_added, imp.lines_added, "added for {file:?}");
2308 assert_eq!(
2309 git_churn.lines_deleted, imp.lines_deleted,
2310 "deleted for {file:?}"
2311 );
2312 assert_eq!(git_churn.trend, imp.trend, "trend for {file:?}");
2313 assert_eq!(git_churn.authors, imp.authors, "authors for {file:?}");
2314 assert!(
2315 (git_churn.weighted_commits - imp.weighted_commits).abs() < f64::EPSILON,
2316 "weighted_commits for {file:?}: {} vs {}",
2317 git_churn.weighted_commits,
2318 imp.weighted_commits
2319 );
2320 }
2321 }
2322}