Running pre-commit in CI on changed files Jump to heading
The usual CI recipe for the pre-commit framework is pre-commit run --all-files. On a small repository that is fine. On a large one it is slow, and on a repository adopting new hooks it is worse than slow: every pull request fails on files it never touched, because the new hooks flag years of existing code. Running hooks only on the files a pull request changed mirrors what developers’ local hooks do, keeps CI fast, and makes failures relevant to the change. The framework supports this directly with --from-ref and --to-ref. There is one case where checking only changed files is wrong — when the hook configuration itself changed — and the CI job should detect that and check everything. This page builds that job, within the pre-commit framework for polyglot repos.
When to use this approach Jump to heading
pre-commit run --all-filesin CI takes minutes on your repository.- You are introducing hooks to a codebase with existing violations and cannot fix everything at once.
- You want CI to match what local hooks check: the files being changed.
- Your hooks are pinned, as in pinning and autoupdating pre-commit hook versions, so local and CI runs agree.
Step 1 — Fetch enough history for a diff Jump to heading
Checking changed files needs the merge base between the pull request and its target branch. Fetch full history, or at least enough to reach it.
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # Verification: the merge base is available
git merge-base "origin/$GITHUB_BASE_REF" HEAD Step 2 — Run hooks on the pull request’s changed files Jump to heading
--from-ref and --to-ref make pre-commit run hooks on files changed between two revisions, the same set a developer’s hooks would have seen across all their commits.
- name: pre-commit on changed files
run: |
base=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
pre-commit run --show-diff-on-failure --color=always --from-ref "$base" --to-ref HEAD Using the merge base rather than the target branch’s tip matters: comparing against the moving tip would include files changed on main since the branch was created, which are not this pull request’s concern.
Step 3 — Check everything when the hook config changes Jump to heading
If a pull request changes .pre-commit-config.yaml — adding a hook, updating versions — checking only changed files would test the new configuration against almost nothing. Detect that case and run on all files.
- name: pre-commit
run: |
base=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
if git diff --name-only "$base" HEAD | grep -qx '.pre-commit-config.yaml'; then
echo "Hook config changed: checking all files"
pre-commit run --show-diff-on-failure --all-files
else
pre-commit run --show-diff-on-failure --from-ref "$base" --to-ref HEAD
fi Step 4 — Run a periodic all-files check on main Jump to heading
Changed-files mode never revisits untouched files, so drift can accumulate: a file excluded from a hook by mistake, a merge that slipped through while the config was being changed. A scheduled all-files run on the default branch catches it without slowing down pull requests.
on:
schedule: [{ cron: "11 3 * * *" }]
jobs:
pre-commit-all:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pipx install pre-commit && pre-commit run --all-files --show-diff-on-failure Step 5 — Adopt a new hook without a big-bang fix Jump to heading
To introduce a strict new hook into a codebase with many existing violations, changed-files mode lets you enforce it on new and touched code immediately while the backlog is fixed gradually.
# Measure the backlog once
pre-commit run new-hook --all-files 2>&1 | grep -c '^[^ ]*:[0-9]*:' || true
# Enforce on changed files from today; fix the rest in batches by directory
pre-commit run new-hook --files $(git ls-files 'services/billing/*.py') Step 6 — Keep the job fast with caching Jump to heading
Hook environments are installed on first use. Cache ~/.cache/pre-commit keyed on the config file’s hash, so CI reinstalls only when hook versions change.
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }} The caching details, including what to do for hooks with large environments, are in caching pre-commit environments in CI.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Why not just pass --files with a list from git diff? Jump to heading
You can, but --from-ref/--to-ref handles renames, deletions and file name quoting for you, and matches exactly how pre-commit’s own pre-push stage selects files.
What about hooks that need to see the whole repository, like a dependency check? Jump to heading
Mark them with pass_filenames: false and always_run: true in the config. They run once regardless of which files changed, in both modes.
Does this work on GitLab? Jump to heading
Yes. Use CI_MERGE_REQUEST_DIFF_BASE_SHA as the from-ref in merge request pipelines, and the same config-change fallback.
Related Jump to heading
- The pre-commit Framework for Polyglot Repos — the parent topic.
- Commit-msg and Pre-Push Stages in pre-commit — the local stages these CI runs mirror.
- Formatting Only Changed Lines in a Legacy Codebase — an even narrower scope for legacy code.
- Optimizing CI Triggers for Path-Specific Changes — skipping whole jobs by path.