metrale_bench/gate/
check_paths.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Which changed paths invalidate a gate record.
4//!
5//! Owner: bench gate.
6//! Invariants:
7//! - When git cannot diff the two commits, [`invalidating_paths`] returns `None`, never an
8//!   empty list.
9
10use std::path::Path;
11
12/// 2026-09-26: The changed paths that invalidate `gate` between two commits; empty when the
13/// commits are equal.
14///
15/// `None` means git failed or one commit is not in this clone; callers treat it as not
16/// covered.
17///
18/// The question is whether the trees differ on paths the gate cares about, so this runs
19/// `git diff --name-only` and does not require `record_sha` to be an ancestor of `head`: a
20/// record measured on a branch still covers the squash-merged commit when those paths agree.
21///
22/// The diff has no pathspec; [`super::coverage::invalidates`] filters it in Rust, so the
23/// per-gate policy stays in `coverage.rs`.
24pub fn invalidating_paths(
25    root: &Path,
26    head: &str,
27    record_sha: &str,
28    gate: &super::coverage::GateCoverage,
29) -> Option<Vec<String>> {
30    invalidating_paths_with(root, head, record_sha, gate, |path| {
31        super::amnesty::excused(root, head, path)
32    })
33}
34
35fn invalidating_paths_with(
36    root: &Path,
37    head: &str,
38    record_sha: &str,
39    gate: &super::coverage::GateCoverage,
40    mut is_excused: impl FnMut(&str) -> bool,
41) -> Option<Vec<String>> {
42    if head == record_sha {
43        return Some(Vec::new());
44    }
45    let out = std::process::Command::new("git")
46        .arg("-C")
47        .arg(root)
48        .args(["diff", "--name-only", record_sha, head])
49        .stdin(std::process::Stdio::null())
50        .output()
51        .ok()?;
52    if !out.status.success() {
53        return None;
54    }
55    Some(
56        String::from_utf8_lossy(&out.stdout)
57            .lines()
58            .map(str::trim)
59            .filter(|p| !p.is_empty())
60            .filter(|p| super::coverage::invalidates(gate, p))
61            // 2026-09-26: A path whose blob at `head` is the OID an amnesty entry pins is
62            // excused, with a warning. `ONE_TIME_AMNESTY` is empty, so in production nothing
63            // is.
64            .filter(|p| {
65                if is_excused(p) {
66                    tracing::warn!(
67                        "amnesty: {p} would re-open {} but its content at {head} is the \
68                         pinned one-time grant; excused (see gate/amnesty.rs)",
69                        gate.id
70                    );
71                    return false;
72                }
73                true
74            })
75            .map(str::to_string)
76            .collect(),
77    )
78}
79
80#[cfg(test)]
81pub(crate) fn invalidating_paths_with_amnesty(
82    root: &Path,
83    head: &str,
84    record_sha: &str,
85    gate: &super::coverage::GateCoverage,
86    table: &[super::amnesty::AmnestyEntry],
87) -> Option<Vec<String>> {
88    invalidating_paths_with(root, head, record_sha, gate, |path| {
89        super::amnesty::excused_by(root, head, path, table)
90    })
91}