{"id":44837801,"url":"https://github.com/vanandrew/difftrace","last_synced_at":"2026-04-14T06:10:20.061Z","repository":{"id":338893631,"uuid":"1159398704","full_name":"vanandrew/difftrace","owner":"vanandrew","description":"Change detection for uv monorepos. Conditionally test or build packages.","archived":false,"fork":false,"pushed_at":"2026-04-13T21:38:01.000Z","size":81,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-13T23:27:10.398Z","etag":null,"topics":["build-optimization","change-detection","ci-cd","dependency-graph","devtools","git-diff","github-actions","monorepo","python","uv"],"latest_commit_sha":null,"homepage":"","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/vanandrew.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-02-16T17:22:33.000Z","updated_at":"2026-04-13T21:35:42.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/vanandrew/difftrace","commit_stats":null,"previous_names":["vanandrew/difftrace"],"tags_count":6,"template":false,"template_full_name":null,"purl":"pkg:github/vanandrew/difftrace","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vanandrew%2Fdifftrace","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vanandrew%2Fdifftrace/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vanandrew%2Fdifftrace/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vanandrew%2Fdifftrace/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/vanandrew","download_url":"https://codeload.github.com/vanandrew/difftrace/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vanandrew%2Fdifftrace/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31784313,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-14T02:24:21.117Z","status":"ssl_error","status_checked_at":"2026-04-14T02:24:20.627Z","response_time":153,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["build-optimization","change-detection","ci-cd","dependency-graph","devtools","git-diff","github-actions","monorepo","python","uv"],"created_at":"2026-02-17T02:13:46.781Z","updated_at":"2026-04-14T06:10:20.055Z","avatar_url":"https://github.com/vanandrew.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"[![CI](https://github.com/vanandrew/difftrace/actions/workflows/ci.yml/badge.svg)](https://github.com/vanandrew/difftrace/actions/workflows/ci.yml)\n[![codecov](https://codecov.io/gh/vanandrew/difftrace/graph/badge.svg?token=OukcZItBZo)](https://codecov.io/gh/vanandrew/difftrace)\n[![PyPI - Version](https://img.shields.io/pypi/v/difftrace?style=flat)](https://pypi.org/project/difftrace/)\n[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/difftrace?style=flat)](https://pypi.org/project/difftrace/)\n[![PyPI - License](https://img.shields.io/pypi/l/difftrace?style=flat)](https://pypi.org/project/difftrace/)\n\n# difftrace\n\nChange 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.\n\n**Zero runtime dependencies** — stdlib only. Python 3.11+.\n\n## Why?\n\nIn 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.\n\n```\npackages/shared/lib.py changed\n        │\n        ▼\n   ┌─────────┐\n   │ shared  │  ← directly changed\n   └─────────┘\n    ▲        ▲\n    │        │\n┌──────┐ ┌────────┐\n│  api │ │ worker │  ← transitively affected\n└──────┘ └────────┘\n```\n\n## GitHub Action\n\ndifftrace 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.\n\n```yaml\njobs:\n  detect:\n    runs-on: ubuntu-latest\n    outputs:\n      matrix: ${{ steps.diff.outputs.matrix }}\n      has_affected: ${{ steps.diff.outputs.has_affected }}\n      test_all: ${{ steps.diff.outputs.test_all }}\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0  # required so git diff can see the full history\n      - uses: vanandrew/difftrace@v1\n        id: diff\n\n  test:\n    needs: detect\n    if: needs.detect.outputs.has_affected == 'true'\n    runs-on: ubuntu-latest\n    strategy:\n      matrix: ${{ fromJson(needs.detect.outputs.matrix) }}\n      fail-fast: false\n    steps:\n      - uses: actions/checkout@v4\n      - uses: astral-sh/setup-uv@v5\n      - name: Run pytest\n        run: uv run --directory packages/${{ matrix.package }} pytest\n\n  build:\n    needs: [detect, test]\n    if: needs.detect.outputs.has_affected == 'true'\n    runs-on: ubuntu-latest\n    strategy:\n      matrix: ${{ fromJson(needs.detect.outputs.matrix) }}\n    steps:\n      - uses: actions/checkout@v4\n      - name: Build image\n        run: |\n          docker build \\\n            -f packages/${{ matrix.package }}/Dockerfile \\\n            -t ${{ matrix.package }}:${{ github.sha }} .\n\n  deploy:\n    needs: [detect, build]\n    if: github.ref == 'refs/heads/main' \u0026\u0026 needs.detect.outputs.has_affected == 'true'\n    runs-on: ubuntu-latest\n    strategy:\n      matrix: ${{ fromJson(needs.detect.outputs.matrix) }}\n    steps:\n      - uses: actions/checkout@v4\n      - name: Deploy ${{ matrix.package }}\n        run: echo \"Deploying ${{ matrix.package }}\"\n```\n\nThe `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.\n\n\u003e **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.\n\n### Base Ref Auto-Detection\n\nWhen no explicit `base` is provided, the action automatically picks the right ref based on the GitHub event:\n\n| Event | Base ref used |\n|-------|---------------|\n| `pull_request` | `origin/\u003cPR target branch\u003e` |\n| `push` | `github.event.before` (the pre-push SHA) |\n| Other / fallback | `origin/\u003cdefault branch\u003e` |\n\nThis 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.\n\nYou can always override with an explicit `base`:\n\n```yaml\n- uses: vanandrew/difftrace@v1\n  with:\n    base: origin/develop\n```\n\n### Action Inputs\n\n| Input | Default | Description |\n|-------|---------|-------------|\n| `base` | auto-detect | Base ref to diff against (see above) |\n| `lock-file` | `uv.lock` | Path to uv lock file |\n| `exclude-packages` | — | Comma-separated list of packages to exclude |\n| `no-dev` | `false` | Exclude dev dependencies from the dependency graph |\n| `no-optional` | `false` | Exclude optional dependencies from the dependency graph |\n| `direct-only` | `false` | Only output directly changed packages, skip transitive dependents |\n| `root-triggers` | — | Comma-separated list of additional trigger patterns (e.g. `Dockerfile,docker/`) |\n| `verbose` | `false` | Enable debug logging to stderr |\n\n### Action Outputs\n\n| Output | Description |\n|--------|-------------|\n| `affected` | JSON array of affected package names |\n| `matrix` | `{\"package\": [...]}` for `strategy.matrix` |\n| `has_affected` | `\"true\"` or `\"false\"` |\n| `test_all` | `\"true\"` if root config changed |\n\n## Installation\n\n```bash\npip install difftrace\n```\n\nOr with uv:\n\n```bash\nuv add difftrace --dev\n```\n\n## CLI Usage\n\n```bash\n# Show affected packages (human-readable)\ndifftrace --base origin/main\n\n# JSON output for CI pipelines\ndifftrace --base origin/main --json\n\n# Just the package names, one per line (useful for scripting)\ndifftrace --names\n\n# Just the source paths, one per line\ndifftrace --paths\n\n# Only directly changed packages (skip transitive dependents)\ndifftrace --direct-only\n\n# Show which files mapped to which packages\ndifftrace --detailed\n\n# Custom lock file path\ndifftrace --lock-file path/to/uv.lock\n\n# Exclude dev/optional dependencies from the graph\ndifftrace --no-dev --no-optional\n\n# Exclude specific packages from the output\ndifftrace --exclude docs --exclude examples\n\n# Add custom root-level triggers\ndifftrace --root-trigger Dockerfile --root-trigger \"config/\"\n\n# Debug logging\ndifftrace -v\n```\n\n### Output Formats\n\n**Human-readable** (default):\n```\nAffected packages (3):\n  - shared (direct)\n  - api (transitive)\n  - worker (transitive)\n```\n\n**Human-readable with `--detailed`**:\n```\nChanged files (2):\n  packages/shared/lib.py -\u003e shared\n  README.md -\u003e (root/unmatched)\n\nAffected packages (3):\n  - shared (direct)\n  - api (transitive)\n  - worker (transitive)\n```\n\n**JSON** (`--json`):\n```json\n{\n  \"directly_changed\": [\"shared\"],\n  \"affected\": [\"api\", \"shared\", \"worker\"],\n  \"test_all\": false\n}\n```\n\n**Names** (`--names`):\n```\napi\nshared\nworker\n```\n\n**Paths** (`--paths`):\n```\npackages/api\npackages/shared\npackages/worker\n```\n\n### All Flags\n\n| Flag | Default | Description |\n|------|---------|-------------|\n| `--base` | `origin/main` | Base git ref to diff against |\n| `--lock-file` | `uv.lock` | Path to uv lock file |\n| `--json` | off | Output as JSON |\n| `--names` | off | Output affected package names, one per line |\n| `--paths` | off | Output affected source paths, one per line |\n| `--direct-only` | off | Only report directly changed packages |\n| `--detailed` | off | Include file-to-package mappings in output |\n| `--no-dev` | off | Exclude dev dependencies from the graph |\n| `--no-optional` | off | Exclude optional dependencies from the graph |\n| `--root-trigger` | — | Additional root-level trigger patterns (repeatable) |\n| `--exclude` | — | Exclude a package from the affected set (repeatable) |\n| `-v` / `--verbose` | off | Enable debug logging |\n\n\u003e `--json`, `--names`, and `--paths` are mutually exclusive. If none are specified, human-readable output is used.\n\n## How It Works\n\n1. **Parse** `uv.lock` to extract workspace members and their inter-package dependencies (external packages are excluded)\n2. **Diff** `git diff --name-only base...HEAD` to get changed files\n3. **Map** changed files to packages via longest source-path prefix matching\n4. **Traverse** the reverse dependency graph (BFS) to find all transitively affected packages\n\n### Root Triggers\n\nCertain 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`.\n\n### Edge Cases\n\n- **Nested workspaces** — workspace root != git root? Paths are normalized automatically\n- **Virtual root packages** — skipped during file matching to avoid false positives (a virtual root at `.` would otherwise match every file)\n- **Cycles** — BFS uses a visited set to prevent infinite loops\n- **Longest prefix matching** — `packages/api-extra/foo.py` won't incorrectly match `packages/api`\n\n### Compatibility\n\n| Component | Supported |\n|-----------|-----------|\n| Python | 3.11+ |\n| uv lock format | version 1 (uv 0.4.x – latest) |\n\nCI tests run against uv 0.4.30, 0.6.14, and the latest release.\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fvanandrew%2Fdifftrace","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fvanandrew%2Fdifftrace","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fvanandrew%2Fdifftrace/lists"}