use std::{
collections::BTreeSet,
fs,
path::{Path, PathBuf},
process::{Command, ExitCode},
};
use anyhow::{Context, Result, bail};
use crate::{
claim, cli,
ledger::{LedgerEntry, LedgerStore},
};
pub fn run(args: cli::GateArgs, state_dir: &Path) -> Result<ExitCode> {
if let Some(range) = args.pre_push {
let commits = commits_for_range(&range)?;
match pre_push_decision(&LedgerStore::new(state_dir), &commits)? {
PushGateDecision::Allow => return Ok(ExitCode::SUCCESS),
PushGateDecision::Block(entries) => {
bail!(
"pre-push blocked unresolved rejection(s): {}",
rejection_summary(&entries)
);
}
}
}
let commit_msg_path = args
.commit_msg
.as_ref()
.context("gate requires --commit-msg or --pre-push")?;
let commit_message = fs::read_to_string(commit_msg_path).with_context(|| {
format!(
"failed to read commit message {}",
commit_msg_path.display()
)
})?;
let claim_file = read_optional_file(args.claim_file.as_ref(), "claim file")?;
let diff_file = read_optional_file(args.diff_file.as_ref(), "diff file")?;
claim::evaluate_commit_message(
&commit_message,
claim_file.as_deref(),
diff_file.as_deref(),
&args.fake_markers,
)?;
Ok(ExitCode::SUCCESS)
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PushGateDecision {
Allow,
Block(Vec<LedgerEntry>),
}
pub fn pre_push_decision(store: &LedgerStore, commits: &[String]) -> Result<PushGateDecision> {
let commit_set = commits.iter().map(String::as_str).collect::<BTreeSet<_>>();
let blocked = store
.unresolved_rejections()?
.into_iter()
.filter(|entry| commit_set.contains(entry.commit_sha.as_str()))
.collect::<Vec<_>>();
if blocked.is_empty() {
Ok(PushGateDecision::Allow)
} else {
Ok(PushGateDecision::Block(blocked))
}
}
fn commits_for_range(range: &str) -> Result<Vec<String>> {
if range == "all" {
return Ok(Vec::new());
}
if !range.contains("..") {
return Ok(vec![range.to_owned()]);
}
let output = Command::new("git")
.args(["rev-list", range])
.output()
.context("failed to run git rev-list for pre-push range")?;
if !output.status.success() {
bail!(
"git rev-list failed for pre-push range {range}: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_owned)
.collect())
}
fn rejection_summary(entries: &[LedgerEntry]) -> String {
entries
.iter()
.map(|entry| format!("{} {}", entry.commit_sha, entry.claim))
.collect::<Vec<_>>()
.join("; ")
}
fn read_optional_file(path: Option<&PathBuf>, label: &str) -> Result<Option<String>> {
path.map(|path| {
fs::read_to_string(path)
.with_context(|| format!("failed to read {label} {}", path.display()))
})
.transpose()
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use crate::ledger::{LedgerEntry, LedgerStore, ReviewerConfig, Verdict};
use super::{PushGateDecision, pre_push_decision};
fn reject_entry(sha: &str) -> LedgerEntry {
LedgerEntry::new_at(
sha,
Verdict::Reject,
"CLAIM: bad | verified: cargo test | evidence: tests:cargo-test",
vec!["tests:cargo-test".to_owned()],
ReviewerConfig::new("claude", "opus", false),
vec!["unsupported".to_owned()],
100,
)
}
#[test]
fn pre_push_blocks_unresolved_rejection_in_range() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&reject_entry("abc123")).unwrap();
let decision = pre_push_decision(&store, &["abc123".to_owned()]).unwrap();
assert!(matches!(decision, PushGateDecision::Block(_)));
}
#[test]
fn pre_push_allows_after_resolve_or_waive() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&reject_entry("abc123")).unwrap();
store.resolve("abc123").unwrap();
let decision = pre_push_decision(&store, &["abc123".to_owned()]).unwrap();
assert_eq!(decision, PushGateDecision::Allow);
}
proptest! {
#[test]
fn pushed_range_decision_blocks_only_matching_unresolved_sha(
rejected_sha in "[a-f0-9]{7,40}",
other_sha in "[a-f0-9]{7,40}",
) {
prop_assume!(rejected_sha != other_sha);
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&reject_entry(&rejected_sha)).unwrap();
let unrelated = pre_push_decision(&store, std::slice::from_ref(&other_sha)).unwrap();
prop_assert_eq!(unrelated, PushGateDecision::Allow);
let blocked = pre_push_decision(&store, std::slice::from_ref(&rejected_sha)).unwrap();
prop_assert!(matches!(blocked, PushGateDecision::Block(_)));
}
}
}