pub mod templates;
use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use git2::Repository;
use templates::{HOOK_MARKER, PRE_COMMIT_TEMPLATE, PRE_PUSH_TEMPLATE};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookKind {
PreCommit,
PrePush,
}
impl HookKind {
pub fn file_name(self) -> &'static str {
match self {
HookKind::PreCommit => "pre-commit",
HookKind::PrePush => "pre-push",
}
}
pub fn template(self) -> &'static str {
match self {
HookKind::PreCommit => PRE_COMMIT_TEMPLATE,
HookKind::PrePush => PRE_PUSH_TEMPLATE,
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"pre-commit" => Some(HookKind::PreCommit),
"pre-push" => Some(HookKind::PrePush),
_ => None,
}
}
}
#[derive(thiserror::Error, Debug)]
pub enum HooksError {
#[error("not inside a git repository: {0}")]
NotARepo(PathBuf),
#[error("git error: {0}")]
Git(#[from] git2::Error),
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("existing hook is not Quorum-managed; remove it manually first ({0})")]
NotManaged(PathBuf),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstallOutcome {
Written(PathBuf),
Overwritten(PathBuf),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UninstallOutcome {
Removed(PathBuf),
AlreadyAbsent(PathBuf),
}
pub fn hook_path(start: &Path, kind: HookKind) -> Result<PathBuf, HooksError> {
let repo =
Repository::discover(start).map_err(|_| HooksError::NotARepo(start.to_path_buf()))?;
Ok(repo.path().join("hooks").join(kind.file_name()))
}
pub fn install(start: &Path, kind: HookKind) -> Result<InstallOutcome, HooksError> {
let path = hook_path(start, kind)?;
let outcome = if path.exists() {
if is_managed(&path)? {
InstallOutcome::Overwritten(path.clone())
} else {
return Err(HooksError::NotManaged(path));
}
} else {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
InstallOutcome::Written(path.clone())
};
fs::write(&path, kind.template())?;
set_executable_unix(&path);
Ok(outcome)
}
pub fn uninstall(start: &Path, kind: HookKind) -> Result<UninstallOutcome, HooksError> {
let path = hook_path(start, kind)?;
if !path.exists() {
return Ok(UninstallOutcome::AlreadyAbsent(path));
}
if !is_managed(&path)? {
return Err(HooksError::NotManaged(path));
}
fs::remove_file(&path)?;
Ok(UninstallOutcome::Removed(path))
}
pub fn is_managed(path: &Path) -> Result<bool, HooksError> {
let mut buf = String::new();
fs::File::open(path)?.take(8192).read_to_string(&mut buf)?;
Ok(first_five_lines_contain_marker(&buf))
}
fn first_five_lines_contain_marker(s: &str) -> bool {
s.lines().take(5).any(|line| line.contains(HOOK_MARKER))
}
#[cfg(unix)]
fn set_executable_unix(path: &Path) {
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(path) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = fs::set_permissions(path, perms);
}
}
#[cfg(not(unix))]
fn set_executable_unix(_path: &Path) {
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn init_repo() -> TempDir {
let td = TempDir::new().unwrap();
Repository::init(td.path()).unwrap();
td
}
#[test]
fn parse_round_trip() {
assert_eq!(HookKind::parse("pre-commit"), Some(HookKind::PreCommit));
assert_eq!(HookKind::parse("pre-push"), Some(HookKind::PrePush));
assert_eq!(HookKind::parse("pre-merge-commit"), None);
assert_eq!(HookKind::PreCommit.file_name(), "pre-commit");
assert_eq!(HookKind::PrePush.file_name(), "pre-push");
}
#[test]
fn hook_path_under_dot_git_hooks() {
let td = init_repo();
let p = hook_path(td.path(), HookKind::PreCommit).unwrap();
let trail: Vec<_> = p.components().rev().take(3).collect();
assert_eq!(trail[0].as_os_str(), "pre-commit");
assert_eq!(trail[1].as_os_str(), "hooks");
assert_eq!(trail[2].as_os_str(), ".git");
}
#[test]
fn install_writes_template_and_marker_present() {
let td = init_repo();
let outcome = install(td.path(), HookKind::PreCommit).unwrap();
match outcome {
InstallOutcome::Written(_) => {}
other => panic!("first install must be Written, got {other:?}"),
}
let path = hook_path(td.path(), HookKind::PreCommit).unwrap();
let body = fs::read_to_string(&path).unwrap();
assert!(body.starts_with("#!/bin/sh"));
assert!(first_five_lines_contain_marker(&body));
assert!(is_managed(&path).unwrap());
}
#[test]
fn install_is_idempotent_for_managed_hook() {
let td = init_repo();
install(td.path(), HookKind::PrePush).unwrap();
let again = install(td.path(), HookKind::PrePush).unwrap();
match again {
InstallOutcome::Overwritten(_) => {}
other => panic!("re-install must be Overwritten, got {other:?}"),
}
}
#[test]
fn install_refuses_non_managed_existing_hook() {
let td = init_repo();
let path = hook_path(td.path(), HookKind::PreCommit).unwrap();
if let Some(p) = path.parent() {
fs::create_dir_all(p).unwrap();
}
fs::write(
&path,
"#!/bin/sh\n# some other tool\necho 'foreign'\nexit 0\n",
)
.unwrap();
let err = install(td.path(), HookKind::PreCommit).unwrap_err();
assert!(matches!(err, HooksError::NotManaged(_)));
let body = fs::read_to_string(&path).unwrap();
assert!(body.contains("some other tool"));
}
#[test]
fn uninstall_removes_managed_idempotent_on_absent() {
let td = init_repo();
let absent = uninstall(td.path(), HookKind::PreCommit).unwrap();
assert!(matches!(absent, UninstallOutcome::AlreadyAbsent(_)));
install(td.path(), HookKind::PreCommit).unwrap();
let removed = uninstall(td.path(), HookKind::PreCommit).unwrap();
assert!(matches!(removed, UninstallOutcome::Removed(_)));
let again = uninstall(td.path(), HookKind::PreCommit).unwrap();
assert!(matches!(again, UninstallOutcome::AlreadyAbsent(_)));
}
#[test]
fn uninstall_refuses_non_managed() {
let td = init_repo();
let path = hook_path(td.path(), HookKind::PrePush).unwrap();
if let Some(p) = path.parent() {
fs::create_dir_all(p).unwrap();
}
fs::write(&path, "#!/bin/sh\n# not ours\nexit 0\n").unwrap();
let err = uninstall(td.path(), HookKind::PrePush).unwrap_err();
assert!(matches!(err, HooksError::NotManaged(_)));
}
#[test]
fn outside_repo_errs_with_not_a_repo() {
let td = TempDir::new().unwrap(); let err = hook_path(td.path(), HookKind::PreCommit).unwrap_err();
assert!(matches!(err, HooksError::NotARepo(_)));
let err = install(td.path(), HookKind::PreCommit).unwrap_err();
assert!(matches!(err, HooksError::NotARepo(_)));
let err = uninstall(td.path(), HookKind::PreCommit).unwrap_err();
assert!(matches!(err, HooksError::NotARepo(_)));
}
#[test]
fn marker_scan_first_five_lines_only() {
let s =
"#!/bin/sh\n# line 2\n# line 3\n# line 4\n# line 5\n# quorum-managed-hook v1\nexit 0\n";
assert!(!first_five_lines_contain_marker(s));
let s = "#!/bin/sh\n# quorum-managed-hook v1 (pre-commit)\nexit 0\n";
assert!(first_five_lines_contain_marker(s));
}
#[cfg(unix)]
#[test]
fn install_sets_0755_mode_on_unix() {
use std::os::unix::fs::PermissionsExt;
let td = init_repo();
let outcome = install(td.path(), HookKind::PreCommit).unwrap();
let path = match outcome {
InstallOutcome::Written(p) | InstallOutcome::Overwritten(p) => p,
};
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o755);
}
}