Skip to main content

Git Hooks & Commit Conventions

Why Hooks and Conventions Belong Together

Two problems show up on every team I've worked with:

  1. Broken code gets committed — failing lint, failing tests, console.log debris
  2. 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:

HookRunsTypical job
pre-commitBefore the commit is createdLint, format, run fast tests
commit-msgAfter you write the messageValidate the message format
pre-pushBefore git push uploadsRun the full test suite
post-mergeAfter a merge or pull completesReinstall 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

.git/hooks/pre-commit
#!/bin/sh
npm run lint || exit 1
npm test || exit 1
Make it executable
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.

Install and initialize
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:

.husky/pre-commit
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:

package.json
{
"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

TypeUse forVersion impact
featA new featureMinor bump
fixA bug fixPatch bump
docsDocumentation only
styleFormatting, no code change
refactorRestructuring, no behavior change
perfPerformance improvementPatch bump
testAdding or fixing tests
buildBuild system or dependencies
ciCI configuration
choreEverything 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 feat and fix commits into release notes
  • Automatic versioningsemantic-release reads commits and decides the next version by itself
  • Scannable historygit log --oneline becomes a readable story instead of noise
  • Reviewable intentfix(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:

Install commitlint
npm install --save-dev @commitlint/cli @commitlint/config-conventional
echo "module.exports = { extends: ['@commitlint/config-conventional'] };" > commitlint.config.js
.husky/commit-msg
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