Git Hooks Automation: Pre-Commit to Post-Deploy
A practical guide to Git hooks automation covering pre-commit, commit-msg, pre-push hooks, Husky setup, lint-staged, commitlint, secret detection, and team-wide hook sharing.
Git Hooks Automation: Pre-Commit to Post-Deploy
Git hooks are scripts that Git executes automatically at specific points in your workflow -- before a commit, after a merge, before a push. They are the cheapest quality gate you can add to a project, catching bad code, malformed commit messages, and leaked secrets before they ever reach a remote repository. If you are not using them, you are relying entirely on CI pipelines that run minutes later and cost real money.
This article walks through every hook that matters, shows how to share them across a team, and builds a production-ready setup using Husky, lint-staged, and commitlint in a Node.js project.
Prerequisites
- Git 2.9 or later (for
core.hooksPathsupport) - Node.js v18+ installed
- A terminal you are comfortable with (bash, zsh, or Git Bash on Windows)
- Basic Git knowledge: commits, branches, staging area
- Familiarity with npm and
package.json
How Git Hooks Work
Every Git repository has a .git/hooks directory. When you initialize a repository with git init, Git populates this directory with sample scripts:
ls .git/hooks/
applypatch-msg.sample pre-commit.sample
commit-msg.sample pre-merge-commit.sample
fsmonitor-watchman.sample pre-push.sample
post-update.sample pre-rebase.sample
pre-applypatch.sample pre-receive.sample
prepare-commit-msg.sample update.sample
Each .sample file is a template. To activate a hook, you remove the .sample extension and make the script executable. The script can be written in any language -- bash, Python, Node.js, Perl -- as long as it has a valid shebang line and the executable bit set.
# Activate the pre-commit hook
cp .git/hooks/pre-commit.sample .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
The critical rule: if a hook script exits with a non-zero status, the Git operation is aborted. A pre-commit hook that exits with 1 stops the commit. A pre-push hook that exits with 1 stops the push. This is the enforcement mechanism.
Here is the simplest possible pre-commit hook:
#!/bin/sh
# .git/hooks/pre-commit
echo "Running pre-commit checks..."
# Run linter
npm run lint
if [ $? -ne 0 ]; then
echo "Lint failed. Fix errors before committing."
exit 1
fi
exit 0
Client-Side Hooks
Client-side hooks run on the developer's machine. They are the ones you will use most.
pre-commit
Fires before Git even prompts you for a commit message. This is where you run linters, formatters, type checkers, and secret scanners. If it exits non-zero, the commit is aborted and nothing is staged.
#!/bin/sh
# Runs before the commit message editor opens
echo "Pre-commit: checking staged files..."
npm run lint:staged
This is the most commonly automated hook. I run ESLint, Prettier checks, and secret detection here on every project.
prepare-commit-msg
Fires after Git creates the default commit message but before the editor opens. Use this to prepend branch names, ticket numbers, or templates to your commit messages automatically.
#!/bin/sh
# .git/hooks/prepare-commit-msg
# Prepend the branch name (e.g., feature/JIRA-123) to the commit message
BRANCH_NAME=$(git symbolic-ref --short HEAD)
TICKET=$(echo "$BRANCH_NAME" | grep -oE '[A-Z]+-[0-9]+')
if [ -n "$TICKET" ]; then
sed -i.bak -e "1s/^/[$TICKET] /" "$1"
fi
Now when you commit on branch feature/JIRA-456, your commit message automatically starts with [JIRA-456].
commit-msg
Fires after you write your commit message, before the commit is finalized. This is where you validate commit message format -- enforcing conventional commits, minimum length, ticket number requirements.
#!/bin/sh
# .git/hooks/commit-msg
# Enforce conventional commit format
COMMIT_MSG=$(cat "$1")
PATTERN="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .{1,}"
if ! echo "$COMMIT_MSG" | grep -qE "$PATTERN"; then
echo "ERROR: Commit message does not follow Conventional Commits format."
echo "Expected: <type>(<scope>): <description>"
echo "Examples:"
echo " feat(auth): add OAuth2 login support"
echo " fix: resolve null pointer in user lookup"
echo " docs: update API reference for v3 endpoints"
echo ""
echo "Your message: $COMMIT_MSG"
exit 1
fi
post-commit
Fires after the commit completes. This hook cannot stop the commit because it has already happened. Use it for notifications, logging, or triggering builds.
#!/bin/sh
# .git/hooks/post-commit
echo "Commit successful: $(git log -1 --pretty=format:'%h %s')"
# Could send a Slack notification, update a dashboard, etc.
pre-push
Fires before objects are transferred to the remote. This is your last chance to stop bad code from reaching the shared repository. Run your test suite here, validate branch names, or check that you are not pushing to a protected branch.
#!/bin/sh
# .git/hooks/pre-push
# Run tests before pushing
echo "Running test suite before push..."
npm test
if [ $? -ne 0 ]; then
echo "Tests failed. Push aborted."
exit 1
fi
# Prevent direct pushes to main
BRANCH=$(git symbolic-ref --short HEAD)
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
echo "ERROR: Direct push to $BRANCH is not allowed."
echo "Create a feature branch and open a pull request."
exit 1
fi
exit 0
Server-Side Hooks
Server-side hooks run on the repository that receives a push. If you host your own Git server (Gitea, GitLab self-hosted), these are extremely powerful. If you use GitHub or GitLab SaaS, you accomplish the same thing through branch protection rules and CI pipelines.
pre-receive
Fires once when a push arrives. It receives all the refs being pushed on stdin. Reject the push by exiting non-zero.
#!/bin/sh
# Server-side: reject pushes with commits larger than 10MB
while read oldrev newrev refname; do
# Check each new commit for oversized files
if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then
COMMITS=$(git rev-list "$newrev")
else
COMMITS=$(git rev-list "$oldrev..$newrev")
fi
for commit in $COMMITS; do
git diff-tree -r --diff-filter=d "$commit" | while read mode_old mode_new hash_old hash_new status filename; do
size=$(git cat-file -s "$hash_new" 2>/dev/null || echo 0)
if [ "$size" -gt 10485760 ]; then
echo "ERROR: File $filename in commit $commit exceeds 10MB limit ($size bytes)"
exit 1
fi
done
if [ $? -ne 0 ]; then exit 1; fi
done
done
update
Similar to pre-receive but runs once per branch being updated. Useful for per-branch policies.
post-receive
Fires after the entire push completes. This is where you trigger deployments, send notifications, or update external systems. Since the push has already been accepted, this hook cannot reject anything.
#!/bin/sh
# Server-side: deploy on push to main
while read oldrev newrev refname; do
if [ "$refname" = "refs/heads/main" ]; then
echo "Deploying to production..."
GIT_WORK_TREE=/var/www/app git checkout -f main
cd /var/www/app && npm install --production && pm2 restart app
echo "Deployment complete."
fi
done
Sharing Hooks with Your Team
The .git/hooks directory is local to each clone. It is not tracked by Git, so you cannot just commit hooks and have everyone get them automatically. This is the single biggest problem with raw Git hooks. There are two solutions.
core.hooksPath
Git 2.9 introduced the core.hooksPath configuration option. You create a directory in your repository (conventionally .githooks/), put your hook scripts there, commit them, and tell Git to use that directory instead of .git/hooks/.
# Create the hooks directory
mkdir .githooks
# Move your hooks there
cp .git/hooks/pre-commit .githooks/pre-commit
cp .git/hooks/commit-msg .githooks/commit-msg
chmod +x .githooks/*
# Configure Git to use this directory
git config core.hooksPath .githooks
# Commit the hooks
git add .githooks/
git commit -m "chore: add shared git hooks"
The catch: every developer must run git config core.hooksPath .githooks after cloning. You can automate this with a setup script or an npm prepare script:
{
"scripts": {
"prepare": "git config core.hooksPath .githooks"
}
}
Now npm install automatically configures the hooks path.
The .githooks Directory Convention
Some teams create a .githooks/ directory and add a Makefile or shell script that symlinks hooks into .git/hooks/:
#!/bin/sh
# setup-hooks.sh
for hook in .githooks/*; do
ln -sf "../../$hook" ".git/hooks/$(basename $hook)"
done
echo "Git hooks installed."
This works but is brittle. Husky is a better solution for Node.js projects.
Using Husky for Node.js Projects
Husky is the standard tool for managing Git hooks in Node.js projects. It installs hooks automatically when someone runs npm install, and it stores hook scripts in your repository so they are version-controlled and shared.
Setup
# Install Husky
npm install --save-dev husky
# Initialize Husky (creates .husky/ directory)
npx husky init
This does three things:
- Creates a
.husky/directory in your project root - Sets
core.hooksPathto.husky/_ - Adds a
preparescript topackage.jsonthat runshuskyon install
Your package.json now has:
{
"scripts": {
"prepare": "husky"
}
}
Creating Hooks
Husky hooks are plain shell scripts in the .husky/ directory:
# Create a pre-commit hook
echo "npm run lint" > .husky/pre-commit
# Create a commit-msg hook
echo "npx --no -- commitlint --edit \$1" > .husky/commit-msg
# Create a pre-push hook
echo "npm test" > .husky/pre-push
Each file is a shell script. No shebang needed -- Husky handles that. The file name must match the Git hook name exactly.
Here is what a real .husky/pre-commit looks like:
npx lint-staged
That is it. One line. The complexity lives in your lint-staged configuration.
lint-staged: Running Linters on Staged Files Only
The biggest performance mistake with pre-commit hooks is running linters on your entire codebase. If you have 500 JavaScript files and you changed one, there is no reason to lint all 500. lint-staged solves this by running commands only on files that are currently staged (git add-ed).
Installation
npm install --save-dev lint-staged
Configuration
Add the configuration to your package.json:
{
"lint-staged": {
"*.js": [
"eslint --fix",
"prettier --write"
],
"*.{json,md}": [
"prettier --write"
],
"*.css": [
"prettier --write"
]
}
}
Or use a separate .lintstagedrc.json file:
{
"*.js": ["eslint --fix", "prettier --write"],
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,yml,yaml,md}": ["prettier --write"],
"*.css": ["stylelint --fix", "prettier --write"]
}
When you commit, lint-staged does the following:
- Identifies files that are staged
- Matches them against the glob patterns in your config
- Runs the specified commands on matching files
- Re-stages the files if they were modified (by
--fixor--write) - If any command exits non-zero, the commit is aborted
Here is what the output looks like when it works:
$ git commit -m "feat: add user validation"
✔ Preparing lint-staged...
✔ Running tasks for staged files...
✔ *.js — 2 files
✔ eslint --fix — passed
✔ prettier --write — passed
✔ *.json — 1 file
✔ prettier --write — passed
✔ Applying modifications from tasks...
✔ Cleaning up temporary files...
[main a1b2c3d] feat: add user validation
3 files changed, 45 insertions(+), 12 deletions(-)
And when it fails:
$ git commit -m "feat: add broken code"
✔ Preparing lint-staged...
✘ Running tasks for staged files...
✘ *.js — 1 file
✘ eslint --fix — failed
/home/user/project/src/handlers/user.js
12:5 error 'result' is assigned a value but never used no-unused-vars
18:22 error Unexpected use of undefined no-undefined
24:1 error Missing return statement consistent-return
✘ 3 problems (3 errors, 0 warnings)
> lint-staged failed. Commit aborted.
Commit Message Validation with commitlint
Conventional Commits is a specification for structuring commit messages. The format is:
<type>(<scope>): <description>
[optional body]
[optional footer(s)]
Types include feat, fix, docs, style, refactor, perf, test, build, ci, chore, and revert. This format enables automatic changelog generation, semantic versioning, and makes git log actually readable.
Setting Up commitlint
# Install commitlint and the conventional config
npm install --save-dev @commitlint/cli @commitlint/config-conventional
Create a commitlint.config.js file:
// commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [2, 'always', [
'feat', 'fix', 'docs', 'style', 'refactor',
'perf', 'test', 'build', 'ci', 'chore', 'revert'
]],
'subject-max-length': [2, 'always', 100],
'subject-case': [2, 'never', ['start-case', 'pascal-case', 'upper-case']],
'body-max-line-length': [1, 'always', 100]
}
};
Wire it up with a Husky commit-msg hook:
echo "npx --no -- commitlint --edit \$1" > .husky/commit-msg
Now bad commit messages are rejected immediately:
$ git commit -m "updated stuff"
⧗ input: updated stuff
✖ subject may not be empty [subject-empty]
✖ type may not be empty [type-empty]
✖ Found 2 problems, 0 warnings
ⓘ Get help: https://github.com/conventional-changelog/commitlint/#what-is-commitlint
husky - commit-msg script failed (code 1)
And a properly formatted message goes through:
$ git commit -m "feat(auth): add OAuth2 login support"
✔ Linting commit message...
[feature/oauth a1b2c3d] feat(auth): add OAuth2 login support
4 files changed, 180 insertions(+)
Pre-Commit Checks Beyond Linting
Linting is table stakes. Here are the other checks I run in pre-commit hooks.
Secret Detection
This is non-negotiable. Accidentally committing an API key, database password, or private key to a public repository is a career-defining mistake. Detect them before they leave your machine.
#!/bin/sh
# .githooks/check-secrets.sh
# Scan staged files for potential secrets
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
# Patterns that should never appear in committed code
PATTERNS=(
'AKIA[0-9A-Z]{16}' # AWS Access Key
'sk-[a-zA-Z0-9]{48}' # OpenAI API Key
'ghp_[a-zA-Z0-9]{36}' # GitHub Personal Access Token
'xoxb-[0-9]+-[0-9]+-[a-zA-Z0-9]+' # Slack Bot Token
'mongodb\+srv://[^"'\'']*@' # MongoDB connection string
'postgres://[^"'\'']*@' # PostgreSQL connection string
'password\s*[:=]\s*["\x27][^"\x27]+' # Hardcoded passwords
'PRIVATE KEY-----' # Private keys
)
FOUND=0
for file in $STAGED_FILES; do
for pattern in "${PATTERNS[@]}"; do
if git show ":$file" 2>/dev/null | grep -qE "$pattern"; then
echo "ERROR: Potential secret found in $file"
echo "Pattern: $pattern"
git show ":$file" | grep -nE "$pattern" | head -3
echo ""
FOUND=1
fi
done
done
if [ $FOUND -eq 1 ]; then
echo "Secrets detected in staged files. Commit aborted."
echo "If these are false positives, use 'git commit --no-verify'"
exit 1
fi
exit 0
File Size Limits
Prevent someone from committing a 200MB video or a node_modules zip:
#!/bin/sh
# Reject files larger than 5MB
MAX_SIZE=5242880 # 5MB in bytes
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
for file in $STAGED_FILES; do
size=$(git cat-file -s ":$file" 2>/dev/null || echo 0)
if [ "$size" -gt "$MAX_SIZE" ]; then
echo "ERROR: $file is $(($size / 1048576))MB, exceeds 5MB limit."
echo "Consider using Git LFS for large files."
exit 1
fi
done
Formatting Checks
Instead of auto-fixing with Prettier, some teams prefer to just check and fail:
npx prettier --check "src/**/*.js"
This catches formatting issues without modifying files during the commit process. I prefer --write with lint-staged (auto-fix and re-stage), but some teams want the developer to fix formatting explicitly.
Pre-Push Checks
Running Tests
The pre-push hook is the right place for tests because tests take longer than linting:
#!/bin/sh
# .husky/pre-push
echo "Running tests before push..."
npm test 2>&1
if [ $? -ne 0 ]; then
echo ""
echo "Tests failed. Push aborted."
echo "Fix failing tests or use 'git push --no-verify' to skip."
exit 1
fi
Branch Name Validation
Enforce a naming convention so branches are consistent:
#!/bin/sh
# Validate branch name format
BRANCH=$(git symbolic-ref --short HEAD)
PATTERN="^(feature|fix|hotfix|chore|docs|refactor|test)/[a-z0-9._-]+$"
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ] || [ "$BRANCH" = "develop" ]; then
exit 0
fi
if ! echo "$BRANCH" | grep -qE "$PATTERN"; then
echo "ERROR: Branch name '$BRANCH' does not match required pattern."
echo "Expected format: <type>/<description>"
echo "Types: feature, fix, hotfix, chore, docs, refactor, test"
echo "Example: feature/add-user-auth"
exit 1
fi
Post-Merge Hooks
The post-merge hook fires after a successful git merge or git pull. The most useful automation here is auto-installing dependencies when package.json changes:
#!/bin/sh
# .husky/post-merge
# Auto-install dependencies if package.json changed
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
echo "Dependencies updated."
fi
if echo "$CHANGED_FILES" | grep -q "package-lock.json"; then
echo "package-lock.json changed. Running npm ci..."
npm ci
echo "Dependencies synchronized."
fi
This eliminates the "it works on my machine" issue where someone pulls changes that add a new dependency but forgets to run npm install.
Building a File-Type-Aware Pre-Commit Hook
Sometimes you want to run different checks based on what types of files are being committed. Here is a hook that dispatches to different tools based on file extensions:
#!/bin/sh
# .githooks/pre-commit-dispatch.sh
# Run different checks based on staged file types
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
EXIT_CODE=0
# Collect file types
JS_FILES=$(echo "$STAGED_FILES" | grep -E '\.(js|jsx)$' || true)
TS_FILES=$(echo "$STAGED_FILES" | grep -E '\.(ts|tsx)$' || true)
CSS_FILES=$(echo "$STAGED_FILES" | grep -E '\.(css|scss)$' || true)
PY_FILES=$(echo "$STAGED_FILES" | grep -E '\.py$' || true)
DOCKER_FILES=$(echo "$STAGED_FILES" | grep -E 'Dockerfile' || true)
YAML_FILES=$(echo "$STAGED_FILES" | grep -E '\.(yml|yaml)$' || true)
SQL_FILES=$(echo "$STAGED_FILES" | grep -E '\.sql$' || true)
# JavaScript/JSX
if [ -n "$JS_FILES" ]; then
echo "Checking JavaScript files..."
echo "$JS_FILES" | xargs npx eslint --fix
if [ $? -ne 0 ]; then EXIT_CODE=1; fi
echo "$JS_FILES" | xargs npx prettier --write
echo "$JS_FILES" | xargs git add
fi
# TypeScript/TSX
if [ -n "$TS_FILES" ]; then
echo "Checking TypeScript files..."
echo "$TS_FILES" | xargs npx eslint --fix
if [ $? -ne 0 ]; then EXIT_CODE=1; fi
npx tsc --noEmit
if [ $? -ne 0 ]; then EXIT_CODE=1; fi
fi
# CSS/SCSS
if [ -n "$CSS_FILES" ]; then
echo "Checking CSS files..."
echo "$CSS_FILES" | xargs npx stylelint --fix
if [ $? -ne 0 ]; then EXIT_CODE=1; fi
fi
# Python
if [ -n "$PY_FILES" ]; then
echo "Checking Python files..."
echo "$PY_FILES" | xargs python -m flake8
if [ $? -ne 0 ]; then EXIT_CODE=1; fi
echo "$PY_FILES" | xargs python -m black --check
if [ $? -ne 0 ]; then EXIT_CODE=1; fi
fi
# Dockerfiles
if [ -n "$DOCKER_FILES" ]; then
echo "Linting Dockerfiles..."
echo "$DOCKER_FILES" | xargs npx dockerfilelint
if [ $? -ne 0 ]; then EXIT_CODE=1; fi
fi
# YAML
if [ -n "$YAML_FILES" ]; then
echo "Validating YAML files..."
echo "$YAML_FILES" | xargs npx yaml-lint
if [ $? -ne 0 ]; then EXIT_CODE=1; fi
fi
exit $EXIT_CODE
This approach means you only run relevant tools. Committing a Markdown file does not trigger ESLint. Committing a Dockerfile does not run stylelint.
Bypassing Hooks
Every hook can be bypassed with the --no-verify flag:
# Skip pre-commit and commit-msg hooks
git commit --no-verify -m "wip: debugging production issue"
# Skip pre-push hook
git push --no-verify
When is this acceptable? Honestly, almost never. But here are the legitimate cases:
- Emergency hotfix in production: The build is down, you have a one-line fix, and the linter is complaining about an unrelated import ordering issue. Ship the fix. Clean it up in the next commit.
- WIP commits on a feature branch: You are saving your work at the end of the day on a branch that only you use. Nobody else will see these commits, and you will squash them before merging.
- False positives in secret detection: Your test fixture contains a string that looks like an API key pattern but is not one.
What is not acceptable: bypassing hooks because "the linter is annoying" or "the tests take too long." If the hooks are too slow, fix the hooks. Do not work around them.
Some teams track --no-verify usage by adding a post-commit hook that logs when hooks were skipped. That is a reasonable approach for audit purposes.
Hook Performance Optimization
Slow hooks are hooks that get bypassed. Here are the techniques I use to keep hooks fast.
Only Check Changed Files
This is what lint-staged does automatically, but if you are writing custom hooks:
# Bad: lint everything
npx eslint src/
# Good: lint only staged files
STAGED=$(git diff --cached --name-only --diff-filter=ACM | grep '\.js$')
if [ -n "$STAGED" ]; then
echo "$STAGED" | xargs npx eslint
fi
Parallel Execution
Run independent checks in parallel:
#!/bin/sh
# Run lint and type-check in parallel
npm run lint &
LINT_PID=$!
npm run typecheck &
TYPE_PID=$!
wait $LINT_PID
LINT_EXIT=$?
wait $TYPE_PID
TYPE_EXIT=$?
if [ $LINT_EXIT -ne 0 ] || [ $TYPE_EXIT -ne 0 ]; then
echo "Pre-commit checks failed."
exit 1
fi
Cache ESLint Results
ESLint supports caching, which dramatically speeds up repeated runs:
{
"lint-staged": {
"*.js": ["eslint --fix --cache --cache-location .eslintcache"]
}
}
Add .eslintcache to your .gitignore.
Skip Hooks in CI
Your CI pipeline runs its own checks. There is no reason to run client-side hooks inside CI:
# In your CI configuration
export HUSKY=0
# or
git config --global core.hooksPath /dev/null
Husky respects the HUSKY=0 environment variable and skips hook installation.
Complete Working Example
Here is a full Node.js project setup with Husky, lint-staged, commitlint, secret detection, and branch naming enforcement. This is what I use on production projects.
package.json
{
"name": "my-api-service",
"version": "1.0.0",
"scripts": {
"start": "node server.js",
"test": "mocha --recursive test/",
"lint": "eslint src/ --cache",
"lint:fix": "eslint src/ --fix --cache",
"format": "prettier --write \"src/**/*.js\"",
"format:check": "prettier --check \"src/**/*.js\"",
"typecheck": "tsc --noEmit",
"prepare": "husky"
},
"devDependencies": {
"@commitlint/cli": "^19.0.0",
"@commitlint/config-conventional": "^19.0.0",
"eslint": "^8.56.0",
"husky": "^9.0.0",
"lint-staged": "^15.2.0",
"mocha": "^10.2.0",
"prettier": "^3.2.0"
},
"lint-staged": {
"*.js": [
"eslint --fix --cache --cache-location .eslintcache",
"prettier --write"
],
"*.{json,md,yml,yaml}": [
"prettier --write"
]
}
}
commitlint.config.js
// commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [2, 'always', [
'feat', 'fix', 'docs', 'style', 'refactor',
'perf', 'test', 'build', 'ci', 'chore', 'revert'
]],
'subject-max-length': [2, 'always', 100],
'subject-case': [2, 'never', ['start-case', 'pascal-case', 'upper-case']],
'body-max-line-length': [1, 'always', 120],
'header-max-length': [2, 'always', 120]
}
};
.husky/pre-commit
# Run lint-staged for linting and formatting
npx lint-staged
# Run secret detection
sh .githooks/check-secrets.sh
.husky/commit-msg
npx --no -- commitlint --edit $1
.husky/pre-push
# Run tests
echo "Running test suite..."
npm test
if [ $? -ne 0 ]; then
echo "Tests failed. Push aborted."
exit 1
fi
# Validate branch name
BRANCH=$(git symbolic-ref --short HEAD)
VALID_PATTERN="^(feature|fix|hotfix|chore|docs|refactor|test)/[a-z0-9._-]+$"
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ] || [ "$BRANCH" = "develop" ]; then
exit 0
fi
if ! echo "$BRANCH" | grep -qE "$VALID_PATTERN"; then
echo "ERROR: Branch '$BRANCH' does not follow naming convention."
echo "Use: <type>/<description> (e.g., feature/add-user-auth)"
exit 1
fi
.husky/post-merge
# Auto-install when package.json changes
CHANGED=$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD)
if echo "$CHANGED" | grep -qE "package(-lock)?\.json"; then
echo "Dependencies changed. Running npm install..."
npm install
fi
.githooks/check-secrets.sh
#!/bin/sh
# Secret detection for staged files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
FOUND_SECRET=0
for file in $STAGED_FILES; do
# Skip binary files and common non-secret files
case "$file" in
*.png|*.jpg|*.gif|*.ico|*.woff|*.ttf|*.lock) continue ;;
esac
CONTENT=$(git show ":$file" 2>/dev/null)
# AWS Access Key
if echo "$CONTENT" | grep -qE 'AKIA[0-9A-Z]{16}'; then
echo "ALERT: Possible AWS Access Key in $file"
FOUND_SECRET=1
fi
# Generic API Key patterns
if echo "$CONTENT" | grep -qE '(api[_-]?key|apikey)\s*[:=]\s*["\x27][a-zA-Z0-9]{20,}'; then
echo "ALERT: Possible API key in $file"
FOUND_SECRET=1
fi
# Private keys
if echo "$CONTENT" | grep -q 'PRIVATE KEY-----'; then
echo "ALERT: Private key detected in $file"
FOUND_SECRET=1
fi
# Connection strings with credentials
if echo "$CONTENT" | grep -qE '(mongodb|postgres|mysql|redis)(\+srv)?://[^/]+:[^@]+@'; then
echo "ALERT: Database connection string with credentials in $file"
FOUND_SECRET=1
fi
# OpenAI / Anthropic keys
if echo "$CONTENT" | grep -qE 'sk-[a-zA-Z0-9]{40,}'; then
echo "ALERT: Possible API secret key in $file"
FOUND_SECRET=1
fi
# .env file being committed
if echo "$file" | grep -qE '^\.env(\.|$)'; then
echo "ALERT: .env file being committed: $file"
FOUND_SECRET=1
fi
done
if [ $FOUND_SECRET -eq 1 ]; then
echo ""
echo "Secrets detected. Commit aborted."
echo "Move secrets to environment variables or .env (which should be in .gitignore)."
echo "Use 'git commit --no-verify' only if these are false positives."
exit 1
fi
exit 0
Custom Hook: Node.js Secret Scanner
For more sophisticated detection, you can write hooks in Node.js. Here is a scanner that checks for high-entropy strings that might be leaked tokens:
#!/usr/bin/env node
// .githooks/scan-entropy.js
// Detect high-entropy strings that might be secrets
var childProcess = require('child_process');
var path = require('path');
var ENTROPY_THRESHOLD = 4.5;
var MIN_LENGTH = 20;
function calculateEntropy(str) {
var freq = {};
var len = str.length;
for (var i = 0; i < len; i++) {
var c = str[i];
freq[c] = (freq[c] || 0) + 1;
}
var entropy = 0;
var keys = Object.keys(freq);
for (var j = 0; j < keys.length; j++) {
var p = freq[keys[j]] / len;
entropy -= p * Math.log2(p);
}
return entropy;
}
function getStagedFiles() {
var result = childProcess.execSync(
'git diff --cached --name-only --diff-filter=ACM',
{ encoding: 'utf-8' }
);
return result.trim().split('\n').filter(Boolean);
}
function getFileContent(file) {
try {
return childProcess.execSync(
'git show ":' + file + '"',
{ encoding: 'utf-8' }
);
} catch (e) {
return '';
}
}
var files = getStagedFiles();
var found = false;
files.forEach(function(file) {
// Skip non-text files
if (/\.(png|jpg|gif|ico|woff|ttf|lock|map)$/.test(file)) return;
var content = getFileContent(file);
var lines = content.split('\n');
lines.forEach(function(line, idx) {
// Find quoted strings
var matches = line.match(/["']([a-zA-Z0-9+/=_-]{20,})["']/g);
if (!matches) return;
matches.forEach(function(match) {
var value = match.slice(1, -1);
if (value.length < MIN_LENGTH) return;
var entropy = calculateEntropy(value);
if (entropy > ENTROPY_THRESHOLD) {
console.log(
'WARNING: High-entropy string in ' + file +
':' + (idx + 1)
);
console.log(
' Value: ' + value.substring(0, 12) + '...' +
' (entropy: ' + entropy.toFixed(2) + ')'
);
found = true;
}
});
});
});
if (found) {
console.log('');
console.log('High-entropy strings detected. These might be secrets.');
console.log('Review the warnings above. Use --no-verify if they are false positives.');
process.exit(1);
}
process.exit(0);
Project Initialization Script
Tie everything together with a setup command:
#!/bin/sh
# setup.sh - Run after cloning the repository
echo "Installing dependencies..."
npm install
echo "Making custom hooks executable..."
chmod +x .githooks/*.sh .githooks/*.js
echo "Verifying Husky installation..."
if [ -d ".husky" ]; then
echo "Husky hooks directory found."
else
echo "ERROR: Husky not initialized. Run 'npx husky init'"
exit 1
fi
echo ""
echo "Setup complete. The following hooks are active:"
echo " pre-commit: lint-staged + secret detection"
echo " commit-msg: conventional commit validation"
echo " pre-push: test suite + branch name validation"
echo " post-merge: auto npm install on dependency changes"
Common Issues and Troubleshooting
1. Hook Not Executing
$ git commit -m "feat: add new feature"
[main a1b2c3d] feat: add new feature
The commit goes through with no hook output. The hook is either not installed, not executable, or the path is wrong.
Fix:
# Check if hooks path is configured
git config core.hooksPath
# Should output: .husky/_
# Check if the hook file exists and is executable
ls -la .husky/pre-commit
# Should show: -rwxr-xr-x
# On Windows, executable bit is not relevant, but the file must exist
# Re-initialize Husky
npx husky init
2. Husky Not Running After npm install
$ npm install
added 847 packages in 12s
$ git commit -m "test"
[main b2c3d4e] test
The prepare script did not run. This happens when npm install is run with --ignore-scripts or when the prepare script is missing from package.json.
Fix:
# Verify the prepare script exists
node -e "var p = require('./package.json'); console.log(p.scripts.prepare)"
# Should output: husky
# Run prepare manually
npm run prepare
# If using npm ci in CI, set HUSKY=0 to intentionally skip
HUSKY=0 npm ci
3. lint-staged Fails with "No staged files match any configured task"
$ git commit -m "chore: update readme"
✔ Preparing lint-staged...
ℹ No staged files match any configured task.
✔ Cleaning up temporary files...
This is not actually an error -- it is informational. If you are committing a .txt file and your lint-staged config only has patterns for *.js and *.json, nothing runs. This is correct behavior.
If you expected files to match but they did not, check your glob patterns. A common mistake is writing src/**/*.js when the staged file is at the project root.
4. commit-msg Hook Fails on Merge Commits
$ git merge feature/auth
⧗ input: Merge branch 'feature/auth' into main
✖ subject may not be empty [subject-empty]
✖ type may not be empty [type-empty]
Git auto-generates merge commit messages that do not follow conventional commit format. The fix is to tell commitlint to ignore merge commits:
// commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional'],
ignores: [
function(message) {
return /^Merge\s/.test(message);
}
]
};
5. Hooks Fail in GUI Git Clients
Some GUI Git clients (SourceTree, GitKraken, VS Code's built-in Git) do not always respect core.hooksPath or fail to find npx because they do not inherit the terminal's PATH.
Fix: Add the Node.js bin directory to the hook script:
# .husky/pre-commit
export PATH="/usr/local/bin:$HOME/.nvm/versions/node/$(node -v)/bin:$PATH"
npx lint-staged
On Windows with nvm-windows, the path issue is less common, but you may need to ensure the Husky scripts use the correct shell.
6. pre-push Hook Runs Tests Twice
If your pre-push hook runs npm test and your CI also runs tests, that is intentional -- they serve different purposes. But if the hook is running twice locally, it is usually because you have hooks in both .git/hooks/ and .husky/:
# Check for duplicate hooks
ls .git/hooks/pre-push
ls .husky/pre-push
# Remove the old one
rm .git/hooks/pre-push
Best Practices
Keep pre-commit hooks under 10 seconds. If your pre-commit hook takes 30 seconds, developers will start using
--no-verify. Use lint-staged to only check changed files. Use ESLint caching. Run expensive checks (full test suites, integration tests) in the pre-push hook instead.Always commit your hook configuration. Husky's
.husky/directory, your.lintstagedrc, yourcommitlint.config.js-- all of these must be in version control. A hook setup that only works on your machine is worse than no hooks at all because it creates a false sense of security.Use the
preparescript for automatic setup. Any developer who runsnpm installshould have hooks installed automatically. They should never need to run a separate setup command or read a wiki page. If hooks require manual configuration, they will not be used.Secret detection is not optional. Run a secret scanner in your pre-commit hook on every project, even personal ones. A leaked API key on a public GitHub repository can be detected and exploited in minutes. The cost of adding a secret scanner is trivial compared to the cost of a breach.
Do not run your full test suite in pre-commit. Save comprehensive tests for pre-push or CI. The pre-commit hook should be fast enough that developers do not even notice it. Linting and formatting, yes. A 45-second integration test suite, no.
Treat
--no-verifyas a code smell. It exists for genuine emergencies. If someone on your team is using it regularly, the hooks are too slow, too strict, or both. Fix the root cause instead of working around it.Set up post-merge hooks for dependency management. Auto-running
npm installaftergit pullwhenpackage.jsonchanges eliminates an entire category of "works on my machine" bugs. This is a five-minute setup that saves hours of debugging over the life of a project.Test your hooks in CI. Run
npx commitlint --from=HEAD~1andnpx lint-staged --diff="HEAD~1"in your CI pipeline. This catches cases where someone bypassed hooks locally. Your CI pipeline should enforce the same rules that your hooks enforce.Document the hook setup in your project README. A one-paragraph explanation of what hooks are installed and what they enforce sets expectations for new contributors. Include the
--no-verifyescape hatch so people know it exists for legitimate cases.
References
- Git Hooks Documentation -- Official Git reference for all available hooks
- Husky -- Modern Git hooks for Node.js projects
- lint-staged -- Run linters on staged files only
- commitlint -- Lint commit messages against conventional commits
- Conventional Commits -- The specification for structured commit messages
- git-secrets -- AWS tool for preventing secret commits
- Pro Git Book: Customizing Git Hooks -- In-depth guide from the Pro Git book
