use std::{
fs,
io::{self, Read},
path::{Component, 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];
pub(crate) const MANAGED_MARKER: &str = "# truth-mirror managed hook";
const MANAGED_END_MARKER: &str = "# truth-mirror hook end";
pub(crate) const FORWARDER_NAME: &str = "_forward-local.sh";
const OUTER_HOOK_MARKERS: &[&str] = &[
"# Entire CLI hooks",
"# truth-mirror managed hook",
"entire hooks git",
];
const STATE_GITIGNORE_HEADER: &str = "# truth-mirror runtime state - machine-generated";
const STATE_GITIGNORE_LINES: &[&str] = &[
"*",
"!.gitignore",
"!config.toml",
"runs/",
"review-queue.jsonl",
"ledger.jsonl",
"ledger.md",
"tmp/",
"logs/",
"*.local.*",
];
pub(crate) 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.";
const GROK_SKILL_NOTE: &str = "grok: installed .grok/skills/truth-mirror/SKILL.md — Grok does not inject passive hook stdout; run `truth-mirror reinject --agent grok` (skill reminds the agent). Project hooks need `/hooks-trust` if enforcement hooks are installed.";
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,
git_hooks_path: git_common_dir()?.join("hooks"),
mode: detect_hook_mode(&repo_root, state_dir)?,
..plan
};
let agents = file_surface_agents(&args);
let pi = pi_targeted(&args);
let grok = grok_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);
}
if grok {
println!("surface: grok -> {}", surface::GROK_SKILL_RELATIVE);
if config.enforcement.is_enabled() {
println!(
"surface: grok-enforcement -> {}",
surface::surface_relative_path(Agent::Grok)
);
}
}
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)?;
}
if grok {
if let Err(error) = surface::uninstall_enforcement(&repo_root, Agent::Grok) {
eprintln!(
"{} grok enforcement uninstall failed (continuing skill cleanup): {error}",
crate::messages::diagnostic_prefix()
);
}
surface::uninstall_grok_skill(&repo_root)?;
surface::cleanup_empty_grok_dirs(&repo_root);
}
uninstall_legacy_hook_references_at(&repo_root, &plan.git_hooks_path)?;
} else {
install(&plan, args.inject_forwarder)?;
print_async_review_guidance(&plan.global_args);
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}");
}
if args.grok {
surface::install_grok_skill(&repo_root)?;
if enforcement_enabled {
surface::install_enforcement(&repo_root, Agent::Grok, &plan.global_args)?;
}
println!("{GROK_SKILL_NOTE}");
}
}
Ok(ExitCode::SUCCESS)
}
fn print_async_review_guidance(global_args: &str) {
eprintln!(
"{} post-commit hooks queue async reviews; {}.",
crate::messages::diagnostic_prefix(),
crate::messages::async_review_drain_hint(global_args)
);
}
pub fn run_uninstall(
args: cli::UninstallArgs,
state_dir: &Path,
config_path: Option<&Path>,
) -> Result<std::process::ExitCode> {
let repo_root = git_root()?;
let hooks_path = repo_root.join(state_dir).join("hooks");
let global_args = hook_global_args(config_path, &repo_root, state_dir);
let plan = HookInstallPlan {
repo_root: repo_root.clone(),
hooks_path: hooks_path.clone(),
git_hooks_path: git_common_dir()?.join("hooks"),
uninstall: true,
mode: detect_hook_mode(&repo_root, state_dir)?,
global_args,
};
if args.dry_run {
println!("{}", plan.render());
let agent_list = surface::FILE_SURFACE_AGENTS
.iter()
.map(|agent| surface::agent_slug(*agent))
.chain(["pi", "grok"])
.collect::<Vec<_>>()
.join(", ");
println!("uninstall: would remove all agent surfaces ({agent_list})");
println!(
"uninstall: would clean .pre-entire bridges and orphaned .pre-truth-mirror backups"
);
println!("uninstall: would remove legacy .truth-mirror/hooks/ install artifacts");
println!(
"uninstall: would scrub legacy truth-mirror hook references without managed markers"
);
if args.purge {
println!(
"uninstall: --purge: would delete {} and .truth-mirror/ entirely",
state_dir.display()
);
} else {
println!(
"uninstall: ledger data in {} is preserved (use --purge to delete)",
state_dir.display()
);
}
return Ok(std::process::ExitCode::SUCCESS);
}
uninstall(&plan)?;
if !matches!(plan.mode, HookInstallMode::Plain) {
uninstall_plain(&plan)?;
}
let mut surface_errors: Vec<String> = Vec::new();
for &agent in surface::FILE_SURFACE_AGENTS.iter() {
if let Err(error) = surface::uninstall_enforcement(&repo_root, agent) {
surface_errors.push(format!(
"enforcement/{}: {error}",
surface::agent_slug(agent)
));
}
if let Err(error) = surface::SurfacePlan::for_agent(&repo_root, agent).uninstall() {
surface_errors.push(format!("surface/{}: {error}", surface::agent_slug(agent)));
}
}
if let Err(error) = surface::uninstall_pi_extension(&repo_root) {
surface_errors.push(format!("surface/pi-extension: {error}"));
} else {
let pi_extensions = repo_root.join(".pi/extensions");
let pi_dir = repo_root.join(".pi");
let _ = fs::remove_dir(&pi_extensions);
let _ = fs::remove_dir(&pi_dir);
}
if let Err(error) = surface::uninstall_enforcement(&repo_root, Agent::Grok) {
surface_errors.push(format!("enforcement/grok: {error}"));
}
if let Err(error) = surface::uninstall_grok_skill(&repo_root) {
surface_errors.push(format!("surface/grok-skill: {error}"));
} else {
surface::cleanup_empty_grok_dirs(&repo_root);
}
if let Err(error) = remove_legacy_pi_hooks(&repo_root) {
surface_errors.push(format!("surface/pi-legacy: {error}"));
}
if let Err(e) = uninstall_pre_entire_bridges(&plan) {
surface_errors.push(format!("bridges: {e}"));
}
let git_hooks = &plan.git_hooks_path;
if let Err(e) = cleanup_orphaned_backups(git_hooks) {
surface_errors.push(format!("backups/{}: {e}", git_hooks.display()));
}
if let HookInstallMode::CustomCommitted { ref hooks_path } = plan.mode.clone()
&& let Err(e) = cleanup_orphaned_backups(hooks_path)
{
surface_errors.push(format!("backups/{}: {e}", hooks_path.display()));
}
let legacy_state = repo_root.join(crate::config::LEGACY_STATE_DIR);
let legacy_hooks_dir = legacy_state.join("hooks");
if legacy_hooks_dir.is_dir()
&& let Err(e) = fs::remove_dir_all(&legacy_hooks_dir)
{
surface_errors.push(format!("legacy-hooks-dir: {e}"));
}
let real_state_dir = absolutize(&repo_root, state_dir);
remove_state_gitignore_if_pristine(&real_state_dir);
if args.purge {
if real_state_dir.is_dir()
&& let Err(e) = fs::remove_dir_all(&real_state_dir)
{
surface_errors.push(format!("purge {}: {e}", real_state_dir.display()));
}
if legacy_state.is_dir()
&& let Err(e) = fs::remove_dir_all(&legacy_state)
{
surface_errors.push(format!("purge {}: {e}", legacy_state.display()));
}
}
if let Err(e) = uninstall_legacy_hook_references_at(&repo_root, &plan.git_hooks_path) {
surface_errors.push(format!("legacy-hook-refs: {e}"));
}
if !surface_errors.is_empty() {
for error in &surface_errors {
eprintln!(
"{} uninstall error: {error}",
crate::messages::diagnostic_prefix()
);
}
return Ok(std::process::ExitCode::FAILURE);
}
Ok(std::process::ExitCode::SUCCESS)
}
fn uninstall_pre_entire_bridges(plan: &HookInstallPlan) -> Result<()> {
let dirs: Vec<PathBuf> = match &plan.mode {
HookInstallMode::Husky { content_dir } => {
vec![plan.git_hooks_path.clone(), content_dir.clone()]
}
_ => vec![plan.git_hooks_path.clone()],
};
for dir in dirs {
for hook in INSTALLED_HOOKS {
let bridge = dir.join(format!("{}.pre-entire", hook.as_str()));
if read_to_string_if_file(&bridge)?
.as_deref()
.is_some_and(is_truth_mirror_managed)
{
remove_file_if_exists(&bridge)?;
}
}
}
Ok(())
}
fn cleanup_orphaned_backups(hooks_dir: &Path) -> Result<()> {
if !hooks_dir.is_dir() {
return Ok(());
}
for hook in INSTALLED_HOOKS {
let active = hooks_dir.join(hook.as_str());
let backup = backup_path(&active, "pre-truth-mirror");
if !backup.is_file() {
continue;
}
let backup_bytes = match fs::read(&backup) {
Ok(b) => b,
Err(e) => {
eprintln!(
"{} warning: could not read backup {}; leaving it in place: {e}",
crate::messages::diagnostic_prefix(),
backup.display(),
);
continue;
}
};
if active.is_dir() {
eprintln!(
"{} warning: active hook path {} is a directory, not a file; \
leaving backup {} in place.",
crate::messages::diagnostic_prefix(),
active.display(),
backup.display(),
);
continue;
}
let active_bytes = match read_bytes_if_file(&active) {
Ok(b) => b,
Err(e) => {
eprintln!(
"{} warning: could not read active hook {}; leaving backup {} in place: {e}",
crate::messages::diagnostic_prefix(),
active.display(),
backup.display(),
);
continue;
}
};
let active_str = active_bytes
.as_deref()
.and_then(|b| std::str::from_utf8(b).ok());
if active_str.is_some_and(is_truth_mirror_managed) {
continue;
}
match active_bytes {
None => {
fs::write(&active, &backup_bytes)?;
make_executable(&active)?;
fs::remove_file(&backup)?;
}
Some(ref ab) if ab == &backup_bytes => {
fs::remove_file(&backup)?;
}
Some(_) => {
eprintln!(
"{} warning: preserved {} because its content differs from the active \
hook {}; inspect and remove manually.",
crate::messages::diagnostic_prefix(),
backup.display(),
active.display(),
);
}
}
}
Ok(())
}
fn remove_state_gitignore_if_pristine(state_dir: &Path) {
let gitignore = state_dir.join(".gitignore");
match read_to_string_if_file(&gitignore) {
Ok(Some(content)) if content.starts_with(STATE_GITIGNORE_HEADER) => {
if let Err(error) = remove_file_if_exists(&gitignore) {
eprintln!(
"{} warning: could not remove state .gitignore {}: {error}",
crate::messages::diagnostic_prefix(),
gitignore.display(),
);
}
}
Ok(Some(_)) => {
eprintln!(
"{} hint: {} appears user-modified; leaving it in place.",
crate::messages::diagnostic_prefix(),
gitignore.display(),
);
}
_ => {}
}
}
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 && !args.grok {
return surface::FILE_SURFACE_AGENTS.to_vec();
}
agents
}
fn pi_targeted(args: &cli::InstallHooksArgs) -> bool {
args.pi || (args.uninstall && !args.claude && !args.codex && !args.grok)
}
fn grok_targeted(args: &cli::InstallHooksArgs) -> bool {
args.grok || (args.uninstall && !args.claude && !args.codex && !args.pi)
}
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_path: Option<&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_path, config, &args.args)?,
}
Ok(ExitCode::SUCCESS)
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct HookInstallPlan {
pub repo_root: PathBuf,
pub hooks_path: PathBuf,
pub git_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(),
git_hooks_path: repo_root.join(".git/hooks"),
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)
));
}
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.git_hooks_path.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_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 {
let configured_path = normalize_path(&repo_relative_path(repo_root, Path::new(configured)));
[
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| normalize_path(&repo_relative_path(repo_root, candidate)) == configured_path)
}
fn normalize_path(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
if normalized.as_os_str().is_empty()
|| (!normalized.has_root() && normalized.ends_with(".."))
{
normalized.push("..");
} else {
normalized.pop();
}
}
Component::Normal(part) => normalized.push(part),
Component::RootDir | Component::Prefix(_) => normalized.push(component.as_os_str()),
}
}
normalized
}
fn install(plan: &HookInstallPlan, inject_forwarder: bool) -> Result<()> {
let state_dir = state_dir_from_hooks_path(&plan.hooks_path);
match &plan.mode {
HookInstallMode::Plain => {
prepare_state_hooks(plan)?;
migrate_truth_md(&plan.repo_root, state_dir)?;
install_plain(plan)
}
HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
prepare_state_hooks(plan)?;
migrate_truth_md(&plan.repo_root, state_dir)?;
unset_core_hooks_path(&plan.repo_root)?;
install_plain(plan)
}
HookInstallMode::Husky { content_dir } => {
prepare_state_hooks(plan)?;
migrate_truth_md(&plan.repo_root, state_dir)?;
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<()> {
fs::create_dir_all(&plan.git_hooks_path)?;
for hook in INSTALLED_HOOKS {
install_local_hook(plan, &plan.git_hooks_path, *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<()> {
for hook in INSTALLED_HOOKS {
let active = plan.git_hooks_path.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)?;
}
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)?;
}
}
}
Ok(())
}
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!(
"{} core.hooksPath is set to '{}' (a non-husky committed directory).\nTo install, either:\n (a) run `{}` 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",
crate::messages::diagnostic_prefix(),
hooks_path.display(),
crate::messages::command_for_cli(&plan.global_args, "install-hooks --inject-forwarder"),
);
}
if inject_forwarder {
inject_custom_forwarders(hooks_path)?;
}
prepare_state_hooks(plan)?;
let state_dir = state_dir_from_hooks_path(&plan.hooks_path);
migrate_truth_md(&plan.repo_root, state_dir)?;
fs::create_dir_all(&plan.git_hooks_path)?;
for hook in INSTALLED_HOOKS {
install_local_hook(plan, &plan.git_hooks_path, *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"]
}
fn shell_syntax_ok(path: &Path) -> bool {
std::process::Command::new("sh")
.args(["-n"])
.arg(path)
.status()
.map(|status| status.success())
.unwrap_or(false)
}
fn is_effectively_empty_shell(content: &str) -> bool {
content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.all(|line| line == "#!/bin/sh" || line == "#!/usr/bin/env sh" || line.starts_with("set "))
}
#[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| {
active_hook_lines(&content).any(|line| line.contains(FORWARDER_NAME))
|| content_forwards_to_local_git_hook(&content, hook)
|| 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 existing = read_to_string_if_file(&gitignore)?.unwrap_or_default();
let content = render_state_gitignore(&existing);
fs::write(&gitignore, content)?;
print_tracked_runtime_hints(repo_root, &state_dir)?;
Ok(())
}
fn render_state_gitignore(existing: &str) -> String {
let mut content = String::new();
content.push_str(STATE_GITIGNORE_HEADER);
content.push('\n');
for line in STATE_GITIGNORE_LINES {
content.push_str(line);
content.push('\n');
}
let standard = |line: &str| {
let trimmed = line.trim();
trimmed == STATE_GITIGNORE_HEADER || STATE_GITIGNORE_LINES.contains(&trimmed)
};
let mut wrote_separator = false;
for line in existing.lines().filter(|line| !standard(line)) {
if !wrote_separator {
content.push('\n');
wrote_separator = true;
}
content.push_str(line);
content.push('\n');
}
content
}
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("ledger.jsonl"),
rel_state.join("ledger.md"),
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))
|| (!line.trim_start().starts_with('#') && line_invokes_truth_hook_dispatch(line, None))
})
}
fn is_truth_mirror_managed(content: &str) -> bool {
content.contains(MANAGED_MARKER)
|| active_hook_lines(content).any(|line| line_invokes_truth_hook_dispatch(line, None))
}
const ENTIRE_MARKERS: &[&str] = &[
"# Entire CLI hooks",
"entire hooks git",
"command -v entire",
];
fn is_legacy_truth_reference(content: &str) -> bool {
content.contains("truth-mirror")
|| content.contains("truth_mirror_bin")
|| is_truth_mirror_managed(content)
}
fn is_simple_pre_entire_bridge(content: &str, hook_name: &str) -> bool {
let normalized: String = content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.collect::<Vec<_>>()
.join(" ");
normalized == format!(r#"exec "$(dirname "$0")/{hook_name}" "$@""#)
}
fn strip_legacy_truth_lines(content: &str) -> String {
let mut out = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.contains("truth-mirror")
|| trimmed.contains("truth_mirror_bin")
|| trimmed.starts_with("# Truthfulness gate")
|| trimmed.starts_with("# truth-mirror's")
{
continue;
}
out.push(line);
}
if out.is_empty() {
String::new()
} else {
format!("{}\n", out.join("\n"))
}
}
#[cfg(test)]
fn uninstall_legacy_hook_references(repo_root: &Path) -> Result<()> {
uninstall_legacy_hook_references_at(repo_root, &repo_root.join(".git/hooks"))
}
fn uninstall_legacy_hook_references_at(repo_root: &Path, git_hooks: &Path) -> Result<()> {
let husky_dir = repo_root.join(".husky");
let legacy_hooks = repo_root
.join(crate::config::LEGACY_STATE_DIR)
.join("hooks");
for hook in INSTALLED_HOOKS {
let hook_name = hook.as_str();
let names: Vec<String> = vec![hook_name.to_owned(), format!("{hook_name}.pre-entire")];
for dir in [&husky_dir, git_hooks, &legacy_hooks] {
for name in &names {
let path = dir.join(name);
let Some(content) = read_to_string_if_file(&path)? else {
continue;
};
if content.contains(MANAGED_MARKER) {
continue;
}
if name.ends_with(".pre-entire") {
if is_simple_pre_entire_bridge(&content, hook_name) {
continue;
}
if is_legacy_truth_reference(&content) || !shell_syntax_ok(&path) {
let bridge =
format!("#!/bin/sh\nexec \"$(dirname \"$0\")/{hook_name}\" \"$@\"\n");
fs::write(&path, bridge)?;
make_executable(&path)?;
}
continue;
}
if !is_legacy_truth_reference(&content) {
continue;
}
if ENTIRE_MARKERS.iter().any(|marker| content.contains(marker)) {
let stripped = strip_legacy_truth_lines(&content);
if is_effectively_empty_shell(&stripped) {
fs::remove_file(&path)?;
} else {
fs::write(&path, stripped)?;
make_executable(&path)?;
}
} else {
fs::remove_file(&path)?;
}
}
}
}
Ok(())
}
fn migrate_truth_md(repo_root: &Path, state_dir: &Path) -> Result<()> {
let target = state_dir.join("TRUTH.md");
if target.is_file() {
return Ok(());
}
let legacy = repo_root
.join(crate::config::LEGACY_STATE_DIR)
.join("TRUTH.md");
if legacy.is_file() {
fs::copy(&legacy, &target)?;
fs::remove_file(&legacy)?;
return Ok(());
}
let root = repo_root.join("TRUTH.md");
if root.is_file() {
fs::rename(&root, &target)?;
}
Ok(())
}
fn active_hook_lines(content: &str) -> impl Iterator<Item = &str> {
content
.lines()
.map(str::trim_start)
.filter(|line| !line.trim().is_empty() && !line.starts_with('#'))
}
fn content_forwards_to_local_git_hook(content: &str, hook: HookName) -> bool {
let resolves_git_dir =
content.contains("--git-common-dir") || content.contains("git rev-parse --git-common-dir");
resolves_git_dir
&& active_hook_lines(content)
.any(|line| line_forwards_to_local_git_hook_with_args(line, hook))
}
fn line_forwards_to_local_git_hook_with_args(line: &str, hook: HookName) -> bool {
line.contains("$@")
&& (line.contains(&format!("hooks/{}", hook.as_str()))
|| line.contains("hooks/$name")
|| line.contains("hooks/${name}")
|| line.contains("hooks/$hook")
|| line.contains("hooks/${hook}"))
}
fn line_invokes_truth_hook_dispatch(line: &str, expected_hook: Option<HookName>) -> bool {
crate::shell::shellish_token_segments(line)
.iter()
.any(|tokens| token_segment_invokes_truth_hook_dispatch(tokens, expected_hook))
}
fn token_segment_invokes_truth_hook_dispatch(
tokens: &[&str],
expected_hook: Option<HookName>,
) -> bool {
for (index, token) in tokens.iter().enumerate() {
if !is_truth_binary(token) {
continue;
}
let Some(dispatch_offset) = tokens[index + 1..]
.iter()
.position(|candidate| *candidate == "hook-dispatch")
else {
continue;
};
let hook_index = index + 1 + dispatch_offset + 1;
let Some(hook) = tokens.get(hook_index) else {
continue;
};
if expected_hook.is_some_and(|expected| hook == &expected.as_str())
|| expected_hook.is_none() && token_is_installed_hook(hook)
{
return true;
}
}
false
}
fn is_truth_binary(token: &str) -> bool {
token == "truth"
|| token == "truth-mirror"
|| token.ends_with("/truth")
|| token.ends_with("/truth-mirror")
}
fn token_is_installed_hook(token: &str) -> bool {
INSTALLED_HOOKS.iter().any(|hook| token == hook.as_str())
}
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 read_bytes_if_file(path: &Path) -> Result<Option<Vec<u8>>> {
match fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
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())?;
if let Err(error) = crate::watcher::ensure_watcher(state_dir) {
tracing::warn!(%error, "post-commit: failed to ensure a review watcher");
}
Ok(())
}
fn dispatch_pre_push(
state_dir: &Path,
config_path: Option<&Path>,
config: &crate::config::TruthMirrorConfig,
args: &[String],
) -> Result<()> {
let remote_name = pre_push_remote_name_from_args(args);
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_for_remote(line, remote_name) {
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_path,
config,
)?;
}
}
Ok(())
}
fn pre_push_range_from_line_for_remote(line: &str, remote_name: Option<&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) {
match remote_name.filter(|name| safe_pre_push_remote_name(name)) {
Some(remote_name) => Some(format!("new:{remote_name}:{local_sha}")),
None => Some(format!("new:{local_sha}")),
}
} else {
Some(format!("{remote_sha}..{local_sha}"))
}
}
fn pre_push_remote_name_from_args(args: &[String]) -> Option<&str> {
let remote_name = args.first()?.as_str();
let remote_url = args.get(1).map(String::as_str);
if remote_url == Some(remote_name) || !safe_pre_push_remote_name(remote_name) {
return None;
}
Some(remote_name)
}
fn safe_pre_push_remote_name(value: &str) -> bool {
!value.is_empty()
&& !value.starts_with('-')
&& !value.contains(':')
&& !value.contains("..")
&& !value
.chars()
.any(|character| character.is_whitespace() || character.is_control())
&& !value
.chars()
.any(|character| matches!(character, '*' | '?' | '['))
}
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!(
"{} skipping unsafe chained hook {} because it appears to invoke an outer hook manager",
crate::messages::diagnostic_prefix(),
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_common_dir() -> Result<PathBuf> {
let common_dir = git_stdout(&["rev-parse", "--git-common-dir"])?;
let common_dir = Path::new(common_dir.trim());
if common_dir.is_absolute() {
Ok(common_dir.to_path_buf())
} else {
Ok(std::env::current_dir()?.join(common_dir))
}
}
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 std::{fs, process::Command};
use proptest::prelude::*;
use super::{HookInstallPlan, pre_push_range_from_line_for_remote, 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 normalize_path_preserves_unmatched_relative_parents() {
use std::path::{Path, PathBuf};
assert_eq!(
super::normalize_path(Path::new("../hooks")),
PathBuf::from("../hooks")
);
assert_eq!(
super::normalize_path(Path::new("a/../../hooks")),
PathBuf::from("../hooks")
);
}
#[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 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 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\nexec truth hook-dispatch commit-msg \"$@\"\n"
));
assert!(super::has_outer_hook_marker(
"#!/bin/sh\nexec /usr/local/bin/truth hook-dispatch commit-msg \"$@\"\n"
));
assert!(!super::has_outer_hook_marker(
"#!/bin/sh\n# exec truth hook-dispatch commit-msg \"$@\"\n"
));
assert!(!super::has_outer_hook_marker("#!/bin/sh\necho safe\n"));
}
#[test]
fn managed_detection_ignores_commented_and_unrelated_truth_lines() {
assert!(!super::is_truth_mirror_managed(
"#!/bin/sh\n# exec truth hook-dispatch commit-msg \"$@\"\n"
));
assert!(!super::is_truth_mirror_managed(
"#!/bin/sh\nexec truth unrelated \"$@\"\n"
));
assert!(!super::is_truth_mirror_managed(
"#!/bin/sh\nexec truth --version && hook-dispatch commit-msg \"$@\"\n"
));
assert!(!super::is_truth_mirror_managed(
"#!/bin/sh\nhook-dispatch commit-msg \"$@\"; exec truth --version\n"
));
assert!(super::is_truth_mirror_managed(
"#!/bin/sh\nexec /opt/bin/truth hook-dispatch pre-push \"$@\"\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_for_remote(line, None),
Some("def456..abc123".to_owned())
);
}
#[test]
fn pre_push_new_branch_maps_to_new_branch_token() {
let line =
"refs/heads/topic abc123 refs/heads/topic 0000000000000000000000000000000000000000";
assert_eq!(
pre_push_range_from_line_for_remote(line, None),
Some("new:abc123".to_owned())
);
}
#[test]
fn pre_push_new_branch_includes_safe_remote_name_when_available() {
let line =
"refs/heads/topic abc123 refs/heads/topic 0000000000000000000000000000000000000000";
assert_eq!(
pre_push_range_from_line_for_remote(line, Some("origin")),
Some("new:origin:abc123".to_owned())
);
assert_eq!(
pre_push_range_from_line_for_remote(line, Some("../bad")),
Some("new:abc123".to_owned())
);
}
#[test]
fn pre_push_args_omit_remote_name_for_direct_path_pushes() {
assert_eq!(
super::pre_push_remote_name_from_args(&[
"origin".to_owned(),
"git@example/repo".to_owned()
]),
Some("origin")
);
assert_eq!(
super::pre_push_remote_name_from_args(&[
"../repo.git".to_owned(),
"../repo.git".to_owned()
]),
None
);
}
#[test]
fn pre_push_args_omit_glob_like_remote_names() {
for remote_name in ["bad*remote", "bad?remote", "bad[remote"] {
assert_eq!(
super::pre_push_remote_name_from_args(&[
remote_name.to_owned(),
"git@example/repo".to_owned()
]),
None
);
}
}
#[test]
fn legacy_truth_only_husky_hook_is_removed() {
let temp = tempfile::tempdir().unwrap();
let husky = temp.path().join(".husky");
fs::create_dir_all(&husky).unwrap();
let hook = husky.join("commit-msg");
fs::write(
&hook,
"#!/usr/bin/env sh\nset -e\n# Truthfulness gate\nif command -v truth-mirror >/dev/null 2>&1; then truth-mirror hook-dispatch commit-msg \"$@\"; else :; fi\n",
)
.unwrap();
super::make_executable(&hook).unwrap();
super::uninstall_legacy_hook_references(temp.path()).unwrap();
assert!(!hook.exists());
}
#[test]
fn legacy_truth_hook_with_entire_is_stripped_not_removed() {
let temp = tempfile::tempdir().unwrap();
let husky = temp.path().join(".husky");
fs::create_dir_all(&husky).unwrap();
let hook = husky.join("pre-push");
fs::write(
&hook,
"#!/bin/sh\nif command -v entire >/dev/null 2>&1; then entire hooks git pre-push \"$1\"; fi\nexec truth-mirror hook-dispatch pre-push \"$@\"\n",
)
.unwrap();
super::make_executable(&hook).unwrap();
super::uninstall_legacy_hook_references(temp.path()).unwrap();
let content = fs::read_to_string(&hook).unwrap();
assert!(!content.contains("truth-mirror"));
assert!(content.contains("entire hooks git pre-push"));
}
#[test]
fn legacy_truth_mirror_bin_pre_entire_bridge_is_rewritten_to_simple_bridge() {
let temp = tempfile::tempdir().unwrap();
let husky = temp.path().join(".husky");
fs::create_dir_all(&husky).unwrap();
let bridge = husky.join("pre-push.pre-entire");
fs::write(
&bridge,
"#!/usr/bin/env sh\ntruth_mirror_bin=\"$(command -v truth-mirror || true)\"\n[ -n \"$truth_mirror_bin\" ] || exit 0\n\"$truth_mirror_bin\" hook-dispatch pre-push \"$@\"\n",
)
.unwrap();
super::make_executable(&bridge).unwrap();
super::uninstall_legacy_hook_references(temp.path()).unwrap();
let content = fs::read_to_string(&bridge).unwrap();
assert!(!content.contains("truth-mirror"));
assert!(content.contains(r#"exec "$(dirname "$0")/pre-push" "$@""#));
}
#[test]
fn broken_pre_entire_bridge_is_rewritten_even_without_truth_reference() {
let temp = tempfile::tempdir().unwrap();
let husky = temp.path().join(".husky");
fs::create_dir_all(&husky).unwrap();
let bridge = husky.join("pre-push.pre-entire");
fs::write(
&bridge,
"#!/usr/bin/env sh\nset -e\nif command -v entire >/dev/null; then\n echo ok\nelse\nfi\n",
)
.unwrap();
super::make_executable(&bridge).unwrap();
super::uninstall_legacy_hook_references(temp.path()).unwrap();
let content = fs::read_to_string(&bridge).unwrap();
assert!(content.contains(r#"exec "$(dirname "$0")/pre-push" "$@""#));
}
#[test]
fn simple_pre_entire_bridge_is_preserved() {
let temp = tempfile::tempdir().unwrap();
let husky = temp.path().join(".husky");
fs::create_dir_all(&husky).unwrap();
let bridge = husky.join("commit-msg.pre-entire");
fs::write(
&bridge,
"#!/bin/sh\nexec \"$(dirname \"$0\")/commit-msg\" \"$@\"\n",
)
.unwrap();
super::make_executable(&bridge).unwrap();
super::uninstall_legacy_hook_references(temp.path()).unwrap();
let content = fs::read_to_string(&bridge).unwrap();
assert!(content.contains(r#"exec "$(dirname "$0")/commit-msg" "$@""#));
}
#[test]
fn install_migrates_root_truth_md_to_state_dir() {
let temp = tempfile::tempdir().unwrap();
let repo_root = temp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
Command::new("git")
.args(["init"])
.current_dir(&repo_root)
.output()
.unwrap();
let root_truth = repo_root.join("TRUTH.md");
fs::write(&root_truth, "# truth\n").unwrap();
let state_dir = repo_root.join(".truth");
fs::create_dir_all(&state_dir).unwrap();
super::migrate_truth_md(&repo_root, &state_dir).unwrap();
assert!(state_dir.join("TRUTH.md").is_file());
assert!(!root_truth.exists());
assert_eq!(
fs::read_to_string(state_dir.join("TRUTH.md")).unwrap(),
"# truth\n"
);
}
#[test]
fn install_migrates_legacy_truth_mirror_md_to_state_dir() {
let temp = tempfile::tempdir().unwrap();
let repo_root = temp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
let legacy_dir = repo_root.join(".truth-mirror");
fs::create_dir_all(&legacy_dir).unwrap();
fs::write(legacy_dir.join("TRUTH.md"), "# legacy truth\n").unwrap();
let state_dir = repo_root.join(".truth");
fs::create_dir_all(&state_dir).unwrap();
super::migrate_truth_md(&repo_root, &state_dir).unwrap();
assert!(state_dir.join("TRUTH.md").is_file());
assert!(!legacy_dir.join("TRUTH.md").exists());
}
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);
}
}
}