use crate::files::file_contains_marker;
use crate::git_helpers::hooks_dir;
use crate::git_helpers::repo::resolve_protection_scope_from;
use crate::git_helpers::worktree;
use std::fs::{self, File};
use std::path::{Path, PathBuf};
pub const HOOK_MARKER: &str = "RALPH_RUST_MANAGED_HOOK";
pub const RALPH_HOOK_NAMES: &[&str] = &["pre-commit", "pre-push", "pre-merge-commit", "commit-msg"];
fn bash_single_quote_literal(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
fn make_hook_content(
hook_name: &str,
marker_path_bash: &str,
track_file_path_bash: &str,
orig_path_bash: &str,
) -> String {
format!(
r#"#!/usr/bin/env bash
set -euo pipefail
# {HOOK_MARKER} - generated by ralph
marker={marker_path_bash}
track_file={track_file_path_bash}
if [[ -f "$marker" ]] || [[ -f "$track_file" ]]; then
echo "{hook_name} blocked: agent phase protections active."
exit 1
fi
orig={orig_path_bash}
if [[ -f "$orig" ]]; then
exec "$orig" "$@"
fi
exit 0
"#
)
}
#[cfg(unix)]
fn make_writable_for_removal(path: &Path) {
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(path) {
let mut perms = meta.permissions();
perms.set_mode(perms.mode() | 0o200);
let _ = fs::set_permissions(path, perms);
}
}
#[cfg(not(unix))]
fn make_writable_for_removal(_path: &Path) {}
fn validate_hook_path_in_hooks_dir(hook_path: &Path, hooks_dir: &Path) -> std::io::Result<()> {
let hook_parent_dir = hook_path.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Hook path has no parent directory",
)
})?;
if hook_parent_dir == hooks_dir {
return Ok(());
}
Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"refusing to install hook outside scoped hooks dir: {}",
hook_path.display()
),
))
}
fn resolve_orig_path(hooks_dir: &Path, hook_path: &Path) -> std::io::Result<PathBuf> {
let resolved_hook_dir = fs::canonicalize(hooks_dir)?;
let hook_file_name = hook_path.file_name().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Hook path has no file name",
)
})?;
let hook_path_abs = resolved_hook_dir.join(hook_file_name);
Ok(PathBuf::from(format!("{}.ralph.orig", hook_path_abs.display())))
}
fn backup_existing_hook(hook_path: &Path, orig_path: &Path) -> std::io::Result<()> {
if !hook_path.exists() || file_contains_marker(hook_path, HOOK_MARKER)? {
return Ok(());
}
fs::copy(hook_path, orig_path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(orig_path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(orig_path, perms)?;
}
Ok(())
}
fn write_hook_file(hook_path: &Path, hook_content: &str) -> std::io::Result<()> {
#[cfg(unix)]
if hook_path.exists() {
make_writable_for_removal(hook_path);
}
let mut file = File::create(hook_path)?;
std::io::Write::write_all(&mut file, hook_content.as_bytes())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(hook_path)?.permissions();
perms.set_mode(0o555);
fs::set_permissions(hook_path, perms)?;
}
Ok(())
}
pub(crate) fn install_hook_with_repo_root(
hook_name: &str,
ralph_dir: &Path,
hooks_dir: &Path,
hook_path: &Path,
) -> std::io::Result<()> {
validate_hook_path_in_hooks_dir(hook_path, hooks_dir)?;
let marker_path = ralph_dir.join("no_agent_commit");
let track_file_path = ralph_dir.join("git-wrapper-dir.txt");
let orig_path_abs = resolve_orig_path(hooks_dir, hook_path)?;
backup_existing_hook(hook_path, &orig_path_abs)?;
let hook_content = make_hook_content(
hook_name,
&bash_single_quote_literal(&marker_path.display().to_string()),
&bash_single_quote_literal(&track_file_path.display().to_string()),
&bash_single_quote_literal(&orig_path_abs.display().to_string()),
);
write_hook_file(hook_path, &hook_content)
}
fn hook_display_label(hook_name: &str) -> &str {
match hook_name {
"pre-commit" => "Commit",
"pre-push" => "Push",
"pre-merge-commit" => "Merge commit",
"commit-msg" => "Commit message",
_ => hook_name,
}
}
fn install_hooks_for_hook_names(
ralph_dir: &Path,
hooks_dir: &Path,
hook_names: &[&str],
) -> std::io::Result<()> {
hook_names.iter().try_for_each(|hook_name| {
let label = hook_display_label(hook_name);
install_hook_with_repo_root(label, ralph_dir, hooks_dir, &hooks_dir.join(hook_name))
})
}
pub fn install_hooks_in_repo(repo_root: &Path) -> std::io::Result<()> {
let scope = resolve_protection_scope_from(repo_root)?;
let ralph_dir = crate::git_helpers::repo::ensure_ralph_git_dir(repo_root)?;
let hooks_dir = scope.hooks_dir.clone();
hooks_dir::ensure_scoped_hooks_dir_is_owned(&scope)?;
worktree::ensure_worktree_hook_scoping(&scope)?;
install_hooks_for_hook_names(&ralph_dir, &hooks_dir, RALPH_HOOK_NAMES)
}
#[cfg(any(test, feature = "test-utils"))]
pub fn install_hook(hook_name: &str, hook_path: &Path) -> std::io::Result<()> {
let repo_root = crate::git_helpers::repo::get_repo_root()?;
let scope = resolve_protection_scope_from(&repo_root)?;
let ralph_dir = crate::git_helpers::repo::ensure_ralph_git_dir(&repo_root)?;
let hooks_dir = hook_path.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Hook path has no parent directory",
)
})?;
if hooks_dir != scope.hooks_dir {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"refusing to install hook outside resolved hooks dir: {}",
hook_path.display()
),
));
}
hooks_dir::validate_hooks_dir_for_scope(&scope, true)?;
install_hook_with_repo_root(hook_name, &ralph_dir, hooks_dir, hook_path)
}
#[cfg(any(test, feature = "test-utils"))]
pub fn install_hooks() -> std::io::Result<()> {
let repo_root = crate::git_helpers::repo::get_repo_root()?;
install_hooks_in_repo(&repo_root)
}
#[cfg(test)]
mod tests {
use super::*;
fn generate_hook_content_for_test(
hook_label: &str,
hook_filename: &str,
ralph_dir: &str,
) -> String {
let marker_path_bash = bash_single_quote_literal(&format!("{ralph_dir}/no_agent_commit"));
let track_file_path_bash =
bash_single_quote_literal(&format!("{ralph_dir}/git-wrapper-dir.txt"));
let orig_path_bash =
bash_single_quote_literal(&format!("{ralph_dir}/../hooks/{hook_filename}.ralph.orig"));
make_hook_content(
hook_label,
&marker_path_bash,
&track_file_path_bash,
&orig_path_bash,
)
}
#[test]
fn test_ralph_hook_names_contains_all_hooks() {
assert_eq!(RALPH_HOOK_NAMES.len(), 4);
assert!(RALPH_HOOK_NAMES.contains(&"pre-commit"));
assert!(RALPH_HOOK_NAMES.contains(&"pre-push"));
assert!(RALPH_HOOK_NAMES.contains(&"pre-merge-commit"));
assert!(RALPH_HOOK_NAMES.contains(&"commit-msg"));
}
#[test]
fn test_generate_hook_content_for_test_uses_hook_filename_for_orig_path() {
let hook_content =
generate_hook_content_for_test("Commit", "pre-commit", "/tmp/test-repo/.git/ralph");
assert!(
hook_content.contains("pre-commit.ralph.orig"),
"orig hook path should use hook filename (pre-commit); got:\n{hook_content}"
);
}
#[test]
fn test_hook_blocking_message_is_ascii_only() {
let hook_content =
generate_hook_content_for_test("Commit", "pre-commit", "/tmp/test-repo/.git/ralph");
assert!(
hook_content.is_ascii(),
"hook content must be ASCII-only; got:\n{hook_content}"
);
}
#[test]
fn test_hook_content_contains_track_file_check() {
let hook_content =
generate_hook_content_for_test("Commit", "pre-commit", "/tmp/test-repo/.git/ralph");
assert!(
hook_content.contains("git-wrapper-dir.txt"),
"hook must check track file (git-wrapper-dir.txt); got:\n{hook_content}"
);
assert!(
hook_content.contains("no_agent_commit"),
"hook must check enforcement marker (no_agent_commit); got:\n{hook_content}"
);
}
#[test]
fn test_hook_blocks_when_only_track_file_exists() {
let hook_content =
generate_hook_content_for_test("Commit", "pre-commit", "/tmp/test-repo/.git/ralph");
assert!(
hook_content.contains("||"),
"hook must use OR logic to check marker OR track file; got:\n{hook_content}"
);
}
#[test]
fn test_hook_blocking_message_is_generic() {
let hook_content =
generate_hook_content_for_test("Commit", "pre-commit", "/tmp/test-repo/.git/ralph");
assert!(
hook_content.contains("agent phase"),
"hook blocking message should mention 'agent phase'; got:\n{hook_content}"
);
}
#[test]
fn test_commit_msg_hook_content_generated() {
let hook_content = generate_hook_content_for_test(
"Commit message",
"commit-msg",
"/tmp/test-repo/.git/ralph",
);
assert!(
hook_content.contains(HOOK_MARKER),
"commit-msg hook must contain the Ralph marker; got:\n{hook_content}"
);
assert!(
hook_content.contains("Commit message blocked"),
"commit-msg hook must reference 'Commit message' in blocking msg; got:\n{hook_content}"
);
}
#[test]
fn test_verify_hooks_removed_with_workspace() {
let expected_hooks = ["pre-commit", "pre-push", "pre-merge-commit", "commit-msg"];
expected_hooks.iter().for_each(|hook| {
assert!(
RALPH_HOOK_NAMES.contains(hook),
"verify_hooks_removed checks RALPH_HOOK_NAMES which must contain {hook}"
);
});
}
}