Files
DevGen/devtools/hooks/cli.py
T

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()