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