Git Hooks: Automating Your Development Workflow

Shunku

Introduction

Git hooks are scripts that run automatically at certain points in the Git workflow. They let you enforce code quality, validate commits, and automate repetitive tasks before code ever reaches your repository.

This article shows you how to use Git hooks effectively in your projects.

How Git Hooks Work

flowchart LR
    subgraph Commit["git commit"]
        A["pre-commit"] --> B["prepare-commit-msg"]
        B --> C["commit-msg"]
        C --> D["post-commit"]
    end

    subgraph Push["git push"]
        E["pre-push"] --> F["Push to remote"]
    end

    Commit --> Push

    style A fill:#f59e0b,color:#fff
    style C fill:#f59e0b,color:#fff
    style E fill:#f59e0b,color:#fff

Available Hooks

Client-Side Hooks

Hook Trigger Common Use
pre-commit Before commit is created Lint, format, tests
prepare-commit-msg Before editor opens Template messages
commit-msg After message is entered Validate format
post-commit After commit is created Notifications
pre-push Before push to remote Full test suite
pre-rebase Before rebase starts Prevent on certain branches

Server-Side Hooks

Hook Trigger Common Use
pre-receive Before accepting push Validate all commits
update Before updating each ref Per-branch policies
post-receive After push is accepted Deploy, notify

Creating Basic Hooks

Hook Location

Hooks live in .git/hooks/:

ls .git/hooks/
# applypatch-msg.sample  pre-push.sample
# commit-msg.sample      pre-rebase.sample
# post-update.sample     prepare-commit-msg.sample
# pre-applypatch.sample  update.sample
# pre-commit.sample

Simple Pre-Commit Hook

#!/bin/sh
# .git/hooks/pre-commit

# Run linting
npm run lint
if [ $? -ne 0 ]; then
    echo "Linting failed. Please fix errors before committing."
    exit 1
fi

# Run tests
npm test
if [ $? -ne 0 ]; then
    echo "Tests failed. Please fix before committing."
    exit 1
fi

exit 0

Make it executable:

chmod +x .git/hooks/pre-commit

Commit Message Validation

#!/bin/sh
# .git/hooks/commit-msg

commit_msg_file=$1
commit_msg=$(cat "$commit_msg_file")

# Check for conventional commit format
pattern="^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .{1,50}"

if ! echo "$commit_msg" | grep -qE "$pattern"; then
    echo "Error: Commit message doesn't follow conventional format."
    echo "Expected: <type>(<scope>): <subject>"
    echo "Example: feat(auth): add login functionality"
    exit 1
fi

exit 0

Using Husky (Recommended)

Husky makes Git hooks easy to manage and share across teams.

Installation

npm install husky --save-dev
npx husky init

This creates a .husky/ directory:

.husky/
β”œβ”€β”€ _/
β”‚   └── husky.sh
└── pre-commit

Configure Pre-Commit Hook

# .husky/pre-commit
npm run lint
npm test

Configure Commit-Msg Hook

npx husky add .husky/commit-msg 'npx commitlint --edit $1'
# .husky/commit-msg
npx commitlint --edit $1

Configure Pre-Push Hook

npx husky add .husky/pre-push 'npm run test:e2e'

Lint-Staged: Run Linters on Staged Files Only

Running linters on all files is slow. lint-staged runs only on staged files.

Installation

npm install lint-staged --save-dev

Configuration

// package.json
{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{css,scss}": [
      "stylelint --fix"
    ],
    "*.{json,md}": [
      "prettier --write"
    ]
  }
}

With Husky

# .husky/pre-commit
npx lint-staged

Commitlint: Enforce Commit Message Convention

Installation

npm install @commitlint/cli @commitlint/config-conventional --save-dev

Configuration

// commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [
      2,
      'always',
      [
        'feat',     // New feature
        'fix',      // Bug fix
        'docs',     // Documentation
        'style',    // Formatting
        'refactor', // Refactoring
        'test',     // Tests
        'chore',    // Maintenance
        'perf',     // Performance
        'ci',       // CI changes
        'build',    // Build changes
        'revert'    // Revert commit
      ]
    ],
    'subject-max-length': [2, 'always', 72],
    'body-max-line-length': [2, 'always', 100]
  }
};

Commit Format

<type>(<scope>): <subject>

<body>

<footer>

Examples:

feat(auth): add Google OAuth login

Implement Google OAuth 2.0 authentication flow.
Users can now sign in with their Google accounts.

Closes #123
fix(api): handle null response from payment gateway

The payment gateway sometimes returns null for declined cards.
Added null check to prevent crashes.

Fixes #456

Complete Setup Example

Package.json

{
  "name": "my-project",
  "scripts": {
    "lint": "eslint src/",
    "format": "prettier --write src/",
    "test": "jest",
    "test:e2e": "cypress run",
    "prepare": "husky"
  },
  "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"
  },
  "lint-staged": {
    "*.{js,ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{json,md,yml}": ["prettier --write"]
  }
}

Husky Hooks

# .husky/pre-commit
npx lint-staged

# .husky/commit-msg
npx commitlint --edit $1

# .husky/pre-push
npm run test

Advanced Hook Examples

Prevent Commits to Protected Branches

#!/bin/sh
# .husky/pre-commit

branch=$(git symbolic-ref --short HEAD)
protected_branches="main master develop"

for protected in $protected_branches; do
    if [ "$branch" = "$protected" ]; then
        echo "Error: Direct commits to $branch are not allowed."
        echo "Please create a feature branch."
        exit 1
    fi
done

npx lint-staged

Check for Secrets

#!/bin/sh
# .husky/pre-commit

# Check for potential secrets
if git diff --cached --name-only | xargs grep -l -E "(api[_-]?key|password|secret|token|private[_-]?key)" 2>/dev/null; then
    echo "Warning: Possible secrets detected in staged files."
    echo "Please review before committing."
    read -p "Continue anyway? (y/n) " answer
    if [ "$answer" != "y" ]; then
        exit 1
    fi
fi

npx lint-staged

Run Different Checks Based on Files

#!/bin/sh
# .husky/pre-commit

# Get list of staged files
staged_files=$(git diff --cached --name-only)

# Check if any TypeScript files changed
if echo "$staged_files" | grep -q '\.tsx\?$'; then
    echo "TypeScript files changed, running type check..."
    npx tsc --noEmit
fi

# Check if any test files changed
if echo "$staged_files" | grep -q '\.test\.[jt]sx\?$'; then
    echo "Test files changed, running tests..."
    npm test
fi

npx lint-staged

Add Issue Number to Commit

#!/bin/sh
# .husky/prepare-commit-msg

commit_msg_file=$1
branch=$(git symbolic-ref --short HEAD)

# Extract issue number from branch name (e.g., feature/PROJ-123-description)
issue=$(echo "$branch" | grep -oE '[A-Z]+-[0-9]+')

if [ -n "$issue" ]; then
    # Prepend issue number to commit message
    sed -i.bak "1s/^/[$issue] /" "$commit_msg_file"
fi

Sharing Hooks Across Team

Problem: .git/hooks Not Tracked

Hooks in .git/hooks/ aren't version controlled.

Solution 1: Husky (Recommended)

Husky stores hooks in .husky/ which is tracked:

.husky/
β”œβ”€β”€ pre-commit
β”œβ”€β”€ commit-msg
└── pre-push

Solution 2: Custom Hooks Directory

# Set custom hooks path
git config core.hooksPath .githooks

# Or in .gitconfig for all repos
[core]
    hooksPath = ~/.git-hooks

Solution 3: npm postinstall

{
  "scripts": {
    "postinstall": "cp -r hooks/* .git/hooks/ && chmod +x .git/hooks/*"
  }
}

Bypassing Hooks

Sometimes you need to skip hooks:

# Skip pre-commit and commit-msg hooks
git commit --no-verify -m "WIP: temporary commit"

# Skip pre-push hook
git push --no-verify

Use sparingly and only when necessary.

Troubleshooting

Hook Not Running

# Check if hook is executable
ls -la .git/hooks/pre-commit

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

Hook Fails Silently

# Add debugging
#!/bin/sh
set -x  # Print commands as they execute
set -e  # Exit on error

Windows Line Endings

# If hook fails on Windows, check line endings
# Should be LF, not CRLF

Summary

Tool Purpose
Git hooks Run scripts at Git events
Husky Manage hooks, share with team
lint-staged Run linters on staged files only
commitlint Enforce commit message format

Git hooks catch issues before they reach your repository, saving time and maintaining code quality.

References

  • O'Reilly - Version Control with Git, Chapter 14
  • Git Documentation - Git Hooks
  • Husky Documentation
  • Conventional Commits Specification