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

bash
npx --no -- commitlint --edit $1
1 line of bash code

$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:

text
⧗ 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)
8 lines of text code

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 --edit to 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

bash
npx lint-staged
1 line of bash code

The lint-staged configuration lives in the root package.json:

json
{
"lint-staged": {
"*.{ts,tsx,js,jsx}": [
"eslint --fix"
]
}
}
7 lines of json code

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:

text
✖ 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)
8 lines of text code

How to diagnose

  • Read the ESLint output — it names the file, line, and rule.
  • Run pnpm lint from the root to lint everything at once if you suspect the failure is wider than the staged files.
  • Run pnpm --filter <package> lint to scope to a single package.
  • If the failure is auto-fixable, re-running the commit usually works (because --fix has already rewritten the file). If it is not auto-fixable (like no-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:

text
ERROR: Branch name 'feature/add-playground' does not match the naming convention.
1 line of text code

Part 2: scoped a11y audit

If the branch check passes, the hook then figures out which UI components you have touched relative to main:

bash
git diff --name-only main...HEAD -- packages/ui/src/components/
1 line of bash code

For each changed component directory it derives a slug, deduplicates, and runs:

bash
pnpm --filter @itu/ui exec tsx scripts/extract-variants.ts
pnpm --filter @itu/ui run a11y $SLUGS
2 lines of bash code

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:

text
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
...
5 lines of text code

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, or pnpm --filter @itu/ui run a11y --all for everything.
  • The accessibility audit page has the full playbook.

Hook install and opt-out

Husky is installed by the root prepare script:

json
{
"scripts": {
"prepare": "husky"
}
}
5 lines of json code

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