use std::{
fs,
io::{self, Read},
path::{Path, PathBuf},
process::{Command, ExitCode},
};
use anyhow::{Context, Result, bail};
use crate::{
claim,
cli::{self, Agent, HookName},
gate,
reviewer::ReviewQueue,
surface::{self, SurfacePlan},
};
const INSTALLED_HOOKS: &[HookName] =
&[HookName::CommitMsg, HookName::PostCommit, HookName::PrePush];
const PI_TRUST_NOTE: &str = "pi: installed .pi/extensions/truth-mirror.js — Pi loads it once you trust this project folder in Pi.";
pub fn run(
args: cli::InstallHooksArgs,
state_dir: &Path,
config_path: Option<&Path>,
config: &crate::config::TruthMirrorConfig,
) -> Result<ExitCode> {
let repo_root = git_root()?;
let hooks_path = repo_root.join(state_dir).join("hooks");
let plan = HookInstallPlan::new(&repo_root, &hooks_path, args.uninstall);
let global_args = hook_global_args(config_path, &repo_root, state_dir);
let plan = HookInstallPlan {
global_args,
..plan
};
let agents = file_surface_agents(&args);
let pi = pi_targeted(&args);
if args.dry_run {
println!("{}", plan.render());
for agent in &agents {
println!(
"surface: {} -> {}",
surface::agent_slug(*agent),
surface::surface_relative_path(*agent)
);
}
if pi {
println!("surface: pi -> {}", surface::PI_EXTENSION_RELATIVE);
}
return Ok(ExitCode::SUCCESS);
}
let enforcement_enabled = config.enforcement.is_enabled();
if args.uninstall {
uninstall(&plan)?;
for agent in &agents {
surface::uninstall_enforcement(&repo_root, *agent)?;
SurfacePlan::for_agent(&repo_root, *agent).uninstall()?;
}
if pi {
surface::uninstall_pi_extension(&repo_root)?;
remove_legacy_pi_hooks(&repo_root)?;
}
} else {
install(&plan)?;
for agent in &agents {
SurfacePlan::for_agent(&repo_root, *agent).install()?;
if enforcement_enabled {
surface::install_enforcement(&repo_root, *agent, &plan.global_args)?;
}
}
if args.pi {
remove_legacy_pi_hooks(&repo_root)?;
surface::install_pi_extension(&repo_root)?;
println!("{PI_TRUST_NOTE}");
}
}
Ok(ExitCode::SUCCESS)
}
fn file_surface_agents(args: &cli::InstallHooksArgs) -> Vec<Agent> {
let mut agents = Vec::new();
if args.claude {
agents.push(Agent::Claude);
}
if args.codex {
agents.push(Agent::Codex);
}
if agents.is_empty() && args.uninstall && !args.pi {
return surface::FILE_SURFACE_AGENTS.to_vec();
}
agents
}
fn pi_targeted(args: &cli::InstallHooksArgs) -> bool {
args.pi || (args.uninstall && !args.claude && !args.codex)
}
fn remove_legacy_pi_hooks(repo_root: &Path) -> Result<()> {
let path = repo_root.join(".pi/hooks.json");
if path.is_file() {
fs::remove_file(&path)?;
}
Ok(())
}
pub fn dispatch(
args: cli::HookDispatchArgs,
state_dir: &Path,
config: &crate::config::TruthMirrorConfig,
) -> Result<ExitCode> {
run_chained_hook(state_dir, args.hook, &args.args)?;
match args.hook {
HookName::CommitMsg => dispatch_commit_msg(state_dir, &args.args, config)?,
HookName::PostCommit => dispatch_post_commit(state_dir)?,
HookName::PrePush => dispatch_pre_push(state_dir, config)?,
}
Ok(ExitCode::SUCCESS)
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct HookInstallPlan {
pub repo_root: PathBuf,
pub hooks_path: PathBuf,
pub uninstall: bool,
pub global_args: String,
}
impl HookInstallPlan {
pub fn new(repo_root: &Path, hooks_path: &Path, uninstall: bool) -> Self {
Self {
repo_root: repo_root.to_path_buf(),
hooks_path: hooks_path.to_path_buf(),
uninstall,
global_args: String::new(),
}
}
pub fn render(&self) -> String {
let action = if self.uninstall {
"uninstall"
} else {
"install"
};
let hooks = INSTALLED_HOOKS
.iter()
.map(|hook| hook.as_str())
.collect::<Vec<_>>()
.join(", ");
format!(
"truth-mirror hook plan\nrepo={}\naction={action}\nhooksPath={}\nhooks={hooks}",
self.repo_root.display(),
self.hooks_path.display()
)
}
}
fn hook_global_args(config_path: Option<&Path>, repo_root: &Path, state_dir: &Path) -> String {
let mut parts = Vec::new();
if let Some(config) = config_path {
parts.push(format!(
"--config {}",
quote_git_arg(&absolutize(repo_root, config))
));
}
if state_dir != Path::new(".truth-mirror") {
parts.push(format!(
"--state-dir {}",
quote_git_arg(&absolutize(repo_root, state_dir))
));
}
if parts.is_empty() {
String::new()
} else {
format!("{} ", parts.join(" "))
}
}
fn absolutize(repo_root: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
repo_root.join(path)
}
}
fn quote_git_arg(path: &Path) -> String {
let value = path.to_string_lossy();
format!("'{}'", value.replace('\'', "'\\''"))
}
pub fn render_shim(hook: HookName, global_args: &str) -> String {
format!(
"#!/bin/sh\nexec truth-mirror {global_args}hook-dispatch {} \"$@\"\n",
hook.as_str()
)
}
fn install(plan: &HookInstallPlan) -> Result<()> {
fs::create_dir_all(&plan.hooks_path)?;
fs::create_dir_all(plan.hooks_path.join("chained"))?;
let git_hooks = plan.repo_root.join(".git/hooks");
for hook in INSTALLED_HOOKS {
let existing = git_hooks.join(hook.as_str());
if existing.is_file() {
let chained = plan.hooks_path.join("chained").join(hook.as_str());
fs::copy(&existing, chained)?;
}
let hook_path = plan.hooks_path.join(hook.as_str());
fs::write(&hook_path, render_shim(*hook, &plan.global_args))?;
make_executable(&hook_path)?;
}
git_config(&["config", "core.hooksPath", &path_for_git(&plan.hooks_path)])?;
Ok(())
}
fn uninstall(plan: &HookInstallPlan) -> Result<()> {
let _ = Command::new("git")
.args(["config", "--unset", "core.hooksPath"])
.current_dir(&plan.repo_root)
.status();
if plan.hooks_path.exists() {
fs::remove_dir_all(&plan.hooks_path)?;
}
Ok(())
}
fn dispatch_commit_msg(
state_dir: &Path,
args: &[String],
config: &crate::config::TruthMirrorConfig,
) -> Result<()> {
let commit_msg_path = args
.first()
.context("commit-msg hook requires COMMIT_EDITMSG path")?;
let commit_message = fs::read_to_string(commit_msg_path)?;
let diff = git_stdout(&["diff", "--cached"])?;
let claim_file = fs::read_to_string(state_dir.join("claim.txt")).ok();
let policy = config.gates.to_policy();
claim::evaluate_commit_message(&commit_message, claim_file.as_deref(), Some(&diff), &policy)?;
Ok(())
}
fn dispatch_post_commit(state_dir: &Path) -> Result<()> {
let sha = git_stdout(&["rev-parse", "HEAD"])?;
ReviewQueue::new(state_dir).enqueue(sha.trim())?;
Ok(())
}
fn dispatch_pre_push(state_dir: &Path, config: &crate::config::TruthMirrorConfig) -> Result<()> {
let mut stdin = String::new();
io::stdin().read_to_string(&mut stdin)?;
for line in stdin.lines() {
if let Some(range) = pre_push_range_from_line(line) {
gate::run(
cli::GateArgs {
pre_push: Some(range),
commit_msg: None,
claim_file: None,
diff_file: None,
fake_markers: Vec::new(),
pre_tool_use: false,
tool: None,
},
state_dir,
config,
)?;
}
}
Ok(())
}
fn pre_push_range_from_line(line: &str) -> Option<String> {
let parts = line.split_whitespace().collect::<Vec<_>>();
let local_sha = parts.get(1)?;
let remote_sha = parts.get(3)?;
if is_zero_sha(local_sha) {
return None;
}
if is_zero_sha(remote_sha) {
Some((*local_sha).to_owned())
} else {
Some(format!("{remote_sha}..{local_sha}"))
}
}
fn is_zero_sha(value: &str) -> bool {
value.chars().all(|character| character == '0')
}
fn run_chained_hook(state_dir: &Path, hook: HookName, args: &[String]) -> Result<()> {
let chained = state_dir.join("hooks/chained").join(hook.as_str());
if !chained.is_file() {
return Ok(());
}
let status = Command::new(&chained).args(args).status()?;
if !status.success() {
bail!(
"chained hook {} failed with status {status}",
chained.display()
);
}
Ok(())
}
fn git_root() -> Result<PathBuf> {
Ok(PathBuf::from(
git_stdout(&["rev-parse", "--show-toplevel"])?.trim(),
))
}
fn git_stdout(args: &[&str]) -> Result<String> {
let output = Command::new("git").args(args).output()?;
if !output.status.success() {
bail!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn git_config(args: &[&str]) -> Result<()> {
let status = Command::new("git").args(args).status()?;
if !status.success() {
bail!("git {} failed with status {status}", args.join(" "));
}
Ok(())
}
fn path_for_git(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
#[cfg(unix)]
fn make_executable(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut permissions = fs::metadata(path)?.permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions)?;
Ok(())
}
#[cfg(not(unix))]
fn make_executable(_path: &Path) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use super::{HookInstallPlan, pre_push_range_from_line, render_shim};
use crate::cli::HookName;
#[test]
fn hook_shim_is_only_exec_delegation() {
let shim = render_shim(HookName::CommitMsg, "");
let lines = shim.lines().collect::<Vec<_>>();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0], "#!/bin/sh");
assert_eq!(
lines[1],
"exec truth-mirror hook-dispatch commit-msg \"$@\""
);
}
#[test]
fn quote_git_arg_escapes_shell_metacharacters() {
use std::path::Path;
assert_eq!(super::quote_git_arg(Path::new("/a/b.toml")), "'/a/b.toml'");
assert_eq!(
super::quote_git_arg(Path::new("/a/c;$(touch x).toml")),
"'/a/c;$(touch x).toml'"
);
assert_eq!(
super::quote_git_arg(Path::new("/a/it's.toml")),
"'/a/it'\\''s.toml'"
);
}
#[test]
fn hook_shim_preserves_global_args() {
let shim = render_shim(HookName::PrePush, "--config /abs/enforce.toml ");
let lines = shim.lines().collect::<Vec<_>>();
assert_eq!(lines.len(), 2, "shim stays tiny + exec-only");
assert_eq!(
lines[1],
"exec truth-mirror --config /abs/enforce.toml hook-dispatch pre-push \"$@\""
);
}
#[test]
fn dry_run_plan_names_hooks_and_hooks_path() {
let plan = HookInstallPlan::new(
std::path::Path::new("/repo"),
std::path::Path::new("/repo/.truth-mirror/hooks"),
false,
);
let rendered = plan.render();
assert!(rendered.contains("commit-msg"));
assert!(rendered.contains("post-commit"));
assert!(rendered.contains("pre-push"));
assert!(rendered.contains("hooksPath=/repo/.truth-mirror/hooks"));
}
#[test]
fn pre_push_line_maps_to_git_range() {
let line = "refs/heads/main abc123 refs/heads/main def456";
assert_eq!(
pre_push_range_from_line(line),
Some("def456..abc123".to_owned())
);
}
proptest! {
#[test]
fn hook_shim_rendering_stays_tiny_exec_only(index in 0usize..3) {
let hook = [HookName::CommitMsg, HookName::PostCommit, HookName::PrePush][index];
let shim = render_shim(hook, "");
let lines = shim.lines().collect::<Vec<_>>();
prop_assert_eq!(lines.len(), 2);
prop_assert_eq!(lines[0], "#!/bin/sh");
prop_assert!(lines[1].starts_with("exec truth-mirror hook-dispatch "));
prop_assert!(lines[1].contains(hook.as_str()));
}
}
}