use anyhow::{bail, Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
pub const GIT_HOOK_NAMES: &[&str] = &[
"pre-commit",
"pre-push",
"pre-rebase",
"post-checkout",
"commit-msg",
"prepare-commit-msg",
"post-merge",
"post-rewrite",
];
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GitHookStatus {
pub is_configured: bool,
pub global: bool,
pub hooks_dir: PathBuf,
pub configured_hooks_path: Option<String>,
pub active_hooks: Vec<String>,
}
pub fn get_git_hooks_dir(global: bool, base_dir: Option<&Path>) -> Result<PathBuf> {
if global {
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.context("neither HOME nor USERPROFILE is set")?;
Ok(home.join(".vetto").join("git-hooks"))
} else {
let base = match base_dir {
Some(b) => b.to_path_buf(),
None => std::env::current_dir().context("getcwd")?,
};
Ok(base.join(".vetto").join("git-hooks"))
}
}
pub fn generate_git_hook_script(hook_name: &str) -> String {
format!(
r#"#!/bin/sh
# Vetto transparent Git hook wrapper for: {hook_name}
# Automatically generated by `vetto hook install --git`. Do not edit.
HOOK_NAME="{hook_name}"
# If already running inside Vetto sandbox, bypass to avoid recursion
if [ -n "$VETTO_SANDBOXED" ] || [ -n "$VETTO_SHIM_ACTIVE" ]; then
# If a repository-local hook was chained or backed up, execute it directly
if [ -n "$VETTO_CHAINED_HOOKS_DIR" ] && [ -x "$VETTO_CHAINED_HOOKS_DIR/$HOOK_NAME" ]; then
exec "$VETTO_CHAINED_HOOKS_DIR/$HOOK_NAME" "$@"
fi
exit 0
fi
# Locate repository root and local hook if present
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"
LOCAL_HOOK=""
if [ -n "$REPO_ROOT" ] && [ -f "$REPO_ROOT/.git/hooks/$HOOK_NAME" ] && [ -x "$REPO_ROOT/.git/hooks/$HOOK_NAME" ]; then
LOCAL_HOOK="$REPO_ROOT/.git/hooks/$HOOK_NAME"
fi
export VETTO_SANDBOXED=1
export VETTO_SHIM_ACTIVE=1
if [ -n "$LOCAL_HOOK" ]; then
if command -v vetto >/dev/null 2>&1; then
exec vetto -- "$LOCAL_HOOK" "$@"
else
exec "$LOCAL_HOOK" "$@"
fi
else
# Default: allow hook to succeed cleanly
exit 0
fi
"#
)
}
pub fn install_git_hooks(global: bool, base_dir: Option<&Path>, _force: bool) -> Result<PathBuf> {
let hooks_dir = get_git_hooks_dir(global, base_dir)?;
fs::create_dir_all(&hooks_dir).with_context(|| {
format!(
"failed to create git hooks directory {}",
hooks_dir.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&hooks_dir)?.permissions();
perms.set_mode(0o755);
let _ = fs::set_permissions(&hooks_dir, perms);
}
for &hook_name in GIT_HOOK_NAMES {
let hook_file = hooks_dir.join(hook_name);
let script = generate_git_hook_script(hook_name);
fs::write(&hook_file, script)
.with_context(|| format!("failed to write hook file {}", hook_file.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&hook_file)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&hook_file, perms).with_context(|| {
format!("failed to make hook executable: {}", hook_file.display())
})?;
}
}
let mut git_cmd = Command::new("git");
git_cmd.arg("config");
if global {
git_cmd.arg("--global");
} else {
git_cmd.arg("--local");
}
git_cmd.arg("core.hooksPath");
git_cmd.arg(hooks_dir.to_string_lossy().as_ref());
if let Some(b) = base_dir {
if !global {
git_cmd.current_dir(b);
}
}
let output = git_cmd.output();
match output {
Ok(out) if out.status.success() => Ok(hooks_dir),
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
bail!("git config core.hooksPath failed: {stderr}");
}
Err(e) => {
tracing::warn!("could not invoke git to set core.hooksPath: {e}");
Ok(hooks_dir)
}
}
}
pub fn uninstall_git_hooks(global: bool, base_dir: Option<&Path>) -> Result<bool> {
let hooks_dir = get_git_hooks_dir(global, base_dir)?;
let mut get_cmd = Command::new("git");
get_cmd.arg("config");
if global {
get_cmd.arg("--global");
} else {
get_cmd.arg("--local");
}
get_cmd.arg("core.hooksPath");
if let Some(b) = base_dir {
if !global {
get_cmd.current_dir(b);
}
}
let configured = if let Ok(out) = get_cmd.output() {
if out.status.success() {
let val = String::from_utf8_lossy(&out.stdout).trim().to_string();
Some(val)
} else {
None
}
} else {
None
};
let should_unset = if let Some(ref path_str) = configured {
Path::new(path_str) == hooks_dir || path_str.contains(".vetto/git-hooks")
} else {
false
};
if should_unset {
let mut unset_cmd = Command::new("git");
unset_cmd.arg("config");
if global {
unset_cmd.arg("--global");
} else {
unset_cmd.arg("--local");
}
unset_cmd.args(["--unset", "core.hooksPath"]);
if let Some(b) = base_dir {
if !global {
unset_cmd.current_dir(b);
}
}
let _ = unset_cmd.output();
}
if hooks_dir.exists() {
let _ = fs::remove_dir_all(&hooks_dir);
}
Ok(should_unset)
}
pub fn git_hooks_status(global: bool, base_dir: Option<&Path>) -> Result<GitHookStatus> {
let hooks_dir = get_git_hooks_dir(global, base_dir)?;
let mut get_cmd = Command::new("git");
get_cmd.arg("config");
if global {
get_cmd.arg("--global");
} else {
get_cmd.arg("--local");
}
get_cmd.arg("core.hooksPath");
if let Some(b) = base_dir {
if !global {
get_cmd.current_dir(b);
}
}
let configured_hooks_path = if let Ok(out) = get_cmd.output() {
if out.status.success() {
let val = String::from_utf8_lossy(&out.stdout).trim().to_string();
if val.is_empty() {
None
} else {
Some(val)
}
} else {
None
}
} else {
None
};
let is_configured = if let Some(ref path_str) = configured_hooks_path {
Path::new(path_str) == hooks_dir || path_str.contains(".vetto/git-hooks")
} else {
false
};
let mut active_hooks = Vec::new();
if hooks_dir.exists() {
if let Ok(entries) = fs::read_dir(&hooks_dir) {
for entry in entries.flatten() {
if let Ok(file_type) = entry.file_type() {
if file_type.is_file() || file_type.is_symlink() {
active_hooks.push(entry.file_name().to_string_lossy().to_string());
}
}
}
}
}
active_hooks.sort();
Ok(GitHookStatus {
is_configured,
global,
hooks_dir,
configured_hooks_path,
active_hooks,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_test_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"vetto-git-hooks-{name}-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn generates_valid_hook_script() {
let script = generate_git_hook_script("pre-commit");
assert!(script.contains("#!/bin/sh"));
assert!(script.contains("HOOK_NAME=\"pre-commit\""));
assert!(script.contains("VETTO_SANDBOXED"));
assert!(script.contains("vetto -- \"$LOCAL_HOOK\""));
}
#[test]
fn gets_local_hooks_dir_correctly() {
let temp = temp_test_dir("local-path");
let path = get_git_hooks_dir(false, Some(&temp)).unwrap();
assert_eq!(path, temp.join(".vetto").join("git-hooks"));
let _ = fs::remove_dir_all(&temp);
}
}