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 reviewer::ReviewQueue,
16};
17
18pub fn run(
19 args: cli::GateArgs,
20 state_dir: &Path,
21 config_path: Option<&Path>,
22 config: &crate::config::TruthMirrorConfig,
23) -> Result<ExitCode> {
24 if let Some(range) = args.pre_push {
25 warn_pending_reviews(state_dir, config_path);
26 let all_commits = range == "all";
27 let commits = commits_for_range(&range)?;
28 match pre_push_decision(&LedgerStore::new(state_dir), &commits, all_commits)? {
29 PushGateDecision::Allow => {
30 block_for_pending_memory_skill(state_dir, config, all_commits, &commits)?;
31 return Ok(ExitCode::SUCCESS);
32 }
33 PushGateDecision::Block(entries) => {
34 bail!(
35 "pre-push blocked unresolved rejection(s): {}",
36 rejection_summary(&entries)
37 );
38 }
39 }
40 }
41
42 if args.pre_tool_use {
43 return pre_tool_use_gate(args, state_dir, config);
44 }
45
46 let commit_msg_path = args
47 .commit_msg
48 .as_ref()
49 .context("gate requires --commit-msg, --pre-push, or --pre-tool-use")?;
50 let commit_message = fs::read_to_string(commit_msg_path).with_context(|| {
51 format!(
52 "failed to read commit message {}",
53 commit_msg_path.display()
54 )
55 })?;
56
57 let claim_file = read_optional_file(args.claim_file.as_ref(), "claim file")?;
58 let diff_file = read_optional_file(args.diff_file.as_ref(), "diff file")?;
59
60 let mut policy = config.gates.to_policy();
61 if !args.fake_markers.is_empty() {
62 for marker in &args.fake_markers {
64 if !policy
65 .fake_markers
66 .iter()
67 .any(|existing| existing == marker)
68 {
69 policy.fake_markers.push(marker.clone());
70 }
71 }
72 }
73
74 claim::evaluate_commit_message(
75 &commit_message,
76 claim_file.as_deref(),
77 diff_file.as_deref(),
78 &policy,
79 )?;
80
81 Ok(ExitCode::SUCCESS)
82}
83
84fn warn_pending_reviews(state_dir: &Path, config_path: Option<&Path>) {
85 let summary = match ReviewQueue::new(state_dir).summary() {
86 Ok(summary) => summary,
87 Err(error) => {
88 eprintln!(
89 "{} warning: could not read async review queue for advisory pre-push warning: {error}",
90 crate::messages::diagnostic_prefix()
91 );
92 return;
93 }
94 };
95 if summary.pending_count == 0 {
96 return;
97 }
98 let oldest = summary
99 .oldest_age_secs_at(crate::time::unix_now())
100 .map_or_else(|| "unknown age".to_owned(), |age| format!("{age}s old"));
101 eprintln!(
102 "{} warning: {} unreviewed claim(s) queued, oldest {oldest}. {}.",
103 crate::messages::diagnostic_prefix(),
104 summary.pending_count,
105 crate::messages::async_review_drain_hint_for_cli(config_path, state_dir)
106 );
107}
108
109fn pre_tool_use_gate(
110 args: cli::GateArgs,
111 state_dir: &Path,
112 config: &crate::config::TruthMirrorConfig,
113) -> Result<ExitCode> {
114 let unresolved = LedgerStore::new(state_dir).unresolved_rejections()?;
115 let count = u32::try_from(unresolved.len()).unwrap_or(u32::MAX);
116 let oldest_age = unresolved
117 .iter()
118 .map(|entry| entry.created_at_unix)
119 .min()
120 .map(|oldest| crate::time::unix_now().saturating_sub(oldest));
121
122 let (tool, mutating) = match args.tool {
126 Some(name) => {
127 let mutating = crate::enforcement::is_mutating_tool(
128 &name,
129 crate::enforcement::DEFAULT_MUTATING_TOOLS,
130 );
131 (name, mutating)
132 }
133 None => match resolve_tool_from_stdin() {
134 ResolvedTool::Named(name) => {
135 let mutating = crate::enforcement::is_mutating_tool(
136 &name,
137 crate::enforcement::DEFAULT_MUTATING_TOOLS,
138 );
139 (name, mutating)
140 }
141 ResolvedTool::Unknown => ("<unparseable-hook-payload>".to_owned(), true),
143 ResolvedTool::None => (String::new(), false),
145 },
146 };
147
148 match crate::enforcement::pre_tool_use_decision(
149 count,
150 oldest_age,
151 mutating,
152 &config.enforcement,
153 ) {
154 crate::enforcement::ToolGateDecision::Allow => Ok(ExitCode::SUCCESS),
155 crate::enforcement::ToolGateDecision::Block { reason } => {
156 eprintln!(
159 "{} blocked tool {tool:?}: {reason}. Resolve or waive the ledger to continue.",
160 crate::messages::diagnostic_prefix()
161 );
162 Ok(ExitCode::from(2))
163 }
164 }
165}
166
167enum ResolvedTool {
169 Named(String),
171 Unknown,
173 None,
175}
176
177fn resolve_tool_from_stdin() -> ResolvedTool {
178 use std::io::{IsTerminal, Read};
179 if std::io::stdin().is_terminal() {
180 return ResolvedTool::None;
181 }
182 let mut buffer = String::new();
183 if std::io::stdin().read_to_string(&mut buffer).is_err() {
185 return ResolvedTool::Unknown;
186 }
187 if buffer.trim().is_empty() {
188 return ResolvedTool::None;
189 }
190
191 match serde_json::from_str::<serde_json::Value>(&buffer)
192 .ok()
193 .and_then(|value| {
194 value
195 .get("tool_name")
196 .or_else(|| value.get("toolName"))
197 .or_else(|| value.get("tool"))
198 .and_then(|name| name.as_str())
199 .map(str::to_owned)
200 }) {
201 Some(name) if !name.trim().is_empty() => ResolvedTool::Named(name),
202 _ => ResolvedTool::Unknown,
204 }
205}
206
207#[derive(Clone, Debug, Eq, PartialEq)]
208pub enum PushGateDecision {
209 Allow,
210 Block(Vec<LedgerEntry>),
211}
212
213fn block_for_pending_memory_skill(
214 state_dir: &Path,
215 config: &crate::config::TruthMirrorConfig,
216 all_commits: bool,
217 commits: &[String],
218) -> Result<()> {
219 if !config.memory_skill.effective_enabled(&config.skills)
220 || !config.memory_skill.pre_push_blocks_pending
221 {
222 return Ok(());
223 }
224
225 let store = crate::memory_skill::MemorySkillStore::new(state_dir, &config.memory_skill)?;
226 let pending = pending_memory_skill_candidates(&store, all_commits, commits)?;
227 if pending.is_empty() {
228 return Ok(());
229 }
230
231 bail!(
232 "pre-push blocked pending memory-skill candidate(s): {}",
233 pending
234 .iter()
235 .map(|candidate| format!("{} {}", candidate.id, candidate.source_commit))
236 .collect::<Vec<_>>()
237 .join("; ")
238 );
239}
240
241fn pending_memory_skill_candidates(
242 store: &crate::memory_skill::MemorySkillStore,
243 all_commits: bool,
244 commits: &[String],
245) -> Result<Vec<crate::memory_skill::MemorySkillCandidate>> {
246 if all_commits {
247 return Ok(store.pending_candidates()?);
248 }
249
250 let commit_set = commits.iter().map(String::as_str).collect::<BTreeSet<_>>();
251 Ok(store.pending_for_commits(&commit_set)?)
252}
253
254pub fn pre_push_decision(
255 store: &LedgerStore,
256 commits: &[String],
257 all_commits: bool,
258) -> Result<PushGateDecision> {
259 let commit_set = commits.iter().map(String::as_str).collect::<BTreeSet<_>>();
260 let blocked = store
261 .unresolved_rejections()?
262 .into_iter()
263 .filter(|entry| all_commits || commit_set.contains(entry.commit_sha.as_str()))
264 .collect::<Vec<_>>();
265
266 if blocked.is_empty() {
267 Ok(PushGateDecision::Allow)
268 } else {
269 Ok(PushGateDecision::Block(blocked))
270 }
271}
272
273fn commits_for_range(range: &str) -> Result<Vec<String>> {
274 commits_for_range_in(range, Path::new("."))
275}
276
277fn commits_for_range_in(range: &str, cwd: &Path) -> Result<Vec<String>> {
278 if range == "all" {
279 return Ok(Vec::new());
280 }
281
282 if let Some(new_branch) = range.strip_prefix("new:") {
283 return match new_branch.split_once(':') {
284 Some((remote, local_sha)) => {
285 let remote = remote.trim();
286 let local_sha = local_sha.trim();
287 if remote.is_empty()
288 || remote
289 .chars()
290 .any(|character| matches!(character, '*' | '?' | '['))
291 || local_sha.is_empty()
292 || local_sha.contains(':')
293 || local_sha.starts_with('-')
294 {
295 bail!(
296 "malformed new-branch pre-push range {range}: expected new:<remote>:<sha>"
297 );
298 }
299 git_rev_list_for_new_branch_on_remote(cwd, remote, local_sha, range)
300 }
301 _ => git_rev_list(cwd, &[new_branch, "--not", "--remotes"], range),
302 };
303 }
304
305 if !range.contains("..") {
306 return Ok(vec![range.to_owned()]);
307 }
308
309 git_rev_list(cwd, &[range], range)
310}
311
312fn git_rev_list(cwd: &Path, args: &[&str], label: &str) -> Result<Vec<String>> {
313 let output = Command::new("git")
314 .arg("rev-list")
315 .args(args)
316 .current_dir(cwd)
317 .output()
318 .context("failed to run git rev-list for pre-push range")?;
319 if !output.status.success() {
320 bail!(
321 "git rev-list failed for pre-push range {label}: {}",
322 String::from_utf8_lossy(&output.stderr)
323 );
324 }
325
326 Ok(String::from_utf8_lossy(&output.stdout)
327 .lines()
328 .map(str::trim)
329 .filter(|line| !line.is_empty())
330 .map(str::to_owned)
331 .collect())
332}
333
334fn git_rev_list_for_new_branch_on_remote(
335 cwd: &Path,
336 remote: &str,
337 local_sha: &str,
338 label: &str,
339) -> Result<Vec<String>> {
340 let remote_refs = remote_tracking_refs(cwd, remote)?;
341 let mut args = vec![local_sha.to_owned()];
342 if !remote_refs.is_empty() {
343 args.push("--not".to_owned());
344 args.extend(remote_refs);
345 }
346 let args = args.iter().map(String::as_str).collect::<Vec<_>>();
347 git_rev_list(cwd, &args, label)
348}
349
350fn remote_tracking_refs(cwd: &Path, remote: &str) -> Result<Vec<String>> {
351 let prefix = format!("refs/remotes/{remote}/");
352 let output = Command::new("git")
353 .args(["for-each-ref", "--format=%(refname)", &prefix])
354 .current_dir(cwd)
355 .output()
356 .context("failed to list remote refs for pre-push range")?;
357 if !output.status.success() {
358 bail!(
359 "git for-each-ref failed for pre-push remote {remote}: {}",
360 String::from_utf8_lossy(&output.stderr)
361 );
362 }
363
364 Ok(String::from_utf8_lossy(&output.stdout)
365 .lines()
366 .map(str::trim)
367 .filter(|line| !line.is_empty())
368 .filter(|line| line.starts_with(&prefix))
369 .map(str::to_owned)
370 .collect())
371}
372
373fn rejection_summary(entries: &[LedgerEntry]) -> String {
374 entries
375 .iter()
376 .map(|entry| format!("{} {}", entry.commit_sha, entry.claim))
377 .collect::<Vec<_>>()
378 .join("; ")
379}
380
381fn read_optional_file(path: Option<&PathBuf>, label: &str) -> Result<Option<String>> {
382 path.map(|path| {
383 fs::read_to_string(path)
384 .with_context(|| format!("failed to read {label} {}", path.display()))
385 })
386 .transpose()
387}
388
389#[cfg(test)]
390mod tests {
391 use std::{fs, path::Path, process::Command};
392
393 use proptest::prelude::*;
394
395 use crate::{
396 config::{MemorySkillCandidateKind, MemorySkillConfig, TruthMirrorConfig},
397 ledger::{LedgerEntry, LedgerStore, ReviewerConfig, Verdict},
398 memory_skill::{
399 CANDIDATE_SCHEMA_VERSION, CandidateStatus, EvidenceRef, MemorySkillCandidate,
400 MemorySkillStore,
401 },
402 };
403
404 use super::{
405 PushGateDecision, block_for_pending_memory_skill, commits_for_range_in,
406 pending_memory_skill_candidates, pre_push_decision,
407 };
408
409 fn reject_entry(sha: &str) -> LedgerEntry {
410 LedgerEntry::new_at(
411 sha,
412 Verdict::Reject,
413 "CLAIM: bad | verified: cargo test | evidence: tests:cargo-test",
414 vec!["tests:cargo-test".to_owned()],
415 ReviewerConfig::new("claude", "opus", false),
416 vec!["unsupported".to_owned()],
417 100,
418 )
419 }
420
421 #[test]
422 fn pre_push_blocks_unresolved_rejection_in_range() {
423 let temp = tempfile::tempdir().unwrap();
424 let store = LedgerStore::new(temp.path());
425 store.append_entry(&reject_entry("abc123")).unwrap();
426
427 let decision = pre_push_decision(&store, &["abc123".to_owned()], false).unwrap();
428
429 assert!(matches!(decision, PushGateDecision::Block(_)));
430 }
431
432 #[test]
433 fn pre_push_all_blocks_any_unresolved_rejection() {
434 let temp = tempfile::tempdir().unwrap();
435 let store = LedgerStore::new(temp.path());
436 store.append_entry(&reject_entry("abc123")).unwrap();
437
438 let decision = pre_push_decision(&store, &[], true).unwrap();
439
440 assert!(matches!(decision, PushGateDecision::Block(_)));
441 }
442
443 #[test]
444 fn pre_push_empty_range_without_all_does_not_block_everything() {
445 let temp = tempfile::tempdir().unwrap();
446 let store = LedgerStore::new(temp.path());
447 store.append_entry(&reject_entry("abc123")).unwrap();
448
449 let decision = pre_push_decision(&store, &[], false).unwrap();
450
451 assert_eq!(decision, PushGateDecision::Allow);
452 }
453
454 #[test]
455 fn pre_push_allows_after_resolve_or_waive() {
456 let temp = tempfile::tempdir().unwrap();
457 let store = LedgerStore::new(temp.path());
458 store.append_entry(&reject_entry("abc123")).unwrap();
459 store.resolve("abc123").unwrap();
460
461 let decision = pre_push_decision(&store, &["abc123".to_owned()], false).unwrap();
462
463 assert_eq!(decision, PushGateDecision::Allow);
464 }
465
466 #[test]
467 fn pending_memory_skill_filter_honors_commit_set() {
468 let temp = tempfile::tempdir().unwrap();
469 let config = MemorySkillConfig {
470 candidate_dir: "skills/candidates".to_owned(),
471 ..MemorySkillConfig::default()
472 };
473 let store = MemorySkillStore::new(temp.path(), &config).unwrap();
474 store
475 .write_candidate(&candidate_for_commit("candidate-keep", "keep123"))
476 .unwrap();
477 store
478 .write_candidate(&candidate_for_commit("candidate-skip", "skip123"))
479 .unwrap();
480
481 let commits = vec!["keep123".to_owned()];
482 let pending = pending_memory_skill_candidates(&store, false, &commits).unwrap();
483 let all_pending = pending_memory_skill_candidates(&store, true, &[]).unwrap();
484
485 assert_eq!(
486 pending
487 .iter()
488 .map(|candidate| candidate.id.as_str())
489 .collect::<Vec<_>>(),
490 vec!["candidate-keep"]
491 );
492 assert_eq!(all_pending.len(), 2);
493 }
494
495 #[test]
496 fn pending_memory_skill_gate_honors_enable_and_block_flags() {
497 let temp = tempfile::tempdir().unwrap();
498 let mut config = TruthMirrorConfig::default();
499 config.skills.enabled = true;
500 config.memory_skill.enabled = true;
501 config.memory_skill.pre_push_blocks_pending = true;
502 let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
503 store
504 .write_candidate(&candidate_for_commit("candidate-keep", "keep123"))
505 .unwrap();
506 let commits = vec!["keep123".to_owned()];
507
508 config.memory_skill.enabled = false;
509 block_for_pending_memory_skill(temp.path(), &config, false, &commits).unwrap();
510
511 config.memory_skill.enabled = true;
512 config.memory_skill.pre_push_blocks_pending = false;
513 block_for_pending_memory_skill(temp.path(), &config, false, &commits).unwrap();
514
515 config.memory_skill.pre_push_blocks_pending = true;
516 let error = block_for_pending_memory_skill(temp.path(), &config, false, &commits)
517 .unwrap_err()
518 .to_string();
519
520 assert!(error.contains("pending memory-skill candidate"));
521 }
522
523 #[test]
524 fn new_branch_range_expands_to_all_unpushed_commits() {
525 let temp = tempfile::tempdir().unwrap();
526 let repo = temp.path();
527 git(repo, &["init"]);
528 git(repo, &["config", "user.email", "truth@example.invalid"]);
529 git(repo, &["config", "user.name", "Truth Mirror Test"]);
530 fs::write(repo.join("one.txt"), "one\n").unwrap();
531 git(repo, &["add", "one.txt"]);
532 git(repo, &["commit", "-m", "one"]);
533 let first = git_stdout(repo, &["rev-parse", "HEAD"]);
534 fs::write(repo.join("two.txt"), "two\n").unwrap();
535 git(repo, &["add", "two.txt"]);
536 git(repo, &["commit", "-m", "two"]);
537 let second = git_stdout(repo, &["rev-parse", "HEAD"]);
538
539 let commits = commits_for_range_in(&format!("new:{second}"), repo).unwrap();
540
541 assert_eq!(commits, vec![second, first]);
542 }
543
544 #[test]
545 fn new_branch_range_excludes_only_target_remote_refs_when_remote_is_named() {
546 let temp = tempfile::tempdir().unwrap();
547 let repo = temp.path();
548 git(repo, &["init"]);
549 git(repo, &["config", "user.email", "truth@example.invalid"]);
550 git(repo, &["config", "user.name", "Truth Mirror Test"]);
551 fs::write(repo.join("one.txt"), "one\n").unwrap();
552 git(repo, &["add", "one.txt"]);
553 git(repo, &["commit", "-m", "one"]);
554 let first = git_stdout(repo, &["rev-parse", "HEAD"]);
555 fs::write(repo.join("two.txt"), "two\n").unwrap();
556 git(repo, &["add", "two.txt"]);
557 git(repo, &["commit", "-m", "two"]);
558 let second = git_stdout(repo, &["rev-parse", "HEAD"]);
559 git(repo, &["update-ref", "refs/remotes/backup/main", &first]);
560
561 let missing_from_origin =
562 commits_for_range_in(&format!("new:origin:{second}"), repo).unwrap();
563 let missing_with_whitespace =
564 commits_for_range_in(&format!("new: origin : {second} "), repo).unwrap();
565 git(repo, &["update-ref", "refs/remotes/origin/main", &first]);
566 let missing_after_origin_has_first =
567 commits_for_range_in(&format!("new:origin:{second}"), repo).unwrap();
568
569 assert_eq!(missing_from_origin, vec![second.clone(), first.clone()]);
570 assert_eq!(missing_with_whitespace, vec![second.clone(), first]);
571 assert_eq!(missing_after_origin_has_first, vec![second]);
572 }
573
574 #[test]
575 fn malformed_remote_new_branch_range_fails_before_git_rev_list() {
576 let temp = tempfile::tempdir().unwrap();
577 let repo = temp.path();
578 git(repo, &["init"]);
579
580 let error = commits_for_range_in("new::abc123", repo)
581 .unwrap_err()
582 .to_string();
583 let glob_error = commits_for_range_in("new:or*:abc123", repo)
584 .unwrap_err()
585 .to_string();
586 let option_like_sha_error = commits_for_range_in("new:origin:--output=bad", repo)
587 .unwrap_err()
588 .to_string();
589
590 assert!(error.contains("malformed new-branch pre-push range"));
591 assert!(glob_error.contains("malformed new-branch pre-push range"));
592 assert!(option_like_sha_error.contains("malformed new-branch pre-push range"));
593 }
594
595 fn git(repo: &Path, args: &[&str]) {
596 let output = Command::new("git")
597 .args(args)
598 .current_dir(repo)
599 .output()
600 .unwrap();
601 assert!(
602 output.status.success(),
603 "git {args:?} failed\nstdout:\n{}\nstderr:\n{}",
604 String::from_utf8_lossy(&output.stdout),
605 String::from_utf8_lossy(&output.stderr)
606 );
607 }
608
609 fn git_stdout(repo: &Path, args: &[&str]) -> String {
610 let output = Command::new("git")
611 .args(args)
612 .current_dir(repo)
613 .output()
614 .unwrap();
615 assert!(
616 output.status.success(),
617 "git {args:?} failed\nstdout:\n{}\nstderr:\n{}",
618 String::from_utf8_lossy(&output.stdout),
619 String::from_utf8_lossy(&output.stderr)
620 );
621 String::from_utf8(output.stdout).unwrap().trim().to_owned()
622 }
623
624 fn candidate_for_commit(id: &str, sha: &str) -> MemorySkillCandidate {
625 let ledger_ref = EvidenceRef::ledger(sha);
626 MemorySkillCandidate {
627 schema_version: CANDIDATE_SCHEMA_VERSION,
628 id: id.to_owned(),
629 fingerprint: format!("fingerprint-{id}"),
630 learning_key: id.to_owned(),
631 status: CandidateStatus::Pending,
632 candidate_kind: MemorySkillCandidateKind::HowToSkill,
633 scope: "project".to_owned(),
634 source_commit: sha.to_owned(),
635 source_claim: format!("CLAIM: {id} | verified: cargo test | evidence: tests:{id}"),
636 truth_label: Verdict::Pass,
637 ledger_entry_ref: ledger_ref.clone(),
638 source_run_ref: EvidenceRef::run("run-1"),
639 occurrence_count: 1,
640 occurrence_refs: vec![ledger_ref.clone()],
641 slug: id.to_owned(),
642 title: id.to_owned(),
643 description: "test candidate".to_owned(),
644 when_to_use: vec!["When testing pending candidate filtering.".to_owned()],
645 procedure_steps: vec!["Run the pending candidate filter.".to_owned()],
646 pitfalls: vec!["Do not include unrelated commits.".to_owned()],
647 verification_steps: vec!["Assert only matching commits remain.".to_owned()],
648 evidence: vec![ledger_ref],
649 created_at_unix: 100,
650 }
651 }
652
653 proptest! {
654 #[test]
655 fn pushed_range_decision_blocks_only_matching_unresolved_sha(
656 rejected_sha in "[a-f0-9]{7,40}",
657 other_sha in "[a-f0-9]{7,40}",
658 ) {
659 prop_assume!(rejected_sha != other_sha);
660 let temp = tempfile::tempdir().unwrap();
661 let store = LedgerStore::new(temp.path());
662 store.append_entry(&reject_entry(&rejected_sha)).unwrap();
663
664 let unrelated = pre_push_decision(&store, std::slice::from_ref(&other_sha), false).unwrap();
665 prop_assert_eq!(unrelated, PushGateDecision::Allow);
666
667 let blocked = pre_push_decision(&store, std::slice::from_ref(&rejected_sha), false).unwrap();
668 prop_assert!(matches!(blocked, PushGateDecision::Block(_)));
669 }
670 }
671}