big_code_analysis/vcs/git/mod.rs
1//! The `vcs-git` backend: a `gix`-powered history walk.
2//!
3//! `build` is the single entry point `build_history_index` delegates
4//! to. It opens the repository, resolves the target ref, enumerates the
5//! tracked text files at that ref, walks history once into a raw
6//! `CommitEvent` log, then replays that
7//! log into per-file [`Stats`](crate::vcs::stats::Stats) (the `replay`
8//! module). Routing the walk through the same replay a cache hit uses is
9//! what keeps the two bit-identical (issue #334); `build_cached` adds the
10//! persistent-cache layer on top.
11//!
12//! Per-function attribution (issue #329) is a separate, blame-based
13//! path: see [`PerFunctionBlame`].
14
15mod blame;
16mod cached;
17mod diff_parse;
18mod history;
19mod identity;
20mod jit;
21mod repo;
22mod trend;
23
24pub use blame::{BlameSession, LineSpan, PerFunctionBlame};
25pub(crate) use cached::build_cached;
26pub(crate) use diff_parse::score_diff;
27pub(crate) use jit::score_commit;
28pub(crate) use repo::workdir_root;
29pub(crate) use trend::build_trend;
30
31use std::collections::{HashMap, HashSet};
32use std::path::Path;
33use std::time::{SystemTime, UNIX_EPOCH};
34
35use crate::vcs::HistoryIndex;
36use crate::vcs::error::Error;
37use crate::vcs::options::Options;
38use crate::vcs::replay;
39
40/// Object-cache budget for the walk. Tree diffs look up the same blobs
41/// repeatedly; a few MiB of cache turns an O(commits²)-ish blob-decode
42/// pattern into something tractable (gix docs guidance). Only applied
43/// when the repository config has not already set one.
44pub(super) const OBJECT_CACHE_BYTES: usize = 8 * 1024 * 1024;
45
46/// Walk git history rooted at `root` and build a [`HistoryIndex`].
47///
48/// # Errors
49///
50/// See [`build_history_index`](crate::vcs::build_history_index).
51pub(crate) fn build(root: &Path, options: &Options) -> Result<HistoryIndex, Error> {
52 let repo::OpenRepo {
53 mut repo,
54 workdir,
55 shallow,
56 } = repo::open(root)?;
57 repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES);
58
59 // Resolve the walk anchor. With `--as-of`, re-anchor at the mainline
60 // tip at-or-before that time (issue #648): a plain HEAD-anchored walk
61 // re-bases only the window arithmetic, so commits in the *future* of
62 // `as_of` (reachable from HEAD) still slip into the windowed counts,
63 // contradicting the "reproducible snapshot" the flag documents. This
64 // mirrors `vcs trend` (#333), reusing its `tip_at_or_before` over the
65 // reference's first-parent timeline. Without `--as-of`, anchor at the
66 // resolved reference tip directly.
67 let Some(commit) = resolve_anchor(&repo, options)? else {
68 // `as_of` predates the first commit on the reference's mainline:
69 // the repository did not exist yet at that point, so the snapshot
70 // is empty — handled gracefully, not as an error (matching trend).
71 return Ok(HistoryIndex::new(HashMap::new(), workdir, shallow));
72 };
73 let target_tree = commit.tree().map_err(walk_err)?;
74
75 // Seed file set (path → SLOC) at the target ref, scoped to the
76 // requested file types (issue #576).
77 let seed = repo::enumerate_target_files(&repo, &target_tree, &options.file_types)?;
78
79 let now = options.as_of.unwrap_or_else(current_unix_seconds);
80 // Uncached: walk the whole long window (no splice points) and replay
81 // the resulting event log — the same fold a cache hit takes, so the
82 // two cannot diverge.
83 let (events, _) = history::collect_events(&repo, commit.id, options, now, &HashSet::new())?;
84 let out = replay::replay(seed, &events, options, now);
85 Ok(HistoryIndex::new(out.files, workdir, shallow).with_bus_factor(out.bus_factor))
86}
87
88/// Resolve the commit the walk should anchor at.
89///
90/// Without `--as-of`, this is the resolved reference tip. With `--as-of`,
91/// it is the mainline (first-parent) tip at-or-before that timestamp, so
92/// the windowed counts, the seeded file set, and the SLOC all reflect the
93/// repository as it stood at that moment rather than at HEAD (issue #648).
94/// Returns `Ok(None)` when `--as-of` predates the first commit on the
95/// reference's mainline (the empty-snapshot case).
96fn resolve_anchor<'repo>(
97 repo: &'repo gix::Repository,
98 options: &Options,
99) -> Result<Option<gix::Commit<'repo>>, Error> {
100 let tip = repo::resolve_commit(repo, &options.reference)?;
101 let Some(as_of) = options.as_of else {
102 return Ok(Some(tip));
103 };
104 let timeline = trend::first_parent_timeline(repo, tip.id)?;
105 let Some(anchor) = trend::tip_at_or_before(&timeline, as_of) else {
106 return Ok(None);
107 };
108 // Re-resolve the historical tip to an owned `Commit`; it lies on the
109 // reference's first-parent mainline by construction.
110 Ok(Some(repo::resolve_commit(
111 repo,
112 &anchor.to_hex().to_string(),
113 )?))
114}
115
116/// Map any backend error into [`Error::Walk`] — the catch-all for
117/// rev-walk, object-lookup, and tree-decode failures. Shared by the
118/// backend submodules to keep the `?`-heavy gix plumbing terse.
119pub(super) fn walk_err(e: impl std::fmt::Display) -> Error {
120 Error::Walk(e.to_string())
121}
122
123/// Map any backend error into [`Error::Diff`] — tree-to-tree and
124/// blob-diff failures.
125pub(super) fn diff_err(e: impl std::fmt::Display) -> Error {
126 Error::Diff(e.to_string())
127}
128
129/// Parse an `--as-of` timestamp (RFC 3339 / ISO 8601 / `@unix` / git
130/// date spellings) into Unix seconds via gix's date parser.
131///
132/// # Errors
133///
134/// Returns [`Error::InvalidTimestamp`] when the input is unparseable.
135pub(crate) fn parse_timestamp(input: &str) -> Result<i64, Error> {
136 // Accept a bare `@<unix>` epoch directly — gix's date parser does
137 // not recognise that spelling, but it is a convenient reproducible
138 // form for `--as-of`.
139 if let Some(epoch) = input.strip_prefix('@') {
140 return epoch
141 .parse::<i64>()
142 .map_err(|_| Error::InvalidTimestamp(format!("{input:?}: not a Unix timestamp")));
143 }
144 gix::date::parse(input, Some(SystemTime::now()))
145 .map(|time| time.seconds)
146 .map_err(|e| Error::InvalidTimestamp(format!("{input:?}: {e}")))
147}
148
149/// Wall-clock time as Unix seconds, saturating rather than panicking if
150/// the system clock predates the epoch.
151pub(super) fn current_unix_seconds() -> i64 {
152 SystemTime::now()
153 .duration_since(UNIX_EPOCH)
154 .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
155}
156
157#[cfg(test)]
158#[path = "mod_tests.rs"]
159mod tests;