Git Hooks: Automate the Checks Your Team Keeps Forgetting

by Eric Hanson, Backend Developer at Clean Systems Consulting

The Review Comment That Runs on Every PR

There is a code review comment that appears on your team's PRs every week. Maybe it's "missing test for this function." Maybe it's "this log statement contains a raw password." Maybe it's "commit message is too vague." Whatever it is, a reviewer is spending mental energy on it, the author is fixing it, and the cycle repeats because nothing in the workflow catches it before review.

Git hooks are the fix for checks that can be automated. They run scripts at defined points in the Git workflow and exit non-zero to block an operation when a check fails. The cost is a few minutes of setup. The payoff is removing an entire category of review comments.

The Hook Points That Matter

Git has over twenty hook types. The three that cover most use cases:

pre-commit — runs before a commit is created. Receives no arguments. Operates on the staged files. Use this for linting, formatting checks, and preventing commits of obvious problems.

commit-msg — runs after the commit message is written, before the commit is saved. Receives the path to the commit message file as $1. Use this for enforcing commit message format.

pre-push — runs before git push executes. Receives remote name and URL. Use this for running tests or preventing pushes to protected branches.

.git/hooks/
  pre-commit         # runs before every commit
  commit-msg         # runs after message is typed
  pre-push           # runs before push
  post-merge         # runs after a successful merge
  prepare-commit-msg # runs to populate the commit message template

A Real pre-commit Hook

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

# Run linter on staged Python files
STAGED_PYTHON=$(git diff --cached --name-only --diff-filter=ACM | grep '\.py$')

if [ -n "$STAGED_PYTHON" ]; then
    echo "Running flake8 on staged files..."
    echo "$STAGED_PYTHON" | xargs flake8
    if [ $? -ne 0 ]; then
        echo "Linting failed. Fix errors before committing."
        exit 1
    fi
fi

# Block commits containing common secret patterns
if git diff --cached | grep -qE '(password|secret|api_key)\s*=\s*["\x27][^"\x27]{8,}'; then
    echo "ERROR: Commit appears to contain hardcoded credentials."
    echo "Remove secrets before committing."
    exit 1
fi

exit 0

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

A Commit Message Hook

Enforcing Conventional Commits format:

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

MSG=$(cat "$1")
PATTERN='^(feat|fix|perf|refactor|chore|docs|test|ci|style)(\([a-z-]+\))?: .{1,72}$'

if ! echo "$MSG" | head -1 | grep -qE "$PATTERN"; then
    echo "ERROR: Commit message does not follow Conventional Commits format."
    echo "Expected: type(scope): description"
    echo "Examples:"
    echo "  feat(payment): add retry logic"
    echo "  fix(auth): correct token expiry calculation"
    echo "  chore(deps): upgrade Spring Boot to 3.3.2"
    echo ""
    echo "Your message: $MSG"
    exit 1
fi

A pre-push Hook to Prevent Accidental Main Pushes

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

REMOTE=$1
URL=$2

# Block direct pushes to main
while read LOCAL_REF LOCAL_SHA REMOTE_REF REMOTE_SHA; do
    if [ "$REMOTE_REF" = "refs/heads/main" ]; then
        echo "ERROR: Direct push to main is not allowed."
        echo "Open a pull request instead."
        exit 1
    fi
done

exit 0

The Sharing Problem

The critical limitation of hooks stored in .git/hooks/: .git/ is not tracked by Git and not committed to the repository. Every developer has to set up hooks manually. The hooks you wrote disappear when someone clones the repo.

The two solutions:

1. Commit hooks to the repository and symlink:

# Store hooks in a tracked directory
mkdir -p .githooks
# Move hooks there and commit them
mv .git/hooks/pre-commit .githooks/
git add .githooks/
git commit -m "chore: add pre-commit hook for linting"

# Each developer runs once after clone:
git config core.hooksPath .githooks

Or configure the default in the project:

# In a setup script or Makefile:
git config core.hooksPath .githooks

2. Use a hook manager:

Husky (Node.js projects) is the most widely adopted. It installs hooks during npm install and stores hook definitions in package.json or .husky/:

// package.json
{
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged",
      "commit-msg": "commitlint --edit $1"
    }
  }
}

pre-commit (Python-centric but language-agnostic) manages hooks as a configuration file with a catalog of reusable hook plugins:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: trailing-whitespace
      - id: detect-private-key
      - id: check-merge-conflict
  - repo: https://github.com/pycqa/flake8
    rev: 7.0.0
    hooks:
      - id: flake8
pip install pre-commit
pre-commit install  # installs the pre-commit hook

pre-commit handles downloading, caching, and running the hooks. New developers run pre-commit install after cloning and get the full hook setup immediately.

Performance: Keeping Hooks Fast

Hooks run synchronously in the developer's workflow. A pre-commit hook that takes thirty seconds will be disabled by frustrated developers within a week. Keep hooks fast:

  • Run only on staged files (git diff --cached --name-only), not the entire project
  • Cache results where possible (pre-commit does this automatically)
  • Run only the fastest checks pre-commit; save the slower ones for CI
  • Parallelize independent checks where possible

The standard division: pre-commit handles format, lint, and secret detection (sub-second to a few seconds). Full test suites belong in CI, not hooks.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

ActiveRecord Query Patterns That Actually Scale

ActiveRecord makes simple queries trivial and complex queries dangerous. These are the patterns that remain correct under load — and the common ones that quietly fall apart at scale.

Read more

Negotiating Deadlines Without Feeling Guilty

Deadlines can feel like unbreakable chains—but they’re negotiable if you handle them smartly. Here’s how to ask for more time without stress or guilt.

Read more

Why Junior Contractors Learn the Hardest Lessons First

Starting out as a junior contractor can feel like being thrown into the deep end. The early mistakes sting, but they also teach lessons you won’t forget.

Read more

Java Generics Beyond `List<T>` — Wildcards, Bounds, and When They Actually Matter

Most Java developers use generics as glorified type-safe containers and stop there. Wildcards and bounds solve real API design problems — here is what they are, when they help, and when they make things worse.

Read more