GoSuda

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

By Lemon Mint views ...

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) :

  1. blob : le contenu brut en octets d'un seul fichier.
  2. tree : un listing de répertoire qui associe noms de fichiers et permissions aux hachages de blob ou de tree enfants.
  3. commit : un petit en-tête textuel (typiquement 200 à 300 octets) pointant vers un hachage de tree racine unique, zéro ou plusieurs hachages de commit parents, 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 à chaque git clone et git 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 :

  1. git clone --filter=blob:none --sparse télécharge le plan de contrôle de 20 Ko sur refs/heads/main.
  2. git trunk-init configure +refs/trunk/*:refs/remotes/origin/trunk/* et exécute un fetch sans blobs. Git télécharge les métadonnées tree de refs/trunk/main (généralement quelques dizaines de kilo-octets) et zéro blob de fichier.
  3. git sparse-checkout set net/relay ipc/codec demande au filtre d'index en mode cône de matérialiser uniquement le répertoire racine et ces deux sous-arbres.
  4. git ws-new bascule sur une nouvelle branche enracinée sur origin/trunk/main. Git demande alors à la demande uniquement les objets blob situés dans net/relay et ipc/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 :

  1. Montage instantané de l'arbre complet : un démon de montage local léger lit l'objet tree racine de refs/trunk/main et 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écuter git sparse-checkout set.
  2. 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) ou read(2). Le démon récupère alors le blob SHA-1 correspondant depuis le point d'accès CAS et le met en cache sur le stockage NVMe local.
  3. 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-save ne hache que les chemins modifiés de cette superposition, écrit les objets blob et tree mis à jour, et pousse le commit résultant vers refs/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/main a avancé dans //web/blog pendant qu'Alice modifiait //net/relay, git merge-tree --write-tree calcule directement dans le magasin d'objets de Git l'arbre racine combiné $NEW_TREE en 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 --atomic avec --force-with-lease met à jour refs/trunk/main, ajoute un commit linéaire à refs/trunk/history, et le déclencheur d'atterrissage refs/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épertoireNiveauImports autorisésImports interditsContrôle OWNERS
//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/*Production publiqueProduction publique, //third_party/*//corp/*, //experimental/*Appliqué
//third_party/*Fournisseur externeAutonome ou //third_party/*Tous les domaines hors third_partyAppliqué
//corp/*Privé interneProduction publique, //corp/*, //third_party/*//experimental/*Appliqué
//experimental/<actor>/*Bac à sableTout 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_TREE et $NEW_TREE, le vérificateur remonte depuis le répertoire du fichier jusqu'à //OWNERS.
  • Tout fichier OWNERS peut déclarer set noparent pour bloquer les approbations héritées des répertoires parents, ou per-file *.sql=@dba-team pour restreindre les approbations par motif de nom de fichier.
  • Invariant anti-falsification : les fichiers OWNERS existants 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 modifiant OWNERS dans le même commit d'espace de travail.
  • Exemption bac à sable : tout chemin sous //experimental/<actor>/* contourne les contrôles OWNERS, afin que les ingénieurs et les agents puissent prototyper librement avec git ws-exp <actor> <project>, puis faire passer le paquet dans un domaine de production avec git 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 :

  1. Diffs d'espace de travail à deux points instantanés (git ws-save + routeur de diff en périphérie) : pousser un espace de travail avec git ws-save expose refs/ws/<actor>/<id> via un worker léger en périphérie. Ce worker résout refs/trunk/main et 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.
  2. Pull Requests GitHub éphémères (git ws-pr) : pour une revue ligne par ligne sur GitHub, git ws-pr pousse le commit snapshot racine ($(git rev-list --max-parents=0 HEAD)) vers refs/heads/base/<id> et la pointe de l'espace de travail vers refs/heads/review/<id>. Il ouvre ensuite une Pull Request GitHub comparant review/<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 vers refs/heads/land/<id>, exécute le pipeline d'atterrissage en mémoire, et supprime automatiquement refs/heads/base/<id> et refs/heads/review/<id>.
  3. 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=1 et exécute git update-index --index-info suivi de git 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 via git 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 comme main, ce qui préserve les jalons de release pour les dépôts qui déploient depuis une branche production séparée. Les atterrissages du monorepo se synchronisent avec la branche main du miroir, et les commits automatisés de métadonnées ou de traduction sur main se resynchronisent vers refs/trunk/main via webhook. La promotion de main vers production reste 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.