https://github.com/vanandrew/difftrace
Change detection for uv monorepos. Conditionally test or build packages.
https://github.com/vanandrew/difftrace
build-optimization change-detection ci-cd dependency-graph devtools git-diff github-actions monorepo python uv
Last synced: 29 days ago
JSON representation
Change detection for uv monorepos. Conditionally test or build packages.
- Host: GitHub
- URL: https://github.com/vanandrew/difftrace
- Owner: vanandrew
- License: mit
- Created: 2026-02-16T17:22:33.000Z (3 months ago)
- Default Branch: main
- Last Pushed: 2026-04-13T21:38:01.000Z (30 days ago)
- Last Synced: 2026-04-13T23:27:10.398Z (30 days ago)
- Topics: build-optimization, change-detection, ci-cd, dependency-graph, devtools, git-diff, github-actions, monorepo, python, uv
- Language: Python
- Homepage:
- Size: 79.1 KB
- Stars: 1
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
[](https://github.com/vanandrew/difftrace/actions/workflows/ci.yml)
[](https://codecov.io/gh/vanandrew/difftrace)
[](https://pypi.org/project/difftrace/)
[](https://pypi.org/project/difftrace/)
[](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/` |
| `push` | `github.event.before` (the pre-push SHA) |
| Other / fallback | `origin/` |
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 to uv lock file |
| `exclude-packages` | — | Comma-separated list of packages to exclude |
| `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 |
| `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 |
| `matrix` | `{"package": [...]}` for `strategy.matrix` |
| `has_affected` | `"true"` or `"false"` |
| `test_all` | `"true"` if root config changed |
## 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
# Show which files mapped to which packages
difftrace --detailed
# Custom lock file path
difftrace --lock-file path/to/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
# 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
}
```
**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 |
| `--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 |
| `--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) |
| `-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** `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. **Map** changed files to packages via longest source-path prefix matching
4. **Traverse** the reverse dependency graph (BFS) to find all transitively affected packages
### Root Triggers
Certain files at the root of your workspace indicate a change that affects *all* packages. By default, changes to `pyproject.toml`, `uv.lock`, or anything under `.github/` will set `test_all: true`. You can add custom triggers with `--root-trigger`.
### 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