Sous-modules, subtree et LFS#

Certains projets ne vivent pas en isolation. Il arrive fréquemment qu’un dépôt dépende de code maintenu dans un autre dépôt — une bibliothèque partagée entre plusieurs applications, un framework commun, ou des fichiers de configuration centralisés. Par ailleurs, de nombreux projets doivent gérer des fichiers binaires volumineux (images haute résolution, modèles de machine learning, jeux de données) que Git, conçu pour le texte, gère mal nativement.

Git propose plusieurs mécanismes pour répondre à ces besoins : les sous-modules (submodule) permettent d’imbriquer un dépôt dans un autre, les subtrees fusionnent le code externe directement dans votre historique, et Git LFS déporte les fichiers volumineux vers un serveur dédié. Enfin, l’approche monorepo consiste à regrouper tous les projets dans un seul dépôt. Ce chapitre explore chacune de ces stratégies, leurs avantages, leurs inconvénients, et les situations où elles s’appliquent le mieux.

Git submodule#

Définition 67 (Sous-module (submodule))

Un sous-module est un dépôt Git imbriqué à l’intérieur d’un autre dépôt Git (le dépôt parent). Le dépôt parent ne stocke pas le contenu du sous-module : il enregistre uniquement un pointeur vers un commit spécifique du dépôt externe, ainsi que l’URL de ce dépôt. Ce pointeur est matérialisé par une entrée spéciale dans l’arbre Git et par un fichier .gitmodules à la racine du projet, qui associe chaque sous-module à son URL et son chemin local.

Pour expérimenter les sous-modules, préparons un environnement de démonstration avec un dépôt bare simulant un dépôt distant de bibliothèque, et un dépôt principal qui l’intégrera comme sous-module.

%%bash
cd /tmp && rm -rf demo-sub demo-lib demo-lib-work
git init --bare demo-lib
Dépôt Git vide initialisé dans /tmp/demo-lib/
%%bash
cd /tmp && git -c protocol.file.allow=always clone demo-lib demo-lib-work
cd /tmp/demo-lib-work
git config user.email "demo@example.com" && git config user.name "Demo"
echo "# Bibliothèque partagée" > lib.txt
echo "function_utile() { return 42; }" >> lib.txt
git add . && git commit -m "Lib v1"
git push origin main
Clonage dans 'demo-lib-work'...
avertissement : Vous semblez avoir cloné un dépôt vide.
fait.
[main (commit racine) 27e9504] Lib v1
 1 file changed, 2 insertions(+)
 create mode 100644 lib.txt
To /tmp/demo-lib
 * [new branch]      main -> main

Créons maintenant le dépôt principal et ajoutons-y le sous-module :

%%bash
cd /tmp && rm -rf demo-sub && mkdir demo-sub && cd demo-sub && git init
git config user.email "demo@example.com" && git config user.name "Demo"
echo "# Projet principal" > README.md
git add README.md && git commit -m "Initial commit"
Dépôt Git vide initialisé dans /tmp/demo-sub/.git/
[main (commit racine) fd2d640] Initial commit
 1 file changed, 1 insertion(+)
 create mode 100644 RE
ADME.md

Ajouter un sous-module#

La commande git submodule add enregistre un dépôt externe comme sous-module :

%%bash
cd /tmp/demo-sub
git -c protocol.file.allow=always submodule add /tmp/demo-lib lib/external
git status
echo ""
echo "=== Contenu de .gitmodules ==="
cat .gitmodules
Clonage dans '/tmp/demo-sub/lib/external'...
fait.
Sur la branche main
Modifications qui seront validées :
  (utilisez "git restore --staged <fichier>
..." pour désindexer)
	nouveau fichier : .gitmodules
	nouveau fichier : lib/external
=== Contenu de .gitmodules ===
[submodule "lib/external"]
	path = lib/external
	url = /tmp/demo-lib

Git a cloné le dépôt de la bibliothèque dans lib/external et a créé un fichier .gitmodules contenant les métadonnées du sous-module (URL et chemin local).

%%bash
cd /tmp/demo-sub
git add .gitmodules lib/external
git commit -m "Ajout du sous-module lib/external"
[main 88f4efa] Ajout du sous-module lib/external
 2 files changed, 4 insertions(+)
 create mode 1006
44 .gitmodules
 create mode 160000 lib/external

Vérifions ce que Git enregistre exactement pour le sous-module. L’entrée dans l’arbre n’est pas un blob ordinaire : c’est un commit référencé (mode 160000) :

%%bash
cd /tmp/demo-sub
git ls-tree HEAD lib/
160000 commit 27e9504a8983f60bb1350682ceb64938b66b908a	lib/external

Cloner un dépôt avec des sous-modules#

Lorsqu’un collaborateur clone le dépôt parent, les sous-modules ne sont pas automatiquement téléchargés. Il faut les initialiser et les mettre à jour explicitement :

%%bash
cd /tmp && rm -rf demo-sub-clone
git -c protocol.file.allow=always clone demo-sub demo-sub-clone
echo "=== Contenu de lib/external après clone ==="
ls -la /tmp/demo-sub-clone/lib/external/
Clonage dans 'demo-sub-clone'...
fait.
=== Contenu de lib/external après clone ===
total 0
drwxr-xr-x 2 loc loc 40 15 mars  21:25 .
drwxr-xr-x 3 loc loc 60 15 mars  21:25 ..

Le répertoire existe mais il est vide. Pour récupérer le contenu du sous-module :

%%bash
cd /tmp/demo-sub-clone
git submodule init
git -c protocol.file.allow=always submodule update
echo "=== Contenu après init + update ==="
cat lib/external/lib.txt
Sous-module 'lib/external' (/tmp/demo-lib) enregistré pour le chemin 'lib/external'
Clonage dans '/tmp/demo-sub-clone/lib/external'...
fait.
Chemin de sous-module 'lib/external' : '27e9504a8983f60bb1350682ceb64938b66b908a' extrait
=== Contenu après init + update ===
# Bibliothèque partagée
function_utile() { return 42; }

L’alternative la plus pratique est de cloner directement avec l’option --recurse-submodules, qui initialise et télécharge tous les sous-modules en une seule commande :

git clone --recurse-submodules <url>

Mettre à jour un sous-module#

Supposons que la bibliothèque évolue. Un nouveau commit est poussé dans le dépôt de la bibliothèque :

%%bash
cd /tmp/demo-lib-work
echo "function_nouvelle() { return 99; }" >> lib.txt
git add . && git commit -m "Lib v2 : nouvelle fonction"
git push origin main
[main 9a35b19] Lib v2 : nouvelle fonction
 1 file changed, 1 insertion(+)
To /tmp/demo-lib
   27e9504..9a35b19  main -> main

Pour récupérer cette mise à jour dans le dépôt parent, on utilise git submodule update --remote :

%%bash
cd /tmp/demo-sub
git -c protocol.file.allow=always submodule update --remote lib/external
echo "=== Contenu mis à jour ==="
cat lib/external/lib.txt
Depuis /tmp/demo-lib
   27e9504..9a35b19  main       -> origin/main
Chemin de sous-module 'lib/external' : '9a35b19e1bc2c81500248ffb6857579cbf0f707c' extrait
=== Contenu mis à jour ===
# Bibliothèque partagée
function_utile() { return 42; }
function_nouvelle() { return 99; }
%%bash
cd /tmp/demo-sub
git add lib/external
git commit -m "Mise à jour du sous-module lib/external vers Lib v2"
git log --oneline
[main 328c1bb] Mise à jour du sous-module lib/external vers Lib v2
 1 file changed, 1 insertion(+),
 1 deletion(-)
328c1bb Mise à jour du sous-module lib/external vers Lib v2
88f4efa Ajout du sous-module lib/extern
al
fd2d640 Initial commit

Remarque 62

Les sous-modules épinglent un commit exact, pas une branche. Lorsque vous exécutez git submodule update (sans --remote), Git restaure précisément le commit enregistré dans le dépôt parent, même si le dépôt du sous-module a avancé depuis. Pour mettre à jour vers le dernier commit de la branche suivie, il faut explicitement utiliser --remote, puis committer le nouveau pointeur dans le dépôt parent. Oublier cette étape est l’une des erreurs les plus fréquentes.

Remarque 63

Les sous-modules sont puissants mais présentent une courbe d’apprentissage abrupte. Les pièges courants incluent : oublier d’exécuter git submodule init && git submodule update après un clone, pousser le dépôt parent sans avoir préalablement poussé les modifications du sous-module (les collaborateurs verront alors un pointeur vers un commit inexistant), et travailler dans le sous-module en étant en état detached HEAD sans s’en rendre compte. Malgré ces difficultés, les sous-modules restent la solution canonique de Git pour gérer les dépendances inter-dépôts.

Git subtree#

Définition 68 (Subtree (sous-arbre))

Un subtree est une approche alternative aux sous-modules pour intégrer du code externe. Au lieu de maintenir un pointeur vers un autre dépôt, git subtree fusionne directement le contenu d’un dépôt externe dans un sous-répertoire de votre dépôt. Le code importé fait partie intégrante de votre historique : il n’y a pas de fichier .gitmodules, pas de commande d’initialisation spéciale. Quiconque clone votre dépôt obtient immediatement tout le code, y compris celui du sous-arbre.

Ajouter un subtree#

La commande git subtree add importe un dépôt dans un sous-répertoire. L’option --squash condense tout l’historique du dépôt externe en un seul commit de fusion :

%%bash
cd /tmp && rm -rf demo-subtree && mkdir demo-subtree && cd demo-subtree && git init
git config user.email "demo@example.com" && git config user.name "Demo"
echo "# Projet avec subtree" > README.md
git add README.md && git commit -m "Initial commit"
git -c protocol.file.allow=always subtree add --prefix=lib/external /tmp/demo-lib main --squash
Dépôt Git vide initialisé dans /tmp/demo-subtree/.git/
[main (commit racine) 6f96e1a] Initial commit
 1 file changed, 1 insertion(+)
 create mode 100644 RE
ADME.md
git fetch /tmp/demo-lib main
Depuis /tmp/demo-lib
 * branch            main       -> FETCH_HEAD
Added dir 'lib/external'
%%bash
cd /tmp/demo-subtree
echo "=== Structure du projet ==="
find lib/ -type f
echo ""
echo "=== Historique ==="
git log --oneline
=== Structure du projet ===
lib/external/lib.txt
=== Historique ===
2159d5f Merge commit '450bf13a52934c3da5188fbe40439b5d5e5f0b90' as 'lib/external'
6f96e1a Initial co
mmit
450bf13 Squashed 'lib/external/' content from commit 9a35b19

Contrairement aux sous-modules, un clone de ce dépôt contiendra directement le code de la bibliothèque, sans aucune étape supplémentaire.

Mettre à jour un subtree#

Pour récupérer les nouvelles modifications du dépôt externe, on utilise git subtree pull :

%%bash
cd /tmp/demo-subtree
git -c protocol.file.allow=always subtree pull --prefix=lib/external /tmp/demo-lib main --squash
Depuis /tmp/demo-lib
 * branch            main       -> FETCH_HEAD
Subtree is already at commit 9a35b19e1bc2c81500248ffb6857579cbf0f707c.

Avantages et inconvénients par rapport aux sous-modules#

Les subtrees présentent plusieurs avantages :

  • Simplicité pour les consommateurs : aucune commande init ou update nécessaire après le clone.

  • Historique autonome : tout est dans un seul dépòt, ce qui facilite les opérations comme git bisect ou git log.

  • Pas de fichier de métadonnées : pas de .gitmodules à maintenir.

En revanche :

  • Difficulté à contribuer en amont : pousser des modifications vers le dépôt d’origine (git subtree push) est possible mais complexe et peu intuitif.

  • Historique potentiellement volumineux : sans --squash, l’historique complet du dépôt externe est fusionné dans le votre, ce qui peut alourdir le dépôt.

Remarque 64

En pratique, les subtrees sont souvent préférés pour les dépendances stables que l’on met rarement à jour (une bibliothèque tierce, un ensemble de scripts utilitaires). Les sous-modules conviennent mieux aux dépendances activement developpées en parallèle, ou l’on souhaite suivre précisement les évolutions et contribuer facilement dans les deux sens.

Git LFS (Large File Storage)#

Définition 69 (Git LFS (Large File Storage))

Git LFS est une extension de Git qui gère les fichiers binaires volumineux (images, vidéos, jeux de données, modèles de machine learning, fichiers PSD, etc.) en les stockant sur un serveur LFS dédié plutôt que dans le dépôt Git lui-même. Dans le dépôt, ces fichiers sont remplacés par de légers fichiers pointeurs contenant un hash et des métadonnées. Lors d’un checkout ou d’un clone, Git LFS télécharge automatiquement les fichiers réels depuis le serveur LFS et remplace les pointeurs par le contenu véritable.

Pourquoi LFS est nécessaire#

Git stocke chaque version de chaque fichier sous forme de blob dans son répertoire .git/objects. Pour des fichiers texte, cela fonctionne remarquablement bien grace à la compression delta. Mais pour des fichiers binaires, chaque modification — même minime — produit un nouveau blob de taille comparable à l’original. Un fichier PSD de 100 Mo modifié 10 fois occupe ainsi environ 1 Go dans l’historique, et cette taille est permanente : même en supprimant le fichier, les anciennes versions restent dans l’historique.

LFS résout ce problème en stockant les fichiers volumineux hors du dépôt Git. Seuls les pointeurs (quelques octets) transitent dans l’historique.

Installation et configuration#

L’installation de Git LFS se fait une seule fois par machine :

git lfs install

Ensuite, dans chaque dépôt, on declare les extensions de fichiers à suivre avec git lfs track :

git lfs track "*.psd"
git lfs track "*.zip"
git lfs track "*.bin"
git lfs track "datasets/**"

Cette commande écrit les règles dans le fichier .gitattributes, qui doit être committé dans le dépôt pour que tous les collaborateurs bénéficient du suivi LFS :

# Contenu typique de .gitattributes après git lfs track
*.psd filter=lfs diff=lfs merge=lfs -text
*.zip filter=lfs diff=lfs merge=lfs -text
*.bin filter=lfs diff=lfs merge=lfs -text
datasets/** filter=lfs diff=lfs merge=lfs -text

Commandes essentielles#

Commande

Description

git lfs install

Initialiser LFS sur la machine

git lfs track "<pattern>"

Déclarer un patron de fichiers à suivre

git lfs untrack "<pattern>"

Retirer un patron du suivi LFS

git lfs ls-files

Lister les fichiers actuellement gérés par LFS

git lfs status

Afficher l’état des fichiers LFS (en attente de push, etc.)

git lfs pull

Télécharger les fichiers LFS manquants

git lfs migrate

Migrer des fichiers existants vers LFS rétroactivement

Fonctionnement interne#

Le mécanisme repose sur les filtres Git (clean et smudge) configurés dans .gitattributes :

  1. A l’ajout (git add) : le filtre clean remplace le contenu binaire par un petit fichier pointeur et stocke le fichier réel dans un cache LFS local.

  2. Au push : les fichiers réels sont envoyés au serveur LFS (séparé du serveur Git, mais souvent co-hébergé par GitHub, GitLab ou Bitbucket).

  3. Au checkout/clone : le filtre smudge détecte les pointeurs LFS et télécharge les fichiers réels depuis le serveur, remplacant les pointeurs par le contenu véritable.

Remarque 65

Sans LFS, les fichiers binaires gonflent le dépôt de manière permanente et irréversible : chaque version d’un fichier binaire est stockée intégralement dans l’historique. Même un git rm ne libère pas l’espace, car les anciens commits conservent les blobs. LFS résout ce problème mais nécessite un serveur compatible. Les principaux hébergeurs (GitHub, GitLab, Bitbucket) proposent tous le support LFS, avec des quotas de stockage et de bande passante variables selon les offres.

Monorepos#

Définition 70 (Monorepo)

Un monorepo (pour monolithic repository) est un dépôt unique contenant le code de plusieurs projets, packages ou services distincts. Plutôt que de répartir chaque composant dans son propre dépôt (approche polyrepo), toute la base de code est centralisée. Des entreprises comme Google, Meta et Microsoft utilisent des monorepos contenant des milliards de lignes de code. A plus petite échelle, de nombreux projets open source adoptent cette structure (Babel, React, Next.js).

Avantages du monorepo#

  • Modifications atomiques : une seule pull request peut modifier simultanément une bibliothèque et les applications qui l’utilisent, garantissant la cohérence.

  • Partage de code facilité : pas besoin de publier une bibliothèque interne sur un registre de packages pour la réutiliser.

  • CI/CD unifié : un seul pipeline de build et de tests pour tous les projets.

  • Refactoring à grande echelle : renommer une interface ou modifier une API peut se faire en un seul commit, avec la certitude que tous les usages sont mis à jour.

Inconvénients et défis#

  • Outillage nécessaire : sans outils adaptés, le clone, le build et les tests deviennent très lents à mesure que le dépòt grandit.

  • Permissions granulaires : Git ne permet pas nativement de restreindre l’accès à certains sous-répertoires.

  • Taille du dépôt : l’historique peut devenir volumineux, surtout si le projet inclut des fichiers binaires.

Sparse checkout : ne cloner que l’essentiel#

Pour travailler dans un monorepo sans télécharger l’intégralité du code, Git propose le sparse checkout. Cette fonctionnalité permet de ne matérialiser dans le répertoire de travail que les sous-répertoires qui vous intéressent :

%%bash
cd /tmp && rm -rf demo-monorepo && mkdir demo-monorepo && cd demo-monorepo && git init
git config user.email "demo@example.com" && git config user.name "Demo"

# Simuler une structure de monorepo
mkdir -p packages/frontend packages/backend packages/shared
echo "console.log('frontend');" > packages/frontend/app.js
echo "print('backend')" > packages/backend/server.py
echo "SHARED_CONFIG = True" > packages/shared/config.py
echo "# Monorepo" > README.md
git add . && git commit -m "Structure du monorepo"
Dépôt Git vide initialisé dans /tmp/demo-monorepo/.git/
[main (commit racine) 4f05b10] Structure du monorepo
 4 files changed, 4 insertions(+)
 create mode 
100644 README.md
 create mode 100644 packages/backend/server.py
 create mode 100644 packages/fronten
d/app.js
 create mode 100644 packages/shared/config.py
%%bash
cd /tmp && rm -rf demo-monorepo-sparse
git -c protocol.file.allow=always clone --no-checkout demo-monorepo demo-monorepo-sparse
cd demo-monorepo-sparse
git sparse-checkout init --cone
git sparse-checkout set packages/frontend packages/shared
git checkout main
echo "=== Fichiers présents ==="
find . -not -path './.git/*' -type f | sort
Clonage dans 'demo-monorepo-sparse'...
fait.
Déjà sur 'main'
Votre branche est à jour avec 'origin/main'.
=== Fichiers présents ===
./packages/frontend/app.js
./packages/shared/config.py
./README.md

Seuls les fichiers de packages/frontend et packages/shared sont matérialisés. Le répertoire packages/backend n’apparait pas, bien qu’il reste dans l’historique Git. On peut modifier la sélection à tout moment avec git sparse-checkout set.

Remarque 66

Les monorepos fonctionnent mieux avec des outils spécialisés pour orchestrer les builds et les tests. Parmi les plus populaires : Nx et Turborepo pour l’écosystème JavaScript/TypeScript, Bazel (utilisé en interne par Google) pour les projets multilangage, et Pants pour Python. Git seul peut gérer un monorepo, mais ces outils ajoutent la détection automatique des composants affectés par un changement, le cache de build distribué, et l’exécution parallèle des tâches.

Résumé#

Le tableau suivant récapitule les quatre approches présentées dans ce chapitre, avec leurs cas d’usage typiques :

Approche

Principe

Cas d’usage

Avantages

Inconvénients

Submodule

Pointeur vers un commit d’un dépôt externe

Dépendance activement développée, code partagé entre projets

Suivi précis des versions, contribution bidirectionnelle

Complexité d’usage, commandes supplémentaires au clone

Subtree

Fusion du code externe dans le dépôt

Dépendance stable, rarement mise à jour

Simplicité au clone, pas de métadonnées spéciales

Difficulté à contribuer en amont, historique potentiellement lourd

Git LFS

Pointeurs légers remplaçant les fichiers binaires

Images, vidéos, datasets, modèles ML

Dépôt léger, historique propre

Nécessite un serveur LFS compatible, quotas

Monorepo

Tous les projets dans un seul dépôt

Organisation multi-projets, code fortement couplé

Modifications atomiques, partage facilité

Outillage nécessaire, taille du dépôt

Remarque 67

Ces approches ne sont pas mutuellement exclusives. Un monorepo peut utiliser Git LFS pour ses fichiers binaires. Un projet classique peut combiner des sous-modules pour ses dépendances internes et des subtrees pour des bibliothèques tierces. Le choix dépend de la taille de l’équipe, de la fréquence des mises à jour des dépendances, et de la tolérance à la complexité opérationnelle. Dans le doute, commencez par l’approche la plus simple (subtree ou copie directe) et migrez vers une solution plus élaborée lorsque le besoin s’en fait sentir.