Het Git Monorepo heroverwogen: een CAS-native architectuur voor mensen en coding agents

Elke groeiende engineeringorganisatie kiest uiteindelijk tussen veel repositories en één. Begin met tientallen kleine repositories, en het leven voelt aanvankelijk eenvoudig aan. Elke bibliotheek heeft een eigen issuetracker, een eigen CI-pipeline en een git clone die in enkele seconden klaar is. Vervolgens wijzig je een struct in een kernnetwerkpakket en besteed je de rest van de middag aan het openen van version-bump pull requests in zes afhankelijke repositories, terwijl je wacht tot de CI-wachtrijen in topologische volgorde zijn leeggelopen.
Verplaats alles naar een traditionele Git-monorepo, en je ruilt coördinatie tussen repositories in voor de omvang van de repository. Naarmate de commitgeschiedenis groeit en binaire bestanden zich in eerdere revisies opstapelen, wordt git fetch trager. Werkmappen zwellen op tot gigabytes. Al snel stelt iemand voor om een aangepaste virtuele-bestandssysteemdaemon, een speciale code review-server of een vloot externe buildworkers op te zetten, alleen om de dagelijkse ontwikkeling draaglijk te houden.
Wanneer autonome codeeragents zich bij het team voegen, worden beide faalwijzen erger. Polyrepos ontnemen een agent het zicht op aanroepers in andere projecten en dwingen hem om credentials over repositories heen te beheren. Traditionele monorepo's verzwelgen bij elke taakinitialisatie de schijfruimte van de container van de agent en de netwerkbandbreedte, terwijl gelijktijdige agents die dezelfde branch gebruiken eindeloze rebase-races veroorzaken.
Geïnspireerd door de google3-architectuur hebben we single-trunk atomaire wijzigingen, subseconde sparse onboarding, hiërarchische OWNERS-governance en zelfstandige open-sourcespiegels gebouwd, met standaard Git-plumbing en goedkope objectopslag.
Het ontwerp heeft noch een aangepaste bestandssysteemdaemon nodig, noch een externe review-appliance van derden, omdat het Git behandelt als een Merkle-tree Content-Addressable Store (CAS).
Het misverstand in het hart van trage Git-monorepo's
Wanneer ontwikkelaars klagen dat Git niet schaalt naar grote monorepo's, lopen ze zelden tegen een limiet van Git's objectdatabase aan. Binnen .git/objects slaat Git drie onveranderlijke primitieven op, gesleuteld op hun SHA-1 (of SHA-256) inhoudshash:
blob: De ruwe bytes van de inhoud van één bestand.tree: Een directorylijst die bestandsnamen en rechten koppelt aanblob- of onderliggendetree-hashes.commit: Een kleine tekstheader (doorgaans 200 tot 300 bytes) die verwijst naar één root-tree-hash, nul of meer parent-commit-hashes en auteursmetadata.
Als een monorepo 50.000 bestanden bevat en je wijzigt één regel in één bestand, dan hergebruikt de nieuwe commit de exacte blob- en tree-hashes voor de overige 49.999 bestanden.
Een traditionele Git-monorepo voelt zwaar aan omdat de conventie één enkele referentie, refs/heads/main, belast met drie conflicterende taken:
- Hij bevat de volledige, verenigde directoryboom van elk project.
- Hij sleept de volledige, aan parents gekoppelde keten van historische commits mee tot aan de eerste dag.
- Hij bevindt zich binnen
refs/heads/*, de namespace die Git-servers standaard adverteren en waarover ze onderhandelen bij elkegit cloneengit fetch.
Telkens wanneer een ontwikkelaar of CI-runner refs/heads/main ophaalt, onderhandelen client en server over de bereikbaarheid in de commitgraaf over duizenden historische revisies. Als iemand drie jaar geleden per ongeluk een archief van 100 MB heeft gecommit en het de volgende dag heeft verwijderd, hangt de blob nog steeds aan een historische tree die bereikbaar is vanaf refs/heads/main.
Het referentie-namespace scheiden
Git's wire protocol adverteert tijdens een standaard clone alleen refs/heads/* en refs/tags/*. Referenties onder aangepaste namespaces, zoals refs/trunk/* of refs/ws/*, staan in exact dezelfde objectopslag op de server, profiteren van dezelfde packfile-deltacompressie en kunnen expliciet worden opgehaald of gepusht, maar ze brengen geen overhead met zich mee bij standaard clones.
Het verdelen van deze taken over afzonderlijke referenties binnen één repository verwijdert het knelpunt:
1refs/heads/main Alleen control plane (~20 KB: regels, .gitconfig, synchronisatieconfiguratie, scripts)
2refs/trunk/main Parentloze snapshotcommit die verwijst naar de nieuwste verenigde root-tree
3refs/trunk/history Lineaire, aan parents gekoppelde commitketen die exact dezelfde root-tree-SHA-1 deelt
4refs/ws/<actor>/<id> Geïsoleerde werkruimte-snapshots (changelists) per ontwikkelaar of agent
5refs/ws/cl/<change-id>/<tip> Onveranderlijke patchsets: elke tip van een wijziging die ter review is ingediend
6refs/heads/land/<id> Tijdelijke merge-queue-trigger die direct na landing wordt verwijderd
1. Het control plane (refs/heads/main)
De standaardbranch bevat geen applicatiebroncode. Hij bevat alleen de bedrijfsregels van de repository (AGENTS.md, README.md), een gedeelde .gitconfig met workflow-aliassen, de Starlark-synchronisatieconfiguratie (sync.star) en server-side mergescripts. Het clonen van de standaardbranch transfereert ongeveer 20 KB en is in milliseconden klaar.
2. De parentloze trunk-snapshot (refs/trunk/main)
refs/trunk/main wijst naar de volledige, verenigde broncodeboom van de hele organisatie (//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/*, //corp/*, //third_party/*, //experimental/*), maar het commitobject wordt aangemaakt zonder een -p-parentvlag.
Omdat refs/trunk/main nul parents heeft (parents = []), vereist het ophalen ervan geen onderhandeling over de commitgraaf. De Git-server doorloopt de geschiedenis niet; hij stuurt de enkele head-commit en de huidige directory-tree-objecten. Historische binaire bestanden of bestanden die vorige maand zijn verwijderd, zijn niet bereikbaar vanaf refs/trunk/main en worden nooit overgedragen.
3. De lineaire auditgeschiedenis (refs/trunk/history)
Ontwikkelaars hebben nog steeds git blame en git log nodig bij het onderzoeken van regressies. Telkens wanneer een wijziging landt, maakt het mergescript twee commitobjecten aan die verwijzen naar exact dezelfde $NEW_TREE-SHA-1-hash:
1# Parentloze snapshotcommit voor directe depth-1 werkruimte-checkouts
2SNAP=$(git commit-tree "$NEW_TREE" -m "$RAW_MSG")
3
4# Aan parent gekoppelde commit voor lineaire git log en 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"
Het bewaren van zowel refs/trunk/main als refs/trunk/history verdubbelt de opslagfootprint niet. Omdat beide commits naar dezelfde $NEW_TREE-hash verwijzen, dedupliceert Git's content-addressable store 100% van de tree- en blob-objecten. De extra opslagkosten van de parentloze snapshot bestaan uit slechts één commitheader van 250 bytes.
4. Geïsoleerde werkruimten (refs/ws/<actor>/<id>)
Werk-in-uitvoering-branches worden gepusht naar refs/ws/<actor>/<id> in plaats van naar refs/heads/*. Honderd codeeragents kunnen elke minuut checkpoints opslaan zonder de branch-dropdowns te vervuilen of git fetch voor menselijke ingenieurs te vertragen.
Onboarding in vier stappen: hoe sparse + blobless checkout werkt
Met deze referentie-indeling installeert een nieuwe ontwikkelaar of een tijdelijke codeeragent-container geen aangepaste CLI of FUSE-driver. Onboarding bestaat uit vier stappen met standaard Git en de aliassen van de repository:
1# 1. Clone het control plane van 20 KB met blobfiltering en sparse-modus ingeschakeld
2git clone --filter=blob:none --sparse https://github.com/example-org/monorepo.git
3cd monorepo
4
5# 2. Laad repository-aliassen en koppel refs/trunk/*
6git config include.path ../.gitconfig
7git trunk-init
8
9# 3. Geef de domeinmappen op die nodig zijn voor deze taak
10git sparse-checkout set net/relay ipc/codec
11
12# 4. Maak een geïsoleerde werkruimte aan op basis van de nieuwste trunk-snapshot
13git ws-new alice/optimize-framing
Elke stap haalt alleen op wat nodig is:
git clone --filter=blob:none --sparsedownloadt het control plane van 20 KB oprefs/heads/main.git trunk-initconfigureert+refs/trunk/*:refs/remotes/origin/trunk/*en voert een blobless fetch uit. Git downloadt detree-metadata voorrefs/trunk/main(doorgaans enkele tientallen kilobytes) en nul bestands-blobs.git sparse-checkout set net/relay ipc/codecinstrueert Git's cone-mode indexfilter om alleen de root-directory en die twee subbomen te materialiseren.git ws-newschakelt over naar een nieuwe branch gebaseerd oporigin/trunk/main. Git vraagt vervolgens alleen op verzoek deblob-objecten op binnennet/relayenipc/codec. De overige 95% van de monorepo wordt nooit naar schijf gedownload.
Toekomstige horizon: refs/trunk/main mounten als virtueel bestandssysteem (VFS)
Sparse checkout met blobless clones dekt de dagelijkse workflows zonder extra clienttooling. Omdat refs/trunk/main al een pure Merkle-tree in een Content-Addressable Store is, biedt de architectuur ook een directe basis voor een optioneel userspace virtueel bestandssysteem (VFS) via FUSE op Linux, FileProvider op macOS of ProjFS op Windows:
- Onmiddellijke mount van de volledige boom: Een lichtgewicht lokale mount-daemon leest het root-
tree-object vanrefs/trunk/mainen projecteert de volledige monorepo-directoryhiërarchie binnen milliseconden in de werkmap, waardoorgit sparse-checkout setniet meer nodig is. - Blob-faulting op verzoek: Bestandsinhoud blijft virtueel totdat een compiler, IDE of agent een
open(2)- ofread(2)-systeemaanroep uitvoert. De daemon haalt dan de overeenkomstige SHA-1blobop bij het CAS-eindpunt en cachet die op lokale NVMe-opslag. - Copy-on-write-overlay voor werkruimten: Lokale bewerkingen worden naar een lichtgewicht bovenste overlaylaag geschreven. Het uitvoeren van
git ws-savehasht alleen de gewijzigde paden in die overlay, schrijft de bijgewerkteblob- entree-objecten en pusht de resulterende commit naarrefs/ws/<actor>/<id>zonder ongewijzigde mappen te scannen.
Het changelist-model op pure Git: landing in het geheugen aan de serverzijde
In een traditionele Git-workflow levert het samenvoegen van een featurebranch ofwel een merge-commit met twee parents op, waardoor de geschiedenis niet-lineair wordt, ofwel moeten ontwikkelaars handmatig rebasen en racen tegen gelijktijdige pushes naar main.
Geïnspireerd door het Changelist (CL)-model van google3 vertegenwoordigt elke werkruimte in onze architectuur een enkele atomaire wijziging ten opzichte van de trunk-snapshot. Terwijl een ontwikkelaar of agent lokaal werkt in ws/alice/optimize-framing, kan hij tien incrementele commits maken (wip, fix tests, lint). Wanneer de wijziging klaar is om te landen, reduceert de server het werkruimte-delta tot één lineaire trunk-revisie, zonder ooit een werkmap uit te checken op de CI-runner.
Zo voert het landingsscript (land.sh) in het geheugen uit met Git 2.40+ plumbing:
1# 1. Bepaal de root-snapshotcommit waarvan de werkruimte is afgetakt
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. Bereken de samengevoegde root-tree in het geheugen zonder working-tree-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
In stap 2:
- Ongeacht hoeveel lokale commits er op de werkruimte-branch zijn gemaakt, vergelijkt het script slechts drie tree-hashes: de oorspronkelijke basissnapshot (
$BASE_TREE), de huidige trunk-snapshot ($MAIN_TREE) en de uiteindelijke werkruimte-tree ($WORK_TREE). - Als
refs/trunk/mainin//web/blogis verder gegaan terwijl Alice//net/relaybewerkte, berekentgit merge-tree --write-treede gecombineerde root-tree$NEW_TREEdirect binnen de Git-objectopslag, in milliseconden en zonder bestanden naar het bestandssysteem van de runner te schrijven. - Nadat de onderstaande beleidscontroles zijn geslaagd, werkt één
git push --atomicmet--force-with-leaserefs/trunk/mainbij en voegt één lineaire commit toe aanrefs/trunk/history, waarna de landingstriggerrefs/heads/land/<id>wordt verwijderd.
Architectuurgrenzen afdwingen: viervoudige zichtbaarheid en hiërarchische OWNERS
Het consolideren van code in één repository werkt alleen als de grenzen tussen domeinen mechanisch worden gehandhaafd voordat een commit kan landen. Direct nadat git merge-tree --write-tree $NEW_TREE heeft geproduceerd (en voordat git push --atomic de trunk bijwerkt), voert ons beleidsmechanisme twee O(delta)-controles uit op git diff-tree "$MAIN_TREE" "$NEW_TREE".
1. Viervoudige domeinzichtbaarheid
De root-tree is verdeeld in vier zichtbaarheidsniveaus:
| Directory-prefix | Niveau | Toegestane imports | Verboden imports | OWNERS-poort |
|---|---|---|---|---|
//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/* | Public Production | Public Production, //third_party/* | //corp/*, //experimental/* | Afgedwongen |
//third_party/* | External Vendor | Zelfstandig of //third_party/* | Alle domeinen die geen third_party zijn | Afgedwongen |
//corp/* | Internal Private | Public Production, //corp/*, //third_party/* | //experimental/* | Afgedwongen |
//experimental/<actor>/* | Sandbox | Elk pakket dat geen corp is | //corp/* | Uitgezonderd (automatisch goedgekeurd) |
Als een publiek pakket in //web/* probeert een intern pakket uit //corp/* of een niet-afgestudeerd prototype uit //experimental/* te importeren, breekt de landingsjob af voordat refs/trunk/main wordt aangeraakt.
2. Hiërarchische OWNERS-verificatie zonder Gerrit-server
De verifier handhaaft directory-niveau OWNERS-bestanden met overervingsregels:
- Voor elk pad dat is gewijzigd tussen
$MAIN_TREEen$NEW_TREE, loopt de verifier omhoog vanaf de directory van het bestand tot//OWNERS. - Elk
OWNERS-bestand kanset noparentdeclareren om geërfde goedkeuringen van bovenliggende directories te blokkeren, ofper-file *.sql=@dba-teamom goedkeuringen te beperken op bestandsnaamglob. - Anti-manipulatie-invariant: Bestaande
OWNERS-bestanden worden altijd gelezen uit$MAIN_TREE(de huidige trunk-tree), nooit uit$NEW_TREE. Een niet-goedgekeurde auteur kan zichzelf geen toestemming geven doorOWNERSte bewerken in dezelfde werkruimte-commit. - Sandbox-uitzondering: Elk pad onder
//experimental/<actor>/*omzeilt deOWNERS-controles, zodat ingenieurs en agents vrijuit kunnen prototypen metgit ws-exp <actor> <project>en het pakket later kunnen laten doorstromen naar een productiedomein metgit ws-graduate.
Code review zonder wrijving: tijdelijke PR's en snapshot-diffs
Omdat refs/heads/main alleen het control plane bevat en refs/trunk/main een parentloze commit is, zou het direct openen van een standaard GitHub Pull Request vanaf een werkruimte-branch naar main ongerelateerde geschiedenissen vergelijken. We hebben dit opgelost met drie reviewworkflows, afgestemd op verschillende wijzigingen:
- Directe two-dot diffs van werkruimten (
git ws-save+ Edge Diff Router) Het pushen van een werkruimte metgit ws-savemaaktrefs/ws/<actor>/<id>beschikbaar via een lichtgewicht edge-worker. Deze worker herleidtrefs/trunk/mainen de werkruimtereferentie naar hun commit-SHA's en stuurt door naar de two-dot compare-weergave van GitHub (/compare/<trunk-sha>..<ws-sha>). Bij kleine patches of door agents gegenereerde wijzigingen kunnen reviewers de diff inspecteren en direct in de webinterface op Land klikken. - Tijdelijke GitHub Pull Requests (
git ws-pr) Voor regel-voor-regel-review op GitHub pushtgit ws-prde root-snapshotcommit ($(git rev-list --max-parents=0 HEAD)) naarrefs/heads/base/<id>en de werkruimte-tip naarrefs/heads/review/<id>. Vervolgens opent het een GitHub Pull Request diereview/<id>vergelijkt metbase/<id>. Omdat de basisbranch de snapshot is waarvan de werkruimte is afgetakt, toont het tabblad Files changed van GitHub alleen het werkruimte-delta. Wanneer de reviewer op Merge pull request klikt, promoveert een webhook de head-commit naarrefs/heads/land/<id>, voert de in-memory landingspipeline uit en verwijdert automatisch zowelrefs/heads/base/<id>alsrefs/heads/review/<id>. - Pull Requests op externe spiegels Open-sourcebijdragers hoeven nooit de monorepo-workflow te leren. Zij forken en openen pull requests op zelfstandige spiegelrepositories, zoals gebruikelijk.
Wijzigingen, patchsets en behoud van goedkeuringen
Een branchnaam kan een wijziging niet identificeren. Een agent kan zijn branch hernoemen, de tip amenden of meerdere keren rebasen op een nieuwere trunk voordat hij landt. Zoals bij Gerrit identificeert de workflow een wijziging via een Change-Id: I<40 hex>-trailer in het commitbericht van de tip. git ws-save, git ws-pr en git ws-land voegen de trailer toe wanneer de tip er geen heeft en behouden deze anders, zodat de id amends, rebases en hernoemingen overleeft. De gelande trunk-commit behoudt de trailer en registreert het nummer van zijn review-pull request, zodat git trunk-cl <number|Change-Id> hem vindt in refs/trunk/history.
Elke tip die ter review wordt ingediend, wordt een patchset. git ws-pr pusht deze naar de onveranderlijke referentie refs/ws/cl/<change-id>/<tip>, naast de reviewbranches. Opnieuw git ws-pr uitvoeren na een amend of een git ws-sync voegt een patchset toe aan dezelfde pull request. git ws-ps toont de patchsets van de huidige wijziging, en git ws-psdiff <a> <b> drukt de interdiff tussen twee ervan af, zodat een reviewer alleen leest wat sinds de vorige ronde is veranderd.
GitHub koppelt een goedkeuring aan één enkele commit, dus elke rebase zou normaal gesproken een nieuwe review vereisen. De landingspipeline accepteert ook een goedkeuring die is gegeven aan een andere patchset van dezelfde wijziging, wanneer beide patchsets dezelfde patch maken op hun eigen basissnapshot. Hij vergelijkt git patch-id --verbatim over de volledige binaire diff. Een schone rebase op een nieuwere trunk landt zonder nieuwe review, terwijl elke inhoudswijziging, inclusief een witruimte- of bestandsmodus-wijziging, een nieuwe goedkeuring vereist.
O(delta) bidirectionele synchronisatie en afzonderlijke productiebranches
Veel organisaties publiceren individuele pakketten als zelfstandige GitHub-repositories, zodat externe gebruikers go get of cargo install kunnen uitvoeren, of één project kunnen forken zonder de monorepo aan te raken. Sommige projecten, zoals statische blogs of webapps met releasepoorten, onderhouden ook een aparte production-branch naast main.
Onze Starlark-synchronisatiemachine verwerkt zowel uitgaande exports als inkomende bijdragen in O(delta)-tijd, evenredig aan de bestanden die in een commit zijn gewijzigd in plaats van aan de omvang van de monorepo:
- Blobless index-splicing: Bij het in- of uitsplitsen van een subboom van een spiegelrepository in of uit de root-tree van de monorepo, zet de machine
GIT_NO_LAZY_FETCH=1en voertgit update-index --index-infouit, gevolgd doorgit write-tree --missing-ok. Ongewijzigde subbomen en blobs blijven via SHA-1 gerefereerd en worden nooit naar de CI-runner gedownload. - Prefetching van alleen delta-blobs: Voordat een geëxporteerde commit naar een zelfstandige spiegel wordt gepusht, voert het script
git diff-tree -r --diff-filter=AM "$ext_parent" "$export_tree"uit en prefetcht het alleen de oude en nieuwe blobs van de gewijzigde bestanden viagit cat-file --batch. Git kan dan een dunne deltapack berekenen en in enkele seconden naar de spiegelrepository pushen. - Behoud van workflow- en releasebranches: Tijdens uitgaande export behoudt de synchronisatie-engine de eigen
.github/workflows/-directory van elke zelfstandige repository, zodat publieke CI-runners de projecttests op de spiegel uitvoeren. Elke workflow in de Starlark-configuratie richt zich op een expliciete branch, zoalsmain, wat releasepoorten behoudt voor repositories die vanuit een aparteproduction-branch deployen. Landingen in de monorepo synchroniseren met demain-branch van de spiegel, en geautomatiseerde metadata- of vertaalcommits opmainsynchroniseren via webhook terug naarrefs/trunk/main. Het promoveren vanmainnaarproductionblijft een expliciete releasestap.
Afsluitende gedachten
Deze architectuur behandelt de objectopslag van Git als een Content-Addressable Store, scheidt refs/heads/main van refs/trunk/main en refs/trunk/history, identificeert elke wijziging via een Change-Id met onveranderlijke patchsets, en voegt bomen samen in het geheugen met git merge-tree --write-tree. De onboardingkosten groeien met de directories die een taak nodig heeft, en de kosten van landen en spiegelsynchronisatie groeien met de omvang van een wijziging, niet met de omvang van de repository.