Web Development · Developer Tooling
Pre-commit Hooks and Automated Code Quality: The Setup That Catches Problems Before CI Does
Git hooks enforced by Lefthook, Husky, or lint-staged can stop broken code, style violations, and type errors before they reach your CI pipeline. Here's how to build a hook setup that teams actually keep.
Anurag Verma
8 min read
Sponsored
CI fails are expensive. By the time your pipeline catches a lint error, you’ve pushed the branch, waited for the check to run, seen the failure, fixed it, pushed again, and waited again. The total cost is often 10-15 minutes of interrupted flow for a problem that could have been caught in 2 seconds.
Pre-commit hooks are the fix. They run checks before a commit lands, on the specific files being committed, using the same tools as your CI. The setup takes an hour. After that it runs silently and catches the problems that would otherwise clog your pipeline.
This is what a production-quality pre-commit setup looks like in 2026.
What Git Hooks Actually Are
Git ships with a hooks directory in every repository at .git/hooks/. Drop an executable script named pre-commit in there and Git runs it before every commit. If the script exits with a non-zero status, the commit is blocked.
The problem with raw .git/hooks/ scripts: they aren’t version-controlled. Each developer has to manually set them up on clone, and the hooks drift out of sync as the project evolves.
Tools like Lefthook and Husky solve this by storing hook configuration in the repository and installing the actual .git/hooks/ scripts automatically when developers run npm install (or the equivalent for their package manager).
Lefthook vs Husky vs lint-staged
Three tools dominate this space. They overlap but aren’t interchangeable.
Husky is the most widely used. It installs automatically via npm’s prepare script, stores configuration in .husky/ files, and calls other tools for the actual linting and formatting. Simple to understand; moderate performance.
Lefthook is faster than Husky because it runs commands in parallel by default. The configuration lives in a single lefthook.yml. It handles multiple hooks cleanly and doesn’t require a separate tool for partial-staging. Good choice for larger repositories where hook runtime matters.
lint-staged isn’t a hook runner. It’s a tool that filters staged files by glob and runs commands against them. It’s designed to be called from inside Husky or another runner. You give it patterns and commands; it figures out which staged files match each pattern and runs the command only on those files.
For most projects: use Husky for the hook runner, lint-staged for filtering staged files. For repositories where hook speed is a real constraint, use Lefthook for both.
A Complete Husky + lint-staged Setup
Install the tools:
npm install --save-dev husky lint-staged
npx husky init
husky init creates a .husky/ directory and adds a prepare script to your package.json. The prepare script runs husky on npm install, which installs the hooks.
Edit .husky/pre-commit:
#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
Configure lint-staged in package.json:
{
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{js,jsx}": [
"eslint --fix",
"prettier --write"
],
"*.{css,scss,md,json}": [
"prettier --write"
]
}
}
This runs ESLint and Prettier only on staged TypeScript and JavaScript files, and Prettier only on staged CSS, Markdown, and JSON files. If any command fails, the commit is blocked. Modified files (after --fix) are restaged automatically by lint-staged.
Adding Type Checking
ESLint catches code style issues. It does not check TypeScript types. For a commit that changes types in ways that break other files, you want the TypeScript compiler to catch it.
The caveat: tsc type-checks the whole project, not just staged files. This is slower than lint-staged’s per-file approach. On large projects it can take 15-30 seconds.
Two approaches:
Option 1: Run tsc in the pre-commit hook, after lint-staged.
#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
npx tsc --noEmit
This is the safest option. The type check runs on every commit and catches all type errors.
Option 2: Skip tsc in pre-commit, rely on CI.
If the type check is slow enough to interrupt flow, consider skipping it in the pre-commit hook and running it only in CI. The trade-off is that type errors reach the branch before being caught. For teams who push WIP commits frequently, this is often the right call.
A middle path: run the type check only in a pre-push hook rather than pre-commit. This gives you type safety before code hits the remote, without blocking every local commit.
Add a pre-push hook:
# .husky/pre-push
#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"
npx tsc --noEmit
Commit Message Validation with commitlint
If your project uses Conventional Commits (required for automated changelogs or release tooling), commitlint enforces the format at commit time.
npm install --save-dev @commitlint/cli @commitlint/config-conventional
Create commitlint.config.js:
export default {
extends: ['@commitlint/config-conventional'],
};
Add a commit-msg hook:
# .husky/commit-msg
#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"
npx --no -- commitlint --edit "$1"
Now commits that don’t match the type(scope): description format are rejected:
git commit -m "fixed the login bug"
# ⧗ input: fixed the login bug
# ✖ subject may not be empty [subject-empty]
# ✖ type may not be empty [type-empty]
# ✖ Found 2 problems, 0 warnings
The Lefthook Alternative
If you prefer a single-tool approach or need parallel execution, Lefthook does it all:
npm install --save-dev lefthook
npx lefthook install
Configure in lefthook.yml:
pre-commit:
parallel: true
commands:
lint:
glob: "*.{ts,tsx,js,jsx}"
run: npx eslint --fix {staged_files} && git add {staged_files}
format:
glob: "*.{ts,tsx,js,jsx,css,md,json}"
run: npx prettier --write {staged_files} && git add {staged_files}
pre-push:
commands:
typecheck:
run: npx tsc --noEmit
commit-msg:
commands:
commitlint:
run: npx commitlint --edit {1}
Lefthook passes only staged files matching the glob to each command via {staged_files}. Running lint and format in parallel shaves time on commits that touch many files.
Escaping Hooks When You Need To
Sometimes you need to commit something without running hooks. The standard way:
git commit --no-verify -m "wip: debug session"
--no-verify skips all hooks for that commit. Use it when:
- Committing a work-in-progress that you know is broken
- Debugging a commit hook itself
- Recovering from a bad state and hooks are blocking you
Don’t add --no-verify as a habit. If developers are bypassing hooks regularly, the hooks are too slow or too strict. Fix the hooks.
What to Include and What to Skip
Include in pre-commit:
- Formatting (Prettier, Black, gofmt). Fast, deterministic, auto-fixable.
- Import sorting. Fast and easy to auto-fix.
- Basic linting with
--fix. Fast; fixes common issues automatically. - Trailing whitespace, final newline. Trivial to check.
Include in pre-push:
- Full TypeScript type check. Slower but important.
- Tests that are fast (unit tests under 30 seconds).
Leave in CI only:
- End-to-end tests. Too slow for local hooks.
- Full test suites. Run on branch push, not every commit.
- Security scanning. CI has the right environment and secrets.
- Build verification. Too slow for hooks; CI catches it.
The guiding principle: hooks should be fast enough that developers don’t bypass them. If your pre-commit hook takes 30 seconds, people will use --no-verify within a week. Keep pre-commit under 10 seconds.
Keeping the Team Using Hooks
The biggest failure mode for pre-commit hooks isn’t technical. It’s that developers clone the repo, forget to run npm install, and never get the hooks installed.
Two fixes:
Use the prepare npm script. With Husky, npm install automatically runs husky and installs hooks. As long as people use npm (or pnpm, yarn) to install dependencies, they get the hooks for free.
Document the expectation. One line in your README: “Run npm install. Hooks are configured automatically.” Developers who use other package managers or skip npm install for some reason will know to run npx husky install manually.
If your team uses a different package manager (pnpm, yarn, bun), adjust the prepare script accordingly. With pnpm: "prepare": "husky" still works. With Lefthook: "prepare": "lefthook install".
Syncing ESLint Config with CI
One common problem: hooks pass locally but CI fails. This usually means the ESLint or Prettier configuration used in the hook differs from what CI runs.
The fix is to make sure hooks call the same configuration that CI uses. Avoid one-off overrides in .husky/ files. Call npx eslint with no extra flags and let it pick up your project’s eslint.config.js (or .eslintrc).
If you’re mid-migration to ESLint 9’s flat config, be explicit about which config file each environment uses. The ESLINT_USE_FLAT_CONFIG environment variable controls this during the transition period.
The Complete Setup in One Place
For a new TypeScript project:
# Install
npm install --save-dev husky lint-staged @commitlint/cli @commitlint/config-conventional
# Initialize
npx husky init
# package.json additions:
# "prepare": "husky",
# "lint-staged": { ... }
Three hook files:
.husky/pre-commitcallslint-staged.husky/pre-pushcallstsc --noEmit.husky/commit-msgcallscommitlint
The total setup time is under an hour. After that, the hooks run invisibly on every commit and push, catching formatting, lint, type, and message issues before they reach the remote, before they clog CI.
Sponsored
More from this category
More from Web Development
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored