repotoire 0.7.1

Graph-powered code analysis CLI. 110 detectors for security, architecture, bus factor, and code quality.
Documentation
//! Install the Claude Code commit-time hook into settings.json.

use anyhow::{anyhow, Context, Result};

use super::settings::{
    atomic_write, backup_path, cleanup_empty_containers, filter_pretool_entries,
    find_pretool_entry_by_command, insert_hook_entry, is_old_style_hook_command,
    is_repotoire_hook_command, read_settings_or_empty, settings_path,
};

pub fn run(allow_dev_binary: bool) -> Result<()> {
    // 1. Resolve binary path (NOT canonicalized — preserves user-visible paths like /usr/local/bin/repotoire).
    let bin = std::env::current_exe().context("could not determine current executable path")?;
    let bin_str = bin.to_string_lossy().to_string();

    // 2. Dev-binary guard.
    if !allow_dev_binary
        && (bin_str.contains("/target/debug/")
            || bin_str.contains("/target/release/")
            || bin_str.contains("\\target\\debug\\")
            || bin_str.contains("\\target\\release\\"))
    {
        anyhow::bail!(
            "Error: refusing to install hook with a dev binary at {}. \
             Use the installed binary, or pass --allow-dev-binary if you really mean it.",
            bin.display()
        );
    }

    // Refuse paths with spaces (would break Claude Code's command parser).
    if bin_str.contains(' ') {
        anyhow::bail!(
            "Error: binary path {} contains spaces; cannot install hook. \
             Move the binary or symlink to a space-free path before installing.",
            bin.display()
        );
    }

    let our_command = format!("{bin_str} claude-hook run");

    // 3. Locate settings.json.
    let settings = settings_path()?;

    // 4. Read existing settings (or start from `{}`).
    let mut root = read_settings_or_empty(&settings)
        .with_context(|| format!("read settings.json at {}", settings.display()))?;

    // 5. Detect old-style hook (script under ~/.repotoire/hooks/) and remove it.
    let home = home::home_dir().ok_or_else(|| anyhow!("home directory not resolvable"))?;
    let old_script = home.join(".repotoire").join("hooks").join("pre-commit.sh");
    let old_in_settings =
        find_pretool_entry_by_command(&root, |c| is_old_style_hook_command(c, &home)).is_some();
    if old_script.exists() || old_in_settings {
        eprintln!(
            "\u{26a0} Old-style Repotoire hook detected (slow `repotoire analyze` on every commit). \
             Removing and replacing with the fast `repotoire claude-hook` subcommand."
        );
        let _ = std::fs::remove_file(&old_script);
        filter_pretool_entries(&mut root, |c| is_old_style_hook_command(c, &home));
    }

    // 6. Idempotency: if our exact command already present, skip.
    if find_pretool_entry_by_command(&root, |c| c == our_command).is_some() {
        eprintln!(
            "\u{2139} Repotoire hook already installed at {}",
            settings.display()
        );
        return Ok(());
    }

    // 7. Remove stale binary entries (commands that match our hook shape but at a different path).
    let stale_removed = filter_pretool_entries(&mut root, |c| {
        is_repotoire_hook_command(c) && c != our_command
    });
    if stale_removed > 0 {
        eprintln!(
            "\u{2139} Removed {stale_removed} stale repotoire hook entry/entries from previous installs."
        );
    }

    // 8. Backup if file existed.
    let backup = if settings.exists() {
        let bp = backup_path(&settings);
        std::fs::copy(&settings, &bp).with_context(|| format!("write backup {}", bp.display()))?;
        Some(bp)
    } else {
        None
    };

    // 9. Insert our entry, cleanup any empty-container artifacts left by the filter.
    insert_hook_entry(&mut root, &our_command);
    cleanup_empty_containers(&mut root); // no-op here, but harmless

    // 10. Atomic write.
    let out = serde_json::to_string_pretty(&root)?;
    atomic_write(&settings, out.as_bytes())
        .with_context(|| format!("atomic write {}", settings.display()))?;

    // 11. Confirm.
    println!("\u{2713} Hook installed: {our_command}");
    match backup {
        Some(b) => println!(
            "\u{2713} Updated: {} (backup: {})",
            settings.display(),
            b.display()
        ),
        None => println!("\u{2713} Created: {}", settings.display()),
    }
    println!(
        "\u{2139} Run `repotoire analyze` to seed the diff baseline; the hook stays silent until then."
    );
    println!("\u{2139} If you have Claude Code open, restart it to pick up the new hook.");
    println!("\u{2139} Uninstall: repotoire claude-hook uninstall");

    Ok(())
}