Skip to content

feat: support multiple uv.lock files per repo#8

Merged
vanandrew merged 8 commits into
mainfrom
multi-lock-support
Apr 17, 2026
Merged

feat: support multiple uv.lock files per repo#8
vanandrew merged 8 commits into
mainfrom
multi-lock-support

Conversation

@vanandrew

Copy link
Copy Markdown
Owner

Summary

  • Adds support for monorepos with multiple uv.lock files (sub-projects with incompatible deps or different Python versions).
  • --lock-file is now repeatable; single-lock behavior is unchanged and the JSON shape stays byte-identical when one lock is in play.
  • Multi-lock output qualifies affected entries as {"name", "workspace"}; matrix output switches to GitHub's include form so consumers can do uv run --directory ${{ matrix.workspace }} ....
  • Global test_all still fires on git-root-level triggers (pyproject.toml, uv.lock, .github/). A sub-workspace's own uv.lock/pyproject.toml only marks that workspace's packages as directly changed.

Design

  • New Workspace bundle in graph.py pairs a lock path with its DependencyGraph.
  • New route_files_to_workspaces routes 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.py orchestrates 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.yml accepts newline- or comma-separated lock paths and builds a bash array of --lock-file args.

Test plan

  • All 126 existing tests pass unchanged (backwards-compat guarantee).
  • 25 new tests cover: load_workspaces, route_files_to_workspaces (longest-prefix, collisions, leftover), multi-lock CLI orchestration (routing, collisions, sub-workspace lock change, root-trigger global test_all), multi-lock output formats (--json, --names, --paths, human), and multi-lock action matrix shape.
  • Coverage stays at 95% (uv run pytest --cov=difftrace).
  • action.yml validated via yaml.safe_load.

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

codecov Bot commented Apr 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.08%. Comparing base (e6de2bc) to head (0ac832f).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

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.
📢 Have feedback on the report? Share it here.

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(...) }}`.
@vanandrew vanandrew merged commit 01c0d3c into main Apr 17, 2026
41 checks passed
@vanandrew vanandrew deleted the multi-lock-support branch April 22, 2026 19:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant