Project Setup

Pre-commit Hooks for CLI Projects

Integrate pre-commit hooks into Python CLI repos with ruff, mypy, and pytest gates to automate code quality checks before every commit.

Updated

A command-line tool is only as trustworthy as the code behind it. When users pip install your CLI and run it in their own pipelines, a regression you never caught locally becomes a broken build on someone else's machine. Pre-commit hooks close that gap: they run your linter, formatter, type checker, and tests automatically before any commit lands, so the messy work-in-progress never reaches your history. This overview explains what pre-commit is, why CLI projects benefit from it in particular, and how the individual quality gates fit together.

What pre-commit actually is

pre-commit is a framework — a small Python application — for managing Git hooks. You declare the checks you want in a .pre-commit-config.yaml file at the repo root, and pre-commit installs a Git pre-commit hook that runs them against your staged files every time you git commit. Crucially, it isolates each tool in its own managed environment, so contributors don't need to install ruff, mypy, or black globally. Clone the repo, run one install command, and everyone runs the exact same versions of the exact same checks.

This matters more for CLI projects than for, say, a one-off script. A CLI is software other people execute. It has an entry point, argument parsing, exit codes, and usually a published package on PyPI. Each of those is a surface where a small mistake — an unhandled None, a misformatted help string, a type error in a Typer callback — ships straight to users. Catching it at commit time is the cheapest possible place to catch it.

The gate philosophy

Quality gates before every commit Quality gates before every commit git commit staged files ruff lint catch bugs ruff format style mypy types pytest behavior commit any gate fails ✗ commit blocked gates run in order — the first failure stops the commit, so nothing broken reaches your history

Think of pre-commit as a stack of fast, ordered gates. Each gate has one job, and code only proceeds if it passes all of them. For a Python CLI the four gates that earn their keep are:

  • Ruff (lint) — catches unused imports, undefined names, mutable default arguments, and hundreds of other bugs in milliseconds. It replaces flake8, isort, pyupgrade, and several plugins in a single fast binary.
  • Ruff (format) — applies a deterministic, black-compatible code style so diffs stay about logic, not whitespace. Running format and lint from the same tool keeps their rules from fighting each other.
  • mypy — verifies your type hints actually hold. For CLIs this is where you catch the str | None you forgot to guard before passing it to Path().
  • pytest — runs your test suite as a final gate. A CLI's behavior (exit codes, stdout, error messages) is best pinned with tests, so a green suite before commit is a strong signal.

The ordering is deliberate: cheap, auto-fixing checks first (format, lint), then static analysis (mypy), then the slower behavioral check (pytest) last. If formatting alone fails, you don't waste time running the whole test suite.

Local hooks vs CI hooks

There are two places these gates run, and you want both. Local hooks fire on your machine at commit time. They're fast and give instant feedback, but they're easy to bypass — anyone can git commit --no-verify. CI hooks run the same .pre-commit-config.yaml in your continuous-integration pipeline, where bypassing isn't possible. The single source of truth is the config file, so the local and CI runs check identical things:

The same hooks in two places A comparison of running pre-commit locally on staged files versus running it in CI over the whole repository. The same hooks in two places Aspect Local hook CI run Scope staged files only every file in the repo Speed fast enough to not annoy slower, and that is fine Can be skipped yes — git commit -n no Catches the mistake you just made the skipped commit, and drift Local hooks are a convenience; the CI run is the actual gate, because a local hook can always be bypassed.
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-python@v6
        with:
          python-version: "3.12"
      - run: pip install pre-commit
      - run: pre-commit run --all-files --show-diff-on-failure

Local hooks make the right thing convenient; CI hooks make it mandatory. Use local hooks for fast feedback and CI as the enforcement backstop.

A note on versions and reproducibility

Pre-commit pins each hook to a specific revision (a Git tag or commit), which is what makes runs reproducible across machines and across time. You upgrade those pins deliberately with pre-commit autoupdate rather than drifting silently. This pairs naturally with how you manage the rest of your toolchain — see uv for Python CLI dependency management for keeping the dev dependencies that back these hooks locked and reproducible too.

Keeping hook runs reproducible Practices that keep pre-commit results identical across machines and over time, and the habits that make them drift. Keeping hook runs reproducible Reproducible Pin every hook to an exact rev, not a branch Update revs deliberately with pre-commit autoupdate Run the same config in CI as on developer machines Cache the hook environments in CI Drifts silently rev: main — the hook changes under you Formatter configured differently in CI Hooks installed globally instead of per repo Letting the config and pyproject settings disagree An unpinned hook turns a green pull request into a red one with no commit to blame.

Where to go next

The step-by-step companion to this page walks through every command and the complete config, from a clean clone to a green CI run:

A configuration that holds up

A pre-commit setup that people keep is short, fast and pinned. Here is one that covers a typical Python CLI repository:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.6.9
    hooks:
      - id: ruff-format
      - id: ruff
        args: [--fix, --exit-non-zero-on-fix]

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: end-of-file-fixer
      - id: trailing-whitespace
      - id: check-added-large-files
        args: [--maxkb=512]
      - id: check-toml
      - id: check-merge-conflict
      - id: debug-statements

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.11.2
    hooks:
      - id: mypy
        additional_dependencies: [types-requests]
        stages: [manual]        # too slow for every commit; run it in CI

Four decisions are encoded there. Formatting runs before linting, so the linter sees the final text. --exit-non-zero-on-fix makes a hook that rewrote a file fail, which is the behaviour you want — the commit stops, you look at the change, you commit again. check-added-large-files is the cheapest possible guard against the accidental data dump. And type checking is present but staged as manual, because it is the hook most likely to make people start passing -n.

Install it once per clone:

uv tool install pre-commit        # or pipx install pre-commit
pre-commit install                # writes .git/hooks/pre-commit
pre-commit run --all-files        # the first run, over everything

That first full run is worth doing deliberately, on its own commit. It will reformat files across the repository, and you want that as one reviewable change rather than mixed into a feature branch.

Configure tools once, not twice

The most common way a hook setup drifts is having the tool configured in two places. Keep the behaviour in pyproject.toml, and let the hook file say only which checks run:

[tool.ruff]
target-version = "py311"
line-length = 100
src = ["src", "tests"]

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = ["E501"]                       # the formatter owns line length

[tool.mypy]
python_version = "3.11"
strict = true
files = ["src"]

Now a developer running ruff check . by hand, the pre-commit hook, and the CI job all read the same settings. When they are split — some flags in args:, some in pyproject.toml — you get the situation where a file passes locally and fails in review, and nobody can see why from the diff.

The one legitimate use for args: is selecting what a hook looks at, not how it behaves: restricting a hook to a directory, or passing --fix locally while CI runs the check-only form.

Local hooks and the CI gate

Local hooks are a convenience. They can be skipped with git commit -n, they are not installed in a fresh clone until somebody runs pre-commit install, and they only ever see staged files. That is fine — their job is to catch the mistake you just made, quickly.

The gate is the CI job:

- uses: actions/checkout@v4
- uses: actions/setup-python@v5
  with:
    python-version: "3.12"
- uses: pre-commit/action@v3.0.1

Two properties make it a real gate. It runs over every file, so drift in code nobody has touched recently is caught. And it cannot be skipped, so nothing depends on every contributor having installed the hooks. Caching the hook environments keys on the config file, so the job is fast unless a rev moved.

If you only do one of the two, do the CI job. A repository with CI and no local hooks is consistent and slightly annoying; a repository with local hooks and no CI is inconsistent and believes it is not.

Keeping the set from rotting

Two maintenance habits, both cheap.

Pin every rev to an exact tag. A hook tracking a branch changes under you: a formatter release reformats files, a linter release adds a rule, and a branch that was green yesterday is red today with no commit to blame. Pins turn that into a deliberate update.

Update on purpose. pre-commit autoupdate rewrites the revisions to the latest tags. Run it on its own commit, then pre-commit run --all-files, and review the resulting diff as a unit. A new formatter version touching two hundred files is fine when that is the whole commit and alarming when it is buried in a feature.

Beyond that, resist growth. Every hook is time charged to every commit, and the marginal check that fires once a year is better placed in CI. A good pre-commit configuration is one people forget is there, and the way to earn that is to keep it under a couple of seconds.

Hooks worth adding for a CLI specifically

A few checks matter more for a command-line tool than for a library, because they protect the things users interact with.

The entry point still resolves. A renamed module or a typo in [project.scripts] is invisible until someone installs the wheel. A local hook is a good place for the cheap version of that check:

  - repo: local
    hooks:
      - id: entry-point-imports
        name: entry point target imports
        entry: python -c "import importlib; importlib.import_module('mytool.cli')"
        language: system
        pass_filenames: false
        files: ^(src/mytool/|pyproject\.toml)

It runs only when the package or the metadata changed, takes a few milliseconds, and catches the class of mistake that otherwise ships.

The lockfile matches pyproject.toml. Someone adds a dependency, forgets to re-lock, and CI installs a different graph from the one they tested. Both managers have a one-line check:

      - id: lock-is-current
        name: lockfile matches pyproject
        entry: uv lock --check          # or: poetry check --lock
        language: system
        pass_filenames: false
        files: ^(pyproject\.toml|uv\.lock)$

No stray debugging output. debug-statements from the standard hook set catches breakpoint() and pdb imports. For a CLI it is worth adding a grep for print( inside command modules if your convention is to route everything through a console or logger — a stray print is how progress output ends up contaminating stdout.

Documentation that mentions flags stays honest. If your README shows --help output, a hook that regenerates it and fails on a diff keeps it from drifting. This one is easy to over-engineer; a simple version that only checks the flag list is usually enough.

Introducing hooks to an existing repository

Adding pre-commit to a codebase that has never been formatted produces a first run that touches almost every file, which makes reviewing the next change harder. Three steps avoid that.

Land the reformat on its own. Add the config, run pre-commit run --all-files, and commit the result with a message that says exactly what it is. Nothing else in that commit.

Record it for git blame. Add the commit hash to a .git-blame-ignore-revs file and tell git about it, so history stays readable:

echo "8f2a19c4c0b7e6d5a1f30b2c9e4d7a6b5c8e1f20" >> .git-blame-ignore-revs
git config blame.ignoreRevsFile .git-blame-ignore-revs

Turn rules on gradually. Start with formatting and the trivial file hygiene hooks. Add linting rules in batches, fixing each batch in its own commit. A configuration that emits four hundred errors on day one gets disabled on day two.

What the gates cannot catch

It is worth being clear about the boundary, because a green hook run can create false confidence.

Formatters and linters see one file at a time and know nothing about behaviour. They will not tell you that a flag was renamed, that an exit code changed meaning, that the console script points at a module that no longer exists, or that a command now writes progress to stdout. Those are the regressions that reach users, and they are caught by tests and by the install-and-run smoke check in CI — not by hooks.

The useful way to think about it: hooks protect the codebase from noise, tests protect users from breakage. Both are cheap, and neither substitutes for the other. A repository with immaculate formatting and no test that installs the built wheel is one broken entry point away from a bad release.

The corollary is that hook configuration deserves to stay small. Every check you add is time charged to every commit forever, so the bar for a new hook is that it catches something real, quickly, and that nothing later in the pipeline already catches it.

Frequently asked questions

Should formatters rewrite files in a hook, or just report?

Rewrite locally, report in CI. Locally the point is to remove the chore — the hook fixes the file, the commit stops, you re-commit. In CI a rewrite is invisible and produces artifacts nobody sees, so the check-only form (ruff format --check) is the right one. Same tool, same configuration, different flag.

How do I stop pre-commit slowing everyone down?

Keep the default stage to fast, file-scoped checks and move anything slow to stages: [manual] or to CI. Type checking a whole package, running the test suite and building documentation are all things that belong after the commit, not before it. If the hook takes more than a couple of seconds on a typical commit, people will start bypassing it, and a bypassed hook protects nothing.

What if a hook is wrong about my code?

Fix the configuration rather than the commit. # noqa: RULE on the one genuinely justified line is fine; a habit of git commit -n is not, because it disables every check rather than the one that misfired. If a rule fights the codebase repeatedly, remove the rule in pyproject.toml — that is a decision the whole team can see and revisit.

Do hooks work with uv and Poetry projects?

Yes, and pre-commit should be installed as a standalone tool rather than as a project dependency. It manages its own isolated environments for each hook, so it does not need to share yours, and installing it with uv tool install or pipx means the same version is available across every repository you work in.

Can pre-commit run the test suite?

It can, and for most projects it should not. Tests are the slowest thing in the loop and the least likely to be affected by a single staged file, so running them before every commit trains people to bypass hooks. Run them in CI, where they can take the time they need, and keep the commit-time gate to checks that finish before you have taken your hands off the keyboard.

Should the hook configuration live in the template or the project?

In the template, so every new project starts with the same set — and then owned by the project, because a repository that cannot adjust its own linting will quietly stop running it. Treat the template as the sensible default rather than a policy: it saves the setup work, and a project that needs an extra rule or one fewer is free to say so in its own pyproject.toml.

Can I run only one hook while debugging?

Yes: pre-commit run ruff --all-files runs a single hook by id, and pre-commit run --files a.py limits it to specific paths. Both are much faster than a full pass when you are iterating on a configuration change, and neither requires committing anything.