use super::{HookError, HookInfo};
use std::path::PathBuf;
use std::process::Command;
pub struct LefthookHandler {
project_dir: PathBuf,
}
impl LefthookHandler {
pub fn new(project_dir: PathBuf) -> Self {
Self { project_dir }
}
pub fn install(&self) -> Result<(), HookError> {
if !is_installed() {
return Err(HookError::FrameworkNotInstalled("lefthook".to_string()));
}
let config_path = self.project_dir.join("lefthook.yml");
if !config_path.exists() {
return Err(HookError::Config(format!(
"lefthook config not found at {}; create lefthook.yml first",
config_path.display()
)));
}
let status = Command::new("lefthook")
.arg("install")
.current_dir(&self.project_dir)
.status()
.map_err(HookError::Io)?;
if !status.success() {
return Err(HookError::InstallFailed(format!(
"lefthook install exited with {}",
status.code().unwrap_or(-1)
)));
}
if crate::observability::telemetry_gate::is_enabled() {
tracing::info!(event = "git_hooks.installed", framework = "lefthook");
}
Ok(())
}
pub fn update(&self) -> Result<(), HookError> {
if !is_installed() {
return Err(HookError::FrameworkNotInstalled("lefthook".to_string()));
}
let _ = Command::new("lefthook")
.arg("self-update")
.current_dir(&self.project_dir)
.status();
let status = Command::new("lefthook")
.arg("install")
.current_dir(&self.project_dir)
.status()
.map_err(HookError::Io)?;
if !status.success() {
return Err(HookError::UpdateFailed(format!(
"lefthook install (after self-update) exited with {}",
status.code().unwrap_or(-1)
)));
}
if crate::observability::telemetry_gate::is_enabled() {
tracing::info!(event = "git_hooks.updated", framework = "lefthook");
}
Ok(())
}
pub fn run(&self, all_files: bool, hook_id: Option<&str>) -> Result<(), HookError> {
if !is_installed() {
return Err(HookError::FrameworkNotInstalled("lefthook".to_string()));
}
let stage = hook_id.unwrap_or("pre-commit");
let mut cmd = Command::new("lefthook");
cmd.arg("run").arg(stage);
if all_files {
cmd.arg("--all-files");
}
cmd.current_dir(&self.project_dir);
let status = cmd.status().map_err(HookError::Io)?;
if !status.success() {
return Err(HookError::RunFailed(format!(
"lefthook run {} exited with {}",
stage,
status.code().unwrap_or(-1)
)));
}
Ok(())
}
pub fn list(&self) -> Result<Vec<HookInfo>, HookError> {
let config_path = self.project_dir.join("lefthook.yml");
if !config_path.exists() {
return Ok(Vec::new());
}
let content = std::fs::read_to_string(&config_path)?;
let parsed: std::collections::BTreeMap<String, serde_yaml::Value> =
serde_yaml::from_str(&content)
.map_err(|e| HookError::Config(format!("parse {}: {e}", config_path.display())))?;
let mut hooks = Vec::new();
for (stage, value) in &parsed {
if !is_stage_name(stage) {
continue;
}
let Some(map) = value.as_mapping() else {
continue;
};
let Some(commands) = map.get(serde_yaml::Value::String("commands".to_string())) else {
continue;
};
let Some(cmd_map) = commands.as_mapping() else {
continue;
};
for (id_value, _) in cmd_map {
if let Some(id) = id_value.as_str() {
hooks.push(HookInfo {
id: id.to_string(),
repo: "local".to_string(),
version: String::new(),
hook_type: stage.clone(),
});
}
}
}
hooks.sort_by(|a, b| {
(a.hook_type.as_str(), a.id.as_str()).cmp(&(b.hook_type.as_str(), b.id.as_str()))
});
Ok(hooks)
}
}
fn is_stage_name(s: &str) -> bool {
matches!(
s,
"pre-commit"
| "pre-push"
| "pre-merge-commit"
| "post-commit"
| "post-checkout"
| "post-merge"
| "post-rewrite"
| "commit-msg"
| "prepare-commit-msg"
| "applypatch-msg"
| "pre-applypatch"
| "post-applypatch"
| "pre-rebase"
| "pre-receive"
| "update"
| "post-receive"
| "post-update"
| "push-to-checkout"
| "fsmonitor-watchman"
| "p4-changelist"
| "p4-prepare-changelist"
| "p4-post-changelist"
| "p4-pre-submit"
| "sendemail-validate"
)
}
fn is_installed() -> bool {
Command::new("lefthook")
.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_config() {
let tmp = tempdir().unwrap();
let handler = LefthookHandler::new(tmp.path().to_path_buf());
assert!(handler.list().unwrap().is_empty());
}
#[test]
fn list_parses_stages_and_commands() {
let tmp = tempdir().unwrap();
fs::write(
tmp.path().join("lefthook.yml"),
r#"
pre-commit:
parallel: true
commands:
rustfmt:
run: cargo fmt --check
clippy:
run: cargo clippy
commit-msg:
commands:
conventional:
run: ./scripts/check-commit.sh {1}
skip_output:
- meta
"#,
)
.unwrap();
let handler = LefthookHandler::new(tmp.path().to_path_buf());
let hooks = handler.list().unwrap();
assert_eq!(hooks.len(), 3, "got {hooks:?}");
assert!(hooks.iter().all(|h| h.hook_type != "skip_output"));
assert!(
hooks
.iter()
.any(|h| h.id == "rustfmt" && h.hook_type == "pre-commit")
);
assert!(
hooks
.iter()
.any(|h| h.id == "conventional" && h.hook_type == "commit-msg")
);
}
#[test]
fn list_rejects_malformed_yaml() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("lefthook.yml"), "not: valid: yaml: at all:").unwrap();
let handler = LefthookHandler::new(tmp.path().to_path_buf());
let err = handler.list().expect_err("malformed yaml must error");
assert!(matches!(err, HookError::Config(_)), "got {err:?}");
}
#[test]
fn install_requires_config_file() {
if !is_installed() {
return;
}
let tmp = tempdir().unwrap();
let handler = LefthookHandler::new(tmp.path().to_path_buf());
let err = handler.install().expect_err("must error without config");
assert!(matches!(err, HookError::Config(_)), "got {err:?}");
}
#[test]
fn is_stage_name_table() {
assert!(is_stage_name("pre-commit"));
assert!(is_stage_name("commit-msg"));
assert!(is_stage_name("pre-push"));
assert!(!is_stage_name("skip_output"));
assert!(!is_stage_name("extends"));
assert!(!is_stage_name("random"));
}
}