1use std::{
4 collections::BTreeSet,
5 fs,
6 path::{Path, PathBuf},
7 process::{Command, ExitCode},
8};
9
10use anyhow::{Context, Result, bail};
11
12use crate::{
13 claim, cli,
14 ledger::{LedgerEntry, LedgerStore},
15};
16
17pub fn run(args: cli::GateArgs, state_dir: &Path) -> Result<ExitCode> {
18 if let Some(range) = args.pre_push {
19 let commits = commits_for_range(&range)?;
20 match pre_push_decision(&LedgerStore::new(state_dir), &commits)? {
21 PushGateDecision::Allow => return Ok(ExitCode::SUCCESS),
22 PushGateDecision::Block(entries) => {
23 bail!(
24 "pre-push blocked unresolved rejection(s): {}",
25 rejection_summary(&entries)
26 );
27 }
28 }
29 }
30
31 let commit_msg_path = args
32 .commit_msg
33 .as_ref()
34 .context("gate requires --commit-msg or --pre-push")?;
35 let commit_message = fs::read_to_string(commit_msg_path).with_context(|| {
36 format!(
37 "failed to read commit message {}",
38 commit_msg_path.display()
39 )
40 })?;
41
42 let claim_file = read_optional_file(args.claim_file.as_ref(), "claim file")?;
43 let diff_file = read_optional_file(args.diff_file.as_ref(), "diff file")?;
44
45 claim::evaluate_commit_message(
46 &commit_message,
47 claim_file.as_deref(),
48 diff_file.as_deref(),
49 &args.fake_markers,
50 )?;
51
52 Ok(ExitCode::SUCCESS)
53}
54
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub enum PushGateDecision {
57 Allow,
58 Block(Vec<LedgerEntry>),
59}
60
61pub fn pre_push_decision(store: &LedgerStore, commits: &[String]) -> Result<PushGateDecision> {
62 let commit_set = commits.iter().map(String::as_str).collect::<BTreeSet<_>>();
63 let blocked = store
64 .unresolved_rejections()?
65 .into_iter()
66 .filter(|entry| commit_set.contains(entry.commit_sha.as_str()))
67 .collect::<Vec<_>>();
68
69 if blocked.is_empty() {
70 Ok(PushGateDecision::Allow)
71 } else {
72 Ok(PushGateDecision::Block(blocked))
73 }
74}
75
76fn commits_for_range(range: &str) -> Result<Vec<String>> {
77 if range == "all" {
78 return Ok(Vec::new());
79 }
80
81 if !range.contains("..") {
82 return Ok(vec![range.to_owned()]);
83 }
84
85 let output = Command::new("git")
86 .args(["rev-list", range])
87 .output()
88 .context("failed to run git rev-list for pre-push range")?;
89 if !output.status.success() {
90 bail!(
91 "git rev-list failed for pre-push range {range}: {}",
92 String::from_utf8_lossy(&output.stderr)
93 );
94 }
95
96 Ok(String::from_utf8_lossy(&output.stdout)
97 .lines()
98 .map(str::trim)
99 .filter(|line| !line.is_empty())
100 .map(str::to_owned)
101 .collect())
102}
103
104fn rejection_summary(entries: &[LedgerEntry]) -> String {
105 entries
106 .iter()
107 .map(|entry| format!("{} {}", entry.commit_sha, entry.claim))
108 .collect::<Vec<_>>()
109 .join("; ")
110}
111
112fn read_optional_file(path: Option<&PathBuf>, label: &str) -> Result<Option<String>> {
113 path.map(|path| {
114 fs::read_to_string(path)
115 .with_context(|| format!("failed to read {label} {}", path.display()))
116 })
117 .transpose()
118}
119
120#[cfg(test)]
121mod tests {
122 use proptest::prelude::*;
123
124 use crate::ledger::{LedgerEntry, LedgerStore, ReviewerConfig, Verdict};
125
126 use super::{PushGateDecision, pre_push_decision};
127
128 fn reject_entry(sha: &str) -> LedgerEntry {
129 LedgerEntry::new_at(
130 sha,
131 Verdict::Reject,
132 "CLAIM: bad | verified: cargo test | evidence: tests:cargo-test",
133 vec!["tests:cargo-test".to_owned()],
134 ReviewerConfig::new("claude", "opus", false),
135 vec!["unsupported".to_owned()],
136 100,
137 )
138 }
139
140 #[test]
141 fn pre_push_blocks_unresolved_rejection_in_range() {
142 let temp = tempfile::tempdir().unwrap();
143 let store = LedgerStore::new(temp.path());
144 store.append_entry(&reject_entry("abc123")).unwrap();
145
146 let decision = pre_push_decision(&store, &["abc123".to_owned()]).unwrap();
147
148 assert!(matches!(decision, PushGateDecision::Block(_)));
149 }
150
151 #[test]
152 fn pre_push_allows_after_resolve_or_waive() {
153 let temp = tempfile::tempdir().unwrap();
154 let store = LedgerStore::new(temp.path());
155 store.append_entry(&reject_entry("abc123")).unwrap();
156 store.resolve("abc123").unwrap();
157
158 let decision = pre_push_decision(&store, &["abc123".to_owned()]).unwrap();
159
160 assert_eq!(decision, PushGateDecision::Allow);
161 }
162
163 proptest! {
164 #[test]
165 fn pushed_range_decision_blocks_only_matching_unresolved_sha(
166 rejected_sha in "[a-f0-9]{7,40}",
167 other_sha in "[a-f0-9]{7,40}",
168 ) {
169 prop_assume!(rejected_sha != other_sha);
170 let temp = tempfile::tempdir().unwrap();
171 let store = LedgerStore::new(temp.path());
172 store.append_entry(&reject_entry(&rejected_sha)).unwrap();
173
174 let unrelated = pre_push_decision(&store, std::slice::from_ref(&other_sha)).unwrap();
175 prop_assert_eq!(unrelated, PushGateDecision::Allow);
176
177 let blocked = pre_push_decision(&store, std::slice::from_ref(&rejected_sha)).unwrap();
178 prop_assert!(matches!(blocked, PushGateDecision::Block(_)));
179 }
180 }
181}