big_code_analysis/vcs/cache.rs
1//! Persistent change-history cache, keyed by `HEAD` SHA and repo identity
2//! (issue #334).
3//!
4//! Re-walking the in-window history on every `bca vcs` invocation is the
5//! dominant cost on large repositories, and CI re-runs differ only by the
6//! commits pushed since the last run. This module persists the **raw,
7//! pre-finalize event log** of a walk so a later run can *replay* it (the
8//! `replay` module) instead of re-walking, and *extend* it by walking only
9//! the new commits.
10//!
11//! # Why an event log rather than the finished [`HistoryIndex`](super::HistoryIndex)
12//!
13//! [`Stats`](super::stats::Stats) is the collapsed, *now-relative* output
14//! of [`Accumulator::finalize`](super::stats::Accumulator::finalize):
15//! the time windows are already applied and the per-commit detail is
16//! gone. It therefore cannot be merged with newer commits, nor
17//! re-windowed when wall-clock `now` advances. The cache instead stores
18//! one `CommitEvent` per in-window commit — the same data the walk
19//! folds — so replay reconstructs the index at the *current* `now`
20//! (correct windowing, no staleness) and incremental update is a plain
21//! splice of newer events onto cached ones.
22//!
23//! # Author privacy
24//!
25//! Authors are stored only as their **unkeyed** SHA-256
26//! [`hashed`](super::identity::AuthorId::hashed) digests, never plaintext:
27//! the cache must not write raw author emails to disk. Replay reconstructs
28//! identities with
29//! [`AuthorId::from_digest`](super::identity::AuthorId::from_digest),
30//! which preserves author counts, ownership, and the emitted hashes
31//! bit-for-bit (distinct emails yield distinct digests). The digest is a
32//! stable pseudonym, **not** anonymization — it is recoverable against a
33//! candidate email set; see [`hashed`](super::identity::AuthorId::hashed)
34//! for the threat model.
35//!
36//! The opt-in `--author-hash-key` hardening (issue #956) keys the *emitted*
37//! digest only, applied at finalization (like `--emit-author-details`), so
38//! it never changes what the cache stores: the cache holds the unkeyed
39//! inner digest and replaying it under any key reproduces a fresh walk's
40//! keyed output. The on-disk digest is therefore deliberately unkeyed; it
41//! is local-only and never published. See
42//! [`AuthorId::emit_hashed`](super::identity::AuthorId::emit_hashed).
43//!
44//! # Invalidation
45//!
46//! A cached entry is honoured only when its [`CACHE_SCHEMA_VERSION`],
47//! [`VCS_SCHEMA_VERSION`], [`RISK_SCORE_VERSION`], the `fingerprint` of
48//! the walk-affecting options *and the repository mailmap*, and the
49//! shallow state under which it was walked all match the current run;
50//! otherwise it is ignored and the history recomputed. Window changes
51//! alter the fingerprint, so they force a fresh walk, as the issue
52//! specifies. A `.mailmap` edit does too: it changes the author identities
53//! resolved into the event log while leaving `HEAD` — and every option —
54//! untouched (issue #1262). A shallow clone that is later deepened
55//! (`git fetch --unshallow`) leaves `HEAD` unmoved, so the
56//! entry key is unchanged; the shallow-state match in
57//! `HistoryCache::is_compatible` is what forces a re-walk to replace the
58//! truncated counts (issue #810). A corrupt or unreadable entry is
59//! silently ignored, never fatal.
60
61use std::collections::hash_map::DefaultHasher;
62use std::hash::{Hash, Hasher};
63use std::path::{Path, PathBuf};
64use std::sync::atomic::{AtomicU64, Ordering};
65
66use serde::{Deserialize, Serialize};
67
68use super::classify::Classification;
69use super::error::Error;
70use super::identity::AuthorId;
71use super::options::Options;
72use super::score::RISK_SCORE_VERSION;
73use super::stats::VCS_SCHEMA_VERSION;
74
75/// On-disk format version for the cache. Bump on any change to the
76/// `HistoryCache` / `CommitEvent` shape — and on any change to what a
77/// recorded event *means*, since an older entry is otherwise replayed
78/// under semantics it was not walked with.
79///
80/// Version 2 (#1265): [`BotFilter`](super::identity::BotFilter) became
81/// case-insensitive. The fingerprint hashes only the pattern *string*,
82/// so an event log walked under the case-sensitive matcher would
83/// otherwise replay with its excluded set intact — the same input
84/// answering differently depending on whether the cache was warm. The
85/// bump forces one benign cold walk.
86pub const CACHE_SCHEMA_VERSION: u32 = 2;
87
88/// Front-end control over the persistent cache for one build.
89///
90/// The default both enables caching and uses the platform default
91/// directory, so a front end opts *out* (rather than in) — matching the
92/// issue's `--no-cache` / `--clear-cache` flags.
93#[derive(Clone, Debug)]
94// Sealed like `Options`: external front ends construct via
95// `CacheConfig::default()` + field assignment so additive knobs stay
96// non-breaking (see STABILITY.md).
97#[non_exhaustive]
98pub struct CacheConfig {
99 /// Read from and write to the cache. `false` forces a fresh walk and
100 /// skips persistence (`--no-cache`).
101 pub enabled: bool,
102 /// Remove this repository's cache directory before building
103 /// (`--clear-cache`). Honoured even when `enabled` is `false`.
104 pub clear: bool,
105 /// Cache root directory. `None` selects the platform default (`default_cache_dir`)
106 /// (`--cache-dir` overrides it).
107 pub dir: Option<PathBuf>,
108}
109
110impl Default for CacheConfig {
111 fn default() -> Self {
112 Self {
113 enabled: true,
114 clear: false,
115 dir: None,
116 }
117 }
118}
119
120/// One commit's raw, pre-finalize contribution to the history, as the
121/// walk observed it. Stored newest-first so a replay folds commits in the
122/// same order a fresh walk would — keeping the floating-point entropy
123/// sums bit-identical.
124#[derive(Clone, Debug, Serialize, Deserialize)]
125pub(crate) struct CommitEvent {
126 /// Commit object id (hex), used to splice an incremental walk onto the
127 /// cached tail and to de-duplicate across the splice boundary.
128 pub oid: String,
129 /// Raw committer time in Unix seconds (unclamped). Replay clamps it to
130 /// the *current* `now` so a future-dated commit reads as "today" under
131 /// the new reference time, exactly as the live walk does.
132 pub time: i64,
133 /// Participating author identities as SHA-256 digests — never
134 /// plaintext (see the module docs).
135 pub authors: Vec<String>,
136 /// The commit message matched a bug-fix keyword.
137 #[serde(default, skip_serializing_if = "is_false")]
138 pub bug_fix: bool,
139 /// The commit message matched a security-fix keyword.
140 #[serde(default, skip_serializing_if = "is_false")]
141 pub security_fix: bool,
142 /// The commit is a revert / rollback.
143 #[serde(default, skip_serializing_if = "is_false")]
144 pub revert: bool,
145 /// Rename edges this commit introduced, as `(source, destination)`
146 /// repository-relative paths. Replayed newest-first to rebuild the
147 /// alias chain that attributes pre-rename edits to the current path —
148 /// the one signal that must survive across an incremental boundary
149 /// (a rename in a *new* commit re-homes edits in *cached* ones).
150 #[serde(default, skip_serializing_if = "Vec::is_empty")]
151 pub renames: Vec<(PathBuf, PathBuf)>,
152 /// Each touched text file's **location path at this commit** (before
153 /// alias resolution) and its added+deleted line churn.
154 pub touched: Vec<(PathBuf, u64)>,
155}
156
157/// Serde skip predicate: a `false` flag is omitted to keep the cache
158/// compact (most commits match no keyword class).
159#[allow(clippy::trivially_copy_pass_by_ref)]
160fn is_false(value: &bool) -> bool {
161 !*value
162}
163
164impl CommitEvent {
165 /// Reconstruct the participating identities from their stored digests.
166 #[must_use]
167 pub(crate) fn author_ids(&self) -> Vec<AuthorId> {
168 self.authors
169 .iter()
170 .map(|digest| AuthorId::from_digest(digest.clone()))
171 .collect()
172 }
173
174 /// The commit's keyword classification, rebuilt from the stored flags.
175 #[must_use]
176 pub(crate) fn classification(&self) -> Classification {
177 Classification {
178 bug_fix: self.bug_fix,
179 security_fix: self.security_fix,
180 revert: self.revert,
181 }
182 }
183}
184
185/// A persisted history walk: the event log plus the version stamps and
186/// fingerprint that decide whether it may be reused.
187#[derive(Clone, Debug, Serialize, Deserialize)]
188pub(crate) struct HistoryCache {
189 /// On-disk format version ([`CACHE_SCHEMA_VERSION`]).
190 pub cache_schema_version: u32,
191 /// Output-shape version the events were produced under.
192 pub vcs_schema_version: u32,
193 /// Composite-formula version the events were produced under.
194 pub risk_score_version: u32,
195 /// [`fingerprint`] of the walk-affecting options and the repository
196 /// mailmap. The field name predates the mailmap term (#1262) and is
197 /// kept because renaming it would change the serialized shape.
198 pub options_fingerprint: u64,
199 /// The `HEAD` (or `--ref`) object id the walk reached, hex-encoded.
200 pub head_sha: String,
201 /// The long-window cutoff (`now − long_window`) the events were walked
202 /// to. A later run whose own cutoff is *older* than this (wall-clock
203 /// time ran backwards) cannot reuse the entry — its window reaches
204 /// past the cached tail — and falls back to a fresh walk.
205 pub walk_long_boundary: i64,
206 /// Whether the walk was truncated by a shallow clone.
207 #[serde(default)]
208 pub truncated_shallow_clone: bool,
209 /// The per-commit event log, newest-first.
210 pub events: Vec<CommitEvent>,
211}
212
213impl HistoryCache {
214 /// Whether this entry may be reused by a run with the given option
215 /// fingerprint and current shallow state: the format, schema, score,
216 /// and option versions must all match the current build, and the
217 /// shallow state under which the entry was walked must match the
218 /// repository's current shallow state.
219 ///
220 /// The shallow guard prevents replaying a truncated event log after a
221 /// shallow clone is deepened (`git fetch --unshallow` leaves `HEAD`
222 /// unmoved, so the head SHA — and thus the entry key — is unchanged),
223 /// and prevents reporting a complete walk's counts under a now-shallow
224 /// clone as un-truncated (issue #810). A shallow-state mismatch forces
225 /// a fresh walk that produces the correct counts and truncation flag.
226 #[must_use]
227 pub(crate) fn is_compatible(&self, options_fingerprint: u64, current_shallow: bool) -> bool {
228 self.cache_schema_version == CACHE_SCHEMA_VERSION
229 && self.vcs_schema_version == VCS_SCHEMA_VERSION
230 && self.risk_score_version == RISK_SCORE_VERSION
231 && self.options_fingerprint == options_fingerprint
232 && self.truncated_shallow_clone == current_shallow
233 }
234}
235
236/// A stable 64-bit fingerprint of everything that changes *which commits
237/// the walk visits or how they are recorded* — the window lengths,
238/// traversal mode, merge/rename/bot toggles, the bot pattern, and the
239/// `--as-of` reference time from [`Options`], plus `mailmap_digest`.
240///
241/// # Walk input that lives outside `Options`
242///
243/// `mailmap_digest` (issue #1262) covers the repository `.mailmap`, which
244/// is walk input the way an option is: author identities are canonicalised
245/// through it *at walk time* and stored in the event log as digests, so a
246/// mailmap edit changes what a replay would produce while moving neither
247/// `HEAD` (the entry key) nor any field of `Options`. Left out, an edited
248/// mailmap served stale author counts indefinitely — the incremental
249/// splice re-persisted the pre-edit digests under each new head, so the
250/// divergence outlived `HEAD` moving. The backend's `repo::mailmap_digest`
251/// documents what the value covers and why it digests gix's merged
252/// snapshot rather than the raw source bytes.
253///
254/// It is not the only walk input outside `Options`: the git diff
255/// configuration (`diff.algorithm`, diff drivers) decides the recorded
256/// churn and is not fingerprinted yet — see the `git::cached` module docs.
257///
258/// Note what this term does *not* generalise to. An input is fingerprinted
259/// only when it is hashed here, and what is hashed is the option's
260/// *value*, never the behaviour it selects: `bot_pattern` is the pattern
261/// string, so a change to how [`BotFilter`](super::identity::BotFilter)
262/// *matches* that string leaves every fingerprint untouched. Such a change
263/// belongs to [`CACHE_SCHEMA_VERSION`], which versions the meaning of a
264/// recorded event — see its docs for the case-sensitivity bump (#1265)
265/// that set the precedent.
266///
267/// Finalization-only knobs (`--risk-formula`, `--emit-author-details`,
268/// `--author-hash-key` (#956), `--include-deleted`, the bus-factor options)
269/// are deliberately excluded: they are applied at replay, so changing one
270/// reuses the same event log — including re-finalizing a cached walk under
271/// a different author-hash key without a re-walk.
272/// The file-type scope (`--file-types`, #576) is excluded for the same
273/// reason — the cached event log spans every touched file regardless of
274/// scope, and the scope is re-applied to the freshly-enumerated seed and
275/// at replay, so an entry stays reusable across scopes. The revision
276/// spelling is excluded too — the resolved [`head_sha`] keys the entry,
277/// so two refs naming the same commit share it.
278///
279/// [`head_sha`]: HistoryCache::head_sha
280///
281/// `DefaultHasher` is created with fixed keys, so the digest is stable
282/// across processes built with the same toolchain (its `SipHasher13`
283/// algorithm is not guaranteed stable across Rust releases — a bump
284/// shifts every fingerprint, which costs a benign cold walk, never a
285/// wrong hit). [`CACHE_SCHEMA_VERSION`] guards the *meaning* of the
286/// inputs, so the fingerprint need only be self-consistent within one
287/// format version.
288#[must_use]
289pub(crate) fn fingerprint(options: &Options, mailmap_digest: u64) -> u64 {
290 let mut hasher = DefaultHasher::new();
291 mailmap_digest.hash(&mut hasher);
292 options.long_window_secs.hash(&mut hasher);
293 options.recent_window_secs.hash(&mut hasher);
294 options.full_history.hash(&mut hasher);
295 options.include_merges.hash(&mut hasher);
296 options.follow_renames.hash(&mut hasher);
297 options.exclude_bots.hash(&mut hasher);
298 options.bot_pattern.hash(&mut hasher);
299 options.as_of.hash(&mut hasher);
300 hasher.finish()
301}
302
303/// The default cache root: `$XDG_CACHE_HOME/big-code-analysis/vcs`, or the
304/// platform equivalent (`%LOCALAPPDATA%` on Windows, `~/.cache` as the
305/// POSIX fallback). `None` when no home/cache location can be resolved, in
306/// which case caching is simply disabled rather than erroring.
307#[must_use]
308pub(crate) fn default_cache_dir() -> Option<PathBuf> {
309 let suffix = Path::new("big-code-analysis").join("vcs");
310 if let Some(xdg) = non_empty_env("XDG_CACHE_HOME") {
311 return Some(PathBuf::from(xdg).join(&suffix));
312 }
313 #[cfg(windows)]
314 if let Some(local) = non_empty_env("LOCALAPPDATA") {
315 return Some(PathBuf::from(local).join(&suffix));
316 }
317 let home = non_empty_env("HOME")?;
318 Some(PathBuf::from(home).join(".cache").join(&suffix))
319}
320
321/// Read an environment variable, treating an unset *or empty* value as
322/// absent (an empty `HOME` is as useless as a missing one).
323fn non_empty_env(key: &str) -> Option<std::ffi::OsString> {
324 std::env::var_os(key).filter(|value| !value.is_empty())
325}
326
327/// The per-repository sub-directory under `cache_root`, named by a hash of
328/// the repository's canonical path so distinct working trees never share a
329/// directory (and the same tree is stable across runs).
330#[must_use]
331pub(crate) fn repo_dir(cache_root: &Path, repo_canonical: &Path) -> PathBuf {
332 let mut hasher = DefaultHasher::new();
333 repo_canonical.hash(&mut hasher);
334 cache_root.join(format!("{:016x}", hasher.finish()))
335}
336
337/// The cache file for one `HEAD` SHA within a repository directory.
338#[must_use]
339pub(crate) fn entry_path(repo_dir: &Path, head_sha: &str) -> PathBuf {
340 repo_dir.join(format!("{head_sha}.json"))
341}
342
343/// Load a single cache entry, returning `None` for a missing, unreadable,
344/// or corrupt file (all non-fatal — the history is simply recomputed).
345#[must_use]
346pub(crate) fn load(path: &Path) -> Option<HistoryCache> {
347 let bytes = std::fs::read(path).ok()?;
348 serde_json::from_slice(&bytes).ok()
349}
350
351/// Load every entry in `repo_dir` whose versions, option fingerprint, and
352/// shallow state match the current run, paired with its file path. Used to
353/// find a prior entry whose `HEAD` is an ancestor of the current one for an
354/// incremental walk. A non-existent directory yields an empty list.
355#[must_use]
356pub(crate) fn load_compatible(
357 repo_dir: &Path,
358 options_fingerprint: u64,
359 current_shallow: bool,
360) -> Vec<(PathBuf, HistoryCache)> {
361 let Ok(entries) = std::fs::read_dir(repo_dir) else {
362 return Vec::new();
363 };
364 let mut out = Vec::new();
365 for entry in entries.flatten() {
366 let path = entry.path();
367 if path.extension().is_some_and(|ext| ext == "json")
368 && let Some(cache) = load(&path)
369 && cache.is_compatible(options_fingerprint, current_shallow)
370 {
371 out.push((path, cache));
372 }
373 }
374 out
375}
376
377/// Monotonic counter making concurrent temp-file names unique within a
378/// process; combined with the PID it avoids two writers colliding on the
379/// same temporary path before the atomic rename.
380static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
381
382/// Persist a cache entry atomically: write to a uniquely-named temporary
383/// file in the destination directory, then rename it over the target so a
384/// concurrent reader never observes a half-written file.
385///
386/// # Errors
387///
388/// Returns [`Error::Cache`] if the directory cannot be created or the
389/// file cannot be written or renamed.
390pub(crate) fn write_atomic(path: &Path, cache: &HistoryCache) -> Result<(), Error> {
391 let dir = path
392 .parent()
393 .ok_or_else(|| Error::Cache(format!("cache path {} has no parent", path.display())))?;
394 std::fs::create_dir_all(dir).map_err(|e| cache_io_err("create cache directory", dir, &e))?;
395
396 let unique = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
397 let tmp = dir.join(format!(".{}.{unique}.tmp", std::process::id()));
398 let json =
399 serde_json::to_vec(cache).map_err(|e| Error::Cache(format!("serializing cache: {e}")))?;
400 std::fs::write(&tmp, &json).map_err(|e| cache_io_err("write cache temp file", &tmp, &e))?;
401 std::fs::rename(&tmp, path).map_err(|e| {
402 // Best-effort cleanup of the orphaned temp file on a failed rename.
403 let _ = std::fs::remove_file(&tmp);
404 cache_io_err("rename cache file", path, &e)
405 })
406}
407
408/// Remove a repository's entire cache directory (`--clear-cache`). A
409/// missing directory is success, not an error.
410///
411/// # Errors
412///
413/// Returns [`Error::Cache`] if the directory exists but cannot be removed.
414pub(crate) fn clear_repo(repo_dir: &Path) -> Result<(), Error> {
415 match std::fs::remove_dir_all(repo_dir) {
416 Ok(()) => Ok(()),
417 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
418 Err(e) => Err(cache_io_err("clear cache directory", repo_dir, &e)),
419 }
420}
421
422/// Build an [`Error::Cache`] naming the failed operation and path.
423fn cache_io_err(action: &str, path: &Path, error: &std::io::Error) -> Error {
424 Error::Cache(format!("{action} {}: {error}", path.display()))
425}
426
427#[cfg(test)]
428#[path = "cache_tests.rs"]
429mod tests;