Skip to main content

amont_runtime/
gate_stamp.rs

1//! The record that turns "a declaration exists" into "the check ran".
2//!
3//! Moving a gate entry to commit time (`docs/checks.md`, "Moving a gate entry
4//! earlier") makes the push gate skip a script because a `pre-commit`
5//! declaration covers it. A declaration is a promise on paper: a commit made
6//! with `--no-verify`, from a libgit2 client that runs no hooks, or on a
7//! machine without amont was never judged by it — and until this module
8//! existed, push time had no way to tell those commits from checked ones.
9//!
10//! Three hooks share one record:
11//!
12//! 1. **pre-commit** ([`record`]) writes a one-shot marker into `$GIT_DIR`
13//!    naming the gate scripts that actually ran, bound to the tree the commit
14//!    is about to write (`git write-tree` — during pre-commit the index IS
15//!    the commit's content; `staged_only` parks only the working tree).
16//! 2. **post-commit** ([`bind_to_head`]) consumes the marker and, when the
17//!    marker's tree matches `HEAD^{tree}`, stamps the commit in a notes ref.
18//!    `--no-verify` skips pre-commit but NOT post-commit, so an unchecked
19//!    commit arrives here with no marker and gets no stamp — which is the
20//!    entire point. The tree comparison makes an aborted commit's leftover
21//!    marker harmless, and a retried commit of the SAME tree correctly
22//!    stamped: the check really did run on exactly that content.
23//! 3. **pre-push** ([`stamps_for`]) reads the stamps back and suppresses a
24//!    gate script only for pushes whose relevant commits all carry it.
25//! 4. **pre-push, on the way out** ([`stamp_push`]) writes the push-time
26//!    gates that PASSED onto each pushed tip — and `amont run pre-push`,
27//!    which drives the same dispatcher, writes the same stamp for `HEAD`.
28//!    The next push of that tree finds the stamp and skips the gate. That is
29//!    what lets the suite run BEFORE git opens its connection: a rehearsal
30//!    stamps, the push verifies. And a push that passed the gate but died on
31//!    the wire — a remote that dropped the idle session while the suite ran
32//!    — retries in seconds instead of minutes. Push-time tokens are full
33//!    ids (`pre-push-run-tests-js`), commit-time ones are script names
34//!    (`test`); the two share a note and never collide.
35//!
36//! Every failure mode points the same direction: no marker, a mismatched
37//! tree, a missing note, a rewritten hash — all mean "no stamp", and no stamp
38//! means the push gate RUNS. Nothing here can let an unchecked commit
39//! through; it can only cost a redundant gate run.
40//!
41//! Why a notes ref and not config: notes are keyed by commit, garbage-collect
42//! with unreachable commits (an `amont.checked.<hash>` config key would
43//! outlive every rebase forever), stay local (notes refs are not pushed by
44//! default), and stay out of `git log` (only `refs/notes/commits` displays by
45//! default). `amont uninstall` deletes the ref; see `uninstall_repo_hooks`.
46
47use std::collections::{HashMap, HashSet};
48use std::path::PathBuf;
49
50/// First token of the marker file and of every note body. Versioned like
51/// `staged_only::FORMAT`: a future amont that changes the shape bumps this,
52/// and an old record is ignored rather than misread.
53pub const FORMAT: &str = "amont-gate-v1";
54
55/// The notes ref, spelled the way `git notes --ref` wants it.
56pub const NOTES_REF: &str = "amont-gate";
57
58/// The same ref, fully qualified — what `git update-ref -d` needs.
59pub const NOTES_FULL_REF: &str = "refs/notes/amont-gate";
60
61/// The marker's filename inside `$GIT_DIR`.
62const MARKER: &str = "amont-gate";
63
64/// The switch for push-time stamps — both writing them and honouring them.
65/// On by default: a stamp is amont's own record of a gate it ran on exactly
66/// this content, the same trust the commit-time stamps have always carried.
67const PUSH_STAMPS: &str = "amont.pushStamps";
68
69/// Does this repository reuse push-time stamps?
70pub fn push_stamps_enabled(settings: &crate::config::Settings) -> bool {
71    crate::config::boolean_or(settings, PUSH_STAMPS, true)
72}
73
74/// The switch for commit-time reuse — a pre-commit gate that already ran
75/// clean against exactly this staged tree is not run again. On by default,
76/// for the same reason `pushStamps` is: the record is amont's own, bound to
77/// the content, and identical content is what a test suite reads.
78const COMMIT_STAMPS: &str = "amont.commitStamps";
79
80/// Does this repository reuse a gate's verdict across commit attempts of
81/// one tree?
82pub fn commit_stamps_enabled(settings: &crate::config::Settings) -> bool {
83    crate::config::boolean_or(settings, COMMIT_STAMPS, true)
84}
85
86/// `$GIT_DIR/amont-gate` — the worktree-PRIVATE gitdir, deliberately: the
87/// commit this marker waits for happens in this worktree. The stamps the
88/// marker becomes live in the common dir (a notes ref) and are shared.
89fn marker_path() -> Option<PathBuf> {
90    let dir = crate::git::stdout(&["rev-parse", "--git-dir"])?;
91    Some(std::path::Path::new(&dir).join(MARKER))
92}
93
94/// pre-commit: record that `scripts` ran clean against the tree the commit
95/// will carry.
96///
97/// Called on EVERY pre-commit verdict, with an empty list when nothing
98/// qualifying ran (or the commit is about to be blocked) — an aborted or
99/// unchecked attempt must not inherit a previous attempt's marker.
100///
101/// Best-effort throughout: a failure to record costs one redundant gate run
102/// at push time, which is the safe direction, and a pre-commit that failed a
103/// COMMIT over bookkeeping would be the tail wagging the dog.
104pub fn record(scripts: &[&str]) {
105    let Some(path) = marker_path() else { return };
106    if scripts.is_empty() {
107        let _ = std::fs::remove_file(&path);
108        return;
109    }
110    // The index, as the object id `git commit` is about to seal. Inherits
111    // `GIT_INDEX_FILE`, so `git commit -a`'s temporary index answers here
112    // too. Pure read of the index: writes objects, touches no ref.
113    let Some(tree) = crate::git::stdout(&["write-tree"]) else {
114        // Not "nothing ran": git could not name the tree, so nothing may be
115        // vouched for. Dropping the marker is the fail-safe half (the gate
116        // re-runs at push); saying so is the half that was missing.
117        crate::hooks::common::warn(
118            "git would not name the staged tree — this commit records no gate stamp",
119        );
120        let _ = std::fs::remove_file(&path);
121        return;
122    };
123    let mut body = format!("{FORMAT}\n{tree}\n");
124    for s in scripts {
125        body.push_str(s);
126        body.push('\n');
127    }
128    let _ = std::fs::write(&path, body);
129}
130
131/// pre-commit, before a gate runs: the scripts a previous run already
132/// vouched for against the tree this commit is about to seal.
133///
134/// Two records answer, both bound to the TREE (`git write-tree` of the index
135/// under the staged-only hold — the commit's content, not the working
136/// tree):
137///
138/// 1. the one-shot marker [`record`] left behind. post-commit consumes it,
139///    so a marker that is still there belongs to an attempt that never
140///    reached post-commit — a `commit-msg` refusal, an editor closed on an
141///    empty message, a Ctrl-C at the prompt. The gates ran, the tree is the
142///    same, the verdict stands: measured on this machine, a subject three
143///    characters too long replayed a ten-minute suite for nothing.
144/// 2. the tree note [`bind_to_head`] writes — a reword, a `reset --soft` and
145///    re-commit, or a rebase that kept the tree.
146///
147/// Every failure mode reads as "nothing is vouched for", which runs the
148/// gate: no `write-tree`, a marker for another tree, a wrong format, a git
149/// that would not answer. Reuse can only ever skip a run the record proves
150/// happened on this exact content; it can never let unjudged content
151/// through — the direction every function in this module fails in.
152///
153/// Repo-controlled inputs to the gate (its command line, its scope) live in
154/// the manifest, which is part of the tree: a changed declaration is a
155/// changed tree, and nothing is reused.
156pub fn vouched_for_staged_tree() -> HashSet<String> {
157    let mut out = HashSet::new();
158    let Some(tree) = crate::git::stdout(&["write-tree"]) else {
159        return out;
160    };
161    if let Some(path) = marker_path() {
162        if let Ok(body) = std::fs::read_to_string(&path) {
163            let mut lines = body.lines();
164            if lines.next() == Some(FORMAT) && lines.next() == Some(tree.as_str()) {
165                out.extend(lines.filter(|l| !l.trim().is_empty()).map(str::to_string));
166            }
167        }
168    }
169    // The tree note: `notes show` fails loudly on an absent note, and a
170    // failure here is simply "no note" — the marker's answer stands alone.
171    if let Some(body) = crate::git::stdout(&["notes", "--ref", NOTES_REF, "show", &tree]) {
172        if let Some(first) = body.lines().next() {
173            let mut tokens = first.split_whitespace();
174            if tokens.next() == Some(FORMAT) {
175                // Commit-time tokens are script names; push-time ones are
176                // full ids (`pre-push-…`), which no pre-commit declaration is
177                // named after — harmless in the set.
178                out.extend(tokens.map(str::to_string));
179            }
180        }
181    }
182    out
183}
184
185/// post-commit: consume the marker; stamp HEAD when the tree still matches.
186///
187/// One-shot by construction — the marker is deleted before anything is
188/// judged, so no path through here can leave it to vouch for a later commit.
189///
190/// Returns the scripts it actually stamped (empty on every no-stamp path,
191/// including a note git refused). The caller subtracts this from what the
192/// manifest declares to learn what the commit dodged — [`crate::bypass`]
193/// keeps that count. Two records, two questions: the stamp gates a check,
194/// the ledger only counts.
195pub fn bind_to_head() -> Vec<String> {
196    let Some(path) = marker_path() else {
197        return Vec::new();
198    };
199    let Ok(body) = std::fs::read_to_string(&path) else {
200        return Vec::new(); // no marker: nothing ran at pre-commit, nothing to stamp
201    };
202    let _ = std::fs::remove_file(&path);
203    let mut lines = body.lines();
204    if lines.next() != Some(FORMAT) {
205        return Vec::new();
206    }
207    let Some(tree) = lines.next() else {
208        return Vec::new();
209    };
210    let scripts: Vec<&str> = lines.filter(|l| !l.trim().is_empty()).collect();
211    if scripts.is_empty() {
212        return Vec::new();
213    }
214    let Some(head_tree) = crate::git::stdout(&["rev-parse", "HEAD^{tree}"]) else {
215        crate::hooks::common::warn(
216            "git would not name this commit's tree — no gate stamp was written",
217        );
218        return Vec::new();
219    };
220    // A different tree means this commit is not the one pre-commit judged —
221    // the marker is a dead letter from an aborted attempt.
222    if head_tree != tree {
223        return Vec::new();
224    }
225    let note = format!("{FORMAT} {}", scripts.join(" "));
226    // The stamp goes on the TREE as well as the commit, and the tree is the
227    // one that survives the way work actually reaches `main`.
228    //
229    // A squash-merge is performed by the forge: it produces a commit nobody
230    // here ever saw, carrying no note, so a later `git push` of a tag on
231    // that commit re-ran every gate the branch had already proved. Measured
232    // on this repository: a branch push took 13 seconds and the tag push
233    // that followed took the full suite and died on a reset connection.
234    //
235    // The tree is identical across that merge whenever the base has not
236    // moved — five of five merges in one afternoon here — and identical
237    // trees are identical CONTENT, which is the only thing a test suite
238    // reads. That is the same argument `attest` makes for signing the tree
239    // rather than the commit, and this module's marker has been tree-bound
240    // since it was written; this only carries the binding through to the
241    // note.
242    //
243    // Both, not either: the commit note is what a `git log --notes` reader
244    // sees, and dropping it would make the stamps invisible in the place
245    // people look for them.
246    let _ = crate::git::succeeds(&[
247        "notes", "--ref", NOTES_REF, "add", "-f", "-m", &note, &head_tree,
248    ]);
249    if !crate::git::succeeds(&[
250        "notes", "--ref", NOTES_REF, "add", "-f", "-m", &note, "HEAD",
251    ]) {
252        // A note git refused is not a stamp — and the push will re-run these
253        // checks, which is right but looks arbitrary unless it is said.
254        crate::hooks::common::warn(
255            "git refused to write the gate stamp — these checks will run again at push",
256        );
257        return Vec::new();
258    }
259    scripts.iter().map(|s| s.to_string()).collect()
260}
261
262/// pre-push, after every block gate passed: record that `gates` passed
263/// against `tree`, the content of `commit` — MERGING with whatever the note
264/// already says, because a commit-time stamp (`test`) may already sit there
265/// and `notes add -f` would otherwise erase it.
266///
267/// Best-effort, like every writer here: a stamp that could not be written
268/// costs one redundant gate run on the next push, which is the safe
269/// direction. Returns whether the commit's note was written.
270pub fn stamp_push(commit: &str, tree: &str, gates: &[String]) -> bool {
271    if gates.is_empty() {
272        return false;
273    }
274    let existing = |key: &str| -> Vec<String> {
275        crate::git::stdout(&["notes", "--ref", NOTES_REF, "show", key])
276            .and_then(|body| {
277                let first = body.lines().next()?.to_string();
278                let mut tokens = first.split_whitespace();
279                (tokens.next() == Some(FORMAT)).then(|| tokens.map(str::to_string).collect())
280            })
281            .unwrap_or_default()
282    };
283    let mut written = false;
284    for key in [tree, commit] {
285        let mut tokens = existing(key);
286        for g in gates {
287            if !tokens.iter().any(|t| t == g) {
288                tokens.push(g.clone());
289            }
290        }
291        let note = format!("{FORMAT} {}", tokens.join(" "));
292        let ok =
293            crate::git::succeeds(&["notes", "--ref", NOTES_REF, "add", "-f", "-m", &note, key]);
294        if key == commit {
295            written = ok;
296        }
297    }
298    if !written {
299        crate::hooks::common::warn(
300            "git refused to write the push stamp — these gates will run again on the next push",
301        );
302    }
303    written
304}
305
306/// pre-push: which of `commits` carry a stamp, and for which scripts.
307///
308/// One `notes list` narrows the reads to commits that have a note at all;
309/// absent ref, unparseable note, wrong format version — all read as "no
310/// stamp", which re-runs the gate.
311pub fn stamps_for(commits: &[String]) -> HashMap<String, Vec<String>> {
312    let mut out = HashMap::new();
313    if commits.is_empty() {
314        return out;
315    }
316    let Some(list) = crate::git::stdout(&["notes", "--ref", NOTES_REF, "list"]) else {
317        // NOT the absent-ref case, whatever an older comment here claimed:
318        // `notes list` exits 0 with empty output when the ref does not exist,
319        // so that arrives as `Some("")` and falls through as "nothing is
320        // stamped" — correctly. Reaching HERE means git could not answer at
321        // all. Same verdict (the gates re-run: never skip work on a question
322        // we could not ask), different sentence, because a transient git
323        // failure that reads as "nothing is stamped" is indistinguishable
324        // from the real thing — which is exactly how one flaky spawn cost a
325        // day of not-diagnosing.
326        crate::hooks::common::warn(
327            "git would not list the gate stamps — every gated check will run again",
328        );
329        return out;
330    };
331    let noted: HashSet<&str> = list
332        .lines()
333        .filter_map(|l| l.split_whitespace().nth(1))
334        .collect();
335    // Commit -> tree, in ONE spawn rather than one per commit: this is the
336    // push path, and a `rev-parse` each would be a process per pushed
337    // commit to answer a question `git log` answers in a batch.
338    let mut trees: HashMap<String, String> = HashMap::new();
339    {
340        let mut args: Vec<&str> = vec!["log", "--no-walk", "--format=%H %T"];
341        args.extend(commits.iter().map(String::as_str));
342        if let Some(out) = crate::git::stdout(&args) {
343            for line in out.lines() {
344                let mut it = line.split_whitespace();
345                if let (Some(c), Some(t)) = (it.next(), it.next()) {
346                    trees.insert(c.to_string(), t.to_string());
347                }
348            }
349        }
350        // No mapping is not an error: every commit simply falls back to the
351        // commit-keyed lookup below, which is what happened before trees
352        // were stamped at all.
353    }
354
355    for commit in commits {
356        // The commit's own note first, then its TREE's. A squash-merge
357        // produces a commit this machine never saw — no note — while the
358        // content, and therefore the tree, is the one the gates ran on.
359        // Falling through to the tree is what lets a tag push on a merged
360        // commit skip work the branch already proved.
361        //
362        // The direction of failure is unchanged: no note on either, an
363        // unparseable one, or a git that would not answer all mean "no
364        // stamp", and no stamp runs the gate.
365        let key: &str = if noted.contains(commit.as_str()) {
366            commit
367        } else {
368            match trees.get(commit).filter(|t| noted.contains(t.as_str())) {
369                Some(tree) => tree,
370                None => continue,
371            }
372        };
373        let Some(body) = crate::git::stdout(&["notes", "--ref", NOTES_REF, "show", key]) else {
374            continue;
375        };
376        let Some(first) = body.lines().next() else {
377            continue;
378        };
379        let mut tokens = first.split_whitespace();
380        if tokens.next() != Some(FORMAT) {
381            continue;
382        }
383        out.insert(commit.clone(), tokens.map(str::to_string).collect());
384    }
385    out
386}
387
388/// uninstall: forget everything this module ever wrote here.
389///
390/// The stamps are OUR bookkeeping — unlike `hook.skip` and `amont.severity`,
391/// which are the user's statements and are never touched.
392pub fn forget() -> bool {
393    let marker = marker_path().is_some_and(|path| std::fs::remove_file(&path).is_ok());
394    let notes = crate::git::succeeds(&["update-ref", "-d", NOTES_FULL_REF]);
395    marker || notes
396}
397
398/// The same, for a repository this process is not standing in.
399pub fn forget_in(repo: &std::path::Path) -> bool {
400    let marker = crate::git::stdout_in(repo, &["rev-parse", "--absolute-git-dir"])
401        .is_some_and(|dir| std::fs::remove_file(std::path::Path::new(&dir).join(MARKER)).is_ok());
402    let notes = crate::git::succeeds_in(repo, &["update-ref", "-d", NOTES_FULL_REF]);
403    marker || notes
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use std::path::Path;
410
411    /// A real repository, because every function here is a conversation with
412    /// git — hand-rolled fixtures would test the conversation we imagined.
413    fn repo(name: &str) -> PathBuf {
414        let dir = std::env::temp_dir().join(format!("gate-stamp-{name}-{}", std::process::id()));
415        let _ = std::fs::remove_dir_all(&dir);
416        std::fs::create_dir_all(&dir).unwrap();
417        git(&dir, &["init", "-q", "--template=", "."]);
418        git(&dir, &["config", "user.email", "t@t.test"]);
419        git(&dir, &["config", "user.name", "t"]);
420        dir
421    }
422
423    /// A fixture git call that FAILS where it fails.
424    ///
425    /// This used to discard the exit status, and that is how a rare flake
426    /// stayed unreadable for a day: if the setup `git commit` did not
427    /// happen, the test carried on to an unborn HEAD, and the panic landed
428    /// three lines later on a missing gate stamp — a product-shaped
429    /// failure for a fixture-shaped cause. Same rule the checks obey:
430    /// git failing is not git answering.
431    fn git(dir: &Path, args: &[&str]) -> String {
432        let out = std::process::Command::new("git")
433            .arg("-C")
434            .arg(dir)
435            .args(args)
436            .output()
437            .expect("git");
438        assert!(
439            out.status.success(),
440            "fixture: git {args:?} in {} exited {:?}: {}",
441            dir.display(),
442            out.status.code(),
443            String::from_utf8_lossy(&out.stderr).trim()
444        );
445        String::from_utf8_lossy(&out.stdout).trim().to_string()
446    }
447
448    /// The module talks to the repo at the process cwd; these tests each set
449    /// it. Serialised via the crate-wide lock, because cwd is process-global
450    /// and `attest`'s tests move it too.
451    fn in_repo<T>(dir: &Path, f: impl FnOnce() -> T) -> T {
452        let _guard = crate::TEST_CWD.lock().unwrap_or_else(|p| p.into_inner());
453        let prev = std::env::current_dir().unwrap();
454        std::env::set_current_dir(dir).unwrap();
455        let r = f();
456        std::env::set_current_dir(prev).unwrap();
457        r
458    }
459
460    #[test]
461    fn a_recorded_marker_becomes_a_stamp_on_the_matching_commit() {
462        let dir = repo("roundtrip");
463        std::fs::write(dir.join("a.ts"), "x").unwrap();
464        git(&dir, &["add", "a.ts"]);
465        in_repo(&dir, || {
466            record(&["typecheck", "test"]);
467            git(&dir, &["commit", "-qm", "chore: a"]);
468            let stamped = bind_to_head();
469            assert_eq!(
470                stamped,
471                vec!["typecheck".to_string(), "test".to_string()],
472                "bind_to_head reports the scripts it stamped"
473            );
474            let head = git(&dir, &["rev-parse", "HEAD"]);
475            let stamps = stamps_for(std::slice::from_ref(&head));
476            assert_eq!(
477                stamps.get(&head).map(Vec::as_slice),
478                Some(&["typecheck".to_string(), "test".to_string()][..])
479            );
480            // One-shot: the marker is gone.
481            // One-shot: the marker is gone. The fixture's own path, not
482            // `marker_path()` — that helper spawns git, and a transient
483            // spawn failure on a loaded runner reads as `None` here while
484            // production code correctly treats it as "no marker". Seen once,
485            // on Windows, as an unwrap panic in a sibling test.
486            assert!(!dir.join(".git").join(MARKER).exists());
487        });
488        let _ = std::fs::remove_dir_all(&dir);
489    }
490
491    #[test]
492    fn a_marker_for_a_different_tree_stamps_nothing() {
493        let dir = repo("stale");
494        std::fs::write(dir.join("a.ts"), "x").unwrap();
495        git(&dir, &["add", "a.ts"]);
496        in_repo(&dir, || {
497            record(&["typecheck"]);
498            // The commit that actually lands carries DIFFERENT content — the
499            // aborted-attempt-then-different-retry shape.
500            std::fs::write(dir.join("a.ts"), "y").unwrap();
501            git(&dir, &["add", "a.ts"]);
502            git(&dir, &["commit", "-qm", "chore: different"]);
503            assert!(
504                bind_to_head().is_empty(),
505                "bind_to_head reports nothing when the tree moved"
506            );
507            let head = git(&dir, &["rev-parse", "HEAD"]);
508            assert!(
509                stamps_for(&[head]).is_empty(),
510                "a stale marker must not vouch"
511            );
512            assert!(
513                !dir.join(".git").join(MARKER).exists(),
514                "consumed either way"
515            );
516        });
517        let _ = std::fs::remove_dir_all(&dir);
518    }
519
520    #[test]
521    fn an_empty_record_clears_a_previous_marker() {
522        let dir = repo("clears");
523        std::fs::write(dir.join("a.ts"), "x").unwrap();
524        git(&dir, &["add", "a.ts"]);
525        in_repo(&dir, || {
526            record(&["typecheck"]);
527            assert!(dir.join(".git").join(MARKER).exists());
528            record(&[]);
529            assert!(!dir.join(".git").join(MARKER).exists());
530        });
531        let _ = std::fs::remove_dir_all(&dir);
532    }
533
534    /// The version guard's REJECT branch, fed a hand-written marker: an old
535    /// (or future) format is ignored rather than misread — the doc's claim,
536    /// now pinned. Every other test's markers come from record() itself and
537    /// so always carry the current FORMAT.
538    #[test]
539    fn a_marker_in_an_unknown_format_stamps_nothing() {
540        let dir = repo("wrongformat");
541        std::fs::write(dir.join("a.ts"), "x").unwrap();
542        git(&dir, &["add", "a.ts"]);
543        in_repo(&dir, || {
544            let tree = git(&dir, &["write-tree"]);
545            let marker = dir.join(".git").join(MARKER);
546            std::fs::write(&marker, format!("amont-gate-v99\n{tree}\ntypecheck\n")).unwrap();
547            git(&dir, &["commit", "-qm", "chore: a"]);
548            bind_to_head();
549            let head = git(&dir, &["rev-parse", "HEAD"]);
550            assert!(
551                stamps_for(std::slice::from_ref(&head)).is_empty(),
552                "an unknown format was trusted"
553            );
554            assert!(!marker.exists(), "consumed either way");
555        });
556        let _ = std::fs::remove_dir_all(&dir);
557    }
558
559    /// A note somebody else wrote into OUR ref is not a stamp. Absent this,
560    /// `git notes --ref=amont-gate add` would be a one-line way to vouch for
561    /// an unchecked commit — the parsing trust boundary of the whole chain.
562    #[test]
563    fn a_foreign_note_is_not_a_stamp() {
564        let dir = repo("foreignnote");
565        std::fs::write(dir.join("a.ts"), "x").unwrap();
566        git(&dir, &["add", "a.ts"]);
567        in_repo(&dir, || {
568            git(&dir, &["commit", "-qm", "chore: a"]);
569            git(
570                &dir,
571                &[
572                    "notes",
573                    "--ref",
574                    NOTES_REF,
575                    "add",
576                    "-m",
577                    "typecheck test",
578                    "HEAD",
579                ],
580            );
581            let head = git(&dir, &["rev-parse", "HEAD"]);
582            assert!(
583                stamps_for(std::slice::from_ref(&head)).is_empty(),
584                "a note without the format token was trusted"
585            );
586        });
587        let _ = std::fs::remove_dir_all(&dir);
588    }
589
590    /// An absent notes ref is `Some("")`, not `None` — the distinction the
591    /// warning on that branch depends on. If git ever starts failing here
592    /// instead, this test fails and the warning stops being a lie.
593    #[test]
594    fn a_repo_with_no_stamps_answers_emptily_rather_than_failing() {
595        let dir = repo("no-stamps");
596        std::fs::write(dir.join("a.ts"), "x").unwrap();
597        git(&dir, &["add", "a.ts"]);
598        git(&dir, &["commit", "-qm", "chore: a"]);
599        in_repo(&dir, || {
600            assert_eq!(
601                crate::git::stdout(&["notes", "--ref", NOTES_REF, "list"]).as_deref(),
602                Some(""),
603                "an absent notes ref must be an ANSWER, not a failure — the \
604                 no-stamps path and the git-is-broken path are told apart by it"
605            );
606            let head = git(&dir, &["rev-parse", "HEAD"]);
607            assert!(stamps_for(&[head]).is_empty());
608        });
609        let _ = std::fs::remove_dir_all(&dir);
610    }
611
612    #[test]
613    fn forget_removes_the_stamps() {
614        let dir = repo("forget");
615        std::fs::write(dir.join("a.ts"), "x").unwrap();
616        git(&dir, &["add", "a.ts"]);
617        in_repo(&dir, || {
618            record(&["typecheck"]);
619            git(&dir, &["commit", "-qm", "chore: a"]);
620            bind_to_head();
621            let head = git(&dir, &["rev-parse", "HEAD"]);
622            assert!(!stamps_for(std::slice::from_ref(&head)).is_empty());
623            forget();
624            assert!(stamps_for(&[head]).is_empty());
625        });
626        let _ = std::fs::remove_dir_all(&dir);
627    }
628
629    /// A squash-merge produces a commit this machine never saw, and the
630    /// stamp has to survive it.
631    ///
632    /// This is the case that motivated tree-stamping. A branch is verified
633    /// locally and stamped; the forge squashes it onto `main` as a NEW
634    /// commit with no note; pushing a tag on that commit re-ran every gate
635    /// the branch had already proved. Measured on this repository: 13
636    /// seconds for the branch push, then the full suite for the tag push,
637    /// which died on a reset connection.
638    ///
639    /// The tree is what the gates actually read, and it is identical across
640    /// that merge whenever the base has not moved — five of five merges in
641    /// one afternoon here.
642    #[test]
643    fn a_stamp_survives_a_commit_being_rewritten_with_the_same_tree() {
644        let dir = repo("squashed");
645        std::fs::write(dir.join("a.ts"), "x").unwrap();
646        git(&dir, &["add", "a.ts"]);
647        in_repo(&dir, || {
648            record(&["test"]);
649            git(&dir, &["commit", "-qm", "feat: on a branch"]);
650            assert_eq!(bind_to_head(), vec!["test".to_string()]);
651            let branch_tip = git(&dir, &["rev-parse", "HEAD"]);
652
653            // Stand in for the forge's squash: a DIFFERENT commit object
654            // with the SAME tree. `--amend` with a new message is the
655            // cheapest way to get exactly that shape.
656            git(
657                &dir,
658                &[
659                    "commit",
660                    "-q",
661                    "--amend",
662                    "-m",
663                    "feat: squashed by the forge",
664                ],
665            );
666            let merged = git(&dir, &["rev-parse", "HEAD"]);
667            assert_ne!(merged, branch_tip, "the fixture must produce a new commit");
668            assert_eq!(
669                git(&dir, &["rev-parse", "HEAD^{tree}"]),
670                git(&dir, &["rev-parse", &format!("{branch_tip}^{{tree}}")]),
671                "…carrying the same tree, which is the whole premise"
672            );
673
674            let stamps = stamps_for(std::slice::from_ref(&merged));
675            assert_eq!(
676                stamps.get(&merged).map(Vec::as_slice),
677                Some(&["test".to_string()][..]),
678                "the stamp must follow the content, not the commit hash"
679            );
680        });
681        let _ = std::fs::remove_dir_all(&dir);
682    }
683
684    /// And it must not follow anything else. A commit whose tree was never
685    /// stamped gets nothing, however many other stamps exist — otherwise
686    /// this widening would vouch for content nobody checked.
687    #[test]
688    fn a_different_tree_gets_no_stamp_from_the_fallback() {
689        let dir = repo("othertree");
690        std::fs::write(dir.join("a.ts"), "x").unwrap();
691        git(&dir, &["add", "a.ts"]);
692        in_repo(&dir, || {
693            record(&["test"]);
694            git(&dir, &["commit", "-qm", "chore: stamped"]);
695            assert_eq!(bind_to_head(), vec!["test".to_string()]);
696
697            // Different CONTENT, so a different tree, and no marker: this
698            // commit was never judged.
699            std::fs::write(dir.join("b.ts"), "y").unwrap();
700            git(&dir, &["add", "b.ts"]);
701            git(&dir, &["commit", "-qm", "chore: unjudged"]);
702            let unstamped = git(&dir, &["rev-parse", "HEAD"]);
703
704            assert!(
705                stamps_for(std::slice::from_ref(&unstamped)).is_empty(),
706                "a tree nobody stamped must not inherit one"
707            );
708        });
709        let _ = std::fs::remove_dir_all(&dir);
710    }
711}