1use std::collections::{HashMap, HashSet};
74use std::path::PathBuf;
75
76pub const FORMAT: &str = "amont-gate-v1";
80
81pub const NOTES_REF: &str = "amont-gate";
83
84pub const NOTES_FULL_REF: &str = "refs/notes/amont-gate";
86
87pub const INPUTS_REF: &str = "amont-gate-inputs";
95pub const INPUTS_FULL_REF: &str = "refs/notes/amont-gate-inputs";
96
97const MARKER: &str = "amont-gate";
99
100const PUSH_STAMPS: &str = "amont.pushStamps";
104
105pub fn push_stamps_enabled(settings: &crate::config::Settings) -> bool {
107 crate::config::boolean_or(settings, PUSH_STAMPS, true)
108}
109
110const COMMIT_STAMPS: &str = "amont.commitStamps";
115
116pub fn commit_stamps_enabled(settings: &crate::config::Settings) -> bool {
119 crate::config::boolean_or(settings, COMMIT_STAMPS, true)
120}
121
122const RUN: &str = "run";
125
126const MAX_RUNS: usize = 64;
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum RunOutcome {
140 Passed,
141 Failed,
142 Warned,
143 Fixed,
144 Unavailable,
145 Inert,
146 Cold,
150 Busy,
152 Skew,
154 Withheld,
157 Cancelled,
159 Slow,
162}
163
164impl RunOutcome {
165 pub fn as_str(self) -> &'static str {
166 match self {
167 RunOutcome::Passed => "pass",
168 RunOutcome::Failed => "fail",
169 RunOutcome::Warned => "warn",
170 RunOutcome::Fixed => "fixed",
171 RunOutcome::Unavailable => "unavailable",
172 RunOutcome::Inert => "inert",
173 RunOutcome::Cold => "cold",
174 RunOutcome::Busy => "busy",
175 RunOutcome::Skew => "skew",
176 RunOutcome::Withheld => "withheld",
177 RunOutcome::Cancelled => "cancelled",
178 RunOutcome::Slow => "slow",
179 }
180 }
181
182 pub fn parse(s: &str) -> Option<RunOutcome> {
186 Some(match s {
187 "pass" => RunOutcome::Passed,
188 "fail" => RunOutcome::Failed,
189 "warn" => RunOutcome::Warned,
190 "fixed" => RunOutcome::Fixed,
191 "unavailable" => RunOutcome::Unavailable,
192 "inert" => RunOutcome::Inert,
193 "cold" => RunOutcome::Cold,
194 "busy" => RunOutcome::Busy,
195 "skew" => RunOutcome::Skew,
196 "withheld" => RunOutcome::Withheld,
197 "cancelled" => RunOutcome::Cancelled,
198 "slow" => RunOutcome::Slow,
199 _ => return None,
200 })
201 }
202
203 pub fn is_verdict(self) -> bool {
207 matches!(self, RunOutcome::Passed | RunOutcome::Failed)
208 }
209}
210
211#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct Run {
219 pub at: u64,
221 pub gate: String,
222 pub outcome: RunOutcome,
223 pub ms: u64,
224}
225
226impl Run {
227 fn render(&self) -> String {
228 format!(
229 "{RUN} {} {} {} {}",
230 self.at,
231 self.gate,
232 self.outcome.as_str(),
233 self.ms
234 )
235 }
236
237 fn parse(line: &str) -> Option<Run> {
240 let mut t = line.split_whitespace();
241 if t.next() != Some(RUN) {
242 return None;
243 }
244 let at = t.next()?.parse().ok()?;
245 let gate = t.next()?.to_string();
246 let outcome = RunOutcome::parse(t.next()?)?;
247 let ms = t.next()?.parse().ok()?;
248 Some(Run {
249 at,
250 gate,
251 outcome,
252 ms,
253 })
254 }
255}
256
257#[derive(Debug, Clone, Default, PartialEq, Eq)]
266pub struct Note {
267 pub tokens: Vec<String>,
268 pub runs: Vec<Run>,
269}
270
271impl Note {
272 pub fn parse(body: &str) -> Note {
276 let mut lines = body.lines();
277 let Some(first) = lines.next() else {
278 return Note::default();
279 };
280 let mut tokens = first.split_whitespace();
281 if tokens.next() != Some(FORMAT) {
282 return Note::default();
283 }
284 Note {
285 tokens: tokens.map(str::to_string).collect(),
286 runs: lines.filter_map(Run::parse).collect(),
287 }
288 }
289
290 pub fn render(&self) -> String {
293 let mut body = format!("{FORMAT} {}", self.tokens.join(" "));
294 for run in &self.runs {
295 body.push('\n');
296 body.push_str(&run.render());
297 }
298 body
299 }
300
301 fn add_tokens(&mut self, tokens: &[String]) {
302 for t in tokens {
303 if !self.tokens.iter().any(|have| have == t) {
304 self.tokens.push(t.clone());
305 }
306 }
307 }
308
309 fn add_runs(&mut self, runs: &[Run]) {
311 self.runs.extend(runs.iter().cloned());
312 if self.runs.len() > MAX_RUNS {
313 self.runs.drain(..self.runs.len() - MAX_RUNS);
314 }
315 }
316}
317
318fn note_at(key: &str) -> Note {
322 note_in(NOTES_REF, key)
323}
324
325fn note_in(notes_ref: &str, key: &str) -> Note {
326 crate::git::stdout(&["notes", "--ref", notes_ref, "show", key])
327 .map(|body| Note::parse(&body))
328 .unwrap_or_default()
329}
330
331fn input_tokens(tree: &str) -> Vec<String> {
340 let Some(spec) = crate::attest_inputs::spec_at(tree) else {
341 return canon_tokens(tree);
342 };
343 let mut out: Vec<String> = Vec::new();
344 for (gate, paths) in spec {
345 if !manifest_is_an_input(tree, &paths) {
346 continue;
347 }
348 let Some(fp) = crate::attest_inputs::fingerprint(tree, &paths) else {
349 continue;
350 };
351 let Some(key) = crate::attest_inputs::input_key(&gate, &fp) else {
352 continue;
353 };
354 for t in note_in(INPUTS_REF, &key).tokens {
355 if crate::gate_identity::base(&t) == gate && !out.contains(&t) {
356 out.push(t);
357 }
358 }
359 }
360 out
361}
362
363fn canon_tokens(tree: &str) -> Vec<String> {
370 canon_key_of(tree)
371 .map(|key| note_in(INPUTS_REF, &key).tokens)
372 .unwrap_or_default()
373}
374
375fn canon_key_of(tree: &str) -> Option<String> {
377 let canon = crate::attest_inputs::canonical_route(tree)?;
378 crate::attest_inputs::canon_gate_key(&canon)
379}
380
381fn file_canon(tree: &str, tokens: &[String]) {
385 let Some(key) = canon_key_of(tree) else {
386 return;
387 };
388 let mut note = note_in(INPUTS_REF, &key);
389 note.add_tokens(tokens);
390 let _ = write_note_in(INPUTS_REF, &key, ¬e);
391}
392
393fn file_inputs(tree: &str, tokens: &[String]) {
398 if tokens.is_empty() {
399 return;
400 }
401 let Some(spec) = crate::attest_inputs::spec_at(tree) else {
402 file_canon(tree, tokens);
403 return;
404 };
405 for (gate, paths) in spec {
406 let mine: Vec<String> = tokens
407 .iter()
408 .filter(|t| crate::gate_identity::base(t) == gate)
409 .cloned()
410 .collect();
411 if mine.is_empty() || !manifest_is_an_input(tree, &paths) {
412 continue;
413 }
414 let Some(fp) = crate::attest_inputs::fingerprint(tree, &paths) else {
415 continue;
416 };
417 let Some(key) = crate::attest_inputs::input_key(&gate, &fp) else {
418 continue;
419 };
420 let mut note = note_in(INPUTS_REF, &key);
421 note.add_tokens(&mine);
422 let _ = write_note_in(INPUTS_REF, &key, ¬e);
423 }
424}
425
426fn manifest_is_an_input(tree: &str, paths: &[crate::attest_inputs::PathTok]) -> bool {
432 let tracked = crate::git::succeeds(&[
433 "cat-file",
434 "-e",
435 &format!("{tree}:{}", crate::manifest::MANIFEST),
436 ]);
437 !tracked || crate::attest_inputs::declares(paths, crate::manifest::MANIFEST)
438}
439
440fn inputs_ref_exists() -> bool {
444 crate::git::succeeds(&["rev-parse", "-q", "--verify", INPUTS_FULL_REF])
445}
446
447pub fn tree_tokens(tree: &str) -> Vec<String> {
451 let mut tokens = note_at(tree).tokens;
452 if inputs_ref_exists() {
453 for t in input_tokens(tree) {
454 if !tokens.contains(&t) {
455 tokens.push(t);
456 }
457 }
458 }
459 tokens
460}
461
462pub fn stamp_tree(tree: &str, tokens: &[String]) -> bool {
466 let mut note = note_at(tree);
467 note.add_tokens(tokens);
468 let ok = write_note(tree, ¬e);
469 file_inputs(tree, tokens);
470 ok
471}
472
473fn write_note(key: &str, note: &Note) -> bool {
475 write_note_in(NOTES_REF, key, note)
476}
477
478fn write_note_in(notes_ref: &str, key: &str, note: &Note) -> bool {
479 let body = note.render();
480 crate::git::succeeds(&["notes", "--ref", notes_ref, "add", "-f", "-m", &body, key])
481}
482
483pub fn record_runs(key: &str, runs: &[Run]) {
494 let runs: Vec<Run> = runs
499 .iter()
500 .filter(|r| !r.gate.is_empty() && !r.gate.contains(char::is_whitespace))
501 .cloned()
502 .collect();
503 if runs.is_empty() {
504 return;
505 }
506 let mut note = note_at(key);
507 note.add_runs(&runs);
508 let _ = write_note(key, ¬e);
509}
510
511fn marker_path() -> Option<PathBuf> {
515 let dir = crate::git::stdout(&["rev-parse", "--git-dir"])?;
516 Some(std::path::Path::new(&dir).join(MARKER))
517}
518
519pub fn record(scripts: &[&str]) {
530 let Some(path) = marker_path() else { return };
531 if scripts.is_empty() {
532 let _ = std::fs::remove_file(&path);
533 return;
534 }
535 let Some(tree) = crate::git::stdout(&["write-tree"]) else {
539 crate::hooks::common::warn(
543 "git would not name the staged tree — this commit records no gate stamp",
544 );
545 let _ = std::fs::remove_file(&path);
546 return;
547 };
548 let mut body = format!("{FORMAT}\n{tree}\n");
549 for s in scripts {
550 body.push_str(s);
551 body.push('\n');
552 }
553 let _ = std::fs::write(&path, body);
554}
555
556pub fn vouched_for_staged_tree() -> HashSet<String> {
584 let mut out = HashSet::new();
585 let Some(tree) = crate::git::stdout(&["write-tree"]) else {
586 return out;
587 };
588 if let Some(path) = marker_path() {
589 if let Ok(body) = std::fs::read_to_string(&path) {
590 let mut lines = body.lines();
591 if lines.next() == Some(FORMAT) && lines.next() == Some(tree.as_str()) {
592 out.extend(lines.filter(|l| !l.trim().is_empty()).map(str::to_string));
593 }
594 }
595 }
596 out.extend(note_at(&tree).tokens);
605 if inputs_ref_exists() {
611 out.extend(input_tokens(&tree));
612 }
613 out
614}
615
616pub fn bind_to_head() -> Vec<String> {
627 let Some(path) = marker_path() else {
628 return Vec::new();
629 };
630 let Ok(body) = std::fs::read_to_string(&path) else {
631 return Vec::new(); };
633 let _ = std::fs::remove_file(&path);
634 let mut lines = body.lines();
635 if lines.next() != Some(FORMAT) {
636 return Vec::new();
637 }
638 let Some(tree) = lines.next() else {
639 return Vec::new();
640 };
641 let scripts: Vec<&str> = lines.filter(|l| !l.trim().is_empty()).collect();
642 if scripts.is_empty() {
643 return Vec::new();
644 }
645 let Some(head_tree) = crate::git::stdout(&["rev-parse", "HEAD^{tree}"]) else {
646 crate::hooks::common::warn(
647 "git would not name this commit's tree — no gate stamp was written",
648 );
649 return Vec::new();
650 };
651 if head_tree != tree {
654 return Vec::new();
655 }
656 let scripts: Vec<String> = scripts.iter().map(|s| s.to_string()).collect();
657 let mut tree_note = note_at(&head_tree);
682 tree_note.add_tokens(&scripts);
683 let _ = write_note(&head_tree, &tree_note);
684 file_inputs(&head_tree, &scripts);
685 let mut head_note = note_at("HEAD");
686 head_note.add_tokens(&scripts);
687 if !write_note("HEAD", &head_note) {
688 crate::hooks::common::warn(
691 "git refused to write the gate stamp — these checks will run again at push",
692 );
693 return Vec::new();
694 }
695 scripts
698 .iter()
699 .map(|s| crate::gate_identity::base(s).to_string())
700 .collect()
701}
702
703pub fn stamp_push(commit: &str, tree: &str, gates: &[String]) -> bool {
712 if gates.is_empty() {
713 return false;
714 }
715 let mut written = false;
716 for key in [tree, commit] {
717 let mut note = note_at(key);
718 note.add_tokens(gates);
719 let ok = write_note(key, ¬e);
720 if key == commit {
721 written = ok;
722 }
723 }
724 file_inputs(tree, gates);
725 if !written {
726 crate::hooks::common::warn(
727 "git refused to write the push stamp — these gates will run again on the next push",
728 );
729 }
730 written
731}
732
733pub fn stamps_for(commits: &[String]) -> HashMap<String, Vec<String>> {
739 let mut out = HashMap::new();
740 if commits.is_empty() {
741 return out;
742 }
743 let Some(list) = crate::git::stdout(&["notes", "--ref", NOTES_REF, "list"]) else {
744 crate::hooks::common::warn(
754 "git would not list the gate stamps — every gated check will run again",
755 );
756 return out;
757 };
758 let noted: HashSet<&str> = list
759 .lines()
760 .filter_map(|l| l.split_whitespace().nth(1))
761 .collect();
762 let mut trees: HashMap<String, String> = HashMap::new();
766 {
767 let mut args: Vec<&str> = vec!["log", "--no-walk", "--format=%H %T"];
768 args.extend(commits.iter().map(String::as_str));
769 if let Some(out) = crate::git::stdout(&args) {
770 for line in out.lines() {
771 let mut it = line.split_whitespace();
772 if let (Some(c), Some(t)) = (it.next(), it.next()) {
773 trees.insert(c.to_string(), t.to_string());
774 }
775 }
776 }
777 }
781
782 let by_inputs = inputs_ref_exists();
788
789 for commit in commits {
790 let mut tokens: Vec<String> = Vec::new();
809 let mut keys: Vec<&str> = Vec::new();
810 if noted.contains(commit.as_str()) {
811 keys.push(commit);
812 }
813 if let Some(tree) = trees.get(commit).filter(|t| noted.contains(t.as_str())) {
814 keys.push(tree);
815 }
816 for key in keys {
817 for t in note_at(key).tokens {
818 if !tokens.contains(&t) {
819 tokens.push(t);
820 }
821 }
822 }
823 if by_inputs {
824 if let Some(tree) = trees.get(commit) {
825 for t in input_tokens(tree) {
826 if !tokens.contains(&t) {
827 tokens.push(t);
828 }
829 }
830 }
831 }
832 if tokens.is_empty() {
833 continue;
834 }
835 out.insert(commit.clone(), tokens);
836 }
837 out
838}
839
840pub fn forget() -> bool {
845 let marker = marker_path().is_some_and(|path| std::fs::remove_file(&path).is_ok());
846 let notes = crate::git::succeeds(&["update-ref", "-d", NOTES_FULL_REF]);
847 let inputs = crate::git::succeeds(&["update-ref", "-d", INPUTS_FULL_REF]);
848 marker || notes || inputs
849}
850
851pub fn forget_in(repo: &std::path::Path) -> bool {
853 let marker = crate::git::stdout_in(repo, &["rev-parse", "--absolute-git-dir"])
854 .is_some_and(|dir| std::fs::remove_file(std::path::Path::new(&dir).join(MARKER)).is_ok());
855 let notes = crate::git::succeeds_in(repo, &["update-ref", "-d", NOTES_FULL_REF]);
856 let inputs = crate::git::succeeds_in(repo, &["update-ref", "-d", INPUTS_FULL_REF]);
857 marker || notes || inputs
858}
859
860#[cfg(test)]
861mod tests {
862 use super::*;
863 use std::path::Path;
864
865 fn repo(name: &str) -> PathBuf {
868 let dir = std::env::temp_dir().join(format!("gate-stamp-{name}-{}", std::process::id()));
869 let _ = std::fs::remove_dir_all(&dir);
870 std::fs::create_dir_all(&dir).unwrap();
871 git(&dir, &["init", "-q", "--template=", "."]);
872 git(&dir, &["config", "user.email", "t@t.test"]);
873 git(&dir, &["config", "user.name", "t"]);
874 dir
875 }
876
877 fn git(dir: &Path, args: &[&str]) -> String {
886 let out = std::process::Command::new("git")
887 .arg("-C")
888 .arg(dir)
889 .args(args)
890 .output()
891 .expect("git");
892 assert!(
893 out.status.success(),
894 "fixture: git {args:?} in {} exited {:?}: {}",
895 dir.display(),
896 out.status.code(),
897 String::from_utf8_lossy(&out.stderr).trim()
898 );
899 String::from_utf8_lossy(&out.stdout).trim().to_string()
900 }
901
902 fn in_repo<T>(dir: &Path, f: impl FnOnce() -> T) -> T {
906 let _guard = crate::TEST_CWD.lock().unwrap_or_else(|p| p.into_inner());
907 let prev = std::env::current_dir().unwrap();
908 std::env::set_current_dir(dir).unwrap();
909 let r = f();
910 std::env::set_current_dir(prev).unwrap();
911 r
912 }
913
914 #[test]
915 fn the_commit_note_does_not_hide_the_tree_notes_push_tokens() {
916 let dir = repo("union");
917 std::fs::write(dir.join("a.ts"), "x").unwrap();
918 git(&dir, &["add", "a.ts"]);
919 in_repo(&dir, || {
920 record(&["test"]);
921 git(&dir, &["commit", "-qm", "chore: a"]);
922 bind_to_head();
923 let head = git(&dir, &["rev-parse", "HEAD"]);
924 let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
925 assert!(stamp_tree(&tree, &["pre-push-cargo-test".to_string()]));
928 assert_eq!(note_at(&head).tokens, vec!["test".to_string()]);
929 let stamps = stamps_for(std::slice::from_ref(&head));
930 assert_eq!(
931 stamps.get(&head).map(Vec::as_slice),
932 Some(&["test".to_string(), "pre-push-cargo-test".to_string()][..]),
933 "both notes speak for the same tree"
934 );
935 });
936 let _ = std::fs::remove_dir_all(&dir);
937 }
938
939 #[test]
940 fn a_commit_with_only_a_tree_note_still_reads_the_tree_tokens() {
941 let dir = repo("tree-only");
942 std::fs::write(dir.join("a.ts"), "x").unwrap();
943 git(&dir, &["add", "a.ts"]);
944 git(&dir, &["commit", "-qm", "chore: a"]);
945 in_repo(&dir, || {
946 let head = git(&dir, &["rev-parse", "HEAD"]);
947 let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
948 assert!(stamp_tree(&tree, &["pre-push-cargo-test".to_string()]));
949 let stamps = stamps_for(std::slice::from_ref(&head));
950 assert_eq!(
951 stamps.get(&head).map(Vec::as_slice),
952 Some(&["pre-push-cargo-test".to_string()][..])
953 );
954 });
955 let _ = std::fs::remove_dir_all(&dir);
956 }
957
958 #[test]
959 fn a_commit_with_only_a_commit_note_still_reads_its_tokens() {
960 let dir = repo("commit-only");
961 std::fs::write(dir.join("a.ts"), "x").unwrap();
962 git(&dir, &["add", "a.ts"]);
963 git(&dir, &["commit", "-qm", "chore: a"]);
964 in_repo(&dir, || {
965 let head = git(&dir, &["rev-parse", "HEAD"]);
966 let mut note = Note::default();
967 note.add_tokens(&["typecheck".to_string()]);
968 assert!(write_note(&head, ¬e));
969 let stamps = stamps_for(std::slice::from_ref(&head));
970 assert_eq!(
971 stamps.get(&head).map(Vec::as_slice),
972 Some(&["typecheck".to_string()][..])
973 );
974 });
975 let _ = std::fs::remove_dir_all(&dir);
976 }
977
978 #[test]
979 fn a_token_on_both_notes_is_read_once() {
980 let dir = repo("dedup");
981 std::fs::write(dir.join("a.ts"), "x").unwrap();
982 git(&dir, &["add", "a.ts"]);
983 in_repo(&dir, || {
984 record(&["test"]);
985 git(&dir, &["commit", "-qm", "chore: a"]);
986 bind_to_head(); let head = git(&dir, &["rev-parse", "HEAD"]);
988 let stamps = stamps_for(std::slice::from_ref(&head));
989 assert_eq!(
990 stamps.get(&head).map(Vec::as_slice),
991 Some(&["test".to_string()][..])
992 );
993 });
994 let _ = std::fs::remove_dir_all(&dir);
995 }
996
997 #[test]
998 fn a_recorded_marker_becomes_a_stamp_on_the_matching_commit() {
999 let dir = repo("roundtrip");
1000 std::fs::write(dir.join("a.ts"), "x").unwrap();
1001 git(&dir, &["add", "a.ts"]);
1002 in_repo(&dir, || {
1003 record(&["typecheck", "test"]);
1004 git(&dir, &["commit", "-qm", "chore: a"]);
1005 let stamped = bind_to_head();
1006 assert_eq!(
1007 stamped,
1008 vec!["typecheck".to_string(), "test".to_string()],
1009 "bind_to_head reports the scripts it stamped"
1010 );
1011 let head = git(&dir, &["rev-parse", "HEAD"]);
1012 let stamps = stamps_for(std::slice::from_ref(&head));
1013 assert_eq!(
1014 stamps.get(&head).map(Vec::as_slice),
1015 Some(&["typecheck".to_string(), "test".to_string()][..])
1016 );
1017 assert!(!dir.join(".git").join(MARKER).exists());
1024 });
1025 let _ = std::fs::remove_dir_all(&dir);
1026 }
1027
1028 #[test]
1029 fn a_marker_for_a_different_tree_stamps_nothing() {
1030 let dir = repo("stale");
1031 std::fs::write(dir.join("a.ts"), "x").unwrap();
1032 git(&dir, &["add", "a.ts"]);
1033 in_repo(&dir, || {
1034 record(&["typecheck"]);
1035 std::fs::write(dir.join("a.ts"), "y").unwrap();
1038 git(&dir, &["add", "a.ts"]);
1039 git(&dir, &["commit", "-qm", "chore: different"]);
1040 assert!(
1041 bind_to_head().is_empty(),
1042 "bind_to_head reports nothing when the tree moved"
1043 );
1044 let head = git(&dir, &["rev-parse", "HEAD"]);
1045 assert!(
1046 stamps_for(&[head]).is_empty(),
1047 "a stale marker must not vouch"
1048 );
1049 assert!(
1050 !dir.join(".git").join(MARKER).exists(),
1051 "consumed either way"
1052 );
1053 });
1054 let _ = std::fs::remove_dir_all(&dir);
1055 }
1056
1057 #[test]
1058 fn an_empty_record_clears_a_previous_marker() {
1059 let dir = repo("clears");
1060 std::fs::write(dir.join("a.ts"), "x").unwrap();
1061 git(&dir, &["add", "a.ts"]);
1062 in_repo(&dir, || {
1063 record(&["typecheck"]);
1064 assert!(dir.join(".git").join(MARKER).exists());
1065 record(&[]);
1066 assert!(!dir.join(".git").join(MARKER).exists());
1067 });
1068 let _ = std::fs::remove_dir_all(&dir);
1069 }
1070
1071 #[test]
1076 fn a_marker_in_an_unknown_format_stamps_nothing() {
1077 let dir = repo("wrongformat");
1078 std::fs::write(dir.join("a.ts"), "x").unwrap();
1079 git(&dir, &["add", "a.ts"]);
1080 in_repo(&dir, || {
1081 let tree = git(&dir, &["write-tree"]);
1082 let marker = dir.join(".git").join(MARKER);
1083 std::fs::write(&marker, format!("amont-gate-v99\n{tree}\ntypecheck\n")).unwrap();
1084 git(&dir, &["commit", "-qm", "chore: a"]);
1085 bind_to_head();
1086 let head = git(&dir, &["rev-parse", "HEAD"]);
1087 assert!(
1088 stamps_for(std::slice::from_ref(&head)).is_empty(),
1089 "an unknown format was trusted"
1090 );
1091 assert!(!marker.exists(), "consumed either way");
1092 });
1093 let _ = std::fs::remove_dir_all(&dir);
1094 }
1095
1096 #[test]
1100 fn a_foreign_note_is_not_a_stamp() {
1101 let dir = repo("foreignnote");
1102 std::fs::write(dir.join("a.ts"), "x").unwrap();
1103 git(&dir, &["add", "a.ts"]);
1104 in_repo(&dir, || {
1105 git(&dir, &["commit", "-qm", "chore: a"]);
1106 git(
1107 &dir,
1108 &[
1109 "notes",
1110 "--ref",
1111 NOTES_REF,
1112 "add",
1113 "-m",
1114 "typecheck test",
1115 "HEAD",
1116 ],
1117 );
1118 let head = git(&dir, &["rev-parse", "HEAD"]);
1119 assert!(
1120 stamps_for(std::slice::from_ref(&head)).is_empty(),
1121 "a note without the format token was trusted"
1122 );
1123 });
1124 let _ = std::fs::remove_dir_all(&dir);
1125 }
1126
1127 #[test]
1131 fn a_repo_with_no_stamps_answers_emptily_rather_than_failing() {
1132 let dir = repo("no-stamps");
1133 std::fs::write(dir.join("a.ts"), "x").unwrap();
1134 git(&dir, &["add", "a.ts"]);
1135 git(&dir, &["commit", "-qm", "chore: a"]);
1136 in_repo(&dir, || {
1137 assert_eq!(
1138 crate::git::stdout(&["notes", "--ref", NOTES_REF, "list"]).as_deref(),
1139 Some(""),
1140 "an absent notes ref must be an ANSWER, not a failure — the \
1141 no-stamps path and the git-is-broken path are told apart by it"
1142 );
1143 let head = git(&dir, &["rev-parse", "HEAD"]);
1144 assert!(stamps_for(&[head]).is_empty());
1145 });
1146 let _ = std::fs::remove_dir_all(&dir);
1147 }
1148
1149 #[test]
1156 fn a_note_from_before_the_run_lines_parses_unchanged() {
1157 let note = Note::parse("amont-gate-v1 typecheck test");
1158 assert_eq!(note.tokens, vec!["typecheck", "test"]);
1159 assert!(note.runs.is_empty());
1160 assert_eq!(note.render(), "amont-gate-v1 typecheck test");
1163 }
1164
1165 #[test]
1167 fn run_lines_are_read_without_disturbing_the_tokens() {
1168 let body = "amont-gate-v1 pre-push-cargo-test\n\
1169 run 1726900000 pre-push-cargo-test pass 412391\n\
1170 run 1726903600 pre-push-audit-js fail 903\n";
1171 let note = Note::parse(body);
1172 assert_eq!(note.tokens, vec!["pre-push-cargo-test"]);
1173 assert_eq!(note.runs.len(), 2);
1174 assert_eq!(
1175 note.runs[0],
1176 Run {
1177 at: 1_726_900_000,
1178 gate: "pre-push-cargo-test".into(),
1179 outcome: RunOutcome::Passed,
1180 ms: 412_391,
1181 }
1182 );
1183 assert_eq!(note.runs[1].outcome, RunOutcome::Failed);
1184 assert_eq!(note.render(), body.trim_end());
1185 }
1186
1187 #[test]
1191 fn an_unreadable_run_line_is_dropped_and_the_rest_survives() {
1192 let note = Note::parse(
1193 "amont-gate-v1 test\n\
1194 run 1726900000 pre-push-cargo-test pass 400\n\
1195 run tomorrow pre-push-cargo-test pass 400\n\
1196 run 1726900001 pre-push-cargo-test sideways 400\n\
1197 banana\n\
1198 run 1726900002 pre-push-cargo-test fail 500\n",
1199 );
1200 assert_eq!(note.tokens, vec!["test"]);
1201 assert_eq!(note.runs.len(), 2, "{:?}", note.runs);
1202 assert_eq!(note.runs[1].outcome, RunOutcome::Failed);
1203 }
1204
1205 #[test]
1208 fn a_foreign_note_yields_no_runs() {
1209 let note = Note::parse("hello\nrun 1726900000 pre-push-cargo-test pass 400\n");
1210 assert!(note.tokens.is_empty() && note.runs.is_empty());
1211 }
1212
1213 #[test]
1218 fn a_stamp_and_its_evidence_survive_each_other() {
1219 let dir = repo("evidence");
1220 std::fs::write(dir.join("a.ts"), "x").unwrap();
1221 git(&dir, &["add", "a.ts"]);
1222 in_repo(&dir, || {
1223 git(&dir, &["commit", "-qm", "chore: a"]);
1224 let head = git(&dir, &["rev-parse", "HEAD"]);
1225 let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1226 record_runs(
1227 &tree,
1228 &[Run {
1229 at: 1_726_900_000,
1230 gate: "pre-push-cargo-test".into(),
1231 outcome: RunOutcome::Failed,
1232 ms: 412_391,
1233 }],
1234 );
1235 assert!(stamp_push(&head, &tree, &["pre-push-cargo-test".into()]));
1237 let note = note_at(&tree);
1238 assert_eq!(note.tokens, vec!["pre-push-cargo-test"]);
1239 assert_eq!(note.runs.len(), 1, "the evidence survived the stamp");
1240 let stamps = stamps_for(std::slice::from_ref(&head));
1242 assert_eq!(
1243 stamps.get(&head).map(Vec::as_slice),
1244 Some(&["pre-push-cargo-test".to_string()][..])
1245 );
1246 });
1247 let _ = std::fs::remove_dir_all(&dir);
1248 }
1249
1250 #[test]
1254 fn a_recorded_run_is_not_a_stamp() {
1255 let dir = repo("not-a-stamp");
1256 std::fs::write(dir.join("a.ts"), "x").unwrap();
1257 git(&dir, &["add", "a.ts"]);
1258 in_repo(&dir, || {
1259 git(&dir, &["commit", "-qm", "chore: a"]);
1260 let head = git(&dir, &["rev-parse", "HEAD"]);
1261 let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1262 record_runs(
1263 &tree,
1264 &[Run {
1265 at: 1_726_900_000,
1266 gate: "pre-push-cargo-test".into(),
1267 outcome: RunOutcome::Passed,
1268 ms: 412_391,
1269 }],
1270 );
1271 assert!(
1272 stamps_for(std::slice::from_ref(&head)).is_empty(),
1273 "a run line vouched for a gate — evidence must never gate"
1274 );
1275 assert!(
1276 !vouched_for_staged_tree().contains("pre-push-cargo-test"),
1277 "a run line vouched at commit time"
1278 );
1279 let history = crate::gate_evidence::history_in(&dir);
1281 assert_eq!(history.runs.len(), 1);
1282 assert_eq!(history.runs[0].0, tree, "keyed by the content it ran on");
1283 });
1284 let _ = std::fs::remove_dir_all(&dir);
1285 }
1286
1287 #[test]
1288 fn forget_removes_the_stamps() {
1289 let dir = repo("forget");
1290 std::fs::write(dir.join("a.ts"), "x").unwrap();
1291 git(&dir, &["add", "a.ts"]);
1292 in_repo(&dir, || {
1293 record(&["typecheck"]);
1294 git(&dir, &["commit", "-qm", "chore: a"]);
1295 bind_to_head();
1296 let head = git(&dir, &["rev-parse", "HEAD"]);
1297 assert!(!stamps_for(std::slice::from_ref(&head)).is_empty());
1298 forget();
1299 assert!(stamps_for(&[head]).is_empty());
1300 });
1301 let _ = std::fs::remove_dir_all(&dir);
1302 }
1303
1304 #[test]
1318 fn a_stamp_survives_a_commit_being_rewritten_with_the_same_tree() {
1319 let dir = repo("squashed");
1320 std::fs::write(dir.join("a.ts"), "x").unwrap();
1321 git(&dir, &["add", "a.ts"]);
1322 in_repo(&dir, || {
1323 record(&["test"]);
1324 git(&dir, &["commit", "-qm", "feat: on a branch"]);
1325 assert_eq!(bind_to_head(), vec!["test".to_string()]);
1326 let branch_tip = git(&dir, &["rev-parse", "HEAD"]);
1327
1328 git(
1332 &dir,
1333 &[
1334 "commit",
1335 "-q",
1336 "--amend",
1337 "-m",
1338 "feat: squashed by the forge",
1339 ],
1340 );
1341 let merged = git(&dir, &["rev-parse", "HEAD"]);
1342 assert_ne!(merged, branch_tip, "the fixture must produce a new commit");
1343 assert_eq!(
1344 git(&dir, &["rev-parse", "HEAD^{tree}"]),
1345 git(&dir, &["rev-parse", &format!("{branch_tip}^{{tree}}")]),
1346 "…carrying the same tree, which is the whole premise"
1347 );
1348
1349 let stamps = stamps_for(std::slice::from_ref(&merged));
1350 assert_eq!(
1351 stamps.get(&merged).map(Vec::as_slice),
1352 Some(&["test".to_string()][..]),
1353 "the stamp must follow the content, not the commit hash"
1354 );
1355 });
1356 let _ = std::fs::remove_dir_all(&dir);
1357 }
1358
1359 #[test]
1363 fn a_different_tree_gets_no_stamp_from_the_fallback() {
1364 let dir = repo("othertree");
1365 std::fs::write(dir.join("a.ts"), "x").unwrap();
1366 git(&dir, &["add", "a.ts"]);
1367 in_repo(&dir, || {
1368 record(&["test"]);
1369 git(&dir, &["commit", "-qm", "chore: stamped"]);
1370 assert_eq!(bind_to_head(), vec!["test".to_string()]);
1371
1372 std::fs::write(dir.join("b.ts"), "y").unwrap();
1375 git(&dir, &["add", "b.ts"]);
1376 git(&dir, &["commit", "-qm", "chore: unjudged"]);
1377 let unstamped = git(&dir, &["rev-parse", "HEAD"]);
1378
1379 assert!(
1380 stamps_for(std::slice::from_ref(&unstamped)).is_empty(),
1381 "a tree nobody stamped must not inherit one"
1382 );
1383 });
1384 let _ = std::fs::remove_dir_all(&dir);
1385 }
1386
1387 fn declared(name: &str, spec: &str) -> PathBuf {
1393 let dir = repo(name);
1394 std::fs::create_dir_all(dir.join(".github")).unwrap();
1395 std::fs::create_dir_all(dir.join("docs/plans")).unwrap();
1396 std::fs::write(dir.join("a.ts"), "x").unwrap();
1397 std::fs::write(dir.join("docs/plans/p.md"), "plan v1").unwrap();
1398 std::fs::write(dir.join(".github/attest-inputs"), spec).unwrap();
1399 git(&dir, &["add", "-A"]);
1400 in_repo(&dir, || {
1401 record(&["test@abc"]);
1402 git(&dir, &["commit", "-qm", "feat: judged"]);
1403 assert_eq!(bind_to_head(), vec!["test".to_string()]);
1404 });
1405 dir
1406 }
1407
1408 fn plan_only_commit(dir: &Path) -> String {
1411 std::fs::write(dir.join("docs/plans/p.md"), "plan v2, closing").unwrap();
1412 git(dir, &["add", "docs/plans/p.md"]);
1413 git(dir, &["commit", "-qm", "docs(plan): done"]);
1414 git(dir, &["rev-parse", "HEAD"])
1415 }
1416
1417 #[test]
1418 fn a_plan_only_commit_reuses_the_stamp_of_a_declared_gate() {
1419 let dir = declared("plan-only", "test a.ts\n");
1420 in_repo(&dir, || {
1421 let head = plan_only_commit(&dir);
1422 let stamps = stamps_for(std::slice::from_ref(&head));
1423 assert_eq!(
1424 stamps.get(&head).map(Vec::as_slice),
1425 Some(&["test@abc".to_string()][..]),
1426 "the closing commit changed the tree and none of the declared inputs"
1427 );
1428 });
1429 let _ = std::fs::remove_dir_all(&dir);
1430 }
1431
1432 #[test]
1433 fn the_staged_tree_of_a_plan_only_commit_is_vouched_for() {
1434 let dir = declared("staged", "test a.ts\n");
1435 in_repo(&dir, || {
1436 std::fs::write(dir.join("docs/plans/p.md"), "plan v2").unwrap();
1437 git(&dir, &["add", "docs/plans/p.md"]);
1438 assert!(vouched_for_staged_tree().contains("test@abc"));
1439 });
1440 let _ = std::fs::remove_dir_all(&dir);
1441 }
1442
1443 #[test]
1444 fn partial_staging_is_judged_on_the_index_not_the_working_copy() {
1445 let dir = declared("partial", "test a.ts\n");
1446 in_repo(&dir, || {
1447 std::fs::write(dir.join("docs/plans/p.md"), "plan v2").unwrap();
1450 git(&dir, &["add", "docs/plans/p.md"]);
1451 std::fs::write(dir.join("a.ts"), "unstaged edit").unwrap();
1452 assert!(vouched_for_staged_tree().contains("test@abc"));
1453
1454 std::fs::write(dir.join("a.ts"), "staged edit").unwrap();
1457 git(&dir, &["add", "a.ts"]);
1458 std::fs::write(dir.join("a.ts"), "x").unwrap();
1459 assert!(!vouched_for_staged_tree().contains("test@abc"));
1460 });
1461 let _ = std::fs::remove_dir_all(&dir);
1462 }
1463
1464 #[test]
1465 fn a_changed_declared_input_misses() {
1466 let dir = declared("input-changed", "test a.ts\n");
1467 in_repo(&dir, || {
1468 std::fs::write(dir.join("a.ts"), "changed").unwrap();
1469 git(&dir, &["add", "a.ts"]);
1470 git(&dir, &["commit", "-qm", "feat: unjudged"]);
1471 let head = git(&dir, &["rev-parse", "HEAD"]);
1472 assert!(stamps_for(std::slice::from_ref(&head)).is_empty());
1473 });
1474 let _ = std::fs::remove_dir_all(&dir);
1475 }
1476
1477 #[test]
1478 fn a_changed_declaration_misses() {
1479 let dir = declared("declaration-changed", "test a.ts\n");
1480 in_repo(&dir, || {
1481 std::fs::write(dir.join("b.ts"), "y").unwrap();
1484 std::fs::write(dir.join(".github/attest-inputs"), "test a.ts b.ts\n").unwrap();
1485 git(&dir, &["add", "-A"]);
1486 git(&dir, &["commit", "-qm", "chore: widen the declaration"]);
1487 let head = git(&dir, &["rev-parse", "HEAD"]);
1488 assert!(stamps_for(std::slice::from_ref(&head)).is_empty());
1489 });
1490 let _ = std::fs::remove_dir_all(&dir);
1491 }
1492
1493 #[test]
1494 fn a_declared_config_file_is_an_input_like_any_other() {
1495 let dir = declared("config", "test a.ts amont.conf\n");
1498 in_repo(&dir, || {
1499 assert!(!inputs_ref_exists());
1502
1503 std::fs::write(dir.join("amont.conf"), "scope = src\n").unwrap();
1504 git(&dir, &["add", "amont.conf"]);
1505 record(&["test@abc"]);
1506 git(&dir, &["commit", "-qm", "chore: config"]);
1507 assert_eq!(bind_to_head(), vec!["test".to_string()]);
1508
1509 std::fs::write(dir.join("docs/plans/p.md"), "plan v2").unwrap();
1510 git(&dir, &["add", "docs/plans/p.md"]);
1511 git(&dir, &["commit", "-qm", "docs(plan): done"]);
1512 let plan = git(&dir, &["rev-parse", "HEAD"]);
1513 assert!(stamps_for(std::slice::from_ref(&plan)).contains_key(&plan));
1514
1515 std::fs::write(dir.join("amont.conf"), "scope = lib\n").unwrap();
1516 git(&dir, &["add", "amont.conf"]);
1517 git(&dir, &["commit", "-qm", "chore: scope-only change"]);
1518 let scope = git(&dir, &["rev-parse", "HEAD"]);
1519 assert!(
1520 stamps_for(std::slice::from_ref(&scope)).is_empty(),
1521 "a scope-only change must miss"
1522 );
1523 });
1524 let _ = std::fs::remove_dir_all(&dir);
1525 }
1526
1527 #[test]
1528 fn a_declared_stamp_is_read_back_as_its_complete_token() {
1529 let dir = declared("identity", "test a.ts\n");
1535 in_repo(&dir, || {
1536 let head = plan_only_commit(&dir);
1537 let tokens = stamps_for(std::slice::from_ref(&head))
1538 .remove(&head)
1539 .unwrap_or_default();
1540 assert_eq!(tokens, vec!["test@abc".to_string()]);
1541
1542 let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1543 assert!(stamp_tree(&tree, &["test@def".to_string()]));
1544 let tokens = tree_tokens(&tree);
1545 assert!(tokens.contains(&"test@abc".to_string()));
1546 assert!(tokens.contains(&"test@def".to_string()));
1547 assert!(!tokens.contains(&"test@zzz".to_string()));
1548 assert!(!tokens.contains(&"test".to_string()));
1549 });
1550 let _ = std::fs::remove_dir_all(&dir);
1551 }
1552
1553 #[test]
1554 fn a_gate_that_does_not_declare_the_manifest_is_not_offered_the_route() {
1555 let dir = repo("manifest-undeclared");
1559 std::fs::create_dir_all(dir.join(".github")).unwrap();
1560 std::fs::create_dir_all(dir.join("docs/plans")).unwrap();
1561 std::fs::write(dir.join("a.ts"), "x").unwrap();
1562 std::fs::write(dir.join("amont.conf"), "scope = src\n").unwrap();
1563 std::fs::write(dir.join("docs/plans/p.md"), "v1").unwrap();
1564 std::fs::write(dir.join(".github/attest-inputs"), "test a.ts\n").unwrap();
1565 git(&dir, &["add", "-A"]);
1566 in_repo(&dir, || {
1567 record(&["test@abc"]);
1568 git(&dir, &["commit", "-qm", "feat: judged"]);
1569 bind_to_head();
1570 assert!(!inputs_ref_exists(), "the route must not open");
1571 let head = plan_only_commit(&dir);
1572 assert!(stamps_for(std::slice::from_ref(&head)).is_empty());
1573 });
1574 let _ = std::fs::remove_dir_all(&dir);
1575 }
1576
1577 #[test]
1578 fn a_gate_declaring_the_manifest_opens_the_route() {
1579 let dir = declared("manifest-declared", "test a.ts amont.conf\n");
1582 in_repo(&dir, || {
1583 std::fs::write(dir.join("amont.conf"), "scope = src\n").unwrap();
1584 git(&dir, &["add", "amont.conf"]);
1585 record(&["test@abc"]);
1586 git(&dir, &["commit", "-qm", "chore: config"]);
1587 bind_to_head();
1588 assert!(inputs_ref_exists());
1589 });
1590 let _ = std::fs::remove_dir_all(&dir);
1591 }
1592
1593 #[test]
1594 fn an_undeclared_gate_keeps_whole_tree_behaviour() {
1595 let dir = declared("undeclared", "test a.ts\n");
1596 in_repo(&dir, || {
1597 let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1600 assert!(stamp_tree(&tree, &["lint@zzz".to_string()]));
1601 let head = plan_only_commit(&dir);
1602 let tokens = stamps_for(std::slice::from_ref(&head))
1603 .remove(&head)
1604 .unwrap_or_default();
1605 assert!(tokens.contains(&"test@abc".to_string()));
1606 assert!(!tokens.iter().any(|t| t.starts_with("lint@")));
1607 });
1608 let _ = std::fs::remove_dir_all(&dir);
1609 }
1610
1611 #[test]
1612 fn an_invalid_or_ambiguous_spec_falls_back_to_whole_tree_behaviour() {
1613 let dir = declared("invalid", "test src/*\n");
1615 in_repo(&dir, || {
1616 assert!(!inputs_ref_exists(), "no valid spec, nothing is filed");
1617 let head = plan_only_commit(&dir);
1618 assert!(stamps_for(std::slice::from_ref(&head)).is_empty());
1619 });
1620 let _ = std::fs::remove_dir_all(&dir);
1621
1622 let dir = repo("ambiguous");
1624 std::fs::create_dir_all(dir.join(".github")).unwrap();
1625 std::fs::create_dir_all(dir.join(".forgejo")).unwrap();
1626 std::fs::create_dir_all(dir.join("docs/plans")).unwrap();
1627 std::fs::write(dir.join("a.ts"), "x").unwrap();
1628 std::fs::write(dir.join("docs/plans/p.md"), "v1").unwrap();
1629 std::fs::write(dir.join(".github/attest-inputs"), "test a.ts\n").unwrap();
1630 std::fs::write(dir.join(".forgejo/attest-inputs"), "test a.ts\n").unwrap();
1631 git(&dir, &["add", "-A"]);
1632 in_repo(&dir, || {
1633 record(&["test@abc"]);
1634 git(&dir, &["commit", "-qm", "feat: judged"]);
1635 bind_to_head();
1636 assert!(!inputs_ref_exists());
1637 let head = plan_only_commit(&dir);
1638 assert!(stamps_for(std::slice::from_ref(&head)).is_empty());
1639 });
1640 let _ = std::fs::remove_dir_all(&dir);
1641 }
1642
1643 #[test]
1644 fn a_gate_that_did_not_pass_leaves_no_declared_input_stamp() {
1645 let dir = repo("failed");
1646 std::fs::create_dir_all(dir.join(".github")).unwrap();
1647 std::fs::write(dir.join("a.ts"), "x").unwrap();
1648 std::fs::write(dir.join(".github/attest-inputs"), "test a.ts\n").unwrap();
1649 git(&dir, &["add", "-A"]);
1650 in_repo(&dir, || {
1651 record(&[]);
1653 git(&dir, &["commit", "-qm", "feat: unjudged"]);
1654 assert!(bind_to_head().is_empty());
1655 let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1656 let head = git(&dir, &["rev-parse", "HEAD"]);
1657 assert!(!stamp_push(&head, &tree, &[]));
1658 assert!(!inputs_ref_exists());
1659 });
1660 let _ = std::fs::remove_dir_all(&dir);
1661 }
1662
1663 #[test]
1664 fn stamp_tree_and_tree_tokens_use_the_inputs_ref() {
1665 let dir = declared("rehearsal", "test a.ts\n");
1666 in_repo(&dir, || {
1667 let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1669 assert!(stamp_tree(&tree, &["test@rehearsed".to_string()]));
1670 plan_only_commit(&dir);
1672 let later = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1673 assert_ne!(later, tree);
1674 assert!(tree_tokens(&later).contains(&"test@rehearsed".to_string()));
1675 });
1676 let _ = std::fs::remove_dir_all(&dir);
1677 }
1678
1679 #[test]
1680 fn the_inputs_ref_is_keyed_the_way_attest_keys_its_own() {
1681 let dir = declared("key", "test a.ts\n");
1682 in_repo(&dir, || {
1683 let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1684 let spec = crate::attest_inputs::spec_at(&tree).expect("spec");
1685 let fp = crate::attest_inputs::fingerprint(&tree, &spec[0].1).expect("fp");
1686 let key = crate::attest_inputs::input_key("test", &fp).expect("key");
1687 let listed = git(&dir, &["notes", "--ref", INPUTS_REF, "list"]);
1688 assert!(
1689 listed
1690 .lines()
1691 .any(|l| l.split_whitespace().nth(1) == Some(key.as_str())),
1692 "{listed}"
1693 );
1694 });
1695 let _ = std::fs::remove_dir_all(&dir);
1696 }
1697
1698 #[test]
1699 fn a_foreign_token_in_the_inputs_ref_vouches_for_nothing() {
1700 let dir = declared("foreign", "test a.ts\n");
1701 in_repo(&dir, || {
1702 let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1703 let spec = crate::attest_inputs::spec_at(&tree).expect("spec");
1704 let fp = crate::attest_inputs::fingerprint(&tree, &spec[0].1).expect("fp");
1705 let key = crate::attest_inputs::input_key("test", &fp).expect("key");
1706 let mut note = note_in(INPUTS_REF, &key);
1708 note.add_tokens(&["publish@evil".to_string()]);
1709 assert!(write_note_in(INPUTS_REF, &key, ¬e));
1710 assert!(!tree_tokens(&tree).contains(&"publish@evil".to_string()));
1711 });
1712 let _ = std::fs::remove_dir_all(&dir);
1713 }
1714
1715 #[test]
1716 fn forget_removes_the_inputs_ref_too() {
1717 let dir = declared("forget-inputs", "test a.ts\n");
1718 in_repo(&dir, || {
1719 assert!(inputs_ref_exists());
1720 assert!(forget());
1721 assert!(!inputs_ref_exists());
1722 });
1723 let _ = std::fs::remove_dir_all(&dir);
1724 }
1725
1726 fn canonical(name: &str, conf: Option<&str>) -> PathBuf {
1729 let dir = repo(name);
1730 std::fs::create_dir_all(dir.join("docs/plans")).unwrap();
1731 std::fs::write(dir.join("a.ts"), "x").unwrap();
1732 std::fs::write(dir.join("docs/plans/p.md"), "plan v1").unwrap();
1733 if let Some(conf) = conf {
1734 std::fs::write(dir.join("amont.conf"), conf).unwrap();
1735 }
1736 git(&dir, &["add", "-A"]);
1737 in_repo(&dir, || {
1738 record(&["test@abc"]);
1739 git(&dir, &["commit", "-qm", "feat: judged"]);
1740 assert_eq!(bind_to_head(), vec!["test".to_string()]);
1741 });
1742 dir
1743 }
1744
1745 fn vouched(dir: &Path, commit: &str) -> Vec<String> {
1746 in_repo(dir, || {
1747 stamps_for(std::slice::from_ref(&commit.to_string()))
1748 .remove(commit)
1749 .unwrap_or_default()
1750 })
1751 }
1752
1753 #[test]
1754 fn with_no_spec_a_plans_only_commit_is_vouched_for_canonically() {
1755 let dir = canonical("canon-plan-edit", None);
1756 in_repo(&dir, || {
1757 assert!(inputs_ref_exists(), "the canonical stamp is filed");
1758 let head = plan_only_commit(&dir);
1759 assert_eq!(
1760 stamps_for(std::slice::from_ref(&head))
1761 .remove(&head)
1762 .unwrap_or_default(),
1763 vec!["test@abc".to_string()]
1764 );
1765 std::fs::write(dir.join("docs/plans/p.md"), "plan v3").unwrap();
1767 git(&dir, &["add", "docs/plans/p.md"]);
1768 assert!(vouched_for_staged_tree().contains("test@abc"));
1769 });
1770 let _ = std::fs::remove_dir_all(&dir);
1771 }
1772
1773 #[test]
1774 fn a_plan_added_deleted_or_renamed_is_not_vouched_for() {
1775 let dir = canonical("canon-plan-exists", None);
1776 std::fs::write(dir.join("docs/plans/q.md"), "second plan").unwrap();
1777 git(&dir, &["add", "docs/plans/q.md"]);
1778 git(&dir, &["commit", "-qm", "docs(plan): add"]);
1779 let added = git(&dir, &["rev-parse", "HEAD"]);
1780 assert!(vouched(&dir, &added).is_empty(), "a plan added");
1781 git(&dir, &["mv", "docs/plans/p.md", "docs/plans/r.md"]);
1782 git(&dir, &["commit", "-qm", "docs(plan): rename"]);
1783 let renamed = git(&dir, &["rev-parse", "HEAD"]);
1784 assert!(vouched(&dir, &renamed).is_empty(), "a plan renamed");
1785 let _ = std::fs::remove_dir_all(&dir);
1786 }
1787
1788 #[test]
1789 fn with_no_spec_any_other_change_is_not_vouched_for() {
1790 let dir = canonical("canon-code-edit", Some("gate test = make test\n"));
1791 std::fs::write(dir.join("a.ts"), "changed").unwrap();
1792 git(&dir, &["commit", "-qam", "feat: code"]);
1793 let code = git(&dir, &["rev-parse", "HEAD"]);
1794 assert!(vouched(&dir, &code).is_empty(), "a source edit");
1795 std::fs::write(dir.join("amont.conf"), "gate test = make other\n").unwrap();
1797 git(&dir, &["commit", "-qam", "chore: config"]);
1798 let conf = git(&dir, &["rev-parse", "HEAD"]);
1799 assert!(vouched(&dir, &conf).is_empty(), "a manifest edit");
1800 let _ = std::fs::remove_dir_all(&dir);
1801 }
1802
1803 #[test]
1804 fn plan_exempt_off_closes_the_canonical_route() {
1805 for (name, conf) in [
1806 ("off", "set planExempt off\n"),
1807 ("spelled", "SET PLANEXEMPT\tOff\r\n"),
1808 ("after", "set planExempt on\nset planExempt off\n"),
1809 ] {
1810 let dir = canonical(&format!("canon-off-{name}"), Some(conf));
1811 in_repo(&dir, || {
1812 assert!(!inputs_ref_exists(), "{name}: nothing is filed");
1813 let head = plan_only_commit(&dir);
1814 assert!(
1815 stamps_for(std::slice::from_ref(&head)).is_empty(),
1816 "{name}: a plans-only commit is a new tree"
1817 );
1818 });
1819 let _ = std::fs::remove_dir_all(&dir);
1820 }
1821 for (name, conf) in [
1823 ("on", "set planExempt on\n"),
1824 ("comment", "set planExempt off # no\n"),
1825 ("commented", "# set planExempt off\n"),
1826 ] {
1827 let dir = canonical(&format!("canon-on-{name}"), Some(conf));
1828 let head = plan_only_commit(&dir);
1829 assert!(!vouched(&dir, &head).is_empty(), "{name}: still canonical");
1830 let _ = std::fs::remove_dir_all(&dir);
1831 }
1832 }
1833
1834 #[test]
1835 fn a_spec_that_cannot_be_used_means_no_canonical_stamps() {
1836 let dir = repo("canon-unreadable-spec");
1838 std::fs::create_dir_all(dir.join(".github/attest-inputs")).unwrap();
1839 std::fs::create_dir_all(dir.join("docs/plans")).unwrap();
1840 std::fs::write(dir.join(".github/attest-inputs/x"), "x").unwrap();
1841 std::fs::write(dir.join("a.ts"), "x").unwrap();
1842 std::fs::write(dir.join("docs/plans/p.md"), "v1").unwrap();
1843 git(&dir, &["add", "-A"]);
1844 in_repo(&dir, || {
1845 record(&["test@abc"]);
1846 git(&dir, &["commit", "-qm", "feat: judged"]);
1847 bind_to_head();
1848 assert!(!inputs_ref_exists(), "an unreadable spec opens nothing");
1849 let head = plan_only_commit(&dir);
1850 assert!(stamps_for(std::slice::from_ref(&head)).is_empty());
1851 });
1852 let _ = std::fs::remove_dir_all(&dir);
1853 }
1854
1855 #[test]
1856 fn two_specs_mean_no_canonical_stamps() {
1857 let dir = repo("canon-ambiguous");
1858 std::fs::create_dir_all(dir.join(".github")).unwrap();
1859 std::fs::create_dir_all(dir.join(".forgejo")).unwrap();
1860 std::fs::create_dir_all(dir.join("docs/plans")).unwrap();
1861 std::fs::write(dir.join("a.ts"), "x").unwrap();
1862 std::fs::write(dir.join("docs/plans/p.md"), "v1").unwrap();
1863 std::fs::write(dir.join(".github/attest-inputs"), "test a.ts\n").unwrap();
1864 std::fs::write(dir.join(".forgejo/attest-inputs"), "test a.ts\n").unwrap();
1865 git(&dir, &["add", "-A"]);
1866 in_repo(&dir, || {
1867 record(&["test@abc"]);
1868 git(&dir, &["commit", "-qm", "feat: judged"]);
1869 bind_to_head();
1870 assert!(!inputs_ref_exists());
1871 let head = plan_only_commit(&dir);
1872 assert!(stamps_for(std::slice::from_ref(&head)).is_empty());
1873 });
1874 let _ = std::fs::remove_dir_all(&dir);
1875 }
1876
1877 #[test]
1878 fn a_valid_spec_keeps_precedence_over_the_canonical_route() {
1879 let dir = declared("canon-precedence", "test a.ts\n");
1881 in_repo(&dir, || {
1882 let tree = git(&dir, &["rev-parse", "HEAD^{tree}"]);
1883 assert!(stamp_tree(&tree, &["lint@zzz".to_string()]));
1884 let head = plan_only_commit(&dir);
1885 let tokens = stamps_for(std::slice::from_ref(&head))
1886 .remove(&head)
1887 .unwrap_or_default();
1888 assert!(tokens.contains(&"test@abc".to_string()));
1889 assert!(!tokens.iter().any(|t| t.starts_with("lint@")));
1890 });
1891 let _ = std::fs::remove_dir_all(&dir);
1892 }
1893}