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.

git commitgit pushedit &git addpre-commitlint + formatstaged filescommit-msgcheck messageformatpre-pushtsc, testswhole projectremotefast · every commitinstant · every commitslower · every pushany non-zero exit → abort, nothing moves forward
The commit-and-push lifecycle. Each hook fires at a specific moment; a non-zero exit at any stage stops everything and nothing enters git.
  • pre-commit runs 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-msg runs 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-push runs before git push contacts 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.

husky + lint-stagedNode · sequential · most popular.husky/pre-commit.husky/commit-msglint-stagedfilters stagedtwo tools, two config styles,runs commands one after anotherlefthookGo binary · parallel · the riserlefthook.ymlall hooks, one fileeslintprettierstaged-file filtering built in;independent commands run at once
Two managers, same job. husky writes one shell file per hook and leans on lint-staged for filtering; lefthook keeps everything in one YAML file, runs commands in parallel, and filters staged files itself.

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.

working treeapi.tsutils.tshome.tsxcart.tsxdb.tsstyles.cssreadme.md… 1,412more fileshome.tsxstyles.cssgit diff–stagedstaged onlyhome.tsxstyles.csseslint +prettier*.tsxprettier*.css2 files checked, not 1,419 — the commit stays instant
lint-staged narrows the entire working tree down to just the staged files, then runs each tool on the slice that matches its glob. The untouched thousands of files are never re-checked.

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.

feat(auth)!: require passkey on logintyperequiredscopeoptionalbreakingdescriptionimperative, lower-case
Anatomy of a Conventional Commit subject. Each part is a token a tool can parse — the type drives the version bump, the scope groups the changelog, an optional ! marks a breaking change.

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.

interactiveConventional Commit checker

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.

Layer 1 — local hookyour machine · instant✓ lint + format✓ commit message✓ fast feedback✕ skippable: –no-verify✕ absent on fresh clonepushLayer 2 — CIthe server · unskippable✓ re-runs lint + types✓ runs full test suite✓ nobody can bypasscatches what slipped pastlayer 1mergeonly if both passthe hook makes the common case fast; CI makes the guarantee real
Defense in depth. The local hook gives instant feedback and catches most problems on the developer's machine; CI is the backstop that re-runs the same checks on the server, catching anything that slipped past via --no-verify or a missing hook.

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), and pre-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-commit fast enough that nobody wants to bypass it. lefthook does this filtering itself via {staged_files}.
  • The standard stack is husky + lint-staged running eslint --fix and prettier --write on pre-commit, plus tsc --noEmit on pre-push.
  • Conventional Commits (type(scope): description) enforced by commitlint on commit-msg turns your history into machine-readable input for automated changelogs and versioning.
  • Hooks are a convenience, not a guarantee--no-verify and 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.