Skip to main content

amont_runtime/
gate_stamp.rs

1//! The record that turns "a declaration exists" into "the check ran".
2//!
3//! Moving a gate entry to commit time (`docs/checks.md`, "Moving a gate entry
4//! earlier") makes the push gate skip a script because a `pre-commit`
5//! declaration covers it. A declaration is a promise on paper: a commit made
6//! with `--no-verify`, from a libgit2 client that runs no hooks, or on a
7//! machine without amont was never judged by it — and until this module
8//! existed, push time had no way to tell those commits from checked ones.
9//!
10//! Three hooks share one record:
11//!
12//! 1. **pre-commit** ([`record`]) writes a one-shot marker into `$GIT_DIR`
13//!    naming the gate scripts that actually ran, bound to the tree the commit
14//!    is about to write (`git write-tree` — during pre-commit the index IS
15//!    the commit's content; `staged_only` parks only the working tree).
16//! 2. **post-commit** ([`bind_to_head`]) consumes the marker and, when the
17//!    marker's tree matches `HEAD^{tree}`, stamps the commit in a notes ref.
18//!    `--no-verify` skips pre-commit but NOT post-commit, so an unchecked
19//!    commit arrives here with no marker and gets no stamp — which is the
20//!    entire point. The tree comparison makes an aborted commit's leftover
21//!    marker harmless, and a retried commit of the SAME tree correctly
22//!    stamped: the check really did run on exactly that content.
23//! 3. **pre-push** ([`stamps_for`]) reads the stamps back and suppresses a
24//!    gate script only for pushes whose relevant commits all carry it.
25//! 4. **pre-push, on the way out** ([`stamp_push`]) writes the push-time
26//!    gates that PASSED onto each pushed tip — and `amont run pre-push`,
27//!    which drives the same dispatcher, writes the same stamp for `HEAD`.
28//!    The next push of that tree finds the stamp and skips the gate. That is
29//!    what lets the suite run BEFORE git opens its connection: a rehearsal
30//!    stamps, the push verifies. And a push that passed the gate but died on
31//!    the wire — a remote that dropped the idle session while the suite ran
32//!    — retries in seconds instead of minutes. Push-time tokens are full
33//!    ids (`pre-push-run-tests-js`), commit-time ones are script names
34//!    (`test`); the two share a note and never collide.
35//!
36//! Every failure mode points the same direction: no marker, a mismatched
37//! tree, a missing note, a rewritten hash — all mean "no stamp", and no stamp
38//! means the push gate RUNS. Nothing here can let an unchecked commit
39//! through; it can only cost a redundant gate run.
40//!
41//! # The second half of the note: what a run COST, and how it ENDED
42//!
43//! The token line above answers one question — may this gate be skipped for
44//! this content — and it is deliberately a set, with no history in it. A
45//! stamp says a gate passed; it cannot say a gate has been passing in four
46//! seconds since the day its test runner stopped finding any tests.
47//!
48//! So the note carries optional extra lines, one per RUN:
49//!
50//! ```text
51//! amont-gate-v1 pre-push-cargo-test
52//! run 1726900000 pre-push-cargo-test pass 412391
53//! run 1726903600 pre-push-audit-js fail 903
54//! ```
55//!
56//! Additive on purpose, and in the only direction that is safe: every reader
57//! that existed before these lines did reads `body.lines().next()` and is
58//! unaffected, and an amont old enough to rewrite the note without them loses
59//! history — never a verdict. The `run` lines are EVIDENCE, read by
60//! [`crate::gate_evidence`]; nothing in this module's skip decisions consults
61//! them, so a corrupted or forged one cannot make a check be skipped.
62//!
63//! The note's key is the tree, which makes the record a per-FINGERPRINT one
64//! for free: two runs of one gate against one tree are two lines in one note,
65//! and if they disagree the gate is flaky on content that did not change.
66//!
67//! Why a notes ref and not config: notes are keyed by commit, garbage-collect
68//! with unreachable commits (an `amont.checked.<hash>` config key would
69//! outlive every rebase forever), stay local (notes refs are not pushed by
70//! default), and stay out of `git log` (only `refs/notes/commits` displays by
71//! default). `amont uninstall` deletes the ref; see `uninstall_repo_hooks`.
72
73use std::collections::{HashMap, HashSet};
74use std::path::PathBuf;
75
76/// First token of the marker file and of every note body. Versioned like
77/// `staged_only::FORMAT`: a future amont that changes the shape bumps this,
78/// and an old record is ignored rather than misread.
79pub const FORMAT: &str = "amont-gate-v1";
80
81/// The notes ref, spelled the way `git notes --ref` wants it.
82pub const NOTES_REF: &str = "amont-gate";
83
84/// The same ref, fully qualified — what `git update-ref -d` needs.
85pub const NOTES_FULL_REF: &str = "refs/notes/amont-gate";
86
87/// The marker's filename inside `$GIT_DIR`.
88const MARKER: &str = "amont-gate";
89
90/// The switch for push-time stamps — both writing them and honouring them.
91/// On by default: a stamp is amont's own record of a gate it ran on exactly
92/// this content, the same trust the commit-time stamps have always carried.
93const PUSH_STAMPS: &str = "amont.pushStamps";
94
95/// Does this repository reuse push-time stamps?
96pub fn push_stamps_enabled(settings: &crate::config::Settings) -> bool {
97    crate::config::boolean_or(settings, PUSH_STAMPS, true)
98}
99
100/// The switch for commit-time reuse — a pre-commit gate that already ran
101/// clean against exactly this staged tree is not run again. On by default,
102/// for the same reason `pushStamps` is: the record is amont's own, bound to
103/// the content, and identical content is what a test suite reads.
104const COMMIT_STAMPS: &str = "amont.commitStamps";
105
106/// Does this repository reuse a gate's verdict across commit attempts of
107/// one tree?
108pub fn commit_stamps_enabled(settings: &crate::config::Settings) -> bool {
109    crate::config::boolean_or(settings, COMMIT_STAMPS, true)
110}
111
112/// The first word of an evidence line. A line that does not start with it is
113/// not one, and is dropped rather than guessed at.
114const RUN: &str = "run";
115
116/// How many runs one note keeps, newest last. A note is read and rewritten on
117/// the push path, so it has to stay small; 64 runs of one tree is already far
118/// more retries than any content sees, and the statistics this feeds are
119/// computed ACROSS notes, not within one.
120const MAX_RUNS: usize = 64;
121
122/// How a single run of one gate ended.
123///
124/// The check vocabulary, kept whole rather than flattened to pass/fail:
125/// `Unavailable` is the outcome the fleet audit of 2026-09-19 kept finding —
126/// a gate that could not run, reported as a warning, counted by nobody — and
127/// collapsing it into "fail" would hide it all over again.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum RunOutcome {
130    Passed,
131    Failed,
132    Warned,
133    Fixed,
134    Unavailable,
135    Inert,
136}
137
138impl RunOutcome {
139    pub fn as_str(self) -> &'static str {
140        match self {
141            RunOutcome::Passed => "pass",
142            RunOutcome::Failed => "fail",
143            RunOutcome::Warned => "warn",
144            RunOutcome::Fixed => "fixed",
145            RunOutcome::Unavailable => "unavailable",
146            RunOutcome::Inert => "inert",
147        }
148    }
149
150    /// The parse side. `None` for anything unknown — a future amont may write
151    /// an outcome this one has never heard of, and inventing a meaning for it
152    /// would be worse than dropping the line.
153    pub fn parse(s: &str) -> Option<RunOutcome> {
154        Some(match s {
155            "pass" => RunOutcome::Passed,
156            "fail" => RunOutcome::Failed,
157            "warn" => RunOutcome::Warned,
158            "fixed" => RunOutcome::Fixed,
159            "unavailable" => RunOutcome::Unavailable,
160            "inert" => RunOutcome::Inert,
161            _ => return None,
162        })
163    }
164
165    /// Did this run reach a verdict about the content? Only these two are
166    /// evidence about whether a gate works — the rest are a gate declining to
167    /// judge, and counting them as passes is how a no-op looks healthy.
168    pub fn is_verdict(self) -> bool {
169        matches!(self, RunOutcome::Passed | RunOutcome::Failed)
170    }
171}
172
173/// One run of one gate: when, which, how it ended, how long it took.
174///
175/// Wall clock, in milliseconds, measured around the check's own `run` — not
176/// CPU time and not the hook's total, because the number this exists to catch
177/// is "the suite that used to take eleven minutes returned in 0.4 seconds",
178/// and that is a wall-clock claim.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct Run {
181    /// Seconds since the epoch, as the machine that ran it saw them.
182    pub at: u64,
183    pub gate: String,
184    pub outcome: RunOutcome,
185    pub ms: u64,
186}
187
188impl Run {
189    fn render(&self) -> String {
190        format!(
191            "{RUN} {} {} {} {}",
192            self.at,
193            self.gate,
194            self.outcome.as_str(),
195            self.ms
196        )
197    }
198
199    /// `run <epoch> <gate> <outcome> <ms>`, or nothing. Every field must be
200    /// there and parse; a half-understood line is dropped whole.
201    fn parse(line: &str) -> Option<Run> {
202        let mut t = line.split_whitespace();
203        if t.next() != Some(RUN) {
204            return None;
205        }
206        let at = t.next()?.parse().ok()?;
207        let gate = t.next()?.to_string();
208        let outcome = RunOutcome::parse(t.next()?)?;
209        let ms = t.next()?.parse().ok()?;
210        Some(Run {
211            at,
212            gate,
213            outcome,
214            ms,
215        })
216    }
217}
218
219/// A parsed note body: the skip tokens, and the runs recorded against this
220/// key.
221///
222/// THE reader and THE writer of the format, so that the three callers that
223/// rewrite a note cannot each drop a half of it they were not thinking
224/// about. `stamp_push` merging tokens used to render the note from its own
225/// token list alone, which — once runs existed — would have erased every one
226/// of them on the next push.
227#[derive(Debug, Clone, Default, PartialEq, Eq)]
228pub struct Note {
229    pub tokens: Vec<String>,
230    pub runs: Vec<Run>,
231}
232
233impl Note {
234    /// Parse a note body. A first line that is not ours yields an EMPTY note:
235    /// a note somebody else wrote into our ref vouches for nothing, and its
236    /// remaining lines are not evidence either.
237    pub fn parse(body: &str) -> Note {
238        let mut lines = body.lines();
239        let Some(first) = lines.next() else {
240            return Note::default();
241        };
242        let mut tokens = first.split_whitespace();
243        if tokens.next() != Some(FORMAT) {
244            return Note::default();
245        }
246        Note {
247            tokens: tokens.map(str::to_string).collect(),
248            runs: lines.filter_map(Run::parse).collect(),
249        }
250    }
251
252    /// The bytes to hand `git notes add -m`. Line one is exactly what it has
253    /// always been, so every reader that stops there sees no change at all.
254    pub fn render(&self) -> String {
255        let mut body = format!("{FORMAT} {}", self.tokens.join(" "));
256        for run in &self.runs {
257            body.push('\n');
258            body.push_str(&run.render());
259        }
260        body
261    }
262
263    fn add_tokens(&mut self, tokens: &[String]) {
264        for t in tokens {
265            if !self.tokens.iter().any(|have| have == t) {
266                self.tokens.push(t.clone());
267            }
268        }
269    }
270
271    /// Append runs, keeping the newest [`MAX_RUNS`].
272    fn add_runs(&mut self, runs: &[Run]) {
273        self.runs.extend(runs.iter().cloned());
274        if self.runs.len() > MAX_RUNS {
275            self.runs.drain(..self.runs.len() - MAX_RUNS);
276        }
277    }
278}
279
280/// The note at `key`, parsed. An absent note, an absent ref and a git that
281/// would not answer are all an empty note — the same direction everything
282/// here fails in.
283fn note_at(key: &str) -> Note {
284    crate::git::stdout(&["notes", "--ref", NOTES_REF, "show", key])
285        .map(|body| Note::parse(&body))
286        .unwrap_or_default()
287}
288
289/// Write `note` at `key`. Best-effort, like every writer here.
290fn write_note(key: &str, note: &Note) -> bool {
291    let body = note.render();
292    crate::git::succeeds(&["notes", "--ref", NOTES_REF, "add", "-f", "-m", &body, key])
293}
294
295/// Record what a gate run COST and how it ENDED, against the content it ran
296/// on.
297///
298/// Evidence only: nothing in this module reads these back to decide whether a
299/// check may be skipped, which is why recording a FAILED run — the case the
300/// stamp path deliberately has no opinion about — is safe here.
301///
302/// Best-effort and silent. A note git refused costs a row in a report nobody
303/// is blocked on; warning about it on every push would train people to ignore
304/// the warnings that do gate something.
305pub fn record_runs(key: &str, runs: &[Run]) {
306    // A gate name with whitespace in it would forge a second field on read.
307    // Refused rather than escaped: every id this repository can produce is
308    // already free of it, so the escaping would be a code path no real input
309    // ever reaches — untested by construction.
310    let runs: Vec<Run> = runs
311        .iter()
312        .filter(|r| !r.gate.is_empty() && !r.gate.contains(char::is_whitespace))
313        .cloned()
314        .collect();
315    if runs.is_empty() {
316        return;
317    }
318    let mut note = note_at(key);
319    note.add_runs(&runs);
320    let _ = write_note(key, &note);
321}
322
323/// `$GIT_DIR/amont-gate` — the worktree-PRIVATE gitdir, deliberately: the
324/// commit this marker waits for happens in this worktree. The stamps the
325/// marker becomes live in the common dir (a notes ref) and are shared.
326fn marker_path() -> Option<PathBuf> {
327    let dir = crate::git::stdout(&["rev-parse", "--git-dir"])?;
328    Some(std::path::Path::new(&dir).join(MARKER))
329}
330
331/// pre-commit: record that `scripts` ran clean against the tree the commit
332/// will carry.
333///
334/// Called on EVERY pre-commit verdict, with an empty list when nothing
335/// qualifying ran (or the commit is about to be blocked) — an aborted or
336/// unchecked attempt must not inherit a previous attempt's marker.
337///
338/// Best-effort throughout: a failure to record costs one redundant gate run
339/// at push time, which is the safe direction, and a pre-commit that failed a
340/// COMMIT over bookkeeping would be the tail wagging the dog.
341pub fn record(scripts: &[&str]) {
342    let Some(path) = marker_path() else { return };
343    if scripts.is_empty() {
344        let _ = std::fs::remove_file(&path);
345        return;
346    }
347    // The index, as the object id `git commit` is about to seal. Inherits
348    // `GIT_INDEX_FILE`, so `git commit -a`'s temporary index answers here
349    // too. Pure read of the index: writes objects, touches no ref.
350    let Some(tree) = crate::git::stdout(&["write-tree"]) else {
351        // Not "nothing ran": git could not name the tree, so nothing may be
352        // vouched for. Dropping the marker is the fail-safe half (the gate
353        // re-runs at push); saying so is the half that was missing.
354        crate::hooks::common::warn(
355            "git would not name the staged tree — this commit records no gate stamp",
356        );
357        let _ = std::fs::remove_file(&path);
358        return;
359    };
360    let mut body = format!("{FORMAT}\n{tree}\n");
361    for s in scripts {
362        body.push_str(s);
363        body.push('\n');
364    }
365    let _ = std::fs::write(&path, body);
366}
367
368/// pre-commit, before a gate runs: the scripts a previous run already
369/// vouched for against the tree this commit is about to seal.
370///
371/// Two records answer, both bound to the TREE (`git write-tree` of the index
372/// under the staged-only hold — the commit's content, not the working
373/// tree):
374///
375/// 1. the one-shot marker [`record`] left behind. post-commit consumes it,
376///    so a marker that is still there belongs to an attempt that never
377///    reached post-commit — a `commit-msg` refusal, an editor closed on an
378///    empty message, a Ctrl-C at the prompt. The gates ran, the tree is the
379///    same, the verdict stands: measured on this machine, a subject three
380///    characters too long replayed a ten-minute suite for nothing.
381/// 2. the tree note [`bind_to_head`] writes — a reword, a `reset --soft` and
382///    re-commit, or a rebase that kept the tree.
383///
384/// Every failure mode reads as "nothing is vouched for", which runs the
385/// gate: no `write-tree`, a marker for another tree, a wrong format, a git
386/// that would not answer. Reuse can only ever skip a run the record proves
387/// happened on this exact content; it can never let unjudged content
388/// through — the direction every function in this module fails in.
389///
390/// Repo-controlled inputs to the gate (its command line, its scope) live in
391/// the manifest, which is part of the tree: a changed declaration is a
392/// changed tree, and nothing is reused.
393pub fn vouched_for_staged_tree() -> HashSet<String> {
394    let mut out = HashSet::new();
395    let Some(tree) = crate::git::stdout(&["write-tree"]) else {
396        return out;
397    };
398    if let Some(path) = marker_path() {
399        if let Ok(body) = std::fs::read_to_string(&path) {
400            let mut lines = body.lines();
401            if lines.next() == Some(FORMAT) && lines.next() == Some(tree.as_str()) {
402                out.extend(lines.filter(|l| !l.trim().is_empty()).map(str::to_string));
403            }
404        }
405    }
406    // The tree note: `notes show` fails loudly on an absent note, and a
407    // failure here is simply "no note" — the marker's answer stands alone.
408    // Commit-time tokens are script names; push-time ones are full ids
409    // (`pre-push-…`), which no pre-commit declaration is named after —
410    // harmless in the set. The note's `run` lines are evidence and are not
411    // tokens: `Note` keeps the two apart so nothing here can be skipped by a
412    // line that only records how long something took.
413    out.extend(note_at(&tree).tokens);
414    out
415}
416
417/// post-commit: consume the marker; stamp HEAD when the tree still matches.
418///
419/// One-shot by construction — the marker is deleted before anything is
420/// judged, so no path through here can leave it to vouch for a later commit.
421///
422/// Returns the scripts it actually stamped (empty on every no-stamp path,
423/// including a note git refused). The caller subtracts this from what the
424/// manifest declares to learn what the commit dodged — [`crate::bypass`]
425/// keeps that count. Two records, two questions: the stamp gates a check,
426/// the ledger only counts.
427pub fn bind_to_head() -> Vec<String> {
428    let Some(path) = marker_path() else {
429        return Vec::new();
430    };
431    let Ok(body) = std::fs::read_to_string(&path) else {
432        return Vec::new(); // no marker: nothing ran at pre-commit, nothing to stamp
433    };
434    let _ = std::fs::remove_file(&path);
435    let mut lines = body.lines();
436    if lines.next() != Some(FORMAT) {
437        return Vec::new();
438    }
439    let Some(tree) = lines.next() else {
440        return Vec::new();
441    };
442    let scripts: Vec<&str> = lines.filter(|l| !l.trim().is_empty()).collect();
443    if scripts.is_empty() {
444        return Vec::new();
445    }
446    let Some(head_tree) = crate::git::stdout(&["rev-parse", "HEAD^{tree}"]) else {
447        crate::hooks::common::warn(
448            "git would not name this commit's tree — no gate stamp was written",
449        );
450        return Vec::new();
451    };
452    // A different tree means this commit is not the one pre-commit judged —
453    // the marker is a dead letter from an aborted attempt.
454    if head_tree != tree {
455        return Vec::new();
456    }
457    let scripts: Vec<String> = scripts.iter().map(|s| s.to_string()).collect();
458    // The stamp goes on the TREE as well as the commit, and the tree is the
459    // one that survives the way work actually reaches `main`.
460    //
461    // A squash-merge is performed by the forge: it produces a commit nobody
462    // here ever saw, carrying no note, so a later `git push` of a tag on
463    // that commit re-ran every gate the branch had already proved. Measured
464    // on this repository: a branch push took 13 seconds and the tag push
465    // that followed took the full suite and died on a reset connection.
466    //
467    // The tree is identical across that merge whenever the base has not
468    // moved — five of five merges in one afternoon here — and identical
469    // trees are identical CONTENT, which is the only thing a test suite
470    // reads. That is the same argument `attest` makes for signing the tree
471    // rather than the commit, and this module's marker has been tree-bound
472    // since it was written; this only carries the binding through to the
473    // note.
474    //
475    // Both, not either: the commit note is what a `git log --notes` reader
476    // sees, and dropping it would make the stamps invisible in the place
477    // people look for them.
478    // MERGED into whatever is there, never rendered from the scripts alone:
479    // a pre-push run against this same tree may already have recorded
480    // evidence lines on it, and an `add -f` built from this list would erase
481    // them.
482    let mut tree_note = note_at(&head_tree);
483    tree_note.add_tokens(&scripts);
484    let _ = write_note(&head_tree, &tree_note);
485    let mut head_note = note_at("HEAD");
486    head_note.add_tokens(&scripts);
487    if !write_note("HEAD", &head_note) {
488        // A note git refused is not a stamp — and the push will re-run these
489        // checks, which is right but looks arbitrary unless it is said.
490        crate::hooks::common::warn(
491            "git refused to write the gate stamp — these checks will run again at push",
492        );
493        return Vec::new();
494    }
495    scripts
496}
497
498/// pre-push, after every block gate passed: record that `gates` passed
499/// against `tree`, the content of `commit` — MERGING with whatever the note
500/// already says, because a commit-time stamp (`test`) may already sit there
501/// and `notes add -f` would otherwise erase it.
502///
503/// Best-effort, like every writer here: a stamp that could not be written
504/// costs one redundant gate run on the next push, which is the safe
505/// direction. Returns whether the commit's note was written.
506pub fn stamp_push(commit: &str, tree: &str, gates: &[String]) -> bool {
507    if gates.is_empty() {
508        return false;
509    }
510    let mut written = false;
511    for key in [tree, commit] {
512        let mut note = note_at(key);
513        note.add_tokens(gates);
514        let ok = write_note(key, &note);
515        if key == commit {
516            written = ok;
517        }
518    }
519    if !written {
520        crate::hooks::common::warn(
521            "git refused to write the push stamp — these gates will run again on the next push",
522        );
523    }
524    written
525}
526
527/// pre-push: which of `commits` carry a stamp, and for which scripts.
528///
529/// One `notes list` narrows the reads to commits that have a note at all;
530/// absent ref, unparseable note, wrong format version — all read as "no
531/// stamp", which re-runs the gate.
532pub fn stamps_for(commits: &[String]) -> HashMap<String, Vec<String>> {
533    let mut out = HashMap::new();
534    if commits.is_empty() {
535        return out;
536    }
537    let Some(list) = crate::git::stdout(&["notes", "--ref", NOTES_REF, "list"]) else {
538        // NOT the absent-ref case, whatever an older comment here claimed:
539        // `notes list` exits 0 with empty output when the ref does not exist,
540        // so that arrives as `Some("")` and falls through as "nothing is
541        // stamped" — correctly. Reaching HERE means git could not answer at
542        // all. Same verdict (the gates re-run: never skip work on a question
543        // we could not ask), different sentence, because a transient git
544        // failure that reads as "nothing is stamped" is indistinguishable
545        // from the real thing — which is exactly how one flaky spawn cost a
546        // day of not-diagnosing.
547        crate::hooks::common::warn(
548            "git would not list the gate stamps — every gated check will run again",
549        );
550        return out;
551    };
552    let noted: HashSet<&str> = list
553        .lines()
554        .filter_map(|l| l.split_whitespace().nth(1))
555        .collect();
556    // Commit -> tree, in ONE spawn rather than one per commit: this is the
557    // push path, and a `rev-parse` each would be a process per pushed
558    // commit to answer a question `git log` answers in a batch.
559    let mut trees: HashMap<String, String> = HashMap::new();
560    {
561        let mut args: Vec<&str> = vec!["log", "--no-walk", "--format=%H %T"];
562        args.extend(commits.iter().map(String::as_str));
563        if let Some(out) = crate::git::stdout(&args) {
564            for line in out.lines() {
565                let mut it = line.split_whitespace();
566                if let (Some(c), Some(t)) = (it.next(), it.next()) {
567                    trees.insert(c.to_string(), t.to_string());
568                }
569            }
570        }
571        // No mapping is not an error: every commit simply falls back to the
572        // commit-keyed lookup below, which is what happened before trees
573        // were stamped at all.
574    }
575
576    for commit in commits {
577        // The commit's own note first, then its TREE's. A squash-merge
578        // produces a commit this machine never saw — no note — while the
579        // content, and therefore the tree, is the one the gates ran on.
580        // Falling through to the tree is what lets a tag push on a merged
581        // commit skip work the branch already proved.
582        //
583        // The direction of failure is unchanged: no note on either, an
584        // unparseable one, or a git that would not answer all mean "no
585        // stamp", and no stamp runs the gate.
586        let key: &str = if noted.contains(commit.as_str()) {
587            commit
588        } else {
589            match trees.get(commit).filter(|t| noted.contains(t.as_str())) {
590                Some(tree) => tree,
591                None => continue,
592            }
593        };
594        let tokens = note_at(key).tokens;
595        if tokens.is_empty() {
596            continue;
597        }
598        out.insert(commit.clone(), tokens);
599    }
600    out
601}
602
603/// uninstall: forget everything this module ever wrote here.
604///
605/// The stamps are OUR bookkeeping — unlike `hook.skip` and `amont.severity`,
606/// which are the user's statements and are never touched.
607pub fn forget() -> bool {
608    let marker = marker_path().is_some_and(|path| std::fs::remove_file(&path).is_ok());
609    let notes = crate::git::succeeds(&["update-ref", "-d", NOTES_FULL_REF]);
610    marker || notes
611}
612
613/// The same, for a repository this process is not standing in.
614pub fn forget_in(repo: &std::path::Path) -> bool {
615    let marker = crate::git::stdout_in(repo, &["rev-parse", "--absolute-git-dir"])
616        .is_some_and(|dir| std::fs::remove_file(std::path::Path::new(&dir).join(MARKER)).is_ok());
617    let notes = crate::git::succeeds_in(repo, &["update-ref", "-d", NOTES_FULL_REF]);
618    marker || notes
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624    use std::path::Path;
625
626    /// A real repository, because every function here is a conversation with
627    /// git — hand-rolled fixtures would test the conversation we imagined.
628    fn repo(name: &str) -> PathBuf {
629        let dir = std::env::temp_dir().join(format!("gate-stamp-{name}-{}", std::process::id()));
630        let _ = std::fs::remove_dir_all(&dir);
631        std::fs::create_dir_all(&dir).unwrap();
632        git(&dir, &["init", "-q", "--template=", "."]);
633        git(&dir, &["config", "user.email", "t@t.test"]);
634        git(&dir, &["config", "user.name", "t"]);
635        dir
636    }
637
638    /// A fixture git call that FAILS where it fails.
639    ///
640    /// This used to discard the exit status, and that is how a rare flake
641    /// stayed unreadable for a day: if the setup `git commit` did not
642    /// happen, the test carried on to an unborn HEAD, and the panic landed
643    /// three lines later on a missing gate stamp — a product-shaped
644    /// failure for a fixture-shaped cause. Same rule the checks obey:
645    /// git failing is not git answering.
646    fn git(dir: &Path, args: &[&str]) -> String {
647        let out = std::process::Command::new("git")
648            .arg("-C")
649            .arg(dir)
650            .args(args)
651            .output()
652            .expect("git");
653        assert!(
654            out.status.success(),
655            "fixture: git {args:?} in {} exited {:?}: {}",
656            dir.display(),
657            out.status.code(),
658            String::from_utf8_lossy(&out.stderr).trim()
659        );
660        String::from_utf8_lossy(&out.stdout).trim().to_string()
661    }
662
663    /// The module talks to the repo at the process cwd; these tests each set
664    /// it. Serialised via the crate-wide lock, because cwd is process-global
665    /// and `attest`'s tests move it too.
666    fn in_repo<T>(dir: &Path, f: impl FnOnce() -> T) -> T {
667        let _guard = crate::TEST_CWD.lock().unwrap_or_else(|p| p.into_inner());
668        let prev = std::env::current_dir().unwrap();
669        std::env::set_current_dir(dir).unwrap();
670        let r = f();
671        std::env::set_current_dir(prev).unwrap();
672        r
673    }
674
675    #[test]
676    fn a_recorded_marker_becomes_a_stamp_on_the_matching_commit() {
677        let dir = repo("roundtrip");
678        std::fs::write(dir.join("a.ts"), "x").unwrap();
679        git(&dir, &["add", "a.ts"]);
680        in_repo(&dir, || {
681            record(&["typecheck", "test"]);
682            git(&dir, &["commit", "-qm", "chore: a"]);
683            let stamped = bind_to_head();
684            assert_eq!(
685                stamped,
686                vec!["typecheck".to_string(), "test".to_string()],
687                "bind_to_head reports the scripts it stamped"
688            );
689            let head = git(&dir, &["rev-parse", "HEAD"]);
690            let stamps = stamps_for(std::slice::from_ref(&head));
691            assert_eq!(
692                stamps.get(&head).map(Vec::as_slice),
693                Some(&["typecheck".to_string(), "test".to_string()][..])
694            );
695            // One-shot: the marker is gone.
696            // One-shot: the marker is gone. The fixture's own path, not
697            // `marker_path()` — that helper spawns git, and a transient
698            // spawn failure on a loaded runner reads as `None` here while
699            // production code correctly treats it as "no marker". Seen once,
700            // on Windows, as an unwrap panic in a sibling test.
701            assert!(!dir.join(".git").join(MARKER).exists());
702        });
703        let _ = std::fs::remove_dir_all(&dir);
704    }
705
706    #[test]
707    fn a_marker_for_a_different_tree_stamps_nothing() {
708        let dir = repo("stale");
709        std::fs::write(dir.join("a.ts"), "x").unwrap();
710        git(&dir, &["add", "a.ts"]);
711        in_repo(&dir, || {
712            record(&["typecheck"]);
713            // The commit that actually lands carries DIFFERENT content — the
714            // aborted-attempt-then-different-retry shape.
715            std::fs::write(dir.join("a.ts"), "y").unwrap();
716            git(&dir, &["add", "a.ts"]);
717            git(&dir, &["commit", "-qm", "chore: different"]);
718            assert!(
719                bind_to_head().is_empty(),
720                "bind_to_head reports nothing when the tree moved"
721            );
722            let head = git(&dir, &["rev-parse", "HEAD"]);
723            assert!(
724                stamps_for(&[head]).is_empty(),
725                "a stale marker must not vouch"
726            );
727            assert!(
728                !dir.join(".git").join(MARKER).exists(),
729                "consumed either way"
730            );
731        });
732        let _ = std::fs::remove_dir_all(&dir);
733    }
734
735    #[test]
736    fn an_empty_record_clears_a_previous_marker() {
737        let dir = repo("clears");
738        std::fs::write(dir.join("a.ts"), "x").unwrap();
739        git(&dir, &["add", "a.ts"]);
740        in_repo(&dir, || {
741            record(&["typecheck"]);
742            assert!(dir.join(".git").join(MARKER).exists());
743            record(&[]);
744            assert!(!dir.join(".git").join(MARKER).exists());
745        });
746        let _ = std::fs::remove_dir_all(&dir);
747    }
748
749    /// The version guard's REJECT branch, fed a hand-written marker: an old
750    /// (or future) format is ignored rather than misread — the doc's claim,
751    /// now pinned. Every other test's markers come from record() itself and
752    /// so always carry the current FORMAT.
753    #[test]
754    fn a_marker_in_an_unknown_format_stamps_nothing() {
755        let dir = repo("wrongformat");
756        std::fs::write(dir.join("a.ts"), "x").unwrap();
757        git(&dir, &["add", "a.ts"]);
758        in_repo(&dir, || {
759            let tree = git(&dir, &["write-tree"]);
760            let marker = dir.join(".git").join(MARKER);
761            std::fs::write(&marker, format!("amont-gate-v99\n{tree}\ntypecheck\n")).unwrap();
762            git(&dir, &["commit", "-qm", "chore: a"]);
763            bind_to_head();
764            let head = git(&dir, &["rev-parse", "HEAD"]);
765            assert!(
766                stamps_for(std::slice::from_ref(&head)).is_empty(),
767                "an unknown format was trusted"
768            );
769            assert!(!marker.exists(), "consumed either way");
770        });
771        let _ = std::fs::remove_dir_all(&dir);
772    }
773
774    /// A note somebody else wrote into OUR ref is not a stamp. Absent this,
775    /// `git notes --ref=amont-gate add` would be a one-line way to vouch for
776    /// an unchecked commit — the parsing trust boundary of the whole chain.
777    #[test]
778    fn a_foreign_note_is_not_a_stamp() {
779        let dir = repo("foreignnote");
780        std::fs::write(dir.join("a.ts"), "x").unwrap();
781        git(&dir, &["add", "a.ts"]);
782        in_repo(&dir, || {
783            git(&dir, &["commit", "-qm", "chore: a"]);
784            git(
785                &dir,
786                &[
787                    "notes",
788                    "--ref",
789                    NOTES_REF,
790                    "add",
791                    "-m",
792                    "typecheck test",
793                    "HEAD",
794                ],
795            );
796            let head = git(&dir, &["rev-parse", "HEAD"]);
797            assert!(
798                stamps_for(std::slice::from_ref(&head)).is_empty(),
799                "a note without the format token was trusted"
800            );
801        });
802        let _ = std::fs::remove_dir_all(&dir);
803    }
804
805    /// An absent notes ref is `Some("")`, not `None` — the distinction the
806    /// warning on that branch depends on. If git ever starts failing here
807    /// instead, this test fails and the warning stops being a lie.
808    #[test]
809    fn a_repo_with_no_stamps_answers_emptily_rather_than_failing() {
810        let dir = repo("no-stamps");
811        std::fs::write(dir.join("a.ts"), "x").unwrap();
812        git(&dir, &["add", "a.ts"]);
813        git(&dir, &["commit", "-qm", "chore: a"]);
814        in_repo(&dir, || {
815            assert_eq!(
816                crate::git::stdout(&["notes", "--ref", NOTES_REF, "list"]).as_deref(),
817                Some(""),
818                "an absent notes ref must be an ANSWER, not a failure — the \
819                 no-stamps path and the git-is-broken path are told apart by it"
820            );
821            let head = git(&dir, &["rev-parse", "HEAD"]);
822            assert!(stamps_for(&[head]).is_empty());
823        });
824        let _ = std::fs::remove_dir_all(&dir);
825    }
826
827    /// The compatibility promise, in both directions.
828    ///
829    /// A note written before evidence lines existed is ONE line, and it must
830    /// parse to exactly the tokens it always did — every skip decision in
831    /// this module reads that line, and a reader that needed the new shape
832    /// would stop honouring every stamp on every machine the day it shipped.
833    #[test]
834    fn a_note_from_before_the_run_lines_parses_unchanged() {
835        let note = Note::parse("amont-gate-v1 typecheck test");
836        assert_eq!(note.tokens, vec!["typecheck", "test"]);
837        assert!(note.runs.is_empty());
838        // And round-trips to the same bytes, so an old amont reading a note
839        // this one rewrote sees what it wrote.
840        assert_eq!(note.render(), "amont-gate-v1 typecheck test");
841    }
842
843    /// The new shape: line one unchanged, evidence after it.
844    #[test]
845    fn run_lines_are_read_without_disturbing_the_tokens() {
846        let body = "amont-gate-v1 pre-push-cargo-test\n\
847                    run 1726900000 pre-push-cargo-test pass 412391\n\
848                    run 1726903600 pre-push-audit-js fail 903\n";
849        let note = Note::parse(body);
850        assert_eq!(note.tokens, vec!["pre-push-cargo-test"]);
851        assert_eq!(note.runs.len(), 2);
852        assert_eq!(
853            note.runs[0],
854            Run {
855                at: 1_726_900_000,
856                gate: "pre-push-cargo-test".into(),
857                outcome: RunOutcome::Passed,
858                ms: 412_391,
859            }
860        );
861        assert_eq!(note.runs[1].outcome, RunOutcome::Failed);
862        assert_eq!(note.render(), body.trim_end());
863    }
864
865    /// A line this version does not understand is dropped, not guessed at —
866    /// so a future amont may add a field (or an outcome) without an older one
867    /// inventing a meaning for it.
868    #[test]
869    fn an_unreadable_run_line_is_dropped_and_the_rest_survives() {
870        let note = Note::parse(
871            "amont-gate-v1 test\n\
872             run 1726900000 pre-push-cargo-test pass 400\n\
873             run tomorrow pre-push-cargo-test pass 400\n\
874             run 1726900001 pre-push-cargo-test sideways 400\n\
875             banana\n\
876             run 1726900002 pre-push-cargo-test fail 500\n",
877        );
878        assert_eq!(note.tokens, vec!["test"]);
879        assert_eq!(note.runs.len(), 2, "{:?}", note.runs);
880        assert_eq!(note.runs[1].outcome, RunOutcome::Failed);
881    }
882
883    /// A note somebody else wrote into our ref is not evidence either. The
884    /// token line is the trust boundary for BOTH halves of the note.
885    #[test]
886    fn a_foreign_note_yields_no_runs() {
887        let note = Note::parse("hello\nrun 1726900000 pre-push-cargo-test pass 400\n");
888        assert!(note.tokens.is_empty() && note.runs.is_empty());
889    }
890
891    /// Recording evidence must never cost a stamp. `stamp_push` and
892    /// `bind_to_head` rewrite the same note, and the first version of this
893    /// rendered the note from its own token list — which would have erased
894    /// every run line on the next push.
895    #[test]
896    fn a_stamp_and_its_evidence_survive_each_other() {
897        let dir = repo("evidence");
898        std::fs::write(dir.join("a.ts"), "x").unwrap();
899        git(&dir, &["add", "a.ts"]);
900        in_repo(&dir, || {
901            git(&dir, &["commit", "-qm", "chore: a"]);
902            let head = git(&dir, &["rev-parse", "HEAD"]);
903            let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
904            record_runs(
905                &tree,
906                &[Run {
907                    at: 1_726_900_000,
908                    gate: "pre-push-cargo-test".into(),
909                    outcome: RunOutcome::Failed,
910                    ms: 412_391,
911                }],
912            );
913            // …and now a later push of the same content passes and stamps it.
914            assert!(stamp_push(&head, &tree, &["pre-push-cargo-test".into()]));
915            let note = note_at(&tree);
916            assert_eq!(note.tokens, vec!["pre-push-cargo-test"]);
917            assert_eq!(note.runs.len(), 1, "the evidence survived the stamp");
918            // The stamp still reads back as a stamp.
919            let stamps = stamps_for(std::slice::from_ref(&head));
920            assert_eq!(
921                stamps.get(&head).map(Vec::as_slice),
922                Some(&["pre-push-cargo-test".to_string()][..])
923            );
924        });
925        let _ = std::fs::remove_dir_all(&dir);
926    }
927
928    /// The whole point of the evidence half: it is not a stamp. A recorded
929    /// run — even a passing one — must not let the gate be skipped, because
930    /// skipping is decided by the token line and nothing else.
931    #[test]
932    fn a_recorded_run_is_not_a_stamp() {
933        let dir = repo("not-a-stamp");
934        std::fs::write(dir.join("a.ts"), "x").unwrap();
935        git(&dir, &["add", "a.ts"]);
936        in_repo(&dir, || {
937            git(&dir, &["commit", "-qm", "chore: a"]);
938            let head = git(&dir, &["rev-parse", "HEAD"]);
939            let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
940            record_runs(
941                &tree,
942                &[Run {
943                    at: 1_726_900_000,
944                    gate: "pre-push-cargo-test".into(),
945                    outcome: RunOutcome::Passed,
946                    ms: 412_391,
947                }],
948            );
949            assert!(
950                stamps_for(std::slice::from_ref(&head)).is_empty(),
951                "a run line vouched for a gate — evidence must never gate"
952            );
953            assert!(
954                !vouched_for_staged_tree().contains("pre-push-cargo-test"),
955                "a run line vouched at commit time"
956            );
957            // It IS readable as history, though.
958            let history = crate::gate_evidence::history_in(&dir);
959            assert_eq!(history.runs.len(), 1);
960            assert_eq!(history.runs[0].0, tree, "keyed by the content it ran on");
961        });
962        let _ = std::fs::remove_dir_all(&dir);
963    }
964
965    #[test]
966    fn forget_removes_the_stamps() {
967        let dir = repo("forget");
968        std::fs::write(dir.join("a.ts"), "x").unwrap();
969        git(&dir, &["add", "a.ts"]);
970        in_repo(&dir, || {
971            record(&["typecheck"]);
972            git(&dir, &["commit", "-qm", "chore: a"]);
973            bind_to_head();
974            let head = git(&dir, &["rev-parse", "HEAD"]);
975            assert!(!stamps_for(std::slice::from_ref(&head)).is_empty());
976            forget();
977            assert!(stamps_for(&[head]).is_empty());
978        });
979        let _ = std::fs::remove_dir_all(&dir);
980    }
981
982    /// A squash-merge produces a commit this machine never saw, and the
983    /// stamp has to survive it.
984    ///
985    /// This is the case that motivated tree-stamping. A branch is verified
986    /// locally and stamped; the forge squashes it onto `main` as a NEW
987    /// commit with no note; pushing a tag on that commit re-ran every gate
988    /// the branch had already proved. Measured on this repository: 13
989    /// seconds for the branch push, then the full suite for the tag push,
990    /// which died on a reset connection.
991    ///
992    /// The tree is what the gates actually read, and it is identical across
993    /// that merge whenever the base has not moved — five of five merges in
994    /// one afternoon here.
995    #[test]
996    fn a_stamp_survives_a_commit_being_rewritten_with_the_same_tree() {
997        let dir = repo("squashed");
998        std::fs::write(dir.join("a.ts"), "x").unwrap();
999        git(&dir, &["add", "a.ts"]);
1000        in_repo(&dir, || {
1001            record(&["test"]);
1002            git(&dir, &["commit", "-qm", "feat: on a branch"]);
1003            assert_eq!(bind_to_head(), vec!["test".to_string()]);
1004            let branch_tip = git(&dir, &["rev-parse", "HEAD"]);
1005
1006            // Stand in for the forge's squash: a DIFFERENT commit object
1007            // with the SAME tree. `--amend` with a new message is the
1008            // cheapest way to get exactly that shape.
1009            git(
1010                &dir,
1011                &[
1012                    "commit",
1013                    "-q",
1014                    "--amend",
1015                    "-m",
1016                    "feat: squashed by the forge",
1017                ],
1018            );
1019            let merged = git(&dir, &["rev-parse", "HEAD"]);
1020            assert_ne!(merged, branch_tip, "the fixture must produce a new commit");
1021            assert_eq!(
1022                git(&dir, &["rev-parse", "HEAD^{tree}"]),
1023                git(&dir, &["rev-parse", &format!("{branch_tip}^{{tree}}")]),
1024                "…carrying the same tree, which is the whole premise"
1025            );
1026
1027            let stamps = stamps_for(std::slice::from_ref(&merged));
1028            assert_eq!(
1029                stamps.get(&merged).map(Vec::as_slice),
1030                Some(&["test".to_string()][..]),
1031                "the stamp must follow the content, not the commit hash"
1032            );
1033        });
1034        let _ = std::fs::remove_dir_all(&dir);
1035    }
1036
1037    /// And it must not follow anything else. A commit whose tree was never
1038    /// stamped gets nothing, however many other stamps exist — otherwise
1039    /// this widening would vouch for content nobody checked.
1040    #[test]
1041    fn a_different_tree_gets_no_stamp_from_the_fallback() {
1042        let dir = repo("othertree");
1043        std::fs::write(dir.join("a.ts"), "x").unwrap();
1044        git(&dir, &["add", "a.ts"]);
1045        in_repo(&dir, || {
1046            record(&["test"]);
1047            git(&dir, &["commit", "-qm", "chore: stamped"]);
1048            assert_eq!(bind_to_head(), vec!["test".to_string()]);
1049
1050            // Different CONTENT, so a different tree, and no marker: this
1051            // commit was never judged.
1052            std::fs::write(dir.join("b.ts"), "y").unwrap();
1053            git(&dir, &["add", "b.ts"]);
1054            git(&dir, &["commit", "-qm", "chore: unjudged"]);
1055            let unstamped = git(&dir, &["rev-parse", "HEAD"]);
1056
1057            assert!(
1058                stamps_for(std::slice::from_ref(&unstamped)).is_empty(),
1059                "a tree nobody stamped must not inherit one"
1060            );
1061        });
1062        let _ = std::fs::remove_dir_all(&dir);
1063    }
1064}