1use rustc_hash::FxHashMap;
7use std::io::Read as _;
8use std::path::{Component, Path, PathBuf};
9use std::process::{Command, Output, Stdio};
10use std::sync::OnceLock;
11
12use serde::Deserialize;
13
14pub use fallow_types::churn::ChurnTrend;
15
16pub type ChurnSpawnHook = fn(&mut Command) -> std::io::Result<Output>;
22
23static SPAWN_HOOK: OnceLock<ChurnSpawnHook> = OnceLock::new();
24
25#[expect(
26 clippy::disallowed_methods,
27 reason = "engine-owned git spawn wrapper clears ambient git env before churn subprocesses"
28)]
29fn git_command() -> Command {
30 let mut command = Command::new("git");
31 crate::git_env::clear_ambient_git_env(&mut command);
32 command.stdin(Stdio::null());
34 command
35}
36
37pub fn set_spawn_hook(hook: ChurnSpawnHook) {
43 let _ = SPAWN_HOOK.set(hook);
44}
45
46fn spawn_output(command: &mut Command) -> std::io::Result<Output> {
47 if let Some(hook) = SPAWN_HOOK.get() {
48 hook(command)
49 } else {
50 command.output()
51 }
52}
53
54const SECS_PER_DAY: f64 = 86_400.0;
56
57const HALF_LIFE_DAYS: f64 = 90.0;
60
61const CHURN_FILE_SCHEMA: &str = "fallow-churn/v1";
63
64const MAX_CHURN_EVENTS: usize = 5_000_000;
70
71const MAX_CHURN_FILE_BYTES: usize = 256 * 1024 * 1024;
73
74const MAX_FUTURE_TIMESTAMP_SECS: u64 = 365 * 24 * 60 * 60;
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct SinceDuration {
86 pub git_after: String,
88 pub display: String,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq)]
97pub struct AuthorContribution {
98 pub commits: u32,
100 pub weighted_commits: f64,
102 pub first_commit_ts: u64,
104 pub last_commit_ts: u64,
106}
107
108#[derive(Debug, Clone)]
110pub struct FileChurn {
111 pub path: PathBuf,
113 pub commits: u32,
115 pub weighted_commits: f64,
117 pub lines_added: u32,
119 pub lines_deleted: u32,
121 pub trend: ChurnTrend,
123 pub authors: FxHashMap<u32, AuthorContribution>,
126}
127
128#[derive(Debug, Clone)]
130pub struct ChurnResult {
131 pub files: FxHashMap<PathBuf, FileChurn>,
133 pub shallow_clone: bool,
135 pub author_pool: Vec<String>,
138}
139
140pub fn parse_since(input: &str) -> Result<SinceDuration, String> {
151 if is_iso_date(input) {
152 return Ok(SinceDuration {
153 git_after: input.to_string(),
154 display: input.to_string(),
155 });
156 }
157
158 let (num_str, unit) = split_number_unit(input)?;
159 let num: u64 = num_str
160 .parse()
161 .map_err(|_| format!("invalid number in --since: {input}"))?;
162
163 if num == 0 {
164 return Err("--since duration must be greater than 0".to_string());
165 }
166
167 match unit {
168 "d" | "day" | "days" => {
169 let s = if num == 1 { "" } else { "s" };
170 Ok(SinceDuration {
171 git_after: format!("{num} day{s} ago"),
172 display: format!("{num} day{s}"),
173 })
174 }
175 "w" | "week" | "weeks" => {
176 let s = if num == 1 { "" } else { "s" };
177 Ok(SinceDuration {
178 git_after: format!("{num} week{s} ago"),
179 display: format!("{num} week{s}"),
180 })
181 }
182 "m" | "month" | "months" => {
183 let s = if num == 1 { "" } else { "s" };
184 Ok(SinceDuration {
185 git_after: format!("{num} month{s} ago"),
186 display: format!("{num} month{s}"),
187 })
188 }
189 "y" | "year" | "years" => {
190 let s = if num == 1 { "" } else { "s" };
191 Ok(SinceDuration {
192 git_after: format!("{num} year{s} ago"),
193 display: format!("{num} year{s}"),
194 })
195 }
196 _ => Err(format!(
197 "unknown duration unit '{unit}' in --since. Use d/w/m/y (e.g., 6m, 90d, 1y)"
198 )),
199 }
200}
201
202pub fn analyze_churn(root: &Path, since: &SinceDuration) -> Option<ChurnResult> {
206 let shallow = is_shallow_clone(root);
207 let state = analyze_churn_events(root, since, None)?;
208 Some(build_churn_result(state, shallow))
209}
210
211#[derive(Debug, Deserialize)]
217struct ChurnFileDoc {
218 schema: String,
219 #[serde(default)]
220 events: Vec<ChurnFileEvent>,
221}
222
223#[derive(Debug, Deserialize)]
228struct ChurnFileEvent {
229 path: String,
231 timestamp: u64,
233 #[serde(default)]
236 author: Option<String>,
237 added: u32,
239 deleted: u32,
241}
242
243pub(crate) fn analyze_churn_from_file(path: &Path, root: &Path) -> Result<ChurnResult, String> {
257 let raw = read_churn_file_with_limit(path, MAX_CHURN_FILE_BYTES)?;
258 let doc: ChurnFileDoc = serde_json::from_str(&raw)
259 .map_err(|e| format!("failed to parse churn file {}: {e}", path.display()))?;
260 if doc.schema != CHURN_FILE_SCHEMA {
261 return Err(format!(
262 "churn file {} declares schema \"{}\", expected \"{CHURN_FILE_SCHEMA}\"",
263 path.display(),
264 doc.schema
265 ));
266 }
267 if doc.events.len() > MAX_CHURN_EVENTS {
268 return Err(format!(
269 "churn file {} has {} events, exceeding the {MAX_CHURN_EVENTS} limit",
270 path.display(),
271 doc.events.len()
272 ));
273 }
274
275 let state = churn_event_state_from_doc(&doc, path, root)?;
276 Ok(build_churn_result(state, false))
277}
278
279fn read_churn_file_with_limit(path: &Path, limit: usize) -> Result<String, String> {
280 let file = std::fs::File::open(path)
281 .map_err(|e| format!("failed to read churn file {}: {e}", path.display()))?;
282 let mut bytes = Vec::new();
283 file.take(limit as u64 + 1)
284 .read_to_end(&mut bytes)
285 .map_err(|e| format!("failed to read churn file {}: {e}", path.display()))?;
286 if bytes.len() > limit {
287 return Err(format!(
288 "churn file {} is at least {} bytes, exceeding the {limit} byte limit",
289 path.display(),
290 bytes.len()
291 ));
292 }
293 String::from_utf8(bytes)
294 .map_err(|e| format!("failed to read churn file {} as UTF-8: {e}", path.display()))
295}
296
297fn churn_event_state_from_doc(
303 doc: &ChurnFileDoc,
304 path: &Path,
305 root: &Path,
306) -> Result<ChurnEventState, String> {
307 let mut builder = ChurnFileImportBuilder::new(path, root, churn_file_future_limit());
308
309 for event in &doc.events {
310 builder.push_event(event)?;
311 }
312
313 Ok(builder.finish())
314}
315
316fn churn_file_future_limit() -> u64 {
317 let now_secs = std::time::SystemTime::now()
318 .duration_since(std::time::UNIX_EPOCH)
319 .unwrap_or_default()
320 .as_secs();
321 now_secs.saturating_add(MAX_FUTURE_TIMESTAMP_SECS)
322}
323
324struct ChurnFileImportBuilder<'a> {
325 path: &'a Path,
326 root: &'a Path,
327 future_limit: u64,
328 files: FxHashMap<PathBuf, FileEvents>,
329 totals: FxHashMap<PathBuf, (u64, u64)>,
330 author_pool: Vec<String>,
331 author_index: FxHashMap<String, u32>,
332}
333
334impl<'a> ChurnFileImportBuilder<'a> {
335 fn new(path: &'a Path, root: &'a Path, future_limit: u64) -> Self {
336 Self {
337 path,
338 root,
339 future_limit,
340 files: FxHashMap::default(),
341 totals: FxHashMap::default(),
342 author_pool: Vec::new(),
343 author_index: FxHashMap::default(),
344 }
345 }
346
347 fn push_event(&mut self, event: &ChurnFileEvent) -> Result<(), String> {
348 let rel = normalize_churn_event_path(self.path, &event.path)?;
349 validate_churn_event_timestamp(self.path, event.timestamp, self.future_limit, &rel)?;
350
351 let abs_path = self.root.join(&rel);
352 let totals = self.totals.entry(abs_path.clone()).or_default();
353 totals.0 += u64::from(event.added);
354 totals.1 += u64::from(event.deleted);
355 if totals.0 > u64::from(u32::MAX) || totals.1 > u64::from(u32::MAX) {
356 return Err(format!(
357 "churn file {} has line totals for \"{rel}\" exceeding the u32 limit \
358 (added {}, deleted {})",
359 self.path.display(),
360 totals.0,
361 totals.1
362 ));
363 }
364 let author_idx = self.intern_author(event.author.as_deref());
365 self.files
366 .entry(abs_path)
367 .or_insert_with(|| FileEvents { events: Vec::new() })
368 .events
369 .push(CachedCommitEvent {
370 timestamp: event.timestamp,
371 lines_added: event.added,
372 lines_deleted: event.deleted,
373 author_idx,
374 });
375 Ok(())
376 }
377
378 fn intern_author(&mut self, author: Option<&str>) -> Option<u32> {
379 author
380 .map(str::trim)
381 .filter(|email| !email.is_empty())
382 .map(|email| intern_author(email, &mut self.author_pool, &mut self.author_index))
383 }
384
385 fn finish(self) -> ChurnEventState {
386 ChurnEventState {
387 files: self.files,
388 author_pool: self.author_pool,
389 }
390 }
391}
392
393fn normalize_churn_event_path(path: &Path, event_path: &str) -> Result<String, String> {
394 let normalized = event_path.replace('\\', "/");
395 let rel = normalized.trim();
396 if rel.is_empty() {
397 return Err(format!(
398 "churn file {} has an event with an empty path",
399 path.display()
400 ));
401 }
402 let bytes = rel.as_bytes();
403 let has_drive_prefix = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':';
404 let has_empty_component = rel.split('/').any(str::is_empty);
405 let components_are_normal = Path::new(rel)
406 .components()
407 .all(|component| matches!(component, Component::Normal(_)));
408 if rel.starts_with('/') || has_drive_prefix || has_empty_component || !components_are_normal {
409 return Err(format!(
410 "churn file {} has an invalid repo-relative event path \"{event_path}\"",
411 path.display()
412 ));
413 }
414 Ok(rel.to_string())
415}
416
417fn validate_churn_event_timestamp(
418 path: &Path,
419 timestamp: u64,
420 future_limit: u64,
421 rel: &str,
422) -> Result<(), String> {
423 if timestamp <= future_limit {
424 return Ok(());
425 }
426
427 Err(format!(
428 "churn file {} has event timestamp {} for \"{rel}\" more than a year in the \
429 future; timestamps must be unix SECONDS (not milliseconds), UTC",
430 path.display(),
431 timestamp
432 ))
433}
434
435#[must_use]
437fn is_shallow_clone(root: &Path) -> bool {
438 let mut command = git_command();
439 command
440 .args(["rev-parse", "--is-shallow-repository"])
441 .current_dir(root);
442 command.output().is_ok_and(|o| {
443 String::from_utf8_lossy(&o.stdout)
444 .trim()
445 .eq_ignore_ascii_case("true")
446 })
447}
448
449#[must_use]
451pub fn is_git_repo(root: &Path) -> bool {
452 let mut command = git_command();
453 command
454 .args(["rev-parse", "--git-dir"])
455 .current_dir(root)
456 .stdout(std::process::Stdio::null())
457 .stderr(std::process::Stdio::null());
458 command.status().is_ok_and(|s| s.success())
459}
460
461const MAX_CHURN_CACHE_SIZE: usize = 64 * 1024 * 1024;
464
465const CHURN_CACHE_VERSION: u8 = 5;
469
470#[derive(Clone, bitcode::Encode, bitcode::Decode)]
472struct CachedCommitEvent {
473 timestamp: u64,
474 lines_added: u32,
475 lines_deleted: u32,
476 author_idx: Option<u32>,
477}
478
479#[derive(Clone, bitcode::Encode, bitcode::Decode)]
481struct CachedFileChurn {
482 path: Vec<u8>,
483 events: Vec<CachedCommitEvent>,
484}
485
486#[derive(Clone, bitcode::Encode, bitcode::Decode)]
488struct ChurnCache {
489 version: u8,
491 last_indexed_sha: String,
492 git_after: String,
493 files: Vec<CachedFileChurn>,
494 shallow_clone: bool,
495 author_pool: Vec<String>,
497}
498
499struct FileEvents {
501 events: Vec<CachedCommitEvent>,
502}
503
504struct ChurnEventState {
507 files: FxHashMap<PathBuf, FileEvents>,
508 author_pool: Vec<String>,
509}
510
511fn get_head_sha(root: &Path) -> Option<String> {
513 let mut command = git_command();
514 command.args(["rev-parse", "HEAD"]).current_dir(root);
515 command
516 .output()
517 .ok()
518 .filter(|o| o.status.success())
519 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
520}
521
522fn is_ancestor(root: &Path, ancestor: &str, descendant: &str) -> bool {
524 let mut command = git_command();
525 command
526 .args(["merge-base", "--is-ancestor", ancestor, descendant])
527 .current_dir(root);
528 command.status().is_ok_and(|s| s.success())
529}
530
531fn load_churn_cache(cache_dir: &Path, git_after: &str) -> Option<ChurnCache> {
534 let cache_file = cache_dir.join("churn.bin");
535 let data = std::fs::read(&cache_file).ok()?;
536 if data.len() > MAX_CHURN_CACHE_SIZE {
537 return None;
538 }
539 let cache: ChurnCache = bitcode::decode(&data).ok()?;
540 if cache.version != CHURN_CACHE_VERSION || cache.git_after != git_after {
541 return None;
542 }
543 Some(cache)
544}
545
546fn save_churn_cache(
548 cache_dir: &Path,
549 last_indexed_sha: &str,
550 git_after: &str,
551 state: &ChurnEventState,
552 shallow_clone: bool,
553) {
554 let files: Vec<CachedFileChurn> = state
555 .files
556 .iter()
557 .map(|f| CachedFileChurn {
558 path: path_to_cache_bytes(f.0),
559 events: f.1.events.clone(),
560 })
561 .collect();
562 let cache = ChurnCache {
563 version: CHURN_CACHE_VERSION,
564 last_indexed_sha: last_indexed_sha.to_string(),
565 git_after: git_after.to_string(),
566 files,
567 shallow_clone,
568 author_pool: state.author_pool.clone(),
569 };
570 let _ = std::fs::create_dir_all(cache_dir);
571 let data = bitcode::encode(&cache);
572 let tmp = cache_dir.join("churn.bin.tmp");
573 if std::fs::write(&tmp, data).is_ok() {
574 let _ = std::fs::rename(&tmp, cache_dir.join("churn.bin"));
575 }
576}
577
578pub fn analyze_churn_cached(
586 root: &Path,
587 since: &SinceDuration,
588 cache_dir: &Path,
589 no_cache: bool,
590) -> Option<(ChurnResult, bool)> {
591 let head_sha = get_head_sha(root)?;
592
593 if !no_cache && let Some(result) = try_reuse_churn_cache(root, since, cache_dir, &head_sha) {
594 return Some((result, true));
595 }
596
597 analyze_fresh_churn(root, since, cache_dir, no_cache, &head_sha).map(|result| (result, false))
598}
599
600fn try_reuse_churn_cache(
601 root: &Path,
602 since: &SinceDuration,
603 cache_dir: &Path,
604 head_sha: &str,
605) -> Option<ChurnResult> {
606 let cache = load_churn_cache(cache_dir, &since.git_after)?;
607 if cache.last_indexed_sha == head_sha {
608 let shallow_clone = cache.shallow_clone;
609 return Some(build_churn_result(cache.into_event_state(), shallow_clone));
610 }
611
612 if !is_ancestor(root, &cache.last_indexed_sha, head_sha) {
613 return None;
614 }
615
616 extend_churn_cache(root, since, cache_dir, head_sha, cache)
617}
618
619fn extend_churn_cache(
620 root: &Path,
621 since: &SinceDuration,
622 cache_dir: &Path,
623 head_sha: &str,
624 cache: ChurnCache,
625) -> Option<ChurnResult> {
626 let shallow_clone = is_shallow_clone(root);
627 let range = format!("{}..HEAD", cache.last_indexed_sha);
628 let delta = analyze_churn_events(root, since, Some(&range))?;
629 let mut state = cache.into_event_state();
630 merge_churn_states(&mut state, delta);
631 save_churn_cache(cache_dir, head_sha, &since.git_after, &state, shallow_clone);
632 Some(build_churn_result(state, shallow_clone))
633}
634
635fn analyze_fresh_churn(
636 root: &Path,
637 since: &SinceDuration,
638 cache_dir: &Path,
639 no_cache: bool,
640 head_sha: &str,
641) -> Option<ChurnResult> {
642 let shallow_clone = is_shallow_clone(root);
643 let state = analyze_churn_events(root, since, None)?;
644 if !no_cache {
645 save_churn_cache(cache_dir, head_sha, &since.git_after, &state, shallow_clone);
646 }
647
648 Some(build_churn_result(state, shallow_clone))
649}
650
651impl ChurnCache {
652 fn into_event_state(self) -> ChurnEventState {
653 let files = self
654 .files
655 .into_iter()
656 .filter_map(|entry| {
657 path_from_cache_bytes(&entry.path).map(|path| {
658 (
659 path,
660 FileEvents {
661 events: entry.events,
662 },
663 )
664 })
665 })
666 .collect();
667 ChurnEventState {
668 files,
669 author_pool: self.author_pool,
670 }
671 }
672}
673
674#[cfg(unix)]
675fn path_to_cache_bytes(path: &Path) -> Vec<u8> {
676 use std::os::unix::ffi::OsStrExt;
677
678 path.as_os_str().as_bytes().to_vec()
679}
680
681#[cfg(unix)]
682#[allow(
683 clippy::unnecessary_wraps,
684 reason = "Windows rejects truncated UTF-16 bytes through this shared fallible contract"
685)]
686fn path_from_cache_bytes(path: &[u8]) -> Option<PathBuf> {
687 use std::ffi::OsStr;
688 use std::os::unix::ffi::OsStrExt;
689
690 Some(PathBuf::from(OsStr::from_bytes(path)))
691}
692
693#[cfg(windows)]
694fn path_to_cache_bytes(path: &Path) -> Vec<u8> {
695 use std::os::windows::ffi::OsStrExt;
696
697 path.as_os_str()
698 .encode_wide()
699 .flat_map(u16::to_le_bytes)
700 .collect()
701}
702
703#[cfg(windows)]
704fn path_from_cache_bytes(path: &[u8]) -> Option<PathBuf> {
705 use std::ffi::OsString;
706 use std::os::windows::ffi::OsStringExt;
707
708 let chunks = path.chunks_exact(2);
709 if !chunks.remainder().is_empty() {
710 return None;
711 }
712 let wide: Vec<u16> = chunks
713 .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
714 .collect();
715 Some(PathBuf::from(OsString::from_wide(&wide)))
716}
717
718fn analyze_churn_events(
720 root: &Path,
721 since: &SinceDuration,
722 revision_range: Option<&str>,
723) -> Option<ChurnEventState> {
724 let mut command = git_command();
725 command.arg("log");
726 if let Some(range) = revision_range {
727 command.arg(range);
728 }
729 command
730 .args([
731 "--numstat",
732 "--no-merges",
733 "--no-renames",
734 "--use-mailmap",
735 "-z",
736 "--format=format:%at|%ae%x00",
737 &format!("--after={}", since.git_after),
738 ])
739 .current_dir(root);
740
741 let output = match spawn_output(&mut command) {
742 Ok(o) => o,
743 Err(e) => {
744 tracing::warn!("hotspot analysis skipped: failed to run git: {e}");
745 return None;
746 }
747 };
748
749 if !output.status.success() {
750 let stderr = String::from_utf8_lossy(&output.stderr);
751 tracing::warn!("hotspot analysis skipped: git log failed: {stderr}");
752 return None;
753 }
754
755 Some(parse_git_log_events_z(&output.stdout, root))
756}
757
758fn merge_churn_states(base: &mut ChurnEventState, delta: ChurnEventState) {
760 let mut base_author_index: FxHashMap<String, u32> = base
761 .author_pool
762 .iter()
763 .enumerate()
764 .filter_map(|(idx, email)| u32::try_from(idx).ok().map(|idx| (email.clone(), idx)))
765 .collect();
766
767 let mut author_mapping: FxHashMap<u32, u32> = FxHashMap::default();
768 for (old_idx, email) in delta.author_pool.into_iter().enumerate() {
769 let Ok(old_idx) = u32::try_from(old_idx) else {
770 continue;
771 };
772 let new_idx = intern_author(&email, &mut base.author_pool, &mut base_author_index);
773 author_mapping.insert(old_idx, new_idx);
774 }
775
776 for (path, mut file) in delta.files {
777 for event in &mut file.events {
778 event.author_idx = event
779 .author_idx
780 .and_then(|idx| author_mapping.get(&idx).copied());
781 }
782 base.files
783 .entry(path)
784 .and_modify(|existing| existing.events.append(&mut file.events))
785 .or_insert(file);
786 }
787}
788
789#[cfg(test)]
791fn parse_git_log_events(stdout: &str, root: &Path) -> ChurnEventState {
792 let now_secs = std::time::SystemTime::now()
793 .duration_since(std::time::UNIX_EPOCH)
794 .unwrap_or_default()
795 .as_secs();
796
797 let mut parser = GitLogEventParser::new(root, now_secs);
798
799 for line in stdout.lines() {
800 parser.consume_line(line);
801 }
802
803 parser.finish()
804}
805
806fn parse_git_log_events_z(stdout: &[u8], root: &Path) -> ChurnEventState {
807 let now_secs = std::time::SystemTime::now()
808 .duration_since(std::time::UNIX_EPOCH)
809 .unwrap_or_default()
810 .as_secs();
811
812 let mut parser = GitLogEventParser::new(root, now_secs);
813 for record in stdout.split(|byte| *byte == 0) {
814 let record = record.strip_prefix(b"\n").unwrap_or(record);
815 if record.is_empty() {
816 continue;
817 }
818 if record.contains(&b'\t') {
819 parser.record_numstat_bytes(record);
820 } else {
821 parser.consume_line(&String::from_utf8_lossy(record));
822 }
823 }
824 parser.finish()
825}
826
827struct GitLogEventParser<'a> {
828 root: &'a Path,
829 now_secs: u64,
830 files: FxHashMap<PathBuf, FileEvents>,
831 author_pool: Vec<String>,
832 author_index: FxHashMap<String, u32>,
833 current_timestamp: Option<u64>,
834 current_author_idx: Option<u32>,
835}
836
837impl<'a> GitLogEventParser<'a> {
838 fn new(root: &'a Path, now_secs: u64) -> Self {
839 Self {
840 root,
841 now_secs,
842 files: FxHashMap::default(),
843 author_pool: Vec::new(),
844 author_index: FxHashMap::default(),
845 current_timestamp: None,
846 current_author_idx: None,
847 }
848 }
849
850 fn consume_line(&mut self, line: &str) {
851 let line = line.trim();
852 if line.is_empty() {
853 return;
854 }
855
856 if self.record_commit_header(line) {
857 return;
858 }
859 if self.record_legacy_timestamp(line) {
860 return;
861 }
862 self.record_numstat(line);
863 }
864
865 fn record_commit_header(&mut self, line: &str) -> bool {
866 let Some((ts_str, email)) = line.split_once('|') else {
867 return false;
868 };
869 let Ok(ts) = ts_str.parse::<u64>() else {
870 return false;
871 };
872
873 self.current_timestamp = Some(ts);
874 self.current_author_idx = Some(intern_author(
875 email,
876 &mut self.author_pool,
877 &mut self.author_index,
878 ));
879 true
880 }
881
882 fn record_legacy_timestamp(&mut self, line: &str) -> bool {
883 let Ok(ts) = line.parse::<u64>() else {
884 return false;
885 };
886
887 self.current_timestamp = Some(ts);
888 self.current_author_idx = None;
889 true
890 }
891
892 fn record_numstat(&mut self, line: &str) {
893 let Some((added, deleted, path)) = parse_numstat_line(line) else {
894 return;
895 };
896
897 self.record_numstat_path(added, deleted, PathBuf::from(path));
898 }
899
900 fn record_numstat_bytes(&mut self, record: &[u8]) {
901 let Some(first_tab) = record.iter().position(|byte| *byte == b'\t') else {
902 return;
903 };
904 let Some(second_tab_offset) = record[first_tab + 1..]
905 .iter()
906 .position(|byte| *byte == b'\t')
907 else {
908 return;
909 };
910 let second_tab = first_tab + 1 + second_tab_offset;
911 let Some(added) = std::str::from_utf8(&record[..first_tab])
912 .ok()
913 .and_then(|value| value.parse().ok())
914 else {
915 return;
916 };
917 let Some(deleted) = std::str::from_utf8(&record[first_tab + 1..second_tab])
918 .ok()
919 .and_then(|value| value.parse().ok())
920 else {
921 return;
922 };
923 let path = git_path_from_bytes(&record[second_tab + 1..]);
924 self.record_numstat_path(added, deleted, path);
925 }
926
927 fn record_numstat_path(&mut self, added: u32, deleted: u32, path: PathBuf) {
928 let ts = self.current_timestamp.unwrap_or(self.now_secs);
929 self.files
930 .entry(self.root.join(path))
931 .or_insert_with(|| FileEvents { events: Vec::new() })
932 .events
933 .push(CachedCommitEvent {
934 timestamp: ts,
935 lines_added: added,
936 lines_deleted: deleted,
937 author_idx: self.current_author_idx,
938 });
939 }
940
941 fn finish(self) -> ChurnEventState {
942 ChurnEventState {
943 files: self.files,
944 author_pool: self.author_pool,
945 }
946 }
947}
948
949#[cfg(unix)]
950fn git_path_from_bytes(path: &[u8]) -> PathBuf {
951 use std::ffi::OsString;
952 use std::os::unix::ffi::OsStringExt;
953
954 PathBuf::from(OsString::from_vec(path.to_vec()))
955}
956
957#[cfg(windows)]
958fn git_path_from_bytes(path: &[u8]) -> PathBuf {
959 PathBuf::from(String::from_utf8_lossy(path).replace('/', "\\"))
960}
961
962#[expect(
965 clippy::cast_possible_truncation,
966 reason = "commit count per file is bounded by git history depth"
967)]
968fn aggregate_file_churn(path: PathBuf, file: FileEvents, now_secs: u64) -> FileChurn {
969 let mut timestamps = Vec::with_capacity(file.events.len());
970 let mut weighted_commits = 0.0;
971 let mut lines_added = 0_u32;
972 let mut lines_deleted = 0_u32;
973 let mut authors: FxHashMap<u32, AuthorContribution> = FxHashMap::default();
974
975 for event in file.events {
976 timestamps.push(event.timestamp);
977 let age_days = (now_secs.saturating_sub(event.timestamp)) as f64 / SECS_PER_DAY;
978 let weight = 0.5_f64.powf(age_days / HALF_LIFE_DAYS);
979 weighted_commits += weight;
980 lines_added = lines_added.saturating_add(event.lines_added);
981 lines_deleted = lines_deleted.saturating_add(event.lines_deleted);
982 accumulate_author(&mut authors, event.author_idx, weight, event.timestamp);
983 }
984
985 let commits = timestamps.len() as u32;
986 let trend = compute_trend(×tamps);
987 for c in authors.values_mut() {
988 c.weighted_commits = (c.weighted_commits * 100.0).round() / 100.0;
989 }
990 FileChurn {
991 path,
992 commits,
993 weighted_commits: (weighted_commits * 100.0).round() / 100.0,
994 lines_added,
995 lines_deleted,
996 trend,
997 authors,
998 }
999}
1000
1001fn accumulate_author(
1003 authors: &mut FxHashMap<u32, AuthorContribution>,
1004 author_idx: Option<u32>,
1005 weight: f64,
1006 timestamp: u64,
1007) {
1008 let Some(idx) = author_idx else {
1009 return;
1010 };
1011 authors
1012 .entry(idx)
1013 .and_modify(|c| {
1014 c.commits += 1;
1015 c.weighted_commits += weight;
1016 c.first_commit_ts = c.first_commit_ts.min(timestamp);
1017 c.last_commit_ts = c.last_commit_ts.max(timestamp);
1018 })
1019 .or_insert(AuthorContribution {
1020 commits: 1,
1021 weighted_commits: weight,
1022 first_commit_ts: timestamp,
1023 last_commit_ts: timestamp,
1024 });
1025}
1026
1027fn build_churn_result(state: ChurnEventState, shallow_clone: bool) -> ChurnResult {
1029 let now_secs = std::time::SystemTime::now()
1030 .duration_since(std::time::UNIX_EPOCH)
1031 .unwrap_or_default()
1032 .as_secs();
1033
1034 let files = state
1035 .files
1036 .into_iter()
1037 .map(|(path, file)| {
1038 let churn = aggregate_file_churn(path.clone(), file, now_secs);
1039 (path, churn)
1040 })
1041 .collect();
1042
1043 ChurnResult {
1044 files,
1045 shallow_clone,
1046 author_pool: state.author_pool,
1047 }
1048}
1049
1050#[cfg(test)]
1055fn parse_git_log(stdout: &str, root: &Path) -> (FxHashMap<PathBuf, FileChurn>, Vec<String>) {
1056 let result = build_churn_result(parse_git_log_events(stdout, root), false);
1057 (result.files, result.author_pool)
1058}
1059
1060fn intern_author(email: &str, pool: &mut Vec<String>, index: &mut FxHashMap<String, u32>) -> u32 {
1062 if let Some(&idx) = index.get(email) {
1063 return idx;
1064 }
1065 #[expect(
1066 clippy::cast_possible_truncation,
1067 reason = "author count is bounded by git history; u32 is far above any realistic ceiling"
1068 )]
1069 let idx = pool.len() as u32;
1070 let owned = email.to_string();
1071 index.insert(owned.clone(), idx);
1072 pool.push(owned);
1073 idx
1074}
1075
1076fn parse_numstat_line(line: &str) -> Option<(u32, u32, &str)> {
1079 let mut parts = line.splitn(3, '\t');
1080 let added_str = parts.next()?;
1081 let deleted_str = parts.next()?;
1082 let path = parts.next()?;
1083
1084 let added: u32 = added_str.parse().ok()?;
1085 let deleted: u32 = deleted_str.parse().ok()?;
1086
1087 Some((added, deleted, path))
1088}
1089
1090fn compute_trend(timestamps: &[u64]) -> ChurnTrend {
1098 if timestamps.len() < 2 {
1099 return ChurnTrend::Stable;
1100 }
1101
1102 let min_ts = timestamps.iter().copied().min().unwrap_or(0);
1103 let max_ts = timestamps.iter().copied().max().unwrap_or(0);
1104
1105 if max_ts == min_ts {
1106 return ChurnTrend::Stable;
1107 }
1108
1109 let midpoint = min_ts + (max_ts - min_ts) / 2;
1110 let recent = timestamps.iter().filter(|&&ts| ts > midpoint).count() as f64;
1111 let older = timestamps.iter().filter(|&&ts| ts <= midpoint).count() as f64;
1112
1113 if older < 1.0 {
1114 return ChurnTrend::Stable;
1115 }
1116
1117 let ratio = recent / older;
1118 if ratio > 1.5 {
1119 ChurnTrend::Accelerating
1120 } else if ratio < 0.67 {
1121 ChurnTrend::Cooling
1122 } else {
1123 ChurnTrend::Stable
1124 }
1125}
1126
1127fn is_iso_date(input: &str) -> bool {
1128 input.len() == 10
1129 && input.as_bytes().get(4) == Some(&b'-')
1130 && input.as_bytes().get(7) == Some(&b'-')
1131 && input[..4].bytes().all(|b| b.is_ascii_digit())
1132 && input[5..7].bytes().all(|b| b.is_ascii_digit())
1133 && input[8..10].bytes().all(|b| b.is_ascii_digit())
1134}
1135
1136fn split_number_unit(input: &str) -> Result<(&str, &str), String> {
1137 let pos = input.find(|c: char| !c.is_ascii_digit()).ok_or_else(|| {
1138 format!("--since requires a unit suffix (e.g., 6m, 90d, 1y), got: {input}")
1139 })?;
1140 if pos == 0 {
1141 return Err(format!(
1142 "--since must start with a number (e.g., 6m, 90d, 1y), got: {input}"
1143 ));
1144 }
1145 Ok((&input[..pos], &input[pos..]))
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150 use super::*;
1151
1152 #[test]
1153 fn parse_since_months_short() {
1154 let d = parse_since("6m").unwrap();
1155 assert_eq!(d.git_after, "6 months ago");
1156 assert_eq!(d.display, "6 months");
1157 }
1158
1159 #[test]
1160 fn parse_since_months_long() {
1161 let d = parse_since("6months").unwrap();
1162 assert_eq!(d.git_after, "6 months ago");
1163 assert_eq!(d.display, "6 months");
1164 }
1165
1166 #[test]
1167 fn parse_since_days() {
1168 let d = parse_since("90d").unwrap();
1169 assert_eq!(d.git_after, "90 days ago");
1170 assert_eq!(d.display, "90 days");
1171 }
1172
1173 #[test]
1174 fn parse_since_year_singular() {
1175 let d = parse_since("1y").unwrap();
1176 assert_eq!(d.git_after, "1 year ago");
1177 assert_eq!(d.display, "1 year");
1178 }
1179
1180 #[test]
1181 fn parse_since_years_plural() {
1182 let d = parse_since("2years").unwrap();
1183 assert_eq!(d.git_after, "2 years ago");
1184 assert_eq!(d.display, "2 years");
1185 }
1186
1187 #[test]
1188 fn parse_since_weeks() {
1189 let d = parse_since("2w").unwrap();
1190 assert_eq!(d.git_after, "2 weeks ago");
1191 assert_eq!(d.display, "2 weeks");
1192 }
1193
1194 #[test]
1195 fn parse_since_iso_date() {
1196 let d = parse_since("2025-06-01").unwrap();
1197 assert_eq!(d.git_after, "2025-06-01");
1198 assert_eq!(d.display, "2025-06-01");
1199 }
1200
1201 #[test]
1202 fn parse_since_month_singular() {
1203 let d = parse_since("1month").unwrap();
1204 assert_eq!(d.display, "1 month");
1205 }
1206
1207 #[test]
1208 fn parse_since_day_singular() {
1209 let d = parse_since("1day").unwrap();
1210 assert_eq!(d.display, "1 day");
1211 }
1212
1213 #[test]
1214 fn parse_since_zero_rejected() {
1215 assert!(parse_since("0m").is_err());
1216 }
1217
1218 #[test]
1219 fn parse_since_no_unit_rejected() {
1220 assert!(parse_since("90").is_err());
1221 }
1222
1223 #[test]
1224 fn parse_since_unknown_unit_rejected() {
1225 assert!(parse_since("6x").is_err());
1226 }
1227
1228 #[test]
1229 fn parse_since_no_number_rejected() {
1230 assert!(parse_since("months").is_err());
1231 }
1232
1233 #[test]
1234 fn numstat_normal() {
1235 let (a, d, p) = parse_numstat_line("10\t5\tsrc/file.ts").unwrap();
1236 assert_eq!(a, 10);
1237 assert_eq!(d, 5);
1238 assert_eq!(p, "src/file.ts");
1239 }
1240
1241 #[test]
1242 fn numstat_binary_skipped() {
1243 assert!(parse_numstat_line("-\t-\tsrc/image.png").is_none());
1244 }
1245
1246 #[test]
1247 fn numstat_zero_lines() {
1248 let (a, d, p) = parse_numstat_line("0\t0\tsrc/empty.ts").unwrap();
1249 assert_eq!(a, 0);
1250 assert_eq!(d, 0);
1251 assert_eq!(p, "src/empty.ts");
1252 }
1253
1254 #[test]
1255 fn trend_empty_is_stable() {
1256 assert_eq!(compute_trend(&[]), ChurnTrend::Stable);
1257 }
1258
1259 #[test]
1260 fn trend_single_commit_is_stable() {
1261 assert_eq!(compute_trend(&[100]), ChurnTrend::Stable);
1262 }
1263
1264 #[test]
1265 fn trend_accelerating() {
1266 let timestamps = vec![100, 200, 800, 850, 900, 950, 1000];
1267 assert_eq!(compute_trend(×tamps), ChurnTrend::Accelerating);
1268 }
1269
1270 #[test]
1271 fn trend_cooling() {
1272 let timestamps = vec![100, 150, 200, 250, 300, 900, 1000];
1273 assert_eq!(compute_trend(×tamps), ChurnTrend::Cooling);
1274 }
1275
1276 #[test]
1277 fn trend_stable_even_distribution() {
1278 let timestamps = vec![100, 200, 300, 700, 800, 900];
1279 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1280 }
1281
1282 #[test]
1283 fn trend_same_timestamp_is_stable() {
1284 let timestamps = vec![500, 500, 500];
1285 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1286 }
1287
1288 #[test]
1289 fn iso_date_valid() {
1290 assert!(is_iso_date("2025-06-01"));
1291 assert!(is_iso_date("2025-12-31"));
1292 }
1293
1294 #[test]
1295 fn iso_date_with_time_rejected() {
1296 assert!(!is_iso_date("2025-06-01T00:00:00"));
1297 }
1298
1299 #[test]
1300 fn iso_date_invalid() {
1301 assert!(!is_iso_date("6months"));
1302 assert!(!is_iso_date("2025"));
1303 assert!(!is_iso_date("not-a-date"));
1304 assert!(!is_iso_date("abcd-ef-gh"));
1305 }
1306
1307 #[test]
1308 fn trend_display() {
1309 assert_eq!(ChurnTrend::Accelerating.to_string(), "accelerating");
1310 assert_eq!(ChurnTrend::Stable.to_string(), "stable");
1311 assert_eq!(ChurnTrend::Cooling.to_string(), "cooling");
1312 }
1313
1314 #[test]
1315 fn parse_git_log_single_commit() {
1316 let root = Path::new("/project");
1317 let output = "1700000000\n10\t5\tsrc/index.ts\n";
1318 let (result, _) = parse_git_log(output, root);
1319 assert_eq!(result.len(), 1);
1320 let churn = &result[&PathBuf::from("/project/src/index.ts")];
1321 assert_eq!(churn.commits, 1);
1322 assert_eq!(churn.lines_added, 10);
1323 assert_eq!(churn.lines_deleted, 5);
1324 }
1325
1326 #[test]
1327 fn parse_git_log_multiple_commits_same_file() {
1328 let root = Path::new("/project");
1329 let output = "1700000000\n10\t5\tsrc/index.ts\n\n1700100000\n3\t2\tsrc/index.ts\n";
1330 let (result, _) = parse_git_log(output, root);
1331 assert_eq!(result.len(), 1);
1332 let churn = &result[&PathBuf::from("/project/src/index.ts")];
1333 assert_eq!(churn.commits, 2);
1334 assert_eq!(churn.lines_added, 13);
1335 assert_eq!(churn.lines_deleted, 7);
1336 }
1337
1338 #[test]
1339 fn parse_git_log_multiple_files() {
1340 let root = Path::new("/project");
1341 let output = "1700000000\n10\t5\tsrc/a.ts\n3\t1\tsrc/b.ts\n";
1342 let (result, _) = parse_git_log(output, root);
1343 assert_eq!(result.len(), 2);
1344 assert!(result.contains_key(&PathBuf::from("/project/src/a.ts")));
1345 assert!(result.contains_key(&PathBuf::from("/project/src/b.ts")));
1346 }
1347
1348 #[test]
1349 fn parse_git_log_empty_output() {
1350 let root = Path::new("/project");
1351 let (result, _) = parse_git_log("", root);
1352 assert!(result.is_empty());
1353 }
1354
1355 #[test]
1356 fn parse_git_log_skips_binary_files() {
1357 let root = Path::new("/project");
1358 let output = "1700000000\n-\t-\timage.png\n10\t5\tsrc/a.ts\n";
1359 let (result, _) = parse_git_log(output, root);
1360 assert_eq!(result.len(), 1);
1361 assert!(!result.contains_key(&PathBuf::from("/project/image.png")));
1362 }
1363
1364 #[test]
1365 fn parse_git_log_weighted_commits_are_positive() {
1366 let root = Path::new("/project");
1367 let now_secs = std::time::SystemTime::now()
1368 .duration_since(std::time::UNIX_EPOCH)
1369 .unwrap()
1370 .as_secs();
1371 let output = format!("{now_secs}\n10\t5\tsrc/a.ts\n");
1372 let (result, _) = parse_git_log(&output, root);
1373 let churn = &result[&PathBuf::from("/project/src/a.ts")];
1374 assert!(
1375 churn.weighted_commits > 0.0,
1376 "weighted_commits should be positive for recent commits"
1377 );
1378 }
1379
1380 #[test]
1381 fn trend_boundary_1_5x_ratio() {
1382 let timestamps = vec![100, 200, 600, 800, 1000];
1383 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1384 }
1385
1386 #[test]
1387 fn trend_just_above_1_5x() {
1388 let timestamps = vec![100, 600, 800, 1000];
1389 assert_eq!(compute_trend(×tamps), ChurnTrend::Accelerating);
1390 }
1391
1392 #[test]
1393 fn trend_boundary_0_67x_ratio() {
1394 let timestamps = vec![100, 200, 300, 600, 1000];
1395 assert_eq!(compute_trend(×tamps), ChurnTrend::Cooling);
1396 }
1397
1398 #[test]
1399 fn trend_two_timestamps_different() {
1400 let timestamps = vec![100, 200];
1401 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1402 }
1403
1404 #[test]
1405 fn parse_since_week_singular() {
1406 let d = parse_since("1week").unwrap();
1407 assert_eq!(d.git_after, "1 week ago");
1408 assert_eq!(d.display, "1 week");
1409 }
1410
1411 #[test]
1412 fn parse_since_weeks_long() {
1413 let d = parse_since("3weeks").unwrap();
1414 assert_eq!(d.git_after, "3 weeks ago");
1415 assert_eq!(d.display, "3 weeks");
1416 }
1417
1418 #[test]
1419 fn parse_since_days_long() {
1420 let d = parse_since("30days").unwrap();
1421 assert_eq!(d.git_after, "30 days ago");
1422 assert_eq!(d.display, "30 days");
1423 }
1424
1425 #[test]
1426 fn parse_since_year_long() {
1427 let d = parse_since("1year").unwrap();
1428 assert_eq!(d.git_after, "1 year ago");
1429 assert_eq!(d.display, "1 year");
1430 }
1431
1432 #[test]
1433 fn parse_since_overflow_number_rejected() {
1434 let result = parse_since("99999999999999999999d");
1435 assert!(result.is_err());
1436 let err = result.unwrap_err();
1437 assert!(err.contains("invalid number"));
1438 }
1439
1440 #[test]
1441 fn parse_since_zero_days_rejected() {
1442 assert!(parse_since("0d").is_err());
1443 }
1444
1445 #[test]
1446 fn parse_since_zero_weeks_rejected() {
1447 assert!(parse_since("0w").is_err());
1448 }
1449
1450 #[test]
1451 fn parse_since_zero_years_rejected() {
1452 assert!(parse_since("0y").is_err());
1453 }
1454
1455 #[test]
1456 fn numstat_missing_path() {
1457 assert!(parse_numstat_line("10\t5").is_none());
1458 }
1459
1460 #[test]
1461 fn numstat_single_field() {
1462 assert!(parse_numstat_line("10").is_none());
1463 }
1464
1465 #[test]
1466 fn numstat_empty_string() {
1467 assert!(parse_numstat_line("").is_none());
1468 }
1469
1470 #[test]
1471 fn numstat_only_added_is_binary() {
1472 assert!(parse_numstat_line("-\t5\tsrc/file.ts").is_none());
1473 }
1474
1475 #[test]
1476 fn numstat_only_deleted_is_binary() {
1477 assert!(parse_numstat_line("10\t-\tsrc/file.ts").is_none());
1478 }
1479
1480 #[test]
1481 fn numstat_path_with_spaces() {
1482 let (a, d, p) = parse_numstat_line("3\t1\tpath with spaces/file.ts").unwrap();
1483 assert_eq!(a, 3);
1484 assert_eq!(d, 1);
1485 assert_eq!(p, "path with spaces/file.ts");
1486 }
1487
1488 #[test]
1489 fn numstat_large_numbers() {
1490 let (a, d, p) = parse_numstat_line("9999\t8888\tsrc/big.ts").unwrap();
1491 assert_eq!(a, 9999);
1492 assert_eq!(d, 8888);
1493 assert_eq!(p, "src/big.ts");
1494 }
1495
1496 #[test]
1497 fn iso_date_wrong_separator_positions() {
1498 assert!(!is_iso_date("20-25-0601"));
1499 assert!(!is_iso_date("202506-01-"));
1500 }
1501
1502 #[test]
1503 fn iso_date_too_short() {
1504 assert!(!is_iso_date("2025-06-0"));
1505 }
1506
1507 #[test]
1508 fn iso_date_letters_in_day() {
1509 assert!(!is_iso_date("2025-06-ab"));
1510 }
1511
1512 #[test]
1513 fn iso_date_letters_in_month() {
1514 assert!(!is_iso_date("2025-ab-01"));
1515 }
1516
1517 #[test]
1518 fn split_number_unit_valid() {
1519 let (num, unit) = split_number_unit("42days").unwrap();
1520 assert_eq!(num, "42");
1521 assert_eq!(unit, "days");
1522 }
1523
1524 #[test]
1525 fn split_number_unit_single_digit() {
1526 let (num, unit) = split_number_unit("1m").unwrap();
1527 assert_eq!(num, "1");
1528 assert_eq!(unit, "m");
1529 }
1530
1531 #[test]
1532 fn split_number_unit_no_digits() {
1533 let err = split_number_unit("abc").unwrap_err();
1534 assert!(err.contains("must start with a number"));
1535 }
1536
1537 #[test]
1538 fn split_number_unit_no_unit() {
1539 let err = split_number_unit("123").unwrap_err();
1540 assert!(err.contains("requires a unit suffix"));
1541 }
1542
1543 #[test]
1544 fn parse_git_log_numstat_before_timestamp_uses_now() {
1545 let root = Path::new("/project");
1546 let output = "10\t5\tsrc/no_ts.ts\n";
1547 let (result, _) = parse_git_log(output, root);
1548 assert_eq!(result.len(), 1);
1549 let churn = &result[&PathBuf::from("/project/src/no_ts.ts")];
1550 assert_eq!(churn.commits, 1);
1551 assert_eq!(churn.lines_added, 10);
1552 assert_eq!(churn.lines_deleted, 5);
1553 assert!(
1554 churn.weighted_commits > 0.9,
1555 "weight should be near 1.0 when timestamp defaults to now"
1556 );
1557 }
1558
1559 #[test]
1560 fn parse_git_log_whitespace_lines_ignored() {
1561 let root = Path::new("/project");
1562 let output = " \n1700000000\n \n10\t5\tsrc/a.ts\n \n";
1563 let (result, _) = parse_git_log(output, root);
1564 assert_eq!(result.len(), 1);
1565 }
1566
1567 #[test]
1568 fn parse_git_log_trend_is_computed_per_file() {
1569 let root = Path::new("/project");
1570 let output = "\
15711000\n5\t1\tsrc/old.ts\n\
15722000\n3\t1\tsrc/old.ts\n\
15731000\n1\t0\tsrc/hot.ts\n\
15741800\n1\t0\tsrc/hot.ts\n\
15751900\n1\t0\tsrc/hot.ts\n\
15761950\n1\t0\tsrc/hot.ts\n\
15772000\n1\t0\tsrc/hot.ts\n";
1578 let (result, _) = parse_git_log(output, root);
1579 let old = &result[&PathBuf::from("/project/src/old.ts")];
1580 let hot = &result[&PathBuf::from("/project/src/hot.ts")];
1581 assert_eq!(old.commits, 2);
1582 assert_eq!(hot.commits, 5);
1583 assert_eq!(hot.trend, ChurnTrend::Accelerating);
1584 }
1585
1586 #[test]
1587 fn parse_git_log_weighted_decay_for_old_commits() {
1588 let root = Path::new("/project");
1589 let now = std::time::SystemTime::now()
1590 .duration_since(std::time::UNIX_EPOCH)
1591 .unwrap()
1592 .as_secs();
1593 let old_ts = now - (180 * 86_400);
1594 let output = format!("{old_ts}\n10\t5\tsrc/old.ts\n");
1595 let (result, _) = parse_git_log(&output, root);
1596 let churn = &result[&PathBuf::from("/project/src/old.ts")];
1597 assert!(
1598 churn.weighted_commits < 0.5,
1599 "180-day-old commit should weigh ~0.25, got {}",
1600 churn.weighted_commits
1601 );
1602 assert!(
1603 churn.weighted_commits > 0.1,
1604 "180-day-old commit should weigh ~0.25, got {}",
1605 churn.weighted_commits
1606 );
1607 }
1608
1609 #[test]
1610 fn parse_git_log_path_stored_as_absolute() {
1611 let root = Path::new("/my/project");
1612 let output = "1700000000\n1\t0\tlib/utils.ts\n";
1613 let (result, _) = parse_git_log(output, root);
1614 let key = PathBuf::from("/my/project/lib/utils.ts");
1615 assert!(result.contains_key(&key));
1616 assert_eq!(result[&key].path, key);
1617 }
1618
1619 #[test]
1620 fn parse_git_log_weighted_commits_rounded() {
1621 let root = Path::new("/project");
1622 let now = std::time::SystemTime::now()
1623 .duration_since(std::time::UNIX_EPOCH)
1624 .unwrap()
1625 .as_secs();
1626 let output = format!("{now}\n1\t0\tsrc/a.ts\n");
1627 let (result, _) = parse_git_log(&output, root);
1628 let churn = &result[&PathBuf::from("/project/src/a.ts")];
1629 let decimals = format!("{:.2}", churn.weighted_commits);
1630 assert_eq!(
1631 churn.weighted_commits.to_string().len(),
1632 decimals.len().min(churn.weighted_commits.to_string().len()),
1633 "weighted_commits should be rounded to at most 2 decimal places"
1634 );
1635 }
1636
1637 #[test]
1638 fn trend_serde_serialization() {
1639 assert_eq!(
1640 serde_json::to_string(&ChurnTrend::Accelerating).unwrap(),
1641 "\"accelerating\""
1642 );
1643 assert_eq!(
1644 serde_json::to_string(&ChurnTrend::Stable).unwrap(),
1645 "\"stable\""
1646 );
1647 assert_eq!(
1648 serde_json::to_string(&ChurnTrend::Cooling).unwrap(),
1649 "\"cooling\""
1650 );
1651 }
1652
1653 #[test]
1654 fn parse_git_log_extracts_author_email() {
1655 let root = Path::new("/project");
1656 let output = "1700000000|alice@example.com\n10\t5\tsrc/index.ts\n";
1657 let (result, pool) = parse_git_log(output, root);
1658 assert_eq!(pool, vec!["alice@example.com".to_string()]);
1659 let churn = &result[&PathBuf::from("/project/src/index.ts")];
1660 assert_eq!(churn.authors.len(), 1);
1661 let alice = &churn.authors[&0];
1662 assert_eq!(alice.commits, 1);
1663 assert_eq!(alice.first_commit_ts, 1_700_000_000);
1664 assert_eq!(alice.last_commit_ts, 1_700_000_000);
1665 }
1666
1667 #[test]
1668 fn parse_git_log_intern_dedupes_authors() {
1669 let root = Path::new("/project");
1670 let output = "\
16711700000000|alice@example.com
16721\t0\ta.ts
16731700100000|bob@example.com
16742\t1\tb.ts
16751700200000|alice@example.com
16763\t2\tc.ts
1677";
1678 let (_result, pool) = parse_git_log(output, root);
1679 assert_eq!(pool.len(), 2);
1680 assert!(pool.contains(&"alice@example.com".to_string()));
1681 assert!(pool.contains(&"bob@example.com".to_string()));
1682 }
1683
1684 #[test]
1685 fn parse_git_log_aggregates_per_author() {
1686 let root = Path::new("/project");
1687 let output = "\
16881700000000|alice@example.com
16891\t0\tsrc/index.ts
16901700100000|bob@example.com
16912\t0\tsrc/index.ts
16921700200000|alice@example.com
16931\t1\tsrc/index.ts
1694";
1695 let (result, pool) = parse_git_log(output, root);
1696 let churn = &result[&PathBuf::from("/project/src/index.ts")];
1697 assert_eq!(churn.commits, 3);
1698 assert_eq!(churn.authors.len(), 2);
1699
1700 let alice_idx =
1701 u32::try_from(pool.iter().position(|a| a == "alice@example.com").unwrap()).unwrap();
1702 let alice = &churn.authors[&alice_idx];
1703 assert_eq!(alice.commits, 2);
1704 assert_eq!(alice.first_commit_ts, 1_700_000_000);
1705 assert_eq!(alice.last_commit_ts, 1_700_200_000);
1706 }
1707
1708 #[test]
1709 fn parse_git_log_legacy_bare_timestamp_still_parses() {
1710 let root = Path::new("/project");
1711 let output = "1700000000\n10\t5\tsrc/index.ts\n";
1712 let (result, pool) = parse_git_log(output, root);
1713 assert!(pool.is_empty());
1714 let churn = &result[&PathBuf::from("/project/src/index.ts")];
1715 assert_eq!(churn.commits, 1);
1716 assert!(churn.authors.is_empty());
1717 }
1718
1719 #[test]
1720 fn intern_author_returns_existing_index() {
1721 let mut pool = Vec::new();
1722 let mut index = FxHashMap::default();
1723 let i1 = intern_author("alice@x", &mut pool, &mut index);
1724 let i2 = intern_author("alice@x", &mut pool, &mut index);
1725 assert_eq!(i1, i2);
1726 assert_eq!(pool.len(), 1);
1727 }
1728
1729 #[test]
1730 fn intern_author_assigns_sequential_indices() {
1731 let mut pool = Vec::new();
1732 let mut index = FxHashMap::default();
1733 assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1734 assert_eq!(intern_author("bob@x", &mut pool, &mut index), 1);
1735 assert_eq!(intern_author("carol@x", &mut pool, &mut index), 2);
1736 assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1737 }
1738
1739 fn git(root: &Path, args: &[&str]) {
1740 let status = std::process::Command::new("git")
1741 .args(args)
1742 .current_dir(root)
1743 .status()
1744 .expect("run git");
1745 assert!(status.success(), "git {args:?} failed");
1746 }
1747
1748 fn write(root: &Path, path: &str, contents: &str) {
1749 let path = root.join(path);
1750 std::fs::create_dir_all(path.parent().expect("test path has parent")).unwrap();
1751 std::fs::write(path, contents).unwrap();
1752 }
1753
1754 #[cfg(unix)]
1755 #[test]
1756 fn churn_preserves_special_filenames() {
1757 let repo = tempfile::tempdir().expect("create repo");
1758 let root = repo.path();
1759 git(root, &["init", "--quiet"]);
1760 git(root, &["config", "user.email", "churn@example.test"]);
1761 git(root, &["config", "user.name", "Churn Test"]);
1762 git(root, &["config", "commit.gpgsign", "false"]);
1763
1764 let special_files = [
1765 "src/line\nbreak.ts",
1766 "src/space name.ts",
1767 "src/quote\"name.ts",
1768 "src/back\\slash.ts",
1769 "src/unicode-λ.ts",
1770 ]
1771 .map(|path| root.join(path));
1772 std::fs::create_dir_all(root.join("src")).expect("source dir");
1773 for special in &special_files {
1774 std::fs::write(special, "export const value = 1;\n").expect("special fixture");
1775 }
1776 git(root, &["add", "."]);
1777 git(root, &["commit", "--quiet", "-m", "initial"]);
1778
1779 let since = parse_since("1y").expect("valid duration");
1780 let churn = analyze_churn(root, &since).expect("churn result");
1781 for special in special_files {
1782 assert!(
1783 churn.files.contains_key(&special),
1784 "missing {special:?}: {:?}",
1785 churn.files.keys()
1786 );
1787 }
1788 }
1789
1790 #[cfg(unix)]
1791 #[test]
1792 fn churn_cache_preserves_non_utf8_filenames() {
1793 use std::ffi::OsString;
1794 use std::os::unix::ffi::OsStringExt;
1795
1796 let invalid_path = PathBuf::from(OsString::from_vec(b"src/non-utf8-\xff.ts".to_vec()));
1797 let mut files = FxHashMap::default();
1798 files.insert(
1799 invalid_path.clone(),
1800 FileEvents {
1801 events: vec![CachedCommitEvent {
1802 timestamp: 1,
1803 lines_added: 2,
1804 lines_deleted: 1,
1805 author_idx: None,
1806 }],
1807 },
1808 );
1809 let state = ChurnEventState {
1810 files,
1811 author_pool: Vec::new(),
1812 };
1813 let cache_dir = tempfile::tempdir().expect("cache directory");
1814 save_churn_cache(cache_dir.path(), "abc123", "1 year ago", &state, false);
1815 let warm = load_churn_cache(cache_dir.path(), "1 year ago")
1816 .expect("warm churn cache")
1817 .into_event_state();
1818
1819 assert!(warm.files.contains_key(&invalid_path));
1820 }
1821
1822 #[cfg(windows)]
1823 #[test]
1824 fn git_path_bytes_use_windows_separators() {
1825 assert_eq!(
1826 git_path_from_bytes(b"src/nested/file.ts"),
1827 PathBuf::from(r"src\nested\file.ts")
1828 );
1829 }
1830
1831 #[test]
1832 fn churn_cache_rejects_pre_lossless_path_encoding_version() {
1833 let cache_dir = tempfile::tempdir().expect("cache directory");
1834 let cache = ChurnCache {
1835 version: 4,
1836 last_indexed_sha: "abc123".to_string(),
1837 git_after: "1 year ago".to_string(),
1838 files: vec![CachedFileChurn {
1839 path: br#"/project/\"src/line\\nbreak.ts\""#.to_vec(),
1840 events: Vec::new(),
1841 }],
1842 shallow_clone: false,
1843 author_pool: Vec::new(),
1844 };
1845 std::fs::write(cache_dir.path().join("churn.bin"), bitcode::encode(&cache))
1846 .expect("cache fixture");
1847
1848 assert!(load_churn_cache(cache_dir.path(), "1 year ago").is_none());
1849 }
1850
1851 #[test]
1852 fn cached_churn_merges_new_commits_after_head_advances() {
1853 let repo = tempfile::tempdir().expect("create repo");
1854 let root = repo.path();
1855 git(root, &["init"]);
1856 git(root, &["config", "user.email", "churn@example.test"]);
1857 git(root, &["config", "user.name", "Churn Test"]);
1858 git(root, &["config", "commit.gpgsign", "false"]);
1859
1860 write(root, "src/a.ts", "export const a = 1;\n");
1861 git(root, &["add", "."]);
1862 git(root, &["commit", "-m", "initial"]);
1863
1864 let since = parse_since("1y").unwrap();
1865 let cache = tempfile::tempdir().expect("create cache dir");
1866 let (cold, cold_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1867 assert!(!cold_hit);
1868 let file = root.join("src/a.ts");
1869 assert_eq!(cold.files[&file].commits, 1);
1870
1871 let (_warm, warm_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1872 assert!(warm_hit);
1873
1874 write(
1875 root,
1876 "src/a.ts",
1877 "export const a = 1;\nexport const b = 2;\n",
1878 );
1879 git(root, &["add", "."]);
1880 git(root, &["commit", "-m", "update a"]);
1881 let head = get_head_sha(root).unwrap();
1882
1883 let (incremental, incremental_hit) =
1884 analyze_churn_cached(root, &since, cache.path(), false).unwrap();
1885 assert!(incremental_hit);
1886 assert_eq!(incremental.files[&file].commits, 2);
1887
1888 let cache = load_churn_cache(cache.path(), &since.git_after).unwrap();
1889 assert_eq!(cache.last_indexed_sha, head);
1890 }
1891
1892 fn write_churn_file(dir: &std::path::Path, contents: &str) -> PathBuf {
1893 let path = dir.join("churn.json");
1894 std::fs::write(&path, contents).unwrap();
1895 path
1896 }
1897
1898 #[test]
1899 fn churn_file_happy_path() {
1900 let dir = tempfile::tempdir().unwrap();
1901 let root = Path::new("/project");
1902 let path = write_churn_file(
1903 dir.path(),
1904 r#"{
1905 "schema": "fallow-churn/v1",
1906 "events": [
1907 { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 10, "deleted": 5 },
1908 { "path": "src/a.ts", "timestamp": 1700100000, "author": "bob@corp", "added": 3, "deleted": 2 }
1909 ]
1910 }"#,
1911 );
1912 let result = analyze_churn_from_file(&path, root).unwrap();
1913 let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
1914 assert_eq!(churn.commits, 2);
1915 assert_eq!(churn.lines_added, 13);
1916 assert_eq!(churn.lines_deleted, 7);
1917 assert_eq!(churn.authors.len(), 2);
1918 assert!(result.author_pool.contains(&"alice@corp".to_string()));
1919 assert!(result.author_pool.contains(&"bob@corp".to_string()));
1920 assert!(!result.shallow_clone);
1921 }
1922
1923 #[test]
1924 fn churn_file_matches_git_parse() {
1925 let dir = tempfile::tempdir().unwrap();
1929 let root = Path::new("/project");
1930 let git_output = "1700000000|alice@corp\n10\t5\tsrc/a.ts\n3\t1\tsrc/b.ts\n\n1700100000|bob@corp\n3\t2\tsrc/a.ts\n";
1931 let (git_files, git_pool) = parse_git_log(git_output, root);
1932
1933 let path = write_churn_file(
1934 dir.path(),
1935 r#"{
1936 "schema": "fallow-churn/v1",
1937 "events": [
1938 { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 10, "deleted": 5 },
1939 { "path": "src/b.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 3, "deleted": 1 },
1940 { "path": "src/a.ts", "timestamp": 1700100000, "author": "bob@corp", "added": 3, "deleted": 2 }
1941 ]
1942 }"#,
1943 );
1944 let imported = analyze_churn_from_file(&path, root).unwrap();
1945
1946 assert_eq!(git_pool, imported.author_pool, "author pools diverge");
1947 assert_eq!(git_files.len(), imported.files.len());
1948 for (file, git_churn) in &git_files {
1949 let imp = &imported.files[file];
1950 assert_eq!(git_churn.commits, imp.commits, "commits for {file:?}");
1951 assert_eq!(git_churn.lines_added, imp.lines_added, "added for {file:?}");
1952 assert_eq!(
1953 git_churn.lines_deleted, imp.lines_deleted,
1954 "deleted for {file:?}"
1955 );
1956 assert_eq!(git_churn.trend, imp.trend, "trend for {file:?}");
1957 assert_eq!(
1958 git_churn.authors.len(),
1959 imp.authors.len(),
1960 "authors for {file:?}"
1961 );
1962 assert!(
1963 (git_churn.weighted_commits - imp.weighted_commits).abs() < 0.02,
1964 "weighted_commits for {file:?}: {} vs {}",
1965 git_churn.weighted_commits,
1966 imp.weighted_commits
1967 );
1968 }
1969 }
1970
1971 #[test]
1972 fn churn_file_empty_events_is_valid() {
1973 let dir = tempfile::tempdir().unwrap();
1974 let path = write_churn_file(
1975 dir.path(),
1976 r#"{ "schema": "fallow-churn/v1", "events": [] }"#,
1977 );
1978 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1979 assert!(result.files.is_empty());
1980 assert!(result.author_pool.is_empty());
1981 }
1982
1983 #[test]
1984 fn churn_file_missing_events_key_is_valid() {
1985 let dir = tempfile::tempdir().unwrap();
1986 let path = write_churn_file(dir.path(), r#"{ "schema": "fallow-churn/v1" }"#);
1987 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
1988 assert!(result.files.is_empty());
1989 }
1990
1991 #[test]
1992 fn churn_file_bad_schema_rejected() {
1993 let dir = tempfile::tempdir().unwrap();
1994 let path = write_churn_file(
1995 dir.path(),
1996 r#"{ "schema": "fallow-churn/v2", "events": [] }"#,
1997 );
1998 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
1999 assert!(err.contains("expected \"fallow-churn/v1\""), "{err}");
2000 }
2001
2002 #[test]
2003 fn churn_file_malformed_json_rejected() {
2004 let dir = tempfile::tempdir().unwrap();
2005 let path = write_churn_file(dir.path(), "{ not json");
2006 assert!(analyze_churn_from_file(&path, Path::new("/project")).is_err());
2007 }
2008
2009 #[test]
2010 fn churn_file_missing_file_rejected() {
2011 let err = analyze_churn_from_file(Path::new("/no/such/churn.json"), Path::new("/project"))
2012 .unwrap_err();
2013 assert!(err.contains("failed to read churn file"), "{err}");
2014 }
2015
2016 #[test]
2017 fn churn_file_reader_accepts_exact_limit() {
2018 let dir = tempfile::tempdir().unwrap();
2019 let path = write_churn_file(dir.path(), "12345678");
2020 assert_eq!(read_churn_file_with_limit(&path, 8).unwrap(), "12345678");
2021 }
2022
2023 #[test]
2024 fn churn_file_reader_rejects_limit_plus_one() {
2025 let dir = tempfile::tempdir().unwrap();
2026 let path = write_churn_file(dir.path(), "123456789");
2027 let err = read_churn_file_with_limit(&path, 8).unwrap_err();
2028 assert!(err.contains("at least 9 bytes"), "{err}");
2029 assert!(err.contains("8 byte limit"), "{err}");
2030 }
2031
2032 #[test]
2033 fn churn_file_empty_path_rejected() {
2034 let dir = tempfile::tempdir().unwrap();
2035 let path = write_churn_file(
2036 dir.path(),
2037 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": " ", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
2038 );
2039 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
2040 assert!(err.contains("empty path"), "{err}");
2041 }
2042
2043 #[test]
2044 fn churn_file_rejects_non_relative_paths() {
2045 let invalid = [
2046 "/tmp/a.ts",
2047 r"C:\tmp\a.ts",
2048 "../a.ts",
2049 "src/../../a.ts",
2050 "./src/a.ts",
2051 "//server/share/a.ts",
2052 ];
2053 for event_path in invalid {
2054 let dir = tempfile::tempdir().unwrap();
2055 let body = format!(
2056 r#"{{ "schema": "fallow-churn/v1", "events": [ {{ "path": {event_path:?}, "timestamp": 1700000000, "added": 1, "deleted": 0 }} ] }}"#
2057 );
2058 let path = write_churn_file(dir.path(), &body);
2059 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
2060 assert!(err.contains(event_path), "{event_path}: {err}");
2061 assert!(err.contains("repo-relative"), "{event_path}: {err}");
2062 }
2063 }
2064
2065 #[test]
2066 fn churn_file_accepts_unicode_and_spaces_in_path_components() {
2067 let dir = tempfile::tempdir().unwrap();
2068 let path = write_churn_file(
2069 dir.path(),
2070 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/ruimte map/naïef.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
2071 );
2072 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2073 assert!(
2074 result
2075 .files
2076 .contains_key(&PathBuf::from("/project/src/ruimte map/naïef.ts"))
2077 );
2078 }
2079
2080 #[test]
2081 fn churn_file_millisecond_timestamp_rejected() {
2082 let dir = tempfile::tempdir().unwrap();
2083 let path = write_churn_file(
2085 dir.path(),
2086 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000000, "added": 1, "deleted": 0 } ] }"#,
2087 );
2088 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
2089 assert!(err.contains("milliseconds"), "{err}");
2090 }
2091
2092 #[test]
2093 fn churn_file_missing_author_contributes_no_signal() {
2094 let dir = tempfile::tempdir().unwrap();
2095 let path = write_churn_file(
2096 dir.path(),
2097 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
2098 );
2099 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2100 let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
2101 assert_eq!(churn.commits, 1);
2102 assert!(churn.authors.is_empty());
2103 assert!(result.author_pool.is_empty());
2104 }
2105
2106 #[test]
2107 fn churn_file_empty_author_string_treated_as_absent() {
2108 let dir = tempfile::tempdir().unwrap();
2109 let path = write_churn_file(
2110 dir.path(),
2111 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "author": " ", "added": 1, "deleted": 0 } ] }"#,
2112 );
2113 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2114 assert!(result.author_pool.is_empty());
2115 }
2116
2117 #[test]
2118 fn churn_file_unknown_fields_ignored() {
2119 let dir = tempfile::tempdir().unwrap();
2122 let path = write_churn_file(
2123 dir.path(),
2124 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" } ] }"#,
2125 );
2126 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2127 assert_eq!(result.files[&PathBuf::from("/project/src/a.ts")].commits, 1);
2128 }
2129
2130 #[test]
2131 fn churn_file_backslash_paths_normalized() {
2132 let dir = tempfile::tempdir().unwrap();
2133 let path = write_churn_file(
2134 dir.path(),
2135 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src\\a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
2136 );
2137 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2138 assert!(
2139 result
2140 .files
2141 .contains_key(&PathBuf::from("/project/src/a.ts"))
2142 );
2143 }
2144
2145 #[test]
2146 fn churn_file_rejects_line_totals_above_u32() {
2147 let dir = tempfile::tempdir().unwrap();
2148 let path = write_churn_file(
2149 dir.path(),
2150 r#"{ "schema": "fallow-churn/v1", "events": [
2151 { "path": "src/a.ts", "timestamp": 1700000000, "added": 4294967295, "deleted": 0 },
2152 { "path": "src/a.ts", "timestamp": 1700000001, "added": 1, "deleted": 0 }
2153 ] }"#,
2154 );
2155 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
2156 assert!(err.contains("exceeding the u32 limit"), "{err}");
2157 assert!(err.contains("src/a.ts"), "{err}");
2158 }
2159
2160 #[test]
2161 fn churn_file_accepts_line_totals_at_u32_max() {
2162 let dir = tempfile::tempdir().unwrap();
2163 let path = write_churn_file(
2164 dir.path(),
2165 r#"{ "schema": "fallow-churn/v1", "events": [
2166 { "path": "src/a.ts", "timestamp": 1700000000, "added": 4294967294, "deleted": 4294967295 },
2167 { "path": "src/a.ts", "timestamp": 1700000001, "added": 1, "deleted": 0 }
2168 ] }"#,
2169 );
2170 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2171 let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
2172 assert_eq!(churn.lines_added, u32::MAX);
2173 assert_eq!(churn.lines_deleted, u32::MAX);
2174 }
2175}