amont-runtime 1.46.0

The amont hook logic: registry, dispatchers, checks and the trust model
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
//! The record that turns "a declaration exists" into "the check ran".
//!
//! Moving a gate entry to commit time (`docs/checks.md`, "Moving a gate entry
//! earlier") makes the push gate skip a script because a `pre-commit`
//! declaration covers it. A declaration is a promise on paper: a commit made
//! with `--no-verify`, from a libgit2 client that runs no hooks, or on a
//! machine without amont was never judged by it — and until this module
//! existed, push time had no way to tell those commits from checked ones.
//!
//! Three hooks share one record:
//!
//! 1. **pre-commit** ([`record`]) writes a one-shot marker into `$GIT_DIR`
//!    naming the gate scripts that actually ran, bound to the tree the commit
//!    is about to write (`git write-tree` — during pre-commit the index IS
//!    the commit's content; `staged_only` parks only the working tree).
//! 2. **post-commit** ([`bind_to_head`]) consumes the marker and, when the
//!    marker's tree matches `HEAD^{tree}`, stamps the commit in a notes ref.
//!    `--no-verify` skips pre-commit but NOT post-commit, so an unchecked
//!    commit arrives here with no marker and gets no stamp — which is the
//!    entire point. The tree comparison makes an aborted commit's leftover
//!    marker harmless, and a retried commit of the SAME tree correctly
//!    stamped: the check really did run on exactly that content.
//! 3. **pre-push** ([`stamps_for`]) reads the stamps back and suppresses a
//!    gate script only for pushes whose relevant commits all carry it.
//! 4. **pre-push, on the way out** ([`stamp_push`]) writes the push-time
//!    gates that PASSED onto each pushed tip — and `amont run pre-push`,
//!    which drives the same dispatcher, writes the same stamp for `HEAD`.
//!    The next push of that tree finds the stamp and skips the gate. That is
//!    what lets the suite run BEFORE git opens its connection: a rehearsal
//!    stamps, the push verifies. And a push that passed the gate but died on
//!    the wire — a remote that dropped the idle session while the suite ran
//!    — retries in seconds instead of minutes. Push-time tokens are full
//!    ids (`pre-push-run-tests-js`), commit-time ones are script names
//!    (`test`); the two share a note and never collide.
//!
//! Every failure mode points the same direction: no marker, a mismatched
//! tree, a missing note, a rewritten hash — all mean "no stamp", and no stamp
//! means the push gate RUNS. Nothing here can let an unchecked commit
//! through; it can only cost a redundant gate run.
//!
//! # The second half of the note: what a run COST, and how it ENDED
//!
//! The token line above answers one question — may this gate be skipped for
//! this content — and it is deliberately a set, with no history in it. A
//! stamp says a gate passed; it cannot say a gate has been passing in four
//! seconds since the day its test runner stopped finding any tests.
//!
//! So the note carries optional extra lines, one per RUN:
//!
//! ```text
//! amont-gate-v1 pre-push-cargo-test
//! run 1726900000 pre-push-cargo-test pass 412391
//! run 1726903600 pre-push-audit-js fail 903
//! ```
//!
//! Additive on purpose, and in the only direction that is safe: every reader
//! that existed before these lines did reads `body.lines().next()` and is
//! unaffected, and an amont old enough to rewrite the note without them loses
//! history — never a verdict. The `run` lines are EVIDENCE, read by
//! [`crate::gate_evidence`]; nothing in this module's skip decisions consults
//! them, so a corrupted or forged one cannot make a check be skipped.
//!
//! The note's key is the tree, which makes the record a per-FINGERPRINT one
//! for free: two runs of one gate against one tree are two lines in one note,
//! and if they disagree the gate is flaky on content that did not change.
//!
//! Why a notes ref and not config: notes are keyed by commit, garbage-collect
//! with unreachable commits (an `amont.checked.<hash>` config key would
//! outlive every rebase forever), stay local (notes refs are not pushed by
//! default), and stay out of `git log` (only `refs/notes/commits` displays by
//! default). `amont uninstall` deletes the ref; see `uninstall_repo_hooks`.

use std::collections::{HashMap, HashSet};
use std::path::PathBuf;

/// First token of the marker file and of every note body. Versioned like
/// `staged_only::FORMAT`: a future amont that changes the shape bumps this,
/// and an old record is ignored rather than misread.
pub const FORMAT: &str = "amont-gate-v1";

/// The notes ref, spelled the way `git notes --ref` wants it.
pub const NOTES_REF: &str = "amont-gate";

/// The same ref, fully qualified — what `git update-ref -d` needs.
pub const NOTES_FULL_REF: &str = "refs/notes/amont-gate";

/// The marker's filename inside `$GIT_DIR`.
const MARKER: &str = "amont-gate";

/// The switch for push-time stamps — both writing them and honouring them.
/// On by default: a stamp is amont's own record of a gate it ran on exactly
/// this content, the same trust the commit-time stamps have always carried.
const PUSH_STAMPS: &str = "amont.pushStamps";

/// Does this repository reuse push-time stamps?
pub fn push_stamps_enabled(settings: &crate::config::Settings) -> bool {
    crate::config::boolean_or(settings, PUSH_STAMPS, true)
}

/// The switch for commit-time reuse — a pre-commit gate that already ran
/// clean against exactly this staged tree is not run again. On by default,
/// for the same reason `pushStamps` is: the record is amont's own, bound to
/// the content, and identical content is what a test suite reads.
const COMMIT_STAMPS: &str = "amont.commitStamps";

/// Does this repository reuse a gate's verdict across commit attempts of
/// one tree?
pub fn commit_stamps_enabled(settings: &crate::config::Settings) -> bool {
    crate::config::boolean_or(settings, COMMIT_STAMPS, true)
}

/// The first word of an evidence line. A line that does not start with it is
/// not one, and is dropped rather than guessed at.
const RUN: &str = "run";

/// How many runs one note keeps, newest last. A note is read and rewritten on
/// the push path, so it has to stay small; 64 runs of one tree is already far
/// more retries than any content sees, and the statistics this feeds are
/// computed ACROSS notes, not within one.
const MAX_RUNS: usize = 64;

/// How a single run of one gate ended.
///
/// The check vocabulary, kept whole rather than flattened to pass/fail:
/// `Unavailable` is the outcome the fleet audit of 2026-09-19 kept finding —
/// a gate that could not run, reported as a warning, counted by nobody — and
/// collapsing it into "fail" would hide it all over again.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunOutcome {
    Passed,
    Failed,
    Warned,
    Fixed,
    Unavailable,
    Inert,
    // Tree gates (ADR-0024): why a commit's tree was NOT proven. None is a
    // verdict about the content; together they are the hit rate's misses.
    /// No completion marker in the current cache namespace.
    Cold,
    /// Another run held the gate's cache lock.
    Busy,
    /// The tool the gate would run is not the one CI resolves.
    Skew,
    /// The tree was not exactly the commit's (unstaged, untracked, ignored
    /// outside the allow-list, or an operation in progress).
    Withheld,
    /// Still running when the commit was ready, or past its deadline.
    Cancelled,
    /// Not started: its last run would not fit in this commit's cover plus
    /// the slack.
    Slow,
}

impl RunOutcome {
    pub fn as_str(self) -> &'static str {
        match self {
            RunOutcome::Passed => "pass",
            RunOutcome::Failed => "fail",
            RunOutcome::Warned => "warn",
            RunOutcome::Fixed => "fixed",
            RunOutcome::Unavailable => "unavailable",
            RunOutcome::Inert => "inert",
            RunOutcome::Cold => "cold",
            RunOutcome::Busy => "busy",
            RunOutcome::Skew => "skew",
            RunOutcome::Withheld => "withheld",
            RunOutcome::Cancelled => "cancelled",
            RunOutcome::Slow => "slow",
        }
    }

    /// The parse side. `None` for anything unknown — a future amont may write
    /// an outcome this one has never heard of, and inventing a meaning for it
    /// would be worse than dropping the line.
    pub fn parse(s: &str) -> Option<RunOutcome> {
        Some(match s {
            "pass" => RunOutcome::Passed,
            "fail" => RunOutcome::Failed,
            "warn" => RunOutcome::Warned,
            "fixed" => RunOutcome::Fixed,
            "unavailable" => RunOutcome::Unavailable,
            "inert" => RunOutcome::Inert,
            "cold" => RunOutcome::Cold,
            "busy" => RunOutcome::Busy,
            "skew" => RunOutcome::Skew,
            "withheld" => RunOutcome::Withheld,
            "cancelled" => RunOutcome::Cancelled,
            "slow" => RunOutcome::Slow,
            _ => return None,
        })
    }

    /// Did this run reach a verdict about the content? Only these two are
    /// evidence about whether a gate works — the rest are a gate declining to
    /// judge, and counting them as passes is how a no-op looks healthy.
    pub fn is_verdict(self) -> bool {
        matches!(self, RunOutcome::Passed | RunOutcome::Failed)
    }
}

/// One run of one gate: when, which, how it ended, how long it took.
///
/// Wall clock, in milliseconds, measured around the check's own `run` — not
/// CPU time and not the hook's total, because the number this exists to catch
/// is "the suite that used to take eleven minutes returned in 0.4 seconds",
/// and that is a wall-clock claim.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Run {
    /// Seconds since the epoch, as the machine that ran it saw them.
    pub at: u64,
    pub gate: String,
    pub outcome: RunOutcome,
    pub ms: u64,
}

impl Run {
    fn render(&self) -> String {
        format!(
            "{RUN} {} {} {} {}",
            self.at,
            self.gate,
            self.outcome.as_str(),
            self.ms
        )
    }

    /// `run <epoch> <gate> <outcome> <ms>`, or nothing. Every field must be
    /// there and parse; a half-understood line is dropped whole.
    fn parse(line: &str) -> Option<Run> {
        let mut t = line.split_whitespace();
        if t.next() != Some(RUN) {
            return None;
        }
        let at = t.next()?.parse().ok()?;
        let gate = t.next()?.to_string();
        let outcome = RunOutcome::parse(t.next()?)?;
        let ms = t.next()?.parse().ok()?;
        Some(Run {
            at,
            gate,
            outcome,
            ms,
        })
    }
}

/// A parsed note body: the skip tokens, and the runs recorded against this
/// key.
///
/// THE reader and THE writer of the format, so that the three callers that
/// rewrite a note cannot each drop a half of it they were not thinking
/// about. `stamp_push` merging tokens used to render the note from its own
/// token list alone, which — once runs existed — would have erased every one
/// of them on the next push.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Note {
    pub tokens: Vec<String>,
    pub runs: Vec<Run>,
}

impl Note {
    /// Parse a note body. A first line that is not ours yields an EMPTY note:
    /// a note somebody else wrote into our ref vouches for nothing, and its
    /// remaining lines are not evidence either.
    pub fn parse(body: &str) -> Note {
        let mut lines = body.lines();
        let Some(first) = lines.next() else {
            return Note::default();
        };
        let mut tokens = first.split_whitespace();
        if tokens.next() != Some(FORMAT) {
            return Note::default();
        }
        Note {
            tokens: tokens.map(str::to_string).collect(),
            runs: lines.filter_map(Run::parse).collect(),
        }
    }

    /// The bytes to hand `git notes add -m`. Line one is exactly what it has
    /// always been, so every reader that stops there sees no change at all.
    pub fn render(&self) -> String {
        let mut body = format!("{FORMAT} {}", self.tokens.join(" "));
        for run in &self.runs {
            body.push('\n');
            body.push_str(&run.render());
        }
        body
    }

    fn add_tokens(&mut self, tokens: &[String]) {
        for t in tokens {
            if !self.tokens.iter().any(|have| have == t) {
                self.tokens.push(t.clone());
            }
        }
    }

    /// Append runs, keeping the newest [`MAX_RUNS`].
    fn add_runs(&mut self, runs: &[Run]) {
        self.runs.extend(runs.iter().cloned());
        if self.runs.len() > MAX_RUNS {
            self.runs.drain(..self.runs.len() - MAX_RUNS);
        }
    }
}

/// The note at `key`, parsed. An absent note, an absent ref and a git that
/// would not answer are all an empty note — the same direction everything
/// here fails in.
fn note_at(key: &str) -> Note {
    crate::git::stdout(&["notes", "--ref", NOTES_REF, "show", key])
        .map(|body| Note::parse(&body))
        .unwrap_or_default()
}

/// The stamp tokens on `tree` (a tree object id). Tree gates are read here
/// and only here (ADR-0024): `tree:<name>` on the pushed tree proves the gate
/// on exactly that content, whichever commit carried it.
pub fn tree_tokens(tree: &str) -> Vec<String> {
    note_at(tree).tokens
}

/// Add `tokens` to the gate note on `tree` — how a rehearsal records tree
/// gates it proved in its snapshot of exactly that tree. Merged, never
/// rendered from the tokens alone, so evidence already on the note survives.
pub fn stamp_tree(tree: &str, tokens: &[String]) -> bool {
    let mut note = note_at(tree);
    note.add_tokens(tokens);
    write_note(tree, &note)
}

/// Write `note` at `key`. Best-effort, like every writer here.
fn write_note(key: &str, note: &Note) -> bool {
    let body = note.render();
    crate::git::succeeds(&["notes", "--ref", NOTES_REF, "add", "-f", "-m", &body, key])
}

/// Record what a gate run COST and how it ENDED, against the content it ran
/// on.
///
/// Evidence only: nothing in this module reads these back to decide whether a
/// check may be skipped, which is why recording a FAILED run — the case the
/// stamp path deliberately has no opinion about — is safe here.
///
/// Best-effort and silent. A note git refused costs a row in a report nobody
/// is blocked on; warning about it on every push would train people to ignore
/// the warnings that do gate something.
pub fn record_runs(key: &str, runs: &[Run]) {
    // A gate name with whitespace in it would forge a second field on read.
    // Refused rather than escaped: every id this repository can produce is
    // already free of it, so the escaping would be a code path no real input
    // ever reaches — untested by construction.
    let runs: Vec<Run> = runs
        .iter()
        .filter(|r| !r.gate.is_empty() && !r.gate.contains(char::is_whitespace))
        .cloned()
        .collect();
    if runs.is_empty() {
        return;
    }
    let mut note = note_at(key);
    note.add_runs(&runs);
    let _ = write_note(key, &note);
}

/// `$GIT_DIR/amont-gate` — the worktree-PRIVATE gitdir, deliberately: the
/// commit this marker waits for happens in this worktree. The stamps the
/// marker becomes live in the common dir (a notes ref) and are shared.
fn marker_path() -> Option<PathBuf> {
    let dir = crate::git::stdout(&["rev-parse", "--git-dir"])?;
    Some(std::path::Path::new(&dir).join(MARKER))
}

/// pre-commit: record that `scripts` ran clean against the tree the commit
/// will carry.
///
/// Called on EVERY pre-commit verdict, with an empty list when nothing
/// qualifying ran (or the commit is about to be blocked) — an aborted or
/// unchecked attempt must not inherit a previous attempt's marker.
///
/// Best-effort throughout: a failure to record costs one redundant gate run
/// at push time, which is the safe direction, and a pre-commit that failed a
/// COMMIT over bookkeeping would be the tail wagging the dog.
pub fn record(scripts: &[&str]) {
    let Some(path) = marker_path() else { return };
    if scripts.is_empty() {
        let _ = std::fs::remove_file(&path);
        return;
    }
    // The index, as the object id `git commit` is about to seal. Inherits
    // `GIT_INDEX_FILE`, so `git commit -a`'s temporary index answers here
    // too. Pure read of the index: writes objects, touches no ref.
    let Some(tree) = crate::git::stdout(&["write-tree"]) else {
        // Not "nothing ran": git could not name the tree, so nothing may be
        // vouched for. Dropping the marker is the fail-safe half (the gate
        // re-runs at push); saying so is the half that was missing.
        crate::hooks::common::warn(
            "git would not name the staged tree — this commit records no gate stamp",
        );
        let _ = std::fs::remove_file(&path);
        return;
    };
    let mut body = format!("{FORMAT}\n{tree}\n");
    for s in scripts {
        body.push_str(s);
        body.push('\n');
    }
    let _ = std::fs::write(&path, body);
}

/// pre-commit, before a gate runs: the scripts a previous run already
/// vouched for against the tree this commit is about to seal.
///
/// Two records answer, both bound to the TREE (`git write-tree` of the index
/// under the staged-only hold — the commit's content, not the working
/// tree):
///
/// 1. the one-shot marker [`record`] left behind. post-commit consumes it,
///    so a marker that is still there belongs to an attempt that never
///    reached post-commit — a `commit-msg` refusal, an editor closed on an
///    empty message, a Ctrl-C at the prompt. The gates ran, the tree is the
///    same, the verdict stands: measured on this machine, a subject three
///    characters too long replayed a ten-minute suite for nothing.
/// 2. the tree note [`bind_to_head`] writes — a reword, a `reset --soft` and
///    re-commit, or a rebase that kept the tree.
///
/// Every failure mode reads as "nothing is vouched for", which runs the
/// gate: no `write-tree`, a marker for another tree, a wrong format, a git
/// that would not answer. Reuse can only ever skip a run the record proves
/// happened on this exact content; it can never let unjudged content
/// through — the direction every function in this module fails in.
///
/// Repo-controlled inputs to the gate (its command line, its scope) live in
/// the manifest, which is part of the tree: a changed declaration is a
/// changed tree, and nothing is reused.
pub fn vouched_for_staged_tree() -> HashSet<String> {
    let mut out = HashSet::new();
    let Some(tree) = crate::git::stdout(&["write-tree"]) else {
        return out;
    };
    if let Some(path) = marker_path() {
        if let Ok(body) = std::fs::read_to_string(&path) {
            let mut lines = body.lines();
            if lines.next() == Some(FORMAT) && lines.next() == Some(tree.as_str()) {
                out.extend(lines.filter(|l| !l.trim().is_empty()).map(str::to_string));
            }
        }
    }
    // The tree note: `notes show` fails loudly on an absent note, and a
    // failure here is simply "no note" — the marker's answer stands alone.
    // Commit-time tokens are script names; push-time ones are full ids
    // (`pre-push-…`), which no pre-commit declaration is named after —
    // harmless in the set. The note's `run` lines are evidence and are not
    // tokens: `Note` keeps the two apart so nothing here can be skipped by a
    // line that only records how long something took.
    out.extend(note_at(&tree).tokens);
    out
}

/// post-commit: consume the marker; stamp HEAD when the tree still matches.
///
/// One-shot by construction — the marker is deleted before anything is
/// judged, so no path through here can leave it to vouch for a later commit.
///
/// Returns the scripts it actually stamped (empty on every no-stamp path,
/// including a note git refused). The caller subtracts this from what the
/// manifest declares to learn what the commit dodged — [`crate::bypass`]
/// keeps that count. Two records, two questions: the stamp gates a check,
/// the ledger only counts.
pub fn bind_to_head() -> Vec<String> {
    let Some(path) = marker_path() else {
        return Vec::new();
    };
    let Ok(body) = std::fs::read_to_string(&path) else {
        return Vec::new(); // no marker: nothing ran at pre-commit, nothing to stamp
    };
    let _ = std::fs::remove_file(&path);
    let mut lines = body.lines();
    if lines.next() != Some(FORMAT) {
        return Vec::new();
    }
    let Some(tree) = lines.next() else {
        return Vec::new();
    };
    let scripts: Vec<&str> = lines.filter(|l| !l.trim().is_empty()).collect();
    if scripts.is_empty() {
        return Vec::new();
    }
    let Some(head_tree) = crate::git::stdout(&["rev-parse", "HEAD^{tree}"]) else {
        crate::hooks::common::warn(
            "git would not name this commit's tree — no gate stamp was written",
        );
        return Vec::new();
    };
    // A different tree means this commit is not the one pre-commit judged —
    // the marker is a dead letter from an aborted attempt.
    if head_tree != tree {
        return Vec::new();
    }
    let scripts: Vec<String> = scripts.iter().map(|s| s.to_string()).collect();
    // The stamp goes on the TREE as well as the commit, and the tree is the
    // one that survives the way work actually reaches `main`.
    //
    // A squash-merge is performed by the forge: it produces a commit nobody
    // here ever saw, carrying no note, so a later `git push` of a tag on
    // that commit re-ran every gate the branch had already proved. Measured
    // on this repository: a branch push took 13 seconds and the tag push
    // that followed took the full suite and died on a reset connection.
    //
    // The tree is identical across that merge whenever the base has not
    // moved — five of five merges in one afternoon here — and identical
    // trees are identical CONTENT, which is the only thing a test suite
    // reads. That is the same argument `attest` makes for signing the tree
    // rather than the commit, and this module's marker has been tree-bound
    // since it was written; this only carries the binding through to the
    // note.
    //
    // Both, not either: the commit note is what a `git log --notes` reader
    // sees, and dropping it would make the stamps invisible in the place
    // people look for them.
    // MERGED into whatever is there, never rendered from the scripts alone:
    // a pre-push run against this same tree may already have recorded
    // evidence lines on it, and an `add -f` built from this list would erase
    // them.
    let mut tree_note = note_at(&head_tree);
    tree_note.add_tokens(&scripts);
    let _ = write_note(&head_tree, &tree_note);
    let mut head_note = note_at("HEAD");
    head_note.add_tokens(&scripts);
    if !write_note("HEAD", &head_note) {
        // A note git refused is not a stamp — and the push will re-run these
        // checks, which is right but looks arbitrary unless it is said.
        crate::hooks::common::warn(
            "git refused to write the gate stamp — these checks will run again at push",
        );
        return Vec::new();
    }
    scripts
}

/// pre-push, after every block gate passed: record that `gates` passed
/// against `tree`, the content of `commit` — MERGING with whatever the note
/// already says, because a commit-time stamp (`test`) may already sit there
/// and `notes add -f` would otherwise erase it.
///
/// Best-effort, like every writer here: a stamp that could not be written
/// costs one redundant gate run on the next push, which is the safe
/// direction. Returns whether the commit's note was written.
pub fn stamp_push(commit: &str, tree: &str, gates: &[String]) -> bool {
    if gates.is_empty() {
        return false;
    }
    let mut written = false;
    for key in [tree, commit] {
        let mut note = note_at(key);
        note.add_tokens(gates);
        let ok = write_note(key, &note);
        if key == commit {
            written = ok;
        }
    }
    if !written {
        crate::hooks::common::warn(
            "git refused to write the push stamp — these gates will run again on the next push",
        );
    }
    written
}

/// pre-push: which of `commits` carry a stamp, and for which scripts.
///
/// One `notes list` narrows the reads to commits that have a note at all;
/// absent ref, unparseable note, wrong format version — all read as "no
/// stamp", which re-runs the gate.
pub fn stamps_for(commits: &[String]) -> HashMap<String, Vec<String>> {
    let mut out = HashMap::new();
    if commits.is_empty() {
        return out;
    }
    let Some(list) = crate::git::stdout(&["notes", "--ref", NOTES_REF, "list"]) else {
        // NOT the absent-ref case, whatever an older comment here claimed:
        // `notes list` exits 0 with empty output when the ref does not exist,
        // so that arrives as `Some("")` and falls through as "nothing is
        // stamped" — correctly. Reaching HERE means git could not answer at
        // all. Same verdict (the gates re-run: never skip work on a question
        // we could not ask), different sentence, because a transient git
        // failure that reads as "nothing is stamped" is indistinguishable
        // from the real thing — which is exactly how one flaky spawn cost a
        // day of not-diagnosing.
        crate::hooks::common::warn(
            "git would not list the gate stamps — every gated check will run again",
        );
        return out;
    };
    let noted: HashSet<&str> = list
        .lines()
        .filter_map(|l| l.split_whitespace().nth(1))
        .collect();
    // Commit -> tree, in ONE spawn rather than one per commit: this is the
    // push path, and a `rev-parse` each would be a process per pushed
    // commit to answer a question `git log` answers in a batch.
    let mut trees: HashMap<String, String> = HashMap::new();
    {
        let mut args: Vec<&str> = vec!["log", "--no-walk", "--format=%H %T"];
        args.extend(commits.iter().map(String::as_str));
        if let Some(out) = crate::git::stdout(&args) {
            for line in out.lines() {
                let mut it = line.split_whitespace();
                if let (Some(c), Some(t)) = (it.next(), it.next()) {
                    trees.insert(c.to_string(), t.to_string());
                }
            }
        }
        // No mapping is not an error: every commit simply falls back to the
        // commit-keyed lookup below, which is what happened before trees
        // were stamped at all.
    }

    for commit in commits {
        // The commit's own note first, then its TREE's. A squash-merge
        // produces a commit this machine never saw — no note — while the
        // content, and therefore the tree, is the one the gates ran on.
        // Falling through to the tree is what lets a tag push on a merged
        // commit skip work the branch already proved.
        //
        // The direction of failure is unchanged: no note on either, an
        // unparseable one, or a git that would not answer all mean "no
        // stamp", and no stamp runs the gate.
        let key: &str = if noted.contains(commit.as_str()) {
            commit
        } else {
            match trees.get(commit).filter(|t| noted.contains(t.as_str())) {
                Some(tree) => tree,
                None => continue,
            }
        };
        let tokens = note_at(key).tokens;
        if tokens.is_empty() {
            continue;
        }
        out.insert(commit.clone(), tokens);
    }
    out
}

/// uninstall: forget everything this module ever wrote here.
///
/// The stamps are OUR bookkeeping — unlike `hook.skip` and `amont.severity`,
/// which are the user's statements and are never touched.
pub fn forget() -> bool {
    let marker = marker_path().is_some_and(|path| std::fs::remove_file(&path).is_ok());
    let notes = crate::git::succeeds(&["update-ref", "-d", NOTES_FULL_REF]);
    marker || notes
}

/// The same, for a repository this process is not standing in.
pub fn forget_in(repo: &std::path::Path) -> bool {
    let marker = crate::git::stdout_in(repo, &["rev-parse", "--absolute-git-dir"])
        .is_some_and(|dir| std::fs::remove_file(std::path::Path::new(&dir).join(MARKER)).is_ok());
    let notes = crate::git::succeeds_in(repo, &["update-ref", "-d", NOTES_FULL_REF]);
    marker || notes
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;

    /// A real repository, because every function here is a conversation with
    /// git — hand-rolled fixtures would test the conversation we imagined.
    fn repo(name: &str) -> PathBuf {
        let dir = std::env::temp_dir().join(format!("gate-stamp-{name}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        git(&dir, &["init", "-q", "--template=", "."]);
        git(&dir, &["config", "user.email", "t@t.test"]);
        git(&dir, &["config", "user.name", "t"]);
        dir
    }

    /// A fixture git call that FAILS where it fails.
    ///
    /// This used to discard the exit status, and that is how a rare flake
    /// stayed unreadable for a day: if the setup `git commit` did not
    /// happen, the test carried on to an unborn HEAD, and the panic landed
    /// three lines later on a missing gate stamp — a product-shaped
    /// failure for a fixture-shaped cause. Same rule the checks obey:
    /// git failing is not git answering.
    fn git(dir: &Path, args: &[&str]) -> String {
        let out = std::process::Command::new("git")
            .arg("-C")
            .arg(dir)
            .args(args)
            .output()
            .expect("git");
        assert!(
            out.status.success(),
            "fixture: git {args:?} in {} exited {:?}: {}",
            dir.display(),
            out.status.code(),
            String::from_utf8_lossy(&out.stderr).trim()
        );
        String::from_utf8_lossy(&out.stdout).trim().to_string()
    }

    /// The module talks to the repo at the process cwd; these tests each set
    /// it. Serialised via the crate-wide lock, because cwd is process-global
    /// and `attest`'s tests move it too.
    fn in_repo<T>(dir: &Path, f: impl FnOnce() -> T) -> T {
        let _guard = crate::TEST_CWD.lock().unwrap_or_else(|p| p.into_inner());
        let prev = std::env::current_dir().unwrap();
        std::env::set_current_dir(dir).unwrap();
        let r = f();
        std::env::set_current_dir(prev).unwrap();
        r
    }

    #[test]
    fn a_recorded_marker_becomes_a_stamp_on_the_matching_commit() {
        let dir = repo("roundtrip");
        std::fs::write(dir.join("a.ts"), "x").unwrap();
        git(&dir, &["add", "a.ts"]);
        in_repo(&dir, || {
            record(&["typecheck", "test"]);
            git(&dir, &["commit", "-qm", "chore: a"]);
            let stamped = bind_to_head();
            assert_eq!(
                stamped,
                vec!["typecheck".to_string(), "test".to_string()],
                "bind_to_head reports the scripts it stamped"
            );
            let head = git(&dir, &["rev-parse", "HEAD"]);
            let stamps = stamps_for(std::slice::from_ref(&head));
            assert_eq!(
                stamps.get(&head).map(Vec::as_slice),
                Some(&["typecheck".to_string(), "test".to_string()][..])
            );
            // One-shot: the marker is gone.
            // One-shot: the marker is gone. The fixture's own path, not
            // `marker_path()` — that helper spawns git, and a transient
            // spawn failure on a loaded runner reads as `None` here while
            // production code correctly treats it as "no marker". Seen once,
            // on Windows, as an unwrap panic in a sibling test.
            assert!(!dir.join(".git").join(MARKER).exists());
        });
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn a_marker_for_a_different_tree_stamps_nothing() {
        let dir = repo("stale");
        std::fs::write(dir.join("a.ts"), "x").unwrap();
        git(&dir, &["add", "a.ts"]);
        in_repo(&dir, || {
            record(&["typecheck"]);
            // The commit that actually lands carries DIFFERENT content — the
            // aborted-attempt-then-different-retry shape.
            std::fs::write(dir.join("a.ts"), "y").unwrap();
            git(&dir, &["add", "a.ts"]);
            git(&dir, &["commit", "-qm", "chore: different"]);
            assert!(
                bind_to_head().is_empty(),
                "bind_to_head reports nothing when the tree moved"
            );
            let head = git(&dir, &["rev-parse", "HEAD"]);
            assert!(
                stamps_for(&[head]).is_empty(),
                "a stale marker must not vouch"
            );
            assert!(
                !dir.join(".git").join(MARKER).exists(),
                "consumed either way"
            );
        });
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn an_empty_record_clears_a_previous_marker() {
        let dir = repo("clears");
        std::fs::write(dir.join("a.ts"), "x").unwrap();
        git(&dir, &["add", "a.ts"]);
        in_repo(&dir, || {
            record(&["typecheck"]);
            assert!(dir.join(".git").join(MARKER).exists());
            record(&[]);
            assert!(!dir.join(".git").join(MARKER).exists());
        });
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// The version guard's REJECT branch, fed a hand-written marker: an old
    /// (or future) format is ignored rather than misread — the doc's claim,
    /// now pinned. Every other test's markers come from record() itself and
    /// so always carry the current FORMAT.
    #[test]
    fn a_marker_in_an_unknown_format_stamps_nothing() {
        let dir = repo("wrongformat");
        std::fs::write(dir.join("a.ts"), "x").unwrap();
        git(&dir, &["add", "a.ts"]);
        in_repo(&dir, || {
            let tree = git(&dir, &["write-tree"]);
            let marker = dir.join(".git").join(MARKER);
            std::fs::write(&marker, format!("amont-gate-v99\n{tree}\ntypecheck\n")).unwrap();
            git(&dir, &["commit", "-qm", "chore: a"]);
            bind_to_head();
            let head = git(&dir, &["rev-parse", "HEAD"]);
            assert!(
                stamps_for(std::slice::from_ref(&head)).is_empty(),
                "an unknown format was trusted"
            );
            assert!(!marker.exists(), "consumed either way");
        });
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// A note somebody else wrote into OUR ref is not a stamp. Absent this,
    /// `git notes --ref=amont-gate add` would be a one-line way to vouch for
    /// an unchecked commit — the parsing trust boundary of the whole chain.
    #[test]
    fn a_foreign_note_is_not_a_stamp() {
        let dir = repo("foreignnote");
        std::fs::write(dir.join("a.ts"), "x").unwrap();
        git(&dir, &["add", "a.ts"]);
        in_repo(&dir, || {
            git(&dir, &["commit", "-qm", "chore: a"]);
            git(
                &dir,
                &[
                    "notes",
                    "--ref",
                    NOTES_REF,
                    "add",
                    "-m",
                    "typecheck test",
                    "HEAD",
                ],
            );
            let head = git(&dir, &["rev-parse", "HEAD"]);
            assert!(
                stamps_for(std::slice::from_ref(&head)).is_empty(),
                "a note without the format token was trusted"
            );
        });
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// An absent notes ref is `Some("")`, not `None` — the distinction the
    /// warning on that branch depends on. If git ever starts failing here
    /// instead, this test fails and the warning stops being a lie.
    #[test]
    fn a_repo_with_no_stamps_answers_emptily_rather_than_failing() {
        let dir = repo("no-stamps");
        std::fs::write(dir.join("a.ts"), "x").unwrap();
        git(&dir, &["add", "a.ts"]);
        git(&dir, &["commit", "-qm", "chore: a"]);
        in_repo(&dir, || {
            assert_eq!(
                crate::git::stdout(&["notes", "--ref", NOTES_REF, "list"]).as_deref(),
                Some(""),
                "an absent notes ref must be an ANSWER, not a failure — the \
                 no-stamps path and the git-is-broken path are told apart by it"
            );
            let head = git(&dir, &["rev-parse", "HEAD"]);
            assert!(stamps_for(&[head]).is_empty());
        });
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// The compatibility promise, in both directions.
    ///
    /// A note written before evidence lines existed is ONE line, and it must
    /// parse to exactly the tokens it always did — every skip decision in
    /// this module reads that line, and a reader that needed the new shape
    /// would stop honouring every stamp on every machine the day it shipped.
    #[test]
    fn a_note_from_before_the_run_lines_parses_unchanged() {
        let note = Note::parse("amont-gate-v1 typecheck test");
        assert_eq!(note.tokens, vec!["typecheck", "test"]);
        assert!(note.runs.is_empty());
        // And round-trips to the same bytes, so an old amont reading a note
        // this one rewrote sees what it wrote.
        assert_eq!(note.render(), "amont-gate-v1 typecheck test");
    }

    /// The new shape: line one unchanged, evidence after it.
    #[test]
    fn run_lines_are_read_without_disturbing_the_tokens() {
        let body = "amont-gate-v1 pre-push-cargo-test\n\
                    run 1726900000 pre-push-cargo-test pass 412391\n\
                    run 1726903600 pre-push-audit-js fail 903\n";
        let note = Note::parse(body);
        assert_eq!(note.tokens, vec!["pre-push-cargo-test"]);
        assert_eq!(note.runs.len(), 2);
        assert_eq!(
            note.runs[0],
            Run {
                at: 1_726_900_000,
                gate: "pre-push-cargo-test".into(),
                outcome: RunOutcome::Passed,
                ms: 412_391,
            }
        );
        assert_eq!(note.runs[1].outcome, RunOutcome::Failed);
        assert_eq!(note.render(), body.trim_end());
    }

    /// A line this version does not understand is dropped, not guessed at —
    /// so a future amont may add a field (or an outcome) without an older one
    /// inventing a meaning for it.
    #[test]
    fn an_unreadable_run_line_is_dropped_and_the_rest_survives() {
        let note = Note::parse(
            "amont-gate-v1 test\n\
             run 1726900000 pre-push-cargo-test pass 400\n\
             run tomorrow pre-push-cargo-test pass 400\n\
             run 1726900001 pre-push-cargo-test sideways 400\n\
             banana\n\
             run 1726900002 pre-push-cargo-test fail 500\n",
        );
        assert_eq!(note.tokens, vec!["test"]);
        assert_eq!(note.runs.len(), 2, "{:?}", note.runs);
        assert_eq!(note.runs[1].outcome, RunOutcome::Failed);
    }

    /// A note somebody else wrote into our ref is not evidence either. The
    /// token line is the trust boundary for BOTH halves of the note.
    #[test]
    fn a_foreign_note_yields_no_runs() {
        let note = Note::parse("hello\nrun 1726900000 pre-push-cargo-test pass 400\n");
        assert!(note.tokens.is_empty() && note.runs.is_empty());
    }

    /// Recording evidence must never cost a stamp. `stamp_push` and
    /// `bind_to_head` rewrite the same note, and the first version of this
    /// rendered the note from its own token list — which would have erased
    /// every run line on the next push.
    #[test]
    fn a_stamp_and_its_evidence_survive_each_other() {
        let dir = repo("evidence");
        std::fs::write(dir.join("a.ts"), "x").unwrap();
        git(&dir, &["add", "a.ts"]);
        in_repo(&dir, || {
            git(&dir, &["commit", "-qm", "chore: a"]);
            let head = git(&dir, &["rev-parse", "HEAD"]);
            let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
            record_runs(
                &tree,
                &[Run {
                    at: 1_726_900_000,
                    gate: "pre-push-cargo-test".into(),
                    outcome: RunOutcome::Failed,
                    ms: 412_391,
                }],
            );
            // …and now a later push of the same content passes and stamps it.
            assert!(stamp_push(&head, &tree, &["pre-push-cargo-test".into()]));
            let note = note_at(&tree);
            assert_eq!(note.tokens, vec!["pre-push-cargo-test"]);
            assert_eq!(note.runs.len(), 1, "the evidence survived the stamp");
            // The stamp still reads back as a stamp.
            let stamps = stamps_for(std::slice::from_ref(&head));
            assert_eq!(
                stamps.get(&head).map(Vec::as_slice),
                Some(&["pre-push-cargo-test".to_string()][..])
            );
        });
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// The whole point of the evidence half: it is not a stamp. A recorded
    /// run — even a passing one — must not let the gate be skipped, because
    /// skipping is decided by the token line and nothing else.
    #[test]
    fn a_recorded_run_is_not_a_stamp() {
        let dir = repo("not-a-stamp");
        std::fs::write(dir.join("a.ts"), "x").unwrap();
        git(&dir, &["add", "a.ts"]);
        in_repo(&dir, || {
            git(&dir, &["commit", "-qm", "chore: a"]);
            let head = git(&dir, &["rev-parse", "HEAD"]);
            let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
            record_runs(
                &tree,
                &[Run {
                    at: 1_726_900_000,
                    gate: "pre-push-cargo-test".into(),
                    outcome: RunOutcome::Passed,
                    ms: 412_391,
                }],
            );
            assert!(
                stamps_for(std::slice::from_ref(&head)).is_empty(),
                "a run line vouched for a gate — evidence must never gate"
            );
            assert!(
                !vouched_for_staged_tree().contains("pre-push-cargo-test"),
                "a run line vouched at commit time"
            );
            // It IS readable as history, though.
            let history = crate::gate_evidence::history_in(&dir);
            assert_eq!(history.runs.len(), 1);
            assert_eq!(history.runs[0].0, tree, "keyed by the content it ran on");
        });
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn forget_removes_the_stamps() {
        let dir = repo("forget");
        std::fs::write(dir.join("a.ts"), "x").unwrap();
        git(&dir, &["add", "a.ts"]);
        in_repo(&dir, || {
            record(&["typecheck"]);
            git(&dir, &["commit", "-qm", "chore: a"]);
            bind_to_head();
            let head = git(&dir, &["rev-parse", "HEAD"]);
            assert!(!stamps_for(std::slice::from_ref(&head)).is_empty());
            forget();
            assert!(stamps_for(&[head]).is_empty());
        });
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// A squash-merge produces a commit this machine never saw, and the
    /// stamp has to survive it.
    ///
    /// This is the case that motivated tree-stamping. A branch is verified
    /// locally and stamped; the forge squashes it onto `main` as a NEW
    /// commit with no note; pushing a tag on that commit re-ran every gate
    /// the branch had already proved. Measured on this repository: 13
    /// seconds for the branch push, then the full suite for the tag push,
    /// which died on a reset connection.
    ///
    /// The tree is what the gates actually read, and it is identical across
    /// that merge whenever the base has not moved — five of five merges in
    /// one afternoon here.
    #[test]
    fn a_stamp_survives_a_commit_being_rewritten_with_the_same_tree() {
        let dir = repo("squashed");
        std::fs::write(dir.join("a.ts"), "x").unwrap();
        git(&dir, &["add", "a.ts"]);
        in_repo(&dir, || {
            record(&["test"]);
            git(&dir, &["commit", "-qm", "feat: on a branch"]);
            assert_eq!(bind_to_head(), vec!["test".to_string()]);
            let branch_tip = git(&dir, &["rev-parse", "HEAD"]);

            // Stand in for the forge's squash: a DIFFERENT commit object
            // with the SAME tree. `--amend` with a new message is the
            // cheapest way to get exactly that shape.
            git(
                &dir,
                &[
                    "commit",
                    "-q",
                    "--amend",
                    "-m",
                    "feat: squashed by the forge",
                ],
            );
            let merged = git(&dir, &["rev-parse", "HEAD"]);
            assert_ne!(merged, branch_tip, "the fixture must produce a new commit");
            assert_eq!(
                git(&dir, &["rev-parse", "HEAD^{tree}"]),
                git(&dir, &["rev-parse", &format!("{branch_tip}^{{tree}}")]),
                "…carrying the same tree, which is the whole premise"
            );

            let stamps = stamps_for(std::slice::from_ref(&merged));
            assert_eq!(
                stamps.get(&merged).map(Vec::as_slice),
                Some(&["test".to_string()][..]),
                "the stamp must follow the content, not the commit hash"
            );
        });
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// And it must not follow anything else. A commit whose tree was never
    /// stamped gets nothing, however many other stamps exist — otherwise
    /// this widening would vouch for content nobody checked.
    #[test]
    fn a_different_tree_gets_no_stamp_from_the_fallback() {
        let dir = repo("othertree");
        std::fs::write(dir.join("a.ts"), "x").unwrap();
        git(&dir, &["add", "a.ts"]);
        in_repo(&dir, || {
            record(&["test"]);
            git(&dir, &["commit", "-qm", "chore: stamped"]);
            assert_eq!(bind_to_head(), vec!["test".to_string()]);

            // Different CONTENT, so a different tree, and no marker: this
            // commit was never judged.
            std::fs::write(dir.join("b.ts"), "y").unwrap();
            git(&dir, &["add", "b.ts"]);
            git(&dir, &["commit", "-qm", "chore: unjudged"]);
            let unstamped = git(&dir, &["rev-parse", "HEAD"]);

            assert!(
                stamps_for(std::slice::from_ref(&unstamped)).is_empty(),
                "a tree nobody stamped must not inherit one"
            );
        });
        let _ = std::fs::remove_dir_all(&dir);
    }
}