Git Hooks & Commit Conventions
Why Hooks and Conventions Belong Together
Two problems show up on every team I've worked with:
- Broken code gets committed — failing lint, failing tests,
console.logdebris - Commit history reads like
fix,update,changes,final fix v2
Git hooks solve the first by running checks automatically at commit time. Commit conventions solve the second by giving messages a machine-readable structure. Together they turn "please remember to..." team rules into enforced ones.
What Are Git Hooks?
Hooks are scripts that Git runs automatically at specific points in its lifecycle. They live in .git/hooks/ — every repo already has a folder full of .sample files showing what's available:
ls .git/hooks
# pre-commit.sample commit-msg.sample pre-push.sample ...
Rename one to drop the .sample suffix, make it executable, and Git runs it. The hooks I actually use:
| Hook | Runs | Typical job |
|---|---|---|
pre-commit | Before the commit is created | Lint, format, run fast tests |
commit-msg | After you write the message | Validate the message format |
pre-push | Before git push uploads | Run the full test suite |
post-merge | After a merge or pull completes | Reinstall deps if the lockfile changed |
If a pre-commit or commit-msg hook exits with a non-zero status, the commit is rejected. That's the enforcement mechanism.
A minimal hand-written hook
#!/bin/sh
npm run lint || exit 1
npm test || exit 1
chmod +x .git/hooks/pre-commit
Now every git commit runs lint and tests first. In an emergency you can bypass hooks with git commit --no-verify — which is exactly why hooks are a convenience layer, not a security boundary. CI should re-run the same checks.
The catch: hooks are not committed
.git/hooks/ is not part of the repository, so hand-written hooks exist only on your machine. Ibrahim clones the repo tomorrow and gets no hooks at all. Two fixes:
Option 1 — a versioned hooks folder:
mkdir .githooks
# move your hook scripts in, commit them
git config core.hooksPath .githooks
Each developer still has to run that git config once (often wired into an npm run setup script).
Option 2 — Husky, which automates all of this for JavaScript projects.
Husky: Shared Hooks Without the Ceremony
Husky stores hooks in a committed .husky/ folder and configures core.hooksPath automatically when anyone runs npm install. Zero manual setup for teammates.
npm install --save-dev husky
npx husky init
That creates .husky/pre-commit and adds a prepare script to package.json. Edit the hook to run whatever you want:
npx lint-staged
Pair with lint-staged
Running the linter on the whole project for every commit gets slow. lint-staged runs tools only on the files you're actually committing:
{
"lint-staged": {
"*.{js,ts}": ["eslint --fix", "prettier --write"],
"*.{md,json}": ["prettier --write"]
}
}
Commits stay fast, and no unformatted file ever lands in history.
Conventional Commits
Conventional Commits is the de facto standard structure for commit messages:
type(scope): short description
optional longer body explaining the why
optional footer, e.g. BREAKING CHANGE: or issue refs
Real examples:
feat(auth): add password reset endpoint
fix(cart): prevent negative quantities in checkout
docs: add local setup instructions to README
refactor(api): extract validation into middleware
test(auth): cover expired-token path
chore: bump express to 4.19
The standard types
| Type | Use for | Version impact |
|---|---|---|
feat | A new feature | Minor bump |
fix | A bug fix | Patch bump |
docs | Documentation only | — |
style | Formatting, no code change | — |
refactor | Restructuring, no behavior change | — |
perf | Performance improvement | Patch bump |
test | Adding or fixing tests | — |
build | Build system or dependencies | — |
ci | CI configuration | — |
chore | Everything else | — |
Breaking changes get a ! after the type, or a BREAKING CHANGE: footer — either triggers a major version bump:
feat(api)!: return dates as ISO 8601 strings
BREAKING CHANGE: response timestamps changed from unix epoch to ISO 8601
Why bother?
The structure isn't bureaucracy — it's what makes automation possible:
- Automatic changelogs — tools group
featandfixcommits into release notes - Automatic versioning —
semantic-releasereads commits and decides the next version by itself - Scannable history —
git log --onelinebecomes a readable story instead of noise - Reviewable intent —
fix(cart):tells me what a commit claims to do before I read the diff
Enforcing the format with commitlint
Convention without enforcement decays in a week. commitlint validates messages in a commit-msg hook:
npm install --save-dev @commitlint/cli @commitlint/config-conventional
echo "module.exports = { extends: ['@commitlint/config-conventional'] };" > commitlint.config.js
npx --no -- commitlint --edit "$1"
Now git commit -m "stuff" is rejected with a clear error, while git commit -m "fix(auth): handle expired tokens" sails through — for everyone on the team, automatically.
Quick Reference
# Native hooks
ls .git/hooks # see available hooks
chmod +x .git/hooks/pre-commit # activate a hand-written hook
git config core.hooksPath .githooks # use a versioned hooks folder
git commit --no-verify # bypass hooks (emergencies only)
# Husky setup
npm install --save-dev husky
npx husky init
# Commit message format
# type(scope): description
# feat | fix | docs | style | refactor | perf | test | build | ci | chore
# breaking change: add ! after type or a BREAKING CHANGE: footer