Change detection for uv monorepos
Project description
difftrace
Change detection for 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.
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
with:
base: origin/main
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: 0is required on the checkout step so thatgit diffcan compare against the base ref. Without it, the shallow clone won't have enough history and difftrace will fail.
Action Inputs
| Input | Default | Description |
|---|---|---|
base |
origin/main |
Base ref to diff against |
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
pip install difftrace
Or with uv:
uv add difftrace --dev
CLI Usage
# 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):
{
"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--pathsare mutually exclusive. If none are specified, human-readable output is used.
How It Works
- Parse
uv.lockto extract workspace members and their inter-package dependencies (external packages are excluded) - Diff
git diff --name-only base...HEADto get changed files - Map changed files to packages via longest source-path prefix matching
- 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.pywon't incorrectly matchpackages/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
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file difftrace-1.0.3.tar.gz.
File metadata
- Download URL: difftrace-1.0.3.tar.gz
- Upload date:
- Size: 46.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
608ada4e4539f8c659204ca13b680e46e24336895ec37b8d67e1b7efac7cf12e
|
|
| MD5 |
fbf8501811afcd89718eb50e832cf7c7
|
|
| BLAKE2b-256 |
e44dde1dd37cb05ac61ad81f4bfb96a175634022d3f702373132bbf92609099b
|
Provenance
The following attestation bundles were made for difftrace-1.0.3.tar.gz:
Publisher:
release.yml on vanandrew/difftrace
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
difftrace-1.0.3.tar.gz -
Subject digest:
608ada4e4539f8c659204ca13b680e46e24336895ec37b8d67e1b7efac7cf12e - Sigstore transparency entry: 956012424
- Sigstore integration time:
-
Permalink:
vanandrew/difftrace@c7eaa225f89655f2e025f414156e6f68ab19eae7 -
Branch / Tag:
refs/tags/v1.0.3 - Owner: https://github.com/vanandrew
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c7eaa225f89655f2e025f414156e6f68ab19eae7 -
Trigger Event:
release
-
Statement type:
File details
Details for the file difftrace-1.0.3-py3-none-any.whl.
File metadata
- Download URL: difftrace-1.0.3-py3-none-any.whl
- Upload date:
- Size: 12.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1167b9140fbc5620c98a0c274e2682fe1cf272a147ba7d38c482d1525e2cf6cb
|
|
| MD5 |
a3f04695b22ea69764910fa2c459b6cc
|
|
| BLAKE2b-256 |
f103517a0269a6e0bd8db56653bc3c2edbcbbd53676ee88d66cfbb4e9b989f3e
|
Provenance
The following attestation bundles were made for difftrace-1.0.3-py3-none-any.whl:
Publisher:
release.yml on vanandrew/difftrace
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
difftrace-1.0.3-py3-none-any.whl -
Subject digest:
1167b9140fbc5620c98a0c274e2682fe1cf272a147ba7d38c482d1525e2cf6cb - Sigstore transparency entry: 956012425
- Sigstore integration time:
-
Permalink:
vanandrew/difftrace@c7eaa225f89655f2e025f414156e6f68ab19eae7 -
Branch / Tag:
refs/tags/v1.0.3 - Owner: https://github.com/vanandrew
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c7eaa225f89655f2e025f414156e6f68ab19eae7 -
Trigger Event:
release
-
Statement type: