Git Hooks Automation: Pre-Commit to Post-Deploy
A complete guide to Git hooks for automating code quality checks, testing, deployment, and workflow enforcement from pre-commit through post-deploy.
Git Hooks Automation: Pre-Commit to Post-Deploy
Git hooks are scripts that run automatically at specific points in the Git workflow. They catch problems before they reach the repository, enforce standards without human discipline, and automate repetitive tasks that developers forget to do manually. A pre-commit hook that runs your linter prevents every badly formatted commit. A pre-push hook that runs tests prevents every broken push. These safeguards cost zero effort after setup.
I have hooks on every project. The ones in this guide are the hooks that actually earn their keep — not theoretical examples, but the specific checks and automations that have caught real bugs before they shipped.
Prerequisites
- Git installed (v2.9+ for core.hooksPath support)
- Node.js installed (for JavaScript-based hooks and Husky)
- A project with linting and testing configured
- Basic shell scripting knowledge
How Git Hooks Work
Git hooks are executable scripts in the .git/hooks/ directory. Git ships with sample hooks (files ending in .sample). Remove the .sample extension and make the file executable to activate a hook.
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 prepare-commit-msg.sample
# pre-commit.sample update.sample
Hook Types
Client-side hooks (run on your machine):
pre-commit Before commit is created
prepare-commit-msg Before commit message editor opens
commit-msg After commit message is entered
post-commit After commit is created
pre-push Before push to remote
pre-rebase Before rebase starts
post-checkout After checkout or switch
post-merge After merge completes
Server-side hooks (run on the remote):
pre-receive Before accepting pushed refs
update Like pre-receive, per branch
post-receive After all refs are updated
post-update After refs are updated (legacy)
Hook Execution
Hooks must be executable. If a hook exits with a non-zero status, the Git operation is aborted:
# Make a hook executable
chmod +x .git/hooks/pre-commit
# Exit 0 = allow the operation
# Exit 1 = abort the operation
Writing Hooks from Scratch
Pre-Commit Hook
The most valuable hook. Runs before every commit:
#!/bin/bash
# .git/hooks/pre-commit
echo "Running pre-commit checks..."
# Check for debug statements
DEBUG_PATTERN="console\.log\|debugger\|TODO.*FIXME"
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.js$')
if [ -n "$STAGED_FILES" ]; then
FOUND=$(echo "$STAGED_FILES" | xargs grep -l "$DEBUG_PATTERN" 2>/dev/null)
if [ -n "$FOUND" ]; then
echo ""
echo "ERROR: Debug statements found in staged files:"
echo "$FOUND" | while read file; do
echo " $file:"
grep -n "$DEBUG_PATTERN" "$file" | head -5
done
echo ""
echo "Remove debug statements before committing."
exit 1
fi
fi
# Run linter on staged files only
if [ -n "$STAGED_FILES" ]; then
echo "Linting staged JavaScript files..."
echo "$STAGED_FILES" | xargs npx eslint --quiet
if [ $? -ne 0 ]; then
echo ""
echo "ERROR: Linting failed. Fix errors before committing."
exit 1
fi
fi
# Check for large files
MAX_SIZE=1048576 # 1MB
LARGE_FILES=$(git diff --cached --name-only --diff-filter=ACM | while read file; do
SIZE=$(wc -c < "$file" 2>/dev/null)
if [ "$SIZE" -gt "$MAX_SIZE" ] 2>/dev/null; then
echo "$file ($SIZE bytes)"
fi
done)
if [ -n "$LARGE_FILES" ]; then
echo ""
echo "WARNING: Large files detected:"
echo "$LARGE_FILES"
echo ""
echo "Consider using Git LFS for files over 1MB."
# Warning only — does not block commit
fi
echo "Pre-commit checks passed."
exit 0
Commit-Msg Hook
Enforce commit message format:
#!/bin/bash
# .git/hooks/commit-msg
MSG_FILE=$1
MSG=$(cat "$MSG_FILE")
# Enforce conventional commit format
PATTERN="^(feat|fix|refactor|docs|test|chore|perf|style|ci|build|revert)(\(.+\))?: .{3,}"
if ! echo "$MSG" | grep -qE "$PATTERN"; then
echo ""
echo "ERROR: Invalid commit message format."
echo ""
echo "Expected: <type>(<scope>): <description>"
echo ""
echo "Types: feat, fix, refactor, docs, test, chore, perf, style, ci, build, revert"
echo ""
echo "Examples:"
echo " feat: add user authentication"
echo " fix(api): handle null response from payment service"
echo " refactor(db): extract query builder into separate module"
echo ""
echo "Your message: $MSG"
exit 1
fi
# Check minimum length (excluding type prefix)
DESC=$(echo "$MSG" | sed 's/^[a-z]*\(([^)]*)\)\?: //')
if [ ${#DESC} -lt 10 ]; then
echo ""
echo "ERROR: Commit description too short (minimum 10 characters)."
echo "Your description: $DESC"
exit 1
fi
exit 0
Pre-Push Hook
Run tests before pushing:
#!/bin/bash
# .git/hooks/pre-push
echo "Running pre-push checks..."
# Run tests
echo "Running test suite..."
npm test 2>&1
if [ $? -ne 0 ]; then
echo ""
echo "ERROR: Tests failed. Push aborted."
echo "Fix failing tests before pushing."
exit 1
fi
# Check for uncommitted changes
if [ -n "$(git status --porcelain)" ]; then
echo ""
echo "WARNING: You have uncommitted changes."
echo "These changes will not be included in the push."
fi
# Prevent pushing to main directly (optional safety)
BRANCH=$(git rev-parse --abbrev-ref HEAD)
PROTECTED_BRANCHES="^(main|master|production)$"
while read local_ref local_sha remote_ref remote_sha; do
REMOTE_BRANCH=$(echo "$remote_ref" | sed 's|refs/heads/||')
if echo "$REMOTE_BRANCH" | grep -qE "$PROTECTED_BRANCHES"; then
if [ "$BRANCH" != "$REMOTE_BRANCH" ]; then
echo ""
echo "WARNING: Pushing to protected branch '$REMOTE_BRANCH' from '$BRANCH'."
echo "Make sure this is intentional."
fi
fi
done
echo "Pre-push checks passed."
exit 0
Post-Checkout Hook
Set up environment after switching branches:
#!/bin/bash
# .git/hooks/post-checkout
PREV_HEAD=$1
NEW_HEAD=$2
BRANCH_CHECKOUT=$3
# Only run on branch checkouts, not file checkouts
if [ "$BRANCH_CHECKOUT" != "1" ]; then
exit 0
fi
BRANCH=$(git rev-parse --abbrev-ref HEAD)
echo "Switched to branch: $BRANCH"
# Check if package.json changed
CHANGED=$(git diff --name-only "$PREV_HEAD" "$NEW_HEAD" -- package.json package-lock.json)
if [ -n "$CHANGED" ]; then
echo "Dependencies changed. Running npm install..."
npm install
fi
# Check if database migrations changed
MIGRATIONS_CHANGED=$(git diff --name-only "$PREV_HEAD" "$NEW_HEAD" -- migrations/)
if [ -n "$MIGRATIONS_CHANGED" ]; then
echo ""
echo "NOTE: Database migrations changed. You may need to run:"
echo " npm run db:migrate"
fi
# Check for .env.example changes
ENV_CHANGED=$(git diff --name-only "$PREV_HEAD" "$NEW_HEAD" -- .env.example)
if [ -n "$ENV_CHANGED" ]; then
echo ""
echo "NOTE: .env.example changed. Check for new environment variables."
fi
exit 0
Post-Merge Hook
Run setup tasks after pulling or merging:
#!/bin/bash
# .git/hooks/post-merge
CHANGED_FILES=$(git diff --name-only HEAD@{1} HEAD)
# Install dependencies if package files changed
if echo "$CHANGED_FILES" | grep -qE "package(-lock)?\.json$"; then
echo "Dependencies changed. Running npm install..."
npm install
fi
# Rebuild if source files changed
if echo "$CHANGED_FILES" | grep -qE "^src/"; then
echo "Source files changed. Rebuilding..."
npm run build 2>/dev/null
fi
echo "Post-merge tasks complete."
exit 0
Husky: Modern Hook Management
Writing hooks directly in .git/hooks/ works but has a problem — the .git directory is not committed to version control. Team members do not get your hooks automatically. Husky solves this.
Installation
npm install --save-dev husky
# Initialize Husky
npx husky init
This creates a .husky/ directory in your project root and configures Git to use it.
Husky Pre-Commit Hook
# .husky/pre-commit
npm run lint
Husky Commit-Msg Hook
# .husky/commit-msg
npx commitlint --edit $1
Husky Pre-Push Hook
# .husky/pre-push
npm test
Package.json Scripts
{
"scripts": {
"lint": "eslint src/ --quiet",
"test": "jest --passWithNoTests",
"prepare": "husky"
},
"devDependencies": {
"husky": "^9.0.0"
}
}
The prepare script runs automatically after npm install, setting up hooks for every developer.
lint-staged: Lint Only Changed Files
Running the linter on the entire codebase in a pre-commit hook is slow. lint-staged runs tools only on the files being committed.
Installation
npm install --save-dev lint-staged
Configuration
// package.json
{
"lint-staged": {
"*.js": [
"eslint --fix",
"prettier --write"
],
"*.{json,md}": [
"prettier --write"
],
"*.css": [
"stylelint --fix"
]
}
}
Husky + lint-staged
# .husky/pre-commit
npx lint-staged
Now the pre-commit hook only lints and formats the staged files. A 30-second full lint becomes a 2-second staged lint.
Advanced lint-staged Configuration
{
"lint-staged": {
"*.js": [
"eslint --fix --max-warnings 0",
"prettier --write",
"jest --bail --findRelatedTests --passWithNoTests"
],
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,yaml,yml}": [
"prettier --write"
],
"*.sql": [
"sql-formatter --fix"
],
"package.json": [
"sort-package-json"
]
}
}
The jest --findRelatedTests option runs only the tests that import the changed files. This catches breaking changes without running the full suite.
commitlint: Enforce Message Format
Installation
npm install --save-dev @commitlint/cli @commitlint/config-conventional
Configuration
// commitlint.config.js
module.exports = {
extends: ["@commitlint/config-conventional"],
rules: {
"type-enum": [2, "always", [
"feat", "fix", "refactor", "docs", "test",
"chore", "perf", "style", "ci", "build", "revert"
]],
"subject-min-length": [2, "always", 10],
"subject-max-length": [2, "always", 72],
"body-max-line-length": [1, "always", 100]
}
};
Husky Integration
# .husky/commit-msg
npx commitlint --edit $1
Now every commit message is validated:
git commit -m "update stuff"
# ERROR: subject may not be empty
# ERROR: type may not be empty
git commit -m "feat: add search API with pagination support"
# OK
Complete Working Example: Full Hook Pipeline
// package.json
{
"name": "my-project",
"scripts": {
"lint": "eslint src/ --quiet",
"lint:fix": "eslint src/ --fix",
"test": "jest --passWithNoTests",
"test:related": "jest --bail --findRelatedTests --passWithNoTests",
"build": "node scripts/build.js",
"prepare": "husky",
"check:types": "tsc --noEmit"
},
"devDependencies": {
"husky": "^9.0.0",
"lint-staged": "^15.0.0",
"@commitlint/cli": "^18.0.0",
"@commitlint/config-conventional": "^18.0.0",
"eslint": "^8.0.0",
"prettier": "^3.0.0",
"jest": "^29.0.0"
},
"lint-staged": {
"*.js": [
"eslint --fix --max-warnings 0",
"prettier --write",
"jest --bail --findRelatedTests --passWithNoTests"
],
"*.{json,md,yaml}": [
"prettier --write"
]
}
}
# .husky/pre-commit
npx lint-staged
# .husky/commit-msg
npx commitlint --edit $1
# .husky/pre-push
npm test
// commitlint.config.js
module.exports = {
extends: ["@commitlint/config-conventional"],
rules: {
"subject-min-length": [2, "always", 10],
"subject-max-length": [2, "always", 72]
}
};
The Workflow in Action
# Developer makes changes
git add src/auth.js
# Pre-commit hook runs:
# 1. ESLint fixes and checks src/auth.js
# 2. Prettier formats src/auth.js
# 3. Jest runs tests related to src/auth.js
git commit -m "feat: add JWT refresh token rotation"
# commit-msg hook runs:
# 1. commitlint validates the message format
git push origin main
# pre-push hook runs:
# 1. Full test suite executes
# 2. Push proceeds only if tests pass
Custom Hook Scripts
Prevent Commits to Protected Branches
#!/bin/bash
# .husky/pre-commit (add to existing)
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "production" ]; then
echo "ERROR: Direct commits to '$BRANCH' are not allowed."
echo "Create a feature branch first: git checkout -b feature/my-change"
exit 1
fi
npx lint-staged
Check for Secrets
#!/bin/bash
# scripts/check-secrets.sh (called from pre-commit)
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
SECRET_PATTERNS=(
"AKIA[0-9A-Z]{16}" # AWS Access Key
"-----BEGIN (RSA|DSA|EC) PRIVATE KEY" # Private keys
"password\s*=\s*['\"][^'\"]+['\"]" # Hardcoded passwords
"[a-z0-9]{32,}" # Generic long tokens
)
FOUND=0
for pattern in "${SECRET_PATTERNS[@]}"; do
MATCHES=$(echo "$STAGED_FILES" | xargs grep -lE "$pattern" 2>/dev/null)
if [ -n "$MATCHES" ]; then
echo "POTENTIAL SECRET DETECTED: $pattern"
echo "$MATCHES"
FOUND=1
fi
done
if [ $FOUND -eq 1 ]; then
echo ""
echo "Potential secrets found in staged files."
echo "Review the matches above. If they are false positives, use:"
echo " git commit --no-verify"
exit 1
fi
exit 0
Auto-Update Dependencies Notification
#!/bin/bash
# .husky/post-merge
CHANGED=$(git diff --name-only HEAD@{1} HEAD)
if echo "$CHANGED" | grep -q "package-lock.json"; then
echo ""
echo "========================================="
echo " package-lock.json changed!"
echo " Run: npm install"
echo "========================================="
echo ""
fi
if echo "$CHANGED" | grep -q "\.env\.example"; then
echo ""
echo "========================================="
echo " .env.example changed!"
echo " Check for new environment variables"
echo "========================================="
echo ""
fi
Common Issues and Troubleshooting
Hooks not running after cloning the repo
Husky hooks require running npm install to set up the Git hooks path:
Fix: Run npm install after cloning. The prepare script in package.json runs husky, which configures Git to use .husky/ as the hooks directory. If prepare is missing, add "prepare": "husky" to your scripts.
Hook runs but takes too long
The pre-commit hook lints the entire codebase instead of just staged files:
Fix: Use lint-staged to run tools only on staged files. Replace eslint src/ with the lint-staged configuration. For tests, use jest --findRelatedTests instead of running the full suite.
Hook blocks a legitimate commit
A false positive from a secret checker or an overly strict linter rule:
Fix: Use git commit --no-verify to skip hooks for a single commit. But investigate why the hook triggered — false positives should be fixed in the hook configuration, not routinely bypassed.
Husky hooks have wrong permissions on Linux/macOS
The hook file is not executable:
Fix: Run chmod +x .husky/pre-commit. When creating hooks with Husky, they should be executable by default. If they lose permissions after a checkout, check your Git configuration for core.fileMode.
lint-staged shows "No staged files match any configured task"
The staged files do not match the glob patterns in the lint-staged configuration:
Fix: Check your glob patterns. "*.js" matches JavaScript files in any directory. "src/**/*.js" matches only in src/. If you stage a file in lib/, the first pattern matches but the second does not.
Best Practices
- Start with a pre-commit hook and lint-staged. This is the highest-impact combination. It catches 80% of issues before they enter the repository.
- Keep hooks fast. Pre-commit should finish in under 5 seconds. Slow hooks make developers use
--no-verify, defeating the purpose. Use lint-staged, run only related tests, skip type checking in pre-commit. - Put tests in pre-push, not pre-commit. Full test suites are too slow for pre-commit. Run them before push instead, where a 30-second wait is acceptable.
- Use Husky to share hooks through version control. Hooks in
.git/hooks/are not committed. Husky stores hooks in.husky/which is tracked by Git, ensuring every developer has the same checks. - Never disable hooks permanently. If a hook is annoying, fix the hook.
--no-verifyshould be rare and deliberate, not a daily habit. - Enforce commit message format with commitlint. Consistent messages make
git loguseful. Without enforcement, message quality degrades within a week. - Add a post-merge hook for dependency changes. Forgetting to run
npm installafter pulling new dependencies is one of the most common "it works on my machine" problems.