"""Manage Ralph git hooks for the Python CLI."""
from __future__ import annotations
import os
import shutil
from typing import TYPE_CHECKING
from loguru import logger as default_logger
if TYPE_CHECKING:
from pathlib import Path
from loguru import Logger
import contextlib
from ralph.git.operations import find_repo_root
HOOK_MARKER = "RALPH_RUST_MANAGED_HOOK"
"""Marker string embedded in every Ralph-managed hook."""
RALPH_HOOK_NAMES = (
"pre-commit",
"pre-push",
"pre-merge-commit",
"commit-msg",
)
"""Hook names managed by Ralph workflows."""
_HOOK_DISPLAY_LABELS = {
"pre-commit": "Commit",
"pre-push": "Push",
"pre-merge-commit": "Merge commit",
"commit-msg": "Commit message",
}
[docs]
def get_hooks_dir(repo_root: Path | str | None = None) -> Path:
"""Return the git hooks directory for a repository root."""
repo_root_path = _resolve_repo_root(repo_root)
hooks_dir = repo_root_path / ".git" / "hooks"
hooks_dir.mkdir(parents=True, exist_ok=True)
return hooks_dir
[docs]
def install_hooks_in_repo(repo_root: Path | str | None = None) -> Path:
"""Install the Ralph-managed hooks into a repository."""
repo_root_path = _resolve_repo_root(repo_root)
hooks_dir = get_hooks_dir(repo_root_path)
ralph_dir = repo_root_path / ".git" / "ralph"
ralph_dir.mkdir(parents=True, exist_ok=True)
_ensure_marker_files(ralph_dir)
for hook_name in RALPH_HOOK_NAMES:
_install_hook(hook_name, hooks_dir, ralph_dir)
return hooks_dir
[docs]
def reinstall_hooks_if_tampered(
*,
logger: Logger | None = None,
repo_root: Path | str | None = None,
) -> bool:
"""Reinstall hooks when they are missing or do not contain the marker."""
logger = logger or default_logger
repo_root_path = _resolve_repo_root(repo_root)
hooks_dir = get_hooks_dir(repo_root_path)
if _hooks_missing_or_tampered(hooks_dir):
logger.warning("Git hooks tampered or missing — reinstalling Ralph hooks")
install_hooks_in_repo(repo_root_path)
return True
return False
[docs]
def uninstall_hooks(
*,
logger: Logger | None = None,
repo_root: Path | str | None = None,
) -> bool:
"""Remove Ralph-managed hooks from the repository."""
logger = logger or default_logger
repo_root_path = _resolve_repo_root(repo_root)
hooks_dir = get_hooks_dir(repo_root_path)
removed = 0
for hook_name in RALPH_HOOK_NAMES:
removed += _remove_hook(hooks_dir / hook_name)
if removed:
logger.info("Uninstalled {removed} Ralph hook(s)", removed=removed)
else:
logger.info("No Ralph-managed hooks were found to uninstall")
return bool(removed)
def _ensure_marker_files(ralph_dir: Path) -> None:
for name in ("no_agent_commit", "git-wrapper-dir.txt"):
(ralph_dir / name).touch(exist_ok=True)
def _install_hook(hook_name: str, hooks_dir: Path, ralph_dir: Path) -> None:
hook_path = hooks_dir / hook_name
_backup_existing_hook(hook_path)
marker_path = ralph_dir / "no_agent_commit"
track_file = ralph_dir / "git-wrapper-dir.txt"
orig_path = _orig_hook_path(hooks_dir, hook_name)
content = _make_hook_content(
_hook_display_label(hook_name),
marker_path,
track_file,
orig_path,
)
_write_hook_file(hook_path, content)
def _bash_single_quote(path: Path | str) -> str:
value = str(path)
return "'" + value.replace("'", "'\\''") + "'"
def _make_hook_content(
hook_label: str,
marker_path: Path,
track_file_path: Path,
orig_path: Path,
) -> str:
return (
"#!/usr/bin/env bash\n"
"set -euo pipefail\n"
f"# {HOOK_MARKER} - generated by ralph\n\n"
f"marker={_bash_single_quote(marker_path)}\n"
f"track_file={_bash_single_quote(track_file_path)}\n\n"
'if [[ -f "$marker" ]] || [[ -f "$track_file" ]]; then\n'
f' echo "{hook_label} blocked: agent phase protections active."\n'
" exit 1\n"
"fi\n\n"
f"orig={_bash_single_quote(orig_path)}\n"
'if [[ -f "$orig" ]]; then\n'
' exec "$orig" "$@"\n'
"fi\n\n"
"exit 0\n"
)
def _hook_display_label(hook_name: str) -> str:
return _HOOK_DISPLAY_LABELS.get(hook_name, hook_name)
def _backup_existing_hook(hook_path: Path) -> None:
if not hook_path.exists():
return
try:
content = hook_path.read_text()
except OSError:
return
if HOOK_MARKER in content:
return
orig_path = _orig_hook_path(hook_path.parent, hook_path.name)
shutil.copyfile(hook_path, orig_path)
_make_writable(orig_path)
def _orig_hook_path(hooks_dir: Path, hook_name: str) -> Path:
return hooks_dir / f"{hook_name}.ralph.orig"
def _write_hook_file(hook_path: Path, content: str) -> None:
if hook_path.exists():
_make_writable(hook_path)
with contextlib.suppress(OSError):
hook_path.unlink()
hook_path.write_text(content)
_make_executable(hook_path)
def _make_executable(path: Path) -> None:
if os.name == "nt":
return
with contextlib.suppress(OSError):
path.chmod(0o555)
def _make_writable(path: Path) -> None:
if os.name == "nt":
return
with contextlib.suppress(OSError):
path.chmod(0o755)
def _hooks_missing_or_tampered(hooks_dir: Path) -> bool:
return any(not _hook_has_marker(hooks_dir / name) for name in RALPH_HOOK_NAMES)
def _hook_has_marker(hook_path: Path) -> bool:
if not hook_path.exists():
return False
try:
return HOOK_MARKER in hook_path.read_text()
except OSError:
return False
def _remove_hook(hook_path: Path) -> int:
if not _hook_has_marker(hook_path):
return 0
_make_writable(hook_path)
orig_path = _orig_hook_path(hook_path.parent, hook_path.name)
if orig_path.exists():
shutil.move(orig_path, hook_path)
_make_writable(hook_path)
return 1
with contextlib.suppress(FileNotFoundError):
hook_path.unlink()
return 1
def _resolve_repo_root(repo_root: Path | str | None) -> Path:
if repo_root is None:
return find_repo_root()
return find_repo_root(repo_root)
__all__ = [
"HOOK_MARKER",
"RALPH_HOOK_NAMES",
"get_hooks_dir",
"install_hooks",
"install_hooks_in_repo",
"reinstall_hooks_if_tampered",
"uninstall_hooks",
]
install_hooks = install_hooks_in_repo