Skip to main content

truth_mirror/
hooks.rs

1//! Hook installation, uninstallation, and dry-run planning.
2
3use std::{
4    fs,
5    io::{self, Read},
6    path::{Component, Path, PathBuf},
7    process::{Command, ExitCode},
8};
9
10use anyhow::{Context, Result, bail};
11
12use crate::{
13    claim,
14    cli::{self, Agent, HookName},
15    gate,
16    reviewer::ReviewQueue,
17    surface::{self, SurfacePlan},
18};
19
20const INSTALLED_HOOKS: &[HookName] =
21    &[HookName::CommitMsg, HookName::PostCommit, HookName::PrePush];
22pub(crate) const MANAGED_MARKER: &str = "# truth-mirror managed hook";
23const MANAGED_END_MARKER: &str = "# truth-mirror hook end";
24pub(crate) const HUSKY_ENTIRE_BRIDGE_MARKER: &str = "# Bridge for the husky+entire chain";
25pub(crate) const FORWARDER_NAME: &str = "_forward-local.sh";
26const OUTER_HOOK_MARKERS: &[&str] = &[
27    "# Entire CLI hooks",
28    HUSKY_ENTIRE_BRIDGE_MARKER,
29    "# truth-mirror managed hook",
30    "entire hooks git",
31];
32const STATE_GITIGNORE_HEADER: &str = "# truth-mirror runtime state - machine-generated";
33const STATE_GITIGNORE_LINES: &[&str] =
34    &["runs/", "review-queue.jsonl", "tmp/", "logs/", "*.local.*"];
35pub(crate) const FORWARDER_SOURCE: &str = r#"#!/bin/sh
36# _forward-local.sh - delegate to .git/hooks from a committed core.hooksPath.
37# Uses --git-common-dir (resolves to .git/) not core.hooksPath to prevent an infinite loop.
38name="$1"; shift
39git_dir="$(git rev-parse --git-common-dir 2>/dev/null)"
40[ -n "$git_dir" ] || exit 0
41local_hook="$git_dir/hooks/$name"
42if [ -x "$local_hook" ]; then exec "$local_hook" "$@"; fi
43exit 0
44"#;
45
46/// Printed after `--pi` install: Pi only executes project extensions once the
47/// folder is trusted (verified against Pi 0.80.3 `core/project-trust.js`).
48const PI_TRUST_NOTE: &str = "pi: installed .pi/extensions/truth-mirror.js — Pi loads it once you trust this project folder in Pi.";
49
50pub fn run(
51    args: cli::InstallHooksArgs,
52    state_dir: &Path,
53    config_path: Option<&Path>,
54    config: &crate::config::TruthMirrorConfig,
55) -> Result<ExitCode> {
56    let repo_root = git_root()?;
57    let hooks_path = repo_root.join(state_dir).join("hooks");
58    let plan = HookInstallPlan::new(&repo_root, &hooks_path, args.uninstall);
59    // Preserve non-default global flags so the INSTALLED hooks use the same config
60    // and state dir at runtime instead of silently reloading defaults.
61    let global_args = hook_global_args(config_path, &repo_root, state_dir);
62    let plan = HookInstallPlan {
63        global_args,
64        mode: detect_hook_mode(&repo_root, state_dir)?,
65        ..plan
66    };
67    let agents = file_surface_agents(&args);
68    let pi = pi_targeted(&args);
69
70    if args.dry_run {
71        println!("{}", plan.render());
72        for agent in &agents {
73            println!(
74                "surface: {} -> {}",
75                surface::agent_slug(*agent),
76                surface::surface_relative_path(*agent)
77            );
78        }
79        if pi {
80            println!("surface: pi -> {}", surface::PI_EXTENSION_RELATIVE);
81        }
82        return Ok(ExitCode::SUCCESS);
83    }
84
85    let enforcement_enabled = config.enforcement.is_enabled();
86
87    if args.uninstall {
88        uninstall(&plan)?;
89        for agent in &agents {
90            surface::uninstall_enforcement(&repo_root, *agent)?;
91            SurfacePlan::for_agent(&repo_root, *agent).uninstall()?;
92        }
93        if pi {
94            surface::uninstall_pi_extension(&repo_root)?;
95            remove_legacy_pi_hooks(&repo_root)?;
96        }
97    } else {
98        install(&plan, args.inject_forwarder)?;
99        print_async_review_guidance(&plan.global_args);
100        for agent in &agents {
101            SurfacePlan::for_agent(&repo_root, *agent).install()?;
102            // Enforcement is opt-in: only install the tool-blocking hook when the
103            // repo config enables it. Preserve global flags so it uses this config.
104            if enforcement_enabled {
105                surface::install_enforcement(&repo_root, *agent, &plan.global_args)?;
106            }
107        }
108        if args.pi {
109            // Clean any bogus .pi/hooks.json a prior truth-mirror wrote, install the
110            // real project-local extension, and note the one-time trust step.
111            remove_legacy_pi_hooks(&repo_root)?;
112            surface::install_pi_extension(&repo_root)?;
113            println!("{PI_TRUST_NOTE}");
114        }
115    }
116
117    Ok(ExitCode::SUCCESS)
118}
119
120fn print_async_review_guidance(global_args: &str) {
121    eprintln!(
122        "{} post-commit hooks queue async reviews; {}.",
123        crate::messages::diagnostic_prefix(),
124        crate::messages::async_review_drain_hint(global_args)
125    );
126}
127
128/// File-surface agents (Claude, Codex) touched by this invocation. Install acts
129/// only on explicitly selected agents; a bare `install-hooks --uninstall` clears
130/// all file surfaces so it fully reverses a prior per-agent install.
131fn file_surface_agents(args: &cli::InstallHooksArgs) -> Vec<Agent> {
132    let mut agents = Vec::new();
133    if args.claude {
134        agents.push(Agent::Claude);
135    }
136    if args.codex {
137        agents.push(Agent::Codex);
138    }
139
140    if agents.is_empty() && args.uninstall && !args.pi {
141        return surface::FILE_SURFACE_AGENTS.to_vec();
142    }
143    agents
144}
145
146/// Whether this invocation should act on Pi: explicit `--pi`, or a bare
147/// `--uninstall` (no agent flags) that clears everything including legacy Pi files.
148fn pi_targeted(args: &cli::InstallHooksArgs) -> bool {
149    args.pi || (args.uninstall && !args.claude && !args.codex)
150}
151
152/// Remove a `.pi/hooks.json` left by an earlier (incorrect) truth-mirror version.
153fn remove_legacy_pi_hooks(repo_root: &Path) -> Result<()> {
154    let path = repo_root.join(".pi/hooks.json");
155    if path.is_file() {
156        fs::remove_file(&path)?;
157    }
158    Ok(())
159}
160
161pub fn dispatch(
162    args: cli::HookDispatchArgs,
163    state_dir: &Path,
164    config_path: Option<&Path>,
165    config: &crate::config::TruthMirrorConfig,
166) -> Result<ExitCode> {
167    run_chained_hook(state_dir, args.hook, &args.args)?;
168
169    match args.hook {
170        HookName::CommitMsg => dispatch_commit_msg(state_dir, &args.args, config)?,
171        HookName::PostCommit => dispatch_post_commit(state_dir)?,
172        HookName::PrePush => dispatch_pre_push(state_dir, config_path, config, &args.args)?,
173    }
174
175    Ok(ExitCode::SUCCESS)
176}
177
178#[derive(Clone, Debug, Default, Eq, PartialEq)]
179pub struct HookInstallPlan {
180    pub repo_root: PathBuf,
181    pub hooks_path: PathBuf,
182    pub uninstall: bool,
183    pub mode: HookInstallMode,
184    /// Global CLI flags (`--config`, `--state-dir`) preserved into the shims so a
185    /// custom-config install keeps using that config at hook runtime. Empty for a
186    /// default install (the trailing space is included when non-empty).
187    pub global_args: String,
188}
189
190#[derive(Clone, Debug, Default, Eq, PartialEq)]
191pub enum HookInstallMode {
192    #[default]
193    Plain,
194    LegacyTruthMirrorHooksPath {
195        configured_path: String,
196    },
197    Husky {
198        content_dir: PathBuf,
199    },
200    CustomCommitted {
201        hooks_path: PathBuf,
202    },
203}
204
205impl HookInstallPlan {
206    pub fn new(repo_root: &Path, hooks_path: &Path, uninstall: bool) -> Self {
207        Self {
208            repo_root: repo_root.to_path_buf(),
209            hooks_path: hooks_path.to_path_buf(),
210            uninstall,
211            mode: HookInstallMode::Plain,
212            global_args: String::new(),
213        }
214    }
215
216    pub fn render(&self) -> String {
217        let action = if self.uninstall {
218            "uninstall"
219        } else {
220            "install"
221        };
222        let mut output = format!(
223            "truth-mirror hook plan\nrepo={}\naction={action}\nmode={}\nstateHooks={}\n",
224            self.repo_root.display(),
225            self.mode.as_str(),
226            self.hooks_path.display()
227        );
228        for hook in INSTALLED_HOOKS {
229            output.push_str(&format!(
230                "\nhook={}\npath={}\nbody:\n{}",
231                hook.as_str(),
232                self.active_hook_path(*hook).display(),
233                self.render_hook_body(*hook)
234            ));
235            if let HookInstallMode::Husky { content_dir } = &self.mode {
236                output.push_str(&format!(
237                    "\nbridge={}.pre-entire\npath={}\nbody:\n{}",
238                    hook.as_str(),
239                    husky_entire_bridge_path(content_dir, *hook).display(),
240                    render_husky_entire_bridge(*hook)
241                ));
242            }
243        }
244        output
245    }
246
247    fn active_hook_path(&self, hook: HookName) -> PathBuf {
248        match &self.mode {
249            HookInstallMode::Husky { content_dir } => content_dir.join(hook.as_str()),
250            HookInstallMode::Plain | HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
251                self.repo_root.join(".git/hooks").join(hook.as_str())
252            }
253            HookInstallMode::CustomCommitted { hooks_path } => hooks_path.join(hook.as_str()),
254        }
255    }
256
257    fn render_hook_body(&self, hook: HookName) -> String {
258        match self.mode {
259            HookInstallMode::Husky { .. } => render_husky_block(hook, &self.global_args),
260            HookInstallMode::CustomCommitted { .. } => render_custom_forwarder_stub(hook),
261            HookInstallMode::Plain | HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
262                render_shim(hook, &self.global_args)
263            }
264        }
265    }
266}
267
268impl HookInstallMode {
269    fn as_str(&self) -> &'static str {
270        match self {
271            HookInstallMode::Plain => "plain",
272            HookInstallMode::LegacyTruthMirrorHooksPath { .. } => "plain-legacy-truth-mirror",
273            HookInstallMode::Husky { .. } => "husky",
274            HookInstallMode::CustomCommitted { .. } => "custom-committed",
275        }
276    }
277}
278
279/// Build the preserved global-flag prefix (with a trailing space when non-empty).
280fn hook_global_args(config_path: Option<&Path>, repo_root: &Path, state_dir: &Path) -> String {
281    let mut parts = Vec::new();
282    if let Some(config) = config_path {
283        parts.push(format!(
284            "--config {}",
285            quote_git_arg(&absolutize(repo_root, config))
286        ));
287    }
288    if state_dir != Path::new(crate::config::DEFAULT_STATE_DIR) {
289        parts.push(format!(
290            "--state-dir {}",
291            quote_git_arg(&absolutize(repo_root, state_dir))
292        ));
293    }
294    if parts.is_empty() {
295        String::new()
296    } else {
297        format!("{} ", parts.join(" "))
298    }
299}
300
301fn absolutize(repo_root: &Path, path: &Path) -> PathBuf {
302    if path.is_absolute() {
303        path.to_path_buf()
304    } else {
305        repo_root.join(path)
306    }
307}
308
309/// POSIX single-quote escaping for a path embedded in the generated `/bin/sh`
310/// shim: wrap in single quotes and replace any embedded single quote with `'\''`
311/// so no metacharacter (`;`, `$()`, `'`, spaces, …) can break or inject shell.
312fn quote_git_arg(path: &Path) -> String {
313    let value = path.to_string_lossy();
314    format!("'{}'", value.replace('\'', "'\\''"))
315}
316
317pub fn render_shim(hook: HookName, global_args: &str) -> String {
318    format!(
319        "#!/bin/sh\n{MANAGED_MARKER}\nexec truth-mirror {global_args}hook-dispatch {} \"$@\"\n",
320        hook.as_str()
321    )
322}
323
324fn render_husky_block(hook: HookName, global_args: &str) -> String {
325    format!(
326        "{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",
327        hook.as_str()
328    )
329}
330
331fn render_husky_entire_bridge(hook: HookName) -> String {
332    format!(
333        "#!/bin/sh\n{HUSKY_ENTIRE_BRIDGE_MARKER}\n{}\n",
334        husky_entire_bridge_exec_line(hook)
335    )
336}
337
338fn husky_entire_bridge_exec_line(hook: HookName) -> String {
339    format!("exec \"$(dirname \"$0\")/{}\" \"$@\"", hook.as_str())
340}
341
342fn render_custom_forwarder_stub(hook: HookName) -> String {
343    format!(
344        "#!/bin/sh\n{MANAGED_MARKER}\nexec \"$(dirname \"$0\")/{FORWARDER_NAME}\" {} \"$@\"\n",
345        hook.as_str()
346    )
347}
348
349fn detect_hook_mode(repo_root: &Path, state_dir: &Path) -> Result<HookInstallMode> {
350    let Some(configured) = git_config_get(repo_root, "core.hooksPath")? else {
351        return Ok(HookInstallMode::Plain);
352    };
353    let configured = configured.trim().to_owned();
354    if configured.is_empty() {
355        return Ok(HookInstallMode::Plain);
356    }
357    if is_managed_hooks_path(repo_root, state_dir, &configured) {
358        return Ok(HookInstallMode::LegacyTruthMirrorHooksPath {
359            configured_path: configured,
360        });
361    }
362    if configured.contains(".husky") {
363        let hooks_path = repo_relative_path(repo_root, Path::new(&configured));
364        let content_dir = if hooks_path.file_name().and_then(|name| name.to_str()) == Some("_") {
365            hooks_path
366                .parent()
367                .map_or_else(|| repo_root.join(".husky"), Path::to_path_buf)
368        } else {
369            hooks_path
370        };
371        return Ok(HookInstallMode::Husky { content_dir });
372    }
373    Ok(HookInstallMode::CustomCommitted {
374        hooks_path: repo_relative_path(repo_root, Path::new(&configured)),
375    })
376}
377
378fn repo_relative_path(repo_root: &Path, path: &Path) -> PathBuf {
379    if path.is_absolute() {
380        path.to_path_buf()
381    } else {
382        repo_root.join(path)
383    }
384}
385
386fn is_managed_hooks_path(repo_root: &Path, state_dir: &Path, configured: &str) -> bool {
387    let configured_path = normalize_path(&repo_relative_path(repo_root, Path::new(configured)));
388    [
389        state_dir.join("hooks"),
390        PathBuf::from(crate::config::DEFAULT_STATE_DIR).join("hooks"),
391        PathBuf::from(crate::config::LEGACY_STATE_DIR).join("hooks"),
392    ]
393    .iter()
394    .any(|candidate| normalize_path(&repo_relative_path(repo_root, candidate)) == configured_path)
395}
396
397fn normalize_path(path: &Path) -> PathBuf {
398    let mut normalized = PathBuf::new();
399    for component in path.components() {
400        match component {
401            Component::CurDir => {}
402            Component::ParentDir => {
403                if normalized.as_os_str().is_empty()
404                    || (!normalized.has_root() && normalized.ends_with(".."))
405                {
406                    normalized.push("..");
407                } else {
408                    normalized.pop();
409                }
410            }
411            Component::Normal(part) => normalized.push(part),
412            Component::RootDir | Component::Prefix(_) => normalized.push(component.as_os_str()),
413        }
414    }
415    normalized
416}
417
418fn install(plan: &HookInstallPlan, inject_forwarder: bool) -> Result<()> {
419    match &plan.mode {
420        HookInstallMode::Plain => {
421            prepare_state_hooks(plan)?;
422            install_plain(plan)
423        }
424        HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
425            prepare_state_hooks(plan)?;
426            unset_core_hooks_path(&plan.repo_root)?;
427            install_plain(plan)
428        }
429        HookInstallMode::Husky { content_dir } => {
430            prepare_state_hooks(plan)?;
431            install_husky(plan, content_dir)
432        }
433        HookInstallMode::CustomCommitted { hooks_path } => {
434            install_custom(plan, hooks_path, inject_forwarder)
435        }
436    }
437}
438
439fn uninstall(plan: &HookInstallPlan) -> Result<()> {
440    match &plan.mode {
441        HookInstallMode::Plain => uninstall_plain(plan)?,
442        HookInstallMode::LegacyTruthMirrorHooksPath { .. } => {
443            unset_core_hooks_path(&plan.repo_root)?;
444            uninstall_plain(plan)?;
445        }
446        HookInstallMode::Husky { content_dir } => uninstall_husky(plan, content_dir)?,
447        HookInstallMode::CustomCommitted { hooks_path } => {
448            uninstall_plain(plan)?;
449            uninstall_custom_forwarders(hooks_path)?;
450        }
451    }
452
453    if plan.hooks_path.exists() {
454        fs::remove_dir_all(&plan.hooks_path)?;
455    }
456    Ok(())
457}
458
459fn state_dir_from_hooks_path(hooks_path: &Path) -> &Path {
460    hooks_path.parent().unwrap_or(hooks_path)
461}
462
463fn prepare_state_hooks(plan: &HookInstallPlan) -> Result<()> {
464    ensure_state_gitignore(&plan.repo_root, state_dir_from_hooks_path(&plan.hooks_path))?;
465    fs::create_dir_all(&plan.hooks_path)?;
466    fs::create_dir_all(plan.hooks_path.join("chained"))?;
467    clear_chained_hooks(plan)
468}
469
470fn install_plain(plan: &HookInstallPlan) -> Result<()> {
471    let git_hooks = plan.repo_root.join(".git/hooks");
472    fs::create_dir_all(&git_hooks)?;
473    for hook in INSTALLED_HOOKS {
474        install_local_hook(plan, &git_hooks, *hook)?;
475    }
476    Ok(())
477}
478
479fn install_local_hook(plan: &HookInstallPlan, git_hooks: &Path, hook: HookName) -> Result<()> {
480    let active = git_hooks.join(hook.as_str());
481    let backup = backup_path(&active, "pre-truth-mirror");
482    let chained = plan.hooks_path.join("chained").join(hook.as_str());
483    remove_file_if_exists(&chained)?;
484
485    let source = local_hook_source(&active, &backup)?;
486    if let Some(source) = source {
487        if source.path != backup {
488            fs::copy(&source.path, &backup)?;
489            make_executable(&backup)?;
490        }
491        if !has_outer_hook_marker(&source.content) {
492            fs::copy(&source.path, &chained)?;
493            make_executable(&chained)?;
494        }
495    }
496
497    fs::write(&active, render_shim(hook, &plan.global_args))?;
498    make_executable(&active)?;
499    Ok(())
500}
501
502fn uninstall_plain(plan: &HookInstallPlan) -> Result<()> {
503    let git_hooks = plan.repo_root.join(".git/hooks");
504    for hook in INSTALLED_HOOKS {
505        let active = git_hooks.join(hook.as_str());
506        let backup = backup_path(&active, "pre-truth-mirror");
507        let active_content = read_to_string_if_file(&active)?;
508        if active_content
509            .as_deref()
510            .is_some_and(is_truth_mirror_managed)
511        {
512            if backup.is_file() {
513                fs::copy(&backup, &active)?;
514                make_executable(&active)?;
515                fs::remove_file(&backup)?;
516            } else {
517                remove_file_if_exists(&active)?;
518            }
519        }
520        remove_file_if_exists(&plan.hooks_path.join("chained").join(hook.as_str()))?;
521    }
522    Ok(())
523}
524
525fn install_husky(plan: &HookInstallPlan, content_dir: &Path) -> Result<()> {
526    fs::create_dir_all(content_dir)?;
527    for hook in INSTALLED_HOOKS {
528        let target = content_dir.join(hook.as_str());
529        let block = render_husky_block(*hook, &plan.global_args);
530        let next = match read_to_string_if_file(&target)? {
531            Some(content) => append_managed_block(&content, &block),
532            None => format!("#!/bin/sh\n{block}"),
533        };
534        fs::write(&target, next)?;
535        make_executable(&target)?;
536        install_husky_entire_bridge(content_dir, *hook)?;
537    }
538    Ok(())
539}
540
541fn install_husky_entire_bridge(content_dir: &Path, hook: HookName) -> Result<()> {
542    let target = husky_entire_bridge_path(content_dir, hook);
543    let backup = backup_path(&target, "pre-truth-mirror");
544    if let Some(content) = read_to_string_if_file(&target)? {
545        if is_husky_entire_bridge(&content) && has_husky_entire_bridge_exec(&content, hook) {
546            make_executable(&target)?;
547            return Ok(());
548        }
549        if !is_husky_entire_bridge(&content) {
550            fs::copy(&target, &backup)?;
551            make_executable(&backup)?;
552        }
553    }
554    fs::write(&target, render_husky_entire_bridge(hook))?;
555    make_executable(&target)?;
556    Ok(())
557}
558
559fn uninstall_husky(_plan: &HookInstallPlan, content_dir: &Path) -> Result<()> {
560    for hook in INSTALLED_HOOKS {
561        let target = content_dir.join(hook.as_str());
562        if let Some(content) = read_to_string_if_file(&target)? {
563            let next = remove_managed_block(&content);
564            if is_empty_or_shebang_only(&next) {
565                fs::remove_file(&target)?;
566            } else {
567                fs::write(&target, next)?;
568                make_executable(&target)?;
569            }
570        }
571        uninstall_husky_entire_bridge(content_dir, *hook)?;
572    }
573    Ok(())
574}
575
576fn uninstall_husky_entire_bridge(content_dir: &Path, hook: HookName) -> Result<()> {
577    let target = husky_entire_bridge_path(content_dir, hook);
578    let backup = backup_path(&target, "pre-truth-mirror");
579    match read_to_string_if_file(&target)? {
580        Some(content) if is_husky_entire_bridge(&content) => {
581            if backup.is_file() {
582                fs::copy(&backup, &target)?;
583                make_executable(&target)?;
584                fs::remove_file(&backup)?;
585            } else {
586                remove_file_if_exists(&target)?;
587            }
588        }
589        None if backup.is_file() => {
590            fs::copy(&backup, &target)?;
591            make_executable(&target)?;
592            fs::remove_file(&backup)?;
593        }
594        _ => {}
595    }
596    Ok(())
597}
598
599fn husky_entire_bridge_path(content_dir: &Path, hook: HookName) -> PathBuf {
600    content_dir.join(format!("{}.pre-entire", hook.as_str()))
601}
602
603fn install_custom(plan: &HookInstallPlan, hooks_path: &Path, inject_forwarder: bool) -> Result<()> {
604    let missing_forwarder = INSTALLED_HOOKS
605        .iter()
606        .any(|hook| !custom_forwarder_present(hooks_path, *hook));
607    if missing_forwarder && !inject_forwarder {
608        bail!(
609            "{} 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",
610            crate::messages::diagnostic_prefix(),
611            hooks_path.display(),
612            crate::messages::command_for_cli(&plan.global_args, "install-hooks --inject-forwarder"),
613        );
614    }
615
616    if inject_forwarder {
617        inject_custom_forwarders(hooks_path)?;
618    }
619
620    prepare_state_hooks(plan)?;
621    let git_hooks = plan.repo_root.join(".git/hooks");
622    fs::create_dir_all(&git_hooks)?;
623    for hook in INSTALLED_HOOKS {
624        install_local_hook(plan, &git_hooks, *hook)?;
625    }
626    Ok(())
627}
628
629fn inject_custom_forwarders(hooks_path: &Path) -> Result<()> {
630    fs::create_dir_all(hooks_path)?;
631    let forwarder = hooks_path.join(FORWARDER_NAME);
632    fs::write(&forwarder, FORWARDER_SOURCE)?;
633    make_executable(&forwarder)?;
634
635    for hook in INSTALLED_HOOKS {
636        let target = hooks_path.join(hook.as_str());
637        if custom_forwarder_present(hooks_path, *hook) {
638            continue;
639        }
640        if target.is_file() {
641            fs::copy(&target, backup_path(&target, "pre-truth-mirror"))?;
642        }
643        fs::write(&target, render_custom_forwarder_stub(*hook))?;
644        make_executable(&target)?;
645    }
646    eprintln!(
647        "hint: commit the truth-mirror forwarder changes in {}",
648        hooks_path.display()
649    );
650    Ok(())
651}
652
653fn uninstall_custom_forwarders(hooks_path: &Path) -> Result<()> {
654    for hook in INSTALLED_HOOKS {
655        let target = hooks_path.join(hook.as_str());
656        let backup = backup_path(&target, "pre-truth-mirror");
657        let content = read_to_string_if_file(&target)?;
658        if content.as_deref().is_some_and(is_truth_mirror_managed) {
659            if backup.is_file() {
660                fs::copy(&backup, &target)?;
661                make_executable(&target)?;
662                fs::remove_file(&backup)?;
663            } else {
664                remove_file_if_exists(&target)?;
665            }
666        }
667    }
668    let forwarder = hooks_path.join(FORWARDER_NAME);
669    if read_to_string_if_file(&forwarder)?.as_deref() == Some(FORWARDER_SOURCE) {
670        fs::remove_file(forwarder)?;
671    }
672    Ok(())
673}
674
675fn append_managed_block(content: &str, block: &str) -> String {
676    let mut next = remove_managed_block(content);
677    if !next.ends_with('\n') {
678        next.push('\n');
679    }
680    next.push_str(block);
681    next
682}
683
684fn remove_managed_block(content: &str) -> String {
685    let mut out = Vec::new();
686    let mut skipping = false;
687    for line in content.lines() {
688        if line == MANAGED_MARKER {
689            skipping = true;
690            continue;
691        }
692        if skipping {
693            if line == MANAGED_END_MARKER {
694                skipping = false;
695            }
696            continue;
697        }
698        out.push(line);
699    }
700    if out.is_empty() {
701        String::new()
702    } else {
703        format!("{}\n", out.join("\n"))
704    }
705}
706
707fn is_empty_or_shebang_only(content: &str) -> bool {
708    let lines = content
709        .lines()
710        .map(str::trim)
711        .filter(|line| !line.is_empty())
712        .collect::<Vec<_>>();
713    lines.is_empty() || lines == ["#!/bin/sh"]
714}
715
716#[derive(Debug)]
717struct LocalHookSource {
718    path: PathBuf,
719    content: String,
720}
721
722fn local_hook_source(active: &Path, backup: &Path) -> Result<Option<LocalHookSource>> {
723    let Some(active_content) = read_to_string_if_file(active)? else {
724        return Ok(None);
725    };
726    if is_truth_mirror_managed(&active_content) {
727        return read_to_string_if_file(backup)?.map_or(Ok(None), |content| {
728            Ok(Some(LocalHookSource {
729                path: backup.to_path_buf(),
730                content,
731            }))
732        });
733    }
734    Ok(Some(LocalHookSource {
735        path: active.to_path_buf(),
736        content: active_content,
737    }))
738}
739
740fn custom_forwarder_present(hooks_path: &Path, hook: HookName) -> bool {
741    read_to_string_if_file(&hooks_path.join(hook.as_str()))
742        .ok()
743        .flatten()
744        .is_some_and(|content| {
745            active_hook_lines(&content).any(|line| line.contains(FORWARDER_NAME))
746                || content_forwards_to_local_git_hook(&content, hook)
747                || is_truth_mirror_managed(&content)
748        })
749}
750
751fn ensure_state_gitignore(repo_root: &Path, state_dir: &Path) -> Result<()> {
752    let state_dir = absolutize(repo_root, state_dir);
753    fs::create_dir_all(&state_dir)?;
754    let gitignore = state_dir.join(".gitignore");
755    let mut content = read_to_string_if_file(&gitignore)?.unwrap_or_default();
756    if content.is_empty() {
757        content.push_str(STATE_GITIGNORE_HEADER);
758        content.push('\n');
759    }
760    for line in STATE_GITIGNORE_LINES {
761        if !content.lines().any(|existing| existing.trim() == *line) {
762            if !content.ends_with('\n') {
763                content.push('\n');
764            }
765            content.push_str(line);
766            content.push('\n');
767        }
768    }
769    fs::write(&gitignore, content)?;
770    print_tracked_runtime_hints(repo_root, &state_dir)?;
771    Ok(())
772}
773
774fn print_tracked_runtime_hints(repo_root: &Path, state_dir: &Path) -> Result<()> {
775    let rel_state = state_dir.strip_prefix(repo_root).unwrap_or(state_dir);
776    for path in [
777        rel_state.join("review-queue.jsonl"),
778        rel_state.join("runs"),
779        rel_state.join("tmp"),
780        rel_state.join("logs"),
781    ] {
782        let output = Command::new("git")
783            .args(["ls-files", "--"])
784            .arg(&path)
785            .current_dir(repo_root)
786            .output()?;
787        if output.status.success() {
788            for tracked in String::from_utf8_lossy(&output.stdout)
789                .lines()
790                .filter(|line| !line.trim().is_empty())
791            {
792                eprintln!("hint: run: git rm --cached {tracked}");
793            }
794        }
795    }
796    Ok(())
797}
798
799fn clear_chained_hooks(plan: &HookInstallPlan) -> Result<()> {
800    for hook in INSTALLED_HOOKS {
801        remove_file_if_exists(&plan.hooks_path.join("chained").join(hook.as_str()))?;
802    }
803    Ok(())
804}
805
806fn has_outer_hook_marker(content: &str) -> bool {
807    content.lines().take(20).any(|line| {
808        OUTER_HOOK_MARKERS
809            .iter()
810            .any(|marker| line.contains(marker))
811            || (!line.trim_start().starts_with('#') && line_invokes_truth_hook_dispatch(line, None))
812    })
813}
814
815fn is_truth_mirror_managed(content: &str) -> bool {
816    content.contains(MANAGED_MARKER)
817        || content.contains(HUSKY_ENTIRE_BRIDGE_MARKER)
818        || active_hook_lines(content).any(|line| line_invokes_truth_hook_dispatch(line, None))
819}
820
821fn active_hook_lines(content: &str) -> impl Iterator<Item = &str> {
822    content
823        .lines()
824        .map(str::trim_start)
825        .filter(|line| !line.trim().is_empty() && !line.starts_with('#'))
826}
827
828fn content_forwards_to_local_git_hook(content: &str, hook: HookName) -> bool {
829    let resolves_git_dir =
830        content.contains("--git-common-dir") || content.contains("git rev-parse --git-common-dir");
831    resolves_git_dir
832        && active_hook_lines(content)
833            .any(|line| line_forwards_to_local_git_hook_with_args(line, hook))
834}
835
836fn line_forwards_to_local_git_hook_with_args(line: &str, hook: HookName) -> bool {
837    line.contains("$@")
838        && (line.contains(&format!("hooks/{}", hook.as_str()))
839            || line.contains("hooks/$name")
840            || line.contains("hooks/${name}")
841            || line.contains("hooks/$hook")
842            || line.contains("hooks/${hook}"))
843}
844
845fn line_invokes_truth_hook_dispatch(line: &str, expected_hook: Option<HookName>) -> bool {
846    crate::shell::shellish_token_segments(line)
847        .iter()
848        .any(|tokens| token_segment_invokes_truth_hook_dispatch(tokens, expected_hook))
849}
850
851fn token_segment_invokes_truth_hook_dispatch(
852    tokens: &[&str],
853    expected_hook: Option<HookName>,
854) -> bool {
855    for (index, token) in tokens.iter().enumerate() {
856        if !is_truth_binary(token) {
857            continue;
858        }
859        let Some(dispatch_offset) = tokens[index + 1..]
860            .iter()
861            .position(|candidate| *candidate == "hook-dispatch")
862        else {
863            continue;
864        };
865        let hook_index = index + 1 + dispatch_offset + 1;
866        let Some(hook) = tokens.get(hook_index) else {
867            continue;
868        };
869        if expected_hook.is_some_and(|expected| hook == &expected.as_str())
870            || expected_hook.is_none() && token_is_installed_hook(hook)
871        {
872            return true;
873        }
874    }
875    false
876}
877
878fn is_truth_binary(token: &str) -> bool {
879    token == "truth"
880        || token == "truth-mirror"
881        || token.ends_with("/truth")
882        || token.ends_with("/truth-mirror")
883}
884
885fn token_is_installed_hook(token: &str) -> bool {
886    INSTALLED_HOOKS.iter().any(|hook| token == hook.as_str())
887}
888
889fn is_husky_entire_bridge(content: &str) -> bool {
890    content.contains(HUSKY_ENTIRE_BRIDGE_MARKER)
891}
892
893fn has_husky_entire_bridge_exec(content: &str, hook: HookName) -> bool {
894    let expected = husky_entire_bridge_exec_line(hook);
895    content.lines().any(|line| line.trim() == expected)
896}
897
898fn backup_path(path: &Path, suffix: &str) -> PathBuf {
899    let file_name = path
900        .file_name()
901        .and_then(|name| name.to_str())
902        .unwrap_or("hook");
903    path.with_file_name(format!("{file_name}.{suffix}"))
904}
905
906fn read_to_string_if_file(path: &Path) -> Result<Option<String>> {
907    match fs::read_to_string(path) {
908        Ok(content) => Ok(Some(content)),
909        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
910        Err(error) => Err(error.into()),
911    }
912}
913
914fn remove_file_if_exists(path: &Path) -> Result<()> {
915    match fs::remove_file(path) {
916        Ok(()) => Ok(()),
917        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
918        Err(error) => Err(error.into()),
919    }
920}
921
922fn unset_core_hooks_path(repo_root: &Path) -> Result<()> {
923    let status = Command::new("git")
924        .args(["config", "--unset", "core.hooksPath"])
925        .current_dir(repo_root)
926        .status()?;
927    if !status.success() {
928        // Git exits non-zero when the key is already absent; migration should be idempotent.
929        return Ok(());
930    }
931    Ok(())
932}
933
934fn dispatch_commit_msg(
935    state_dir: &Path,
936    args: &[String],
937    config: &crate::config::TruthMirrorConfig,
938) -> Result<()> {
939    let commit_msg_path = args
940        .first()
941        .context("commit-msg hook requires COMMIT_EDITMSG path")?;
942    let commit_message = fs::read_to_string(commit_msg_path)?;
943    let diff = git_stdout(&["diff", "--cached"])?;
944    let claim_file = fs::read_to_string(state_dir.join("claim.txt")).ok();
945    let policy = config.gates.to_policy();
946    claim::evaluate_commit_message(&commit_message, claim_file.as_deref(), Some(&diff), &policy)?;
947    Ok(())
948}
949
950fn dispatch_post_commit(state_dir: &Path) -> Result<()> {
951    let sha = git_stdout(&["rev-parse", "HEAD"])?;
952    ReviewQueue::new(state_dir).enqueue(sha.trim())?;
953    Ok(())
954}
955
956fn dispatch_pre_push(
957    state_dir: &Path,
958    config_path: Option<&Path>,
959    config: &crate::config::TruthMirrorConfig,
960    args: &[String],
961) -> Result<()> {
962    let remote_name = pre_push_remote_name_from_args(args);
963    let mut stdin = String::new();
964    io::stdin().read_to_string(&mut stdin)?;
965    for line in stdin.lines() {
966        if let Some(range) = pre_push_range_from_line_for_remote(line, remote_name) {
967            gate::run(
968                cli::GateArgs {
969                    pre_push: Some(range),
970                    commit_msg: None,
971                    claim_file: None,
972                    diff_file: None,
973                    fake_markers: Vec::new(),
974                    pre_tool_use: false,
975                    tool: None,
976                },
977                state_dir,
978                config_path,
979                config,
980            )?;
981        }
982    }
983    Ok(())
984}
985
986fn pre_push_range_from_line_for_remote(line: &str, remote_name: Option<&str>) -> Option<String> {
987    let parts = line.split_whitespace().collect::<Vec<_>>();
988    let local_sha = parts.get(1)?;
989    let remote_sha = parts.get(3)?;
990    if is_zero_sha(local_sha) {
991        return None;
992    }
993
994    if is_zero_sha(remote_sha) {
995        match remote_name.filter(|name| safe_pre_push_remote_name(name)) {
996            Some(remote_name) => Some(format!("new:{remote_name}:{local_sha}")),
997            None => Some(format!("new:{local_sha}")),
998        }
999    } else {
1000        Some(format!("{remote_sha}..{local_sha}"))
1001    }
1002}
1003
1004fn pre_push_remote_name_from_args(args: &[String]) -> Option<&str> {
1005    let remote_name = args.first()?.as_str();
1006    let remote_url = args.get(1).map(String::as_str);
1007    if remote_url == Some(remote_name) || !safe_pre_push_remote_name(remote_name) {
1008        return None;
1009    }
1010    Some(remote_name)
1011}
1012
1013fn safe_pre_push_remote_name(value: &str) -> bool {
1014    !value.is_empty()
1015        && !value.starts_with('-')
1016        && !value.contains(':')
1017        && !value.contains("..")
1018        && !value
1019            .chars()
1020            .any(|character| character.is_whitespace() || character.is_control())
1021        && !value
1022            .chars()
1023            .any(|character| matches!(character, '*' | '?' | '['))
1024}
1025
1026fn is_zero_sha(value: &str) -> bool {
1027    value.chars().all(|character| character == '0')
1028}
1029
1030fn run_chained_hook(state_dir: &Path, hook: HookName, args: &[String]) -> Result<()> {
1031    let chained = state_dir.join("hooks/chained").join(hook.as_str());
1032    if !chained.is_file() {
1033        return Ok(());
1034    }
1035    let content = fs::read_to_string(&chained)?;
1036    if has_outer_hook_marker(&content) {
1037        eprintln!(
1038            "{} skipping unsafe chained hook {} because it appears to invoke an outer hook manager",
1039            crate::messages::diagnostic_prefix(),
1040            chained.display()
1041        );
1042        return Ok(());
1043    }
1044
1045    let status = Command::new(&chained).args(args).status()?;
1046    if !status.success() {
1047        bail!(
1048            "chained hook {} failed with status {status}",
1049            chained.display()
1050        );
1051    }
1052    Ok(())
1053}
1054
1055fn git_root() -> Result<PathBuf> {
1056    Ok(PathBuf::from(
1057        git_stdout(&["rev-parse", "--show-toplevel"])?.trim(),
1058    ))
1059}
1060
1061fn git_stdout(args: &[&str]) -> Result<String> {
1062    let output = Command::new("git").args(args).output()?;
1063    if !output.status.success() {
1064        bail!(
1065            "git {} failed: {}",
1066            args.join(" "),
1067            String::from_utf8_lossy(&output.stderr)
1068        );
1069    }
1070    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1071}
1072
1073fn git_config_get(repo_root: &Path, key: &str) -> Result<Option<String>> {
1074    let output = Command::new("git")
1075        .args(["config", "--get", key])
1076        .current_dir(repo_root)
1077        .output()?;
1078    if output.status.success() {
1079        return Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()));
1080    }
1081    if output.status.code() == Some(1) {
1082        return Ok(None);
1083    }
1084    bail!(
1085        "git config --get {key} failed: {}",
1086        String::from_utf8_lossy(&output.stderr)
1087    );
1088}
1089
1090#[cfg(unix)]
1091fn make_executable(path: &Path) -> Result<()> {
1092    use std::os::unix::fs::PermissionsExt;
1093
1094    let mut permissions = fs::metadata(path)?.permissions();
1095    permissions.set_mode(0o755);
1096    fs::set_permissions(path, permissions)?;
1097    Ok(())
1098}
1099
1100#[cfg(not(unix))]
1101fn make_executable(_path: &Path) -> Result<()> {
1102    Ok(())
1103}
1104
1105#[cfg(test)]
1106mod tests {
1107    use proptest::prelude::*;
1108
1109    use super::{
1110        HookInstallMode, HookInstallPlan, pre_push_range_from_line_for_remote, render_shim,
1111    };
1112    use crate::cli::HookName;
1113
1114    #[test]
1115    fn hook_shim_is_only_exec_delegation() {
1116        let shim = render_shim(HookName::CommitMsg, "");
1117        let lines = shim.lines().collect::<Vec<_>>();
1118
1119        assert_eq!(lines.len(), 3);
1120        assert_eq!(lines[0], "#!/bin/sh");
1121        assert_eq!(lines[1], super::MANAGED_MARKER);
1122        assert_eq!(
1123            lines[2],
1124            "exec truth-mirror hook-dispatch commit-msg \"$@\""
1125        );
1126    }
1127
1128    #[test]
1129    fn quote_git_arg_escapes_shell_metacharacters() {
1130        use std::path::Path;
1131        assert_eq!(super::quote_git_arg(Path::new("/a/b.toml")), "'/a/b.toml'");
1132        // metacharacters are neutralized by single-quoting
1133        assert_eq!(
1134            super::quote_git_arg(Path::new("/a/c;$(touch x).toml")),
1135            "'/a/c;$(touch x).toml'"
1136        );
1137        // an embedded single quote is escaped as '\''
1138        assert_eq!(
1139            super::quote_git_arg(Path::new("/a/it's.toml")),
1140            "'/a/it'\\''s.toml'"
1141        );
1142    }
1143
1144    #[test]
1145    fn normalize_path_preserves_unmatched_relative_parents() {
1146        use std::path::{Path, PathBuf};
1147
1148        assert_eq!(
1149            super::normalize_path(Path::new("../hooks")),
1150            PathBuf::from("../hooks")
1151        );
1152        assert_eq!(
1153            super::normalize_path(Path::new("a/../../hooks")),
1154            PathBuf::from("../hooks")
1155        );
1156    }
1157
1158    #[test]
1159    fn hook_shim_preserves_global_args() {
1160        let shim = render_shim(HookName::PrePush, "--config /abs/enforce.toml ");
1161        let lines = shim.lines().collect::<Vec<_>>();
1162
1163        assert_eq!(lines.len(), 3, "shim stays <=3 lines + exec-only");
1164        assert_eq!(
1165            lines[2],
1166            "exec truth-mirror --config /abs/enforce.toml hook-dispatch pre-push \"$@\""
1167        );
1168    }
1169
1170    #[test]
1171    fn husky_entire_bridge_is_only_exec_delegation() {
1172        let bridge = super::render_husky_entire_bridge(HookName::CommitMsg);
1173        let lines = bridge.lines().collect::<Vec<_>>();
1174
1175        assert_eq!(lines.len(), 3);
1176        assert_eq!(lines[0], "#!/bin/sh");
1177        assert_eq!(lines[1], super::HUSKY_ENTIRE_BRIDGE_MARKER);
1178        assert_eq!(lines[2], "exec \"$(dirname \"$0\")/commit-msg\" \"$@\"");
1179    }
1180
1181    #[test]
1182    fn dry_run_plan_names_hooks_and_hooks_path() {
1183        let plan = HookInstallPlan::new(
1184            std::path::Path::new("/repo"),
1185            std::path::Path::new("/repo/.truth/hooks"),
1186            false,
1187        );
1188        let rendered = plan.render();
1189
1190        assert!(rendered.contains("commit-msg"));
1191        assert!(rendered.contains("post-commit"));
1192        assert!(rendered.contains("pre-push"));
1193        assert!(rendered.contains("stateHooks=/repo/.truth/hooks"));
1194        assert!(rendered.contains("path=/repo/.git/hooks/commit-msg"));
1195        assert!(rendered.contains("body:\n#!/bin/sh\n# truth-mirror managed hook"));
1196    }
1197
1198    #[test]
1199    fn husky_dry_run_plan_names_entire_bridge_paths() {
1200        let mut plan = HookInstallPlan::new(
1201            std::path::Path::new("/repo"),
1202            std::path::Path::new("/repo/.truth/hooks"),
1203            false,
1204        );
1205        plan.mode = HookInstallMode::Husky {
1206            content_dir: std::path::PathBuf::from("/repo/.husky"),
1207        };
1208        let rendered = plan.render();
1209
1210        assert!(rendered.contains("bridge=commit-msg.pre-entire"));
1211        assert!(rendered.contains("path=/repo/.husky/commit-msg.pre-entire"));
1212        assert!(rendered.contains(super::HUSKY_ENTIRE_BRIDGE_MARKER));
1213    }
1214
1215    #[test]
1216    fn managed_block_replace_is_idempotent() {
1217        let block = super::render_husky_block(HookName::CommitMsg, "");
1218        let original = "#!/bin/sh\necho before\n";
1219        let once = super::append_managed_block(original, &block);
1220        let twice = super::append_managed_block(&once, &block);
1221
1222        assert_eq!(twice.matches(super::MANAGED_MARKER).count(), 1);
1223        assert!(twice.contains("echo before"));
1224    }
1225
1226    #[test]
1227    fn chained_hook_safety_filter_detects_outer_tools() {
1228        assert!(super::has_outer_hook_marker(
1229            "#!/bin/sh\n# Entire CLI hooks\nentire hooks git commit-msg\n"
1230        ));
1231        assert!(super::has_outer_hook_marker(
1232            "#!/bin/sh\n# truth-mirror managed hook\nexec truth-mirror hook-dispatch commit-msg \"$@\"\n"
1233        ));
1234        assert!(super::has_outer_hook_marker(
1235            "#!/bin/sh\nexec truth hook-dispatch commit-msg \"$@\"\n"
1236        ));
1237        assert!(super::has_outer_hook_marker(
1238            "#!/bin/sh\nexec /usr/local/bin/truth hook-dispatch commit-msg \"$@\"\n"
1239        ));
1240        assert!(!super::has_outer_hook_marker(
1241            "#!/bin/sh\n# exec truth hook-dispatch commit-msg \"$@\"\n"
1242        ));
1243        assert!(!super::has_outer_hook_marker("#!/bin/sh\necho safe\n"));
1244    }
1245
1246    #[test]
1247    fn managed_detection_ignores_commented_and_unrelated_truth_lines() {
1248        assert!(!super::is_truth_mirror_managed(
1249            "#!/bin/sh\n# exec truth hook-dispatch commit-msg \"$@\"\n"
1250        ));
1251        assert!(!super::is_truth_mirror_managed(
1252            "#!/bin/sh\nexec truth unrelated \"$@\"\n"
1253        ));
1254        assert!(!super::is_truth_mirror_managed(
1255            "#!/bin/sh\nexec truth --version && hook-dispatch commit-msg \"$@\"\n"
1256        ));
1257        assert!(!super::is_truth_mirror_managed(
1258            "#!/bin/sh\nhook-dispatch commit-msg \"$@\"; exec truth --version\n"
1259        ));
1260        assert!(super::is_truth_mirror_managed(
1261            "#!/bin/sh\nexec /opt/bin/truth hook-dispatch pre-push \"$@\"\n"
1262        ));
1263    }
1264
1265    #[test]
1266    fn run_chained_hook_skips_marker_bearing_stale_hook() {
1267        let temp = tempfile::tempdir().unwrap();
1268        let chained = temp.path().join("hooks/chained/commit-msg");
1269        std::fs::create_dir_all(chained.parent().unwrap()).unwrap();
1270        std::fs::write(&chained, "#!/bin/sh\n# Entire CLI hooks\nexit 42\n").unwrap();
1271        super::make_executable(&chained).unwrap();
1272
1273        super::run_chained_hook(temp.path(), HookName::CommitMsg, &[]).unwrap();
1274    }
1275
1276    #[test]
1277    fn pre_push_line_maps_to_git_range() {
1278        let line = "refs/heads/main abc123 refs/heads/main def456";
1279
1280        assert_eq!(
1281            pre_push_range_from_line_for_remote(line, None),
1282            Some("def456..abc123".to_owned())
1283        );
1284    }
1285
1286    #[test]
1287    fn pre_push_new_branch_maps_to_new_branch_token() {
1288        let line =
1289            "refs/heads/topic abc123 refs/heads/topic 0000000000000000000000000000000000000000";
1290
1291        assert_eq!(
1292            pre_push_range_from_line_for_remote(line, None),
1293            Some("new:abc123".to_owned())
1294        );
1295    }
1296
1297    #[test]
1298    fn pre_push_new_branch_includes_safe_remote_name_when_available() {
1299        let line =
1300            "refs/heads/topic abc123 refs/heads/topic 0000000000000000000000000000000000000000";
1301
1302        assert_eq!(
1303            pre_push_range_from_line_for_remote(line, Some("origin")),
1304            Some("new:origin:abc123".to_owned())
1305        );
1306        assert_eq!(
1307            pre_push_range_from_line_for_remote(line, Some("../bad")),
1308            Some("new:abc123".to_owned())
1309        );
1310    }
1311
1312    #[test]
1313    fn pre_push_args_omit_remote_name_for_direct_path_pushes() {
1314        assert_eq!(
1315            super::pre_push_remote_name_from_args(&[
1316                "origin".to_owned(),
1317                "git@example/repo".to_owned()
1318            ]),
1319            Some("origin")
1320        );
1321        assert_eq!(
1322            super::pre_push_remote_name_from_args(&[
1323                "../repo.git".to_owned(),
1324                "../repo.git".to_owned()
1325            ]),
1326            None
1327        );
1328    }
1329
1330    #[test]
1331    fn pre_push_args_omit_glob_like_remote_names() {
1332        for remote_name in ["bad*remote", "bad?remote", "bad[remote"] {
1333            assert_eq!(
1334                super::pre_push_remote_name_from_args(&[
1335                    remote_name.to_owned(),
1336                    "git@example/repo".to_owned()
1337                ]),
1338                None
1339            );
1340        }
1341    }
1342
1343    proptest! {
1344        #[test]
1345        fn hook_shim_rendering_stays_tiny_exec_only(index in 0usize..3) {
1346            let hook = [HookName::CommitMsg, HookName::PostCommit, HookName::PrePush][index];
1347            let shim = render_shim(hook, "");
1348            let lines = shim.lines().collect::<Vec<_>>();
1349
1350            prop_assert_eq!(lines.len(), 3);
1351            prop_assert_eq!(lines[0], "#!/bin/sh");
1352            prop_assert_eq!(lines[1], super::MANAGED_MARKER);
1353            prop_assert!(lines[2].starts_with("exec truth-mirror hook-dispatch "));
1354            prop_assert!(lines[2].contains(hook.as_str()));
1355            prop_assert_eq!(shim.matches(super::MANAGED_MARKER).count(), 1);
1356            prop_assert_eq!(shim.matches("exec truth-mirror").count(), 1);
1357        }
1358
1359        #[test]
1360        fn husky_entire_bridge_rendering_stays_tiny_exec_only(index in 0usize..3) {
1361            let hook = [HookName::CommitMsg, HookName::PostCommit, HookName::PrePush][index];
1362            let bridge = super::render_husky_entire_bridge(hook);
1363            let lines = bridge.lines().collect::<Vec<_>>();
1364
1365            prop_assert_eq!(lines.len(), 3);
1366            prop_assert_eq!(lines[0], "#!/bin/sh");
1367            prop_assert_eq!(lines[1], super::HUSKY_ENTIRE_BRIDGE_MARKER);
1368            prop_assert_eq!(lines[2], format!("exec \"$(dirname \"$0\")/{}\" \"$@\"", hook.as_str()));
1369            prop_assert_eq!(bridge.matches(super::HUSKY_ENTIRE_BRIDGE_MARKER).count(), 1);
1370            prop_assert_eq!(bridge.matches("exec ").count(), 1);
1371        }
1372    }
1373}