Git & GitHub

Git Hooks

13 min Lesson 29 of 35

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:

  1. Check for PHP syntax errors in staged files
  2. Prevent committing files with dd() or dump() (Laravel debug functions)
  3. 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!