Skip to main content

amont_runtime/
pushrefs.rs

1//! The refs git feeds `pre-push` on stdin, read ONCE and shared.
2//!
3//! git writes one line per ref being pushed:
4//!
5//! ```text
6//! <local ref> <local oid> <remote ref> <remote oid>
7//! ```
8//!
9//! This exists because stdin can only be consumed once. While checks were
10//! separate processes with INHERITED stdin, whichever ran first drained it and
11//! the rest saw EOF — silently. Two repos in the fleet had a custom
12//! `pre-push-branch-protect.sh` whose `while read` loop sorted BEFORE
13//! `pre-push-run-tests-js`, so the test gate received no refs and ran nothing.
14//! Nobody noticed, because "no refs" and "nothing to test" look identical.
15//!
16//! Reading it in one place and lending the result to every check removes that
17//! whole class of bug, and makes it impossible to reintroduce by adding a
18//! second stdin reader.
19
20use std::io::BufRead;
21use std::sync::OnceLock;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct PushRef {
25    pub local_ref: String,
26    pub local_oid: String,
27    pub remote_ref: String,
28    pub remote_oid: String,
29}
30
31/// Lazily-read pushed refs. `OnceLock` rather than `OnceCell` because
32/// pre-commit runs its checks on threads and `Ctx` must therefore be `Sync`.
33#[derive(Default)]
34pub struct PushRefs(OnceLock<Vec<PushRef>>);
35
36impl PushRefs {
37    /// Read on first use. A check that never asks never blocks on stdin —
38    /// which matters, because pre-commit's stdin is not a ref list.
39    pub fn get(&self) -> &[PushRef] {
40        self.0.get_or_init(|| parse(std::io::stdin().lock()))
41    }
42
43    /// Pre-populated, so `.get()` never touches stdin at all.
44    ///
45    /// For a context with no real `pre-push` invocation to read refs from —
46    /// `amont run <pre-push-check>` is the one today — where `.get()`
47    /// would otherwise block reading a TTY that has no ref list coming, or
48    /// (piped from `/dev/null`, say) read nothing and let the check treat an
49    /// empty ref list as "nothing to check": branch-protect calls that "no
50    /// push to a protected branch", and a scope-gated suite just never loops.
51    pub fn preloaded(refs: Vec<PushRef>) -> PushRefs {
52        let cell = OnceLock::new();
53        let _ = cell.set(refs);
54        PushRefs(cell)
55    }
56}
57
58/// Synthesise the one `PushRef` a standalone invocation — no real `pre-push`
59/// on the other end of stdin — has no other way to obtain: `@{u}..HEAD`.
60///
61/// Shared by `list --pushed` and `amont run <pre-push-check>`, both of
62/// which need to answer "what would this check see" without an actual push
63/// in flight.
64pub fn synthetic_from_upstream() -> Result<PushRef, String> {
65    // No upstream means a branch that has never been pushed. That used to be
66    // the end of it — but the branch that has never been pushed is exactly
67    // the one about to be, and `amont run pre-push` exists to rehearse that
68    // push before git opens a connection for it. So fall back to what the
69    // first push would be judged against: the remote's default branch.
70    let upstream =
71        match crate::git::stdout(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]) {
72            Some(u) => u,
73            None => match DEFAULT_BASES.iter().find(|b| {
74                crate::git::succeeds(&["rev-parse", "-q", "--verify", &format!("{b}^{{commit}}")])
75            }) {
76                Some(b) => b.to_string(),
77                None => {
78                    return Err("no upstream configured for the current branch and no \
79                     origin/HEAD, origin/main or origin/master to diff against — \
80                     nothing has been fetched yet"
81                        .to_string())
82                }
83            },
84        };
85    let Some(local_oid) = crate::git::stdout(&["rev-parse", "HEAD"]) else {
86        return Err("could not resolve HEAD".to_string());
87    };
88    let Some(remote_oid) = crate::git::stdout(&["rev-parse", &upstream]) else {
89        return Err(format!("could not resolve upstream {upstream}"));
90    };
91    let local_ref =
92        crate::git::stdout(&["symbolic-ref", "-q", "HEAD"]).unwrap_or_else(|| "HEAD".to_string());
93    Ok(PushRef {
94        local_ref: local_ref.clone(),
95        local_oid,
96        remote_ref: local_ref,
97        remote_oid,
98    })
99}
100
101/// What a never-pushed branch is measured against, in order of preference:
102/// the remote's advertised default, then the two names it is almost always
103/// called.
104const DEFAULT_BASES: &[&str] = &["origin/HEAD", "origin/main", "origin/master"];
105
106pub fn parse<R: BufRead>(r: R) -> Vec<PushRef> {
107    r.lines()
108        .map_while(Result::ok)
109        .filter_map(|line| {
110            let mut f = line.split_whitespace();
111            let (a, b, c, d) = (f.next()?, f.next()?, f.next()?, f.next()?);
112            Some(PushRef {
113                local_ref: a.to_owned(),
114                local_oid: b.to_owned(),
115                remote_ref: c.to_owned(),
116                remote_oid: d.to_owned(),
117            })
118        })
119        .collect()
120}
121
122/// Every path touched by the refs being pushed.
123///
124/// Shared because two callers need exactly this list and would otherwise write
125/// the zero-oid and range handling twice: `cargo-test` decides whether a suite
126/// is worth running, and a declared pre-push check decides whether its `scope`
127/// applies. A copy that got the delete case wrong would run a test suite on a
128/// branch deletion.
129pub fn changed_files(refs: &[PushRef]) -> Vec<String> {
130    let zero = crate::git::stdout(&["hash-object", "--stdin"])
131        .map(|h| "0".repeat(h.len()))
132        .unwrap_or_else(|| "0".repeat(40));
133    let mut changed: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
134    for r in refs {
135        changed.extend(changed_files_for(r, &zero));
136    }
137    changed.into_iter().collect()
138}
139
140/// Every path touched by ONE ref being pushed.
141///
142/// Split out from `changed_files` so a caller that runs a suite in a
143/// per-ref worktree — `where_to_run` takes one tip, not the whole ref
144/// list, for exactly this reason — can ask "what did THIS ref change"
145/// instead of the aggregate across every ref in the push.
146pub fn changed_files_for(r: &PushRef, zero: &str) -> Vec<String> {
147    if r.local_oid == zero {
148        return Vec::new(); // deleting a ref pushes no code
149    }
150    if r.remote_oid == zero {
151        // A brand-new ref: no remote tree to diff against, so the
152        // `remote..local` trick below does not apply. Walk every commit
153        // this push would introduce that no remote-tracking ref already
154        // has — `rev-list --not --remotes` — rather than just the tip's
155        // own diff against its parent, which silently missed every
156        // earlier commit on a multi-commit new branch: a crate added two
157        // commits back, with a docs-only commit on top, reported only the
158        // docs file as changed, so a scope-gated check like
159        // `pre-push-cargo-test` never ran.
160        return match new_ref_commits(&r.local_oid) {
161            NewRefCommits::Introduced(commits) => diff_tree_stdin(&commits),
162            NewRefCommits::AlreadyOnARemote => Vec::new(),
163            NewRefCommits::Unknown => diff_tree_stdin(&r.local_oid),
164        };
165    }
166    range_changed_files(&r.remote_oid, &r.local_oid)
167}
168
169/// What a brand-new ref brings that no remote already has.
170///
171/// `rev-list <tip> --not --remotes` answers it, and its EMPTY answer used to
172/// share the fallback arm with a failed `rev-list`. Empty is not "unknown":
173/// the tip itself is listed unless a remote-tracking ref already reaches it,
174/// so empty means every pushed commit is on a remote. That is a fork being
175/// seeded from an upstream clone, and the fallback then gated whatever
176/// upstream's last commit touched — a full JS suite, 40 minutes, over code
177/// this push did not bring (amont#301).
178///
179/// No remote-tracking ref at all is NOT a third case: `--not --remotes` then
180/// excludes nothing and the walk returns the whole history, which is right —
181/// all of it is new. Diverting that case to the tip alone skipped a crate
182/// added two commits back (`rust_tools`' several-commits-back test).
183enum NewRefCommits {
184    /// The commits to judge, newline-separated, never empty.
185    Introduced(String),
186    /// Every pushed commit is already reachable from a remote-tracking ref.
187    AlreadyOnARemote,
188    /// `rev-list` failed: judge the tip, as the check always did.
189    Unknown,
190}
191
192fn new_ref_commits(local_oid: &str) -> NewRefCommits {
193    match crate::git::stdout(&["rev-list", local_oid, "--not", "--remotes"]) {
194        Some(commits) if !commits.is_empty() => NewRefCommits::Introduced(commits),
195        Some(_) => NewRefCommits::AlreadyOnARemote,
196        None => NewRefCommits::Unknown,
197    }
198}
199
200/// `(commit, files)` for every commit ONE ref would push — the same walks as
201/// [`changed_files_for`], keeping the commit identities its union discards.
202///
203/// For a caller matching per-commit STATE (a `gate_stamp` note) against
204/// per-commit CHANGES, the union is useless: it says the ref touched `a.ts`
205/// without saying which commit did, and the whole question is whether THAT
206/// commit was checked. One `diff-tree` per commit rather than a parse of
207/// `--stdin` output with commit ids left in: a push is a handful of commits,
208/// and this reuses `diff_tree_stdin`'s tested flag set (`-z` for unusual
209/// bytes, `-m` for merges) instead of growing a second parser for the
210/// interleaved id-and-paths stream.
211pub fn commits_and_files_for(r: &PushRef, zero: &str) -> Vec<(String, Vec<String>)> {
212    if r.local_oid == zero {
213        return Vec::new(); // deleting a ref pushes no code
214    }
215    let commits = if r.remote_oid == zero {
216        // Same three cases as `changed_files_for`'s new-branch arm.
217        match new_ref_commits(&r.local_oid) {
218            NewRefCommits::Introduced(commits) => commits,
219            NewRefCommits::AlreadyOnARemote => return Vec::new(),
220            NewRefCommits::Unknown => r.local_oid.clone(),
221        }
222    } else {
223        let range = format!("{}..{}", r.remote_oid, r.local_oid);
224        match crate::git::stdout(&["rev-list", &range]) {
225            Some(commits) if !commits.is_empty() => commits,
226            _ => return Vec::new(),
227        }
228    };
229    commits
230        .lines()
231        .map(|c| (c.to_string(), diff_tree_stdin(c)))
232        .collect()
233}
234
235/// Every path touched by ANY commit reachable in `remote..local`, not just
236/// the net difference between the two endpoint trees.
237///
238/// `diff-tree remote..local` looks like the obvious tool, and is wrong: for
239/// `diff`/`diff-tree`, a two-dot range is shorthand for a straight two-tree
240/// comparison (`diff-tree remote local`) — unlike `log`, where the identical
241/// syntax means a commit walk. A file changed by one commit and reverted by
242/// a later one in the same push nets to "unchanged" between the endpoints,
243/// so a scope-gated check never learns the file was touched at all.
244/// `rev-list` walks the commits; `diff-tree --stdin` diffs each one against
245/// its own parent, and the per-commit results are unioned here.
246fn range_changed_files(remote_oid: &str, local_oid: &str) -> Vec<String> {
247    let range = format!("{remote_oid}..{local_oid}");
248    let Some(commits) = crate::git::stdout(&["rev-list", &range]) else {
249        return Vec::new();
250    };
251    if commits.is_empty() {
252        return Vec::new();
253    }
254    diff_tree_stdin(&commits)
255}
256
257/// Feed a newline-separated list of commit ids through `diff-tree --stdin`,
258/// diffing each against its own parent(s), and return the union of paths.
259///
260/// `-z`, not bare `--name-only`. Without it git QUOTES any path holding an
261/// "unusual" byte, non-ASCII included: `é.ts` prints as the nine-byte literal
262/// `"\303\251.ts"`. That string ends with neither `.ts` nor anything else the
263/// callers look for, so `is_js`, `is_rust_path` and `Scope::matches` all miss
264/// it and a scope-gated pre-push check silently never runs on the very commit
265/// that changed the file. `git::split_nul_paths` is the same parser
266/// `stdout_paths` uses, kept in one tested place rather than copied here.
267fn diff_tree_stdin(commits: &str) -> Vec<String> {
268    // `git::stdout` trims the trailing newline `rev-list` itself always
269    // writes — and `diff-tree --stdin` treats "no newline after this hash"
270    // as "the line isn't finished yet", silently dropping the LAST commit
271    // rather than reading it. Put the newline back before feeding it in.
272    //
273    // `-m`: without it, `diff-tree` shows NOTHING for a merge commit at all
274    // — confirmed empirically, not merely documented — so a file edited only
275    // to resolve a conflict (never touched by either parent individually) is
276    // invisible to a scope-gated check. `-m` diffs a merge against EACH
277    // parent and unions the results, which is exactly "did this commit,
278    // merge or not, touch this path" — the caller already de-duplicates the
279    // combined list, so the extra per-parent repeats cost nothing.
280    crate::git::stdout_piped_raw(
281        &[
282            "diff-tree",
283            "--no-commit-id",
284            "--name-only",
285            "-r",
286            "-m",
287            "-z",
288            "--stdin",
289        ],
290        &format!("{}\n", commits.trim_end()),
291    )
292    .map(|raw| crate::git::split_nul_paths(&raw))
293    .unwrap_or_default()
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn parses_the_four_fields() {
302        let got = parse(&b"refs/heads/x aaa refs/heads/y bbb\n"[..]);
303        assert_eq!(got.len(), 1);
304        assert_eq!(got[0].local_ref, "refs/heads/x");
305        assert_eq!(got[0].remote_ref, "refs/heads/y");
306        assert_eq!(got[0].remote_oid, "bbb");
307    }
308
309    #[test]
310    fn several_refs_and_junk_lines() {
311        let got = parse(&b"a 1 b 2\ngarbage\n\nc 3 d 4\n"[..]);
312        assert_eq!(got.len(), 2, "short lines are skipped, not fatal");
313        assert_eq!(got[1].local_ref, "c");
314    }
315
316    #[test]
317    fn empty_stdin_is_no_refs_not_an_error() {
318        assert!(parse(&b""[..]).is_empty());
319    }
320
321    /// Mirrors `git::a_non_ascii_path_is_not_reinterpreted_as_its_quoted_form`,
322    /// because this module used to parse `diff-tree` output ITSELF, by lines,
323    /// with no `-z`.
324    ///
325    /// Under bare `--name-only`, git prints `é.ts` as the nine-byte literal
326    /// `"\303\251.ts"` — backslashes, digits and quotes standing in for two
327    /// UTF-8 bytes. That string ends with neither `.ts` nor `.rs`, so `is_js`,
328    /// `is_rust_path` and `Scope::matches` all said "not mine" and the
329    /// scope-gated pre-push check silently never ran on the one commit that
330    /// changed the file. Feeding the same real bytes through `-z` output must
331    /// hand the path back untouched.
332    #[test]
333    fn a_non_ascii_path_survives_the_diff_tree_parse() {
334        let mut raw = "src/é.ts".as_bytes().to_vec();
335        raw.push(0);
336        raw.extend_from_slice(b"src/plain.ts\0");
337        let got = crate::git::split_nul_paths(&raw);
338        assert_eq!(
339            got,
340            vec!["src/é.ts".to_string(), "src/plain.ts".to_string()]
341        );
342        assert!(
343            got[0].ends_with(".ts"),
344            "the quoted form ends with a quote, not an extension, and is why \
345             a scope-gated check never fired: {:?}",
346            got[0]
347        );
348    }
349}