GoSuda

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

By Lemon Mint views ...

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:

  1. blob: De ruwe bytes van de inhoud van één bestand.
  2. tree: Een directorylijst die bestandsnamen en rechten koppelt aan blob- of onderliggende tree-hashes.
  3. 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 elke git clone en git 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:

  1. git clone --filter=blob:none --sparse downloadt het control plane van 20 KB op refs/heads/main.
  2. git trunk-init configureert +refs/trunk/*:refs/remotes/origin/trunk/* en voert een blobless fetch uit. Git downloadt de tree-metadata voor refs/trunk/main (doorgaans enkele tientallen kilobytes) en nul bestands-blobs.
  3. git sparse-checkout set net/relay ipc/codec instrueert Git's cone-mode indexfilter om alleen de root-directory en die twee subbomen te materialiseren.
  4. git ws-new schakelt over naar een nieuwe branch gebaseerd op origin/trunk/main. Git vraagt vervolgens alleen op verzoek de blob-objecten op binnen net/relay en ipc/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:

  1. Onmiddellijke mount van de volledige boom: Een lichtgewicht lokale mount-daemon leest het root-tree-object van refs/trunk/main en projecteert de volledige monorepo-directoryhiërarchie binnen milliseconden in de werkmap, waardoor git sparse-checkout set niet meer nodig is.
  2. Blob-faulting op verzoek: Bestandsinhoud blijft virtueel totdat een compiler, IDE of agent een open(2)- of read(2)-systeemaanroep uitvoert. De daemon haalt dan de overeenkomstige SHA-1 blob op bij het CAS-eindpunt en cachet die op lokale NVMe-opslag.
  3. Copy-on-write-overlay voor werkruimten: Lokale bewerkingen worden naar een lichtgewicht bovenste overlaylaag geschreven. Het uitvoeren van git ws-save hasht alleen de gewijzigde paden in die overlay, schrijft de bijgewerkte blob- en tree-objecten en pusht de resulterende commit naar refs/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/main in //web/blog is verder gegaan terwijl Alice //net/relay bewerkte, berekent git merge-tree --write-tree de gecombineerde root-tree $NEW_TREE direct 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 --atomic met --force-with-lease refs/trunk/main bij en voegt één lineaire commit toe aan refs/trunk/history, waarna de landingstrigger refs/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-prefixNiveauToegestane importsVerboden importsOWNERS-poort
//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/*Public ProductionPublic Production, //third_party/*//corp/*, //experimental/*Afgedwongen
//third_party/*External VendorZelfstandig of //third_party/*Alle domeinen die geen third_party zijnAfgedwongen
//corp/*Internal PrivatePublic Production, //corp/*, //third_party/*//experimental/*Afgedwongen
//experimental/<actor>/*SandboxElk 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_TREE en $NEW_TREE, loopt de verifier omhoog vanaf de directory van het bestand tot //OWNERS.
  • Elk OWNERS-bestand kan set noparent declareren om geërfde goedkeuringen van bovenliggende directories te blokkeren, of per-file *.sql=@dba-team om 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 door OWNERS te bewerken in dezelfde werkruimte-commit.
  • Sandbox-uitzondering: Elk pad onder //experimental/<actor>/* omzeilt de OWNERS-controles, zodat ingenieurs en agents vrijuit kunnen prototypen met git ws-exp <actor> <project> en het pakket later kunnen laten doorstromen naar een productiedomein met git 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:

  1. Directe two-dot diffs van werkruimten (git ws-save + Edge Diff Router) Het pushen van een werkruimte met git ws-save maakt refs/ws/<actor>/<id> beschikbaar via een lichtgewicht edge-worker. Deze worker herleidt refs/trunk/main en 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.
  2. Tijdelijke GitHub Pull Requests (git ws-pr) Voor regel-voor-regel-review op GitHub pusht git ws-pr de root-snapshotcommit ($(git rev-list --max-parents=0 HEAD)) naar refs/heads/base/<id> en de werkruimte-tip naar refs/heads/review/<id>. Vervolgens opent het een GitHub Pull Request die review/<id> vergelijkt met base/<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 naar refs/heads/land/<id>, voert de in-memory landingspipeline uit en verwijdert automatisch zowel refs/heads/base/<id> als refs/heads/review/<id>.
  3. 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=1 en voert git update-index --index-info uit, gevolgd door git 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 via git 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, zoals main, wat releasepoorten behoudt voor repositories die vanuit een aparte production-branch deployen. Landingen in de monorepo synchroniseren met de main-branch van de spiegel, en geautomatiseerde metadata- of vertaalcommits op main synchroniseren via webhook terug naar refs/trunk/main. Het promoveren van main naar production blijft 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.