Repenser le Git Monorepo : une architecture CAS-Native pour les humains et les Coding Agents

Chaque organisation d'ingénierie en croissance finit par devoir choisir entre plusieurs dépôts et un seul. Au départ, avec des dizaines de petits dépôts, la vie semble simple. Chaque bibliothèque possède son propre issue tracker, son propre pipeline CI et un git clone qui se termine en quelques secondes. Puis vous modifiez une struct dans un package réseau central et passez le reste de l'après-midi à ouvrir des pull requests de montée de version dans six dépôts en aval, en attendant que les files CI se vident dans l'ordre topologique.
Si vous regroupez tout dans un monorepo Git traditionnel, vous échangez la coordination entre dépôts contre la taille du dépôt. À mesure que l'historique des commits s'approfondit et que les actifs binaires s'accumulent dans les révisions passées, git fetch ralentit. Les working trees atteignent plusieurs gigaoctets. Bientôt, quelqu'un propose de mettre en place un démon de système de fichiers virtuel sur mesure, un serveur de revue de code dédié ou une flotte de workers de build distants, simplement pour rendre le développement quotidien supportable.
Lorsque des agents de codage autonomes rejoignent l'équipe, ces deux modes de défaillance s'aggravent. Les polyrepos privent un agent des appelants inter-projets et le forcent à jongler avec des identifiants répartis entre les dépôts. Les monorepos traditionnels saturent le disque du conteneur de l'agent et sa bande passante réseau à chaque initialisation de tâche, tandis que des agents concurrents travaillant sur la même branche créent des courses de rebase sans fin.
Inspirés par l'architecture google3, nous avons conçu des changements atomiques sur un trunk unique, une intégration clairsemée en moins d'une seconde, une gouvernance hiérarchique par OWNERS, et des miroirs open source autonomes utilisant la plomberie Git standard et un stockage d'objets banalisé.
La conception ne nécessite ni démon de système de fichiers sur mesure, ni appliance de revue tierce, car elle traite Git comme un Content-Addressable Store (CAS) à arbre de Merkle.
Le malentendu au cœur de la lenteur des monorepos Git
Lorsque des développeurs se plaignent que Git ne passe pas à l'échelle des grands monorepos, ils atteignent rarement une limite de la base d'objets de Git. Dans .git/objects, Git stocke trois primitives immuables identifiées par leur empreinte de contenu SHA-1 (ou SHA-256) :
blob: le contenu brut en octets d'un seul fichier.tree: un listing de répertoire qui associe noms de fichiers et permissions aux hachages deblobou detreeenfants.commit: un petit en-tête textuel (typiquement 200 à 300 octets) pointant vers un hachage detreeracine unique, zéro ou plusieurs hachages decommitparents, et les métadonnées de l'auteur.
Si un monorepo contient 50 000 fichiers et que vous modifiez une seule ligne dans un fichier, le nouveau commit réutilise exactement les hachages de blob et de tree des 49 999 autres fichiers.
Un monorepo Git traditionnel paraît lourd parce que la convention surcharge une seule référence, refs/heads/main, de trois missions contradictoires :
- Il contient l'arbre de répertoires unifié complet de chaque projet.
- Il entraîne avec lui la chaîne complète des commits historiques liés par leurs parents, jusqu'au premier jour.
- Il se trouve dans
refs/heads/*, l'espace de noms que les serveurs Git annoncent et négocient par défaut à chaquegit cloneetgit fetch.
Chaque fois qu'un développeur ou un runner CI récupère refs/heads/main, le client et le serveur négocient l'accessibilité dans le graphe de commits à travers des milliers de révisions historiques. Si quelqu'un a commité par erreur une archive de 100 Mo il y a trois ans et l'a supprimée le lendemain, son blob reste rattaché à un tree historique accessible depuis refs/heads/main.
Séparer l'espace de noms des références
Le protocole réseau de Git n'annonce que refs/heads/* et refs/tags/* lors d'un clone standard. Les références sous des espaces de noms personnalisés, tels que refs/trunk/* ou refs/ws/*, résident dans exactement le même magasin d'objets sur le serveur, bénéficient de la même compression delta des packfiles, et peuvent être récupérées ou poussées explicitement, tout en n'imposant aucun surcoût aux clones par défaut.
Séparer ces missions entre des références distinctes au sein d'un seul dépôt supprime le goulot d'étranglement :
1refs/heads/main Plan de contrôle uniquement (~20 Ko : règles, .gitconfig, configuration de synchronisation, scripts)
2refs/trunk/main Commit snapshot sans parent pointant vers le dernier arbre racine unifié
3refs/trunk/history Chaîne de commits liés par leurs parents partageant exactement le SHA-1 de l'arbre racine
4refs/ws/<actor>/<id> Instantanés d'espace de travail isolés (changelists) par développeur ou agent
5refs/ws/cl/<change-id>/<tip> Patchsets immuables : chaque pointe d'un change envoyé en revue
6refs/heads/land/<id> Déclencheur éphémère de merge queue, supprimé dès l'atterrissage
1. Le plan de contrôle (refs/heads/main)
La branche par défaut ne contient aucun code source applicatif. Elle ne suit que les règles d'exploitation du dépôt (AGENTS.md, README.md), un .gitconfig partagé d'alias de workflow, la configuration de synchronisation Starlark (sync.star) et les scripts de fusion côté serveur. Cloner la branche par défaut transfère environ 20 Ko et se termine en quelques millisecondes.
2. L'instantané de trunk sans parent (refs/trunk/main)
refs/trunk/main pointe vers l'arbre source unifié complet de toute l'organisation (//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/*, //corp/*, //third_party/*, //experimental/*), mais son objet commit est créé sans le drapeau de parent -p.
Parce que refs/trunk/main n'a aucun parent (parents = []), le récupérer ne nécessite aucune négociation sur le graphe de commits. Le serveur Git ne parcourt pas l'historique : il envoie le commit de tête unique et ses objets tree de répertoires courants. Les binaires historiques ou les fichiers supprimés du mois dernier ne sont pas accessibles depuis refs/trunk/main et ne sont jamais transférés.
3. L'historique d'audit linéaire (refs/trunk/history)
Les développeurs ont toujours besoin de git blame et git log pour enquêter sur les régressions. À chaque atterrissage d'un changement, le script de fusion crée deux objets commit pointant vers le même hachage SHA-1 $NEW_TREE d'arbre racine :
1# Commit snapshot sans parent pour des checkouts instantanés de l'espace de travail en profondeur 1
2SNAP=$(git commit-tree "$NEW_TREE" -m "$RAW_MSG")
3
4# Commit lié à son parent pour un git log et un git blame linéaires
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"
Conserver à la fois refs/trunk/main et refs/trunk/history ne double pas l'empreinte de stockage. Comme les deux commits référencent le même hachage $NEW_TREE identique, le magasin adressable par contenu de Git déduplique 100 % des objets tree et blob. Le coût de stockage supplémentaire du snapshot sans parent se limite à un en-tête de commit de 250 octets.
4. Espaces de travail isolés (refs/ws/<actor>/<id>)
Les branches de travail en cours sont poussées vers refs/ws/<actor>/<id> plutôt que vers refs/heads/*. Une centaine d'agents de codage peuvent sauvegarder des points de contrôle chaque minute sans encombrer les listes de branches ni ralentir git fetch pour les ingénieurs humains.
Intégration en quatre étapes : fonctionnement du sparse et du blobless checkout
Avec cette disposition des références, un nouveau développeur ou un conteneur d'agent de codage éphémère n'installe ni CLI ni pilote FUSE sur mesure. L'intégration se fait en quatre étapes avec Git standard et les alias du dépôt :
1# 1. Cloner le plan de contrôle de 20 Ko avec filtrage des blobs et mode sparse activé
2git clone --filter=blob:none --sparse https://github.com/example-org/monorepo.git
3cd monorepo
4
5# 2. Charger les alias du dépôt et attacher refs/trunk/*
6git config include.path ../.gitconfig
7git trunk-init
8
9# 3. Déclarer les répertoires de domaine nécessaires à cette tâche
10git sparse-checkout set net/relay ipc/codec
11
12# 4. Créer un espace de travail isolé enraciné dans le dernier instantané du trunk
13git ws-new alice/optimize-framing
Chaque étape ne récupère que ce dont elle a besoin :
git clone --filter=blob:none --sparsetélécharge le plan de contrôle de 20 Ko surrefs/heads/main.git trunk-initconfigure+refs/trunk/*:refs/remotes/origin/trunk/*et exécute un fetch sans blobs. Git télécharge les métadonnéestreederefs/trunk/main(généralement quelques dizaines de kilo-octets) et zéro blob de fichier.git sparse-checkout set net/relay ipc/codecdemande au filtre d'index en mode cône de matérialiser uniquement le répertoire racine et ces deux sous-arbres.git ws-newbascule sur une nouvelle branche enracinée surorigin/trunk/main. Git demande alors à la demande uniquement les objetsblobsitués dansnet/relayetipc/codec. Les 95 % restants du monorepo ne sont jamais téléchargés sur le disque.
Horizon futur : monter refs/trunk/main comme système de fichiers virtuel (VFS)
Le sparse checkout avec clones sans blobs couvre les workflows quotidiens sans outillage client supplémentaire. Comme refs/trunk/main est déjà un arbre de Merkle pur dans un Content-Addressable Store, l'architecture fournit aussi une base directe pour un système de fichiers virtuel (VFS) optionnel en espace utilisateur, via FUSE sous Linux, FileProvider sous macOS ou ProjFS sous Windows :
- Montage instantané de l'arbre complet : un démon de montage local léger lit l'objet
treeracine derefs/trunk/mainet projette toute la hiérarchie de répertoires du monorepo dans le répertoire de travail en quelques millisecondes, sans qu'il soit nécessaire d'exécutergit sparse-checkout set. - Chargement des blobs à la demande : le contenu des fichiers reste virtuel jusqu'à ce qu'un compilateur, un IDE ou un agent émette un appel système
open(2)ouread(2). Le démon récupère alors leblobSHA-1 correspondant depuis le point d'accès CAS et le met en cache sur le stockage NVMe local. - Superposition en copie sur écriture pour les espaces de travail : les modifications locales s'écrivent dans une couche de superposition supérieure légère. Exécuter
git ws-savene hache que les chemins modifiés de cette superposition, écrit les objetsblobettreemis à jour, et pousse le commit résultant versrefs/ws/<actor>/<id>sans parcourir les répertoires non modifiés.
Le modèle de changelist sur Git pur : atterrissage en mémoire côté serveur
Dans un workflow Git traditionnel, fusionner une branche de fonctionnalité crée soit un commit de fusion à deux parents, ce qui rend l'historique non linéaire, soit oblige les développeurs à rebaser manuellement et à rivaliser avec les pushes concurrents sur main.
Inspirée du modèle Changelist (CL) de google3, chaque espace de travail de notre architecture représente un changement atomique unique par rapport à l'instantané du trunk. Pendant le travail local dans ws/alice/optimize-framing, un développeur ou un agent peut effectuer dix commits incrémentaux (wip, fix tests, lint). Lorsque le changement est prêt à atterrir, le serveur réduit le delta de l'espace de travail en une seule révision linéaire du trunk, sans jamais extraire de répertoire de travail sur le runner CI.
Voici comment le script d'atterrissage (land.sh) s'exécute en mémoire avec la plomberie de Git 2.40+ :
1# 1. Identifier le commit snapshot racine à partir duquel l'espace de travail a été embranché
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. Calculer l'arbre racine fusionné en mémoire, sans extraction de répertoire de travail
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
Dans l'étape 2 :
- Quel que soit le nombre de commits locaux effectués sur la branche de l'espace de travail, le script ne compare que trois hachages d'arbres : le snapshot de base d'origine (
$BASE_TREE), le snapshot actuel du trunk ($MAIN_TREE) et l'arbre final de l'espace de travail ($WORK_TREE). - Si
refs/trunk/maina avancé dans//web/blogpendant qu'Alice modifiait//net/relay,git merge-tree --write-treecalcule directement dans le magasin d'objets de Git l'arbre racine combiné$NEW_TREEen quelques millisecondes, sans écrire de fichiers sur le système de fichiers du runner. - Une fois les contrôles de politique ci-dessous passés, un seul
git push --atomicavec--force-with-leasemet à jourrefs/trunk/main, ajoute un commit linéaire àrefs/trunk/history, et le déclencheur d'atterrissagerefs/heads/land/<id>est supprimé.
Faire respecter les frontières d'architecture : visibilité en quatre niveaux et OWNERS hiérarchiques
Regrouper le code dans un seul dépôt ne fonctionne que si les frontières entre domaines sont appliquées mécaniquement avant qu'un commit puisse atterrir. Juste après que git merge-tree --write-tree produit $NEW_TREE (et avant que git push --atomic ne mette à jour le trunk), notre moteur de politiques exécute deux contrôles en O(delta) sur git diff-tree "$MAIN_TREE" "$NEW_TREE".
1. Visibilité des domaines en quatre niveaux
L'arbre racine est réparti en quatre niveaux de visibilité :
| Préfixe de répertoire | Niveau | Imports autorisés | Imports interdits | Contrôle OWNERS |
|---|---|---|---|---|
//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/* | Production publique | Production publique, //third_party/* | //corp/*, //experimental/* | Appliqué |
//third_party/* | Fournisseur externe | Autonome ou //third_party/* | Tous les domaines hors third_party | Appliqué |
//corp/* | Privé interne | Production publique, //corp/*, //third_party/* | //experimental/* | Appliqué |
//experimental/<actor>/* | Bac à sable | Tout paquet hors corp | //corp/* | Exempté (approbation automatique) |
Si un paquet public de //web/* tente d'importer un paquet interne de //corp/* ou un prototype non diplômé de //experimental/*, le job d'atterrissage est interrompu avant de toucher refs/trunk/main.
2. Vérification hiérarchique des OWNERS sans serveur Gerrit
Le vérificateur applique les fichiers OWNERS au niveau des répertoires avec des règles d'héritage :
- Pour chaque chemin modifié entre
$MAIN_TREEet$NEW_TREE, le vérificateur remonte depuis le répertoire du fichier jusqu'à//OWNERS. - Tout fichier
OWNERSpeut déclarerset noparentpour bloquer les approbations héritées des répertoires parents, ouper-file *.sql=@dba-teampour restreindre les approbations par motif de nom de fichier. - Invariant anti-falsification : les fichiers
OWNERSexistants sont toujours lus depuis$MAIN_TREE(l'arbre actuel du trunk), jamais depuis$NEW_TREE. Un auteur non approuvé ne peut pas s'accorder lui-même la permission en modifiantOWNERSdans le même commit d'espace de travail. - Exemption bac à sable : tout chemin sous
//experimental/<actor>/*contourne les contrôlesOWNERS, afin que les ingénieurs et les agents puissent prototyper librement avecgit ws-exp <actor> <project>, puis faire passer le paquet dans un domaine de production avecgit ws-graduate.
Revue de code sans friction : PRs éphémères et diffs d'instantanés
Comme refs/heads/main ne contient que le plan de contrôle et que refs/trunk/main est un commit sans parent, ouvrir directement une Pull Request GitHub standard depuis une branche d'espace de travail vers main tenterait de comparer des historiques non liés. Nous avons résolu ce problème avec trois workflows de revue adaptés aux différents types de changements :
- Diffs d'espace de travail à deux points instantanés (
git ws-save+ routeur de diff en périphérie) : pousser un espace de travail avecgit ws-saveexposerefs/ws/<actor>/<id>via un worker léger en périphérie. Ce worker résoutrefs/trunk/mainet la référence de l'espace de travail en leurs SHA de commit, puis redirige vers la vue de comparaison à deux points de GitHub (/compare/<trunk-sha>..<ws-sha>). Pour les petits correctifs ou les changements générés par des agents, les relecteurs peuvent inspecter le diff et cliquer directement sur Land dans l'interface web. - Pull Requests GitHub éphémères (
git ws-pr) : pour une revue ligne par ligne sur GitHub,git ws-prpousse le commit snapshot racine ($(git rev-list --max-parents=0 HEAD)) versrefs/heads/base/<id>et la pointe de l'espace de travail versrefs/heads/review/<id>. Il ouvre ensuite une Pull Request GitHub comparantreview/<id>àbase/<id>. Comme la branche de base est l'instantané à partir duquel l'espace de travail a été embranché, l'onglet Files changed de GitHub n'affiche que le delta de l'espace de travail. Lorsque le relecteur clique sur Merge pull request, un webhook promeut le commit de tête versrefs/heads/land/<id>, exécute le pipeline d'atterrissage en mémoire, et supprime automatiquementrefs/heads/base/<id>etrefs/heads/review/<id>. - Pull Requests vers les miroirs externes : les contributeurs open source n'ont jamais besoin d'apprendre le workflow du monorepo. Ils forkent et ouvrent des pull requests vers les dépôts miroirs autonomes, comme d'habitude.
Changements, patchsets et report d'approbation
Un nom de branche ne peut pas identifier un changement. Un agent peut renommer sa branche, amender la pointe ou rebaser sur un trunk plus récent plusieurs fois avant d'atterrir. Comme dans Gerrit, le workflow identifie un changement par un trailer Change-Id: I<40 hex> dans le message du commit de tête. git ws-save, git ws-pr et git ws-land ajoutent ce trailer lorsque la pointe n'en a pas et le conservent sinon, de sorte que l'identifiant survit aux amendements, rebases et renommages. Le commit trunk atterri conserve le trailer et enregistre le numéro de sa pull request de revue, afin que git trunk-cl <number|Change-Id> le retrouve dans refs/trunk/history.
Chaque pointe envoyée en revue devient un patchset. git ws-pr le pousse vers la référence immuable refs/ws/cl/<change-id>/<tip>, à côté des branches de revue. Relancer git ws-pr après un amend ou un git ws-sync ajoute un patchset à la même pull request. git ws-ps liste les patchsets du changement courant, et git ws-psdiff <a> <b> affiche l'interdiff entre deux d'entre eux, afin qu'un relecteur ne lise que ce qui a changé depuis le dernier tour.
GitHub lie une approbation à un commit unique, si bien que chaque rebase imposerait normalement une nouvelle revue. Le pipeline d'atterrissage accepte aussi une approbation donnée à un autre patchset du même changement lorsque les deux patchsets apportent le même correctif à leur propre snapshot de base. Il compare git patch-id --verbatim sur le diff binaire complet. Un rebase propre sur un trunk plus récent atterrit sans nouvelle revue, tandis que toute modification de contenu, y compris un changement d'espaces ou de mode de fichier, nécessite une nouvelle approbation.
Synchronisation bidirectionnelle en O(delta) et branches de production séparées
De nombreuses organisations publient des paquets individuels sous forme de dépôts GitHub autonomes afin que des utilisateurs externes puissent go get, cargo install ou forker un seul projet sans toucher au monorepo. Certains projets, comme les blogs statiques ou les applications web soumises à des jalons de publication, maintiennent aussi une branche production distincte à côté de main.
Notre moteur de synchronisation Starlark gère à la fois les exports sortants et les contributions entrantes en temps O(delta), proportionnel aux fichiers modifiés dans un commit plutôt qu'à la taille du monorepo :
- Épissage d'index sans blobs : lorsqu'il insère ou extrait le sous-arbre d'un dépôt miroir dans l'arbre racine du monorepo, le moteur définit
GIT_NO_LAZY_FETCH=1et exécutegit update-index --index-infosuivi degit write-tree --missing-ok. Les sous-arbres et blobs non modifiés restent référencés par leur SHA-1 et ne sont jamais téléchargés sur le runner CI. - Préchargement des blobs du seul delta : avant de pousser un commit exporté vers un miroir autonome, le script exécute
git diff-tree -r --diff-filter=AM "$ext_parent" "$export_tree"et précharge uniquement les anciens et nouveaux blobs des fichiers modifiés viagit cat-file --batch. Git peut alors calculer un pack delta léger et pousser vers le dépôt miroir en quelques secondes. - Préservation des workflows et des branches de release : lors de l'export sortant, le synchroniseur préserve le répertoire
.github/workflows/propre à chaque dépôt autonome, afin que les runners CI publics exécutent les tests du projet sur le miroir. Chaque workflow de la configuration Starlark cible une branche explicite commemain, ce qui préserve les jalons de release pour les dépôts qui déploient depuis une brancheproductionséparée. Les atterrissages du monorepo se synchronisent avec la branchemaindu miroir, et les commits automatisés de métadonnées ou de traduction surmainse resynchronisent versrefs/trunk/mainvia webhook. La promotion demainversproductionreste une étape de release explicite.
Réflexions finales
Cette architecture traite le magasin d'objets de Git comme un Content-Addressable Store, sépare refs/heads/main de refs/trunk/main et refs/trunk/history, identifie chaque changement par un Change-Id accompagné de patchsets immuables, et fusionne les arbres en mémoire avec git merge-tree --write-tree. Le coût d'intégration croît avec les répertoires dont une tâche a besoin, et le coût de l'atterrissage et de la synchronisation des miroirs croît avec la taille d'un changement, et non avec la taille du dépôt.