跳轉至

Git Hooks API 參考

boring.hooks

Git Hooks Module for Boring Local Teams.

Provides pre-commit and pre-push hooks that run Boring verification before allowing commits/pushes. This implements a local version of "Boring for Teams" without requiring server infrastructure.

HooksManager

Manages Git hooks installation and removal.

Source code in src/boring/hooks.py
class HooksManager:
    """Manages Git hooks installation and removal."""

    def __init__(self, project_root: Path = None):
        self.project_root = project_root or Path.cwd()
        self.git_dir = self.project_root / ".git"
        self.hooks_dir = self.git_dir / "hooks"

    def _get_venv_python_path(self) -> Path | None:
        """Determines the path to the Python executable in the project's virtual environment."""
        venv_path = self.project_root / ".venv"
        if not venv_path.is_dir():
            return None

        # Prioritize Windows path
        win_python = venv_path / "Scripts" / "python.exe"
        if win_python.exists():
            return win_python

        # Fallback to Unix-like path
        unix_python = venv_path / "bin" / "python"
        if unix_python.exists():
            return unix_python

        return None

    def is_git_repo(self) -> bool:
        """Check if current directory is a Git repository."""
        return self.git_dir.exists() and self.git_dir.is_dir()

    def install_hook(self, hook_name: str, content: str) -> tuple[bool, str]:
        """Install a single Git hook."""
        if not self.is_git_repo():
            return False, "Not a Git repository. Run 'git init' first."

        # Ensure hooks directory exists
        self.hooks_dir.mkdir(exist_ok=True)

        hook_path = self.hooks_dir / hook_name

        # Check for existing hook
        if hook_path.exists():
            # Backup existing hook
            backup_path = hook_path.with_suffix(".backup")
            hook_path.rename(backup_path)
            console.print(f"[yellow]Backed up existing {hook_name} to {hook_name}.backup[/yellow]")

        # Get absolute path to the virtual environment's python executable
        # This will be embedded directly into the hook script
        venv_python_path_windows = self.project_root / ".venv" / "Scripts" / "python.exe"
        venv_python_path_unix = self.project_root / ".venv" / "bin" / "python"

        if venv_python_path_windows.exists():
            python_exe = venv_python_path_windows.as_posix()
        elif venv_python_path_unix.exists():
            python_exe = venv_python_path_unix.as_posix()
        else:
            # Fallback to system python if venv not found (this should ideally not be used)
            console.print(
                "[yellow]Warning: Virtual environment python not found. Falling back to system python.[/yellow]"
            )
            python_exe = sys.executable.replace("\\", "/")

        # Ensure forward slashes for shell script compatibility (even on Windows)
        python_exe = python_exe.replace("\\", "/")

        # Replace 'boring' command with explicit python module invocation
        # This ensures the hook runs in the same environment where it was installed
        final_content = content.replace("boring verify", f'"{python_exe}" -m boring.main verify')
        final_content = final_content.replace(
            "boring speckit-analyze", f'"{python_exe}" -m boring.main speckit-analyze'
        )

        # Replace existence check
        check_cmd = f'"{python_exe}" -m boring.main --help'
        final_content = final_content.replace("command -v boring", check_cmd)

        # Write new hook
        hook_path.write_text(final_content, encoding="utf-8")

        # Make executable (Unix systems)
        try:
            current_mode = os.stat(hook_path).st_mode
            os.chmod(hook_path, current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
        except Exception:
            pass  # Windows doesn't need this

        return True, f"Installed {hook_name} hook."

    def install_all(self) -> tuple[bool, str]:
        """Install all Boring hooks."""
        if not self.is_git_repo():
            return False, "Not a Git repository. Run 'git init' first."

        results = []

        # Install pre-commit
        success, msg = self.install_hook("pre-commit", PRE_COMMIT_HOOK)
        results.append(msg)

        # Install pre-push
        success, msg = self.install_hook("pre-push", PRE_PUSH_HOOK)
        results.append(msg)

        return True, "\n".join(results)

    def uninstall_hook(self, hook_name: str) -> tuple[bool, str]:
        """Remove a Boring hook (restores backup if exists)."""
        hook_path = self.hooks_dir / hook_name
        backup_path = hook_path.with_suffix(".backup")

        if not hook_path.exists():
            return False, f"No {hook_name} hook found."

        hook_path.unlink()

        # Restore backup if exists
        if backup_path.exists():
            backup_path.rename(hook_path)
            return True, f"Removed Boring {hook_name} and restored backup."

        return True, f"Removed Boring {hook_name} hook."

    def uninstall_all(self) -> tuple[bool, str]:
        """Remove all Boring hooks."""
        results = []

        for hook_name in ["pre-commit", "pre-push"]:
            success, msg = self.uninstall_hook(hook_name)
            results.append(msg)

        return True, "\n".join(results)

    def status(self) -> dict:
        """Get status of installed hooks."""
        status = {"is_git_repo": self.is_git_repo(), "hooks": {}}

        if not self.is_git_repo():
            return status

        for hook_name in ["pre-commit", "pre-push"]:
            hook_path = self.hooks_dir / hook_name
            if hook_path.exists():
                content = hook_path.read_text(encoding="utf-8", errors="ignore")
                is_boring = "Boring" in content
                status["hooks"][hook_name] = {"installed": True, "is_boring_hook": is_boring}
            else:
                status["hooks"][hook_name] = {"installed": False, "is_boring_hook": False}

        return status

is_git_repo()

Check if current directory is a Git repository.

Source code in src/boring/hooks.py
def is_git_repo(self) -> bool:
    """Check if current directory is a Git repository."""
    return self.git_dir.exists() and self.git_dir.is_dir()

install_hook(hook_name, content)

Install a single Git hook.

Source code in src/boring/hooks.py
def install_hook(self, hook_name: str, content: str) -> tuple[bool, str]:
    """Install a single Git hook."""
    if not self.is_git_repo():
        return False, "Not a Git repository. Run 'git init' first."

    # Ensure hooks directory exists
    self.hooks_dir.mkdir(exist_ok=True)

    hook_path = self.hooks_dir / hook_name

    # Check for existing hook
    if hook_path.exists():
        # Backup existing hook
        backup_path = hook_path.with_suffix(".backup")
        hook_path.rename(backup_path)
        console.print(f"[yellow]Backed up existing {hook_name} to {hook_name}.backup[/yellow]")

    # Get absolute path to the virtual environment's python executable
    # This will be embedded directly into the hook script
    venv_python_path_windows = self.project_root / ".venv" / "Scripts" / "python.exe"
    venv_python_path_unix = self.project_root / ".venv" / "bin" / "python"

    if venv_python_path_windows.exists():
        python_exe = venv_python_path_windows.as_posix()
    elif venv_python_path_unix.exists():
        python_exe = venv_python_path_unix.as_posix()
    else:
        # Fallback to system python if venv not found (this should ideally not be used)
        console.print(
            "[yellow]Warning: Virtual environment python not found. Falling back to system python.[/yellow]"
        )
        python_exe = sys.executable.replace("\\", "/")

    # Ensure forward slashes for shell script compatibility (even on Windows)
    python_exe = python_exe.replace("\\", "/")

    # Replace 'boring' command with explicit python module invocation
    # This ensures the hook runs in the same environment where it was installed
    final_content = content.replace("boring verify", f'"{python_exe}" -m boring.main verify')
    final_content = final_content.replace(
        "boring speckit-analyze", f'"{python_exe}" -m boring.main speckit-analyze'
    )

    # Replace existence check
    check_cmd = f'"{python_exe}" -m boring.main --help'
    final_content = final_content.replace("command -v boring", check_cmd)

    # Write new hook
    hook_path.write_text(final_content, encoding="utf-8")

    # Make executable (Unix systems)
    try:
        current_mode = os.stat(hook_path).st_mode
        os.chmod(hook_path, current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
    except Exception:
        pass  # Windows doesn't need this

    return True, f"Installed {hook_name} hook."

install_all()

Install all Boring hooks.

Source code in src/boring/hooks.py
def install_all(self) -> tuple[bool, str]:
    """Install all Boring hooks."""
    if not self.is_git_repo():
        return False, "Not a Git repository. Run 'git init' first."

    results = []

    # Install pre-commit
    success, msg = self.install_hook("pre-commit", PRE_COMMIT_HOOK)
    results.append(msg)

    # Install pre-push
    success, msg = self.install_hook("pre-push", PRE_PUSH_HOOK)
    results.append(msg)

    return True, "\n".join(results)

uninstall_hook(hook_name)

Remove a Boring hook (restores backup if exists).

Source code in src/boring/hooks.py
def uninstall_hook(self, hook_name: str) -> tuple[bool, str]:
    """Remove a Boring hook (restores backup if exists)."""
    hook_path = self.hooks_dir / hook_name
    backup_path = hook_path.with_suffix(".backup")

    if not hook_path.exists():
        return False, f"No {hook_name} hook found."

    hook_path.unlink()

    # Restore backup if exists
    if backup_path.exists():
        backup_path.rename(hook_path)
        return True, f"Removed Boring {hook_name} and restored backup."

    return True, f"Removed Boring {hook_name} hook."

uninstall_all()

Remove all Boring hooks.

Source code in src/boring/hooks.py
def uninstall_all(self) -> tuple[bool, str]:
    """Remove all Boring hooks."""
    results = []

    for hook_name in ["pre-commit", "pre-push"]:
        success, msg = self.uninstall_hook(hook_name)
        results.append(msg)

    return True, "\n".join(results)

status()

Get status of installed hooks.

Source code in src/boring/hooks.py
def status(self) -> dict:
    """Get status of installed hooks."""
    status = {"is_git_repo": self.is_git_repo(), "hooks": {}}

    if not self.is_git_repo():
        return status

    for hook_name in ["pre-commit", "pre-push"]:
        hook_path = self.hooks_dir / hook_name
        if hook_path.exists():
            content = hook_path.read_text(encoding="utf-8", errors="ignore")
            is_boring = "Boring" in content
            status["hooks"][hook_name] = {"installed": True, "is_boring_hook": is_boring}
        else:
            status["hooks"][hook_name] = {"installed": False, "is_boring_hook": False}

    return status