uv-lock-merge: ein Git-Merge-Driver für uv.lock

Ein kleines Werkzeug, das uv.lock-Dateien bei Git-Merges und Rebases paketweise zusammenführt, sodass die gesperrten Versionen beider Branches erhalten bleiben und das Ergebnis im Format von uv geschrieben wird.

Das Problem

Wenn in einem Python-Projekt mit uv zwei Branches die Abhängigkeiten ändern, gibt es oft einen Konflikt in uv.lock. Beide Branches ergänzen die Listen dependencies und requires-dist des Projekts, neue Pakete landen in der sortierten Datei nebeneinander, oder beide aktualisieren dasselbe Paket. uv kann eine Lockdatei mit Konfliktmarkern nicht lesen, also muss jemand eine generierte Datei von Hand reparieren.

Die übliche Umgehung: die Lockdatei einer Seite nehmen und uv lock erneut ausführen. Das funktioniert, behält aber nur die Versionen dieser Seite. Ein Update auf dem anderen Branch, etwa einer transitiven Abhängigkeit, fällt unbemerkt auf die alte Version zurück.

Andere haben dasselbe Problem:

  • astral-sh/uv#11185, "Automatic resolution of merge conflicts in lockfiles", offen seit Februar 2025, 28 Reaktionen, und astral-sh/uv#5633, "Document how to resolve uv.lock merge conflicts", offen seit Juli 2024, 22 Reaktionen.
  • Eine GitHub-Codesuche nach uv.lock merge= ergab 95 Repositories, die einen eigenen Driver eintragen (einige davon Forks eines Projekts). Die, die ich gelesen habe, behalten eine Seite und führen uv lock aus.
  • Poetry hat dasselbe Issue seit 2018 offen (python-poetry/poetry#496, 124 Reaktionen). yarn, npm und Deno lösen solche Konflikte selbst.

Wie es arbeitet

uv-lock-merge wird als Git-Merge-Driver für uv.lock eingetragen. Git ruft es mit Basis-, eigener und fremder Version auf, sobald beide Seiten die Datei geändert haben. Das Werkzeug liest alle drei mit tomllib und führt sie zusammen:

  • Der Kopf (requires-python, Marker, [options], [manifest]) wird schlüsselweise zusammengeführt. Listen werden als Mengen behandelt: auf einer Seite hinzugefügt heißt hinzugefügt, auf einer Seite entfernt heißt entfernt.
  • Paketeinträge werden über Name, Version und Quelle identifiziert. Ein Eintrag, den nur eine Seite geändert hat, kommt von dieser Seite. Ein Eintrag, den beide geändert haben, meist das Projekt selbst, wird Feld für Feld zusammengeführt.
  • Haben beide Branches ein Paket auf verschiedene Versionen gehoben, gewinnt die höhere, und der Driver meldet das.
  • Pakete, die eine verbliebene Abhängigkeit noch braucht, werden wiederhergestellt; Pakete, die nichts mehr erreicht, fallen weg.

Geschrieben wird das Ergebnis genau im Layout von uv. Der Writer folgt dem Lock-Serializer von uv: welche Arrays ein Element pro Zeile haben, welche das von ihrer Länge abhängig machen, wann Tabellenköpfe erscheinen. 60 öffentliche uv.lock-Dateien (Revision 1 bis 5) kamen nach Einlesen und Schreiben byte-gleich heraus. Wo der Driver raten müsste (beide Seiten ändern requires-python oder die Versionen eines bereits geforkten Pakets), hinterlässt er einen normalen Git-Konflikt und nennt den Grund.

Ein Beispiel aus den Tests mit uv 0.13.7. Branch feature-a fügt rich hinzu und hebt certifi an, Branch feature-b fügt requests hinzu und hebt idna an:

$ 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.

Nach dem Auflösen von pyproject.toml enthält die Lockdatei das neue certifi und idna 3.7, und uv lock --locked läuft durch, uv würde also dieselbe Datei schreiben. Nimmt man eine Seite und führt uv lock aus, bleibt eine der alten Versionen stehen.

Tests

Die Offline-Tests prüfen den Writer, die Merge-Regeln, den Rückfall auf Konfliktmarker und die Installation. Die End-to-End-Tests legen ein Repository an, erzeugen zwei Branches mit echten uv lock-Läufen, mergen sie per Git mit eingetragenem Driver und prüfen am Ende mit uv lock --locked. Abgedeckt sind: beide Branches fügen eine Abhängigkeit hinzu, Updates auf beiden Branches, eine entfernte neben einer neuen Abhängigkeit, beide heben dasselbe Paket an, Workspace-Mitglieder, eine geforkte Auflösung, ein requires-python-Konflikt und das Auflösen während eines Rebase. Die CI führt sie mit uv latest, 0.11.1 und 0.8.0 aus.

Grenzen

  • Es ist ein struktureller Merge, keine neue Auflösung. Zwei Updates, die nicht zusammenpassen, fallen erst bei der nächsten Auflösung durch uv auf; uv lock --locked in der CI bleibt sinnvoll.
  • Die höhere Version zu behalten ist eine Entscheidung und wird zur Prüfung ausgegeben.
  • Unterstützt wird nur Lockdatei-Version 1. Die experimentelle Version 2 von uv führt zu einem normalen Konflikt.
  • Git führt nur Merge-Driver aus, die in der Git-Konfiguration stehen; jeder Klon braucht einmal uv-lock-merge install.

Der Code liegt unter github.com/Jwrede/uv-lock-merge.