Metadata-Version: 2.4
Name: difftrace
Version: 1.4.0
Summary: Change detection for uv monorepos
Author: Andrew Van
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.11
Description-Content-Type: text/markdown

[![CI](https://github.com/vanandrew/difftrace/actions/workflows/ci.yml/badge.svg)](https://github.com/vanandrew/difftrace/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/vanandrew/difftrace/graph/badge.svg?token=OukcZItBZo)](https://codecov.io/gh/vanandrew/difftrace)
[![PyPI - Version](https://img.shields.io/pypi/v/difftrace?style=flat)](https://pypi.org/project/difftrace/)
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/difftrace?style=flat)](https://pypi.org/project/difftrace/)
[![PyPI - License](https://img.shields.io/pypi/l/difftrace?style=flat)](https://pypi.org/project/difftrace/)

# difftrace

Change detection for [uv](https://docs.astral.sh/uv/) monorepos. Parses `uv.lock` to build the workspace dependency graph, maps `git diff` output to packages, and BFS-traverses reverse dependencies to find all transitively affected packages.

**Zero runtime dependencies** — stdlib only. Python 3.11+.

## Why?

In a monorepo with many packages, running every pipeline on every PR is slow and wasteful. difftrace figures out *which* packages are actually affected by a change — both directly (files changed inside the package) and transitively (a dependency of that package changed) — so your CI only builds, tests, lints, and deploys what matters.

```
packages/shared/lib.py changed
        │
        ▼
   ┌─────────┐
   │ shared  │  ← directly changed
   └─────────┘
    ▲        ▲
    │        │
┌──────┐ ┌────────┐
│  api │ │ worker │  ← transitively affected
└──────┘ └────────┘
```

## GitHub Action

difftrace ships as a composite GitHub Action so you can use it directly in your workflows. It handles Python setup, installation, and output parsing for you.

```yaml
jobs:
  detect:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.diff.outputs.matrix }}
      has_affected: ${{ steps.diff.outputs.has_affected }}
      test_all: ${{ steps.diff.outputs.test_all }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # required so git diff can see the full history
      - uses: vanandrew/difftrace@v1
        id: diff

  test:
    needs: detect
    if: needs.detect.outputs.has_affected == 'true'
    runs-on: ubuntu-latest
    strategy:
      matrix: ${{ fromJson(needs.detect.outputs.matrix) }}
      fail-fast: false
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v5
      - name: Run pytest
        run: uv run --directory packages/${{ matrix.package }} pytest

  build:
    needs: [detect, test]
    if: needs.detect.outputs.has_affected == 'true'
    runs-on: ubuntu-latest
    strategy:
      matrix: ${{ fromJson(needs.detect.outputs.matrix) }}
    steps:
      - uses: actions/checkout@v4
      - name: Build image
        run: |
          docker build \
            -f packages/${{ matrix.package }}/Dockerfile \
            -t ${{ matrix.package }}:${{ github.sha }} .

  deploy:
    needs: [detect, build]
    if: github.ref == 'refs/heads/main' && needs.detect.outputs.has_affected == 'true'
    runs-on: ubuntu-latest
    strategy:
      matrix: ${{ fromJson(needs.detect.outputs.matrix) }}
    steps:
      - uses: actions/checkout@v4
      - name: Deploy ${{ matrix.package }}
        run: echo "Deploying ${{ matrix.package }}"
```

The `matrix.package` output works with any per-package step — tests, builds, linting, deploys, etc. The example above shows a typical pipeline where each stage gates the next: **detect** → **test** → **build** → **deploy**. The `build` job only runs for packages that pass tests, and `deploy` only runs on the `main` branch.

> **Note:** `fetch-depth: 0` is required on the checkout step so that `git diff` can compare against the base ref. Without it, the shallow clone won't have enough history and difftrace will fail.

### Base Ref Auto-Detection

When no explicit `base` is provided, the action automatically picks the right ref based on the GitHub event:

| Event | Base ref used |
|-------|---------------|
| `pull_request` | `origin/<PR target branch>` |
| `push` | `github.event.before` (the pre-push SHA) |
| Other / fallback | `origin/<default branch>` |

This matters for **push-to-main workflows**: by the time the action runs, `origin/main` already points to the just-pushed commit, so diffing against it would produce an empty diff. The action avoids this by using the pre-push SHA instead.

You can always override with an explicit `base`:

```yaml
- uses: vanandrew/difftrace@v1
  with:
    base: origin/develop
```

### Action Inputs

| Input | Default | Description |
|-------|---------|-------------|
| `base` | auto-detect | Base ref to diff against (see above) |
| `lock-file` | `uv.lock` | Path(s) to uv lock file(s). Newline- or comma-separated for multi-workspace repos |
| `exclude-packages` | — | Comma-separated list of packages to exclude |
| `exclude-extensions` | — | Comma-separated list of file extensions to ignore in the diff (e.g. `.md,.txt`). Excluded files don't contribute to package mapping or root triggers |
| `no-dev` | `false` | Exclude dev dependencies from the dependency graph |
| `no-optional` | `false` | Exclude optional dependencies from the dependency graph |
| `direct-only` | `false` | Only output directly changed packages, skip transitive dependents |
| `test-all` | `false` | Force testing all packages, skipping git diff entirely |
| `root-triggers` | — | Comma-separated list of additional trigger patterns (e.g. `Dockerfile,docker/`) |
| `verbose` | `false` | Enable debug logging to stderr |

### Action Outputs

| Output | Description |
|--------|-------------|
| `affected` | JSON array of affected package names. In multi-lock mode, names are qualified as `workspace/name` |
| `matrix` | Single-lock: `{"package": [...]}`. Multi-lock: `{"include": [{"package","workspace"}, ...]}` |
| `has_affected` | `"true"` or `"false"` |
| `test_all` | `"true"` if a root trigger fires or `test-all` input is set. Single-lock: any trigger match (git-root or workspace-root). Multi-lock: only git-root triggers; sub-workspace triggers stay scoped to that workspace |

### Multi-Workspace Repos

If your monorepo has sub-projects with incompatible dependencies or different Python versions, each will have its own `uv.lock`. Pass them all as newline-separated paths:

```yaml
- uses: vanandrew/difftrace@v1
  id: diff
  with:
    lock-file: |
      python/uv.lock
      python2/uv.lock

- name: Test
  strategy:
    matrix: ${{ fromJson(steps.diff.outputs.matrix) }}
  run: |
    uv run --directory ${{ matrix.workspace }} pytest packages/${{ matrix.package }}
```

difftrace routes each changed file to the workspace whose root is the longest-matching prefix, then runs the BFS per-workspace and unions the results. Packages with colliding names are disambiguated by their workspace label.

Trigger scope in multi-lock mode:

- **Git-root triggers** (top-level `pyproject.toml`, `uv.lock`, `.github/`) fan out to every workspace via global `test_all`.
- **Sub-workspace triggers** (e.g. `python/uv.lock`, `python2/pyproject.toml`) mark every package in *that* workspace as directly changed, but don't force a full test run across sibling workspaces.

## Installation

```bash
pip install difftrace
```

Or with uv:

```bash
uv add difftrace --dev
```

## CLI Usage

```bash
# Show affected packages (human-readable)
difftrace --base origin/main

# JSON output for CI pipelines
difftrace --base origin/main --json

# Just the package names, one per line (useful for scripting)
difftrace --names

# Just the source paths, one per line
difftrace --paths

# Only directly changed packages (skip transitive dependents)
difftrace --direct-only

# Force testing all packages (skip git diff entirely)
difftrace --test-all

# Show which files mapped to which packages
difftrace --detailed

# Custom lock file path
difftrace --lock-file path/to/uv.lock

# Multiple lock files (multi-workspace monorepos)
difftrace --lock-file python/uv.lock --lock-file python2/uv.lock

# Exclude dev/optional dependencies from the graph
difftrace --no-dev --no-optional

# Exclude specific packages from the output
difftrace --exclude docs --exclude examples

# Ignore docs-only changes (matched files don't trigger tests)
difftrace --exclude-ext .md --exclude-ext .rst

# Add custom root-level triggers
difftrace --root-trigger Dockerfile --root-trigger "config/"

# Debug logging
difftrace -v
```

### Output Formats

**Human-readable** (default):
```
Affected packages (3):
  - shared (direct)
  - api (transitive)
  - worker (transitive)
```

**Human-readable with `--detailed`**:
```
Changed files (2):
  packages/shared/lib.py -> shared
  README.md -> (root/unmatched)

Affected packages (3):
  - shared (direct)
  - api (transitive)
  - worker (transitive)
```

**JSON** (`--json`):
```json
{
  "directly_changed": ["shared"],
  "affected": ["api", "shared", "worker"],
  "test_all": false
}
```

**JSON** (multi-lock — entries qualified by workspace):
```json
{
  "directly_changed": [{"name": "shared", "workspace": "python"}],
  "affected": [
    {"name": "api",    "workspace": "python"},
    {"name": "shared", "workspace": "python"},
    {"name": "worker", "workspace": "python2"}
  ],
  "test_all": false,
  "workspaces": ["python", "python2"]
}
```

**Names** (`--names`):
```
api
shared
worker
```

**Paths** (`--paths`):
```
packages/api
packages/shared
packages/worker
```

### All Flags

| Flag | Default | Description |
|------|---------|-------------|
| `--base` | `origin/main` | Base git ref to diff against |
| `--lock-file` | `uv.lock` | Path to uv lock file (repeatable for multi-workspace repos) |
| `--json` | off | Output as JSON |
| `--names` | off | Output affected package names, one per line |
| `--paths` | off | Output affected source paths, one per line |
| `--direct-only` | off | Only report directly changed packages |
| `--test-all` | off | Force testing all packages, skip git diff entirely |
| `--detailed` | off | Include file-to-package mappings in output |
| `--no-dev` | off | Exclude dev dependencies from the graph |
| `--no-optional` | off | Exclude optional dependencies from the graph |
| `--root-trigger` | — | Additional root-level trigger patterns (repeatable) |
| `--exclude` | — | Exclude a package from the affected set (repeatable) |
| `--exclude-ext` | — | Ignore changed files with this extension (repeatable, e.g. `.md`). Matched files don't contribute to package mapping or root triggers |
| `-v` / `--verbose` | off | Enable debug logging |

> `--json`, `--names`, and `--paths` are mutually exclusive. If none are specified, human-readable output is used.

## How It Works

1. **Parse** each `uv.lock` to extract workspace members and their inter-package dependencies (external packages are excluded)
2. **Diff** `git diff --name-only base...HEAD` to get changed files
3. **Route** each changed file to the workspace whose root is the longest-matching prefix (single-lock: all files go to the one workspace)
4. **Map** within each workspace: changed files to packages via longest source-path prefix matching
5. **Traverse** the reverse dependency graph (BFS) per workspace to find all transitively affected packages, then union

### Root Triggers

Certain files indicate a config change broad enough to affect every package. By default, changes to `pyproject.toml`, `uv.lock`, or anything under `.github/` are treated as triggers. You can add custom patterns with `--root-trigger`.

Scope depends on the lock count:

- **Single-lock** — any trigger match (whether at git root or nested workspace root) sets `test_all: true`.
- **Multi-lock** — only triggers at the git root set global `test_all`. A sub-workspace's own `uv.lock` / `pyproject.toml` marks that workspace's packages as directly changed but doesn't fan out to sibling workspaces.

### Edge Cases

- **Nested workspaces** — workspace root != git root? Paths are normalized automatically
- **Virtual root packages** — skipped during file matching to avoid false positives (a virtual root at `.` would otherwise match every file)
- **Cycles** — BFS uses a visited set to prevent infinite loops
- **Longest prefix matching** — `packages/api-extra/foo.py` won't incorrectly match `packages/api`

### Compatibility

| Component | Supported |
|-----------|-----------|
| Python | 3.11+ |
| uv lock format | version 1 (uv 0.4.x – latest) |

CI tests run against uv 0.4.30, 0.6.14, and the latest release.

## License

MIT
