Git Hooks
Tour of every husky git hook wired in the ITU monorepo.
The monorepo uses husky to wire client-side git hooks that run on every commit and every push. The hooks are the first line of defence against bad commits, broken lint state, misnamed branches, and accessibility regressions — by the time CI picks up a push, the heavy-lifting checks have already run locally.
There are three hooks installed: commit-msg, pre-commit, and pre-push. Each one lives in .husky/ at the monorepo root and runs shell scripts directly — no configuration layer, no magic, just plain sh.
Source of truth. The hooks live in .husky/commit-msg, .husky/pre-commit, and .husky/pre-push. If this page and those scripts disagree, the scripts win.
Don't use --no-verify
Never bypass the hooks with git commit --no-verify or git push --no-verify. It is explicitly discouraged in the monorepo CLAUDE.md agent instructions, and for good reason: the hooks exist because every one of them catches a class of problem that is painful to fix after the fact.
Skipping commit-msg produces a commit history that can't be parsed by changelog tools. Skipping pre-commit pushes unlinted code that will fail CI anyway. Skipping pre-push ships a misnamed branch or a UI component with a fresh accessibility regression — either of which someone else has to clean up.
If a hook fails, diagnose the underlying issue and fix it. That is almost always faster than triaging whatever the hook would have caught. The error messages the hooks produce are designed to point at the exact problem — read them, fix the cause, and retry.
commit-msg
Validates the commit message you just wrote against the project's conventional-commit rules.
When it fires
Immediately after you save your commit message, before git writes the commit to history. If the hook exits non-zero, git aborts the commit — your staged changes stay staged and nothing lands in the log.
What it runs
npx --no -- commitlint --edit $1
$1 is the path to the temporary file holding the message you just wrote. --edit tells commitlint to read from that file. --no tells npx not to fetch commitlint over the network — it must already be installed locally via the root devDependencies (it is).
The rules themselves live in commitlint.config.js, which extends @commitlint/config-conventional and adds a custom type-enum list.
What failure looks like
A rejected message prints something like:
⧗ input: Feat(ui): add compact density to DataTable ✖ type must be lower-case [type-case] ✖ type must be one of [feat, fix, chore, ci, docs, refactor, test, variant, style, perf, build, revert] [type-enum] ✖ found 2 problems, 0 warnings ⓘ Get help: https://github.com/conventional-changelog/commitlint/#what-is-commitlint husky - commit-msg script failed (code 1)
The ⧗ input: line echoes your message and each ✖ line names a specific broken rule.
How to diagnose
- Read the
[rule-name]at the end of each✖line — it tells you exactly which rule fired. - Re-run with
git commit --editto re-open your last attempted message in an editor and fix it in place. - See the commit messages page for the full format, the allowed type list, and passing/failing examples.
pre-commit
Runs lint-staged on the files you have staged.
When it fires
After you run git commit but before the commit-msg hook runs. If it fails, the commit is aborted.
What it runs
npx lint-staged
The lint-staged configuration lives in the root package.json:
{ "lint-staged": { "*.{ts,tsx,js,jsx}": [ "eslint --fix" ] } }
That means: for every staged TypeScript or JavaScript file, run eslint --fix. The --fix pass rewrites the file in place for any auto-fixable rule (unused imports, import order, quote style, etc.) and re-stages the fixed file so the fix lands in the same commit.
Crucially, lint-staged only lints files that are staged — not the whole monorepo. A commit that touches three files in apps/docs never triggers linting for packages/ui. This keeps the hook fast; typical runs finish in a few seconds.
Files outside the *.{ts,tsx,js,jsx} glob (MDX, JSON, Markdown, shell scripts, CSS) are not linted by this hook.
What failure looks like
Lint-staged prints a summary of which tasks ran on which files, followed by the ESLint error output for any rule that is not auto-fixable:
✖ eslint --fix: /Users/you/itu-monorepo/packages/ui/src/components/button/button.tsx 42:9 error 'unusedVar' is assigned a value but never used no-unused-vars ✖ 1 problem (1 error, 0 warnings) husky - pre-commit script failed (code 1)
How to diagnose
- Read the ESLint output — it names the file, line, and rule.
- Run
pnpm lintfrom the root to lint everything at once if you suspect the failure is wider than the staged files. - Run
pnpm --filter <package> lintto scope to a single package. - If the failure is auto-fixable, re-running the commit usually works (because
--fixhas already rewritten the file). If it is not auto-fixable (likeno-unused-vars), you have to edit the file by hand and re-stage it.
pre-push
Does two things: validates the branch name, then — if you touched any files under packages/ui/src/components/ — runs an automated accessibility audit against only the components that changed.
When it fires
Every time you run git push. If it exits non-zero, the push is rejected.
Part 1: branch name check
The first half of .husky/pre-push validates that the current branch matches <type>/<kebab-case> (with a short list of exempt patterns like main, develop, release/*, and hotfix/*). The full rules, examples, and failing error messages live on the branch naming page — that is the place to go when you see:
ERROR: Branch name 'feature/add-playground' does not match the naming convention.
Part 2: scoped a11y audit
If the branch check passes, the hook then figures out which UI components you have touched relative to main:
git diff --name-only main...HEAD -- packages/ui/src/components/
For each changed component directory it derives a slug, deduplicates, and runs:
pnpm --filter @itu/ui exec tsx scripts/extract-variants.ts pnpm --filter @itu/ui run a11y $SLUGS
The first command extracts CVA variants (the audit needs the full variant matrix). The second command runs the axe-core based a11y audit scoped to just the slugs that changed. Any resulting packages/ui/generated/a11y/<slug>.json files are automatically re-staged so the audit results travel with the push.
The audit is scoped on purpose: a one-line change in Button does not trigger a full audit of every component in @itu/ui. This keeps the push fast while still catching regressions on the surface area you actually touched.
For a deep-dive into what the a11y audit actually checks, how to read the output, and how to add new components to the rotation, see the dedicated accessibility audit page.
What failure looks like
Two distinct failure modes:
Branch name failure — prints the branch-naming error block and exits 1 before touching a11y:
ERROR: Branch name 'feature/my-branch' does not match the naming convention. Pattern: <type>/<kebab-case-description> Types: feat, fix, chore, ci, docs, refactor, test, variant ...
A11y audit failure — the audit script exits non-zero when one or more components fail the axe-core ruleset. The full per-component report is written to packages/ui/generated/a11y/<slug>.json; the hook aborts the push so you notice.
How to diagnose
- For branch errors, rename the branch:
git branch -m feat/better-name. - For a11y errors, open
packages/ui/generated/a11y/<slug>.json— it contains the axe-core violations, including the failing rule and the offending node selector. - To reproduce locally without pushing, run:
pnpm --filter @itu/ui run a11y <slug>for a single component, orpnpm --filter @itu/ui run a11y --allfor everything. - The accessibility audit page has the full playbook.
Hook install and opt-out
Husky is installed by the root prepare script:
{ "scripts": { "prepare": "husky" } }
pnpm install runs prepare automatically, which wires the hooks into your local .git/hooks/ directory. If you ever end up in a state where the hooks are not running (e.g. you cloned without running pnpm install), run pnpm install again from the root.
To genuinely opt out of husky for a given session — which you should not do — the environment variable HUSKY=0 short-circuits husky entirely. Reach for this only when you are deliberately scripting git operations that conflict with the hooks (release automation, migration scripts), and restore the normal behaviour as soon as you are done.
Related
- Commit messages — what
commit-msgvalidates and why. - Branch naming — what the first half of
pre-pushvalidates and why. - Accessibility audit — deep-dive into the a11y pass that runs in the second half of
pre-push.