GoSuda

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

By Lemon Mint views ...

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

  1. blob : le contenu brut en octets d'un fichier unique.
  2. tree : une liste de répertoire qui associe des noms de fichiers et des permissions à des empreintes de blob ou de tree enfants.
  3. commit : un petit en-tête textuel (généralement de 200 à 300 octets) pointant vers une empreinte de tree racine unique, zéro ou plusieurs empreintes de commit parents, 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 à 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 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 :

  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 une récupération sans blobs. Git télécharge les métadonnées tree de refs/trunk/main (généralement quelques dizaines de kilo-octets) et aucun 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. À cet instant précis, Git ne demande à la demande que 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.

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 :

  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 la hiérarchie complète du monorepo dans le répertoire de travail en quelques millisecondes, supprimant le besoin 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 auprès du point de terminaison CAS et le met en cache sur le stockage NVMe local.
  3. 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-save ne hache que les chemins modifiés de cette superposition, écrit les objets blob et tree mis à jour, puis pousse le commit résultant vers refs/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/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. Aucun fichier n'est écrit sur le système de fichiers du runner.
  • Enfin, git push --atomic avec --force-with-lease met à jour refs/trunk/main, ajoute un commit linéaire à refs/trunk/history et supprime le déclencheur d'intégration refs/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é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/*Interne privéProduction 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/*, 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_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 à un motif de nom de fichier.
  • Invariant anti-falsification : les fichiers OWNERS existants 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 modifiant OWNERS dans le même commit d'espace de travail.
  • Exemption pour le 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 : 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 :

  1. 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 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 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.
  2. Pull Requests GitHub éphémères (git ws-pr) Pour une revue ligne par ligne sur GitHub, git ws-pr pousse le commit d'instantané 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 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 vers refs/heads/land/<id>, exécute le pipeline d'intégration en mémoire et supprime automatiquement refs/heads/base/<id> et refs/heads/review/<id>.
  3. 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=1 et exécute git update-index --index-info, suivi de git 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 via git 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 que main, ce qui préserve les validations de publication pour les dépôts déployés depuis une branche production distincte. Les intégrations 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 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.