commit-msg and pre-push stages in pre-commit Jump to heading

The pre-commit framework’s name suggests one hook, but it can manage most of Git’s client-side hooks: commit-msg to check messages, pre-push to run slower checks before code leaves the machine, post-checkout and post-merge to refresh dependencies. Each hook in the configuration declares the stages it runs in. Two details trip teams up. Installing the framework only activates the pre-commit stage unless you ask for others, so a commit-msg hook can sit in the configuration for months without running. And hooks without an explicit stage run at every installed stage, so a slow check meant for pre-push also runs at every commit. This page sets up stages deliberately, within the pre-commit framework for polyglot repos.

When to use this approach Jump to heading

  • You use the pre-commit framework and want commit message checks.
  • Some checks are too slow for every commit but should run before pushing.
  • A hook in your configuration never seems to run.
  • You are moving from Husky, which handles stages differently, as in migrating from Husky to the pre-commit framework.

Step 1 β€” Declare the stages to install Jump to heading

default_install_hook_types in the config tells pre-commit install which Git hooks to activate. Without it, only pre-commit is installed.

# .pre-commit-config.yaml
default_install_hook_types: [pre-commit, commit-msg, pre-push]
default_stages: [pre-commit]
pre-commit install            # installs all three hook types from the config
ls .git/hooks/ | grep -E '^(pre-commit|commit-msg|pre-push)$'

default_stages sets the stage for hooks that do not declare one. Setting it to [pre-commit] stops unlabelled hooks from running at every stage.

Installing stages explicitly against relying on defaultsWith no install types declared, pre-commit install activates only the pre-commit hook, so commit-msg and pre-push hooks in the config never run. With default_install_hook_types and default_stages set, every intended stage is installed and each hook runs only where it belongs.Defaults onlyDeclared in confighooks installedpre-commit onlypre-commit, commit-msg, pre-pushcommit-msg hooks runneveryesunlabelled hooks runevery installed stagepre-commit onlya hook that silently never runs is worse than no hook β€” it looks like coverage

Step 2 β€” Check commit messages at commit-msg Jump to heading

Hooks in the commit-msg stage receive the path to the message file. Commitlint, gitlint and custom scripts all work this way.

repos:
  - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook
    rev: v9.18.0
    hooks:
      - id: commitlint
        stages: [commit-msg]
        additional_dependencies: ["@commitlint/config-conventional"]
  - repo: local
    hooks:
      - id: issue-key
        name: require an issue key
        entry: sh -c 'grep -Eq "[A-Z]+-[0-9]+" "$1" || { echo "add an issue key like PAY-123"; exit 1; }' --
        language: system
        stages: [commit-msg]
# Verification: the message check runs on commit
git commit --allow-empty -m "fix stuff"          # rejected by both hooks
git commit --allow-empty -m "fix(pay): round totals PAY-812"

Step 3 β€” Move slow checks to pre-push Jump to heading

pre-push runs once per push rather than once per commit, which makes it the right place for checks that take tens of seconds: type checking, a fast test subset, a full lint of changed packages. In this stage the framework runs hooks on the files changed between the remote and local refs being pushed.

  - repo: local
    hooks:
      - id: mypy
        name: mypy (pre-push)
        entry: mypy src/
        language: system
        pass_filenames: false
        stages: [pre-push]
      - id: unit-tests
        name: fast unit tests (pre-push)
        entry: pytest -q -x tests/unit
        language: system
        pass_filenames: false
        stages: [pre-push]
Which stage runs what, in one working sessionEvery commit runs the fast pre-commit stage, formatting and linting the staged files, and the commit-msg stage, checking the message. Pushing runs the pre-push stage once, with type checks and fast tests over everything being pushed. CI then repeats all of them.pre-commitformat + lint stagedcommit 1commit-msgmessage rulescommit 1pre-commitformat + lint stagedcommit 2pre-pushmypy + unit testspushCIall of the abovePRslow checks run once per push instead of once per commit
# Run a stage's hooks manually without pushing
pre-commit run --hook-stage pre-push --from-ref origin/main --to-ref HEAD

Step 4 β€” Use post-checkout and post-merge for housekeeping Jump to heading

Two more stages are useful for keeping working copies healthy: refreshing dependencies when switching to a branch with a different lockfile, and reminding people about migrations after a pull.

default_install_hook_types: [pre-commit, commit-msg, pre-push, post-checkout, post-merge]
repos:
  - repo: local
    hooks:
      - id: deps-changed
        name: warn when the lockfile changed
        entry: sh -c 'git diff --quiet HEAD@{1} HEAD -- poetry.lock 2>/dev/null || echo "poetry.lock changed β€” run poetry install"'
        language: system
        pass_filenames: false
        always_run: true
        stages: [post-checkout, post-merge]

Post-stage hooks run after the operation completes and cannot block it, so keep them informative and quick.

Step 5 β€” Keep CI aligned with every stage Jump to heading

Hooks in non-default stages are easy to forget in CI, because pre-commit run runs only the pre-commit stage by default. Run each stage explicitly.

      - run: pre-commit run --from-ref "$BASE" --to-ref HEAD                        # pre-commit stage
      - run: pre-commit run --hook-stage pre-push --from-ref "$BASE" --to-ref HEAD  # pre-push stage
      - run: |                                                                      # commit-msg stage, per commit
          for c in $(git rev-list "$BASE..HEAD"); do
            git log -1 --format=%B "$c" > /tmp/msg
            pre-commit run --hook-stage commit-msg --commit-msg-filename /tmp/msg || exit 1
          done
Which stage should a hook use?A hook that checks the message belongs in commit-msg. A hook that finishes in a second or two on staged files belongs in pre-commit. A hook that needs the whole project or takes tens of seconds belongs in pre-push. Anything slower runs only in CI.What does the hook check, and how long does it take?the commit messagecommit-msgmessage file pathstaged files, ~1 spre-commitevery commitproject-wide, ~30 spre-pushonce per pushCI runs every stage regardless β€” hooks are the fast local copy

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Do existing clones pick up new stages automatically? Jump to heading

No. A developer must re-run pre-commit install after default_install_hook_types changes. Mention it in the pull request that adds a stage, or add a post-merge hook that warns when the config’s install types changed.

Can a hook run in more than one stage? Jump to heading

Yes, list several: stages: [pre-commit, pre-push]. That is useful for a cheap check you also want enforced on pushes made with --no-verify at commit time.

How does pre-push know which files to check? Jump to heading

Git passes the refs being pushed on standard input. The framework compares each local ref with its remote counterpart and runs hooks on files changed in between, falling back sensibly for new branches.