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//!
26//! Every failure mode points the same direction: no marker, a mismatched
27//! tree, a missing note, a rewritten hash — all mean "no stamp", and no stamp
28//! means the push gate RUNS. Nothing here can let an unchecked commit
29//! through; it can only cost a redundant gate run.
30//!
31//! Why a notes ref and not config: notes are keyed by commit, garbage-collect
32//! with unreachable commits (an `amont.checked.<hash>` config key would
33//! outlive every rebase forever), stay local (notes refs are not pushed by
34//! default), and stay out of `git log` (only `refs/notes/commits` displays by
35//! default). `amont uninstall` deletes the ref; see `uninstall_repo_hooks`.
36
37use std::collections::{HashMap, HashSet};
38use std::path::PathBuf;
39
40/// First token of the marker file and of every note body. Versioned like
41/// `staged_only::FORMAT`: a future amont that changes the shape bumps this,
42/// and an old record is ignored rather than misread.
43pub const FORMAT: &str = "amont-gate-v1";
44
45/// The notes ref, spelled the way `git notes --ref` wants it.
46pub const NOTES_REF: &str = "amont-gate";
47
48/// The same ref, fully qualified — what `git update-ref -d` needs.
49pub const NOTES_FULL_REF: &str = "refs/notes/amont-gate";
50
51/// The marker's filename inside `$GIT_DIR`.
52const MARKER: &str = "amont-gate";
53
54/// `$GIT_DIR/amont-gate` — the worktree-PRIVATE gitdir, deliberately: the
55/// commit this marker waits for happens in this worktree. The stamps the
56/// marker becomes live in the common dir (a notes ref) and are shared.
57fn marker_path() -> Option<PathBuf> {
58    let dir = crate::git::stdout(&["rev-parse", "--git-dir"])?;
59    Some(std::path::Path::new(&dir).join(MARKER))
60}
61
62/// pre-commit: record that `scripts` ran clean against the tree the commit
63/// will carry.
64///
65/// Called on EVERY pre-commit verdict, with an empty list when nothing
66/// qualifying ran (or the commit is about to be blocked) — an aborted or
67/// unchecked attempt must not inherit a previous attempt's marker.
68///
69/// Best-effort throughout: a failure to record costs one redundant gate run
70/// at push time, which is the safe direction, and a pre-commit that failed a
71/// COMMIT over bookkeeping would be the tail wagging the dog.
72pub fn record(scripts: &[&str]) {
73    let Some(path) = marker_path() else { return };
74    if scripts.is_empty() {
75        let _ = std::fs::remove_file(&path);
76        return;
77    }
78    // The index, as the object id `git commit` is about to seal. Inherits
79    // `GIT_INDEX_FILE`, so `git commit -a`'s temporary index answers here
80    // too. Pure read of the index: writes objects, touches no ref.
81    let Some(tree) = crate::git::stdout(&["write-tree"]) else {
82        // Not "nothing ran": git could not name the tree, so nothing may be
83        // vouched for. Dropping the marker is the fail-safe half (the gate
84        // re-runs at push); saying so is the half that was missing.
85        crate::hooks::common::warn(
86            "git would not name the staged tree — this commit records no gate stamp",
87        );
88        let _ = std::fs::remove_file(&path);
89        return;
90    };
91    let mut body = format!("{FORMAT}\n{tree}\n");
92    for s in scripts {
93        body.push_str(s);
94        body.push('\n');
95    }
96    let _ = std::fs::write(&path, body);
97}
98
99/// post-commit: consume the marker; stamp HEAD when the tree still matches.
100///
101/// One-shot by construction — the marker is deleted before anything is
102/// judged, so no path through here can leave it to vouch for a later commit.
103///
104/// Returns the scripts it actually stamped (empty on every no-stamp path,
105/// including a note git refused). The caller subtracts this from what the
106/// manifest declares to learn what the commit dodged — [`crate::bypass`]
107/// keeps that count. Two records, two questions: the stamp gates a check,
108/// the ledger only counts.
109pub fn bind_to_head() -> Vec<String> {
110    let Some(path) = marker_path() else {
111        return Vec::new();
112    };
113    let Ok(body) = std::fs::read_to_string(&path) else {
114        return Vec::new(); // no marker: nothing ran at pre-commit, nothing to stamp
115    };
116    let _ = std::fs::remove_file(&path);
117    let mut lines = body.lines();
118    if lines.next() != Some(FORMAT) {
119        return Vec::new();
120    }
121    let Some(tree) = lines.next() else {
122        return Vec::new();
123    };
124    let scripts: Vec<&str> = lines.filter(|l| !l.trim().is_empty()).collect();
125    if scripts.is_empty() {
126        return Vec::new();
127    }
128    let Some(head_tree) = crate::git::stdout(&["rev-parse", "HEAD^{tree}"]) else {
129        crate::hooks::common::warn(
130            "git would not name this commit's tree — no gate stamp was written",
131        );
132        return Vec::new();
133    };
134    // A different tree means this commit is not the one pre-commit judged —
135    // the marker is a dead letter from an aborted attempt.
136    if head_tree != tree {
137        return Vec::new();
138    }
139    let note = format!("{FORMAT} {}", scripts.join(" "));
140    if !crate::git::succeeds(&[
141        "notes", "--ref", NOTES_REF, "add", "-f", "-m", &note, "HEAD",
142    ]) {
143        // A note git refused is not a stamp — and the push will re-run these
144        // checks, which is right but looks arbitrary unless it is said.
145        crate::hooks::common::warn(
146            "git refused to write the gate stamp — these checks will run again at push",
147        );
148        return Vec::new();
149    }
150    scripts.iter().map(|s| s.to_string()).collect()
151}
152
153/// pre-push: which of `commits` carry a stamp, and for which scripts.
154///
155/// One `notes list` narrows the reads to commits that have a note at all;
156/// absent ref, unparseable note, wrong format version — all read as "no
157/// stamp", which re-runs the gate.
158pub fn stamps_for(commits: &[String]) -> HashMap<String, Vec<String>> {
159    let mut out = HashMap::new();
160    if commits.is_empty() {
161        return out;
162    }
163    let Some(list) = crate::git::stdout(&["notes", "--ref", NOTES_REF, "list"]) else {
164        // NOT the absent-ref case, whatever an older comment here claimed:
165        // `notes list` exits 0 with empty output when the ref does not exist,
166        // so that arrives as `Some("")` and falls through as "nothing is
167        // stamped" — correctly. Reaching HERE means git could not answer at
168        // all. Same verdict (the gates re-run: never skip work on a question
169        // we could not ask), different sentence, because a transient git
170        // failure that reads as "nothing is stamped" is indistinguishable
171        // from the real thing — which is exactly how one flaky spawn cost a
172        // day of not-diagnosing.
173        crate::hooks::common::warn(
174            "git would not list the gate stamps — every gated check will run again",
175        );
176        return out;
177    };
178    let noted: HashSet<&str> = list
179        .lines()
180        .filter_map(|l| l.split_whitespace().nth(1))
181        .collect();
182    for commit in commits {
183        if !noted.contains(commit.as_str()) {
184            continue;
185        }
186        let Some(body) = crate::git::stdout(&["notes", "--ref", NOTES_REF, "show", commit]) else {
187            continue;
188        };
189        let Some(first) = body.lines().next() else {
190            continue;
191        };
192        let mut tokens = first.split_whitespace();
193        if tokens.next() != Some(FORMAT) {
194            continue;
195        }
196        out.insert(commit.clone(), tokens.map(str::to_string).collect());
197    }
198    out
199}
200
201/// uninstall: forget everything this module ever wrote here.
202///
203/// The stamps are OUR bookkeeping — unlike `hook.skip` and `amont.severity`,
204/// which are the user's statements and are never touched.
205pub fn forget() -> bool {
206    let marker = marker_path().is_some_and(|path| std::fs::remove_file(&path).is_ok());
207    let notes = crate::git::succeeds(&["update-ref", "-d", NOTES_FULL_REF]);
208    marker || notes
209}
210
211/// The same, for a repository this process is not standing in.
212pub fn forget_in(repo: &std::path::Path) -> bool {
213    let marker = crate::git::stdout_in(repo, &["rev-parse", "--absolute-git-dir"])
214        .is_some_and(|dir| std::fs::remove_file(std::path::Path::new(&dir).join(MARKER)).is_ok());
215    let notes = crate::git::succeeds_in(repo, &["update-ref", "-d", NOTES_FULL_REF]);
216    marker || notes
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use std::path::Path;
223
224    /// A real repository, because every function here is a conversation with
225    /// git — hand-rolled fixtures would test the conversation we imagined.
226    fn repo(name: &str) -> PathBuf {
227        let dir = std::env::temp_dir().join(format!("gate-stamp-{name}-{}", std::process::id()));
228        let _ = std::fs::remove_dir_all(&dir);
229        std::fs::create_dir_all(&dir).unwrap();
230        git(&dir, &["init", "-q", "--template=", "."]);
231        git(&dir, &["config", "user.email", "t@t.test"]);
232        git(&dir, &["config", "user.name", "t"]);
233        dir
234    }
235
236    /// A fixture git call that FAILS where it fails.
237    ///
238    /// This used to discard the exit status, and that is how a rare flake
239    /// stayed unreadable for a day: if the setup `git commit` did not
240    /// happen, the test carried on to an unborn HEAD, and the panic landed
241    /// three lines later on a missing gate stamp — a product-shaped
242    /// failure for a fixture-shaped cause. Same rule the checks obey:
243    /// git failing is not git answering.
244    fn git(dir: &Path, args: &[&str]) -> String {
245        let out = std::process::Command::new("git")
246            .arg("-C")
247            .arg(dir)
248            .args(args)
249            .output()
250            .expect("git");
251        assert!(
252            out.status.success(),
253            "fixture: git {args:?} in {} exited {:?}: {}",
254            dir.display(),
255            out.status.code(),
256            String::from_utf8_lossy(&out.stderr).trim()
257        );
258        String::from_utf8_lossy(&out.stdout).trim().to_string()
259    }
260
261    /// The module talks to the repo at the process cwd; these tests each set
262    /// it. Serialised via the crate-wide lock, because cwd is process-global
263    /// and `attest`'s tests move it too.
264    fn in_repo<T>(dir: &Path, f: impl FnOnce() -> T) -> T {
265        let _guard = crate::TEST_CWD.lock().unwrap_or_else(|p| p.into_inner());
266        let prev = std::env::current_dir().unwrap();
267        std::env::set_current_dir(dir).unwrap();
268        let r = f();
269        std::env::set_current_dir(prev).unwrap();
270        r
271    }
272
273    #[test]
274    fn a_recorded_marker_becomes_a_stamp_on_the_matching_commit() {
275        let dir = repo("roundtrip");
276        std::fs::write(dir.join("a.ts"), "x").unwrap();
277        git(&dir, &["add", "a.ts"]);
278        in_repo(&dir, || {
279            record(&["typecheck", "test"]);
280            git(&dir, &["commit", "-qm", "chore: a"]);
281            let stamped = bind_to_head();
282            assert_eq!(
283                stamped,
284                vec!["typecheck".to_string(), "test".to_string()],
285                "bind_to_head reports the scripts it stamped"
286            );
287            let head = git(&dir, &["rev-parse", "HEAD"]);
288            let stamps = stamps_for(std::slice::from_ref(&head));
289            assert_eq!(
290                stamps.get(&head).map(Vec::as_slice),
291                Some(&["typecheck".to_string(), "test".to_string()][..])
292            );
293            // One-shot: the marker is gone.
294            // One-shot: the marker is gone. The fixture's own path, not
295            // `marker_path()` — that helper spawns git, and a transient
296            // spawn failure on a loaded runner reads as `None` here while
297            // production code correctly treats it as "no marker". Seen once,
298            // on Windows, as an unwrap panic in a sibling test.
299            assert!(!dir.join(".git").join(MARKER).exists());
300        });
301        let _ = std::fs::remove_dir_all(&dir);
302    }
303
304    #[test]
305    fn a_marker_for_a_different_tree_stamps_nothing() {
306        let dir = repo("stale");
307        std::fs::write(dir.join("a.ts"), "x").unwrap();
308        git(&dir, &["add", "a.ts"]);
309        in_repo(&dir, || {
310            record(&["typecheck"]);
311            // The commit that actually lands carries DIFFERENT content — the
312            // aborted-attempt-then-different-retry shape.
313            std::fs::write(dir.join("a.ts"), "y").unwrap();
314            git(&dir, &["add", "a.ts"]);
315            git(&dir, &["commit", "-qm", "chore: different"]);
316            assert!(
317                bind_to_head().is_empty(),
318                "bind_to_head reports nothing when the tree moved"
319            );
320            let head = git(&dir, &["rev-parse", "HEAD"]);
321            assert!(
322                stamps_for(&[head]).is_empty(),
323                "a stale marker must not vouch"
324            );
325            assert!(
326                !dir.join(".git").join(MARKER).exists(),
327                "consumed either way"
328            );
329        });
330        let _ = std::fs::remove_dir_all(&dir);
331    }
332
333    #[test]
334    fn an_empty_record_clears_a_previous_marker() {
335        let dir = repo("clears");
336        std::fs::write(dir.join("a.ts"), "x").unwrap();
337        git(&dir, &["add", "a.ts"]);
338        in_repo(&dir, || {
339            record(&["typecheck"]);
340            assert!(dir.join(".git").join(MARKER).exists());
341            record(&[]);
342            assert!(!dir.join(".git").join(MARKER).exists());
343        });
344        let _ = std::fs::remove_dir_all(&dir);
345    }
346
347    /// The version guard's REJECT branch, fed a hand-written marker: an old
348    /// (or future) format is ignored rather than misread — the doc's claim,
349    /// now pinned. Every other test's markers come from record() itself and
350    /// so always carry the current FORMAT.
351    #[test]
352    fn a_marker_in_an_unknown_format_stamps_nothing() {
353        let dir = repo("wrongformat");
354        std::fs::write(dir.join("a.ts"), "x").unwrap();
355        git(&dir, &["add", "a.ts"]);
356        in_repo(&dir, || {
357            let tree = git(&dir, &["write-tree"]);
358            let marker = dir.join(".git").join(MARKER);
359            std::fs::write(&marker, format!("amont-gate-v99\n{tree}\ntypecheck\n")).unwrap();
360            git(&dir, &["commit", "-qm", "chore: a"]);
361            bind_to_head();
362            let head = git(&dir, &["rev-parse", "HEAD"]);
363            assert!(
364                stamps_for(std::slice::from_ref(&head)).is_empty(),
365                "an unknown format was trusted"
366            );
367            assert!(!marker.exists(), "consumed either way");
368        });
369        let _ = std::fs::remove_dir_all(&dir);
370    }
371
372    /// A note somebody else wrote into OUR ref is not a stamp. Absent this,
373    /// `git notes --ref=amont-gate add` would be a one-line way to vouch for
374    /// an unchecked commit — the parsing trust boundary of the whole chain.
375    #[test]
376    fn a_foreign_note_is_not_a_stamp() {
377        let dir = repo("foreignnote");
378        std::fs::write(dir.join("a.ts"), "x").unwrap();
379        git(&dir, &["add", "a.ts"]);
380        in_repo(&dir, || {
381            git(&dir, &["commit", "-qm", "chore: a"]);
382            git(
383                &dir,
384                &[
385                    "notes",
386                    "--ref",
387                    NOTES_REF,
388                    "add",
389                    "-m",
390                    "typecheck test",
391                    "HEAD",
392                ],
393            );
394            let head = git(&dir, &["rev-parse", "HEAD"]);
395            assert!(
396                stamps_for(std::slice::from_ref(&head)).is_empty(),
397                "a note without the format token was trusted"
398            );
399        });
400        let _ = std::fs::remove_dir_all(&dir);
401    }
402
403    /// An absent notes ref is `Some("")`, not `None` — the distinction the
404    /// warning on that branch depends on. If git ever starts failing here
405    /// instead, this test fails and the warning stops being a lie.
406    #[test]
407    fn a_repo_with_no_stamps_answers_emptily_rather_than_failing() {
408        let dir = repo("no-stamps");
409        std::fs::write(dir.join("a.ts"), "x").unwrap();
410        git(&dir, &["add", "a.ts"]);
411        git(&dir, &["commit", "-qm", "chore: a"]);
412        in_repo(&dir, || {
413            assert_eq!(
414                crate::git::stdout(&["notes", "--ref", NOTES_REF, "list"]).as_deref(),
415                Some(""),
416                "an absent notes ref must be an ANSWER, not a failure — the \
417                 no-stamps path and the git-is-broken path are told apart by it"
418            );
419            let head = git(&dir, &["rev-parse", "HEAD"]);
420            assert!(stamps_for(&[head]).is_empty());
421        });
422        let _ = std::fs::remove_dir_all(&dir);
423    }
424
425    #[test]
426    fn forget_removes_the_stamps() {
427        let dir = repo("forget");
428        std::fs::write(dir.join("a.ts"), "x").unwrap();
429        git(&dir, &["add", "a.ts"]);
430        in_repo(&dir, || {
431            record(&["typecheck"]);
432            git(&dir, &["commit", "-qm", "chore: a"]);
433            bind_to_head();
434            let head = git(&dir, &["rev-parse", "HEAD"]);
435            assert!(!stamps_for(std::slice::from_ref(&head)).is_empty());
436            forget();
437            assert!(stamps_for(&[head]).is_empty());
438        });
439        let _ = std::fs::remove_dir_all(&dir);
440    }
441}