uv-lock-merge: a git merge driver for uv.lock

A small tool that merges uv.lock files package by package during git merges and rebases, so both branches' locked versions survive and the result is written in uv's own layout.

The problem

When two branches of a Python project managed with uv both change dependencies, uv.lock often conflicts. Both branches add entries to the project's own dependencies and requires-dist lists, new packages land next to each other in sorted order, or both upgrade the same package. uv cannot read a lockfile with conflict markers, so someone has to fix a generated file by hand.

The usual workaround is to take one side's lockfile and run uv lock again. That works, but it keeps only that side's pins. An upgrade made on the other branch, for example of a transitive dependency, quietly goes back to the old version.

Others have the same problem:

  • astral-sh/uv#11185, "Automatic resolution of merge conflicts in lockfiles", open since February 2025, 28 reactions, and astral-sh/uv#5633, "Document how to resolve uv.lock merge conflicts", open since July 2024, 22 reactions.
  • A GitHub code search for uv.lock merge= returned 95 repositories that register their own driver (some are forks of one project). The ones I read keep one side and run uv lock.
  • Poetry has the same issue open since 2018 (python-poetry/poetry#496, 124 reactions). yarn, npm and Deno resolve such conflicts themselves.

How it works

uv-lock-merge is registered as a git merge driver for uv.lock, so git calls it with the base, ours and theirs versions whenever both sides changed the file. It parses all three with tomllib and merges them:

  • The header (requires-python, markers, [options], [manifest]) is merged key by key. Lists are merged as sets: added on either side means added, removed on either side means removed.
  • Package entries are keyed by name, version and source. An entry changed on one side takes that side's version. An entry changed on both sides, usually the project itself, is merged field by field.
  • If both branches move one package to different versions, the higher version wins and the driver prints that.
  • Packages that a remaining dependency still needs are restored, and packages that nothing reaches any more are dropped.

The result is written in exactly the layout uv uses. The writer follows uv's own lock serializer: which arrays have one element per line, which depend on their length, when table headers appear. Parsing and writing 60 public uv.lock files, from revision 1 to 5, gave identical bytes for every file. Where the driver would have to guess (both sides change requires-python, or the versions of an already forked package), it leaves a normal git conflict and says why.

An example from the tests, with uv 0.13.7. Branch feature-a adds rich and upgrades certifi, branch feature-b adds requests and upgrades idna:

$ git merge feature-b   # with the driver
uv-lock-merge: merged uv.lock
Auto-merging pyproject.toml
CONFLICT (content): Merge conflict in pyproject.toml
Auto-merging uv.lock
Automatic merge failed; fix conflicts and then commit the result.

After fixing pyproject.toml, the lockfile has the new certifi and idna 3.7, and uv lock --locked passes, which means uv would write the same file. Taking either side and running uv lock keeps one of the old versions.

Testing

The offline tests cover the writer, the merge rules, the fallback to conflict markers and the installer. The end to end tests create a repository, make two branches with real uv lock runs, merge them through git with the driver installed and finish with uv lock --locked. The scenarios are both branches adding a dependency, upgrades on both branches, a removed dependency next to an added one, both branches upgrading the same package, workspace members, a forked resolution, a requires-python conflict and resolving during a rebase. CI runs them with uv latest, 0.11.1 and 0.8.0.

Limitations

  • It is a structural merge, not a new resolution. Two upgrades that do not fit together are only noticed when uv resolves next, so uv lock --locked in CI stays useful.
  • Keeping the higher version is a choice and is printed for review.
  • Only lockfile version 1 is supported. uv's experimental version 2 falls back to a normal conflict.
  • Git only runs merge drivers configured in the git config, so every clone needs uv-lock-merge install once.

The code is at github.com/Jwrede/uv-lock-merge.