feat(hooks): add git hooks management CLI
This commit is contained in:
24 files changed
+1221
-531
No files matched your search
@@ -0,0 +1,424 @@
|
||||
"""
|
||||
Command-line interface for Git hooks management.
|
||||
"""
|
||||
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
HOOK_TEMPLATES = {
|
||||
"pre-commit": """#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "Running pre-commit checks..."
|
||||
|
||||
# If pre-commit is configured, defer to it
|
||||
if command -v pre-commit >/dev/null 2>&1 && [ -f ".pre-commit-config.yaml" ]; then
|
||||
pre-commit run --all-files
|
||||
exit 0
|
||||
fi
|
||||
|
||||
run_lint() {
|
||||
# Node.js/TypeScript
|
||||
if [ -f "package.json" ]; then
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
npm run -s lint --if-present || true
|
||||
if command -v npx >/dev/null 2>&1; then
|
||||
npx --yes eslint . || npx --yes prettier --check . || true
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
if command -v pnpm >/dev/null 2>&1; then
|
||||
pnpm -s lint || true
|
||||
pnpm dlx eslint . || pnpm dlx prettier --check . || true
|
||||
return 0
|
||||
fi
|
||||
if command -v yarn >/dev/null 2>&1; then
|
||||
yarn -s lint || true
|
||||
npx --yes eslint . || npx --yes prettier --check . || true
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Python
|
||||
if [ -f "pyproject.toml" ] || [ -f "requirements.txt" ]; then
|
||||
if command -v ruff >/dev/null 2>&1; then
|
||||
ruff .
|
||||
return 0
|
||||
elif command -v black >/dev/null 2>&1; then
|
||||
echo "ruff not found; running black --check instead"
|
||||
black --check .
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Rust
|
||||
if [ -f "Cargo.toml" ] && command -v cargo >/dev/null 2>&1; then
|
||||
cargo fmt --all -- --check
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Go
|
||||
if [ -f "go.mod" ]; then
|
||||
if command -v golangci-lint >/dev/null 2>&1; then
|
||||
golangci-lint run
|
||||
return 0
|
||||
fi
|
||||
if command -v go >/dev/null 2>&1; then
|
||||
fmt_out=$(gofmt -l .)
|
||||
if [ -n "$fmt_out" ]; then
|
||||
echo "Files not formatted (gofmt):"
|
||||
echo "$fmt_out"
|
||||
exit 1
|
||||
fi
|
||||
go vet ./...
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Java (best-effort)
|
||||
if [ -f "pom.xml" ] || [ -f "build.gradle" ] || [ -f "build.gradle.kts" ]; then
|
||||
if [ -x "./gradlew" ]; then
|
||||
./gradlew -q check || true
|
||||
return 0
|
||||
fi
|
||||
if command -v mvn >/dev/null 2>&1; then
|
||||
mvn -q -DskipTests=true verify || true
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "No recognized linter/formatter found; skipping lint/format checks"
|
||||
}
|
||||
|
||||
run_tests() {
|
||||
# Node.js/TypeScript
|
||||
if [ -f "package.json" ]; then
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
npm test -s --if-present || true
|
||||
return 0
|
||||
fi
|
||||
if command -v pnpm >/dev/null 2>&1; then
|
||||
pnpm -s test || true
|
||||
return 0
|
||||
fi
|
||||
if command -v yarn >/dev/null 2>&1; then
|
||||
yarn -s test || true
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Python
|
||||
if [ -f "pyproject.toml" ] || [ -f "requirements.txt" ]; then
|
||||
if command -v pytest >/dev/null 2>&1; then
|
||||
pytest -q
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Rust
|
||||
if [ -f "Cargo.toml" ] && command -v cargo >/dev/null 2>&1; then
|
||||
cargo test -q
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Go
|
||||
if [ -f "go.mod" ] && command -v go >/dev/null 2>&1; then
|
||||
go test ./...
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Java
|
||||
if [ -f "pom.xml" ] || [ -f "build.gradle" ] || [ -f "build.gradle.kts" ]; then
|
||||
if [ -x "./gradlew" ]; then
|
||||
./gradlew -q test || true
|
||||
return 0
|
||||
fi
|
||||
if command -v mvn >/dev/null 2>&1; then
|
||||
mvn -q test || true
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "No recognized test runner found; skipping tests"
|
||||
}
|
||||
|
||||
run_lint
|
||||
run_tests
|
||||
|
||||
""",
|
||||
"pre-push": """#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "Running pre-push checks..."
|
||||
|
||||
# If pre-commit exists we can reuse tests configuration from it
|
||||
if command -v pre-commit >/dev/null 2>&1 && [ -f ".pre-commit-config.yaml" ]; then
|
||||
pre-commit run --hook-stage push --all-files || true
|
||||
fi
|
||||
|
||||
# Prefer ecosystem-specific tests
|
||||
if [ -f "package.json" ]; then
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
npm test -s --if-present || true
|
||||
exit 0
|
||||
fi
|
||||
if command -v pnpm >/dev/null 2>&1; then
|
||||
pnpm -s test || true
|
||||
exit 0
|
||||
fi
|
||||
if command -v yarn >/dev/null 2>&1; then
|
||||
yarn -s test || true
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "Cargo.toml" ] && command -v cargo >/dev/null 2>&1; then
|
||||
cargo test -q
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -f "go.mod" ] && command -v go >/dev/null 2>&1; then
|
||||
go test ./...
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -f "pyproject.toml" ] || [ -f "requirements.txt" ]; then
|
||||
if command -v pytest >/dev/null 2>&1; then
|
||||
pytest -q
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "pom.xml" ] || [ -f "build.gradle" ] || [ -f "build.gradle.kts" ]; then
|
||||
if [ -x "./gradlew" ]; then
|
||||
./gradlew -q test || true
|
||||
exit 0
|
||||
fi
|
||||
if command -v mvn >/dev/null 2>&1; then
|
||||
mvn -q test || true
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "No recognized test runner found; skipping tests"
|
||||
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
def _write_hook(repo_root: Path, name: str, content: str) -> Path:
|
||||
hooks_dir = repo_root / ".git" / "hooks"
|
||||
hooks_dir.mkdir(parents=True, exist_ok=True)
|
||||
hook_path = hooks_dir / name
|
||||
# Backup existing hook if present
|
||||
if hook_path.exists():
|
||||
ts = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
backup_path = hooks_dir / f"{name}.{ts}.bak"
|
||||
backup_path.write_text(hook_path.read_text())
|
||||
hook_path.write_text(content)
|
||||
hook_path.chmod(
|
||||
hook_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
||||
)
|
||||
return hook_path
|
||||
|
||||
|
||||
@click.group()
|
||||
def cli():
|
||||
"""Manage Git hooks (pre-commit, pre-push)."""
|
||||
pass
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option(
|
||||
"--repo",
|
||||
"repo",
|
||||
type=click.Path(exists=True, file_okay=False),
|
||||
default=os.getcwd(),
|
||||
help="Repository path",
|
||||
)
|
||||
@click.option(
|
||||
"--pre-commit/--no-pre-commit", default=True, help="Install pre-commit hook"
|
||||
)
|
||||
@click.option("--pre-push/--no-pre-push", default=True, help="Install pre-push hook")
|
||||
@click.option(
|
||||
"--pre-commit-template",
|
||||
type=click.Path(exists=True, dir_okay=False),
|
||||
help="Path to custom pre-commit script template",
|
||||
)
|
||||
@click.option(
|
||||
"--pre-push-template",
|
||||
type=click.Path(exists=True, dir_okay=False),
|
||||
help="Path to custom pre-push script template",
|
||||
)
|
||||
def install(
|
||||
repo: str,
|
||||
pre_commit: bool,
|
||||
pre_push: bool,
|
||||
pre_commit_template: str | None,
|
||||
pre_push_template: str | None,
|
||||
):
|
||||
"""Install default hooks (lint/test)."""
|
||||
try:
|
||||
repo_root = Path(repo)
|
||||
if not (repo_root / ".git").exists():
|
||||
console.print("[red]Not a git repository[/red]")
|
||||
raise click.Abort()
|
||||
|
||||
installed = []
|
||||
if pre_commit:
|
||||
content = None
|
||||
if pre_commit_template:
|
||||
content = Path(pre_commit_template).read_text()
|
||||
else:
|
||||
content = HOOK_TEMPLATES["pre-commit"]
|
||||
path = _write_hook(repo_root, "pre-commit", content)
|
||||
installed.append(path.name)
|
||||
if pre_push:
|
||||
content = None
|
||||
if pre_push_template:
|
||||
content = Path(pre_push_template).read_text()
|
||||
else:
|
||||
content = HOOK_TEMPLATES["pre-push"]
|
||||
path = _write_hook(repo_root, "pre-push", content)
|
||||
installed.append(path.name)
|
||||
|
||||
if installed:
|
||||
console.print(
|
||||
Panel(
|
||||
f"Installed hooks: {', '.join(installed)}",
|
||||
title="Git Hooks",
|
||||
border_style="green",
|
||||
)
|
||||
)
|
||||
else:
|
||||
console.print("[yellow]No hooks selected.[/yellow]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option(
|
||||
"--repo",
|
||||
"repo",
|
||||
type=click.Path(exists=True, file_okay=False),
|
||||
default=os.getcwd(),
|
||||
help="Repository path",
|
||||
)
|
||||
@click.option(
|
||||
"--pre-commit/--no-pre-commit", default=True, help="Remove pre-commit hook"
|
||||
)
|
||||
@click.option("--pre-push/--no-pre-push", default=True, help="Remove pre-push hook")
|
||||
def uninstall(repo: str, pre_commit: bool, pre_push: bool):
|
||||
"""Remove installed hooks."""
|
||||
try:
|
||||
repo_root = Path(repo)
|
||||
if not (repo_root / ".git").exists():
|
||||
console.print("[red]Not a git repository[/red]")
|
||||
raise click.Abort()
|
||||
|
||||
removed = []
|
||||
for name, selected in [("pre-commit", pre_commit), ("pre-push", pre_push)]:
|
||||
if not selected:
|
||||
continue
|
||||
hook_path = repo_root / ".git" / "hooks" / name
|
||||
if hook_path.exists():
|
||||
hook_path.unlink()
|
||||
removed.append(name)
|
||||
|
||||
if removed:
|
||||
console.print(
|
||||
Panel(
|
||||
f"Removed hooks: {', '.join(removed)}",
|
||||
title="Git Hooks",
|
||||
border_style="yellow",
|
||||
)
|
||||
)
|
||||
else:
|
||||
console.print("[yellow]No hooks removed.")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
@cli.command(name="list")
|
||||
@click.option(
|
||||
"--repo",
|
||||
"repo",
|
||||
type=click.Path(exists=True, file_okay=False),
|
||||
default=os.getcwd(),
|
||||
help="Repository path",
|
||||
)
|
||||
def list_hooks(repo: str):
|
||||
"""List hooks present in the repository."""
|
||||
try:
|
||||
repo_root = Path(repo)
|
||||
hooks_dir = repo_root / ".git" / "hooks"
|
||||
if not hooks_dir.exists():
|
||||
console.print(
|
||||
"[yellow]No .git/hooks directory found (not a git repo?).[/yellow]"
|
||||
)
|
||||
return
|
||||
|
||||
known = [
|
||||
"applypatch-msg",
|
||||
"pre-applypatch",
|
||||
"post-applypatch",
|
||||
"pre-commit",
|
||||
"pre-merge-commit",
|
||||
"prepare-commit-msg",
|
||||
"commit-msg",
|
||||
"post-commit",
|
||||
"pre-rebase",
|
||||
"post-checkout",
|
||||
"post-merge",
|
||||
"pre-push",
|
||||
"pre-receive",
|
||||
"update",
|
||||
"post-receive",
|
||||
"post-update",
|
||||
"reference-transaction",
|
||||
"push-to-checkout",
|
||||
"pre-auto-gc",
|
||||
"post-rewrite",
|
||||
"sendemail-validate",
|
||||
"fsmonitor-watchman",
|
||||
"p4-pre-submit",
|
||||
]
|
||||
|
||||
table = Table(title="Git Hooks")
|
||||
table.add_column("Hook", style="cyan")
|
||||
table.add_column("Status", style="green")
|
||||
table.add_column("Executable", style="magenta")
|
||||
|
||||
found_any = False
|
||||
for name in known:
|
||||
p = hooks_dir / name
|
||||
if p.exists():
|
||||
found_any = True
|
||||
is_exec = os.access(p, os.X_OK)
|
||||
table.add_row(name, "present", "yes" if is_exec else "no")
|
||||
|
||||
# Include unknown custom hooks present
|
||||
for p in sorted(hooks_dir.iterdir()):
|
||||
if p.is_file() and p.name not in known:
|
||||
found_any = True
|
||||
is_exec = os.access(p, os.X_OK)
|
||||
table.add_row(p.name, "custom", "yes" if is_exec else "no")
|
||||
|
||||
if not found_any:
|
||||
console.print("[yellow]No hooks found in .git/hooks.[/yellow]")
|
||||
return
|
||||
|
||||
console.print(table)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise click.Abort()
|
||||
Reference in new issue
Block a user