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