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, remainder) = path.as_chunks::<2>();
836 if !remainder.is_empty() {
837 return None;
838 }
839 let wide: Vec<u16> = chunks
840 .iter()
841 .map(|&pair| u16::from_le_bytes(pair))
842 .collect();
843 Some(PathBuf::from(OsString::from_wide(&wide)))
844}
845
846fn analyze_churn_events(
852 root: &Path,
853 since: &SinceDuration,
854 revision_range: Option<&str>,
855 clock: &crate::clock::AnalysisClock,
856) -> Option<ChurnEventState> {
857 let mut command = git_command();
858 command.arg("log");
859 if let Some(range) = revision_range {
860 command.arg(range);
861 }
862 command
863 .args([
864 "--numstat",
865 "--no-merges",
866 "--no-renames",
867 "--use-mailmap",
868 "-z",
869 "--format=format:%at|%ct|%ae%x00",
870 ])
871 .current_dir(root);
872 if let Some(cutoff) = since.window.cutoff_secs(clock) {
873 command.arg(format!("--after=@{cutoff}"));
874 }
875 command.args(["--relative", "--", "."]);
880
881 let output = match spawn_output(&mut command) {
882 Ok(o) => o,
883 Err(e) => {
884 tracing::warn!("hotspot analysis skipped: failed to run git: {e}");
885 return None;
886 }
887 };
888
889 if !output.status.success() {
890 let stderr = String::from_utf8_lossy(&output.stderr);
891 tracing::warn!("hotspot analysis skipped: git log failed: {stderr}");
892 return None;
893 }
894
895 let mut state = parse_git_log_events_z(&output.stdout, root, clock.epoch_secs());
896 state.git_log_bytes = output.stdout.len() as u64;
897 Some(state)
898}
899
900fn merge_churn_states(base: &mut ChurnEventState, delta: ChurnEventState) {
902 base.git_log_bytes += delta.git_log_bytes;
903 let mut base_author_index: FxHashMap<String, u32> = base
904 .author_pool
905 .iter()
906 .enumerate()
907 .filter_map(|(idx, email)| u32::try_from(idx).ok().map(|idx| (email.clone(), idx)))
908 .collect();
909
910 let mut author_mapping: FxHashMap<u32, u32> = FxHashMap::default();
911 for (old_idx, email) in delta.author_pool.into_iter().enumerate() {
912 let Ok(old_idx) = u32::try_from(old_idx) else {
913 continue;
914 };
915 let new_idx = intern_author(&email, &mut base.author_pool, &mut base_author_index);
916 author_mapping.insert(old_idx, new_idx);
917 }
918
919 for (path, mut file) in delta.files {
920 for event in &mut file.events {
921 event.author_idx = event
922 .author_idx
923 .and_then(|idx| author_mapping.get(&idx).copied());
924 }
925 base.files
926 .entry(path)
927 .and_modify(|existing| existing.events.append(&mut file.events))
928 .or_insert(file);
929 }
930}
931
932fn parse_git_log_events_z(stdout: &[u8], root: &Path, now_secs: u64) -> ChurnEventState {
936 let mut parser = GitLogEventParser::new(root, now_secs);
937 for record in stdout.split(|byte| *byte == 0) {
938 let record = record.strip_prefix(b"\n").unwrap_or(record);
939 if record.is_empty() {
940 continue;
941 }
942 if record.contains(&b'\t') {
943 parser.record_numstat_bytes(record);
944 } else {
945 parser.consume_header(&String::from_utf8_lossy(record));
946 }
947 }
948 parser.finish()
949}
950
951struct GitLogEventParser<'a> {
952 root: &'a Path,
953 now_secs: u64,
954 files: FxHashMap<PathBuf, FileEvents>,
955 author_pool: Vec<String>,
956 author_index: FxHashMap<String, u32>,
957 current_timestamp: Option<u64>,
958 current_committed_at: Option<u64>,
959 current_author_idx: Option<u32>,
960}
961
962impl<'a> GitLogEventParser<'a> {
963 fn new(root: &'a Path, now_secs: u64) -> Self {
964 Self {
965 root,
966 now_secs,
967 files: FxHashMap::default(),
968 author_pool: Vec::new(),
969 author_index: FxHashMap::default(),
970 current_timestamp: None,
971 current_committed_at: None,
972 current_author_idx: None,
973 }
974 }
975
976 fn consume_header(&mut self, line: &str) {
979 let line = line.trim();
980 if line.is_empty() || self.record_commit_header(line) {
981 return;
982 }
983 self.record_legacy_timestamp(line);
984 }
985
986 fn record_commit_header(&mut self, line: &str) -> bool {
990 let Some((ts_str, rest)) = line.split_once('|') else {
991 return false;
992 };
993 let Ok(ts) = ts_str.parse::<u64>() else {
994 return false;
995 };
996 let (committed_at, email) = match rest.split_once('|') {
997 Some((committer_str, email)) => (committer_str.parse::<u64>().unwrap_or(ts), email),
998 None => (ts, rest),
999 };
1000
1001 self.current_timestamp = Some(ts);
1002 self.current_committed_at = Some(committed_at);
1003 self.current_author_idx = Some(intern_author(
1004 email,
1005 &mut self.author_pool,
1006 &mut self.author_index,
1007 ));
1008 true
1009 }
1010
1011 fn record_legacy_timestamp(&mut self, line: &str) -> bool {
1012 let Ok(ts) = line.parse::<u64>() else {
1013 return false;
1014 };
1015
1016 self.current_timestamp = Some(ts);
1017 self.current_committed_at = Some(ts);
1018 self.current_author_idx = None;
1019 true
1020 }
1021
1022 fn record_numstat_bytes(&mut self, record: &[u8]) {
1023 let Some(first_tab) = record.iter().position(|byte| *byte == b'\t') else {
1024 return;
1025 };
1026 let Some(second_tab_offset) = record[first_tab + 1..]
1027 .iter()
1028 .position(|byte| *byte == b'\t')
1029 else {
1030 return;
1031 };
1032 let second_tab = first_tab + 1 + second_tab_offset;
1033 let Some(added) = std::str::from_utf8(&record[..first_tab])
1034 .ok()
1035 .and_then(|value| value.parse().ok())
1036 else {
1037 return;
1038 };
1039 let Some(deleted) = std::str::from_utf8(&record[first_tab + 1..second_tab])
1040 .ok()
1041 .and_then(|value| value.parse().ok())
1042 else {
1043 return;
1044 };
1045 let path = git_path_from_bytes(&record[second_tab + 1..]);
1046 self.record_numstat_path(added, deleted, path);
1047 }
1048
1049 fn record_numstat_path(&mut self, added: u32, deleted: u32, path: PathBuf) {
1050 let ts = self.current_timestamp.unwrap_or(self.now_secs);
1051 self.files
1052 .entry(self.root.join(path))
1053 .or_insert_with(|| FileEvents { events: Vec::new() })
1054 .events
1055 .push(CachedCommitEvent {
1056 timestamp: ts,
1057 committed_at: self.current_committed_at.unwrap_or(ts),
1058 lines_added: added,
1059 lines_deleted: deleted,
1060 author_idx: self.current_author_idx,
1061 });
1062 }
1063
1064 fn finish(self) -> ChurnEventState {
1065 ChurnEventState {
1066 files: self.files,
1067 author_pool: self.author_pool,
1068 git_log_bytes: 0,
1069 }
1070 }
1071}
1072
1073#[expect(
1076 clippy::cast_possible_truncation,
1077 reason = "commit count per file is bounded by git history depth"
1078)]
1079fn aggregate_file_churn(path: PathBuf, file: FileEvents, now_secs: u64) -> FileChurn {
1080 let mut timestamps = Vec::with_capacity(file.events.len());
1081 let mut weighted_commits = 0.0;
1082 let mut lines_added = 0_u32;
1083 let mut lines_deleted = 0_u32;
1084 let mut authors: FxHashMap<u32, AuthorContribution> = FxHashMap::default();
1085
1086 for event in file.events {
1087 timestamps.push(event.timestamp);
1088 let age_days = (now_secs.saturating_sub(event.timestamp)) as f64 / SECS_PER_DAY;
1089 let weight = 0.5_f64.powf(age_days / HALF_LIFE_DAYS);
1090 weighted_commits += weight;
1091 lines_added = lines_added.saturating_add(event.lines_added);
1092 lines_deleted = lines_deleted.saturating_add(event.lines_deleted);
1093 accumulate_author(&mut authors, event.author_idx, weight, event.timestamp);
1094 }
1095
1096 let commits = timestamps.len() as u32;
1097 let trend = compute_trend(×tamps);
1098 for c in authors.values_mut() {
1099 c.weighted_commits = (c.weighted_commits * 100.0).round() / 100.0;
1100 }
1101 FileChurn {
1102 path,
1103 commits,
1104 weighted_commits: (weighted_commits * 100.0).round() / 100.0,
1105 lines_added,
1106 lines_deleted,
1107 trend,
1108 authors,
1109 }
1110}
1111
1112fn accumulate_author(
1114 authors: &mut FxHashMap<u32, AuthorContribution>,
1115 author_idx: Option<u32>,
1116 weight: f64,
1117 timestamp: u64,
1118) {
1119 let Some(idx) = author_idx else {
1120 return;
1121 };
1122 authors
1123 .entry(idx)
1124 .and_modify(|c| {
1125 c.commits += 1;
1126 c.weighted_commits += weight;
1127 c.first_commit_ts = c.first_commit_ts.min(timestamp);
1128 c.last_commit_ts = c.last_commit_ts.max(timestamp);
1129 })
1130 .or_insert(AuthorContribution {
1131 commits: 1,
1132 weighted_commits: weight,
1133 first_commit_ts: timestamp,
1134 last_commit_ts: timestamp,
1135 });
1136}
1137
1138fn build_churn_result(
1143 state: ChurnEventState,
1144 shallow_clone: bool,
1145 clock: crate::clock::AnalysisClock,
1146) -> ChurnResult {
1147 let now_secs = clock.epoch_secs();
1148
1149 let files = state
1150 .files
1151 .into_iter()
1152 .map(|(path, file)| {
1153 let churn = aggregate_file_churn(path.clone(), file, now_secs);
1154 (path, churn)
1155 })
1156 .collect();
1157
1158 ChurnResult {
1159 files,
1160 shallow_clone,
1161 author_pool: state.author_pool,
1162 clock,
1163 git_log_bytes: state.git_log_bytes,
1164 }
1165}
1166
1167fn intern_author(email: &str, pool: &mut Vec<String>, index: &mut FxHashMap<String, u32>) -> u32 {
1169 if let Some(&idx) = index.get(email) {
1170 return idx;
1171 }
1172 #[expect(
1173 clippy::cast_possible_truncation,
1174 reason = "author count is bounded by git history; u32 is far above any realistic ceiling"
1175 )]
1176 let idx = pool.len() as u32;
1177 let owned = email.to_string();
1178 index.insert(owned.clone(), idx);
1179 pool.push(owned);
1180 idx
1181}
1182
1183fn compute_trend(timestamps: &[u64]) -> ChurnTrend {
1191 if timestamps.len() < 2 {
1192 return ChurnTrend::Stable;
1193 }
1194
1195 let min_ts = timestamps.iter().copied().min().unwrap_or(0);
1196 let max_ts = timestamps.iter().copied().max().unwrap_or(0);
1197
1198 if max_ts == min_ts {
1199 return ChurnTrend::Stable;
1200 }
1201
1202 let midpoint = min_ts + (max_ts - min_ts) / 2;
1203 let recent = timestamps.iter().filter(|&&ts| ts > midpoint).count() as f64;
1204 let older = timestamps.iter().filter(|&&ts| ts <= midpoint).count() as f64;
1205
1206 if older < 1.0 {
1207 return ChurnTrend::Stable;
1208 }
1209
1210 let ratio = recent / older;
1211 if ratio > 1.5 {
1212 ChurnTrend::Accelerating
1213 } else if ratio < 0.67 {
1214 ChurnTrend::Cooling
1215 } else {
1216 ChurnTrend::Stable
1217 }
1218}
1219
1220fn is_iso_date(input: &str) -> bool {
1221 input.len() == 10
1222 && input.as_bytes().get(4) == Some(&b'-')
1223 && input.as_bytes().get(7) == Some(&b'-')
1224 && input[..4].bytes().all(|b| b.is_ascii_digit())
1225 && input[5..7].bytes().all(|b| b.is_ascii_digit())
1226 && input[8..10].bytes().all(|b| b.is_ascii_digit())
1227}
1228
1229fn split_number_unit(input: &str) -> Result<(&str, &str), String> {
1230 let pos = input.find(|c: char| !c.is_ascii_digit()).ok_or_else(|| {
1231 format!("--since requires a unit suffix (e.g., 6m, 90d, 1y), got: {input}")
1232 })?;
1233 if pos == 0 {
1234 return Err(format!(
1235 "--since must start with a number (e.g., 6m, 90d, 1y), got: {input}"
1236 ));
1237 }
1238 Ok((&input[..pos], &input[pos..]))
1239}
1240
1241#[cfg(test)]
1242mod tests {
1243 use super::*;
1244
1245 #[test]
1246 fn parse_since_months_short() {
1247 let d = parse_since("6m").unwrap();
1248 assert_eq!(
1249 d.window,
1250 ChurnWindow::Relative {
1251 count: 6,
1252 unit: ChurnWindowUnit::Months
1253 }
1254 );
1255 assert_eq!(d.display, "6 months");
1256 }
1257
1258 #[test]
1259 fn parse_since_months_long() {
1260 let d = parse_since("6months").unwrap();
1261 assert_eq!(
1262 d.window,
1263 ChurnWindow::Relative {
1264 count: 6,
1265 unit: ChurnWindowUnit::Months
1266 }
1267 );
1268 assert_eq!(d.display, "6 months");
1269 }
1270
1271 #[test]
1272 fn parse_since_days() {
1273 let d = parse_since("90d").unwrap();
1274 assert_eq!(
1275 d.window,
1276 ChurnWindow::Relative {
1277 count: 90,
1278 unit: ChurnWindowUnit::Days
1279 }
1280 );
1281 assert_eq!(d.display, "90 days");
1282 }
1283
1284 #[test]
1285 fn parse_since_year_singular() {
1286 let d = parse_since("1y").unwrap();
1287 assert_eq!(
1288 d.window,
1289 ChurnWindow::Relative {
1290 count: 1,
1291 unit: ChurnWindowUnit::Years
1292 }
1293 );
1294 assert_eq!(d.display, "1 year");
1295 }
1296
1297 #[test]
1298 fn parse_since_years_plural() {
1299 let d = parse_since("2years").unwrap();
1300 assert_eq!(
1301 d.window,
1302 ChurnWindow::Relative {
1303 count: 2,
1304 unit: ChurnWindowUnit::Years
1305 }
1306 );
1307 assert_eq!(d.display, "2 years");
1308 }
1309
1310 #[test]
1311 fn parse_since_weeks() {
1312 let d = parse_since("2w").unwrap();
1313 assert_eq!(
1314 d.window,
1315 ChurnWindow::Relative {
1316 count: 2,
1317 unit: ChurnWindowUnit::Weeks
1318 }
1319 );
1320 assert_eq!(d.display, "2 weeks");
1321 }
1322
1323 #[test]
1324 fn parse_since_iso_date() {
1325 let d = parse_since("2025-06-01").unwrap();
1326 assert_eq!(d.window, ChurnWindow::Date("2025-06-01".to_string()));
1327 assert_eq!(d.display, "2025-06-01");
1328 }
1329
1330 #[test]
1331 fn parse_since_month_singular() {
1332 let d = parse_since("1month").unwrap();
1333 assert_eq!(d.display, "1 month");
1334 }
1335
1336 #[test]
1337 fn parse_since_day_singular() {
1338 let d = parse_since("1day").unwrap();
1339 assert_eq!(d.display, "1 day");
1340 }
1341
1342 #[test]
1343 fn parse_since_zero_rejected() {
1344 assert!(parse_since("0m").is_err());
1345 }
1346
1347 #[test]
1348 fn parse_since_no_unit_rejected() {
1349 assert!(parse_since("90").is_err());
1350 }
1351
1352 #[test]
1353 fn parse_since_unknown_unit_rejected() {
1354 assert!(parse_since("6x").is_err());
1355 }
1356
1357 #[test]
1358 fn parse_since_no_number_rejected() {
1359 assert!(parse_since("months").is_err());
1360 }
1361
1362 #[test]
1363 fn trend_empty_is_stable() {
1364 assert_eq!(compute_trend(&[]), ChurnTrend::Stable);
1365 }
1366
1367 #[test]
1368 fn trend_single_commit_is_stable() {
1369 assert_eq!(compute_trend(&[100]), ChurnTrend::Stable);
1370 }
1371
1372 #[test]
1373 fn trend_accelerating() {
1374 let timestamps = vec![100, 200, 800, 850, 900, 950, 1000];
1375 assert_eq!(compute_trend(×tamps), ChurnTrend::Accelerating);
1376 }
1377
1378 #[test]
1379 fn trend_cooling() {
1380 let timestamps = vec![100, 150, 200, 250, 300, 900, 1000];
1381 assert_eq!(compute_trend(×tamps), ChurnTrend::Cooling);
1382 }
1383
1384 #[test]
1385 fn trend_stable_even_distribution() {
1386 let timestamps = vec![100, 200, 300, 700, 800, 900];
1387 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1388 }
1389
1390 #[test]
1391 fn trend_same_timestamp_is_stable() {
1392 let timestamps = vec![500, 500, 500];
1393 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1394 }
1395
1396 #[test]
1397 fn iso_date_valid() {
1398 assert!(is_iso_date("2025-06-01"));
1399 assert!(is_iso_date("2025-12-31"));
1400 }
1401
1402 #[test]
1403 fn iso_date_with_time_rejected() {
1404 assert!(!is_iso_date("2025-06-01T00:00:00"));
1405 }
1406
1407 #[test]
1408 fn iso_date_invalid() {
1409 assert!(!is_iso_date("6months"));
1410 assert!(!is_iso_date("2025"));
1411 assert!(!is_iso_date("not-a-date"));
1412 assert!(!is_iso_date("abcd-ef-gh"));
1413 }
1414
1415 #[test]
1416 fn trend_display() {
1417 assert_eq!(ChurnTrend::Accelerating.to_string(), "accelerating");
1418 assert_eq!(ChurnTrend::Stable.to_string(), "stable");
1419 assert_eq!(ChurnTrend::Cooling.to_string(), "cooling");
1420 }
1421
1422 #[test]
1423 fn trend_boundary_1_5x_ratio() {
1424 let timestamps = vec![100, 200, 600, 800, 1000];
1425 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1426 }
1427
1428 #[test]
1429 fn trend_just_above_1_5x() {
1430 let timestamps = vec![100, 600, 800, 1000];
1431 assert_eq!(compute_trend(×tamps), ChurnTrend::Accelerating);
1432 }
1433
1434 #[test]
1435 fn trend_boundary_0_67x_ratio() {
1436 let timestamps = vec![100, 200, 300, 600, 1000];
1437 assert_eq!(compute_trend(×tamps), ChurnTrend::Cooling);
1438 }
1439
1440 #[test]
1441 fn trend_two_timestamps_different() {
1442 let timestamps = vec![100, 200];
1443 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1444 }
1445
1446 #[test]
1447 fn parse_since_week_singular() {
1448 let d = parse_since("1week").unwrap();
1449 assert_eq!(
1450 d.window,
1451 ChurnWindow::Relative {
1452 count: 1,
1453 unit: ChurnWindowUnit::Weeks
1454 }
1455 );
1456 assert_eq!(d.display, "1 week");
1457 }
1458
1459 #[test]
1460 fn parse_since_weeks_long() {
1461 let d = parse_since("3weeks").unwrap();
1462 assert_eq!(
1463 d.window,
1464 ChurnWindow::Relative {
1465 count: 3,
1466 unit: ChurnWindowUnit::Weeks
1467 }
1468 );
1469 assert_eq!(d.display, "3 weeks");
1470 }
1471
1472 #[test]
1473 fn parse_since_days_long() {
1474 let d = parse_since("30days").unwrap();
1475 assert_eq!(
1476 d.window,
1477 ChurnWindow::Relative {
1478 count: 30,
1479 unit: ChurnWindowUnit::Days
1480 }
1481 );
1482 assert_eq!(d.display, "30 days");
1483 }
1484
1485 #[test]
1486 fn parse_since_year_long() {
1487 let d = parse_since("1year").unwrap();
1488 assert_eq!(
1489 d.window,
1490 ChurnWindow::Relative {
1491 count: 1,
1492 unit: ChurnWindowUnit::Years
1493 }
1494 );
1495 assert_eq!(d.display, "1 year");
1496 }
1497
1498 #[test]
1499 fn parse_since_overflow_number_rejected() {
1500 let result = parse_since("99999999999999999999d");
1501 assert!(result.is_err());
1502 let err = result.unwrap_err();
1503 assert!(err.contains("invalid number"));
1504 }
1505
1506 #[test]
1507 fn parse_since_zero_days_rejected() {
1508 assert!(parse_since("0d").is_err());
1509 }
1510
1511 #[test]
1512 fn parse_since_zero_weeks_rejected() {
1513 assert!(parse_since("0w").is_err());
1514 }
1515
1516 #[test]
1517 fn parse_since_zero_years_rejected() {
1518 assert!(parse_since("0y").is_err());
1519 }
1520
1521 #[test]
1522 fn iso_date_wrong_separator_positions() {
1523 assert!(!is_iso_date("20-25-0601"));
1524 assert!(!is_iso_date("202506-01-"));
1525 }
1526
1527 #[test]
1528 fn iso_date_too_short() {
1529 assert!(!is_iso_date("2025-06-0"));
1530 }
1531
1532 #[test]
1533 fn iso_date_letters_in_day() {
1534 assert!(!is_iso_date("2025-06-ab"));
1535 }
1536
1537 #[test]
1538 fn iso_date_letters_in_month() {
1539 assert!(!is_iso_date("2025-ab-01"));
1540 }
1541
1542 #[test]
1543 fn split_number_unit_valid() {
1544 let (num, unit) = split_number_unit("42days").unwrap();
1545 assert_eq!(num, "42");
1546 assert_eq!(unit, "days");
1547 }
1548
1549 #[test]
1550 fn split_number_unit_single_digit() {
1551 let (num, unit) = split_number_unit("1m").unwrap();
1552 assert_eq!(num, "1");
1553 assert_eq!(unit, "m");
1554 }
1555
1556 #[test]
1557 fn split_number_unit_no_digits() {
1558 let err = split_number_unit("abc").unwrap_err();
1559 assert!(err.contains("must start with a number"));
1560 }
1561
1562 #[test]
1563 fn split_number_unit_no_unit() {
1564 let err = split_number_unit("123").unwrap_err();
1565 assert!(err.contains("requires a unit suffix"));
1566 }
1567
1568 #[test]
1569 fn trend_serde_serialization() {
1570 assert_eq!(
1571 serde_json::to_string(&ChurnTrend::Accelerating).unwrap(),
1572 "\"accelerating\""
1573 );
1574 assert_eq!(
1575 serde_json::to_string(&ChurnTrend::Stable).unwrap(),
1576 "\"stable\""
1577 );
1578 assert_eq!(
1579 serde_json::to_string(&ChurnTrend::Cooling).unwrap(),
1580 "\"cooling\""
1581 );
1582 }
1583
1584 #[test]
1585 fn intern_author_returns_existing_index() {
1586 let mut pool = Vec::new();
1587 let mut index = FxHashMap::default();
1588 let i1 = intern_author("alice@x", &mut pool, &mut index);
1589 let i2 = intern_author("alice@x", &mut pool, &mut index);
1590 assert_eq!(i1, i2);
1591 assert_eq!(pool.len(), 1);
1592 }
1593
1594 #[test]
1595 fn intern_author_assigns_sequential_indices() {
1596 let mut pool = Vec::new();
1597 let mut index = FxHashMap::default();
1598 assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1599 assert_eq!(intern_author("bob@x", &mut pool, &mut index), 1);
1600 assert_eq!(intern_author("carol@x", &mut pool, &mut index), 2);
1601 assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1602 }
1603
1604 fn git(root: &Path, args: &[&str]) {
1605 let status = super::git_command()
1606 .args(args)
1607 .current_dir(root)
1608 .status()
1609 .expect("run git");
1610 assert!(status.success(), "git {args:?} failed");
1611 }
1612
1613 fn write(root: &Path, path: &str, contents: &str) {
1614 let path = root.join(path);
1615 std::fs::create_dir_all(path.parent().expect("test path has parent")).unwrap();
1616 std::fs::write(path, contents).unwrap();
1617 }
1618
1619 #[cfg(unix)]
1620 #[test]
1621 fn churn_preserves_special_filenames() {
1622 let repo = tempfile::tempdir().expect("create repo");
1623 let root = repo.path();
1624 git(root, &["init", "--quiet"]);
1625 git(root, &["config", "user.email", "churn@example.test"]);
1626 git(root, &["config", "user.name", "Churn Test"]);
1627 git(root, &["config", "commit.gpgsign", "false"]);
1628
1629 let special_files = [
1630 "src/line\nbreak.ts",
1631 "src/space name.ts",
1632 "src/quote\"name.ts",
1633 "src/back\\slash.ts",
1634 "src/unicode-λ.ts",
1635 ]
1636 .map(|path| root.join(path));
1637 std::fs::create_dir_all(root.join("src")).expect("source dir");
1638 for special in &special_files {
1639 std::fs::write(special, "export const value = 1;\n").expect("special fixture");
1640 }
1641 git(root, &["add", "."]);
1642 git(root, &["commit", "--quiet", "-m", "initial"]);
1643
1644 let since = parse_since("1y").expect("valid duration");
1645 let churn = analyze_churn(root, &since).expect("churn result");
1646 for special in special_files {
1647 assert!(
1648 churn.files.contains_key(&special),
1649 "missing {special:?}: {:?}",
1650 churn.files.keys()
1651 );
1652 }
1653 }
1654
1655 #[cfg(unix)]
1656 #[test]
1657 fn churn_cache_preserves_non_utf8_filenames() {
1658 use std::ffi::OsString;
1659 use std::os::unix::ffi::OsStringExt;
1660
1661 let invalid_path = PathBuf::from(OsString::from_vec(b"src/non-utf8-\xff.ts".to_vec()));
1662 let mut files = FxHashMap::default();
1663 files.insert(
1664 invalid_path.clone(),
1665 FileEvents {
1666 events: vec![CachedCommitEvent {
1667 timestamp: 1,
1668 committed_at: 1,
1669 lines_added: 2,
1670 lines_deleted: 1,
1671 author_idx: None,
1672 }],
1673 },
1674 );
1675 let state = ChurnEventState {
1676 files,
1677 author_pool: Vec::new(),
1678 git_log_bytes: 0,
1679 };
1680 let cache_dir = tempfile::tempdir().expect("cache directory");
1681 save_churn_cache(cache_dir.path(), "abc123", "1y", &state, false);
1682 let warm = load_churn_cache(cache_dir.path(), "1y")
1683 .expect("warm churn cache")
1684 .into_event_state(None);
1685
1686 assert!(warm.files.contains_key(&invalid_path));
1687 }
1688
1689 #[test]
1690 fn churn_cache_rejects_pre_lossless_path_encoding_version() {
1691 let cache_dir = tempfile::tempdir().expect("cache directory");
1692 let cache = ChurnCache {
1693 version: 4,
1694 last_indexed_sha: "abc123".to_string(),
1695 window_token: "1y".to_string(),
1696 files: vec![CachedFileChurn {
1697 path: br#"/project/\"src/line\\nbreak.ts\""#.to_vec(),
1698 events: Vec::new(),
1699 }],
1700 shallow_clone: false,
1701 author_pool: Vec::new(),
1702 };
1703 std::fs::write(cache_dir.path().join("churn.bin"), bitcode::encode(&cache))
1704 .expect("cache fixture");
1705
1706 assert!(load_churn_cache(cache_dir.path(), "1y").is_none());
1707 }
1708
1709 #[test]
1710 fn cached_churn_merges_new_commits_after_head_advances() {
1711 let repo = tempfile::tempdir().expect("create repo");
1712 let root = repo.path();
1713 git(root, &["init"]);
1714 git(root, &["config", "user.email", "churn@example.test"]);
1715 git(root, &["config", "user.name", "Churn Test"]);
1716 git(root, &["config", "commit.gpgsign", "false"]);
1717
1718 write(root, "src/a.ts", "export const a = 1;\n");
1719 git(root, &["add", "."]);
1720 git(root, &["commit", "-m", "initial"]);
1721
1722 let since = parse_since("1y").unwrap();
1723 let cache = tempfile::tempdir().expect("create cache dir");
1724 let (cold, cold_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1725 assert!(!cold_hit);
1726 let file = root.join("src/a.ts");
1727 assert_eq!(cold.files[&file].commits, 1);
1728
1729 let (_warm, warm_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1730 assert!(warm_hit);
1731
1732 write(
1733 root,
1734 "src/a.ts",
1735 "export const a = 1;\nexport const b = 2;\n",
1736 );
1737 git(root, &["add", "."]);
1738 git(root, &["commit", "-m", "update a"]);
1739 let head = get_head_sha(root).unwrap();
1740
1741 let (incremental, incremental_hit) =
1742 analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1743 assert!(incremental_hit);
1744 assert_eq!(incremental.files[&file].commits, 2);
1745
1746 let cache = load_churn_cache(cache.path(), &since.window.cache_token()).unwrap();
1747 assert_eq!(cache.last_indexed_sha, head);
1748 }
1749
1750 fn write_churn_file(dir: &std::path::Path, contents: &str) -> PathBuf {
1751 let path = dir.join("churn.json");
1752 std::fs::write(&path, contents).unwrap();
1753 path
1754 }
1755
1756 #[test]
1757 fn churn_file_happy_path() {
1758 let dir = tempfile::tempdir().unwrap();
1759 let root = Path::new("/project");
1760 let path = write_churn_file(
1761 dir.path(),
1762 r#"{
1763 "schema": "fallow-churn/v1",
1764 "events": [
1765 { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 10, "deleted": 5 },
1766 { "path": "src/a.ts", "timestamp": 1700100000, "author": "bob@corp", "added": 3, "deleted": 2 }
1767 ]
1768 }"#,
1769 );
1770 let result = analyze_churn_from_file(&path, root).unwrap();
1771 let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
1772 assert_eq!(churn.commits, 2);
1773 assert_eq!(churn.lines_added, 13);
1774 assert_eq!(churn.lines_deleted, 7);
1775 assert_eq!(churn.authors.len(), 2);
1776 assert!(result.author_pool.contains(&"alice@corp".to_string()));
1777 assert!(result.author_pool.contains(&"bob@corp".to_string()));
1778 assert!(!result.shallow_clone);
1779 }
1780
1781 #[test]
1782 fn churn_file_empty_events_is_valid() {
1783 let dir = tempfile::tempdir().unwrap();
1784 let path = write_churn_file(
1785 dir.path(),
1786 r#"{ "schema": "fallow-churn/v1", "events": [] }"#,
1787 );
1788 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1789 assert!(result.files.is_empty());
1790 assert!(result.author_pool.is_empty());
1791 }
1792
1793 #[test]
1794 fn churn_file_missing_events_key_is_valid() {
1795 let dir = tempfile::tempdir().unwrap();
1796 let path = write_churn_file(dir.path(), r#"{ "schema": "fallow-churn/v1" }"#);
1797 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1798 assert!(result.files.is_empty());
1799 }
1800
1801 #[test]
1802 fn churn_file_bad_schema_rejected() {
1803 let dir = tempfile::tempdir().unwrap();
1804 let path = write_churn_file(
1805 dir.path(),
1806 r#"{ "schema": "fallow-churn/v2", "events": [] }"#,
1807 );
1808 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1809 assert!(err.contains("expected \"fallow-churn/v1\""), "{err}");
1810 }
1811
1812 #[test]
1813 fn churn_file_malformed_json_rejected() {
1814 let dir = tempfile::tempdir().unwrap();
1815 let path = write_churn_file(dir.path(), "{ not json");
1816 assert!(analyze_churn_from_file(&path, Path::new("/project")).is_err());
1817 }
1818
1819 #[test]
1820 fn churn_file_missing_file_rejected() {
1821 let err = analyze_churn_from_file(Path::new("/no/such/churn.json"), Path::new("/project"))
1822 .unwrap_err();
1823 assert!(err.contains("failed to read churn file"), "{err}");
1824 }
1825
1826 #[test]
1827 fn churn_file_reader_accepts_exact_limit() {
1828 let dir = tempfile::tempdir().unwrap();
1829 let path = write_churn_file(dir.path(), "12345678");
1830 assert_eq!(read_churn_file_with_limit(&path, 8).unwrap(), "12345678");
1831 }
1832
1833 #[test]
1834 fn churn_file_reader_rejects_limit_plus_one() {
1835 let dir = tempfile::tempdir().unwrap();
1836 let path = write_churn_file(dir.path(), "123456789");
1837 let err = read_churn_file_with_limit(&path, 8).unwrap_err();
1838 assert!(err.contains("at least 9 bytes"), "{err}");
1839 assert!(err.contains("8 byte limit"), "{err}");
1840 }
1841
1842 #[test]
1843 fn churn_file_empty_path_rejected() {
1844 let dir = tempfile::tempdir().unwrap();
1845 let path = write_churn_file(
1846 dir.path(),
1847 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": " ", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
1848 );
1849 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1850 assert!(err.contains("empty path"), "{err}");
1851 }
1852
1853 #[test]
1854 fn churn_file_rejects_non_relative_paths() {
1855 let invalid = [
1856 "/tmp/a.ts",
1857 r"C:\tmp\a.ts",
1858 "../a.ts",
1859 "src/../../a.ts",
1860 "./src/a.ts",
1861 "//server/share/a.ts",
1862 ];
1863 for event_path in invalid {
1864 let dir = tempfile::tempdir().unwrap();
1865 let body = format!(
1866 r#"{{ "schema": "fallow-churn/v1", "events": [ {{ "path": {event_path:?}, "timestamp": 1700000000, "added": 1, "deleted": 0 }} ] }}"#
1867 );
1868 let path = write_churn_file(dir.path(), &body);
1869 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1870 assert!(err.contains(event_path), "{event_path}: {err}");
1871 assert!(err.contains("repo-relative"), "{event_path}: {err}");
1872 }
1873 }
1874
1875 #[test]
1876 fn churn_file_accepts_unicode_and_spaces_in_path_components() {
1877 let dir = tempfile::tempdir().unwrap();
1878 let path = write_churn_file(
1879 dir.path(),
1880 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/ruimte map/naïef.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
1881 );
1882 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1883 assert!(
1884 result
1885 .files
1886 .contains_key(&PathBuf::from("/project/src/ruimte map/naïef.ts"))
1887 );
1888 }
1889
1890 #[test]
1891 fn churn_file_millisecond_timestamp_rejected() {
1892 let dir = tempfile::tempdir().unwrap();
1893 let path = write_churn_file(
1895 dir.path(),
1896 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000000, "added": 1, "deleted": 0 } ] }"#,
1897 );
1898 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1899 assert!(err.contains("milliseconds"), "{err}");
1900 }
1901
1902 #[test]
1903 fn churn_file_missing_author_contributes_no_signal() {
1904 let dir = tempfile::tempdir().unwrap();
1905 let path = write_churn_file(
1906 dir.path(),
1907 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
1908 );
1909 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1910 let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
1911 assert_eq!(churn.commits, 1);
1912 assert!(churn.authors.is_empty());
1913 assert!(result.author_pool.is_empty());
1914 }
1915
1916 #[test]
1917 fn churn_file_empty_author_string_treated_as_absent() {
1918 let dir = tempfile::tempdir().unwrap();
1919 let path = write_churn_file(
1920 dir.path(),
1921 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "author": " ", "added": 1, "deleted": 0 } ] }"#,
1922 );
1923 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1924 assert!(result.author_pool.is_empty());
1925 }
1926
1927 #[test]
1928 fn churn_file_unknown_fields_ignored() {
1929 let dir = tempfile::tempdir().unwrap();
1932 let path = write_churn_file(
1933 dir.path(),
1934 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" } ] }"#,
1935 );
1936 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1937 assert_eq!(result.files[&PathBuf::from("/project/src/a.ts")].commits, 1);
1938 }
1939
1940 #[test]
1941 fn churn_file_backslash_paths_normalized() {
1942 let dir = tempfile::tempdir().unwrap();
1943 let path = write_churn_file(
1944 dir.path(),
1945 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src\\a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
1946 );
1947 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1948 assert!(
1949 result
1950 .files
1951 .contains_key(&PathBuf::from("/project/src/a.ts"))
1952 );
1953 }
1954
1955 #[test]
1956 fn churn_file_rejects_line_totals_above_u32() {
1957 let dir = tempfile::tempdir().unwrap();
1958 let path = write_churn_file(
1959 dir.path(),
1960 r#"{ "schema": "fallow-churn/v1", "events": [
1961 { "path": "src/a.ts", "timestamp": 1700000000, "added": 4294967295, "deleted": 0 },
1962 { "path": "src/a.ts", "timestamp": 1700000001, "added": 1, "deleted": 0 }
1963 ] }"#,
1964 );
1965 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1966 assert!(err.contains("exceeding the u32 limit"), "{err}");
1967 assert!(err.contains("src/a.ts"), "{err}");
1968 }
1969
1970 #[test]
1971 fn churn_file_accepts_line_totals_at_u32_max() {
1972 let dir = tempfile::tempdir().unwrap();
1973 let path = write_churn_file(
1974 dir.path(),
1975 r#"{ "schema": "fallow-churn/v1", "events": [
1976 { "path": "src/a.ts", "timestamp": 1700000000, "added": 4294967294, "deleted": 4294967295 },
1977 { "path": "src/a.ts", "timestamp": 1700000001, "added": 1, "deleted": 0 }
1978 ] }"#,
1979 );
1980 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1981 let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
1982 assert_eq!(churn.lines_added, u32::MAX);
1983 assert_eq!(churn.lines_deleted, u32::MAX);
1984 }
1985
1986 #[test]
1991 fn headerless_numstat_falls_back_to_the_run_clock() {
1992 let root = Path::new("/project");
1993 let pinned = 1_700_000_000;
1994
1995 let state = parse_git_log_events_z(b"10\t5\tsrc/a.ts", root, pinned);
1996
1997 let events = &state.files[&PathBuf::from("/project/src/a.ts")].events;
1998 assert_eq!(events.len(), 1);
1999 assert_eq!(events[0].timestamp, pinned);
2000 assert_eq!(events[0].committed_at, pinned);
2001 }
2002
2003 #[test]
2007 fn headerless_numstat_tracks_only_the_supplied_clock() {
2008 let root = Path::new("/project");
2009 let record: &[u8] = b"1\t0\tsrc/a.ts";
2010
2011 let early = parse_git_log_events_z(record, root, 1_600_000_000);
2012 let late = parse_git_log_events_z(record, root, 1_700_000_000);
2013
2014 let key = PathBuf::from("/project/src/a.ts");
2015 assert_eq!(early.files[&key].events[0].timestamp, 1_600_000_000);
2016 assert_eq!(late.files[&key].events[0].timestamp, 1_700_000_000);
2017 }
2018
2019 #[test]
2021 fn commit_header_timestamp_beats_the_run_clock_fallback() {
2022 let root = Path::new("/project");
2023 let record = b"1700000000|1700000500|dev@example.com\x002\t1\tsrc/a.ts";
2024
2025 let state = parse_git_log_events_z(record, root, 1_234_567_890);
2026
2027 let events = &state.files[&PathBuf::from("/project/src/a.ts")].events;
2028 assert_eq!(events[0].timestamp, 1_700_000_000);
2029 assert_eq!(events[0].committed_at, 1_700_000_500);
2030 }
2031
2032 const NOW: u64 = 1_750_000_000;
2034 const HEADER: &str = "1700000000|1700000000|dev@example.com";
2035
2036 fn git_log_z(commits: &[(&str, &[&str])]) -> Vec<u8> {
2040 let mut out = Vec::new();
2041 for (index, (header, rows)) in commits.iter().enumerate() {
2042 if index > 0 {
2043 out.push(0);
2044 }
2045 out.extend_from_slice(header.as_bytes());
2046 out.extend_from_slice(b"\0\n");
2047 for row in *rows {
2048 out.extend_from_slice(row.as_bytes());
2049 out.push(0);
2050 }
2051 }
2052 out
2053 }
2054
2055 fn churn_from_git_log_z(stdout: &[u8], root: &Path, now_secs: u64) -> ChurnResult {
2056 build_churn_result(
2057 parse_git_log_events_z(stdout, root, now_secs),
2058 false,
2059 crate::clock::AnalysisClock::pinned(now_secs),
2060 )
2061 }
2062
2063 fn churn_from_commits(commits: &[(&str, &[&str])]) -> ChurnResult {
2064 churn_from_git_log_z(&git_log_z(commits), Path::new("/project"), NOW)
2065 }
2066
2067 #[test]
2068 fn git_log_z_fixture_matches_real_git_output() {
2069 assert_eq!(
2070 git_log_z(&[
2071 ("1|2|a@b", &["1\t0\ta.ts"]),
2072 ("3|4|a@b", &["1\t0\ta.ts", "1\t0\tb c.ts"])
2073 ]),
2074 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()
2075 );
2076 }
2077
2078 #[test]
2079 fn numstat_records_parse_counts_and_skip_binary_or_malformed_rows() {
2080 let root = Path::new("/project");
2081 type Expected = Option<(u32, u32, &'static str)>;
2082 let cases: &[(&str, Expected)] = &[
2083 ("10\t5\tsrc/file.ts", Some((10, 5, "src/file.ts"))),
2084 ("0\t0\tsrc/empty.ts", Some((0, 0, "src/empty.ts"))),
2085 (
2086 "3\t1\tpath with spaces/file.ts",
2087 Some((3, 1, "path with spaces/file.ts")),
2088 ),
2089 ("1\t0\tweird\tname.ts", Some((1, 0, "weird\tname.ts"))),
2090 (
2091 "4294967295\t8888\tsrc/big.ts",
2092 Some((u32::MAX, 8888, "src/big.ts")),
2093 ),
2094 ("4294967296\t0\tsrc/overflow.ts", None),
2095 ("-\t-\tsrc/image.png", None),
2096 ("-\t5\tsrc/file.ts", None),
2097 ("10\t-\tsrc/file.ts", None),
2098 ("10\t5", None),
2099 ];
2100 for (row, expected) in cases {
2101 let state = parse_git_log_events_z(&git_log_z(&[(HEADER, &[row])]), root, NOW);
2102 let Some((added, deleted, path)) = expected else {
2103 assert!(state.files.is_empty(), "{row:?} must not record churn");
2104 continue;
2105 };
2106 assert_eq!(state.files.len(), 1, "{row:?}");
2107 let events = &state.files[&root.join(path)].events;
2108 assert_eq!(events.len(), 1, "{row:?}");
2109 assert_eq!(events[0].lines_added, *added, "{row:?}");
2110 assert_eq!(events[0].lines_deleted, *deleted, "{row:?}");
2111 }
2112 }
2113
2114 #[test]
2115 fn git_log_z_aggregates_commits_and_lines_per_file() {
2116 let result = churn_from_commits(&[
2117 (
2118 HEADER,
2119 &["10\t5\tsrc/a.ts", "3\t1\tsrc/b.ts", "-\t-\timage.png"],
2120 ),
2121 ("1700100000|1700100000|dev@example.com", &["3\t2\tsrc/a.ts"]),
2122 ]);
2123 assert_eq!(result.files.len(), 2);
2124 let a = &result.files[&PathBuf::from("/project/src/a.ts")];
2125 assert_eq!((a.commits, a.lines_added, a.lines_deleted), (2, 13, 7));
2126 let b = &result.files[&PathBuf::from("/project/src/b.ts")];
2127 assert_eq!((b.commits, b.lines_added, b.lines_deleted), (1, 3, 1));
2128 assert!(
2129 !result
2130 .files
2131 .contains_key(&PathBuf::from("/project/image.png"))
2132 );
2133 }
2134
2135 #[test]
2136 fn git_log_z_empty_or_blank_output_records_nothing() {
2137 assert!(
2138 churn_from_git_log_z(b"", Path::new("/project"), NOW)
2139 .files
2140 .is_empty()
2141 );
2142 assert!(
2143 churn_from_git_log_z(b"\0\0\n\0 \0", Path::new("/project"), NOW)
2144 .files
2145 .is_empty()
2146 );
2147 }
2148
2149 #[test]
2150 fn git_log_z_blank_records_between_commits_are_ignored() {
2151 let stdout = b" \0\n1700000000|1700000000|dev@example.com\0\n \0\n10\t5\tsrc/a.ts\0\0";
2152 let result = churn_from_git_log_z(stdout, Path::new("/project"), NOW);
2153 assert_eq!(result.files.len(), 1);
2154 assert_eq!(result.files[&PathBuf::from("/project/src/a.ts")].commits, 1);
2155 }
2156
2157 #[test]
2158 fn git_log_z_paths_are_joined_to_the_root() {
2159 let root = Path::new("/my/project");
2160 let result =
2161 churn_from_git_log_z(&git_log_z(&[(HEADER, &["1\t0\tlib/utils.ts"])]), root, NOW);
2162 let key = PathBuf::from("/my/project/lib/utils.ts");
2163 assert_eq!(result.files[&key].path, key);
2164 }
2165
2166 #[test]
2167 fn git_log_z_weights_commits_by_age_against_the_run_clock() {
2168 let day = 86_400;
2169 let now = format!("{NOW}|{NOW}|dev@example.com");
2170 let half_life = format!("{0}|{0}|dev@example.com", NOW - 45 * day);
2171 let two_half_lives = format!("{0}|{0}|dev@example.com", NOW - 180 * day);
2172 let result = churn_from_commits(&[
2173 (&now, &["1\t0\tsrc/fresh.ts"]),
2174 (&half_life, &["1\t0\tsrc/half.ts"]),
2175 (&two_half_lives, &["1\t0\tsrc/old.ts"]),
2176 ]);
2177 let weight = |name: &str| {
2178 result.files[&PathBuf::from(format!("/project/src/{name}"))].weighted_commits
2179 };
2180 assert!((weight("fresh.ts") - 1.0).abs() < f64::EPSILON);
2181 assert!((weight("half.ts") - 0.71).abs() < f64::EPSILON);
2183 assert!((weight("old.ts") - 0.25).abs() < f64::EPSILON);
2184 }
2185
2186 #[test]
2187 fn git_log_z_headerless_numstat_weighs_as_a_commit_at_the_run_clock() {
2188 let result = churn_from_git_log_z(b"10\t5\tsrc/no_ts.ts\0", Path::new("/project"), NOW);
2189 let churn = &result.files[&PathBuf::from("/project/src/no_ts.ts")];
2190 assert_eq!(
2191 (churn.commits, churn.lines_added, churn.lines_deleted),
2192 (1, 10, 5)
2193 );
2194 assert!((churn.weighted_commits - 1.0).abs() < f64::EPSILON);
2195 }
2196
2197 #[test]
2198 fn git_log_z_trend_is_computed_per_file() {
2199 let result = churn_from_commits(&[
2200 (
2201 "1000|1000|dev@example.com",
2202 &["5\t1\tsrc/old.ts", "1\t0\tsrc/hot.ts"],
2203 ),
2204 ("1800|1800|dev@example.com", &["1\t0\tsrc/hot.ts"]),
2205 ("1900|1900|dev@example.com", &["1\t0\tsrc/hot.ts"]),
2206 ("1950|1950|dev@example.com", &["1\t0\tsrc/hot.ts"]),
2207 (
2208 "2000|2000|dev@example.com",
2209 &["3\t1\tsrc/old.ts", "1\t0\tsrc/hot.ts"],
2210 ),
2211 ]);
2212 let old = &result.files[&PathBuf::from("/project/src/old.ts")];
2213 let hot = &result.files[&PathBuf::from("/project/src/hot.ts")];
2214 assert_eq!(old.commits, 2);
2215 assert_eq!(old.trend, ChurnTrend::Stable);
2216 assert_eq!(hot.commits, 5);
2217 assert_eq!(hot.trend, ChurnTrend::Accelerating);
2218 }
2219
2220 #[test]
2221 fn git_log_z_interns_authors_and_aggregates_per_author() {
2222 let result = churn_from_commits(&[
2223 (
2224 "1700000000|1700000000|alice@example.com",
2225 &["1\t0\tsrc/index.ts"],
2226 ),
2227 (
2228 "1700100000|1700100000|bob@example.com",
2229 &["2\t0\tsrc/index.ts"],
2230 ),
2231 (
2232 "1700200000|1700200000|alice@example.com",
2233 &["1\t1\tsrc/index.ts"],
2234 ),
2235 ]);
2236 assert_eq!(
2237 result.author_pool,
2238 vec![
2239 "alice@example.com".to_string(),
2240 "bob@example.com".to_string()
2241 ]
2242 );
2243 let churn = &result.files[&PathBuf::from("/project/src/index.ts")];
2244 assert_eq!(churn.commits, 3);
2245 assert_eq!(churn.authors.len(), 2);
2246 let alice = &churn.authors[&0];
2247 assert_eq!(alice.commits, 2);
2248 assert_eq!(alice.first_commit_ts, 1_700_000_000);
2249 assert_eq!(alice.last_commit_ts, 1_700_200_000);
2250 let bob = &churn.authors[&1];
2251 assert_eq!(bob.commits, 1);
2252 assert_eq!(bob.first_commit_ts, 1_700_100_000);
2253 }
2254
2255 #[test]
2256 fn git_log_z_legacy_headers_still_parse() {
2257 let result = churn_from_commits(&[
2258 ("1700000000", &["10\t5\tsrc/bare.ts"]),
2259 ("1700100000|alice@example.com", &["1\t0\tsrc/two_field.ts"]),
2260 ]);
2261 assert_eq!(result.author_pool, vec!["alice@example.com".to_string()]);
2262 let bare = &result.files[&PathBuf::from("/project/src/bare.ts")];
2263 assert_eq!(bare.commits, 1);
2264 assert!(bare.authors.is_empty());
2265 let two_field = &result.files[&PathBuf::from("/project/src/two_field.ts")];
2266 assert_eq!(two_field.authors[&0].first_commit_ts, 1_700_100_000);
2267 }
2268
2269 #[test]
2270 fn churn_file_matches_git_log_z_parse() {
2271 let dir = tempfile::tempdir().unwrap();
2275 let root = Path::new("/project");
2276 let path = write_churn_file(
2277 dir.path(),
2278 r#"{
2279 "schema": "fallow-churn/v1",
2280 "events": [
2281 { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 10, "deleted": 5 },
2282 { "path": "src/b.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 3, "deleted": 1 },
2283 { "path": "src/a.ts", "timestamp": 1700100000, "author": "bob@corp", "added": 3, "deleted": 2 }
2284 ]
2285 }"#,
2286 );
2287 let imported = analyze_churn_from_file(&path, root).unwrap();
2288 let git = churn_from_git_log_z(
2289 &git_log_z(&[
2290 (
2291 "1700000000|1700000000|alice@corp",
2292 &["10\t5\tsrc/a.ts", "3\t1\tsrc/b.ts"],
2293 ),
2294 ("1700100000|1700100000|bob@corp", &["3\t2\tsrc/a.ts"]),
2295 ]),
2296 root,
2297 imported.clock.epoch_secs(),
2298 );
2299
2300 assert_eq!(
2301 git.author_pool, imported.author_pool,
2302 "author pools diverge"
2303 );
2304 assert_eq!(git.files.len(), imported.files.len());
2305 for (file, git_churn) in &git.files {
2306 let imp = &imported.files[file];
2307 assert_eq!(git_churn.commits, imp.commits, "commits for {file:?}");
2308 assert_eq!(git_churn.lines_added, imp.lines_added, "added for {file:?}");
2309 assert_eq!(
2310 git_churn.lines_deleted, imp.lines_deleted,
2311 "deleted for {file:?}"
2312 );
2313 assert_eq!(git_churn.trend, imp.trend, "trend for {file:?}");
2314 assert_eq!(git_churn.authors, imp.authors, "authors for {file:?}");
2315 assert!(
2316 (git_churn.weighted_commits - imp.weighted_commits).abs() < f64::EPSILON,
2317 "weighted_commits for {file:?}: {} vs {}",
2318 git_churn.weighted_commits,
2319 imp.weighted_commits
2320 );
2321 }
2322 }
2323}