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
cat > commitlint.config.cjs << 'EOF'
module.exports = { extends: ['@commitlint/config-conventional'] };
EOF

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.cjs
module.exports = { 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.