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 let _ = std::fs::remove_file(&path);
83 return;
84 };
85 let mut body = format!("{FORMAT}\n{tree}\n");
86 for s in scripts {
87 body.push_str(s);
88 body.push('\n');
89 }
90 let _ = std::fs::write(&path, body);
91}
92
93/// post-commit: consume the marker; stamp HEAD when the tree still matches.
94///
95/// One-shot by construction — the marker is deleted before anything is
96/// judged, so no path through here can leave it to vouch for a later commit.
97///
98/// Returns the scripts it actually stamped (empty on every no-stamp path,
99/// including a note git refused). The caller subtracts this from what the
100/// manifest declares to learn what the commit dodged — [`crate::bypass`]
101/// keeps that count. Two records, two questions: the stamp gates a check,
102/// the ledger only counts.
103pub fn bind_to_head() -> Vec<String> {
104 let Some(path) = marker_path() else {
105 return Vec::new();
106 };
107 let Ok(body) = std::fs::read_to_string(&path) else {
108 return Vec::new(); // no marker: nothing ran at pre-commit, nothing to stamp
109 };
110 let _ = std::fs::remove_file(&path);
111 let mut lines = body.lines();
112 if lines.next() != Some(FORMAT) {
113 return Vec::new();
114 }
115 let Some(tree) = lines.next() else {
116 return Vec::new();
117 };
118 let scripts: Vec<&str> = lines.filter(|l| !l.trim().is_empty()).collect();
119 if scripts.is_empty() {
120 return Vec::new();
121 }
122 let Some(head_tree) = crate::git::stdout(&["rev-parse", "HEAD^{tree}"]) else {
123 return Vec::new();
124 };
125 // A different tree means this commit is not the one pre-commit judged —
126 // the marker is a dead letter from an aborted attempt.
127 if head_tree != tree {
128 return Vec::new();
129 }
130 let note = format!("{FORMAT} {}", scripts.join(" "));
131 if !crate::git::succeeds(&[
132 "notes", "--ref", NOTES_REF, "add", "-f", "-m", ¬e, "HEAD",
133 ]) {
134 return Vec::new(); // a note git refused is not a stamp
135 }
136 scripts.iter().map(|s| s.to_string()).collect()
137}
138
139/// pre-push: which of `commits` carry a stamp, and for which scripts.
140///
141/// One `notes list` narrows the reads to commits that have a note at all;
142/// absent ref, unparseable note, wrong format version — all read as "no
143/// stamp", which re-runs the gate.
144pub fn stamps_for(commits: &[String]) -> HashMap<String, Vec<String>> {
145 let mut out = HashMap::new();
146 if commits.is_empty() {
147 return out;
148 }
149 let Some(list) = crate::git::stdout(&["notes", "--ref", NOTES_REF, "list"]) else {
150 return out; // no ref yet: nothing is stamped
151 };
152 let noted: HashSet<&str> = list
153 .lines()
154 .filter_map(|l| l.split_whitespace().nth(1))
155 .collect();
156 for commit in commits {
157 if !noted.contains(commit.as_str()) {
158 continue;
159 }
160 let Some(body) = crate::git::stdout(&["notes", "--ref", NOTES_REF, "show", commit]) else {
161 continue;
162 };
163 let Some(first) = body.lines().next() else {
164 continue;
165 };
166 let mut tokens = first.split_whitespace();
167 if tokens.next() != Some(FORMAT) {
168 continue;
169 }
170 out.insert(commit.clone(), tokens.map(str::to_string).collect());
171 }
172 out
173}
174
175/// uninstall: forget everything this module ever wrote here.
176///
177/// The stamps are OUR bookkeeping — unlike `hook.skip` and `amont.severity`,
178/// which are the user's statements and are never touched.
179pub fn forget() -> bool {
180 let marker = marker_path().is_some_and(|path| std::fs::remove_file(&path).is_ok());
181 let notes = crate::git::succeeds(&["update-ref", "-d", NOTES_FULL_REF]);
182 marker || notes
183}
184
185/// The same, for a repository this process is not standing in.
186pub fn forget_in(repo: &std::path::Path) -> bool {
187 let marker = crate::git::stdout_in(repo, &["rev-parse", "--absolute-git-dir"])
188 .is_some_and(|dir| std::fs::remove_file(std::path::Path::new(&dir).join(MARKER)).is_ok());
189 let notes = crate::git::succeeds_in(repo, &["update-ref", "-d", NOTES_FULL_REF]);
190 marker || notes
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use std::path::Path;
197
198 /// A real repository, because every function here is a conversation with
199 /// git — hand-rolled fixtures would test the conversation we imagined.
200 fn repo(name: &str) -> PathBuf {
201 let dir = std::env::temp_dir().join(format!("gate-stamp-{name}-{}", std::process::id()));
202 let _ = std::fs::remove_dir_all(&dir);
203 std::fs::create_dir_all(&dir).unwrap();
204 git(&dir, &["init", "-q", "--template=", "."]);
205 git(&dir, &["config", "user.email", "t@t.test"]);
206 git(&dir, &["config", "user.name", "t"]);
207 dir
208 }
209
210 fn git(dir: &Path, args: &[&str]) -> String {
211 let out = std::process::Command::new("git")
212 .arg("-C")
213 .arg(dir)
214 .args(args)
215 .output()
216 .expect("git");
217 String::from_utf8_lossy(&out.stdout).trim().to_string()
218 }
219
220 /// The module talks to the repo at the process cwd; these tests each set
221 /// it. Serialised via the crate-wide lock, because cwd is process-global
222 /// and `attest`'s tests move it too.
223 fn in_repo<T>(dir: &Path, f: impl FnOnce() -> T) -> T {
224 let _guard = crate::TEST_CWD.lock().unwrap_or_else(|p| p.into_inner());
225 let prev = std::env::current_dir().unwrap();
226 std::env::set_current_dir(dir).unwrap();
227 let r = f();
228 std::env::set_current_dir(prev).unwrap();
229 r
230 }
231
232 #[test]
233 fn a_recorded_marker_becomes_a_stamp_on_the_matching_commit() {
234 let dir = repo("roundtrip");
235 std::fs::write(dir.join("a.ts"), "x").unwrap();
236 git(&dir, &["add", "a.ts"]);
237 in_repo(&dir, || {
238 record(&["typecheck", "test"]);
239 git(&dir, &["commit", "-qm", "chore: a"]);
240 let stamped = bind_to_head();
241 assert_eq!(
242 stamped,
243 vec!["typecheck".to_string(), "test".to_string()],
244 "bind_to_head reports the scripts it stamped"
245 );
246 let head = git(&dir, &["rev-parse", "HEAD"]);
247 let stamps = stamps_for(std::slice::from_ref(&head));
248 assert_eq!(
249 stamps.get(&head).map(Vec::as_slice),
250 Some(&["typecheck".to_string(), "test".to_string()][..])
251 );
252 // One-shot: the marker is gone.
253 // One-shot: the marker is gone. The fixture's own path, not
254 // `marker_path()` — that helper spawns git, and a transient
255 // spawn failure on a loaded runner reads as `None` here while
256 // production code correctly treats it as "no marker". Seen once,
257 // on Windows, as an unwrap panic in a sibling test.
258 assert!(!dir.join(".git").join(MARKER).exists());
259 });
260 let _ = std::fs::remove_dir_all(&dir);
261 }
262
263 #[test]
264 fn a_marker_for_a_different_tree_stamps_nothing() {
265 let dir = repo("stale");
266 std::fs::write(dir.join("a.ts"), "x").unwrap();
267 git(&dir, &["add", "a.ts"]);
268 in_repo(&dir, || {
269 record(&["typecheck"]);
270 // The commit that actually lands carries DIFFERENT content — the
271 // aborted-attempt-then-different-retry shape.
272 std::fs::write(dir.join("a.ts"), "y").unwrap();
273 git(&dir, &["add", "a.ts"]);
274 git(&dir, &["commit", "-qm", "chore: different"]);
275 assert!(
276 bind_to_head().is_empty(),
277 "bind_to_head reports nothing when the tree moved"
278 );
279 let head = git(&dir, &["rev-parse", "HEAD"]);
280 assert!(
281 stamps_for(&[head]).is_empty(),
282 "a stale marker must not vouch"
283 );
284 assert!(
285 !dir.join(".git").join(MARKER).exists(),
286 "consumed either way"
287 );
288 });
289 let _ = std::fs::remove_dir_all(&dir);
290 }
291
292 #[test]
293 fn an_empty_record_clears_a_previous_marker() {
294 let dir = repo("clears");
295 std::fs::write(dir.join("a.ts"), "x").unwrap();
296 git(&dir, &["add", "a.ts"]);
297 in_repo(&dir, || {
298 record(&["typecheck"]);
299 assert!(dir.join(".git").join(MARKER).exists());
300 record(&[]);
301 assert!(!dir.join(".git").join(MARKER).exists());
302 });
303 let _ = std::fs::remove_dir_all(&dir);
304 }
305
306 /// The version guard's REJECT branch, fed a hand-written marker: an old
307 /// (or future) format is ignored rather than misread — the doc's claim,
308 /// now pinned. Every other test's markers come from record() itself and
309 /// so always carry the current FORMAT.
310 #[test]
311 fn a_marker_in_an_unknown_format_stamps_nothing() {
312 let dir = repo("wrongformat");
313 std::fs::write(dir.join("a.ts"), "x").unwrap();
314 git(&dir, &["add", "a.ts"]);
315 in_repo(&dir, || {
316 let tree = git(&dir, &["write-tree"]);
317 let marker = dir.join(".git").join(MARKER);
318 std::fs::write(&marker, format!("amont-gate-v99\n{tree}\ntypecheck\n")).unwrap();
319 git(&dir, &["commit", "-qm", "chore: a"]);
320 bind_to_head();
321 let head = git(&dir, &["rev-parse", "HEAD"]);
322 assert!(
323 stamps_for(std::slice::from_ref(&head)).is_empty(),
324 "an unknown format was trusted"
325 );
326 assert!(!marker.exists(), "consumed either way");
327 });
328 let _ = std::fs::remove_dir_all(&dir);
329 }
330
331 /// A note somebody else wrote into OUR ref is not a stamp. Absent this,
332 /// `git notes --ref=amont-gate add` would be a one-line way to vouch for
333 /// an unchecked commit — the parsing trust boundary of the whole chain.
334 #[test]
335 fn a_foreign_note_is_not_a_stamp() {
336 let dir = repo("foreignnote");
337 std::fs::write(dir.join("a.ts"), "x").unwrap();
338 git(&dir, &["add", "a.ts"]);
339 in_repo(&dir, || {
340 git(&dir, &["commit", "-qm", "chore: a"]);
341 git(
342 &dir,
343 &[
344 "notes",
345 "--ref",
346 NOTES_REF,
347 "add",
348 "-m",
349 "typecheck test",
350 "HEAD",
351 ],
352 );
353 let head = git(&dir, &["rev-parse", "HEAD"]);
354 assert!(
355 stamps_for(std::slice::from_ref(&head)).is_empty(),
356 "a note without the format token was trusted"
357 );
358 });
359 let _ = std::fs::remove_dir_all(&dir);
360 }
361
362 #[test]
363 fn forget_removes_the_stamps() {
364 let dir = repo("forget");
365 std::fs::write(dir.join("a.ts"), "x").unwrap();
366 git(&dir, &["add", "a.ts"]);
367 in_repo(&dir, || {
368 record(&["typecheck"]);
369 git(&dir, &["commit", "-qm", "chore: a"]);
370 bind_to_head();
371 let head = git(&dir, &["rev-parse", "HEAD"]);
372 assert!(!stamps_for(std::slice::from_ref(&head)).is_empty());
373 forget();
374 assert!(stamps_for(&[head]).is_empty());
375 });
376 let _ = std::fs::remove_dir_all(&dir);
377 }
378}