use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
pub const FORMAT: &str = "amont-gate-v1";
pub const NOTES_REF: &str = "amont-gate";
pub const NOTES_FULL_REF: &str = "refs/notes/amont-gate";
const MARKER: &str = "amont-gate";
const PUSH_STAMPS: &str = "amont.pushStamps";
pub fn push_stamps_enabled(settings: &crate::config::Settings) -> bool {
crate::config::boolean_or(settings, PUSH_STAMPS, true)
}
const COMMIT_STAMPS: &str = "amont.commitStamps";
pub fn commit_stamps_enabled(settings: &crate::config::Settings) -> bool {
crate::config::boolean_or(settings, COMMIT_STAMPS, true)
}
const RUN: &str = "run";
const MAX_RUNS: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunOutcome {
Passed,
Failed,
Warned,
Fixed,
Unavailable,
Inert,
Cold,
Busy,
Skew,
Withheld,
Cancelled,
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",
}
}
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,
})
}
pub fn is_verdict(self) -> bool {
matches!(self, RunOutcome::Passed | RunOutcome::Failed)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Run {
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
)
}
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,
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Note {
pub tokens: Vec<String>,
pub runs: Vec<Run>,
}
impl Note {
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(),
}
}
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());
}
}
}
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);
}
}
}
fn note_at(key: &str) -> Note {
crate::git::stdout(&["notes", "--ref", NOTES_REF, "show", key])
.map(|body| Note::parse(&body))
.unwrap_or_default()
}
pub fn tree_tokens(tree: &str) -> Vec<String> {
note_at(tree).tokens
}
pub fn stamp_tree(tree: &str, tokens: &[String]) -> bool {
let mut note = note_at(tree);
note.add_tokens(tokens);
write_note(tree, ¬e)
}
fn write_note(key: &str, note: &Note) -> bool {
let body = note.render();
crate::git::succeeds(&["notes", "--ref", NOTES_REF, "add", "-f", "-m", &body, key])
}
pub fn record_runs(key: &str, runs: &[Run]) {
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, ¬e);
}
fn marker_path() -> Option<PathBuf> {
let dir = crate::git::stdout(&["rev-parse", "--git-dir"])?;
Some(std::path::Path::new(&dir).join(MARKER))
}
pub fn record(scripts: &[&str]) {
let Some(path) = marker_path() else { return };
if scripts.is_empty() {
let _ = std::fs::remove_file(&path);
return;
}
let Some(tree) = crate::git::stdout(&["write-tree"]) else {
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);
}
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));
}
}
}
out.extend(note_at(&tree).tokens);
out
}
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(); };
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();
};
if head_tree != tree {
return Vec::new();
}
let scripts: Vec<String> = scripts.iter().map(|s| s.to_string()).collect();
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) {
crate::hooks::common::warn(
"git refused to write the gate stamp — these checks will run again at push",
);
return Vec::new();
}
scripts
}
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, ¬e);
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
}
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 {
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();
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());
}
}
}
}
for commit in commits {
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
}
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
}
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;
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
}
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()
}
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()][..])
);
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"]);
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);
}
#[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);
}
#[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);
}
#[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);
}
#[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());
assert_eq!(note.render(), "amont-gate-v1 typecheck test");
}
#[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());
}
#[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);
}
#[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());
}
#[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,
}],
);
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");
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);
}
#[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"
);
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);
}
#[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"]);
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);
}
#[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()]);
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);
}
}