---
id: 773766eac9e6ff3c46bd686d78539aed
author: Lemon Mint
title: 'Ripensare il monorepo Git: un''architettura CAS-native per esseri umani e agenti di codifica'
description: Come costruire un monorepo zero-daemon, CAS-native, su Git standard, utilizzando snapshot di trunk senza genitori, riferimenti di workspace isolati, alberi di merge in memoria, OWNERS gerarchici e sincronizzazione dei mirror in O(delta).
language: it
date: 2026-10-30T01:17:15.292299Z
path: /blog/posts/rethinking-the-git-monorepo-a-cas-native-architecture-for-humans-and-coding-agents-zfd3d7931
category: lowlevel
---

Ogni organizzazione ingegneristica in crescita sceglie, prima o poi, tra molti repository e uno solo. Si parte con decine di piccoli repository e, all'inizio, tutto sembra semplice. Ogni libreria ha il proprio issue tracker, la propria pipeline CI e un `git clone` che termina in pochi secondi. Poi si modifica una struct in un pacchetto di networking centrale e si passa il resto del pomeriggio ad aprire pull request di aggiornamento di versione in sei repository a valle, aspettando che le code CI si svuotino seguendo l'ordine topologico.

Se si sposta tutto in un monorepo Git tradizionale, si scambia il coordinamento tra repository con le dimensioni del repository. Man mano che la cronologia dei commit si approfondisce e gli asset binari si accumulano nelle revisioni passate, `git fetch` rallenta. Le 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, solo per rendere sopportabile lo sviluppo quotidiano.

Quando agenti di programmazione autonomi entrano nel team, entrambe le modalità di fallimento peggiorano. I polyrepo rendono un agente cieco rispetto ai chiamanti nei progetti vicini e lo costringono a gestire le credenziali di più repository. I monorepo tradizionali saturano il disco del container dell'agente e la larghezza di banda di rete a ogni inizializzazione di un'attività, mentre agenti concorrenti che operano sullo stesso branch generano continue corse di rebase.

Ispirandoci all'architettura `google3`, abbiamo realizzato modifiche atomiche su un unico trunk, un onboarding sparse in meno di un secondo, una governance gerarchica tramite `OWNERS` e mirror open source autonomi, basati su plumbing Git standard e archiviazione di oggetti commodity.

Il progetto non richiede né un demone di filesystem personalizzato né un'appliance di review di terze parti, perché tratta Git come un Content-Addressable Store (CAS) basato su alberi di Merkle.

## Il malinteso alla base dei lenti monorepo Git

Quando gli sviluppatori lamentano che Git non scala ai grandi monorepo, raramente stanno raggiungendo un limite del database degli oggetti di Git. All'interno di `.git/objects`, Git memorizza tre primitive immutabili, indicizzate per il proprio hash del contenuto SHA-1 (o SHA-256):

1. **`blob`**: i byte grezzi del contenuto di un singolo file.
2. **`tree`**: un elenco di directory che associa nomi di file e permessi a hash di `blob` o di `tree` figli.
3. **`commit`**: un piccolo header di testo (tipicamente da 200 a 300 byte) che punta a un unico hash di `tree` radice, a zero o più hash di `commit` genitori e ai metadati dell'autore.

Se un monorepo contiene 50.000 file e si modifica una sola riga di un file, il nuovo commit riutilizza esattamente gli hash di `blob` e `tree` degli altri 49.999 file.

Un monorepo Git tradizionale appare pesante perché la convenzione sovraccarica un unico riferimento, `refs/heads/main`, con tre compiti in conflitto:

- Contiene l'**intero albero directory unificato** di ogni progetto.
- Trascina con sé l'**intera catena storica di commit collegati ai genitori** fino al primo giorno.
- Risiede in `refs/heads/*`, lo spazio dei nomi che i server Git annunciano e negoziano per impostazione predefinita a ogni `git clone` e `git fetch`.

Ogni volta che uno sviluppatore o un runner CI esegue il fetch di `refs/heads/main`, client e server negoziano la raggiungibilità nel grafo dei commit su migliaia di revisioni storiche. Se qualcuno ha committato per errore un archivio da 100 MB tre anni fa e lo ha eliminato il giorno dopo, il suo `blob` resta agganciato a un `tree` storico raggiungibile da `refs/heads/main`.

## Separare lo spazio dei nomi dei riferimenti

Durante un clone standard, il protocollo di comunicazione di Git annuncia solo `refs/heads/*` e `refs/tags/*`. I riferimenti in namespace personalizzati, come `refs/trunk/*` o `refs/ws/*`, risiedono nello stesso object store del server, beneficiano della stessa compressione delta dei packfile e possono essere scaricati o inviati esplicitamente, senza però introdurre alcun overhead nei clone predefiniti.

Suddividere questi compiti tra riferimenti separati all'interno di un unico repository elimina il collo di bottiglia:

```
refs/heads/main               Solo control plane (~20 KB: regole, .gitconfig, configurazione di sync, script)
refs/trunk/main               Commit snapshot senza genitori che punta all'albero radice unificato più recente
refs/trunk/history            Catena lineare di commit collegati ai genitori che condivide l'esatto SHA-1 dell'albero radice
refs/ws/<actor>/<id>          Snapshot isolati dei workspace (changelist) per sviluppatore o agente
refs/ws/cl/<change-id>/<tip>  Patchset immutabili: ogni tip di una modifica inviata per la review
refs/heads/land/<id>          Trigger effimero della merge queue, eliminato subito dopo l'integrazione
```

### 1. Il control plane (`refs/heads/main`)

Il branch predefinito non contiene codice sorgente applicativo. Traccia solo le regole operative del repository (`AGENTS.md`, `README.md`), un `.gitconfig` condiviso con alias di workflow, la configurazione di sincronizzazione in Starlark (`sync.star`) e gli script di merge lato server. Il clone del branch predefinito trasferisce circa 20 KB e termina in pochi millisecondi.

### 2. Lo snapshot senza genitori del trunk (`refs/trunk/main`)

`refs/trunk/main` punta all'albero sorgente completo e unificato dell'intera organizzazione (`//web/*`, `//net/*`, `//ipc/*`, `//storage/*`, `//crypto/*`, `//ai/*`, `//devtools/*`, `//corp/*`, `//third_party/*`, `//experimental/*`), ma il suo oggetto commit viene creato **senza il flag `-p` per il genitore**.

Poiché `refs/trunk/main` non ha genitori (`parents = []`), il suo fetch non richiede alcuna negoziazione del grafo dei commit. Il server Git non percorre la cronologia: invia il singolo commit di testa e i relativi oggetti `tree` delle directory correnti. I binari storici o i file eliminati il 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 comunque 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 all'**esatto stesso hash SHA-1 `$NEW_TREE`** dell'albero radice:

```bash
# Commit snapshot senza genitori per checkout istantanei di workspace a profondità 1
SNAP=$(git commit-tree "$NEW_TREE" -m "$RAW_MSG")

# Commit collegato al genitore per git log e git blame lineari
HIST=$(git commit-tree "$NEW_TREE" -p "$OLD_HIST" -m "$RAW_MSG")

git push --atomic origin \
  --force-with-lease="refs/trunk/main:${OLD_MAIN}" \
  --force-with-lease="refs/trunk/history:${OLD_HIST}" \
  "${SNAP}:refs/trunk/main" \
  "${HIST}:refs/trunk/history"
```

Mantenere sia `refs/trunk/main` sia `refs/trunk/history` non raddoppia l'occupazione di spazio. Poiché entrambi i commit fanno riferimento allo stesso hash `$NEW_TREE`, il content-addressable store di Git deduplica il 100% degli oggetti `tree` e `blob`. Il costo aggiuntivo dello snapshot senza genitori è un solo 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>` invece che a `refs/heads/*`. Cento agenti di programmazione possono salvare checkpoint ogni minuto senza inquinare i menu dei branch né rallentare `git fetch` per gli ingegneri umani.

## Onboarding in quattro passaggi: come funziona il checkout sparse e blobless

Con questa disposizione dei riferimenti, un nuovo sviluppatore o un container effimero di un agente di programmazione non installa alcuna CLI personalizzata né alcun driver FUSE. L'onboarding richiede quattro passaggi con Git standard e gli alias del repository:

```bash
# 1. Clona il control plane da 20 KB con filtro sui blob e modalità sparse abilitata
git clone --filter=blob:none --sparse https://github.com/example-org/monorepo.git
cd monorepo

# 2. Carica gli alias del repository e collega refs/trunk/*
git config include.path ../.gitconfig
git trunk-init

# 3. Dichiara le directory di dominio necessarie per questa attività
git sparse-checkout set net/relay ipc/codec

# 4. Crea un workspace isolato radicato nello snapshot di trunk più recente
git ws-new alice/optimize-framing
```

Ogni passaggio scarica solo ciò di cui ha bisogno:

1. `git clone --filter=blob:none --sparse` scarica il control plane da 20 KB su `refs/heads/main`.
2. `git trunk-init` configura `+refs/trunk/*:refs/remotes/origin/trunk/*` ed esegue un fetch blobless. Git scarica i metadati `tree` di `refs/trunk/main` (tipicamente alcune decine di kilobyte) e **zero blob di file**.
3. `git sparse-checkout set net/relay ipc/codec` istruisce il filtro dell'indice in modalità cone di Git a materializzare solo la directory radice e quei due sottoalberi.
4. `git ws-new` passa a un nuovo branch radicato in `origin/trunk/main`. Git richiede poi, su richiesta, solo gli oggetti `blob` contenuti in `net/relay` e `ipc/codec`. Il restante 95% del monorepo non viene mai scaricato su disco.

### Prospettiva futura: montare `refs/trunk/main` come filesystem virtuale (VFS)

Il checkout sparse con clone blobless copre i flussi di lavoro quotidiani senza strumenti client aggiuntivi. Poiché `refs/trunk/main` è già un albero di Merkle puro in un Content-Addressable Store, l'architettura offre anche una base diretta per un Virtual Filesystem (VFS) opzionale in userspace, tramite FUSE su Linux, FileProvider su macOS o ProjFS su Windows:

1. **Montaggio istantaneo dell'intero albero**: un leggero demone di mount locale legge l'oggetto `tree` radice di `refs/trunk/main` e proietta l'intera gerarchia di directory del monorepo nella working directory in millisecondi, eliminando la necessità di eseguire `git sparse-checkout set`.
2. **Caricamento on-demand dei blob**: il contenuto dei file resta virtuale finché un compilatore, un IDE o un agente non invoca la system call `open(2)` o `read(2)`. Il demone recupera quindi il `blob` SHA-1 corrispondente dall'endpoint CAS e lo memorizza nella cache su storage NVMe locale.
3. **Overlay copy-on-write per i workspace**: le modifiche locali vengono scritte in un livello overlay superiore leggero. Eseguire `git ws-save` calcola l'hash solo dei percorsi modificati in quell'overlay, scrive gli oggetti `blob` e `tree` aggiornati e invia il commit risultante a `refs/ws/<actor>/<id>`, senza scansionare le directory non modificate.

## Il modello delle changelist su Git puro: integrazione lato server in memoria

In un flusso di lavoro Git tradizionale, l'integrazione di un branch di funzionalità crea un commit di merge con due genitori, che rende la cronologia non lineare, oppure richiede che gli sviluppatori eseguano manualmente il rebase e gareggino con i push concorrenti su `main`.

Ispirandoci al modello Changelist (CL) di `google3`, ogni workspace della nostra architettura rappresenta una **singola modifica atomica** rispetto allo snapshot del trunk. Mentre lavora localmente in `ws/alice/optimize-framing`, uno sviluppatore o un agente può effettuare dieci commit incrementali (`wip`, `fix tests`, `lint`). Quando la modifica è pronta per essere integrata, il server comprime il delta del workspace in un'unica revisione lineare del trunk, senza mai effettuare il checkout di una working directory sul runner CI.

Ecco come lo script di integrazione (`land.sh`) viene eseguito in memoria, usando il plumbing di Git 2.40+:

```bash
# 1. Identifica il commit dello snapshot radice da cui il workspace si è ramificato
BASE=$(git rev-list --max-parents=0 "$WORK_SHA")

BASE_TREE=$(git rev-parse "${BASE}^{tree}")
MAIN_TREE=$(git rev-parse "${OLD_MAIN}^{tree}")
WORK_TREE=$(git rev-parse "${WORK_SHA}^{tree}")

# 2. Calcola in memoria l'albero radice unito senza un checkout della working tree
if [ "$MAIN_TREE" = "$BASE_TREE" ]; then
  NEW_TREE="$WORK_TREE"
elif OUT=$(git merge-tree --write-tree --merge-base="$BASE_TREE" "$MAIN_TREE" "$WORK_TREE"); then
  NEW_TREE=$(head -n1 <<<"$OUT")
else
  echo "Merge conflict detected; run git ws-sync to rebase." >&2
  exit 1
fi
```

Nel passaggio 2:

- Indipendentemente dal numero di commit locali effettuati sul branch del workspace, lo script confronta solo tre hash di albero: lo snapshot di base originale (`$BASE_TREE`), lo snapshot corrente del trunk (`$MAIN_TREE`) e l'albero finale del workspace (`$WORK_TREE`).
- Se `refs/trunk/main` avanza in `//web/blog` mentre Alice modifica `//net/relay`, `git merge-tree --write-tree` calcola direttamente all'interno dell'object store di Git, in millisecondi, l'albero radice combinato `$NEW_TREE`, senza scrivere file sul filesystem del runner.
- Dopo il superamento dei controlli di policy descritti di seguito, un unico `git push --atomic` con `--force-with-lease` aggiorna `refs/trunk/main` e aggiunge un commit lineare a `refs/trunk/history`, mentre il trigger di integrazione `refs/heads/land/<id>` viene eliminato.

## 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 in modo meccanico 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

L'albero radice è suddiviso in quattro livelli di visibilità:

| Prefisso di 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/*`                                                                      | Fornitore esterno  | Autosufficiente o `//third_party/*`              | Tutti i domini non `third_party` | Applicato             |
| `//corp/*`                                                                             | Privato interno    | Produzione pubblica, `//corp/*`, `//third_party/*` | `//experimental/*`           | Applicato               |
| `//experimental/<actor>/*`                                                             | Sandbox            | Qualsiasi pacchetto non `corp`                   | `//corp/*`                     | Esente (approvazione automatica) |

Se un pacchetto pubblico in `//web/*` tenta di importare un pacchetto interno da `//corp/*` o un prototipo non ancora promosso da `//experimental/*`, il job di integrazione viene interrotto prima di toccare `refs/trunk/main`.

### 2. Verifica gerarchica di `OWNERS` senza un server Gerrit

Il verificatore applica i file `OWNERS` a livello di directory secondo regole di ereditarietà:

- Per ogni percorso modificato tra `$MAIN_TREE` e `$NEW_TREE`, il verificatore risale dalla directory del file fino a `//OWNERS`.
- Qualsiasi file `OWNERS` può dichiarare `set noparent` per bloccare le approvazioni ereditate dalle directory superiori, oppure `per-file *.sql=@dba-team` per limitare le approvazioni tramite glob sul nome del file.
- **Invariante anti-manomissione**: i file `OWNERS` esistenti vengono sempre letti da `$MAIN_TREE` (l'albero corrente del trunk), mai da `$NEW_TREE`. Un autore non approvato non può concedersi autorizzazioni modificando `OWNERS` nello stesso commit del workspace.
- **Esenzione per la sandbox**: qualsiasi percorso sotto `//experimental/<actor>/*` salta i controlli `OWNERS`, così ingegneri e agenti possono prototipare liberamente con `git ws-exp <actor> <project>` e, in seguito, promuovere il pacchetto a un dominio di produzione con `git ws-graduate`.

## Review del codice senza attriti: PR effimere e diff degli snapshot

Poiché `refs/heads/main` contiene solo il control plane e `refs/trunk/main` è un commit senza genitori, aprire direttamente su GitHub una Pull Request standard dal branch di un workspace verso `main` tenterebbe di confrontare cronologie non correlate. Abbiamo risolto il problema con tre flussi di review, pensati per tipi di modifica diversi:

1. **Diff istantanei a due punti per i workspace (`git ws-save` + Edge Diff Router)**
   Inviare un workspace con `git ws-save` espone `refs/ws/<actor>/<id>` attraverso un leggero worker edge. Questo worker risolve `refs/trunk/main` e il riferimento del workspace nei rispettivi SHA dei commit e reindirizza alla vista di confronto a due punti di GitHub (`/compare/<trunk-sha>..<ws-sha>`). Per patch piccole o modifiche generate da agenti, i revisori possono ispezionare il diff e fare clic su **Land** direttamente nell'interfaccia web.
2. **Pull Request effimere su GitHub (`git ws-pr`)**
   Per la review riga per riga su GitHub, `git ws-pr` invia il commit snapshot radice (`$(git rev-list --max-parents=0 HEAD)`) a `refs/heads/base/<id>` e la punta del workspace a `refs/heads/review/<id>`. Apre quindi una Pull Request su GitHub che confronta `review/<id>` con `base/<id>`. Poiché il branch base è lo snapshot da cui il workspace si è ramificato, la scheda **Files changed** di GitHub mostra solo il delta del workspace. Quando il revisore fa clic su **Merge pull request**, un webhook promuove il commit di testa a `refs/heads/land/<id>`, esegue la pipeline di integrazione in memoria ed elimina automaticamente sia `refs/heads/base/<id>` sia `refs/heads/review/<id>`.
3. **Pull Request sui mirror esterni**
   I contributori open source non devono mai imparare il flusso di lavoro del monorepo. Fanno il fork e aprono pull request verso i repository mirror autonomi, come di consueto.

### Modifiche, patchset e riporto delle approvazioni

Il nome di un branch non può identificare una modifica. Un agente può rinominare il proprio branch, correggere la punta con amend o eseguire il rebase su un trunk più recente più volte prima dell'integrazione. Come in Gerrit, il flusso di lavoro identifica una modifica tramite un trailer `Change-Id: I<40 hex>` nel messaggio del commit di punta. `git ws-save`, `git ws-pr` e `git ws-land` aggiungono il trailer quando la punta non ne ha uno e lo mantengono se è già presente, così che l'identificativo sopravviva ad amend, rebase e rinomine. Il commit del trunk integrato conserva il trailer e registra il numero della propria pull request di review, così che `git trunk-cl <number|Change-Id>` lo trovi in `refs/trunk/history`.

Ogni punta inviata per la review diventa un patchset. `git ws-pr` la invia al riferimento immutabile `refs/ws/cl/<change-id>/<tip>`, accanto ai branch di review. Eseguire di nuovo `git ws-pr` dopo un amend o un `git ws-sync` aggiunge un patchset alla stessa pull request. `git ws-ps` elenca i patchset della modifica corrente, mentre `git ws-psdiff <a> <b>` stampa l'interdiff tra due di essi, in modo che il revisore legga solo ciò che è cambiato dall'ultimo round.

GitHub associa un'approvazione a un singolo commit, quindi ogni rebase richiederebbe normalmente una nuova review. La pipeline di integrazione accetta inoltre un'approvazione assegnata a un altro patchset della stessa modifica quando entrambi i patchset apportano la stessa patch al proprio snapshot di base. Il confronto avviene tramite `git patch-id --verbatim` sull'intero diff binario. Un rebase pulito su un trunk più recente viene integrato senza un'ulteriore review, mentre qualsiasi modifica al contenuto, incluso un cambiamento di spazi bianchi o di modalità del file, richiede una nuova approvazione.

## Sincronizzazione bidirezionale in tempo O(delta) e branch di produzione separati

Molte organizzazioni pubblicano i singoli pacchetti come repository GitHub autonomi, così che gli utenti esterni possano eseguire `go get`, `cargo install` o fare il fork di un singolo progetto senza toccare il monorepo. Alcuni progetti, come i blog statici o le applicazioni web con rilasci controllati, mantengono anche un branch `production` separato accanto a `main`.

Il nostro motore di sincronizzazione in Starlark gestisce sia le esportazioni verso l'esterno sia i contributi in entrata in tempo **O(delta)**, cioè proporzionale ai file modificati in un commit anziché alle dimensioni del monorepo:

- **Splicing dell'indice senza blob**: quando inserisce o estrae il sottoalbero di un repository mirror nell'albero radice del monorepo, il motore imposta `GIT_NO_LAZY_FETCH=1` ed esegue `git update-index --index-info`, seguito da `git write-tree --missing-ok`. I sottoalberi e i blob non modificati restano referenziati tramite SHA-1 e non vengono mai 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 tramite `git cat-file --batch`. Git può così calcolare un packfile delta ridotto e inviarlo al repository mirror in pochi secondi.
- **Preservazione dei workflow e dei branch di rilascio**: durante l'esportazione verso l'esterno, 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, come `main`, preservando i gate di rilascio per i repository che effettuano il deploy da un branch `production` separato. Le integrazioni nel monorepo si sincronizzano con il branch `main` del mirror, e i commit automatici di metadati o traduzioni su `main` vengono sincronizzati di nuovo in `refs/trunk/main` tramite webhook. La promozione di `main` a `production` resta un passaggio di rilascio esplicito.

## Considerazioni conclusive

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`, identifica ogni modifica tramite un `Change-Id` con patchset immutabili e fonde gli alberi in memoria con `git merge-tree --write-tree`. Il costo dell'onboarding cresce con le directory di cui un'attività ha bisogno, mentre il costo dell'integrazione e della sincronizzazione dei mirror cresce con le dimensioni di una modifica, non con le dimensioni del repository.
