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

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:
blob: Die rohen Bytes des Inhalts einer einzelnen Datei.tree: Ein Verzeichnislisting, das Dateinamen und Berechtigungen aufblob- oder untergeordnetetree-Hashes abbildet.commit: Ein kleiner Textkopf (typischerweise 200 bis 300 Bytes), der auf einen einzelnen Wurzel-tree-Hash, null oder mehr übergeordnetecommit-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 jedemgit cloneundgit fetchankü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:
git clone --filter=blob:none --sparselädt die 20 KB große Kontrollebene vonrefs/heads/mainherunter.git trunk-initkonfiguriert+refs/trunk/*:refs/remotes/origin/trunk/*und führt einen Blobless-Fetch aus. Git lädt dietree-Metadaten fürrefs/trunk/main(typischerweise einige Dutzend Kilobyte) und null Datei-Blobs herunter.git sparse-checkout set net/relay ipc/codecweist den Index-Filter von Git im Cone-Modus an, nur das Wurzelverzeichnis und diese beiden Unterbäume zu materialisieren.git ws-newwechselt zu einem neuen Branch, der auforigin/trunk/mainbasiert. Genau in diesem Moment fordert Git bei Bedarf nur dieblob-Objekte innerhalb vonnet/relayundipc/codecan. 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:
- Sofortiges Einbinden des gesamten Baums: Ein leichtgewichtiger lokaler Mount-Daemon liest das Wurzel-
tree-Objekt vonrefs/trunk/mainund projiziert die gesamte Verzeichnishierarchie des Monorepos innerhalb von Millisekunden in das Arbeitsverzeichnis, wodurch sichgit sparse-checkout seterübrigt. - Bedarfsgesteuertes Nachladen von Blobs: Dateiinhalte bleiben virtuell, bis ein Compiler, eine IDE oder ein Agent einen Systemaufruf
open(2)oderread(2)ausführt. Der Daemon ruft dann das entsprechende SHA-1-blobvom CAS-Endpunkt ab und cached es auf lokalem NVMe-Speicher. - Copy-on-Write-Overlay für Workspaces: Lokale Änderungen werden in eine leichtgewichtige obere Overlay-Schicht geschrieben.
git ws-savehasht nur die geänderten Pfade in diesem Overlay, schreibt die aktualisiertenblob- undtree-Objekte und pusht den resultierenden Commit nachrefs/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/mainin//web/blogvorangeschritten ist, während Alice//net/relaybearbeitet hat, berechnetgit merge-tree --write-treeden kombinierten Wurzel-Tree$NEW_TREEdirekt im Git-Objektspeicher in Millisekunden. Auf das Dateisystem des Runners werden keine Dateien geschrieben. - Schließlich aktualisiert
git push --atomicmit--force-with-leasedie Referenzrefs/trunk/main, hängt einen linearen Commit anrefs/trunk/historyan und löscht den Landing-Auslöserrefs/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äfix | Stufe | Erlaubte Importe | Verbotene Importe | OWNERS-Prüfung |
|---|---|---|---|---|
//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/* | Öffentliche Produktion | Öffentliche Produktion, //third_party/* | //corp/*, //experimental/* | Erzwungen |
//third_party/* | Externer Anbieter | In sich abgeschlossen oder //third_party/* | Alle Nicht-third_party-Domänen | Erzwungen |
//corp/* | Interne Private | Öffentliche Produktion, //corp/*, //third_party/* | //experimental/* | Erzwungen |
//experimental/<actor>/* | Sandbox | Jedes 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_TREEund$NEW_TREEgeändert wurde, läuft der Verifizierer vom Verzeichnis der Datei aufwärts bis zu//OWNERS. - Jede
OWNERS-Datei kannset noparentdeklarieren, um vererbte Genehmigungen aus übergeordneten Verzeichnissen zu blockieren, oderper-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 erOWNERSim selben Workspace-Commit bearbeitet. - Sandbox-Ausnahme: Jeder Pfad unter
//experimental/<actor>/*umgeht dieOWNERS-Prüfungen, sodass Ingenieure und Agenten mitgit ws-exp <actor> <project>frei prototypisieren und das Paket später mitgit ws-graduatein 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:
- Sofortige Zwei-Punkte-Workspace-Diffs (
git ws-save+ Edge-Diff-Router) Das Pushen eines Workspaces mitgit ws-savemachtrefs/ws/<actor>/<id>über einen leichtgewichtigen Edge-Worker zugänglich. Dieser Worker löstrefs/trunk/mainund 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. - Flüchtige GitHub Pull Requests (
git ws-pr) Für zeilenweises Review auf GitHub pushtgit ws-prden Wurzel-Snapshot-Commit ($(git rev-list --max-parents=0 HEAD)) nachrefs/heads/base/<id>und die Workspace-Spitze nachrefs/heads/review/<id>. Anschließend öffnet es einen GitHub Pull Request, derreview/<id>mitbase/<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 nachrefs/heads/land/<id>, führt die In-Memory-Landing-Pipeline aus und löscht automatisch sowohlrefs/heads/base/<id>als auchrefs/heads/review/<id>. - 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=1und führtgit update-index --index-infogefolgt vongit write-tree --missing-okaus. 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 übergit cat-file --batchnur 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 wiemain, wodurch Release-Gates für Repositories erhalten bleiben, die aus einem separatenproduction-Branch deployen. Monorepo-Landungen synchronisieren mit demmain-Branch des Spiegels, und automatisierte Metadaten- oder Übersetzungs-Commits aufmainwerden per Webhook zurück nachrefs/trunk/mainsynchronisiert. Das Befördern vonmainnachproductionbleibt 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.