Git Hooks & Automation: Your Custom Workflow Engine

What Are Git Hooks?

Git hooks are automated scripts that run before or after specific Git events (like commit, push, merge). Think of them as customizable guardrails for your Git workflow that can enforce rules, run tests, or trigger other automation.

Simple Analogy: Git hooks are like airport security checkpoints. Before you can board a plane (commit code), you must pass through security (pre-commit hooks). If security finds something prohibited (code errors), you can't board until you fix it.

Git Hook Lifecycle

Edit Files
Make changes to code
pre-commit
Run tests & checks
Commit
Save changes locally
pre-push
Final validation

Hooks (blue stages) run automatically and can prevent actions if checks fail.

Why Git Hooks Matter

Git hooks transform Git from a version control tool into a complete workflow automation system. They help you:

Catch Bugs Early

Run tests before code reaches remote repositories

Enforce Standards

Ensure code style, commit message format, etc.

Automate Tasks

Auto-generate docs, update dependencies, send notifications

Warning: Without Git hooks, teams rely on manual processes that are error-prone, inconsistent, and don't scale. Git hooks provide automated, consistent quality control.

Understanding Hook Types

Git hooks are categorized by when they run in the Git workflow. There are two main categories:

Category When They Run Common Examples Can Block Action?
Client-side Hooks On your local machine pre-commit, pre-push, commit-msg ✅ Yes
Server-side Hooks On the Git server (GitHub, GitLab, etc.) pre-receive, update, post-receive ✅ Yes
Notification Hooks After actions complete post-commit, post-merge, post-checkout ❌ No (notification only)

Tip: Client-side hooks are stored in your local .git/hooks directory. Server-side hooks are stored on the Git server. Notification hooks can't prevent Git actions but are great for logging or triggering other processes.

Finding & Exploring Hooks

1

Discover Built-in Hooks

Every Git repository comes with sample hooks:

# Navigate to hooks directory
cd your-project/.git/hooks

# List available hooks (sample files)
ls -la

# Sample output:
-rwxr-xr-x 1 user staff 478 Dec 1 10:30 applypatch-msg.sample
-rwxr-xr-x 1 user staff 896 Dec 1 10:30 commit-msg.sample
-rwxr-xr-x 1 user staff 189 Dec 1 10:30 post-update.sample
-rwxr-xr-x 1 user staff 424 Dec 1 10:30 pre-applypatch.sample
-rwxr-xr-x 1 user staff 1642 Dec 1 10:30 pre-commit.sample
-rwxr-xr-x 1 user staff 1239 Dec 1 10:30 pre-push.sample
-rwxr-xr-x 1 user staff 4898 Dec 1 10:30 pre-rebase.sample
-rwxr-xr-x 1 user staff 544 Dec 1 10:30 prepare-commit-msg.sample
-rwxr-xr-x 1 user staff 3610 Dec 1 10:30 update.sample

Key insight: These .sample files are templates. To activate a hook, you need to remove the .sample extension and make it executable.

2

Hook File Structure

Understanding hook file requirements:

.git/
hooks/
pre-commit ← Active hook
pre-commit.sample ← Sample (inactive)
commit-msg
post-merge
# Check if a hook is executable
ls -la .git/hooks/pre-commit

# Should show execute permissions:
-rwxr-xr-x 1 user staff 189 Dec 1 10:30 .git/hooks/pre-commit

# If not executable, make it so:
chmod +x .git/hooks/pre-commit

Important: Hook files must be executable (on Unix-like systems) and can be written in any scripting language (Bash, Python, Ruby, Node.js, etc.).

3

Hook Execution Order

Hooks run in a specific sequence during Git operations:

1

During git commit

  1. pre-commit: Before commit is created
  2. prepare-commit-msg: Before commit message editor opens
  3. commit-msg: After message is written, before commit is finalized
  4. post-commit: After commit is complete
2

During git push

  1. pre-push: Before any refs are pushed
  2. (On server) pre-receive: Before updates are accepted
  3. (On server) update: For each ref being updated
  4. (On server) post-receive: After all refs are updated
3

During git merge

  1. pre-merge-commit: Before merge commit is created
  2. prepare-commit-msg: For merge commit message
  3. post-merge: After merge completes

Pro tip: The exit code of a hook determines if the Git operation continues. Exit with 0 for success, non-zero for failure (which stops the operation).

Essential Client-Side Hooks

pre-commit

Runs: Before commit is created

Purpose: Validate code before it's committed

#!/bin/bash
# Simple pre-commit hook
echo "🔍 Running pre-commit checks..."

# Run linter
npm run lint
# If lint fails, exit with error
if [ $? -ne 0 ]; then
  echo "❌ Linting failed"
  exit 1
fi

# Run tests
npm test
if [ $? -ne 0 ]; then
  echo "❌ Tests failed"
  exit 1
fi

echo "✅ All checks passed!"
exit 0

commit-msg

Runs: After message is written, before commit

Purpose: Validate commit message format

#!/bin/bash
# Validate conventional commit format
COMMIT_MSG_FILE=$1
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")

# Check format: type(scope): description
if ! echo "$COMMIT_MSG" | grep -qE "^(feat|fix|docs|style|refactor|test|chore)(\([a-z-]+\))?: .{1,50}$"; then
  echo "❌ Invalid commit message format!"
  echo "Use: type(scope): description"
  echo "Types: feat, fix, docs, style, refactor, test, chore"
  exit 1
fi

echo "✅ Commit message format OK"
exit 0

pre-push

Runs: Before pushing to remote

Purpose: Final validation before sharing code

#!/bin/bash
# pre-push hook - run integration tests
echo "🚀 Running pre-push validation..."

# Get current branch
CURRENT_BRANCH=$(git symbolic-ref --short HEAD)

# Don't run on main branch (CI handles it)
if [ "$CURRENT_BRANCH" = "main" ]; then
  echo "⚠️ Skipping pre-push on main branch"
  exit 0
fi

# Run integration tests
echo "🧪 Running integration tests..."
npm run test:integration

if [ $? -ne 0 ]; then
  echo "❌ Integration tests failed!"
  exit 1
fi

echo "✅ Ready to push!"
exit 0

Interactive Git Hooks Demo

Try It: Create and Test Git Hooks

Simulate creating and running hooks in a virtual repository:

demo-project/
.git/
hooks/
pre-commit ❌ Not created
commit-msg ❌ Not created
post-merge ❌ Not created
Active Hooks: 0
Hook Blocks: 0
Successful Runs: 0

Advanced Hook Techniques

A

Language-Agnostic Hooks

Hooks can be written in any language. Here's how to specify the interpreter:

#!/usr/bin/env bash
# Bash hook (default)
echo "Running bash hook"

#!/usr/bin/env python3
# Python hook
print("Running Python hook")

#!/usr/bin/env node
# Node.js hook
console.log("Running Node.js hook");

#!/usr/bin/env ruby
# Ruby hook
puts "Running Ruby hook"

#!/bin/sh
# POSIX shell (most portable)
echo "Running portable shell hook"

Pro tip: Use #!/usr/bin/env <interpreter> instead of hardcoded paths for better portability across different systems.

B

Conditional Hook Execution

Make hooks smart by running only when needed:

#!/bin/bash
# pre-commit: Only run tests on changed files

# Get staged files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)

# Check if JavaScript files changed
if echo "$STAGED_FILES" | grep -q "\.js$"; then
  echo "🔍 JavaScript files changed, running ESLint..."
  npx eslint $(git diff --cached --name-only --diff-filter=ACM | grep "\.js$")
  if [ $? -ne 0 ]; then exit 1; fi
fi

# Check if CSS files changed
if echo "$STAGED_FILES" | grep -q "\.css$"; then
  echo "🎨 CSS files changed, running stylelint..."
  npx stylelint $(git diff --cached --name-only --diff-filter=ACM | grep "\.css$")
  if [ $? -ne 0 ]; then exit 1; fi
fi

# Skip if only docs changed
if echo "$STAGED_FILES" | grep -vq "\.md$"; then
  echo "✅ No documentation-only changes, checks complete"
  exit 0
fi

Performance benefit: Only run checks on files that actually changed, not the entire codebase.

C

Hook Parameters & Environment

Git passes useful information to hooks:

#!/bin/bash
# Different hooks receive different parameters

# pre-commit: No parameters
# commit-msg: $1 = path to commit message file
COMMIT_MSG_FILE="$1"
# prepare-commit-msg: $1 = msg file, $2 = source, $3 = commit SHA

# pre-push: $1 = remote name, $2 = remote URL
REMOTE_NAME="$1"
REMOTE_URL="$2"

# Environment variables available:
echo "GIT_DIR: $GIT_DIR"
echo "GIT_WORK_TREE: $GIT_WORK_TREE"
echo "GIT_INDEX_FILE: $GIT_INDEX_FILE"

# Get current branch
CURRENT_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null)
echo "Current branch: $CURRENT_BRANCH"

# Get commit author
COMMIT_AUTHOR=$(git config user.name)
echo "Author: $COMMIT_AUTHOR"

Debugging tip: Add set -x at the beginning of your hook to see all commands executed (debug mode).

Production-Ready Hook Examples

Security Scanning Hook

Prevent committing secrets or vulnerable code:

#!/bin/bash
# pre-commit: Security checks

# Check for secrets/API keys
PATTERNS="password|secret|key|token|api[_-]?key|aws[_-]?key"
STAGED_FILES=$(git diff --cached --name-only)

for FILE in $STAGED_FILES; do
  if grep -iE "$PATTERNS" "$FILE" 2>/dev/null | grep -v "//.*test.*key"; then
    echo "❌ Potential secret found in $FILE"
    grep -iE "$PATTERNS" "$FILE"
    exit 1
  fi
done

# Check for debug statements
if git diff --cached | grep -q "console\.log\|debugger\|alert("; then
  echo "❌ Debug statements found in staged code"
  exit 1
fi

echo "✅ Security checks passed"
exit 0

Build Automation Hook

Automatically build and validate before push:

#!/bin/bash
# pre-push: Build and validate

echo "🔨 Building project..."
npm run build

if [ $? -ne 0 ]; then
  echo "❌ Build failed"
  exit 1
fi

# Check bundle size
BUNDLE_SIZE=$(du -sh dist/ | cut -f1)
echo "📦 Bundle size: $BUNDLE_SIZE"

# Warn if bundle is too large
if [[ "$BUNDLE_SIZE" =~ ^[2-9]\.?[0-9]*M ]]; then
  echo "⚠️ Warning: Bundle size exceeds 2MB"
  read -p "Continue with push? (y/n): " -n 1 -r
  echo
  if [[ ! $REPLY =~ ^[Yy]$ ]]; then
    exit 1
  fi
fi

echo "✅ Build validation complete"
exit 0

Auto-Update Hook

Automatically update dependencies after merge:

#!/bin/bash
# post-merge: Update dependencies

# Check if package.json changed
if git diff HEAD~1 HEAD --name-only | grep -q package.json; then
  echo "📦 package.json changed, updating dependencies..."
  npm install

  if [ $? -eq 0 ]; then
    echo "✅ Dependencies updated successfully"
  else
    echo "❌ Dependency update failed"
    exit 1
  fi
fi

# Check for lock file changes
if git diff HEAD~1 HEAD --name-only | grep -q package-lock.json; then
  echo "⚠️ Lock file changed, consider updating"
fi

exit 0

Managing Hooks in Teams

Individual hooks are great, but team-wide consistency is better. Here's how to share hooks:

1

The Problem: Hooks Don't Commit

Git hooks in .git/hooks aren't tracked by Git. This means:

# Try to add hooks to Git
git add .git/hooks/pre-commit
# Output: The following paths are ignored...

# Check .gitignore
cat .gitignore
# Usually contains: .git/

# Result: Hooks stay on your machine only
# Teammates don't get them when they clone

Problem: Without shared hooks, each developer has different checks (or none at all), leading to inconsistent code quality.

2

Solution 1: Template Directory

Use Git's template feature to distribute hooks:

# 1. Create a hooks directory in your project
mkdir -p git-hooks
cp .git/hooks/pre-commit git-hooks/
cp .git/hooks/commit-msg git-hooks/

# 2. Track these hooks in Git
git add git-hooks/
git commit -m "Add shared Git hooks"

# 3. Developers run setup script
# setup-hooks.sh:
#!/bin/bash
cp git-hooks/* .git/hooks/
chmod +x .git/hooks/*
echo "✅ Hooks installed"

# 4. Add to README
# "Run ./setup-hooks.sh after cloning"

Limitation: Relies on developers remembering to run setup script.

3

Solution 2: Husky (Recommended)

Husky is a modern tool for managing Git hooks:

# 1. Install Husky
npm install husky --save-dev

# 2. Enable Git hooks
npx husky init

# 3. Creates .husky/ directory with hooks
.husky/
├── pre-commit
├── commit-msg
└── _/ (helpers)

# 4. Add hooks to package.json
{
  "husky": {
    "hooks": {
      "pre-commit": "npm run lint",
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
  }
}

# 5. Hooks are automatically installed when teammates run npm install

Advantage: Hooks are automatically installed with dependencies. No manual setup required.

4

Solution 3: pre-commit Framework

A comprehensive framework for managing multi-language hooks:

# 1. Install pre-commit
pip install pre-commit

# 2. Create .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.3.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml

  - repo: https://github.com/psf/black
    rev: 22.3.0
    hooks:
      - id: black

  - repo: https://github.com/pre-commit/mirrors-eslint
    rev: v8.15.0
    hooks:
      - id: eslint

# 3. Install hooks
pre-commit install

# 4. Hooks run automatically on commit
# 5. Teammates just run pre-commit install

Best for: Multi-language projects with complex validation needs.

Server-Side Hooks for Teams

Server hooks run on your Git server (GitHub, GitLab, self-hosted) and provide an additional layer of protection.

pre-receive Hook

Runs on server: Before accepting pushed commits

Use case: Enforce organization-wide rules

#!/bin/bash
# GitHub/GitLab server hook
# Reject pushes to protected branches from non-admins

# Protected branches
PROTECTED_BRANCHES="main develop"

# Read refs being pushed
while read oldrev newrev refname; do
  BRANCH=$(echo "$refname" | sed 's|refs/heads/||')

  # Check if pushing to protected branch
  if echo "$PROTECTED_BRANCHES" | grep -q "\b$BRANCH\b"; then
    echo "🚫 Branch $BRANCH is protected"
    
    # Check if user is admin (simplified)
    USER_EMAIL=$(git log -1 --format="%ae" $newrev)
    if [[ ! "$USER_EMAIL" =~ @company\.com$ ]]; then
      echo "❌ Only company admins can push to $BRANCH"
      exit 1
    fi
  fi
done

echo "✅ Push accepted"
exit 0

update Hook

Runs on server: For each ref being updated

Use case: Validate specific branch updates

#!/bin/bash
# update hook: Validate branch-specific rules

# Parameters: refname oldrev newrev
REFNAME="$1"
OLDREV="$2"
NEWREV="$3"

# Extract branch name
BRANCH=$(echo "$REFNAME" | sed 's|refs/heads/||')

# Check commit message format for feature branches
if [[ "$BRANCH" == feature/* ]]; then
  COMMIT_MSG=$(git log --format=%B -n 1 $NEWREV)
  if ! echo "$COMMIT_MSG" | grep -q "^feat:"; then
    echo "❌ Feature branch commits must start with 'feat:'"
    exit 1
  fi
fi

# Check for JIRA ticket in commit message
if [[ "$BRANCH" =~ ^(feature|bugfix)/.* ]]; then
  if ! echo "$COMMIT_MSG" | grep -q "JIRA-[0-9]\+"; then
    echo "❌ Please include JIRA ticket number"
    exit 1
  fi
fi

echo "✅ Branch $BRANCH update accepted"
exit 0

Server Hook Limitations: GitHub and GitLab have limited server hook support through webhooks and protected branch rules. For full server hooks, you need self-hosted Git solutions like GitLab CE/EE, Gitea, or Gogs.

Troubleshooting Common Hook Issues

Debugging Hook Problems

Hook not running?
Check: 1) File exists without .sample extension, 2) File is executable (chmod +x), 3) No syntax errors in script
Hook running but not blocking?
Ensure hook exits with non-zero code (exit 1) on failure. Exit code 0 = success, anything else = failure.
Hook too slow?
Only run checks on changed files, not entire codebase. Use tools like lint-staged.
Need to skip a hook?
Use git commit --no-verify or git push --no-verify to bypass hooks (use sparingly!).
Debugging hook execution?
Add set -x at the top of your hook script to see each command as it executes.
# Quick debugging script
#!/bin/bash
set -x # Enable debug mode

echo "🔍 Debugging hook..."
echo "Arguments: $@"
echo "Environment variables:"
env | grep GIT_

# Rest of your hook logic...

set +x # Disable debug mode

Best Practices Summary

Hook Design Principles

Fast Feedback
Hooks should run quickly (<5 seconds). Developers hate waiting.
Clear Error Messages
Tell developers exactly what's wrong and how to fix it.
Idempotent Operations
Running a hook multiple times should have the same effect.
Share with Team
Use Husky or pre-commit so everyone has the same hooks.

When to Use Which Hook

pre-commit
Code style, linting, simple tests. Fast checks only.
commit-msg
Commit message format validation.
pre-push
Integration tests, build validation, slower checks.
post-merge / post-checkout
Dependency updates, environment setup, notifications.

Frequently Asked Questions

How do I share Git hooks with my team?

Sharing hooks is essential for team consistency. Here are the best approaches:

Option 1: Husky (Recommended for Node.js projects)

# 1. Install Husky
npm install husky --save-dev

# 2. Initialize
npx husky init

# 3. Add hooks to .husky/ directory
npx husky add .husky/pre-commit "npm run lint"
npx husky add .husky/commit-msg 'npx --no-install commitlint --edit "$1"'

# 4. Hooks are automatically installed when teammates run npm install
# package.json includes:
"scripts": {
  "prepare": "husky install"
}

Option 2: pre-commit Framework (Multi-language)

# 1. Create .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.3.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-added-large-files

  - repo: local
    hooks:
      - id: eslint
        name: ESLint
        entry: npm run lint
        language: system
        files: \.(js|ts|jsx|tsx)$

# 2. Teammates install with:
pip install pre-commit
pre-commit install

Option 3: Custom Script with Git Template

# 1. Create project hooks directory
mkdir -p .githooks
cp .git/hooks/pre-commit .githooks/

# 2. Create setup script
#!/bin/bash
# setup-hooks.sh
echo "Installing Git hooks..."
cp .githooks/* .git/hooks/
chmod +x .git/hooks/*
echo "✅ Hooks installed"

# 3. Configure Git to use template directory
git config core.hooksPath .githooks

# 4. Add to README
# "After cloning, run: git config core.hooksPath .githooks"

Option 4: Git Template Directory (Global Sharing)

# 1. Create global template directory
mkdir -p ~/.git-templates/hooks

# 2. Add hooks
cat > ~/.git-templates/hooks/pre-commit << 'EOF'
#!/bin/bash
echo "Running global pre-commit hook"
EOF

# 3. Make executable
chmod +x ~/.git-templates/hooks/pre-commit

# 4. Configure Git globally
git config --global init.templatedir ~/.git-templates

# 5. New repositories get these hooks automatically
# Existing repos: git init (re-initializes hooks)

Recommendation: For most teams, use Husky (Node.js projects) or pre-commit framework (multi-language projects). These tools handle the complexity of hook sharing and installation automatically.

How can I temporarily bypass or disable a Git hook?

Sometimes you need to bypass hooks for emergency fixes or debugging. Here are safe ways to do it:

Method 1: Git's Built-in Bypass Flags

# Bypass pre-commit and commit-msg hooks
git commit --no-verify -m "Emergency fix"

# Bypass pre-push hook
git push --no-verify origin main

# Bypass ALL hooks for a command
git -c core.hooksPath=/dev/null commit -m "No hooks"

# Alternative: Set empty hooks directory
git --git-dir=.git --work-tree=. commit --allow-empty -m "Bypass"

Important: Only bypass hooks when absolutely necessary, and document why. Bypassing hooks can let bad code through.

Method 2: Environment Variables (For Hook Designers)

# Design hooks to respect skip flags
#!/bin/bash
# pre-commit hook with skip option

# Check for skip flag
if [ -n "$SKIP_HOOKS" ]; then
  echo "⚠️ Skipping hooks (SKIP_HOOKS is set)"
  exit 0
fi

# Or check for specific hook skip
if [ -n "$SKIP_PRE_COMMIT" ]; then
  echo "⚠️ Skipping pre-commit hook"
  exit 0
fi

# Use:
SKIP_PRE_COMMIT=1 git commit -m "Skip pre-commit"
# OR
SKIP_HOOKS=1 git commit -m "Skip all hooks"

Method 3: Conditional Execution in Hooks

#!/bin/bash
# Smart hook that knows when to skip

# Skip on main branch emergency fixes
BRANCH=$(git symbolic-ref --short HEAD)
COMMIT_MSG=$(git log -1 --pretty=%B)

if [[ "$BRANCH" == "main" ]] && [[ "$COMMIT_MSG" == *"[EMERGENCY]"* ]]; then
  echo "🚨 Emergency commit on main - skipping hooks"
  exit 0
fi

# Skip for WIP commits
if [[ "$COMMIT_MSG" == "WIP"* ]] || [[ "$COMMIT_MSG" == *"wip"* ]]; then
  echo "⏸️ WIP commit - skipping hooks"
  exit 0
fi

# Skip for version tag commits
if [[ "$COMMIT_MSG" == *"chore(release):"* ]]; then
  echo "🏷️ Release commit - skipping hooks"
  exit 0
fi

Method 4: Disable Hooks Completely

# Temporarily rename hook file
mv .git/hooks/pre-commit .git/hooks/pre-commit.disabled

# Do your work...
git commit -m "Fix something"

# Re-enable
mv .git/hooks/pre-commit.disabled .git/hooks/pre-commit

# With Husky:
npx husky uninstall # Disable all hooks
npx husky install # Re-enable

Method 5: Create a Bypass Alias

# Add to ~/.gitconfig
[alias]
  commit-nv = commit --no-verify
  push-nv = push --no-verify
  wip = !git add -A && git commit -m \"WIP\" --no-verify

# Usage:
git commit-nv -m "Emergency fix"
git push-nv origin main
git wip # Save work in progress

Best Practice: Design hooks to be bypassable when needed, but track bypass usage. Consider adding a log entry or requiring a specific commit message format when hooks are skipped.

What are the performance implications of complex hooks?

Slow hooks frustrate developers and reduce productivity. Here's how to optimize hook performance:

The Problem: Hook Latency Impact

# Bad: Running everything on every commit
#!/bin/bash
echo "Running all tests..."
npm run lint # 3 seconds
npm test # 30 seconds
npm run test:e2e # 2 minutes
npm run build # 1 minute
# Total: ~3.5 minutes per commit! ❌

Solution 1: Staged Checks with lint-staged

# 1. Install lint-staged
npm install lint-staged --save-dev

# 2. Configure in package.json
{
  "lint-staged": {
    "*.js": "eslint --fix",
    "*.css": "stylelint --fix",
    "*.{json,md}": "prettier --write"
  }
}

# 3. Update pre-commit hook
#!/bin/bash
npx lint-staged

# Result: Only runs on changed files, not entire codebase
# Performance: 500ms vs 30 seconds

Solution 2: Tiered Hook Strategy

# Split checks by speed and importance
# .husky/pre-commit (FAST: < 3 seconds)
#!/bin/bash
npx lint-staged # Style & formatting
npm run type-check # TypeScript checks
npm run test:unit # Unit tests only

# .husky/pre-push (SLOWER: < 2 minutes)
#!/bin/bash
npm run test:integration # Integration tests
npm run build # Build verification
npm run audit # Security audit

# .husky/post-merge (BACKGROUND: Async)
#!/bin/bash
npm run update:deps & # Async dependency updates
npm run generate:docs & # Async documentation

Solution 3: Caching and Incremental Checks

# Use tools with built-in caching
# ESLint with cache
npx eslint --cache --fix "**/*.js"

# Jest with cache
npx jest --changedSince=origin/main --passWithNoTests

# TypeScript incremental compilation
npx tsc --incremental

# Custom caching example
#!/bin/bash
CACHE_DIR=".hook-cache"
mkdir -p "$CACHE_DIR"

# Generate cache key from staged files
CACHE_KEY=$(git diff --cached --name-only | sort | md5sum | cut -d' ' -f1)
CACHE_FILE="$CACHE_DIR/$CACHE_KEY"

# Use cache if available
if [ -f "$CACHE_FILE" ]; then
  echo "✅ Using cached result"
  exit 0
fi

# Run expensive check
npm run expensive-check

# Save to cache
touch "$CACHE_FILE"

Solution 4: Parallel Execution

# Run independent checks in parallel
#!/bin/bash
# pre-commit with parallel checks

# Define checks to run in parallel
run_lint() {
  npx eslint --quiet .
  return $?
}

run_tests() {
  npx jest --changedSince=origin/main --passWithNoTests
  return $?
}

run_type_check() {
  npx tsc --noEmit
  return $?
}

# Run all in parallel
run_lint &
LINT_PID=$!
run_tests &
TESTS_PID=$!
run_type_check &
TYPE_PID=$!

# Wait for all and collect results
wait $LINT_PID && LINT_RESULT=$? || LINT_RESULT=$?
wait $TESTS_PID && TEST_RESULT=$? || TEST_RESULT=$?
wait $TYPE_PID && TYPE_RESULT=$? || TYPE_RESULT=$?

# Check results
if [ $LINT_RESULT -ne 0 ] || [ $TEST_RESULT -ne 0 ] || [ $TYPE_RESULT -ne 0 ]; then
  exit 1
fi

Performance Monitoring

# Add timing to hooks
#!/bin/bash
start_time=$(date +%s)

# Your hook logic here...
npx lint-staged

end_time=$(date +%s)
execution_time=$((end_time - start_time))

# Log performance
echo "⏱️ Hook executed in ${execution_time}s"

# Warn if too slow
if [ $execution_time -gt 10 ]; then
  echo "⚠️ Hook is slow (>10s), consider optimizing"
  echo "Consider: lint-staged, caching, or moving to pre-push"
fi

Performance Guidelines:

  • pre-commit: Should complete in < 5 seconds
  • pre-push: Can take 1-2 minutes (developers push less often)
  • post- hooks: Can run async in background
  • Rule of thumb: If a check takes > 10 seconds, move it to pre-push or run it async
How do I handle hooks in CI/CD pipelines?

CI/CD pipelines need special consideration for hooks. You want consistency without duplication or conflicts.

The CI/CD Challenge

Hooks run locally during development, but CI runs in isolated environments. You need to ensure:

  1. CI runs the same checks as local hooks
  2. No duplicate work (checks running twice)
  3. CI can run hooks even if local hooks are skipped
  4. Hook failures in CI provide clear feedback

Solution 1: Separate CI Scripts

# package.json scripts
{
  "scripts": {
    # Local development hooks
    "precommit": "lint-staged",
    "prepush": "npm run test:integration",

    # CI pipeline scripts (run everything)
    "ci:validate": "npm run lint && npm test && npm run build",
    "ci:security": "npm audit && npm run check:dependencies",

    # Combined CI pipeline
    "ci": "npm run ci:validate && npm run ci:security"
  }
}

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
      - run: npm ci
      - run: npm run ci # Runs all CI checks

Solution 2: Environment-Aware Hooks

# Hooks that behave differently in CI
#!/bin/bash
# pre-commit hook

# Check if running in CI
if [ -n "$CI" ]; then
  echo "🔧 CI environment detected"
  echo "Running extended checks..."

  # Run everything (CI has more resources)
  npm run lint
  npm test
  npm run build
  npm audit

  if [ $? -ne 0 ]; then
    exit 1
  fi

  exit 0
fi

# Local development: fast checks only
npx lint-staged
npm run test:unit -- --changedSince=origin/main

exit $?

Solution 3: Disable Hooks in CI

# Configure CI to skip hooks (they'll run in CI scripts instead)
# GitHub Actions example
name: CI
env:
  HUSKY: 0 # Disable Husky hooks
  SKIP_HOOKS: 1 # Disable custom hooks
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        env:
          HUSKY: 0 # Husky won't install hooks
      - uses: actions/checkout@v3

# GitLab CI example
variables:
  HUSKY: "0"

test:
  script:
    - npm ci
    - npm run ci:validate # Run CI checks directly

Solution 4: Reusable CI Configuration

# Share hook logic between local and CI
# scripts/validate.sh (used by both)
#!/bin/bash
# Common validation script

echo "🔍 Running validation..."

# Language checks
npx eslint .
npx stylelint "**/*.css"

# Tests
npm test

# Build
npm run build

# Security
npm audit --audit-level=moderate

# pre-commit hook (runs fast version)
#!/bin/bash
./scripts/validate.sh --fast

# CI script (runs full version)
#!/bin/bash
./scripts/validate.sh --full

Solution 5: Hook Testing in CI

# Test hooks themselves in CI
# .github/workflows/test-hooks.yml
name: Test Hooks
on:
push:
paths:
- '.husky/**'
- '.pre-commit-config.yaml'
- 'scripts/*'

jobs:
test-hooks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3

- name: Test pre-commit hook
run: |
# Simulate commit with bad code
echo "console.log('bad')" > test.js
git add test.js

# Should fail
if git commit -m "test: bad commit" 2>/dev/null; then
echo "❌ Hook didn't catch error"
exit 1
fi

echo "✅ Hook correctly rejected bad commit"

- name: Test commit-msg hook
run: |
# Try bad commit message
if git commit --allow-empty -m "bad message" 2>/dev/null; then
echo "❌ commit-msg hook failed"
exit 1
fi

# Try good commit message
if ! git commit --allow-empty -m "feat: good message" 2>/dev/null; then
echo "❌ commit-msg hook failed on valid message"
exit 1
fi

echo "✅ commit-msg hook works correctly"

CI/CD Best Practices:

  • Run hooks locally first: CI should be a backup, not the primary quality gate
  • Consistency is key: Same checks should run locally and in CI (maybe with different thoroughness)
  • Fast feedback: Fail fast in CI - run quick checks first, slow checks later
  • Document failures: CI hook failures should explain how to fix the issue
  • Test your hooks: Include hook tests in your CI pipeline
What are creative/advanced uses of Git hooks beyond linting?

Git hooks can do much more than just code validation. Here are creative and advanced uses:

1. Automated Documentation Generation

# post-commit: Generate API documentation
#!/bin/bash
echo "📚 Generating documentation..."

# Check if source files changed
if git diff HEAD~1 HEAD --name-only | grep -q "\.(js|ts|py|rb)$"; then
  echo "Source files changed, updating docs..."

  # Generate TypeScript docs
  npx typedoc --out docs/api src/

  # Generate CHANGELOG
  npx conventional-changelog -p angular -i CHANGELOG.md -s

  # Commit generated docs
  git add docs/ CHANGELOG.md
  git commit --amend --no-edit 2>/dev/null || true

  echo "✅ Documentation updated"
fi

2. Intelligent Branch Management

# post-checkout: Update branch-specific dependencies
#!/bin/bash
PREV_HEAD=$1
NEW_HEAD=$2
BRANCH_SWITCH=$3

if [ "$BRANCH_SWITCH" = "1" ]; then
  CURRENT_BRANCH=$(git symbolic-ref --short HEAD)

  echo "🌿 Switched to branch: $CURRENT_BRANCH"

  # Check for branch-specific dependencies
  if [ -f "requirements-$CURRENT_BRANCH.txt" ]; then
    echo "Installing branch-specific dependencies..."
    pip install -r "requirements-$CURRENT_BRANCH.txt"
  fi

  # Set branch-specific environment
  if [ -f ".env.$CURRENT_BRANCH" ]; then
    echo "Loading branch environment..."
    cp ".env.$CURRENT_BRANCH" .env
  fi
fi

3. Smart Commit Message Enhancement

# prepare-commit-msg: Add context to commit messages
#!/bin/bash
COMMIT_MSG_FILE=$1
COMMIT_SOURCE=$2
SHA1=$3

# Only for non-merge commits
if [ "$COMMIT_SOURCE" != "message" ] && [ "$COMMIT_SOURCE" != "merge" ]; then
  # Get current branch
  BRANCH=$(git symbolic-ref --short HEAD)

  # Extract JIRA ticket from branch name
  if [[ "$BRANCH" =~ ([A-Z]+-[0-9]+) ]]; then
    JIRA_TICKET="${BASH_REMATCH[1]}"
    echo "Found JIRA ticket: $JIRA_TICKET"

    # Prepend to commit message
    COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
    echo "[$JIRA_TICKET] $COMMIT_MSG" > "$COMMIT_MSG_FILE"
  fi

  # Add co-authors for pair programming
  if [ -f ".pair" ]; then
    echo "" >> "$COMMIT_MSG_FILE"
    echo "Co-authored-by:" >> "$COMMIT_MSG_FILE"
    while IFS= read -r PAIR; do
      echo " $PAIR" >> "$COMMIT_MSG_FILE"
    done < ".pair"
  fi
fi

4. Automated Dependency Management

# post-merge: Smart dependency updates
#!/bin/bash
echo "🔄 Checking for dependency updates..."

# Check if package.json changed
if git diff HEAD~1 HEAD --name-only | grep -q package.json; then
  echo "📦 package.json changed, checking for updates..."

  # Check for outdated dependencies
  OUTDATED=$(npm outdated --json || echo "{}")

  if [ "$OUTDATED" != "{}" ]; then
    echo "📈 Outdated dependencies found:"
    echo "$OUTDATED" | jq -r 'to_entries[] | " \(.key): \(.value.current) -> \(.value.latest)"'

    # Ask to update
    read -p "Update dependencies? (y/n): " -n 1 -r
    echo
    if [[ $REPLY =~ ^[Yy]$ ]]; then
      npm update
      echo "✅ Dependencies updated"
    fi
  fi
fi

5. Code Quality Metrics Collection

# post-commit: Collect code metrics
#!/bin/bash
echo "📊 Collecting code metrics..."

# Get commit hash
COMMIT_HASH=$(git rev-parse HEAD)

# Calculate complexity metrics
COMPLEXITY=$(npx complexity-report --format=json . | jq '.total.cyclomatic')
LINES=$(git diff --stat HEAD~1 HEAD | tail -1 | awk '{print $4}')
FILES_CHANGED=$(git diff --name-only HEAD~1 HEAD | wc -l)

# Store metrics
echo "{\"commit\":\"$COMMIT_HASH\",\"complexity\":$COMPLEXITY,\"lines\":$LINES,\"files\":$FILES_CHANGED}" >> metrics.json

# Check for complexity increase
PREV_COMPLEXITY=$(tail -2 metrics.json | head -1 | jq '.complexity')
if [ "$COMPLEXITY" -gt "$((PREV_COMPLEXITY * 120 / 100))" ]; then
  echo "⚠️ Warning: Complexity increased by >20%"
  echo " Consider refactoring complex code"
fi

echo "✅ Metrics collected"

6. Integration with External Systems

# post-commit: Notify external systems
#!/bin/bash
echo "🔗 Notifying external systems..."

# Get commit info
COMMIT_HASH=$(git rev-parse HEAD)
COMMIT_MSG=$(git log -1 --pretty=%B)
BRANCH=$(git symbolic-ref --short HEAD)
AUTHOR=$(git log -1 --pretty=%an)

# Send to Slack
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"🚀 New commit on $BRANCH by $AUTHOR: $COMMIT_MSG\"}" \
$SLACK_WEBHOOK_URL

# Update JIRA
if [[ "$COMMIT_MSG" =~ ([A-Z]+-[0-9]+) ]]; then
  JIRA_TICKET="${BASH_REMATCH[1]}"
  echo "Updating JIRA ticket $JIRA_TICKET..."
  # API call to JIRA
fi

# Trigger CI/CD (if not already)
if [[ "$BRANCH" == "main" ]] || [[ "$BRANCH" == "develop" ]]; then
  echo "Triggering deployment..."
  curl -X POST $DEPLOY_WEBHOOK
fi

echo "✅ Notifications sent"

7. Learning and Onboarding Assistant

# pre-commit: Educational hints for new developers
#!/bin/bash
# Check if developer is new (first 30 days)
FIRST_COMMIT_DATE=$(git log --reverse --format="%ad" --date=short | head -1)
DAYS_SINCE_FIRST=$(( ($(date +%s) - $(date -d "$FIRST_COMMIT_DATE" +%s)) / 86400 ))

if [ "$DAYS_SINCE_FIRST" -lt 30 ]; then
  echo "👋 Welcome new developer! Here are some tips:"

  # Check for common newbie patterns
  if git diff --cached | grep -q "console\.log"; then
    echo "💡 Tip: Remove console.log before committing"
    echo " Use debugger or logging library instead"
  fi

  if git diff --cached --stat | tail -1 | awk '{print $4}' -gt 200; then
    echo "💡 Tip: Consider smaller commits (< 200 lines)"
    echo " Smaller commits are easier to review"
  fi

  # Suggest helpful resources
  echo "📚 Helpful resources:"
  echo " - CONTRIBUTING.md"
  echo " - docs/code-style.md"
  echo " - Ask in #help channel"
fi

# Continue with normal checks
npx lint-staged

Creative Hook Ideas:

  • Auto-tagging: Create Git tags based on commit messages
  • Translation sync: Update translation files when source strings change
  • Asset optimization: Compress images before committing
  • Database migrations: Generate migration files from model changes
  • Code generation: Auto-generate boilerplate code
  • Accessibility checks: Validate HTML for accessibility issues
  • License compliance: Check for problematic licenses in dependencies

Remember: The most creative hooks solve real workflow problems. Start with pain points in your development process and design hooks to address them.

Previous: Collaboration Best Practices Next: Git Worktrees