|
| 1 | +#!/usr/bin/env bash |
| 2 | +# LearnStack Hub pre-commit hook. |
| 3 | +# |
| 4 | +# Activated via `git config core.hooksPath .githooks` (run by `make install`). |
| 5 | +# Mirrors the LearnStack core hook line-by-line; the only differences are the |
| 6 | +# solution name (`LearnStack.Hub.slnx`) and the frontend path comments. |
| 7 | +# |
| 8 | +# Formats staged files in place ONLY — never the working tree at large — so |
| 9 | +# the check is fast and does not pollute unrelated changes. |
| 10 | +# |
| 11 | +# Languages handled: |
| 12 | +# *.cs → dotnet format (whitespace + style) |
| 13 | +# *.{ts,tsx,js,jsx,mjs,cjs} → prettier --write + ESLint via pnpm |
| 14 | +# *.{json,md} → prettier --write |
| 15 | +# |
| 16 | +# Intentionally NOT handled: *.yml / *.yaml. Compose / Dapr / APISIX YAMLs |
| 17 | +# are comment-heavy and prettier reflows them in a way that hurts review |
| 18 | +# readability. CI runs `yamllint` on those files separately. |
| 19 | +# |
| 20 | +# Secret scanning runs Leakwatch (`leakwatch scan fs <staged-file>` per file) |
| 21 | +# when the binary is on PATH. If not installed, the hook warns once and |
| 22 | +# continues — CI re-runs the same scan as a hard gate. Config lives at |
| 23 | +# `.leakwatch.yaml` + `.leakwatchignore`. |
| 24 | +# |
| 25 | +# WIP isolation: formatters mutate the working tree, then we re-stage the |
| 26 | +# changed paths. Without isolation that re-stage would silently capture any |
| 27 | +# unstaged edits the developer was holding back. We stash unstaged changes |
| 28 | +# first and pop on EXIT. |
| 29 | +# |
| 30 | +# Bypass once: `git commit --no-verify` (allowed for emergency fixes only; |
| 31 | +# CI re-checks every check this hook runs). |
| 32 | + |
| 33 | +set -eu -o pipefail |
| 34 | + |
| 35 | +REPO_ROOT="$(git rev-parse --show-toplevel)" |
| 36 | +cd "$REPO_ROOT" |
| 37 | + |
| 38 | +# ─── Stash unstaged changes so we only format what's actually indexed ─── |
| 39 | +STASH_REF="" |
| 40 | +unstaged_changes=$(git diff --quiet || echo "yes") |
| 41 | +unstaged_untracked=$(git ls-files --others --exclude-standard | head -n 1) |
| 42 | +if [[ -n "$unstaged_changes" || -n "$unstaged_untracked" ]]; then |
| 43 | + STASH_REF="hub-precommit-$(date +%s)" |
| 44 | + git stash push --keep-index --include-untracked --quiet -m "$STASH_REF" |
| 45 | + pop_stash() { |
| 46 | + if [[ -n "$STASH_REF" ]]; then |
| 47 | + entry=$(git stash list | awk -F: -v m="$STASH_REF" '$0 ~ m { print $1; exit }') |
| 48 | + if [[ -n "$entry" ]]; then |
| 49 | + git stash pop --quiet "$entry" || \ |
| 50 | + printf "pre-commit: stash pop failed for %s — recover with: git stash list\n" "$entry" >&2 |
| 51 | + fi |
| 52 | + fi |
| 53 | + } |
| 54 | + trap pop_stash EXIT |
| 55 | +fi |
| 56 | + |
| 57 | +# ─── Collect staged paths grouped by file type ────────────────────────── |
| 58 | +staged_files() { |
| 59 | + git diff --cached --name-only --diff-filter=ACMR -z "$@" |
| 60 | +} |
| 61 | + |
| 62 | +cs_files=() |
| 63 | +js_like_files=() |
| 64 | +prettier_only_files=() |
| 65 | +all_staged=() |
| 66 | + |
| 67 | +while IFS= read -r -d '' f; do |
| 68 | + all_staged+=("$f") |
| 69 | + case "$f" in |
| 70 | + *.cs) cs_files+=("$f") ;; |
| 71 | + *.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs) js_like_files+=("$f") ;; |
| 72 | + *.json|*.md) prettier_only_files+=("$f") ;; |
| 73 | + esac |
| 74 | +done < <(staged_files) |
| 75 | + |
| 76 | +restage() { git add -- "$@"; } |
| 77 | + |
| 78 | +# ─── Secret scanning (Leakwatch if available) ─────────────────────────── |
| 79 | +# Leakwatch's `scan fs` CLI takes a DIRECTORY (not a single file); per-file |
| 80 | +# scanning is not a supported invocation. Scope: pre-commit runs a single |
| 81 | +# repo-root scan whenever any file is staged. The .leakwatch.yaml + |
| 82 | +# .leakwatchignore filter out lock files, build artifacts, and intentional |
| 83 | +# dev-only literals so the scan stays fast for everyday commits. |
| 84 | +# |
| 85 | +# Trade-off: a pre-existing secret elsewhere in the tree fails this commit |
| 86 | +# too, not only secrets introduced by the staged diff. That is defensible — |
| 87 | +# secret scans are meant to catch the current state, not only the delta. |
| 88 | +# Use inline `# leakwatch:ignore` or .leakwatchignore for intentional dev |
| 89 | +# credentials. |
| 90 | + |
| 91 | +if command -v leakwatch >/dev/null 2>&1; then |
| 92 | + if [[ ${#all_staged[@]} -gt 0 ]]; then |
| 93 | + printf "pre-commit: leakwatch scan fs . (staged: %d file(s)) …\n" "${#all_staged[@]}" |
| 94 | + if ! scan_output=$(leakwatch scan fs . --config .leakwatch.yaml --format table --min-severity medium --no-verify 2>&1); then |
| 95 | + printf "\npre-commit: leakwatch found likely secrets:\n\n" >&2 |
| 96 | + printf "%s\n\n" "$scan_output" >&2 |
| 97 | + printf "If a finding is a legitimate dev credential, add an inline\n" >&2 |
| 98 | + printf "\`# leakwatch:ignore\` comment or extend .leakwatchignore.\n" >&2 |
| 99 | + exit 1 |
| 100 | + fi |
| 101 | + fi |
| 102 | +else |
| 103 | + printf "pre-commit: leakwatch not on PATH — skipping local secret scan (CI re-runs it).\n" >&2 |
| 104 | + printf " install: brew install cemililik/tap/leakwatch\n" >&2 |
| 105 | + printf " or: go install github.com/cemililik/leakwatch@latest\n" >&2 |
| 106 | +fi |
| 107 | + |
| 108 | +# ─── Backend: dotnet format ───────────────────────────────────────────── |
| 109 | +if [[ ${#cs_files[@]} -gt 0 ]]; then |
| 110 | + if ! command -v dotnet >/dev/null 2>&1; then |
| 111 | + printf "pre-commit: dotnet SDK not found — staged C# files left unformatted.\n" >&2 |
| 112 | + exit 1 |
| 113 | + fi |
| 114 | + printf "pre-commit: dotnet format (%d file(s)) …\n" "${#cs_files[@]}" |
| 115 | + rel_cs=() |
| 116 | + for f in "${cs_files[@]}"; do |
| 117 | + rel_cs+=("${f#backend/}") |
| 118 | + done |
| 119 | + (cd backend && dotnet format LearnStack.Hub.slnx --include "${rel_cs[@]}" --no-restore) |
| 120 | + restage "${cs_files[@]}" |
| 121 | +fi |
| 122 | + |
| 123 | +# ─── Frontend: prettier + ESLint via pnpm ─────────────────────────────── |
| 124 | +prettier_batch=("${js_like_files[@]:-}" "${prettier_only_files[@]:-}") |
| 125 | +real_prettier_batch=() |
| 126 | +for f in "${prettier_batch[@]}"; do |
| 127 | + [[ -n "$f" ]] && real_prettier_batch+=("$f") |
| 128 | +done |
| 129 | + |
| 130 | +if [[ ${#real_prettier_batch[@]} -gt 0 || ${#js_like_files[@]} -gt 0 ]]; then |
| 131 | + if ! command -v pnpm >/dev/null 2>&1; then |
| 132 | + printf "pre-commit: pnpm not found — staged JS/JSON/MD files left unformatted.\n" >&2 |
| 133 | + exit 1 |
| 134 | + fi |
| 135 | + |
| 136 | + if [[ ${#real_prettier_batch[@]} -gt 0 ]]; then |
| 137 | + printf "pre-commit: prettier --write (%d file(s)) …\n" "${#real_prettier_batch[@]}" |
| 138 | + (cd frontend && pnpm exec prettier --write --log-level warn "${real_prettier_batch[@]/#/../}") |
| 139 | + restage "${real_prettier_batch[@]}" |
| 140 | + fi |
| 141 | + |
| 142 | + if [[ ${#js_like_files[@]} -gt 0 ]]; then |
| 143 | + printf "pre-commit: eslint --fix (%d file(s)) …\n" "${#js_like_files[@]}" |
| 144 | + (cd frontend && pnpm exec eslint --fix --max-warnings 0 "${js_like_files[@]/#/../}") |
| 145 | + restage "${js_like_files[@]}" |
| 146 | + fi |
| 147 | +fi |
0 commit comments