1use rustc_hash::FxHashMap;
7use std::io::Read as _;
8use std::path::{Component, Path, PathBuf};
9use std::process::{Command, Output};
10use std::sync::OnceLock;
11
12use serde::Deserialize;
13
14use crate::changed_files::git_path_from_bytes;
15
16pub use fallow_types::churn::ChurnTrend;
17
18pub type ChurnSpawnHook = fn(&mut Command) -> std::io::Result<Output>;
24
25static SPAWN_HOOK: OnceLock<ChurnSpawnHook> = OnceLock::new();
26
27fn git_command() -> Command {
28 crate::git_env::git_command()
29}
30
31pub fn set_spawn_hook(hook: ChurnSpawnHook) {
37 let _ = SPAWN_HOOK.set(hook);
38}
39
40fn spawn_output(command: &mut Command) -> std::io::Result<Output> {
41 if let Some(hook) = SPAWN_HOOK.get() {
42 hook(command)
43 } else {
44 command.output()
45 }
46}
47
48const SECS_PER_DAY: f64 = 86_400.0;
50
51const HALF_LIFE_DAYS: f64 = 90.0;
54
55const CHURN_FILE_SCHEMA: &str = "fallow-churn/v1";
57
58const MAX_CHURN_EVENTS: usize = 5_000_000;
64
65const MAX_CHURN_FILE_BYTES: usize = 256 * 1024 * 1024;
67
68const MAX_FUTURE_TIMESTAMP_SECS: u64 = 365 * 24 * 60 * 60;
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ChurnWindowUnit {
80 Days,
82 Weeks,
84 Months,
86 Years,
88}
89
90impl ChurnWindowUnit {
91 const fn token(self) -> char {
93 match self {
94 Self::Days => 'd',
95 Self::Weeks => 'w',
96 Self::Months => 'm',
97 Self::Years => 'y',
98 }
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum ChurnWindow {
109 Relative {
111 count: u64,
113 unit: ChurnWindowUnit,
115 },
116 Date(String),
118 Imported,
120}
121
122impl ChurnWindow {
123 #[must_use]
126 pub fn cache_token(&self) -> String {
127 match self {
128 Self::Relative { count, unit } => format!("{count}{}", unit.token()),
129 Self::Date(date) => date.clone(),
130 Self::Imported => String::new(),
131 }
132 }
133
134 #[must_use]
137 pub fn cutoff_secs(&self, clock: &crate::clock::AnalysisClock) -> Option<u64> {
138 match self {
139 Self::Relative { count, unit } => Some(match unit {
140 ChurnWindowUnit::Days => clock.minus_days(*count),
141 ChurnWindowUnit::Weeks => clock.minus_days(count.saturating_mul(7)),
142 ChurnWindowUnit::Months => clock.minus_months(*count),
143 ChurnWindowUnit::Years => clock.minus_years(*count),
144 }),
145 Self::Date(date) => crate::clock::utc_midnight_epoch(date),
146 Self::Imported => None,
147 }
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct SinceDuration {
154 pub window: ChurnWindow,
156 pub display: String,
158}
159
160impl SinceDuration {
161 #[must_use]
163 pub fn relative(count: u64, unit: ChurnWindowUnit, display: impl Into<String>) -> Self {
164 Self {
165 window: ChurnWindow::Relative { count, unit },
166 display: display.into(),
167 }
168 }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq)]
176pub struct AuthorContribution {
177 pub commits: u32,
179 pub weighted_commits: f64,
181 pub first_commit_ts: u64,
183 pub last_commit_ts: u64,
185}
186
187#[derive(Debug, Clone)]
189pub struct FileChurn {
190 pub path: PathBuf,
192 pub commits: u32,
194 pub weighted_commits: f64,
196 pub lines_added: u32,
198 pub lines_deleted: u32,
200 pub trend: ChurnTrend,
202 pub authors: FxHashMap<u32, AuthorContribution>,
205}
206
207#[derive(Debug, Clone)]
209pub struct ChurnResult {
210 pub files: FxHashMap<PathBuf, FileChurn>,
212 pub shallow_clone: bool,
214 pub author_pool: Vec<String>,
217 pub clock: crate::clock::AnalysisClock,
221 pub git_log_bytes: u64,
224 pub commits: Vec<ChurnCommit>,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct ChurnCommit {
235 pub sha: String,
237 pub committed_at: u64,
239 pub files: Vec<ChurnCommitFile>,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq)]
245pub struct ChurnCommitFile {
246 pub path: PathBuf,
248 pub lines_changed: u32,
250}
251
252pub fn parse_since(input: &str) -> Result<SinceDuration, String> {
263 if is_iso_date(input) {
264 return Ok(SinceDuration {
265 window: ChurnWindow::Date(input.to_string()),
266 display: input.to_string(),
267 });
268 }
269
270 let (num_str, unit) = split_number_unit(input)?;
271 let num: u64 = num_str
272 .parse()
273 .map_err(|_| format!("invalid number in --since: {input}"))?;
274
275 if num == 0 {
276 return Err("--since duration must be greater than 0".to_string());
277 }
278
279 let (unit, label) = match unit {
280 "d" | "day" | "days" => (ChurnWindowUnit::Days, "day"),
281 "w" | "week" | "weeks" => (ChurnWindowUnit::Weeks, "week"),
282 "m" | "month" | "months" => (ChurnWindowUnit::Months, "month"),
283 "y" | "year" | "years" => (ChurnWindowUnit::Years, "year"),
284 _ => {
285 return Err(format!(
286 "unknown duration unit '{unit}' in --since. Use d/w/m/y (e.g., 6m, 90d, 1y)"
287 ));
288 }
289 };
290 let plural = if num == 1 { "" } else { "s" };
291 Ok(SinceDuration::relative(
292 num,
293 unit,
294 format!("{num} {label}{plural}"),
295 ))
296}
297
298pub fn analyze_churn(root: &Path, since: &SinceDuration) -> Option<ChurnResult> {
302 let clock = crate::clock::AnalysisClock::for_repo(root);
303 let shallow = is_shallow_clone(root);
304 let state = analyze_churn_events(root, since, None, &clock)?;
305 Some(build_churn_result(state, shallow, clock))
306}
307
308#[derive(Debug, Deserialize)]
312struct ChurnFileDoc {
313 schema: String,
314 #[serde(default)]
315 events: Vec<ChurnFileEvent>,
316}
317
318#[derive(Debug, Deserialize)]
323struct ChurnFileEvent {
324 #[serde(default)]
326 commit: Option<String>,
327 path: String,
329 timestamp: u64,
331 #[serde(default)]
334 author: Option<String>,
335 added: u32,
337 deleted: u32,
339}
340
341pub(crate) fn analyze_churn_from_file(path: &Path, root: &Path) -> Result<ChurnResult, String> {
355 let raw = read_churn_file_with_limit(path, MAX_CHURN_FILE_BYTES)?;
356 let doc: ChurnFileDoc = serde_json::from_str(&raw)
357 .map_err(|e| format!("failed to parse churn file {}: {e}", path.display()))?;
358 if doc.schema != CHURN_FILE_SCHEMA {
359 return Err(format!(
360 "churn file {} declares schema \"{}\", expected \"{CHURN_FILE_SCHEMA}\"",
361 path.display(),
362 doc.schema
363 ));
364 }
365 if doc.events.len() > MAX_CHURN_EVENTS {
366 return Err(format!(
367 "churn file {} has {} events, exceeding the {MAX_CHURN_EVENTS} limit",
368 path.display(),
369 doc.events.len()
370 ));
371 }
372
373 let state = churn_event_state_from_doc(&doc, path, root)?;
374 Ok(build_churn_result(
375 state,
376 false,
377 crate::clock::AnalysisClock::for_repo(root),
378 ))
379}
380
381fn read_churn_file_with_limit(path: &Path, limit: usize) -> Result<String, String> {
382 let file = std::fs::File::open(path)
383 .map_err(|e| format!("failed to read churn file {}: {e}", path.display()))?;
384 let mut bytes = Vec::new();
385 file.take(limit as u64 + 1)
386 .read_to_end(&mut bytes)
387 .map_err(|e| format!("failed to read churn file {}: {e}", path.display()))?;
388 if bytes.len() > limit {
389 return Err(format!(
390 "churn file {} is at least {} bytes, exceeding the {limit} byte limit",
391 path.display(),
392 bytes.len()
393 ));
394 }
395 String::from_utf8(bytes)
396 .map_err(|e| format!("failed to read churn file {} as UTF-8: {e}", path.display()))
397}
398
399fn churn_event_state_from_doc(
405 doc: &ChurnFileDoc,
406 path: &Path,
407 root: &Path,
408) -> Result<ChurnEventState, String> {
409 let mut builder = ChurnFileImportBuilder::new(path, root, churn_file_future_limit());
410
411 for event in &doc.events {
412 builder.push_event(event)?;
413 }
414
415 Ok(builder.finish())
416}
417
418fn churn_file_future_limit() -> u64 {
431 let now_secs = std::time::SystemTime::now()
432 .duration_since(std::time::UNIX_EPOCH)
433 .unwrap_or_default()
434 .as_secs();
435 now_secs.saturating_add(MAX_FUTURE_TIMESTAMP_SECS)
436}
437
438struct ChurnFileImportBuilder<'a> {
439 path: &'a Path,
440 root: &'a Path,
441 future_limit: u64,
442 files: FxHashMap<PathBuf, FileEvents>,
443 totals: FxHashMap<PathBuf, (u64, u64)>,
444 author_pool: Vec<String>,
445 author_index: FxHashMap<String, u32>,
446 commit_pool: Vec<String>,
447 commit_index: FxHashMap<String, u32>,
448}
449
450impl<'a> ChurnFileImportBuilder<'a> {
451 fn new(path: &'a Path, root: &'a Path, future_limit: u64) -> Self {
452 Self {
453 path,
454 root,
455 future_limit,
456 files: FxHashMap::default(),
457 totals: FxHashMap::default(),
458 author_pool: Vec::new(),
459 author_index: FxHashMap::default(),
460 commit_pool: Vec::new(),
461 commit_index: FxHashMap::default(),
462 }
463 }
464
465 fn push_event(&mut self, event: &ChurnFileEvent) -> Result<(), String> {
466 let rel = normalize_churn_event_path(self.path, &event.path)?;
467 validate_churn_event_timestamp(self.path, event.timestamp, self.future_limit, &rel)?;
468
469 let abs_path = self.root.join(&rel);
470 let totals = self.totals.entry(abs_path.clone()).or_default();
471 totals.0 += u64::from(event.added);
472 totals.1 += u64::from(event.deleted);
473 if totals.0 > u64::from(u32::MAX) || totals.1 > u64::from(u32::MAX) {
474 return Err(format!(
475 "churn file {} has line totals for \"{rel}\" exceeding the u32 limit \
476 (added {}, deleted {})",
477 self.path.display(),
478 totals.0,
479 totals.1
480 ));
481 }
482 let author_idx = self.intern_author(event.author.as_deref());
483 let commit_idx = event
484 .commit
485 .as_deref()
486 .map(str::trim)
487 .filter(|commit| !commit.is_empty())
488 .map(|commit| intern_author(commit, &mut self.commit_pool, &mut self.commit_index));
489 self.files
490 .entry(abs_path)
491 .or_insert_with(|| FileEvents { events: Vec::new() })
492 .events
493 .push(CachedCommitEvent {
494 timestamp: event.timestamp,
495 committed_at: event.timestamp,
496 lines_added: event.added,
497 lines_deleted: event.deleted,
498 author_idx,
499 commit_idx,
500 });
501 Ok(())
502 }
503
504 fn intern_author(&mut self, author: Option<&str>) -> Option<u32> {
505 author
506 .map(str::trim)
507 .filter(|email| !email.is_empty())
508 .map(|email| intern_author(email, &mut self.author_pool, &mut self.author_index))
509 }
510
511 fn finish(self) -> ChurnEventState {
512 ChurnEventState {
513 files: self.files,
514 author_pool: self.author_pool,
515 commit_pool: self.commit_pool,
516 git_log_bytes: 0,
517 }
518 }
519}
520
521fn normalize_churn_event_path(path: &Path, event_path: &str) -> Result<String, String> {
522 let normalized = event_path.replace('\\', "/");
523 let rel = normalized.trim();
524 if rel.is_empty() {
525 return Err(format!(
526 "churn file {} has an event with an empty path",
527 path.display()
528 ));
529 }
530 let bytes = rel.as_bytes();
531 let has_drive_prefix = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':';
532 let has_empty_component = rel.split('/').any(str::is_empty);
533 let components_are_normal = Path::new(rel)
534 .components()
535 .all(|component| matches!(component, Component::Normal(_)));
536 if rel.starts_with('/') || has_drive_prefix || has_empty_component || !components_are_normal {
537 return Err(format!(
538 "churn file {} has an invalid repo-relative event path \"{event_path}\"",
539 path.display()
540 ));
541 }
542 Ok(rel.to_string())
543}
544
545fn validate_churn_event_timestamp(
546 path: &Path,
547 timestamp: u64,
548 future_limit: u64,
549 rel: &str,
550) -> Result<(), String> {
551 if timestamp <= future_limit {
552 return Ok(());
553 }
554
555 Err(format!(
556 "churn file {} has event timestamp {} for \"{rel}\" more than a year in the \
557 future; timestamps must be unix SECONDS (not milliseconds), UTC",
558 path.display(),
559 timestamp
560 ))
561}
562
563#[must_use]
565fn is_shallow_clone(root: &Path) -> bool {
566 let mut command = git_command();
567 command
568 .args(["rev-parse", "--is-shallow-repository"])
569 .current_dir(root);
570 command.output().is_ok_and(|o| {
571 String::from_utf8_lossy(&o.stdout)
572 .trim()
573 .eq_ignore_ascii_case("true")
574 })
575}
576
577#[must_use]
579pub fn is_git_repo(root: &Path) -> bool {
580 let mut command = git_command();
581 command
582 .args(["rev-parse", "--git-dir"])
583 .current_dir(root)
584 .stdout(std::process::Stdio::null())
585 .stderr(std::process::Stdio::null());
586 command.status().is_ok_and(|s| s.success())
587}
588
589const MAX_CHURN_CACHE_SIZE: usize = 64 * 1024 * 1024;
592
593const CHURN_CACHE_VERSION: u8 = 8;
604
605#[derive(Clone, bitcode::Encode, bitcode::Decode)]
607struct CachedCommitEvent {
608 timestamp: u64,
609 committed_at: u64,
613 lines_added: u32,
614 lines_deleted: u32,
615 author_idx: Option<u32>,
616 commit_idx: Option<u32>,
619}
620
621#[derive(Clone, bitcode::Encode, bitcode::Decode)]
623struct CachedFileChurn {
624 path: Vec<u8>,
625 events: Vec<CachedCommitEvent>,
626}
627
628#[derive(Clone, bitcode::Encode, bitcode::Decode)]
630struct ChurnCache {
631 version: u8,
633 last_indexed_sha: String,
634 window_token: String,
636 files: Vec<CachedFileChurn>,
637 shallow_clone: bool,
638 author_pool: Vec<String>,
640 commit_pool: Vec<String>,
642}
643
644struct FileEvents {
646 events: Vec<CachedCommitEvent>,
647}
648
649struct ChurnEventState {
652 files: FxHashMap<PathBuf, FileEvents>,
653 author_pool: Vec<String>,
654 commit_pool: Vec<String>,
656 git_log_bytes: u64,
659}
660
661fn get_head_sha(root: &Path) -> Option<String> {
663 crate::repo_refs::head_sha(root).ok().flatten()
664}
665
666fn is_ancestor(root: &Path, ancestor: &str, descendant: &str) -> bool {
668 let mut command = git_command();
669 command
670 .args(["merge-base", "--is-ancestor", ancestor, descendant])
671 .current_dir(root);
672 command.status().is_ok_and(|s| s.success())
673}
674
675fn read_churn_cache(cache_dir: &Path) -> Option<ChurnCache> {
678 let cache_file = cache_dir.join("churn.bin");
679 let data = std::fs::read(&cache_file).ok()?;
680 if data.len() > MAX_CHURN_CACHE_SIZE {
681 return None;
682 }
683 let cache: ChurnCache = bitcode::decode(&data).ok()?;
684 (cache.version == CHURN_CACHE_VERSION).then_some(cache)
685}
686
687#[cfg(test)]
690fn load_churn_cache(cache_dir: &Path, window_token: &str) -> Option<ChurnCache> {
691 read_churn_cache(cache_dir).filter(|cache| cache.window_token == window_token)
692}
693
694fn window_covers(
696 outer: &ChurnWindow,
697 inner: &ChurnWindow,
698 clock: &crate::clock::AnalysisClock,
699) -> bool {
700 if outer == inner {
701 return true;
702 }
703 match (outer.cutoff_secs(clock), inner.cutoff_secs(clock)) {
704 (Some(outer_cutoff), Some(inner_cutoff)) => outer_cutoff <= inner_cutoff,
705 _ => false,
706 }
707}
708
709fn load_covering_churn_cache(
717 cache_dir: &Path,
718 window: &ChurnWindow,
719 clock: &crate::clock::AnalysisClock,
720) -> Option<(ChurnCache, SinceDuration)> {
721 let cache = read_churn_cache(cache_dir)?;
722 let cached = parse_since(&cache.window_token).ok()?;
723 window_covers(&cached.window, window, clock).then_some((cache, cached))
724}
725
726fn save_churn_cache(
728 cache_dir: &Path,
729 last_indexed_sha: &str,
730 window_token: &str,
731 state: &ChurnEventState,
732 shallow_clone: bool,
733) {
734 let files: Vec<CachedFileChurn> = state
735 .files
736 .iter()
737 .map(|f| CachedFileChurn {
738 path: path_to_cache_bytes(f.0),
739 events: f.1.events.clone(),
740 })
741 .collect();
742 let cache = ChurnCache {
743 version: CHURN_CACHE_VERSION,
744 last_indexed_sha: last_indexed_sha.to_string(),
745 window_token: window_token.to_string(),
746 files,
747 shallow_clone,
748 author_pool: state.author_pool.clone(),
749 commit_pool: state.commit_pool.clone(),
750 };
751 let _ = std::fs::create_dir_all(cache_dir);
752 let data = bitcode::encode(&cache);
753 let tmp = cache_dir.join("churn.bin.tmp");
754 if std::fs::write(&tmp, data).is_ok() {
755 let _ = std::fs::rename(&tmp, cache_dir.join("churn.bin"));
756 }
757}
758
759pub fn analyze_churn_cached(
767 root: &Path,
768 since: &SinceDuration,
769 cache_dir: &Path,
770 no_cache: bool,
771) -> Option<(ChurnResult, bool)> {
772 analyze_churn_cached_with_history(root, since, since, cache_dir, no_cache)
773}
774
775pub fn analyze_churn_cached_with_history(
784 root: &Path,
785 since: &SinceDuration,
786 history: &SinceDuration,
787 cache_dir: &Path,
788 no_cache: bool,
789) -> Option<(ChurnResult, bool)> {
790 let head_sha = get_head_sha(root)?;
791 let clock = crate::clock::AnalysisClock::for_repo(root);
792 let history = if window_covers(&history.window, &since.window, &clock) {
793 history
794 } else {
795 since
796 };
797 let files_cutoff = since.window.cutoff_secs(&clock);
798
799 if !no_cache
800 && let Some((state, shallow_clone)) =
801 try_reuse_churn_cache(root, history, cache_dir, &head_sha, &clock)
802 {
803 return Some((
804 build_windowed_churn_result(state, files_cutoff, shallow_clone, clock),
805 true,
806 ));
807 }
808
809 let (state, shallow_clone) =
810 analyze_fresh_churn(root, history, cache_dir, no_cache, &head_sha, &clock)?;
811 Some((
812 build_windowed_churn_result(state, files_cutoff, shallow_clone, clock),
813 false,
814 ))
815}
816
817fn try_reuse_churn_cache(
818 root: &Path,
819 since: &SinceDuration,
820 cache_dir: &Path,
821 head_sha: &str,
822 clock: &crate::clock::AnalysisClock,
823) -> Option<(ChurnEventState, bool)> {
824 let (cache, cached_since) = load_covering_churn_cache(cache_dir, &since.window, clock)?;
825 let cutoff = since.window.cutoff_secs(clock);
826 if cache.last_indexed_sha == head_sha {
827 let shallow_clone = cache.shallow_clone;
828 return Some((cache.into_event_state(cutoff), shallow_clone));
829 }
830
831 if !is_ancestor(root, &cache.last_indexed_sha, head_sha) {
832 return None;
833 }
834
835 let (mut state, shallow_clone) =
838 extend_churn_cache(root, &cached_since, cache_dir, head_sha, cache, clock)?;
839 state.retain_window(cutoff);
840 Some((state, shallow_clone))
841}
842
843fn extend_churn_cache(
844 root: &Path,
845 since: &SinceDuration,
846 cache_dir: &Path,
847 head_sha: &str,
848 cache: ChurnCache,
849 clock: &crate::clock::AnalysisClock,
850) -> Option<(ChurnEventState, bool)> {
851 let shallow_clone = is_shallow_clone(root);
852 let range = format!("{}..HEAD", cache.last_indexed_sha);
853 let delta = analyze_churn_events(root, since, Some(&range), clock)?;
854 let mut state = cache.into_event_state(since.window.cutoff_secs(clock));
855 merge_churn_states(&mut state, delta);
856 save_churn_cache(
857 cache_dir,
858 head_sha,
859 &since.window.cache_token(),
860 &state,
861 shallow_clone,
862 );
863 Some((state, shallow_clone))
864}
865
866fn analyze_fresh_churn(
867 root: &Path,
868 since: &SinceDuration,
869 cache_dir: &Path,
870 no_cache: bool,
871 head_sha: &str,
872 clock: &crate::clock::AnalysisClock,
873) -> Option<(ChurnEventState, bool)> {
874 let shallow_clone = is_shallow_clone(root);
875 let state = analyze_churn_events(root, since, None, clock)?;
876 if !no_cache {
877 save_churn_cache(
878 cache_dir,
879 head_sha,
880 &since.window.cache_token(),
881 &state,
882 shallow_clone,
883 );
884 }
885
886 Some((state, shallow_clone))
887}
888
889impl ChurnCache {
890 fn into_event_state(self, cutoff_secs: Option<u64>) -> ChurnEventState {
899 let files = self
900 .files
901 .into_iter()
902 .filter_map(|entry| {
903 let path = path_from_cache_bytes(&entry.path)?;
904 Some((
905 path,
906 FileEvents {
907 events: entry.events,
908 },
909 ))
910 })
911 .collect();
912 let mut state = ChurnEventState {
913 files,
914 author_pool: self.author_pool,
915 commit_pool: self.commit_pool,
916 git_log_bytes: 0,
917 };
918 state.retain_window(cutoff_secs);
919 state
920 }
921}
922
923impl ChurnEventState {
924 fn retain_window(&mut self, cutoff_secs: Option<u64>) {
927 let Some(cutoff) = cutoff_secs else {
928 return;
929 };
930 self.files.retain(|_, file| {
931 file.events.retain(|event| event.committed_at >= cutoff);
932 !file.events.is_empty()
933 });
934 }
935}
936
937#[cfg(unix)]
938fn path_to_cache_bytes(path: &Path) -> Vec<u8> {
939 use std::os::unix::ffi::OsStrExt;
940
941 path.as_os_str().as_bytes().to_vec()
942}
943
944#[cfg(unix)]
945#[allow(
946 clippy::unnecessary_wraps,
947 reason = "Windows rejects truncated UTF-16 bytes through this shared fallible contract"
948)]
949fn path_from_cache_bytes(path: &[u8]) -> Option<PathBuf> {
950 use std::ffi::OsStr;
951 use std::os::unix::ffi::OsStrExt;
952
953 Some(PathBuf::from(OsStr::from_bytes(path)))
954}
955
956#[cfg(windows)]
957fn path_to_cache_bytes(path: &Path) -> Vec<u8> {
958 use std::os::windows::ffi::OsStrExt;
959
960 path.as_os_str()
961 .encode_wide()
962 .flat_map(u16::to_le_bytes)
963 .collect()
964}
965
966#[cfg(windows)]
967fn path_from_cache_bytes(path: &[u8]) -> Option<PathBuf> {
968 use std::ffi::OsString;
969 use std::os::windows::ffi::OsStringExt;
970
971 let (chunks, remainder) = path.as_chunks::<2>();
972 if !remainder.is_empty() {
973 return None;
974 }
975 let wide: Vec<u16> = chunks
976 .iter()
977 .map(|&pair| u16::from_le_bytes(pair))
978 .collect();
979 Some(PathBuf::from(OsString::from_wide(&wide)))
980}
981
982fn analyze_churn_events(
988 root: &Path,
989 since: &SinceDuration,
990 revision_range: Option<&str>,
991 clock: &crate::clock::AnalysisClock,
992) -> Option<ChurnEventState> {
993 let mut command = git_command();
994 command.arg("log");
995 if let Some(range) = revision_range {
996 command.arg(range);
997 }
998 command
999 .args([
1000 "--numstat",
1001 "--no-merges",
1002 "--no-renames",
1003 "--use-mailmap",
1004 "-z",
1005 "--format=format:%at|%ct|%H|%ae%x00",
1006 ])
1007 .current_dir(root);
1008 if let Some(cutoff) = since.window.cutoff_secs(clock) {
1009 command.arg(format!("--after=@{cutoff}"));
1010 }
1011 command.args(["--relative", "--", "."]);
1016
1017 let output = match spawn_output(&mut command) {
1018 Ok(o) => o,
1019 Err(e) => {
1020 tracing::warn!("hotspot analysis skipped: failed to run git: {e}");
1021 return None;
1022 }
1023 };
1024
1025 if !output.status.success() {
1026 let stderr = String::from_utf8_lossy(&output.stderr);
1027 tracing::warn!("hotspot analysis skipped: git log failed: {stderr}");
1028 return None;
1029 }
1030
1031 let mut state = parse_git_log_events_z(&output.stdout, root, clock.epoch_secs());
1032 state.git_log_bytes = output.stdout.len() as u64;
1033 Some(state)
1034}
1035
1036fn remap_pool(base: &mut Vec<String>, delta: Vec<String>) -> FxHashMap<u32, u32> {
1039 let mut base_index: FxHashMap<String, u32> = base
1040 .iter()
1041 .enumerate()
1042 .filter_map(|(idx, value)| u32::try_from(idx).ok().map(|idx| (value.clone(), idx)))
1043 .collect();
1044 let mut mapping: FxHashMap<u32, u32> = FxHashMap::default();
1045 for (old_idx, value) in delta.into_iter().enumerate() {
1046 let Ok(old_idx) = u32::try_from(old_idx) else {
1047 continue;
1048 };
1049 mapping.insert(old_idx, intern_author(&value, base, &mut base_index));
1050 }
1051 mapping
1052}
1053
1054fn merge_churn_states(base: &mut ChurnEventState, delta: ChurnEventState) {
1056 base.git_log_bytes += delta.git_log_bytes;
1057 let author_mapping = remap_pool(&mut base.author_pool, delta.author_pool);
1058 let commit_mapping = remap_pool(&mut base.commit_pool, delta.commit_pool);
1059
1060 for (path, mut file) in delta.files {
1061 for event in &mut file.events {
1062 event.author_idx = event
1063 .author_idx
1064 .and_then(|idx| author_mapping.get(&idx).copied());
1065 event.commit_idx = event
1066 .commit_idx
1067 .and_then(|idx| commit_mapping.get(&idx).copied());
1068 }
1069 base.files
1070 .entry(path)
1071 .and_modify(|existing| existing.events.append(&mut file.events))
1072 .or_insert(file);
1073 }
1074}
1075
1076fn parse_git_log_events_z(stdout: &[u8], root: &Path, now_secs: u64) -> ChurnEventState {
1080 let mut parser = GitLogEventParser::new(root, now_secs);
1081 for record in stdout.split(|byte| *byte == 0) {
1082 let record = record.strip_prefix(b"\n").unwrap_or(record);
1083 if record.is_empty() {
1084 continue;
1085 }
1086 if record.contains(&b'\t') {
1087 parser.record_numstat_bytes(record);
1088 } else {
1089 parser.consume_header(&String::from_utf8_lossy(record));
1090 }
1091 }
1092 parser.finish()
1093}
1094
1095struct GitLogEventParser<'a> {
1096 root: &'a Path,
1097 now_secs: u64,
1098 files: FxHashMap<PathBuf, FileEvents>,
1099 author_pool: Vec<String>,
1100 author_index: FxHashMap<String, u32>,
1101 commit_pool: Vec<String>,
1102 commit_index: FxHashMap<String, u32>,
1103 current_timestamp: Option<u64>,
1104 current_committed_at: Option<u64>,
1105 current_author_idx: Option<u32>,
1106 current_commit_idx: Option<u32>,
1107}
1108
1109impl<'a> GitLogEventParser<'a> {
1110 fn new(root: &'a Path, now_secs: u64) -> Self {
1111 Self {
1112 root,
1113 now_secs,
1114 files: FxHashMap::default(),
1115 author_pool: Vec::new(),
1116 author_index: FxHashMap::default(),
1117 commit_pool: Vec::new(),
1118 commit_index: FxHashMap::default(),
1119 current_timestamp: None,
1120 current_committed_at: None,
1121 current_author_idx: None,
1122 current_commit_idx: None,
1123 }
1124 }
1125
1126 fn consume_header(&mut self, line: &str) {
1129 let line = line.trim();
1130 if line.is_empty() || self.record_commit_header(line) {
1131 return;
1132 }
1133 self.record_legacy_timestamp(line);
1134 }
1135
1136 fn record_commit_header(&mut self, line: &str) -> bool {
1142 let Some((ts_str, rest)) = line.split_once('|') else {
1143 return false;
1144 };
1145 let Ok(ts) = ts_str.parse::<u64>() else {
1146 return false;
1147 };
1148 let (committed_at, rest) = match rest.split_once('|') {
1149 Some((committer_str, rest)) => (committer_str.parse::<u64>().unwrap_or(ts), rest),
1150 None => (ts, rest),
1151 };
1152 let (sha, email) = match rest.split_once('|') {
1153 Some((sha, email)) if is_commit_id(sha) => (Some(sha), email),
1154 _ => (None, rest),
1155 };
1156
1157 self.current_timestamp = Some(ts);
1158 self.current_committed_at = Some(committed_at);
1159 self.current_author_idx = Some(intern_author(
1160 email,
1161 &mut self.author_pool,
1162 &mut self.author_index,
1163 ));
1164 self.current_commit_idx =
1165 sha.map(|sha| intern_author(sha, &mut self.commit_pool, &mut self.commit_index));
1166 true
1167 }
1168
1169 fn record_legacy_timestamp(&mut self, line: &str) -> bool {
1170 let Ok(ts) = line.parse::<u64>() else {
1171 return false;
1172 };
1173
1174 self.current_timestamp = Some(ts);
1175 self.current_committed_at = Some(ts);
1176 self.current_author_idx = None;
1177 self.current_commit_idx = None;
1178 true
1179 }
1180
1181 fn record_numstat_bytes(&mut self, record: &[u8]) {
1182 let Some(first_tab) = record.iter().position(|byte| *byte == b'\t') else {
1183 return;
1184 };
1185 let Some(second_tab_offset) = record[first_tab + 1..]
1186 .iter()
1187 .position(|byte| *byte == b'\t')
1188 else {
1189 return;
1190 };
1191 let second_tab = first_tab + 1 + second_tab_offset;
1192 let Some(added) = std::str::from_utf8(&record[..first_tab])
1193 .ok()
1194 .and_then(|value| value.parse().ok())
1195 else {
1196 return;
1197 };
1198 let Some(deleted) = std::str::from_utf8(&record[first_tab + 1..second_tab])
1199 .ok()
1200 .and_then(|value| value.parse().ok())
1201 else {
1202 return;
1203 };
1204 let path = git_path_from_bytes(&record[second_tab + 1..]);
1205 self.record_numstat_path(added, deleted, path);
1206 }
1207
1208 fn record_numstat_path(&mut self, added: u32, deleted: u32, path: PathBuf) {
1209 let ts = self.current_timestamp.unwrap_or(self.now_secs);
1210 self.files
1211 .entry(self.root.join(path))
1212 .or_insert_with(|| FileEvents { events: Vec::new() })
1213 .events
1214 .push(CachedCommitEvent {
1215 timestamp: ts,
1216 committed_at: self.current_committed_at.unwrap_or(ts),
1217 lines_added: added,
1218 lines_deleted: deleted,
1219 author_idx: self.current_author_idx,
1220 commit_idx: self.current_commit_idx,
1221 });
1222 }
1223
1224 fn finish(self) -> ChurnEventState {
1225 ChurnEventState {
1226 files: self.files,
1227 author_pool: self.author_pool,
1228 commit_pool: self.commit_pool,
1229 git_log_bytes: 0,
1230 }
1231 }
1232}
1233
1234fn is_commit_id(value: &str) -> bool {
1236 matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
1237}
1238
1239#[expect(
1242 clippy::cast_possible_truncation,
1243 reason = "commit count per file is bounded by git history depth"
1244)]
1245fn aggregate_file_churn(path: PathBuf, file: FileEvents, now_secs: u64) -> FileChurn {
1246 let mut timestamps = Vec::with_capacity(file.events.len());
1247 let mut weighted_commits = 0.0;
1248 let mut lines_added = 0_u32;
1249 let mut lines_deleted = 0_u32;
1250 let mut authors: FxHashMap<u32, AuthorContribution> = FxHashMap::default();
1251
1252 for event in file.events {
1253 timestamps.push(event.timestamp);
1254 let age_days = (now_secs.saturating_sub(event.timestamp)) as f64 / SECS_PER_DAY;
1255 let weight = 0.5_f64.powf(age_days / HALF_LIFE_DAYS);
1256 weighted_commits += weight;
1257 lines_added = lines_added.saturating_add(event.lines_added);
1258 lines_deleted = lines_deleted.saturating_add(event.lines_deleted);
1259 accumulate_author(&mut authors, event.author_idx, weight, event.timestamp);
1260 }
1261
1262 let commits = timestamps.len() as u32;
1263 let trend = compute_trend(×tamps);
1264 for c in authors.values_mut() {
1265 c.weighted_commits = (c.weighted_commits * 100.0).round() / 100.0;
1266 }
1267 FileChurn {
1268 path,
1269 commits,
1270 weighted_commits: (weighted_commits * 100.0).round() / 100.0,
1271 lines_added,
1272 lines_deleted,
1273 trend,
1274 authors,
1275 }
1276}
1277
1278fn accumulate_author(
1280 authors: &mut FxHashMap<u32, AuthorContribution>,
1281 author_idx: Option<u32>,
1282 weight: f64,
1283 timestamp: u64,
1284) {
1285 let Some(idx) = author_idx else {
1286 return;
1287 };
1288 authors
1289 .entry(idx)
1290 .and_modify(|c| {
1291 c.commits += 1;
1292 c.weighted_commits += weight;
1293 c.first_commit_ts = c.first_commit_ts.min(timestamp);
1294 c.last_commit_ts = c.last_commit_ts.max(timestamp);
1295 })
1296 .or_insert(AuthorContribution {
1297 commits: 1,
1298 weighted_commits: weight,
1299 first_commit_ts: timestamp,
1300 last_commit_ts: timestamp,
1301 });
1302}
1303
1304fn build_churn_result(
1309 state: ChurnEventState,
1310 shallow_clone: bool,
1311 clock: crate::clock::AnalysisClock,
1312) -> ChurnResult {
1313 build_windowed_churn_result(state, None, shallow_clone, clock)
1314}
1315
1316fn build_windowed_churn_result(
1319 mut state: ChurnEventState,
1320 files_cutoff: Option<u64>,
1321 shallow_clone: bool,
1322 clock: crate::clock::AnalysisClock,
1323) -> ChurnResult {
1324 let commits = collect_churn_commits(&state);
1325 state.retain_window(files_cutoff);
1326 let now_secs = clock.epoch_secs();
1327
1328 let files = state
1329 .files
1330 .into_iter()
1331 .map(|(path, file)| {
1332 let churn = aggregate_file_churn(path.clone(), file, now_secs);
1333 (path, churn)
1334 })
1335 .collect();
1336
1337 ChurnResult {
1338 files,
1339 shallow_clone,
1340 author_pool: state.author_pool,
1341 clock,
1342 git_log_bytes: state.git_log_bytes,
1343 commits,
1344 }
1345}
1346
1347fn collect_churn_commits(state: &ChurnEventState) -> Vec<ChurnCommit> {
1350 let mut by_idx: FxHashMap<u32, ChurnCommit> = FxHashMap::default();
1351 for (path, file) in &state.files {
1352 for event in &file.events {
1353 let Some(idx) = event.commit_idx else {
1354 continue;
1355 };
1356 let Some(sha) = state.commit_pool.get(idx as usize) else {
1357 continue;
1358 };
1359 by_idx
1360 .entry(idx)
1361 .or_insert_with(|| ChurnCommit {
1362 sha: sha.clone(),
1363 committed_at: event.committed_at,
1364 files: Vec::new(),
1365 })
1366 .files
1367 .push(ChurnCommitFile {
1368 path: path.clone(),
1369 lines_changed: event.lines_added.saturating_add(event.lines_deleted),
1370 });
1371 }
1372 }
1373 let mut commits: Vec<ChurnCommit> = by_idx
1374 .into_values()
1375 .map(|mut commit| {
1376 commit.files.sort_by(|a, b| a.path.cmp(&b.path));
1377 commit.files.dedup_by(|later, kept| {
1378 if later.path != kept.path {
1379 return false;
1380 }
1381 kept.lines_changed = kept.lines_changed.saturating_add(later.lines_changed);
1382 true
1383 });
1384 commit
1385 })
1386 .collect();
1387 commits.sort_by(|a, b| {
1388 a.committed_at
1389 .cmp(&b.committed_at)
1390 .then_with(|| a.sha.cmp(&b.sha))
1391 });
1392 commits
1393}
1394
1395fn intern_author(email: &str, pool: &mut Vec<String>, index: &mut FxHashMap<String, u32>) -> u32 {
1397 if let Some(&idx) = index.get(email) {
1398 return idx;
1399 }
1400 #[expect(
1401 clippy::cast_possible_truncation,
1402 reason = "author count is bounded by git history; u32 is far above any realistic ceiling"
1403 )]
1404 let idx = pool.len() as u32;
1405 let owned = email.to_string();
1406 index.insert(owned.clone(), idx);
1407 pool.push(owned);
1408 idx
1409}
1410
1411fn compute_trend(timestamps: &[u64]) -> ChurnTrend {
1419 if timestamps.len() < 2 {
1420 return ChurnTrend::Stable;
1421 }
1422
1423 let min_ts = timestamps.iter().copied().min().unwrap_or(0);
1424 let max_ts = timestamps.iter().copied().max().unwrap_or(0);
1425
1426 if max_ts == min_ts {
1427 return ChurnTrend::Stable;
1428 }
1429
1430 let midpoint = min_ts + (max_ts - min_ts) / 2;
1431 let recent = timestamps.iter().filter(|&&ts| ts > midpoint).count() as f64;
1432 let older = timestamps.iter().filter(|&&ts| ts <= midpoint).count() as f64;
1433
1434 if older < 1.0 {
1435 return ChurnTrend::Stable;
1436 }
1437
1438 let ratio = recent / older;
1439 if ratio > 1.5 {
1440 ChurnTrend::Accelerating
1441 } else if ratio < 0.67 {
1442 ChurnTrend::Cooling
1443 } else {
1444 ChurnTrend::Stable
1445 }
1446}
1447
1448fn is_iso_date(input: &str) -> bool {
1449 input.len() == 10
1450 && input.as_bytes().get(4) == Some(&b'-')
1451 && input.as_bytes().get(7) == Some(&b'-')
1452 && input[..4].bytes().all(|b| b.is_ascii_digit())
1453 && input[5..7].bytes().all(|b| b.is_ascii_digit())
1454 && input[8..10].bytes().all(|b| b.is_ascii_digit())
1455}
1456
1457fn split_number_unit(input: &str) -> Result<(&str, &str), String> {
1458 let pos = input.find(|c: char| !c.is_ascii_digit()).ok_or_else(|| {
1459 format!("--since requires a unit suffix (e.g., 6m, 90d, 1y), got: {input}")
1460 })?;
1461 if pos == 0 {
1462 return Err(format!(
1463 "--since must start with a number (e.g., 6m, 90d, 1y), got: {input}"
1464 ));
1465 }
1466 Ok((&input[..pos], &input[pos..]))
1467}
1468
1469#[cfg(test)]
1470mod tests {
1471 use super::*;
1472
1473 #[test]
1474 fn parse_since_months_short() {
1475 let d = parse_since("6m").unwrap();
1476 assert_eq!(
1477 d.window,
1478 ChurnWindow::Relative {
1479 count: 6,
1480 unit: ChurnWindowUnit::Months
1481 }
1482 );
1483 assert_eq!(d.display, "6 months");
1484 }
1485
1486 #[test]
1487 fn parse_since_months_long() {
1488 let d = parse_since("6months").unwrap();
1489 assert_eq!(
1490 d.window,
1491 ChurnWindow::Relative {
1492 count: 6,
1493 unit: ChurnWindowUnit::Months
1494 }
1495 );
1496 assert_eq!(d.display, "6 months");
1497 }
1498
1499 #[test]
1500 fn parse_since_days() {
1501 let d = parse_since("90d").unwrap();
1502 assert_eq!(
1503 d.window,
1504 ChurnWindow::Relative {
1505 count: 90,
1506 unit: ChurnWindowUnit::Days
1507 }
1508 );
1509 assert_eq!(d.display, "90 days");
1510 }
1511
1512 #[test]
1513 fn parse_since_year_singular() {
1514 let d = parse_since("1y").unwrap();
1515 assert_eq!(
1516 d.window,
1517 ChurnWindow::Relative {
1518 count: 1,
1519 unit: ChurnWindowUnit::Years
1520 }
1521 );
1522 assert_eq!(d.display, "1 year");
1523 }
1524
1525 #[test]
1526 fn parse_since_years_plural() {
1527 let d = parse_since("2years").unwrap();
1528 assert_eq!(
1529 d.window,
1530 ChurnWindow::Relative {
1531 count: 2,
1532 unit: ChurnWindowUnit::Years
1533 }
1534 );
1535 assert_eq!(d.display, "2 years");
1536 }
1537
1538 #[test]
1539 fn parse_since_weeks() {
1540 let d = parse_since("2w").unwrap();
1541 assert_eq!(
1542 d.window,
1543 ChurnWindow::Relative {
1544 count: 2,
1545 unit: ChurnWindowUnit::Weeks
1546 }
1547 );
1548 assert_eq!(d.display, "2 weeks");
1549 }
1550
1551 #[test]
1552 fn parse_since_iso_date() {
1553 let d = parse_since("2025-06-01").unwrap();
1554 assert_eq!(d.window, ChurnWindow::Date("2025-06-01".to_string()));
1555 assert_eq!(d.display, "2025-06-01");
1556 }
1557
1558 #[test]
1559 fn parse_since_month_singular() {
1560 let d = parse_since("1month").unwrap();
1561 assert_eq!(d.display, "1 month");
1562 }
1563
1564 #[test]
1565 fn parse_since_day_singular() {
1566 let d = parse_since("1day").unwrap();
1567 assert_eq!(d.display, "1 day");
1568 }
1569
1570 #[test]
1571 fn parse_since_zero_rejected() {
1572 assert!(parse_since("0m").is_err());
1573 }
1574
1575 #[test]
1576 fn parse_since_no_unit_rejected() {
1577 assert!(parse_since("90").is_err());
1578 }
1579
1580 #[test]
1581 fn parse_since_unknown_unit_rejected() {
1582 assert!(parse_since("6x").is_err());
1583 }
1584
1585 #[test]
1586 fn parse_since_no_number_rejected() {
1587 assert!(parse_since("months").is_err());
1588 }
1589
1590 #[test]
1591 fn trend_empty_is_stable() {
1592 assert_eq!(compute_trend(&[]), ChurnTrend::Stable);
1593 }
1594
1595 #[test]
1596 fn trend_single_commit_is_stable() {
1597 assert_eq!(compute_trend(&[100]), ChurnTrend::Stable);
1598 }
1599
1600 #[test]
1601 fn trend_accelerating() {
1602 let timestamps = vec![100, 200, 800, 850, 900, 950, 1000];
1603 assert_eq!(compute_trend(×tamps), ChurnTrend::Accelerating);
1604 }
1605
1606 #[test]
1607 fn trend_cooling() {
1608 let timestamps = vec![100, 150, 200, 250, 300, 900, 1000];
1609 assert_eq!(compute_trend(×tamps), ChurnTrend::Cooling);
1610 }
1611
1612 #[test]
1613 fn trend_stable_even_distribution() {
1614 let timestamps = vec![100, 200, 300, 700, 800, 900];
1615 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1616 }
1617
1618 #[test]
1619 fn trend_same_timestamp_is_stable() {
1620 let timestamps = vec![500, 500, 500];
1621 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1622 }
1623
1624 #[test]
1625 fn iso_date_valid() {
1626 assert!(is_iso_date("2025-06-01"));
1627 assert!(is_iso_date("2025-12-31"));
1628 }
1629
1630 #[test]
1631 fn iso_date_with_time_rejected() {
1632 assert!(!is_iso_date("2025-06-01T00:00:00"));
1633 }
1634
1635 #[test]
1636 fn iso_date_invalid() {
1637 assert!(!is_iso_date("6months"));
1638 assert!(!is_iso_date("2025"));
1639 assert!(!is_iso_date("not-a-date"));
1640 assert!(!is_iso_date("abcd-ef-gh"));
1641 }
1642
1643 #[test]
1644 fn trend_display() {
1645 assert_eq!(ChurnTrend::Accelerating.to_string(), "accelerating");
1646 assert_eq!(ChurnTrend::Stable.to_string(), "stable");
1647 assert_eq!(ChurnTrend::Cooling.to_string(), "cooling");
1648 }
1649
1650 #[test]
1651 fn trend_boundary_1_5x_ratio() {
1652 let timestamps = vec![100, 200, 600, 800, 1000];
1653 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1654 }
1655
1656 #[test]
1657 fn trend_just_above_1_5x() {
1658 let timestamps = vec![100, 600, 800, 1000];
1659 assert_eq!(compute_trend(×tamps), ChurnTrend::Accelerating);
1660 }
1661
1662 #[test]
1663 fn trend_boundary_0_67x_ratio() {
1664 let timestamps = vec![100, 200, 300, 600, 1000];
1665 assert_eq!(compute_trend(×tamps), ChurnTrend::Cooling);
1666 }
1667
1668 #[test]
1669 fn trend_two_timestamps_different() {
1670 let timestamps = vec![100, 200];
1671 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1672 }
1673
1674 #[test]
1675 fn parse_since_week_singular() {
1676 let d = parse_since("1week").unwrap();
1677 assert_eq!(
1678 d.window,
1679 ChurnWindow::Relative {
1680 count: 1,
1681 unit: ChurnWindowUnit::Weeks
1682 }
1683 );
1684 assert_eq!(d.display, "1 week");
1685 }
1686
1687 #[test]
1688 fn parse_since_weeks_long() {
1689 let d = parse_since("3weeks").unwrap();
1690 assert_eq!(
1691 d.window,
1692 ChurnWindow::Relative {
1693 count: 3,
1694 unit: ChurnWindowUnit::Weeks
1695 }
1696 );
1697 assert_eq!(d.display, "3 weeks");
1698 }
1699
1700 #[test]
1701 fn parse_since_days_long() {
1702 let d = parse_since("30days").unwrap();
1703 assert_eq!(
1704 d.window,
1705 ChurnWindow::Relative {
1706 count: 30,
1707 unit: ChurnWindowUnit::Days
1708 }
1709 );
1710 assert_eq!(d.display, "30 days");
1711 }
1712
1713 #[test]
1714 fn parse_since_year_long() {
1715 let d = parse_since("1year").unwrap();
1716 assert_eq!(
1717 d.window,
1718 ChurnWindow::Relative {
1719 count: 1,
1720 unit: ChurnWindowUnit::Years
1721 }
1722 );
1723 assert_eq!(d.display, "1 year");
1724 }
1725
1726 #[test]
1727 fn parse_since_overflow_number_rejected() {
1728 let result = parse_since("99999999999999999999d");
1729 assert!(result.is_err());
1730 let err = result.unwrap_err();
1731 assert!(err.contains("invalid number"));
1732 }
1733
1734 #[test]
1735 fn parse_since_zero_days_rejected() {
1736 assert!(parse_since("0d").is_err());
1737 }
1738
1739 #[test]
1740 fn parse_since_zero_weeks_rejected() {
1741 assert!(parse_since("0w").is_err());
1742 }
1743
1744 #[test]
1745 fn parse_since_zero_years_rejected() {
1746 assert!(parse_since("0y").is_err());
1747 }
1748
1749 #[test]
1750 fn iso_date_wrong_separator_positions() {
1751 assert!(!is_iso_date("20-25-0601"));
1752 assert!(!is_iso_date("202506-01-"));
1753 }
1754
1755 #[test]
1756 fn iso_date_too_short() {
1757 assert!(!is_iso_date("2025-06-0"));
1758 }
1759
1760 #[test]
1761 fn iso_date_letters_in_day() {
1762 assert!(!is_iso_date("2025-06-ab"));
1763 }
1764
1765 #[test]
1766 fn iso_date_letters_in_month() {
1767 assert!(!is_iso_date("2025-ab-01"));
1768 }
1769
1770 #[test]
1771 fn split_number_unit_valid() {
1772 let (num, unit) = split_number_unit("42days").unwrap();
1773 assert_eq!(num, "42");
1774 assert_eq!(unit, "days");
1775 }
1776
1777 #[test]
1778 fn split_number_unit_single_digit() {
1779 let (num, unit) = split_number_unit("1m").unwrap();
1780 assert_eq!(num, "1");
1781 assert_eq!(unit, "m");
1782 }
1783
1784 #[test]
1785 fn split_number_unit_no_digits() {
1786 let err = split_number_unit("abc").unwrap_err();
1787 assert!(err.contains("must start with a number"));
1788 }
1789
1790 #[test]
1791 fn split_number_unit_no_unit() {
1792 let err = split_number_unit("123").unwrap_err();
1793 assert!(err.contains("requires a unit suffix"));
1794 }
1795
1796 #[test]
1797 fn trend_serde_serialization() {
1798 assert_eq!(
1799 serde_json::to_string(&ChurnTrend::Accelerating).unwrap(),
1800 "\"accelerating\""
1801 );
1802 assert_eq!(
1803 serde_json::to_string(&ChurnTrend::Stable).unwrap(),
1804 "\"stable\""
1805 );
1806 assert_eq!(
1807 serde_json::to_string(&ChurnTrend::Cooling).unwrap(),
1808 "\"cooling\""
1809 );
1810 }
1811
1812 #[test]
1813 fn intern_author_returns_existing_index() {
1814 let mut pool = Vec::new();
1815 let mut index = FxHashMap::default();
1816 let i1 = intern_author("alice@x", &mut pool, &mut index);
1817 let i2 = intern_author("alice@x", &mut pool, &mut index);
1818 assert_eq!(i1, i2);
1819 assert_eq!(pool.len(), 1);
1820 }
1821
1822 #[test]
1823 fn intern_author_assigns_sequential_indices() {
1824 let mut pool = Vec::new();
1825 let mut index = FxHashMap::default();
1826 assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1827 assert_eq!(intern_author("bob@x", &mut pool, &mut index), 1);
1828 assert_eq!(intern_author("carol@x", &mut pool, &mut index), 2);
1829 assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1830 }
1831
1832 fn git(root: &Path, args: &[&str]) {
1833 let status = super::git_command()
1834 .args(args)
1835 .current_dir(root)
1836 .status()
1837 .expect("run git");
1838 assert!(status.success(), "git {args:?} failed");
1839 }
1840
1841 fn write(root: &Path, path: &str, contents: &str) {
1842 let path = root.join(path);
1843 std::fs::create_dir_all(path.parent().expect("test path has parent")).unwrap();
1844 std::fs::write(path, contents).unwrap();
1845 }
1846
1847 #[cfg(unix)]
1848 #[test]
1849 fn churn_preserves_special_filenames() {
1850 let repo = tempfile::tempdir().expect("create repo");
1851 let root = repo.path();
1852 git(root, &["init", "--quiet"]);
1853 git(root, &["config", "user.email", "churn@example.test"]);
1854 git(root, &["config", "user.name", "Churn Test"]);
1855 git(root, &["config", "commit.gpgsign", "false"]);
1856
1857 let special_files = [
1858 "src/line\nbreak.ts",
1859 "src/space name.ts",
1860 "src/quote\"name.ts",
1861 "src/back\\slash.ts",
1862 "src/unicode-λ.ts",
1863 ]
1864 .map(|path| root.join(path));
1865 std::fs::create_dir_all(root.join("src")).expect("source dir");
1866 for special in &special_files {
1867 std::fs::write(special, "export const value = 1;\n").expect("special fixture");
1868 }
1869 git(root, &["add", "."]);
1870 git(root, &["commit", "--quiet", "-m", "initial"]);
1871
1872 let since = parse_since("1y").expect("valid duration");
1873 let churn = analyze_churn(root, &since).expect("churn result");
1874 for special in special_files {
1875 assert!(
1876 churn.files.contains_key(&special),
1877 "missing {special:?}: {:?}",
1878 churn.files.keys()
1879 );
1880 }
1881 }
1882
1883 #[cfg(unix)]
1884 #[test]
1885 fn churn_cache_preserves_non_utf8_filenames() {
1886 use std::ffi::OsString;
1887 use std::os::unix::ffi::OsStringExt;
1888
1889 let invalid_path = PathBuf::from(OsString::from_vec(b"src/non-utf8-\xff.ts".to_vec()));
1890 let mut files = FxHashMap::default();
1891 files.insert(
1892 invalid_path.clone(),
1893 FileEvents {
1894 events: vec![CachedCommitEvent {
1895 timestamp: 1,
1896 committed_at: 1,
1897 lines_added: 2,
1898 lines_deleted: 1,
1899 author_idx: None,
1900 commit_idx: None,
1901 }],
1902 },
1903 );
1904 let state = ChurnEventState {
1905 files,
1906 author_pool: Vec::new(),
1907 commit_pool: Vec::new(),
1908 git_log_bytes: 0,
1909 };
1910 let cache_dir = tempfile::tempdir().expect("cache directory");
1911 save_churn_cache(cache_dir.path(), "abc123", "1y", &state, false);
1912 let warm = load_churn_cache(cache_dir.path(), "1y")
1913 .expect("warm churn cache")
1914 .into_event_state(None);
1915
1916 assert!(warm.files.contains_key(&invalid_path));
1917 }
1918
1919 #[test]
1920 fn churn_cache_rejects_pre_lossless_path_encoding_version() {
1921 let cache_dir = tempfile::tempdir().expect("cache directory");
1922 let cache = ChurnCache {
1923 version: 4,
1924 last_indexed_sha: "abc123".to_string(),
1925 window_token: "1y".to_string(),
1926 files: vec![CachedFileChurn {
1927 path: br#"/project/\"src/line\\nbreak.ts\""#.to_vec(),
1928 events: Vec::new(),
1929 }],
1930 shallow_clone: false,
1931 author_pool: Vec::new(),
1932 commit_pool: Vec::new(),
1933 };
1934 std::fs::write(cache_dir.path().join("churn.bin"), bitcode::encode(&cache))
1935 .expect("cache fixture");
1936
1937 assert!(load_churn_cache(cache_dir.path(), "1y").is_none());
1938 }
1939
1940 #[test]
1941 fn churn_cache_rejects_entries_without_commit_ids() {
1942 let cache_dir = tempfile::tempdir().expect("cache directory");
1943 let cache = ChurnCache {
1944 version: 7,
1945 last_indexed_sha: "abc123".to_string(),
1946 window_token: "1y".to_string(),
1947 files: Vec::new(),
1948 shallow_clone: false,
1949 author_pool: Vec::new(),
1950 commit_pool: Vec::new(),
1951 };
1952 std::fs::write(cache_dir.path().join("churn.bin"), bitcode::encode(&cache))
1953 .expect("cache fixture");
1954
1955 assert!(load_churn_cache(cache_dir.path(), "1y").is_none());
1956 }
1957
1958 #[test]
1959 fn commit_header_with_sha_records_the_commit_id() {
1960 let sha = "0123456789abcdef0123456789abcdef01234567";
1961 let header = format!("1700000000|1700000001|{sha}|alice@corp");
1962 let state = parse_git_log_events_z(
1963 &git_log_z(&[(header.as_str(), &["1\t2\tsrc/a.ts", "3\t0\tsrc/b.ts"])]),
1964 Path::new("/project"),
1965 1_700_000_100,
1966 );
1967 let result = build_churn_result(
1968 state,
1969 false,
1970 crate::clock::AnalysisClock::pinned(1_700_000_100),
1971 );
1972
1973 assert_eq!(result.author_pool, vec!["alice@corp".to_string()]);
1974 assert_eq!(result.commits.len(), 1);
1975 let commit = &result.commits[0];
1976 assert_eq!(commit.sha, sha);
1977 assert_eq!(commit.committed_at, 1_700_000_001);
1978 let files: Vec<(PathBuf, u32)> = commit
1979 .files
1980 .iter()
1981 .map(|file| (file.path.clone(), file.lines_changed))
1982 .collect();
1983 assert_eq!(
1984 files,
1985 vec![
1986 (PathBuf::from("/project/src/a.ts"), 3),
1987 (PathBuf::from("/project/src/b.ts"), 3),
1988 ]
1989 );
1990 }
1991
1992 #[test]
1993 fn commit_header_without_sha_keeps_the_email_and_no_commit() {
1994 let state = parse_git_log_events_z(
1995 &git_log_z(&[("1700000000|1700000000|alice@corp", &["1\t2\tsrc/a.ts"])]),
1996 Path::new("/project"),
1997 1_700_000_100,
1998 );
1999 let result = build_churn_result(
2000 state,
2001 false,
2002 crate::clock::AnalysisClock::pinned(1_700_000_100),
2003 );
2004
2005 assert_eq!(result.author_pool, vec!["alice@corp".to_string()]);
2006 assert!(result.commits.is_empty());
2007 assert_eq!(result.files.len(), 1);
2008 }
2009
2010 #[test]
2011 fn churn_file_commit_field_groups_events_into_commits() {
2012 let dir = tempfile::tempdir().unwrap();
2013 let path = write_churn_file(
2014 dir.path(),
2015 r#"{
2016 "schema": "fallow-churn/v1",
2017 "events": [
2018 { "commit": "c1", "path": "src/a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 },
2019 { "commit": "c1", "path": "src/b.ts", "timestamp": 1700000000, "added": 2, "deleted": 1 },
2020 { "commit": "c2", "path": "src/a.ts", "timestamp": 1700100000, "added": 4, "deleted": 0 },
2021 { "path": "src/c.ts", "timestamp": 1700100000, "added": 1, "deleted": 0 }
2022 ]
2023 }"#,
2024 );
2025 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2026
2027 let commits: Vec<(String, usize)> = result
2028 .commits
2029 .iter()
2030 .map(|commit| (commit.sha.clone(), commit.files.len()))
2031 .collect();
2032 assert_eq!(commits, vec![("c1".to_string(), 2), ("c2".to_string(), 1)]);
2033 assert_eq!(
2034 result.files.len(),
2035 3,
2036 "an event without commit still counts"
2037 );
2038 }
2039
2040 #[test]
2041 fn cached_churn_merges_new_commits_after_head_advances() {
2042 let repo = tempfile::tempdir().expect("create repo");
2043 let root = repo.path();
2044 git(root, &["init"]);
2045 git(root, &["config", "user.email", "churn@example.test"]);
2046 git(root, &["config", "user.name", "Churn Test"]);
2047 git(root, &["config", "commit.gpgsign", "false"]);
2048
2049 write(root, "src/a.ts", "export const a = 1;\n");
2050 git(root, &["add", "."]);
2051 git(root, &["commit", "-m", "initial"]);
2052
2053 let since = parse_since("1y").unwrap();
2054 let cache = tempfile::tempdir().expect("create cache dir");
2055 let (cold, cold_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
2056 assert!(!cold_hit);
2057 let file = root.join("src/a.ts");
2058 assert_eq!(cold.files[&file].commits, 1);
2059
2060 let (_warm, warm_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
2061 assert!(warm_hit);
2062
2063 write(
2064 root,
2065 "src/a.ts",
2066 "export const a = 1;\nexport const b = 2;\n",
2067 );
2068 git(root, &["add", "."]);
2069 git(root, &["commit", "-m", "update a"]);
2070 let head = get_head_sha(root).unwrap();
2071
2072 let (incremental, incremental_hit) =
2073 analyze_churn_cached(root, &since, cache.path(), false).unwrap();
2074 assert!(incremental_hit);
2075 assert_eq!(incremental.files[&file].commits, 2);
2076
2077 let cache = load_churn_cache(cache.path(), &since.window.cache_token()).unwrap();
2078 assert_eq!(cache.last_indexed_sha, head);
2079 }
2080
2081 fn write_churn_file(dir: &std::path::Path, contents: &str) -> PathBuf {
2082 let path = dir.join("churn.json");
2083 std::fs::write(&path, contents).unwrap();
2084 path
2085 }
2086
2087 #[test]
2088 fn churn_file_happy_path() {
2089 let dir = tempfile::tempdir().unwrap();
2090 let root = Path::new("/project");
2091 let path = write_churn_file(
2092 dir.path(),
2093 r#"{
2094 "schema": "fallow-churn/v1",
2095 "events": [
2096 { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 10, "deleted": 5 },
2097 { "path": "src/a.ts", "timestamp": 1700100000, "author": "bob@corp", "added": 3, "deleted": 2 }
2098 ]
2099 }"#,
2100 );
2101 let result = analyze_churn_from_file(&path, root).unwrap();
2102 let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
2103 assert_eq!(churn.commits, 2);
2104 assert_eq!(churn.lines_added, 13);
2105 assert_eq!(churn.lines_deleted, 7);
2106 assert_eq!(churn.authors.len(), 2);
2107 assert!(result.author_pool.contains(&"alice@corp".to_string()));
2108 assert!(result.author_pool.contains(&"bob@corp".to_string()));
2109 assert!(!result.shallow_clone);
2110 }
2111
2112 #[test]
2113 fn churn_file_empty_events_is_valid() {
2114 let dir = tempfile::tempdir().unwrap();
2115 let path = write_churn_file(
2116 dir.path(),
2117 r#"{ "schema": "fallow-churn/v1", "events": [] }"#,
2118 );
2119 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2120 assert!(result.files.is_empty());
2121 assert!(result.author_pool.is_empty());
2122 }
2123
2124 #[test]
2125 fn churn_file_missing_events_key_is_valid() {
2126 let dir = tempfile::tempdir().unwrap();
2127 let path = write_churn_file(dir.path(), r#"{ "schema": "fallow-churn/v1" }"#);
2128 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2129 assert!(result.files.is_empty());
2130 }
2131
2132 #[test]
2133 fn churn_file_bad_schema_rejected() {
2134 let dir = tempfile::tempdir().unwrap();
2135 let path = write_churn_file(
2136 dir.path(),
2137 r#"{ "schema": "fallow-churn/v2", "events": [] }"#,
2138 );
2139 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
2140 assert!(err.contains("expected \"fallow-churn/v1\""), "{err}");
2141 }
2142
2143 #[test]
2144 fn churn_file_malformed_json_rejected() {
2145 let dir = tempfile::tempdir().unwrap();
2146 let path = write_churn_file(dir.path(), "{ not json");
2147 assert!(analyze_churn_from_file(&path, Path::new("/project")).is_err());
2148 }
2149
2150 #[test]
2151 fn churn_file_missing_file_rejected() {
2152 let err = analyze_churn_from_file(Path::new("/no/such/churn.json"), Path::new("/project"))
2153 .unwrap_err();
2154 assert!(err.contains("failed to read churn file"), "{err}");
2155 }
2156
2157 #[test]
2158 fn churn_file_reader_accepts_exact_limit() {
2159 let dir = tempfile::tempdir().unwrap();
2160 let path = write_churn_file(dir.path(), "12345678");
2161 assert_eq!(read_churn_file_with_limit(&path, 8).unwrap(), "12345678");
2162 }
2163
2164 #[test]
2165 fn churn_file_reader_rejects_limit_plus_one() {
2166 let dir = tempfile::tempdir().unwrap();
2167 let path = write_churn_file(dir.path(), "123456789");
2168 let err = read_churn_file_with_limit(&path, 8).unwrap_err();
2169 assert!(err.contains("at least 9 bytes"), "{err}");
2170 assert!(err.contains("8 byte limit"), "{err}");
2171 }
2172
2173 #[test]
2174 fn churn_file_empty_path_rejected() {
2175 let dir = tempfile::tempdir().unwrap();
2176 let path = write_churn_file(
2177 dir.path(),
2178 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": " ", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
2179 );
2180 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
2181 assert!(err.contains("empty path"), "{err}");
2182 }
2183
2184 #[test]
2185 fn churn_file_rejects_non_relative_paths() {
2186 let invalid = [
2187 "/tmp/a.ts",
2188 r"C:\tmp\a.ts",
2189 "../a.ts",
2190 "src/../../a.ts",
2191 "./src/a.ts",
2192 "//server/share/a.ts",
2193 ];
2194 for event_path in invalid {
2195 let dir = tempfile::tempdir().unwrap();
2196 let body = format!(
2197 r#"{{ "schema": "fallow-churn/v1", "events": [ {{ "path": {event_path:?}, "timestamp": 1700000000, "added": 1, "deleted": 0 }} ] }}"#
2198 );
2199 let path = write_churn_file(dir.path(), &body);
2200 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
2201 assert!(err.contains(event_path), "{event_path}: {err}");
2202 assert!(err.contains("repo-relative"), "{event_path}: {err}");
2203 }
2204 }
2205
2206 #[test]
2207 fn churn_file_accepts_unicode_and_spaces_in_path_components() {
2208 let dir = tempfile::tempdir().unwrap();
2209 let path = write_churn_file(
2210 dir.path(),
2211 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/ruimte map/naïef.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
2212 );
2213 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2214 assert!(
2215 result
2216 .files
2217 .contains_key(&PathBuf::from("/project/src/ruimte map/naïef.ts"))
2218 );
2219 }
2220
2221 #[test]
2222 fn churn_file_millisecond_timestamp_rejected() {
2223 let dir = tempfile::tempdir().unwrap();
2224 let path = write_churn_file(
2226 dir.path(),
2227 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000000, "added": 1, "deleted": 0 } ] }"#,
2228 );
2229 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
2230 assert!(err.contains("milliseconds"), "{err}");
2231 }
2232
2233 #[test]
2234 fn churn_file_missing_author_contributes_no_signal() {
2235 let dir = tempfile::tempdir().unwrap();
2236 let path = write_churn_file(
2237 dir.path(),
2238 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
2239 );
2240 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2241 let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
2242 assert_eq!(churn.commits, 1);
2243 assert!(churn.authors.is_empty());
2244 assert!(result.author_pool.is_empty());
2245 }
2246
2247 #[test]
2248 fn churn_file_empty_author_string_treated_as_absent() {
2249 let dir = tempfile::tempdir().unwrap();
2250 let path = write_churn_file(
2251 dir.path(),
2252 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "author": " ", "added": 1, "deleted": 0 } ] }"#,
2253 );
2254 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2255 assert!(result.author_pool.is_empty());
2256 }
2257
2258 #[test]
2259 fn churn_file_unknown_fields_ignored() {
2260 let dir = tempfile::tempdir().unwrap();
2263 let path = write_churn_file(
2264 dir.path(),
2265 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" } ] }"#,
2266 );
2267 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2268 assert_eq!(result.files[&PathBuf::from("/project/src/a.ts")].commits, 1);
2269 }
2270
2271 #[test]
2272 fn churn_file_backslash_paths_normalized() {
2273 let dir = tempfile::tempdir().unwrap();
2274 let path = write_churn_file(
2275 dir.path(),
2276 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src\\a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
2277 );
2278 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2279 assert!(
2280 result
2281 .files
2282 .contains_key(&PathBuf::from("/project/src/a.ts"))
2283 );
2284 }
2285
2286 #[test]
2287 fn churn_file_rejects_line_totals_above_u32() {
2288 let dir = tempfile::tempdir().unwrap();
2289 let path = write_churn_file(
2290 dir.path(),
2291 r#"{ "schema": "fallow-churn/v1", "events": [
2292 { "path": "src/a.ts", "timestamp": 1700000000, "added": 4294967295, "deleted": 0 },
2293 { "path": "src/a.ts", "timestamp": 1700000001, "added": 1, "deleted": 0 }
2294 ] }"#,
2295 );
2296 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
2297 assert!(err.contains("exceeding the u32 limit"), "{err}");
2298 assert!(err.contains("src/a.ts"), "{err}");
2299 }
2300
2301 #[test]
2302 fn churn_file_accepts_line_totals_at_u32_max() {
2303 let dir = tempfile::tempdir().unwrap();
2304 let path = write_churn_file(
2305 dir.path(),
2306 r#"{ "schema": "fallow-churn/v1", "events": [
2307 { "path": "src/a.ts", "timestamp": 1700000000, "added": 4294967294, "deleted": 4294967295 },
2308 { "path": "src/a.ts", "timestamp": 1700000001, "added": 1, "deleted": 0 }
2309 ] }"#,
2310 );
2311 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2312 let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
2313 assert_eq!(churn.lines_added, u32::MAX);
2314 assert_eq!(churn.lines_deleted, u32::MAX);
2315 }
2316
2317 #[test]
2322 fn headerless_numstat_falls_back_to_the_run_clock() {
2323 let root = Path::new("/project");
2324 let pinned = 1_700_000_000;
2325
2326 let state = parse_git_log_events_z(b"10\t5\tsrc/a.ts", root, pinned);
2327
2328 let events = &state.files[&PathBuf::from("/project/src/a.ts")].events;
2329 assert_eq!(events.len(), 1);
2330 assert_eq!(events[0].timestamp, pinned);
2331 assert_eq!(events[0].committed_at, pinned);
2332 }
2333
2334 #[test]
2338 fn headerless_numstat_tracks_only_the_supplied_clock() {
2339 let root = Path::new("/project");
2340 let record: &[u8] = b"1\t0\tsrc/a.ts";
2341
2342 let early = parse_git_log_events_z(record, root, 1_600_000_000);
2343 let late = parse_git_log_events_z(record, root, 1_700_000_000);
2344
2345 let key = PathBuf::from("/project/src/a.ts");
2346 assert_eq!(early.files[&key].events[0].timestamp, 1_600_000_000);
2347 assert_eq!(late.files[&key].events[0].timestamp, 1_700_000_000);
2348 }
2349
2350 #[test]
2352 fn commit_header_timestamp_beats_the_run_clock_fallback() {
2353 let root = Path::new("/project");
2354 let record = b"1700000000|1700000500|dev@example.com\x002\t1\tsrc/a.ts";
2355
2356 let state = parse_git_log_events_z(record, root, 1_234_567_890);
2357
2358 let events = &state.files[&PathBuf::from("/project/src/a.ts")].events;
2359 assert_eq!(events[0].timestamp, 1_700_000_000);
2360 assert_eq!(events[0].committed_at, 1_700_000_500);
2361 }
2362
2363 const NOW: u64 = 1_750_000_000;
2365 const HEADER: &str = "1700000000|1700000000|dev@example.com";
2366
2367 fn git_log_z(commits: &[(&str, &[&str])]) -> Vec<u8> {
2371 let mut out = Vec::new();
2372 for (index, (header, rows)) in commits.iter().enumerate() {
2373 if index > 0 {
2374 out.push(0);
2375 }
2376 out.extend_from_slice(header.as_bytes());
2377 out.extend_from_slice(b"\0\n");
2378 for row in *rows {
2379 out.extend_from_slice(row.as_bytes());
2380 out.push(0);
2381 }
2382 }
2383 out
2384 }
2385
2386 fn churn_from_git_log_z(stdout: &[u8], root: &Path, now_secs: u64) -> ChurnResult {
2387 build_churn_result(
2388 parse_git_log_events_z(stdout, root, now_secs),
2389 false,
2390 crate::clock::AnalysisClock::pinned(now_secs),
2391 )
2392 }
2393
2394 fn churn_from_commits(commits: &[(&str, &[&str])]) -> ChurnResult {
2395 churn_from_git_log_z(&git_log_z(commits), Path::new("/project"), NOW)
2396 }
2397
2398 #[test]
2399 fn git_log_z_fixture_matches_real_git_output() {
2400 assert_eq!(
2401 git_log_z(&[
2402 ("1|2|a@b", &["1\t0\ta.ts"]),
2403 ("3|4|a@b", &["1\t0\ta.ts", "1\t0\tb c.ts"])
2404 ]),
2405 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()
2406 );
2407 }
2408
2409 #[test]
2410 fn numstat_records_parse_counts_and_skip_binary_or_malformed_rows() {
2411 let root = Path::new("/project");
2412 type Expected = Option<(u32, u32, &'static str)>;
2413 let cases: &[(&str, Expected)] = &[
2414 ("10\t5\tsrc/file.ts", Some((10, 5, "src/file.ts"))),
2415 ("0\t0\tsrc/empty.ts", Some((0, 0, "src/empty.ts"))),
2416 (
2417 "3\t1\tpath with spaces/file.ts",
2418 Some((3, 1, "path with spaces/file.ts")),
2419 ),
2420 ("1\t0\tweird\tname.ts", Some((1, 0, "weird\tname.ts"))),
2421 (
2422 "4294967295\t8888\tsrc/big.ts",
2423 Some((u32::MAX, 8888, "src/big.ts")),
2424 ),
2425 ("4294967296\t0\tsrc/overflow.ts", None),
2426 ("-\t-\tsrc/image.png", None),
2427 ("-\t5\tsrc/file.ts", None),
2428 ("10\t-\tsrc/file.ts", None),
2429 ("10\t5", None),
2430 ];
2431 for (row, expected) in cases {
2432 let state = parse_git_log_events_z(&git_log_z(&[(HEADER, &[row])]), root, NOW);
2433 let Some((added, deleted, path)) = expected else {
2434 assert!(state.files.is_empty(), "{row:?} must not record churn");
2435 continue;
2436 };
2437 assert_eq!(state.files.len(), 1, "{row:?}");
2438 let events = &state.files[&root.join(path)].events;
2439 assert_eq!(events.len(), 1, "{row:?}");
2440 assert_eq!(events[0].lines_added, *added, "{row:?}");
2441 assert_eq!(events[0].lines_deleted, *deleted, "{row:?}");
2442 }
2443 }
2444
2445 #[test]
2446 fn git_log_z_aggregates_commits_and_lines_per_file() {
2447 let result = churn_from_commits(&[
2448 (
2449 HEADER,
2450 &["10\t5\tsrc/a.ts", "3\t1\tsrc/b.ts", "-\t-\timage.png"],
2451 ),
2452 ("1700100000|1700100000|dev@example.com", &["3\t2\tsrc/a.ts"]),
2453 ]);
2454 assert_eq!(result.files.len(), 2);
2455 let a = &result.files[&PathBuf::from("/project/src/a.ts")];
2456 assert_eq!((a.commits, a.lines_added, a.lines_deleted), (2, 13, 7));
2457 let b = &result.files[&PathBuf::from("/project/src/b.ts")];
2458 assert_eq!((b.commits, b.lines_added, b.lines_deleted), (1, 3, 1));
2459 assert!(
2460 !result
2461 .files
2462 .contains_key(&PathBuf::from("/project/image.png"))
2463 );
2464 }
2465
2466 #[test]
2467 fn git_log_z_empty_or_blank_output_records_nothing() {
2468 assert!(
2469 churn_from_git_log_z(b"", Path::new("/project"), NOW)
2470 .files
2471 .is_empty()
2472 );
2473 assert!(
2474 churn_from_git_log_z(b"\0\0\n\0 \0", Path::new("/project"), NOW)
2475 .files
2476 .is_empty()
2477 );
2478 }
2479
2480 #[test]
2481 fn git_log_z_blank_records_between_commits_are_ignored() {
2482 let stdout = b" \0\n1700000000|1700000000|dev@example.com\0\n \0\n10\t5\tsrc/a.ts\0\0";
2483 let result = churn_from_git_log_z(stdout, Path::new("/project"), NOW);
2484 assert_eq!(result.files.len(), 1);
2485 assert_eq!(result.files[&PathBuf::from("/project/src/a.ts")].commits, 1);
2486 }
2487
2488 #[test]
2489 fn git_log_z_paths_are_joined_to_the_root() {
2490 let root = Path::new("/my/project");
2491 let result =
2492 churn_from_git_log_z(&git_log_z(&[(HEADER, &["1\t0\tlib/utils.ts"])]), root, NOW);
2493 let key = PathBuf::from("/my/project/lib/utils.ts");
2494 assert_eq!(result.files[&key].path, key);
2495 }
2496
2497 #[test]
2498 fn git_log_z_weights_commits_by_age_against_the_run_clock() {
2499 let day = 86_400;
2500 let now = format!("{NOW}|{NOW}|dev@example.com");
2501 let half_life = format!("{0}|{0}|dev@example.com", NOW - 45 * day);
2502 let two_half_lives = format!("{0}|{0}|dev@example.com", NOW - 180 * day);
2503 let result = churn_from_commits(&[
2504 (&now, &["1\t0\tsrc/fresh.ts"]),
2505 (&half_life, &["1\t0\tsrc/half.ts"]),
2506 (&two_half_lives, &["1\t0\tsrc/old.ts"]),
2507 ]);
2508 let weight = |name: &str| {
2509 result.files[&PathBuf::from(format!("/project/src/{name}"))].weighted_commits
2510 };
2511 assert!((weight("fresh.ts") - 1.0).abs() < f64::EPSILON);
2512 assert!((weight("half.ts") - 0.71).abs() < f64::EPSILON);
2514 assert!((weight("old.ts") - 0.25).abs() < f64::EPSILON);
2515 }
2516
2517 #[test]
2518 fn git_log_z_headerless_numstat_weighs_as_a_commit_at_the_run_clock() {
2519 let result = churn_from_git_log_z(b"10\t5\tsrc/no_ts.ts\0", Path::new("/project"), NOW);
2520 let churn = &result.files[&PathBuf::from("/project/src/no_ts.ts")];
2521 assert_eq!(
2522 (churn.commits, churn.lines_added, churn.lines_deleted),
2523 (1, 10, 5)
2524 );
2525 assert!((churn.weighted_commits - 1.0).abs() < f64::EPSILON);
2526 }
2527
2528 #[test]
2529 fn git_log_z_trend_is_computed_per_file() {
2530 let result = churn_from_commits(&[
2531 (
2532 "1000|1000|dev@example.com",
2533 &["5\t1\tsrc/old.ts", "1\t0\tsrc/hot.ts"],
2534 ),
2535 ("1800|1800|dev@example.com", &["1\t0\tsrc/hot.ts"]),
2536 ("1900|1900|dev@example.com", &["1\t0\tsrc/hot.ts"]),
2537 ("1950|1950|dev@example.com", &["1\t0\tsrc/hot.ts"]),
2538 (
2539 "2000|2000|dev@example.com",
2540 &["3\t1\tsrc/old.ts", "1\t0\tsrc/hot.ts"],
2541 ),
2542 ]);
2543 let old = &result.files[&PathBuf::from("/project/src/old.ts")];
2544 let hot = &result.files[&PathBuf::from("/project/src/hot.ts")];
2545 assert_eq!(old.commits, 2);
2546 assert_eq!(old.trend, ChurnTrend::Stable);
2547 assert_eq!(hot.commits, 5);
2548 assert_eq!(hot.trend, ChurnTrend::Accelerating);
2549 }
2550
2551 #[test]
2552 fn git_log_z_interns_authors_and_aggregates_per_author() {
2553 let result = churn_from_commits(&[
2554 (
2555 "1700000000|1700000000|alice@example.com",
2556 &["1\t0\tsrc/index.ts"],
2557 ),
2558 (
2559 "1700100000|1700100000|bob@example.com",
2560 &["2\t0\tsrc/index.ts"],
2561 ),
2562 (
2563 "1700200000|1700200000|alice@example.com",
2564 &["1\t1\tsrc/index.ts"],
2565 ),
2566 ]);
2567 assert_eq!(
2568 result.author_pool,
2569 vec![
2570 "alice@example.com".to_string(),
2571 "bob@example.com".to_string()
2572 ]
2573 );
2574 let churn = &result.files[&PathBuf::from("/project/src/index.ts")];
2575 assert_eq!(churn.commits, 3);
2576 assert_eq!(churn.authors.len(), 2);
2577 let alice = &churn.authors[&0];
2578 assert_eq!(alice.commits, 2);
2579 assert_eq!(alice.first_commit_ts, 1_700_000_000);
2580 assert_eq!(alice.last_commit_ts, 1_700_200_000);
2581 let bob = &churn.authors[&1];
2582 assert_eq!(bob.commits, 1);
2583 assert_eq!(bob.first_commit_ts, 1_700_100_000);
2584 }
2585
2586 #[test]
2587 fn git_log_z_legacy_headers_still_parse() {
2588 let result = churn_from_commits(&[
2589 ("1700000000", &["10\t5\tsrc/bare.ts"]),
2590 ("1700100000|alice@example.com", &["1\t0\tsrc/two_field.ts"]),
2591 ]);
2592 assert_eq!(result.author_pool, vec!["alice@example.com".to_string()]);
2593 let bare = &result.files[&PathBuf::from("/project/src/bare.ts")];
2594 assert_eq!(bare.commits, 1);
2595 assert!(bare.authors.is_empty());
2596 let two_field = &result.files[&PathBuf::from("/project/src/two_field.ts")];
2597 assert_eq!(two_field.authors[&0].first_commit_ts, 1_700_100_000);
2598 }
2599
2600 #[test]
2601 fn churn_file_matches_git_log_z_parse() {
2602 let dir = tempfile::tempdir().unwrap();
2606 let root = Path::new("/project");
2607 let path = write_churn_file(
2608 dir.path(),
2609 r#"{
2610 "schema": "fallow-churn/v1",
2611 "events": [
2612 { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 10, "deleted": 5 },
2613 { "path": "src/b.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 3, "deleted": 1 },
2614 { "path": "src/a.ts", "timestamp": 1700100000, "author": "bob@corp", "added": 3, "deleted": 2 }
2615 ]
2616 }"#,
2617 );
2618 let imported = analyze_churn_from_file(&path, root).unwrap();
2619 let git = churn_from_git_log_z(
2620 &git_log_z(&[
2621 (
2622 "1700000000|1700000000|alice@corp",
2623 &["10\t5\tsrc/a.ts", "3\t1\tsrc/b.ts"],
2624 ),
2625 ("1700100000|1700100000|bob@corp", &["3\t2\tsrc/a.ts"]),
2626 ]),
2627 root,
2628 imported.clock.epoch_secs(),
2629 );
2630
2631 assert_eq!(
2632 git.author_pool, imported.author_pool,
2633 "author pools diverge"
2634 );
2635 assert_eq!(git.files.len(), imported.files.len());
2636 for (file, git_churn) in &git.files {
2637 let imp = &imported.files[file];
2638 assert_eq!(git_churn.commits, imp.commits, "commits for {file:?}");
2639 assert_eq!(git_churn.lines_added, imp.lines_added, "added for {file:?}");
2640 assert_eq!(
2641 git_churn.lines_deleted, imp.lines_deleted,
2642 "deleted for {file:?}"
2643 );
2644 assert_eq!(git_churn.trend, imp.trend, "trend for {file:?}");
2645 assert_eq!(git_churn.authors, imp.authors, "authors for {file:?}");
2646 assert!(
2647 (git_churn.weighted_commits - imp.weighted_commits).abs() < f64::EPSILON,
2648 "weighted_commits for {file:?}: {} vs {}",
2649 git_churn.weighted_commits,
2650 imp.weighted_commits
2651 );
2652 }
2653 }
2654}