Skip to main content

Hooks and Automation

Git hooks are scripts that Git runs automatically at specific points in the workflow. They let you enforce standards, run checks, and automate repetitive tasks without requiring developers to remember to do them manually. A pre-commit hook can run your linter before every commit. A commit-msg hook can validate your commit message format. A pre-push hook can run your full test suite before code leaves the machine.

Hooks turn "we should always do X" into "we always do X."

How Hooks Work

Hooks are executable scripts stored in .git/hooks/. Git ships with sample files for every hook:

ls .git/hooks/
# applypatch-msg.sample
# commit-msg.sample
# pre-commit.sample
# pre-push.sample
# pre-rebase.sample
# prepare-commit-msg.sample
# ...

To activate a hook, remove the .sample extension and make it executable:

mv .git/hooks/pre-commit.sample .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

Hooks can be written in any language the shell can execute: bash, Python, Node.js, Ruby — whatever is available on the developer's machine.

Exit codes matter

Git checks the exit code of the hook script:

  • Exit 0 → success, Git continues the operation
  • Exit non-zero → failure, Git aborts the operation and shows an error

This is how a pre-commit hook blocks a commit: return a non-zero exit code and Git stops the commit.

The .git/hooks/ limitation

.git/ is not tracked by Git. This means hooks written there are not shared with the team. Every developer would have to set them up manually. This is why tools like husky and lefthook exist — they move hooks into the repository itself (tracked by Git) and install them on npm install / first run.

Common Hook Types

Client-side hooks

HookRuns whenAbortable?Common use
pre-commitBefore the commit is madeYesLint, format, run quick tests
prepare-commit-msgBefore the editor opens for the messageYesPre-populate template or branch name
commit-msgAfter the message is writtenYesValidate conventional commit format
post-commitAfter the commit is madeNoNotifications, logging
pre-pushBefore pushing to remoteYesRun full test suite, block broken pushes
pre-rebaseBefore rebasingYesSafety checks
post-checkoutAfter checkout or switchNoInstall dependencies, update environment
post-mergeAfter a mergeNoInstall dependencies

Server-side hooks

Server-side hooks run on the Git server (GitHub, GitLab, Gitea, Bitbucket) and cannot be bypassed by the client:

HookRuns whenCommon use
pre-receiveBefore server accepts a pushEnforce branch protection, check access
updatePer-branch during receivePer-branch policy enforcement
post-receiveAfter push is acceptedTrigger CI, send notifications, deploy

This guide focuses on client-side hooks.

Writing a pre-commit Hook

Example: Run ESLint before every commit

#!/bin/sh
# .git/hooks/pre-commit

# Run ESLint on staged JavaScript files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.(js|jsx|ts|tsx)$')

if [ -z "$STAGED_FILES" ]; then
# No JS/TS files staged — nothing to lint
exit 0
fi

echo "Running ESLint on staged files..."
npx eslint $STAGED_FILES

if [ $? -ne 0 ]; then
echo ""
echo "ESLint failed. Fix the errors above before committing."
echo "To skip this check (emergency only): git commit --no-verify"
exit 1
fi

echo "ESLint passed!"
exit 0

Example: Run Prettier format check

#!/bin/sh
# .git/hooks/pre-commit

STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.(js|jsx|ts|tsx|css|json|md)$')

if [ -z "$STAGED_FILES" ]; then
exit 0
fi

echo "Checking formatting with Prettier..."
npx prettier --check $STAGED_FILES

if [ $? -ne 0 ]; then
echo ""
echo "Formatting issues found. Run: npx prettier --write ."
echo "Then re-stage your files and commit again."
exit 1
fi

exit 0

Writing a commit-msg Hook

The commit-msg hook receives the path to a temporary file containing the commit message. Read that file and validate the format.

Example: Enforce Conventional Commits format

#!/bin/sh
# .git/hooks/commit-msg

COMMIT_MSG_FILE="$1"
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")

# Pattern: type(scope): description
# Examples: feat(auth): add login, fix: handle null pointer
PATTERN='^(feat|fix|docs|style|refactor|test|chore|perf|ci|revert|build)(\([a-z0-9-]+\))?(!)?: .{1,100}$'

# Allow merge commits and revert commits
if echo "$COMMIT_MSG" | grep -qE '^(Merge|Revert)'; then
exit 0
fi

if ! echo "$COMMIT_MSG" | head -1 | grep -qE "$PATTERN"; then
echo ""
echo "ERROR: Invalid commit message format."
echo ""
echo "Your message: $COMMIT_MSG"
echo ""
echo "Expected format: type(scope): description"
echo ""
echo "Valid types: feat, fix, docs, style, refactor, test, chore, perf, ci, revert, build"
echo ""
echo "Examples:"
echo " feat(auth): add password reset flow"
echo " fix(cart): prevent duplicate items"
echo " docs: update API authentication examples"
echo ""
exit 1
fi

exit 0

Writing a pre-push Hook

The pre-push hook runs before commits are sent to the remote. It receives the remote name and URL as arguments. Stdin provides a list of refs being pushed.

Example: Run tests before push

#!/bin/sh
# .git/hooks/pre-push

echo "Running tests before push..."
npm test

if [ $? -ne 0 ]; then
echo ""
echo "Tests failed. Fix them before pushing."
echo "To push anyway (dangerous): git push --no-verify"
exit 1
fi

echo "All tests passed. Pushing..."
exit 0

Example: Prevent pushing directly to main

#!/bin/sh
# .git/hooks/pre-push

REMOTE="$1"
BRANCH=$(git rev-parse --abbrev-ref HEAD)

if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
echo "ERROR: Direct push to $BRANCH is not allowed."
echo "Please create a feature branch and open a pull request."
exit 1
fi

exit 0

Bypassing Hooks

Any hook can be bypassed with --no-verify:

git commit --no-verify -m "emergency: hotfix for production outage"
git push --no-verify

This is an escape hatch for genuine emergencies. Document its use in your team's norms — --no-verify should be rare and traceable.

husky — Hooks for Node.js Projects

husky is the most popular hook manager for JavaScript/Node.js projects. It stores hooks as files in your repository and installs them automatically via a prepare npm script.

Setup

npm install --save-dev husky
npx husky init

This creates a .husky/ directory and adds a prepare script to package.json:

{
"scripts": {
"prepare": "husky"
}
}

Now anyone who runs npm install automatically gets the hooks installed.

Add a pre-commit hook

echo "npx lint-staged" > .husky/pre-commit
chmod +x .husky/pre-commit

Add a commit-msg hook

echo "npx commitlint --edit \$1" > .husky/commit-msg
chmod +x .husky/commit-msg

Directory structure

.husky/
├── pre-commit
├── commit-msg
└── pre-push

These files are committed to the repository and shared with the whole team.

lint-staged — Run Linters on Staged Files Only

Running ESLint on your entire codebase before every commit is slow. lint-staged runs linters only on the files that are staged for commit.

Setup

npm install --save-dev lint-staged

Configuration in package.json

{
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{css,scss}": [
"prettier --write"
],
"*.{json,md}": [
"prettier --write"
]
}
}

The pre-commit hook calls lint-staged

# .husky/pre-commit
npx lint-staged

Now when you run git commit, lint-staged lints and auto-fixes only the staged files, then re-stages them. The commit only proceeds if all checks pass.

commitlint — Validate Commit Messages

commitlint validates commit messages against a configurable rule set. Combined with the commit-msg hook, it enforces Conventional Commits automatically.

Setup

npm install --save-dev @commitlint/cli @commitlint/config-conventional
echo "export default { extends: ['@commitlint/config-conventional'] };" > commitlint.config.js

The commit-msg hook

# .husky/commit-msg
npx commitlint --edit $1

Now every commit message is validated:

git commit -m "add some stuff"
# ⧗ input: add some stuff
# ✖ subject may not be empty [subject-empty]
# ✖ type may not be empty [type-empty]
# ✖ found 2 problems, 0 warnings

git commit -m "feat(auth): add multi-factor authentication"
# ✔ commit validated successfully

lefthook — A Fast Alternative to husky

lefthook is a multi-language hook manager written in Go. It is faster than husky, works with any language (not just Node.js), and uses a YAML configuration file.

Setup

# Install globally or via package manager
npm install --save-dev lefthook
# or: brew install lefthook (macOS)

npx lefthook install

Configuration — lefthook.yml

pre-commit:
parallel: true
commands:
lint:
glob: "*.{js,jsx,ts,tsx}"
run: npx eslint {staged_files}
stage_fixed: true

format:
glob: "*.{js,jsx,ts,tsx,css,json,md}"
run: npx prettier --write {staged_files}
stage_fixed: true

commit-msg:
commands:
validate:
run: npx commitlint --edit {1}

pre-push:
commands:
tests:
run: npm test

parallel: true runs all pre-commit commands simultaneously, making hook execution faster on multi-core machines. stage_fixed: true automatically re-stages files that ESLint or Prettier modified.

lefthook vs husky comparison

Featurehuskylefthook
LanguageJavaScript / Node.jsGo (binary, no runtime needed)
SpeedSequential by defaultParallel by default
Config formatPer-file scriptsSingle lefthook.yml
Multi-languageWorks with any shell scriptFirst-class support
EcosystemLargest in JS worldGrowing
Zero-dependencyNo (needs Node)Yes (single binary)

Complete Example — JavaScript Project

Here is a complete, production-ready setup for a Node.js / TypeScript project:

# Install tools
npm install --save-dev husky lint-staged @commitlint/cli @commitlint/config-conventional
// package.json
{
"scripts": {
"prepare": "husky"
},
"lint-staged": {
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{js,json,md}": ["prettier --write"]
}
}
// commitlint.config.js
export default { extends: ['@commitlint/config-conventional'] };
# .husky/pre-commit
npx lint-staged
# .husky/commit-msg
npx commitlint --edit $1
# .husky/pre-push
npm run build && npm test

Now every developer on the team automatically has:

  • Linting and formatting on every commit
  • Commit message validation
  • Build and test validation before every push

Summary

You now understand:

  • Git hooks are scripts that run automatically at specific points in the Git workflow
  • pre-commit — lint and format staged files before a commit is created
  • commit-msg — validate the commit message format
  • pre-push — run tests or checks before code leaves the machine
  • husky — the standard hook manager for Node.js projects; stores hooks in the repo
  • lint-staged — run linters only on staged files for speed
  • commitlint — enforce Conventional Commits via the commit-msg hook
  • lefthook — a faster, language-agnostic alternative to husky

Next up: Practice Project — a complete end-to-end exercise covering the full workflow from initialisation to a tagged release.