Over the course of this series I've rebuilt my Python stack with uv (Part 1), ruff (Part 2) and ty (Part 3). In Part 4 I bring it all together: the checks should run automatically, before code even reaches the repository. Nobody feels like running this stack of tools by hand all the time.

Git hooks as a quality gate

Every check that only fails in CI is a delay and pulls me out of my tasks: write, commit, push, wait five minutes, red pipeline, fix, "Fix linting" commit, push again. A pre-commit hook moves exactly those same checks to the earliest possible point: errors get fixed before they land in the history, and CI turns from the thing that finds errors into a pure safety net.

The bottleneck with classic pre-commit

The pre-commit framework has been a standard for this for years. Tools like lefthook or husky are great too. But prek is fast and, thanks to its compatibility with pre-commit, my tool of choice. Because pre-commit has a few downsides: it's a Python package itself, isolates every hook in its own virtual environment, and runs hooks sequentially. The first git commit after a fresh clone can take minutes while environments are built.

prek

prek is a reimplementation of the pre-commit framework in Rust that reads the same .pre-commit-config.yaml, just with smarter caching and without the virtualenv overhead:

# Installation (a single static binary, no Python needed)
uv tool install prek
# alternatively via brew/pacman/apt/etc.

# Enable the git hook in the repository (once per clone)
prek install

# Run hooks manually
prek run                # against staged files
prek run --all-files    # against the whole codebase
prek run gitleaks --all-files  # a single hook specifically

My hook setup in detail

The .pre-commit-config.yaml of my template chains together everything I built in the last three parts. I split the hooks into two kinds:

Remote hooks come from foreign repositories: the basics like YAML/TOML validation, trailing whitespace, and the secret scanner gitleaks:

- repo: https://github.com/pre-commit/pre-commit-hooks
  rev: v6.0.0
  hooks:
  - id: check-yaml
  - id: check-toml
  - id: check-merge-conflict
  - id: trailing-whitespace
  - id: end-of-file-fixer

- repo: https://github.com/gitleaks/gitleaks
  rev: v8.30.1
  hooks:
  - id: gitleaks

Local hooks, on the other hand, run via uv run in my project environment. That way ruff, ty and bandit check with exactly the versions pinned in uv.lock:

- repo: local
  hooks:
  - id: ruff-format
    name: ruff format
    entry: uv run ruff format
    language: system
    types: [python]

  - id: ruff-check
    name: ruff check
    entry: uv run ruff check --fix
    language: system
    types: [python]

  - id: ty
    name: ty-check
    entry: uv run ty check --verbose --output-format=full .
    language: system
    types: [python]
    pass_filenames: false

  - id: bandit
    name: bandit
    entry: uv run bandit -r src/app -c pyproject.toml
    language: system
    types: [python]
    pass_filenames: false

On top of that there's import-linter, which guards my architecture: it fails as soon as someone imports infrastructure from the domain layer. That turns the architecture rules into an executable check.

One commit, the whole quality gate

With that, every git commit automatically runs: formatting, linting with auto-fix, type checking, security scan (bandit), secret scan (gitleaks) and the architecture check. Thanks to prek largely in parallel and within a few seconds, fast enough that nobody is tempted to switch it off.

For emergencies there's still the back door:

git commit --no-verify

But that's exactly what CI is the safety net for (well, that and the cases where someone forgot to run prek install): there the same checks run again (ruff format --check, ruff check, ty check, bandit, lint-imports, pytest), just as the last line of defence.

Series conclusion

These four tools, uv, ruff, ty and prek, each replace a slow, fragmented part of the Python toolchain with a fast Rust binary, while staying compatible with what they replace (pyproject.toml, Black formatting, .pre-commit-config.yaml).

And if I'm honest, they're usually the first tools of their kind that fit: a codebase that was already perfect here is something I've never seen before.


The series: