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 second ref: stamps keyed by a gate's declared-input fingerprint
88/// (`.github/attest-inputs`, see [`crate::attest_inputs`]) instead of by the
89/// whole tree. A gate whose inputs a repository declares is reused on any tree
90/// where those inputs are byte-identical — the closing plan commit changes the
91/// tree and none of them. Every key there is an oid that is not an object, as
92/// in attest's own inputs ref, so `git notes prune` on it would drop
93/// everything: never run it, and never merge the ref with `cat_sort_uniq`.
94pub const INPUTS_REF: &str = "amont-gate-inputs";
95pub const INPUTS_FULL_REF: &str = "refs/notes/amont-gate-inputs";
96
97/// The marker's filename inside `$GIT_DIR`.
98const MARKER: &str = "amont-gate";
99
100/// The switch for push-time stamps — both writing them and honouring them.
101/// On by default: a stamp is amont's own record of a gate it ran on exactly
102/// this content, the same trust the commit-time stamps have always carried.
103const PUSH_STAMPS: &str = "amont.pushStamps";
104
105/// Does this repository reuse push-time stamps?
106pub fn push_stamps_enabled(settings: &crate::config::Settings) -> bool {
107    crate::config::boolean_or(settings, PUSH_STAMPS, true)
108}
109
110/// The switch for commit-time reuse — a pre-commit gate that already ran
111/// clean against exactly this staged tree is not run again. On by default,
112/// for the same reason `pushStamps` is: the record is amont's own, bound to
113/// the content, and identical content is what a test suite reads.
114const COMMIT_STAMPS: &str = "amont.commitStamps";
115
116/// Does this repository reuse a gate's verdict across commit attempts of
117/// one tree?
118pub fn commit_stamps_enabled(settings: &crate::config::Settings) -> bool {
119    crate::config::boolean_or(settings, COMMIT_STAMPS, true)
120}
121
122/// The first word of an evidence line. A line that does not start with it is
123/// not one, and is dropped rather than guessed at.
124const RUN: &str = "run";
125
126/// How many runs one note keeps, newest last. A note is read and rewritten on
127/// the push path, so it has to stay small; 64 runs of one tree is already far
128/// more retries than any content sees, and the statistics this feeds are
129/// computed ACROSS notes, not within one.
130const MAX_RUNS: usize = 64;
131
132/// How a single run of one gate ended.
133///
134/// The check vocabulary, kept whole rather than flattened to pass/fail:
135/// `Unavailable` is the outcome the fleet audit of 2026-09-19 kept finding —
136/// a gate that could not run, reported as a warning, counted by nobody — and
137/// collapsing it into "fail" would hide it all over again.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum RunOutcome {
140    Passed,
141    Failed,
142    Warned,
143    Fixed,
144    Unavailable,
145    Inert,
146    // Tree gates (ADR-0024): why a commit's tree was NOT proven. None is a
147    // verdict about the content; together they are the hit rate's misses.
148    /// No completion marker in the current cache namespace.
149    Cold,
150    /// Another run held the gate's cache lock.
151    Busy,
152    /// The tool the gate would run is not the one CI resolves.
153    Skew,
154    /// The tree was not exactly the commit's (unstaged, untracked, ignored
155    /// outside the allow-list, or an operation in progress).
156    Withheld,
157    /// Still running when the commit was ready, or past its deadline.
158    Cancelled,
159    /// Not started: its last run would not fit in this commit's cover plus
160    /// the slack.
161    Slow,
162}
163
164impl RunOutcome {
165    pub fn as_str(self) -> &'static str {
166        match self {
167            RunOutcome::Passed => "pass",
168            RunOutcome::Failed => "fail",
169            RunOutcome::Warned => "warn",
170            RunOutcome::Fixed => "fixed",
171            RunOutcome::Unavailable => "unavailable",
172            RunOutcome::Inert => "inert",
173            RunOutcome::Cold => "cold",
174            RunOutcome::Busy => "busy",
175            RunOutcome::Skew => "skew",
176            RunOutcome::Withheld => "withheld",
177            RunOutcome::Cancelled => "cancelled",
178            RunOutcome::Slow => "slow",
179        }
180    }
181
182    /// The parse side. `None` for anything unknown — a future amont may write
183    /// an outcome this one has never heard of, and inventing a meaning for it
184    /// would be worse than dropping the line.
185    pub fn parse(s: &str) -> Option<RunOutcome> {
186        Some(match s {
187            "pass" => RunOutcome::Passed,
188            "fail" => RunOutcome::Failed,
189            "warn" => RunOutcome::Warned,
190            "fixed" => RunOutcome::Fixed,
191            "unavailable" => RunOutcome::Unavailable,
192            "inert" => RunOutcome::Inert,
193            "cold" => RunOutcome::Cold,
194            "busy" => RunOutcome::Busy,
195            "skew" => RunOutcome::Skew,
196            "withheld" => RunOutcome::Withheld,
197            "cancelled" => RunOutcome::Cancelled,
198            "slow" => RunOutcome::Slow,
199            _ => return None,
200        })
201    }
202
203    /// Did this run reach a verdict about the content? Only these two are
204    /// evidence about whether a gate works — the rest are a gate declining to
205    /// judge, and counting them as passes is how a no-op looks healthy.
206    pub fn is_verdict(self) -> bool {
207        matches!(self, RunOutcome::Passed | RunOutcome::Failed)
208    }
209}
210
211/// One run of one gate: when, which, how it ended, how long it took.
212///
213/// Wall clock, in milliseconds, measured around the check's own `run` — not
214/// CPU time and not the hook's total, because the number this exists to catch
215/// is "the suite that used to take eleven minutes returned in 0.4 seconds",
216/// and that is a wall-clock claim.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct Run {
219    /// Seconds since the epoch, as the machine that ran it saw them.
220    pub at: u64,
221    pub gate: String,
222    pub outcome: RunOutcome,
223    pub ms: u64,
224}
225
226impl Run {
227    fn render(&self) -> String {
228        format!(
229            "{RUN} {} {} {} {}",
230            self.at,
231            self.gate,
232            self.outcome.as_str(),
233            self.ms
234        )
235    }
236
237    /// `run <epoch> <gate> <outcome> <ms>`, or nothing. Every field must be
238    /// there and parse; a half-understood line is dropped whole.
239    fn parse(line: &str) -> Option<Run> {
240        let mut t = line.split_whitespace();
241        if t.next() != Some(RUN) {
242            return None;
243        }
244        let at = t.next()?.parse().ok()?;
245        let gate = t.next()?.to_string();
246        let outcome = RunOutcome::parse(t.next()?)?;
247        let ms = t.next()?.parse().ok()?;
248        Some(Run {
249            at,
250            gate,
251            outcome,
252            ms,
253        })
254    }
255}
256
257/// A parsed note body: the skip tokens, and the runs recorded against this
258/// key.
259///
260/// THE reader and THE writer of the format, so that the three callers that
261/// rewrite a note cannot each drop a half of it they were not thinking
262/// about. `stamp_push` merging tokens used to render the note from its own
263/// token list alone, which — once runs existed — would have erased every one
264/// of them on the next push.
265#[derive(Debug, Clone, Default, PartialEq, Eq)]
266pub struct Note {
267    pub tokens: Vec<String>,
268    pub runs: Vec<Run>,
269}
270
271impl Note {
272    /// Parse a note body. A first line that is not ours yields an EMPTY note:
273    /// a note somebody else wrote into our ref vouches for nothing, and its
274    /// remaining lines are not evidence either.
275    pub fn parse(body: &str) -> Note {
276        let mut lines = body.lines();
277        let Some(first) = lines.next() else {
278            return Note::default();
279        };
280        let mut tokens = first.split_whitespace();
281        if tokens.next() != Some(FORMAT) {
282            return Note::default();
283        }
284        Note {
285            tokens: tokens.map(str::to_string).collect(),
286            runs: lines.filter_map(Run::parse).collect(),
287        }
288    }
289
290    /// The bytes to hand `git notes add -m`. Line one is exactly what it has
291    /// always been, so every reader that stops there sees no change at all.
292    pub fn render(&self) -> String {
293        let mut body = format!("{FORMAT} {}", self.tokens.join(" "));
294        for run in &self.runs {
295            body.push('\n');
296            body.push_str(&run.render());
297        }
298        body
299    }
300
301    fn add_tokens(&mut self, tokens: &[String]) {
302        for t in tokens {
303            if !self.tokens.iter().any(|have| have == t) {
304                self.tokens.push(t.clone());
305            }
306        }
307    }
308
309    /// Append runs, keeping the newest [`MAX_RUNS`].
310    fn add_runs(&mut self, runs: &[Run]) {
311        self.runs.extend(runs.iter().cloned());
312        if self.runs.len() > MAX_RUNS {
313            self.runs.drain(..self.runs.len() - MAX_RUNS);
314        }
315    }
316}
317
318/// The note at `key`, parsed. An absent note, an absent ref and a git that
319/// would not answer are all an empty note — the same direction everything
320/// here fails in.
321fn note_at(key: &str) -> Note {
322    note_in(NOTES_REF, key)
323}
324
325fn note_in(notes_ref: &str, key: &str) -> Note {
326    crate::git::stdout(&["notes", "--ref", notes_ref, "show", key])
327        .map(|body| Note::parse(&body))
328        .unwrap_or_default()
329}
330
331/// The tokens of the gates `tree`'s declared inputs vouch for: for each gate
332/// the spec at `tree` declares, the note filed under `K(gate, fingerprint)`.
333///
334/// Only a token written under that gate's own name is honoured from the note
335/// at its key: a note somebody else wrote into the ref cannot vouch for a
336/// gate it was not filed under. No spec, an invalid or ambiguous one, a gate
337/// with no fingerprint on `tree` (a required path missing), a git that would
338/// not answer: all contribute nothing, and nothing means the gate runs.
339fn input_tokens(tree: &str) -> Vec<String> {
340    let Some(spec) = crate::attest_inputs::spec_at(tree) else {
341        return canon_tokens(tree);
342    };
343    let mut out: Vec<String> = Vec::new();
344    for (gate, _) in &spec.gates {
345        if !manifest_is_an_input(tree, &spec, gate) {
346            continue;
347        }
348        let Some(fp) = crate::attest_inputs::fingerprint(tree, &spec, gate) else {
349            continue;
350        };
351        let Some(key) = crate::attest_inputs::input_key(gate, &fp) else {
352            continue;
353        };
354        for t in note_in(INPUTS_REF, &key).tokens {
355            if crate::gate_identity::base(&t) == gate.as_str() && !out.contains(&t) {
356                out.push(t);
357            }
358        }
359    }
360    out
361}
362
363/// The tokens `tree`'s canonical fingerprint vouches for: a repository that
364/// declares nothing is reused on any tree that differs only in the CONTENT of
365/// its plans (attest SPEC, "Canonical tree"). One note per fingerprint holds
366/// the tokens of every gate that passed on it, as the tree note does for a
367/// tree. A declared (even invalid) spec, an opt-out, or a git that would not
368/// answer: no canonical route, nothing contributed, the gate runs.
369fn canon_tokens(tree: &str) -> Vec<String> {
370    canon_key_of(tree)
371        .map(|key| note_in(INPUTS_REF, &key).tokens)
372        .unwrap_or_default()
373}
374
375/// The key of the canonical note of `tree`, if the canonical route is open.
376fn canon_key_of(tree: &str) -> Option<String> {
377    let canon = crate::attest_inputs::canonical_route(tree)?;
378    crate::attest_inputs::canon_gate_key(&canon)
379}
380
381/// File `tokens` under the canonical key of `tree`. Best-effort and merged,
382/// like [`file_inputs`]. The manifest needs no rule here: `amont.conf` is part
383/// of the canonical tree, so a changed declaration is a changed fingerprint.
384fn file_canon(tree: &str, tokens: &[String]) {
385    let Some(key) = canon_key_of(tree) else {
386        return;
387    };
388    let mut note = note_in(INPUTS_REF, &key);
389    note.add_tokens(tokens);
390    let _ = write_note_in(INPUTS_REF, &key, &note);
391}
392
393/// File `tokens` under the fingerprint keys of the gates `tree` declares, in
394/// addition to wherever the caller put them by tree; with no spec at all, under
395/// the canonical key. Best-effort, silent, and merged: a miss here is one
396/// redundant gate run, which is the safe direction.
397fn file_inputs(tree: &str, tokens: &[String]) {
398    if tokens.is_empty() {
399        return;
400    }
401    let Some(spec) = crate::attest_inputs::spec_at(tree) else {
402        file_canon(tree, tokens);
403        return;
404    };
405    for (gate, _) in &spec.gates {
406        let mine: Vec<String> = tokens
407            .iter()
408            .filter(|t| crate::gate_identity::base(t) == gate.as_str())
409            .cloned()
410            .collect();
411        if mine.is_empty() || !manifest_is_an_input(tree, &spec, gate) {
412            continue;
413        }
414        let Some(fp) = crate::attest_inputs::fingerprint(tree, &spec, gate) else {
415            continue;
416        };
417        let Some(key) = crate::attest_inputs::input_key(gate, &fp) else {
418            continue;
419        };
420        let mut note = note_in(INPUTS_REF, &key);
421        note.add_tokens(&mine);
422        let _ = write_note_in(INPUTS_REF, &key, &note);
423    }
424}
425
426/// May a gate's stamp travel by fingerprint on `tree`? A gate's scope and its
427/// opt-ins live in the manifest, and under a whole-tree key a manifest change
428/// was a tree change; under a fingerprint it is one only if the gate declares
429/// the manifest. So a tree that tracks `amont.conf` offers the route only to a
430/// gate whose declared inputs cover it, and a scope-only edit then misses.
431fn manifest_is_an_input(tree: &str, spec: &crate::attest_inputs::Spec, gate: &str) -> bool {
432    let tracked = crate::git::succeeds(&[
433        "cat-file",
434        "-e",
435        &format!("{tree}:{}", crate::manifest::MANIFEST),
436    ]);
437    !tracked || crate::attest_inputs::declares(spec, gate, crate::manifest::MANIFEST)
438}
439
440/// Does the inputs ref exist here? One spawn that lets a repository nothing
441/// was ever filed for (an opted-out one, or one whose stamps never passed)
442/// skip the fingerprint and canonical routes entirely on the push path.
443fn inputs_ref_exists() -> bool {
444    crate::git::succeeds(&["rev-parse", "-q", "--verify", INPUTS_FULL_REF])
445}
446
447/// The stamp tokens on `tree` (a tree object id). Tree gates are read here
448/// and only here (ADR-0024): `tree:<name>` on the pushed tree proves the gate
449/// on exactly that content, whichever commit carried it.
450pub fn tree_tokens(tree: &str) -> Vec<String> {
451    let mut tokens = note_at(tree).tokens;
452    if inputs_ref_exists() {
453        for t in input_tokens(tree) {
454            if !tokens.contains(&t) {
455                tokens.push(t);
456            }
457        }
458    }
459    tokens
460}
461
462/// Add `tokens` to the gate note on `tree` — how a rehearsal records tree
463/// gates it proved in its snapshot of exactly that tree. Merged, never
464/// rendered from the tokens alone, so evidence already on the note survives.
465pub fn stamp_tree(tree: &str, tokens: &[String]) -> bool {
466    let mut note = note_at(tree);
467    note.add_tokens(tokens);
468    let ok = write_note(tree, &note);
469    file_inputs(tree, tokens);
470    ok
471}
472
473/// Write `note` at `key`. Best-effort, like every writer here.
474fn write_note(key: &str, note: &Note) -> bool {
475    write_note_in(NOTES_REF, key, note)
476}
477
478fn write_note_in(notes_ref: &str, key: &str, note: &Note) -> bool {
479    let body = note.render();
480    crate::git::succeeds(&["notes", "--ref", notes_ref, "add", "-f", "-m", &body, key])
481}
482
483/// Record what a gate run COST and how it ENDED, against the content it ran
484/// on.
485///
486/// Evidence only: nothing in this module reads these back to decide whether a
487/// check may be skipped, which is why recording a FAILED run — the case the
488/// stamp path deliberately has no opinion about — is safe here.
489///
490/// Best-effort and silent. A note git refused costs a row in a report nobody
491/// is blocked on; warning about it on every push would train people to ignore
492/// the warnings that do gate something.
493pub fn record_runs(key: &str, runs: &[Run]) {
494    // A gate name with whitespace in it would forge a second field on read.
495    // Refused rather than escaped: every id this repository can produce is
496    // already free of it, so the escaping would be a code path no real input
497    // ever reaches — untested by construction.
498    let runs: Vec<Run> = runs
499        .iter()
500        .filter(|r| !r.gate.is_empty() && !r.gate.contains(char::is_whitespace))
501        .cloned()
502        .collect();
503    if runs.is_empty() {
504        return;
505    }
506    let mut note = note_at(key);
507    note.add_runs(&runs);
508    let _ = write_note(key, &note);
509}
510
511/// `$GIT_DIR/amont-gate` — the worktree-PRIVATE gitdir, deliberately: the
512/// commit this marker waits for happens in this worktree. The stamps the
513/// marker becomes live in the common dir (a notes ref) and are shared.
514fn marker_path() -> Option<PathBuf> {
515    let dir = crate::git::stdout(&["rev-parse", "--git-dir"])?;
516    Some(std::path::Path::new(&dir).join(MARKER))
517}
518
519/// pre-commit: record that `scripts` ran clean against the tree the commit
520/// will carry.
521///
522/// Called on EVERY pre-commit verdict, with an empty list when nothing
523/// qualifying ran (or the commit is about to be blocked) — an aborted or
524/// unchecked attempt must not inherit a previous attempt's marker.
525///
526/// Best-effort throughout: a failure to record costs one redundant gate run
527/// at push time, which is the safe direction, and a pre-commit that failed a
528/// COMMIT over bookkeeping would be the tail wagging the dog.
529pub fn record(scripts: &[&str]) {
530    let Some(path) = marker_path() else { return };
531    if scripts.is_empty() {
532        let _ = std::fs::remove_file(&path);
533        return;
534    }
535    // The index, as the object id `git commit` is about to seal. Inherits
536    // `GIT_INDEX_FILE`, so `git commit -a`'s temporary index answers here
537    // too. Pure read of the index: writes objects, touches no ref.
538    let Some(tree) = crate::git::stdout(&["write-tree"]) else {
539        // Not "nothing ran": git could not name the tree, so nothing may be
540        // vouched for. Dropping the marker is the fail-safe half (the gate
541        // re-runs at push); saying so is the half that was missing.
542        crate::hooks::common::warn(
543            "git would not name the staged tree — this commit records no gate stamp",
544        );
545        let _ = std::fs::remove_file(&path);
546        return;
547    };
548    let mut body = format!("{FORMAT}\n{tree}\n");
549    for s in scripts {
550        body.push_str(s);
551        body.push('\n');
552    }
553    let _ = std::fs::write(&path, body);
554}
555
556/// pre-commit, before a gate runs: the scripts a previous run already
557/// vouched for against the tree this commit is about to seal.
558///
559/// Two records answer, both bound to the TREE (`git write-tree` of the index
560/// under the staged-only hold — the commit's content, not the working
561/// tree):
562///
563/// 1. the one-shot marker [`record`] left behind. post-commit consumes it,
564///    so a marker that is still there belongs to an attempt that never
565///    reached post-commit — a `commit-msg` refusal, an editor closed on an
566///    empty message, a Ctrl-C at the prompt. The gates ran, the tree is the
567///    same, the verdict stands: measured on this machine, a subject three
568///    characters too long replayed a ten-minute suite for nothing.
569/// 2. the tree note [`bind_to_head`] writes — a reword, a `reset --soft` and
570///    re-commit, or a rebase that kept the tree.
571///
572/// Every failure mode reads as "nothing is vouched for", which runs the
573/// gate: no `write-tree`, a marker for another tree, a wrong format, a git
574/// that would not answer. Reuse can only ever skip a run the record proves
575/// happened on this exact content; it can never let unjudged content
576/// through — the direction every function in this module fails in.
577///
578/// Repo-controlled inputs to the gate (its command line, its scope) live in
579/// the manifest, which is part of the tree: a changed declaration is a
580/// changed tree, and nothing is reused by tree. A gate reused by fingerprint
581/// gets the same guarantee only if it declares the manifest as an input
582/// ([`manifest_is_an_input`]); otherwise it is not offered the route at all.
583pub fn vouched_for_staged_tree() -> HashSet<String> {
584    let mut out = HashSet::new();
585    let Some(tree) = crate::git::stdout(&["write-tree"]) else {
586        return out;
587    };
588    if let Some(path) = marker_path() {
589        if let Ok(body) = std::fs::read_to_string(&path) {
590            let mut lines = body.lines();
591            if lines.next() == Some(FORMAT) && lines.next() == Some(tree.as_str()) {
592                out.extend(lines.filter(|l| !l.trim().is_empty()).map(str::to_string));
593            }
594        }
595    }
596    // The tree note: `notes show` fails loudly on an absent note, and a
597    // failure here is simply "no note" — the marker's answer stands alone.
598    // Tokens are `<name>@<identity>` (`gate_identity`): commit-time ones are
599    // named by script, push-time ones by full id (`pre-push-…`), which no
600    // pre-commit declaration is named after — harmless in the set, and a
601    // reader skips only on the complete token. The note's `run` lines are evidence and are not
602    // tokens: `Note` keeps the two apart so nothing here can be skipped by a
603    // line that only records how long something took.
604    out.extend(note_at(&tree).tokens);
605    // A gate the repository declares inputs for is also vouched for by any
606    // earlier tree whose declared inputs are byte-identical to this staged
607    // tree's: a plan-only commit changes the tree and none of them. The tree
608    // here is the INDEX's (`write-tree`), so partial staging is judged on what
609    // is about to be committed, not on the working copy.
610    if inputs_ref_exists() {
611        out.extend(input_tokens(&tree));
612    }
613    out
614}
615
616/// post-commit: consume the marker; stamp HEAD when the tree still matches.
617///
618/// One-shot by construction — the marker is deleted before anything is
619/// judged, so no path through here can leave it to vouch for a later commit.
620///
621/// Returns the scripts it actually stamped (empty on every no-stamp path,
622/// including a note git refused). The caller subtracts this from what the
623/// manifest declares to learn what the commit dodged — [`crate::bypass`]
624/// keeps that count. Two records, two questions: the stamp gates a check,
625/// the ledger only counts.
626pub fn bind_to_head() -> Vec<String> {
627    let Some(path) = marker_path() else {
628        return Vec::new();
629    };
630    let Ok(body) = std::fs::read_to_string(&path) else {
631        return Vec::new(); // no marker: nothing ran at pre-commit, nothing to stamp
632    };
633    let _ = std::fs::remove_file(&path);
634    let mut lines = body.lines();
635    if lines.next() != Some(FORMAT) {
636        return Vec::new();
637    }
638    let Some(tree) = lines.next() else {
639        return Vec::new();
640    };
641    let scripts: Vec<&str> = lines.filter(|l| !l.trim().is_empty()).collect();
642    if scripts.is_empty() {
643        return Vec::new();
644    }
645    let Some(head_tree) = crate::git::stdout(&["rev-parse", "HEAD^{tree}"]) else {
646        crate::hooks::common::warn(
647            "git would not name this commit's tree — no gate stamp was written",
648        );
649        return Vec::new();
650    };
651    // A different tree means this commit is not the one pre-commit judged —
652    // the marker is a dead letter from an aborted attempt.
653    if head_tree != tree {
654        return Vec::new();
655    }
656    let scripts: Vec<String> = scripts.iter().map(|s| s.to_string()).collect();
657    // The stamp goes on the TREE as well as the commit, and the tree is the
658    // one that survives the way work actually reaches `main`.
659    //
660    // A squash-merge is performed by the forge: it produces a commit nobody
661    // here ever saw, carrying no note, so a later `git push` of a tag on
662    // that commit re-ran every gate the branch had already proved. Measured
663    // on this repository: a branch push took 13 seconds and the tag push
664    // that followed took the full suite and died on a reset connection.
665    //
666    // The tree is identical across that merge whenever the base has not
667    // moved — five of five merges in one afternoon here — and identical
668    // trees are identical CONTENT, which is the only thing a test suite
669    // reads. That is the same argument `attest` makes for signing the tree
670    // rather than the commit, and this module's marker has been tree-bound
671    // since it was written; this only carries the binding through to the
672    // note.
673    //
674    // Both, not either: the commit note is what a `git log --notes` reader
675    // sees, and dropping it would make the stamps invisible in the place
676    // people look for them.
677    // MERGED into whatever is there, never rendered from the scripts alone:
678    // a pre-push run against this same tree may already have recorded
679    // evidence lines on it, and an `add -f` built from this list would erase
680    // them.
681    let mut tree_note = note_at(&head_tree);
682    tree_note.add_tokens(&scripts);
683    let _ = write_note(&head_tree, &tree_note);
684    file_inputs(&head_tree, &scripts);
685    let mut head_note = note_at("HEAD");
686    head_note.add_tokens(&scripts);
687    if !write_note("HEAD", &head_note) {
688        // A note git refused is not a stamp — and the push will re-run these
689        // checks, which is right but looks arbitrary unless it is said.
690        crate::hooks::common::warn(
691            "git refused to write the gate stamp — these checks will run again at push",
692        );
693        return Vec::new();
694    }
695    // The ledger counts NAMES: what the commit dodged is judged against what
696    // the manifest declares, and the identity is the stamp's business alone.
697    scripts
698        .iter()
699        .map(|s| crate::gate_identity::base(s).to_string())
700        .collect()
701}
702
703/// pre-push, after every block gate passed: record that `gates` passed
704/// against `tree`, the content of `commit` — MERGING with whatever the note
705/// already says, because a commit-time stamp (`test`) may already sit there
706/// and `notes add -f` would otherwise erase it.
707///
708/// Best-effort, like every writer here: a stamp that could not be written
709/// costs one redundant gate run on the next push, which is the safe
710/// direction. Returns whether the commit's note was written.
711pub fn stamp_push(commit: &str, tree: &str, gates: &[String]) -> bool {
712    if gates.is_empty() {
713        return false;
714    }
715    let mut written = false;
716    for key in [tree, commit] {
717        let mut note = note_at(key);
718        note.add_tokens(gates);
719        let ok = write_note(key, &note);
720        if key == commit {
721            written = ok;
722        }
723    }
724    file_inputs(tree, gates);
725    if !written {
726        crate::hooks::common::warn(
727            "git refused to write the push stamp — these gates will run again on the next push",
728        );
729    }
730    written
731}
732
733/// pre-push: which of `commits` carry a stamp, and for which scripts.
734///
735/// One `notes list` narrows the reads to commits that have a note at all;
736/// absent ref, unparseable note, wrong format version — all read as "no
737/// stamp", which re-runs the gate.
738pub fn stamps_for(commits: &[String]) -> HashMap<String, Vec<String>> {
739    let mut out = HashMap::new();
740    if commits.is_empty() {
741        return out;
742    }
743    let Some(list) = crate::git::stdout(&["notes", "--ref", NOTES_REF, "list"]) else {
744        // NOT the absent-ref case, whatever an older comment here claimed:
745        // `notes list` exits 0 with empty output when the ref does not exist,
746        // so that arrives as `Some("")` and falls through as "nothing is
747        // stamped" — correctly. Reaching HERE means git could not answer at
748        // all. Same verdict (the gates re-run: never skip work on a question
749        // we could not ask), different sentence, because a transient git
750        // failure that reads as "nothing is stamped" is indistinguishable
751        // from the real thing — which is exactly how one flaky spawn cost a
752        // day of not-diagnosing.
753        crate::hooks::common::warn(
754            "git would not list the gate stamps — every gated check will run again",
755        );
756        return out;
757    };
758    let noted: HashSet<&str> = list
759        .lines()
760        .filter_map(|l| l.split_whitespace().nth(1))
761        .collect();
762    // Commit -> tree, in ONE spawn rather than one per commit: this is the
763    // push path, and a `rev-parse` each would be a process per pushed
764    // commit to answer a question `git log` answers in a batch.
765    let mut trees: HashMap<String, String> = HashMap::new();
766    {
767        let mut args: Vec<&str> = vec!["log", "--no-walk", "--format=%H %T"];
768        args.extend(commits.iter().map(String::as_str));
769        if let Some(out) = crate::git::stdout(&args) {
770            for line in out.lines() {
771                let mut it = line.split_whitespace();
772                if let (Some(c), Some(t)) = (it.next(), it.next()) {
773                    trees.insert(c.to_string(), t.to_string());
774                }
775            }
776        }
777        // No mapping is not an error: every commit simply falls back to the
778        // commit-keyed lookup below, which is what happened before trees
779        // were stamped at all.
780    }
781
782    // Declared-input stamps are keyed by fingerprint, not by tree, so a commit
783    // whose tree carries no note can still be vouched for. Asked only when the
784    // ref exists: a repository nothing was filed for pays one spawn. Once
785    // stamps are filed (declared or canonical), each pushed commit pays the
786    // few git spawns of its route.
787    let by_inputs = inputs_ref_exists();
788
789    for commit in commits {
790        // The commit's own note AND its TREE's, merged. Both are written only
791        // for that tree (`bind_to_head` checks `HEAD^{tree}`, `stamp_push` is
792        // handed the commit's own tree), so every token on either vouches for
793        // exactly this content.
794        //
795        // Not "the commit's note, else the tree's". A squash-merge produces a
796        // commit this machine never saw — no note — while the content, and
797        // therefore the tree, is the one the gates ran on, and falling through
798        // to the tree is what lets a tag push on a merged commit skip work the
799        // branch already proved. But the same preference hid the tree's push
800        // tokens behind a commit note after an AMEND that kept the tree:
801        // post-commit wrote a commit note holding only the commit-time
802        // scripts, `stamps_for` then stopped there, and the push gate ran
803        // again for content it had already judged.
804        //
805        // The direction of failure is unchanged: no note on either, an
806        // unparseable one, or a git that would not answer all mean "no
807        // stamp", and no stamp runs the gate.
808        let mut tokens: Vec<String> = Vec::new();
809        let mut keys: Vec<&str> = Vec::new();
810        if noted.contains(commit.as_str()) {
811            keys.push(commit);
812        }
813        if let Some(tree) = trees.get(commit).filter(|t| noted.contains(t.as_str())) {
814            keys.push(tree);
815        }
816        for key in keys {
817            for t in note_at(key).tokens {
818                if !tokens.contains(&t) {
819                    tokens.push(t);
820                }
821            }
822        }
823        if by_inputs {
824            if let Some(tree) = trees.get(commit) {
825                for t in input_tokens(tree) {
826                    if !tokens.contains(&t) {
827                        tokens.push(t);
828                    }
829                }
830            }
831        }
832        if tokens.is_empty() {
833            continue;
834        }
835        out.insert(commit.clone(), tokens);
836    }
837    out
838}
839
840/// uninstall: forget everything this module ever wrote here.
841///
842/// The stamps are OUR bookkeeping — unlike `hook.skip` and `amont.severity`,
843/// which are the user's statements and are never touched.
844pub fn forget() -> bool {
845    let marker = marker_path().is_some_and(|path| std::fs::remove_file(&path).is_ok());
846    let notes = crate::git::succeeds(&["update-ref", "-d", NOTES_FULL_REF]);
847    let inputs = crate::git::succeeds(&["update-ref", "-d", INPUTS_FULL_REF]);
848    marker || notes || inputs
849}
850
851/// The same, for a repository this process is not standing in.
852pub fn forget_in(repo: &std::path::Path) -> bool {
853    let marker = crate::git::stdout_in(repo, &["rev-parse", "--absolute-git-dir"])
854        .is_some_and(|dir| std::fs::remove_file(std::path::Path::new(&dir).join(MARKER)).is_ok());
855    let notes = crate::git::succeeds_in(repo, &["update-ref", "-d", NOTES_FULL_REF]);
856    let inputs = crate::git::succeeds_in(repo, &["update-ref", "-d", INPUTS_FULL_REF]);
857    marker || notes || inputs
858}
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863    use std::path::Path;
864
865    /// A real repository, because every function here is a conversation with
866    /// git — hand-rolled fixtures would test the conversation we imagined.
867    fn repo(name: &str) -> PathBuf {
868        let dir = std::env::temp_dir().join(format!("gate-stamp-{name}-{}", std::process::id()));
869        let _ = std::fs::remove_dir_all(&dir);
870        std::fs::create_dir_all(&dir).unwrap();
871        git(&dir, &["init", "-q", "--template=", "."]);
872        git(&dir, &["config", "user.email", "t@t.test"]);
873        git(&dir, &["config", "user.name", "t"]);
874        dir
875    }
876
877    /// A fixture git call that FAILS where it fails.
878    ///
879    /// This used to discard the exit status, and that is how a rare flake
880    /// stayed unreadable for a day: if the setup `git commit` did not
881    /// happen, the test carried on to an unborn HEAD, and the panic landed
882    /// three lines later on a missing gate stamp — a product-shaped
883    /// failure for a fixture-shaped cause. Same rule the checks obey:
884    /// git failing is not git answering.
885    fn git(dir: &Path, args: &[&str]) -> String {
886        let out = std::process::Command::new("git")
887            .arg("-C")
888            .arg(dir)
889            .args(args)
890            .output()
891            .expect("git");
892        assert!(
893            out.status.success(),
894            "fixture: git {args:?} in {} exited {:?}: {}",
895            dir.display(),
896            out.status.code(),
897            String::from_utf8_lossy(&out.stderr).trim()
898        );
899        String::from_utf8_lossy(&out.stdout).trim().to_string()
900    }
901
902    /// The module talks to the repo at the process cwd; these tests each set
903    /// it. Serialised via the crate-wide lock, because cwd is process-global
904    /// and `attest`'s tests move it too.
905    fn in_repo<T>(dir: &Path, f: impl FnOnce() -> T) -> T {
906        let _guard = crate::TEST_CWD.lock().unwrap_or_else(|p| p.into_inner());
907        let prev = std::env::current_dir().unwrap();
908        std::env::set_current_dir(dir).unwrap();
909        let r = f();
910        std::env::set_current_dir(prev).unwrap();
911        r
912    }
913
914    #[test]
915    fn the_commit_note_does_not_hide_the_tree_notes_push_tokens() {
916        let dir = repo("union");
917        std::fs::write(dir.join("a.ts"), "x").unwrap();
918        git(&dir, &["add", "a.ts"]);
919        in_repo(&dir, || {
920            record(&["test"]);
921            git(&dir, &["commit", "-qm", "chore: a"]);
922            bind_to_head();
923            let head = git(&dir, &["rev-parse", "HEAD"]);
924            let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
925            // A push-time stamp lands on the TREE only (the shape a rehearsal
926            // leaves), while the commit note keeps just the commit-time script.
927            assert!(stamp_tree(&tree, &["pre-push-cargo-test".to_string()]));
928            assert_eq!(note_at(&head).tokens, vec!["test".to_string()]);
929            let stamps = stamps_for(std::slice::from_ref(&head));
930            assert_eq!(
931                stamps.get(&head).map(Vec::as_slice),
932                Some(&["test".to_string(), "pre-push-cargo-test".to_string()][..]),
933                "both notes speak for the same tree"
934            );
935        });
936        let _ = std::fs::remove_dir_all(&dir);
937    }
938
939    #[test]
940    fn a_commit_with_only_a_tree_note_still_reads_the_tree_tokens() {
941        let dir = repo("tree-only");
942        std::fs::write(dir.join("a.ts"), "x").unwrap();
943        git(&dir, &["add", "a.ts"]);
944        git(&dir, &["commit", "-qm", "chore: a"]);
945        in_repo(&dir, || {
946            let head = git(&dir, &["rev-parse", "HEAD"]);
947            let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
948            assert!(stamp_tree(&tree, &["pre-push-cargo-test".to_string()]));
949            let stamps = stamps_for(std::slice::from_ref(&head));
950            assert_eq!(
951                stamps.get(&head).map(Vec::as_slice),
952                Some(&["pre-push-cargo-test".to_string()][..])
953            );
954        });
955        let _ = std::fs::remove_dir_all(&dir);
956    }
957
958    #[test]
959    fn a_commit_with_only_a_commit_note_still_reads_its_tokens() {
960        let dir = repo("commit-only");
961        std::fs::write(dir.join("a.ts"), "x").unwrap();
962        git(&dir, &["add", "a.ts"]);
963        git(&dir, &["commit", "-qm", "chore: a"]);
964        in_repo(&dir, || {
965            let head = git(&dir, &["rev-parse", "HEAD"]);
966            let mut note = Note::default();
967            note.add_tokens(&["typecheck".to_string()]);
968            assert!(write_note(&head, &note));
969            let stamps = stamps_for(std::slice::from_ref(&head));
970            assert_eq!(
971                stamps.get(&head).map(Vec::as_slice),
972                Some(&["typecheck".to_string()][..])
973            );
974        });
975        let _ = std::fs::remove_dir_all(&dir);
976    }
977
978    #[test]
979    fn a_token_on_both_notes_is_read_once() {
980        let dir = repo("dedup");
981        std::fs::write(dir.join("a.ts"), "x").unwrap();
982        git(&dir, &["add", "a.ts"]);
983        in_repo(&dir, || {
984            record(&["test"]);
985            git(&dir, &["commit", "-qm", "chore: a"]);
986            bind_to_head(); // writes `test` on the commit AND the tree
987            let head = git(&dir, &["rev-parse", "HEAD"]);
988            let stamps = stamps_for(std::slice::from_ref(&head));
989            assert_eq!(
990                stamps.get(&head).map(Vec::as_slice),
991                Some(&["test".to_string()][..])
992            );
993        });
994        let _ = std::fs::remove_dir_all(&dir);
995    }
996
997    #[test]
998    fn a_recorded_marker_becomes_a_stamp_on_the_matching_commit() {
999        let dir = repo("roundtrip");
1000        std::fs::write(dir.join("a.ts"), "x").unwrap();
1001        git(&dir, &["add", "a.ts"]);
1002        in_repo(&dir, || {
1003            record(&["typecheck", "test"]);
1004            git(&dir, &["commit", "-qm", "chore: a"]);
1005            let stamped = bind_to_head();
1006            assert_eq!(
1007                stamped,
1008                vec!["typecheck".to_string(), "test".to_string()],
1009                "bind_to_head reports the scripts it stamped"
1010            );
1011            let head = git(&dir, &["rev-parse", "HEAD"]);
1012            let stamps = stamps_for(std::slice::from_ref(&head));
1013            assert_eq!(
1014                stamps.get(&head).map(Vec::as_slice),
1015                Some(&["typecheck".to_string(), "test".to_string()][..])
1016            );
1017            // One-shot: the marker is gone.
1018            // One-shot: the marker is gone. The fixture's own path, not
1019            // `marker_path()` — that helper spawns git, and a transient
1020            // spawn failure on a loaded runner reads as `None` here while
1021            // production code correctly treats it as "no marker". Seen once,
1022            // on Windows, as an unwrap panic in a sibling test.
1023            assert!(!dir.join(".git").join(MARKER).exists());
1024        });
1025        let _ = std::fs::remove_dir_all(&dir);
1026    }
1027
1028    #[test]
1029    fn a_marker_for_a_different_tree_stamps_nothing() {
1030        let dir = repo("stale");
1031        std::fs::write(dir.join("a.ts"), "x").unwrap();
1032        git(&dir, &["add", "a.ts"]);
1033        in_repo(&dir, || {
1034            record(&["typecheck"]);
1035            // The commit that actually lands carries DIFFERENT content — the
1036            // aborted-attempt-then-different-retry shape.
1037            std::fs::write(dir.join("a.ts"), "y").unwrap();
1038            git(&dir, &["add", "a.ts"]);
1039            git(&dir, &["commit", "-qm", "chore: different"]);
1040            assert!(
1041                bind_to_head().is_empty(),
1042                "bind_to_head reports nothing when the tree moved"
1043            );
1044            let head = git(&dir, &["rev-parse", "HEAD"]);
1045            assert!(
1046                stamps_for(&[head]).is_empty(),
1047                "a stale marker must not vouch"
1048            );
1049            assert!(
1050                !dir.join(".git").join(MARKER).exists(),
1051                "consumed either way"
1052            );
1053        });
1054        let _ = std::fs::remove_dir_all(&dir);
1055    }
1056
1057    #[test]
1058    fn an_empty_record_clears_a_previous_marker() {
1059        let dir = repo("clears");
1060        std::fs::write(dir.join("a.ts"), "x").unwrap();
1061        git(&dir, &["add", "a.ts"]);
1062        in_repo(&dir, || {
1063            record(&["typecheck"]);
1064            assert!(dir.join(".git").join(MARKER).exists());
1065            record(&[]);
1066            assert!(!dir.join(".git").join(MARKER).exists());
1067        });
1068        let _ = std::fs::remove_dir_all(&dir);
1069    }
1070
1071    /// The version guard's REJECT branch, fed a hand-written marker: an old
1072    /// (or future) format is ignored rather than misread — the doc's claim,
1073    /// now pinned. Every other test's markers come from record() itself and
1074    /// so always carry the current FORMAT.
1075    #[test]
1076    fn a_marker_in_an_unknown_format_stamps_nothing() {
1077        let dir = repo("wrongformat");
1078        std::fs::write(dir.join("a.ts"), "x").unwrap();
1079        git(&dir, &["add", "a.ts"]);
1080        in_repo(&dir, || {
1081            let tree = git(&dir, &["write-tree"]);
1082            let marker = dir.join(".git").join(MARKER);
1083            std::fs::write(&marker, format!("amont-gate-v99\n{tree}\ntypecheck\n")).unwrap();
1084            git(&dir, &["commit", "-qm", "chore: a"]);
1085            bind_to_head();
1086            let head = git(&dir, &["rev-parse", "HEAD"]);
1087            assert!(
1088                stamps_for(std::slice::from_ref(&head)).is_empty(),
1089                "an unknown format was trusted"
1090            );
1091            assert!(!marker.exists(), "consumed either way");
1092        });
1093        let _ = std::fs::remove_dir_all(&dir);
1094    }
1095
1096    /// A note somebody else wrote into OUR ref is not a stamp. Absent this,
1097    /// `git notes --ref=amont-gate add` would be a one-line way to vouch for
1098    /// an unchecked commit — the parsing trust boundary of the whole chain.
1099    #[test]
1100    fn a_foreign_note_is_not_a_stamp() {
1101        let dir = repo("foreignnote");
1102        std::fs::write(dir.join("a.ts"), "x").unwrap();
1103        git(&dir, &["add", "a.ts"]);
1104        in_repo(&dir, || {
1105            git(&dir, &["commit", "-qm", "chore: a"]);
1106            git(
1107                &dir,
1108                &[
1109                    "notes",
1110                    "--ref",
1111                    NOTES_REF,
1112                    "add",
1113                    "-m",
1114                    "typecheck test",
1115                    "HEAD",
1116                ],
1117            );
1118            let head = git(&dir, &["rev-parse", "HEAD"]);
1119            assert!(
1120                stamps_for(std::slice::from_ref(&head)).is_empty(),
1121                "a note without the format token was trusted"
1122            );
1123        });
1124        let _ = std::fs::remove_dir_all(&dir);
1125    }
1126
1127    /// An absent notes ref is `Some("")`, not `None` — the distinction the
1128    /// warning on that branch depends on. If git ever starts failing here
1129    /// instead, this test fails and the warning stops being a lie.
1130    #[test]
1131    fn a_repo_with_no_stamps_answers_emptily_rather_than_failing() {
1132        let dir = repo("no-stamps");
1133        std::fs::write(dir.join("a.ts"), "x").unwrap();
1134        git(&dir, &["add", "a.ts"]);
1135        git(&dir, &["commit", "-qm", "chore: a"]);
1136        in_repo(&dir, || {
1137            assert_eq!(
1138                crate::git::stdout(&["notes", "--ref", NOTES_REF, "list"]).as_deref(),
1139                Some(""),
1140                "an absent notes ref must be an ANSWER, not a failure — the \
1141                 no-stamps path and the git-is-broken path are told apart by it"
1142            );
1143            let head = git(&dir, &["rev-parse", "HEAD"]);
1144            assert!(stamps_for(&[head]).is_empty());
1145        });
1146        let _ = std::fs::remove_dir_all(&dir);
1147    }
1148
1149    /// The compatibility promise, in both directions.
1150    ///
1151    /// A note written before evidence lines existed is ONE line, and it must
1152    /// parse to exactly the tokens it always did — every skip decision in
1153    /// this module reads that line, and a reader that needed the new shape
1154    /// would stop honouring every stamp on every machine the day it shipped.
1155    #[test]
1156    fn a_note_from_before_the_run_lines_parses_unchanged() {
1157        let note = Note::parse("amont-gate-v1 typecheck test");
1158        assert_eq!(note.tokens, vec!["typecheck", "test"]);
1159        assert!(note.runs.is_empty());
1160        // And round-trips to the same bytes, so an old amont reading a note
1161        // this one rewrote sees what it wrote.
1162        assert_eq!(note.render(), "amont-gate-v1 typecheck test");
1163    }
1164
1165    /// The new shape: line one unchanged, evidence after it.
1166    #[test]
1167    fn run_lines_are_read_without_disturbing_the_tokens() {
1168        let body = "amont-gate-v1 pre-push-cargo-test\n\
1169                    run 1726900000 pre-push-cargo-test pass 412391\n\
1170                    run 1726903600 pre-push-audit-js fail 903\n";
1171        let note = Note::parse(body);
1172        assert_eq!(note.tokens, vec!["pre-push-cargo-test"]);
1173        assert_eq!(note.runs.len(), 2);
1174        assert_eq!(
1175            note.runs[0],
1176            Run {
1177                at: 1_726_900_000,
1178                gate: "pre-push-cargo-test".into(),
1179                outcome: RunOutcome::Passed,
1180                ms: 412_391,
1181            }
1182        );
1183        assert_eq!(note.runs[1].outcome, RunOutcome::Failed);
1184        assert_eq!(note.render(), body.trim_end());
1185    }
1186
1187    /// A line this version does not understand is dropped, not guessed at —
1188    /// so a future amont may add a field (or an outcome) without an older one
1189    /// inventing a meaning for it.
1190    #[test]
1191    fn an_unreadable_run_line_is_dropped_and_the_rest_survives() {
1192        let note = Note::parse(
1193            "amont-gate-v1 test\n\
1194             run 1726900000 pre-push-cargo-test pass 400\n\
1195             run tomorrow pre-push-cargo-test pass 400\n\
1196             run 1726900001 pre-push-cargo-test sideways 400\n\
1197             banana\n\
1198             run 1726900002 pre-push-cargo-test fail 500\n",
1199        );
1200        assert_eq!(note.tokens, vec!["test"]);
1201        assert_eq!(note.runs.len(), 2, "{:?}", note.runs);
1202        assert_eq!(note.runs[1].outcome, RunOutcome::Failed);
1203    }
1204
1205    /// A note somebody else wrote into our ref is not evidence either. The
1206    /// token line is the trust boundary for BOTH halves of the note.
1207    #[test]
1208    fn a_foreign_note_yields_no_runs() {
1209        let note = Note::parse("hello\nrun 1726900000 pre-push-cargo-test pass 400\n");
1210        assert!(note.tokens.is_empty() && note.runs.is_empty());
1211    }
1212
1213    /// Recording evidence must never cost a stamp. `stamp_push` and
1214    /// `bind_to_head` rewrite the same note, and the first version of this
1215    /// rendered the note from its own token list — which would have erased
1216    /// every run line on the next push.
1217    #[test]
1218    fn a_stamp_and_its_evidence_survive_each_other() {
1219        let dir = repo("evidence");
1220        std::fs::write(dir.join("a.ts"), "x").unwrap();
1221        git(&dir, &["add", "a.ts"]);
1222        in_repo(&dir, || {
1223            git(&dir, &["commit", "-qm", "chore: a"]);
1224            let head = git(&dir, &["rev-parse", "HEAD"]);
1225            let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1226            record_runs(
1227                &tree,
1228                &[Run {
1229                    at: 1_726_900_000,
1230                    gate: "pre-push-cargo-test".into(),
1231                    outcome: RunOutcome::Failed,
1232                    ms: 412_391,
1233                }],
1234            );
1235            // …and now a later push of the same content passes and stamps it.
1236            assert!(stamp_push(&head, &tree, &["pre-push-cargo-test".into()]));
1237            let note = note_at(&tree);
1238            assert_eq!(note.tokens, vec!["pre-push-cargo-test"]);
1239            assert_eq!(note.runs.len(), 1, "the evidence survived the stamp");
1240            // The stamp still reads back as a stamp.
1241            let stamps = stamps_for(std::slice::from_ref(&head));
1242            assert_eq!(
1243                stamps.get(&head).map(Vec::as_slice),
1244                Some(&["pre-push-cargo-test".to_string()][..])
1245            );
1246        });
1247        let _ = std::fs::remove_dir_all(&dir);
1248    }
1249
1250    /// The whole point of the evidence half: it is not a stamp. A recorded
1251    /// run — even a passing one — must not let the gate be skipped, because
1252    /// skipping is decided by the token line and nothing else.
1253    #[test]
1254    fn a_recorded_run_is_not_a_stamp() {
1255        let dir = repo("not-a-stamp");
1256        std::fs::write(dir.join("a.ts"), "x").unwrap();
1257        git(&dir, &["add", "a.ts"]);
1258        in_repo(&dir, || {
1259            git(&dir, &["commit", "-qm", "chore: a"]);
1260            let head = git(&dir, &["rev-parse", "HEAD"]);
1261            let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1262            record_runs(
1263                &tree,
1264                &[Run {
1265                    at: 1_726_900_000,
1266                    gate: "pre-push-cargo-test".into(),
1267                    outcome: RunOutcome::Passed,
1268                    ms: 412_391,
1269                }],
1270            );
1271            assert!(
1272                stamps_for(std::slice::from_ref(&head)).is_empty(),
1273                "a run line vouched for a gate — evidence must never gate"
1274            );
1275            assert!(
1276                !vouched_for_staged_tree().contains("pre-push-cargo-test"),
1277                "a run line vouched at commit time"
1278            );
1279            // It IS readable as history, though.
1280            let history = crate::gate_evidence::history_in(&dir);
1281            assert_eq!(history.runs.len(), 1);
1282            assert_eq!(history.runs[0].0, tree, "keyed by the content it ran on");
1283        });
1284        let _ = std::fs::remove_dir_all(&dir);
1285    }
1286
1287    #[test]
1288    fn forget_removes_the_stamps() {
1289        let dir = repo("forget");
1290        std::fs::write(dir.join("a.ts"), "x").unwrap();
1291        git(&dir, &["add", "a.ts"]);
1292        in_repo(&dir, || {
1293            record(&["typecheck"]);
1294            git(&dir, &["commit", "-qm", "chore: a"]);
1295            bind_to_head();
1296            let head = git(&dir, &["rev-parse", "HEAD"]);
1297            assert!(!stamps_for(std::slice::from_ref(&head)).is_empty());
1298            forget();
1299            assert!(stamps_for(&[head]).is_empty());
1300        });
1301        let _ = std::fs::remove_dir_all(&dir);
1302    }
1303
1304    /// A squash-merge produces a commit this machine never saw, and the
1305    /// stamp has to survive it.
1306    ///
1307    /// This is the case that motivated tree-stamping. A branch is verified
1308    /// locally and stamped; the forge squashes it onto `main` as a NEW
1309    /// commit with no note; pushing a tag on that commit re-ran every gate
1310    /// the branch had already proved. Measured on this repository: 13
1311    /// seconds for the branch push, then the full suite for the tag push,
1312    /// which died on a reset connection.
1313    ///
1314    /// The tree is what the gates actually read, and it is identical across
1315    /// that merge whenever the base has not moved — five of five merges in
1316    /// one afternoon here.
1317    #[test]
1318    fn a_stamp_survives_a_commit_being_rewritten_with_the_same_tree() {
1319        let dir = repo("squashed");
1320        std::fs::write(dir.join("a.ts"), "x").unwrap();
1321        git(&dir, &["add", "a.ts"]);
1322        in_repo(&dir, || {
1323            record(&["test"]);
1324            git(&dir, &["commit", "-qm", "feat: on a branch"]);
1325            assert_eq!(bind_to_head(), vec!["test".to_string()]);
1326            let branch_tip = git(&dir, &["rev-parse", "HEAD"]);
1327
1328            // Stand in for the forge's squash: a DIFFERENT commit object
1329            // with the SAME tree. `--amend` with a new message is the
1330            // cheapest way to get exactly that shape.
1331            git(
1332                &dir,
1333                &[
1334                    "commit",
1335                    "-q",
1336                    "--amend",
1337                    "-m",
1338                    "feat: squashed by the forge",
1339                ],
1340            );
1341            let merged = git(&dir, &["rev-parse", "HEAD"]);
1342            assert_ne!(merged, branch_tip, "the fixture must produce a new commit");
1343            assert_eq!(
1344                git(&dir, &["rev-parse", "HEAD^{tree}"]),
1345                git(&dir, &["rev-parse", &format!("{branch_tip}^{{tree}}")]),
1346                "…carrying the same tree, which is the whole premise"
1347            );
1348
1349            let stamps = stamps_for(std::slice::from_ref(&merged));
1350            assert_eq!(
1351                stamps.get(&merged).map(Vec::as_slice),
1352                Some(&["test".to_string()][..]),
1353                "the stamp must follow the content, not the commit hash"
1354            );
1355        });
1356        let _ = std::fs::remove_dir_all(&dir);
1357    }
1358
1359    /// And it must not follow anything else. A commit whose tree was never
1360    /// stamped gets nothing, however many other stamps exist — otherwise
1361    /// this widening would vouch for content nobody checked.
1362    #[test]
1363    fn a_different_tree_gets_no_stamp_from_the_fallback() {
1364        let dir = repo("othertree");
1365        std::fs::write(dir.join("a.ts"), "x").unwrap();
1366        git(&dir, &["add", "a.ts"]);
1367        in_repo(&dir, || {
1368            record(&["test"]);
1369            git(&dir, &["commit", "-qm", "chore: stamped"]);
1370            assert_eq!(bind_to_head(), vec!["test".to_string()]);
1371
1372            // Different CONTENT, so a different tree, and no marker: this
1373            // commit was never judged.
1374            std::fs::write(dir.join("b.ts"), "y").unwrap();
1375            git(&dir, &["add", "b.ts"]);
1376            git(&dir, &["commit", "-qm", "chore: unjudged"]);
1377            let unstamped = git(&dir, &["rev-parse", "HEAD"]);
1378
1379            assert!(
1380                stamps_for(std::slice::from_ref(&unstamped)).is_empty(),
1381                "a tree nobody stamped must not inherit one"
1382            );
1383        });
1384        let _ = std::fs::remove_dir_all(&dir);
1385    }
1386
1387    // ---- declared-input reuse (`.github/attest-inputs`) -------------------
1388
1389    /// A repository declaring that gate `test` reads only `a.ts`, with one
1390    /// commit stamped `test@abc`. The plans directory is where a later,
1391    /// plan-only commit will change the tree without touching any input.
1392    fn declared(name: &str, spec: &str) -> PathBuf {
1393        let dir = repo(name);
1394        std::fs::create_dir_all(dir.join(".github")).unwrap();
1395        std::fs::create_dir_all(dir.join("docs/plans")).unwrap();
1396        std::fs::write(dir.join("a.ts"), "x").unwrap();
1397        std::fs::write(dir.join("docs/plans/p.md"), "plan v1").unwrap();
1398        std::fs::write(dir.join(".github/attest-inputs"), spec).unwrap();
1399        git(&dir, &["add", "-A"]);
1400        in_repo(&dir, || {
1401            record(&["test@abc"]);
1402            git(&dir, &["commit", "-qm", "feat: judged"]);
1403            assert_eq!(bind_to_head(), vec!["test".to_string()]);
1404        });
1405        dir
1406    }
1407
1408    /// Change only the plan and commit it WITHOUT a marker: nothing was
1409    /// judged for this tree, so only the fingerprint route can speak.
1410    fn plan_only_commit(dir: &Path) -> String {
1411        std::fs::write(dir.join("docs/plans/p.md"), "plan v2, closing").unwrap();
1412        git(dir, &["add", "docs/plans/p.md"]);
1413        git(dir, &["commit", "-qm", "docs(plan): done"]);
1414        git(dir, &["rev-parse", "HEAD"])
1415    }
1416
1417    #[test]
1418    fn a_plan_only_commit_reuses_the_stamp_of_a_declared_gate() {
1419        let dir = declared("plan-only", "test a.ts\n");
1420        in_repo(&dir, || {
1421            let head = plan_only_commit(&dir);
1422            let stamps = stamps_for(std::slice::from_ref(&head));
1423            assert_eq!(
1424                stamps.get(&head).map(Vec::as_slice),
1425                Some(&["test@abc".to_string()][..]),
1426                "the closing commit changed the tree and none of the declared inputs"
1427            );
1428        });
1429        let _ = std::fs::remove_dir_all(&dir);
1430    }
1431
1432    #[test]
1433    fn the_staged_tree_of_a_plan_only_commit_is_vouched_for() {
1434        let dir = declared("staged", "test a.ts\n");
1435        in_repo(&dir, || {
1436            std::fs::write(dir.join("docs/plans/p.md"), "plan v2").unwrap();
1437            git(&dir, &["add", "docs/plans/p.md"]);
1438            assert!(vouched_for_staged_tree().contains("test@abc"));
1439        });
1440        let _ = std::fs::remove_dir_all(&dir);
1441    }
1442
1443    #[test]
1444    fn partial_staging_is_judged_on_the_index_not_the_working_copy() {
1445        let dir = declared("partial", "test a.ts\n");
1446        in_repo(&dir, || {
1447            // The plan is staged; an unstaged edit to a declared input sits in
1448            // the working copy. The commit would carry only the plan.
1449            std::fs::write(dir.join("docs/plans/p.md"), "plan v2").unwrap();
1450            git(&dir, &["add", "docs/plans/p.md"]);
1451            std::fs::write(dir.join("a.ts"), "unstaged edit").unwrap();
1452            assert!(vouched_for_staged_tree().contains("test@abc"));
1453
1454            // The other way round: the input IS staged, so it misses even
1455            // though the working copy was put back.
1456            std::fs::write(dir.join("a.ts"), "staged edit").unwrap();
1457            git(&dir, &["add", "a.ts"]);
1458            std::fs::write(dir.join("a.ts"), "x").unwrap();
1459            assert!(!vouched_for_staged_tree().contains("test@abc"));
1460        });
1461        let _ = std::fs::remove_dir_all(&dir);
1462    }
1463
1464    #[test]
1465    fn a_changed_declared_input_misses() {
1466        let dir = declared("input-changed", "test a.ts\n");
1467        in_repo(&dir, || {
1468            std::fs::write(dir.join("a.ts"), "changed").unwrap();
1469            git(&dir, &["add", "a.ts"]);
1470            git(&dir, &["commit", "-qm", "feat: unjudged"]);
1471            let head = git(&dir, &["rev-parse", "HEAD"]);
1472            assert!(stamps_for(std::slice::from_ref(&head)).is_empty());
1473        });
1474        let _ = std::fs::remove_dir_all(&dir);
1475    }
1476
1477    #[test]
1478    fn a_changed_declaration_misses() {
1479        let dir = declared("declaration-changed", "test a.ts\n");
1480        in_repo(&dir, || {
1481            // The spec's own blob is in every fingerprint: widening what the
1482            // gate reads is a different declaration, so nothing is reused.
1483            std::fs::write(dir.join("b.ts"), "y").unwrap();
1484            std::fs::write(dir.join(".github/attest-inputs"), "test a.ts b.ts\n").unwrap();
1485            git(&dir, &["add", "-A"]);
1486            git(&dir, &["commit", "-qm", "chore: widen the declaration"]);
1487            let head = git(&dir, &["rev-parse", "HEAD"]);
1488            assert!(stamps_for(std::slice::from_ref(&head)).is_empty());
1489        });
1490        let _ = std::fs::remove_dir_all(&dir);
1491    }
1492
1493    #[test]
1494    fn a_declared_config_file_is_an_input_like_any_other() {
1495        // A gate's scope lives in a tracked file (`amont.conf`). Declared, a
1496        // scope-only edit changes the fingerprint and the stamp misses.
1497        let dir = declared("config", "test a.ts amont.conf\n");
1498        in_repo(&dir, || {
1499            // `declared` ran before amont.conf existed: it is a required path,
1500            // so that first commit had no fingerprint and filed nothing.
1501            assert!(!inputs_ref_exists());
1502
1503            std::fs::write(dir.join("amont.conf"), "scope = src\n").unwrap();
1504            git(&dir, &["add", "amont.conf"]);
1505            record(&["test@abc"]);
1506            git(&dir, &["commit", "-qm", "chore: config"]);
1507            assert_eq!(bind_to_head(), vec!["test".to_string()]);
1508
1509            std::fs::write(dir.join("docs/plans/p.md"), "plan v2").unwrap();
1510            git(&dir, &["add", "docs/plans/p.md"]);
1511            git(&dir, &["commit", "-qm", "docs(plan): done"]);
1512            let plan = git(&dir, &["rev-parse", "HEAD"]);
1513            assert!(stamps_for(std::slice::from_ref(&plan)).contains_key(&plan));
1514
1515            std::fs::write(dir.join("amont.conf"), "scope = lib\n").unwrap();
1516            git(&dir, &["add", "amont.conf"]);
1517            git(&dir, &["commit", "-qm", "chore: scope-only change"]);
1518            let scope = git(&dir, &["rev-parse", "HEAD"]);
1519            assert!(
1520                stamps_for(std::slice::from_ref(&scope)).is_empty(),
1521                "a scope-only change must miss"
1522            );
1523        });
1524        let _ = std::fs::remove_dir_all(&dir);
1525    }
1526
1527    #[test]
1528    fn a_declared_stamp_is_read_back_as_its_complete_token() {
1529        // The identity comparison lives in the readers (`pair_verdict`, the
1530        // push gate): they skip only on the complete token they compute. This
1531        // pins what the new route owes them: tokens come back verbatim, never
1532        // widened to the gate's name, and a second identity under the same key
1533        // is kept apart from the first.
1534        let dir = declared("identity", "test a.ts\n");
1535        in_repo(&dir, || {
1536            let head = plan_only_commit(&dir);
1537            let tokens = stamps_for(std::slice::from_ref(&head))
1538                .remove(&head)
1539                .unwrap_or_default();
1540            assert_eq!(tokens, vec!["test@abc".to_string()]);
1541
1542            let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1543            assert!(stamp_tree(&tree, &["test@def".to_string()]));
1544            let tokens = tree_tokens(&tree);
1545            assert!(tokens.contains(&"test@abc".to_string()));
1546            assert!(tokens.contains(&"test@def".to_string()));
1547            assert!(!tokens.contains(&"test@zzz".to_string()));
1548            assert!(!tokens.contains(&"test".to_string()));
1549        });
1550        let _ = std::fs::remove_dir_all(&dir);
1551    }
1552
1553    #[test]
1554    fn a_gate_that_does_not_declare_the_manifest_is_not_offered_the_route() {
1555        // `amont.conf` is tracked but the gate's declaration omits it: a
1556        // scope-only edit would be invisible to the fingerprint, so the route
1557        // is refused outright. Nothing is filed and nothing is reused.
1558        let dir = repo("manifest-undeclared");
1559        std::fs::create_dir_all(dir.join(".github")).unwrap();
1560        std::fs::create_dir_all(dir.join("docs/plans")).unwrap();
1561        std::fs::write(dir.join("a.ts"), "x").unwrap();
1562        std::fs::write(dir.join("amont.conf"), "scope = src\n").unwrap();
1563        std::fs::write(dir.join("docs/plans/p.md"), "v1").unwrap();
1564        std::fs::write(dir.join(".github/attest-inputs"), "test a.ts\n").unwrap();
1565        git(&dir, &["add", "-A"]);
1566        in_repo(&dir, || {
1567            record(&["test@abc"]);
1568            git(&dir, &["commit", "-qm", "feat: judged"]);
1569            bind_to_head();
1570            assert!(!inputs_ref_exists(), "the route must not open");
1571            let head = plan_only_commit(&dir);
1572            assert!(stamps_for(std::slice::from_ref(&head)).is_empty());
1573        });
1574        let _ = std::fs::remove_dir_all(&dir);
1575    }
1576
1577    #[test]
1578    fn a_gate_declaring_the_manifest_opens_the_route() {
1579        // The counterpart: with `amont.conf` among its declared inputs the
1580        // gate is filed by fingerprint again.
1581        let dir = declared("manifest-declared", "test a.ts amont.conf\n");
1582        in_repo(&dir, || {
1583            std::fs::write(dir.join("amont.conf"), "scope = src\n").unwrap();
1584            git(&dir, &["add", "amont.conf"]);
1585            record(&["test@abc"]);
1586            git(&dir, &["commit", "-qm", "chore: config"]);
1587            bind_to_head();
1588            assert!(inputs_ref_exists());
1589        });
1590        let _ = std::fs::remove_dir_all(&dir);
1591    }
1592
1593    #[test]
1594    fn an_undeclared_gate_keeps_whole_tree_behaviour() {
1595        let dir = declared("undeclared", "test a.ts\n");
1596        in_repo(&dir, || {
1597            // `lint` passed on the first tree but the spec declares only
1598            // `test`: the plan-only commit is a new tree for `lint`.
1599            let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1600            assert!(stamp_tree(&tree, &["lint@zzz".to_string()]));
1601            let head = plan_only_commit(&dir);
1602            let tokens = stamps_for(std::slice::from_ref(&head))
1603                .remove(&head)
1604                .unwrap_or_default();
1605            assert!(tokens.contains(&"test@abc".to_string()));
1606            assert!(!tokens.iter().any(|t| t.starts_with("lint@")));
1607        });
1608        let _ = std::fs::remove_dir_all(&dir);
1609    }
1610
1611    #[test]
1612    fn an_invalid_or_ambiguous_spec_falls_back_to_whole_tree_behaviour() {
1613        // A glob invalidates the whole spec.
1614        let dir = declared("invalid", "test src/*\n");
1615        in_repo(&dir, || {
1616            assert!(!inputs_ref_exists(), "no valid spec, nothing is filed");
1617            let head = plan_only_commit(&dir);
1618            assert!(stamps_for(std::slice::from_ref(&head)).is_empty());
1619        });
1620        let _ = std::fs::remove_dir_all(&dir);
1621
1622        // Both locations present: ambiguous, the route is off.
1623        let dir = repo("ambiguous");
1624        std::fs::create_dir_all(dir.join(".github")).unwrap();
1625        std::fs::create_dir_all(dir.join(".forgejo")).unwrap();
1626        std::fs::create_dir_all(dir.join("docs/plans")).unwrap();
1627        std::fs::write(dir.join("a.ts"), "x").unwrap();
1628        std::fs::write(dir.join("docs/plans/p.md"), "v1").unwrap();
1629        std::fs::write(dir.join(".github/attest-inputs"), "test a.ts\n").unwrap();
1630        std::fs::write(dir.join(".forgejo/attest-inputs"), "test a.ts\n").unwrap();
1631        git(&dir, &["add", "-A"]);
1632        in_repo(&dir, || {
1633            record(&["test@abc"]);
1634            git(&dir, &["commit", "-qm", "feat: judged"]);
1635            bind_to_head();
1636            assert!(!inputs_ref_exists());
1637            let head = plan_only_commit(&dir);
1638            assert!(stamps_for(std::slice::from_ref(&head)).is_empty());
1639        });
1640        let _ = std::fs::remove_dir_all(&dir);
1641    }
1642
1643    #[test]
1644    fn a_gate_that_did_not_pass_leaves_no_declared_input_stamp() {
1645        let dir = repo("failed");
1646        std::fs::create_dir_all(dir.join(".github")).unwrap();
1647        std::fs::write(dir.join("a.ts"), "x").unwrap();
1648        std::fs::write(dir.join(".github/attest-inputs"), "test a.ts\n").unwrap();
1649        git(&dir, &["add", "-A"]);
1650        in_repo(&dir, || {
1651            // pre-commit blocked or ran nothing: an empty record, no marker.
1652            record(&[]);
1653            git(&dir, &["commit", "-qm", "feat: unjudged"]);
1654            assert!(bind_to_head().is_empty());
1655            let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1656            let head = git(&dir, &["rev-parse", "HEAD"]);
1657            assert!(!stamp_push(&head, &tree, &[]));
1658            assert!(!inputs_ref_exists());
1659        });
1660        let _ = std::fs::remove_dir_all(&dir);
1661    }
1662
1663    #[test]
1664    fn stamp_tree_and_tree_tokens_use_the_inputs_ref() {
1665        let dir = declared("rehearsal", "test a.ts\n");
1666        in_repo(&dir, || {
1667            // A rehearsal proves a gate in a snapshot of this tree …
1668            let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1669            assert!(stamp_tree(&tree, &["test@rehearsed".to_string()]));
1670            // … and a later tree with identical declared inputs reads it back.
1671            plan_only_commit(&dir);
1672            let later = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1673            assert_ne!(later, tree);
1674            assert!(tree_tokens(&later).contains(&"test@rehearsed".to_string()));
1675        });
1676        let _ = std::fs::remove_dir_all(&dir);
1677    }
1678
1679    #[test]
1680    fn the_inputs_ref_is_keyed_the_way_attest_keys_its_own() {
1681        let dir = declared("key", "test a.ts\n");
1682        in_repo(&dir, || {
1683            let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1684            let spec = crate::attest_inputs::spec_at(&tree).expect("spec");
1685            let fp = crate::attest_inputs::fingerprint(&tree, &spec, &spec.gates[0].0).expect("fp");
1686            let key = crate::attest_inputs::input_key("test", &fp).expect("key");
1687            let listed = git(&dir, &["notes", "--ref", INPUTS_REF, "list"]);
1688            assert!(
1689                listed
1690                    .lines()
1691                    .any(|l| l.split_whitespace().nth(1) == Some(key.as_str())),
1692                "{listed}"
1693            );
1694        });
1695        let _ = std::fs::remove_dir_all(&dir);
1696    }
1697
1698    #[test]
1699    fn a_foreign_token_in_the_inputs_ref_vouches_for_nothing() {
1700        let dir = declared("foreign", "test a.ts\n");
1701        in_repo(&dir, || {
1702            let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1703            let spec = crate::attest_inputs::spec_at(&tree).expect("spec");
1704            let fp = crate::attest_inputs::fingerprint(&tree, &spec, &spec.gates[0].0).expect("fp");
1705            let key = crate::attest_inputs::input_key("test", &fp).expect("key");
1706            // Somebody files a token for ANOTHER gate under `test`'s key.
1707            let mut note = note_in(INPUTS_REF, &key);
1708            note.add_tokens(&["publish@evil".to_string()]);
1709            assert!(write_note_in(INPUTS_REF, &key, &note));
1710            assert!(!tree_tokens(&tree).contains(&"publish@evil".to_string()));
1711        });
1712        let _ = std::fs::remove_dir_all(&dir);
1713    }
1714
1715    #[test]
1716    fn forget_removes_the_inputs_ref_too() {
1717        let dir = declared("forget-inputs", "test a.ts\n");
1718        in_repo(&dir, || {
1719            assert!(inputs_ref_exists());
1720            assert!(forget());
1721            assert!(!inputs_ref_exists());
1722        });
1723        let _ = std::fs::remove_dir_all(&dir);
1724    }
1725
1726    /// A repository that declares nothing: `a.ts`, a plan, and optionally an
1727    /// `amont.conf`, judged once (`test@abc`).
1728    fn canonical(name: &str, conf: Option<&str>) -> PathBuf {
1729        let dir = repo(name);
1730        std::fs::create_dir_all(dir.join("docs/plans")).unwrap();
1731        std::fs::write(dir.join("a.ts"), "x").unwrap();
1732        std::fs::write(dir.join("docs/plans/p.md"), "plan v1").unwrap();
1733        if let Some(conf) = conf {
1734            std::fs::write(dir.join("amont.conf"), conf).unwrap();
1735        }
1736        git(&dir, &["add", "-A"]);
1737        in_repo(&dir, || {
1738            record(&["test@abc"]);
1739            git(&dir, &["commit", "-qm", "feat: judged"]);
1740            assert_eq!(bind_to_head(), vec!["test".to_string()]);
1741        });
1742        dir
1743    }
1744
1745    fn vouched(dir: &Path, commit: &str) -> Vec<String> {
1746        in_repo(dir, || {
1747            stamps_for(std::slice::from_ref(&commit.to_string()))
1748                .remove(commit)
1749                .unwrap_or_default()
1750        })
1751    }
1752
1753    #[test]
1754    fn with_no_spec_a_plans_only_commit_is_vouched_for_canonically() {
1755        let dir = canonical("canon-plan-edit", None);
1756        in_repo(&dir, || {
1757            assert!(inputs_ref_exists(), "the canonical stamp is filed");
1758            let head = plan_only_commit(&dir);
1759            assert_eq!(
1760                stamps_for(std::slice::from_ref(&head))
1761                    .remove(&head)
1762                    .unwrap_or_default(),
1763                vec!["test@abc".to_string()]
1764            );
1765            // The staged tree, as pre-commit judges it, is vouched for too.
1766            std::fs::write(dir.join("docs/plans/p.md"), "plan v3").unwrap();
1767            git(&dir, &["add", "docs/plans/p.md"]);
1768            assert!(vouched_for_staged_tree().contains("test@abc"));
1769        });
1770        let _ = std::fs::remove_dir_all(&dir);
1771    }
1772
1773    #[test]
1774    fn a_plan_added_deleted_or_renamed_is_not_vouched_for() {
1775        let dir = canonical("canon-plan-exists", None);
1776        std::fs::write(dir.join("docs/plans/q.md"), "second plan").unwrap();
1777        git(&dir, &["add", "docs/plans/q.md"]);
1778        git(&dir, &["commit", "-qm", "docs(plan): add"]);
1779        let added = git(&dir, &["rev-parse", "HEAD"]);
1780        assert!(vouched(&dir, &added).is_empty(), "a plan added");
1781        git(&dir, &["mv", "docs/plans/p.md", "docs/plans/r.md"]);
1782        git(&dir, &["commit", "-qm", "docs(plan): rename"]);
1783        let renamed = git(&dir, &["rev-parse", "HEAD"]);
1784        assert!(vouched(&dir, &renamed).is_empty(), "a plan renamed");
1785        let _ = std::fs::remove_dir_all(&dir);
1786    }
1787
1788    #[test]
1789    fn with_no_spec_any_other_change_is_not_vouched_for() {
1790        let dir = canonical("canon-code-edit", Some("gate test = make test\n"));
1791        std::fs::write(dir.join("a.ts"), "changed").unwrap();
1792        git(&dir, &["commit", "-qam", "feat: code"]);
1793        let code = git(&dir, &["rev-parse", "HEAD"]);
1794        assert!(vouched(&dir, &code).is_empty(), "a source edit");
1795        // amont.conf is part of the canonical tree: a scope-only edit misses.
1796        std::fs::write(dir.join("amont.conf"), "gate test = make other\n").unwrap();
1797        git(&dir, &["commit", "-qam", "chore: config"]);
1798        let conf = git(&dir, &["rev-parse", "HEAD"]);
1799        assert!(vouched(&dir, &conf).is_empty(), "a manifest edit");
1800        let _ = std::fs::remove_dir_all(&dir);
1801    }
1802
1803    #[test]
1804    fn plan_exempt_off_closes_the_canonical_route() {
1805        for (name, conf) in [
1806            ("off", "set planExempt off\n"),
1807            ("spelled", "SET  PLANEXEMPT\tOff\r\n"),
1808            ("after", "set planExempt on\nset planExempt off\n"),
1809        ] {
1810            let dir = canonical(&format!("canon-off-{name}"), Some(conf));
1811            in_repo(&dir, || {
1812                assert!(!inputs_ref_exists(), "{name}: nothing is filed");
1813                let head = plan_only_commit(&dir);
1814                assert!(
1815                    stamps_for(std::slice::from_ref(&head)).is_empty(),
1816                    "{name}: a plans-only commit is a new tree"
1817                );
1818            });
1819            let _ = std::fs::remove_dir_all(&dir);
1820        }
1821        // `on`, a trailing comment and a comment line do not opt out.
1822        for (name, conf) in [
1823            ("on", "set planExempt on\n"),
1824            ("comment", "set planExempt off # no\n"),
1825            ("commented", "# set planExempt off\n"),
1826        ] {
1827            let dir = canonical(&format!("canon-on-{name}"), Some(conf));
1828            let head = plan_only_commit(&dir);
1829            assert!(!vouched(&dir, &head).is_empty(), "{name}: still canonical");
1830            let _ = std::fs::remove_dir_all(&dir);
1831        }
1832    }
1833
1834    #[test]
1835    fn a_spec_that_cannot_be_used_means_no_canonical_stamps() {
1836        // A directory where the spec should be: listed, unreadable as a blob.
1837        let dir = repo("canon-unreadable-spec");
1838        std::fs::create_dir_all(dir.join(".github/attest-inputs")).unwrap();
1839        std::fs::create_dir_all(dir.join("docs/plans")).unwrap();
1840        std::fs::write(dir.join(".github/attest-inputs/x"), "x").unwrap();
1841        std::fs::write(dir.join("a.ts"), "x").unwrap();
1842        std::fs::write(dir.join("docs/plans/p.md"), "v1").unwrap();
1843        git(&dir, &["add", "-A"]);
1844        in_repo(&dir, || {
1845            record(&["test@abc"]);
1846            git(&dir, &["commit", "-qm", "feat: judged"]);
1847            bind_to_head();
1848            assert!(!inputs_ref_exists(), "an unreadable spec opens nothing");
1849            let head = plan_only_commit(&dir);
1850            assert!(stamps_for(std::slice::from_ref(&head)).is_empty());
1851        });
1852        let _ = std::fs::remove_dir_all(&dir);
1853    }
1854
1855    #[test]
1856    fn two_specs_mean_no_canonical_stamps() {
1857        let dir = repo("canon-ambiguous");
1858        std::fs::create_dir_all(dir.join(".github")).unwrap();
1859        std::fs::create_dir_all(dir.join(".forgejo")).unwrap();
1860        std::fs::create_dir_all(dir.join("docs/plans")).unwrap();
1861        std::fs::write(dir.join("a.ts"), "x").unwrap();
1862        std::fs::write(dir.join("docs/plans/p.md"), "v1").unwrap();
1863        std::fs::write(dir.join(".github/attest-inputs"), "test a.ts\n").unwrap();
1864        std::fs::write(dir.join(".forgejo/attest-inputs"), "test a.ts\n").unwrap();
1865        git(&dir, &["add", "-A"]);
1866        in_repo(&dir, || {
1867            record(&["test@abc"]);
1868            git(&dir, &["commit", "-qm", "feat: judged"]);
1869            bind_to_head();
1870            assert!(!inputs_ref_exists());
1871            let head = plan_only_commit(&dir);
1872            assert!(stamps_for(std::slice::from_ref(&head)).is_empty());
1873        });
1874        let _ = std::fs::remove_dir_all(&dir);
1875    }
1876
1877    #[test]
1878    fn a_valid_spec_keeps_precedence_over_the_canonical_route() {
1879        // `lint` is not declared: it must NOT ride the canonical route.
1880        let dir = declared("canon-precedence", "test a.ts\n");
1881        in_repo(&dir, || {
1882            let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1883            assert!(stamp_tree(&tree, &["lint@zzz".to_string()]));
1884            let head = plan_only_commit(&dir);
1885            let tokens = stamps_for(std::slice::from_ref(&head))
1886                .remove(&head)
1887                .unwrap_or_default();
1888            assert!(tokens.contains(&"test@abc".to_string()));
1889            assert!(!tokens.iter().any(|t| t.starts_with("lint@")));
1890        });
1891        let _ = std::fs::remove_dir_all(&dir);
1892    }
1893}