Skip to content
Home
Git Hooks: Automate Your Workflow

Git Hooks: Automate Your Workflow

Git Git 7 min read 1468 words Beginner David Chen

Git hooks are scripts that run automatically before or after Git events — committing, pushing, merging, and receiving pushes. They let you enforce code quality, run tests, check for secrets, and automate repetitive tasks. Hooks are stored in the .git/hooks/ directory of every repository.

How Hooks Work

A hook is an executable script placed in .git/hooks/ with a specific name. Git runs the script when the corresponding event occurs. If the script exits with a non-zero status, Git aborts the operation.

.git/hooks/
  applypatch-msg.sample
  commit-msg.sample
  fsmonitor-watchman.sample
  post-update.sample
  pre-applypatch.sample
  pre-commit.sample
  pre-merge-commit.sample
  pre-push.sample
  pre-rebase.sample
  prepare-commit-msg.sample
  push-to-checkout.sample
  sendemail-validate.sample
  update.sample

The .sample extension prevents them from executing. To activate a hook, remove the .sample extension and make the file executable.

Client-Side Hooks

Client-side hooks run on your local machine. They are not pushed with the repository — each developer must set them up separately.

Pre-Commit Hook

The most popular hook. It runs before a commit message is created. Use it to:

  • Check code formatting (Prettier, Black, gofmt)
  • Run linting (ESLint, Pylint, golangci-lint)
  • Check for large files or secrets
  • Run unit tests for changed files
  • Verify file naming conventions
#!/bin/bash
# .git/hooks/pre-commit

echo "Running linter..."
npm run lint
if [ $? -ne 0 ]; then
    echo "Linting failed. Commit aborted."
    exit 1
fi

echo "Running tests for staged files..."
npx jest --onlyChanged
if [ $? -ne 0 ]; then
    echo "Tests failed. Commit aborted."
    exit 1
fi

Pre-Commit with Staged Files Only

Lint only the files that are being committed:

#!/bin/bash

STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep "\.js$")

if [ -n "$STAGED_FILES" ]; then
    echo "$STAGED_FILES" | xargs npx eslint
    if [ $? -ne 0 ]; then
        echo "ESLint found errors in staged files."
        exit 1
    fi
fi

This staged-only approach is significantly faster than running the linter on the entire codebase. For large projects, running ESLint on 5 changed files instead of 500 can mean the difference between a 2-second hook and a 30-second hook — making developers much less likely to use --no-verify.

Commit-Msg Hook

Validates the commit message format before the commit completes:

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

# Enforce conventional commit format
# Format: type(scope): description

PATTERN="^(feat|fix|docs|style|refactor|test|chore|perf)(\(.+\))?: .{1,72}$"

if ! grep -qE "$PATTERN" "$1"; then
    echo "Error: Commit message must follow conventional commit format:"
    echo "  type(scope): description"
    echo "  Valid types: feat, fix, docs, style, refactor, test, chore, perf"
    exit 1
fi

Prepare-Commit-Msg Hook

Modifies the default commit message before the editor opens. Useful for automatically adding issue numbers:

#!/bin/bash
# .git/hooks/prepare-commit-msg

BRANCH_NAME=$(git symbolic-ref --short HEAD)
ISSUE_NUMBER=$(echo "$BRANCH_NAME" | grep -oP '(?<=issue-)\d+')

if [ -n "$ISSUE_NUMBER" ]; then
    echo "Issue: #$ISSUE_NUMBER" >> "$1"
fi

Pre-Push Hook

Runs before a push. Use it for checks that take longer or require network access:

#!/bin/bash
# .git/hooks/pre-push

echo "Running full test suite..."
npm test
if [ $? -ne 0 ]; then
    echo "Tests failed. Push aborted."
    exit 1
fi

echo "Checking for merge conflicts..."
if git diff --check | grep -q "conflict"; then
    echo "Merge conflict markers found. Push aborted."
    exit 1
fi

Server-Side Hooks

Server-side hooks run on the Git server when receiving pushes. They enforce policies across the entire team.

Pre-Receive Hook

Runs on the server before accepting a push. Can reject pushes based on branch names, file patterns, or commit messages:

#!/bin/bash
# hooks/pre-receive on the server

while read oldrev newrev refname; do
    # Reject direct pushes to main
    if [[ "$refname" =~ "refs/heads/main" ]]; then
        echo "Error: Direct pushes to main are not allowed."
        echo "Use pull requests instead."
        exit 1
    fi

    # Check for large files (>5MB)
    for commit in $(git rev-list $oldrev..$newrev); do
        for file in $(git diff-tree --no-commit-id -r $commit); do
            size=$(git cat-file -s $file)
            if [ $size -gt 5242880 ]; then
                echo "Error: File larger than 5MB found."
                exit 1
            fi
        done
    done
done

Update Hook

Similar to pre-receive but runs once per branch being updated:

#!/bin/bash
# hooks/update on the server

refname="$1"
oldrev="$2"
newrev="$3"

# Enforce branch naming
if [[ "$refname" =~ "refs/heads/" ]]; then
    branchname="${refname#refs/heads/}"
    if ! [[ "$branchname" =~ ^(feature|bugfix|hotfix|release)/ ]]; then
        echo "Error: Branch name must start with feature/, bugfix/, hotfix/, or release/"
        exit 1
    fi
fi

Managing Hooks with a Framework

Since hooks are not tracked by Git, teams typically use frameworks to share them.

Husky (JavaScript)

Husky manages Git hooks through npm:

npm install husky --save-dev

# package.json
{
  "husky": {
    "hooks": {
      "pre-commit": "npm run lint-staged",
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS",
      "pre-push": "npm test"
    }
  }
---

pre-commit (Python)

The pre-commit framework works with any language:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.4.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-added-large-files
        args: ['--maxkb=500']

  - repo: https://github.com/psf/black
    rev: 23.1.0
    hooks:
      - id: black
pip install pre-commit
pre-commit install

# Run on all files
pre-commit run --all-files

Real-World Hook Setup

A typical mid-size web project might use the following hook configuration:

pre-commit (fast checks):

  • ESLint on staged JavaScript files
  • Prettier formatting check
  • Detect private keys or credentials
  • Check for large files (>1MB)
  • Validate file naming conventions

commit-msg:

  • Enforce Conventional Commits format (feat:, fix:, chore:, etc.)
  • Limit subject line to 72 characters
  • Ensure the body is separated by a blank line

pre-push (slower checks):

  • Run full test suite
  • Build the project
  • Run security audit (npm audit or similar)
  • Check for unresolved merge conflict markers

This layered approach ensures that fast checks catch issues immediately (pre-commit), while more expensive validations run only when pushing (pre-push). Developers never wait more than a few seconds for the pre-commit hook, making them less likely to use --no-verify.

Common Hook Patterns

Check for Secrets

#!/bin/bash
# pre-commit

if git diff --cached | grep -P '(password|secret|api.?key|token).*[''"]?[A-Za-z0-9_-]{20,}[''"]?' > /dev/null; then
    echo "Warning: Possible secret detected in staged changes."
    echo "Review the diff before committing."
    exit 1
fi

Auto-format Staged Files

A pre-commit hook that automatically formats staged files ensures consistent code style without manual effort:

#!/bin/bash
# pre-commit — auto-format staged files

STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|ts|jsx|tsx|json|css|md)$')

if [ -n "$STAGED_FILES" ]; then
    echo "$STAGED_FILES" | xargs npx prettier --write
    echo "$STAGED_FILES" | xargs git add
fi

This hook runs Prettier on staged files, then re-stages the formatted versions. The developer never sees the formatting changes — they just happen automatically at commit time.

Enforce Branch Name Policy

#!/bin/bash
# pre-push

BRANCH=$(git symbolic-ref --short HEAD)
if [[ "$BRANCH" != "main" && "$BRANCH" != "develop" ]]; then
    if ! echo "$BRANCH" | grep -qE "^(feature|bugfix|hotfix|chore)/"; then
        echo "Branch name must start with feature/, bugfix/, hotfix/, or chore/"
        exit 1
    fi
fi

Migrating from Shell Hooks to Pre-commit Framework

If your team currently uses custom shell scripts in .git/hooks/, migrating to the pre-commit framework involves these steps:

  1. Install pre-commit: pip install pre-commit
  2. Create .pre-commit-config.yaml with hooks that replace your shell scripts
  3. Run pre-commit install to set up the framework
  4. Test by running pre-commit run --all-files
  5. Remove old shell scripts from .git/hooks/
  6. Commit the .pre-commit-config.yaml to your repository

The migration pays off quickly through automatic updates, cross-platform compatibility, and access to hundreds of community-maintained hooks.

Conclusion

Git hooks are a powerful automation tool that runs scripts at key points in the Git lifecycle. Client-side hooks (pre-commit, commit-msg, pre-push) enforce quality standards locally before code reaches the server. Server-side hooks (pre-receive, update) enforce policies across the entire team. Using a framework like pre-commit or Husky makes hooks easy to install and maintain. A well-designed hook system prevents common issues, enforces coding standards, and reduces the burden on code reviewers — catching formatting issues, secrets, and broken tests long before they reach a pull request.


Related: See our Git workflow strategies and Git branching strategy.

FAQ

Q: Can I write Git hooks in languages other than bash? A: Yes. Hooks can be any executable — Python, Ruby, Node.js, Go, or compiled binaries. The shebang line determines the interpreter.

Q: How do I distribute hooks to my team? A: The most maintainable approach is the pre-commit framework with a .pre-commit-config.yaml file. Alternatively, use a .githooks/ directory with git config core.hooksPath .githooks.

Q: What is the difference between pre-commit and pre-push hooks? A: pre-commit runs before each commit and should be fast (linting, formatting). pre-push runs before pushing and can run slower checks (full test suite, integration tests).

Q: How do I skip hooks temporarily? A: Use git commit --no-verify to skip pre-commit and commit-msg hooks, or git push --no-verify to skip pre-push. Use these sparingly — they should be reserved for emergencies.

Q: Can hooks modify my files? A: Yes, hooks can modify files. For example, a pre-commit hook can auto-format code with Prettier or Black. Modified files will be included in the commit automatically.

Q: What happens if a hook takes too long? A: There is no timeout — Git waits for the hook to complete. Developers will find ways to bypass slow hooks, so keep them fast enough to run on every commit.

Section: Git 1468 words 7 min read Beginner 1251 articles in section Report inaccuracy Back to top
DC
David Chen Technology Editor

Technology Editor at ExcellentWiki covering programming, engineering, and consumer technology topics.

View author profile →