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