1use anyhow::{Context, Result};
13use regex::Regex;
14use std::path::Path;
15use std::process::Command;
16use std::sync::OnceLock;
17
18static JIRA_RE: OnceLock<Regex> = OnceLock::new();
19static GITHUB_RE: OnceLock<Regex> = OnceLock::new();
20
21fn jira_re() -> &'static Regex {
22 JIRA_RE.get_or_init(|| Regex::new(r"([A-Z]+-\d+)").unwrap())
23}
24
25fn github_re() -> &'static Regex {
26 GITHUB_RE.get_or_init(|| Regex::new(r"(?:fixes|closes|fixed|closed)?\s*#(\d+)").unwrap())
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct GitContext {
32 pub head_sha: String,
33 pub parent_shas: Vec<String>,
34 pub timestamp: i64,
35 pub branch: Option<String>,
36 pub is_detached: bool,
37 pub message: Option<String>,
38 pub author: Option<String>,
39 pub is_fix_commit: Option<bool>,
40 pub is_revert_commit: Option<bool>,
41 pub ticket_ids: Vec<String>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct FileChurn {
47 pub file: String,
48 pub lines_added: usize,
49 pub lines_deleted: usize,
50}
51
52#[derive(Debug, Clone)]
57pub struct BatchedTouchMetrics {
58 pub touch_count_30d: std::collections::HashMap<String, usize>,
60 pub days_since_last_change: std::collections::HashMap<String, u32>,
62}
63
64const GIT_DISCOVERY_ENV_VARS: [&str; 3] = ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"];
73
74fn git(args: &[&str]) -> Result<String> {
76 let mut cmd = Command::new("git");
77 for var in GIT_DISCOVERY_ENV_VARS {
78 cmd.env_remove(var);
79 }
80 let output = cmd.args(args).output().context("failed to invoke git")?;
81
82 if !output.status.success() {
83 anyhow::bail!(
84 "git {:?} failed: {}",
85 args,
86 String::from_utf8_lossy(&output.stderr)
87 );
88 }
89
90 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
91}
92
93fn git_at(repo_path: &Path, args: &[&str]) -> Result<String> {
95 let mut cmd = Command::new("git");
96 for var in GIT_DISCOVERY_ENV_VARS {
97 cmd.env_remove(var);
98 }
99 let output = cmd
100 .current_dir(repo_path)
101 .args(args)
102 .output()
103 .context("failed to invoke git")?;
104
105 if !output.status.success() {
106 anyhow::bail!(
107 "git {:?} failed: {}",
108 args,
109 String::from_utf8_lossy(&output.stderr)
110 );
111 }
112
113 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
114}
115
116pub fn extract_git_context() -> Result<GitContext> {
132 if git(&["rev-parse", "--git-dir"]).is_err() {
135 anyhow::bail!("not in a git repository");
136 }
137
138 let head_sha = git(&["rev-parse", "HEAD"]).context("failed to extract HEAD SHA")?;
139
140 let parents_raw = git(&["rev-list", "--parents", "-n", "1", "HEAD"])
143 .context("failed to extract parent SHAs")?;
144
145 let mut parts = parents_raw.split_whitespace();
147 let _ = parts.next(); let parent_shas = parts.map(|s| s.to_string()).collect::<Vec<_>>();
149
150 if parent_shas.is_empty() && head_sha != "4b825dc642cb6eb9a060e54bf8d69288fbee4904" {
152 }
156
157 let timestamp = git(&["show", "-s", "--format=%ct", "HEAD"])
159 .context("failed to extract commit timestamp")?
160 .parse::<i64>()
161 .context("failed to parse commit timestamp")?;
162
163 let branch = git(&["symbolic-ref", "--short", "HEAD"]).ok();
165
166 let message = git(&["log", "-1", "--format=%B", "HEAD"]).ok();
168 let author = git(&["log", "-1", "--format=%an", "HEAD"]).ok();
169
170 let is_fix_commit = message.as_ref().map(|m| detect_fix_commit(m));
172 let is_revert_commit = message.as_ref().map(|m| detect_revert_commit(m));
173
174 let ticket_ids = message
176 .as_ref()
177 .map(|m| extract_ticket_ids(m, branch.as_deref()))
178 .unwrap_or_default();
179
180 Ok(GitContext {
181 head_sha,
182 parent_shas,
183 timestamp,
184 is_detached: branch.is_none(),
185 branch,
186 message,
187 author,
188 is_fix_commit,
189 is_revert_commit,
190 ticket_ids,
191 })
192}
193
194pub fn extract_git_context_at(repo_path: &Path) -> Result<GitContext> {
209 if git_at(repo_path, &["rev-parse", "--git-dir"]).is_err() {
211 anyhow::bail!("not in a git repository at {}", repo_path.display());
212 }
213
214 let head_sha =
215 git_at(repo_path, &["rev-parse", "HEAD"]).context("failed to extract HEAD SHA")?;
216
217 let parents_raw = git_at(repo_path, &["rev-list", "--parents", "-n", "1", "HEAD"])
219 .context("failed to extract parent SHAs")?;
220
221 let mut parts = parents_raw.split_whitespace();
223 let _ = parts.next(); let parent_shas = parts.map(|s| s.to_string()).collect::<Vec<_>>();
225
226 let timestamp = git_at(repo_path, &["show", "-s", "--format=%ct", "HEAD"])
228 .context("failed to extract commit timestamp")?
229 .parse::<i64>()
230 .context("failed to parse commit timestamp")?;
231
232 let branch = git_at(repo_path, &["symbolic-ref", "--short", "HEAD"]).ok();
234
235 let message = git_at(repo_path, &["log", "-1", "--format=%B", "HEAD"]).ok();
237 let author = git_at(repo_path, &["log", "-1", "--format=%an", "HEAD"]).ok();
238
239 let is_fix_commit = message.as_ref().map(|m| detect_fix_commit(m));
241 let is_revert_commit = message.as_ref().map(|m| detect_revert_commit(m));
242
243 let ticket_ids = message
245 .as_ref()
246 .map(|m| extract_ticket_ids(m, branch.as_deref()))
247 .unwrap_or_default();
248
249 Ok(GitContext {
250 head_sha,
251 parent_shas,
252 timestamp,
253 is_detached: branch.is_none(),
254 branch,
255 message,
256 author,
257 is_fix_commit,
258 is_revert_commit,
259 ticket_ids,
260 })
261}
262
263#[derive(Debug, Clone, PartialEq, Eq)]
265pub struct PrContext {
266 pub is_pr: bool,
267 pub merge_base: Option<String>,
268}
269
270pub fn detect_pr_context() -> PrContext {
276 let github_event_name = std::env::var("GITHUB_EVENT_NAME").ok();
278 let github_ref = std::env::var("GITHUB_REF").ok();
279
280 let is_pr = match (&github_event_name, &github_ref) {
282 (Some(event), Some(ref_name)) => {
283 event == "pull_request" || ref_name.starts_with("refs/pull/")
285 }
286 _ => false,
287 };
288
289 PrContext {
290 is_pr,
291 merge_base: None, }
293}
294
295pub fn resolve_merge_base(target_branch: &str) -> Result<Option<String>> {
308 match git(&["merge-base", "HEAD", target_branch]) {
311 Ok(sha) => Ok(Some(sha)),
312 Err(_) => {
313 Ok(None)
317 }
318 }
319}
320
321pub fn resolve_merge_base_auto() -> Option<String> {
327 let base_names = ["main", "master", "develop", "trunk"];
328
329 for base in &base_names {
330 if let Ok(Some(sha)) = resolve_merge_base(base) {
331 return Some(sha);
332 }
333 let remote = format!("origin/{base}");
336 if let Ok(Some(sha)) = resolve_merge_base(&remote) {
337 return Some(sha);
338 }
339 }
340
341 None
342}
343
344pub fn resolve_ref_to_sha(repo_root: &Path, git_ref: &str) -> Result<String> {
352 git_at(repo_root, &["rev-parse", "--verify", git_ref])
353 .with_context(|| format!("failed to resolve git ref '{git_ref}'"))
354}
355
356pub struct TempWorktree {
362 pub path: std::path::PathBuf,
364 repo_root: std::path::PathBuf,
366}
367
368impl Drop for TempWorktree {
369 fn drop(&mut self) {
370 let result = git_at(
371 &self.repo_root,
372 &[
373 "worktree",
374 "remove",
375 "--force",
376 &self.path.to_string_lossy(),
377 ],
378 );
379 if let Err(e) = result {
380 eprintln!(
381 "warning: failed to remove temporary worktree {}: {e}",
382 self.path.display()
383 );
384 }
385 }
386}
387
388pub fn create_worktree(repo_root: &Path, sha: &str) -> Result<TempWorktree> {
396 let dir = std::env::temp_dir().join(format!("hotspots-worktree-{sha}-{}", std::process::id()));
397
398 if let Err(e) = git_at(
403 repo_root,
404 &["worktree", "remove", "--force", &dir.to_string_lossy()],
405 ) {
406 eprintln!(
408 "warning: failed to remove existing git worktree at {}: {e}",
409 dir.display()
410 );
411 }
412 if dir.exists() {
414 std::fs::remove_dir_all(&dir).with_context(|| {
415 format!(
416 "failed to remove existing worktree directory {}; \
417 try removing it manually and re-run",
418 dir.display()
419 )
420 })?;
421 }
422
423 git_at(
424 repo_root,
425 &["worktree", "add", "--detach", &dir.to_string_lossy(), sha],
426 )
427 .with_context(|| format!("failed to create worktree for {sha} at {}", dir.display()))?;
428 Ok(TempWorktree {
429 path: dir,
430 repo_root: repo_root.to_path_buf(),
431 })
432}
433
434pub fn find_merge_base(repo_root: &Path) -> Option<(String, i64)> {
437 for branch in &["main", "master", "origin/HEAD"] {
438 if let Ok(sha) = git_at(repo_root, &["merge-base", "HEAD", branch]) {
439 let sha = sha.trim().to_string();
440 if sha.is_empty() {
441 continue;
442 }
443 if let Ok(head) = git_at(repo_root, &["rev-parse", "HEAD"]) {
444 if head.trim() == sha {
445 return None; }
447 }
448 if let Ok(ts_str) = git_at(repo_root, &["log", "-1", "--format=%ct", &sha]) {
449 if let Ok(ts) = ts_str.trim().parse::<i64>() {
450 return Some((sha, ts));
451 }
452 }
453 }
454 }
455 None
456}
457
458pub fn days_since_last_change_at_sha(
461 repo_root: &Path,
462 file: &str,
463 before_sha: &str,
464 as_of_timestamp: i64,
465) -> Option<u32> {
466 git_at(
467 repo_root,
468 &["log", "-1", "--format=%ct", before_sha, "--", file],
469 )
470 .ok()
471 .and_then(|out| out.trim().parse::<i64>().ok())
472 .map(|ts| ((as_of_timestamp - ts).max(0) / 86400) as u32)
473}
474
475pub fn extract_commit_churn(sha: &str) -> Result<Vec<FileChurn>> {
496 let output = match git(&["show", "--numstat", "--format=", sha]) {
500 Ok(out) => out,
501 Err(_) => {
502 return Ok(Vec::new());
504 }
505 };
506
507 let mut churns = Vec::new();
508 for line in output.lines() {
509 let line = line.trim();
510 if line.is_empty() {
511 continue;
512 }
513
514 let parts: Vec<&str> = line.split('\t').collect();
515 if parts.len() < 3 {
516 continue; }
518
519 let added_str = parts[0];
520 let deleted_str = parts[1];
521 let file = parts[2].to_string();
522
523 if added_str == "-" || deleted_str == "-" {
525 continue;
526 }
527
528 let lines_added = added_str
530 .parse::<usize>()
531 .with_context(|| format!("Failed to parse lines added: {}", added_str))?;
532 let lines_deleted = deleted_str
533 .parse::<usize>()
534 .with_context(|| format!("Failed to parse lines deleted: {}", deleted_str))?;
535
536 churns.push(FileChurn {
537 file,
538 lines_added,
539 lines_deleted,
540 });
541 }
542
543 Ok(churns)
544}
545
546pub fn extract_commit_churn_at(repo_path: &Path, sha: &str) -> Result<Vec<FileChurn>> {
559 let output = match git_at(repo_path, &["show", "--numstat", "--format=", sha]) {
560 Ok(out) => out,
561 Err(_) => {
562 return Ok(Vec::new());
563 }
564 };
565
566 let mut churns = Vec::new();
567 for line in output.lines() {
568 let line = line.trim();
569 if line.is_empty() {
570 continue;
571 }
572
573 let parts: Vec<&str> = line.split('\t').collect();
574 if parts.len() < 3 {
575 continue;
576 }
577
578 let added_str = parts[0];
579 let deleted_str = parts[1];
580 let file = parts[2].to_string();
581
582 if added_str == "-" || deleted_str == "-" {
583 continue;
584 }
585
586 let lines_added = added_str
587 .parse::<usize>()
588 .with_context(|| format!("Failed to parse lines added: {}", added_str))?;
589 let lines_deleted = deleted_str
590 .parse::<usize>()
591 .with_context(|| format!("Failed to parse lines deleted: {}", deleted_str))?;
592
593 churns.push(FileChurn {
594 file,
595 lines_added,
596 lines_deleted,
597 });
598 }
599
600 Ok(churns)
601}
602
603pub fn batch_touch_metrics_at(
616 repo_root: &Path,
617 as_of_timestamp: i64,
618) -> Result<BatchedTouchMetrics> {
619 use std::collections::HashMap;
620
621 let thirty_days_ago = as_of_timestamp - (30 * 24 * 60 * 60);
622 let since_arg = format!("--since={}", thirty_days_ago);
623 let until_arg = format!("--until={}", as_of_timestamp);
624
625 let window_output = git_at(
627 repo_root,
628 &[
629 "log",
630 "--format=COMMIT %ct",
631 "--name-only",
632 &since_arg,
633 &until_arg,
634 ],
635 )
636 .unwrap_or_default();
637
638 let mut touch_count: HashMap<String, usize> = HashMap::new();
639 let mut last_touch_ts: HashMap<String, i64> = HashMap::new();
640 let mut current_ts: i64 = 0;
641
642 for line in window_output.lines() {
643 if let Some(ts_str) = line.strip_prefix("COMMIT ") {
644 current_ts = ts_str.trim().parse().unwrap_or(0);
645 } else if !line.trim().is_empty() {
646 let file = line.trim().to_string();
647 *touch_count.entry(file.clone()).or_insert(0) += 1;
648 last_touch_ts.entry(file).or_insert(current_ts);
650 }
651 }
652
653 let days_since: HashMap<String, u32> = last_touch_ts
655 .iter()
656 .map(|(file, &ts)| {
657 let days = ((as_of_timestamp - ts).max(0) / (24 * 60 * 60)) as u32;
658 (file.clone(), days)
659 })
660 .collect();
661
662 Ok(BatchedTouchMetrics {
663 touch_count_30d: touch_count,
664 days_since_last_change: days_since,
665 })
666}
667
668pub fn batch_last_touch_for_files(
672 repo_path: &Path,
673 stale_files: &std::collections::HashSet<&str>,
674 as_of_timestamp: i64,
675) -> std::collections::HashMap<String, u32> {
676 use std::collections::HashMap;
677 if stale_files.is_empty() {
678 return HashMap::new();
679 }
680
681 let until_arg = format!("--until={}", as_of_timestamp);
682 let files: Vec<&str> = stale_files.iter().copied().collect();
683 let mut result: HashMap<String, u32> = HashMap::new();
684
685 for chunk in files.chunks(500) {
686 let mut args: Vec<&str> = vec![
687 "log",
688 "--format=COMMIT %ct",
689 "--name-only",
690 &until_arg,
691 "--",
692 ];
693 args.extend_from_slice(chunk);
694
695 let output = match git_at(repo_path, &args) {
696 Ok(o) => o,
697 Err(_) => continue,
698 };
699
700 let mut current_ts: i64 = 0;
701 for line in output.lines() {
702 if let Some(ts_str) = line.strip_prefix("COMMIT ") {
703 current_ts = ts_str.trim().parse().unwrap_or(0);
704 } else if current_ts > 0 && !line.trim().is_empty() {
705 let file = line.trim();
706 if stale_files.contains(file) {
707 result.entry(file.to_string()).or_insert_with(|| {
709 ((as_of_timestamp - current_ts).max(0) / (24 * 60 * 60)) as u32
710 });
711 }
712 }
713 }
714 }
715
716 result
717}
718
719pub fn function_touch_metrics_at(
732 repo_path: &Path,
733 file: &str,
734 start_line: u32,
735 end_line: u32,
736 as_of_timestamp: i64,
737) -> Result<(usize, Option<u32>)> {
738 let thirty_days_ago = as_of_timestamp - (30 * 24 * 60 * 60);
739 let since_arg = format!("--since={}", thirty_days_ago);
740 let until_arg = format!("--until={}", as_of_timestamp);
741 let range_arg = format!("-L{},{}:{}", start_line, end_line, file);
742
743 let window_output = git_at(
745 repo_path,
746 &[
747 "log",
748 &range_arg,
749 "--format=COMMIT %ct",
750 &since_arg,
751 &until_arg,
752 ],
753 )
754 .unwrap_or_default();
755
756 let window_timestamps: Vec<i64> = window_output
757 .lines()
758 .filter_map(|l| l.strip_prefix("COMMIT "))
759 .filter_map(|ts| ts.trim().parse::<i64>().ok())
760 .collect();
761
762 let touch_count = window_timestamps.len();
763
764 let days_since = if let Some(&ts) = window_timestamps.first() {
765 Some(((as_of_timestamp - ts).max(0) / (24 * 60 * 60)) as u32)
766 } else {
767 let recent_until = format!("--until={}", as_of_timestamp);
769 let recent_output = git_at(
770 repo_path,
771 &[
772 "log",
773 &range_arg,
774 "--format=COMMIT %ct",
775 "-1",
776 &recent_until,
777 ],
778 )
779 .unwrap_or_default();
780
781 recent_output
782 .lines()
783 .filter_map(|l| l.strip_prefix("COMMIT "))
784 .filter_map(|ts| ts.trim().parse::<i64>().ok())
785 .next()
786 .map(|ts| ((as_of_timestamp - ts).max(0) / (24 * 60 * 60)) as u32)
787 };
788
789 Ok((touch_count, days_since))
790}
791
792pub fn count_file_touches_30d(file: &str, as_of_timestamp: i64) -> Result<usize> {
799 let thirty_days_ago = as_of_timestamp - (30 * 24 * 60 * 60);
801
802 let since_arg = format!("--since={}", thirty_days_ago);
805 let until_arg = format!("--until={}", as_of_timestamp);
806
807 let output = match git(&["log", &since_arg, &until_arg, "--oneline", "--", file]) {
808 Ok(out) => out,
809 Err(_) => {
810 return Ok(0);
812 }
813 };
814
815 let count = output
817 .lines()
818 .filter(|line| !line.trim().is_empty())
819 .count();
820 Ok(count)
821}
822
823pub fn count_file_touches_30d_at(
825 repo_path: &Path,
826 file: &str,
827 as_of_timestamp: i64,
828) -> Result<usize> {
829 let thirty_days_ago = as_of_timestamp - (30 * 24 * 60 * 60);
830 let since_arg = format!("--since={}", thirty_days_ago);
831 let until_arg = format!("--until={}", as_of_timestamp);
832
833 let output = match git_at(
834 repo_path,
835 &["log", &since_arg, &until_arg, "--oneline", "--", file],
836 ) {
837 Ok(out) => out,
838 Err(_) => {
839 return Ok(0);
840 }
841 };
842
843 let count = output
844 .lines()
845 .filter(|line| !line.trim().is_empty())
846 .count();
847 Ok(count)
848}
849
850pub fn days_since_last_change(file: &str, as_of_timestamp: i64) -> Result<u32> {
864 let output = match git(&["log", "-1", "--format=%ct", "--", file]) {
867 Ok(out) => out,
868 Err(_) => {
869 return Ok(0);
871 }
872 };
873
874 let last_change_timestamp = output
875 .trim()
876 .parse::<i64>()
877 .context("failed to parse last change timestamp")?;
878
879 let seconds_diff = as_of_timestamp - last_change_timestamp;
881 let days = (seconds_diff / (24 * 60 * 60)).max(0) as u32;
882
883 Ok(days)
884}
885
886pub fn days_since_last_change_at(
888 repo_path: &Path,
889 file: &str,
890 as_of_timestamp: i64,
891) -> Result<u32> {
892 let output = match git_at(repo_path, &["log", "-1", "--format=%ct", "--", file]) {
893 Ok(out) => out,
894 Err(_) => {
895 return Ok(0);
896 }
897 };
898
899 let last_change_timestamp = output
900 .trim()
901 .parse::<i64>()
902 .context("failed to parse last change timestamp")?;
903
904 let seconds_diff = as_of_timestamp - last_change_timestamp;
905 let days = (seconds_diff / (24 * 60 * 60)).max(0) as u32;
906
907 Ok(days)
908}
909
910pub fn detect_fix_commit(message: &str) -> bool {
914 let lower = message.to_lowercase();
915 lower.contains("fix")
916 || lower.contains("bug")
917 || lower.contains("hotfix")
918 || lower.contains("bugfix")
919}
920
921pub fn detect_revert_commit(message: &str) -> bool {
925 let lower = message.to_lowercase();
926 lower.contains("revert")
927}
928
929pub fn extract_ticket_ids(message: &str, branch: Option<&str>) -> Vec<String> {
938 let mut tickets = Vec::new();
939
940 for cap in jira_re().captures_iter(message) {
942 if let Some(ticket) = cap.get(1) {
943 let ticket_str = ticket.as_str().to_string();
944 if !tickets.contains(&ticket_str) {
945 tickets.push(ticket_str);
946 }
947 }
948 }
949
950 for cap in github_re().captures_iter(message) {
952 if let Some(number) = cap.get(1) {
953 let ticket_str = format!("#{}", number.as_str());
954 if !tickets.contains(&ticket_str) {
955 tickets.push(ticket_str);
956 }
957 }
958 }
959
960 if let Some(branch_name) = branch {
962 for cap in jira_re().captures_iter(branch_name) {
963 if let Some(ticket) = cap.get(1) {
964 let ticket_str = ticket.as_str().to_string();
965 if !tickets.contains(&ticket_str) {
966 tickets.push(ticket_str);
967 }
968 }
969 }
970
971 for cap in github_re().captures_iter(branch_name) {
972 if let Some(number) = cap.get(1) {
973 let ticket_str = format!("#{}", number.as_str());
974 if !tickets.contains(&ticket_str) {
975 tickets.push(ticket_str);
976 }
977 }
978 }
979 }
980
981 tickets
982}
983
984#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
986#[serde(rename_all = "snake_case")]
987pub struct CoChangePair {
988 pub file_a: String,
989 pub file_b: String,
990 pub co_change_count: usize,
992 pub coupling_ratio: f64,
994 pub risk: String,
997 pub has_static_dep: bool,
999}
1000
1001fn is_trivial_pair(file_a: &str, file_b: &str) -> bool {
1004 let dir_a = std::path::Path::new(file_a)
1006 .parent()
1007 .unwrap_or(std::path::Path::new(""));
1008 let dir_b = std::path::Path::new(file_b)
1009 .parent()
1010 .unwrap_or(std::path::Path::new(""));
1011 let stem_a = std::path::Path::new(file_a)
1012 .file_stem()
1013 .and_then(|s| s.to_str())
1014 .unwrap_or("");
1015 let stem_b = std::path::Path::new(file_b)
1016 .file_stem()
1017 .and_then(|s| s.to_str())
1018 .unwrap_or("");
1019
1020 if dir_a == dir_b {
1021 if stem_b == format!("{}_test", stem_a) || stem_b == format!("{}_tests", stem_a) {
1023 return true;
1024 }
1025 if stem_a == format!("{}_test", stem_b) || stem_a == format!("{}_tests", stem_b) {
1026 return true;
1027 }
1028 if stem_a == "mod" || stem_b == "mod" {
1030 return true;
1031 }
1032 }
1033 false
1034}
1035
1036pub fn extract_co_change_pairs(
1041 repo_root: &Path,
1042 window_days: u64,
1043 min_count: usize,
1044) -> Result<Vec<CoChangePair>> {
1045 let since = format!("{} days ago", window_days);
1046 let output = git_at(
1047 repo_root,
1048 &[
1049 "log",
1050 "--name-only",
1051 "--format=COMMIT:%H",
1052 &format!("--since={}", since),
1053 "--diff-filter=AM",
1054 ],
1055 )
1056 .unwrap_or_default();
1057
1058 let mut commit_files: Vec<Vec<String>> = Vec::new();
1060 let mut current: Vec<String> = Vec::new();
1061 for line in output.lines() {
1062 if line.starts_with("COMMIT:") {
1063 if !current.is_empty() {
1064 commit_files.push(std::mem::take(&mut current));
1065 }
1066 } else if !line.trim().is_empty() {
1067 current.push(line.trim().to_string());
1068 }
1069 }
1070 if !current.is_empty() {
1071 commit_files.push(current);
1072 }
1073
1074 let mut file_counts: std::collections::HashMap<String, usize> =
1076 std::collections::HashMap::new();
1077 let mut pair_counts: std::collections::HashMap<(String, String), usize> =
1078 std::collections::HashMap::new();
1079
1080 const MAX_FILES_PER_COMMIT: usize = 200;
1084
1085 for files in &commit_files {
1086 for f in files {
1087 *file_counts.entry(f.clone()).or_insert(0) += 1;
1088 }
1089 if files.len() > MAX_FILES_PER_COMMIT {
1090 continue;
1091 }
1092 let mut sorted = files.clone();
1094 sorted.sort();
1095 sorted.dedup();
1096 for i in 0..sorted.len() {
1097 for j in (i + 1)..sorted.len() {
1098 let key = (sorted[i].clone(), sorted[j].clone());
1099 *pair_counts.entry(key).or_insert(0) += 1;
1100 }
1101 }
1102 }
1103
1104 let mut pairs: Vec<CoChangePair> = pair_counts
1105 .into_iter()
1106 .filter(|(_, count)| *count >= min_count)
1107 .filter(|((file_a, file_b), _)| {
1108 repo_root.join(file_a).exists() && repo_root.join(file_b).exists()
1110 })
1111 .filter(|((file_a, file_b), _)| !is_trivial_pair(file_a, file_b))
1112 .map(|((file_a, file_b), co_change_count)| {
1113 let count_a = file_counts.get(&file_a).copied().unwrap_or(1);
1114 let count_b = file_counts.get(&file_b).copied().unwrap_or(1);
1115 let coupling_ratio = co_change_count as f64 / count_a.min(count_b) as f64;
1116 let risk = if coupling_ratio > 0.5 {
1117 "high".to_string()
1118 } else if coupling_ratio > 0.25 {
1119 "moderate".to_string()
1120 } else {
1121 "low".to_string()
1122 };
1123 CoChangePair {
1124 file_a,
1125 file_b,
1126 co_change_count,
1127 coupling_ratio: (coupling_ratio * 1000.0).round() / 1000.0,
1128 risk,
1129 has_static_dep: false, }
1131 })
1132 .collect();
1133
1134 pairs.sort_by(|a, b| {
1135 b.coupling_ratio
1136 .partial_cmp(&a.coupling_ratio)
1137 .unwrap_or(std::cmp::Ordering::Equal)
1138 .then(a.file_a.cmp(&b.file_a))
1139 .then(a.file_b.cmp(&b.file_b))
1140 });
1141
1142 Ok(pairs)
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147 use super::*;
1148
1149 static CWD_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1158
1159 fn lock_cwd() -> std::sync::MutexGuard<'static, ()> {
1160 CWD_TEST_LOCK
1161 .lock()
1162 .unwrap_or_else(|poisoned| poisoned.into_inner())
1163 }
1164
1165 #[test]
1166 fn test_extract_git_context() {
1167 let _guard = lock_cwd();
1168 if git(&["rev-parse", "--git-dir"]).is_err() {
1171 eprintln!("Skipping test: not in a git repository");
1172 return;
1173 }
1174
1175 let context = extract_git_context().expect("should extract git context");
1176
1177 assert!(!context.head_sha.is_empty(), "HEAD SHA should not be empty");
1179
1180 assert!(
1182 context.timestamp > 1112832000,
1183 "timestamp should be after 2005"
1184 );
1185 assert!(
1186 context.timestamp < 4102444800,
1187 "timestamp should be before 2100"
1188 );
1189
1190 assert_eq!(
1192 context.is_detached,
1193 context.branch.is_none(),
1194 "is_detached should match branch presence"
1195 );
1196 }
1197
1198 #[test]
1199 fn test_extract_commit_churn() {
1200 let _guard = lock_cwd();
1201 if git(&["rev-parse", "--git-dir"]).is_err() {
1203 eprintln!("Skipping test: not in a git repository");
1204 return;
1205 }
1206
1207 let context = match extract_git_context() {
1208 Ok(ctx) => ctx,
1209 Err(_) => {
1210 eprintln!("Skipping test: could not extract git context");
1211 return;
1212 }
1213 };
1214
1215 if context.parent_shas.is_empty() {
1217 eprintln!("Skipping test: initial commit has no churn");
1218 return;
1219 }
1220
1221 let churns = extract_commit_churn(&context.head_sha).expect("should extract churn");
1223
1224 println!("Extracted {} file churns for HEAD", churns.len());
1227
1228 for churn in &churns {
1230 assert!(!churn.file.is_empty(), "file path should not be empty");
1231 }
1233 }
1234
1235 #[test]
1236 fn test_resolve_ref_to_sha_head() {
1237 let _guard = lock_cwd();
1238 let cwd = std::env::current_dir().expect("failed to get cwd");
1239 if git_at(&cwd, &["rev-parse", "--git-dir"]).is_err() {
1240 eprintln!("Skipping test: not in a git repository");
1241 return;
1242 }
1243
1244 let sha = resolve_ref_to_sha(&cwd, "HEAD").expect("should resolve HEAD");
1245
1246 assert_eq!(sha.len(), 40, "resolved SHA should be 40 characters");
1248 assert!(
1249 sha.chars().all(|c| c.is_ascii_hexdigit()),
1250 "resolved SHA should be hex"
1251 );
1252 }
1253
1254 #[test]
1255 fn test_resolve_ref_to_sha_abbreviated() {
1256 let _guard = lock_cwd();
1257 let cwd = std::env::current_dir().expect("failed to get cwd");
1258 if git_at(&cwd, &["rev-parse", "--git-dir"]).is_err() {
1259 eprintln!("Skipping test: not in a git repository");
1260 return;
1261 }
1262
1263 let full_sha = resolve_ref_to_sha(&cwd, "HEAD").expect("should resolve HEAD");
1265 let abbrev = &full_sha[..8];
1266 let resolved = resolve_ref_to_sha(&cwd, abbrev).expect("should resolve abbreviated SHA");
1267
1268 assert_eq!(
1269 resolved, full_sha,
1270 "abbreviated SHA should resolve to the same full SHA as HEAD"
1271 );
1272 }
1273
1274 #[test]
1275 fn test_resolve_ref_to_sha_invalid_ref() {
1276 let _guard = lock_cwd();
1277 let cwd = std::env::current_dir().expect("failed to get cwd");
1278 if git_at(&cwd, &["rev-parse", "--git-dir"]).is_err() {
1279 eprintln!("Skipping test: not in a git repository");
1280 return;
1281 }
1282
1283 let result = resolve_ref_to_sha(&cwd, "refs/heads/this-branch-definitely-does-not-exist");
1284 assert!(result.is_err(), "invalid ref should return an error");
1285 }
1286
1287 #[test]
1288 fn resolve_merge_base_auto_does_not_panic() {
1289 let _guard = lock_cwd();
1290 let _result: Option<String> = resolve_merge_base_auto();
1295 }
1297
1298 #[test]
1299 fn resolve_merge_base_auto_returns_none_in_no_git_repo() {
1300 let _guard = lock_cwd();
1301 let tmp = tempfile::tempdir().unwrap();
1303 let old_dir = std::env::current_dir().unwrap();
1304 std::env::set_current_dir(tmp.path()).unwrap();
1305
1306 let result = resolve_merge_base_auto();
1307
1308 std::env::set_current_dir(old_dir).unwrap();
1309 assert!(
1310 result.is_none(),
1311 "should return None when not in a git repo"
1312 );
1313 }
1314}