feat(web): add husky pre-commit/pre-push/commit-msg guardrails

Installs husky git hooks (via web/'s lint-staged + npm, since the JS/TS
project lives in web/ with no root package.json) to catch lint, format,
type, and generated-API-drift errors locally before they reach CI.

Hooks (committed at repo root under .husky/):
- pre-commit: (a) lint-staged runs eslint --fix on staged
  web/src/**/*.{ts,tsx} + a project-wide typecheck; (b) if any *.cs are
  staged, dotnet format --verify-no-changes on just those files (skipped
  when no .cs staged, so web-only commits skip the sln load).
- pre-push: CI-parity gate — cd web && check:api && lint && typecheck &&
  build. Blocks pushing drift or a change that breaks an unstaged file.
- commit-msg: requires a Co-Authored-By trailer (merge commits exempt).

Wiring: web/package.json gains husky + lint-staged devDeps, a lint-staged
config, and a `prepare` script (cd .. && husky) that points git's
core.hooksPath at the repo-root .husky dir on npm install. A fresh
`web/` npm install installs all four hooks automatically.

Monorepo/worktree gotchas handled:
- husky init hard-checks for .git in cwd, so `prepare` cd's to the repo
  root before invoking husky (npm keeps web/node_modules/.bin on PATH).
- git exports GIT_DIR while running hooks; in a worktree/subdir that made
  pre-push's `git diff` (check:api) mislocate the working tree and pass
  silently on drift — pre-push now unsets GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE.

docs/ci-cd.md: new "Pre-commit hooks (web/)" section covering all four.

Verified: eslint error blocks commit; clean commit passes; bad-format .cs
blocks (dotnet format ~6-7s scoped), good .cs passes; check:api drift and
a lint error each block `git push --dry-run`, clean state passes; missing
Co-Authored-By blocks commit-msg, present passes; non-web/.cs commits skip
lint/format. npm run lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 19:31:22 +02:00
co-authored by Claude Opus 4.8
parent 3981abc7f9
commit f27f458ff5
6 changed files with 545 additions and 1 deletions
+49
View File
@@ -242,6 +242,55 @@ outside a transaction and warn at startup; they can't be rolled back mid-migrati
migrations carefully (this is part of what motivated the apply-to-fresh check before the prod
cutover, server-management#481).
## Pre-commit hooks (web/)
The repo uses **husky** git hooks (installed via `web/`'s **lint-staged** + npm) to catch
lint/format/type/API-drift errors locally, before they reach CI. Because the git root and
the npm project dir differ (monorepo: no root `package.json`, the JS/TS project lives
entirely in `web/`), the wiring is:
- `husky` + `lint-staged` are devDependencies of `web/package.json` (not a root package —
there isn't one).
- The committed hook scripts live at the repo root: `.husky/pre-commit`, `.husky/pre-push`,
`.husky/commit-msg`.
- `web/package.json`'s `prepare` script (`cd .. && husky`) runs on `npm install` inside
`web/` and points git at the repo-root `.husky` dir (`git config core.hooksPath
.husky/_` — the `_` subdir is husky's generated internal dir, gitignored via its own
`.husky/_/.gitignore`; only the hook scripts themselves are committed). This works
because npm keeps `web/node_modules/.bin` on `PATH` for the `prepare` script even after
it `cd ..`s to the repo root (which husky's init requires — it hard-checks for `.git`
in the *current* directory).
**The four hooks:**
1. **`pre-commit`** — (a) `cd web && npx lint-staged`: runs `eslint --fix` on staged
`web/src/**/*.{ts,tsx}` files, then a project-wide `npm run typecheck` (`tsc -b` isn't
file-scoped, so it runs the full check, but only when a `.ts`/`.tsx` file is staged);
(b) back at the repo root, if any **`*.cs`** files are staged, `dotnet format
ErsatzTV.sln --verify-no-changes --include <staged .cs>` — a formatting violation
blocks the commit. The .cs step is **skipped entirely when no .cs is staged**, so
web-only commits don't pay the sln-load cost; when it does run it's scoped to the staged
files (~6-7s wall in practice, dominated by the workspace load).
2. **`pre-push`** — CI-parity gate: `cd web && npm run check:api && npm run lint && npm run
typecheck && npm run build`. `check:api` guards generated-OpenAPI drift
(`ErsatzTV/wwwroot/openapi/v1.json` → `web/src/api/generated/v1.d.ts`); the full
lint/typecheck/build catch a staged change that breaks an *unstaged* file (lint-staged
only sees staged files). Any failure blocks the push.
3. **`commit-msg`** — enforces the CLAUDE.md protocol: the message must carry a
`Co-Authored-By:` trailer, else the commit is rejected. Merge commits are exempt
(detected via `git rev-parse --verify MERGE_HEAD`).
- **Worktree/subdir gotcha**: git exports `GIT_DIR` (and friends) while running hooks. In a
worktree or any subdir, an explicit `GIT_DIR` makes nested `git` commands mislocate the
working tree — `pre-push`'s `check:api` (`git diff --exit-code`, run from `web/`) then
silently reports "no diff" and lets drift through. `pre-push` therefore `unset`s
`GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE` first. (`pre-commit`'s `.cs` collection uses
`git diff --cached`, index-vs-HEAD, which needs only `GIT_DIR` and is unaffected.)
- **Practical effect**: a fresh `web/` `npm install` (after cloning or pulling this change)
installs all four hooks automatically — no separate setup step. Commits that touch only
non-`web/`, non-`.cs` files skip linting/formatting (lint-staged no-ops with nothing to
run, the `.cs` step is skipped).
## Registry
Gitea Packages, HTTP-only at `192.168.1.95:3000`. the `ci-runner` VM's Docker daemon (192.168.1.127) has it as an