---
id: 773766eac9e6ff3c46bd686d78539aed
author: Lemon Mint
title: 'Repenser le Git Monorepo : une architecture CAS-Native pour les humains et les Coding Agents'
description: Comment construire un monorepo sans démon, natif CAS, sur Git standard, à l'aide de snapshots de trunk sans parent, de références d'espaces de travail isolés, d'arbres de fusion en mémoire, de fichiers OWNERS hiérarchiques et d'une synchronisation de miroirs en O(delta).
language: fr
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
---

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 :

```
refs/heads/main               Plan de contrôle uniquement (~20 Ko : règles, .gitconfig, configuration de synchronisation, scripts)
refs/trunk/main               Commit snapshot sans parent pointant vers le dernier arbre racine unifié
refs/trunk/history            Chaîne de commits liés par leurs parents partageant exactement le SHA-1 de l'arbre racine
refs/ws/<actor>/<id>          Instantanés d'espace de travail isolés (changelists) par développeur ou agent
refs/ws/cl/<change-id>/<tip>  Patchsets immuables : chaque pointe d'un change envoyé en revue
refs/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 :

```bash
# Commit snapshot sans parent pour des checkouts instantanés de l'espace de travail en profondeur 1
SNAP=$(git commit-tree "$NEW_TREE" -m "$RAW_MSG")

# Commit lié à son parent pour un git log et un git blame linéaires
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"
```

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 :

```bash
# 1. Cloner le plan de contrôle de 20 Ko avec filtrage des blobs et mode sparse activé
git clone --filter=blob:none --sparse https://github.com/example-org/monorepo.git
cd monorepo

# 2. Charger les alias du dépôt et attacher refs/trunk/*
git config include.path ../.gitconfig
git trunk-init

# 3. Déclarer les répertoires de domaine nécessaires à cette tâche
git sparse-checkout set net/relay ipc/codec

# 4. Créer un espace de travail isolé enraciné dans le dernier instantané du trunk
git 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+ :

```bash
# 1. Identifier le commit snapshot racine à partir duquel l'espace de travail a été embranché
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. Calculer l'arbre racine fusionné en mémoire, sans extraction de répertoire de travail
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
```

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é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_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.
