#!/bin/sh # Pre-push: full CI checks run locally before any push. # This replaces remote CI for normal task pushes. # DO NOT skip with --no-verify (Claude Code is configured to never do this). # # ── Optimizations (Feb 2026) ───────────────────────────────────────────── # # 1. --no-clean on cargo llvm-cov: reuses previous instrumented build # artifacts for incremental compilation. Reduces Rust coverage from # ~8 min (full recompile) to ~30-60s (incremental rebuild). # # 2. Change detection: skips Rust checks entirely when no files under # src-tauri/ changed. Saves ~1-2 min on frontend-only pushes. # # 3. Merged redundant test runs: frontend coverage (step 2) already runs # all tests, so the separate test step was removed. # # 4. Fast-fail ordering: within Rust checks, fast lints (fmt ~2s, clippy # ~15s) run before slow coverage (~30-60s) for quicker feedback. # # 5. Force full coverage: set LAPUTA_FULL_COVERAGE=1 to run cargo llvm-cov # without --no-clean (clean rebuild, accurate baseline). # # Expected times (warm cache, incremental): # Frontend only: ~1 min # Frontend+Rust: ~2-3 min # Full coverage: ~9-10 min (with LAPUTA_FULL_COVERAGE=1) # ───────────────────────────────────────────────────────────────────────── set -e ensure_cargo_tooling() { if command -v cargo >/dev/null 2>&1; then return 0 fi if [ -s "$HOME/.cargo/env" ]; then # shellcheck disable=SC1091 . "$HOME/.cargo/env" fi if ! command -v cargo >/dev/null 2>&1; then echo "❌ cargo must be available before pushing" echo " Install Rust via https://rustup.rs or ensure ~/.cargo/bin is in PATH." exit 1 fi } ensure_node_tooling() { if command -v node >/dev/null 2>&1 && command -v pnpm >/dev/null 2>&1; then return 0 fi NVM_DIR="${NVM_DIR:-$HOME/.nvm}" if [ -s "$NVM_DIR/nvm.sh" ]; then # shellcheck disable=SC1090 . "$NVM_DIR/nvm.sh" --no-use nvm use --silent node >/dev/null 2>&1 || true fi if ! command -v node >/dev/null 2>&1 || ! command -v pnpm >/dev/null 2>&1; then echo "❌ node and pnpm must be available before pushing" echo " Install them or make sure your nvm setup is available to git hooks." exit 1 fi } require_main_push() { CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) if [ "$CURRENT_BRANCH" != "main" ] && [ "$CURRENT_BRANCH" != "HEAD" ]; then echo "❌ Pushes must happen from main or a detached HEAD that is pushed directly to main. Current branch: $CURRENT_BRANCH" exit 1 fi while IFS=' ' read -r LOCAL_REF LOCAL_SHA REMOTE_REF REMOTE_SHA; do [ -z "$LOCAL_REF" ] && continue case "$LOCAL_REF:$REMOTE_REF" in refs/heads/main:refs/heads/main) ;; HEAD:refs/heads/main) ;; refs/tags/*:refs/tags/*) ;; *) echo "❌ Pushes must be main -> main only." echo " Attempted: ${LOCAL_REF:-} -> ${REMOTE_REF:-}" exit 1 ;; esac done </dev/null || echo "") RUST_CHANGED=true APP_CHANGED=true SITE_CHANGED=false if [ -n "$PUSH_TARGET" ]; then CHANGED=$(git diff --name-only "$PUSH_TARGET"..HEAD) APP_CHANGED=false if ! echo "$CHANGED" | grep -qE '^(src-tauri/|Cargo)'; then RUST_CHANGED=false fi for FILE in $CHANGED; do case "$FILE" in site/*) SITE_CHANGED=true ;; .github/workflows/*|.husky/*|docs/*|*.md) ;; *) APP_CHANGED=true ;; esac done fi if [ "$APP_CHANGED" = false ]; then if [ "$SITE_CHANGED" = true ]; then echo "" echo "📚 Docs-only push detected; running docs build..." pnpm docs:build echo " ✅ Docs build OK" else echo "" echo "⏭️ App checks skipped (docs/workflow/hooks only)" fi ELAPSED=$(($(date +%s) - START_TIME)) echo "" echo "✅ Pre-push passed in ${ELAPSED}s" exit 0 fi run_sidecar_automatic_checks() { if [ "${LAPUTA_PREPUSH_LOCAL:-0}" = "1" ]; then echo "☁️ Chunk sidecar checks disabled by LAPUTA_PREPUSH_LOCAL=1" return 1 fi if ! command -v bash >/dev/null 2>&1; then echo "☁️ bash not found; falling back to local automatic checks" return 1 fi echo "" echo "☁️ Running automatic checks on Chunk sidecar..." if bash .chunk/run-sidecar-gates-local.sh "$RUST_CHANGED"; then echo " ✅ Chunk sidecar automatic checks OK" return 0 else SIDECAR_STATUS=$? fi if [ "$SIDECAR_STATUS" -eq 86 ]; then echo " ⚠️ Chunk sidecar unavailable; falling back to local automatic checks" return 1 fi echo " ❌ Chunk sidecar automatic checks FAILED" exit "$SIDECAR_STATUS" } if ! run_sidecar_automatic_checks; then ensure_cargo_tooling # ── 0. Frontend lint ─────────────────────────────────────────────────── echo "" echo "🔎 [0/6] Frontend lint..." pnpm lint echo " ✅ Lint OK" # ── 1. TypeScript + Vite build ────────────────────────────────────────── echo "" echo "📦 [1/6] TypeScript + Vite build..." pnpm build echo " ✅ Build OK" # ── 2. Frontend coverage (≥70%) — includes all unit tests ─────────────── echo "" echo "📊 [2/6] Frontend tests + coverage (≥70%)..." FRONTEND_COVERAGE_CONCURRENCY="${FRONTEND_COVERAGE_CONCURRENCY:-1}" \ node scripts/run-vitest-coverage-shards.mjs --silent echo " ✅ Frontend coverage OK" # ── 3. Rust lint (clippy + fmt) — fast, run before coverage ───────────── echo "" if [ "$RUST_CHANGED" = true ]; then echo "🔧 [3/6] Clippy + rustfmt..." cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings cargo fmt --manifest-path=src-tauri/Cargo.toml -- --check echo " ✅ Rust lint OK" else echo "⏭️ [3/6] Rust lint — skipped (no src-tauri/ changes)" fi # ── 4. Rust coverage (≥85% lines) ────────────────────────────────────── echo "" if [ "$RUST_CHANGED" = true ]; then LLVM_COV_FLAGS="--no-clean" if [ "${LAPUTA_FULL_COVERAGE:-0}" = "1" ]; then LLVM_COV_FLAGS="" echo "🦀 [4/6] Rust coverage — FULL (LAPUTA_FULL_COVERAGE=1)..." else echo "🦀 [4/6] Rust coverage (≥85%, incremental)..." fi # Unset GIT_DIR so git tests create isolated repos without inheriting hook context unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE # shellcheck disable=SC2086 cargo llvm-cov \ --manifest-path src-tauri/Cargo.toml \ $LLVM_COV_FLAGS \ --ignore-filename-regex "lib\.rs|main\.rs|menu\.rs" \ --fail-under-lines 85 \ -- --test-threads=1 echo " ✅ Rust coverage OK" else echo "⏭️ [4/6] Rust coverage — skipped (no src-tauri/ changes)" fi # ── 5. Playwright core smoke lane (if any exist) ────────────────────── echo "" SMOKE_FILES=$(find tests/smoke tests/integration -name '*.spec.ts' 2>/dev/null | head -1) if [ -n "$SMOKE_FILES" ]; then echo "🎭 [5/6] Playwright core smoke tests..." if ! pnpm playwright:smoke; then echo " ❌ Core smoke tests FAILED" exit 1 fi echo " ✅ Core smoke tests OK" else echo "⏭️ [5/6] Playwright core smoke tests — skipped (no tests/**/*.spec.ts)" fi fi # ── 6. CodeScene code health gate (ratchet) ────────────────────────────── # Thresholds live in .codescene-thresholds and only ever go UP (ratchet). # If remote scores improved, the hook updates the file and stops so the new # floor is committed with normal verified hooks before the next push. # If the remote baseline is already below threshold, allow recovery pushes to # land; otherwise the stale remote score would block the refactors required to # restore the gate. THRESHOLDS_FILE="$(git rev-parse --show-toplevel)/.codescene-thresholds" HOTSPOT_MIN=9.45 AVERAGE_MIN=9.29 if [ -f "$THRESHOLDS_FILE" ]; then HOTSPOT_MIN=$(grep HOTSPOT_THRESHOLD "$THRESHOLDS_FILE" | cut -d= -f2) AVERAGE_MIN=$(grep AVERAGE_THRESHOLD "$THRESHOLDS_FILE" | cut -d= -f2) fi echo "" echo "🏥 [6/6] CodeScene code health (Hotspot ≥${HOTSPOT_MIN} + Average ≥${AVERAGE_MIN})..." if [ -z "$CODESCENE_PAT" ] || [ -z "$CODESCENE_PROJECT_ID" ]; then echo " ⚠️ CODESCENE_PAT or CODESCENE_PROJECT_ID not set — skipping" else API_RESPONSE=$(curl -sf \ -H "Authorization: Bearer $CODESCENE_PAT" \ -H "Accept: application/json" \ "https://api.codescene.io/v2/projects/$CODESCENE_PROJECT_ID" 2>/dev/null || echo "{}") HOTSPOT_SCORE=$(echo "$API_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['hotspot_code_health']['now'])" 2>/dev/null || echo "") AVERAGE_SCORE=$(echo "$API_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['code_health']['now'])" 2>/dev/null || echo "") if [ -z "$HOTSPOT_SCORE" ] || [ -z "$AVERAGE_SCORE" ]; then echo " ⚠️ Could not fetch remote scores — skipping (CI will enforce)" else echo " Remote Hotspot Code Health: $HOTSPOT_SCORE (threshold: $HOTSPOT_MIN)" echo " Remote Average Code Health: $AVERAGE_SCORE (threshold: $AVERAGE_MIN)" PYTHON_STATUS=0 python3 -c " import sys hotspot = float('$HOTSPOT_SCORE') average = float('$AVERAGE_SCORE') hotspot_min = float('$HOTSPOT_MIN') average_min = float('$AVERAGE_MIN') failed = False if hotspot < hotspot_min: print(f'WARN: Hotspot Code Health {hotspot:.2f} < {hotspot_min} — remote baseline is currently red') failed = True else: print(f'OK: Hotspot {hotspot:.2f} >= {hotspot_min}') if average < average_min: print(f'WARN: Average Code Health {average:.2f} < {average_min} — remote baseline is currently red') failed = True else: print(f'OK: Average {average:.2f} >= {average_min}') if failed: print(' ⚠️ Recovery mode: allowing this push so refactors can land and restore the gate on a later analysis.') sys.exit(0) import math thresholds_file = '$THRESHOLDS_FILE' new_hotspot = max(hotspot_min, math.floor(hotspot * 100) / 100) new_average = max(average_min, math.floor(average * 100) / 100) if new_hotspot > hotspot_min or new_average > average_min: with open(thresholds_file, 'w') as f: f.write(f'HOTSPOT_THRESHOLD={new_hotspot}\nAVERAGE_THRESHOLD={new_average}\n') print(f' 📈 Ratchet updated: Hotspot {hotspot_min} → {new_hotspot}, Average {average_min} → {new_average}') sys.exit(3) " || PYTHON_STATUS=$? if [ "$PYTHON_STATUS" -ne 0 ] && [ "$PYTHON_STATUS" -ne 3 ]; then exit "$PYTHON_STATUS" fi if [ "$PYTHON_STATUS" -eq 3 ]; then git add "$THRESHOLDS_FILE" echo " ❌ Commit the updated .codescene-thresholds with a normal verified commit, then push again." exit 1 fi fi fi END_TIME=$(date +%s) ELAPSED=$((END_TIME - START_TIME)) MINUTES=$((ELAPSED / 60)) SECONDS=$((ELAPSED % 60)) echo "" echo "================================================" echo "✅ All checks passed — pushing (${MINUTES}m ${SECONDS}s)" echo ""