# Setting Up pre-commit with uv for Python 3 Projects

## Overview

This document describes how to set up the **pre-commit** framework with
**uv** (the Python package/project manager from Astral) in a Python 3
project. It covers what pre-commit is, why to use it, and provides exact
steps to deploy it.

## What is pre-commit?

**pre-commit** is a tool that manages git pre-commit hooks declaratively.
Instead of manually writing scripts in `.git/hooks/`, you declare hooks in a
`.pre-commit-config.yaml` file that is committed to the repo. The framework
handles downloading, caching, and running hooks in isolated environments.

Key benefits:

- Hooks are version-controlled and shared across the team
- Each hook runs in its own isolated environment (no dependency conflicts)
- Hooks are sourced from external repos and pinned to specific versions
- Runs automatically on every `git commit`

**Website:** <https://pre-commit.com/>

## What is uv.lock and why commit it?

`uv.lock` is a cross-platform lockfile generated by uv that pins exact
dependency versions. The official uv documentation states it should be
committed to version control for reproducible builds. The lockfile ensures
all developers and CI get identical dependency trees.

- **Applications/services (deployed code):** Always commit `uv.lock`.
- **Libraries (published to PyPI):** Commit `uv.lock` for reproducible dev
  environments. Also test without `--locked` in CI to verify declared
  version ranges work.

## Prerequisites

- Python 3.8+
- uv installed (`curl -LsSf https://astral.sh/uv/install.sh | sh`)
- git initialized in the project
- A `pyproject.toml` managed by uv

## Installation Steps

### 1. Install pre-commit

Using uv as a tool (recommended — keeps it out of project dependencies):

```bash
uv tool install pre-commit
```

Alternatively, with pip:

```bash
pip install pre-commit
```

Verify installation:

```bash
pre-commit --version
```

### 2. Create `.pre-commit-config.yaml`

Create this file in the project root (same directory as `pyproject.toml`).

**This project uses black for formatting and ruff for linting only:**

```yaml
# .pre-commit-config.yaml
#
# Run `pre-commit install` after cloning to enable hooks.
# Run `pre-commit run --all-files` to check all files manually.
# Run `pre-commit autoupdate` to update hook versions.

repos:
  # --- General file hygiene ---
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-toml
      - id: check-added-large-files
      - id: check-merge-conflict

  # --- Python formatting (black) ---
  - repo: https://github.com/psf/black
    rev: 24.10.0
    hooks:
      - id: black

  # --- Python linting (ruff) ---
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.9.10
    hooks:
      - id: ruff
        args: [--fix]

  # --- Keep uv.lock in sync with pyproject.toml ---
  - repo: https://github.com/astral-sh/uv-pre-commit
    rev: 0.10.6
    hooks:
      - id: uv-lock
```

**Note on `rev` values:** These are current as of early 2026. Run
`pre-commit autoupdate` to fetch the latest versions.

### 3. Install the git hooks

From the project root:

```bash
pre-commit install
```

This writes a script to `.git/hooks/pre-commit` that invokes the framework.
This step must be run once per clone (it modifies `.git/` which is not
version-controlled).

### 4. (Optional) Run against all existing files

To retroactively check all files in the repo:

```bash
pre-commit run --all-files
```

### 5. Commit the configuration

```bash
git add .pre-commit-config.yaml
git commit -m "Add pre-commit configuration"
```

## How It Works at Commit Time

When a developer runs `git commit`:

1. Git triggers the pre-commit hook installed in `.git/hooks/`
2. The pre-commit framework reads `.pre-commit-config.yaml`
3. Each hook runs against the staged files (only files being committed)
4. If any hook fails, the commit is blocked and the developer sees the error
5. Some hooks auto-fix files (e.g., `trailing-whitespace`, `ruff --fix`).
   The developer reviews the fixes, re-stages, and commits again.

The `uv-lock` hook specifically checks if `pyproject.toml` has changed and,
if so, runs `uv lock` to regenerate `uv.lock` and stages the updated
lockfile.

## CI Complement: GitHub Actions

pre-commit runs locally to catch issues early. CI should enforce
correctness. Here is a minimal GitHub Actions workflow that complements the
local hooks:

```yaml
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install uv
        uses: astral-sh/setup-uv@v5

      - name: Verify lockfile is up to date
        run: uv lock --check

      - name: Install dependencies (exact lockfile)
        run: uv sync --locked

      - name: Lint
        run: uv run ruff check .

      - name: Format check
        run: uv run black --check .

      - name: Run tests
        run: uv run pytest
```

Key CI commands:

- `uv lock --check` — Fails if `uv.lock` is out of sync with
  `pyproject.toml`. Does not modify files.
- `uv sync --locked` — Installs exactly what's in `uv.lock`. Fails if
  lockfile is missing or stale.

## CI Complement: GitLab CI

GitLab CI runs the same checks as GitHub Actions. Use Astral's uv Docker
image with `UV_LINK_MODE: copy` (required for GitLab CI build directories):

```yaml
# .gitlab-ci.yml
variables:
  UV_VERSION: "0.10.7"
  UV_LINK_MODE: copy

.check_template: &check_definition
  image: ghcr.io/astral-sh/uv:${UV_VERSION}-python${PYTHON_VERSION}-trixie-slim
  variables:
    UV_LINK_MODE: copy
  before_script:
    - uv lock --check
    - uv sync --locked
  script:
    - uv run ruff check .
    - uv run black --check .
    - uv run pytest tests/ -v

check:python3.11:
  <<: *check_definition
  variables:
    PYTHON_VERSION: "3.11"

check:python3.12:
  <<: *check_definition
  variables:
    PYTHON_VERSION: "3.12"

check:python3.13:
  <<: *check_definition
  variables:
    PYTHON_VERSION: "3.13"
```

Optional: add caching keyed on `uv.lock` for faster runs, with `uv cache
prune --ci` at the end of the job.

## Files to Commit to Git

| File | Commit? | Notes |
|------|---------|-------|
| `pyproject.toml` | Yes | Project metadata and dependency declarations |
| `uv.lock` | Yes | Pinned dependency versions for reproducibility |
| `.pre-commit-config.yaml` | Yes | Hook configuration shared across team |
| `.venv/` | No | Auto-excluded by uv's internal `.gitignore` |
| `.git/hooks/` | No | Generated locally by `pre-commit install` |

## Common Commands Reference

| Command | Purpose |
|---------|---------|
| `pre-commit install` | Wire up git hooks (run once per clone) |
| `pre-commit run --all-files` | Run all hooks on all files |
| `pre-commit run ruff --all-files` | Run a specific hook on all files |
| `pre-commit autoupdate` | Update all hook `rev` values to latest |
| `pre-commit clean` | Clear the hook cache |
| `git commit --no-verify` | Skip hooks for this commit (escape hatch) |

## Onboarding New Contributors

Add this to your README or CONTRIBUTING.md:

```markdown
## Development Setup

1. Install uv: `curl -LsSf https://astral.sh/uv/install.sh | sh`
2. Install dependencies: `uv sync`
3. Install pre-commit hooks: `uv tool install pre-commit && pre-commit install`
4. Run tests: `uv run pytest tests/ -v`
```

## Customization Notes

- **Adding mypy:** Use `https://github.com/pre-commit/mirrors-mypy` as a
  hook repo. Note that mypy hooks need access to your project's
  dependencies, which can require additional configuration.
- **Adding pytest as a hook:** Generally not recommended — tests are slow
  and better suited for CI. Keep pre-commit hooks fast (<5 seconds total).
- **Skipping hooks for specific files:** Use the `exclude` key on a hook
  definition (regex pattern against file paths).
- **Hook stages:** By default hooks run on `pre-commit`. You can configure
  hooks to run on `pre-push` instead by setting `stages: [pre-push]` on the
  hook.
