Skip to main content

truth_mirror/
gate.rs

1//! Deterministic pre-push and repository safety gates.
2
3use 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}