use crate::rules::{Confirmed, Context, Evidence, Examine, Finding, Rule, Stance, Trend};
use crate::shell::Parsed;
pub const RULE: Rule = Rule {
id: "unbounded-background-push",
default_stance: Stance::Advise,
max_stance: Stance::Deny,
evidence: Evidence {
per_1000: 0.0,
measured: "2026-10-08",
trend: Trend::Rare,
},
examine: Examine::Legacy(examine),
confirm: Some(confirm),
};
fn examine(parsed: &Parsed) -> Option<Finding> {
let cmd = crate::push_target::find(parsed)?;
if cmd.is_dry_run() || cmd.has_short('n') || cmd.wrapped_by("timeout") {
return None;
}
let to_a_file = cmd
.redirects
.iter()
.any(|(op, target)| op.contains('>') && !target.raw.is_empty());
if !to_a_file {
return None;
}
Some(Finding {
reason: "a push in the background has no deadline: the pre-push gate runs inside it \
(a test suite, a lint pass), no clock ends it, and nothing says it is still \
going — a seeding push held a session for forty minutes this way."
.to_string(),
remedy: "Bound it with `timeout <seconds> git push <remote> <ref>` (push-preview and \
implementation-review read through `timeout`); rehearse first with \
`amont rehearse --wait` so the push itself skips the suite."
.to_string(),
span: cmd.at..cmd.end,
})
}
fn confirm(ctx: &Context, _f: &Finding) -> Confirmed {
if !ctx.background {
return Confirmed::No("in the foreground the tool's own timeout bounds the push");
}
Confirmed::Yes
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shell::lex;
fn fires(command: &str) -> bool {
examine(&lex(command)).is_some()
}
#[test]
fn an_unwrapped_push_to_a_log_is_examined() {
assert!(fires("git push origin dev > /tmp/p.log 2>&1"));
assert!(fires(
"cd repo && git push -u origin feat/x > /tmp/p.log 2>&1"
));
assert!(fires("nice git push origin dev >> push.log"));
}
#[test]
fn a_push_whose_output_is_not_kept_is_not() {
assert!(!fires("git push origin main"));
assert!(!fires("git push origin main 2>&1"));
}
#[test]
fn a_timeout_or_a_dry_run_is_not() {
assert!(!fires("timeout 1800 git push origin dev > p.log 2>&1"));
assert!(!fires("timeout -k 30 1800 git push origin dev"));
assert!(!fires("timeout --signal=TERM 900 git push origin dev"));
assert!(!fires("git push --dry-run origin dev > p.log"));
assert!(!fires("git push -n origin dev > p.log"));
assert!(!fires("git status"));
}
#[test]
fn timeout_as_an_argument_is_not_a_wrapper() {
assert!(fires("git push origin timeout > p.log"));
}
}