Regândirea monorepo-ului Git: o arhitectură CAS-Native pentru oameni și Coding Agents

Fiecare organizație de inginerie în creștere alege, în cele din urmă, între multe depozite și unul singur. Începi cu zeci de depozite mici, iar viața pare simplă la început. Fiecare bibliotecă are propriul issue tracker, propriul pipeline CI și un git clone care se termină în câteva secunde. Apoi modifici o structură dintr-un pachet de rețea de bază și îți petreci restul după-amiezii deschizând pull request-uri de actualizare a versiunii în șase depozite dependente, așteptând ca cozile CI să se golească în ordine topologică.
Muți totul într-un monorepo Git tradițional și schimbi coordonarea dintre depozite pe dimensiunea depozitului. Pe măsură ce istoricul commit-urilor se adâncește și activele binare se acumulează în reviziile anterioare, git fetch încetinește. Directoarele de lucru ajung la gigabytes. Curând, cineva propune instalarea unui daemon personalizat de sistem de fișiere virtual, a unui server dedicat de code review sau a unei flote de workeri de build la distanță, doar pentru a face dezvoltarea zilnică suportabilă.
Când agenți de programare autonomi se alătură echipei, ambele moduri de defectare se agravează. Polirepo-urile orbesc agentul față de apelanții din alte proiecte și îl obligă să jongleze cu credențialele între depozite. Monorepo-urile tradiționale epuizează discul containerului agentului și lățimea de bandă a rețelei la fiecare inițializare a unei sarcini, iar agenții concurenți care calcă pe aceeași ramură creează curse nesfârșite de rebase.
Inspirați de arhitectura google3, am construit modificări atomice pe un singur trunk, onboarding sparse în sub o secundă, guvernanță ierarhică prin OWNERS și oglinzi open-source independente, folosind plumbing Git standard și stocare de obiecte de larg consum.
Designul nu necesită nici un daemon personalizat de sistem de fișiere, nici un aparat terț de review, deoarece tratează Git ca pe un Content-Addressable Store (CAS) bazat pe arbore Merkle.
Concepția greșită din centrul monorepo-urilor Git lente
Când dezvoltatorii se plâng că Git nu scalează la monorepo-uri mari, rareori ating o limită a bazei de date de obiecte a Git. În .git/objects, Git stochează trei primitive imuabile, indexate după hash-ul de conținut SHA-1 (sau SHA-256):
blob: Conținutul brut în octeți al unui singur fișier.tree: O listare de director care asociază nume de fișiere și permisiuni cu hash-uri deblobsau detreecopil.commit: Un antet text mic (de obicei între 200 și 300 de octeți) care indică un singur hash detreerădăcină, zero sau mai multe hash-uri decommitpărinte și metadate despre autor.
Dacă un monorepo conține 50.000 de fișiere și editezi o singură linie dintr-un fișier, noul commit reutilizează hash-urile exacte ale blob-urilor și tree-urilor pentru celelalte 49.999 de fișiere.
Un monorepo Git tradițional pare greoi deoarece convenția încarcă o singură referință, refs/heads/main, cu trei sarcini conflictuale:
- Deține întregul arbore de directoare unificat al fiecărui proiect.
- Târăște după sine întregul lanț de commit-uri istorice legate de părinți până în prima zi.
- Se află în
refs/heads/*, spațiul de nume pe care serverele Git îl anunță și îl negociază implicit la fiecaregit cloneșigit fetch.
De fiecare dată când un dezvoltator sau un runner CI face fetch la refs/heads/main, clientul și serverul negociază accesibilitatea în graful de commit-uri peste mii de revizii istorice. Dacă cineva a comis accidental o arhivă de 100 MB acum trei ani și a șters-o a doua zi, blob-ul ei încă atârnă de un tree istoric accesibil din refs/heads/main.
Separarea spațiului de nume al referințelor
Protocolul de rețea al Git anunță doar refs/heads/* și refs/tags/* în timpul unui clone standard. Referințele din spații de nume personalizate, precum refs/trunk/* sau refs/ws/*, trăiesc în exact același depozit de obiecte de pe server, beneficiază de aceeași compresie delta din packfile și pot fi descărcate sau trimise explicit, dar nu impun nicio suprapunere asupra clonărilor implicite.
Separarea acestor sarcini între referințe distincte dintr-un singur depozit elimină blocajul:
1refs/heads/main Doar plan de control (~20 KB: reguli, .gitconfig, configurație de sincronizare, scripturi)
2refs/trunk/main Snapshot de commit fără părinte, care indică arborele rădăcină unificat cel mai recent
3refs/trunk/history Lanț liniar de commit-uri legate de părinți, care partajează exact SHA-1-ul arborelui rădăcină
4refs/ws/<actor>/<id> Snapshot-uri izolate de spațiu de lucru (changelist-uri) pentru fiecare dezvoltator sau agent
5refs/ws/cl/<change-id>/<tip> Patchset-uri imuabile: fiecare vârf al unei modificări trimise la review
6refs/heads/land/<id> Declanșator efemer al cozii de merge, șters imediat după integrare
1. Planul de control (refs/heads/main)
Ramura implicită nu conține cod sursă al aplicației. Ea urmărește doar regulile de funcționare ale depozitului (AGENTS.md, README.md), un .gitconfig partajat cu alias-uri de workflow, configurația Starlark de sincronizare (sync.star) și scripturile de merge de pe server. Clonarea ramurii implicite transferă aproximativ 20 KB și se termină în milisecunde.
2. Snapshot-ul trunk fără părinte (refs/trunk/main)
refs/trunk/main indică spre arborele sursă complet și unificat al întregii organizații (//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/*, //corp/*, //third_party/*, //experimental/*), dar obiectul său de commit este creat fără flag-ul de părinte -p.
Deoarece refs/trunk/main are zero părinți (parents = []), descărcarea sa nu necesită nicio negociere a grafului de commit-uri. Serverul Git nu parcurge istoricul; trimite singurul commit de vârf și obiectele tree ale directoarelor sale curente. Binarele istorice sau fișierele șterse în luna precedentă nu sunt accesibile din refs/trunk/main și nu sunt niciodată transferate.
3. Istoricul liniar de audit (refs/trunk/history)
Dezvoltatorii au totuși nevoie de git blame și git log atunci când investighează regresii. De fiecare dată când o modificare este integrată, scriptul de merge creează două obiecte commit care indică exact același hash SHA-1 $NEW_TREE al arborelui rădăcină:
1# Commit snapshot fără părinte, pentru checkout-uri instantanee de workspace la adâncimea 1
2SNAP=$(git commit-tree "$NEW_TREE" -m "$RAW_MSG")
3
4# Commit legat de părinte, pentru git log liniar și 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"
Păstrarea atât a lui refs/trunk/main, cât și a lui refs/trunk/history nu dublează amprenta de stocare. Deoarece ambele commit-uri referă același hash $NEW_TREE, Content-Addressable Store al Git deduplică 100% din obiectele tree și blob. Costul suplimentar de stocare al snapshot-ului fără părinte este un singur antet de commit de 250 de octeți.
4. Spații de lucru izolate (refs/ws/<actor>/<id>)
Ramurile de lucru în curs sunt trimise către refs/ws/<actor>/<id>, nu către refs/heads/*. Sute de agenți de programare pot salva checkpoint-uri la fiecare minut fără a polua meniurile de ramuri sau a încetini git fetch pentru inginerii umani.
Onboarding în patru pași: cum funcționează checkout-ul sparse și blobless
Cu această structură de referințe, un dezvoltator nou sau un container efemer al unui agent de programare nu instalează un CLI personalizat sau un driver FUSE. Onboarding-ul durează patru pași, cu Git standard și alias-urile depozitului:
1# 1. Clonează planul de control de 20 KB, cu filtrare de blob-uri și modul sparse activat
2git clone --filter=blob:none --sparse https://github.com/example-org/monorepo.git
3cd monorepo
4
5# 2. Încarcă alias-urile depozitului și atașează refs/trunk/*
6git config include.path ../.gitconfig
7git trunk-init
8
9# 3. Declară directoarele de domeniu necesare pentru această sarcină
10git sparse-checkout set net/relay ipc/codec
11
12# 4. Creează un spațiu de lucru izolat, ancorat la cel mai recent snapshot trunk
13git ws-new alice/optimize-framing
Fiecare pas descarcă doar ceea ce are nevoie:
git clone --filter=blob:none --sparsedescarcă planul de control de 20 KB de perefs/heads/main.git trunk-initconfigurează+refs/trunk/*:refs/remotes/origin/trunk/*și rulează un fetch fără blob-uri. Git descarcă metadateletreepentrurefs/trunk/main(de obicei câteva zeci de kilobytes) și zero blob-uri de fișiere.git sparse-checkout set net/relay ipc/codecîi indică filtrului de index în mod cone al Git să materializeze doar directorul rădăcină și cei doi subarbori.git ws-newcomută la o ramură nouă ancorată laorigin/trunk/main. Apoi Git cere la cerere doar obiecteleblobdinnet/relayșiipc/codec. Restul de 95% din monorepo nu este niciodată descărcat pe disc.
Perspectiva viitoare: montarea refs/trunk/main ca sistem de fișiere virtual (VFS)
Checkout-ul sparse cu clonări fără blob-uri acoperă fluxurile zilnice fără unelte suplimentare pe client. Deoarece refs/trunk/main este deja un arbore Merkle pur într-un Content-Addressable Store, arhitectura oferă și o bază directă pentru un sistem de fișiere virtual (VFS) opțional, în spațiul utilizatorului, prin FUSE pe Linux, FileProvider pe macOS sau ProjFS pe Windows:
- Montare instantanee a arborelui complet: Un daemon local ușor de montare citește obiectul
treerădăcină al luirefs/trunk/mainși proiectează întreaga ierarhie de directoare a monorepo-ului în directorul de lucru în milisecunde, eliminând necesitatea rulăriigit sparse-checkout set. - Încărcarea blob-urilor la cerere: Conținutul fișierelor rămâne virtual până când un compilator, un IDE sau un agent emite un apel de sistem
open(2)sauread(2). Daemonul preia atunciblob-ul SHA-1 corespunzător de la endpoint-ul CAS și îl memorează în cache pe stocarea NVMe locală. - Overlay copy-on-write pentru spații de lucru: Modificările locale se scriu într-un strat overlay superior ușor. Rularea
git ws-savecalculează hash-ul doar pentru căile modificate din acel overlay, scrie obiecteleblobșitreeactualizate și trimite commit-ul rezultat cătrerefs/ws/<actor>/<id>, fără a scana directoarele nemodificate.
Modelul changelist pe Git pur: integrare pe server, în memorie
Într-un flux Git tradițional, integrarea unei ramuri de funcționalitate fie creează un commit de merge cu doi părinți, care face istoricul nelinear, fie obligă dezvoltatorii să facă rebase manual și să concureze cu push-uri concurente către main.
Inspirat de modelul Changelist (CL) al google3, fiecare spațiu de lucru din arhitectura noastră reprezintă o modificare atomică unică față de snapshot-ul trunk. În timp ce lucrează local în ws/alice/optimize-framing, un dezvoltator sau un agent poate face zece commit-uri incrementale (wip, fix tests, lint). Când modificarea este gata de integrare, serverul comprimă delta spațiului de lucru într-o singură revizie liniară de trunk, fără a face niciodată checkout unui director de lucru pe runner-ul CI.
Iată cum se execută în memorie scriptul de integrare (land.sh), folosind plumbing-ul Git 2.40+:
1# 1. Identifică commit-ul snapshot rădăcină din care a fost ramificat spațiul de lucru
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. Calculează arborele rădăcină combinat în memorie, fără checkout al unui director de lucru
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
În pasul 2:
- Indiferent câte commit-uri locale au fost făcute pe ramura spațiului de lucru, scriptul compară doar trei hash-uri de arbore: snapshot-ul de bază original (
$BASE_TREE), snapshot-ul trunk curent ($MAIN_TREE) și arborele final al spațiului de lucru ($WORK_TREE). - Dacă
refs/trunk/maina avansat în//web/blogîn timp ce Alice edita//net/relay,git merge-tree --write-treecalculează direct în interiorul depozitului de obiecte Git arborele rădăcină combinat$NEW_TREE, în milisecunde, fără a scrie fișiere pe sistemul de fișiere al runner-ului. - După ce verificările de politică de mai jos trec, un singur
git push --atomiccu--force-with-leaseactualizeazărefs/trunk/mainși adaugă un singur commit liniar larefs/trunk/history, iar declanșatorul de integrarerefs/heads/land/<id>este șters.
Aplicarea granițelor arhitecturale: vizibilitate pe patru niveluri și OWNERS ierarhic
Consolidarea codului într-un singur depozit funcționează doar dacă granițele dintre domenii sunt aplicate mecanic înainte ca un commit să poată fi integrat. Imediat după ce git merge-tree --write-tree produce $NEW_TREE (și înainte ca git push --atomic să actualizeze trunk-ul), motorul nostru de politici rulează două verificări O(delta) asupra git diff-tree "$MAIN_TREE" "$NEW_TREE".
1. Vizibilitatea domeniilor pe patru niveluri
Arborele rădăcină este partiționat în patru niveluri de vizibilitate:
| Prefix de director | Nivel | Importuri permise | Importuri interzise | Poartă OWNERS |
|---|---|---|---|---|
//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/* | Producție publică | Producție publică, //third_party/* | //corp/*, //experimental/* | Aplicată |
//third_party/* | Furnizor extern | Autonome sau //third_party/* | Toate domeniile non-third_party | Aplicată |
//corp/* | Privat intern | Producție publică, //corp/*, //third_party/* | //experimental/* | Aplicată |
//experimental/<actor>/* | Sandbox | Orice pachet non-corp | //corp/* | Exceptată (aprobată automat) |
Dacă un pachet public din //web/* încearcă să importe un pachet intern din //corp/* sau un prototip neabsolvit din //experimental/*, job-ul de integrare se oprește înainte de a atinge refs/trunk/main.
2. Verificarea ierarhică a OWNERS fără un server Gerrit
Verificatorul aplică fișiere OWNERS la nivel de director, cu reguli de moștenire:
- Pentru fiecare cale modificată între
$MAIN_TREEși$NEW_TREE, verificatorul urcă din directorul fișierului până la//OWNERS. - Orice fișier
OWNERSpoate declaraset noparent, pentru a bloca aprobările moștenite din directoarele părinte, sauper-file *.sql=@dba-team, pentru a limita aprobările după un glob de nume de fișier. - Invariantul anti-manipulare: Fișierele
OWNERSexistente sunt întotdeauna citite din$MAIN_TREE(arborele trunk curent), niciodată din$NEW_TREE. Un autor neaprobat nu își poate acorda singur permisiunea editândOWNERSîn același commit de spațiu de lucru. - Exceptarea sandbox: Orice cale sub
//experimental/<actor>/*ocolește verificărileOWNERS, astfel încât inginerii și agenții să poată prototipa liber cugit ws-exp <actor> <project>și să promoveze ulterior pachetul într-un domeniu de producție cugit ws-graduate.
Review fără fricțiune: PR-uri efemere și diff-uri de snapshot
Deoarece refs/heads/main deține doar planul de control, iar refs/trunk/main este un commit fără părinte, deschiderea directă a unui Pull Request standard GitHub dintr-o ramură de spațiu de lucru către main ar încerca să compare istorii fără legătură. Am rezolvat această problemă prin trei fluxuri de review adaptate diferitelor tipuri de modificări:
- Diff-uri instantanee two-dot pentru spațiul de lucru (
git ws-save+ Edge Diff Router) Trimiterea unui spațiu de lucru cugit ws-saveexpunerefs/ws/<actor>/<id>printr-un worker edge ușor. Acest worker rezolvărefs/trunk/mainși referința spațiului de lucru la SHA-urile lor de commit și redirecționează către vizualizarea de comparare two-dot a GitHub (/compare/<trunk-sha>..<ws-sha>). Pentru patch-uri mici sau modificări generate de agenți, reviewerii pot inspecta diff-ul și pot da click pe Land direct în interfața web. - Pull Request-uri GitHub efemere (
git ws-pr) Pentru review linie cu linie pe GitHub,git ws-prtrimite commit-ul snapshot rădăcină ($(git rev-list --max-parents=0 HEAD)) cătrerefs/heads/base/<id>și vârful spațiului de lucru cătrerefs/heads/review/<id>. Apoi deschide un Pull Request GitHub care comparăreview/<id>cubase/<id>. Deoarece ramura de bază este snapshot-ul din care a fost ramificat spațiul de lucru, tab-ul Files changed al GitHub arată doar delta spațiului de lucru. Când reviewerul dă click pe Merge pull request, un webhook promovează commit-ul head larefs/heads/land/<id>, rulează pipeline-ul de integrare în memorie și șterge automat atâtrefs/heads/base/<id>, cât șirefs/heads/review/<id>. - Pull Request-uri pe oglinzi externe Contribuitorii open-source nu trebuie să învețe niciodată fluxul de lucru al monorepo-ului. Ei fac fork și deschid pull request-uri către depozitele oglindă independente, ca de obicei.
Modificări, patchset-uri și transferul aprobărilor
Un nume de ramură nu poate identifica o modificare. Un agent își poate redenumi ramura, modifica vârful sau face rebase pe un trunk mai nou de mai multe ori înainte de integrare. Ca în Gerrit, fluxul identifică o modificare printr-un trailer Change-Id: I<40 hex> din mesajul commit-ului de vârf. git ws-save, git ws-pr și git ws-land adaugă trailer-ul atunci când vârful nu îl are și îl păstrează în caz contrar, astfel încât identificatorul supraviețuiește amend-urilor, rebase-urilor și redenumirilor. Commit-ul trunk integrat păstrează trailer-ul și înregistrează numărul pull request-ului său de review, astfel încât git trunk-cl <number|Change-Id> îl găsește în refs/trunk/history.
Fiecare vârf trimis la review devine un patchset. git ws-pr îl trimite către referința imuabilă refs/ws/cl/<change-id>/<tip>, lângă ramurile de review. Rularea din nou a git ws-pr după un amend sau un git ws-sync adaugă un patchset la același pull request. git ws-ps listează patchset-urile modificării curente, iar git ws-psdiff <a> <b> afișează interdiff-ul dintre două dintre ele, astfel încât reviewerul citește doar ce s-a schimbat de la ultima rundă.
GitHub leagă o aprobare de un singur commit, deci fiecare rebase ar necesita în mod normal un review nou. Pipeline-ul de integrare acceptă și o aprobare acordată altui patchset al aceleiași modificări, atunci când ambele patchset-uri aduc același patch față de propriul snapshot de bază. Compară git patch-id --verbatim peste întregul diff binar. Un rebase curat pe un trunk mai nou se integrează fără un nou review, în timp ce orice modificare de conținut, inclusiv o modificare de spații sau de mod al fișierului, necesită o nouă aprobare.
Sincronizare bidirecțională O(delta) și ramuri de producție separate
Multe organizații publică pachete individuale ca depozite GitHub independente, astfel încât utilizatorii externi să poată rula go get, cargo install sau să facă fork unui singur proiect fără a atinge monorepo-ul. Unele proiecte, precum blogurile statice sau aplicațiile web cu porți de lansare, mențin și o ramură separată production alături de main.
Motorul nostru de sincronizare Starlark gestionează atât exporturile, cât și contribuțiile venite din exterior, în timp O(delta), proporțional cu fișierele modificate într-un commit și nu cu dimensiunea monorepo-ului:
- Splicing de index fără blob-uri: Atunci când inserează sau extrage subarborele unui depozit oglindă din arborele rădăcină al monorepo-ului, motorul setează
GIT_NO_LAZY_FETCH=1și ruleazăgit update-index --index-info, urmat degit write-tree --missing-ok. Subarborii și blob-urile nemodificate rămân referite prin SHA-1 și nu sunt niciodată descărcate pe runner-ul CI. - Prefetch de blob-uri doar pentru delta: Înainte de a trimite un commit exportat către o oglindă independentă, scriptul rulează
git diff-tree -r --diff-filter=AM "$ext_parent" "$export_tree"și preia, pringit cat-file --batch, doar blob-urile vechi și noi ale fișierelor modificate. Git poate apoi calcula un pachet delta subțire și îl poate trimite către depozitul oglindă în câteva secunde. - Păstrarea workflow-urilor și a ramurilor de lansare: În timpul exportului, sincronizatorul păstrează directorul
.github/workflows/propriu al fiecărui depozit independent, astfel încât runner-ele CI publice să ruleze testele proiectului pe oglindă. Fiecare workflow din configurația Starlark vizează o ramură explicită, precummain, ceea ce păstrează porțile de lansare pentru depozitele care se implementează dintr-o ramurăproductionseparată. Integrările în monorepo se sincronizează cu ramuramaina oglinzii, iar commit-urile automate de metadate sau de traducere dinmainse sincronizează înapoi înrefs/trunk/mainprin webhook. Promovareamaincătreproductionrămâne un pas explicit de lansare.
Gânduri finale
Această arhitectură tratează depozitul de obiecte Git ca pe un Content-Addressable Store, separă refs/heads/main de refs/trunk/main și refs/trunk/history, identifică fiecare modificare printr-un Change-Id cu patchset-uri imuabile și îmbină arborii în memorie cu git merge-tree --write-tree. Costul onboarding-ului crește odată cu directoarele de care are nevoie o sarcină, iar costul integrării și al sincronizării oglinzii crește cu dimensiunea unei modificări, nu cu dimensiunea depozitului.