Repenser le monorepo Git : une architecture native CAS pour les humains et les agents de codage

Chaque organisation d'ingénierie en croissance finit par atteindre la même bifurcation. Au départ, avec des dizaines de petits dépôts, tout semble simple. Chaque bibliothèque possède son propre gestionnaire de tickets, son propre pipeline CI et un git clone qui se termine avant que vous ayez bu une gorgée de café. Puis le coût arrive. Vous modifiez une structure dans un paquet réseau central et passez le reste de l'après-midi à ouvrir des pull requests de mise à jour de version dans six dépôts en aval, en attendant que les files CI se vident dans l'ordre topologique.
Si l'on regroupe tout dans un monorepo Git traditionnel, on échange les difficultés de coordination contre des difficultés physiques. À mesure que l'historique des commits s'allonge et que des actifs binaires s'accumulent dans les révisions passées, git fetch ralentit. Les arbres de travail gonflent jusqu'à atteindre plusieurs gigaoctets. Rapidement, 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 compilation 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 l'obligent à jongler avec des identifiants répartis entre plusieurs dépôts. Les monorepos traditionnels saturent le disque et la bande passante réseau du conteneur de l'agent à chaque initialisation de tâche, tandis que des agents concurrents qui travaillent sur la même branche provoquent des courses de rebase sans fin.
Nous voulions une troisième voie. Inspirés par l'architecture google3, nous nous sommes posé une question simple : peut-on obtenir des modifications atomiques sur un tronc unique, une préparation éparse en moins d'une seconde, une gouvernance hiérarchique des OWNERS et des miroirs open source autonomes, en n'utilisant que la plomberie Git standard et un stockage objet banalisé ?
La réponse est oui, et cela ne nécessitait ni démon de système de fichiers sur mesure ni appliance de revue tierce. Il suffisait d'utiliser Git pour ce qu'il est réellement sous le capot : un Content-Addressable Store (CAS) à arbres de Merkle.
La méprise au cœur des monorepos Git lents
Lorsque des développeurs se plaignent que Git ne passe pas à l'échelle sur de grands monorepos, ils atteignent rarement une limite de la base d'objets de Git. Dans .git/objects, Git stocke trois primitives immuables, indexées par leur empreinte de contenu SHA-1 (ou SHA-256) :
blob: le contenu brut en octets d'un fichier unique.tree: une liste de répertoire qui associe des noms de fichiers et des permissions à des empreintes deblobou detreeenfants.commit: un petit en-tête textuel (généralement de 200 à 300 octets) pointant vers une empreinte detreeracine unique, zéro ou plusieurs empreintes decommitparents, et les métadonnées de l'auteur.
En tant que content-addressable store, cette conception est remarquablement efficace. Si un monorepo contient 50 000 fichiers et que vous modifiez une seule ligne dans un fichier, le nouveau commit réutilise exactement les empreintes de blob et de tree des 49 999 autres fichiers.
Pourquoi, dès lors, un monorepo Git traditionnel semble-t-il lourd ? Parce que la convention surcharge une seule référence, refs/heads/main, de trois fonctions contradictoires :
- Il contient l'arbre de répertoires unifié complet de chaque projet.
- Il entraîne avec lui toute la chaîne de 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 des 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, puis 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 en quatre couches
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, comme refs/trunk/* ou refs/ws/*, résident dans 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 aucune surcharge aux clones par défaut.
En répartissant les responsabilités entre quatre espaces de noms de références au sein d'un même dépôt, le goulot d'étranglement disparaît :
1refs/heads/main Plan de contrôle uniquement (~20 Ko : règles, .gitconfig, configuration de synchronisation, scripts)
2refs/trunk/main Commit d'instantané sans parent pointant vers l'arbre racine unifié le plus récent
3refs/trunk/history Chaîne linéaire de commits liés par leurs parents partageant exactement le SHA-1 de l'arbre racine
4refs/ws/<actor>/<id> Instantanés d'espaces de travail isolés (listes de modifications) par développeur ou agent
5refs/heads/land/<id> Déclencheur éphémère de file de fusion, supprimé immédiatement après l'intégration
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. Le clone de la branche par défaut transfère environ 20 Ko et se termine en quelques millisecondes.
2. L'instantané de tronc sans parent (refs/trunk/main)
refs/trunk/main pointe vers l'arbre source unifié complet de l'organisation (//web/*, //net/*, //ipc/*, //devtools/*, //corp/*, //third_party/*, //experimental/*), mais son objet commit est créé sans drapeau de parent -p.
Comme refs/trunk/main n'a aucun parent (parents = []), sa récupération ne nécessite aucune négociation du 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 de git log lorsqu'ils enquêtent sur des régressions. À chaque intégration d'une modification, le script de fusion crée deux objets commit pointant vers exactement le même SHA-1 d'arbre racine $NEW_TREE :
1# Commit d'instantané sans parent pour des extractions d'espace de travail instantanées 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"
Le fait de maintenir à la fois refs/trunk/main et refs/trunk/history double-t-il l'empreinte de stockage du dépôt ? Absolument pas. Comme les deux commits référencent le même $NEW_TREE, le content-addressable store de Git déduplique 100 % des objets tree et blob. Le surcoût de stockage de l'instantané sans parent se limite à un seul en-tête de commit d'environ 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/*. Cent agents de codage peuvent enregistrer des points de contrôle chaque minute sans polluer les listes déroulantes de branches ni ralentir git fetch pour les ingénieurs humains.
Intégration en cinq commandes : fonctionnement de l'extraction éparse et sans blobs
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 personnalisée ni pilote FUSE. L'intégration se fait en cinq commandes Git standard :
1# 1. Cloner le plan de contrôle de 20 Ko avec filtrage des blobs et mode épars activés
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é sur le dernier instantané du tronc
13git ws-new alice/optimize-framing
Sous le capot, chaque étape n'effectue que le minimum de travail requis :
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 une récupération sans blobs. Git télécharge les métadonnéestreederefs/trunk/main(généralement quelques dizaines de kilo-octets) et aucun 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. À cet instant précis, Git ne demande à la demande que les objetsblobsitués dansnet/relayetipc/codec. Les 95 % restants du monorepo ne sont jamais téléchargés sur le disque.
Perspective future : monter refs/trunk/main comme système de fichiers virtuel (VFS)
L'extraction éparse avec des 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 offre également 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 la hiérarchie complète du monorepo dans le répertoire de travail en quelques millisecondes, supprimant le besoin 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 auprès du point de terminaison CAS et le met en cache sur le stockage NVMe local. - Superposition copy-on-write pour les espaces de travail : les modifications locales s'écrivent dans une couche de superposition supérieure légère. L'exécution de
git ws-savene hache que les chemins modifiés de cette superposition, écrit les objetsblobettreemis à jour, puis pousse le commit résultant versrefs/ws/<actor>/<id>sans parcourir les répertoires non modifiés.
Le modèle de liste de modifications sur Git pur : intégration en mémoire côté serveur
Dans un workflow Git traditionnel, la fusion d'une branche de fonctionnalité crée soit un commit de fusion à deux parents (transformant l'historique en gare de triage enchevêtrée), soit oblige les développeurs à rebaser manuellement et à rivaliser avec d'autres ingénieurs qui poussent vers main.
Inspirée par le modèle de Changelist (CL) de google3, chaque espace de travail de notre architecture représente une modification atomique unique par rapport à l'instantané du tronc. Pendant le travail local dans ws/alice/optimize-framing, un développeur ou un agent peut effectuer une dizaine de commits incrémentaux (wip, fix tests, lint). Lorsque la modification est prête à être intégrée, le serveur réduit le delta de l'espace de travail en une seule révision linéaire du tronc, sans jamais extraire de répertoire de travail sur le runner CI.
Voici comment le script d'intégration (land.sh) s'exécute en mémoire à l'aide de la plomberie de Git 2.40+ :
1# 1. Identifier le commit d'instantané racine à partir duquel l'espace de travail a divergé
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 l'arbre 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
Examinez attentivement 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 empreintes d'arbres : l'instantané de base d'origine (
$BASE_TREE), l'instantané actuel du tronc ($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. Aucun fichier n'est écrit sur le système de fichiers du runner. - Enfin,
git push --atomicavec--force-with-leasemet à jourrefs/trunk/main, ajoute un commit linéaire àrefs/trunk/historyet supprime le déclencheur d'intégrationrefs/heads/land/<id>dans une transaction unique.
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 être intégré. Juste après que git merge-tree --write-tree a produit $NEW_TREE (et avant que git push --atomic ne mette à jour le tronc), notre moteur de politiques exécute deux vérifications 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/* | Interne privé | 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/*, la tâche d'intégration est interrompue avant de toucher à refs/trunk/main.
2. Vérification hiérarchique des OWNERS sans serveur Gerrit
Il n'est pas nécessaire de disposer d'une instance Gerrit dédiée pour appliquer des 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 à un motif de nom de fichier. - Invariant anti-falsification : les fichiers
OWNERSexistants sont toujours lus depuis$MAIN_TREE(l'arbre actuel du tronc), jamais depuis$NEW_TREE. Un auteur non approuvé ne peut pas s'accorder lui-même une autorisation en modifiantOWNERSdans le même commit d'espace de travail. - Exemption pour le bac à sable : tout chemin sous
//experimental/<actor>/*contourne les contrôlesOWNERSafin 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 : PR é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 sans rapport. Nous avons résolu ce problème avec trois workflows de revue adaptés aux différents types de modifications :
- Diffs instantanés à deux points pour l'espace de travail (
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 et redirige vers la vue de comparaison à deux points de GitHub (/compare/<trunk-sha>..<ws-sha>). Pour les petits correctifs ou les modifications générées 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 d'instantané 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 correspond exactement à l'instantané à partir duquel l'espace de travail a divergé, l'onglet Files changed de GitHub n'affiche que les lignes exactement modifiées dans 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'intégration en mémoire et supprime automatiquementrefs/heads/base/<id>etrefs/heads/review/<id>. - Pull Requests sur les miroirs externes Les contributeurs open source n'ont jamais besoin d'apprendre le workflow du monorepo. Ils forkent le dépôt et ouvrent des pull requests vers les dépôts miroirs autonomes, comme d'habitude.
Synchronisation bidirectionnelle en O(delta) et branches de production distinctes
De nombreuses organisations publient des paquets individuels sous forme de dépôts GitHub autonomes afin que des utilisateurs externes puissent exécuter go get, cargo install ou forker un projet unique sans toucher au monorepo. Certains projets, comme les blogs statiques ou les applications web soumises à des validations de publication, maintiennent également une branche production distincte aux côtés de main.
Notre moteur de synchronisation Starlark gère à la fois les exports sortants et les contributions entrantes en un temps O(delta), proportionnel aux fichiers modifiés dans un commit et non à la taille du monorepo :
- Épissage d'index sans blobs : lors de l'insertion ou du retrait du 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-info, suivi degit write-tree --missing-ok. Les sous-arbres et blobs non modifiés sont référencés uniquement par leurs empreintes SHA-1, sans jamais être 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 packfile delta allégé et le pousser vers le dépôt miroir en quelques secondes. - Préservation des workflows et des branches de publication : lors de l'export sortant, le synchroniseur conserve 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, telle quemain, ce qui préserve les validations de publication pour les dépôts déployés depuis une brancheproductiondistincte. Les intégrations 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 publication explicite.
Réflexions finales
Construire un monorepo à haute vélocité ne requiert ni d'abandonner Git standard, ni d'exploiter une infrastructure complexe composée de serveurs sur mesure. 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, et fusionne les arbres en mémoire avec git merge-tree --write-tree. En conséquence, les ingénieurs humains comme les agents de codage autonomes peuvent cloner un espace de travail en moins d'une seconde et passer à l'échelle proprement sur des centaines de projets.