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 Vec::new();
342 };
343 let mut out: Vec<String> = Vec::new();
344 for (gate, paths) in spec {
345 if !manifest_is_an_input(tree, &paths) {
346 continue;
347 }
348 let Some(fp) = crate::attest_inputs::fingerprint(tree, &paths) 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 && !out.contains(&t) {
356 out.push(t);
357 }
358 }
359 }
360 out
361}
362
363/// File `tokens` under the fingerprint keys of the gates `tree` declares, in
364/// addition to wherever the caller put them by tree. Best-effort, silent, and
365/// merged: a miss here is one redundant gate run, which is the safe direction.
366fn file_inputs(tree: &str, tokens: &[String]) {
367 if tokens.is_empty() {
368 return;
369 }
370 let Some(spec) = crate::attest_inputs::spec_at(tree) else {
371 return;
372 };
373 for (gate, paths) in spec {
374 let mine: Vec<String> = tokens
375 .iter()
376 .filter(|t| crate::gate_identity::base(t) == gate)
377 .cloned()
378 .collect();
379 if mine.is_empty() || !manifest_is_an_input(tree, &paths) {
380 continue;
381 }
382 let Some(fp) = crate::attest_inputs::fingerprint(tree, &paths) else {
383 continue;
384 };
385 let Some(key) = crate::attest_inputs::input_key(&gate, &fp) else {
386 continue;
387 };
388 let mut note = note_in(INPUTS_REF, &key);
389 note.add_tokens(&mine);
390 let _ = write_note_in(INPUTS_REF, &key, ¬e);
391 }
392}
393
394/// May a gate's stamp travel by fingerprint on `tree`? A gate's scope and its
395/// opt-ins live in the manifest, and under a whole-tree key a manifest change
396/// was a tree change; under a fingerprint it is one only if the gate declares
397/// the manifest. So a tree that tracks `amont.conf` offers the route only to a
398/// gate whose declared inputs cover it, and a scope-only edit then misses.
399fn manifest_is_an_input(tree: &str, paths: &[crate::attest_inputs::PathTok]) -> bool {
400 let tracked = crate::git::succeeds(&[
401 "cat-file",
402 "-e",
403 &format!("{tree}:{}", crate::manifest::MANIFEST),
404 ]);
405 !tracked || crate::attest_inputs::declares(paths, crate::manifest::MANIFEST)
406}
407
408/// Does the inputs ref exist here? One spawn that lets a repository which
409/// declares nothing skip the fingerprint route entirely on the push path.
410fn inputs_ref_exists() -> bool {
411 crate::git::succeeds(&["rev-parse", "-q", "--verify", INPUTS_FULL_REF])
412}
413
414/// The stamp tokens on `tree` (a tree object id). Tree gates are read here
415/// and only here (ADR-0024): `tree:<name>` on the pushed tree proves the gate
416/// on exactly that content, whichever commit carried it.
417pub fn tree_tokens(tree: &str) -> Vec<String> {
418 let mut tokens = note_at(tree).tokens;
419 if inputs_ref_exists() {
420 for t in input_tokens(tree) {
421 if !tokens.contains(&t) {
422 tokens.push(t);
423 }
424 }
425 }
426 tokens
427}
428
429/// Add `tokens` to the gate note on `tree` — how a rehearsal records tree
430/// gates it proved in its snapshot of exactly that tree. Merged, never
431/// rendered from the tokens alone, so evidence already on the note survives.
432pub fn stamp_tree(tree: &str, tokens: &[String]) -> bool {
433 let mut note = note_at(tree);
434 note.add_tokens(tokens);
435 let ok = write_note(tree, ¬e);
436 file_inputs(tree, tokens);
437 ok
438}
439
440/// Write `note` at `key`. Best-effort, like every writer here.
441fn write_note(key: &str, note: &Note) -> bool {
442 write_note_in(NOTES_REF, key, note)
443}
444
445fn write_note_in(notes_ref: &str, key: &str, note: &Note) -> bool {
446 let body = note.render();
447 crate::git::succeeds(&["notes", "--ref", notes_ref, "add", "-f", "-m", &body, key])
448}
449
450/// Record what a gate run COST and how it ENDED, against the content it ran
451/// on.
452///
453/// Evidence only: nothing in this module reads these back to decide whether a
454/// check may be skipped, which is why recording a FAILED run — the case the
455/// stamp path deliberately has no opinion about — is safe here.
456///
457/// Best-effort and silent. A note git refused costs a row in a report nobody
458/// is blocked on; warning about it on every push would train people to ignore
459/// the warnings that do gate something.
460pub fn record_runs(key: &str, runs: &[Run]) {
461 // A gate name with whitespace in it would forge a second field on read.
462 // Refused rather than escaped: every id this repository can produce is
463 // already free of it, so the escaping would be a code path no real input
464 // ever reaches — untested by construction.
465 let runs: Vec<Run> = runs
466 .iter()
467 .filter(|r| !r.gate.is_empty() && !r.gate.contains(char::is_whitespace))
468 .cloned()
469 .collect();
470 if runs.is_empty() {
471 return;
472 }
473 let mut note = note_at(key);
474 note.add_runs(&runs);
475 let _ = write_note(key, ¬e);
476}
477
478/// `$GIT_DIR/amont-gate` — the worktree-PRIVATE gitdir, deliberately: the
479/// commit this marker waits for happens in this worktree. The stamps the
480/// marker becomes live in the common dir (a notes ref) and are shared.
481fn marker_path() -> Option<PathBuf> {
482 let dir = crate::git::stdout(&["rev-parse", "--git-dir"])?;
483 Some(std::path::Path::new(&dir).join(MARKER))
484}
485
486/// pre-commit: record that `scripts` ran clean against the tree the commit
487/// will carry.
488///
489/// Called on EVERY pre-commit verdict, with an empty list when nothing
490/// qualifying ran (or the commit is about to be blocked) — an aborted or
491/// unchecked attempt must not inherit a previous attempt's marker.
492///
493/// Best-effort throughout: a failure to record costs one redundant gate run
494/// at push time, which is the safe direction, and a pre-commit that failed a
495/// COMMIT over bookkeeping would be the tail wagging the dog.
496pub fn record(scripts: &[&str]) {
497 let Some(path) = marker_path() else { return };
498 if scripts.is_empty() {
499 let _ = std::fs::remove_file(&path);
500 return;
501 }
502 // The index, as the object id `git commit` is about to seal. Inherits
503 // `GIT_INDEX_FILE`, so `git commit -a`'s temporary index answers here
504 // too. Pure read of the index: writes objects, touches no ref.
505 let Some(tree) = crate::git::stdout(&["write-tree"]) else {
506 // Not "nothing ran": git could not name the tree, so nothing may be
507 // vouched for. Dropping the marker is the fail-safe half (the gate
508 // re-runs at push); saying so is the half that was missing.
509 crate::hooks::common::warn(
510 "git would not name the staged tree — this commit records no gate stamp",
511 );
512 let _ = std::fs::remove_file(&path);
513 return;
514 };
515 let mut body = format!("{FORMAT}\n{tree}\n");
516 for s in scripts {
517 body.push_str(s);
518 body.push('\n');
519 }
520 let _ = std::fs::write(&path, body);
521}
522
523/// pre-commit, before a gate runs: the scripts a previous run already
524/// vouched for against the tree this commit is about to seal.
525///
526/// Two records answer, both bound to the TREE (`git write-tree` of the index
527/// under the staged-only hold — the commit's content, not the working
528/// tree):
529///
530/// 1. the one-shot marker [`record`] left behind. post-commit consumes it,
531/// so a marker that is still there belongs to an attempt that never
532/// reached post-commit — a `commit-msg` refusal, an editor closed on an
533/// empty message, a Ctrl-C at the prompt. The gates ran, the tree is the
534/// same, the verdict stands: measured on this machine, a subject three
535/// characters too long replayed a ten-minute suite for nothing.
536/// 2. the tree note [`bind_to_head`] writes — a reword, a `reset --soft` and
537/// re-commit, or a rebase that kept the tree.
538///
539/// Every failure mode reads as "nothing is vouched for", which runs the
540/// gate: no `write-tree`, a marker for another tree, a wrong format, a git
541/// that would not answer. Reuse can only ever skip a run the record proves
542/// happened on this exact content; it can never let unjudged content
543/// through — the direction every function in this module fails in.
544///
545/// Repo-controlled inputs to the gate (its command line, its scope) live in
546/// the manifest, which is part of the tree: a changed declaration is a
547/// changed tree, and nothing is reused by tree. A gate reused by fingerprint
548/// gets the same guarantee only if it declares the manifest as an input
549/// ([`manifest_is_an_input`]); otherwise it is not offered the route at all.
550pub fn vouched_for_staged_tree() -> HashSet<String> {
551 let mut out = HashSet::new();
552 let Some(tree) = crate::git::stdout(&["write-tree"]) else {
553 return out;
554 };
555 if let Some(path) = marker_path() {
556 if let Ok(body) = std::fs::read_to_string(&path) {
557 let mut lines = body.lines();
558 if lines.next() == Some(FORMAT) && lines.next() == Some(tree.as_str()) {
559 out.extend(lines.filter(|l| !l.trim().is_empty()).map(str::to_string));
560 }
561 }
562 }
563 // The tree note: `notes show` fails loudly on an absent note, and a
564 // failure here is simply "no note" — the marker's answer stands alone.
565 // Tokens are `<name>@<identity>` (`gate_identity`): commit-time ones are
566 // named by script, push-time ones by full id (`pre-push-…`), which no
567 // pre-commit declaration is named after — harmless in the set, and a
568 // reader skips only on the complete token. The note's `run` lines are evidence and are not
569 // tokens: `Note` keeps the two apart so nothing here can be skipped by a
570 // line that only records how long something took.
571 out.extend(note_at(&tree).tokens);
572 // A gate the repository declares inputs for is also vouched for by any
573 // earlier tree whose declared inputs are byte-identical to this staged
574 // tree's: a plan-only commit changes the tree and none of them. The tree
575 // here is the INDEX's (`write-tree`), so partial staging is judged on what
576 // is about to be committed, not on the working copy.
577 if inputs_ref_exists() {
578 out.extend(input_tokens(&tree));
579 }
580 out
581}
582
583/// post-commit: consume the marker; stamp HEAD when the tree still matches.
584///
585/// One-shot by construction — the marker is deleted before anything is
586/// judged, so no path through here can leave it to vouch for a later commit.
587///
588/// Returns the scripts it actually stamped (empty on every no-stamp path,
589/// including a note git refused). The caller subtracts this from what the
590/// manifest declares to learn what the commit dodged — [`crate::bypass`]
591/// keeps that count. Two records, two questions: the stamp gates a check,
592/// the ledger only counts.
593pub fn bind_to_head() -> Vec<String> {
594 let Some(path) = marker_path() else {
595 return Vec::new();
596 };
597 let Ok(body) = std::fs::read_to_string(&path) else {
598 return Vec::new(); // no marker: nothing ran at pre-commit, nothing to stamp
599 };
600 let _ = std::fs::remove_file(&path);
601 let mut lines = body.lines();
602 if lines.next() != Some(FORMAT) {
603 return Vec::new();
604 }
605 let Some(tree) = lines.next() else {
606 return Vec::new();
607 };
608 let scripts: Vec<&str> = lines.filter(|l| !l.trim().is_empty()).collect();
609 if scripts.is_empty() {
610 return Vec::new();
611 }
612 let Some(head_tree) = crate::git::stdout(&["rev-parse", "HEAD^{tree}"]) else {
613 crate::hooks::common::warn(
614 "git would not name this commit's tree — no gate stamp was written",
615 );
616 return Vec::new();
617 };
618 // A different tree means this commit is not the one pre-commit judged —
619 // the marker is a dead letter from an aborted attempt.
620 if head_tree != tree {
621 return Vec::new();
622 }
623 let scripts: Vec<String> = scripts.iter().map(|s| s.to_string()).collect();
624 // The stamp goes on the TREE as well as the commit, and the tree is the
625 // one that survives the way work actually reaches `main`.
626 //
627 // A squash-merge is performed by the forge: it produces a commit nobody
628 // here ever saw, carrying no note, so a later `git push` of a tag on
629 // that commit re-ran every gate the branch had already proved. Measured
630 // on this repository: a branch push took 13 seconds and the tag push
631 // that followed took the full suite and died on a reset connection.
632 //
633 // The tree is identical across that merge whenever the base has not
634 // moved — five of five merges in one afternoon here — and identical
635 // trees are identical CONTENT, which is the only thing a test suite
636 // reads. That is the same argument `attest` makes for signing the tree
637 // rather than the commit, and this module's marker has been tree-bound
638 // since it was written; this only carries the binding through to the
639 // note.
640 //
641 // Both, not either: the commit note is what a `git log --notes` reader
642 // sees, and dropping it would make the stamps invisible in the place
643 // people look for them.
644 // MERGED into whatever is there, never rendered from the scripts alone:
645 // a pre-push run against this same tree may already have recorded
646 // evidence lines on it, and an `add -f` built from this list would erase
647 // them.
648 let mut tree_note = note_at(&head_tree);
649 tree_note.add_tokens(&scripts);
650 let _ = write_note(&head_tree, &tree_note);
651 file_inputs(&head_tree, &scripts);
652 let mut head_note = note_at("HEAD");
653 head_note.add_tokens(&scripts);
654 if !write_note("HEAD", &head_note) {
655 // A note git refused is not a stamp — and the push will re-run these
656 // checks, which is right but looks arbitrary unless it is said.
657 crate::hooks::common::warn(
658 "git refused to write the gate stamp — these checks will run again at push",
659 );
660 return Vec::new();
661 }
662 // The ledger counts NAMES: what the commit dodged is judged against what
663 // the manifest declares, and the identity is the stamp's business alone.
664 scripts
665 .iter()
666 .map(|s| crate::gate_identity::base(s).to_string())
667 .collect()
668}
669
670/// pre-push, after every block gate passed: record that `gates` passed
671/// against `tree`, the content of `commit` — MERGING with whatever the note
672/// already says, because a commit-time stamp (`test`) may already sit there
673/// and `notes add -f` would otherwise erase it.
674///
675/// Best-effort, like every writer here: a stamp that could not be written
676/// costs one redundant gate run on the next push, which is the safe
677/// direction. Returns whether the commit's note was written.
678pub fn stamp_push(commit: &str, tree: &str, gates: &[String]) -> bool {
679 if gates.is_empty() {
680 return false;
681 }
682 let mut written = false;
683 for key in [tree, commit] {
684 let mut note = note_at(key);
685 note.add_tokens(gates);
686 let ok = write_note(key, ¬e);
687 if key == commit {
688 written = ok;
689 }
690 }
691 file_inputs(tree, gates);
692 if !written {
693 crate::hooks::common::warn(
694 "git refused to write the push stamp — these gates will run again on the next push",
695 );
696 }
697 written
698}
699
700/// pre-push: which of `commits` carry a stamp, and for which scripts.
701///
702/// One `notes list` narrows the reads to commits that have a note at all;
703/// absent ref, unparseable note, wrong format version — all read as "no
704/// stamp", which re-runs the gate.
705pub fn stamps_for(commits: &[String]) -> HashMap<String, Vec<String>> {
706 let mut out = HashMap::new();
707 if commits.is_empty() {
708 return out;
709 }
710 let Some(list) = crate::git::stdout(&["notes", "--ref", NOTES_REF, "list"]) else {
711 // NOT the absent-ref case, whatever an older comment here claimed:
712 // `notes list` exits 0 with empty output when the ref does not exist,
713 // so that arrives as `Some("")` and falls through as "nothing is
714 // stamped" — correctly. Reaching HERE means git could not answer at
715 // all. Same verdict (the gates re-run: never skip work on a question
716 // we could not ask), different sentence, because a transient git
717 // failure that reads as "nothing is stamped" is indistinguishable
718 // from the real thing — which is exactly how one flaky spawn cost a
719 // day of not-diagnosing.
720 crate::hooks::common::warn(
721 "git would not list the gate stamps — every gated check will run again",
722 );
723 return out;
724 };
725 let noted: HashSet<&str> = list
726 .lines()
727 .filter_map(|l| l.split_whitespace().nth(1))
728 .collect();
729 // Commit -> tree, in ONE spawn rather than one per commit: this is the
730 // push path, and a `rev-parse` each would be a process per pushed
731 // commit to answer a question `git log` answers in a batch.
732 let mut trees: HashMap<String, String> = HashMap::new();
733 {
734 let mut args: Vec<&str> = vec!["log", "--no-walk", "--format=%H %T"];
735 args.extend(commits.iter().map(String::as_str));
736 if let Some(out) = crate::git::stdout(&args) {
737 for line in out.lines() {
738 let mut it = line.split_whitespace();
739 if let (Some(c), Some(t)) = (it.next(), it.next()) {
740 trees.insert(c.to_string(), t.to_string());
741 }
742 }
743 }
744 // No mapping is not an error: every commit simply falls back to the
745 // commit-keyed lookup below, which is what happened before trees
746 // were stamped at all.
747 }
748
749 // Declared-input stamps are keyed by fingerprint, not by tree, so a commit
750 // whose tree carries no note can still be vouched for. Asked only when the
751 // ref exists: a repository that declares nothing pays one spawn.
752 let by_inputs = inputs_ref_exists();
753
754 for commit in commits {
755 // The commit's own note AND its TREE's, merged. Both are written only
756 // for that tree (`bind_to_head` checks `HEAD^{tree}`, `stamp_push` is
757 // handed the commit's own tree), so every token on either vouches for
758 // exactly this content.
759 //
760 // Not "the commit's note, else the tree's". A squash-merge produces a
761 // commit this machine never saw — no note — while the content, and
762 // therefore the tree, is the one the gates ran on, and falling through
763 // to the tree is what lets a tag push on a merged commit skip work the
764 // branch already proved. But the same preference hid the tree's push
765 // tokens behind a commit note after an AMEND that kept the tree:
766 // post-commit wrote a commit note holding only the commit-time
767 // scripts, `stamps_for` then stopped there, and the push gate ran
768 // again for content it had already judged.
769 //
770 // The direction of failure is unchanged: no note on either, an
771 // unparseable one, or a git that would not answer all mean "no
772 // stamp", and no stamp runs the gate.
773 let mut tokens: Vec<String> = Vec::new();
774 let mut keys: Vec<&str> = Vec::new();
775 if noted.contains(commit.as_str()) {
776 keys.push(commit);
777 }
778 if let Some(tree) = trees.get(commit).filter(|t| noted.contains(t.as_str())) {
779 keys.push(tree);
780 }
781 for key in keys {
782 for t in note_at(key).tokens {
783 if !tokens.contains(&t) {
784 tokens.push(t);
785 }
786 }
787 }
788 if by_inputs {
789 if let Some(tree) = trees.get(commit) {
790 for t in input_tokens(tree) {
791 if !tokens.contains(&t) {
792 tokens.push(t);
793 }
794 }
795 }
796 }
797 if tokens.is_empty() {
798 continue;
799 }
800 out.insert(commit.clone(), tokens);
801 }
802 out
803}
804
805/// uninstall: forget everything this module ever wrote here.
806///
807/// The stamps are OUR bookkeeping — unlike `hook.skip` and `amont.severity`,
808/// which are the user's statements and are never touched.
809pub fn forget() -> bool {
810 let marker = marker_path().is_some_and(|path| std::fs::remove_file(&path).is_ok());
811 let notes = crate::git::succeeds(&["update-ref", "-d", NOTES_FULL_REF]);
812 let inputs = crate::git::succeeds(&["update-ref", "-d", INPUTS_FULL_REF]);
813 marker || notes || inputs
814}
815
816/// The same, for a repository this process is not standing in.
817pub fn forget_in(repo: &std::path::Path) -> bool {
818 let marker = crate::git::stdout_in(repo, &["rev-parse", "--absolute-git-dir"])
819 .is_some_and(|dir| std::fs::remove_file(std::path::Path::new(&dir).join(MARKER)).is_ok());
820 let notes = crate::git::succeeds_in(repo, &["update-ref", "-d", NOTES_FULL_REF]);
821 let inputs = crate::git::succeeds_in(repo, &["update-ref", "-d", INPUTS_FULL_REF]);
822 marker || notes || inputs
823}
824
825#[cfg(test)]
826mod tests {
827 use super::*;
828 use std::path::Path;
829
830 /// A real repository, because every function here is a conversation with
831 /// git — hand-rolled fixtures would test the conversation we imagined.
832 fn repo(name: &str) -> PathBuf {
833 let dir = std::env::temp_dir().join(format!("gate-stamp-{name}-{}", std::process::id()));
834 let _ = std::fs::remove_dir_all(&dir);
835 std::fs::create_dir_all(&dir).unwrap();
836 git(&dir, &["init", "-q", "--template=", "."]);
837 git(&dir, &["config", "user.email", "t@t.test"]);
838 git(&dir, &["config", "user.name", "t"]);
839 dir
840 }
841
842 /// A fixture git call that FAILS where it fails.
843 ///
844 /// This used to discard the exit status, and that is how a rare flake
845 /// stayed unreadable for a day: if the setup `git commit` did not
846 /// happen, the test carried on to an unborn HEAD, and the panic landed
847 /// three lines later on a missing gate stamp — a product-shaped
848 /// failure for a fixture-shaped cause. Same rule the checks obey:
849 /// git failing is not git answering.
850 fn git(dir: &Path, args: &[&str]) -> String {
851 let out = std::process::Command::new("git")
852 .arg("-C")
853 .arg(dir)
854 .args(args)
855 .output()
856 .expect("git");
857 assert!(
858 out.status.success(),
859 "fixture: git {args:?} in {} exited {:?}: {}",
860 dir.display(),
861 out.status.code(),
862 String::from_utf8_lossy(&out.stderr).trim()
863 );
864 String::from_utf8_lossy(&out.stdout).trim().to_string()
865 }
866
867 /// The module talks to the repo at the process cwd; these tests each set
868 /// it. Serialised via the crate-wide lock, because cwd is process-global
869 /// and `attest`'s tests move it too.
870 fn in_repo<T>(dir: &Path, f: impl FnOnce() -> T) -> T {
871 let _guard = crate::TEST_CWD.lock().unwrap_or_else(|p| p.into_inner());
872 let prev = std::env::current_dir().unwrap();
873 std::env::set_current_dir(dir).unwrap();
874 let r = f();
875 std::env::set_current_dir(prev).unwrap();
876 r
877 }
878
879 #[test]
880 fn the_commit_note_does_not_hide_the_tree_notes_push_tokens() {
881 let dir = repo("union");
882 std::fs::write(dir.join("a.ts"), "x").unwrap();
883 git(&dir, &["add", "a.ts"]);
884 in_repo(&dir, || {
885 record(&["test"]);
886 git(&dir, &["commit", "-qm", "chore: a"]);
887 bind_to_head();
888 let head = git(&dir, &["rev-parse", "HEAD"]);
889 let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
890 // A push-time stamp lands on the TREE only (the shape a rehearsal
891 // leaves), while the commit note keeps just the commit-time script.
892 assert!(stamp_tree(&tree, &["pre-push-cargo-test".to_string()]));
893 assert_eq!(note_at(&head).tokens, vec!["test".to_string()]);
894 let stamps = stamps_for(std::slice::from_ref(&head));
895 assert_eq!(
896 stamps.get(&head).map(Vec::as_slice),
897 Some(&["test".to_string(), "pre-push-cargo-test".to_string()][..]),
898 "both notes speak for the same tree"
899 );
900 });
901 let _ = std::fs::remove_dir_all(&dir);
902 }
903
904 #[test]
905 fn a_commit_with_only_a_tree_note_still_reads_the_tree_tokens() {
906 let dir = repo("tree-only");
907 std::fs::write(dir.join("a.ts"), "x").unwrap();
908 git(&dir, &["add", "a.ts"]);
909 git(&dir, &["commit", "-qm", "chore: a"]);
910 in_repo(&dir, || {
911 let head = git(&dir, &["rev-parse", "HEAD"]);
912 let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
913 assert!(stamp_tree(&tree, &["pre-push-cargo-test".to_string()]));
914 let stamps = stamps_for(std::slice::from_ref(&head));
915 assert_eq!(
916 stamps.get(&head).map(Vec::as_slice),
917 Some(&["pre-push-cargo-test".to_string()][..])
918 );
919 });
920 let _ = std::fs::remove_dir_all(&dir);
921 }
922
923 #[test]
924 fn a_commit_with_only_a_commit_note_still_reads_its_tokens() {
925 let dir = repo("commit-only");
926 std::fs::write(dir.join("a.ts"), "x").unwrap();
927 git(&dir, &["add", "a.ts"]);
928 git(&dir, &["commit", "-qm", "chore: a"]);
929 in_repo(&dir, || {
930 let head = git(&dir, &["rev-parse", "HEAD"]);
931 let mut note = Note::default();
932 note.add_tokens(&["typecheck".to_string()]);
933 assert!(write_note(&head, ¬e));
934 let stamps = stamps_for(std::slice::from_ref(&head));
935 assert_eq!(
936 stamps.get(&head).map(Vec::as_slice),
937 Some(&["typecheck".to_string()][..])
938 );
939 });
940 let _ = std::fs::remove_dir_all(&dir);
941 }
942
943 #[test]
944 fn a_token_on_both_notes_is_read_once() {
945 let dir = repo("dedup");
946 std::fs::write(dir.join("a.ts"), "x").unwrap();
947 git(&dir, &["add", "a.ts"]);
948 in_repo(&dir, || {
949 record(&["test"]);
950 git(&dir, &["commit", "-qm", "chore: a"]);
951 bind_to_head(); // writes `test` on the commit AND the tree
952 let head = git(&dir, &["rev-parse", "HEAD"]);
953 let stamps = stamps_for(std::slice::from_ref(&head));
954 assert_eq!(
955 stamps.get(&head).map(Vec::as_slice),
956 Some(&["test".to_string()][..])
957 );
958 });
959 let _ = std::fs::remove_dir_all(&dir);
960 }
961
962 #[test]
963 fn a_recorded_marker_becomes_a_stamp_on_the_matching_commit() {
964 let dir = repo("roundtrip");
965 std::fs::write(dir.join("a.ts"), "x").unwrap();
966 git(&dir, &["add", "a.ts"]);
967 in_repo(&dir, || {
968 record(&["typecheck", "test"]);
969 git(&dir, &["commit", "-qm", "chore: a"]);
970 let stamped = bind_to_head();
971 assert_eq!(
972 stamped,
973 vec!["typecheck".to_string(), "test".to_string()],
974 "bind_to_head reports the scripts it stamped"
975 );
976 let head = git(&dir, &["rev-parse", "HEAD"]);
977 let stamps = stamps_for(std::slice::from_ref(&head));
978 assert_eq!(
979 stamps.get(&head).map(Vec::as_slice),
980 Some(&["typecheck".to_string(), "test".to_string()][..])
981 );
982 // One-shot: the marker is gone.
983 // One-shot: the marker is gone. The fixture's own path, not
984 // `marker_path()` — that helper spawns git, and a transient
985 // spawn failure on a loaded runner reads as `None` here while
986 // production code correctly treats it as "no marker". Seen once,
987 // on Windows, as an unwrap panic in a sibling test.
988 assert!(!dir.join(".git").join(MARKER).exists());
989 });
990 let _ = std::fs::remove_dir_all(&dir);
991 }
992
993 #[test]
994 fn a_marker_for_a_different_tree_stamps_nothing() {
995 let dir = repo("stale");
996 std::fs::write(dir.join("a.ts"), "x").unwrap();
997 git(&dir, &["add", "a.ts"]);
998 in_repo(&dir, || {
999 record(&["typecheck"]);
1000 // The commit that actually lands carries DIFFERENT content — the
1001 // aborted-attempt-then-different-retry shape.
1002 std::fs::write(dir.join("a.ts"), "y").unwrap();
1003 git(&dir, &["add", "a.ts"]);
1004 git(&dir, &["commit", "-qm", "chore: different"]);
1005 assert!(
1006 bind_to_head().is_empty(),
1007 "bind_to_head reports nothing when the tree moved"
1008 );
1009 let head = git(&dir, &["rev-parse", "HEAD"]);
1010 assert!(
1011 stamps_for(&[head]).is_empty(),
1012 "a stale marker must not vouch"
1013 );
1014 assert!(
1015 !dir.join(".git").join(MARKER).exists(),
1016 "consumed either way"
1017 );
1018 });
1019 let _ = std::fs::remove_dir_all(&dir);
1020 }
1021
1022 #[test]
1023 fn an_empty_record_clears_a_previous_marker() {
1024 let dir = repo("clears");
1025 std::fs::write(dir.join("a.ts"), "x").unwrap();
1026 git(&dir, &["add", "a.ts"]);
1027 in_repo(&dir, || {
1028 record(&["typecheck"]);
1029 assert!(dir.join(".git").join(MARKER).exists());
1030 record(&[]);
1031 assert!(!dir.join(".git").join(MARKER).exists());
1032 });
1033 let _ = std::fs::remove_dir_all(&dir);
1034 }
1035
1036 /// The version guard's REJECT branch, fed a hand-written marker: an old
1037 /// (or future) format is ignored rather than misread — the doc's claim,
1038 /// now pinned. Every other test's markers come from record() itself and
1039 /// so always carry the current FORMAT.
1040 #[test]
1041 fn a_marker_in_an_unknown_format_stamps_nothing() {
1042 let dir = repo("wrongformat");
1043 std::fs::write(dir.join("a.ts"), "x").unwrap();
1044 git(&dir, &["add", "a.ts"]);
1045 in_repo(&dir, || {
1046 let tree = git(&dir, &["write-tree"]);
1047 let marker = dir.join(".git").join(MARKER);
1048 std::fs::write(&marker, format!("amont-gate-v99\n{tree}\ntypecheck\n")).unwrap();
1049 git(&dir, &["commit", "-qm", "chore: a"]);
1050 bind_to_head();
1051 let head = git(&dir, &["rev-parse", "HEAD"]);
1052 assert!(
1053 stamps_for(std::slice::from_ref(&head)).is_empty(),
1054 "an unknown format was trusted"
1055 );
1056 assert!(!marker.exists(), "consumed either way");
1057 });
1058 let _ = std::fs::remove_dir_all(&dir);
1059 }
1060
1061 /// A note somebody else wrote into OUR ref is not a stamp. Absent this,
1062 /// `git notes --ref=amont-gate add` would be a one-line way to vouch for
1063 /// an unchecked commit — the parsing trust boundary of the whole chain.
1064 #[test]
1065 fn a_foreign_note_is_not_a_stamp() {
1066 let dir = repo("foreignnote");
1067 std::fs::write(dir.join("a.ts"), "x").unwrap();
1068 git(&dir, &["add", "a.ts"]);
1069 in_repo(&dir, || {
1070 git(&dir, &["commit", "-qm", "chore: a"]);
1071 git(
1072 &dir,
1073 &[
1074 "notes",
1075 "--ref",
1076 NOTES_REF,
1077 "add",
1078 "-m",
1079 "typecheck test",
1080 "HEAD",
1081 ],
1082 );
1083 let head = git(&dir, &["rev-parse", "HEAD"]);
1084 assert!(
1085 stamps_for(std::slice::from_ref(&head)).is_empty(),
1086 "a note without the format token was trusted"
1087 );
1088 });
1089 let _ = std::fs::remove_dir_all(&dir);
1090 }
1091
1092 /// An absent notes ref is `Some("")`, not `None` — the distinction the
1093 /// warning on that branch depends on. If git ever starts failing here
1094 /// instead, this test fails and the warning stops being a lie.
1095 #[test]
1096 fn a_repo_with_no_stamps_answers_emptily_rather_than_failing() {
1097 let dir = repo("no-stamps");
1098 std::fs::write(dir.join("a.ts"), "x").unwrap();
1099 git(&dir, &["add", "a.ts"]);
1100 git(&dir, &["commit", "-qm", "chore: a"]);
1101 in_repo(&dir, || {
1102 assert_eq!(
1103 crate::git::stdout(&["notes", "--ref", NOTES_REF, "list"]).as_deref(),
1104 Some(""),
1105 "an absent notes ref must be an ANSWER, not a failure — the \
1106 no-stamps path and the git-is-broken path are told apart by it"
1107 );
1108 let head = git(&dir, &["rev-parse", "HEAD"]);
1109 assert!(stamps_for(&[head]).is_empty());
1110 });
1111 let _ = std::fs::remove_dir_all(&dir);
1112 }
1113
1114 /// The compatibility promise, in both directions.
1115 ///
1116 /// A note written before evidence lines existed is ONE line, and it must
1117 /// parse to exactly the tokens it always did — every skip decision in
1118 /// this module reads that line, and a reader that needed the new shape
1119 /// would stop honouring every stamp on every machine the day it shipped.
1120 #[test]
1121 fn a_note_from_before_the_run_lines_parses_unchanged() {
1122 let note = Note::parse("amont-gate-v1 typecheck test");
1123 assert_eq!(note.tokens, vec!["typecheck", "test"]);
1124 assert!(note.runs.is_empty());
1125 // And round-trips to the same bytes, so an old amont reading a note
1126 // this one rewrote sees what it wrote.
1127 assert_eq!(note.render(), "amont-gate-v1 typecheck test");
1128 }
1129
1130 /// The new shape: line one unchanged, evidence after it.
1131 #[test]
1132 fn run_lines_are_read_without_disturbing_the_tokens() {
1133 let body = "amont-gate-v1 pre-push-cargo-test\n\
1134 run 1726900000 pre-push-cargo-test pass 412391\n\
1135 run 1726903600 pre-push-audit-js fail 903\n";
1136 let note = Note::parse(body);
1137 assert_eq!(note.tokens, vec!["pre-push-cargo-test"]);
1138 assert_eq!(note.runs.len(), 2);
1139 assert_eq!(
1140 note.runs[0],
1141 Run {
1142 at: 1_726_900_000,
1143 gate: "pre-push-cargo-test".into(),
1144 outcome: RunOutcome::Passed,
1145 ms: 412_391,
1146 }
1147 );
1148 assert_eq!(note.runs[1].outcome, RunOutcome::Failed);
1149 assert_eq!(note.render(), body.trim_end());
1150 }
1151
1152 /// A line this version does not understand is dropped, not guessed at —
1153 /// so a future amont may add a field (or an outcome) without an older one
1154 /// inventing a meaning for it.
1155 #[test]
1156 fn an_unreadable_run_line_is_dropped_and_the_rest_survives() {
1157 let note = Note::parse(
1158 "amont-gate-v1 test\n\
1159 run 1726900000 pre-push-cargo-test pass 400\n\
1160 run tomorrow pre-push-cargo-test pass 400\n\
1161 run 1726900001 pre-push-cargo-test sideways 400\n\
1162 banana\n\
1163 run 1726900002 pre-push-cargo-test fail 500\n",
1164 );
1165 assert_eq!(note.tokens, vec!["test"]);
1166 assert_eq!(note.runs.len(), 2, "{:?}", note.runs);
1167 assert_eq!(note.runs[1].outcome, RunOutcome::Failed);
1168 }
1169
1170 /// A note somebody else wrote into our ref is not evidence either. The
1171 /// token line is the trust boundary for BOTH halves of the note.
1172 #[test]
1173 fn a_foreign_note_yields_no_runs() {
1174 let note = Note::parse("hello\nrun 1726900000 pre-push-cargo-test pass 400\n");
1175 assert!(note.tokens.is_empty() && note.runs.is_empty());
1176 }
1177
1178 /// Recording evidence must never cost a stamp. `stamp_push` and
1179 /// `bind_to_head` rewrite the same note, and the first version of this
1180 /// rendered the note from its own token list — which would have erased
1181 /// every run line on the next push.
1182 #[test]
1183 fn a_stamp_and_its_evidence_survive_each_other() {
1184 let dir = repo("evidence");
1185 std::fs::write(dir.join("a.ts"), "x").unwrap();
1186 git(&dir, &["add", "a.ts"]);
1187 in_repo(&dir, || {
1188 git(&dir, &["commit", "-qm", "chore: a"]);
1189 let head = git(&dir, &["rev-parse", "HEAD"]);
1190 let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1191 record_runs(
1192 &tree,
1193 &[Run {
1194 at: 1_726_900_000,
1195 gate: "pre-push-cargo-test".into(),
1196 outcome: RunOutcome::Failed,
1197 ms: 412_391,
1198 }],
1199 );
1200 // …and now a later push of the same content passes and stamps it.
1201 assert!(stamp_push(&head, &tree, &["pre-push-cargo-test".into()]));
1202 let note = note_at(&tree);
1203 assert_eq!(note.tokens, vec!["pre-push-cargo-test"]);
1204 assert_eq!(note.runs.len(), 1, "the evidence survived the stamp");
1205 // The stamp still reads back as a stamp.
1206 let stamps = stamps_for(std::slice::from_ref(&head));
1207 assert_eq!(
1208 stamps.get(&head).map(Vec::as_slice),
1209 Some(&["pre-push-cargo-test".to_string()][..])
1210 );
1211 });
1212 let _ = std::fs::remove_dir_all(&dir);
1213 }
1214
1215 /// The whole point of the evidence half: it is not a stamp. A recorded
1216 /// run — even a passing one — must not let the gate be skipped, because
1217 /// skipping is decided by the token line and nothing else.
1218 #[test]
1219 fn a_recorded_run_is_not_a_stamp() {
1220 let dir = repo("not-a-stamp");
1221 std::fs::write(dir.join("a.ts"), "x").unwrap();
1222 git(&dir, &["add", "a.ts"]);
1223 in_repo(&dir, || {
1224 git(&dir, &["commit", "-qm", "chore: a"]);
1225 let head = git(&dir, &["rev-parse", "HEAD"]);
1226 let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1227 record_runs(
1228 &tree,
1229 &[Run {
1230 at: 1_726_900_000,
1231 gate: "pre-push-cargo-test".into(),
1232 outcome: RunOutcome::Passed,
1233 ms: 412_391,
1234 }],
1235 );
1236 assert!(
1237 stamps_for(std::slice::from_ref(&head)).is_empty(),
1238 "a run line vouched for a gate — evidence must never gate"
1239 );
1240 assert!(
1241 !vouched_for_staged_tree().contains("pre-push-cargo-test"),
1242 "a run line vouched at commit time"
1243 );
1244 // It IS readable as history, though.
1245 let history = crate::gate_evidence::history_in(&dir);
1246 assert_eq!(history.runs.len(), 1);
1247 assert_eq!(history.runs[0].0, tree, "keyed by the content it ran on");
1248 });
1249 let _ = std::fs::remove_dir_all(&dir);
1250 }
1251
1252 #[test]
1253 fn forget_removes_the_stamps() {
1254 let dir = repo("forget");
1255 std::fs::write(dir.join("a.ts"), "x").unwrap();
1256 git(&dir, &["add", "a.ts"]);
1257 in_repo(&dir, || {
1258 record(&["typecheck"]);
1259 git(&dir, &["commit", "-qm", "chore: a"]);
1260 bind_to_head();
1261 let head = git(&dir, &["rev-parse", "HEAD"]);
1262 assert!(!stamps_for(std::slice::from_ref(&head)).is_empty());
1263 forget();
1264 assert!(stamps_for(&[head]).is_empty());
1265 });
1266 let _ = std::fs::remove_dir_all(&dir);
1267 }
1268
1269 /// A squash-merge produces a commit this machine never saw, and the
1270 /// stamp has to survive it.
1271 ///
1272 /// This is the case that motivated tree-stamping. A branch is verified
1273 /// locally and stamped; the forge squashes it onto `main` as a NEW
1274 /// commit with no note; pushing a tag on that commit re-ran every gate
1275 /// the branch had already proved. Measured on this repository: 13
1276 /// seconds for the branch push, then the full suite for the tag push,
1277 /// which died on a reset connection.
1278 ///
1279 /// The tree is what the gates actually read, and it is identical across
1280 /// that merge whenever the base has not moved — five of five merges in
1281 /// one afternoon here.
1282 #[test]
1283 fn a_stamp_survives_a_commit_being_rewritten_with_the_same_tree() {
1284 let dir = repo("squashed");
1285 std::fs::write(dir.join("a.ts"), "x").unwrap();
1286 git(&dir, &["add", "a.ts"]);
1287 in_repo(&dir, || {
1288 record(&["test"]);
1289 git(&dir, &["commit", "-qm", "feat: on a branch"]);
1290 assert_eq!(bind_to_head(), vec!["test".to_string()]);
1291 let branch_tip = git(&dir, &["rev-parse", "HEAD"]);
1292
1293 // Stand in for the forge's squash: a DIFFERENT commit object
1294 // with the SAME tree. `--amend` with a new message is the
1295 // cheapest way to get exactly that shape.
1296 git(
1297 &dir,
1298 &[
1299 "commit",
1300 "-q",
1301 "--amend",
1302 "-m",
1303 "feat: squashed by the forge",
1304 ],
1305 );
1306 let merged = git(&dir, &["rev-parse", "HEAD"]);
1307 assert_ne!(merged, branch_tip, "the fixture must produce a new commit");
1308 assert_eq!(
1309 git(&dir, &["rev-parse", "HEAD^{tree}"]),
1310 git(&dir, &["rev-parse", &format!("{branch_tip}^{{tree}}")]),
1311 "…carrying the same tree, which is the whole premise"
1312 );
1313
1314 let stamps = stamps_for(std::slice::from_ref(&merged));
1315 assert_eq!(
1316 stamps.get(&merged).map(Vec::as_slice),
1317 Some(&["test".to_string()][..]),
1318 "the stamp must follow the content, not the commit hash"
1319 );
1320 });
1321 let _ = std::fs::remove_dir_all(&dir);
1322 }
1323
1324 /// And it must not follow anything else. A commit whose tree was never
1325 /// stamped gets nothing, however many other stamps exist — otherwise
1326 /// this widening would vouch for content nobody checked.
1327 #[test]
1328 fn a_different_tree_gets_no_stamp_from_the_fallback() {
1329 let dir = repo("othertree");
1330 std::fs::write(dir.join("a.ts"), "x").unwrap();
1331 git(&dir, &["add", "a.ts"]);
1332 in_repo(&dir, || {
1333 record(&["test"]);
1334 git(&dir, &["commit", "-qm", "chore: stamped"]);
1335 assert_eq!(bind_to_head(), vec!["test".to_string()]);
1336
1337 // Different CONTENT, so a different tree, and no marker: this
1338 // commit was never judged.
1339 std::fs::write(dir.join("b.ts"), "y").unwrap();
1340 git(&dir, &["add", "b.ts"]);
1341 git(&dir, &["commit", "-qm", "chore: unjudged"]);
1342 let unstamped = git(&dir, &["rev-parse", "HEAD"]);
1343
1344 assert!(
1345 stamps_for(std::slice::from_ref(&unstamped)).is_empty(),
1346 "a tree nobody stamped must not inherit one"
1347 );
1348 });
1349 let _ = std::fs::remove_dir_all(&dir);
1350 }
1351
1352 // ---- declared-input reuse (`.github/attest-inputs`) -------------------
1353
1354 /// A repository declaring that gate `test` reads only `a.ts`, with one
1355 /// commit stamped `test@abc`. The plans directory is where a later,
1356 /// plan-only commit will change the tree without touching any input.
1357 fn declared(name: &str, spec: &str) -> PathBuf {
1358 let dir = repo(name);
1359 std::fs::create_dir_all(dir.join(".github")).unwrap();
1360 std::fs::create_dir_all(dir.join("docs/plans")).unwrap();
1361 std::fs::write(dir.join("a.ts"), "x").unwrap();
1362 std::fs::write(dir.join("docs/plans/p.md"), "plan v1").unwrap();
1363 std::fs::write(dir.join(".github/attest-inputs"), spec).unwrap();
1364 git(&dir, &["add", "-A"]);
1365 in_repo(&dir, || {
1366 record(&["test@abc"]);
1367 git(&dir, &["commit", "-qm", "feat: judged"]);
1368 assert_eq!(bind_to_head(), vec!["test".to_string()]);
1369 });
1370 dir
1371 }
1372
1373 /// Change only the plan and commit it WITHOUT a marker: nothing was
1374 /// judged for this tree, so only the fingerprint route can speak.
1375 fn plan_only_commit(dir: &Path) -> String {
1376 std::fs::write(dir.join("docs/plans/p.md"), "plan v2, closing").unwrap();
1377 git(dir, &["add", "docs/plans/p.md"]);
1378 git(dir, &["commit", "-qm", "docs(plan): done"]);
1379 git(dir, &["rev-parse", "HEAD"])
1380 }
1381
1382 #[test]
1383 fn a_plan_only_commit_reuses_the_stamp_of_a_declared_gate() {
1384 let dir = declared("plan-only", "test a.ts\n");
1385 in_repo(&dir, || {
1386 let head = plan_only_commit(&dir);
1387 let stamps = stamps_for(std::slice::from_ref(&head));
1388 assert_eq!(
1389 stamps.get(&head).map(Vec::as_slice),
1390 Some(&["test@abc".to_string()][..]),
1391 "the closing commit changed the tree and none of the declared inputs"
1392 );
1393 });
1394 let _ = std::fs::remove_dir_all(&dir);
1395 }
1396
1397 #[test]
1398 fn the_staged_tree_of_a_plan_only_commit_is_vouched_for() {
1399 let dir = declared("staged", "test a.ts\n");
1400 in_repo(&dir, || {
1401 std::fs::write(dir.join("docs/plans/p.md"), "plan v2").unwrap();
1402 git(&dir, &["add", "docs/plans/p.md"]);
1403 assert!(vouched_for_staged_tree().contains("test@abc"));
1404 });
1405 let _ = std::fs::remove_dir_all(&dir);
1406 }
1407
1408 #[test]
1409 fn partial_staging_is_judged_on_the_index_not_the_working_copy() {
1410 let dir = declared("partial", "test a.ts\n");
1411 in_repo(&dir, || {
1412 // The plan is staged; an unstaged edit to a declared input sits in
1413 // the working copy. The commit would carry only the plan.
1414 std::fs::write(dir.join("docs/plans/p.md"), "plan v2").unwrap();
1415 git(&dir, &["add", "docs/plans/p.md"]);
1416 std::fs::write(dir.join("a.ts"), "unstaged edit").unwrap();
1417 assert!(vouched_for_staged_tree().contains("test@abc"));
1418
1419 // The other way round: the input IS staged, so it misses even
1420 // though the working copy was put back.
1421 std::fs::write(dir.join("a.ts"), "staged edit").unwrap();
1422 git(&dir, &["add", "a.ts"]);
1423 std::fs::write(dir.join("a.ts"), "x").unwrap();
1424 assert!(!vouched_for_staged_tree().contains("test@abc"));
1425 });
1426 let _ = std::fs::remove_dir_all(&dir);
1427 }
1428
1429 #[test]
1430 fn a_changed_declared_input_misses() {
1431 let dir = declared("input-changed", "test a.ts\n");
1432 in_repo(&dir, || {
1433 std::fs::write(dir.join("a.ts"), "changed").unwrap();
1434 git(&dir, &["add", "a.ts"]);
1435 git(&dir, &["commit", "-qm", "feat: unjudged"]);
1436 let head = git(&dir, &["rev-parse", "HEAD"]);
1437 assert!(stamps_for(std::slice::from_ref(&head)).is_empty());
1438 });
1439 let _ = std::fs::remove_dir_all(&dir);
1440 }
1441
1442 #[test]
1443 fn a_changed_declaration_misses() {
1444 let dir = declared("declaration-changed", "test a.ts\n");
1445 in_repo(&dir, || {
1446 // The spec's own blob is in every fingerprint: widening what the
1447 // gate reads is a different declaration, so nothing is reused.
1448 std::fs::write(dir.join("b.ts"), "y").unwrap();
1449 std::fs::write(dir.join(".github/attest-inputs"), "test a.ts b.ts\n").unwrap();
1450 git(&dir, &["add", "-A"]);
1451 git(&dir, &["commit", "-qm", "chore: widen the declaration"]);
1452 let head = git(&dir, &["rev-parse", "HEAD"]);
1453 assert!(stamps_for(std::slice::from_ref(&head)).is_empty());
1454 });
1455 let _ = std::fs::remove_dir_all(&dir);
1456 }
1457
1458 #[test]
1459 fn a_declared_config_file_is_an_input_like_any_other() {
1460 // A gate's scope lives in a tracked file (`amont.conf`). Declared, a
1461 // scope-only edit changes the fingerprint and the stamp misses.
1462 let dir = declared("config", "test a.ts amont.conf\n");
1463 in_repo(&dir, || {
1464 // `declared` ran before amont.conf existed: it is a required path,
1465 // so that first commit had no fingerprint and filed nothing.
1466 assert!(!inputs_ref_exists());
1467
1468 std::fs::write(dir.join("amont.conf"), "scope = src\n").unwrap();
1469 git(&dir, &["add", "amont.conf"]);
1470 record(&["test@abc"]);
1471 git(&dir, &["commit", "-qm", "chore: config"]);
1472 assert_eq!(bind_to_head(), vec!["test".to_string()]);
1473
1474 std::fs::write(dir.join("docs/plans/p.md"), "plan v2").unwrap();
1475 git(&dir, &["add", "docs/plans/p.md"]);
1476 git(&dir, &["commit", "-qm", "docs(plan): done"]);
1477 let plan = git(&dir, &["rev-parse", "HEAD"]);
1478 assert!(stamps_for(std::slice::from_ref(&plan)).contains_key(&plan));
1479
1480 std::fs::write(dir.join("amont.conf"), "scope = lib\n").unwrap();
1481 git(&dir, &["add", "amont.conf"]);
1482 git(&dir, &["commit", "-qm", "chore: scope-only change"]);
1483 let scope = git(&dir, &["rev-parse", "HEAD"]);
1484 assert!(
1485 stamps_for(std::slice::from_ref(&scope)).is_empty(),
1486 "a scope-only change must miss"
1487 );
1488 });
1489 let _ = std::fs::remove_dir_all(&dir);
1490 }
1491
1492 #[test]
1493 fn a_declared_stamp_is_read_back_as_its_complete_token() {
1494 // The identity comparison lives in the readers (`pair_verdict`, the
1495 // push gate): they skip only on the complete token they compute. This
1496 // pins what the new route owes them: tokens come back verbatim, never
1497 // widened to the gate's name, and a second identity under the same key
1498 // is kept apart from the first.
1499 let dir = declared("identity", "test a.ts\n");
1500 in_repo(&dir, || {
1501 let head = plan_only_commit(&dir);
1502 let tokens = stamps_for(std::slice::from_ref(&head))
1503 .remove(&head)
1504 .unwrap_or_default();
1505 assert_eq!(tokens, vec!["test@abc".to_string()]);
1506
1507 let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1508 assert!(stamp_tree(&tree, &["test@def".to_string()]));
1509 let tokens = tree_tokens(&tree);
1510 assert!(tokens.contains(&"test@abc".to_string()));
1511 assert!(tokens.contains(&"test@def".to_string()));
1512 assert!(!tokens.contains(&"test@zzz".to_string()));
1513 assert!(!tokens.contains(&"test".to_string()));
1514 });
1515 let _ = std::fs::remove_dir_all(&dir);
1516 }
1517
1518 #[test]
1519 fn a_gate_that_does_not_declare_the_manifest_is_not_offered_the_route() {
1520 // `amont.conf` is tracked but the gate's declaration omits it: a
1521 // scope-only edit would be invisible to the fingerprint, so the route
1522 // is refused outright. Nothing is filed and nothing is reused.
1523 let dir = repo("manifest-undeclared");
1524 std::fs::create_dir_all(dir.join(".github")).unwrap();
1525 std::fs::create_dir_all(dir.join("docs/plans")).unwrap();
1526 std::fs::write(dir.join("a.ts"), "x").unwrap();
1527 std::fs::write(dir.join("amont.conf"), "scope = src\n").unwrap();
1528 std::fs::write(dir.join("docs/plans/p.md"), "v1").unwrap();
1529 std::fs::write(dir.join(".github/attest-inputs"), "test a.ts\n").unwrap();
1530 git(&dir, &["add", "-A"]);
1531 in_repo(&dir, || {
1532 record(&["test@abc"]);
1533 git(&dir, &["commit", "-qm", "feat: judged"]);
1534 bind_to_head();
1535 assert!(!inputs_ref_exists(), "the route must not open");
1536 let head = plan_only_commit(&dir);
1537 assert!(stamps_for(std::slice::from_ref(&head)).is_empty());
1538 });
1539 let _ = std::fs::remove_dir_all(&dir);
1540 }
1541
1542 #[test]
1543 fn a_gate_declaring_the_manifest_opens_the_route() {
1544 // The counterpart: with `amont.conf` among its declared inputs the
1545 // gate is filed by fingerprint again.
1546 let dir = declared("manifest-declared", "test a.ts amont.conf\n");
1547 in_repo(&dir, || {
1548 std::fs::write(dir.join("amont.conf"), "scope = src\n").unwrap();
1549 git(&dir, &["add", "amont.conf"]);
1550 record(&["test@abc"]);
1551 git(&dir, &["commit", "-qm", "chore: config"]);
1552 bind_to_head();
1553 assert!(inputs_ref_exists());
1554 });
1555 let _ = std::fs::remove_dir_all(&dir);
1556 }
1557
1558 #[test]
1559 fn an_undeclared_gate_keeps_whole_tree_behaviour() {
1560 let dir = declared("undeclared", "test a.ts\n");
1561 in_repo(&dir, || {
1562 // `lint` passed on the first tree but the spec declares only
1563 // `test`: the plan-only commit is a new tree for `lint`.
1564 let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1565 assert!(stamp_tree(&tree, &["lint@zzz".to_string()]));
1566 let head = plan_only_commit(&dir);
1567 let tokens = stamps_for(std::slice::from_ref(&head))
1568 .remove(&head)
1569 .unwrap_or_default();
1570 assert!(tokens.contains(&"test@abc".to_string()));
1571 assert!(!tokens.iter().any(|t| t.starts_with("lint@")));
1572 });
1573 let _ = std::fs::remove_dir_all(&dir);
1574 }
1575
1576 #[test]
1577 fn an_invalid_or_ambiguous_spec_falls_back_to_whole_tree_behaviour() {
1578 // A glob invalidates the whole spec.
1579 let dir = declared("invalid", "test src/*\n");
1580 in_repo(&dir, || {
1581 assert!(!inputs_ref_exists(), "no valid spec, nothing is filed");
1582 let head = plan_only_commit(&dir);
1583 assert!(stamps_for(std::slice::from_ref(&head)).is_empty());
1584 });
1585 let _ = std::fs::remove_dir_all(&dir);
1586
1587 // Both locations present: ambiguous, the route is off.
1588 let dir = repo("ambiguous");
1589 std::fs::create_dir_all(dir.join(".github")).unwrap();
1590 std::fs::create_dir_all(dir.join(".forgejo")).unwrap();
1591 std::fs::create_dir_all(dir.join("docs/plans")).unwrap();
1592 std::fs::write(dir.join("a.ts"), "x").unwrap();
1593 std::fs::write(dir.join("docs/plans/p.md"), "v1").unwrap();
1594 std::fs::write(dir.join(".github/attest-inputs"), "test a.ts\n").unwrap();
1595 std::fs::write(dir.join(".forgejo/attest-inputs"), "test a.ts\n").unwrap();
1596 git(&dir, &["add", "-A"]);
1597 in_repo(&dir, || {
1598 record(&["test@abc"]);
1599 git(&dir, &["commit", "-qm", "feat: judged"]);
1600 bind_to_head();
1601 assert!(!inputs_ref_exists());
1602 let head = plan_only_commit(&dir);
1603 assert!(stamps_for(std::slice::from_ref(&head)).is_empty());
1604 });
1605 let _ = std::fs::remove_dir_all(&dir);
1606 }
1607
1608 #[test]
1609 fn a_gate_that_did_not_pass_leaves_no_declared_input_stamp() {
1610 let dir = repo("failed");
1611 std::fs::create_dir_all(dir.join(".github")).unwrap();
1612 std::fs::write(dir.join("a.ts"), "x").unwrap();
1613 std::fs::write(dir.join(".github/attest-inputs"), "test a.ts\n").unwrap();
1614 git(&dir, &["add", "-A"]);
1615 in_repo(&dir, || {
1616 // pre-commit blocked or ran nothing: an empty record, no marker.
1617 record(&[]);
1618 git(&dir, &["commit", "-qm", "feat: unjudged"]);
1619 assert!(bind_to_head().is_empty());
1620 let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1621 let head = git(&dir, &["rev-parse", "HEAD"]);
1622 assert!(!stamp_push(&head, &tree, &[]));
1623 assert!(!inputs_ref_exists());
1624 });
1625 let _ = std::fs::remove_dir_all(&dir);
1626 }
1627
1628 #[test]
1629 fn stamp_tree_and_tree_tokens_use_the_inputs_ref() {
1630 let dir = declared("rehearsal", "test a.ts\n");
1631 in_repo(&dir, || {
1632 // A rehearsal proves a gate in a snapshot of this tree …
1633 let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1634 assert!(stamp_tree(&tree, &["test@rehearsed".to_string()]));
1635 // … and a later tree with identical declared inputs reads it back.
1636 plan_only_commit(&dir);
1637 let later = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1638 assert_ne!(later, tree);
1639 assert!(tree_tokens(&later).contains(&"test@rehearsed".to_string()));
1640 });
1641 let _ = std::fs::remove_dir_all(&dir);
1642 }
1643
1644 #[test]
1645 fn the_inputs_ref_is_keyed_the_way_attest_keys_its_own() {
1646 let dir = declared("key", "test a.ts\n");
1647 in_repo(&dir, || {
1648 let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1649 let spec = crate::attest_inputs::spec_at(&tree).expect("spec");
1650 let fp = crate::attest_inputs::fingerprint(&tree, &spec[0].1).expect("fp");
1651 let key = crate::attest_inputs::input_key("test", &fp).expect("key");
1652 let listed = git(&dir, &["notes", "--ref", INPUTS_REF, "list"]);
1653 assert!(
1654 listed
1655 .lines()
1656 .any(|l| l.split_whitespace().nth(1) == Some(key.as_str())),
1657 "{listed}"
1658 );
1659 });
1660 let _ = std::fs::remove_dir_all(&dir);
1661 }
1662
1663 #[test]
1664 fn a_foreign_token_in_the_inputs_ref_vouches_for_nothing() {
1665 let dir = declared("foreign", "test a.ts\n");
1666 in_repo(&dir, || {
1667 let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1668 let spec = crate::attest_inputs::spec_at(&tree).expect("spec");
1669 let fp = crate::attest_inputs::fingerprint(&tree, &spec[0].1).expect("fp");
1670 let key = crate::attest_inputs::input_key("test", &fp).expect("key");
1671 // Somebody files a token for ANOTHER gate under `test`'s key.
1672 let mut note = note_in(INPUTS_REF, &key);
1673 note.add_tokens(&["publish@evil".to_string()]);
1674 assert!(write_note_in(INPUTS_REF, &key, ¬e));
1675 assert!(!tree_tokens(&tree).contains(&"publish@evil".to_string()));
1676 });
1677 let _ = std::fs::remove_dir_all(&dir);
1678 }
1679
1680 #[test]
1681 fn forget_removes_the_inputs_ref_too() {
1682 let dir = declared("forget-inputs", "test a.ts\n");
1683 in_repo(&dir, || {
1684 assert!(inputs_ref_exists());
1685 assert!(forget());
1686 assert!(!inputs_ref_exists());
1687 });
1688 let _ = std::fs::remove_dir_all(&dir);
1689 }
1690}