pub mod config;
pub mod detection;
pub mod husky;
pub mod lefthook;
pub mod native;
pub mod precommit;
use std::path::Path;
use thiserror::Error;
#[allow(unused_imports)] pub use config::PreCommitConfig;
pub use config::{GitHooksConfig, HookFramework};
pub use detection::detect_framework;
pub use precommit::PreCommitHandler;
#[derive(Debug, Error)]
pub enum HookError {
#[error("hook framework `{0}` is not installed; install it before running `jarvy hooks`")]
FrameworkNotInstalled(String),
#[allow(dead_code)]
#[error("hook framework `{0}` is configured but not yet supported by jarvy")]
UnsupportedFramework(String),
#[error(
"remote-fetched config attempted to install git hooks without \
`[git_hooks] allow_remote = true`; refusing to land arbitrary \
pre-commit hooks from an untrusted source (PRD-054 trust gate)"
)]
RemoteRefused,
#[error("not inside a git repository (no `.git` directory found)")]
NotAGitRepo,
#[error("hook installation failed: {0}")]
InstallFailed(String),
#[error("hook update failed: {0}")]
UpdateFailed(String),
#[error("hook run failed: {0}")]
RunFailed(String),
#[error("config error: {0}")]
Config(String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
impl HookError {
pub fn kind(&self) -> &'static str {
match self {
HookError::FrameworkNotInstalled(_) => "framework_not_installed",
HookError::UnsupportedFramework(_) => "unsupported_framework",
HookError::RemoteRefused => "remote_refused",
HookError::NotAGitRepo => "not_a_git_repo",
HookError::InstallFailed(_) => "install_failed",
HookError::UpdateFailed(_) => "update_failed",
HookError::RunFailed(_) => "run_failed",
HookError::Config(_) => "config",
HookError::Io(_) => "io",
}
}
}
fn enforce_remote_gate(config: &GitHooksConfig) -> Result<(), HookError> {
if config.origin == crate::ai_hooks::ConfigOrigin::Remote && !config.allow_remote {
if crate::observability::telemetry_gate::is_enabled() {
tracing::warn!(
event = "git_hooks.remote_refused",
reason = "allow_remote_not_set",
);
}
return Err(HookError::RemoteRefused);
}
Ok(())
}
pub fn install_hooks(config: &GitHooksConfig, project_dir: &Path) -> Result<bool, HookError> {
let telemetry_on = crate::observability::telemetry_gate::is_enabled();
let started = std::time::Instant::now();
if telemetry_on {
tracing::info!(
event = "git_hooks.install_started",
enabled = config.enabled,
auto_update = config.auto_update,
run_after_install = config.run_after_install,
);
}
let outcome = install_hooks_inner(config, project_dir);
if telemetry_on {
let (status, applied, framework_label) = match &outcome {
Ok(true) => (
"applied",
true,
config
.framework
.or_else(|| detect_framework(project_dir))
.map(HookFramework::as_str)
.unwrap_or("none"),
),
Ok(false) => ("skipped", false, "none"),
Err(_) => ("failed", false, "none"),
};
tracing::info!(
event = "git_hooks.install_completed",
status = status,
applied = applied,
framework = framework_label,
auto_update = config.auto_update,
run_after_install = config.run_after_install,
duration_ms = started.elapsed().as_millis() as u64,
);
}
outcome
}
fn install_hooks_inner(config: &GitHooksConfig, project_dir: &Path) -> Result<bool, HookError> {
if !config.enabled {
return Ok(false);
}
enforce_remote_gate(config)?;
if !project_dir.join(".git").exists() {
return Err(HookError::NotAGitRepo);
}
let framework = match config.framework.or_else(|| detect_framework(project_dir)) {
Some(f) => f,
None => return Ok(false),
};
match framework {
HookFramework::PreCommit => {
let handler = PreCommitHandler::new(
config.pre_commit.clone().unwrap_or_default(),
project_dir.to_path_buf(),
);
handler.install()?;
Ok(true)
}
HookFramework::Husky => {
let handler = husky::HuskyHandler::new(project_dir.to_path_buf());
handler.install()?;
Ok(true)
}
HookFramework::Lefthook => {
let handler = lefthook::LefthookHandler::new(project_dir.to_path_buf());
handler.install()?;
Ok(true)
}
HookFramework::Native => {
let handler = native::NativeHandler::new(
config.native.clone().unwrap_or_default(),
project_dir.to_path_buf(),
);
handler.install()?;
Ok(true)
}
}
}
pub fn update_hooks(config: &GitHooksConfig, project_dir: &Path) -> Result<bool, HookError> {
let telemetry_on = crate::observability::telemetry_gate::is_enabled();
let started = std::time::Instant::now();
if telemetry_on {
tracing::info!(event = "git_hooks.update_started", enabled = config.enabled,);
}
let outcome = update_hooks_inner(config, project_dir);
if telemetry_on {
let (status, applied, framework_label) = match &outcome {
Ok(true) => (
"applied",
true,
config
.framework
.or_else(|| detect_framework(project_dir))
.map(HookFramework::as_str)
.unwrap_or("none"),
),
Ok(false) => ("skipped", false, "none"),
Err(_) => ("failed", false, "none"),
};
tracing::info!(
event = "git_hooks.update_completed",
status = status,
applied = applied,
framework = framework_label,
duration_ms = started.elapsed().as_millis() as u64,
);
}
outcome
}
fn update_hooks_inner(config: &GitHooksConfig, project_dir: &Path) -> Result<bool, HookError> {
if !config.enabled {
return Ok(false);
}
enforce_remote_gate(config)?;
let framework = match config.framework.or_else(|| detect_framework(project_dir)) {
Some(f) => f,
None => return Ok(false),
};
match framework {
HookFramework::PreCommit => {
let handler = PreCommitHandler::new(
config.pre_commit.clone().unwrap_or_default(),
project_dir.to_path_buf(),
);
handler.update()?;
Ok(true)
}
HookFramework::Husky => {
let handler = husky::HuskyHandler::new(project_dir.to_path_buf());
handler.update()?;
Ok(true)
}
HookFramework::Lefthook => {
let handler = lefthook::LefthookHandler::new(project_dir.to_path_buf());
handler.update()?;
Ok(true)
}
HookFramework::Native => {
let handler = native::NativeHandler::new(
config.native.clone().unwrap_or_default(),
project_dir.to_path_buf(),
);
handler.update()?;
Ok(true)
}
}
}
pub fn list_hooks(config: &GitHooksConfig, project_dir: &Path) -> Result<Vec<HookInfo>, HookError> {
let framework = match config.framework.or_else(|| detect_framework(project_dir)) {
Some(f) => f,
None => return Ok(Vec::new()),
};
match framework {
HookFramework::PreCommit => {
let handler = PreCommitHandler::new(
config.pre_commit.clone().unwrap_or_default(),
project_dir.to_path_buf(),
);
handler.list()
}
HookFramework::Husky => {
let handler = husky::HuskyHandler::new(project_dir.to_path_buf());
handler.list()
}
HookFramework::Lefthook => {
let handler = lefthook::LefthookHandler::new(project_dir.to_path_buf());
handler.list()
}
HookFramework::Native => {
let handler = native::NativeHandler::new(
config.native.clone().unwrap_or_default(),
project_dir.to_path_buf(),
);
handler.list()
}
}
}
pub fn run_hooks(
config: &GitHooksConfig,
project_dir: &Path,
all_files: bool,
hook_id: Option<&str>,
) -> Result<(), HookError> {
enforce_remote_gate(config)?;
let framework = match config.framework.or_else(|| detect_framework(project_dir)) {
Some(f) => f,
None => {
return Err(HookError::Config(
"no hook framework detected; nothing to run".to_string(),
));
}
};
match framework {
HookFramework::PreCommit => {
let handler = PreCommitHandler::new(
config.pre_commit.clone().unwrap_or_default(),
project_dir.to_path_buf(),
);
handler.run(all_files, hook_id)
}
HookFramework::Husky => {
let handler = husky::HuskyHandler::new(project_dir.to_path_buf());
handler.run(all_files, hook_id)
}
HookFramework::Lefthook => {
let handler = lefthook::LefthookHandler::new(project_dir.to_path_buf());
handler.run(all_files, hook_id)
}
HookFramework::Native => {
let handler = native::NativeHandler::new(
config.native.clone().unwrap_or_default(),
project_dir.to_path_buf(),
);
handler.run(all_files, hook_id)
}
}
}
#[derive(Debug, Clone)]
pub struct HookStatus {
pub framework: Option<HookFramework>,
pub installed: bool,
pub config_path: Option<String>,
pub hook_count: usize,
}
pub fn hook_status(config: &GitHooksConfig, project_dir: &Path) -> HookStatus {
let framework = config.framework.or_else(|| detect_framework(project_dir));
let installed = project_dir
.join(".git")
.join("hooks")
.join("pre-commit")
.exists();
let (config_path, hook_count) = match framework {
Some(HookFramework::PreCommit) => {
let path = config
.pre_commit
.as_ref()
.map(|c| c.config.clone())
.unwrap_or_else(|| ".pre-commit-config.yaml".to_string());
let count = if project_dir.join(&path).exists() {
let handler = PreCommitHandler::new(
config.pre_commit.clone().unwrap_or_default(),
project_dir.to_path_buf(),
);
handler.list().map(|h| h.len()).unwrap_or(0)
} else {
0
};
(Some(path), count)
}
_ => (None, 0),
};
HookStatus {
framework,
installed,
config_path,
hook_count,
}
}
#[derive(Debug, Clone)]
pub struct HookInfo {
pub id: String,
pub repo: String,
pub version: String,
#[allow(dead_code)] pub hook_type: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ai_hooks::ConfigOrigin;
use tempfile::tempdir;
#[test]
fn install_hooks_refuses_remote_without_allow_remote_opt_in() {
let tmp = tempdir().unwrap();
std::fs::create_dir(tmp.path().join(".git")).unwrap();
std::fs::write(tmp.path().join(".pre-commit-config.yaml"), "repos: []").unwrap();
let cfg = GitHooksConfig {
enabled: true,
framework: Some(HookFramework::PreCommit),
auto_install: true,
auto_update: false,
run_after_install: false,
allow_remote: false,
pre_commit: None,
native: None,
origin: ConfigOrigin::Remote,
};
let err = install_hooks(&cfg, tmp.path()).expect_err("remote must refuse");
assert!(matches!(err, HookError::RemoteRefused), "got {err:?}");
}
#[test]
fn update_hooks_refuses_remote_without_allow_remote_opt_in() {
let tmp = tempdir().unwrap();
std::fs::create_dir(tmp.path().join(".git")).unwrap();
let cfg = GitHooksConfig {
enabled: true,
framework: Some(HookFramework::PreCommit),
auto_install: true,
auto_update: false,
run_after_install: false,
allow_remote: false,
pre_commit: None,
native: None,
origin: ConfigOrigin::Remote,
};
let err = update_hooks(&cfg, tmp.path()).expect_err("remote must refuse");
assert!(matches!(err, HookError::RemoteRefused));
}
#[test]
fn run_hooks_refuses_remote_without_allow_remote_opt_in() {
let tmp = tempdir().unwrap();
std::fs::create_dir(tmp.path().join(".git")).unwrap();
let cfg = GitHooksConfig {
enabled: true,
framework: Some(HookFramework::PreCommit),
auto_install: true,
auto_update: false,
run_after_install: false,
allow_remote: false,
pre_commit: None,
native: None,
origin: ConfigOrigin::Remote,
};
let err = run_hooks(&cfg, tmp.path(), false, None).expect_err("remote must refuse");
assert!(matches!(err, HookError::RemoteRefused));
}
#[test]
fn install_hooks_accepts_remote_when_explicitly_opted_in() {
let tmp = tempdir().unwrap();
let cfg = GitHooksConfig {
enabled: true,
framework: Some(HookFramework::PreCommit),
auto_install: true,
auto_update: false,
run_after_install: false,
allow_remote: true,
pre_commit: None,
native: None,
origin: ConfigOrigin::Remote,
};
let err =
install_hooks(&cfg, tmp.path()).expect_err("no .git → NotAGitRepo, NOT RemoteRefused");
assert!(
matches!(err, HookError::NotAGitRepo),
"expected gate to pass + later check to fail with NotAGitRepo; got {err:?}"
);
}
#[test]
fn install_hooks_local_origin_unchanged() {
let tmp = tempdir().unwrap();
let cfg = GitHooksConfig {
enabled: true,
framework: Some(HookFramework::PreCommit),
auto_install: true,
auto_update: false,
run_after_install: false,
allow_remote: false,
pre_commit: None,
native: None,
origin: ConfigOrigin::Local, };
let err = install_hooks(&cfg, tmp.path()).expect_err("no .git → NotAGitRepo");
assert!(matches!(err, HookError::NotAGitRepo));
}
}