GoSuda

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

By Lemon Mint views ...

Varje växande ingenjörsorganisation står förr eller senare inför samma vägskäl. Man börjar med dussintals små repositorier, och livet känns enkelt i början. Varje bibliotek har sin egen ärendehanterare, sin egen CI-pipeline och ett git clone som är klart innan man hinner ta en klunk kaffe. Sedan infinner sig skatten. Du ändrar en struct i ett centralt nätverkspaket och tillbringar resten av eftermiddagen med att öppna pull requests för versionsuppdateringar i sex nedströmsrepositorier, medan du väntar på att CI-köerna ska töma sig i topologisk ordning.

Flyttar man allt till en traditionell Git-monorepo byter man koordinationssmärta mot fysiksmärta. När commit-historiken växer 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 införa en anpassad virtuell filsystemsdemon, en dedikerad kodgranskningsserver eller en flotta med fjärrbyggarbetare enbart för att göra den dagliga utvecklingen uthärdlig.

När autonoma kodningsagenter ansluter sig till teamet förvärras båda felsituationerna. Polyrepos gör en agent blind för anropare i andra projekt och tvingar den att jonglera med autentiseringsuppgifter över flera repositorier. Traditionella monorepos överväldigar agentens containerdisk och nätverksbandbredd vid varje uppgiftsinitiering, medan parallella agenter som trampar på samma gren skapar oändliga rebase-kapplöpningar.

Vi ville ha ett tredje alternativ. Inspirerade av google3-arkitekturen ställde vi en enkel fråga: Kan vi få atomära ändringar på en enda trunk, sparse-onboarding på under en sekund, hierarkisk OWNERS-styrning och fristående open source-speglar enbart med hjälp av standard-Git-plumbing och billig objektlagring?

Svaret visade sig vara ja, och det krävde varken en anpassad filsystemsdemon eller en tredjepartsgranskningsapplikation. Det krävde bara att man använder Git för vad det faktiskt är under huven: ett Merkle-träd-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 når de sällan någon gräns i Gits objektdatabas. Inne i .git/objects lagrar Git tre oföränderliga primitiver, identifierade med sin SHA-1 (eller SHA-256) innehållshash:

  1. blob: Den råa bytesekvensen för en enskild fil.
  2. tree: En katalogförteckning som mappar filnamn och behörigheter till blob- eller underordnade tree-hashar.
  3. commit: En liten textrubrik (vanligtvis 200 till 300 byte) som pekar på en enda rot-tree-hash, noll eller fler förälder-commit-hashar och författarmetadata.

Som ett Content-Addressable Store är denna design anmärkningsvärt effektiv. Om en monorepo innehåller 50 000 filer och du redigerar en enda rad i en fil, återanvänder den nya commiten de exakta blob- och tree-hasharna för de övriga 49 999 filerna.

Varför känns då en traditionell Git-monorepo tung? Eftersom konventionen överbelastar en enda referens, refs/heads/main, med tre motstridiga uppgifter:

  • Den håller hela det enhetliga katalogträdet för varje projekt.
  • Den släpar med sig hela den förälderlänkade kedjan av historiska commits tillbaka till dag ett.
  • Den ligger inom refs/heads/*, namnrymden som Git-servrar annonserar och förhandlar om som standard vid varje git clone och git fetch.

Varje gång en utvecklare eller CI-runner hämtar refs/heads/main förhandlar klienten och servern om commit-grafens nåbarhet över tusentals historiska revisioner. Om någon av misstag committade ett 100 MB stort arkiv för tre år sedan och raderade det dagen efter, hänger dess blob fortfarande kvar i ett historiskt tree som är nåbart från refs/heads/main.

Uppdelning av referensnamnrymden i fyra lager

Gits wire-protokoll annonserar endast refs/heads/* och refs/tags/* under en standardklonning. Referenser under anpassade namnrymder, till exempel refs/trunk/* eller refs/ws/*, lever 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 belastning för standardklonningar.

Genom att fördela ansvaret över fyra referensnamnrymder i ett enda repositorium försvinner flaskhalsen:

1refs/heads/main          Endast kontrollplan (~20 KB: regler, .gitconfig, synkkonfiguration, skript)
2refs/trunk/main          Föräldralös snapshot-commit som pekar på senaste enhetliga rot-tree
3refs/trunk/history       Linjär förälderlänkad commit-kedja som delar exakt rot-tree-SHA-1
4refs/ws/<actor>/<id>     Isolerade arbetsytesnapshots (ändringslistor) per utvecklare eller agent
5refs/heads/land/<id>     Tillfällig utlösare för merge-kön som raderas direkt vid landning

1. Kontrollplanet (refs/heads/main)

Standardgrenen innehåller ingen applikationskällkod. Den spårar endast repositoriets driftregler (AGENTS.md, README.md), en delad .gitconfig med arbetsflödesalias, Starlark-synkroniseringskonfigurationen (sync.star) och skript för sammanslagning på servern. 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å det kompletta, enhetliga källträdet för hela organisationen (//web/*, //net/*, //ipc/*, //devtools/*, //corp/*, //third_party/*, //experimental/*), men dess commit-objekt skapas utan en -p-förälderflagga.

Eftersom refs/trunk/main saknar 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 katalogträd-tree-objekt. Historiska binärfiler eller filer som raderades förra månaden ä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 sammanslagningsskriptet två commit-objekt som pekar på exakt samma rot-$NEW_TREE-SHA-1-hash:

 1# Föräldralös snapshot-commit för omedelbara utcheckningar av arbetsyta på djup 1
 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"

Fördubblar underhållet av både refs/trunk/main och refs/trunk/history repositoriets lagringsutrymme? Inte alls. Eftersom båda commiterna refererar till exakt samma $NEW_TREE-hash deduplicerar Gits Content-Addressable Store 100 % av tree- och blob-objekten. Den extra lagringskostnaden för den föräldralösa snapshoten är en enda commit-rubrik på 250 byte.

4. Isolerade arbetsytor (refs/ws/<actor>/<id>)

Pågående arbete pushas till refs/ws/<actor>/<id> i stället för refs/heads/*. Hundra kodningsagenter kan spara checkpoints varje minut utan att förorena grenlistorna eller bromsa git fetch för mänskliga ingenjörer.

Onboarding på fem kommandon: så fungerar sparse + blobless checkout

Med denna referensstruktur installerar en ny utvecklare eller en tillfällig kodningsagent-container varken något anpassat CLI eller någon FUSE-drivrutin. Onboarding tar fem standard-Git-kommandon:

 1# 1. Klona 20 KB-kontrollplanet med blobfiltrering och sparse-läge aktiverat
 2git clone --filter=blob:none --sparse https://github.com/example-org/monorepo.git
 3cd monorepo
 4
 5# 2. Läs in repositoriealias 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 baserad på den senaste trunk-snapshoten
13git ws-new alice/optimize-framing

Under huven gör varje steg endast det minimalt nödvändiga arbetet:

  1. git clone --filter=blob:none --sparse laddar ner 20 KB-kontrollplanet på refs/heads/main.
  2. git trunk-init konfigurerar +refs/trunk/*:refs/remotes/origin/trunk/* och kör en blobless-hämtning. Git laddar ner tree-metadatan för refs/trunk/main (vanligtvis några tiotals kilobyte) och noll filblobbar.
  3. git sparse-checkout set net/relay ipc/codec instruerar Gits cone-läge för indexfiltret att endast materialisera rotkatalogen och de två underträden.
  4. git ws-new byter till en ny gren baserad på origin/trunk/main. I exakt det ögonblicket begär Git på begäran endast blob-objekten inuti net/relay och ipc/codec. Resterande 95 % av monorepon laddas aldrig ner till disken.

Framtidshorisont: montering av refs/trunk/main som virtuellt filsystem (VFS)

Sparse checkout med blobless-klonar täcker det dagliga arbetsflödet 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ändarlägesbaserat virtuellt filsystem (VFS) via FUSE på Linux, FileProvider på macOS eller ProjFS på Windows:

  1. Omedelbar montering av hela trädet: En lättviktig lokal monteringsdemon läser rot-tree-objektet för refs/trunk/main och projicerar hela monorepons kataloghierarki i arbetskatalogen på millisekunder, vilket gör att git sparse-checkout set inte längre behövs.
  2. Hämtning av blobbar på begäran: Filinnehåll förblir virtuellt tills en kompilator, IDE eller agent anropar systemanropet open(2) eller read(2). Demonen hämtar då motsvarande SHA-1-blob från CAS-slutpunkten och cachar den på lokal NVMe-lagring.
  3. Copy-on-Write-överlägg för arbetsytor: Lokala redigeringar skrivs till ett lättviktigt övre överlagringslager. Att köra git ws-save hashar endast de smutsiga sökvägarna i överlägget, skriver de uppdaterade blob- och tree-objekten och pushar den resulterande commiten till refs/ws/<actor>/<id> utan att skanna oförändrade kataloger.

Ändringslistmodellen 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 förvandlar historiken till en trasslig rangerbangård) eller kräver att utvecklare manuellt rebasar och tävlar mot andra ingenjörer som pushar till main.

Inspirerad av google3-modellen för ändringslistor (Changelist, CL) 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 klar 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örs landningsskriptet (land.sh) i minnet med Git 2.40+ plumbing:

 1# 1. Identifiera rot-snapshot-commiten 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 rot-trä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

Titta noga på steg 2:

  • Oavsett hur många lokala commits som gjorts på arbetsytans gren jämför skriptet endast tre tree-hashar: den ursprungliga basnapshoten ($BASE_TREE), den aktuella trunk-snapshoten ($MAIN_TREE) och det slutliga arbetsyte-trädet ($WORK_TREE).
  • Om refs/trunk/main avancerade i //web/blog medan Alice redigerade //net/relay beräknar git merge-tree --write-tree det kombinerade rot-trädet $NEW_TREE direkt inne i Gits objektlager på millisekunder. Inga filer skrivs till runnerns filsystem.
  • Slutligen uppdaterar git push --atomic med --force-with-lease refs/trunk/main, lägger till en linjär commit i refs/trunk/history och raderar landningsutlösaren refs/heads/land/<id> i en enda transaktion.

Upprätthållande av arkitekturgränser: fyranivåsynlighet och hierarkiska OWNERS

Att konsolidera kod i ett enda repositorium fungerar endast om gränserna mellan domänerna 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åsynlighet för domäner

Rot-trädet är uppdelat i fyra synlighetsnivåer:

KatalogprefixNivåTillåtna importerFörbjudna importerOWNERS-spärr
//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/*Publik produktionPublik produktion, //third_party/*//corp/*, //experimental/*Tillämpas
//third_party/*Extern leverantörSjälvständig eller //third_party/*Alla domäner utom third_partyTillämpas
//corp/*Intern privatPublik produktion, //corp/*, //third_party/*//experimental/*Tillämpas
//experimental/<actor>/*SandlådaValfritt paket utom corp//corp/*Undantagen (auto-godkänd)

Om ett publikt paket i //web/* försöker importera ett internt paket från //corp/* eller en oexaminerad prototyp från //experimental/* avbryts landningsjobbet innan refs/trunk/main berörs.

2. Hierarkisk OWNERS-verifiering utan en Gerrit-server

Du behöver ingen dedikerad Gerrit-instans för att upprätthålla katalognivå-OWNERS-filer med arvsregler:

  • För varje sökväg som ändrats mellan $MAIN_TREE och $NEW_TREE går verifieraren uppåt från filens katalog till //OWNERS.
  • Vilken OWNERS-fil som helst kan deklarera set noparent för att blockera godkännanden som ärvs från överordnade kataloger, eller per-file *.sql=@dba-team fö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 redigera OWNERS i samma arbetsytecommit.
  • Sandlådeundantag: Varje sökväg under //experimental/<actor>/* kringgår OWNERS-kontroller så att ingenjörer och agenter fritt kan prototypa med git ws-exp <actor> <project> och senare befordra paketet till en produktionsdomän med git 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 föräldralös commit 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:

  1. Omedelbara tvåpunkts-diffar för arbetsytor (git ws-save + Edge Diff Router) Att pusha en arbetsyta med git ws-save exponerar refs/ws/<actor>/<id> via en lättviktig edge-worker. Denna worker löser refs/trunk/main och arbetsytereferensen till deras commit-SHA:er och omdirigerar till GitHubs tvåpunkts-jä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.
  2. Tillfälliga GitHub Pull Requests (git ws-pr) För granskning rad för rad på GitHub pushar git ws-pr rot-snapshot-commiten ($(git rev-list --max-parents=0 HEAD)) till refs/heads/base/<id> och arbetsytans spets till refs/heads/review/<id>. Därefter öppnas en GitHub Pull Request som jämför review/<id> mot base/<id>. Eftersom basgrenen är exakt den snapshot som arbetsytan grenades från visar GitHubs flik Files changed endast de exakta rader som ändrats i arbetsytan. När granskaren klickar på Merge pull request befordrar en webhook head-commiten till refs/heads/land/<id>, kör den minnesbaserade landningspipelinen och raderar automatiskt både refs/heads/base/<id> och refs/heads/review/<id>.
  3. Pull requests från externa speglar Open source-bidragsgivare behöver aldrig lära sig monorepo-arbetsflödet. De forkar och öppnar pull requests mot fristående spegelrepositorier som vanligt.

Tvåvägs-synkronisering i O(delta) 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 monorepon. Vissa projekt, till exempel statiska bloggar eller webbappar med releasespärrar, underhåller dessutom 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 antalet filer som ändrats i en commit snarare än monorepons storlek:

  • Blobless Index Splicing: När ett spegelrepositorys underträd infogas i eller lyfts ut ur monorepons rot-träd sätter motorn GIT_NO_LAZY_FETCH=1 och kör git update-index --index-info följt av git write-tree --missing-ok. Oförändrade underträd och blobbar refereras enbart via sina SHA-1-hashar utan att någonsin laddas ner till CI-runnern.
  • Delta-Only Blob Prefetching: Innan en exporterad commit pushas till ett fristående spegelrepositorium kör skriptet git diff-tree -r --diff-filter=AM "$ext_parent" "$export_tree" och förhämtar endast de gamla och nya blobbarna för de ändrade filerna via git cat-file --batch. Git kan därefter beräkna ett tunt deltapaket och pusha till spegelrepositoriet på sekunder.
  • Bevarande av arbetsflödes- och releasegrenar: Vid utgående export bevarar synkroniseraren varje fristående repositorys egna katalog .github/workflows/ så att publika CI-runners kör projekttesterna på spegeln. Varje arbetsflöde i Starlark-konfigurationen riktar sig mot en explicit gren, till exempel main, vilket bevarar releasespärrar för repositorier som driftsätter från en separat production-gren. Monorepo-landningar synkroniseras med spegelns main-gren, och automatiska metadata- eller översättnings-commits på main synkroniseras tillbaka till refs/trunk/main via webhook. Att befordra main till production förblir ett explicit releasesteg.

Avslutande reflektioner

Att bygga en monorepo med hög hastighet kräver varken att man överger standard-Git eller att man driver en komplex flotta av skräddarsydda infrastrukturservrar. Denna arkitektur behandlar Gits objektlager som ett Content-Addressable Store, separerar refs/heads/main från refs/trunk/main och refs/trunk/history och slår ihop träd i minnet med git merge-tree --write-tree. Som ett resultat kan både mänskliga ingenjörer och autonoma kodningsagenter klona en arbetsyta på under en sekund och skala smidigt över hundratals projekt.