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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user