#!/bin/bash # ───────────────────────────────────────────────────────────────────────────── # pre-commit hook (DEV Ground Rule §A — Code commit) # # Stack-agnostic. All commands come from .cqa-runner.conf: # 1. FORMAT_CMD on staged source files (auto-fix + re-stage) # 2. LINT_CMD on staged source files (+ re-stage) # 3. Unit tests + coverage via ./qa-unit-test-run.sh # 4. API E2E via ./qa-api-e2e-run.sh --skip-llm — runs automatically when the # runner exists. The runner starts the server itself (from START_CMD) when # nothing is listening and tears it down afterwards, so the E2E suite runs # on every commit. Only when START_CMD is unset AND no server is reachable # does it warn and skip (so an offline commit is never blocked). # # {FILES} in FORMAT_CMD/LINT_CMD is replaced with the staged files matching # STAGED_GLOB. A command with no {FILES} runs over the whole project. # # Skip flags (env vars): # SKIP_FORMAT=1 SKIP_LINT=1 SKIP_UNIT=1 SKIP_E2E=1 (default 0 — runs when present) # # Install: # cp .claude/skills/cqa-dev-ground-rule-setup/reference/githooks-pre-commit .githooks/pre-commit # chmod +x .githooks/pre-commit # git config core.hooksPath .githooks # ───────────────────────────────────────────────────────────────────────────── set -e ROOT_DIR="$(git rev-parse --show-toplevel)" cd "$ROOT_DIR" CONF="${CQA_RUNNER_CONF:-$ROOT_DIR/.cqa-runner.conf}" # shellcheck disable=SC1090 [[ -f "$CONF" ]] && source "$CONF" SKIP_FORMAT="${SKIP_FORMAT:-0}" SKIP_LINT="${SKIP_LINT:-0}" SKIP_UNIT="${SKIP_UNIT:-0}" SKIP_E2E="${SKIP_E2E:-0}" STAGED_GLOB="${STAGED_GLOB:-}" staged_files() { # respects STAGED_GLOB (may be multiple quoted globs) eval git diff --cached --name-only --diff-filter=ACMR -- $STAGED_GLOB 2>/dev/null || true } run_on_staged() { # $1=label $2=command-template (with optional {FILES}) local label="$1" tmpl="$2" [[ -z "$tmpl" ]] && return 0 local files; files="$(staged_files)" if [[ "$tmpl" == *"{FILES}"* ]]; then [[ -z "$files" ]] && return 0 echo "▶ $label (staged)" echo "$files" | tr '\n' '\0' | xargs -0 bash -c "${tmpl//\{FILES\}/\"\$@\"}" _ echo "$files" | tr '\n' '\0' | xargs -0 git add else echo "▶ $label" bash -c "$tmpl" fi } [[ "$SKIP_FORMAT" != "1" ]] && run_on_staged "format (${FORMAT_CMD%% *})" "${FORMAT_CMD:-}" [[ "$SKIP_LINT" != "1" ]] && run_on_staged "lint (${LINT_CMD%% *})" "${LINT_CMD:-}" # (C) unit tests + coverage if [[ "$SKIP_UNIT" != "1" ]]; then if [[ -x ./qa-unit-test-run.sh ]]; then echo "▶ ./qa-unit-test-run.sh" ./qa-unit-test-run.sh else echo "⚠ qa-unit-test-run.sh not executable — skipping unit tests" fi fi # (D) API E2E — the runner starts/stops the server itself when START_CMD is set, # so this runs on every commit. It is skipped only when there is no way to reach # a server (START_CMD unset AND nothing already listening). if [[ "$SKIP_E2E" != "1" ]]; then if [[ -x ./qa-api-e2e-run.sh ]]; then E2E_BASE="${DEFAULT_BASE_URL:-http://localhost:8000}" E2E_HEALTH="${HEALTH_PATH:-/health}" # Probe with a tolerant timeout (the first response can be slow); only used # to detect an already-running server when START_CMD is unset. if [[ -n "${START_CMD:-}" ]] || curl -sf -o /dev/null --max-time "${HEALTH_TIMEOUT:-10}" "${E2E_BASE}${E2E_HEALTH}"; then echo "▶ ./qa-api-e2e-run.sh --skip-llm" ./qa-api-e2e-run.sh --skip-llm else echo "⚠ no API server reachable at ${E2E_BASE}${E2E_HEALTH} and START_CMD unset — skipping E2E (set START_CMD, or SKIP_E2E=1)" fi fi fi echo "✓ pre-commit OK"