Git Hooks & Pre-Commit Quality Gates
A teammate pushes at 5:58pm on a Friday. The code has a syntax error, three unused imports, and a commit message that reads fix stuff. Nobody notices until Monday, when the deploy fails and someone spends an hour bisecting a history where every message says fix stuff, wip, or asdf.
None of that had to reach the repository. Every one of those problems is mechanical — a linter finds the unused imports, a formatter fixes the spacing, a type check catches the error, a simple pattern rejects fix stuff. The only missing piece is timing: running those checks automatically at the moment code tries to enter git, not hours later in review. That is exactly what git hooks are for.
This article builds directly on Code Quality: ESLint, Prettier & Biome — hooks are how you make those tools run without anyone remembering to run them.
What a git hook actually is
Strip away the tooling and a git hook is almost embarrassingly simple: it’s an executable script that git runs at a specific moment. Look inside any repository and you’ll find them.
ls .git/hooks
# applypatch-msg.sample pre-commit.sample pre-push.sample
# commit-msg.sample pre-rebase.sample ...
Those .sample files are inert — git ignores anything ending in .sample. Rename pre-commit.sample to pre-commit, make it executable, and git will run it every time you type git commit. If the script exits with a non-zero code, git aborts the operation. That’s the whole mechanism: run a script, check the exit code, continue or abort.
# .git/hooks/pre-commit (executable, no extension)
#!/bin/sh
npm run lint || exit 1 # non-zero exit → commit is cancelled
Three hooks carry almost all the weight in day-to-day work. Knowing when each fires is the key to using them well.
pre-commitruns before the commit is created. This is where you lint and format. It must be fast — you run it dozens of times a day — so it should only touch the files you actually changed.commit-msgruns after you write the message but before the commit is finalized. It receives the message as an argument, which makes it the natural home for enforcing a message format.pre-pushruns beforegit pushcontacts the remote. Because it’s rarer than committing, you can afford heavier checks here: the full type check, the test suite.
Why you don’t write these by hand
You could hand-write shell scripts in .git/hooks, but you’d immediately hit the problem in the callout: nothing you put there is shared. A new hire clones the repo and has zero hooks. You fix a bug in your pre-commit script and have no way to push that fix to the team.
A hook manager solves this by storing hook definitions in a tracked directory (so they travel with the repo) and wiring them into git on install. Two tools dominate in 2026, plus a couple of minimalist options worth knowing.
husky — the default
husky is the Node-based manager most projects reach for, and it has been the de facto standard for years. The current line is v9 (9.1.x as of mid-2026); v10 is expected to drop the last deprecated patterns from the v8 era, so if you’re on an old setup, migrate before then.
Setup is one command:
npm install --save-dev husky
npx husky init
husky init creates a .husky/ directory (tracked in git), writes a starter pre-commit hook, and adds a prepare script to package.json:
{
"scripts": {
"prepare": "husky"
}
}
The prepare script is the clever part. npm runs it automatically after npm install, so the moment a teammate installs dependencies, husky wires the hooks into their local git. Nobody has to remember a setup step.
A husky hook is just a shell script in that folder:
# .husky/pre-commit
npx lint-staged
# .husky/commit-msg
npx --no -- commitlint --edit "$1"
lefthook — the fast riser
lefthook is a single Go binary with no runtime dependency on Node. Its two headline advantages are parallelism — it runs independent commands concurrently instead of one after another — and raw speed; on large repositories teams report it running roughly an order of magnitude faster than a shell-driven husky setup. It’s the tool gaining the most ground in 2026, and it’s language-agnostic, which matters if your repo isn’t pure JavaScript. The current major line is v2 (2.x, actively released through 2026).
Instead of one file per hook, lefthook uses a single declarative lefthook.yml:
# lefthook.yml
pre-commit:
parallel: true
commands:
lint:
glob: "*.{js,ts,jsx,tsx}"
run: npx eslint {staged_files}
format:
glob: "*.{js,ts,json,css,md}"
run: npx prettier --write {staged_files}
stage_fixed: true
commit-msg:
commands:
lint-message:
run: npx --no -- commitlint --edit {1}
Install and wire it up:
npm install --save-dev lefthook
npx lefthook install
Two things stand out. {staged_files} is a built-in template — lefthook hands each command only the files git has staged, so you don’t need a separate tool for that (more on this shortly). And parallel: true means lint and format run at the same time. Because that overlap can race on the same file, stage_fixed: true re-stages whatever the formatter rewrote so the fixes actually land in the commit.
The minimalists
If husky feels heavy, simple-git-hooks does one thing: it maps hook names to commands in a small block in package.json and installs them with npx simple-git-hooks. No .husky/ folder, no per-hook files. It’s a good fit when you want a couple of hooks and nothing more:
{
"simple-git-hooks": {
"pre-commit": "npx lint-staged",
"commit-msg": "npx commitlint --edit $1"
}
}
lint-staged: keep commits fast
Here’s the trap a naive pre-commit falls into. You wire it to run eslint . — lint everything. In a repo with a few thousand files, that takes 20 seconds. Now every commit costs 20 seconds, most of it spent re-checking files you didn’t touch. People start reaching for git commit --no-verify just to escape the wait, and your quality gate quietly dies.
lint-staged fixes this by running your tools against only the files git has staged — the ones in this commit — not the whole tree. A two-file change lints two files. Commits stay near-instant, so nobody wants to bypass them.
You configure it by mapping globs to commands. A lint-staged block in package.json or a .lintstagedrc.json file works:
{
"lint-staged": {
"*.{js,ts,jsx,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,css,md}": "prettier --write"
}
}
For each staged file that matches a glob, lint-staged runs the listed commands and appends the matched filenames automatically — you never write the file list yourself. When eslint --fix or prettier --write rewrites a file, lint-staged re-stages the fix so the cleaned-up version is what actually gets committed.
The standard setup
Putting it together, here’s the arrangement most JavaScript teams converge on. Fast, file-local checks on pre-commit; message validation on commit-msg; the expensive whole-project checks on pre-push.
npm install --save-dev husky lint-staged
npx husky init
# .husky/pre-commit
npx lint-staged
# .husky/pre-push
npx tsc --noEmit
{
"lint-staged": {
"*.{js,ts,jsx,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,css,md}": "prettier --write"
}
}
tsc --noEmit type-checks the whole project without producing any output files — it just asks “does this compile?” It’s too slow to run on every commit but perfect on push. (See TypeScript Tooling for what --noEmit is doing under the hood.) The mirror-image lefthook.yml from earlier gives you the identical behavior with parallel commands if you prefer that manager.
commit-msg: enforcing a message format
Look at a git log full of fix, update, changes, and . and you learn nothing from it. Now imagine one where every line starts with a category:
feat(auth): add passkey login
fix(cart): prevent negative quantities
docs(readme): document the env vars
refactor(api): extract pagination helper
That is Conventional Commits — a tiny grammar for commit subjects. The shape is:
type(optional scope): description
The type says what kind of change it is (feat, fix, docs, refactor, perf, test, chore, build, ci, and a few more). The optional scope in parentheses says what part of the codebase. An optional ! before the colon flags a breaking change. The value isn’t just tidiness — because the format is machine-readable, tools can read your history and generate a changelog and the next version number automatically.
You enforce it with commitlint. Install the CLI and the conventional ruleset, add a tiny config, and wire the commit-msg hook:
npm install --save-dev @commitlint/cli @commitlint/config-conventional
// commitlint.config.js
export default { extends: ["@commitlint/config-conventional"] };
# .husky/commit-msg
npx --no -- commitlint --edit "$1"
The $1 passed to commit-msg is the path to a temporary file holding the message you just wrote; --edit tells commitlint to read it from there. Write fix stuff and the commit is rejected with a specific reason. Write fix(cart): clamp quantity at zero and it sails through.
Try the checker below. It runs the same core rules commitlint applies — parsing out the type and scope, or telling you exactly what’s malformed.
Once messages are structured this way, a release tool can walk the history since the last tag, group commits by type into a changelog, and pick the next semver number — fix bumps the patch, feat the minor, a ! breaking change the major. That whole pipeline belongs to CI/CD; Conventional Commits is the input that makes it possible.
The honest caveat: hooks are not a guarantee
Here’s the part a lot of tutorials skip. Everything above is a convenience, not a guarantee. There are at least two ways for unchecked code to sail straight past every hook you’ve set up.
The --no-verify bypass. Every git command that runs hooks accepts a flag to skip them:
git commit --no-verify -m "fix stuff" # skips pre-commit AND commit-msg
git push --no-verify # skips pre-push
Sometimes that’s legitimate — you’re committing a work-in-progress on a private branch. But it means a hook can never be the only thing standing between bad code and your main branch.
The fresh-clone gap. Remember that hooks live outside the tracked repo. If someone clones the project and starts committing before running npm install (so prepare never fired), or if their install skipped scripts, they have no hooks at all and won’t get a single warning.
The conclusion is not “hooks are pointless.” It’s that hooks are the fast, local layer of a two-layer defense. The second layer is CI, which runs on the server where nobody can pass --no-verify.
Summary
- A git hook is just an executable script git runs at a moment in its lifecycle; a non-zero exit aborts the operation. The three that matter are
pre-commit(lint/format),commit-msg(message format), andpre-push(types/tests). - Hooks live in
.git/hooks, which is not tracked, so a manager is needed to share them. husky (Node, v9, most popular) and lefthook (Go binary, v2, parallel and fast) are the two mainstream choices in 2026; simple-git-hooks is the minimalist option. - lint-staged runs your tools on only the staged files, keeping
pre-commitfast enough that nobody wants to bypass it. lefthook does this filtering itself via{staged_files}. - The standard stack is husky + lint-staged running
eslint --fixandprettier --writeonpre-commit, plustsc --noEmitonpre-push. - Conventional Commits (
type(scope): description) enforced by commitlint oncommit-msgturns your history into machine-readable input for automated changelogs and versioning. - Hooks are a convenience, not a guarantee —
--no-verifyand fresh clones both skip them. Every rule that must hold has to be re-run in CI/CD as a required check. The hook makes the common case fast; CI makes the rule real.