Ripensare il Git Monorepo: un'architettura CAS-Native per esseri umani e Coding Agents

Ogni organizzazione di ingegneria in crescita finisce prima o poi per trovarsi davanti alla stessa biforcazione. Si parte con decine di piccoli repository, e inizialmente la vita appare semplice. Ogni libreria ha il proprio issue tracker, la propria pipeline CI e un git clone che termina prima che si possa sorseggiare un caffè. Poi arriva il costo. Si modifica una struct in un package di rete centrale e si trascorre il resto del pomeriggio ad aprire pull request di version bump in sei repository a valle, in attesa che le code CI si svuotino in ordine topologico.
Spostare tutto in un monorepo Git tradizionale significa scambiare la sofferenza di coordinamento con una sofferenza fisica. Man mano che la cronologia dei commit si approfondisce e gli asset binari si accumulano nelle revisioni passate, git fetch rallenta. I working tree crescono fino a raggiungere i gigabyte. Ben presto qualcuno propone di allestire un demone di filesystem virtuale personalizzato, un server dedicato per la code review o una flotta di worker di build remoti, semplicemente per rendere sopportabile lo sviluppo quotidiano.
Quando agenti di coding autonomi entrano a far parte del team, entrambe le modalità di fallimento peggiorano. I polyrepo privano un agente dei chiamanti tra progetti e lo costringono a gestire le credenziali su più repository. I monorepo tradizionali saturano il disco del container dell'agente e la larghezza di banda di rete a ogni inizializzazione di task, mentre gli agenti concorrenti che operano sullo stesso branch generano continue corse di rebase.
Volevamo una terza opzione. Ispirati dall'architettura google3, ci siamo posti una domanda semplice: è possibile ottenere modifiche atomiche su un unico trunk, onboarding sparse in meno di un secondo, governance gerarchica tramite OWNERS e mirror open source autonomi utilizzando esclusivamente il plumbing standard di Git e object storage di tipo commodity?
La risposta è stata affermativa e non ha richiesto né un demone di filesystem personalizzato né un appliance di review di terze parti. Ha richiesto soltanto di usare Git per ciò che è realmente sotto il cofano: un Merkle-tree Content-Addressable Store (CAS).
Il Malinteso alla Base dei Monorepo Git Lenti
Quando gli sviluppatori lamentano che Git non scala per i monorepo di grandi dimensioni, raramente stanno raggiungendo un limite nel database degli oggetti di Git. All'interno di .git/objects, Git memorizza tre primitive immutabili indicizzate dal loro hash di contenuto SHA-1 (o SHA-256):
blob: I byte grezzi del contenuto di un singolo file.tree: Un elenco di directory che associa nomi di file e permessi a hash diblobo ditreefigli.commit: Un piccolo header di testo (tipicamente da 200 a 300 byte) che punta a un singolo hash ditreeradice, a zero o più hash dicommitgenitori e ai metadati dell'autore.
Come store content-addressable, questo design è straordinariamente efficiente. Se un monorepo contiene 50.000 file e si modifica una singola riga in un file, il nuovo commit riutilizza esattamente gli hash di blob e tree degli altri 49.999 file.
Perché allora un monorepo Git tradizionale appare pesante? Perché la convenzione sovraccarica un singolo riferimento, refs/heads/main, con tre compiti in conflitto:
- Contiene l'intero albero di directory unificato di ogni progetto.
- Trascina con sé l'intera catena di commit storici collegati ai genitori, fino al primo giorno.
- Risiede in
refs/heads/*, il namespace che i server Git annunciano e con cui negoziano per impostazione predefinita a ognigit cloneegit fetch.
Ogni volta che uno sviluppatore o un runner CI esegue il fetch di refs/heads/main, client e server negoziano la raggiungibilità nel commit graph su migliaia di revisioni storiche. Se qualcuno ha accidentalmente committato un archivio da 100 MB tre anni fa e lo ha eliminato il giorno dopo, il suo blob è ancora agganciato a un tree storico raggiungibile da refs/heads/main.
Separare il Namespace dei Riferimenti in Quattro Livelli
Il protocollo di rete di Git annuncia solo refs/heads/* e refs/tags/* durante un clone standard. I riferimenti sotto namespace personalizzati come refs/trunk/* o refs/ws/* risiedono nello stesso object store sul server, beneficiano della stessa compressione delta dei packfile e possono essere scaricati o inviati esplicitamente, pur non imponendo alcun sovraccarico ai clone predefiniti.
Suddividendo le responsabilità su quattro namespace di riferimento all'interno di un singolo repository, il collo di bottiglia scompare:
1refs/heads/main Solo control plane (~20 KB: regole, .gitconfig, configurazione di sync, script)
2refs/trunk/main Snapshot commit senza parent che punta al root tree unificato più recente
3refs/trunk/history Catena lineare di commit collegati ai parent che condivide esattamente lo SHA-1 del root tree
4refs/ws/<actor>/<id> Snapshot di workspace isolati (changelist) per sviluppatore o agente
5refs/heads/land/<id> Trigger effimero della merge queue, eliminato immediatamente dopo il landing
1. Il Control Plane (refs/heads/main)
Il branch predefinito non contiene codice sorgente dell'applicazione. Traccia solo le regole operative del repository (AGENTS.md, README.md), un .gitconfig condiviso con gli alias dei workflow, la configurazione di sincronizzazione in Starlark (sync.star) e gli script di merge lato server. Clonare il branch predefinito trasferisce circa 20 KB e termina in millisecondi.
2. Lo Snapshot di Trunk senza Parent (refs/trunk/main)
refs/trunk/main punta all'albero sorgente completo e unificato dell'intera organizzazione (//web/*, //net/*, //ipc/*, //devtools/*, //corp/*, //third_party/*, //experimental/*), ma il suo oggetto commit viene creato senza il flag -p per il parent.
Poiché refs/trunk/main non ha parent (parents = []), recuperarlo non richiede alcuna negoziazione del commit graph. Il server Git non attraversa la cronologia; invia il singolo commit di testa e i relativi oggetti tree di directory correnti. I binari storici o i file eliminati del mese scorso non sono raggiungibili da refs/trunk/main e non vengono mai trasferiti.
3. La Cronologia Lineare per l'Audit (refs/trunk/history)
Gli sviluppatori hanno ancora bisogno di git blame e git log quando indagano su regressioni. Ogni volta che una modifica viene integrata, lo script di merge crea due oggetti commit che puntano allo stesso identico hash SHA-1 $NEW_TREE del root tree:
1# Commit snapshot senza parent per checkout istantanei dello workspace a profondità 1
2SNAP=$(git commit-tree "$NEW_TREE" -m "$RAW_MSG")
3
4# Commit collegato al parent per git log e git blame lineari
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"
Mantenere sia refs/trunk/main sia refs/trunk/history raddoppia l'impronta di storage del repository? Assolutamente no. Poiché entrambi i commit fanno riferimento allo stesso hash $NEW_TREE, lo store content-addressable di Git deduplica il 100% degli oggetti tree e blob. Il costo aggiuntivo di storage dello snapshot senza parent è un singolo header di commit da 250 byte.
4. Workspace Isolati (refs/ws/<actor>/<id>)
I branch di lavoro in corso vengono inviati a refs/ws/<actor>/<id> anziché a refs/heads/*. Un centinaio di agenti di coding può salvare checkpoint ogni minuto senza inquinare i menu dei branch né rallentare git fetch per gli ingegneri umani.
Onboarding in Cinque Comandi: come Funziona il Checkout Sparse + Blobless
Con questa struttura di riferimenti, un nuovo sviluppatore o un container effimero di un agente di coding non installa una CLI personalizzata né un driver FUSE. L'onboarding richiede cinque comandi Git standard:
1# 1. Clona il control plane da 20 KB con filtro sui blob e modalità sparse abilitata
2git clone --filter=blob:none --sparse https://github.com/example-org/monorepo.git
3cd monorepo
4
5# 2. Carica gli alias del repository e collega refs/trunk/*
6git config include.path ../.gitconfig
7git trunk-init
8
9# 3. Dichiara le directory di dominio necessarie per questo task
10git sparse-checkout set net/relay ipc/codec
11
12# 4. Crea uno workspace isolato radicato nello snapshot di trunk più recente
13git ws-new alice/optimize-framing
In background, ogni passaggio esegue solo il lavoro minimo necessario:
git clone --filter=blob:none --sparsescarica il control plane da 20 KB surefs/heads/main.git trunk-initconfigura+refs/trunk/*:refs/remotes/origin/trunk/*ed esegue un fetch blobless. Git scarica i metadatitreeperrefs/trunk/main(tipicamente alcune decine di kilobyte) e zero blob di file.git sparse-checkout set net/relay ipc/codecistruisce il filtro dell'indice in modalità cone di Git a materializzare solo la directory radice e quei due sottoalberi.git ws-newpassa a un nuovo branch radicato inorigin/trunk/main. In quel preciso momento, Git richiede on demand solo gli oggettibloball'interno dinet/relayeipc/codec. Il restante 95% del monorepo non viene mai scaricato su disco.
Orizzonte Futuro: Montare refs/trunk/main come Virtual Filesystem (VFS)
Il checkout sparse con clone blobless copre i flussi di lavoro quotidiani senza tooling client aggiuntivo. Poiché refs/trunk/main è già un Merkle tree puro in un Content-Addressable Store, l'architettura fornisce anche una base diretta per un Virtual Filesystem (VFS) opzionale in userspace tramite FUSE su Linux, FileProvider su macOS o ProjFS su Windows:
- Mount Istantaneo dell'Albero Completo: Un demone di mount locale leggero legge l'oggetto
treeradice direfs/trunk/maine proietta l'intera gerarchia di directory del monorepo nella working directory in millisecondi, eliminando la necessità di eseguiregit sparse-checkout set. - On-Demand Blob Faulting: I contenuti dei file restano virtuali finché un compilatore, un IDE o un agente non emette una chiamata di sistema
open(2)oread(2). Il demone recupera quindi ilblobSHA-1 corrispondente dall'endpoint CAS e lo mette in cache sullo storage NVMe locale. - Overlay Copy-on-Write per i Workspace: Le modifiche locali vengono scritte in un leggero livello overlay superiore. Eseguire
git ws-savecalcola l'hash solo dei percorsi modificati in quell'overlay, scrive gli oggettiblobetreeaggiornati e invia il commit risultante arefs/ws/<actor>/<id>senza scansionare le directory non modificate.
Il Modello di Changelist su Git Puro: Landing Lato Server in Memoria
In un tradizionale flusso di lavoro Git, unire un branch di funzionalità crea un merge commit con due parent (trasformando la cronologia in uno scalo ferroviario groviglio) oppure richiede agli sviluppatori di eseguire manualmente un rebase e gareggiare con altri ingegneri che effettuano push su main.
Ispirata al modello Changelist (CL) di google3, ogni workspace della nostra architettura rappresenta una singola modifica atomica rispetto allo snapshot di trunk. Mentre lavora localmente in ws/alice/optimize-framing, uno sviluppatore o un agente potrebbe effettuare dieci commit incrementali (wip, fix tests, lint). Quando la modifica è pronta per il landing, il server comprime il delta del workspace in un'unica revisione lineare di trunk senza mai effettuare il checkout di una working directory sul runner CI.
Ecco come lo script di landing (land.sh) viene eseguito in memoria utilizzando il plumbing di Git 2.40+:
1# 1. Identifica il commit root dello snapshot da cui lo workspace è stato diramato
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. Calcola in memoria il root tree unito senza checkout dell'albero di lavoro
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
Osservate attentamente il passaggio 2:
- Indipendentemente da quanti commit locali siano stati effettuati sul branch del workspace, lo script confronta solo tre hash di tree: lo snapshot base originale (
$BASE_TREE), lo snapshot di trunk corrente ($MAIN_TREE) e il tree finale del workspace ($WORK_TREE). - Se
refs/trunk/mainè avanzato in//web/blogmentre Alice modificava//net/relay,git merge-tree --write-treecalcola direttamente all'interno dell'object store di Git, in millisecondi, il root tree combinato$NEW_TREE. Nessun file viene scritto sul filesystem del runner. - Infine,
git push --atomiccon--force-with-leaseaggiornarefs/trunk/main, aggiunge un commit lineare arefs/trunk/historyed elimina il trigger di landingrefs/heads/land/<id>in un'unica transazione.
Far Rispettare i Confini Architetturali: Visibilità a Quattro Livelli e OWNERS Gerarchici
Consolidare il codice in un unico repository funziona solo se i confini tra i domini vengono applicati meccanicamente prima che un commit possa essere integrato. Subito dopo che git merge-tree --write-tree produce $NEW_TREE (e prima che git push --atomic aggiorni il trunk), il nostro motore di policy esegue due controlli O(delta) su git diff-tree "$MAIN_TREE" "$NEW_TREE".
1. Visibilità dei Domini a Quattro Livelli
Il root tree è suddiviso in quattro livelli di visibilità:
| Prefisso della Directory | Livello | Import Consentiti | Import Vietati | Gate OWNERS |
|---|---|---|---|---|
//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/* | Produzione Pubblica | Produzione Pubblica, //third_party/* | //corp/*, //experimental/* | Applicato |
//third_party/* | Vendor Esterno | Autocontenuto o //third_party/* | Tutti i domini non third_party | Applicato |
//corp/* | Privato Interno | Produzione Pubblica, //corp/*, //third_party/* | //experimental/* | Applicato |
//experimental/<actor>/* | Sandbox | Qualsiasi package non corp | //corp/* | Esente (approvato automaticamente) |
Se un package pubblico in //web/* tenta di importare un package interno da //corp/* o un prototipo non ancora promosso da //experimental/*, il job di landing si interrompe prima di toccare refs/trunk/main.
2. Verifica Gerarchica di OWNERS Senza un Server Gerrit
Non serve un'istanza Gerrit dedicata per applicare i file OWNERS a livello di directory con regole di ereditarietà:
- Per ogni percorso modificato tra
$MAIN_TREEe$NEW_TREE, il verificatore risale dalla directory del file fino a//OWNERS. - Qualsiasi file
OWNERSpuò dichiarareset noparentper bloccare le approvazioni ereditate dalle directory superiori, oppureper-file *.sql=@dba-teamper limitare le approvazioni tramite glob sul nome del file. - Invariante Anti-Manomissione: I file
OWNERSesistenti vengono sempre letti da$MAIN_TREE(il tree di trunk corrente), mai da$NEW_TREE. Un autore non approvato non può concedersi il permesso modificandoOWNERSnello stesso commit del workspace. - Esenzione per la Sandbox: Qualsiasi percorso sotto
//experimental/<actor>/*salta i controlliOWNERS, così che ingegneri e agenti possano prototipare liberamente congit ws-exp <actor> <project>e successivamente promuovere il package in un dominio di produzione congit ws-graduate.
Code Review senza Attriti: PR Effimere e Diff di Snapshot
Poiché refs/heads/main contiene solo il control plane e refs/trunk/main è un commit senza parent, aprire una pull request standard su GitHub direttamente da un branch di workspace verso main tenterebbe di confrontare cronologie non correlate. Abbiamo risolto il problema con tre flussi di review adattati a modifiche diverse:
- Diff Istantanei a Due Punti sul Workspace (
git ws-save+ Edge Diff Router) Inviare un workspace congit ws-saveesponerefs/ws/<actor>/<id>attraverso un leggero worker edge. Questo worker risolverefs/trunk/maine il riferimento del workspace nei rispettivi SHA di commit e reindirizza alla vista di confronto a due punti di GitHub (/compare/<trunk-sha>..<ws-sha>). Per patch di piccole dimensioni o modifiche generate da agenti, i revisori possono ispezionare il diff e fare clic su Land direttamente nell'interfaccia web. - Pull Request Effimere su GitHub (
git ws-pr) Per la review riga per riga su GitHub,git ws-prinvia il commit snapshot radice ($(git rev-list --max-parents=0 HEAD)) arefs/heads/base/<id>e la punta del workspace arefs/heads/review/<id>. Apre quindi una pull request su GitHub che confrontareview/<id>conbase/<id>. Poiché il branch base è esattamente lo snapshot da cui il workspace è stato diramato, la scheda Files changed di GitHub mostra solo le righe esatte modificate nel workspace. Quando il revisore fa clic su Merge pull request, un webhook promuove il commit di testa arefs/heads/land/<id>, esegue la pipeline di landing in memoria ed elimina automaticamente siarefs/heads/base/<id>siarefs/heads/review/<id>. - Pull Request sui Mirror Esterni I contributori open source non devono mai imparare il flusso di lavoro del monorepo. Effettuano il fork e aprono pull request sui repository mirror autonomi come di consueto.
Sincronizzazione Bidirezionale O(delta) e Branch di Produzione Separati
Molte organizzazioni pubblicano i singoli package come repository GitHub autonomi, così che gli utenti esterni possano eseguire go get, cargo install o effettuare il fork di un singolo progetto senza toccare il monorepo. Alcuni progetti, come i blog statici o le applicazioni web vincolate ai rilasci, mantengono inoltre un branch production separato accanto a main.
Il nostro motore di sincronizzazione in Starlark gestisce sia le esportazioni in uscita sia i contributi in entrata in tempo O(delta), proporzionale ai file modificati in un commit anziché alle dimensioni del monorepo:
- Splicing dell'Indice Blobless: Quando innesta il sottoalbero di un repository mirror nell'albero radice del monorepo o ne rimuove il sottoalbero, il motore imposta
GIT_NO_LAZY_FETCH=1ed eseguegit update-index --index-infoseguito dagit write-tree --missing-ok. I sottoalberi e i blob non modificati vengono referenziati esclusivamente tramite i loro hash SHA-1, senza mai essere scaricati sul runner CI. - Prefetch dei Blob Solo per il Delta: Prima di inviare un commit esportato a un mirror autonomo, lo script esegue
git diff-tree -r --diff-filter=AM "$ext_parent" "$export_tree"e precarica solo i blob vecchi e nuovi dei file modificati tramitegit cat-file --batch. Git può quindi calcolare un thin delta pack e inviarlo al repository mirror in pochi secondi. - Conservazione dei Workflow e dei Branch di Rilascio: Durante l'esportazione in uscita, il sincronizzatore conserva la directory
.github/workflows/propria di ciascun repository autonomo, così che i runner CI pubblici eseguano i test del progetto sul mirror. Ogni workflow nella configurazione Starlark punta a un branch esplicito comemain, il che preserva i gate di rilascio per i repository che effettuano il deploy da un branchproductionseparato. I landing nel monorepo si sincronizzano con il branchmaindel mirror, e i commit automatici di metadati o traduzioni sumainvengono riportati inrefs/trunk/maintramite webhook. La promozione dimainaproductionresta un passaggio di rilascio esplicito.
Considerazioni Conclusive
Costruire un monorepo ad alta velocità non richiede di abbandonare Git standard né di gestire una complessa flotta di server infrastrutturali su misura. Questa architettura tratta l'object store di Git come un Content-Addressable Store, separa refs/heads/main da refs/trunk/main e refs/trunk/history, e unisce gli alberi in memoria con git merge-tree --write-tree. Di conseguenza, sia gli ingegneri umani sia gli agenti di coding autonomi possono clonare un workspace in meno di un secondo e scalare in modo pulito su centinaia di progetti.