Att ompröva Git-monorepot: En CAS-native arkitektur för människor och coding agents

Varje växande ingenjörsorganisation väljer så småningom mellan många repositorier och ett. Börja med dussintals små repositorier, så känns livet enkelt i början. Varje bibliotek har sin egen ärendehanterare, sin egna CI-pipeline och ett git clone som blir klart på några sekunder. Sedan ändrar du en struct i ett centralt nätverkspaket och tillbringar resten av eftermiddagen med att öppna versionsuppdateringspull requests i sex nedströmsrepositorier, medan du väntar på att CI-köerna töms i topologisk ordning.
Flyttar du allt till ett traditionellt Git-monorepo byter du samordning mellan repositorier mot repositoriestorlek. I takt med att committhistoriken fördjupas och binära tillgångar ackumuleras i tidigare revisioner blir git fetch långsammare. Arbetskataloger sväller till gigabyte. Innan länge föreslår någon att man ska installera en anpassad daemon för virtuellt filsystem, en dedikerad kodgranskningsserver eller en flotta av fjärrbyggarbetare enbart för att göra den dagliga utvecklingen uthärdlig.
När autonoma kodagenter ansluter sig till teamet förvärras båda felmoden. Polyrepos gör en agent blind för anropare i andra projekt och tvingar den att jonglera med autentiseringsuppgifter över repositorier. Traditionella monorepos överväldigar agentens containerdisk och nätverksbandbredd vid varje uppgiftsinitiering, medan samtidiga agenter som trampar på samma gren skapar oändliga rebase-kapplöpningar.
Inspirerade av arkitekturen google3 har vi byggt atomära ändringar på en enda trunk, sparsam onboarding på under en sekund, hierarkisk OWNERS-styrning och fristående mirrors med öppen källkod, med standardmässig Git-infrastruktur och lagring på standardhårdvara.
Designen behöver varken någon anpassad filsystemdaemon eller någon tredjepartsgranskningsapplians, eftersom den behandlar Git som ett Merkle-träbaserat Content-Addressable Store (CAS).
Missuppfattningen i kärnan av långsamma Git-monorepos
När utvecklare klagar på att Git inte skalar till stora monorepos stöter de sällan på en gräns i Gits objektdatabas. Inuti .git/objects lagrar Git tre oföränderliga primitiver, identifierade med sina SHA-1- (eller SHA-256-) innehållshashar:
blob: Det råa byteinnehållet i en enskild fil.tree: En kataloglista som mappar filnamn och behörigheter tillblob- eller underordnadetree-hashar.commit: En liten textheader (vanligen 200 till 300 byte) som pekar på en enda rot-tree-hash, noll eller fler förälder-commit-hashar och författarmetadata.
Om ett monorepo innehåller 50 000 filer och du redigerar en enda rad i en fil, återanvänder den nya committen de exakta blob- och tree-hasharna för de övriga 49 999 filerna.
Ett traditionellt Git-monorepo känns tungt eftersom konventionen överbelastar en enda referens, refs/heads/main, med tre motstridiga uppgifter:
- Den innehåller hela det enhetliga katalogträdet för varje projekt.
- Den drar med sig hela den föräldrasammanlänkade kedjan av historiska commits tillbaka till första dagen.
- Den ligger inuti
refs/heads/*, namnrymden som Git-servrar annonserar och förhandlar om som standard vid varjegit cloneochgit fetch.
Varje gång en utvecklare eller CI-runner hämtar refs/heads/main förhandlar klienten och servern om nåbarhet i commit-grafen över tusentals historiska revisioner. Om någon av misstag committade ett 100 MB stort arkiv för tre år sedan och tog bort det dagen efter, hänger dess blob fortfarande kvar vid ett historiskt tree som är nåbart från refs/heads/main.
Separering av referensnamnrymden
Gits wire-protokoll annonserar endast refs/heads/* och refs/tags/* under en standardklon. Referenser under anpassade namnrymder, såsom refs/trunk/* eller refs/ws/*, finns i exakt samma objektlager på servern, drar nytta av samma packfile-deltakomprimering och kan hämtas eller pushas explicit, men de medför ingen extra kostnad för standardkloner.
Att dela upp dessa uppgifter över separata referenser i ett enda repository eliminerar flaskhalsen:
1refs/heads/main Enbart kontrollplan (~20 KB: regler, .gitconfig, synkkonfiguration, skript)
2refs/trunk/main Commit utan förälder som pekar på det senaste enhetliga rotträdet
3refs/trunk/history Linjär föräldrasammanlänkad commit-kedja som delar exakt rot-SHA-1 för trädet
4refs/ws/<actor>/<id> Isolerade arbetsytesnapshots (changelists) per utvecklare eller agent
5refs/ws/cl/<change-id>/<tip> Oföränderliga patchsets: varje tip för en ändring som skickats för granskning
6refs/heads/land/<id> Kortlivad merge-kö-trigger som raderas omedelbart vid landning
1. Kontrollplanet (refs/heads/main)
Standardgrenen innehåller ingen applikationskällkod. Den spårar endast reglerna för driften av repositoryt (AGENTS.md, README.md), en delad .gitconfig med arbetsflödesaliaser, Starlark-synkroniseringskonfigurationen (sync.star) och serversidiga merge-skript. Att klona standardgrenen överför ungefär 20 KB och är klart på millisekunder.
2. Den föräldralösa trunk-snapshoten (refs/trunk/main)
refs/trunk/main pekar på hela det enhetliga källträdet för hela organisationen (//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/*, //corp/*, //third_party/*, //experimental/*), men dess commit-objekt skapas utan en -p-förälderflagga.
Eftersom refs/trunk/main har noll föräldrar (parents = []) kräver hämtning av den ingen förhandling om commit-grafen. Git-servern går inte igenom historiken; den skickar det enda head-commit-objektet och dess aktuella katalog-tree-objekt. Historiska binärer eller filer som raderades för en månad sedan är inte nåbara från refs/trunk/main och överförs aldrig.
3. Den linjära granskningshistoriken (refs/trunk/history)
Utvecklare behöver fortfarande git blame och git log när de undersöker regressioner. Varje gång en ändring landar skapar merge-skriptet två commit-objekt som pekar på exakt samma $NEW_TREE-SHA-1-hash:
1# Snapshot-commit utan förälder för omedelbara depth-1-utcheckningar av arbetsytor
2SNAP=$(git commit-tree "$NEW_TREE" -m "$RAW_MSG")
3
4# Förälderlänkad commit för linjär git log och 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"
Att behålla både refs/trunk/main och refs/trunk/history fördubblar inte lagringsutrymmet. Eftersom båda commits refererar till samma $NEW_TREE-hash deduplicerar Gits content-addressable store 100 % av tree- och blob-objekten. Den extra lagringskostnaden för snapshot-committen utan förälder är endast en 250 byte stor commit-header.
4. Isolerade arbetsytor (refs/ws/<actor>/<id>)
Pågående arbetsgrenar pushas till refs/ws/<actor>/<id> i stället för refs/heads/*. Hundra kodagenter kan spara checkpoints varje minut utan att förorena grenlistorna eller bromsa git fetch för mänskliga ingenjörer.
Onboarding i fyra steg: så fungerar sparsam och blobfri utcheckning
Med denna referensuppdelning installerar en ny utvecklare eller en tillfällig kodagentcontainer inte något anpassat CLI eller någon FUSE-drivrutin. Onboarding tar fyra steg med standard-Git och repositoryts aliases:
1# 1. Klona 20 KB-kontrollplanet med blobfiltrering och sparsamt läge aktiverat
2git clone --filter=blob:none --sparse https://github.com/example-org/monorepo.git
3cd monorepo
4
5# 2. Läs in repositoryts aliases och koppla refs/trunk/*
6git config include.path ../.gitconfig
7git trunk-init
8
9# 3. Deklarera de domänkataloger som behövs för denna uppgift
10git sparse-checkout set net/relay ipc/codec
11
12# 4. Skapa en isolerad arbetsyta med rot i den senaste trunk-snapshoten
13git ws-new alice/optimize-framing
Varje steg hämtar endast det som behövs:
git clone --filter=blob:none --sparseladdar ner 20 KB-kontrollplanet pårefs/heads/main.git trunk-initkonfigurerar+refs/trunk/*:refs/remotes/origin/trunk/*och kör en blobfri hämtning. Git laddar nertree-metadata förrefs/trunk/main(vanligen några tiotal kilobyte) och noll filblobbar.git sparse-checkout set net/relay ipc/codecinstruerar Gits filter för indexet i cone-läge att endast materialisera rotkatalogen och dessa två underträd.git ws-newväxlar till en ny gren med rot iorigin/trunk/main. Git begär därefter vid behov endastblob-objekten inutinet/relayochipc/codec. Resterande 95 % av monorepot laddas aldrig ner till disk.
Framtidshorisont: montering av refs/trunk/main som ett virtuellt filsystem (VFS)
Sparsam utcheckning med blobfria kloner täcker de dagliga arbetsflödena utan extra klientverktyg. Eftersom refs/trunk/main redan är ett rent Merkle-träd i ett Content-Addressable Store, ger arkitekturen även en direkt grund för ett valfritt användarlandsbaserat virtuellt filsystem (VFS) via FUSE på Linux, FileProvider på macOS eller ProjFS på Windows:
- Omedelbar montering av hela trädet: En lätt lokal monteringsdaemon läser rotobjektet
treeförrefs/trunk/mainoch projicerar hela monorepots katalogstruktur i arbetskatalogen på millisekunder, vilket gör det onödigt att köragit sparse-checkout set. - Blobladdning på begäran: Filinnehåll förblir virtuellt tills en kompilator, IDE eller agent anropar systemanropet
open(2)ellerread(2). Daemonen hämtar då motsvarande SHA-1-blobfrån CAS-slutpunkten och cachar den på lokal NVMe-lagring. - Copy-on-write-overlay för arbetsytor: Lokala redigeringar skrivs till ett lätt övre overlay-lager. Att köra
git ws-savehashar endast de smutsiga sökvägarna i det överlagret, skriver de uppdateradeblob- ochtree-objekten och pushar den resulterande committen tillrefs/ws/<actor>/<id>utan att skanna oförändrade kataloger.
Changelist-modellen i ren Git: sammanslagning i minnet på servern
I ett traditionellt Git-arbetsflöde skapar sammanslagning av en funktionsgren antingen en merge-commit med två föräldrar, vilket gör historiken icke-linjär, eller kräver att utvecklare manuellt rebasear och tävlar mot samtidiga pushar till main.
Inspirerad av modellen Changelist (CL) i google3 representerar varje arbetsyta i vår arkitektur en enda atomär ändring mot trunk-snapshoten. Medan en utvecklare eller agent arbetar lokalt i ws/alice/optimize-framing kan den göra tio inkrementella commits (wip, fix tests, lint). När ändringen är redo att landa kollapsar servern arbetsytans delta till en enda linjär trunk-revision utan att någonsin checka ut en arbetskatalog på CI-runnern.
Så här kör landningsskriptet (land.sh) i minnet med Git 2.40+-plumbing:
1# 1. Identifiera rotsnapshot-committen där arbetsytan grenades av
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. Beräkna det sammanslagna rotträdet i minnet utan utcheckning av arbetskatalog
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
I steg 2 gäller följande:
- Oavsett hur många lokala commits som gjorts på arbetsytans gren jämför skriptet endast tre trädhashar: den ursprungliga basnapshoten (
$BASE_TREE), den aktuella trunk-snapshoten ($MAIN_TREE) och det slutliga arbetsyteträdet ($WORK_TREE). - Om
refs/trunk/mainavancerade i//web/blogmedan Alice redigerade//net/relay, beräknargit merge-tree --write-treedet kombinerade rotträdet$NEW_TREEdirekt i Gits objektlager på millisekunder, utan att skriva filer till runnerns filsystem. - När de policykontroller som beskrivs nedan har godkänts uppdaterar ett enda
git push --atomicmed--force-with-leaserefs/trunk/main, lägger till en linjär commit irefs/trunk/history, och landningstriggernrefs/heads/land/<id>raderas.
Att tillämpa arkitekturgränser: fyranivåsynlighet och hierarkiska OWNERS
Att konsolidera kod i ett enda repository fungerar endast om gränserna mellan domäner upprätthålls mekaniskt innan en commit kan landa. Direkt efter att git merge-tree --write-tree har producerat $NEW_TREE (och innan git push --atomic uppdaterar trunk) kör vår policymotor två O(delta)-kontroller över git diff-tree "$MAIN_TREE" "$NEW_TREE".
1. Fyranivåig domänsynlighet
Rotträdet är indelat i fyra synlighetsnivåer:
| Katalogprefix | Nivå | Tillåtna importer | Förbjudna importer | OWNERS-grind |
|---|---|---|---|---|
//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/* | Publik produktion | Publik produktion, //third_party/* | //corp/*, //experimental/* | Tillämpas |
//third_party/* | Extern leverantör | Självständig eller //third_party/* | Alla domäner utom third_party | Tillämpas |
//corp/* | Intern privat | Publik produktion, //corp/*, //third_party/* | //experimental/* | Tillämpas |
//experimental/<actor>/* | Sandlåda | Valfritt paket utom corp | //corp/* | Undantagen (automatiskt godkänd) |
Om ett publikt paket i //web/* försöker importera ett internt paket från //corp/* eller en oavslutad prototyp från //experimental/*, avbryts landningsjobbet innan refs/trunk/main berörs.
2. Hierarkisk OWNERS-verifiering utan en Gerrit-server
Verifieraren tillämpar katalognivåns OWNERS-filer med arvsregler:
- För varje sökväg som ändrats mellan
$MAIN_TREEoch$NEW_TREEgår verifieraren uppåt från filens katalog till//OWNERS. - Vilken
OWNERS-fil som helst kan deklareraset noparentför att blockera godkännanden som ärvs från överordnade kataloger, ellerper-file *.sql=@dba-teamför att avgränsa godkännanden efter filnamnsmönster. - Invariant mot manipulation: Befintliga
OWNERS-filer läses alltid från$MAIN_TREE(det aktuella trunk-trädet), aldrig från$NEW_TREE. En ogodkänd författare kan inte ge sig själv behörighet genom att redigeraOWNERSi samma arbetsytecommit. - Sandlådeundantag: Varje sökväg under
//experimental/<actor>/*kringgårOWNERS-kontroller, så att ingenjörer och agenter fritt kan prototypa medgit ws-exp <actor> <project>och senare befordra paketet till en produktionsdomän medgit ws-graduate.
Kodgranskning utan friktion: tillfälliga PR:er och snapshot-diffar
Eftersom refs/heads/main endast innehåller kontrollplanet och refs/trunk/main är en commit utan förälder, skulle en vanlig GitHub Pull Request direkt från en arbetsytegren till main försöka jämföra orelaterade historiker. Vi löste detta med tre granskningsarbetsflöden anpassade efter olika ändringar:
- Omedelbara tvåpunktsdiffar för arbetsytor (
git ws-save+ Edge Diff Router) Att pusha en arbetsyta medgit ws-saveexponerarrefs/ws/<actor>/<id>via en lätt edge-worker. Workern löserrefs/trunk/mainoch arbetsytereferensen till deras commit-SHA:er och omdirigerar till GitHubs tvåpunktsjämförelsevy (/compare/<trunk-sha>..<ws-sha>). För små patchar eller agentgenererade ändringar kan granskare inspektera diffen och klicka på Land direkt i webbgränssnittet. - Tillfälliga GitHub Pull Requests (
git ws-pr) För rad-för-rad-granskning på GitHub pushargit ws-prrotsnapshot-committen ($(git rev-list --max-parents=0 HEAD)) tillrefs/heads/base/<id>och arbetsytans spets tillrefs/heads/review/<id>. Därefter öppnar den en GitHub Pull Request som jämförreview/<id>motbase/<id>. Eftersom basgrenen är snapshoten som arbetsytan grenades från visar GitHubs flik Files changed endast arbetsytans delta. När granskaren klickar på Merge pull request befordrar en webhook head-committen tillrefs/heads/land/<id>, kör landningspipelinen i minnet och raderar automatiskt båderefs/heads/base/<id>ochrefs/heads/review/<id>. - Pull Requests mot externa speglar Bidragsgivare till projekt med öppen källkod behöver aldrig lära sig monorepo-arbetsflödet. De forkar och öppnar pull requests mot fristående spegelrepositorier på vanligt sätt.
Ändringar, patchsets och överföring av godkännanden
Ett grennamn kan inte identifiera en ändring. En agent kan byta namn på sin gren, ändra spetsen eller rebasa mot en nyare trunk flera gånger innan landning. Liksom i Gerrit identifierar arbetsflödet en ändring med en Change-Id: I<40 hex>-trailer i spetscommitens meddelande. git ws-save, git ws-pr och git ws-land lägger till trailern när spetsen saknar den och behåller den annars, så att identifieraren överlever ändringar, rebaser och namnbyten. Den landade trunk-committen behåller trailern och registrerar numret på sin granskningspull request, så att git trunk-cl <number|Change-Id> hittar den i refs/trunk/history.
Varje spets som skickas för granskning blir ett patchset. git ws-pr pushar den till den oföränderliga referensen refs/ws/cl/<change-id>/<tip> bredvid granskningsgrenarna. Att köra git ws-pr igen efter en ändring eller en git ws-sync lägger till ett patchset till samma pull request. git ws-ps listar patchseten för den aktuella ändringen, och git ws-psdiff <a> <b> skriver ut interdiffen mellan två av dem, så att en granskare endast läser det som ändrats sedan föregående omgång.
GitHub binder ett godkännande till en enskild commit, så varje rebase skulle normalt kräva en ny granskning. Landningspipelinen accepterar också ett godkännande som givits till ett annat patchset av samma ändring, när båda patchseten gör samma patch mot sin egen basnapshot. Den jämför git patch-id --verbatim över den fullständiga binära diffen. En ren rebase mot en nyare trunk landar utan ytterligare granskning, medan varje innehållsändring, inklusive en ändring av blanksteg eller filläge, kräver ett nytt godkännande.
O(delta) dubbelriktad synkronisering och separata produktionsgrenar
Många organisationer publicerar enskilda paket som fristående GitHub-repositorier, så att externa användare kan köra go get, cargo install eller forka ett enskilt projekt utan att röra monorepot. Vissa projekt, till exempel statiska bloggar eller webbappar med releasespärrar, underhåller också en separat production-gren vid sidan av main.
Vår Starlark-synkroniseringsmotor hanterar både utgående exporter och inkommande bidrag i O(delta)-tid, proportionell mot de filer som ändrats i en commit snarare än mot monorepots storlek:
- Blobfri indexinfogning: När ett speglingsrepositorys underträd infogas i eller tas ut ur monorepots rotträd sätter motorn
GIT_NO_LAZY_FETCH=1och körgit update-index --index-infoföljt avgit write-tree --missing-ok. Oförändrade underträd och blobbar refereras fortsatt via SHA-1 och laddas aldrig ner till CI-runnern. - Prefetch av endast delta-blobbar: Innan en exporterad commit pushas till en fristående spegel kör skriptet
git diff-tree -r --diff-filter=AM "$ext_parent" "$export_tree"och prefetchar endast de gamla och nya blobbarna för de ändrade filerna viagit cat-file --batch. Git kan då beräkna ett tunt deltapaket och pusha till speglingsrepositoryt på några sekunder. - Bevarande av arbetsflöden och releasegrenar: Vid utgående export bevarar synkroniseraren varje fristående repositorys egna
.github/workflows/-katalog, så att publika CI-runners kör projekttester på spegeln. Varje arbetsflöde i Starlark-konfigurationen riktar sig mot en explicit gren, till exempelmain, vilket bevarar releasespärrar för repositorier som deployar från en separatproduction-gren. Monorepo-landningar synkroniseras med speglensmain-gren, och automatiserade metadata- eller översättningscommits påmainsynkroniseras tillbaka tillrefs/trunk/mainvia webhook. Att befordramaintillproductionförblir ett explicit releasesteg.
Avslutande reflektioner
Denna arkitektur behandlar Gits objektlager som ett Content-Addressable Store, separerar refs/heads/main från refs/trunk/main och refs/trunk/history, identifierar varje ändring med ett Change-Id och oföränderliga patchset, och sammanfogar träd i minnet med git merge-tree --write-tree. Introduktionskostnaden växer med de kataloger som en uppgift behöver, och kostnaden för landning och spegelsynkronisering växer med storleken på en ändring, inte med storleken på repositoryt.