Git-monorepon uudelleenajattelu: CAS-Native-arkkitehtuuri ihmisille ja koodausagenteille

Jokainen kasvava ohjelmistotuotannon organisaatio päätyy lopulta samaan risteykseen. Kun aloitetaan kymmenillä pienillä repositorioilla, elämä tuntuu aluksi yksinkertaiselta. Jokaisella kirjastolla on oma ongelmanseurantansa, oma CI-putkensa ja git clone, joka valmistuu ennen kuin ehdit ottaa kulauksen kahvia. Sitten kustannus iskee. Muutat struct-rakennetta keskeisessä verkkopaketissa ja käytät loppupäivän avaamalla version päivityspull requesteja kuudessa jälkikäteisessä repositoriossa odottaen, että CI-jonot tyhjenevät topologisessa järjestyksessä.
Siirrä kaikki perinteiseen Git-monorepoon, ja vaihdat koordinointikivun fysiikkakipuun. Kun commit-historia syvenee ja binääriaineistot kertyvät vanhoihin revisioihin, git fetch hidastuu. Työpuut paisuvat gigatavuiksi. Pian joku ehdottaa mukautetun virtuaalisen tiedostojärjestelmän daemonin, erillisen koodikatselmointipalvelimen tai remote-buildaajien parven pystyttämistä vain siksi, että päivittäinen kehitystyö pysyisi siedettävänä.
Kun autonomiset koodausagentit liittyvät tiimiin, molemmat vikatilanteet pahenevat. Polyrepot sokaisevat agentin projektien välisille kutsujille ja pakottavat sen jongleeraamaan tunnuksia repositorioiden välillä. Perinteiset monorepot upottavat agentin konttilevyn ja verkkokaistan jokaisen tehtävän alustuksen yhteydessä, ja samaan haaraan samanaikaisesti kajoavat agentit aiheuttavat loputtomia rebase-kilpailutilanteita.
Halusimme kolmannen vaihtoehdon. Inspiroituneina google3-arkkitehtuurista esitimme yksinkertaisen kysymyksen: voimmeko saavuttaa yhteen trunkiin kohdistuvat atomiset muutokset, alle sekunnin kestävän sparse-käyttöönoton, hierarkkisen OWNERS-hallinnan ja itsenäiset avoimen lähdekoodin peilit käyttämällä pelkkiä vakiomuotoisia Git-perustoimintoja ja tavallista objektitallennustilaa?
Vastaus oli kyllä, eikä se vaatinut mukautettua tiedostojärjestelmädaemonia tai kolmannen osapuolen katselmointisovellusta. Se vaati vain sen, että Gitiä käytetään siihen, mitä se todellisuudessa on konepellin alla: Merkle-puuhun perustuva Content-Addressable Store (CAS).
Väärinkäsitys hitaiden Git-monorepojen ytimessä
Kun kehittäjät valittavat, ettei Git skaalaudu suuriin monorepoihin, he osuvat harvoin rajaan Gitin objektitietokannassa. Hakemistossa .git/objects Git tallentaa kolme muuttumatonta perusrakennetta, joita avaimena on niiden SHA-1- (tai SHA-256-) sisältötiiviste:
blob: yksittäisen tiedoston raakasisältö tavuina.tree: hakemistolistaus, joka yhdistää tiedostonimet ja oikeudetblob- tai alihakemisto-tree-tiivisteisiin.commit: pieni tekstiotsikko (tyypillisesti 200–300 tavua), joka osoittaa yhteen juuri-tree-tiivisteeseen, nollaan tai useampaan vanhempi-commit-tiivisteeseen sekä tekijän metatietoihin.
Sisältöosoitteisena tallennusjärjestelmänä tämä rakenne on poikkeuksellisen tehokas. Jos monorepo sisältää 50 000 tiedostoa ja muokkaat yhtä riviä yhdessä tiedostossa, uusi commit käyttää uudelleen muiden 49 999 tiedoston täsmälleen samoja blob- ja tree-tiivisteitä.
Miksi perinteinen Git-monorepo sitten tuntuu raskaalta? Syynä on se, että konventio kuormittaa yhtä viitettä, refs/heads/main, kolmella keskenään ristiriitaisella tehtävällä:
- Se pitää hallussaan kaikkien projektien koko yhtenäistä hakemistopuuta.
- Se vetää perässään koko vanhempiin linkittyvän historiallisen commit-ketjun ensimmäisestä päivästä asti.
- Se sijaitsee nimiavaruudessa
refs/heads/*, jonka Git-palvelimet mainostavat ja jonka kanssa ne neuvottelevat oletuksena jokaisellagit clone- jagit fetch-kerralla.
Aina kun kehittäjä tai CI-ajuri hakee viitteen refs/heads/main, asiakas ja palvelin neuvottelevat commit-graafin tavoitettavuudesta tuhansien historiallisten revisioiden yli. Jos joku kommitoi vahingossa 100 MB:n arkiston kolme vuotta sitten ja poisti sen seuraavana päivänä, sen blob roikkuu yhä historiallisen tree-puun varassa, joka on tavoitettavissa viitteestä refs/heads/main.
Viitenimiavaruuden jakaminen neljään kerrokseen
Gitin wire-protokolla mainostaa tavallisen kloonauksen aikana vain viitteet refs/heads/* ja refs/tags/*. Mukautettujen nimiavaruuksien, kuten refs/trunk/* tai refs/ws/*, viitteet elävät palvelimella täsmälleen samassa objektitallennuksessa, hyötyvät samasta packfile-deltapakkauksesta ja voidaan hakea tai työntää eksplisiittisesti. Ne eivät kuitenkaan aiheuta lisäkuormaa oletuskloonauksissa.
Jakamalla vastuut neljän viitenimiavaruuden kesken yhden repositorion sisällä pullonkaula katoaa:
1refs/heads/main Vain hallintataso (~20 KB: säännöt, .gitconfig, synkronointiasetukset, skriptit)
2refs/trunk/main Juuriton snapshot-commit, joka osoittaa uusimpaan yhtenäiseen juuri-treeen
3refs/trunk/history Lineaarinen vanhempiin linkittyvä commit-ketju, joka jakaa täsmälleen saman juuri-treen SHA-1-tiivisteen
4refs/ws/<actor>/<id> Eristetyt työtilan snapshotit (muutoslistat) kehittäjää tai agenttia kohden
5refs/heads/land/<id> Väliaikainen yhdistämisjonon laukaisin, joka poistetaan heti hyväksymisen jälkeen
1. Hallintataso (refs/heads/main)
Oletushaara ei sisällä sovelluksen lähdekoodia. Se seuraa vain repositorion toimintasääntöjä (AGENTS.md, README.md), jaettua .gitconfig-tiedostoa työnkulkualiaksineen, Starlark-synkronointiasetuksia (sync.star) ja palvelinpuolen yhdistämisskriptejä. Oletushaaran kloonaus siirtää noin 20 KB ja valmistuu millisekunneissa.
2. Juuriton trunk-snapshot (refs/trunk/main)
refs/trunk/main osoittaa koko organisaation yhtenäiseen lähdepuuhun (//web/*, //net/*, //ipc/*, //devtools/*, //corp/*, //third_party/*, //experimental/*), mutta sen commit-objekti luodaan ilman lippua -p, eli ilman vanhempaa.
Koska viitteellä refs/trunk/main ei ole vanhempia (parents = []), sen hakeminen ei vaadi lainkaan commit-graafin neuvottelua. Git-palvelin ei kulje historiaa läpi, vaan lähettää yhden huippu-commitin ja sen nykyiset hakemisto-tree-objektit. Viime kuun historialliset binääritiedostot tai poistetut tiedostot eivät ole tavoitettavissa viitteestä refs/trunk/main, eikä niitä siirretä koskaan.
3. Lineaarinen tarkastushistoria (refs/trunk/history)
Kehittäjät tarvitsevat yhä git blame- ja git log-komentoja regressioiden selvittämiseen. Joka kerta kun muutos yhdistetään, yhdistämisskripti luo kaksi commit-objektia, jotka osoittavat täsmälleen samaan juuri-treen $NEW_TREE-SHA-1-tiivisteeseen:
1# Juuriton snapshot-commit välittömiä depth-1-työtilan uloskirjauksia varten
2SNAP=$(git commit-tree "$NEW_TREE" -m "$RAW_MSG")
3
4# Vanhempiin linkittyvä commit lineaarista git logia ja git blamea varten
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"
Kaksinkertaistaako refs/trunk/main- ja refs/trunk/history-viitteiden ylläpito repositorion tallennustilan tarpeen? Ei lainkaan. Koska molemmat commitit viittaavat samaan $NEW_TREE-tiivisteeseen, Gitin sisältöosoitteinen tallennus poistaa päällekkäisyydet 100-prosenttisesti tree- ja blob-objekteista. Juurettoman snapshotin lisäkustannus on vain yhden 250 tavun commit-otsikon verran.
4. Eristetyt työtilat (refs/ws/<actor>/<id>)
Kesken olevat työhaarat työnnetään nimiavaruuteen refs/ws/<actor>/<id> eikä nimiavaruuteen refs/heads/*. Sata koodausagenttia voi tallentaa tarkistuspisteitä joka minuutti ilman, että haaravalikot täyttyvät tai ihmisinsinöörien git fetch hidastuu.
Käyttöönotto viidellä komennolla: miten sparse- ja blobless-uloskirjaus toimii
Tällä viiterakenteella uusi kehittäjä tai väliaikainen koodausagenttikontti ei asenna mukautettua CLI-työkalua tai FUSE-ajuria. Käyttöönotto vaatii viisi tavallista Git-komentoa:
1# 1. Kloonaa 20 KB:n hallintataso blob-suodatuksella ja sparse-tila käytössä
2git clone --filter=blob:none --sparse https://github.com/example-org/monorepo.git
3cd monorepo
4
5# 2. Lataa repositorion aliakset ja liitä refs/trunk/*
6git config include.path ../.gitconfig
7git trunk-init
8
9# 3. Määritä tehtävän vaatimat aluehakemistot
10git sparse-checkout set net/relay ipc/codec
11
12# 4. Luo eristetty työtila uusimman trunk-snapshotin päälle
13git ws-new alice/optimize-framing
Konepellin alla jokainen vaihe tekee vain tarpeellisen vähimmäistyön:
git clone --filter=blob:none --sparselataa 20 KB:n hallintatason viitteestärefs/heads/main.git trunk-initmäärittää asetuksen+refs/trunk/*:refs/remotes/origin/trunk/*ja suorittaa blobless-haun. Git lataa viitteenrefs/trunk/maintree-metatiedot (tyypillisesti muutamia kymmeniä kilotavuja) ja nolla tiedostojen blob-objektia.git sparse-checkout set net/relay ipc/codecohjaa Gitin cone-tilan indeksisuodatinta toteuttamaan vain juurihakemiston ja nuo kaksi alipuuta.git ws-newsiirtyy uuteen haaraan, jonka juuri onorigin/trunk/main. Juuri silloin Git pyytää tarpeen mukaan vain kohteidennet/relayjaipc/codecsisällä olevatblob-objektit. Loput 95 prosenttia monorepostä ei koskaan lataudu levylle.
Tuleva näkymä: viitteen refs/trunk/main liittäminen virtuaaliseksi tiedostojärjestelmäksi (VFS)
Sparse-uloskirjaus blobless-kloonauksilla kattaa päivittäiset työnkulut ilman ylimääräisiä asiakastyökaluja. Koska refs/trunk/main on jo puhdas Merkle-puu sisältöosoitteisessa tallennuksessa, arkkitehtuuri tarjoaa myös suoran perustan valinnaiselle käyttäjätilan virtuaaliselle tiedostojärjestelmälle (VFS): FUSE Linuxilla, FileProvider macOS:llä tai ProjFS Windowsilla.
- Välitön koko puun liittäminen: kevyt paikallinen liitäntädaemon lukee viitteen
refs/trunk/mainjuuri-tree-objektin ja projisoi koko monorepon hakemistorakenteen työhakemistoon millisekunneissa. Komentoagit sparse-checkout setei tarvita. - Tarpeen mukainen blob-haku: tiedostojen sisältö pysyy virtuaalisena, kunnes kääntäjä, IDE tai agentti kutsuu järjestelmäkutsua
open(2)tairead(2). Daemon hakee tällöin vastaavan SHA-1-blob-objektin CAS-päätepisteestä ja tallentaa sen paikalliselle NVMe-tallennustilalle. - Copy-on-write-peite työtiloille: paikalliset muokkaukset kirjoittavat kevyeen ylimpään peitekerrokseen. Komento
git ws-savetiivistää vain peitteen likaiset polut, kirjoittaa päivittyneetblob- jatree-objektit ja työntää tuloksena syntyneen commitin viitteeseenrefs/ws/<actor>/<id>skannaamatta muuttumattomia hakemistoja.
Muutoslistamalli puhtaalla Gitillä: muistissa tapahtuva palvelinpuolen hyväksyntä
Perinteisessä Git-työnkulussa ominaisuushaaran yhdistäminen joko luo kaksivanhemmaisen yhdistämis-commitin, joka muuttaa historian sekaantuneeksi raidesäiliöksi, tai vaatii kehittäjien käsin tekemää rebasea ja kilpajuoksua muiden kehittäjien työntöjen kanssa haaraan main.
Google3-arkkitehtuurin Changelist (CL) -mallin innoittamana jokainen työtila tässä arkkitehtuurissa edustaa yhtä atomista muutosta suhteessa trunk-snapshotiin. Kun kehittäjä tai agentti työskentelee paikallisesti hakemistossa ws/alice/optimize-framing, se voi tehdä kymmenen askelittaista commitia (wip, fix tests, lint). Kun muutos on valmis hyväksyttäväksi, palvelin tiivistää työtilan muutokset yhdeksi lineaariseksi trunk-revisioksi ilman, että CI-ajuri uloskirjoittaa koskaan työhakemistoa.
Näin hyväksyntäskripti (land.sh) suoritetaan muistissa Git 2.40+ -perustoiminnoilla:
1# 1. Tunnista juuri-snapshot-commit, josta työtila haarautui
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. Laske yhdistetty juuri-tree muistissa ilman työpuun uloskirjausta
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
Tarkastele vaihetta 2 tarkasti:
- Riippumatta siitä, kuinka monta paikallista commitia työtilan haaraan on tehty, skripti vertaa vain kolmea tree-tiivistettä: alkuperäistä base-snapshotia (
$BASE_TREE), nykyistä trunk-snapshotia ($MAIN_TREE) ja lopullista työtilan puuta ($WORK_TREE). - Jos
refs/trunk/maineteni kohdassa//web/blog, kun Alice muokkasi kohdetta//net/relay,git merge-tree --write-treelaskee yhdistetyn juuri-treen$NEW_TREEsuoraan Gitin objektitallennuksessa millisekunneissa. Ajurin tiedostojärjestelmään ei kirjoiteta yhtään tiedostoa. - Lopuksi
git push --atomicyhdessä--force-with-lease-asetuksen kanssa päivittää viitteenrefs/trunk/main, lisää yhden lineaarisen commitin viitteeseenrefs/trunk/historyja poistaa laukaisimenrefs/heads/land/<id>yhdessä transaktiossa.
Arkkitehtuurirajojen valvonta: nelitasoinen näkyvyys ja hierarkkinen OWNERS
Koodin yhdistäminen yhteen repositorioon toimii vain, jos domainien väliset rajat valvotaan mekaanisesti ennen kuin commit voidaan hyväksyä. Heti kun git merge-tree --write-tree on tuottanut $NEW_TREE-puun (ja ennen kuin git push --atomic päivittää trunkin), käytännön moottori suorittaa kaksi O(delta)-tarkistusta komennon git diff-tree "$MAIN_TREE" "$NEW_TREE" tuloksille.
1. Nelitasoinen domainien näkyvyys
Juuri-puu on jaettu neljään näkyvyystasoon:
| Hakemistoprefiksi | Taso | Sallitut tuonnit | Kielletyt tuonnit | OWNERS-portti |
|---|---|---|---|---|
//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/* | Julkinen tuotanto | Julkinen tuotanto, //third_party/* | //corp/*, //experimental/* | Pakotettu |
//third_party/* | Ulkoinen toimittaja | Itsenäinen tai //third_party/* | Kaikki muut kuin third_party | Pakotettu |
//corp/* | Sisäinen yksityinen | Julkinen tuotanto, //corp/*, //third_party/* | //experimental/* | Pakotettu |
//experimental/<actor>/* | Hiekkalaatikko | Mikä tahansa muu kuin corp-paketti | //corp/* | Vapautettu (automaattinen hyväksyntä) |
Jos julkinen paketti kohteessa //web/* yrittää tuoda sisäisen paketin kohteesta //corp/* tai keskeneräisen prototyypin kohteesta //experimental/*, hyväksyntätyö keskeytyy ennen kuin se koskee viitettä refs/trunk/main.
2. Hierarkkinen OWNERS-tarkistus ilman Gerrit-palvelinta
Hakemistotason OWNERS-tiedostojen periytymissääntöjen valvontaan ei tarvita omistettua Gerrit-instanssia:
- Jokaiselle polulle, joka on muuttunut
$MAIN_TREE- ja$NEW_TREE-puiden välillä, tarkistaja kulkee tiedoston hakemistosta ylöspäin kohti tiedostoa//OWNERS. - Mikä tahansa
OWNERS-tiedosto voi määrittääset noparent, jolla estetään vanhemmista hakemistoista periytyvät hyväksynnät, taiper-file *.sql=@dba-team, jolla hyväksynnät rajataan tiedostonimien glob-mallien mukaan. - Manipulointisuoja: Olemassa olevat
OWNERS-tiedostot luetaan aina puusta$MAIN_TREE(nykyinen trunk-puu), ei koskaan puusta$NEW_TREE. Hyväksymätön kirjoittaja ei voi myöntää itselleen oikeuksia muokkaamalla tiedostoaOWNERSsamassa työtilan commitissa. - Hiekkalaatikkovapautus: Kaikki polut kohdassa
//experimental/<actor>/*ohittavatOWNERS-tarkistukset, jotta insinöörit ja agentit voivat prototyypittää vapaasti komennollagit ws-exp <actor> <project>ja myöhemmin siirtää paketin tuotantodomainiin komennollagit ws-graduate.
Vaivaton koodikatselmointi: ephemeral-PR:t ja snapshot-diffit
Koska refs/heads/main sisältää vain hallintatason ja refs/trunk/main on juurettomana commitina, tavallisen GitHub-pull requestin avaaminen suoraan työtilan haarasta main-haaraan vertaisi toisiinsa erillisiä historioita. Ratkaisimme tämän kolmella katselmointityönkululla, jotka on räätälöity eri muutostyypeille:
- Välittömät kaksipisteiset työtilan diffit (
git ws-save+ reunadiff-reititin) Kun työtila työnnetään komennollagit ws-save, viiterefs/ws/<actor>/<id>tulee näkyviin kevyen reunaworkerin kautta. Worker selvittää viitteidenrefs/trunk/mainja työtilaviitteen commit-tiivisteet ja ohjaa katselijan GitHubin kaksipisteiseen vertailunäkymään (/compare/<trunk-sha>..<ws-sha>). Pienissä muutoksissa tai agenttien tuottamissa muutoksissa katselmoijat voivat tarkastella diffiä ja napsauttaa suoraan web-käyttöliittymän painiketta Land. - Ephemeral GitHub Pull Requestit (
git ws-pr) Rivikohtaista katselmointia varten GitHubissa komentogit ws-prtyöntää juuri-snapshot-commitin ($(git rev-list --max-parents=0 HEAD)) viitteeseenrefs/heads/base/<id>ja työtilan kärjen viitteeseenrefs/heads/review/<id>. Sen jälkeen se avaa GitHub-pull requestin, joka vertaa haaraareview/<id>haaraanbase/<id>. Koska perushaara on täsmälleen se snapshot, josta työtila haarautui, GitHubin välilehti Files changed näyttää vain ne rivit, jotka työtilassa on todella muokattu. Kun katselmoija napsauttaa painiketta Merge pull request, webhook siirtää huippu-commitin viitteeseenrefs/heads/land/<id>, suorittaa muistissa tapahtuvan hyväksyntäputken ja poistaa automaattisesti sekä viitteenrefs/heads/base/<id>että viitteenrefs/heads/review/<id>. - Ulkoiset peilin pull requestit Avoimen lähdekoodin avustajien ei tarvitse oppia monorepo-työnkulkua. He tekevät forkin ja avaavat pull requestit itsenäisiin peilirepositorioihin tavanomaisesti.
O(delta)-kaksisuuntainen synkronointi ja erilliset tuotantohaarat
Monet organisaatiot julkaisevat yksittäiset paketit erillisinä GitHub-repositorioina, jotta ulkopuoliset käyttäjät voivat suorittaa go get- tai cargo install-komennon tai forkata yksittäisen projektin koskematta monorepoon. Jotkin projektit, kuten staattiset blogit tai julkaisuportitetut web-sovellukset, ylläpitävät myös erillistä haaraa production haaran main rinnalla.
Starlark-synkronointimoottorimme käsittelee sekä lähteviä vientejä että saapuvia avustuksia ajassa O(delta), joka on verrannollinen commitissa muuttuneiden tiedostojen määrään eikä monorepon kokoon:
- Blobless-indeksin liittäminen: Kun peilirepositorion alipuu liitetään monorepon juuri-puuhun tai poistetaan siitä, moottori asettaa muuttujan
GIT_NO_LAZY_FETCH=1ja suorittaa komennongit update-index --index-infosekä sen jälkeen komennongit write-tree --missing-ok. Muuttumattomat alipuut ja blobit viitataan puhtaasti niiden SHA-1-tiivisteillä, eikä niitä koskaan ladata CI-ajurille. - Vain muutosten blob-ennakkohaku: Ennen viedyn commitin työntämistä itsenäiseen peiliin skripti suorittaa komennon
git diff-tree -r --diff-filter=AM "$ext_parent" "$export_tree"ja hakee komennollagit cat-file --batchvain muokattujen tiedostojen vanhat ja uudet blobit. Git voi sen jälkeen laskea kevyen delta-paketin ja työntää sen peilirepositorioon sekunneissa. - Työnkulkujen ja julkaisuhaarojen säilyttäminen: Ulospäin suuntautuvan viennin aikana synkronoija säilyttää kunkin itsenäisen repositorion oman hakemiston
.github/workflows/, jotta julkiset CI-ajurit suorittavat projektin testit peilillä. Jokainen Starlark-asetuksen työnkulku kohdistuu eksplisiittisesti johonkin haaraan, kutenmain, mikä säilyttää julkaisuportit repositorioille, jotka julkaisevat erillisestä haarastaproduction. Monorepon hyväksynnät synkronoidaan peilin haaraanmain, ja haaraanmaintehdyt automaattiset metatieto- tai käännöscommitit synkronoituvat takaisin viitteeseenrefs/trunk/mainwebhookin kautta. Haaranmainsiirtäminen haaraanproductionon edelleen eksplisiittinen julkaisuvaihe.
Loppupohdinta
Nopeatempoisen monorepon rakentaminen ei vaadi tavallisesta Gitistä luopumista eikä monimutkaisen räätälöityjen infrastruktuuripalvelinten parven ylläpitoa. Tämä arkkitehtuuri käsittelee Gitin objektitallennusta sisältöosoitteisena tallennuksena, erottaa viitteet refs/heads/main ja refs/trunk/main sekä refs/trunk/history toisistaan ja yhdistää puut muistissa komennolla git merge-tree --write-tree. Tämän ansiosta sekä ihmisinsinöörit että autonomiset koodausagentit voivat kloonata työtilan alle sekunnissa ja skaalautua siististi satojen projektien yli.