Source code for ralph.git.hooks

"""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