Skip to main content

hotspots_core/
git.rs

1//! Git context extraction
2//!
3//! Extracts git metadata for the current commit deterministically.
4//!
5//! Global invariants enforced:
6//! - Commit hash is the sole identity
7//! - Branch names are metadata only
8//! - Missing parents produce baselines, not errors
9//!
10//! Uses git CLI directly (no libgit2) for portability.
11
12use 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/// Git context for the current commit
30#[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/// File churn metrics (lines added/deleted)
45#[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/// Batched touch metrics for all files in a repository.
53///
54/// Computed by two git log calls instead of one per file, reducing subprocess
55/// overhead from O(files) to O(1).
56#[derive(Debug, Clone)]
57pub struct BatchedTouchMetrics {
58    /// Number of commits touching each file in the 30-day window, keyed by relative path.
59    pub touch_count_30d: std::collections::HashMap<String, usize>,
60    /// Days since last change for each file, keyed by relative path.
61    pub days_since_last_change: std::collections::HashMap<String, u32>,
62}
63
64/// Environment variables git uses to locate a repository, bypassing normal
65/// cwd-based discovery entirely when set. If the calling process inherits
66/// these (e.g. hotspots-core is invoked from within a git hook, where git
67/// sets `GIT_DIR` for the hook subprocess), a bare `git` invocation silently
68/// operates on whatever repo `GIT_DIR` points at instead of the caller's
69/// intended cwd/path -- `current_dir()`/`-C` are ignored once `GIT_DIR` is
70/// present. Every git invocation below clears these first so behavior is
71/// determined solely by the explicit cwd/path this module was given.
72const GIT_DISCOVERY_ENV_VARS: [&str; 3] = ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"];
73
74/// Execute a git command and return the trimmed stdout
75fn 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
93/// Execute a git command in a specific directory and return the trimmed stdout
94fn 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
116/// Extract git context for the current commit
117///
118/// Returns context including HEAD SHA, parent SHAs, timestamp, and branch name.
119/// Handles detached HEAD (branch = None) and shallow clones gracefully.
120///
121/// # Errors
122///
123/// Returns error if:
124/// - Not in a git repository
125/// - Git commands fail for reasons other than shallow history
126///
127/// # Shallow Clone Handling
128///
129/// If parent SHAs cannot be resolved (shallow clone), this function warns
130/// and continues. Missing parents are treated as baseline = true.
131pub fn extract_git_context() -> Result<GitContext> {
132    // Check if we're in a git repository
133    // Use `rev-parse --git-dir` which returns non-zero exit code if not in a repo
134    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    // Extract parent SHAs
141    // Use `rev-list --parents -n 1 HEAD` which outputs: HEAD_SHA PARENT1 PARENT2 ...
142    let parents_raw = git(&["rev-list", "--parents", "-n", "1", "HEAD"])
143        .context("failed to extract parent SHAs")?;
144
145    // Parse parent SHAs (first token is HEAD, rest are parents)
146    let mut parts = parents_raw.split_whitespace();
147    let _ = parts.next(); // Skip HEAD SHA (we already have it)
148    let parent_shas = parts.map(|s| s.to_string()).collect::<Vec<_>>();
149
150    // Warn if no parents (shouldn't happen for normal commits, but handle gracefully)
151    if parent_shas.is_empty() && head_sha != "4b825dc642cb6eb9a060e54bf8d69288fbee4904" {
152        // The SHA above is git's empty tree, which is expected to have no parents
153        // For other commits with no parents, this is likely an initial commit
154        // which is valid, so we don't warn here
155    }
156
157    // Extract commit timestamp (%ct = Unix timestamp)
158    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    // Extract branch name (best effort, None if detached)
164    let branch = git(&["symbolic-ref", "--short", "HEAD"]).ok();
165
166    // Extract commit message and author
167    let message = git(&["log", "-1", "--format=%B", "HEAD"]).ok();
168    let author = git(&["log", "-1", "--format=%an", "HEAD"]).ok();
169
170    // Detect fix and revert commits
171    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    // Extract ticket IDs
175    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
194/// Extract git context for a specific repository path
195///
196/// This variant accepts a repository path instead of using the current working directory.
197/// Useful for tests and scenarios where you don't want to change the process-wide directory.
198///
199/// # Arguments
200///
201/// * `repo_path` - Path to the git repository root
202///
203/// # Errors
204///
205/// Returns error if:
206/// - Not in a git repository at the specified path
207/// - Git commands fail for reasons other than shallow history
208pub fn extract_git_context_at(repo_path: &Path) -> Result<GitContext> {
209    // Check if we're in a git repository
210    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    // Extract parent SHAs
218    let parents_raw = git_at(repo_path, &["rev-list", "--parents", "-n", "1", "HEAD"])
219        .context("failed to extract parent SHAs")?;
220
221    // Parse parent SHAs (first token is HEAD, rest are parents)
222    let mut parts = parents_raw.split_whitespace();
223    let _ = parts.next(); // Skip HEAD SHA (we already have it)
224    let parent_shas = parts.map(|s| s.to_string()).collect::<Vec<_>>();
225
226    // Extract commit timestamp (%ct = Unix timestamp)
227    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    // Extract branch name (best effort, None if detached)
233    let branch = git_at(repo_path, &["symbolic-ref", "--short", "HEAD"]).ok();
234
235    // Extract commit message and author
236    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    // Detect fix and revert commits
240    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    // Extract ticket IDs
244    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/// PR context information
264#[derive(Debug, Clone, PartialEq, Eq)]
265pub struct PrContext {
266    pub is_pr: bool,
267    pub merge_base: Option<String>,
268}
269
270/// Detect if we're in a PR context via CI environment variables
271///
272/// Checks CI environment variables (GitHub: `GITHUB_EVENT_NAME`, `GITHUB_REF`).
273/// Best-effort detection - returns `is_pr=false` if context is ambiguous.
274/// Never hard-fails on ambiguous context.
275pub fn detect_pr_context() -> PrContext {
276    // Check GitHub Actions environment variables
277    let github_event_name = std::env::var("GITHUB_EVENT_NAME").ok();
278    let github_ref = std::env::var("GITHUB_REF").ok();
279
280    // Check if this looks like a PR (pull_request event)
281    let is_pr = match (&github_event_name, &github_ref) {
282        (Some(event), Some(ref_name)) => {
283            // GitHub PR events have event_name = "pull_request" and ref starts with "refs/pull/"
284            event == "pull_request" || ref_name.starts_with("refs/pull/")
285        }
286        _ => false,
287    };
288
289    PrContext {
290        is_pr,
291        merge_base: None, // Will be computed later if needed
292    }
293}
294
295/// Resolve merge-base between current HEAD and target branch
296///
297/// For PRs, this finds the common ancestor with the base branch.
298/// Falls back to direct parent if merge-base cannot be resolved.
299///
300/// # Arguments
301///
302/// * `target_branch` - Target branch (e.g., "main", "master")
303///
304/// # Returns
305///
306/// Returns merge-base SHA, or None if merge-base fails (falls back to parent)
307pub fn resolve_merge_base(target_branch: &str) -> Result<Option<String>> {
308    // Try to get merge-base with target branch
309    // git merge-base HEAD <target_branch>
310    match git(&["merge-base", "HEAD", target_branch]) {
311        Ok(sha) => Ok(Some(sha)),
312        Err(_) => {
313            // Merge-base failed - fall back to direct parent
314            // This is acceptable (best-effort), so we just return None
315            // The caller will use direct parent instead
316            Ok(None)
317        }
318    }
319}
320
321/// Resolve merge-base with common target branches
322///
323/// Tries local and remote-tracking names (main, origin/main, master, …) so
324/// this works both locally (where `main` is checked out) and in CI shallow
325/// clones (where only `origin/main` exists as a remote-tracking ref).
326pub 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        // In CI (GitHub Actions), local branch refs don't exist; only
334        // remote-tracking refs like origin/main are available.
335        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
344/// Resolve a git ref (branch, tag, SHA, HEAD~N, etc.) to a full 40-character SHA.
345///
346/// Runs `git rev-parse <ref>` in the given repository root.
347///
348/// # Errors
349///
350/// Returns an error if the ref does not exist or git fails.
351pub 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
356/// A temporary git worktree that is removed when dropped.
357///
358/// Created by [`create_worktree`]. The worktree directory is cleaned up via
359/// `git worktree remove --force` on drop, so it is always removed even if the
360/// caller returns an error mid-analysis.
361pub struct TempWorktree {
362    /// Absolute path to the worktree root.
363    pub path: std::path::PathBuf,
364    /// Repo root used to issue `git worktree remove`.
365    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
388/// Create a temporary detached worktree at `sha` and return a drop guard.
389///
390/// The directory name includes the current process ID so concurrent hotspots
391/// processes analyzing the same SHA use separate paths. Any stale directory
392/// left by an interrupted previous run with the same PID is removed before
393/// creating the new worktree. The worktree is automatically torn down when
394/// the returned [`TempWorktree`] is dropped.
395pub 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    // Always attempt to unregister any stale git worktree entry first.
399    // A temp cleaner may have removed the directory while the .git/worktrees
400    // metadata entry remains; `git worktree add` fails in that case even though
401    // `dir.exists()` returns false.
402    if let Err(e) = git_at(
403        repo_root,
404        &["worktree", "remove", "--force", &dir.to_string_lossy()],
405    ) {
406        // Not fatal — the entry simply may not exist yet.
407        eprintln!(
408            "warning: failed to remove existing git worktree at {}: {e}",
409            dir.display()
410        );
411    }
412    // Remove the directory itself if it is still present.
413    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
434/// Find the merge-base SHA and Unix timestamp between HEAD and main/master.
435/// Returns None when already on main (merge-base == HEAD) or no divergence found.
436pub 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; // already on main
446                }
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
458/// Days since the last change to `file` at or before `before_sha`.
459/// Returns None if the file has no history before that SHA.
460pub 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
475/// Extract commit churn (lines added/deleted per file)
476///
477/// Uses `git show --numstat <sha>` to get churn data for all files in the commit.
478/// Binary files (shown as `-\t-\t<file>`) are skipped.
479///
480/// # Arguments
481///
482/// * `sha` - Commit SHA to analyze
483///
484/// # Returns
485///
486/// Returns vector of FileChurn entries, one per changed file.
487/// Returns empty vector for:
488/// - Initial commits (no parent)
489/// - Merge commits (uses first parent only)
490/// - Commits with no file changes
491///
492/// # Errors
493///
494/// Returns error if git command fails or output cannot be parsed
495pub fn extract_commit_churn(sha: &str) -> Result<Vec<FileChurn>> {
496    // Use git show --numstat to get lines added/deleted per file
497    // Format: <added>\t<deleted>\t<file>
498    // Binary files show: -\t-\t<file>
499    let output = match git(&["show", "--numstat", "--format=", sha]) {
500        Ok(out) => out,
501        Err(_) => {
502            // If git show fails (e.g., initial commit with no parent), return empty
503            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; // Invalid line, skip
517        }
518
519        let added_str = parts[0];
520        let deleted_str = parts[1];
521        let file = parts[2].to_string();
522
523        // Skip binary files (shown as -)
524        if added_str == "-" || deleted_str == "-" {
525            continue;
526        }
527
528        // Parse numbers
529        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
546/// Extract commit churn at a specific repository path
547///
548/// Like `extract_commit_churn`, but operates on a repository at a specific path.
549///
550/// # Arguments
551///
552/// * `repo_path` - Path to git repository
553/// * `sha` - Commit SHA to analyze
554///
555/// # Returns
556///
557/// Returns vector of FileChurn entries for the commit
558pub 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
603/// Compute touch metrics for all files in a repository using two git log calls.
604///
605/// Replaces the previous O(files) approach (one subprocess per file) with two
606/// batched calls, reducing overhead from ~7.5 ms × N to ~40 ms total.
607///
608/// # Algorithm
609///
610/// Call 1: `git log --format="COMMIT %ct" --name-only --since=X --until=Y`
611///   → builds `touch_count_30d` and finds last-change timestamp for files in window.
612///
613/// Call 2 (fallback): for any file not seen in call 1, a single `git log -1 --format=%ct`
614///   call per file (typically very few files; most active files appear in the window).
615pub 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    // Call 1: all commits in the 30-day window
626    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            // First occurrence = most recent (git log is newest-first)
649            last_touch_ts.entry(file).or_insert(current_ts);
650        }
651    }
652
653    // Build days_since map from what we have so far
654    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
668/// For files absent from the 30-day window, find the most recent commit timestamp
669/// for each using batched `git log` calls (up to 500 files per invocation).
670/// This replaces O(N) individual `git log -1 -- file` calls with O(N/500) calls.
671pub 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                    // entry() only inserts on first (= most recent) occurrence
708                    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
719/// Per-function touch metrics using `git log -L start,end:file`.
720///
721/// Returns `(touch_count_30d, days_since_last_change)` for the specific line range.
722/// More accurate than file-level metrics but ~50× slower per function.
723///
724/// # Arguments
725///
726/// * `repo_path` - Path to git repository
727/// * `file` - Relative path to file from repository root
728/// * `start_line` - First line of function (1-based)
729/// * `end_line` - Last line of function (1-based)
730/// * `as_of_timestamp` - Unix timestamp to use as "now"
731pub 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    // Count touches in 30-day window; filter diff output by looking for "COMMIT <ts>" markers
744    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        // Not in 30-day window: find most recent commit touching this range
768        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
792/// Count how many commits touched a file in the last 30 days
793///
794/// Counts commits relative to a specific timestamp (typically the commit timestamp),
795/// not wall clock time. This allows deterministic analysis of historical commits.
796///
797/// Returns count of commits in the 30 days before `as_of_timestamp`, or 0 if none.
798pub fn count_file_touches_30d(file: &str, as_of_timestamp: i64) -> Result<usize> {
799    // Calculate 30 days before the reference timestamp
800    let thirty_days_ago = as_of_timestamp - (30 * 24 * 60 * 60);
801
802    // Use git log with --since and --until to get commits in the window
803    // Format: --since=<timestamp> --until=<timestamp>
804    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            // File doesn't exist or no commits in window
811            return Ok(0);
812        }
813    };
814
815    // Count non-empty lines
816    let count = output
817        .lines()
818        .filter(|line| !line.trim().is_empty())
819        .count();
820    Ok(count)
821}
822
823/// Count file touches at a specific repository path
824pub 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
850/// Calculate days since last change to a file
851///
852/// Returns number of days between the file's last modification and the reference timestamp.
853///
854/// # Arguments
855///
856/// * `file` - Relative path to file from repository root
857/// * `as_of_timestamp` - Unix timestamp to use as "now" (typically commit timestamp)
858///
859/// # Returns
860///
861/// Returns days since last change, or 0 if file was just modified.
862/// Returns 0 if file doesn't exist or has no history.
863pub fn days_since_last_change(file: &str, as_of_timestamp: i64) -> Result<u32> {
864    // Get the timestamp of the most recent commit that modified this file
865    // Use: git log -1 --format=%ct -- <file>
866    let output = match git(&["log", "-1", "--format=%ct", "--", file]) {
867        Ok(out) => out,
868        Err(_) => {
869            // File doesn't exist or has no history
870            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    // Calculate days difference
880    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
886/// Calculate days since last change at a specific repository path
887pub 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
910/// Detect if a commit message indicates a fix/bug fix
911///
912/// Looks for common keywords: "fix", "bug", "hotfix", "bugfix", etc.
913pub 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
921/// Detect if a commit is a revert
922///
923/// Looks for "revert" keyword in the message
924pub fn detect_revert_commit(message: &str) -> bool {
925    let lower = message.to_lowercase();
926    lower.contains("revert")
927}
928
929/// Extract ticket IDs from commit message and branch name
930///
931/// Supports formats:
932/// - JIRA-1234
933/// - ABC-123
934/// - #123
935/// - fixes #123
936/// - closes #123
937pub fn extract_ticket_ids(message: &str, branch: Option<&str>) -> Vec<String> {
938    let mut tickets = Vec::new();
939
940    // Pattern for JIRA-style tickets: PROJECT-123
941    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    // Pattern for GitHub issues: #123, fixes #123, closes #123
951    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    // Also extract from branch name if provided
961    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/// A pair of files that frequently change together in the same commit
985#[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    /// Number of commits where both files changed
991    pub co_change_count: usize,
992    /// co_change_count / min(total_changes_a, total_changes_b)
993    pub coupling_ratio: f64,
994    /// "high" if ratio > 0.5 and no static dep, "moderate" if > 0.25 and no static dep,
995    /// "expected" if a direct import exists between the two files, else "low"
996    pub risk: String,
997    /// Whether a direct import relationship exists between file_a and file_b
998    pub has_static_dep: bool,
999}
1000
1001/// Returns true for pairs that are trivially expected to co-change (test+source,
1002/// mod.rs+sibling) and should be excluded from coupling analysis.
1003fn is_trivial_pair(file_a: &str, file_b: &str) -> bool {
1004    // foo.rs + foo_test.rs or foo_tests.rs (same dir, same stem)
1005    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        // Test file paired with its source: foo + foo_test or foo_tests
1022        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        // mod.rs paired with any sibling in the same directory
1029        if stem_a == "mod" || stem_b == "mod" {
1030            return true;
1031        }
1032    }
1033    false
1034}
1035
1036/// Mine co-change pairs from git log over the last `window_days` days.
1037///
1038/// Returns pairs that co-changed at least `min_count` times, ranked by
1039/// coupling_ratio descending. Pairs where both files are the same are excluded.
1040pub 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    // Parse into per-commit file sets
1059    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    // Count per-file total appearances and per-pair co-occurrences
1075    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    // Skip commits that touch more than this many files — they are mass-change
1081    // commits (version bumps, renames, reformats) that produce O(n²) pairs and
1082    // dominate memory without adding meaningful co-change signal.
1083    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        // All unique pairs in this commit
1093        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            // Skip pairs where either file no longer exists (e.g. renamed/deleted files)
1109            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, // annotated later in compute_snapshot_aggregates
1130            }
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    /// Serializes tests that depend on (or mutate) the process' ambient working
1150    /// directory. `git()` (as opposed to `git_at()`) always runs relative to
1151    /// `std::env::current_dir()`, and `cargo test` runs tests in parallel
1152    /// threads within one process — CWD is shared, global, mutable state.
1153    /// `resolve_merge_base_auto_returns_none_in_no_git_repo` below temporarily
1154    /// points CWD at a git-free tempdir; without this lock, any other test in
1155    /// this module that shells out to `git()` expecting to be in the repo can
1156    /// race against that window and fail nondeterministically.
1157    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        // This test requires a git repository
1169        // Skip if not in a git repo (e.g., in CI without git context)
1170        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        // HEAD SHA should be 40 characters (full SHA) or 7+ characters (short SHA)
1178        assert!(!context.head_sha.is_empty(), "HEAD SHA should not be empty");
1179
1180        // Timestamp should be reasonable (after 2005-04-07, before year 2100)
1181        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        // is_detached should be consistent with branch
1191        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        // Skip if not in a git repo
1202        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        // Skip if this is an initial commit (no parents)
1216        if context.parent_shas.is_empty() {
1217            eprintln!("Skipping test: initial commit has no churn");
1218            return;
1219        }
1220
1221        // Extract churn for HEAD
1222        let churns = extract_commit_churn(&context.head_sha).expect("should extract churn");
1223
1224        // Churn should be a list (may be empty for commits with no changes)
1225        // We just verify it doesn't error
1226        println!("Extracted {} file churns for HEAD", churns.len());
1227
1228        // Validate structure if we have any churns
1229        for churn in &churns {
1230            assert!(!churn.file.is_empty(), "file path should not be empty");
1231            // lines_added and lines_deleted can be 0, so just check they parse
1232        }
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        // Full SHA is 40 hex characters
1247        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        // Get HEAD SHA, then resolve its first 8 characters as an abbreviated ref
1264        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        // This function returns None when no common branch exists — that is
1291        // the expected outcome in environments where the repo has no main/master
1292        // branch (e.g., a detached shallow clone with only origin/* refs).
1293        // The important invariant: it must not panic regardless of git state.
1294        let _result: Option<String> = resolve_merge_base_auto();
1295        // No assertion on value — just verifying no panic and correct return type.
1296    }
1297
1298    #[test]
1299    fn resolve_merge_base_auto_returns_none_in_no_git_repo() {
1300        let _guard = lock_cwd();
1301        // In a tempdir with no git repo every merge-base attempt fails → None.
1302        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}