A Git Monorepo újragondolása: CAS-Native Architecture emberek és Coding Agents számára

Minden növekvő mérnöki szervezet előbb-utóbb ugyanahhoz az elágazáshoz érkezik. Ha több tucat kis tárolóval indulunk, kezdetben minden egyszerűnek tűnik. Minden könyvtárnak saját hibakövetője, saját CI-folyamata és olyan git clone művelete van, amely befejeződik, mielőtt az ember egy kortyot inna a kávéjából. Aztán megjelenik az adó. Megváltoztatsz egy struktúrát egy központi hálózati csomagban, és a délután hátralévő részét azzal töltöd, hogy hat downstream tárolóban nyiss verziófrissítő pull requesteket, miközben a CI-sorok topologikus sorrendben ürülnek ki.
Ha mindent egy hagyományos Git-monorepóba költöztetsz, a koordinációs fájdalmat fizikai jellegű fájdalomra cseréled. Ahogy a commit-előzmények mélyülnek, és a bináris eszközök felhalmozódnak a korábbi revíziókban, a git fetch lassulni kezd. A munkafák gigabájtos méretűre duzzadnak. Nemsokára valaki javasolni fogja egy egyedi virtuális fájlrendszer-démon, egy dedikált kódellenőrző szerver vagy egy távoli buildmunkás-flotta felállítását, csak hogy a napi fejlesztés elviselhető maradjon.
Amikor autonóm kódoló ügynökök csatlakoznak a csapathoz, mindkét hibamód súlyosabbá válik. A polirepók megvakítják az ügynököt a projektek közötti hívókkal szemben, és arra kényszerítik, hogy tárolók között jongláljon a hitelesítő adataival. A hagyományos monorepók minden feladat inicializálásakor elnyelik az ügynök konténerének lemezterületét és hálózati sávszélességét, miközben a közös ágat egyidejűleg módosító ügynökök végtelen rebase-versenyeket hoznak létre.
Egy harmadik lehetőséget kerestünk. A google3 architektúrától inspirálva egy egyszerű kérdést tettünk fel: Elérhetjük-e az egyetlen törzsön alapuló atomi változtatásokat, a másodperc alatt végbemenő ritka onboardingot, a hierarchikus OWNERS alapú irányítást és az önálló nyílt forráskódú tükröket pusztán a szabványos Git-infrastruktúrával és olcsó objektumtárolással?
A válasz igen volt, és nem igényelt egyedi fájlrendszer-démont vagy harmadik féltől származó felülvizsgálati eszközt. Csupán azt kellett megtenni, hogy a Gitet arra használjuk, ami valójában a motorháztető alatt: egy Merkle-fa alapú tartalomcímzett tárolóra (Content-Addressable Store, CAS).
A lassú Git-monorepók szívében rejlő tévhit
Amikor a fejlesztők panaszkodnak, hogy a Git nem skálázódik nagy monorepókra, ritkán ütköznek a Git objektumadatbázisának korlátjába. A .git/objects könyvtárban a Git három megváltoztathatatlan primitívet tárol, amelyeket SHA-1 (vagy SHA-256) tartalomhash azonosít:
blob: Egyetlen fájl nyers bájttartalma.tree: Könyvtárlista, amely fájlneveket és jogosultságokat képez leblobvagy gyermektreehash-ekre.commit: Apró szöveges fejléc (jellemzően 200 és 300 bájt között), amely egyetlen gyökértreehash-re, nulla vagy több szülőcommithash-re és szerzői metaadatokra mutat.
Tartalomcímzett tárolóként ez a tervezés figyelemre méltóan hatékony. Ha egy monorepóban 50 000 fájl van, és egyetlen fájl egyetlen sorát módosítod, az új commit a másik 49 999 fájl pontos blob és tree hash-eit újrafelhasználja.
Miért érződik akkor a hagyományos Git-monorepó nehéznek? Azért, mert a konvenció egyetlen hivatkozásra, a refs/heads/main-re három egymással ütköző feladatot ró:
- Ez tartalmazza minden projekt teljes, egységes könyvtárfáját.
- Maga után húzza a teljes szülőhivatkozású történeti commit-láncot az első naptól kezdve.
- A
refs/heads/*névtérben van, amelyet a Git-szerverek alapértelmezés szerint hirdetnek és amelyről egyeztetnek mindengit cloneésgit fetchsorán.
Valahányszor egy fejlesztő vagy CI-futtató lekéri a refs/heads/main-t, a kliens és a szerver több ezer történeti revízión keresztül egyeztetik a commit-gráf elérhetőségét. Ha valaki három évvel ezelőtt véletlenül commitolt egy 100 MB-os archívumot, majd másnap törölte, annak blob-ja továbbra is egy olyan történeti tree-n lóg, amely elérhető a refs/heads/main-ről.
A hivatkozási névtér szétválasztása négy rétegre
A Git hálózati protokollja szabványos klónozáskor csak a refs/heads/* és refs/tags/* hivatkozásokat hirdeti. Az egyedi névterek, mint a refs/trunk/* vagy a refs/ws/*, ugyanabban az objektumtárban élnek a szerveren, ugyanazt a packfile-deltatömörítést használják, és kifejezetten letölthetők vagy feltölthetők, mégsem terhelik a párhuzamos alapértelmezett klónokat.
Ha a felelősségeket négy hivatkozási névtér között osztjuk szét egyetlen tárolón belül, a szűk keresztmetszet megszűnik:
1refs/heads/main Csak vezérlési sík (~20 KB: szabályok, .gitconfig, szinkronizációs konfiguráció, szkriptek)
2refs/trunk/main Szülő nélküli pillanatkép-commit, amely a legfrissebb egységes gyökér fára mutat
3refs/trunk/history Lineáris, szülőhivatkozásos commit-lánc, amely pontosan ugyanazt a gyökér fa SHA-1-et használja
4refs/ws/<actor>/<id> Elkülönített munkaterület-pillanatképek (changelistek) fejlesztőnként vagy ügynökönként
5refs/heads/land/<id> Átmeneti összefésülési sorbaállító trigger, amely a landolás után azonnal törlődik
1. A vezérlési sík (refs/heads/main)
Az alapértelmezett ág nem tartalmaz alkalmazásforráskódot. Csak a tárolóműködési szabályokat (AGENTS.md, README.md), a munkafolyamat-aliasok közös .gitconfig fájlját, a Starlark szinkronizációs konfigurációt (sync.star) és a szerveroldali összefésülési szkripteket követi nyomon. Az alapértelmezett ág klónozása nagyjából 20 KB adatot visz át, és milliszekundumok alatt befejeződik.
2. A szülő nélküli törzspillanatkép (refs/trunk/main)
A refs/trunk/main a teljes szervezet egységes forrásfájára mutat (//web/*, //net/*, //ipc/*, //devtools/*, //corp/*, //third_party/*, //experimental/*), de commit-objektuma -p szülőjelző nélkül jön létre.
Mivel a refs/trunk/main-nek nincs szülője (parents = []), a letöltéséhez nincs szükség commit-gráf egyeztetésre. A Git-szerver nem járja be az előzményeket; elküldi az egyetlen fejcommitot és annak aktuális könyvtárfa-objektumait. A múlt hónapban töröltfájlok vagy történeti binárisok nem érhetők el a refs/trunk/main-ről, és soha nem kerülnek átvitelre.
3. A lineáris auditelőzmény (refs/trunk/history)
A fejlesztőknek továbbra is szükségük van a git blame és git log parancsokra regressziók vizsgálatakor. Minden alkalommal, amikor egy változtatás landol, az összefésülési szkript két commit-objektumot hoz létre, amelyek ugyanarra a pontosan azonos $NEW_TREE gyökérfa SHA-1 hash-re mutatnak:
1# Szülő nélküli pillanatkép-commit a azonnali, mélység-1 munkaterület-kijelentkezéshez
2SNAP=$(git commit-tree "$NEW_TREE" -m "$RAW_MSG")
3
4# Szülőhivatkozásos commit a lineáris git log és git blame parancsokhoz
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"
Megduplázza-e a refs/trunk/main és a refs/trunk/history fenntartása a tároló tárhelyigényét? Egyáltalán nem. Mivel mindkét commit ugyanarra az azonos $NEW_TREE hash-re hivatkozik, a Git tartalomcímzett tárolója a tree és blob objektumok 100%-át deduplikálja. A szülő nélküli pillanatkép többletköltsége egyetlen, 250 bájtos commit-fejléc.
4. Elkülönített munkaterületek (refs/ws/<actor>/<id>)
A folyamatban lévő munkaágak a refs/heads/* helyett a refs/ws/<actor>/<id> alá kerülnek feltöltésre. Száz kódoló ügynök percenként mentheti ellenőrzőpontjait anélkül, hogy elszennyezné az ágválasztó listákat vagy lassítaná az emberi mérnökök git fetch műveleteit.
Onboarding öt parancsban: hogyan működik a ritka és blobmentes kijelentkezés
Ezzel a hivatkozási elrendezéssel egy új fejlesztő vagy egy átmeneti kódoló ügynök konténere nem telepít egyedi CLI-t vagy FUSE-illesztőt. Az onboarding öt szabványos Git-parancsot igényel:
1# 1. A 20 KB-os vezérlési sík klónozása blobszűrővel és ritka módban
2git clone --filter=blob:none --sparse https://github.com/example-org/monorepo.git
3cd monorepo
4
5# 2. A tárolóaliasok betöltése és a refs/trunk/* csatolása
6git config include.path ../.gitconfig
7git trunk-init
8
9# 3. A feladathoz szükséges domain-könyvtárak megadása
10git sparse-checkout set net/relay ipc/codec
11
12# 4. Elkülönített munkaterület létrehozása a legfrissebb törzspillanatkép gyökerén
13git ws-new alice/optimize-framing
A motorháztető alatt minden lépés csak a minimálisan szükséges munkát végzi el:
- A
git clone --filter=blob:none --sparseletölti a 20 KB-os vezérlési síkot arefs/heads/mainágról. - A
git trunk-initbeállítja a+refs/trunk/*:refs/remotes/origin/trunk/*leképezést, és blobmentes letöltést futtat. A Git letölti arefs/trunk/maintreemetaadatait (jellemzően néhány tucat kilobájt), és nulla fájl-blobot. - A
git sparse-checkout set net/relay ipc/codecutasítja a Git kúpmódú index-szűrőjét, hogy csak a gyökérkönyvtárat és e két alfát jelenítse meg. - A
git ws-newátvált egy új ágra, amelynek gyökere azorigin/trunk/main. Ebben a pillanatban a Git igény szerint csak anet/relayésipc/codecbelsejében lévőblobobjektumokat kéri le. A monorepó fennmaradó 95%-a soha nem kerül letöltésre a lemezre.
Jövőbeli távlat: a refs/trunk/main csatolása virtuális fájlrendszerként (VFS)
A blobmentes klónokkal végzett ritka kijelentkezés extra kliensoldali eszközök nélkül lefedi a napi munkafolyamatokat. Mivel a refs/trunk/main már tiszta Merkle-fa egy tartalomcímzett tárolóban, az architektúra közvetlen alapot nyújt egy opcionális felhasználói térbeli virtuális fájlrendszerhez (VFS) is: FUSE Linuxon, FileProvider macOS-en vagy ProjFS Windows-on:
- Azonnali teljes fa csatolás: Egy könnyű helyi csatolódémon olvassa a
refs/trunk/maingyökértreeobjektumát, és milliszekundumok alatt levetíti a teljes monorepó könyvtárhierarchiát a munkakönyvtárba, így nincs szükség agit sparse-checkout setfuttatására. - Igény szerinti blob-betöltés: A fájltartalmak virtuálisak maradnak, amíg egy fordító, IDE vagy ügynök nem ad ki
open(2)vagyread(2)rendszerhívást. A démon ekkor lekéri a megfelelő SHA-1blob-ot a CAS-végpontról, és helyi NVMe tárolón gyorsítótárazza. - Copy-on-Write átfedés a munkaterületekhez: A helyi szerkesztések egy könnyű felső átfedő rétegbe íródnak. A
git ws-savefuttatása csak az átfedésben szennyezett útvonalakat hash-eli, kiírja a frissítettblobéstreeobjektumokat, és a keletkező commitot arefs/ws/<actor>/<id>alá tölti fel anélkül, hogy a módosítatlan könyvtárakat átvizsgálná.
A changelist modell tiszta Gitben: memóriabeli landolás szerveroldalon
Egy hagyományos Git-munkafolyamatban egy funkcióág összefésülése vagy kétszülős összefésülési commitot hoz létre (az előzményeket összegubancolt vasúti pályaudvarrá alakítva), vagy arra kényszeríti a fejlesztőket, hogy kézzel rebase-eljenek, és versenyezzenek más mérnökökkel, akik a main-re tolnak.
A google3 Changelist (CL) modelltől inspirálva architektúránk minden munkaterülete egyetlen atomi változtatást képvisel a törzspillanatképhez képest. Amíg a ws/alice/optimize-framing mappában helyben dolgozik, egy fejlesztő vagy ügynök akár tíz fokozatos commitot is készíthet (wip, fix tests, lint). Amikor a változtatás készen áll a landolásra, a szerver összevonja a munkaterület-különbséget egyetlen lineáris törzsrevízióba anélkül, hogy a CI-futtatón munkakönyvtárat kellene kijelentkezni.
Így hajtja végre a landolási szkript (land.sh) memóriában, Git 2.40+ alacsony szintű parancsaival:
1# 1. Azon gyökér-pillanatkép commit azonosítása, ahonnan a munkaterület elágazott
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. Az összevont gyökérfa kiszámítása memóriában, munkafa-kijelentkezés nélkül
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
Figyelmesen nézd meg a 2. lépést:
- Függetlenül attól, hogy hány helyi commit készült a munkaterület-ágon, a szkript csak három fa-hash-t hasonlít össze: az eredeti alappillanatképet (
$BASE_TREE), az aktuális törzspillanatképet ($MAIN_TREE) és a végső munkaterület-fát ($WORK_TREE). - Ha a
refs/trunk/maina//web/blogalatt haladt előre, miközben Alice a//net/relayrészt szerkesztette, agit merge-tree --write-treemilliszekundumok alatt közvetlenül a Git objektumtáron belül kiszámítja a kombinált gyökérfát, a$NEW_TREE-t. A futtató fájlrendszerére nem íródik ki fájl. - Végül a
--force-with-leaseopciósgit push --atomicfrissíti arefs/trunk/main-t, hozzáfűz egy lineáris commitot arefs/trunk/history-hoz, és egyetlen tranzakcióban törli arefs/heads/land/<id>landolási triggert.
Architekturális határok kikényszerítése: négyszintű láthatóság és hierarchikus OWNERS
A kód egyetlen tárolóba konszolidálása csak akkor működik, ha a domainek közötti határokat a commit landolása előtt mechanikusan kikényszerítjük. Közvetlenül azután, hogy a git merge-tree --write-tree előállítja a $NEW_TREE-t (és mielőtt a git push --atomic frissítené a törzset), a szabályzatmotorunk két O(delta) ellenőrzést futtat a git diff-tree "$MAIN_TREE" "$NEW_TREE" kimenetén.
1. Négyszintű domain-láthatóság
A gyökérfa négy láthatósági szintre van osztva:
| Könyvtár-előtag | Szint | Engedélyezett importok | Tiltott importok | OWNERS kapu |
|---|---|---|---|---|
//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/* | Nyilvános éles | Nyilvános éles, //third_party/* | //corp/*, //experimental/* | Kényszerített |
//third_party/* | Külső szállító | Önálló vagy //third_party/* | Minden nem third_party domain | Kényszerített |
//corp/* | Belső magán | Nyilvános éles, //corp/*, //third_party/* | //experimental/* | Kényszerített |
//experimental/<actor>/* | Homokozó | Bármely nem corp csomag | //corp/* | Mentesített (automatikusan jóváhagyott) |
Ha a //web/* egyik nyilvános csomagja megpróbál importálni egy belső csomagot a //corp/*-ből, vagy egy még át nem emelt prototípust a //experimental/*-ból, a landolási feladat megszakad, mielőtt hozzányúlna a refs/trunk/main-hez.
2. Hierarchikus OWNERS ellenőrzés Gerrit-szerver nélkül
Nincs szükség dedikált Gerrit-példányra a könyvtárszintű, öröklési szabályokkal rendelkező OWNERS fájlok kikényszerítéséhez:
- A
$MAIN_TREEés a$NEW_TREEközött módosított minden útvonalra az ellenőrző a fájl könyvtárától felfelé halad a//OWNERSfájlig. - Bármely
OWNERSfájl deklarálhatja aset noparentutasítást, amellyel blokkolja a szülőkönyvtárakból öröklődő jóváhagyásokat, vagy aper-file *.sql=@dba-teamutasítást, amellyel a jóváhagyásokat fájlnév-mintákra szűkíti. - Manipulációgátló invariáns: A meglévő
OWNERSfájlokat mindig a$MAIN_TREE-ből (az aktuális törzsfából) olvassuk, soha nem a$NEW_TREE-ből. Egy jogosulatlan szerző nem adhatja meg magának a jogosultságot úgy, hogy ugyanabban a munkaterület-commitban módosítja azOWNERSfájlt. - Homokozó-mentesség: A
//experimental/<actor>/*alatti minden útvonal megkerüli azOWNERSellenőrzéseket, hogy a mérnökök és ügynökök szabadon prototipizálhassanak agit ws-exp <actor> <project>paranccsal, majd később agit ws-graduateparanccsal egy éles domainbe emelhessék a csomagot.
Felülvizsgálat súrlódás nélkül: átmeneti PR-ok és pillanatkép-diffek
Mivel a refs/heads/main csak a vezérlési síkot tartalmazza, a refs/trunk/main pedig szülő nélküli commit, egy munkaterület-ágból közvetlenül a main-be nyitott hagyományos GitHub Pull Request nem összehasonlítható történeteket próbálna összevetni. Erre három, a különböző változtatásokhoz szabott felülvizsgálati munkafolyamattal válaszoltunk:
- Azonnali kétpontos munkaterület-diffek (
git ws-save+ élszintű diff-útválasztó) Agit ws-saveparanccsal feltöltött munkaterület arefs/ws/<actor>/<id>hivatkozást egy könnyű élszintű munkavégzőn keresztül teszi elérhetővé. Ez a munkavégző arefs/trunk/main-t és a munkaterület-hivatkozást commit SHA-ikra oldja fel, majd átirányít a GitHub kétpontos összehasonlító nézetére (/compare/<trunk-sha>..<ws-sha>). Kisebb javításoknál vagy ügynökök által generált változtatásoknál a felülvizsgálók megvizsgálhatják a különbséget, és közvetlenül a webes felületen a Land gombra kattinthatnak. - Átmeneti GitHub Pull Requestek (
git ws-pr) A GitHubon soronkénti felülvizsgálathoz agit ws-prfeltölti a gyökér-pillanatkép commitot ($(git rev-list --max-parents=0 HEAD)) arefs/heads/base/<id>alá, a munkaterület csúcsát pedig arefs/heads/review/<id>alá. Ezután GitHub Pull Requestet nyit, amely areview/<id>-t hasonlítja össze abase/<id>-vel. Mivel az alapág pontosan az a pillanatkép, ahonnan a munkaterület elágazott, a GitHub Files changed fülén csak a munkaterületen ténylegesen módosított sorok látszanak. Amikor a felülvizsgáló a Merge pull request gombra kattint, egy webhook előléptetí a fejcommitot arefs/heads/land/<id>alá, lefuttatja a memóriabeli landolási csővezetéket, és automatikusan törli arefs/heads/base/<id>és arefs/heads/review/<id>hivatkozásokat. - Külső tükör-pull requestek A nyílt forráskódú közreműködőknek soha nem kell megtanulniuk a monorepó munkafolyamatát. A szokásos módon forkolnak, és pull requesteket nyitnak az önálló tükör-tárolókhoz.
O(delta) kétirányú szinkronizáció és külön éles ágak
Sok szervezet az egyes csomagokat önálló GitHub-tárolóként teszi közzé, hogy külső felhasználók a monorepó érintése nélkül go get-et, cargo install-t vagy egyetlen projekt forkolását végezhessék el. Néhány projekt, például statikus blogok vagy kiadás-kapuzott webalkalmazások, a main mellett külön production ágat is fenntart.
A Starlark szinkronizációs motorunk mind a kimenő exportokat, mind a bejövő közreműködéseket O(delta) időben kezeli, amely arányos a commitban módosított fájlok számával, nem pedig a monorepó méretével:
- Blobmentes index-beillesztés: Amikor egy tükör-tároló alfáját a monorepó gyökérfájába illeszti vagy onnan kiemeli, a motor beállítja a
GIT_NO_LAZY_FETCH=1változót, majd lefuttatja agit update-index --index-infoparancsot, utána pedig agit write-tree --missing-okparancsot. A módosítatlan alfákat és blobokat kizárólag SHA-1 hash-ükkel hivatkozzuk, anélkül hogy azokat a CI-futtatóra letöltenénk. - Csak a deltára korlátozott blob-előtöltés: Mielőtt egy exportált commitot egy önálló tükörre tolnánk, a szkript lefuttatja a
git diff-tree -r --diff-filter=AM "$ext_parent" "$export_tree"parancsot, és agit cat-file --batchsegítségével csak a módosított fájlok régi és új blobjait tölti elő. A Git ezután másodpercek alatt képes vékony deltacsomagot számítani és feltölteni a tükör-tárolóba. - Munkafolyamatok és kiadási ágak megőrzése: Kimenő export során a szinkronizáló megőrzi az egyes önálló tárolók saját
.github/workflows/könyvtárát, így a nyilvános CI-futtatók a tükrön futtatják a projekt teszteit. A Starlark konfigurációban minden munkafolyamat egy explicit ágat céloz meg, például amain-t, ami megőrzi a kiadási kapukat azoknál a tárolóknál, amelyek különproductionágról telepítenek. A monorepó-landolások a tükörmainágához szinkronizálódnak, amainágon lévő automatikus metaadat- vagy fordítási commitok pedig webhookon keresztül visszaszinkronizálódnak arefs/trunk/main-be. Amainproduction-né léptetése továbbra is explicit kiadási lépés marad.
Záró gondolatok
Nagy sebességű monorepó építéséhez nem kell elhagyni a szabványos Gitet, és nem kell összetett, egyedi infrastruktúra-szervereket üzemeltetni. Ez az architektúra a Git objektumtárát tartalomcímzett tárolóként kezeli, szétválasztja a refs/heads/main-t a refs/trunk/main-től és a refs/trunk/history-tól, a fákat pedig memóriában, a git merge-tree --write-tree paranccsal egyesíti. Ennek eredményeképpen mind az emberi mérnökök, mind az autonóm kódoló ügynökök egy másodpercen belül klónozhatnak munkaterületet, és tisztán skálázódhatnak több száz projekten keresztül.