GoSuda

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

By Lemon Mint views ...

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:

  1. blob: Egyetlen fájl nyers bájttartalma.
  2. tree: Könyvtárlista, amely fájlneveket és jogosultságokat képez le blob vagy gyermek tree hash-ekre.
  3. commit: Apró szöveges fejléc (jellemzően 200 és 300 bájt között), amely egyetlen gyökér tree hash-re, nulla vagy több szülő commit hash-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 minden git clone és git fetch sorá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:

  1. A git clone --filter=blob:none --sparse letölti a 20 KB-os vezérlési síkot a refs/heads/main ágról.
  2. A git trunk-init beállítja a +refs/trunk/*:refs/remotes/origin/trunk/* leképezést, és blobmentes letöltést futtat. A Git letölti a refs/trunk/main tree metaadatait (jellemzően néhány tucat kilobájt), és nulla fájl-blobot.
  3. A git sparse-checkout set net/relay ipc/codec utasí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.
  4. A git ws-new átvált egy új ágra, amelynek gyökere az origin/trunk/main. Ebben a pillanatban a Git igény szerint csak a net/relay és ipc/codec belsejében lévő blob objektumokat 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:

  1. Azonnali teljes fa csatolás: Egy könnyű helyi csatolódémon olvassa a refs/trunk/main gyökér tree objektumát, és milliszekundumok alatt levetíti a teljes monorepó könyvtárhierarchiát a munkakönyvtárba, így nincs szükség a git sparse-checkout set futtatására.
  2. 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) vagy read(2) rendszerhívást. A démon ekkor lekéri a megfelelő SHA-1 blob-ot a CAS-végpontról, és helyi NVMe tárolón gyorsítótárazza.
  3. Copy-on-Write átfedés a munkaterületekhez: A helyi szerkesztések egy könnyű felső átfedő rétegbe íródnak. A git ws-save futtatása csak az átfedésben szennyezett útvonalakat hash-eli, kiírja a frissített blob és tree objektumokat, és a keletkező commitot a refs/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/main a //web/blog alatt haladt előre, miközben Alice a //net/relay részt szerkesztette, a git merge-tree --write-tree milliszekundumok 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-lease opciós git push --atomic frissíti a refs/trunk/main-t, hozzáfűz egy lineáris commitot a refs/trunk/history-hoz, és egyetlen tranzakcióban törli a refs/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őtagSzintEngedélyezett importokTiltott importokOWNERS kapu
//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/*Nyilvános élesNyilvá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 domainKényszerített
//corp/*Belső magánNyilvá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_TREE között módosított minden útvonalra az ellenőrző a fájl könyvtárától felfelé halad a //OWNERS fájlig.
  • Bármely OWNERS fájl deklarálhatja a set noparent utasítást, amellyel blokkolja a szülőkönyvtárakból öröklődő jóváhagyásokat, vagy a per-file *.sql=@dba-team utasí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ő OWNERS fá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 az OWNERS fájlt.
  • Homokozó-mentesség: A //experimental/<actor>/* alatti minden útvonal megkerüli az OWNERS ellenőrzéseket, hogy a mérnökök és ügynökök szabadon prototipizálhassanak a git ws-exp <actor> <project> paranccsal, majd később a git ws-graduate paranccsal 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:

  1. Azonnali kétpontos munkaterület-diffek (git ws-save + élszintű diff-útválasztó) A git ws-save paranccsal feltöltött munkaterület a refs/ws/<actor>/<id> hivatkozást egy könnyű élszintű munkavégzőn keresztül teszi elérhetővé. Ez a munkavégző a refs/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.
  2. Átmeneti GitHub Pull Requestek (git ws-pr) A GitHubon soronkénti felülvizsgálathoz a git ws-pr feltölti a gyökér-pillanatkép commitot ($(git rev-list --max-parents=0 HEAD)) a refs/heads/base/<id> alá, a munkaterület csúcsát pedig a refs/heads/review/<id> alá. Ezután GitHub Pull Requestet nyit, amely a review/<id>-t hasonlítja össze a base/<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 a refs/heads/land/<id> alá, lefuttatja a memóriabeli landolási csővezetéket, és automatikusan törli a refs/heads/base/<id> és a refs/heads/review/<id> hivatkozásokat.
  3. 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=1 változót, majd lefuttatja a git update-index --index-info parancsot, utána pedig a git write-tree --missing-ok parancsot. 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 a git cat-file --batch segí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 a main-t, ami megőrzi a kiadási kapukat azoknál a tárolóknál, amelyek külön production ágról telepítenek. A monorepó-landolások a tükör main ágához szinkronizálódnak, a main ágon lévő automatikus metaadat- vagy fordítási commitok pedig webhookon keresztül visszaszinkronizálódnak a refs/trunk/main-be. A main production-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.