Automate Your Code Quality with Git Hooks (And Never Argue in Code Review Again)
Stop Catching Style Issues in Code Review — Your Editor Should've Flagged Them Before You Even Committed
Senior Developer

The Problem Git Hooks Solve
Picture a typical pull request review. Someone opens a PR and a reviewer digs in — but before getting to the actual logic, they're leaving comments like "unused import on line 12," "forgot to run prettier," "this console.log shouldn't be here," and "can you write a more descriptive commit message than 'fix'?" The author fixes the comments. Another review pass happens. Eventually the real work gets looked at.
None of that first round should have involved a human. Those are deterministic rules — a computer can check them in milliseconds, consistently, for everyone on the team, before the code ever leaves the author's machine. Git hooks are how you do that.
How Git Hooks Actually Work
When you run git init, Git creates a .git/ folder. Inside it is a hooks/ directory filled with sample scripts — pre-commit.sample, commit-msg.sample, pre-push.sample, and others. Remove the .sample extension, make the file executable, and Git runs it automatically at that moment in the workflow.
The mechanism is simple but powerful: if a hook script exits with a non-zero status code, Git stops what it was doing. A failing pre-commit hook aborts the commit. A failing pre-push hook aborts the push. The developer sees the error output and knows exactly what to fix before proceeding.
Here are the hooks you'll actually use:
Hook | When It Fires | Best Used For |
|---|---|---|
| Before the commit object is created | Linting, formatting, fast checks |
| After the commit message is written | Enforcing message conventions |
| Before the commit message editor opens | Pre-filling message templates |
| Before data is sent to the remote | Running the full test suite |
| After a successful merge or pull | Auto-installing new dependencies |
The Raw Hook Problem (And Why You Need Tooling)
Here's the catch with raw Git hooks: .git/hooks/ is not tracked by Git. That means every hook you write lives only on your machine. When a teammate clones the repository, they get nothing. You'd have to manually distribute scripts and have every developer install them — which in practice means it never happens.
There's a second problem too. Raw hooks run against your entire working tree. On a large project, linting all 4,000 files every time you commit takes 30–60 seconds. Developers get annoyed and start using git commit --no-verify to skip everything. Once that habit forms, your hooks are useless.
Both problems have clean solutions.
Husky: Share Hooks Through the Repository
Husky solves the sharing problem. Instead of putting hook scripts in .git/hooks/, it stores them in .husky/ — a regular directory that IS tracked by Git. It then tells Git to look for hooks there instead of the default location.
Install and initialize:
npm install --save-dev husky
npx husky inithusky init creates the .husky/ directory with a sample pre-commit hook and adds a "prepare": "husky" script to your package.json. That prepare script is the magic: npm runs it automatically after every npm install. So when a developer clones your repo and installs dependencies, Husky is set up without any extra step.
Your project structure now looks like this:
your-project/
├── .husky/
│ ├── pre-commit ← tracked by Git, shared with everyone
│ └── commit-msg
├── src/
└── package.jsonTo add a hook, just create a file with the hook name inside .husky/:
# .husky/pre-commit
npx lint-stagedEvery developer gets this hook the moment they run npm install. No manual setup. No documentation to follow.
lint-staged: Only Process What Changed
Running your full linter on every commit is what makes hooks feel slow and painful. lint-staged fixes this by running tools only on the files that are staged for this commit — nothing more.
If you changed two files, only those two files are linted. The other 3,998 files are untouched. Most commits go from 30 seconds to under a second.
Install it:
npm install --save-dev lint-stagedConfigure in package.json:
{
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{css,scss,json,md}": [
"prettier --write"
]
}
}Run ESLint before Prettier — ESLint may restructure code, and Prettier should have the last word on formatting. The --fix and --write flags auto-correct issues where possible, so the developer often doesn't need to do anything manually.
Point your pre-commit hook to it:
# .husky/pre-commit
npx lint-stagedNow every commit automatically lints and formats only the files being committed. Clean output, no waiting, no excuses.
commitlint: Make Every Commit Message Mean Something
A git log full of "fix," "update," "wip," and "asdfghjkl" is useless. When you need to understand why a change was made six months later, or when you want to auto-generate a changelog, or when you're trying to trace a regression — a well-structured commit history is invaluable.
Conventional Commits is the most widely adopted standard. The format is:
type(optional scope): short description
feat: add OAuth login with Google
fix(auth): handle expired JWT tokens gracefully
docs: update API reference for /users endpoint
chore: upgrade ESLint to v9commitlint enforces this automatically via the commit-msg hook.
Install it:
npm install --save-dev @commitlint/cli @commitlint/config-conventional
Create the config:
// commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional'],
};Add the hook:
# .husky/commit-msg
npx --no -- commitlint --edit $1Git passes the path to the commit message file as $1. commitlint reads it, validates it, and either approves or rejects the commit with a clear explanation:
# ✖ Rejected
git commit -m "fixed the thing"
# ✖ subject may not be empty [subject-empty]
# ✖ type may not be empty [type-empty]
# ✔ Accepted
git commit -m "fix(auth): resolve token expiry not being handled"
# ✔ found 0 problems, 0 warningsThe common commit types are: feat (new feature), fix (bug fix), docs (documentation), refactor (code restructure without behavior change), test (adding or updating tests), chore (maintenance, build changes), and perf (performance improvement).
Once your history is consistently structured, tools like semantic-release can read it to automatically determine version bumps and generate release notes — something that previously required manual effort.
The pre-push Hook: The Last Safety Net
pre-commit is fast and runs on every commit. Your full test suite shouldn't run there — it would make every commit feel heavy. But tests absolutely need to run before code reaches the remote branch, where they become someone else's problem.
The pre-push hook is the right place for this:
# .husky/pre-push
npm testIf tests fail, the push is blocked. The remote branch stays clean. No broken builds in CI, no colleagues pulling a broken main branch.
For TypeScript projects, add a type check too:
# .husky/pre-push
npx tsc --noEmit && npm test--noEmit runs the TypeScript compiler for type checking only without emitting any files. It catches type errors that might slip past your IDE, and it's much faster than a full build.
The post-merge Hook: Stop Hitting "Cannot Find Module"
Here's a frustration every developer knows: you pull from main, run your app, and immediately hit an error about a missing module. A teammate added a package and you forgot to run npm install after pulling.
The post-merge hook runs after every successful merge and pull. You can use it to check whether package.json changed, and auto-install if it did:
# .husky/post-merge
#!/bin/sh
changed_files="$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD)"
if echo "$changed_files" | grep -q "package.json"; then
echo "📦 package.json changed — running npm install..."
npm install
fiThis small script has saved countless developers from wasted debugging time. It's the kind of automation that makes your repo feel professional.
When to Bypass Hooks
Sometimes you legitimately need to skip the hooks — saving a work-in-progress state, making an emergency fix, committing generated code. Git makes this easy:
git commit --no-verify -m "wip: checkpoint before major refactor"
git push --no-verify--no-verify tells Git to skip all hooks for that operation. This is a valid escape hatch — just not a habit. If you notice teammates using it regularly, that's a signal that a hook is too slow or too noisy. Investigate and fix the hook, not the behavior.
Complete Setup Reference
Here's everything you need for a new Node.js project in one place:
# Install all tools
npm install --save-dev husky lint-staged @commitlint/cli @commitlint/config-conventional
# Initialize Husky
npx husky init
# Create hooks
echo "npx lint-staged" > .husky/pre-commit
echo "npx --no -- commitlint --edit \$1" > .husky/commit-msg
echo "npm test" > .husky/pre-push
// package.json — add this block
{
"lint-staged": {
"*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{css,scss,json,md}": ["prettier --write"]
}
}// commitlint.config.js
module.exports = { extends: ['@commitlint/config-conventional'] };That's a complete, automatically shared code quality pipeline. Every developer on your team runs the same checks, every time, from the moment they clone the repo.
Summary
Git hooks shift quality enforcement from "someone reviews for it" to "the system enforces it automatically." Husky makes hooks part of the repository so the whole team uses them without thinking. lint-staged keeps things fast by only touching changed files. commitlint makes your git history readable and automation-friendly.
The best part is that once this is set up, it runs invisibly. Nobody has to remember to lint before committing, nobody argues about formatting in review, and nobody merges code that breaks tests. The developer experience gets better for everyone, and the codebase stays cleaner with no extra effort.
Comments (0)
Login to post a comment.