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