Skip to main content

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;