klasp 0.4.0

Block AI coding agents on the same quality gates your humans hit. See https://github.com/klasp-dev/klasp
Documentation
//! Plain `.git/hooks` detector — scans `.git/hooks/pre-commit` and
//! `.git/hooks/pre-push`.
//!
//! Each hook file produces one [`DetectedGate`] **only when** the file is
//! _not_ generated by another recognised hook manager. Files that contain
//! generator-line patterns written by the hook managers themselves are silently
//! deferred to the dedicated detectors; this detector returns no finding for
//! them. The markers are intentionally specific (e.g. `"husky.sh"` rather than
//! bare `"husky"`) to avoid false-positives from user comments that merely
//! mention a tool name.
//!
//! `chain_support` is always [`ChainSupport::Unsafe`] per klasp-dev/klasp#97:
//! "Never overwrite plain hooks by default."
//!
//! On Unix the executable bit is checked; non-executable hooks emit an
//! additional warning because git silently skips them.
//!
//! See klasp-dev/klasp#97.

use std::io;
use std::path::Path;

use crate::adopt::plan::{self, HookStage};

/// Hooks this detector examines, in order.
const PLAIN_HOOKS: &[(HookStage, &str)] = &[
    (HookStage::PreCommit, "pre-commit"),
    (HookStage::PrePush, "pre-push"),
];

/// Generator-line patterns that identify a hook as being managed by another
/// hook manager. These are the actual strings the tools write into generated
/// hook files — not bare tool names — to avoid false-positives from user
/// comments (e.g. `# we used to use husky`) and from similarly-named tools
/// (e.g. `/opt/husky-tool/bin/run`).
///
/// - `"# Generated by pre-commit"` — pre-commit framework header (already specific)
/// - `"husky.sh"` — Husky v9 sources `_/husky.sh` in every generated hook
/// - `"lefthook run"` — the actual command Lefthook generates in its hooks
/// - `".husky/"` — trailing slash makes it more specific than bare `".husky"`
const MANAGED_MARKERS: &[&str] = &[
    "# Generated by pre-commit",
    "husky.sh",
    "lefthook run",
    ".husky/",
];

/// Detect plain `.git/hooks` scripts at `repo_root`.
///
/// Returns zero or more [`DetectedGate`]s — one per user-owned hook file
/// found in `.git/hooks/`. Hooks attributed to pre-commit framework, Husky,
/// or Lefthook are excluded (return no finding).
///
/// # Errors
///
/// Returns `Err` only for unexpected I/O failures.
pub fn detect(repo_root: &Path) -> io::Result<Vec<plan::DetectedGate>> {
    let hooks_dir = repo_root.join(".git").join("hooks");
    if !hooks_dir.is_dir() {
        return Ok(vec![]);
    }

    let mut findings = Vec::new();
    for (stage, name) in PLAIN_HOOKS {
        let hook_path = hooks_dir.join(name);

        // Single metadata call: covers both file-existence and executable-bit check.
        let meta = match std::fs::metadata(&hook_path) {
            Ok(m) => m,
            Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
            Err(e) => return Err(e),
        };

        if !meta.is_file() {
            continue;
        }

        let body = match std::fs::read_to_string(&hook_path) {
            Ok(s) => s,
            Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
            Err(e) => return Err(e),
        };

        if is_managed_hook(&body) {
            // Defer to the dedicated detector — emit nothing here.
            continue;
        }
        findings.push(build_gate(*stage, name, hook_path, &meta));
    }
    Ok(findings)
}

/// Build a [`DetectedGate`] for a plain, user-owned `.git/hooks/<hook>` file.
fn build_gate(
    stage: HookStage,
    hook_name: &str,
    source_path: std::path::PathBuf,
    meta: &std::fs::Metadata,
) -> plan::DetectedGate {
    let mut warnings = Vec::new();

    // Warn when the hook exists but is not executable — git will skip it.
    if !is_executable_meta(meta) {
        warnings.push(format!(
            "hook `{hook_name}` present but not executable; git will skip it \
             (run `chmod +x .git/hooks/{hook_name}` to enable)"
        ));
    }

    warnings.push(
        "klasp will not overwrite this hook; mirror its commands manually as \
         `[[checks]]` in klasp.toml if you want them gated."
            .to_string(),
    );

    let instructions = format!(
        "In v1, `--mode chain` does not support plain `.git/hooks` scripts. \
         If you want klasp to run the same commands as `.git/hooks/{hook_name}`, \
         add them as `[[checks]]` entries in `klasp.toml` with \
         `type = \"shell\"` and `command = \"<your-command>\"`. \
         See https://github.com/klasp-dev/klasp for details."
    );

    plan::DetectedGate {
        gate_type: plan::GateType::PlainGitHook { hook: stage },
        source_path,
        proposed_checks: vec![],
        chain_support: plan::ChainSupport::Unsafe,
        manual_chain_instructions: Some(instructions),
        warnings,
    }
}

/// True when the hook body contains a substring that identifies it as being
/// generated or managed by another recognised hook manager.
fn is_managed_hook(body: &str) -> bool {
    MANAGED_MARKERS.iter().any(|marker| body.contains(marker))
}

/// True when the file described by `meta` has the executable bit set.
///
/// On Unix this inspects the mode bits via [`std::os::unix::fs::PermissionsExt`].
/// On Windows there are no POSIX permission bits; the check always returns
/// `true` so callers never emit the "not executable" warning on that platform
/// (Windows does not use the executable bit for hook dispatch).
fn is_executable_meta(meta: &std::fs::Metadata) -> bool {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        meta.permissions().mode() & 0o111 != 0
    }
    #[cfg(not(unix))]
    {
        let _ = meta;
        true
    }
}

#[cfg(test)]
mod tests {
    use std::fs;

    use tempfile::TempDir;

    use super::*;

    /// Create `.git/hooks/<hook>` with `body` and (on Unix) make it executable.
    fn write_hook(dir: &std::path::Path, hook: &str, body: &str, executable: bool) {
        let hooks_dir = dir.join(".git").join("hooks");
        fs::create_dir_all(&hooks_dir).unwrap();
        let path = hooks_dir.join(hook);
        fs::write(&path, body).unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perms = fs::metadata(&path).unwrap().permissions();
            if executable {
                perms.set_mode(0o755);
            } else {
                perms.set_mode(0o644);
            }
            fs::set_permissions(&path, perms).unwrap();
        }
        let _ = executable; // suppress unused-variable on non-Unix
    }

    #[test]
    fn no_git_hooks_dir_returns_empty() {
        let dir = TempDir::new().unwrap();
        let result = detect(dir.path()).unwrap();
        assert!(result.is_empty());
    }

    #[test]
    fn user_script_yields_one_finding_no_proposed_checks() {
        let dir = TempDir::new().unwrap();
        write_hook(
            dir.path(),
            "pre-commit",
            "#!/bin/sh\necho 'running checks'\n",
            true,
        );
        let result = detect(dir.path()).unwrap();
        assert_eq!(result.len(), 1);
        let gate = &result[0];
        assert!(matches!(
            &gate.gate_type,
            plan::GateType::PlainGitHook { hook } if *hook == HookStage::PreCommit
        ));
        assert!(gate.proposed_checks.is_empty());
        assert!(matches!(gate.chain_support, plan::ChainSupport::Unsafe));
    }

    #[test]
    fn pre_commit_generated_by_pre_commit_framework_returns_empty() {
        let dir = TempDir::new().unwrap();
        write_hook(
            dir.path(),
            "pre-commit",
            "#!/bin/sh\n# Generated by pre-commit, do not edit.\nexec pre-commit run\n",
            true,
        );
        let result = detect(dir.path()).unwrap();
        assert!(result.is_empty(), "should defer to pre-commit detector");
    }

    #[test]
    fn hook_referencing_husky_returns_empty() {
        let dir = TempDir::new().unwrap();
        // This hook sources from `.husky/` — the new marker with trailing slash.
        write_hook(
            dir.path(),
            "pre-commit",
            ". \"$(dirname -- \"$0\")/_/husky.sh\"\n",
            true,
        );
        let result = detect(dir.path()).unwrap();
        assert!(result.is_empty(), "should defer to husky detector");
    }

    #[cfg(unix)]
    #[test]
    fn non_executable_hook_emits_not_executable_warning() {
        let dir = TempDir::new().unwrap();
        write_hook(
            dir.path(),
            "pre-commit",
            "#!/bin/sh\necho 'hi'\n",
            false, // not executable
        );
        let result = detect(dir.path()).unwrap();
        assert_eq!(result.len(), 1);
        let gate = &result[0];
        assert!(
            gate.warnings.iter().any(|w| w.contains("not executable")),
            "expected a 'not executable' warning; got: {:?}",
            gate.warnings
        );
    }

    #[test]
    fn both_pre_commit_and_pre_push_user_owned_yield_two_findings() {
        let dir = TempDir::new().unwrap();
        write_hook(dir.path(), "pre-commit", "#!/bin/sh\npnpm lint\n", true);
        write_hook(dir.path(), "pre-push", "#!/bin/sh\npnpm test\n", true);
        let result = detect(dir.path()).unwrap();
        assert_eq!(result.len(), 2);
        let hooks: Vec<_> = result
            .iter()
            .filter_map(|g| match &g.gate_type {
                plan::GateType::PlainGitHook { hook } => Some(*hook),
                _ => None,
            })
            .collect();
        assert!(hooks.contains(&HookStage::PreCommit));
        assert!(hooks.contains(&HookStage::PrePush));
    }

    #[test]
    fn hook_referencing_lefthook_returns_empty() {
        let dir = TempDir::new().unwrap();
        // Body contains `lefthook run` — the actual command Lefthook generates.
        write_hook(
            dir.path(),
            "pre-push",
            "#!/bin/sh\n# managed by lefthook\nlefthook run pre-push\n",
            true,
        );
        let result = detect(dir.path()).unwrap();
        assert!(result.is_empty(), "should defer to lefthook detector");
    }

    #[test]
    fn plain_hook_with_husky_in_user_comment_does_not_defer() {
        let dir = TempDir::new().unwrap();
        // A user comment that merely *mentions* husky must NOT trigger the
        // managed-marker check — only the actual `husky.sh` source line does.
        write_hook(
            dir.path(),
            "pre-commit",
            "#!/bin/sh\n# we used to use husky\nmake lint\n",
            true,
        );
        let result = detect(dir.path()).unwrap();
        assert_eq!(
            result.len(),
            1,
            "user comment mentioning 'husky' should NOT defer: {result:?}"
        );
    }

    #[test]
    fn plain_hook_invoking_husky_named_tool_does_not_defer() {
        let dir = TempDir::new().unwrap();
        // A differently-named binary that starts with "husky" but does NOT
        // source `husky.sh` must not be treated as a Husky-managed hook.
        write_hook(
            dir.path(),
            "pre-commit",
            "#!/bin/sh\n/opt/husky-tool/bin/run\n",
            true,
        );
        let result = detect(dir.path()).unwrap();
        assert_eq!(
            result.len(),
            1,
            "husky-named tool without husky.sh should NOT defer: {result:?}"
        );
    }
}