GoSuda

Überdenken des Git-Monorepos: Eine CAS-native Architektur für Menschen und Coding-Agenten

By Lemon Mint views ...

Jede wachsende Engineering-Organisation trifft früher oder später auf dieselbe Weggabelung. Man beginnt mit Dutzenden kleiner Repositories, und zunächst wirkt alles einfach. Jede Bibliothek hat ihren eigenen Issue-Tracker, ihre eigene CI-Pipeline und einen git clone, der abgeschlossen ist, bevor man einen Schluck Kaffee trinkt. Dann zeigt sich der Preis dafür. Man ändert eine Struktur in einem zentralen Netzwerkpaket und verbringt den Rest des Nachmittags damit, Pull Requests für Versionsanhebungen in sechs nachgelagerten Repositories zu öffnen, während man darauf wartet, dass sich die CI-Warteschlangen in topologischer Reihenfolge leeren.

Verschiebt man alles in ein traditionelles Git-Monorepo, tauscht man Koordinationsschmerz gegen physikalischen Schmerz. Wenn die Commit-Historie wächst und sich Binärdateien in früheren Revisionen ansammeln, wird git fetch langsamer. Arbeitsverzeichnisse schwellen auf Gigabyte an. Bald schlägt jemand vor, einen eigenen virtuellen Dateisystem-Daemon, einen dedizierten Code-Review-Server oder eine Flotte entfernter Build-Worker einzurichten, nur um die tägliche Entwicklung erträglich zu halten.

Wenn autonome Coding-Agenten dem Team beitreten, verschlimmern sich beide Fehlermodi. Polyrepos blenden einen Agenten gegenüber projektübergreifenden Aufrufern aus und zwingen ihn, Zugangsdaten über Repositories hinweg zu verwalten. Traditionelle Monorepos belasten bei jeder Aufgabeninitialisierung den Containerspeicher und die Netzwerkbandbreite des Agenten, während gleichzeitig arbeitende Agenten, die sich auf demselben Branch gegenseitig in die Quere kommen, endlose Rebase-Wettläufe erzeugen.

Wir wollten eine dritte Option. Inspiriert von der google3-Architektur stellten wir eine einfache Frage: Können wir atomare Änderungen auf einem einzigen Trunk, Sparse-Onboarding in unter einer Sekunde, hierarchische OWNERS-Governance und eigenständige Open-Source-Spiegel erreichen, und zwar nur mit Standard-Git-Mechanismen und handelsüblichem Objektspeicher?

Die Antwort lautete ja, und sie erforderte weder einen eigenen Dateisystem-Daemon noch eine Review-Appliance eines Drittanbieters. Sie erforderte lediglich, Git für das zu verwenden, was es im Kern tatsächlich ist: ein auf Merkle-Bäumen basierender Content-Addressable Store (CAS).

Das Missverständnis im Kern langsamer Git-Monorepos

Wenn Entwickler sich beklagen, dass Git nicht für große Monorepos skaliert, stoßen sie selten auf eine Grenze in Gits Objektdatenbank. Innerhalb von .git/objects speichert Git drei unveränderliche Grundelemente, die über ihren SHA-1- (oder SHA-256-) Inhalts-Hash identifiziert werden:

  1. blob: Die rohen Bytes des Inhalts einer einzelnen Datei.
  2. tree: Ein Verzeichnislisting, das Dateinamen und Berechtigungen auf blob- oder untergeordnete tree-Hashes abbildet.
  3. commit: Ein kleiner Textkopf (typischerweise 200 bis 300 Bytes), der auf einen einzelnen Wurzel-tree-Hash, null oder mehr übergeordnete commit-Hashes und Autorenmetadaten verweist.

Als Content-Addressable Store ist dieses Design bemerkenswert effizient. Enthält ein Monorepo 50.000 Dateien und bearbeitet man eine einzelne Zeile in einer Datei, verwendet der neue Commit die exakt gleichen blob- und tree-Hashes für die übrigen 49.999 Dateien wieder.

Warum wirkt ein traditionelles Git-Monorepo dann dennoch schwerfällig? Weil eine Konvention eine einzelne Referenz, refs/heads/main, mit drei widersprüchlichen Aufgaben überlädt:

  • Sie hält den gesamten vereinheitlichten Verzeichnisbaum aller Projekte.
  • Sie schleppt die gesamte, über Eltern verknüpfte Kette historischer Commits bis zum ersten Tag mit.
  • Sie liegt innerhalb von refs/heads/*, dem Namensraum, den Git-Server standardmäßig bei jedem git clone und git fetch ankündigen und aushandeln.

Jedes Mal, wenn ein Entwickler oder CI-Runner refs/heads/main abruft, handeln Client und Server die Erreichbarkeit im Commit-Graphen über Tausende historischer Revisionen aus. Wenn jemand vor drei Jahren versehentlich ein 100-MB-Archiv committet und es am nächsten Tag wieder gelöscht hat, hängt sein blob weiterhin an einem historischen tree, der von refs/heads/main aus erreichbar ist.

Den Referenz-Namensraum in vier Schichten aufteilen

Das Wire-Protokoll von Git kündigt bei einem Standard-Clone nur refs/heads/* und refs/tags/* an. Referenzen unter benutzerdefinierten Namensräumen wie refs/trunk/* oder refs/ws/* liegen im exakt gleichen Objektspeicher auf dem Server, profitieren von derselben Packfile-Delta-Kompression und können explizit abgerufen oder gepusht werden, verursachen jedoch bei Standard-Clones keinen Mehraufwand.

Durch die Aufteilung der Verantwortlichkeiten auf vier Referenz-Namensräume innerhalb eines einzigen Repositorys verschwindet der Engpass:

1refs/heads/main          Nur Kontrollebene (~20 KB: Regeln, .gitconfig, Sync-Konfiguration, Skripte)
2refs/trunk/main          Snapshot-Commit ohne Eltern, der auf den neuesten vereinheitlichten Wurzel-Tree zeigt
3refs/trunk/history       Lineare, über Eltern verknüpfte Commit-Kette, die exakt denselben Wurzel-Tree-SHA-1 teilt
4refs/ws/<actor>/<id>     Isolierte Workspace-Snapshots (Changelists) pro Entwickler oder Agent
5refs/heads/land/<id>     Flüchtiger Merge-Queue-Auslöser, der unmittelbar nach dem Landen gelöscht wird

1. Die Kontrollebene (refs/heads/main)

Der Standard-Branch enthält keinen Anwendungsquellcode. Er verfolgt nur die Betriebsregeln des Repositorys (AGENTS.md, README.md), eine gemeinsame .gitconfig mit Workflow-Aliasen, die Starlark-Synchronisationskonfiguration (sync.star) und serverseitige Merge-Skripte. Das Klonen des Standard-Branches überträgt etwa 20 KB und ist in Millisekunden abgeschlossen.

2. Der Trunk-Snapshot ohne Eltern (refs/trunk/main)

refs/trunk/main zeigt auf den vollständigen, vereinheitlichten Quellbaum der gesamten Organisation (//web/*, //net/*, //ipc/*, //devtools/*, //corp/*, //third_party/*, //experimental/*), doch sein Commit-Objekt wird ohne das -p-Elternflag erzeugt.

Da refs/trunk/main keine Eltern hat (parents = []), erfordert das Abrufen keinerlei Aushandlung des Commit-Graphen. Der Git-Server durchläuft die Historie nicht; er sendet den einzelnen Head-Commit und seine aktuellen Verzeichnis-tree-Objekte. Historische Binärdateien oder Dateien, die im letzten Monat gelöscht wurden, sind von refs/trunk/main nicht erreichbar und werden nie übertragen.

3. Die lineare Audit-Historie (refs/trunk/history)

Entwickler benötigen weiterhin git blame und git log, wenn sie Regressionen untersuchen. Jedes Mal, wenn eine Änderung gelandet wird, erzeugt das Merge-Skript zwei Commit-Objekte, die auf denselben exakten Wurzel-SHA-1-Hash $NEW_TREE verweisen:

 1# Snapshot-Commit ohne Eltern für sofortige Depth-1-Workspace-Checkouts
 2SNAP=$(git commit-tree "$NEW_TREE" -m "$RAW_MSG")
 3
 4# Über Eltern verknüpfter Commit für lineares git log und git blame
 5HIST=$(git commit-tree "$NEW_TREE" -p "$OLD_HIST" -m "$RAW_MSG")
 6
 7git push --atomic origin \
 8  --force-with-lease="refs/trunk/main:${OLD_MAIN}" \
 9  --force-with-lease="refs/trunk/history:${OLD_HIST}" \
10  "${SNAP}:refs/trunk/main" \
11  "${HIST}:refs/trunk/history"

Verdoppelt das Vorhalten sowohl von refs/trunk/main als auch von refs/trunk/history den Speicherbedarf des Repositorys? Keineswegs. Da beide Commits auf den identischen $NEW_TREE-Hash verweisen, dedupliziert der Content-Addressable Store von Git 100 % der tree- und blob-Objekte. Die zusätzlichen Speicherkosten des Snapshots ohne Eltern betragen lediglich einen 250-Byte-Commit-Header.

4. Isolierte Workspaces (refs/ws/<actor>/<id>)

Work-in-Progress-Branches werden an refs/ws/<actor>/<id> statt an refs/heads/* gepusht. Hundert Coding-Agenten können jede Minute Checkpoints speichern, ohne Branch-Auswahllisten zu verschmutzen oder git fetch für menschliche Ingenieure zu verlangsamen.

Onboarding in fünf Befehlen: So funktioniert Sparse- und Blobless-Checkout

Mit diesem Referenzlayout installiert ein neuer Entwickler oder ein flüchtiger Coding-Agent-Container weder eine eigene CLI noch einen FUSE-Treiber. Das Onboarding besteht aus fünf Standard-Git-Befehlen:

 1# 1. Den 20-KB-Kontrollebenen-Clone mit Blob-Filterung und aktiviertem Sparse-Modus klonen
 2git clone --filter=blob:none --sparse https://github.com/example-org/monorepo.git
 3cd monorepo
 4
 5# 2. Repository-Aliase laden und refs/trunk/* anbinden
 6git config include.path ../.gitconfig
 7git trunk-init
 8
 9# 3. Die für diese Aufgabe benötigten Domänenverzeichnisse deklarieren
10git sparse-checkout set net/relay ipc/codec
11
12# 4. Einen isolierten Workspace erstellen, der am neuesten Trunk-Snapshot verankert ist
13git ws-new alice/optimize-framing

Im Hintergrund erledigt jeder Schritt nur das Minimum an notwendiger Arbeit:

  1. git clone --filter=blob:none --sparse lädt die 20 KB große Kontrollebene von refs/heads/main herunter.
  2. git trunk-init konfiguriert +refs/trunk/*:refs/remotes/origin/trunk/* und führt einen Blobless-Fetch aus. Git lädt die tree-Metadaten für refs/trunk/main (typischerweise einige Dutzend Kilobyte) und null Datei-Blobs herunter.
  3. git sparse-checkout set net/relay ipc/codec weist den Index-Filter von Git im Cone-Modus an, nur das Wurzelverzeichnis und diese beiden Unterbäume zu materialisieren.
  4. git ws-new wechselt zu einem neuen Branch, der auf origin/trunk/main basiert. Genau in diesem Moment fordert Git bei Bedarf nur die blob-Objekte innerhalb von net/relay und ipc/codec an. Die verbleibenden 95 % des Monorepos werden nie auf die Festplatte geladen.

Ausblick: Einbinden von refs/trunk/main als virtuelles Dateisystem (VFS)

Sparse-Checkout mit Blobless-Clones deckt die täglichen Workflows ohne zusätzliche Client-Werkzeuge ab. Da refs/trunk/main bereits ein reiner Merkle-Baum in einem Content-Addressable Store ist, bietet die Architektur außerdem eine direkte Grundlage für ein optionales virtuelles Dateisystem (VFS) im Userspace, über FUSE unter Linux, FileProvider unter macOS oder ProjFS unter Windows:

  1. Sofortiges Einbinden des gesamten Baums: Ein leichtgewichtiger lokaler Mount-Daemon liest das Wurzel-tree-Objekt von refs/trunk/main und projiziert die gesamte Verzeichnishierarchie des Monorepos innerhalb von Millisekunden in das Arbeitsverzeichnis, wodurch sich git sparse-checkout set erübrigt.
  2. Bedarfsgesteuertes Nachladen von Blobs: Dateiinhalte bleiben virtuell, bis ein Compiler, eine IDE oder ein Agent einen Systemaufruf open(2) oder read(2) ausführt. Der Daemon ruft dann das entsprechende SHA-1-blob vom CAS-Endpunkt ab und cached es auf lokalem NVMe-Speicher.
  3. Copy-on-Write-Overlay für Workspaces: Lokale Änderungen werden in eine leichtgewichtige obere Overlay-Schicht geschrieben. git ws-save hasht nur die geänderten Pfade in diesem Overlay, schreibt die aktualisierten blob- und tree-Objekte und pusht den resultierenden Commit nach refs/ws/<actor>/<id>, ohne unveränderte Verzeichnisse zu durchsuchen.

Das Changelist-Modell auf reinem Git: Serverseitiges Landen im Speicher

In einem traditionellen Git-Workflow erzeugt das Zusammenführen eines Feature-Branches entweder einen Merge-Commit mit zwei Eltern (wodurch die Historie zu einem verworrenen Rangierbahnhof wird) oder erfordert, dass Entwickler manuell rebasen und mit anderen Ingenieuren um das Pushen auf main konkurrieren.

Inspiriert vom Changelist-(CL-)Modell von google3 repräsentiert in unserer Architektur jeder Workspace eine einzelne atomare Änderung gegenüber dem Trunk-Snapshot. Während ein Entwickler oder Agent lokal in ws/alice/optimize-framing arbeitet, kann er zehn inkrementelle Commits erstellen (wip, fix tests, lint). Ist die Änderung bereit zum Landen, faltet der Server das Workspace-Delta in eine einzige lineare Trunk-Revision zusammen, ohne auf dem CI-Runner jemals ein Arbeitsverzeichnis auszuchecken.

Hier führt das Landing-Skript (land.sh) im Speicher mit Plumbing-Befehlen aus Git 2.40+ aus:

 1# 1. Den Wurzel-Snapshot-Commit identifizieren, von dem der Workspace abgezweigt wurde
 2BASE=$(git rev-list --max-parents=0 "$WORK_SHA")
 3
 4BASE_TREE=$(git rev-parse "${BASE}^{tree}")
 5MAIN_TREE=$(git rev-parse "${OLD_MAIN}^{tree}")
 6WORK_TREE=$(git rev-parse "${WORK_SHA}^{tree}")
 7
 8# 2. Den zusammengeführten Wurzel-Tree im Speicher berechnen, ohne Arbeitsverzeichnis-Checkout
 9if [ "$MAIN_TREE" = "$BASE_TREE" ]; then
10  NEW_TREE="$WORK_TREE"
11elif OUT=$(git merge-tree --write-tree --merge-base="$BASE_TREE" "$MAIN_TREE" "$WORK_TREE"); then
12  NEW_TREE=$(head -n1 <<<"$OUT")
13else
14  echo "Merge conflict detected; run git ws-sync to rebase." >&2
15  exit 1
16fi

Betrachten Sie Schritt 2 genau:

  • Unabhängig davon, wie viele lokale Commits auf dem Workspace-Branch erstellt wurden, vergleicht das Skript nur drei Tree-Hashes: den ursprünglichen Basis-Snapshot ($BASE_TREE), den aktuellen Trunk-Snapshot ($MAIN_TREE) und den endgültigen Workspace-Tree ($WORK_TREE).
  • Wenn refs/trunk/main in //web/blog vorangeschritten ist, während Alice //net/relay bearbeitet hat, berechnet git merge-tree --write-tree den kombinierten Wurzel-Tree $NEW_TREE direkt im Git-Objektspeicher in Millisekunden. Auf das Dateisystem des Runners werden keine Dateien geschrieben.
  • Schließlich aktualisiert git push --atomic mit --force-with-lease die Referenz refs/trunk/main, hängt einen linearen Commit an refs/trunk/history an und löscht den Landing-Auslöser refs/heads/land/<id> in einer einzigen Transaktion.

Architekturgrenzen durchsetzen: Vierstufige Sichtbarkeit und hierarchische OWNERS

Die Konsolidierung von Code in einem Repository funktioniert nur, wenn die Grenzen zwischen Domänen mechanisch durchgesetzt werden, bevor ein Commit landen kann. Unmittelbar nachdem git merge-tree --write-tree $NEW_TREE erzeugt hat (und bevor git push --atomic den Trunk aktualisiert), führt unsere Policy-Engine zwei Prüfungen mit O(Delta)-Aufwand über git diff-tree "$MAIN_TREE" "$NEW_TREE" aus.

1. Vierstufige Domänensichtbarkeit

Der Wurzel-Tree ist in vier Sichtbarkeitsstufen unterteilt:

VerzeichnispräfixStufeErlaubte ImporteVerbotene ImporteOWNERS-Prüfung
//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/*Öffentliche ProduktionÖffentliche Produktion, //third_party/*//corp/*, //experimental/*Erzwungen
//third_party/*Externer AnbieterIn sich abgeschlossen oder //third_party/*Alle Nicht-third_party-DomänenErzwungen
//corp/*Interne PrivateÖffentliche Produktion, //corp/*, //third_party/*//experimental/*Erzwungen
//experimental/<actor>/*SandboxJedes Nicht-corp-Paket//corp/*Ausgenommen (automatisch genehmigt)

Versucht ein öffentliches Paket in //web/*, ein internes Paket aus //corp/* oder einen nicht überführten Prototyp aus //experimental/* zu importieren, bricht der Landing-Job ab, bevor refs/trunk/main angefasst wird.

2. Hierarchische OWNERS-Verifikation ohne Gerrit-Server

Sie benötigen keine dedizierte Gerrit-Instanz, um verzeichnisbezogene OWNERS-Dateien mit Vererbungsregeln durchzusetzen:

  • Für jeden Pfad, der zwischen $MAIN_TREE und $NEW_TREE geändert wurde, läuft der Verifizierer vom Verzeichnis der Datei aufwärts bis zu //OWNERS.
  • Jede OWNERS-Datei kann set noparent deklarieren, um vererbte Genehmigungen aus übergeordneten Verzeichnissen zu blockieren, oder per-file *.sql=@dba-team, um Genehmigungen nach Dateinamen-Glob zu beschränken.
  • Invariante gegen Manipulation: Bestehende OWNERS-Dateien werden immer aus $MAIN_TREE (dem aktuellen Trunk-Tree) gelesen, niemals aus $NEW_TREE. Ein nicht genehmigter Autor kann sich keine Berechtigung verschaffen, indem er OWNERS im selben Workspace-Commit bearbeitet.
  • Sandbox-Ausnahme: Jeder Pfad unter //experimental/<actor>/* umgeht die OWNERS-Prüfungen, sodass Ingenieure und Agenten mit git ws-exp <actor> <project> frei prototypisieren und das Paket später mit git ws-graduate in eine Produktionsdomäne überführen können.

Code-Review ohne Reibung: Flüchtige PRs und Snapshot-Diffs

Da refs/heads/main nur die Kontrollebene enthält und refs/trunk/main ein Commit ohne Eltern ist, würde das direkte Öffnen eines standardmäßigen GitHub Pull Requests von einem Workspace-Branch nach main den Vergleich unverbundener Historien erzwingen. Wir haben dies mit drei auf unterschiedliche Änderungen zugeschnittenen Review-Workflows gelöst:

  1. Sofortige Zwei-Punkte-Workspace-Diffs (git ws-save + Edge-Diff-Router) Das Pushen eines Workspaces mit git ws-save macht refs/ws/<actor>/<id> über einen leichtgewichtigen Edge-Worker zugänglich. Dieser Worker löst refs/trunk/main und die Workspace-Referenz in ihre Commit-SHAs auf und leitet auf die Zwei-Punkte-Vergleichsansicht von GitHub weiter (/compare/<trunk-sha>..<ws-sha>). Bei kleinen Patches oder von Agenten erzeugten Änderungen können Reviewer den Diff prüfen und in der Weboberfläche direkt auf Land klicken.
  2. Flüchtige GitHub Pull Requests (git ws-pr) Für zeilenweises Review auf GitHub pusht git ws-pr den Wurzel-Snapshot-Commit ($(git rev-list --max-parents=0 HEAD)) nach refs/heads/base/<id> und die Workspace-Spitze nach refs/heads/review/<id>. Anschließend öffnet es einen GitHub Pull Request, der review/<id> mit base/<id> vergleicht. Da der Base-Branch exakt dem Snapshot entspricht, von dem der Workspace abgezweigt wurde, zeigt der Tab Files changed von GitHub nur die tatsächlich im Workspace geänderten Zeilen. Klickt der Reviewer auf Merge pull request, befördert ein Webhook den Head-Commit nach refs/heads/land/<id>, führt die In-Memory-Landing-Pipeline aus und löscht automatisch sowohl refs/heads/base/<id> als auch refs/heads/review/<id>.
  3. Pull Requests auf externen Spiegeln Open-Source-Beitragende müssen den Monorepo-Workflow nie erlernen. Sie forken und öffnen Pull Requests wie gewohnt gegen eigenständige Spiegel-Repositories.

O(Delta)-bidirektionale Synchronisation und separate Produktions-Branches

Viele Organisationen veröffentlichen einzelne Pakete als eigenständige GitHub-Repositories, damit externe Nutzer go get oder cargo install ausführen oder ein einzelnes Projekt forken können, ohne das Monorepo zu berühren. Manche Projekte, etwa statische Blogs oder freigabegesteuerte Web-Apps, pflegen zudem neben main einen separaten production-Branch.

Unsere Starlark-Synchronisationsengine behandelt sowohl ausgehende Exporte als auch eingehende Beiträge in O(Delta)-Zeit, proportional zu den in einem Commit geänderten Dateien statt zur Größe des Monorepos:

  • Blobless-Index-Splicing: Beim Einfügen oder Herauslösen des Subtrees eines Spiegel-Repositories in den bzw. aus dem Wurzel-Tree des Monorepos setzt die Engine GIT_NO_LAZY_FETCH=1 und führt git update-index --index-info gefolgt von git write-tree --missing-ok aus. Unveränderte Subtrees und Blobs werden allein über ihre SHA-1-Hashes referenziert, ohne jemals auf den CI-Runner heruntergeladen zu werden.
  • Nur-Delta-Blob-Prefetching: Bevor ein exportierter Commit an ein eigenständiges Spiegel-Repository gepusht wird, führt das Skript git diff-tree -r --diff-filter=AM "$ext_parent" "$export_tree" aus und lädt über git cat-file --batch nur die alten und neuen Blobs der geänderten Dateien vor. Git kann dann ein schlankes Delta-Pack berechnen und in Sekunden an das Spiegel-Repository pushen.
  • Erhalt von Workflow- und Release-Branches: Beim ausgehenden Export bewahrt der Synchronisierer das eigene Verzeichnis .github/workflows/ jedes eigenständigen Repositories, sodass öffentliche CI-Runner die Projekttests auf dem Spiegel ausführen. Jeder Workflow in der Starlark-Konfiguration zielt auf einen expliziten Branch wie main, wodurch Release-Gates für Repositories erhalten bleiben, die aus einem separaten production-Branch deployen. Monorepo-Landungen synchronisieren mit dem main-Branch des Spiegels, und automatisierte Metadaten- oder Übersetzungs-Commits auf main werden per Webhook zurück nach refs/trunk/main synchronisiert. Das Befördern von main nach production bleibt ein expliziter Freigabeschritt.

Schlussbetrachtung

Der Aufbau eines Monorepos mit hoher Entwicklungsgeschwindigkeit erfordert weder die Abkehr von Standard-Git noch den Betrieb einer komplexen Flotte maßgeschneiderter Infrastrukturserver. Diese Architektur behandelt den Objektspeicher von Git als Content-Addressable Store, trennt refs/heads/main von refs/trunk/main und refs/trunk/history und führt Bäume mit git merge-tree --write-tree im Speicher zusammen. Dadurch können sowohl menschliche Ingenieure als auch autonome Coding-Agenten einen Workspace in weniger als einer Sekunde klonen und über Hunderte von Projekten hinweg sauber skalieren.