use std::{
collections::BTreeSet,
fs,
path::{Path, PathBuf},
process::{Command, ExitCode},
};
use anyhow::{Context, Result, bail};
use crate::{
claim, cli,
ledger::{LedgerEntry, LedgerStore},
reviewer::ReviewQueue,
};
pub fn run(
args: cli::GateArgs,
state_dir: &Path,
config_path: Option<&Path>,
config: &crate::config::TruthMirrorConfig,
) -> Result<ExitCode> {
if let Some(range) = args.pre_push {
warn_pending_reviews(state_dir, config_path);
let all_commits = range == "all";
let commits = commits_for_range(&range)?;
match pre_push_decision(&LedgerStore::new(state_dir), &commits, all_commits)? {
PushGateDecision::Allow => {
block_for_pending_memory_skill(state_dir, config, all_commits, &commits)?;
return Ok(ExitCode::SUCCESS);
}
PushGateDecision::Block(entries) => {
bail!(
"pre-push blocked unresolved rejection(s): {}",
rejection_summary(&entries)
);
}
}
}
if args.pre_tool_use {
return pre_tool_use_gate(args, state_dir, config);
}
let commit_msg_path = args
.commit_msg
.as_ref()
.context("gate requires --commit-msg, --pre-push, or --pre-tool-use")?;
let commit_message = fs::read_to_string(commit_msg_path).with_context(|| {
format!(
"failed to read commit message {}",
commit_msg_path.display()
)
})?;
let claim_file = read_optional_file(args.claim_file.as_ref(), "claim file")?;
let diff_file = read_optional_file(args.diff_file.as_ref(), "diff file")?;
let mut policy = config.gates.to_policy();
if !args.fake_markers.is_empty() {
for marker in &args.fake_markers {
if !policy
.fake_markers
.iter()
.any(|existing| existing == marker)
{
policy.fake_markers.push(marker.clone());
}
}
}
claim::evaluate_commit_message(
&commit_message,
claim_file.as_deref(),
diff_file.as_deref(),
&policy,
)?;
Ok(ExitCode::SUCCESS)
}
fn warn_pending_reviews(state_dir: &Path, config_path: Option<&Path>) {
let summary = match ReviewQueue::new(state_dir).summary() {
Ok(summary) => summary,
Err(error) => {
eprintln!(
"{} warning: could not read async review queue for advisory pre-push warning: {error}",
crate::messages::diagnostic_prefix()
);
return;
}
};
if summary.pending_count == 0 {
return;
}
let oldest = summary
.oldest_age_secs_at(crate::time::unix_now())
.map_or_else(|| "unknown age".to_owned(), |age| format!("{age}s old"));
eprintln!(
"{} warning: {} unreviewed claim(s) queued, oldest {oldest}. {}.",
crate::messages::diagnostic_prefix(),
summary.pending_count,
crate::messages::async_review_drain_hint_for_cli(config_path, state_dir)
);
}
fn pre_tool_use_gate(
args: cli::GateArgs,
state_dir: &Path,
config: &crate::config::TruthMirrorConfig,
) -> Result<ExitCode> {
let unresolved = LedgerStore::new(state_dir).unresolved_rejections()?;
let count = u32::try_from(unresolved.len()).unwrap_or(u32::MAX);
let oldest_age = unresolved
.iter()
.map(|entry| entry.created_at_unix)
.min()
.map(|oldest| crate::time::unix_now().saturating_sub(oldest));
let (tool, mutating) = match args.tool {
Some(name) => {
let mutating = crate::enforcement::is_mutating_tool(
&name,
crate::enforcement::DEFAULT_MUTATING_TOOLS,
);
(name, mutating)
}
None => match resolve_tool_from_stdin() {
ResolvedTool::Named(name) => {
let mutating = crate::enforcement::is_mutating_tool(
&name,
crate::enforcement::DEFAULT_MUTATING_TOOLS,
);
(name, mutating)
}
ResolvedTool::Unknown => ("<unparseable-hook-payload>".to_owned(), true),
ResolvedTool::None => (String::new(), false),
},
};
match crate::enforcement::pre_tool_use_decision(
count,
oldest_age,
mutating,
&config.enforcement,
) {
crate::enforcement::ToolGateDecision::Allow => Ok(ExitCode::SUCCESS),
crate::enforcement::ToolGateDecision::Block { reason } => {
eprintln!(
"{} blocked tool {tool:?}: {reason}. Resolve or waive the ledger to continue.",
crate::messages::diagnostic_prefix()
);
Ok(ExitCode::from(2))
}
}
}
enum ResolvedTool {
Named(String),
Unknown,
None,
}
fn resolve_tool_from_stdin() -> ResolvedTool {
use std::io::{IsTerminal, Read};
if std::io::stdin().is_terminal() {
return ResolvedTool::None;
}
let mut buffer = String::new();
if std::io::stdin().read_to_string(&mut buffer).is_err() {
return ResolvedTool::Unknown;
}
if buffer.trim().is_empty() {
return ResolvedTool::None;
}
match serde_json::from_str::<serde_json::Value>(&buffer)
.ok()
.and_then(|value| {
value
.get("tool_name")
.or_else(|| value.get("toolName"))
.or_else(|| value.get("tool"))
.and_then(|name| name.as_str())
.map(str::to_owned)
}) {
Some(name) if !name.trim().is_empty() => ResolvedTool::Named(name),
_ => ResolvedTool::Unknown,
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PushGateDecision {
Allow,
Block(Vec<LedgerEntry>),
}
fn block_for_pending_memory_skill(
state_dir: &Path,
config: &crate::config::TruthMirrorConfig,
all_commits: bool,
commits: &[String],
) -> Result<()> {
if !config.memory_skill.effective_enabled(&config.skills)
|| !config.memory_skill.pre_push_blocks_pending
{
return Ok(());
}
let store = crate::memory_skill::MemorySkillStore::new(state_dir, &config.memory_skill)?;
let pending = pending_memory_skill_candidates(&store, all_commits, commits)?;
if pending.is_empty() {
return Ok(());
}
bail!(
"pre-push blocked pending memory-skill candidate(s): {}",
pending
.iter()
.map(|candidate| format!("{} {}", candidate.id, candidate.source_commit))
.collect::<Vec<_>>()
.join("; ")
);
}
fn pending_memory_skill_candidates(
store: &crate::memory_skill::MemorySkillStore,
all_commits: bool,
commits: &[String],
) -> Result<Vec<crate::memory_skill::MemorySkillCandidate>> {
if all_commits {
return Ok(store.pending_candidates()?);
}
let commit_set = commits.iter().map(String::as_str).collect::<BTreeSet<_>>();
Ok(store.pending_for_commits(&commit_set)?)
}
pub fn pre_push_decision(
store: &LedgerStore,
commits: &[String],
all_commits: bool,
) -> Result<PushGateDecision> {
let commit_set = commits.iter().map(String::as_str).collect::<BTreeSet<_>>();
let blocked = store
.unresolved_rejections()?
.into_iter()
.filter(|entry| all_commits || commit_set.contains(entry.commit_sha.as_str()))
.collect::<Vec<_>>();
if blocked.is_empty() {
Ok(PushGateDecision::Allow)
} else {
Ok(PushGateDecision::Block(blocked))
}
}
fn commits_for_range(range: &str) -> Result<Vec<String>> {
commits_for_range_in(range, Path::new("."))
}
fn commits_for_range_in(range: &str, cwd: &Path) -> Result<Vec<String>> {
if range == "all" {
return Ok(Vec::new());
}
if let Some(new_branch) = range.strip_prefix("new:") {
return match new_branch.split_once(':') {
Some((remote, local_sha)) => {
let remote = remote.trim();
let local_sha = local_sha.trim();
if remote.is_empty()
|| remote
.chars()
.any(|character| matches!(character, '*' | '?' | '['))
|| local_sha.is_empty()
|| local_sha.contains(':')
|| local_sha.starts_with('-')
{
bail!(
"malformed new-branch pre-push range {range}: expected new:<remote>:<sha>"
);
}
git_rev_list_for_new_branch_on_remote(cwd, remote, local_sha, range)
}
_ => git_rev_list(cwd, &[new_branch, "--not", "--remotes"], range),
};
}
if !range.contains("..") {
return Ok(vec![range.to_owned()]);
}
git_rev_list(cwd, &[range], range)
}
fn git_rev_list(cwd: &Path, args: &[&str], label: &str) -> Result<Vec<String>> {
let output = Command::new("git")
.arg("rev-list")
.args(args)
.current_dir(cwd)
.output()
.context("failed to run git rev-list for pre-push range")?;
if !output.status.success() {
bail!(
"git rev-list failed for pre-push range {label}: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_owned)
.collect())
}
fn git_rev_list_for_new_branch_on_remote(
cwd: &Path,
remote: &str,
local_sha: &str,
label: &str,
) -> Result<Vec<String>> {
let remote_refs = remote_tracking_refs(cwd, remote)?;
let mut args = vec![local_sha.to_owned()];
if !remote_refs.is_empty() {
args.push("--not".to_owned());
args.extend(remote_refs);
}
let args = args.iter().map(String::as_str).collect::<Vec<_>>();
git_rev_list(cwd, &args, label)
}
fn remote_tracking_refs(cwd: &Path, remote: &str) -> Result<Vec<String>> {
let prefix = format!("refs/remotes/{remote}/");
let output = Command::new("git")
.args(["for-each-ref", "--format=%(refname)", &prefix])
.current_dir(cwd)
.output()
.context("failed to list remote refs for pre-push range")?;
if !output.status.success() {
bail!(
"git for-each-ref failed for pre-push remote {remote}: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.filter(|line| line.starts_with(&prefix))
.map(str::to_owned)
.collect())
}
fn rejection_summary(entries: &[LedgerEntry]) -> String {
entries
.iter()
.map(|entry| format!("{} {}", entry.commit_sha, entry.claim))
.collect::<Vec<_>>()
.join("; ")
}
fn read_optional_file(path: Option<&PathBuf>, label: &str) -> Result<Option<String>> {
path.map(|path| {
fs::read_to_string(path)
.with_context(|| format!("failed to read {label} {}", path.display()))
})
.transpose()
}
#[cfg(test)]
mod tests {
use std::{fs, path::Path, process::Command};
use proptest::prelude::*;
use crate::{
config::{MemorySkillCandidateKind, MemorySkillConfig, TruthMirrorConfig},
ledger::{LedgerEntry, LedgerStore, ReviewerConfig, Verdict},
memory_skill::{
CANDIDATE_SCHEMA_VERSION, CandidateStatus, EvidenceRef, MemorySkillCandidate,
MemorySkillStore,
},
};
use super::{
PushGateDecision, block_for_pending_memory_skill, commits_for_range_in,
pending_memory_skill_candidates, pre_push_decision,
};
fn reject_entry(sha: &str) -> LedgerEntry {
LedgerEntry::new_at(
sha,
Verdict::Reject,
"CLAIM: bad | verified: cargo test | evidence: tests:cargo-test",
vec!["tests:cargo-test".to_owned()],
ReviewerConfig::new("claude", "opus", false),
vec!["unsupported".to_owned()],
100,
)
}
#[test]
fn pre_push_blocks_unresolved_rejection_in_range() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&reject_entry("abc123")).unwrap();
let decision = pre_push_decision(&store, &["abc123".to_owned()], false).unwrap();
assert!(matches!(decision, PushGateDecision::Block(_)));
}
#[test]
fn pre_push_all_blocks_any_unresolved_rejection() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&reject_entry("abc123")).unwrap();
let decision = pre_push_decision(&store, &[], true).unwrap();
assert!(matches!(decision, PushGateDecision::Block(_)));
}
#[test]
fn pre_push_empty_range_without_all_does_not_block_everything() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&reject_entry("abc123")).unwrap();
let decision = pre_push_decision(&store, &[], false).unwrap();
assert_eq!(decision, PushGateDecision::Allow);
}
#[test]
fn pre_push_allows_after_resolve_or_waive() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&reject_entry("abc123")).unwrap();
store.resolve("abc123").unwrap();
let decision = pre_push_decision(&store, &["abc123".to_owned()], false).unwrap();
assert_eq!(decision, PushGateDecision::Allow);
}
#[test]
fn pending_memory_skill_filter_honors_commit_set() {
let temp = tempfile::tempdir().unwrap();
let config = MemorySkillConfig {
candidate_dir: "skills/candidates".to_owned(),
..MemorySkillConfig::default()
};
let store = MemorySkillStore::new(temp.path(), &config).unwrap();
store
.write_candidate(&candidate_for_commit("candidate-keep", "keep123"))
.unwrap();
store
.write_candidate(&candidate_for_commit("candidate-skip", "skip123"))
.unwrap();
let commits = vec!["keep123".to_owned()];
let pending = pending_memory_skill_candidates(&store, false, &commits).unwrap();
let all_pending = pending_memory_skill_candidates(&store, true, &[]).unwrap();
assert_eq!(
pending
.iter()
.map(|candidate| candidate.id.as_str())
.collect::<Vec<_>>(),
vec!["candidate-keep"]
);
assert_eq!(all_pending.len(), 2);
}
#[test]
fn pending_memory_skill_gate_honors_enable_and_block_flags() {
let temp = tempfile::tempdir().unwrap();
let mut config = TruthMirrorConfig::default();
config.skills.enabled = true;
config.memory_skill.enabled = true;
config.memory_skill.pre_push_blocks_pending = true;
let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
store
.write_candidate(&candidate_for_commit("candidate-keep", "keep123"))
.unwrap();
let commits = vec!["keep123".to_owned()];
config.memory_skill.enabled = false;
block_for_pending_memory_skill(temp.path(), &config, false, &commits).unwrap();
config.memory_skill.enabled = true;
config.memory_skill.pre_push_blocks_pending = false;
block_for_pending_memory_skill(temp.path(), &config, false, &commits).unwrap();
config.memory_skill.pre_push_blocks_pending = true;
let error = block_for_pending_memory_skill(temp.path(), &config, false, &commits)
.unwrap_err()
.to_string();
assert!(error.contains("pending memory-skill candidate"));
}
#[test]
fn new_branch_range_expands_to_all_unpushed_commits() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path();
git(repo, &["init"]);
git(repo, &["config", "user.email", "truth@example.invalid"]);
git(repo, &["config", "user.name", "Truth Mirror Test"]);
fs::write(repo.join("one.txt"), "one\n").unwrap();
git(repo, &["add", "one.txt"]);
git(repo, &["commit", "-m", "one"]);
let first = git_stdout(repo, &["rev-parse", "HEAD"]);
fs::write(repo.join("two.txt"), "two\n").unwrap();
git(repo, &["add", "two.txt"]);
git(repo, &["commit", "-m", "two"]);
let second = git_stdout(repo, &["rev-parse", "HEAD"]);
let commits = commits_for_range_in(&format!("new:{second}"), repo).unwrap();
assert_eq!(commits, vec![second, first]);
}
#[test]
fn new_branch_range_excludes_only_target_remote_refs_when_remote_is_named() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path();
git(repo, &["init"]);
git(repo, &["config", "user.email", "truth@example.invalid"]);
git(repo, &["config", "user.name", "Truth Mirror Test"]);
fs::write(repo.join("one.txt"), "one\n").unwrap();
git(repo, &["add", "one.txt"]);
git(repo, &["commit", "-m", "one"]);
let first = git_stdout(repo, &["rev-parse", "HEAD"]);
fs::write(repo.join("two.txt"), "two\n").unwrap();
git(repo, &["add", "two.txt"]);
git(repo, &["commit", "-m", "two"]);
let second = git_stdout(repo, &["rev-parse", "HEAD"]);
git(repo, &["update-ref", "refs/remotes/backup/main", &first]);
let missing_from_origin =
commits_for_range_in(&format!("new:origin:{second}"), repo).unwrap();
let missing_with_whitespace =
commits_for_range_in(&format!("new: origin : {second} "), repo).unwrap();
git(repo, &["update-ref", "refs/remotes/origin/main", &first]);
let missing_after_origin_has_first =
commits_for_range_in(&format!("new:origin:{second}"), repo).unwrap();
assert_eq!(missing_from_origin, vec![second.clone(), first.clone()]);
assert_eq!(missing_with_whitespace, vec![second.clone(), first]);
assert_eq!(missing_after_origin_has_first, vec![second]);
}
#[test]
fn malformed_remote_new_branch_range_fails_before_git_rev_list() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path();
git(repo, &["init"]);
let error = commits_for_range_in("new::abc123", repo)
.unwrap_err()
.to_string();
let glob_error = commits_for_range_in("new:or*:abc123", repo)
.unwrap_err()
.to_string();
let option_like_sha_error = commits_for_range_in("new:origin:--output=bad", repo)
.unwrap_err()
.to_string();
assert!(error.contains("malformed new-branch pre-push range"));
assert!(glob_error.contains("malformed new-branch pre-push range"));
assert!(option_like_sha_error.contains("malformed new-branch pre-push range"));
}
fn git(repo: &Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(repo)
.output()
.unwrap();
assert!(
output.status.success(),
"git {args:?} failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn git_stdout(repo: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(repo)
.output()
.unwrap();
assert!(
output.status.success(),
"git {args:?} failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().to_owned()
}
fn candidate_for_commit(id: &str, sha: &str) -> MemorySkillCandidate {
let ledger_ref = EvidenceRef::ledger(sha);
MemorySkillCandidate {
schema_version: CANDIDATE_SCHEMA_VERSION,
id: id.to_owned(),
fingerprint: format!("fingerprint-{id}"),
learning_key: id.to_owned(),
status: CandidateStatus::Pending,
candidate_kind: MemorySkillCandidateKind::HowToSkill,
scope: "project".to_owned(),
source_commit: sha.to_owned(),
source_claim: format!("CLAIM: {id} | verified: cargo test | evidence: tests:{id}"),
truth_label: Verdict::Pass,
ledger_entry_ref: ledger_ref.clone(),
source_run_ref: EvidenceRef::run("run-1"),
occurrence_count: 1,
occurrence_refs: vec![ledger_ref.clone()],
slug: id.to_owned(),
title: id.to_owned(),
description: "test candidate".to_owned(),
when_to_use: vec!["When testing pending candidate filtering.".to_owned()],
procedure_steps: vec!["Run the pending candidate filter.".to_owned()],
pitfalls: vec!["Do not include unrelated commits.".to_owned()],
verification_steps: vec!["Assert only matching commits remain.".to_owned()],
evidence: vec![ledger_ref],
created_at_unix: 100,
}
}
proptest! {
#[test]
fn pushed_range_decision_blocks_only_matching_unresolved_sha(
rejected_sha in "[a-f0-9]{7,40}",
other_sha in "[a-f0-9]{7,40}",
) {
prop_assume!(rejected_sha != other_sha);
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&reject_entry(&rejected_sha)).unwrap();
let unrelated = pre_push_decision(&store, std::slice::from_ref(&other_sha), false).unwrap();
prop_assert_eq!(unrelated, PushGateDecision::Allow);
let blocked = pre_push_decision(&store, std::slice::from_ref(&rejected_sha), false).unwrap();
prop_assert!(matches!(blocked, PushGateDecision::Block(_)));
}
}
}