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
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
Discover Built-in Hooks
Every Git repository comes with sample hooks:
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.
Hook File Structure
Understanding hook file requirements:
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.).
Hook Execution Order
Hooks run in a specific sequence during Git operations:
During git commit
- pre-commit: Before commit is created
- prepare-commit-msg: Before commit message editor opens
- commit-msg: After message is written, before commit is finalized
- post-commit: After commit is complete
During git push
- pre-push: Before any refs are pushed
- (On server) pre-receive: Before updates are accepted
- (On server) update: For each ref being updated
- (On server) post-receive: After all refs are updated
During git merge
- pre-merge-commit: Before merge commit is created
- prepare-commit-msg: For merge commit message
- 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
# 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
# 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
# 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:
Advanced Hook Techniques
Language-Agnostic Hooks
Hooks can be written in any language. Here's how to specify the interpreter:
# 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.
Conditional Hook Execution
Make hooks smart by running only when needed:
# 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.
Hook Parameters & Environment
Git passes useful information to hooks:
# 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:
# 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:
# 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:
# 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:
The Problem: Hooks Don't Commit
Git hooks in .git/hooks aren't tracked by Git. This means:
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.
Solution 1: Template Directory
Use Git's template feature to distribute hooks:
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.
Solution 2: Husky (Recommended)
Husky is a modern tool for managing Git hooks:
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.
Solution 3: pre-commit Framework
A comprehensive framework for managing multi-language hooks:
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
# 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
# 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
Check: 1) File exists without .sample extension, 2) File is executable (
chmod +x), 3) No syntax errors in script
Ensure hook exits with non-zero code (
exit 1) on failure. Exit code 0 = success, anything else = failure.
Only run checks on changed files, not entire codebase. Use tools like
lint-staged.
Use
git commit --no-verify or git push --no-verify to bypass hooks (use sparingly!).
Add
set -x at the top of your hook script to see each command as it executes.
#!/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
Hooks should run quickly (<5 seconds). Developers hate waiting.
Tell developers exactly what's wrong and how to fix it.
Running a hook multiple times should have the same effect.
Use Husky or pre-commit so everyone has the same hooks.
When to Use Which Hook
Code style, linting, simple tests. Fast checks only.
Commit message format validation.
Integration tests, build validation, slower checks.
Dependency updates, environment setup, notifications.
Frequently Asked Questions
Sharing hooks is essential for team consistency. Here are the best approaches:
Option 1: Husky (Recommended for Node.js projects)
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)
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
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)
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.
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
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)
#!/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
# 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
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
[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.
Slow hooks frustrate developers and reduce productivity. Here's how to optimize hook performance:
The Problem: Hook Latency Impact
#!/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
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
# .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
# 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
#!/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
#!/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
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:
- CI runs the same checks as local hooks
- No duplicate work (checks running twice)
- CI can run hooks even if local hooks are skipped
- Hook failures in CI provide clear feedback
Solution 1: Separate CI 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
#!/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
# 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
# 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
# .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
Git hooks can do much more than just code validation. Here are creative and advanced uses:
1. Automated Documentation Generation
#!/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
#!/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
#!/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
#!/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
#!/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
#!/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
#!/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.