Git Hooks
Git hooks are scripts that automatically run before or after specific Git events like commits, pushes, and merges. They allow you to automate quality checks, enforce coding standards, and prevent common mistakes. In this lesson, you'll learn how to leverage hooks to improve your development workflow.
What Are Git Hooks?
Git hooks are custom scripts stored in the .git/hooks directory of your repository. They trigger at specific points in the Git workflow, allowing you to automate tasks and enforce policies.
Key Concept: Hooks run locally on your machine or on the server, enabling both client-side and server-side automation.
Types of Git Hooks
Client-Side Hooks (Run on Developer's Machine):
pre-commit
- Runs before commit is created
- Use for: Linting, formatting, test execution
- Exit code 0 = continue, non-zero = abort commit
prepare-commit-msg
- Runs before commit message editor opens
- Use for: Auto-generating commit message templates
commit-msg
- Runs after commit message is entered
- Use for: Validating commit message format
post-commit
- Runs after commit is completed
- Use for: Notifications, triggering CI builds
pre-push
- Runs before push to remote
- Use for: Running full test suite, preventing bad pushes
post-merge
- Runs after successful merge
- Use for: Dependency updates, database migrations
pre-rebase
- Runs before rebase
- Use for: Preventing rebase on protected branches
Server-Side Hooks (Run on Git Server):
pre-receive
- Runs when server receives push
- Use for: Enforcing project policies
update
- Runs for each branch being updated
- Use for: Branch-specific policies
post-receive
- Runs after push is accepted
- Use for: Deployment, notifications, CI/CD triggers
Creating Your First Hook
Let's create a simple pre-commit hook that prevents commits with console.log statements:
# Navigate to hooks directory
cd .git/hooks
# Create pre-commit hook
touch pre-commit
chmod +x pre-commit
# Edit pre-commit file
#!/bin/bash
# Check for console.log in staged JavaScript files
if git diff --cached --name-only | grep '\.js$' | xargs grep -n 'console\.log'; then
echo "Error: Found console.log in staged files"
echo "Please remove console.log statements before committing"
exit 1
fi
echo "Pre-commit checks passed"
exit 0
Pro Tip: Make hooks executable with chmod +x hook-name. Without execute permission, hooks won't run!
Pre-Commit Hook: Code Quality Checks
A comprehensive pre-commit hook for PHP projects:
#!/bin/bash
# .git/hooks/pre-commit
echo "Running pre-commit checks..."
# Get list of staged PHP files
FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.php$')
if [ -z "$FILES" ]; then
echo "No PHP files to check"
exit 0
fi
# Check PHP syntax errors
echo "Checking PHP syntax..."
for FILE in $FILES; do
php -l "$FILE"
if [ $? -ne 0 ]; then
echo "❌ Syntax error in $FILE"
exit 1
fi
done
# Run PHP CodeSniffer
if [ -f ./vendor/bin/phpcs ]; then
echo "Running PHP CodeSniffer..."
./vendor/bin/phpcs --standard=PSR12 $FILES
if [ $? -ne 0 ]; then
echo "❌ Code style violations found"
echo "Run: ./vendor/bin/phpcbf to auto-fix"
exit 1
fi
fi
# Run PHPStan
if [ -f ./vendor/bin/phpstan ]; then
echo "Running PHPStan..."
./vendor/bin/phpstan analyse $FILES --level=5
if [ $? -ne 0 ]; then
echo "❌ Static analysis errors found"
exit 1
fi
fi
echo "✅ All pre-commit checks passed"
exit 0
Commit-Msg Hook: Enforce Commit Message Format
Enforce Conventional Commits format:
#!/bin/bash
# .git/hooks/commit-msg
COMMIT_MSG_FILE=$1
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
# Conventional Commits pattern: type(scope): description
# Example: feat(auth): add login validation
PATTERN="^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .{10,}$"
if ! echo "$COMMIT_MSG" | grep -qE "$PATTERN"; then
echo "❌ Invalid commit message format"
echo ""
echo "Commit message must follow Conventional Commits:"
echo " type(scope): description"
echo ""
echo "Types: feat, fix, docs, style, refactor, test, chore"
echo "Example: feat(auth): add two-factor authentication"
echo ""
echo "Your message: $COMMIT_MSG"
exit 1
fi
# Check message length (at least 10 characters in description)
if [ ${#COMMIT_MSG} -lt 15 ]; then
echo "❌ Commit message too short"
echo "Description must be at least 10 characters"
exit 1
fi
echo "✅ Commit message format valid"
exit 0
Conventional Commits Types:
- feat: New feature
- fix: Bug fix
- docs: Documentation changes
- style: Formatting, missing semicolons, etc.
- refactor: Code restructuring
- test: Adding or updating tests
- chore: Maintenance tasks
Pre-Push Hook: Run Full Test Suite
Ensure all tests pass before pushing:
#!/bin/bash
# .git/hooks/pre-push
echo "Running pre-push checks..."
# Run full test suite
echo "Running tests..."
php artisan test
if [ $? -ne 0 ]; then
echo "❌ Tests failed! Push aborted"
echo "Fix failing tests before pushing"
exit 1
fi
# Check for TODO or FIXME in staged files
echo "Checking for TODO/FIXME comments..."
if git diff origin/main...HEAD --name-only | xargs grep -n "TODO\|FIXME"; then
echo "⚠️ Warning: Found TODO or FIXME comments"
read -p "Continue push anyway? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Push aborted"
exit 1
fi
fi
echo "✅ All pre-push checks passed"
exit 0
Post-Merge Hook: Dependency Management
Automatically update dependencies after merging:
#!/bin/bash
# .git/hooks/post-merge
echo "Post-merge hook running..."
# Check if composer.lock changed
if git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD | grep --quiet "composer.lock"; then
echo "composer.lock changed - running composer install"
composer install
fi
# Check if package-lock.json changed
if git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD | grep --quiet "package-lock.json"; then
echo "package-lock.json changed - running npm install"
npm install
fi
# Check for new migrations
if git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD | grep --quiet "database/migrations"; then
echo "New migrations detected"
echo "Run: php artisan migrate"
fi
exit 0
Important: Hooks in .git/hooks are NOT tracked by Git. You need a strategy to share hooks with your team (see below).
Sharing Hooks with Your Team
Since .git/hooks is not tracked, use these strategies:
Method 1: Manual Installation Script
# Create hooks directory in your repository
mkdir -p .githooks
# Store your hooks there
.githooks/pre-commit
.githooks/commit-msg
# Create installation script
# install-hooks.sh
#!/bin/bash
cp .githooks/* .git/hooks/
chmod +x .git/hooks/*
echo "Hooks installed successfully"
# Team members run:
./install-hooks.sh
Method 2: Git Config (Git 2.9+)
# Set hooks directory in git config
git config core.hooksPath .githooks
# Now Git will use .githooks instead of .git/hooks
# Commit .githooks to repository
# All team members automatically use shared hooks
Method 3: Use Husky (Node.js Projects)
# Install Husky
npm install --save-dev husky
# Initialize Husky
npx husky init
# Add hooks via Husky
npx husky add .husky/pre-commit "npm test"
# Husky hooks are tracked in .husky/ directory
Using Husky for Hook Management
Husky is the most popular hook management tool for Node.js projects:
Installation:
# Install Husky
npm install --save-dev husky
npx husky init
Add Pre-Commit Hook:
npx husky add .husky/pre-commit "npm run lint"
npx husky add .husky/pre-commit "npm test"
Add Commit-Msg Hook:
npx husky add .husky/commit-msg "npx commitlint --edit $1"
package.json Configuration:
{
"scripts": {
"prepare": "husky install",
"lint": "eslint .",
"test": "jest"
},
"devDependencies": {
"husky": "^8.0.0",
"@commitlint/cli": "^17.0.0",
"@commitlint/config-conventional": "^17.0.0"
}
}
.husky/pre-commit:
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run lint
npm test
Best Practice: For Laravel/PHP projects, consider using Captain Hook or GrumPHP as alternatives to Husky.
PHP Hook Management: GrumPHP
GrumPHP is a powerful hook manager for PHP projects:
Installation:
composer require --dev phpro/grumphp
Configuration (grumphp.yml):
grumphp:
tasks:
phplint:
triggered_by: [php]
phpcs:
standard: PSR12
triggered_by: [php]
phpstan:
level: 5
triggered_by: [php]
phpunit:
always_execute: false
git_hook_variables:
EXEC_GRUMPHP_COMMAND: 'php'
Usage:
# GrumPHP automatically installs hooks
# Runs configured tasks on git commit
# Run manually
vendor/bin/grumphp run
# Skip GrumPHP (emergency only!)
git commit --no-verify -m "Emergency fix"
Bypassing Hooks (When Necessary)
Sometimes you need to bypass hooks (use sparingly!):
# Skip pre-commit and commit-msg hooks
git commit --no-verify -m "WIP: Emergency hotfix"
# or
git commit -n -m "WIP: Emergency hotfix"
# Skip pre-push hook
git push --no-verify
When to Bypass:
✓ Emergency production hotfix
✓ Work-in-progress commits in feature branch
✓ Hook is broken and needs fixing
When NOT to Bypass:
✗ Too lazy to fix linting errors
✗ Tests are failing but "it works on my machine"
✗ Commit message doesn't follow format (just fix it!)
Warning: Bypassing hooks should be rare and documented. If you find yourself using --no-verify often, your hooks may be too strict or slow.
Hook Performance Optimization
Make Hooks Fast:
1. Check Only Staged Files
# Good: Only check files being committed
git diff --cached --name-only --diff-filter=ACM
# Bad: Check entire codebase every time
find . -name "*.php"
2. Parallel Execution
# Run linting and tests in parallel
phpcs $FILES &
phpstan analyse $FILES &
wait
3. Cache Results
# Cache PHPStan results
phpstan analyse --cache-dir=.phpstan-cache
4. Skip Slow Tasks in Pre-Commit
# Pre-commit: Fast checks only (lint, syntax)
# Pre-push: Comprehensive checks (full test suite)
5. Early Exit on Failure
# Stop at first failure
command1 || exit 1
command2 || exit 1
Real-World Hook Examples
Prevent Committing Secrets:
#!/bin/bash
# Check for common secret patterns
if git diff --cached | grep -E '(API_KEY|SECRET|PASSWORD|TOKEN).*=.*[^\'\\"]\w{20,}'; then
echo "❌ Potential secret detected in commit"
echo "Never commit API keys or passwords"
exit 1
fi
Auto-Format Code:
#!/bin/bash
# Auto-run PHP CS Fixer on staged files
FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.php$')
if [ ! -z "$FILES" ]; then
./vendor/bin/php-cs-fixer fix $FILES
git add $FILES
fi
Branch Name Validation:
#!/bin/bash
# Enforce branch naming: feature/*, bugfix/*, hotfix/*
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if ! echo "$BRANCH" | grep -qE '^(feature|bugfix|hotfix)\/[a-z0-9-]+$'; then
echo "❌ Invalid branch name: $BRANCH"
echo "Use: feature/name, bugfix/name, or hotfix/name"
exit 1
fi
Ticket Number in Commit:
#!/bin/bash
# Ensure commit message includes ticket number
if ! grep -qE '#[0-9]+|JIRA-[0-9]+' "$1"; then
echo "❌ Commit must reference a ticket (e.g., #123 or JIRA-456)"
exit 1
fi
Practice Exercise:
Create a Pre-Commit Hook
Requirements:
- Check for PHP syntax errors in staged files
- Prevent committing files with
dd() or dump() (Laravel debug functions)
- Ensure all PHP files have proper opening tags
Solution:
#!/bin/bash
# .git/hooks/pre-commit
echo "🔍 Running pre-commit checks..."
FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.php$')
if [ -z "$FILES" ]; then
exit 0
fi
# Check PHP syntax
for FILE in $FILES; do
php -l "$FILE" > /dev/null 2>&1
if [ $? -ne 0 ]; then
echo "❌ Syntax error in $FILE"
php -l "$FILE"
exit 1
fi
done
# Check for debug functions
if echo "$FILES" | xargs grep -n "\(dd\|dump\)("; then
echo "❌ Found dd() or dump() in staged files"
echo "Remove debug statements before committing"
exit 1
fi
# Check for proper PHP opening tags
for FILE in $FILES; do
if ! head -n 1 "$FILE" | grep -q "<?php"; then
echo "❌ Missing PHP opening tag in $FILE"
exit 1
fi
done
echo "✅ All checks passed"
exit 0
Summary
In this lesson, you learned:
- Git hooks are scripts that automate tasks at specific Git events
- Client-side hooks (pre-commit, commit-msg, pre-push) run locally
- Server-side hooks (pre-receive, post-receive) run on Git server
- Hooks enforce code quality, commit message format, and test execution
- Share hooks via
.githooks/ directory or tools like Husky/GrumPHP
- Optimize hook performance by checking only staged files
- Use
--no-verify sparingly to bypass hooks in emergencies
Next Up: In the next lesson, we'll explore advanced Git commands like cherry-pick, reflog, and bisect!