backend / git / 13_hooks.md

Git Hooks

6 interview angles 4 min read source

Git Hooks

Hooks are scripts git runs automatically at specific points in the workflow. Used to enforce style, run tests, prevent bad commits, validate messages, sync external systems.

Two flavors:

  • Client-side: run on developer machines (pre-commit, commit-msg, pre-push).
  • Server-side: run on the central git host (pre-receive, update, post-receive).

Where hooks live

.git/hooks/
├── pre-commit.sample
├── commit-msg.sample
├── pre-push.sample
├── post-merge.sample
└── ...

Sample files are inert (.sample suffix). Rename to the hook name (no extension) and chmod +x to activate.

Common client-side hooks

Hook Fires Common use
pre-commit before git commit writes the commit run linter/formatter/tests, block bad code
prepare-commit-msg before opening commit-message editor inject branch name, ticket ID
commit-msg after writing commit message, before completing commit enforce message format (Conventional Commits)
pre-push before git push sends to remote run tests, block protected branches
post-merge after git merge completes re-install dependencies if requirements.txt changed
post-checkout after git switch/checkout refresh virtualenv, db migrations

Hooks exit non-zero to abort the operation.

A minimal pre-commit:

#!/bin/bash
ruff check . || exit 1
pytest -x --quiet || exit 1

chmod +x .git/hooks/pre-commit. Now every git commit runs ruff and pytest first. Failure aborts the commit.

The big problem with .git/hooks/

It’s not in version control. Each developer would have to set up hooks individually, and there’s no enforcement.

pre-commit framework — the de facto standard

pre-commit (the Python package, not the git hook) manages hooks via a checked-in config file. Set up once, every dev gets the same hooks.

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.4.0
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-merge-conflict
      - id: check-yaml
      - id: check-added-large-files
        args: [--maxkb=500]

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.10.0
    hooks:
      - id: mypy

Setup:

pip install pre-commit
pre-commit install                  # writes a .git/hooks/pre-commit that runs the framework

Now every commit runs the configured hooks. The same config file works in CI:

pre-commit run --all-files

Useful built-in checks:

  • check-merge-conflict — blocks committing files with <<<<<<< markers.
  • check-added-large-files — blocks accidental binary blobs.
  • detect-private-key — refuses commits containing private keys.
  • check-yaml, check-json, check-toml — syntax-validate configs.

Conventional Commits enforcement

commit-msg hook to require feat: / fix: / chore: prefixes:

- repo: https://github.com/compilerla/conventional-pre-commit
  rev: v3.2.0
  hooks:
    - id: conventional-pre-commit
      stages: [commit-msg]

Pairs with semantic-release / changelog tooling.

Bypassing hooks

git commit --no-verify              # skip pre-commit and commit-msg
git push --no-verify                # skip pre-push

Useful for emergencies. But: in CI, run pre-commit run --all-files so bypassed-locally bad commits get caught before merge.

The CLAUDE.md rule “never skip hooks unless explicitly asked” applies to working with AI agents too — bypassing hooks defeats their purpose.

Server-side hooks

Run on the git server (you don’t have these on GitHub/GitLab/Bitbucket SaaS — they replace them with branch protection rules and CI integrations).

Hook Fires
pre-receive before any ref update; can reject the entire push
update per-ref version of pre-receive
post-receive after refs updated; for notifications, deploys

Self-hosted GitLab/Gitea support custom server-side hooks. SaaS git hosts replace this with:

  • Branch protection (required reviews, required CI).
  • GitHub Actions / GitLab CI as policy enforcement.

Husky (JS/TS world)

husky is the Node/JS equivalent of Python’s pre-commit framework. Same idea: checked-in config, installed via npm install, manages .git/hooks/. Common in mixed Python/JS repos for the JS side.

Hook performance

Slow hooks make commits annoying. Tips:

  • Run only on staged files: pre-commit framework does this by default.
  • Heavy checks (full test suite, mypy on the whole repo) → pre-push instead of pre-commit.
  • Truly slow checks → CI only.

A 30-second pre-commit will cause people to use --no-verify or commit less often. Sub-3-second is the goal.

Hooks and submodules / worktrees

Hooks live in .git/hooks/ of the main repo. Worktrees share the same .git/, so they share hooks. Submodules have their own hooks.

Common interview confusions

  • “Hooks are version-controlled.”.git/hooks/ is not. The pre-commit framework’s .pre-commit-config.yaml is, which is the workaround.
  • “Server-side hooks work on GitHub.” — you can’t add server-side hooks to GitHub/GitLab.com. Use branch protection + Actions/CI instead. Self-hosted GitLab/Gitea allow custom server hooks.
  • --no-verify is fine to use freely.” — it bypasses safety nets the team agreed on. Use rarely; rely on CI to catch what was bypassed.

Interview angle

  • “What are git hooks and what do you use them for?” — scripts git runs at specific points. Common: pre-commit (lint/format), commit-msg (enforce message format), pre-push (run tests), post-checkout (refresh deps).
  • “Why use the pre-commit framework instead of writing hooks in .git/hooks/?” — hooks aren’t version-controlled and aren’t shared with the team. The framework uses a checked-in YAML config that everyone installs once.
  • “Common pre-commit checks?” — formatter (ruff/black), linter (ruff/flake8), type check (mypy), trailing whitespace, large file blocker, private-key detector, conflict-marker detector.
  • “What do you put in pre-commit vs pre-push vs CI?” — fast/local-only stuff in pre-commit (formatters, basic lint), slower stuff in pre-push (full test on small project), heavy stuff (full test suite, integration tests, security scans) in CI.
  • “How do you enforce hooks on a SaaS git host like GitHub?” — you can’t run server-side hooks. Use branch protection (required reviews, required status checks) and GitHub Actions to enforce policy.
  • “Someone bypasses pre-commit with --no-verify — what stops bad code from landing?” — CI re-runs the same hooks via pre-commit run --all-files. Branch protection requires CI to pass before merge.