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.
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] # 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 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.
Related Jump to heading
- The pre-commit Framework for Polyglot Repos β the parent topic.
- Finding the New Commits in a pre-push Hook β what pre-push receives, in detail.
- Enforcing Issue Keys in Commit Messages β a commit-msg check worth adding.
- Running pre-commit in CI on Changed Files β the CI side of these stages.