use std::io;
use std::path::Path;
use crate::adopt::plan::{self, HookStage};
const PLAIN_HOOKS: &[(HookStage, &str)] = &[
(HookStage::PreCommit, "pre-commit"),
(HookStage::PrePush, "pre-push"),
];
const MANAGED_MARKERS: &[&str] = &[
"# Generated by pre-commit",
"husky.sh",
"lefthook run",
".husky/",
];
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);
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) {
continue;
}
findings.push(build_gate(*stage, name, hook_path, &meta));
}
Ok(findings)
}
fn build_gate(
stage: HookStage,
hook_name: &str,
source_path: std::path::PathBuf,
meta: &std::fs::Metadata,
) -> plan::DetectedGate {
let mut warnings = Vec::new();
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,
}
}
fn is_managed_hook(body: &str) -> bool {
MANAGED_MARKERS.iter().any(|marker| body.contains(marker))
}
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::*;
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; }
#[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();
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, );
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();
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();
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();
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:?}"
);
}
}