Paul's Programming Notes     Archive     Feed     Github

Switching from pre-commit to prek

I moved my repos from pre-commit to prek, a reimplementation of the same framework in Rust. It reads your existing .pre-commit-config.yaml unchanged, so the switch is mostly a no-op. Two things sold me: it installs as a single binary with no Python of its own (uv tool install prek, or a standalone script), and for language: python hooks it builds the hook environments with uv instead of pip and virtualenv, which is a lot faster on a cold cache.

“Drop-in” came with one wrinkle. prek’s YAML parser is stricter than pre-commit’s, and it rejected two hook manifests that pre-commit had been quietly accepting for years. Both turned out to be real bugs in the upstream hooks.

The first was a duplicate key. The gruntwork-io/pre-commit version I was pinned to, v0.1.20, shipped a golangci-lint hook that declared language twice:

- id: golangci-lint
  entry: hooks/golangci-lint.sh
  language: script
  language: script   # duplicate
  files: \.go$

pre-commit parses with PyYAML, which silently keeps the last of two duplicate keys and moves on. prek’s Rust parser treats a duplicate mapping key as an error and refuses to run. It’s fixed upstream in v0.1.30 (gruntwork PR #134), so bumping the hook version cleared it.

The second was deprecated stage names. The pre-commit-hooks version I was pinned to, 4.4.0, still used the old git-stage spelling:

stages: [commit, push]           # deprecated
stages: [pre-commit, pre-push]   # current

pre-commit renamed the commit stage to pre-commit and push to pre-push a while back and started warning on the old names; newer tooling rejects them outright. pre-commit-hooks uses the modern names in 6.0.0, so upgrading to it sorted it.

Neither was prek’s fault. It just stopped tolerating manifests that were already wrong.

Once everyone on the repo is on prek, you might not need the pre-commit-hooks repo at all. prek ships Rust-native versions of the common checks (trailing whitespace, end-of-file fixer, YAML and JSON validation) that you pull in with repo: builtin instead of cloning and pinning the upstream hooks. Just note that repo: builtin only works under prek, so it’s for repos where you’ve moved the whole team over and don’t need the config to keep running under stock pre-commit.

The reason I bother with any of this: I run the exact same hooks locally and in CI, through j178/prek-action. If a formatter or a lint rule is going to fail, I want it fixed in the commit, not after a five-minute CI round-trip and a “fix lint” follow-up commit. Enforcing it across a whole team kills a lot of that churn.

The catch is that hooks have to stay fast, because they run on every commit. Formatters, linters, a YAML syntax check: fine. The full test suite or Playwright browser tests: no. Those go in CI, where slow is acceptable and the same prek config runs --all-files anyway.