diff --git a/src/hooks.py b/src/hooks.py new file mode 100644 index 0000000..8a9306c --- /dev/null +++ b/src/hooks.py @@ -0,0 +1,227 @@ +"""Git hook integration for prepare-commit-msg.""" + +import os +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + + +@dataclass +class HookResult: + """Result of a hook operation.""" + success: bool + message: str + backup_path: Optional[str] = None + + +class HookManager: + """Manages git prepare-commit-msg hook installation.""" + + HOOK_FILENAME = "prepare-commit-msg" + HOOK_CONTENT = '''#!/bin/bash +# prepare-commit-msg hook installed by local-commit-message-generator +# Generated commit message - edit as needed + +exec commit-gen hook "$@" +''' + + def __init__(self, repo_path: Optional[str] = None): + """Initialize hook manager. + + Args: + repo_path: Optional path to git repository. + """ + self.repo_path = Path(repo_path) if repo_path else Path.cwd() + self.hooks_dir = self.repo_path / ".git" / "hooks" + + def get_hook_path(self) -> Path: + """Get the path to the prepare-commit-msg hook. + + Returns: + Path to the hook file. + """ + return self.hooks_dir / self.HOOK_FILENAME + + def hook_exists(self) -> bool: + """Check if a prepare-commit-msg hook already exists. + + Returns: + True if hook exists, False otherwise. + """ + hook_path = self.get_hook_path() + return hook_path.exists() and hook_path.stat().st_size > 0 + + def has_our_hook(self) -> bool: + """Check if our hook is installed. + + Returns: + True if our hook is installed, False otherwise. + """ + hook_path = self.get_hook_path() + if not hook_path.exists(): + return False + content = hook_path.read_text() + return "commit-gen hook" in content + + def backup_existing_hook(self) -> Optional[Path]: + """Backup existing hook if it exists. + + Returns: + Path to backup file or None if no backup was needed. + """ + hook_path = self.get_hook_path() + if not hook_path.exists(): + return None + + backup_path = hook_path.with_suffix(".backup") + shutil.copy2(hook_path, backup_path) + return backup_path + + def install_hook(self) -> HookResult: + """Install the prepare-commit-msg hook. + + Returns: + HookResult with success status and message. + """ + try: + hook_path = self.get_hook_path() + + if not self.hooks_dir.exists(): + return HookResult( + success=False, + message=f"Git hooks directory not found: {self.hooks_dir}" + ) + + backup_path = None + if self.hook_exists() and not self.has_our_hook(): + backup_path = self.backup_existing_hook() + msg = f"Backed up existing hook to {backup_path}" + else: + msg = "" + + hook_path.write_text(self.HOOK_CONTENT) + os.chmod(hook_path, 0o755) + + if msg: + msg = f"Hook installed. {msg}" + else: + msg = "Hook installed successfully." + + return HookResult( + success=True, + message=msg, + backup_path=str(backup_path) if backup_path else None + ) + + except PermissionError: + return HookResult( + success=False, + message="Permission denied. Check write permissions on .git/hooks/" + ) + except OSError as e: + return HookResult( + success=False, + message=f"Failed to install hook: {e}" + ) + + def uninstall_hook(self) -> HookResult: + """Uninstall the prepare-commit-msg hook. + + Returns: + HookResult with success status and message. + """ + try: + hook_path = self.get_hook_path() + + if not hook_path.exists(): + return HookResult( + success=False, + message="No hook file found to uninstall." + ) + + if self.has_our_hook(): + backup_path = hook_path.with_suffix(".our_backup") + os.rename(hook_path, backup_path) + return HookResult( + success=True, + message=f"Hook uninstalled. Backup saved to {backup_path}" + ) + else: + return HookResult( + success=False, + message="Our hook was not installed. Not modifying." + ) + + except OSError as e: + return HookResult( + success=False, + message=f"Failed to uninstall hook: {e}" + ) + + def restore_hook(self) -> HookResult: + """Restore a backed-up hook. + + Returns: + HookResult with success status and message. + """ + hook_path = self.get_hook_path() + backup_path = hook_path.with_suffix(".backup") + + if not backup_path.exists(): + return HookResult( + success=False, + message="No backup file found to restore." + ) + + try: + os.rename(backup_path, hook_path) + return HookResult( + success=True, + message="Restored original hook from backup." + ) + except OSError as e: + return HookResult( + success=False, + message=f"Failed to restore hook: {e}" + ) + + +def is_hook_mode(args: list) -> bool: + """Check if running in hook mode. + + Args: + args: Command line arguments. + + Returns: + True if running in hook mode. + """ + return len(args) >= 1 and args[0] == "hook" + + +def handle_hook_invocation(args: list, repo_path: Optional[str] = None) -> str: + """Handle prepare-commit-msg hook invocation. + + Args: + args: Command line arguments from hook. + repo_path: Optional repository path. + + Returns: + Generated commit message or empty string. + """ + if len(args) < 1: + return "" + + commit_msg_file = args[0] + commit_source = args[1] if len(args) > 1 else "" + + try: + from .generator import generate_commit_message + message = generate_commit_message(repo_path) + + if message and commit_source != "merge" and commit_source != "squash": + Path(commit_msg_file).write_text(message) + + return message or "" + except Exception: + return ""