Arc 6 Quête A3b

Les Arcanes de la Cité

Scaling Git, sparse checkout et outils monorepo

Tu as compris le choix : la Cité-Monde, un seul atelier géant pour mille artisans. L'Architecte te guide maintenant dans les caves techniques de la Cité, là où les ingénieurs maintiennent les mécanismes qui permettent à tout cela de fonctionner sans s'effondrer.

« Un monorepo naïf devient vite ingérable : clones de plusieurs gigaoctets, builds interminables, CI qui explose. Les arcanes que je vais te montrer sont les outils sans lesquels aucune vraie cité-monde ne tient debout : sparse checkout, partial clone, outils de build monorepo, et un pipeline CI intelligent. »

Si tu n'as pas encore lu la quête stratégique, commence par A3a, La Cité-Monde (Le Choix du Bâtisseur).

Git pour les gros dépôts

Quand un dépôt fait des Go ou des dizaines de Go, les opérations Git classiques deviennent très lentes. Git a développé plusieurs fonctionnalités pour résoudre ce problème.

Partial clone - ne télécharger que le nécessaire

Un git clone classique télécharge tous les objets de l'historique - tous les fichiers, toutes les versions, tous les arbres. Pour un gros dépôt, cela peut prendre des heures.

Le partial clone (clone partiel) permet de ne télécharger que les objets nécessaires. Les objets manquants sont récupérés à la demande, quand tu en as besoin.

# Clone sans les blobs (fichiers) - ne télécharge que les métadonnées
# Les fichiers sont récupérés à la demande quand tu y accèdes
git clone --filter=blob:none https://github.com/example/huge-repo.git

# Clone sans les arbres de sous-répertoires
# Encore plus léger, mais un peu plus lent à l'utilisation
git clone --filter=tree:0 https://github.com/example/huge-repo.git

# Clone filtré par taille - exclure les fichiers de plus de 1 Mo
git clone --filter=blob:limit=1m https://github.com/example/huge-repo.git

Shallow clone - limiter la profondeur d'historique

Le shallow clone (clone superficiel) ne télécharge que les N derniers commits, sans tout l'historique.

# Ne récupérer que le dernier commit
git clone --depth 1 https://github.com/example/huge-repo.git

# Récupérer les 10 derniers commits
git clone --depth 10 https://github.com/example/huge-repo.git

# Récupérer plus d'historique si besoin
git fetch --deepen=50

# Convertir un shallow clone en clone complet
git fetch --unshallow

Quand utiliser quoi ? Le partial clone est préférable pour le développement quotidien (tu as accès à tout l'historique, les fichiers sont chargés à la demande). Le shallow clone est idéal pour la CI/CD (tu n'as besoin que du code actuel, pas de l'historique).

Sparse checkout - ne récupérer que certains dossiers

Dans un monorepo, tu ne travailles probablement que sur un ou deux projets. Le sparse checkout te permet de ne matérialiser que les dossiers qui t'intéressent dans ton répertoire de travail.

# Cloner le dépôt en mode blobless (rapide)
git clone --filter=blob:none https://github.com/example/monorepo.git
cd monorepo

# Activer le sparse checkout en mode cone
git sparse-checkout init --cone

# Ne récupérer que les dossiers dont tu as besoin
git sparse-checkout set apps/frontend packages/shared-utils

# Ton répertoire de travail ne contient maintenant QUE :
# apps/frontend/
# packages/shared-utils/
# (plus les fichiers à la racine)

# Ajouter un dossier supplémentaire
git sparse-checkout add apps/backend-api

# Voir les dossiers inclus
git sparse-checkout list

# Désactiver le sparse checkout (récupérer tout)
git sparse-checkout disable

PowerShell (Windows) :

# Les commandes sont identiques en PowerShell
git clone --filter=blob:none https://github.com/example/monorepo.git
cd monorepo

git sparse-checkout init --cone
git sparse-checkout set apps/frontend packages/shared-utils
git sparse-checkout list
# Pour tout récupérer :
# git sparse-checkout disable

Le mode cone

Le flag --cone active le mode "cône" qui est plus rapide et plus intuitif que le mode classique. En mode cône, tu spécifies des dossiers entiers à inclure, pas des patterns complexes.

# Mode cone (recommandé) - spécifie des dossiers
git sparse-checkout set apps/frontend packages/ui

# Mode classique (ancien) - utilise des patterns gitignore
# Plus flexible mais plus lent et plus complexe
git sparse-checkout init  # (sans --cone)
echo "apps/frontend/**" >> .git/info/sparse-checkout

La combinaison partial clone + sparse checkout est la recette magique pour travailler dans un monorepo sans souffrir. Tu ne télécharges que les métadonnées, et tu ne matérialises que les dossiers dont tu as besoin.

Les outils monorepo

Git seul ne suffit pas pour gérer un monorepo efficacement. Tu as besoin d'outils qui comprennent la structure de ton projet et optimisent les builds et les tests.

Nx (JavaScript / TypeScript)

Nx est un framework de build intelligent pour les monorepos JavaScript/TypeScript. Il comprend les dépendances entre tes projets et ne reconstruit que ce qui a changé.

# Créer un nouveau workspace Nx
npx create-nx-workspace@latest mon-monorepo

# Structure typique d'un workspace Nx
mon-monorepo/
├── apps/
│   ├── frontend/         # Application React/Angular/Vue
│   └── backend/          # API NestJS/Express
├── libs/
│   ├── shared-types/     # Types TypeScript partagés
│   ├── ui-components/    # Composants UI réutilisables
│   └── utils/            # Fonctions utilitaires
├── nx.json               # Configuration Nx
├── package.json
└── tsconfig.base.json

# Construire uniquement ce qui est affecté par les changements
npx nx affected:build
# Note : depuis Nx 17+, la syntaxe est : nx affected -t build

# Tester uniquement ce qui est affecté
npx nx affected:test
# Note : depuis Nx 17+, la syntaxe est : nx affected -t test

# Visualiser le graphe de dépendances
npx nx graph

Les concepts clés de Nx :

  • Affected : Nx sait quels projets sont impactés par un changement. Si tu modifies shared-types, Nx sait que frontend et backend doivent être retestés, mais pas ui-components.
  • Cache : Nx met en cache les résultats des builds et des tests. Si rien n'a changé, le résultat est instantané.
  • Remote cache : le cache peut être partagé entre les développeurs et la CI. Si un collègue a déjà construit le même code, tu récupères son résultat au lieu de reconstruire.

Turborepo

Turborepo (créé par Vercel) est un outil de build pour monorepos JavaScript/TypeScript, plus simple que Nx mais très efficace.

# Créer un monorepo Turborepo
npx create-turbo@latest

# Structure typique
mon-monorepo/
├── apps/
│   ├── web/              # Application Next.js
│   └── docs/             # Site de documentation
├── packages/
│   ├── ui/               # Composants partagés
│   ├── eslint-config/    # Config ESLint partagée
│   └── typescript-config/ # Config TS partagée
├── turbo.json            # Configuration des pipelines
└── package.json

# turbo.json - définir les pipelines
# Note : depuis Turborepo 2.x, la clé "pipeline" est renommée en "tasks"
{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "test": {
      "dependsOn": ["build"]
    },
    "lint": {}
  }
}

# Construire tout (avec cache et parallélisation)
npx turbo build

# Construire uniquement ce qui est affecté par les changements depuis main
npx turbo build --filter=...[origin/main]

Bazel (multi-langage)

Bazel (créé par Google, open source) est le build tool le plus puissant mais aussi le plus complexe. Il supporte n'importe quel langage et fonctionne à l'échelle de Google (des milliards de lignes de code).

# Bazel utilise des fichiers BUILD pour décrire les cibles
# Exemple de fichier BUILD pour un projet Python
load("@rules_python//python:defs.bzl", "py_binary", "py_test")

py_binary(
    name = "server",
    srcs = ["server.py"],
    deps = [
        "//libs/auth:auth_lib",
        "//libs/database:db_lib",
    ],
)

py_test(
    name = "server_test",
    srcs = ["server_test.py"],
    deps = [":server"],
)

# Construire une cible
bazel build //apps/server:server

# Tester une cible
bazel test //apps/server:server_test

# Construire tout ce qui est affecté par les changements
bazel build //... --keep_going

Les concepts clés de Bazel :

  • Herméticité : chaque build est isolé et reproductible. Les mêmes entrées produisent toujours les mêmes sorties.
  • Cache distribué : les résultats de build sont partagés entre tous les développeurs et la CI.
  • Multi-langage : Bazel fonctionne avec Java, Python, Go, C++, Rust, JavaScript, et bien d'autres.
Critère Nx Turborepo Bazel
Langages JS/TS principalement JS/TS Tous
Complexité Moyenne Faible Élevée
Cache distribué Oui (Nx Cloud) Oui (Vercel Remote Cache) Oui (natif)
Affected detection Oui (excellent) Oui (via filter) Oui (natif)
Idéal pour Projets JS/TS moyens à grands Projets JS/TS petits à moyens Très gros projets multi-langages

Gérer les dépendances dans un monorepo

L'un des grands avantages du monorepo, c'est le partage de code. Mais il faut le faire proprement.

Workspaces npm/pnpm/yarn

Les gestionnaires de paquets modernes supportent nativement les workspaces - un mécanisme pour lier les packages locaux entre eux sans passer par un registre npm.

# package.json racine avec workspaces npm
{
  "name": "mon-monorepo",
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*"
  ]
}

# package.json d'une app qui utilise un package local
{
  "name": "frontend",
  "dependencies": {
    "shared-utils": "*",
    "ui-components": "*"
  }
}

# npm résout automatiquement les dépendances locales
# Pas besoin de publier les packages - ils sont liés directement

# Installer toutes les dépendances (racine)
npm install

# Exécuter un script dans un workspace spécifique
npm run build --workspace=apps/frontend

Avec pnpm (recommandé pour les monorepos)

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'

# pnpm est plus rapide et utilise moins d'espace disque que npm
# grâce à son stockage par liens symboliques

# Installer toutes les dépendances
pnpm install

# Exécuter une commande dans tous les packages
pnpm -r build

# Exécuter dans un package spécifique
pnpm --filter frontend build

# Exécuter dans les packages affectés par un changement
pnpm --filter "...[origin/main]" build

CI/CD dans un monorepo

Le plus grand défi d'un monorepo est la CI/CD. Tu ne veux pas reconstruire et retester tout à chaque commit - ce serait trop lent. Il faut détecter ce qui a changé et ne traiter que ça.

Affected detection avec Git

# Voir quels fichiers ont changé depuis la branche main
git diff --name-only origin/main...HEAD

# Exemple de sortie :
# apps/frontend/src/App.tsx
# packages/shared-utils/src/format.ts

# Script pour détecter les packages affectés
# Si un fichier dans apps/frontend/ a changé → tester frontend
# Si un fichier dans packages/shared-utils/ a changé → tester tout ce qui en dépend

GitHub Actions avec affected detection

# .github/workflows/ci.yml
name: CI
on:
  pull_request:
    branches: [main]

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      frontend: ${{ steps.changes.outputs.frontend }}
      backend: ${{ steps.changes.outputs.backend }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: changes
        with:
          filters: |
            frontend:
              - 'apps/frontend/**'
              - 'packages/shared-utils/**'
              - 'packages/ui-components/**'
            backend:
              - 'apps/backend/**'
              - 'packages/shared-utils/**'

  test-frontend:
    needs: detect-changes
    if: ${{ needs.detect-changes.outputs.frontend == 'true' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run test --workspace=apps/frontend

  test-backend:
    needs: detect-changes
    if: ${{ needs.detect-changes.outputs.backend == 'true' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run test --workspace=apps/backend

Avec Nx

# Nx rend l'affected detection triviale
# Dans le workflow CI :
- run: npx nx affected:test --base=origin/main
- run: npx nx affected:build --base=origin/main
- run: npx nx affected:lint --base=origin/main
# Note : depuis Nx 17+, la syntaxe est :
# nx affected -t test --base=origin/main
# nx affected -t build --base=origin/main
# nx affected -t lint --base=origin/main

# Nx comprend le graphe de dépendances et ne teste
# que les projets réellement impactés par les changements

La clé d'un monorepo performant, c'est de ne jamais tout reconstruire. L'affected detection et le cache sont les deux piliers qui rendent un monorepo viable à grande échelle.

Exercice pratique - Mini-monorepo avec sparse checkout

Crée un mini-monorepo, puis utilise le sparse checkout pour ne travailler que sur une partie du code. Tu vas simuler le workflow d'un développeur dans un grand monorepo.

Étape 1 - Créer le monorepo

# Créer le monorepo
mkdir cite-monde-monorepo
cd cite-monde-monorepo
git init -b main

# Créer la structure
mkdir -p apps/frontend/src
mkdir -p apps/backend/src
mkdir -p apps/mobile/src
mkdir -p packages/shared-utils/src
mkdir -p packages/ui-components/src
mkdir -p docs
# Créer le monorepo
mkdir cite-monde-monorepo
cd cite-monde-monorepo
git init -b main

# Créer la structure
New-Item -ItemType Directory -Force -Path apps/frontend/src
New-Item -ItemType Directory -Force -Path apps/backend/src
New-Item -ItemType Directory -Force -Path apps/mobile/src
New-Item -ItemType Directory -Force -Path packages/shared-utils/src
New-Item -ItemType Directory -Force -Path packages/ui-components/src
New-Item -ItemType Directory -Force -Path docs

Étape 2 - Ajouter du contenu dans chaque projet

# Frontend
cat > apps/frontend/src/index.js << 'EOF'
// Application frontend de la Cité-Monde
import { formatName } from '../../../packages/shared-utils/src/format.js';
console.log("Frontend chargé !");
console.log(formatName("Aventurier"));
EOF

# Backend
cat > apps/backend/src/server.js << 'EOF'
// API backend de la Cité-Monde
const http = require('http');
const server = http.createServer((req, res) => {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end('API de la Cité-Monde');
});
server.listen(3000);
console.log("Serveur backend démarré sur le port 3000");
EOF

# Mobile
cat > apps/mobile/src/app.js << 'EOF'
// Application mobile de la Cité-Monde
console.log("App mobile initialisée !");
EOF

# Shared utils
cat > packages/shared-utils/src/format.js << 'EOF'
// Fonctions utilitaires partagées
export function formatName(name) {
  return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase();
}
export function formatDate(date) {
  return new Date(date).toLocaleDateString('fr-FR');
}
EOF

# UI components
cat > packages/ui-components/src/button.js << 'EOF'
// Composant bouton partagé
export function createButton(label, onClick) {
  const btn = document.createElement('button');
  btn.textContent = label;
  btn.addEventListener('click', onClick);
  return btn;
}
EOF

# Documentation
cat > docs/architecture.md << 'EOF'
# Architecture de la Cité-Monde

Ce monorepo contient :
- 3 applications (frontend, backend, mobile)
- 2 packages partagés (shared-utils, ui-components)
EOF

# README racine
cat > README.md << 'EOF'
# Cité-Monde Monorepo
Un mini-monorepo d'exercice pour les Chroniques du Versionneur.
EOF
# Frontend
@"
// Application frontend de la Cité-Monde
import { formatName } from '../../../packages/shared-utils/src/format.js';
console.log("Frontend chargé !");
console.log(formatName("Aventurier"));
"@ | Set-Content apps/frontend/src/index.js

# Backend
@"
// API backend de la Cité-Monde
const http = require('http');
const server = http.createServer((req, res) => {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end('API de la Cité-Monde');
});
server.listen(3000);
console.log("Serveur backend démarré sur le port 3000");
"@ | Set-Content apps/backend/src/server.js

# Mobile
@"
// Application mobile de la Cité-Monde
console.log("App mobile initialisée !");
"@ | Set-Content apps/mobile/src/app.js

# Shared utils
@"
// Fonctions utilitaires partagées
export function formatName(name) {
  return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase();
}
export function formatDate(date) {
  return new Date(date).toLocaleDateString('fr-FR');
}
"@ | Set-Content packages/shared-utils/src/format.js

# UI components
@"
// Composant bouton partagé
export function createButton(label, onClick) {
  const btn = document.createElement('button');
  btn.textContent = label;
  btn.addEventListener('click', onClick);
  return btn;
}
"@ | Set-Content packages/ui-components/src/button.js

# Documentation
@"
# Architecture de la Cité-Monde

Ce monorepo contient :
- 3 applications (frontend, backend, mobile)
- 2 packages partagés (shared-utils, ui-components)
"@ | Set-Content docs/architecture.md

# README racine
"# Cité-Monde Monorepo`nUn mini-monorepo d'exercice pour les Chroniques du Versionneur." | Set-Content README.md

Étape 3 - Commiter tout le contenu

git add .
git commit -m "Initialiser le monorepo de la Cité-Monde"
git add .
git commit -m "Initialiser le monorepo de la Cité-Monde"

Étape 4 - Utiliser le sparse checkout

Maintenant, simulons un développeur frontend qui ne veut travailler que sur son application et les packages partagés.

# Activer le sparse checkout
git sparse-checkout init --cone

# Ne récupérer que le frontend et les packages partagés
git sparse-checkout set apps/frontend packages/shared-utils packages/ui-components

# Vérifier ce qui est visible
ls apps/
# → frontend/  (pas de backend/ ni mobile/)

ls packages/
# → shared-utils/  ui-components/

# La documentation n'est pas visible non plus
ls docs/ 2>/dev/null || echo "docs/ n'est pas dans le sparse checkout"

# Voir les patterns actifs
git sparse-checkout list
# Activer le sparse checkout
git sparse-checkout init --cone

# Ne récupérer que le frontend et les packages partagés
git sparse-checkout set apps/frontend packages/shared-utils packages/ui-components

# Vérifier ce qui est visible
Get-ChildItem apps/
# frontend/  (pas de backend/ ni mobile/)

Get-ChildItem packages/
# shared-utils/  ui-components/

# Voir les patterns actifs
git sparse-checkout list

Étape 5 - Travailler normalement

# Modifier un fichier dans le frontend
echo "// Nouvelle fonctionnalité" >> apps/frontend/src/index.js

# Git fonctionne normalement
git add apps/frontend/src/index.js
git commit -m "Ajouter une fonctionnalité au frontend"

# L'historique complet est toujours accessible
git log --oneline

# Si tu as besoin du backend temporairement :
git sparse-checkout add apps/backend
ls apps/
# → backend/  frontend/

# Pour tout désactiver et revenir au mode normal :
git sparse-checkout disable
# Modifier un fichier dans le frontend
Add-Content apps/frontend/src/index.js "// Nouvelle fonctionnalité"

# Git fonctionne normalement
git add apps/frontend/src/index.js
git commit -m "Ajouter une fonctionnalité au frontend"

# L'historique complet est toujours accessible
git log --oneline

# Si tu as besoin du backend temporairement :
git sparse-checkout add apps/backend

# Pour tout désactiver et revenir au mode normal :
# git sparse-checkout disable

Le sparse checkout te permet de travailler dans un monorepo comme si tu étais dans un polyrepo, tout en gardant l'avantage d'un historique et de commits atomiques partagés.

Récapitulatif des commandes

Commande Description
git clone --filter=blob:none Partial clone sans les blobs (téléchargement à la demande)
git clone --filter=tree:0 Partial clone sans les arbres (encore plus léger)
git clone --depth N Shallow clone avec N commits d'historique
git fetch --unshallow Convertir un shallow clone en clone complet
git sparse-checkout init --cone Activer le sparse checkout en mode cône
git sparse-checkout set <dossiers> Définir les dossiers à matérialiser
git sparse-checkout add <dossier> Ajouter un dossier au sparse checkout
git sparse-checkout list Voir les dossiers inclus
git sparse-checkout disable Désactiver le sparse checkout (tout récupérer)
git maintenance start Activer la maintenance automatique du dépôt
git diff --name-only main...HEAD Voir les fichiers changés depuis main (affected detection)
npx nx affected:test Tester les projets affectés (Nx)
npx turbo build --filter=...[main] Construire les packages affectés (Turborepo)

L'Architecte de la Cité te raccompagne jusqu'aux portes de la métropole. Le soleil se couche sur les toits infinis de l'atelier-monde.

« Tu as vu les deux voies. Le village et la cité. Le polyrepo et le monorepo. Chacun a sa place dans le monde. Le village est simple, autonome, rapide à construire. La cité est puissante, cohérente, mais elle demande des routes, des règles, des architectes. »

« Ne choisis pas la cité par vanité. Ne choisis pas le village par paresse. Choisis en fonction de ce que tu construis et avec qui. Et surtout, souviens-toi : un monorepo sans outillage, c'est un chaos organisé. Investis dans les fondations, ou ne construis pas de cité. »

Il te serre la main et retourne à ses plans. La Cité-Monde continue de grandir, pierre après pierre, commit après commit.