use super::{HookError, HookInfo};
use std::path::{Path, PathBuf};
use std::process::Command;
pub struct HuskyHandler {
project_dir: PathBuf,
}
impl HuskyHandler {
pub fn new(project_dir: PathBuf) -> Self {
Self { project_dir }
}
pub fn install(&self) -> Result<(), HookError> {
if !npx_available() {
return Err(HookError::FrameworkNotInstalled("npx".to_string()));
}
let pkg = self.project_dir.join("package.json");
if !pkg.exists() {
return Err(HookError::Config(format!(
"husky requires package.json; none found at {}",
pkg.display()
)));
}
let status = Command::new("npm")
.args(["install", "--save-dev", "husky"])
.current_dir(&self.project_dir)
.status()
.map_err(HookError::Io)?;
if !status.success() {
return Err(HookError::InstallFailed(format!(
"npm install --save-dev husky exited with {}",
status.code().unwrap_or(-1)
)));
}
let status = Command::new("npx")
.args(["--yes", "husky", "install"])
.current_dir(&self.project_dir)
.status()
.map_err(HookError::Io)?;
if !status.success() {
return Err(HookError::InstallFailed(format!(
"npx husky install exited with {}",
status.code().unwrap_or(-1)
)));
}
if crate::observability::telemetry_gate::is_enabled() {
tracing::info!(event = "git_hooks.installed", framework = "husky");
}
Ok(())
}
pub fn update(&self) -> Result<(), HookError> {
if !npx_available() {
return Err(HookError::FrameworkNotInstalled("npx".to_string()));
}
let status = Command::new("npm")
.args(["install", "--save-dev", "husky@latest"])
.current_dir(&self.project_dir)
.status()
.map_err(HookError::Io)?;
if !status.success() {
return Err(HookError::UpdateFailed(format!(
"npm install husky@latest exited with {}",
status.code().unwrap_or(-1)
)));
}
self.install()?;
if crate::observability::telemetry_gate::is_enabled() {
tracing::info!(event = "git_hooks.updated", framework = "husky");
}
Ok(())
}
pub fn run(&self, _all_files: bool, hook_id: Option<&str>) -> Result<(), HookError> {
let husky_dir = self.project_dir.join(".husky");
if !husky_dir.is_dir() {
return Err(HookError::Config(format!(
"no .husky/ directory at {}; run `jarvy hooks install` first",
husky_dir.display()
)));
}
let hooks = collect_husky_hooks(&husky_dir)?;
let to_run: Vec<&HuskyHookFile> = match hook_id {
Some(id) => hooks.iter().filter(|h| h.name == id).collect(),
None => hooks.iter().collect(),
};
if to_run.is_empty() {
if hook_id.is_some() {
return Err(HookError::RunFailed(format!(
"no husky hook named `{}` in {}",
hook_id.unwrap_or(""),
husky_dir.display()
)));
}
return Ok(());
}
let mut had_failure = false;
for hook in to_run {
let status = Command::new("sh")
.arg(&hook.path)
.current_dir(&self.project_dir)
.status()
.map_err(HookError::Io)?;
if !status.success() {
had_failure = true;
eprintln!(
" husky hook `{}` exited with {}",
hook.name,
status.code().unwrap_or(-1)
);
}
}
if had_failure {
return Err(HookError::RunFailed(
"one or more husky hooks failed".to_string(),
));
}
Ok(())
}
pub fn list(&self) -> Result<Vec<HookInfo>, HookError> {
let husky_dir = self.project_dir.join(".husky");
if !husky_dir.is_dir() {
return Ok(Vec::new());
}
let hooks = collect_husky_hooks(&husky_dir)?;
Ok(hooks
.into_iter()
.map(|h| HookInfo {
id: h.name.clone(),
repo: "local".to_string(),
version: String::new(),
hook_type: h.name,
})
.collect())
}
}
struct HuskyHookFile {
name: String,
path: PathBuf,
}
fn collect_husky_hooks(husky_dir: &Path) -> Result<Vec<HuskyHookFile>, HookError> {
let mut hooks = Vec::new();
for entry in std::fs::read_dir(husky_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.starts_with('.') {
continue;
}
hooks.push(HuskyHookFile {
name: name.to_string(),
path,
});
}
hooks.sort_by(|a, b| a.name.cmp(&b.name));
Ok(hooks)
}
fn npx_available() -> bool {
Command::new("npx")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn list_returns_empty_when_no_husky_dir() {
let tmp = tempdir().unwrap();
let handler = HuskyHandler::new(tmp.path().to_path_buf());
assert!(handler.list().unwrap().is_empty());
}
#[test]
fn list_enumerates_husky_hook_files() {
let tmp = tempdir().unwrap();
let husky = tmp.path().join(".husky");
fs::create_dir(&husky).unwrap();
fs::write(husky.join("pre-commit"), "#!/bin/sh\necho hi\n").unwrap();
fs::write(husky.join("commit-msg"), "#!/bin/sh\necho msg\n").unwrap();
fs::create_dir(husky.join("_")).unwrap();
fs::write(husky.join("_/husky.sh"), "").unwrap();
fs::write(husky.join(".gitignore"), "_/\n").unwrap();
let handler = HuskyHandler::new(tmp.path().to_path_buf());
let hooks = handler.list().unwrap();
let names: Vec<_> = hooks.iter().map(|h| h.id.as_str()).collect();
assert_eq!(names, vec!["commit-msg", "pre-commit"]);
}
#[test]
fn run_errors_when_husky_dir_missing() {
let tmp = tempdir().unwrap();
let handler = HuskyHandler::new(tmp.path().to_path_buf());
let err = handler.run(false, None).expect_err("must error");
assert!(matches!(err, HookError::Config(_)), "got {err:?}");
}
#[test]
fn run_unknown_hook_id_errors() {
let tmp = tempdir().unwrap();
let husky = tmp.path().join(".husky");
fs::create_dir(&husky).unwrap();
fs::write(husky.join("pre-commit"), "#!/bin/sh\nexit 0\n").unwrap();
let handler = HuskyHandler::new(tmp.path().to_path_buf());
let err = handler.run(false, Some("ghost")).expect_err("must error");
assert!(matches!(err, HookError::RunFailed(_)), "got {err:?}");
}
#[test]
fn install_requires_package_json() {
if !npx_available() {
return;
}
let tmp = tempdir().unwrap();
let handler = HuskyHandler::new(tmp.path().to_path_buf());
let err = handler
.install()
.expect_err("must error without package.json");
assert!(matches!(err, HookError::Config(_)), "got {err:?}");
}
}