446 lines
12 KiB
Python
446 lines
12 KiB
Python
"""
|
|
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 -u
|
|
|
|
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 1
|
|
return 0
|
|
elif command -v black >/dev/null 2>&1; then
|
|
echo "ruff not found; running black --check instead"
|
|
black --check . || return 1
|
|
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 1
|
|
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"
|
|
return 1
|
|
fi
|
|
go vet ./... || return 1
|
|
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 || return 1
|
|
return 0
|
|
fi
|
|
if command -v pnpm >/dev/null 2>&1; then
|
|
pnpm -s test || return 1
|
|
return 0
|
|
fi
|
|
if command -v yarn >/dev/null 2>&1; then
|
|
yarn -s test || return 1
|
|
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 1
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
# Rust
|
|
if [ -f "Cargo.toml" ] && command -v cargo >/dev/null 2>&1; then
|
|
cargo test -q || return 1
|
|
return 0
|
|
fi
|
|
|
|
# Go
|
|
if [ -f "go.mod" ] && command -v go >/dev/null 2>&1; then
|
|
go test ./... || return 1
|
|
return 0
|
|
fi
|
|
|
|
# Java
|
|
if [ -f "pom.xml" ] || [ -f "build.gradle" ] || [ -f "build.gradle.kts" ]; then
|
|
if [ -x "./gradlew" ]; then
|
|
./gradlew -q test || return 1
|
|
return 0
|
|
fi
|
|
if command -v mvn >/dev/null 2>&1; then
|
|
mvn -q test || return 1
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
echo "No recognized test runner found; skipping tests"
|
|
}
|
|
|
|
run_checks() {
|
|
lint_failed=0
|
|
test_failed=0
|
|
run_lint || lint_failed=1
|
|
run_tests || test_failed=1
|
|
if [ $lint_failed -ne 0 ] || [ $test_failed -ne 0 ]; then
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Initial check; if it fails, try to auto-fix using local scripts if present
|
|
if ! run_checks; then
|
|
echo "Initial checks failed; attempting auto-fix via scripts/format.py and scripts/lintfix.py if present"
|
|
if [ -f "scripts/format.py" ]; then
|
|
if command -v python3 >/dev/null 2>&1; then python3 scripts/format.py || true; else python scripts/format.py || true; fi
|
|
fi
|
|
if [ -f "scripts/lintfix.py" ]; then
|
|
if command -v python3 >/dev/null 2>&1; then python3 scripts/lintfix.py || true; else python scripts/lintfix.py || true; fi
|
|
fi
|
|
if command -v git >/dev/null 2>&1; then git add -A; fi
|
|
run_checks
|
|
fi
|
|
|
|
""",
|
|
"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()
|