feat: support multiple uv.lock files per repo#8
Merged
Conversation
Monorepos with sub-projects that have incompatible dependencies or
different Python versions need their own uv.lock. Today difftrace
assumes a single lock and silently misses packages outside its
workspace.
- `--lock-file` is now repeatable; default single-lock behavior is
unchanged and the JSON output shape stays byte-identical when one
lock is in play.
- Adds `Workspace` + `load_workspaces()` in graph.py and
`route_files_to_workspaces()` in diff.py (longest-prefix routing
with a leftover bucket for git-root-level files).
- Multi-lock output qualifies affected entries as
`{"name", "workspace"}`; matrix switches to GitHub's `include` form.
- Global `test_all` still fires on git-root-level triggers; a
sub-workspace's own uv.lock/pyproject.toml only scopes to that
workspace.
- Action accepts newline- or comma-separated lock paths and emits the
appropriate matrix shape.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #8 +/- ##
==========================================
+ Coverage 96.38% 98.08% +1.69%
==========================================
Files 6 6
Lines 332 470 +138
==========================================
+ Hits 320 461 +141
+ Misses 12 9 -3 ☔ View full report in Codecov by Sentry. |
The root-trigger action test exercised a single lock at a nested path (tests/fixtures/workspace/uv.lock). Its pyproject.toml sits at the workspace root but not at the git root, so the new routing logic stopped setting global test_all — a regression vs. today's behavior. Now in single-lock mode, a workspace-relative trigger propagates to the global test_all flag (matching legacy behavior). In multi-lock, triggers stay workspace-scoped so one sub-workspace's config change doesn't force testing every sibling workspace. Also adds targeted tests for: - _normalize_lock_arg / _workspace_label / _source_display_path - --json --detailed output combination - multi-lock --detailed file mapping - route_files_to_workspaces with a workspace outside git root - single-lock nested pyproject.toml trigger regression test Coverage on the patch rises from 93% to 98%.
Closes the gaps identified in review:
- --direct-only in multi-lock stops transitive dependents per workspace
- --exclude removes by plain name across every workspace (documents the
collision semantic so it won't surprise future readers)
- Virtual root packages inside one sub-workspace are skipped in both
affected and --detailed file mapping (brings cli.py to 100% coverage)
- Empty-affected multi-lock short-circuits to has_affected=false so
consumers never hand {"include": []} to GitHub Actions
- Root+nested workspace layout: exercises route_files_to_workspaces rel=='' branch in a multi-lock setting (arises when a once-single-lock repo splits out a sub-project into its own workspace) - Custom --root-trigger scope: a git-root Dockerfile fires global test_all, a sub-workspace Dockerfile only scopes to that workspace - --lock-file order independence: result is deterministic regardless of argument order - Three sibling workspaces: routing and union scale beyond the N=2 case - Multi-lock --no-dev / --no-optional: graph-level flags apply per workspace, blocking transitive pull-in through optional/dev edges
Adds a multi-workspace fixture under tests/fixtures/multi-workspace
(python/ + python2/, each with a uv.lock; both define a colliding 'api'
package to exercise disambiguation) and two new workflow jobs:
- test-action-multi: matrix of four scenarios (leaf, transitive,
cross-workspace collision, sub-workspace lock change). Asserts the
action emits the include-form matrix and each expected (package,
workspace) pair is present.
- test-action-multi-downstream-{detect,consume}: runs the README's
two-job pattern — a detect job outputs the matrix, a downstream
strategy.matrix job consumes it via fromJson and confirms both
matrix.package and matrix.workspace populate end-to-end.
Prior state: most of action.yml's bash argument-passing was unit-tested
through the CLI but never exercised end-to-end. The action was the most
consumer-facing surface yet the least tested in its own workflow.
New jobs:
- test-action-inputs: matrix with seven scenarios covering every action
input that has non-trivial bash handling — exclude-packages (single &
comma-multi), no-dev, no-optional, direct-only, custom root-triggers,
and comma-separated lock-file (the non-newline branch of the input
normalizer).
- test-action-missing-lock: negative test. continue-on-error captures the
failure and asserts steps.diff.outcome == 'failure' so the
"Lock file not found" exit path is actually confirmed to fire.
- test-action-single-downstream-{detect,consume}: mirrors the multi-lock
downstream pair so both matrix shapes — {"package":[...]} and
{"include":[...]} — are proven consumable via
`strategy.matrix: ${{ fromJson(...) }}`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
uv.lockfiles (sub-projects with incompatible deps or different Python versions).--lock-fileis now repeatable; single-lock behavior is unchanged and the JSON shape stays byte-identical when one lock is in play.{"name", "workspace"}; matrix output switches to GitHub'sincludeform so consumers can douv run --directory ${{ matrix.workspace }} ....test_allstill fires on git-root-level triggers (pyproject.toml,uv.lock,.github/). A sub-workspace's ownuv.lock/pyproject.tomlonly marks that workspace's packages as directly changed.Design
Workspacebundle in graph.py pairs a lock path with itsDependencyGraph.route_files_to_workspacesroutes changed files to the workspace whose root is the longest-matching prefix; files matching no workspace fall through to a leftover bucket used for global trigger detection.cli.pyorchestrates per-workspace file-to-package matching and BFS, then unions the results. Single-lock branches keep today's flat result dict; multi-lock switches to qualified dicts.action.ymlaccepts newline- or comma-separated lock paths and builds a bash array of--lock-fileargs.Test plan
load_workspaces,route_files_to_workspaces(longest-prefix, collisions, leftover), multi-lock CLI orchestration (routing, collisions, sub-workspace lock change, root-trigger globaltest_all), multi-lock output formats (--json,--names,--paths, human), and multi-lock action matrix shape.uv run pytest --cov=difftrace).action.ymlvalidated viayaml.safe_load.