#!/usr/bin/env bash # Mirrors .github/workflows/frontend-quality.yml (verify job). # Intentionally not mirrored locally: OSV scan, gitleaks (PR-only on GitHub). # # Resume: after a failed push, re-push skips steps whose inputs are unchanged # (checkpoint in .git/ghabilee-prepush-checkpoint). Force full rerun: # PREPUSH_NO_CACHE=1 git push set -euo pipefail ROOT="$(git rev-parse --show-toplevel)" cd "$ROOT" ZERO="0000000000000000000000000000000000000000" CHECKPOINT_FILE="$ROOT/.git/ghabilee-prepush-checkpoint" # Same idea as CI path filters — docs/ops-only pushes skip the heavy suite. FRONTEND_PATH_RE='^(app/|components/|context/|features/|hooks/|services/|lib/|helpers/|validation/|types/|queries/|texts/|constants/|content/|config/|api/|e2e/|public/|styles/|messages/|middleware\.|next\.config|orval\.config|vitest|playwright|openapi\.json$|package\.json$|pnpm-lock\.yaml$|tsconfig|eslint\.config|postcss\.config|tailwind\.config|knip\.json$|\.github/workflows/frontend-quality\.yml$|scripts/)' PUSH_LOCAL_SHA="" PUSH_REMOTE_SHA="" CHECKPOINT_REMOTE_SHA="" if [[ "${SKIP_PREPUSH_CHECKS:-}" == "1" ]]; then echo "⚠ SKIP_PREPUSH_CHECKS=1 — skipping pre-push checks" exit 0 fi run_step() { local title="$1" shift echo "" echo "==> $title" local start start="$(date +%s)" "$@" echo " ($(( $(date +%s) - start ))s)" } hash_paths() { local files="" local spec for spec in "$@"; do local chunk chunk="$(git ls-files -co --exclude-standard -- "$spec" 2>/dev/null || true)" [[ -n "$chunk" ]] && files+="${chunk}"$'\n' done files="$(printf '%s' "$files" | LC_ALL=C sort -u | sed '/^$/d')" if [[ -z "$files" ]]; then printf '%s' "empty" return 0 fi printf '%s\0' "$files" | LC_ALL=C sort -z | xargs -0 git hash-object 2>/dev/null | shasum -a 256 | awk '{print $1}' } checkpoint_clear() { rm -f "$CHECKPOINT_FILE" } checkpoint_get_remote_sha() { [[ -f "$CHECKPOINT_FILE" ]] || return 0 grep '^remote_sha=' "$CHECKPOINT_FILE" | head -1 | cut -d= -f2- } checkpoint_get_step_hash() { local step_id="$1" [[ -f "$CHECKPOINT_FILE" ]] || return 0 grep "^step:${step_id}=" "$CHECKPOINT_FILE" | head -1 | cut -d= -f2- || true } checkpoint_mark_step() { local step_id="$1" local step_hash="$2" local remote_line="remote_sha=${PUSH_REMOTE_SHA}" local tmp tmp="$(mktemp)" if [[ -f "$CHECKPOINT_FILE" ]]; then grep -v "^step:${step_id}=" "$CHECKPOINT_FILE" | grep -v '^remote_sha=' >"$tmp" || true fi { echo "$remote_line" if [[ -s "$tmp" ]]; then cat "$tmp" fi echo "step:${step_id}=${step_hash}" } >"$CHECKPOINT_FILE" rm -f "$tmp" } checkpoint_init_run() { CHECKPOINT_REMOTE_SHA="$(checkpoint_get_remote_sha)" if [[ "$CHECKPOINT_REMOTE_SHA" != "$PUSH_REMOTE_SHA" ]]; then checkpoint_clear echo "remote_sha=${PUSH_REMOTE_SHA}" >"$CHECKPOINT_FILE" elif [[ ! -f "$CHECKPOINT_FILE" ]]; then echo "remote_sha=${PUSH_REMOTE_SHA}" >"$CHECKPOINT_FILE" fi } checkpoint_step_hash_matches() { local step_id="$1" local current_hash="$2" [[ "${PREPUSH_NO_CACHE:-}" == "1" ]] && return 1 [[ "$(checkpoint_get_remote_sha)" == "$PUSH_REMOTE_SHA" ]] || return 1 [[ "$(checkpoint_get_step_hash "$step_id")" == "$current_hash" ]] } run_cached_step() { local step_id="$1" local title="$2" local input_hash="$3" shift 3 if checkpoint_step_hash_matches "$step_id" "$input_hash"; then echo "" echo "==> $title (skipped — inputs unchanged since last run)" return 0 fi run_step "$title" "$@" checkpoint_mark_step "$step_id" "$input_hash" } frontend_orval_drift() { pnpm exec orval --config orval.config.ts git diff --exit-code -- api/generated } frontend_build() { NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL:-http://127.0.0.1:3000}" \ pnpm exec next build pnpm bundle:check } run_frontend_quality() { checkpoint_init_run echo "" echo "── Frontend quality (frontend-quality.yml) ──" if [[ "${PREPUSH_NO_CACHE:-}" != "1" && -s "$CHECKPOINT_FILE" ]] && grep -q '^step:' "$CHECKPOINT_FILE" 2>/dev/null; then echo " Resume enabled — unchanged steps are skipped (PREPUSH_NO_CACHE=1 to rerun all)." fi run_cached_step generate_api "Generate API client from committed OpenAPI" \ "$(hash_paths openapi.json orval.config.ts scripts/generate-api.mjs)" \ pnpm generate:api # Always run full typecheck (no checkpoint skip) — same surface as CI `pnpm typecheck`. run_step "TypeScript" pnpm typecheck run_cached_step lint "ESLint" \ "$(hash_paths '*.ts' '*.tsx' '*.js' '*.jsx' eslint.config.mjs)" \ pnpm lint run_cached_step build "Production build and bundle budget" \ "$(hash_paths app components context features hooks services lib helpers validation types queries texts constants content config api public styles messages scripts '*.ts' '*.tsx' next.config.js postcss.config.mjs tailwind.config.ts package.json pnpm-lock.yaml)" \ frontend_build run_cached_step orval_drift "Verify generated API client" \ "$(hash_paths openapi.json orval.config.ts api scripts/generate-api.mjs)" \ frontend_orval_drift run_cached_step test "Unit tests" \ "$(hash_paths '*.ts' '*.tsx' vitest.config.ts vitest.consumer.config.ts package.json pnpm-lock.yaml)" \ pnpm test run_cached_step architecture "Architecture (no circular dependencies)" \ "$(hash_paths app components context hooks services lib config '*.ts' '*.tsx')" \ pnpm architecture:check run_cached_step pwa "PWA policy" \ "$(hash_paths scripts/check-service-worker-policy.mjs public package.json '*.ts' '*.tsx')" \ pnpm pwa:check run_cached_step knip "Unused code and dependencies" \ "$(hash_paths knip.json package.json pnpm-lock.yaml '*.ts' '*.tsx' '*.js' '*.jsx' '*.mjs')" \ pnpm unused:check checkpoint_clear } echo "" echo "Pre-push CI guard — mirrors frontend-quality.yml on matching paths." echo "Pre-commit already auto-fixes staged files (ESLint --fix + Prettier)." echo "Skip once: HUSKY=0 git push or SKIP_PREPUSH_CHECKS=1 git push" run_frontend=0 changed_files="" compared=0 version_checked=0 compare_and_collect() { local local_sha="$1" local remote_sha="$2" [[ "$local_sha" == "$ZERO" ]] && return 0 if [[ "$remote_sha" == "$ZERO" ]]; then if git rev-parse --verify --quiet origin/main >/dev/null; then remote_sha="$(git rev-parse origin/main)" elif git rev-parse --verify --quiet main >/dev/null; then remote_sha="$(git rev-parse main)" else return 0 fi fi [[ "$local_sha" == "$remote_sha" ]] && return 0 PUSH_LOCAL_SHA="$local_sha" PUSH_REMOTE_SHA="$remote_sha" compared=1 changed_files+="$(git diff --name-only "$remote_sha" "$local_sha")"$'\n' } check_version_bump() { local local_sha="$1" local remote_sha="$2" [[ "$local_sha" == "$ZERO" || "$local_sha" == "$remote_sha" ]] && return 0 if [[ "$remote_sha" == "$ZERO" ]]; then if git rev-parse --verify --quiet origin/main >/dev/null; then remote_sha="$(git rev-parse origin/main)" elif git rev-parse --verify --quiet main >/dev/null; then remote_sha="$(git rev-parse main)" else return 0 fi fi [[ "$local_sha" == "$remote_sha" ]] && return 0 node "$ROOT/scripts/check-version-bump.mjs" "$remote_sha" "$local_sha" version_checked=1 } if [[ -t 0 ]]; then echo " (no git refs on stdin — comparing HEAD to origin/main, running full suite)" PUSH_LOCAL_SHA="$(git rev-parse HEAD)" if git rev-parse --verify --quiet origin/main >/dev/null; then PUSH_REMOTE_SHA="$(git rev-parse origin/main)" else PUSH_REMOTE_SHA="$ZERO" fi compare_and_collect "$PUSH_LOCAL_SHA" "$PUSH_REMOTE_SHA" compared=0 else push_refs="$(mktemp)" trap 'rm -f "$push_refs"' EXIT cat >"$push_refs" while read -r _local_ref local_sha _remote_ref remote_sha; do compare_and_collect "$local_sha" "$remote_sha" done <"$push_refs" fi if [[ -z "$PUSH_LOCAL_SHA" ]]; then PUSH_LOCAL_SHA="$(git rev-parse HEAD)" fi if [[ -z "$PUSH_REMOTE_SHA" || "$PUSH_REMOTE_SHA" == "$ZERO" ]]; then if git rev-parse --verify --quiet origin/main >/dev/null; then PUSH_REMOTE_SHA="$(git rev-parse origin/main)" else PUSH_REMOTE_SHA="$ZERO" fi fi if [[ "$compared" -eq 0 ]]; then run_frontend=1 elif echo "$changed_files" | grep -qE "$FRONTEND_PATH_RE"; then run_frontend=1 fi if [[ "$run_frontend" -eq 1 ]]; then echo "" echo "==> Frontend version bump" if [[ -t 0 ]]; then check_version_bump "$PUSH_LOCAL_SHA" "$PUSH_REMOTE_SHA" else while read -r _local_ref local_sha _remote_ref remote_sha; do if [[ "$local_sha" != "$ZERO" && "$remote_sha" != "$ZERO" && "$local_sha" != "$remote_sha" ]]; then check_version_bump "$local_sha" "$remote_sha" fi done <"$push_refs" fi if [[ "$version_checked" -eq 0 ]]; then echo " (no refs to compare — skipping version check)" fi fi if [[ "$run_frontend" -eq 0 ]]; then echo "" echo "No frontend code paths in this push — skipping quality suite (same as CI path filters)." checkpoint_clear echo "✓ Pre-push checks passed" echo "" exit 0 fi run_frontend_quality echo "" echo "✓ Pre-push checks passed" echo ""