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