Skip to content
Home
Git Hooks: Automate Your Workflow

Git Hooks: Automate Your Workflow

Git Git 7 min read 1468 words Beginner ExcellentWiki Editorial Team

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 756 articles in section Report inaccuracy Back to top