use crate::rules::{Confirmed, Context, Evidence, Finding, Rule, Stance, Trend};
use crate::shell::{Parsed, Simple};
pub const RULE: Rule = Rule {
id: "push-preflight",
default_stance: Stance::Advise,
evidence: Evidence {
per_1000: 0.0,
measured: "2026-09-04",
trend: Trend::Rare,
},
examine,
confirm: Some(confirm),
};
fn detect(parsed: &Parsed) -> Option<&Simple> {
parsed.clauses().iter().find(|cmd| {
cmd.program() == Some("git")
&& cmd.subcommand() == Some("push")
&& !cmd.has_flag("--dry-run")
&& !cmd.has_short('n')
&& !cmd.has_flag("--no-verify")
&& !cmd.has_flag("--tags")
&& !cmd.has_flag("--delete")
&& !cmd.has_short('d')
&& !cmd.operands().iter().skip(1).any(|w| not_a_branch_push(&w.text))
})
}
fn not_a_branch_push(refspec: &str) -> bool {
let dst = refspec.rsplit(':').next().unwrap_or(refspec);
refspec.starts_with("refs/notes/")
|| refspec.starts_with("refs/tags/")
|| refspec.starts_with(':')
|| matches!(
dst,
"main" | "master" | "refs/heads/main" | "refs/heads/master"
)
}
fn examine(parsed: &Parsed) -> Option<Finding> {
let cmd = detect(parsed)?;
Some(Finding {
reason: "git opens its connection to the remote BEFORE running pre-push and \
holds it idle while the test gate runs; a remote that closes idle \
sessions (Forgejo's git timeout is 6 minutes) kills the push after \
the gate has already passed."
.to_string(),
remedy: "Rehearse first: `amont rehearse --wait` runs the same gate on a \
snapshot of HEAD with no connection open and stamps the tree — or \
follows the rehearsal a commit already started — so this push then \
skips the suite and holds the remote for seconds (`amont run \
pre-push` on amont 1.27). Run it, read its verdict, then push."
.to_string(),
span: cmd.at..cmd.end,
})
}
fn slow_gate_would_run(cwd: &std::path::Path) -> bool {
let listed = list_json(cwd, true).or_else(|| list_json(cwd, false));
let Some(json) = listed else { return false };
json.split("\"id\":\"").skip(1).any(|entry| {
let id = entry.split('"').next().unwrap_or_default();
let status = entry
.split("\"status\":\"")
.nth(1)
.and_then(|s| s.split('"').next())
.unwrap_or_default();
status == "runs" && (TEST_GATES.contains(&id) || !entry.contains("\"source\":\"builtin\""))
})
}
const TEST_GATES: &[&str] = &[
"pre-push-run-tests-js",
"pre-push-cargo-test",
"pre-push-go-test",
"pre-push-pytest",
];
fn list_json(cwd: &std::path::Path, pushed: bool) -> Option<String> {
let mut cmd = std::process::Command::new("amont");
cmd.args(["list", "--json", "--stage", "pre-push"]);
if pushed {
cmd.arg("--pushed");
}
let out = cmd
.current_dir(cwd)
.stdin(std::process::Stdio::null())
.output()
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).into_owned())
}
fn head_is_stamped(cwd: &std::path::Path) -> bool {
["HEAD^{tree}", "HEAD"].iter().any(|key| {
crate::git::stdout_in(cwd, &["notes", "--ref", "amont-gate", "show", key]).is_some_and(
|note| {
note.lines()
.next()
.unwrap_or_default()
.split_whitespace()
.any(|t| t.starts_with("pre-push-"))
},
)
})
}
fn confirm(ctx: &Context, f: &Finding) -> Confirmed {
if detect(ctx.parsed).is_none() {
return Confirmed::No("the command no longer matches");
}
let cwd = ctx.cwd_at(f.span.start);
let cwd = cwd.as_path();
if !cwd.is_dir() {
return Confirmed::No("the directory the command moves to does not exist");
}
let Some(hook) = crate::git::stdout_in(cwd, &["rev-parse", "--git-path", "hooks/pre-push"])
else {
return Confirmed::No("not a git repository");
};
let hook_path = if std::path::Path::new(&hook).is_absolute() {
std::path::PathBuf::from(&hook)
} else {
cwd.join(&hook)
};
let guarded = std::fs::read_to_string(&hook_path).is_ok_and(|s| s.contains("amont"));
if !guarded {
return Confirmed::No("amont does not guard this repository's pushes");
}
if head_is_stamped(cwd) {
return Confirmed::No("HEAD's tree already carries a push stamp");
}
if !slow_gate_would_run(cwd) {
return Confirmed::No("no test gate runs at push time here");
}
Confirmed::Yes
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shell::lex;
fn fires(command: &str) -> bool {
examine(&lex(command)).is_some()
}
#[test]
fn a_plain_push_has_the_shape() {
assert!(fires("git push"));
assert!(fires("git push -u origin feat/x"));
assert!(fires("cd ../repo && git push origin HEAD"));
}
#[test]
fn a_dry_run_sends_nothing() {
assert!(!fires("git push --dry-run origin main"));
assert!(!fires("git push -n"));
}
#[test]
fn a_push_that_skips_its_hooks_is_another_rules_business() {
assert!(!fires("git push --no-verify origin feat/x"));
}
#[test]
fn amonts_own_notes_push_is_not_judged() {
assert!(!fires(
"git push origin refs/notes/amont-attest:refs/notes/amont-attest"
));
}
#[test]
fn the_default_branch_and_tags_are_not_judged() {
assert!(!fires("git push origin main"));
assert!(!fires("git push origin HEAD:master"));
assert!(!fires("git push origin v2.2.0 --tags"));
assert!(!fires("git push origin refs/tags/v2.2.0"));
assert!(!fires("git push origin --delete feat/x"));
assert!(!fires("git push origin :feat/x"));
}
#[test]
fn other_git_verbs_are_silent() {
assert!(!fires("git pull"));
assert!(!fires("git fetch origin"));
assert!(!fires("git log --oneline -1"));
}
#[test]
fn the_span_is_the_push_clause() {
let parsed = lex("git status && git push origin feat/x");
let f = examine(&parsed).expect("fires");
assert_eq!(
"git status && git push origin feat/x"[f.span.clone()].trim(),
"git push origin feat/x"
);
}
}