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(cwd: &Path) -> Command {
28 crate::git_env::git_command(cwd)
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(root);
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(root);
581 command.args(["rev-parse", "--git-dir"]).current_dir(root);
582 command.output().is_ok_and(|output| output.status.success())
583}
584
585const MAX_CHURN_CACHE_SIZE: usize = 64 * 1024 * 1024;
588
589const CHURN_CACHE_VERSION: u8 = 8;
600
601#[derive(Clone, bitcode::Encode, bitcode::Decode)]
603struct CachedCommitEvent {
604 timestamp: u64,
605 committed_at: u64,
609 lines_added: u32,
610 lines_deleted: u32,
611 author_idx: Option<u32>,
612 commit_idx: Option<u32>,
615}
616
617#[derive(Clone, bitcode::Encode, bitcode::Decode)]
619struct CachedFileChurn {
620 path: Vec<u8>,
621 events: Vec<CachedCommitEvent>,
622}
623
624#[derive(Clone, bitcode::Encode, bitcode::Decode)]
626struct ChurnCache {
627 version: u8,
629 last_indexed_sha: String,
630 window_token: String,
632 files: Vec<CachedFileChurn>,
633 shallow_clone: bool,
634 author_pool: Vec<String>,
636 commit_pool: Vec<String>,
638}
639
640struct FileEvents {
642 events: Vec<CachedCommitEvent>,
643}
644
645struct ChurnEventState {
648 files: FxHashMap<PathBuf, FileEvents>,
649 author_pool: Vec<String>,
650 commit_pool: Vec<String>,
652 git_log_bytes: u64,
655}
656
657fn get_head_sha(root: &Path) -> Option<String> {
659 crate::repo_refs::head_sha(root).ok().flatten()
660}
661
662fn is_ancestor(root: &Path, ancestor: &str, descendant: &str) -> bool {
664 let mut command = git_command(root);
665 command
666 .args(["merge-base", "--is-ancestor", ancestor, descendant])
667 .current_dir(root);
668 command.status().is_ok_and(|s| s.success())
669}
670
671fn read_churn_cache(cache_dir: &Path) -> Option<ChurnCache> {
674 let cache_file = cache_dir.join("churn.bin");
675 let data = std::fs::read(&cache_file).ok()?;
676 if data.len() > MAX_CHURN_CACHE_SIZE {
677 return None;
678 }
679 let cache: ChurnCache = bitcode::decode(&data).ok()?;
680 (cache.version == CHURN_CACHE_VERSION).then_some(cache)
681}
682
683#[cfg(test)]
686fn load_churn_cache(cache_dir: &Path, window_token: &str) -> Option<ChurnCache> {
687 read_churn_cache(cache_dir).filter(|cache| cache.window_token == window_token)
688}
689
690fn window_covers(
692 outer: &ChurnWindow,
693 inner: &ChurnWindow,
694 clock: &crate::clock::AnalysisClock,
695) -> bool {
696 if outer == inner {
697 return true;
698 }
699 match (outer.cutoff_secs(clock), inner.cutoff_secs(clock)) {
700 (Some(outer_cutoff), Some(inner_cutoff)) => outer_cutoff <= inner_cutoff,
701 _ => false,
702 }
703}
704
705fn load_covering_churn_cache(
713 cache_dir: &Path,
714 window: &ChurnWindow,
715 clock: &crate::clock::AnalysisClock,
716) -> Option<(ChurnCache, SinceDuration)> {
717 let cache = read_churn_cache(cache_dir)?;
718 let cached = parse_since(&cache.window_token).ok()?;
719 window_covers(&cached.window, window, clock).then_some((cache, cached))
720}
721
722fn save_churn_cache(
724 cache_dir: &Path,
725 last_indexed_sha: &str,
726 window_token: &str,
727 state: &ChurnEventState,
728 shallow_clone: bool,
729) {
730 let files: Vec<CachedFileChurn> = state
731 .files
732 .iter()
733 .map(|f| CachedFileChurn {
734 path: path_to_cache_bytes(f.0),
735 events: f.1.events.clone(),
736 })
737 .collect();
738 let cache = ChurnCache {
739 version: CHURN_CACHE_VERSION,
740 last_indexed_sha: last_indexed_sha.to_string(),
741 window_token: window_token.to_string(),
742 files,
743 shallow_clone,
744 author_pool: state.author_pool.clone(),
745 commit_pool: state.commit_pool.clone(),
746 };
747 let _ = std::fs::create_dir_all(cache_dir);
748 let data = bitcode::encode(&cache);
749 let tmp = cache_dir.join("churn.bin.tmp");
750 if std::fs::write(&tmp, data).is_ok() {
751 let _ = std::fs::rename(&tmp, cache_dir.join("churn.bin"));
752 }
753}
754
755pub fn analyze_churn_cached(
763 root: &Path,
764 since: &SinceDuration,
765 cache_dir: &Path,
766 no_cache: bool,
767) -> Option<(ChurnResult, bool)> {
768 analyze_churn_cached_with_history(root, since, since, cache_dir, no_cache)
769}
770
771pub fn analyze_churn_cached_with_history(
780 root: &Path,
781 since: &SinceDuration,
782 history: &SinceDuration,
783 cache_dir: &Path,
784 no_cache: bool,
785) -> Option<(ChurnResult, bool)> {
786 let head_sha = get_head_sha(root)?;
787 let clock = crate::clock::AnalysisClock::for_repo(root);
788 let history = if window_covers(&history.window, &since.window, &clock) {
789 history
790 } else {
791 since
792 };
793 let files_cutoff = since.window.cutoff_secs(&clock);
794
795 if !no_cache
796 && let Some((state, shallow_clone)) =
797 try_reuse_churn_cache(root, history, cache_dir, &head_sha, &clock)
798 {
799 return Some((
800 build_windowed_churn_result(state, files_cutoff, shallow_clone, clock),
801 true,
802 ));
803 }
804
805 let (state, shallow_clone) =
806 analyze_fresh_churn(root, history, cache_dir, no_cache, &head_sha, &clock)?;
807 Some((
808 build_windowed_churn_result(state, files_cutoff, shallow_clone, clock),
809 false,
810 ))
811}
812
813fn try_reuse_churn_cache(
814 root: &Path,
815 since: &SinceDuration,
816 cache_dir: &Path,
817 head_sha: &str,
818 clock: &crate::clock::AnalysisClock,
819) -> Option<(ChurnEventState, bool)> {
820 let (cache, cached_since) = load_covering_churn_cache(cache_dir, &since.window, clock)?;
821 let cutoff = since.window.cutoff_secs(clock);
822 if cache.last_indexed_sha == head_sha {
823 let shallow_clone = cache.shallow_clone;
824 return Some((cache.into_event_state(cutoff), shallow_clone));
825 }
826
827 if !is_ancestor(root, &cache.last_indexed_sha, head_sha) {
828 return None;
829 }
830
831 let (mut state, shallow_clone) =
834 extend_churn_cache(root, &cached_since, cache_dir, head_sha, cache, clock)?;
835 state.retain_window(cutoff);
836 Some((state, shallow_clone))
837}
838
839fn extend_churn_cache(
840 root: &Path,
841 since: &SinceDuration,
842 cache_dir: &Path,
843 head_sha: &str,
844 cache: ChurnCache,
845 clock: &crate::clock::AnalysisClock,
846) -> Option<(ChurnEventState, bool)> {
847 let shallow_clone = is_shallow_clone(root);
848 let range = format!("{}..HEAD", cache.last_indexed_sha);
849 let delta = analyze_churn_events(root, since, Some(&range), clock)?;
850 let mut state = cache.into_event_state(since.window.cutoff_secs(clock));
851 merge_churn_states(&mut state, delta);
852 save_churn_cache(
853 cache_dir,
854 head_sha,
855 &since.window.cache_token(),
856 &state,
857 shallow_clone,
858 );
859 Some((state, shallow_clone))
860}
861
862fn analyze_fresh_churn(
863 root: &Path,
864 since: &SinceDuration,
865 cache_dir: &Path,
866 no_cache: bool,
867 head_sha: &str,
868 clock: &crate::clock::AnalysisClock,
869) -> Option<(ChurnEventState, bool)> {
870 let shallow_clone = is_shallow_clone(root);
871 let state = analyze_churn_events(root, since, None, clock)?;
872 if !no_cache {
873 save_churn_cache(
874 cache_dir,
875 head_sha,
876 &since.window.cache_token(),
877 &state,
878 shallow_clone,
879 );
880 }
881
882 Some((state, shallow_clone))
883}
884
885impl ChurnCache {
886 fn into_event_state(self, cutoff_secs: Option<u64>) -> ChurnEventState {
895 let files = self
896 .files
897 .into_iter()
898 .filter_map(|entry| {
899 let path = path_from_cache_bytes(&entry.path)?;
900 Some((
901 path,
902 FileEvents {
903 events: entry.events,
904 },
905 ))
906 })
907 .collect();
908 let mut state = ChurnEventState {
909 files,
910 author_pool: self.author_pool,
911 commit_pool: self.commit_pool,
912 git_log_bytes: 0,
913 };
914 state.retain_window(cutoff_secs);
915 state
916 }
917}
918
919impl ChurnEventState {
920 fn retain_window(&mut self, cutoff_secs: Option<u64>) {
923 let Some(cutoff) = cutoff_secs else {
924 return;
925 };
926 self.files.retain(|_, file| {
927 file.events.retain(|event| event.committed_at >= cutoff);
928 !file.events.is_empty()
929 });
930 }
931}
932
933#[cfg(unix)]
934fn path_to_cache_bytes(path: &Path) -> Vec<u8> {
935 use std::os::unix::ffi::OsStrExt;
936
937 path.as_os_str().as_bytes().to_vec()
938}
939
940#[cfg(unix)]
941#[allow(
942 clippy::unnecessary_wraps,
943 reason = "Windows rejects truncated UTF-16 bytes through this shared fallible contract"
944)]
945fn path_from_cache_bytes(path: &[u8]) -> Option<PathBuf> {
946 use std::ffi::OsStr;
947 use std::os::unix::ffi::OsStrExt;
948
949 Some(PathBuf::from(OsStr::from_bytes(path)))
950}
951
952#[cfg(windows)]
953fn path_to_cache_bytes(path: &Path) -> Vec<u8> {
954 use std::os::windows::ffi::OsStrExt;
955
956 path.as_os_str()
957 .encode_wide()
958 .flat_map(u16::to_le_bytes)
959 .collect()
960}
961
962#[cfg(windows)]
963fn path_from_cache_bytes(path: &[u8]) -> Option<PathBuf> {
964 use std::ffi::OsString;
965 use std::os::windows::ffi::OsStringExt;
966
967 let (chunks, remainder) = path.as_chunks::<2>();
968 if !remainder.is_empty() {
969 return None;
970 }
971 let wide: Vec<u16> = chunks
972 .iter()
973 .map(|&pair| u16::from_le_bytes(pair))
974 .collect();
975 Some(PathBuf::from(OsString::from_wide(&wide)))
976}
977
978fn analyze_churn_events(
984 root: &Path,
985 since: &SinceDuration,
986 revision_range: Option<&str>,
987 clock: &crate::clock::AnalysisClock,
988) -> Option<ChurnEventState> {
989 let mut command = git_command(root);
990 command.arg("log");
991 if let Some(range) = revision_range {
992 command.arg(range);
993 }
994 command
995 .args([
996 "--numstat",
997 "--no-merges",
998 "--no-renames",
999 "--use-mailmap",
1000 "-z",
1001 "--format=format:%at|%ct|%H|%ae%x00",
1002 ])
1003 .current_dir(root);
1004 if let Some(cutoff) = since.window.cutoff_secs(clock) {
1005 command.arg(format!("--after=@{cutoff}"));
1006 }
1007 command.args(["--relative", "--", "."]);
1012
1013 let output = match spawn_output(&mut command) {
1014 Ok(o) => o,
1015 Err(e) => {
1016 tracing::warn!("hotspot analysis skipped: failed to run git: {e}");
1017 return None;
1018 }
1019 };
1020
1021 if !output.status.success() {
1022 let stderr = String::from_utf8_lossy(&output.stderr);
1023 tracing::warn!("hotspot analysis skipped: git log failed: {stderr}");
1024 return None;
1025 }
1026
1027 let mut state = parse_git_log_events_z(&output.stdout, root, clock.epoch_secs());
1028 state.git_log_bytes = output.stdout.len() as u64;
1029 Some(state)
1030}
1031
1032fn remap_pool(base: &mut Vec<String>, delta: Vec<String>) -> FxHashMap<u32, u32> {
1035 let mut base_index: FxHashMap<String, u32> = base
1036 .iter()
1037 .enumerate()
1038 .filter_map(|(idx, value)| u32::try_from(idx).ok().map(|idx| (value.clone(), idx)))
1039 .collect();
1040 let mut mapping: FxHashMap<u32, u32> = FxHashMap::default();
1041 for (old_idx, value) in delta.into_iter().enumerate() {
1042 let Ok(old_idx) = u32::try_from(old_idx) else {
1043 continue;
1044 };
1045 mapping.insert(old_idx, intern_author(&value, base, &mut base_index));
1046 }
1047 mapping
1048}
1049
1050fn merge_churn_states(base: &mut ChurnEventState, delta: ChurnEventState) {
1052 base.git_log_bytes += delta.git_log_bytes;
1053 let author_mapping = remap_pool(&mut base.author_pool, delta.author_pool);
1054 let commit_mapping = remap_pool(&mut base.commit_pool, delta.commit_pool);
1055
1056 for (path, mut file) in delta.files {
1057 for event in &mut file.events {
1058 event.author_idx = event
1059 .author_idx
1060 .and_then(|idx| author_mapping.get(&idx).copied());
1061 event.commit_idx = event
1062 .commit_idx
1063 .and_then(|idx| commit_mapping.get(&idx).copied());
1064 }
1065 base.files
1066 .entry(path)
1067 .and_modify(|existing| existing.events.append(&mut file.events))
1068 .or_insert(file);
1069 }
1070}
1071
1072fn parse_git_log_events_z(stdout: &[u8], root: &Path, now_secs: u64) -> ChurnEventState {
1076 let mut parser = GitLogEventParser::new(root, now_secs);
1077 for record in stdout.split(|byte| *byte == 0) {
1078 let record = record.strip_prefix(b"\n").unwrap_or(record);
1079 if record.is_empty() {
1080 continue;
1081 }
1082 if record.contains(&b'\t') {
1083 parser.record_numstat_bytes(record);
1084 } else {
1085 parser.consume_header(&String::from_utf8_lossy(record));
1086 }
1087 }
1088 parser.finish()
1089}
1090
1091struct GitLogEventParser<'a> {
1092 root: &'a Path,
1093 now_secs: u64,
1094 files: FxHashMap<PathBuf, FileEvents>,
1095 author_pool: Vec<String>,
1096 author_index: FxHashMap<String, u32>,
1097 commit_pool: Vec<String>,
1098 commit_index: FxHashMap<String, u32>,
1099 current_timestamp: Option<u64>,
1100 current_committed_at: Option<u64>,
1101 current_author_idx: Option<u32>,
1102 current_commit_idx: Option<u32>,
1103}
1104
1105impl<'a> GitLogEventParser<'a> {
1106 fn new(root: &'a Path, now_secs: u64) -> Self {
1107 Self {
1108 root,
1109 now_secs,
1110 files: FxHashMap::default(),
1111 author_pool: Vec::new(),
1112 author_index: FxHashMap::default(),
1113 commit_pool: Vec::new(),
1114 commit_index: FxHashMap::default(),
1115 current_timestamp: None,
1116 current_committed_at: None,
1117 current_author_idx: None,
1118 current_commit_idx: None,
1119 }
1120 }
1121
1122 fn consume_header(&mut self, line: &str) {
1125 let line = line.trim();
1126 if line.is_empty() || self.record_commit_header(line) {
1127 return;
1128 }
1129 self.record_legacy_timestamp(line);
1130 }
1131
1132 fn record_commit_header(&mut self, line: &str) -> bool {
1138 let Some((ts_str, rest)) = line.split_once('|') else {
1139 return false;
1140 };
1141 let Ok(ts) = ts_str.parse::<u64>() else {
1142 return false;
1143 };
1144 let (committed_at, rest) = match rest.split_once('|') {
1145 Some((committer_str, rest)) => (committer_str.parse::<u64>().unwrap_or(ts), rest),
1146 None => (ts, rest),
1147 };
1148 let (sha, email) = match rest.split_once('|') {
1149 Some((sha, email)) if is_commit_id(sha) => (Some(sha), email),
1150 _ => (None, rest),
1151 };
1152
1153 self.current_timestamp = Some(ts);
1154 self.current_committed_at = Some(committed_at);
1155 self.current_author_idx = Some(intern_author(
1156 email,
1157 &mut self.author_pool,
1158 &mut self.author_index,
1159 ));
1160 self.current_commit_idx =
1161 sha.map(|sha| intern_author(sha, &mut self.commit_pool, &mut self.commit_index));
1162 true
1163 }
1164
1165 fn record_legacy_timestamp(&mut self, line: &str) -> bool {
1166 let Ok(ts) = line.parse::<u64>() else {
1167 return false;
1168 };
1169
1170 self.current_timestamp = Some(ts);
1171 self.current_committed_at = Some(ts);
1172 self.current_author_idx = None;
1173 self.current_commit_idx = None;
1174 true
1175 }
1176
1177 fn record_numstat_bytes(&mut self, record: &[u8]) {
1178 let Some(first_tab) = record.iter().position(|byte| *byte == b'\t') else {
1179 return;
1180 };
1181 let Some(second_tab_offset) = record[first_tab + 1..]
1182 .iter()
1183 .position(|byte| *byte == b'\t')
1184 else {
1185 return;
1186 };
1187 let second_tab = first_tab + 1 + second_tab_offset;
1188 let Some(added) = std::str::from_utf8(&record[..first_tab])
1189 .ok()
1190 .and_then(|value| value.parse().ok())
1191 else {
1192 return;
1193 };
1194 let Some(deleted) = std::str::from_utf8(&record[first_tab + 1..second_tab])
1195 .ok()
1196 .and_then(|value| value.parse().ok())
1197 else {
1198 return;
1199 };
1200 let path = git_path_from_bytes(&record[second_tab + 1..]);
1201 self.record_numstat_path(added, deleted, path);
1202 }
1203
1204 fn record_numstat_path(&mut self, added: u32, deleted: u32, path: PathBuf) {
1205 let ts = self.current_timestamp.unwrap_or(self.now_secs);
1206 self.files
1207 .entry(self.root.join(path))
1208 .or_insert_with(|| FileEvents { events: Vec::new() })
1209 .events
1210 .push(CachedCommitEvent {
1211 timestamp: ts,
1212 committed_at: self.current_committed_at.unwrap_or(ts),
1213 lines_added: added,
1214 lines_deleted: deleted,
1215 author_idx: self.current_author_idx,
1216 commit_idx: self.current_commit_idx,
1217 });
1218 }
1219
1220 fn finish(self) -> ChurnEventState {
1221 ChurnEventState {
1222 files: self.files,
1223 author_pool: self.author_pool,
1224 commit_pool: self.commit_pool,
1225 git_log_bytes: 0,
1226 }
1227 }
1228}
1229
1230fn is_commit_id(value: &str) -> bool {
1232 matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
1233}
1234
1235#[expect(
1238 clippy::cast_possible_truncation,
1239 reason = "commit count per file is bounded by git history depth"
1240)]
1241fn aggregate_file_churn(path: PathBuf, file: FileEvents, now_secs: u64) -> FileChurn {
1242 let mut timestamps = Vec::with_capacity(file.events.len());
1243 let mut weighted_commits = 0.0;
1244 let mut lines_added = 0_u32;
1245 let mut lines_deleted = 0_u32;
1246 let mut authors: FxHashMap<u32, AuthorContribution> = FxHashMap::default();
1247
1248 for event in file.events {
1249 timestamps.push(event.timestamp);
1250 let age_days = (now_secs.saturating_sub(event.timestamp)) as f64 / SECS_PER_DAY;
1251 let weight = 0.5_f64.powf(age_days / HALF_LIFE_DAYS);
1252 weighted_commits += weight;
1253 lines_added = lines_added.saturating_add(event.lines_added);
1254 lines_deleted = lines_deleted.saturating_add(event.lines_deleted);
1255 accumulate_author(&mut authors, event.author_idx, weight, event.timestamp);
1256 }
1257
1258 let commits = timestamps.len() as u32;
1259 let trend = compute_trend(×tamps);
1260 for c in authors.values_mut() {
1261 c.weighted_commits = (c.weighted_commits * 100.0).round() / 100.0;
1262 }
1263 FileChurn {
1264 path,
1265 commits,
1266 weighted_commits: (weighted_commits * 100.0).round() / 100.0,
1267 lines_added,
1268 lines_deleted,
1269 trend,
1270 authors,
1271 }
1272}
1273
1274fn accumulate_author(
1276 authors: &mut FxHashMap<u32, AuthorContribution>,
1277 author_idx: Option<u32>,
1278 weight: f64,
1279 timestamp: u64,
1280) {
1281 let Some(idx) = author_idx else {
1282 return;
1283 };
1284 authors
1285 .entry(idx)
1286 .and_modify(|c| {
1287 c.commits += 1;
1288 c.weighted_commits += weight;
1289 c.first_commit_ts = c.first_commit_ts.min(timestamp);
1290 c.last_commit_ts = c.last_commit_ts.max(timestamp);
1291 })
1292 .or_insert(AuthorContribution {
1293 commits: 1,
1294 weighted_commits: weight,
1295 first_commit_ts: timestamp,
1296 last_commit_ts: timestamp,
1297 });
1298}
1299
1300fn build_churn_result(
1305 state: ChurnEventState,
1306 shallow_clone: bool,
1307 clock: crate::clock::AnalysisClock,
1308) -> ChurnResult {
1309 build_windowed_churn_result(state, None, shallow_clone, clock)
1310}
1311
1312fn build_windowed_churn_result(
1315 mut state: ChurnEventState,
1316 files_cutoff: Option<u64>,
1317 shallow_clone: bool,
1318 clock: crate::clock::AnalysisClock,
1319) -> ChurnResult {
1320 let commits = collect_churn_commits(&state);
1321 state.retain_window(files_cutoff);
1322 let now_secs = clock.epoch_secs();
1323
1324 let files = state
1325 .files
1326 .into_iter()
1327 .map(|(path, file)| {
1328 let churn = aggregate_file_churn(path.clone(), file, now_secs);
1329 (path, churn)
1330 })
1331 .collect();
1332
1333 ChurnResult {
1334 files,
1335 shallow_clone,
1336 author_pool: state.author_pool,
1337 clock,
1338 git_log_bytes: state.git_log_bytes,
1339 commits,
1340 }
1341}
1342
1343fn collect_churn_commits(state: &ChurnEventState) -> Vec<ChurnCommit> {
1346 let mut by_idx: FxHashMap<u32, ChurnCommit> = FxHashMap::default();
1347 for (path, file) in &state.files {
1348 for event in &file.events {
1349 let Some(idx) = event.commit_idx else {
1350 continue;
1351 };
1352 let Some(sha) = state.commit_pool.get(idx as usize) else {
1353 continue;
1354 };
1355 by_idx
1356 .entry(idx)
1357 .or_insert_with(|| ChurnCommit {
1358 sha: sha.clone(),
1359 committed_at: event.committed_at,
1360 files: Vec::new(),
1361 })
1362 .files
1363 .push(ChurnCommitFile {
1364 path: path.clone(),
1365 lines_changed: event.lines_added.saturating_add(event.lines_deleted),
1366 });
1367 }
1368 }
1369 let mut commits: Vec<ChurnCommit> = by_idx
1370 .into_values()
1371 .map(|mut commit| {
1372 commit.files.sort_by(|a, b| a.path.cmp(&b.path));
1373 commit.files.dedup_by(|later, kept| {
1374 if later.path != kept.path {
1375 return false;
1376 }
1377 kept.lines_changed = kept.lines_changed.saturating_add(later.lines_changed);
1378 true
1379 });
1380 commit
1381 })
1382 .collect();
1383 commits.sort_by(|a, b| {
1384 a.committed_at
1385 .cmp(&b.committed_at)
1386 .then_with(|| a.sha.cmp(&b.sha))
1387 });
1388 commits
1389}
1390
1391fn intern_author(email: &str, pool: &mut Vec<String>, index: &mut FxHashMap<String, u32>) -> u32 {
1393 if let Some(&idx) = index.get(email) {
1394 return idx;
1395 }
1396 #[expect(
1397 clippy::cast_possible_truncation,
1398 reason = "author count is bounded by git history; u32 is far above any realistic ceiling"
1399 )]
1400 let idx = pool.len() as u32;
1401 let owned = email.to_string();
1402 index.insert(owned.clone(), idx);
1403 pool.push(owned);
1404 idx
1405}
1406
1407fn compute_trend(timestamps: &[u64]) -> ChurnTrend {
1415 if timestamps.len() < 2 {
1416 return ChurnTrend::Stable;
1417 }
1418
1419 let min_ts = timestamps.iter().copied().min().unwrap_or(0);
1420 let max_ts = timestamps.iter().copied().max().unwrap_or(0);
1421
1422 if max_ts == min_ts {
1423 return ChurnTrend::Stable;
1424 }
1425
1426 let midpoint = min_ts + (max_ts - min_ts) / 2;
1427 let recent = timestamps.iter().filter(|&&ts| ts > midpoint).count() as f64;
1428 let older = timestamps.iter().filter(|&&ts| ts <= midpoint).count() as f64;
1429
1430 if older < 1.0 {
1431 return ChurnTrend::Stable;
1432 }
1433
1434 let ratio = recent / older;
1435 if ratio > 1.5 {
1436 ChurnTrend::Accelerating
1437 } else if ratio < 0.67 {
1438 ChurnTrend::Cooling
1439 } else {
1440 ChurnTrend::Stable
1441 }
1442}
1443
1444fn is_iso_date(input: &str) -> bool {
1445 input.len() == 10
1446 && input.as_bytes().get(4) == Some(&b'-')
1447 && input.as_bytes().get(7) == Some(&b'-')
1448 && input[..4].bytes().all(|b| b.is_ascii_digit())
1449 && input[5..7].bytes().all(|b| b.is_ascii_digit())
1450 && input[8..10].bytes().all(|b| b.is_ascii_digit())
1451}
1452
1453fn split_number_unit(input: &str) -> Result<(&str, &str), String> {
1454 let pos = input.find(|c: char| !c.is_ascii_digit()).ok_or_else(|| {
1455 format!("--since requires a unit suffix (e.g., 6m, 90d, 1y), got: {input}")
1456 })?;
1457 if pos == 0 {
1458 return Err(format!(
1459 "--since must start with a number (e.g., 6m, 90d, 1y), got: {input}"
1460 ));
1461 }
1462 Ok((&input[..pos], &input[pos..]))
1463}
1464
1465#[cfg(test)]
1466mod tests {
1467 use super::*;
1468
1469 #[test]
1470 fn parse_since_months_short() {
1471 let d = parse_since("6m").unwrap();
1472 assert_eq!(
1473 d.window,
1474 ChurnWindow::Relative {
1475 count: 6,
1476 unit: ChurnWindowUnit::Months
1477 }
1478 );
1479 assert_eq!(d.display, "6 months");
1480 }
1481
1482 #[test]
1483 fn parse_since_months_long() {
1484 let d = parse_since("6months").unwrap();
1485 assert_eq!(
1486 d.window,
1487 ChurnWindow::Relative {
1488 count: 6,
1489 unit: ChurnWindowUnit::Months
1490 }
1491 );
1492 assert_eq!(d.display, "6 months");
1493 }
1494
1495 #[test]
1496 fn parse_since_days() {
1497 let d = parse_since("90d").unwrap();
1498 assert_eq!(
1499 d.window,
1500 ChurnWindow::Relative {
1501 count: 90,
1502 unit: ChurnWindowUnit::Days
1503 }
1504 );
1505 assert_eq!(d.display, "90 days");
1506 }
1507
1508 #[test]
1509 fn parse_since_year_singular() {
1510 let d = parse_since("1y").unwrap();
1511 assert_eq!(
1512 d.window,
1513 ChurnWindow::Relative {
1514 count: 1,
1515 unit: ChurnWindowUnit::Years
1516 }
1517 );
1518 assert_eq!(d.display, "1 year");
1519 }
1520
1521 #[test]
1522 fn parse_since_years_plural() {
1523 let d = parse_since("2years").unwrap();
1524 assert_eq!(
1525 d.window,
1526 ChurnWindow::Relative {
1527 count: 2,
1528 unit: ChurnWindowUnit::Years
1529 }
1530 );
1531 assert_eq!(d.display, "2 years");
1532 }
1533
1534 #[test]
1535 fn parse_since_weeks() {
1536 let d = parse_since("2w").unwrap();
1537 assert_eq!(
1538 d.window,
1539 ChurnWindow::Relative {
1540 count: 2,
1541 unit: ChurnWindowUnit::Weeks
1542 }
1543 );
1544 assert_eq!(d.display, "2 weeks");
1545 }
1546
1547 #[test]
1548 fn parse_since_iso_date() {
1549 let d = parse_since("2025-06-01").unwrap();
1550 assert_eq!(d.window, ChurnWindow::Date("2025-06-01".to_string()));
1551 assert_eq!(d.display, "2025-06-01");
1552 }
1553
1554 #[test]
1555 fn parse_since_month_singular() {
1556 let d = parse_since("1month").unwrap();
1557 assert_eq!(d.display, "1 month");
1558 }
1559
1560 #[test]
1561 fn parse_since_day_singular() {
1562 let d = parse_since("1day").unwrap();
1563 assert_eq!(d.display, "1 day");
1564 }
1565
1566 #[test]
1567 fn parse_since_zero_rejected() {
1568 assert!(parse_since("0m").is_err());
1569 }
1570
1571 #[test]
1572 fn parse_since_no_unit_rejected() {
1573 assert!(parse_since("90").is_err());
1574 }
1575
1576 #[test]
1577 fn parse_since_unknown_unit_rejected() {
1578 assert!(parse_since("6x").is_err());
1579 }
1580
1581 #[test]
1582 fn parse_since_no_number_rejected() {
1583 assert!(parse_since("months").is_err());
1584 }
1585
1586 #[test]
1587 fn trend_empty_is_stable() {
1588 assert_eq!(compute_trend(&[]), ChurnTrend::Stable);
1589 }
1590
1591 #[test]
1592 fn trend_single_commit_is_stable() {
1593 assert_eq!(compute_trend(&[100]), ChurnTrend::Stable);
1594 }
1595
1596 #[test]
1597 fn trend_accelerating() {
1598 let timestamps = vec![100, 200, 800, 850, 900, 950, 1000];
1599 assert_eq!(compute_trend(×tamps), ChurnTrend::Accelerating);
1600 }
1601
1602 #[test]
1603 fn trend_cooling() {
1604 let timestamps = vec![100, 150, 200, 250, 300, 900, 1000];
1605 assert_eq!(compute_trend(×tamps), ChurnTrend::Cooling);
1606 }
1607
1608 #[test]
1609 fn trend_stable_even_distribution() {
1610 let timestamps = vec![100, 200, 300, 700, 800, 900];
1611 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1612 }
1613
1614 #[test]
1615 fn trend_same_timestamp_is_stable() {
1616 let timestamps = vec![500, 500, 500];
1617 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1618 }
1619
1620 #[test]
1621 fn iso_date_valid() {
1622 assert!(is_iso_date("2025-06-01"));
1623 assert!(is_iso_date("2025-12-31"));
1624 }
1625
1626 #[test]
1627 fn iso_date_with_time_rejected() {
1628 assert!(!is_iso_date("2025-06-01T00:00:00"));
1629 }
1630
1631 #[test]
1632 fn iso_date_invalid() {
1633 assert!(!is_iso_date("6months"));
1634 assert!(!is_iso_date("2025"));
1635 assert!(!is_iso_date("not-a-date"));
1636 assert!(!is_iso_date("abcd-ef-gh"));
1637 }
1638
1639 #[test]
1640 fn trend_display() {
1641 assert_eq!(ChurnTrend::Accelerating.to_string(), "accelerating");
1642 assert_eq!(ChurnTrend::Stable.to_string(), "stable");
1643 assert_eq!(ChurnTrend::Cooling.to_string(), "cooling");
1644 }
1645
1646 #[test]
1647 fn trend_boundary_1_5x_ratio() {
1648 let timestamps = vec![100, 200, 600, 800, 1000];
1649 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1650 }
1651
1652 #[test]
1653 fn trend_just_above_1_5x() {
1654 let timestamps = vec![100, 600, 800, 1000];
1655 assert_eq!(compute_trend(×tamps), ChurnTrend::Accelerating);
1656 }
1657
1658 #[test]
1659 fn trend_boundary_0_67x_ratio() {
1660 let timestamps = vec![100, 200, 300, 600, 1000];
1661 assert_eq!(compute_trend(×tamps), ChurnTrend::Cooling);
1662 }
1663
1664 #[test]
1665 fn trend_two_timestamps_different() {
1666 let timestamps = vec![100, 200];
1667 assert_eq!(compute_trend(×tamps), ChurnTrend::Stable);
1668 }
1669
1670 #[test]
1671 fn parse_since_week_singular() {
1672 let d = parse_since("1week").unwrap();
1673 assert_eq!(
1674 d.window,
1675 ChurnWindow::Relative {
1676 count: 1,
1677 unit: ChurnWindowUnit::Weeks
1678 }
1679 );
1680 assert_eq!(d.display, "1 week");
1681 }
1682
1683 #[test]
1684 fn parse_since_weeks_long() {
1685 let d = parse_since("3weeks").unwrap();
1686 assert_eq!(
1687 d.window,
1688 ChurnWindow::Relative {
1689 count: 3,
1690 unit: ChurnWindowUnit::Weeks
1691 }
1692 );
1693 assert_eq!(d.display, "3 weeks");
1694 }
1695
1696 #[test]
1697 fn parse_since_days_long() {
1698 let d = parse_since("30days").unwrap();
1699 assert_eq!(
1700 d.window,
1701 ChurnWindow::Relative {
1702 count: 30,
1703 unit: ChurnWindowUnit::Days
1704 }
1705 );
1706 assert_eq!(d.display, "30 days");
1707 }
1708
1709 #[test]
1710 fn parse_since_year_long() {
1711 let d = parse_since("1year").unwrap();
1712 assert_eq!(
1713 d.window,
1714 ChurnWindow::Relative {
1715 count: 1,
1716 unit: ChurnWindowUnit::Years
1717 }
1718 );
1719 assert_eq!(d.display, "1 year");
1720 }
1721
1722 #[test]
1723 fn parse_since_overflow_number_rejected() {
1724 let result = parse_since("99999999999999999999d");
1725 assert!(result.is_err());
1726 let err = result.unwrap_err();
1727 assert!(err.contains("invalid number"));
1728 }
1729
1730 #[test]
1731 fn parse_since_zero_days_rejected() {
1732 assert!(parse_since("0d").is_err());
1733 }
1734
1735 #[test]
1736 fn parse_since_zero_weeks_rejected() {
1737 assert!(parse_since("0w").is_err());
1738 }
1739
1740 #[test]
1741 fn parse_since_zero_years_rejected() {
1742 assert!(parse_since("0y").is_err());
1743 }
1744
1745 #[test]
1746 fn iso_date_wrong_separator_positions() {
1747 assert!(!is_iso_date("20-25-0601"));
1748 assert!(!is_iso_date("202506-01-"));
1749 }
1750
1751 #[test]
1752 fn iso_date_too_short() {
1753 assert!(!is_iso_date("2025-06-0"));
1754 }
1755
1756 #[test]
1757 fn iso_date_letters_in_day() {
1758 assert!(!is_iso_date("2025-06-ab"));
1759 }
1760
1761 #[test]
1762 fn iso_date_letters_in_month() {
1763 assert!(!is_iso_date("2025-ab-01"));
1764 }
1765
1766 #[test]
1767 fn split_number_unit_valid() {
1768 let (num, unit) = split_number_unit("42days").unwrap();
1769 assert_eq!(num, "42");
1770 assert_eq!(unit, "days");
1771 }
1772
1773 #[test]
1774 fn split_number_unit_single_digit() {
1775 let (num, unit) = split_number_unit("1m").unwrap();
1776 assert_eq!(num, "1");
1777 assert_eq!(unit, "m");
1778 }
1779
1780 #[test]
1781 fn split_number_unit_no_digits() {
1782 let err = split_number_unit("abc").unwrap_err();
1783 assert!(err.contains("must start with a number"));
1784 }
1785
1786 #[test]
1787 fn split_number_unit_no_unit() {
1788 let err = split_number_unit("123").unwrap_err();
1789 assert!(err.contains("requires a unit suffix"));
1790 }
1791
1792 #[test]
1793 fn trend_serde_serialization() {
1794 assert_eq!(
1795 serde_json::to_string(&ChurnTrend::Accelerating).unwrap(),
1796 "\"accelerating\""
1797 );
1798 assert_eq!(
1799 serde_json::to_string(&ChurnTrend::Stable).unwrap(),
1800 "\"stable\""
1801 );
1802 assert_eq!(
1803 serde_json::to_string(&ChurnTrend::Cooling).unwrap(),
1804 "\"cooling\""
1805 );
1806 }
1807
1808 #[test]
1809 fn intern_author_returns_existing_index() {
1810 let mut pool = Vec::new();
1811 let mut index = FxHashMap::default();
1812 let i1 = intern_author("alice@x", &mut pool, &mut index);
1813 let i2 = intern_author("alice@x", &mut pool, &mut index);
1814 assert_eq!(i1, i2);
1815 assert_eq!(pool.len(), 1);
1816 }
1817
1818 #[test]
1819 fn intern_author_assigns_sequential_indices() {
1820 let mut pool = Vec::new();
1821 let mut index = FxHashMap::default();
1822 assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1823 assert_eq!(intern_author("bob@x", &mut pool, &mut index), 1);
1824 assert_eq!(intern_author("carol@x", &mut pool, &mut index), 2);
1825 assert_eq!(intern_author("alice@x", &mut pool, &mut index), 0);
1826 }
1827
1828 fn git(root: &Path, args: &[&str]) {
1829 let status = super::git_command(root)
1830 .args(args)
1831 .current_dir(root)
1832 .status()
1833 .expect("run git");
1834 assert!(status.success(), "git {args:?} failed");
1835 }
1836
1837 fn write(root: &Path, path: &str, contents: &str) {
1838 let path = root.join(path);
1839 std::fs::create_dir_all(path.parent().expect("test path has parent")).unwrap();
1840 std::fs::write(path, contents).unwrap();
1841 }
1842
1843 #[cfg(unix)]
1844 #[test]
1845 fn churn_preserves_special_filenames() {
1846 let repo = tempfile::tempdir().expect("create repo");
1847 let root = repo.path();
1848 git(root, &["init", "--quiet"]);
1849 git(root, &["config", "user.email", "churn@example.test"]);
1850 git(root, &["config", "user.name", "Churn Test"]);
1851 git(root, &["config", "commit.gpgsign", "false"]);
1852
1853 let special_files = [
1854 "src/line\nbreak.ts",
1855 "src/space name.ts",
1856 "src/quote\"name.ts",
1857 "src/back\\slash.ts",
1858 "src/unicode-λ.ts",
1859 ]
1860 .map(|path| root.join(path));
1861 std::fs::create_dir_all(root.join("src")).expect("source dir");
1862 for special in &special_files {
1863 std::fs::write(special, "export const value = 1;\n").expect("special fixture");
1864 }
1865 git(root, &["add", "."]);
1866 git(root, &["commit", "--quiet", "-m", "initial"]);
1867
1868 let since = parse_since("1y").expect("valid duration");
1869 let churn = analyze_churn(root, &since).expect("churn result");
1870 for special in special_files {
1871 assert!(
1872 churn.files.contains_key(&special),
1873 "missing {special:?}: {:?}",
1874 churn.files.keys()
1875 );
1876 }
1877 }
1878
1879 #[cfg(unix)]
1880 #[test]
1881 fn churn_cache_preserves_non_utf8_filenames() {
1882 use std::ffi::OsString;
1883 use std::os::unix::ffi::OsStringExt;
1884
1885 let invalid_path = PathBuf::from(OsString::from_vec(b"src/non-utf8-\xff.ts".to_vec()));
1886 let mut files = FxHashMap::default();
1887 files.insert(
1888 invalid_path.clone(),
1889 FileEvents {
1890 events: vec![CachedCommitEvent {
1891 timestamp: 1,
1892 committed_at: 1,
1893 lines_added: 2,
1894 lines_deleted: 1,
1895 author_idx: None,
1896 commit_idx: None,
1897 }],
1898 },
1899 );
1900 let state = ChurnEventState {
1901 files,
1902 author_pool: Vec::new(),
1903 commit_pool: Vec::new(),
1904 git_log_bytes: 0,
1905 };
1906 let cache_dir = tempfile::tempdir().expect("cache directory");
1907 save_churn_cache(cache_dir.path(), "abc123", "1y", &state, false);
1908 let warm = load_churn_cache(cache_dir.path(), "1y")
1909 .expect("warm churn cache")
1910 .into_event_state(None);
1911
1912 assert!(warm.files.contains_key(&invalid_path));
1913 }
1914
1915 #[test]
1916 fn churn_cache_rejects_pre_lossless_path_encoding_version() {
1917 let cache_dir = tempfile::tempdir().expect("cache directory");
1918 let cache = ChurnCache {
1919 version: 4,
1920 last_indexed_sha: "abc123".to_string(),
1921 window_token: "1y".to_string(),
1922 files: vec![CachedFileChurn {
1923 path: br#"/project/\"src/line\\nbreak.ts\""#.to_vec(),
1924 events: Vec::new(),
1925 }],
1926 shallow_clone: false,
1927 author_pool: Vec::new(),
1928 commit_pool: Vec::new(),
1929 };
1930 std::fs::write(cache_dir.path().join("churn.bin"), bitcode::encode(&cache))
1931 .expect("cache fixture");
1932
1933 assert!(load_churn_cache(cache_dir.path(), "1y").is_none());
1934 }
1935
1936 #[test]
1937 fn churn_cache_rejects_entries_without_commit_ids() {
1938 let cache_dir = tempfile::tempdir().expect("cache directory");
1939 let cache = ChurnCache {
1940 version: 7,
1941 last_indexed_sha: "abc123".to_string(),
1942 window_token: "1y".to_string(),
1943 files: Vec::new(),
1944 shallow_clone: false,
1945 author_pool: Vec::new(),
1946 commit_pool: Vec::new(),
1947 };
1948 std::fs::write(cache_dir.path().join("churn.bin"), bitcode::encode(&cache))
1949 .expect("cache fixture");
1950
1951 assert!(load_churn_cache(cache_dir.path(), "1y").is_none());
1952 }
1953
1954 #[test]
1955 fn commit_header_with_sha_records_the_commit_id() {
1956 let sha = "0123456789abcdef0123456789abcdef01234567";
1957 let header = format!("1700000000|1700000001|{sha}|alice@corp");
1958 let state = parse_git_log_events_z(
1959 &git_log_z(&[(header.as_str(), &["1\t2\tsrc/a.ts", "3\t0\tsrc/b.ts"])]),
1960 Path::new("/project"),
1961 1_700_000_100,
1962 );
1963 let result = build_churn_result(
1964 state,
1965 false,
1966 crate::clock::AnalysisClock::pinned(1_700_000_100),
1967 );
1968
1969 assert_eq!(result.author_pool, vec!["alice@corp".to_string()]);
1970 assert_eq!(result.commits.len(), 1);
1971 let commit = &result.commits[0];
1972 assert_eq!(commit.sha, sha);
1973 assert_eq!(commit.committed_at, 1_700_000_001);
1974 let files: Vec<(PathBuf, u32)> = commit
1975 .files
1976 .iter()
1977 .map(|file| (file.path.clone(), file.lines_changed))
1978 .collect();
1979 assert_eq!(
1980 files,
1981 vec![
1982 (PathBuf::from("/project/src/a.ts"), 3),
1983 (PathBuf::from("/project/src/b.ts"), 3),
1984 ]
1985 );
1986 }
1987
1988 #[test]
1989 fn commit_header_without_sha_keeps_the_email_and_no_commit() {
1990 let state = parse_git_log_events_z(
1991 &git_log_z(&[("1700000000|1700000000|alice@corp", &["1\t2\tsrc/a.ts"])]),
1992 Path::new("/project"),
1993 1_700_000_100,
1994 );
1995 let result = build_churn_result(
1996 state,
1997 false,
1998 crate::clock::AnalysisClock::pinned(1_700_000_100),
1999 );
2000
2001 assert_eq!(result.author_pool, vec!["alice@corp".to_string()]);
2002 assert!(result.commits.is_empty());
2003 assert_eq!(result.files.len(), 1);
2004 }
2005
2006 #[test]
2007 fn churn_file_commit_field_groups_events_into_commits() {
2008 let dir = tempfile::tempdir().unwrap();
2009 let path = write_churn_file(
2010 dir.path(),
2011 r#"{
2012 "schema": "fallow-churn/v1",
2013 "events": [
2014 { "commit": "c1", "path": "src/a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 },
2015 { "commit": "c1", "path": "src/b.ts", "timestamp": 1700000000, "added": 2, "deleted": 1 },
2016 { "commit": "c2", "path": "src/a.ts", "timestamp": 1700100000, "added": 4, "deleted": 0 },
2017 { "path": "src/c.ts", "timestamp": 1700100000, "added": 1, "deleted": 0 }
2018 ]
2019 }"#,
2020 );
2021 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2022
2023 let commits: Vec<(String, usize)> = result
2024 .commits
2025 .iter()
2026 .map(|commit| (commit.sha.clone(), commit.files.len()))
2027 .collect();
2028 assert_eq!(commits, vec![("c1".to_string(), 2), ("c2".to_string(), 1)]);
2029 assert_eq!(
2030 result.files.len(),
2031 3,
2032 "an event without commit still counts"
2033 );
2034 }
2035
2036 #[test]
2037 fn cached_churn_merges_new_commits_after_head_advances() {
2038 let repo = tempfile::tempdir().expect("create repo");
2039 let root = repo.path();
2040 git(root, &["init"]);
2041 git(root, &["config", "user.email", "churn@example.test"]);
2042 git(root, &["config", "user.name", "Churn Test"]);
2043 git(root, &["config", "commit.gpgsign", "false"]);
2044
2045 write(root, "src/a.ts", "export const a = 1;\n");
2046 git(root, &["add", "."]);
2047 git(root, &["commit", "-m", "initial"]);
2048
2049 let since = parse_since("1y").unwrap();
2050 let cache = tempfile::tempdir().expect("create cache dir");
2051 let (cold, cold_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
2052 assert!(!cold_hit);
2053 let file = root.join("src/a.ts");
2054 assert_eq!(cold.files[&file].commits, 1);
2055
2056 let (_warm, warm_hit) = analyze_churn_cached(root, &since, cache.path(), false).unwrap();
2057 assert!(warm_hit);
2058
2059 write(
2060 root,
2061 "src/a.ts",
2062 "export const a = 1;\nexport const b = 2;\n",
2063 );
2064 git(root, &["add", "."]);
2065 git(root, &["commit", "-m", "update a"]);
2066 let head = get_head_sha(root).unwrap();
2067
2068 let (incremental, incremental_hit) =
2069 analyze_churn_cached(root, &since, cache.path(), false).unwrap();
2070 assert!(incremental_hit);
2071 assert_eq!(incremental.files[&file].commits, 2);
2072
2073 let cache = load_churn_cache(cache.path(), &since.window.cache_token()).unwrap();
2074 assert_eq!(cache.last_indexed_sha, head);
2075 }
2076
2077 fn write_churn_file(dir: &std::path::Path, contents: &str) -> PathBuf {
2078 let path = dir.join("churn.json");
2079 std::fs::write(&path, contents).unwrap();
2080 path
2081 }
2082
2083 #[test]
2084 fn churn_file_happy_path() {
2085 let dir = tempfile::tempdir().unwrap();
2086 let root = Path::new("/project");
2087 let path = write_churn_file(
2088 dir.path(),
2089 r#"{
2090 "schema": "fallow-churn/v1",
2091 "events": [
2092 { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 10, "deleted": 5 },
2093 { "path": "src/a.ts", "timestamp": 1700100000, "author": "bob@corp", "added": 3, "deleted": 2 }
2094 ]
2095 }"#,
2096 );
2097 let result = analyze_churn_from_file(&path, root).unwrap();
2098 let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
2099 assert_eq!(churn.commits, 2);
2100 assert_eq!(churn.lines_added, 13);
2101 assert_eq!(churn.lines_deleted, 7);
2102 assert_eq!(churn.authors.len(), 2);
2103 assert!(result.author_pool.contains(&"alice@corp".to_string()));
2104 assert!(result.author_pool.contains(&"bob@corp".to_string()));
2105 assert!(!result.shallow_clone);
2106 }
2107
2108 #[test]
2109 fn churn_file_empty_events_is_valid() {
2110 let dir = tempfile::tempdir().unwrap();
2111 let path = write_churn_file(
2112 dir.path(),
2113 r#"{ "schema": "fallow-churn/v1", "events": [] }"#,
2114 );
2115 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2116 assert!(result.files.is_empty());
2117 assert!(result.author_pool.is_empty());
2118 }
2119
2120 #[test]
2121 fn churn_file_missing_events_key_is_valid() {
2122 let dir = tempfile::tempdir().unwrap();
2123 let path = write_churn_file(dir.path(), r#"{ "schema": "fallow-churn/v1" }"#);
2124 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2125 assert!(result.files.is_empty());
2126 }
2127
2128 #[test]
2129 fn churn_file_bad_schema_rejected() {
2130 let dir = tempfile::tempdir().unwrap();
2131 let path = write_churn_file(
2132 dir.path(),
2133 r#"{ "schema": "fallow-churn/v2", "events": [] }"#,
2134 );
2135 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
2136 assert!(err.contains("expected \"fallow-churn/v1\""), "{err}");
2137 }
2138
2139 #[test]
2140 fn churn_file_malformed_json_rejected() {
2141 let dir = tempfile::tempdir().unwrap();
2142 let path = write_churn_file(dir.path(), "{ not json");
2143 assert!(analyze_churn_from_file(&path, Path::new("/project")).is_err());
2144 }
2145
2146 #[test]
2147 fn churn_file_missing_file_rejected() {
2148 let err = analyze_churn_from_file(Path::new("/no/such/churn.json"), Path::new("/project"))
2149 .unwrap_err();
2150 assert!(err.contains("failed to read churn file"), "{err}");
2151 }
2152
2153 #[test]
2154 fn churn_file_reader_accepts_exact_limit() {
2155 let dir = tempfile::tempdir().unwrap();
2156 let path = write_churn_file(dir.path(), "12345678");
2157 assert_eq!(read_churn_file_with_limit(&path, 8).unwrap(), "12345678");
2158 }
2159
2160 #[test]
2161 fn churn_file_reader_rejects_limit_plus_one() {
2162 let dir = tempfile::tempdir().unwrap();
2163 let path = write_churn_file(dir.path(), "123456789");
2164 let err = read_churn_file_with_limit(&path, 8).unwrap_err();
2165 assert!(err.contains("at least 9 bytes"), "{err}");
2166 assert!(err.contains("8 byte limit"), "{err}");
2167 }
2168
2169 #[test]
2170 fn churn_file_empty_path_rejected() {
2171 let dir = tempfile::tempdir().unwrap();
2172 let path = write_churn_file(
2173 dir.path(),
2174 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": " ", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
2175 );
2176 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
2177 assert!(err.contains("empty path"), "{err}");
2178 }
2179
2180 #[test]
2181 fn churn_file_rejects_non_relative_paths() {
2182 let invalid = [
2183 "/tmp/a.ts",
2184 r"C:\tmp\a.ts",
2185 "../a.ts",
2186 "src/../../a.ts",
2187 "./src/a.ts",
2188 "//server/share/a.ts",
2189 ];
2190 for event_path in invalid {
2191 let dir = tempfile::tempdir().unwrap();
2192 let body = format!(
2193 r#"{{ "schema": "fallow-churn/v1", "events": [ {{ "path": {event_path:?}, "timestamp": 1700000000, "added": 1, "deleted": 0 }} ] }}"#
2194 );
2195 let path = write_churn_file(dir.path(), &body);
2196 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
2197 assert!(err.contains(event_path), "{event_path}: {err}");
2198 assert!(err.contains("repo-relative"), "{event_path}: {err}");
2199 }
2200 }
2201
2202 #[test]
2203 fn churn_file_accepts_unicode_and_spaces_in_path_components() {
2204 let dir = tempfile::tempdir().unwrap();
2205 let path = write_churn_file(
2206 dir.path(),
2207 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/ruimte map/naïef.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
2208 );
2209 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2210 assert!(
2211 result
2212 .files
2213 .contains_key(&PathBuf::from("/project/src/ruimte map/naïef.ts"))
2214 );
2215 }
2216
2217 #[test]
2218 fn churn_file_millisecond_timestamp_rejected() {
2219 let dir = tempfile::tempdir().unwrap();
2220 let path = write_churn_file(
2222 dir.path(),
2223 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000000, "added": 1, "deleted": 0 } ] }"#,
2224 );
2225 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
2226 assert!(err.contains("milliseconds"), "{err}");
2227 }
2228
2229 #[test]
2230 fn churn_file_missing_author_contributes_no_signal() {
2231 let dir = tempfile::tempdir().unwrap();
2232 let path = write_churn_file(
2233 dir.path(),
2234 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
2235 );
2236 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2237 let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
2238 assert_eq!(churn.commits, 1);
2239 assert!(churn.authors.is_empty());
2240 assert!(result.author_pool.is_empty());
2241 }
2242
2243 #[test]
2244 fn churn_file_empty_author_string_treated_as_absent() {
2245 let dir = tempfile::tempdir().unwrap();
2246 let path = write_churn_file(
2247 dir.path(),
2248 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src/a.ts", "timestamp": 1700000000, "author": " ", "added": 1, "deleted": 0 } ] }"#,
2249 );
2250 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2251 assert!(result.author_pool.is_empty());
2252 }
2253
2254 #[test]
2255 fn churn_file_unknown_fields_ignored() {
2256 let dir = tempfile::tempdir().unwrap();
2259 let path = write_churn_file(
2260 dir.path(),
2261 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" } ] }"#,
2262 );
2263 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2264 assert_eq!(result.files[&PathBuf::from("/project/src/a.ts")].commits, 1);
2265 }
2266
2267 #[test]
2268 fn churn_file_backslash_paths_normalized() {
2269 let dir = tempfile::tempdir().unwrap();
2270 let path = write_churn_file(
2271 dir.path(),
2272 r#"{ "schema": "fallow-churn/v1", "events": [ { "path": "src\\a.ts", "timestamp": 1700000000, "added": 1, "deleted": 0 } ] }"#,
2273 );
2274 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2275 assert!(
2276 result
2277 .files
2278 .contains_key(&PathBuf::from("/project/src/a.ts"))
2279 );
2280 }
2281
2282 #[test]
2283 fn churn_file_rejects_line_totals_above_u32() {
2284 let dir = tempfile::tempdir().unwrap();
2285 let path = write_churn_file(
2286 dir.path(),
2287 r#"{ "schema": "fallow-churn/v1", "events": [
2288 { "path": "src/a.ts", "timestamp": 1700000000, "added": 4294967295, "deleted": 0 },
2289 { "path": "src/a.ts", "timestamp": 1700000001, "added": 1, "deleted": 0 }
2290 ] }"#,
2291 );
2292 let err = analyze_churn_from_file(&path, Path::new("/project")).unwrap_err();
2293 assert!(err.contains("exceeding the u32 limit"), "{err}");
2294 assert!(err.contains("src/a.ts"), "{err}");
2295 }
2296
2297 #[test]
2298 fn churn_file_accepts_line_totals_at_u32_max() {
2299 let dir = tempfile::tempdir().unwrap();
2300 let path = write_churn_file(
2301 dir.path(),
2302 r#"{ "schema": "fallow-churn/v1", "events": [
2303 { "path": "src/a.ts", "timestamp": 1700000000, "added": 4294967294, "deleted": 4294967295 },
2304 { "path": "src/a.ts", "timestamp": 1700000001, "added": 1, "deleted": 0 }
2305 ] }"#,
2306 );
2307 let result = analyze_churn_from_file(&path, Path::new("/project")).unwrap();
2308 let churn = &result.files[&PathBuf::from("/project/src/a.ts")];
2309 assert_eq!(churn.lines_added, u32::MAX);
2310 assert_eq!(churn.lines_deleted, u32::MAX);
2311 }
2312
2313 #[test]
2318 fn headerless_numstat_falls_back_to_the_run_clock() {
2319 let root = Path::new("/project");
2320 let pinned = 1_700_000_000;
2321
2322 let state = parse_git_log_events_z(b"10\t5\tsrc/a.ts", root, pinned);
2323
2324 let events = &state.files[&PathBuf::from("/project/src/a.ts")].events;
2325 assert_eq!(events.len(), 1);
2326 assert_eq!(events[0].timestamp, pinned);
2327 assert_eq!(events[0].committed_at, pinned);
2328 }
2329
2330 #[test]
2334 fn headerless_numstat_tracks_only_the_supplied_clock() {
2335 let root = Path::new("/project");
2336 let record: &[u8] = b"1\t0\tsrc/a.ts";
2337
2338 let early = parse_git_log_events_z(record, root, 1_600_000_000);
2339 let late = parse_git_log_events_z(record, root, 1_700_000_000);
2340
2341 let key = PathBuf::from("/project/src/a.ts");
2342 assert_eq!(early.files[&key].events[0].timestamp, 1_600_000_000);
2343 assert_eq!(late.files[&key].events[0].timestamp, 1_700_000_000);
2344 }
2345
2346 #[test]
2348 fn commit_header_timestamp_beats_the_run_clock_fallback() {
2349 let root = Path::new("/project");
2350 let record = b"1700000000|1700000500|dev@example.com\x002\t1\tsrc/a.ts";
2351
2352 let state = parse_git_log_events_z(record, root, 1_234_567_890);
2353
2354 let events = &state.files[&PathBuf::from("/project/src/a.ts")].events;
2355 assert_eq!(events[0].timestamp, 1_700_000_000);
2356 assert_eq!(events[0].committed_at, 1_700_000_500);
2357 }
2358
2359 const NOW: u64 = 1_750_000_000;
2361 const HEADER: &str = "1700000000|1700000000|dev@example.com";
2362
2363 fn git_log_z(commits: &[(&str, &[&str])]) -> Vec<u8> {
2367 let mut out = Vec::new();
2368 for (index, (header, rows)) in commits.iter().enumerate() {
2369 if index > 0 {
2370 out.push(0);
2371 }
2372 out.extend_from_slice(header.as_bytes());
2373 out.extend_from_slice(b"\0\n");
2374 for row in *rows {
2375 out.extend_from_slice(row.as_bytes());
2376 out.push(0);
2377 }
2378 }
2379 out
2380 }
2381
2382 fn churn_from_git_log_z(stdout: &[u8], root: &Path, now_secs: u64) -> ChurnResult {
2383 build_churn_result(
2384 parse_git_log_events_z(stdout, root, now_secs),
2385 false,
2386 crate::clock::AnalysisClock::pinned(now_secs),
2387 )
2388 }
2389
2390 fn churn_from_commits(commits: &[(&str, &[&str])]) -> ChurnResult {
2391 churn_from_git_log_z(&git_log_z(commits), Path::new("/project"), NOW)
2392 }
2393
2394 #[test]
2395 fn git_log_z_fixture_matches_real_git_output() {
2396 assert_eq!(
2397 git_log_z(&[
2398 ("1|2|a@b", &["1\t0\ta.ts"]),
2399 ("3|4|a@b", &["1\t0\ta.ts", "1\t0\tb c.ts"])
2400 ]),
2401 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()
2402 );
2403 }
2404
2405 #[test]
2406 fn numstat_records_parse_counts_and_skip_binary_or_malformed_rows() {
2407 let root = Path::new("/project");
2408 type Expected = Option<(u32, u32, &'static str)>;
2409 let cases: &[(&str, Expected)] = &[
2410 ("10\t5\tsrc/file.ts", Some((10, 5, "src/file.ts"))),
2411 ("0\t0\tsrc/empty.ts", Some((0, 0, "src/empty.ts"))),
2412 (
2413 "3\t1\tpath with spaces/file.ts",
2414 Some((3, 1, "path with spaces/file.ts")),
2415 ),
2416 ("1\t0\tweird\tname.ts", Some((1, 0, "weird\tname.ts"))),
2417 (
2418 "4294967295\t8888\tsrc/big.ts",
2419 Some((u32::MAX, 8888, "src/big.ts")),
2420 ),
2421 ("4294967296\t0\tsrc/overflow.ts", None),
2422 ("-\t-\tsrc/image.png", None),
2423 ("-\t5\tsrc/file.ts", None),
2424 ("10\t-\tsrc/file.ts", None),
2425 ("10\t5", None),
2426 ];
2427 for (row, expected) in cases {
2428 let state = parse_git_log_events_z(&git_log_z(&[(HEADER, &[row])]), root, NOW);
2429 let Some((added, deleted, path)) = expected else {
2430 assert!(state.files.is_empty(), "{row:?} must not record churn");
2431 continue;
2432 };
2433 assert_eq!(state.files.len(), 1, "{row:?}");
2434 let events = &state.files[&root.join(path)].events;
2435 assert_eq!(events.len(), 1, "{row:?}");
2436 assert_eq!(events[0].lines_added, *added, "{row:?}");
2437 assert_eq!(events[0].lines_deleted, *deleted, "{row:?}");
2438 }
2439 }
2440
2441 #[test]
2442 fn git_log_z_aggregates_commits_and_lines_per_file() {
2443 let result = churn_from_commits(&[
2444 (
2445 HEADER,
2446 &["10\t5\tsrc/a.ts", "3\t1\tsrc/b.ts", "-\t-\timage.png"],
2447 ),
2448 ("1700100000|1700100000|dev@example.com", &["3\t2\tsrc/a.ts"]),
2449 ]);
2450 assert_eq!(result.files.len(), 2);
2451 let a = &result.files[&PathBuf::from("/project/src/a.ts")];
2452 assert_eq!((a.commits, a.lines_added, a.lines_deleted), (2, 13, 7));
2453 let b = &result.files[&PathBuf::from("/project/src/b.ts")];
2454 assert_eq!((b.commits, b.lines_added, b.lines_deleted), (1, 3, 1));
2455 assert!(
2456 !result
2457 .files
2458 .contains_key(&PathBuf::from("/project/image.png"))
2459 );
2460 }
2461
2462 #[test]
2463 fn git_log_z_empty_or_blank_output_records_nothing() {
2464 assert!(
2465 churn_from_git_log_z(b"", Path::new("/project"), NOW)
2466 .files
2467 .is_empty()
2468 );
2469 assert!(
2470 churn_from_git_log_z(b"\0\0\n\0 \0", Path::new("/project"), NOW)
2471 .files
2472 .is_empty()
2473 );
2474 }
2475
2476 #[test]
2477 fn git_log_z_blank_records_between_commits_are_ignored() {
2478 let stdout = b" \0\n1700000000|1700000000|dev@example.com\0\n \0\n10\t5\tsrc/a.ts\0\0";
2479 let result = churn_from_git_log_z(stdout, Path::new("/project"), NOW);
2480 assert_eq!(result.files.len(), 1);
2481 assert_eq!(result.files[&PathBuf::from("/project/src/a.ts")].commits, 1);
2482 }
2483
2484 #[test]
2485 fn git_log_z_paths_are_joined_to_the_root() {
2486 let root = Path::new("/my/project");
2487 let result =
2488 churn_from_git_log_z(&git_log_z(&[(HEADER, &["1\t0\tlib/utils.ts"])]), root, NOW);
2489 let key = PathBuf::from("/my/project/lib/utils.ts");
2490 assert_eq!(result.files[&key].path, key);
2491 }
2492
2493 #[test]
2494 fn git_log_z_weights_commits_by_age_against_the_run_clock() {
2495 let day = 86_400;
2496 let now = format!("{NOW}|{NOW}|dev@example.com");
2497 let half_life = format!("{0}|{0}|dev@example.com", NOW - 45 * day);
2498 let two_half_lives = format!("{0}|{0}|dev@example.com", NOW - 180 * day);
2499 let result = churn_from_commits(&[
2500 (&now, &["1\t0\tsrc/fresh.ts"]),
2501 (&half_life, &["1\t0\tsrc/half.ts"]),
2502 (&two_half_lives, &["1\t0\tsrc/old.ts"]),
2503 ]);
2504 let weight = |name: &str| {
2505 result.files[&PathBuf::from(format!("/project/src/{name}"))].weighted_commits
2506 };
2507 assert!((weight("fresh.ts") - 1.0).abs() < f64::EPSILON);
2508 assert!((weight("half.ts") - 0.71).abs() < f64::EPSILON);
2510 assert!((weight("old.ts") - 0.25).abs() < f64::EPSILON);
2511 }
2512
2513 #[test]
2514 fn git_log_z_headerless_numstat_weighs_as_a_commit_at_the_run_clock() {
2515 let result = churn_from_git_log_z(b"10\t5\tsrc/no_ts.ts\0", Path::new("/project"), NOW);
2516 let churn = &result.files[&PathBuf::from("/project/src/no_ts.ts")];
2517 assert_eq!(
2518 (churn.commits, churn.lines_added, churn.lines_deleted),
2519 (1, 10, 5)
2520 );
2521 assert!((churn.weighted_commits - 1.0).abs() < f64::EPSILON);
2522 }
2523
2524 #[test]
2525 fn git_log_z_trend_is_computed_per_file() {
2526 let result = churn_from_commits(&[
2527 (
2528 "1000|1000|dev@example.com",
2529 &["5\t1\tsrc/old.ts", "1\t0\tsrc/hot.ts"],
2530 ),
2531 ("1800|1800|dev@example.com", &["1\t0\tsrc/hot.ts"]),
2532 ("1900|1900|dev@example.com", &["1\t0\tsrc/hot.ts"]),
2533 ("1950|1950|dev@example.com", &["1\t0\tsrc/hot.ts"]),
2534 (
2535 "2000|2000|dev@example.com",
2536 &["3\t1\tsrc/old.ts", "1\t0\tsrc/hot.ts"],
2537 ),
2538 ]);
2539 let old = &result.files[&PathBuf::from("/project/src/old.ts")];
2540 let hot = &result.files[&PathBuf::from("/project/src/hot.ts")];
2541 assert_eq!(old.commits, 2);
2542 assert_eq!(old.trend, ChurnTrend::Stable);
2543 assert_eq!(hot.commits, 5);
2544 assert_eq!(hot.trend, ChurnTrend::Accelerating);
2545 }
2546
2547 #[test]
2548 fn git_log_z_interns_authors_and_aggregates_per_author() {
2549 let result = churn_from_commits(&[
2550 (
2551 "1700000000|1700000000|alice@example.com",
2552 &["1\t0\tsrc/index.ts"],
2553 ),
2554 (
2555 "1700100000|1700100000|bob@example.com",
2556 &["2\t0\tsrc/index.ts"],
2557 ),
2558 (
2559 "1700200000|1700200000|alice@example.com",
2560 &["1\t1\tsrc/index.ts"],
2561 ),
2562 ]);
2563 assert_eq!(
2564 result.author_pool,
2565 vec![
2566 "alice@example.com".to_string(),
2567 "bob@example.com".to_string()
2568 ]
2569 );
2570 let churn = &result.files[&PathBuf::from("/project/src/index.ts")];
2571 assert_eq!(churn.commits, 3);
2572 assert_eq!(churn.authors.len(), 2);
2573 let alice = &churn.authors[&0];
2574 assert_eq!(alice.commits, 2);
2575 assert_eq!(alice.first_commit_ts, 1_700_000_000);
2576 assert_eq!(alice.last_commit_ts, 1_700_200_000);
2577 let bob = &churn.authors[&1];
2578 assert_eq!(bob.commits, 1);
2579 assert_eq!(bob.first_commit_ts, 1_700_100_000);
2580 }
2581
2582 #[test]
2583 fn git_log_z_legacy_headers_still_parse() {
2584 let result = churn_from_commits(&[
2585 ("1700000000", &["10\t5\tsrc/bare.ts"]),
2586 ("1700100000|alice@example.com", &["1\t0\tsrc/two_field.ts"]),
2587 ]);
2588 assert_eq!(result.author_pool, vec!["alice@example.com".to_string()]);
2589 let bare = &result.files[&PathBuf::from("/project/src/bare.ts")];
2590 assert_eq!(bare.commits, 1);
2591 assert!(bare.authors.is_empty());
2592 let two_field = &result.files[&PathBuf::from("/project/src/two_field.ts")];
2593 assert_eq!(two_field.authors[&0].first_commit_ts, 1_700_100_000);
2594 }
2595
2596 #[test]
2597 fn churn_file_matches_git_log_z_parse() {
2598 let dir = tempfile::tempdir().unwrap();
2602 let root = Path::new("/project");
2603 let path = write_churn_file(
2604 dir.path(),
2605 r#"{
2606 "schema": "fallow-churn/v1",
2607 "events": [
2608 { "path": "src/a.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 10, "deleted": 5 },
2609 { "path": "src/b.ts", "timestamp": 1700000000, "author": "alice@corp", "added": 3, "deleted": 1 },
2610 { "path": "src/a.ts", "timestamp": 1700100000, "author": "bob@corp", "added": 3, "deleted": 2 }
2611 ]
2612 }"#,
2613 );
2614 let imported = analyze_churn_from_file(&path, root).unwrap();
2615 let git = churn_from_git_log_z(
2616 &git_log_z(&[
2617 (
2618 "1700000000|1700000000|alice@corp",
2619 &["10\t5\tsrc/a.ts", "3\t1\tsrc/b.ts"],
2620 ),
2621 ("1700100000|1700100000|bob@corp", &["3\t2\tsrc/a.ts"]),
2622 ]),
2623 root,
2624 imported.clock.epoch_secs(),
2625 );
2626
2627 assert_eq!(
2628 git.author_pool, imported.author_pool,
2629 "author pools diverge"
2630 );
2631 assert_eq!(git.files.len(), imported.files.len());
2632 for (file, git_churn) in &git.files {
2633 let imp = &imported.files[file];
2634 assert_eq!(git_churn.commits, imp.commits, "commits for {file:?}");
2635 assert_eq!(git_churn.lines_added, imp.lines_added, "added for {file:?}");
2636 assert_eq!(
2637 git_churn.lines_deleted, imp.lines_deleted,
2638 "deleted for {file:?}"
2639 );
2640 assert_eq!(git_churn.trend, imp.trend, "trend for {file:?}");
2641 assert_eq!(git_churn.authors, imp.authors, "authors for {file:?}");
2642 assert!(
2643 (git_churn.weighted_commits - imp.weighted_commits).abs() < f64::EPSILON,
2644 "weighted_commits for {file:?}: {} vs {}",
2645 git_churn.weighted_commits,
2646 imp.weighted_commits
2647 );
2648 }
2649 }
2650}