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 gix::object::tree::diff::{Action, Change};
36
37use crate::vcs::HistoryIndex;
38use crate::vcs::error::Error;
39use crate::vcs::options::Options;
40use crate::vcs::replay;
41
42/// Object-cache budget for the walk. Tree diffs look up the same blobs
43/// repeatedly; a few MiB of cache turns an O(commits²)-ish blob-decode
44/// pattern into something tractable (gix docs guidance). Only applied
45/// when the repository config has not already set one.
46pub(super) const OBJECT_CACHE_BYTES: usize = 8 * 1024 * 1024;
47
48/// Walk git history rooted at `root` and build a [`HistoryIndex`].
49///
50/// # Errors
51///
52/// See [`build_history_index`](crate::vcs::build_history_index).
53pub(crate) fn build(root: &Path, options: &Options) -> Result<HistoryIndex, Error> {
54 let repo::OpenRepo {
55 mut repo,
56 workdir,
57 shallow,
58 } = repo::open(root)?;
59 repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES);
60
61 // Resolve the walk anchor. With `--as-of`, re-anchor at the mainline
62 // tip at-or-before that time (issue #648): a plain HEAD-anchored walk
63 // re-bases only the window arithmetic, so commits in the *future* of
64 // `as_of` (reachable from HEAD) still slip into the windowed counts,
65 // contradicting the "reproducible snapshot" the flag documents. This
66 // mirrors `vcs trend` (#333), reusing its `tip_at_or_before` over the
67 // reference's first-parent timeline. Without `--as-of`, anchor at the
68 // resolved reference tip directly.
69 let Some(commit) = resolve_anchor(&repo, options)? else {
70 // `as_of` predates the first commit on the reference's mainline:
71 // the repository did not exist yet at that point, so the snapshot
72 // is empty — handled gracefully, not as an error (matching trend).
73 return Ok(HistoryIndex::new(HashMap::new(), workdir, shallow));
74 };
75 let target_tree = commit.tree().map_err(walk_err)?;
76
77 // Seed file set (path → SLOC) at the target ref, scoped to the
78 // requested file types (issue #576).
79 let seed = repo::enumerate_target_files(&repo, &target_tree, &options.file_types)?;
80
81 let now = options.as_of.unwrap_or_else(current_unix_seconds);
82 // Uncached: walk the whole long window (no splice points) and replay
83 // the resulting event log — the same fold a cache hit takes, so the
84 // two cannot diverge.
85 let (events, _) = history::collect_events(&repo, commit.id, options, now, &HashSet::new())?;
86 let out = replay::replay(seed, &events, options, now);
87 Ok(HistoryIndex::new(out.files, workdir, shallow).with_bus_factor(out.bus_factor))
88}
89
90/// Resolve the commit the walk should anchor at.
91///
92/// Without `--as-of`, this is the resolved reference tip. With `--as-of`,
93/// it is the mainline (first-parent) tip at-or-before that timestamp, so
94/// the windowed counts, the seeded file set, and the SLOC all reflect the
95/// repository as it stood at that moment rather than at HEAD (issue #648).
96/// Returns `Ok(None)` when `--as-of` predates the first commit on the
97/// reference's mainline (the empty-snapshot case).
98fn resolve_anchor<'repo>(
99 repo: &'repo gix::Repository,
100 options: &Options,
101) -> Result<Option<gix::Commit<'repo>>, Error> {
102 let tip = repo::resolve_commit(repo, &options.reference)?;
103 let Some(as_of) = options.as_of else {
104 return Ok(Some(tip));
105 };
106 let timeline = trend::first_parent_timeline(repo, tip.id)?;
107 let Some(anchor) = trend::tip_at_or_before(&timeline, as_of) else {
108 return Ok(None);
109 };
110 // Re-resolve the historical tip to an owned `Commit`; it lies on the
111 // reference's first-parent mainline by construction.
112 Ok(Some(repo::resolve_commit(
113 repo,
114 &anchor.to_hex().to_string(),
115 )?))
116}
117
118/// Map any backend error into [`Error::Walk`] — the catch-all for
119/// rev-walk, object-lookup, and tree-decode failures. Shared by the
120/// backend submodules to keep the `?`-heavy gix plumbing terse.
121pub(super) fn walk_err(e: impl std::fmt::Display) -> Error {
122 Error::Walk(e.to_string())
123}
124
125/// Map any backend error into [`Error::Diff`] — tree-to-tree and
126/// blob-diff failures.
127pub(super) fn diff_err(e: impl std::fmt::Display) -> Error {
128 Error::Diff(e.to_string())
129}
130
131/// Adapt a tree-diff visitor that fails with this crate's [`Error`] to the
132/// `gix::Result` callback that `for_each_to_obtain_tree` requires since
133/// `gix` 0.88. The caller's `.map_err(diff_err)` then flattens the error
134/// to [`Error::Diff`], exactly as it did the boxed `ForEach` variant
135/// before.
136pub(super) fn diff_visitor<'old, 'new>(
137 mut visit: impl FnMut(Change<'_, 'old, 'new>) -> Result<Action, Error>,
138) -> impl FnMut(Change<'_, 'old, 'new>) -> gix::Result<Action> {
139 move |change| visit(change).map_err(gix::Error::from_error)
140}
141
142/// Parse an `--as-of` timestamp (RFC 3339 / ISO 8601 / `@unix` / git
143/// date spellings) into Unix seconds via gix's date parser.
144///
145/// # Errors
146///
147/// Returns [`Error::InvalidTimestamp`] when the input is unparseable.
148pub(crate) fn parse_timestamp(input: &str) -> Result<i64, Error> {
149 // Accept a bare `@<unix>` epoch directly — gix's date parser does
150 // not recognise that spelling, but it is a convenient reproducible
151 // form for `--as-of`.
152 if let Some(epoch) = input.strip_prefix('@') {
153 return epoch
154 .parse::<i64>()
155 .map_err(|_| Error::InvalidTimestamp(format!("{input:?}: not a Unix timestamp")));
156 }
157 gix::date::parse(input, Some(gix::date::Zoned::now()))
158 .map(|time| time.seconds)
159 .map_err(|e| Error::InvalidTimestamp(format!("{input:?}: {e}")))
160}
161
162/// Wall-clock time as Unix seconds, saturating rather than panicking if
163/// the system clock predates the epoch.
164pub(super) fn current_unix_seconds() -> i64 {
165 SystemTime::now()
166 .duration_since(UNIX_EPOCH)
167 .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
168}
169
170#[cfg(test)]
171#[path = "mod_tests.rs"]
172mod tests;