use crate::assertions::{read_briefly, Assertion, Claim, Verdict};
use crate::rules::{Context, Evidence, Stance, Trend};
use crate::shell::{Parsed, Simple};
pub const ASSERTION: Assertion = Assertion {
id: "push-landed",
default_stance: Stance::Observe,
evidence: Evidence {
per_1000: 38.7,
measured: "2026-09-05",
trend: Trend::Routine,
},
examine,
verify,
};
struct Push<'a> {
remote: Option<&'a str>,
branch: Option<&'a str>,
at: usize,
end: usize,
}
const REWRITES_THE_QUESTION: &[&str] = &[
"--delete",
"--mirror",
"--all",
"--tags",
"--follow-tags",
"--prune",
];
fn read_push(parsed: &Parsed) -> Option<Push<'_>> {
if !parsed.fully_read() {
return None;
}
let Parsed::Clear(clauses) = parsed else {
return None;
};
let cmd: &Simple = clauses
.iter()
.find(|c| c.program() == Some("git") && c.subcommand() == Some("push"))?;
if cmd.is_dry_run() || cmd.has_short('n') {
return None;
}
if REWRITES_THE_QUESTION.iter().any(|f| cmd.has_flag(f)) {
return None;
}
let operands: Vec<&str> = cmd
.operands()
.iter()
.map(|w| w.text.as_str())
.filter(|t| *t != "push")
.collect();
let (remote, branch) = match operands.as_slice() {
[] => (None, None),
[remote] => (Some(*remote), None),
[remote, refspec] => {
if refspec.contains(':') || refspec.starts_with('+') {
return None;
}
(Some(*remote), Some(*refspec))
}
_ => return None,
};
Some(Push {
remote,
branch,
at: cmd.at,
end: cmd.end,
})
}
fn examine(parsed: &Parsed) -> Option<Claim> {
let push = read_push(parsed)?;
Some(Claim {
what: "push".to_string(),
span: push.at..push.end,
})
}
fn verify(ctx: &Context, _claim: &Claim) -> Verdict {
let Some(push) = read_push(ctx.parsed) else {
return Verdict::Unknown("the push could not be read a second time");
};
let dir = ctx.cwd_at(push.at);
if !dir.is_dir() {
return Verdict::Unknown("the working directory is gone");
}
let branch = match push.branch {
Some(b) => b.to_string(),
None => match read_briefly(&dir, &["symbolic-ref", "--quiet", "--short", "HEAD"]) {
Some(b) if !b.is_empty() => b,
_ => return Verdict::Unknown("HEAD is detached, so no branch was claimed"),
},
};
let Some(local) = read_briefly(
&dir,
&["rev-parse", "--verify", &format!("refs/heads/{branch}")],
) else {
return Verdict::Unknown("the pushed ref is not a local branch");
};
let remote = match push.remote {
Some(r) => r.to_string(),
None => [
format!("branch.{branch}.pushRemote"),
"remote.pushDefault".to_string(),
format!("branch.{branch}.remote"),
]
.iter()
.find_map(|key| read_briefly(&dir, &["config", "--get", key]).filter(|r| !r.is_empty()))
.unwrap_or_else(|| "origin".to_string()),
};
if remote.contains('/') || remote.contains(':') {
return Verdict::Unknown("the push named a URL rather than a configured remote");
}
let refname = format!("refs/heads/{branch}");
let Some(answer) = read_briefly(&dir, &["ls-remote", "--", &remote, &refname]) else {
return Verdict::Unknown("the remote could not be reached without prompting");
};
let remote_sha = answer.split_whitespace().next().unwrap_or_default();
if remote_sha == local {
return Verdict::Held;
}
if remote_sha.is_empty() {
return Verdict::Broken {
reason: format!(
"`git push` exited 0, but {remote} has no {refname}. The branch is not on the remote."
),
remedy: format!(
"Check `git ls-remote {remote} {refname}` before reporting this pushed."
),
};
}
Verdict::Broken {
reason: format!(
"`git push` exited 0, but {remote}/{branch} is at {} while the local branch is at {}. The push did not land.",
short(remote_sha),
short(&local)
),
remedy: "Run the push again on its own and read its output, then confirm with `git ls-remote`.".to_string(),
}
}
fn short(sha: &str) -> String {
sha.chars().take(9).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shell::lex;
fn claims(command: &str) -> bool {
examine(&lex(command)).is_some()
}
#[test]
fn a_plain_push_is_a_claim() {
assert!(claims("git push"));
assert!(claims("git push origin"));
assert!(claims("git push origin main"));
assert!(claims("git push -u origin feat/x"));
}
#[test]
fn the_ambiguous_shapes_are_not_claims() {
assert!(!claims("git push origin HEAD:refs/heads/other"));
assert!(!claims("git push origin +main"));
assert!(!claims("git push origin main next"));
assert!(!claims("git push --delete origin old"));
assert!(!claims("git push --mirror backup"));
assert!(!claims("git push --tags"));
assert!(!claims("git push --dry-run origin main"));
assert!(!claims("git push -n origin main"));
}
#[test]
fn other_commands_are_not_claims() {
assert!(!claims("git pull"));
assert!(!claims("git commit -m push"));
assert!(!claims("echo git push"));
}
#[test]
fn the_span_covers_the_push_clause() {
let command = "cargo build --release && git push origin main";
let claim = examine(&lex(command)).expect("a claim");
assert_eq!(command[claim.span.clone()].trim(), "git push origin main");
}
}