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+.
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
└──────┘ └────────┘
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
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.
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:
- uses: vanandrew/difftrace@v1
with:
base: origin/develop| 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 |
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 |
| 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 |
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:
- 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 globaltest_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.
pip install difftraceOr with uv:
uv add difftrace --dev# 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
# Add custom root-level triggers
difftrace --root-trigger Dockerfile --root-trigger "config/"
# Debug logging
difftrace -vHuman-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
}JSON (multi-lock — entries qualified by workspace):
{
"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
| 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) |
-v / --verbose |
off | Enable debug logging |
--json,--names, and--pathsare mutually exclusive. If none are specified, human-readable output is used.
- Parse each
uv.lockto extract workspace members and their inter-package dependencies (external packages are excluded) - Diff
git diff --name-only base...HEADto get changed files - Route each changed file to the workspace whose root is the longest-matching prefix (single-lock: all files go to the one workspace)
- Map within each workspace: changed files to packages via longest source-path prefix matching
- Traverse the reverse dependency graph (BFS) per workspace to find all transitively affected packages, then union
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 ownuv.lock/pyproject.tomlmarks that workspace's packages as directly changed but doesn't fan out to sibling workspaces.
- 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
| 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.
MIT