Skip to main content

Change detection for uv monorepos

Project description

CI codecov PyPI - Version PyPI - Python Version PyPI - License

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

  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: detecttestbuilddeploy. 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:

- 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
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:

- 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

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

# 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 -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
}

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

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)
-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 matchingpackages/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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

difftrace-1.2.0.tar.gz (62.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

difftrace-1.2.0-py3-none-any.whl (16.3 kB view details)

Uploaded Python 3

File details

Details for the file difftrace-1.2.0.tar.gz.

File metadata

  • Download URL: difftrace-1.2.0.tar.gz
  • Upload date:
  • Size: 62.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for difftrace-1.2.0.tar.gz
Algorithm Hash digest
SHA256 ee980f7a66d02277150eb52e28d6f237de59e1ed8debeca3682019530af97a48
MD5 d749cf00884c3c0ee34f358d7891e22e
BLAKE2b-256 f853ffa937303239eec9544ee17144d52b489619849a85049dd4be5200044618

See more details on using hashes here.

Provenance

The following attestation bundles were made for difftrace-1.2.0.tar.gz:

Publisher: release.yml on vanandrew/difftrace

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file difftrace-1.2.0-py3-none-any.whl.

File metadata

  • Download URL: difftrace-1.2.0-py3-none-any.whl
  • Upload date:
  • Size: 16.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for difftrace-1.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 f00d485b6d079edeaaa50f5efcec8511884f0e9739c755e0bea1bfeaf3211c52
MD5 f4f228a4598fb9d1add7c423e9828ab6
BLAKE2b-256 46e9d4212107153392c5b7b749bff41746c01718a43835bc2b43bc7b404a061b

See more details on using hashes here.

Provenance

The following attestation bundles were made for difftrace-1.2.0-py3-none-any.whl:

Publisher: release.yml on vanandrew/difftrace

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page