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 MANAGED_MARKER: &str = "# truth-mirror managed hook";
const MANAGED_END_MARKER: &str = "# truth-mirror hook end";
const HUSKY_ENTIRE_BRIDGE_MARKER: &str = "# Bridge for the husky+entire chain";
const FORWARDER_NAME: &str = "_forward-local.sh";
const OUTER_HOOK_MARKERS: &[&str] = &[
"# Entire CLI hooks",
HUSKY_ENTIRE_BRIDGE_MARKER,
"# truth-mirror managed hook",
"exec truth-mirror",
"entire hooks git",
];
const STATE_GITIGNORE_HEADER: &str = "# truth-mirror runtime state - machine-generated";
const STATE_GITIGNORE_LINES: &[&str] =
&["runs/", "review-queue.jsonl", "tmp/", "logs/", "*.local.*"];
const FORWARDER_SOURCE: &str = r#"#!/bin/sh
# _forward-local.sh - delegate to .git/hooks from a committed core.hooksPath.
# Uses --git-common-dir (resolves to .git/) not core.hooksPath to prevent an infinite loop.
name="$1"; shift
git_dir="$(git rev-parse --git-common-dir 2>/dev/null)"
[ -n "$git_dir" ] || exit 0
local_hook="$git_dir/hooks/$name"
if [ -x "$local_hook" ]; then exec "$local_hook" "$@"; fi
exit 0
"#;
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,
mode: detect_hook_mode(&repo_root, state_dir)?,
..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, args.inject_forwarder)?;
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 mode: HookInstallMode,
pub global_args: String,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub enum HookInstallMode {
#[default]
Plain,
LegacyTruthMirrorHooksPath {
configured_path: String,
},
Husky {
content_dir: PathBuf,
},
CustomCommitted {
hooks_path: PathBuf,
},
}
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,
mode: HookInstallMode::Plain,
global_args: String::new(),
}
}
pub fn render(&self) -> String {
let action = if self.uninstall {
"uninstall"
} else {
"install"
};
let mut output = format!(
"truth-mirror hook plan\nrepo={}\naction={action}\nmode={}\nstateHooks={}\n",
self.repo_root.display(),
self.mode.as_str(),
self.hooks_path.display()
);
for hook in INSTALLED_HOOKS {
output.push_str(&format!(
"\nhook={}\npath={}\nbody:\n{}",
hook.as_str(),
self.active_hook_path(*hook).display(),
self.render_hook_body(*hook)
));
if let HookInstallMode::Husky { content_dir } = &self.mode {
output.push_str(&format!(
"\nbridge={}.pre-entire\npath={}\nbody:\n{}",
hook.as_str(),
husky_entire_bridge_path(content_dir, *hook).display(),
render_husky_entire_bridge(*hook)
));
}
}
output
}
fn active_hook_path(&self, hook: HookName) -> PathBuf {
match &self.mode {
HookInstallMode::Husky { content_dir } => content_dir.join(hook.as_str()),
HookInstallMode::Plain | HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
self.repo_root.join(".git/hooks").join(hook.as_str())
}
HookInstallMode::CustomCommitted { hooks_path } => hooks_path.join(hook.as_str()),
}
}
fn render_hook_body(&self, hook: HookName) -> String {
match self.mode {
HookInstallMode::Husky { .. } => render_husky_block(hook, &self.global_args),
HookInstallMode::CustomCommitted { .. } => render_custom_forwarder_stub(hook),
HookInstallMode::Plain | HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
render_shim(hook, &self.global_args)
}
}
}
}
impl HookInstallMode {
fn as_str(&self) -> &'static str {
match self {
HookInstallMode::Plain => "plain",
HookInstallMode::LegacyTruthMirrorHooksPath { .. } => "plain-legacy-truth-mirror",
HookInstallMode::Husky { .. } => "husky",
HookInstallMode::CustomCommitted { .. } => "custom-committed",
}
}
}
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(crate::config::DEFAULT_STATE_DIR) {
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\n{MANAGED_MARKER}\nexec truth-mirror {global_args}hook-dispatch {} \"$@\"\n",
hook.as_str()
)
}
fn render_husky_block(hook: HookName, global_args: &str) -> String {
format!(
"{MANAGED_MARKER}\nif command -v truth-mirror >/dev/null 2>&1; then truth-mirror {global_args}hook-dispatch {} \"$@\"; else :; fi\n{MANAGED_END_MARKER}\n",
hook.as_str()
)
}
fn render_husky_entire_bridge(hook: HookName) -> String {
format!(
"#!/bin/sh\n{HUSKY_ENTIRE_BRIDGE_MARKER}\nexec \"$(dirname \"$0\")/{}\" \"$@\"\n",
hook.as_str()
)
}
fn render_custom_forwarder_stub(hook: HookName) -> String {
format!(
"#!/bin/sh\n{MANAGED_MARKER}\nexec \"$(dirname \"$0\")/{FORWARDER_NAME}\" {} \"$@\"\n",
hook.as_str()
)
}
fn detect_hook_mode(repo_root: &Path, state_dir: &Path) -> Result<HookInstallMode> {
let Some(configured) = git_config_get(repo_root, "core.hooksPath")? else {
return Ok(HookInstallMode::Plain);
};
let configured = configured.trim().to_owned();
if configured.is_empty() {
return Ok(HookInstallMode::Plain);
}
if is_managed_hooks_path(repo_root, state_dir, &configured) {
return Ok(HookInstallMode::LegacyTruthMirrorHooksPath {
configured_path: configured,
});
}
if configured.contains(".husky") {
let hooks_path = repo_relative_path(repo_root, Path::new(&configured));
let content_dir = if hooks_path.file_name().and_then(|name| name.to_str()) == Some("_") {
hooks_path
.parent()
.map_or_else(|| repo_root.join(".husky"), Path::to_path_buf)
} else {
hooks_path
};
return Ok(HookInstallMode::Husky { content_dir });
}
Ok(HookInstallMode::CustomCommitted {
hooks_path: repo_relative_path(repo_root, Path::new(&configured)),
})
}
fn repo_relative_path(repo_root: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
repo_root.join(path)
}
}
fn is_managed_hooks_path(repo_root: &Path, state_dir: &Path, configured: &str) -> bool {
[
state_dir.join("hooks"),
PathBuf::from(crate::config::DEFAULT_STATE_DIR).join("hooks"),
PathBuf::from(crate::config::LEGACY_STATE_DIR).join("hooks"),
]
.iter()
.any(|candidate| path_config_matches(repo_root, candidate, configured))
}
fn path_config_matches(repo_root: &Path, candidate: &Path, configured: &str) -> bool {
let candidate_string = candidate.to_string_lossy().into_owned();
let absolute_string = repo_root.join(candidate).to_string_lossy().into_owned();
configured == candidate_string || configured == absolute_string
}
fn install(plan: &HookInstallPlan, inject_forwarder: bool) -> Result<()> {
match &plan.mode {
HookInstallMode::Plain => {
prepare_state_hooks(plan)?;
install_plain(plan)
}
HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
prepare_state_hooks(plan)?;
unset_core_hooks_path(&plan.repo_root)?;
install_plain(plan)
}
HookInstallMode::Husky { content_dir } => {
prepare_state_hooks(plan)?;
install_husky(plan, content_dir)
}
HookInstallMode::CustomCommitted { hooks_path } => {
install_custom(plan, hooks_path, inject_forwarder)
}
}
}
fn uninstall(plan: &HookInstallPlan) -> Result<()> {
match &plan.mode {
HookInstallMode::Plain => uninstall_plain(plan)?,
HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
unset_core_hooks_path(&plan.repo_root)?;
uninstall_plain(plan)?;
}
HookInstallMode::Husky { content_dir } => uninstall_husky(plan, content_dir)?,
HookInstallMode::CustomCommitted { hooks_path } => {
uninstall_plain(plan)?;
uninstall_custom_forwarders(hooks_path)?;
}
}
if plan.hooks_path.exists() {
fs::remove_dir_all(&plan.hooks_path)?;
}
Ok(())
}
fn state_dir_from_hooks_path(hooks_path: &Path) -> &Path {
hooks_path.parent().unwrap_or(hooks_path)
}
fn prepare_state_hooks(plan: &HookInstallPlan) -> Result<()> {
ensure_state_gitignore(&plan.repo_root, state_dir_from_hooks_path(&plan.hooks_path))?;
fs::create_dir_all(&plan.hooks_path)?;
fs::create_dir_all(plan.hooks_path.join("chained"))?;
clear_chained_hooks(plan)
}
fn install_plain(plan: &HookInstallPlan) -> Result<()> {
let git_hooks = plan.repo_root.join(".git/hooks");
fs::create_dir_all(&git_hooks)?;
for hook in INSTALLED_HOOKS {
install_local_hook(plan, &git_hooks, *hook)?;
}
Ok(())
}
fn install_local_hook(plan: &HookInstallPlan, git_hooks: &Path, hook: HookName) -> Result<()> {
let active = git_hooks.join(hook.as_str());
let backup = backup_path(&active, "pre-truth-mirror");
let chained = plan.hooks_path.join("chained").join(hook.as_str());
remove_file_if_exists(&chained)?;
let source = local_hook_source(&active, &backup)?;
if let Some(source) = source {
if source.path != backup {
fs::copy(&source.path, &backup)?;
make_executable(&backup)?;
}
if !has_outer_hook_marker(&source.content) {
fs::copy(&source.path, &chained)?;
make_executable(&chained)?;
}
}
fs::write(&active, render_shim(hook, &plan.global_args))?;
make_executable(&active)?;
Ok(())
}
fn uninstall_plain(plan: &HookInstallPlan) -> Result<()> {
let git_hooks = plan.repo_root.join(".git/hooks");
for hook in INSTALLED_HOOKS {
let active = git_hooks.join(hook.as_str());
let backup = backup_path(&active, "pre-truth-mirror");
let active_content = read_to_string_if_file(&active)?;
if active_content
.as_deref()
.is_some_and(is_truth_mirror_managed)
{
if backup.is_file() {
fs::copy(&backup, &active)?;
make_executable(&active)?;
fs::remove_file(&backup)?;
} else {
remove_file_if_exists(&active)?;
}
}
remove_file_if_exists(&plan.hooks_path.join("chained").join(hook.as_str()))?;
}
Ok(())
}
fn install_husky(plan: &HookInstallPlan, content_dir: &Path) -> Result<()> {
fs::create_dir_all(content_dir)?;
for hook in INSTALLED_HOOKS {
let target = content_dir.join(hook.as_str());
let block = render_husky_block(*hook, &plan.global_args);
let next = match read_to_string_if_file(&target)? {
Some(content) => append_managed_block(&content, &block),
None => format!("#!/bin/sh\n{block}"),
};
fs::write(&target, next)?;
make_executable(&target)?;
install_husky_entire_bridge(content_dir, *hook)?;
}
Ok(())
}
fn install_husky_entire_bridge(content_dir: &Path, hook: HookName) -> Result<()> {
let target = husky_entire_bridge_path(content_dir, hook);
let backup = backup_path(&target, "pre-truth-mirror");
if let Some(content) = read_to_string_if_file(&target)?
&& !is_husky_entire_bridge(&content)
{
fs::copy(&target, &backup)?;
make_executable(&backup)?;
}
fs::write(&target, render_husky_entire_bridge(hook))?;
make_executable(&target)?;
Ok(())
}
fn uninstall_husky(_plan: &HookInstallPlan, content_dir: &Path) -> Result<()> {
for hook in INSTALLED_HOOKS {
let target = content_dir.join(hook.as_str());
if let Some(content) = read_to_string_if_file(&target)? {
let next = remove_managed_block(&content);
if is_empty_or_shebang_only(&next) {
fs::remove_file(&target)?;
} else {
fs::write(&target, next)?;
make_executable(&target)?;
}
}
uninstall_husky_entire_bridge(content_dir, *hook)?;
}
Ok(())
}
fn uninstall_husky_entire_bridge(content_dir: &Path, hook: HookName) -> Result<()> {
let target = husky_entire_bridge_path(content_dir, hook);
let backup = backup_path(&target, "pre-truth-mirror");
match read_to_string_if_file(&target)? {
Some(content) if is_husky_entire_bridge(&content) => {
if backup.is_file() {
fs::copy(&backup, &target)?;
make_executable(&target)?;
fs::remove_file(&backup)?;
} else {
remove_file_if_exists(&target)?;
}
}
None if backup.is_file() => {
fs::copy(&backup, &target)?;
make_executable(&target)?;
fs::remove_file(&backup)?;
}
_ => {}
}
Ok(())
}
fn husky_entire_bridge_path(content_dir: &Path, hook: HookName) -> PathBuf {
content_dir.join(format!("{}.pre-entire", hook.as_str()))
}
fn install_custom(plan: &HookInstallPlan, hooks_path: &Path, inject_forwarder: bool) -> Result<()> {
let missing_forwarder = INSTALLED_HOOKS
.iter()
.any(|hook| !custom_forwarder_present(hooks_path, *hook));
if missing_forwarder && !inject_forwarder {
bail!(
"truth-mirror: core.hooksPath is set to '{}' (a non-husky committed directory).\nTo install, either:\n (a) run `truth-mirror install-hooks --inject-forwarder` to write a _forward-local.sh forwarder into that directory (a committed repo change), or\n (b) unset core.hooksPath and re-run install-hooks",
hooks_path.display()
);
}
if inject_forwarder {
inject_custom_forwarders(hooks_path)?;
}
prepare_state_hooks(plan)?;
let git_hooks = plan.repo_root.join(".git/hooks");
fs::create_dir_all(&git_hooks)?;
for hook in INSTALLED_HOOKS {
install_local_hook(plan, &git_hooks, *hook)?;
}
Ok(())
}
fn inject_custom_forwarders(hooks_path: &Path) -> Result<()> {
fs::create_dir_all(hooks_path)?;
let forwarder = hooks_path.join(FORWARDER_NAME);
fs::write(&forwarder, FORWARDER_SOURCE)?;
make_executable(&forwarder)?;
for hook in INSTALLED_HOOKS {
let target = hooks_path.join(hook.as_str());
if custom_forwarder_present(hooks_path, *hook) {
continue;
}
if target.is_file() {
fs::copy(&target, backup_path(&target, "pre-truth-mirror"))?;
}
fs::write(&target, render_custom_forwarder_stub(*hook))?;
make_executable(&target)?;
}
eprintln!(
"hint: commit the truth-mirror forwarder changes in {}",
hooks_path.display()
);
Ok(())
}
fn uninstall_custom_forwarders(hooks_path: &Path) -> Result<()> {
for hook in INSTALLED_HOOKS {
let target = hooks_path.join(hook.as_str());
let backup = backup_path(&target, "pre-truth-mirror");
let content = read_to_string_if_file(&target)?;
if content.as_deref().is_some_and(is_truth_mirror_managed) {
if backup.is_file() {
fs::copy(&backup, &target)?;
make_executable(&target)?;
fs::remove_file(&backup)?;
} else {
remove_file_if_exists(&target)?;
}
}
}
let forwarder = hooks_path.join(FORWARDER_NAME);
if read_to_string_if_file(&forwarder)?.as_deref() == Some(FORWARDER_SOURCE) {
fs::remove_file(forwarder)?;
}
Ok(())
}
fn append_managed_block(content: &str, block: &str) -> String {
let mut next = remove_managed_block(content);
if !next.ends_with('\n') {
next.push('\n');
}
next.push_str(block);
next
}
fn remove_managed_block(content: &str) -> String {
let mut out = Vec::new();
let mut skipping = false;
for line in content.lines() {
if line == MANAGED_MARKER {
skipping = true;
continue;
}
if skipping {
if line == MANAGED_END_MARKER {
skipping = false;
}
continue;
}
out.push(line);
}
if out.is_empty() {
String::new()
} else {
format!("{}\n", out.join("\n"))
}
}
fn is_empty_or_shebang_only(content: &str) -> bool {
let lines = content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>();
lines.is_empty() || lines == ["#!/bin/sh"]
}
#[derive(Debug)]
struct LocalHookSource {
path: PathBuf,
content: String,
}
fn local_hook_source(active: &Path, backup: &Path) -> Result<Option<LocalHookSource>> {
let Some(active_content) = read_to_string_if_file(active)? else {
return Ok(None);
};
if is_truth_mirror_managed(&active_content) {
return read_to_string_if_file(backup)?.map_or(Ok(None), |content| {
Ok(Some(LocalHookSource {
path: backup.to_path_buf(),
content,
}))
});
}
Ok(Some(LocalHookSource {
path: active.to_path_buf(),
content: active_content,
}))
}
fn custom_forwarder_present(hooks_path: &Path, hook: HookName) -> bool {
read_to_string_if_file(&hooks_path.join(hook.as_str()))
.ok()
.flatten()
.is_some_and(|content| {
content.contains("--git-common-dir")
|| content.contains(FORWARDER_NAME)
|| is_truth_mirror_managed(&content)
})
}
fn ensure_state_gitignore(repo_root: &Path, state_dir: &Path) -> Result<()> {
let state_dir = absolutize(repo_root, state_dir);
fs::create_dir_all(&state_dir)?;
let gitignore = state_dir.join(".gitignore");
let mut content = read_to_string_if_file(&gitignore)?.unwrap_or_default();
if content.is_empty() {
content.push_str(STATE_GITIGNORE_HEADER);
content.push('\n');
}
for line in STATE_GITIGNORE_LINES {
if !content.lines().any(|existing| existing.trim() == *line) {
if !content.ends_with('\n') {
content.push('\n');
}
content.push_str(line);
content.push('\n');
}
}
fs::write(&gitignore, content)?;
print_tracked_runtime_hints(repo_root, &state_dir)?;
Ok(())
}
fn print_tracked_runtime_hints(repo_root: &Path, state_dir: &Path) -> Result<()> {
let rel_state = state_dir.strip_prefix(repo_root).unwrap_or(state_dir);
for path in [
rel_state.join("review-queue.jsonl"),
rel_state.join("runs"),
rel_state.join("tmp"),
rel_state.join("logs"),
] {
let output = Command::new("git")
.args(["ls-files", "--"])
.arg(&path)
.current_dir(repo_root)
.output()?;
if output.status.success() {
for tracked in String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|line| !line.trim().is_empty())
{
eprintln!("hint: run: git rm --cached {tracked}");
}
}
}
Ok(())
}
fn clear_chained_hooks(plan: &HookInstallPlan) -> Result<()> {
for hook in INSTALLED_HOOKS {
remove_file_if_exists(&plan.hooks_path.join("chained").join(hook.as_str()))?;
}
Ok(())
}
fn has_outer_hook_marker(content: &str) -> bool {
content.lines().take(20).any(|line| {
OUTER_HOOK_MARKERS
.iter()
.any(|marker| line.contains(marker))
})
}
fn is_truth_mirror_managed(content: &str) -> bool {
content.contains(MANAGED_MARKER)
|| content.contains(HUSKY_ENTIRE_BRIDGE_MARKER)
|| content.contains("exec truth-mirror")
}
fn is_husky_entire_bridge(content: &str) -> bool {
content.contains(HUSKY_ENTIRE_BRIDGE_MARKER)
}
fn backup_path(path: &Path, suffix: &str) -> PathBuf {
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("hook");
path.with_file_name(format!("{file_name}.{suffix}"))
}
fn read_to_string_if_file(path: &Path) -> Result<Option<String>> {
match fs::read_to_string(path) {
Ok(content) => Ok(Some(content)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.into()),
}
}
fn remove_file_if_exists(path: &Path) -> Result<()> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}
fn unset_core_hooks_path(repo_root: &Path) -> Result<()> {
let status = Command::new("git")
.args(["config", "--unset", "core.hooksPath"])
.current_dir(repo_root)
.status()?;
if !status.success() {
return Ok(());
}
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 content = fs::read_to_string(&chained)?;
if has_outer_hook_marker(&content) {
eprintln!(
"truth-mirror: skipping unsafe chained hook {} because it appears to invoke an outer hook manager",
chained.display()
);
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_get(repo_root: &Path, key: &str) -> Result<Option<String>> {
let output = Command::new("git")
.args(["config", "--get", key])
.current_dir(repo_root)
.output()?;
if output.status.success() {
return Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()));
}
if output.status.code() == Some(1) {
return Ok(None);
}
bail!(
"git config --get {key} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[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::{HookInstallMode, 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(), 3);
assert_eq!(lines[0], "#!/bin/sh");
assert_eq!(lines[1], super::MANAGED_MARKER);
assert_eq!(
lines[2],
"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(), 3, "shim stays <=3 lines + exec-only");
assert_eq!(
lines[2],
"exec truth-mirror --config /abs/enforce.toml hook-dispatch pre-push \"$@\""
);
}
#[test]
fn husky_entire_bridge_is_only_exec_delegation() {
let bridge = super::render_husky_entire_bridge(HookName::CommitMsg);
let lines = bridge.lines().collect::<Vec<_>>();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "#!/bin/sh");
assert_eq!(lines[1], super::HUSKY_ENTIRE_BRIDGE_MARKER);
assert_eq!(lines[2], "exec \"$(dirname \"$0\")/commit-msg\" \"$@\"");
}
#[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/hooks"),
false,
);
let rendered = plan.render();
assert!(rendered.contains("commit-msg"));
assert!(rendered.contains("post-commit"));
assert!(rendered.contains("pre-push"));
assert!(rendered.contains("stateHooks=/repo/.truth/hooks"));
assert!(rendered.contains("path=/repo/.git/hooks/commit-msg"));
assert!(rendered.contains("body:\n#!/bin/sh\n# truth-mirror managed hook"));
}
#[test]
fn husky_dry_run_plan_names_entire_bridge_paths() {
let mut plan = HookInstallPlan::new(
std::path::Path::new("/repo"),
std::path::Path::new("/repo/.truth/hooks"),
false,
);
plan.mode = HookInstallMode::Husky {
content_dir: std::path::PathBuf::from("/repo/.husky"),
};
let rendered = plan.render();
assert!(rendered.contains("bridge=commit-msg.pre-entire"));
assert!(rendered.contains("path=/repo/.husky/commit-msg.pre-entire"));
assert!(rendered.contains(super::HUSKY_ENTIRE_BRIDGE_MARKER));
}
#[test]
fn managed_block_replace_is_idempotent() {
let block = super::render_husky_block(HookName::CommitMsg, "");
let original = "#!/bin/sh\necho before\n";
let once = super::append_managed_block(original, &block);
let twice = super::append_managed_block(&once, &block);
assert_eq!(twice.matches(super::MANAGED_MARKER).count(), 1);
assert!(twice.contains("echo before"));
}
#[test]
fn chained_hook_safety_filter_detects_outer_tools() {
assert!(super::has_outer_hook_marker(
"#!/bin/sh\n# Entire CLI hooks\nentire hooks git commit-msg\n"
));
assert!(super::has_outer_hook_marker(
"#!/bin/sh\n# truth-mirror managed hook\nexec truth-mirror hook-dispatch commit-msg \"$@\"\n"
));
assert!(!super::has_outer_hook_marker("#!/bin/sh\necho safe\n"));
}
#[test]
fn run_chained_hook_skips_marker_bearing_stale_hook() {
let temp = tempfile::tempdir().unwrap();
let chained = temp.path().join("hooks/chained/commit-msg");
std::fs::create_dir_all(chained.parent().unwrap()).unwrap();
std::fs::write(&chained, "#!/bin/sh\n# Entire CLI hooks\nexit 42\n").unwrap();
super::make_executable(&chained).unwrap();
super::run_chained_hook(temp.path(), HookName::CommitMsg, &[]).unwrap();
}
#[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(), 3);
prop_assert_eq!(lines[0], "#!/bin/sh");
prop_assert_eq!(lines[1], super::MANAGED_MARKER);
prop_assert!(lines[2].starts_with("exec truth-mirror hook-dispatch "));
prop_assert!(lines[2].contains(hook.as_str()));
prop_assert_eq!(shim.matches(super::MANAGED_MARKER).count(), 1);
prop_assert_eq!(shim.matches("exec truth-mirror").count(), 1);
}
#[test]
fn husky_entire_bridge_rendering_stays_tiny_exec_only(index in 0usize..3) {
let hook = [HookName::CommitMsg, HookName::PostCommit, HookName::PrePush][index];
let bridge = super::render_husky_entire_bridge(hook);
let lines = bridge.lines().collect::<Vec<_>>();
prop_assert_eq!(lines.len(), 3);
prop_assert_eq!(lines[0], "#!/bin/sh");
prop_assert_eq!(lines[1], super::HUSKY_ENTIRE_BRIDGE_MARKER);
prop_assert_eq!(lines[2], format!("exec \"$(dirname \"$0\")/{}\" \"$@\"", hook.as_str()));
prop_assert_eq!(bridge.matches(super::HUSKY_ENTIRE_BRIDGE_MARKER).count(), 1);
prop_assert_eq!(bridge.matches("exec ").count(), 1);
}
}
}