Version Control

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-verify should be rare and deliberate, not a daily habit.
  • Enforce commit message format with commitlint. Consistent messages make git log useful. Without enforcement, message quality degrades within a week.
  • Add a post-merge hook for dependency changes. Forgetting to run npm install after pulling new dependencies is one of the most common "it works on my machine" problems.

References

Powered by Contentful