Git Attitude

La gestion de sources qui fait du bien

Comprendre et maîtriser les submodules Git

| Commentaires

Si vous avez déjà utilisé les submodules, vous en êtes certainement revenus avec quelques cicatrices, en jurant (mais un peu tard) qu’on ne vous y reprendrait plus… Les submodules ont en effet l’art de nous faire nous arracher les cheveux, avec leurs pièges omniprésents à la moindre manipulation. Pourtant, ils ne sont pas dénués de mérites, il faut juste savoir comment les prendre.

Dans cet article, nous allons les explorer en détail, en commençant par vérifier qu’ils sont la bonne solution à votre problème, puis en réalisant l’ensemble des manipulations courantes pas à pas, ensemble, pour en illustrer les bonnes pratiques.

(English version of this article here)

Les submodules, comme les subtrees, visent à exploiter le code d’un dépôt tiers quelque part au sein de l’arborescence de votre propre dépôt. L’objectif est généralement de bénéficier d’une maintenance centralisée du code tiers à travers tout un tas de dépôts conteneurs, sans avoir à se taper du copier-coller malhabile et peu fiable.

Afin de simplifier la suite du texte, nous appellerons « module » ce code tiers, présent quelque part dans l’arborescence des dépôts conteneurs. Pour le code du projet utilisant le module au sein de son arborescence, on parlera de « conteneur ».

Ne vous trompez pas de solution

Il existe des cas de figure dans lesquels cette présence physique du module au sein du code conteneur est impérative, dictée par la technologie à l’œuvre dans le conteneur. Par exemple, les thèmes et plugins WordPress, Prestashop, etc. sont souvent de facto installés par leur simple présence à des endroits convenus de l’arborescence du projet, et c’est là leur seule « installation » possible.

Dans de tels cas, recourir aux submodules (ou aux subtrees) est sans doute la bonne solution, si tant est que vous deviez en effet versionner votre code et collaborer avec des tiers autour de celui-ci (ou que vous deviez le déployer ailleurs que sur votre machine) ; pour un cas strictement local et non versionné, des liens symboliques suffisent probablement, mais ce n’est pas le contexte de cet article.

En revanche, dès lors que la technologie employée par le conteneur permet une notion de packaging et de gestion formelle de dépendances, vous devriez absolument utiliser celle-ci à la place : elle vous fournit une gestion correctement découpée, sans effets de bords ou pièges similaires à ceux qui jonchent les submodules, et vous permet qui plus est de bénéficier de facilités de type versions sémantiques (semver) pour vos dépendances.

Pour rappel, voici les principaux langages et leurs mécanismes de gestion de dépendances / référentiels de paquets/modules :

Langage Outil / Référentiel
Clojure Clojars
Erlang Hex
Go GoDoc
Haskell Hackage
Java Maven Central
JavaScript npm, Bower
.NET nuget
Perl CPAN
PHP Composer / Packagist / Pear
Python PyPI
Ruby Bundler / Rubygems
Rust Crates

Sérieux, si vous pouvez plutôt gérer vos interdépendances de code en packagant proprement vos modules « centralisés » et en déclarant vos dépendances via les bons outils, faites-le. Vraiment. Je vous jure. Ça vous épargnera un monde de galères (et ça ne nécessite pas forcément de passer par un référentiel public, ce qui permet d’y recourir pour du code privé aussi).

Mais si vous avez l’obligation d’incorporer le code directement au sein de votre source conteneur, alors il vous reste le choix entre submodules et subtrees.

Submodules ou subtrees ?

En général, les subtrees sont meilleurs. Voilà qui vous vend admirablement cet article, hein ? Le fait est que les submodules et les subtrees sont radicalement différents, et pour tout dire opposés, en termes de concepts et de fonctionnement.

La plupart des gens recourent aux submodules pour une variété de raisons ; ceux-ci sont présents depuis longtemps en Git, disposent d’une commande dédiée (git submodule) et d’une doc détaillée, et leur fonctionnement est souvent analogue aux externals de Subversion, ce qui les rend faussement familiers. La mise en place d’un submodule est également très simple (un bête git submodule add), surtout en comparaison à la mise en place d’un subtree. On ne réalise qu’après que des pièges potentiels nous attendent tous les jours, pour tout le monde.

C’est justement parce que les submodules font souffrir tant de Gitteurs que nous avons choisi de couvrir ceux-ci d’abord, et les subtrees ensuite (dans notre prochain gros article de fond).

Toujours est-il que parfois, les submodules constituent le bon choix. C’est notamment le cas lorsque la codebase est massive et qu’on ne veut pas forcément tout récupérer à chaque fois, en particulier dans des projets tentaculaires. On recourt alors aux submodules pour ne pas exiger la récupération de pans entiers du code pour tout le monde. Divers projets open-source massifs y ont recours précisément pour cette raison (ou en raison d’une forte modularisation qui n’est pas nativement prise en charge par l’écosystème du langage principal) : voyez par exemple Chromium, qui utilise massivement les submodules.

Il est également très préférable que le code du submodule soit indépendant des particularités du conteneur (ou en tout cas, se base sur une configuration externe pour gérer ces particularités), car ce code étant centralisé dans un seul dépôt distant (celui du submodule), il est partagé par tous les conteneurs. Et contourner cette limitation en commençant à pondre dans le submodule des branches spécifiques à tel ou tel conteneur revient à ouvrir la boîte de Pandore : c’est un couplage abusif, contraire aux principes de modularisation et d’encapsulation, et ça reviendra à coup sûr vous mordre la cheville plus tard, vous pouvez me croire.

Fondamentaux des submodules

Petit rappel de terminologie d’abord : en Git, un dépôt est local. La version distante, qui ne sert qu’à l’archivage, à la collaboration et au partage, est appelée un remote. Dans la suite de cet article, quand vous lisez « dépôt » ou « dépôt Git », il s’agit d’un dépôt local interactif, c’est-à-dire d’un répertoire de travail (working directory) avec un .git à sa base.

Les submodules reposent sur l’imbrication de dépôts : vous avez des dépôts… dans des dépôts. Le module a son propre dépôt, quelque part dans le répertoire de travail de son dépôt conteneur.

Notez que dans la pratique, depuis Git 1.7.8, les submodules utilisent comme .git un simple fichier texte, contenant une ligne gitdir: indiquant le chemin du dépôt réel, désormais situé dans le .git/modules du conteneur. Ça permet surtout d’avoir dans le conteneur des branches sans le submodule à l’intérieur, sans avoir à dégager tout le dépôt du submodule à chaque fois.

"La relation entre le fichier .git du submodule et le stockage effectif du dépôt imbriqué au niveau conteneur"

En tous les cas, le conteneur et le submodule agissent vraiment comme deux dépôts distincts : chacun son historique (donc log), chacun son statut, son diff, etc. Attention donc au répertoire courant pour votre prompt et vos commandes : selon que vous êtes dans le submodule ou ailleurs dans le conteneur, le contexte et l’impact des commandes n’a rien à voir !

Enfin, le commit exploité par le submodule dans le conteneur est référencé par son identifiant (SHA1), et non par une référence volatile (genre nom de branche). Et donc oui, un submodule ne se met pas automatiquement à jour, ce qui est une bénédiction en termes de fiabilité, de maintenance et d’assurance qualité (demandez donc aux Subversioniens qui ont recours aux externals le nombre de fois qu’ils indiquent --ignore-externals dans leurs commandes pour éviter une mise à jour intempestive…).

Du coup, la majeure partie du temps, le submodule est en tête détachée dans ses conteneurs, puisqu’on le cale en faisant un checkout sur le SHA1 du commit en question (peu importe qu’il s’agisse ou non d’une tête de branche à ce moment-là).

Une pléthore de pièges

Les submodules, c’est un peu la facilité la première fois contre les pièges tout le reste du temps : si un simple git submodule add suffit à en mettre en place un, tous les contributeurs au dépôt devront être vigilants pour toute la durée de vie du code, afin de ne pas rencontrer de problèmes.

"iOS auto-corrige parfois « submodules » en « sobmodules », ce qui ne manque pas de pertinence…"

Évidemment, comme approche, c’est dangereux. Parce que la vigilance constante, ça ne marche jamais. Ce n’est pas pour rien qu’on les surnomme parfois « sobmodules » (en anglais, « to sob » signifie « sangloter »), comme en atteste même parfois l’auto-correction de l’iPhone :

Au fil de cet article, notamment lors du pas-à-pas qui va suivre, nous verrons dans le détail quels sont les pièges, et dans quelle mesure la majorité d’entre eux peuvent être neutralisés, ou à tout le moins minimisés, grâce à des réglages de configuration ou des options de ligne de commande.

Mais pour l’instant, listons-les rapidement :

  • Tout ajout ou changement d’URL d’un submodule, toute mise à jour de commit référencé par l’un d’eux, nécessite une action de mise à jour explicite par les collaborateurs ;
  • L’oubli de cette action explicite peut entraîner une régression silencieuse du commit référencé par le submodule ;
  • Les commandes telles que status et diff n’affichent par défaut que très peu d’informations sur les états et modifications des submodules ;
  • La récupération des données depuis le remote (c’est à dire le fetch), par défaut, ne prend pas les submodules en compte ;
  • Les cycles de vie étant séparés, une mise à jour locale au conteneur du code d’un submodule nécessite deux commits et deux pushes distincts ;
  • Les têtes des submodules étant généralement détachées, toute mise à jour locale nécessite diverses actions préparatoires pour éviter un commit orphelin ;
  • La suppression d’un submodule nécessite plusieurs commandes et manipulations, dont certaines manuelles et non assistées.

Bref, il va falloir être rigoureux, les amis…

Les submodules pas à pas

Nous allons à présent explorer chaque étape de l’utilisation de submodules dans un projet collaboratif, en prenant soin de bien mettre en lumière les comportements par défaut, leurs pièges, et le cas échéant les améliorations disponibles.

Afin que vous puissiez pratiquer ces exemples par vous-mêmes, je vous ai préparé une série de dépôts d’exemple avec leurs « remotes » qui sont en fait juste des répertoires. Vous pouvez décompresser l’archive où vous voulez et ouvrir un shell (ou un Git Bash, pour ceux sous Windows) dans le dossier git-subs ainsi obtenu :

Téléchargez les dépôts d’exemple

Vous y trouverez trois dossiers :

  • main joue le rôle du dépôt conteneur, local à un premier collaborateur
  • plugin joue le rôle du dépôt central de maintenance d’un module
  • remotes contient les filesystem remotes pour ces dépôts

Dans les exemples de commandes ci-après, le prompt indique toujours le dépôt dans lequel on se trouve.

Ajouter un submodule

Commençons par ajouter le plugin comme submodule à notre conteneur, situé dans main. Le plugin a une structure assez simple :

1
2
3
4
5
.
├── README.md
├── lib
│   └── index.js
└── plugin-config.json

Plaçons-nous donc dans mainet utilisons la commande git submodule add. Celle-ci prend une URL de remote et un sous-répertoire dans lequel « instancier » le submodule.

Ici, comme nous utilisons des chemins plutôt que des URLs, nous tombons sur une petite particularité : les chemins relatifs vont être interprétés relativement à notre remote principal, et non au répertoire courant. C’est super bizarre, ce n’est décrit nulle part, mais c’est ce que j’ai toujours constaté. Du coup, au lieu de faire ../remotes/plugin on fait ../plugin.

1
2
3
4
main (master u=) $ git submodule add ../plugin vendor/plugins/demo
Cloning into 'vendor/plugins/demo'...
done.
main (master + u=) $

Cet ajout a entraîné une modification de notre configuration locale :

1
2
3
4
main (master + u=) $ cat .git/config
[submodule "vendor/plugins/demo"]
  url = ../remotes/plugin

Mais aussi deux ajouts au stage :

1
2
3
4
5
6
7
8
main (master + u=) $ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

  new file:   .gitmodules
  new file:   vendor/plugins/demo

Tiens ?! Qu’est-ce donc que ce fichier .gitmodules ? Examinons-le :

1
2
3
4
main (master + u=) $ cat .gitmodules
[submodule "vendor/plugins/demo"]
  path = vendor/plugins/demo
  url = ../plugin

Voilà qui ressemble furieusement à notre configuration locale… Alors, pourquoi ce doublon ? Justement parce que notre configuration est… locale. Nos collaborateurs ne la voient pas (et c’est normal), du coup il leur faut un mécanisme pour récupérer les définitions de submodules à mettre en place dans leurs propres dépôts. C’est le rôle du .gitmodules, qui sera exploité plus tard par la commande git submodule init, comme nous le verrons tout à l’heure.

Tant qu’on est sur le statut, remarquez combien celui-ci est minimaliste quant à notre submodule : il se contente d’un très générique new file au lieu de nous informer un peu plus sur ce qui se passe à l’intérieur. Car en effet, notre submodule a bien injecté son contenu dans le sous-répertoire :

1
2
3
4
5
6
7
8
9
└── vendor
    └── plugins
        └── demo
            ├── .git
            ├── README.md
            ├── lib
            │   └── index.js
            └── plugin-config.json

Le statut, comme les logs et les diffs, se limite à notre propre dépôt actif (le conteneur), pas aux submodules, qui sont en quelque sorte des dépôts imbriqués. C’est souvent gênant (il est très, très facile de louper une régression en se limitant à ça), aussi je vous invite à configurer une bonne fois pour toutes vos statuts pour fouiller un peu plus dans les submodules :

1
git config --global status.submoduleSummary true

Et ainsi :

1
2
3
4
5
6
7
8
9
10
11
12
13
main (master + u=) $ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

  new file:   .gitmodules
  new file:   vendor/plugins/demo

Submodule changes to be committed:

* vendor/plugins/demo 0000000...fe64799 (3):
  > Fix repo name for main project companion demo repo

Aaaah, voilà qui est nettement mieux. Le statut étend ses informations de base pour ajouter que le submodule présent dans vendor/plugins/demo s’est pris 3 commits de plus qu’avant (vu qu’on vient de le créer, il n’a donc que 3 commits), le dernier étant une introduction (chevron droit >) dont la première ligne de message était « Fix repo name… ».

Histoire de bien voir qu’on a ici affaire à deux dépôts distincts, plaçons-nous dans le sous-répertoire du submodule :

1
2
3
4
5
main (master + u=) $ cd vendor/plugins/demo
demo (master u=) $ git status
On branch master
Your branch is up-to-date with 'origin/master'.
nothing to commit, working directory clean

Le dépôt actif a changé, car un nouveau .git prend le relais : dans le répertoire courant (demo, le dossier du submodule), un .git existe en effet, mais remarquez qu’il ne s’agit pas de l’habituel répertoire : c’est un fichier. Examinons l’intérieur :

1
2
demo (master u=) $ cat .git
gitdir: ../../../.git/modules/vendor/plugins/demo

Depuis déjà plusieurs versions, Git ne place plus les dépôts de submodules dans leurs dossiers propres, mais les centralise dans son .git conteneur (dans .git/modules) et se contente d’une référence gitdir dans les sous-dossiers.

La raison de cette approche est simple : elle permet d’avoir, dans le dépôt conteneur, des branches ne contenant pas le submodule, sans qu’il soit nécessaire pour cela de purger le dépôt correspondant du working directory.

Il est naturellement possible de choisir une branche précise, voire un commit précis au sein du remote avec l’option -b (comme toujours, ce sera par défaut master). Remarquez au passage qu’on n’est pas ici en tête détachée, comme ce sera le cas plus tard : on est sur la branche par défaut, master. Ce serait le cas pour toute autre branche ; il aurait fallu qu’on indique un SHA1 avec -b à la commande git submodule add pour être d’entrée de jeu en tête détachée.

Enfin, s’il est aussi possible à l’ajout de nommer le submodule, en pratique ça n’a que peu d’utilité : le nom logique est alors présent dans les configurations et peut faciliter la mise à jour de l’URL du remote du submodule, mais l’ensemble des sous-commandes de git submodule utilisent quant à elles le chemin (le sous-répertoire) pour identifier le submodule à manipuler.

Revenons au dépôt conteneur, puis finalisons notre ajout de submodule et envoyons ça au remote :

1
2
3
demo (master u=) $ cd -
main (master + u=) $ git commit -m "Ajout submodule plugin demo"
main (master u+1) $ git push

Récupérer un dépôt exploitant des submodules

Afin d’illustrer les problématiques relatives à la collaboration sur un dépôt exploitant des submodules, nous allons cloner le dépôt conteneur dans un dossier collegue (comme ça le dossier nous dira tout de suite quel rôle on joue dans ce petit exercice de dédoublement de personnalité).

1
2
3
4
5
6
main (master u=) $ cd ..
git-subs $ git clone remotes/main collegue
Cloning into 'collegue'...
done.
git-subs $ cd collegue
collegue (master u=) $

La première chose à remarquer, c’est que notre submodule n’est pas présent dans le working directory, seul son dossier de base est là :

1
2
3
vendor
└── plugins
    └── demo

À quoi est-ce dû ? Simplement au fait qu’à ce stade, notre nouveau dépôt (collegue) n’est pas encore au courant qu’il a un submodule : l’info ne figure pas dans sa configuration locale (vous pouvez vérifier son .git/config). Il va falloir retranscrire le contenu du .gitmodules dans .git/config, du coup, ce qui est précisément le rôle de git submodule init :

1
2
collegue (master u=) $ git submodule init
Submodule 'vendor/plugins/demo' (/tmp/git-subs/remotes/plugin) registered for path 'vendor/plugins/demo'

Notre .git/config est désormais au courant des submodules. Cependant, le nôtre n’a toujours pas été récupéré depuis son remote ni, à plus forte raison, retranscrit dans le working directory. Et pourtant, notre statut est vide (clean) !

Il faut en effet récupérer puis retranscrire « à la main » les commits visés par l’info de submodule stockée dans la base. Ce n’est pas spécifique au clone initial, ça devra être fait, manuellement, à chaque pull ultérieur. On y reviendra, d’autant qu’au clone, justement, ça peut être « automatisé » en partie.

1
2
3
4
collegue (master u=) $ git submodule update
Cloning into 'vendor/plugins/demo'...
done.
Submodule path 'vendor/plugins/demo': checked out 'fe6479991d214f4d95ac2ae959d7252a866e01a3'

Dans la pratique, quand on manipule des dépôts à submodules, on regroupe généralement ces deux commandes en une :

1
collegue (master u=) $ git submodule update --init

Il est tout de même dommage que Git ne fasse pas tout ça pour nous. Imaginez, comme c’est le cas dans de (très) gros projets open-source, que nos submodules aient eux-mêmes des submodules, et ainsi de suite… Ce serait vite un cauchemar.

Git fournit en effet une option de ligne de commande pour clone qui va automatiquement réaliser un git submodule update --init juste après le clone, et ceci en profondeur : --recursive, la bien nommée.

Retentons toute l’opération avec :

1
2
3
4
5
6
7
8
9
collegue (master u=) $ cd -
git-subs $ rm -fr collegue
git-subs $ git clone --recursive remotes/main collegue
Cloning into 'collegue'...
done.
Submodule 'vendor/plugins/demo' (/tmp/git-subs/remotes/plugin) registered for path 'vendor/plugins/demo'
Cloning into 'vendor/plugins/demo'...
done.
Submodule path 'vendor/plugins/demo': checked out 'fe6479991d214f4d95ac2ae959d7252a866e01a3'

Voilà qui est mieux ! Remarquez bien qu’on est en tête détachée sur le submodule, cette fois-ci (comme presque tout le temps) :

1
2
git-subs $ cd collegue/vendor/plugins/demo
demo ((master)) $

Notez la double parenthèse dans mon prompt, au lieu d’une simple. Si vous n’avez pas un prompt configuré pour afficher les têtes détachées en mode describe (dans le script de prompt fourni par Git, c’est géré par la variable d’environnement GIT_PS1_DESCRIBE_STYLE=branch), vous verrez plutôt :

1
demo ((fe64799...)) $

Mais en tous les cas, status nous confirme l’état :

1
2
3
demo ((master)) $ git status
HEAD detached at fe64799
nothing to commit, working directory clean

Récupérer une mise à jour au sein d’un submodule

À présent que nous avons notre propre dépôt (main) et celui de notre « collègue » (collegue) mis en place pour collaborer, mettons-nous dans la peau d’une troisième personne : celle qui maintient le plugin. Allez hop, on se met dedans :

1
2
3
4
5
collegue (master u=) $ cd ../plugin
plugin (master u=) $ git log --oneline
fe64799 Fix repo name for main project companion demo repo
89d24ad Main files (incl. subdir) for plugin, to populate its tree.
cc88751 Initial commit

Et maintenant, faisons deux pseudo-commits et publions-les sur le remote :

1
2
3
4
5
6
7
8
9
10
plugin (master u=) $ date > fake-work
plugin (master % u=) $ git add fake-work
plugin (master + u=) $ git commit -m "Pseudo-commit n°1"
[master e6f5bb6] Pseudo-commit n°1
 1 file changed, 1 insertion(+)
 create mode 100644 fake-work

plugin (master u+1) $ date >> fake-work
plugin (master * u+1) $ git commit -am "Pseudo-commit n°2"
plugin (master u+2) $ git push

Enfin, remettons notre casquette « premier développeur » :

1
2
plugin (master u=) $ cd ../main
main (master u=) $

Imaginons à présent que nous souhaitions récupérer ces deux commits dans notre submodule. Pour cela, il nous faut mettre à jour le dépôt local qui lui est dédié, en commençant donc par nous y placer pour que ce soit bien le dépôt actif.

Par ailleurs, je vous invite à éviter de vous reposer sur pull pour ce type de mise à jour. En effet, cette commande nécessite, pour impacter le working directory, que vous soyez sur une branche, ce qui n’est généralement pas le cas (vous êtes le plus souvent en tête détachée au sein d’un submodule). Il faudrait donc commencer par un checkout sur la branche appropriée. Mais surtout, il peut très bien arriver qu’entre la publication du dernier commit souhaité et le moment de la récupération dans le submodule, la branche concernée ait évolué plus loin, et dans un tel cas, un pull va incorporer plus de travail que vous ne le souhaitez.

Du coup, je préconise plutôt de découper le processus à la main : d’abord un git fetch pour récupérer l’ensemble des nouveautés du remote dans le cache local du dépôt, puis un log de vérification et un checkout du SHA1 souhaité. Outre un contrôle accru, cette séquence a l’avantage de ne pas se soucier de votre état actuel (branche active ou tête détachée).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
main (master u=) $ cd vendor/plugins/demo

demo (master u=) $ git fetch
remote: Counting objects: 6, done.
remote: Compressing objects: 100% (5/5), done.
remote: Total 6 (delta 1), reused 0 (delta 0)
Unpacking objects: 100% (6/6), done.
From /private/tmp/git-subs/main/../remotes/plugin
   fe64799..0e90143  master     -> origin/master

demo (master u-2) $ git log --oneline origin/master -10
0e90143 Pseudo-commit n°2
e6f5bb6 Pseudo-commit n°1
fe64799 Fix repo name for main project companion demo repo
89d24ad Main files (incl. subdir) for plugin, to populate its tree.
cc88751 Initial commit

OK, donc on est bons, aucun commit de plus. Mais quoi qu’il en soit, calons-nous sur celui qui nous intéresse (évidemment, le SHA1 n’est pas le même chez vous) :

1
demo (master u-2) $ git checkout -q 0e90143

(Le -q est juste là pour nous éviter la tartine d’avertissements de Git sur le fait d’être en tête détachée. En temps normal ceux-ci sont une bonne idée, mais sur ce coup on sait ce qu’on fait.)

À présent que notre submodule est à jour, cette évolution se voit dans le statut du dépôt conteneur :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
demo ((remotes/origin/HEAD)) $ cd -
main (master * u=) $ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

  modified:   vendor/plugins/demo (new commits)

Submodules changed but not updated:

* vendor/plugins/demo fe64799...0e90143 (2):
  > Pseudo-commit n°2
  > Pseudo-commit n°1

no changes added to commit (use "git add" and/or "git commit -a")

Dans la partie « classique » du diff, on voit un changement de type new commits, ce qui indique un recalage du commit référencé. Une autre possibilité (qui peut se cumuler) serait new contents, qui indiquerait qu’on a des modifs locales en cours sur le working directory du submodule.

La partie basse, activée par notre réglage status.submoduleSummary = true de tout à l’heure, nous indique explicitement les commits introduits (puisqu’on a des chevrons à droite ‘>’ à côté d’eux) depuis notre dernier commit conteneur touchant au submodule.

Dans la série « les comportements pourris par défaut », le fonctionnement de git diff laisse aussi à désirer :

1
2
3
4
5
6
7
8
main (master * u=) $ git diff
diff --git i/vendor/plugins/demo w/vendor/plugins/demo
index fe64799..0e90143 160000
--- i/vendor/plugins/demo
+++ w/vendor/plugins/demo
@@ -1 +1 @@
-Subproject commit fe6479991d214f4d95ac2ae959d7252a866e01a3
+Subproject commit 0e9014309fe6c663e806c9f91297a592ee04cb6c

OK, super, mais encore ? Il existe une option de ligne de commande qui permet d’en savoir plus :

1
2
3
4
main (master * u=) $ git diff --submodule=log
Submodule vendor/plugins/demo fe64799..0e90143:
  > Pseudo-commit n°2
  > Pseudo-commit n°1

Bon, là, vu qu’il n’y a aucune modif locale à part celle du submodule… Vous remarquez qu’il s’agit presque exactement de l’affichage bas de git status lorsqu’il se préoccupe des submodules.

Devoir taper manuellement ce genre d’option (laquelle ne dispose d’ailleurs pas, pour le moment, de la complétion fournie par Git) est assez pénible. Heureusement pour nous, un réglage de configuration existe :

1
2
3
4
5
git config --global diff.submodule log
main (master * u=) $ git diff
Submodule vendor/plugins/demo fe64799..0e90143:
  > Pseudo-commit n°2
  > Pseudo-commit n°1

Il nous reste à faire le commit conteneur qui entérine la mise à jour du submodule. Si vous avez eu besoin de toucher au code conteneur pour qu’il marche avec la nouvelle version du submodule, committez-le aussi, naturellement. En revanche, évitez de mélanger des changements relatifs aux submodules à d’autres qui ne concernent que le code conteneur : en séparant bien les deux sujets, vous facilitez les conversions ultérieures du dépôt vers d’autres types d’approches de partage de code.

Comme nous allons juste après examiner la récupération de mises à jour aux submodules par nos collègues, on pushe dans la foulée (ce qui n’est pas une bonne pratique de façon générale).

1
2
main (master * u=) $ git commit -am "Calage du submodule sur le PC2"
main (master u+1) $ git push

Mettre à jour un dépôt exploitant des submodules

Hop ! Casquette « collègue ».

Durant notre boulot, nous sommes amenés à récupérer les derniers commits partagés via le remote :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
collegue (master u=) $ git pull
remote: Counting objects: 4, done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 4 (delta 1), reused 0 (delta 0)
Unpacking objects: 100% (4/4), done.
From /tmp/git-subs/remotes/main
   c995ed0..ac96c22  master     -> origin/master
Fetching submodule vendor/plugins/demo
remote: Counting objects: 6, done.
remote: Compressing objects: 100% (5/5), done.
remote: Total 6 (delta 1), reused 0 (delta 0)
Unpacking objects: 100% (6/6), done.
From /tmp/git-subs/remotes/plugin
   fe64799..0e90143  master     -> origin/master
Successfully rebased and updated refs/heads/master.
collegue (master * u=) $

Notez que toute la deuxième partie a trait au submodule : elle démarre par « Fetching submodule… ».

Ce comportement provient de la valeur par défaut du réglage de configuration fetch.recurseSubmodules : depuis Git 1.7.5, elle est à on-demand : si le projet conteneur a mis à jour des références de commits pour des submodules, ceux-ci sont récupérés par le fetch (lequel fait partie du pull).

Cependant, et c’est là un point critique : Git auto-fetche, mais n’auto-update pas. Votre cache local est à jour pour le dépôt du submodule, mais celui-ci n’a pas bougé dans votre working directory. Au moins, vous pouvez fermer le laptop et prendre le train pour ensuite gérer le recalage sans avoir besoin de connectivité au remote. Notez tout de même que c’est limité aux submodules déjà connus localement : les nouveaux submodules éventuels, qu’on n’a pas encore en local, ne sont pas auto-récupérés.

D’ailleurs le prompt, avec son *, indique bien des modifs locales, car le WD n’est pas synchro avec l’index, qui lui connaît le nouveau commit référencé pour le submodule. Voyez plutôt le statut :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
collegue (master * u=) $ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

  modified:   vendor/plugins/demo (new commits)

Submodules changed but not updated:

* vendor/plugins/demo 0e90143...fe64799 (2):
  < Pseudo-commit n°2
  < Pseudo-commit n°1

no changes added to commit (use "git add" and/or "git commit -a")

Vous avez remarqué les chevrons à gauche (<) ? Git voit que le WD actuel n’a pas ces deux commits, par rapport à ce qu’attend le projet conteneur.

Voilà l’énorme danger : si on ne met pas à jour le working directory du submodule, au prochain commit conteneur on l’aura fait régresser. C’est un piège de tout premier ordre.

Il est donc impératif de finaliser la mise à jour :

1
2
collegue (master * u=) $ git submodule update
Submodule path 'vendor/plugins/demo': checked out '0e9014309fe6c663e806c9f91297a592ee04cb6c'

D’ailleurs, quitte à prendre des habitudes génériques, il est probablement préférable de faire un git submodule update --init --recursive, histoire d’initialiser ou de mettre à jour en profondeur tous les submodules, connus ou nouveaux.

Il y a un autre cas à la marge : si l’URL du remote d’un submodule a changé depuis la dernière fois (un des collaborateurs a mis à jour le .gitmodules, mais il vous appartient de refléter ça dans votre configuration locale). Dans un tel cas, avant le git submodule update, il vous faudra faire un git submodule sync.

Notez que si, par défaut, le git submodule update se contente de faire un checkout sur tête détachée, il vous est possible de changer ça, si vous le souhaitez, pour systématiquement rebaser vos éventuels commits locaux (voir la prochaine section) par-dessus le nouveau commit référencé pour le submodule. Ça se ferait en définissant le réglage update de votre submodule à rebase, au sein de votre configuration locale (.git/config) du dépôt conteneur.

Et non, il n’y a pas de réglage de configuration, ou même d’option de ligne de commande pour pull, qui automatise cette manip’ après la mise à jour du working directory conteneur. Pour automatiser ces traitements, il faudra recourir soit à des aliases, soit à des scripts personnalisés, soit à des hooks soigneusement mis en place. Voici un exemple d’alias, ‘spull’, qui automatise les deux :

1
git config --global alias.spull '!git pull && git submodule sync --recursive && git submodule update --init --recursive'

Si vous voulez pouvoir passer des arguments complémentaires à git pull, il faut soit en faire une fonction et l’appeler, soit préférer un script personnalisé. La première approche ressemblerait à ceci :

1
git config --global alias.spull '__git_spull() { git pull "$@" && git submodule sync --recursive && git submodule update --init --recursive; }; __git_spull'

Pas très lisible, hein ? Je préfère la seconde. Imaginons que vous placiez un fichier git-spull dans un répertoire qui fait partie de votre PATH (dans mon cas, j’ai un ~/perso/bin qui sert à ça) :

1
2
3
4
#! /bin/bash
git pull "$@" &&
  git submodule sync --recursive &&
  git submodule update --init --recursive

On lui donne les droits d’exécution :

1
chmod +x git-spull

Et on peut désormais l’utiliser comme on l’aurait fait pour l’alias.

Mettre à jour un submodule au sein d’un projet conteneur

C’est le cas le plus compliqué, et autant que possible, il faut l’éviter, en préférant une maintenance centralisée au sein d’un dépôt dédié, autonome.

Toutefois, il peut arriver qu’un code ne puisse être exploité qu’au sein d’un projet conteneur, notamment pour le mettre au point et le tester ; on pense aux plugins, aux thèmes, etc.

La première chose à bien comprendre, c’est que, vu que vous allez faire un ou plusieurs commits, il faut partir d’une base adaptée : ce sera forcément une pointe de branche. Du coup, il vous appartient de vérifier que les commits de cette branche, que vous n’exploitiez pas forcément tous jusqu’ici, ne viennent pas « casser » votre projet conteneur. Dans le cas contraire, il est tentant de créer une branche dédiée à votre conteneur au sein du dépôt submodule, qui partirait de votre dernier commit référencé. Mais attention : ce chemin mène à un couplage si fort et à des ennuis de propagation de code inter-branches si épais qu’il vaut peut-être mieux envisager directement d’arrêter les submodules pour ce code-là…

Mais admettons, par exemple, que vous puissiez en bonne conscience ajouter à l’actuel master du submodule. Commençons par synchroniser notre état local sur le remote :

1
2
3
4
5
6
7
8
9
10
11
12
collegue (master u=) $ cd vendor/plugins/demo

demo ((remotes/origin/HEAD)) $ git checkout master
Previous HEAD position was 0e90143... Pseudo-commit n°2
Switched to branch 'master'
Your branch is behind 'origin/master' by 2 commits, and can be fast-forwarded.
  (use "git pull" to update your local branch)

demo (master u-2) $ git pull --rebase
First, rewinding head to replay your work on top of it...
Fast-forwarded master to 0e9014309fe6c663e806c9f91297a592ee04cb6c.
demo (master u=) $

Une alternative consiste, dans le dépôt conteneur, à explicitement recaler la branche locale du submodule sur son équivalent remote :

1
2
3
4
5
collegue (master u=) $ git submodule update --remote --rebase -- vendor/plugins/demo

collegue (master u=) $ cd vendor/plugins/demo
demo (master u=) $

Nous pouvons à présent modifier le code, le mettre au point, le tester, etc. Une fois que nous serons prêts, nous pourrons réaliser les deux commits et les deux pushes nécessaires (il est facile, et en pratique trop fréquent, d’en oublier une partie).

Ajoutons simplement du faux travail, et faisons les commits au niveau submodule et conteneur :

1
2
3
4
5
6
7
8
9
demo (master u=) $ date >> fake-work
demo (master * u=) $ git commit -am "Pseudo-commit n°3"
[master 12e3a52] Pseudo-commit n°3
 1 file changed, 1 insertion(+)
demo (master u+1) $ cd ../../..
collegue (master * u=) git commit -am "Utilisation du PC3 sur le submodule"
[master ad9da82] Utilisation du PC3 sur le submodule
 1 file changed, 1 insertion(+), 1 deletion(-)
collegue (master u+1) $

À ce stade, le gros danger, c’est d’oublier de pusher le submodule. On remonte dans le projet conteneur, on committe, et on ne pushe que lui. C’est une erreur facile à faire, surtout en EDI. Du coup, à la récupération par les collègues, c’est la chienlit. Voyez plutôt :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
collegue (master u+1) $ git push
Counting objects: 4, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (4/4), 355 bytes | 0 bytes/s, done.
Total 4 (delta 1), reused 0 (delta 0)
To /tmp/git-subs/remotes/main
   766cd47..ad9da82  master -> master

collegue (master u=) $ cd ../main
main (master u=) $ git pull
remote: Counting objects: 4, done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 4 (delta 1), reused 0 (delta 0)
Unpacking objects: 100% (4/4), done.
From ../remotes/main
   766cd47..ad9da82  master     -> origin/master
Fetching submodule vendor/plugins/demo
Successfully rebased and updated refs/heads/master.

main (master * u=) $

Ici, aucune indication dans les messages de Git qu’il n’a pas pu récupérer auprès du remote du submodule le commit désormais référencé par le conteneur. Le premier indice devient visible avec un statut :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
main (master * u=) $ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

  modified:   vendor/plugins/demo (new commits)

Submodules changed but not updated:

* vendor/plugins/demo 12e3a52...0e90143:
  Warn: vendor/plugins/demo doesn't contain commit 12e3a529698c519b2fab790630f71bd531c45727

no changes added to commit (use "git add" and/or "git commit -a")

Voyez l’avertissement : apparemment, le nouveau commit référencé pour le submodule est introuvable. Et de fait, si on tente la mise à jour du working directory :

1
2
3
main (master * u=) $ git submodule update
fatal: reference is not a tree: 12e3a529698c519b2fab790630f71bd531c45727
Unable to checkout '12e3a529698c519b2fab790630f71bd531c45727' in submodule path 'vendor/plugins/demo'

On voit bien l’importance de penser à faire le push au niveau du submodule, de préférence avant de faire celui au niveau conteneur. Effectuons-le dans le submodule de collegue puis retentons la mise à jour de main :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
main (master * u=) $ cd ../collegue/vendor/plugins/demo
demo (master u+1) $ git push
Counting objects: 3, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 329 bytes | 0 bytes/s, done.
Total 3 (delta 1), reused 0 (delta 0)
To /tmp/git-subs/remotes/plugin
   0e90143..12e3a52  master -> master

demo (master u=) $ cd -
main (master * u=) $ git submodule update
remote: Counting objects: 3, done.
remote: Compressing objects: 100% (3/3), done.
remote: Total 3 (delta 1), reused 0 (delta 0)
Unpacking objects: 100% (3/3), done.
From /private/tmp/git-subs/main/../remotes/plugin
   0e90143..12e3a52  master     -> origin/master
Submodule path 'vendor/plugins/demo': checked out '12e3a529698c519b2fab790630f71bd531c45727'

Remarquez qu’il existe une option de ligne de commande qui vérifiera toute seule si les commits référencés pour les submodules locaux ont besoin d’être pushés, eux aussi, et le fera le cas échéant : git push --recurse-submodules=on-demand. Elle a toutefois besoin d’avoir quelque chose à pusher au niveau conteneur pour se soucier ensuite des submodules. Et qui plus est, il n’existe pas de réglage de configuration qui automatise ce comportement : là aussi, un alias du genre spush peut s’avérer utile :

1
git config --global alias.spush 'push --recurse-submodules=on-demand'

Retirer un submodule

Il existe deux cas de figure lorsqu’on veut « retirer » un submodule :

  • On souhaite juste retirer le working directory (en vue d’une archive, par exemple) et la configuration locale, tout en gardant la possibilité de le restaurer plus tard (donc il persiste dans .gitmodules et .git/modules) ;
  • On souhaite retirer définitivement le submodule de la branche courante.

Temporairement

Le premier cas est géré par git submodule deinit. Par exemple :

1
2
3
4
main (master u=) $ git submodule deinit vendor/plugins/demo
Cleared directory 'vendor/plugins/demo'
Submodule 'vendor/plugins/demo' (../remotes/plugin) unregistered for path 'vendor/plugins/demo'
main (master u=) $

Notez que ça ne change rien au statut. Le submodule n’étant plus connu localement (plus de configuration dédiée dans .git/config), son absence du working directory n’est pas remarquée. Il nous reste le dossier vendor/plugins/demo, mais vide, et on pourrait le retirer sans que ça gêne.

Il faut que le submodule ne contienne pas de modifications locales, sans quoi vous devrez ajouter --force à l’appel.

Toute sous-commande ultérieure de git submodule qui ne referait pas un init ignorera donc ce submodule, vu qu’il n’est pas retranscrit dans la configuration locale. Ça inclue notamment update, foreach et sync.

En revanche, le submodule est toujours défini dans .gitmodules : un init suivi d’un update (ou un update --init) va donc le remettre en place :

1
2
3
4
main (master u=) $ git submodule update --init
Submodule 'vendor/plugins/demo' (../remotes/plugin) registered for path 'vendor/plugins/demo'
Submodule path 'vendor/plugins/demo': checked out '12e3a529698c519b2fab790630f71bd531c45727'
main (master u=) $

Définitivement1

Pour le second cas de figure, lorsque vous voulez vous débarrasser du submodule pour de bon, c’est tout simplement git rm qui va s’en charger, comme ce serait le cas pour n’importe quelle partie de notre dépôt. Il faudra toutefois que le submodule utilise bien un fichier .git (gitfile) et non un répertoire classique, ce qui est le cas à partir de Git 1.7.8, sinon vous devrez gérer ça plus manuellement.

En plus du retrait du dossier qui contient le submodule, le .gitmodules sera mis à jour pour ne plus contenir les infos associées. Voyez plutôt :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
main (master u=) $ git rm vendor/plugins/demo
rm 'vendor/plugins/demo'

main (master + u=) $ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

  modified:   .gitmodules
  deleted:    vendor/plugins/demo

fatal: Not a git repository: 'vendor/plugins/demo/.git'
Submodule changes to be committed:

* vendor/plugins/demo 12e3a52...0000000:

Naturellement, les infos avancées de statut se prennent les pieds dans le tapis, vu que le gitfile a disparu (en fait, le dossier demo entier a dégagé).

1
2
3
4
5
6
7
8
9
10
main (master + u=) $ git ci -m "Retrait du submodule de démo"
[master 31cb27d] Retrait du submodule de démo
 2 files changed, 4 deletions(-)
 delete mode 160000 vendor/plugins/demo

main (master u+1) $ git status
On branch master
Your branch is ahead of 'origin/master' by 1 commit.
  (use "git push" to publish your local commits)
nothing to commit, working directory clean

Ce qui reste curieux, c’est que la configuration locale, elle, porte toujours les traces du submodule, contrairement au nettoyage opéré par un deinit. Du coup, pour un retrait complet, je vous invite à faire les deux, en séquence, pour être bien au carré (ici ça ne marcherait pas, car vous avez déjà mis à jour le .gitmodules) :

1
2
git submodule deinit path/to/module # pour garantir la config locale
git rm path/to/module               # pour le WD et le .gitmodules

Quelle que soit l’approche retenue, le dépôt du submodule, lui, reste bien présent dans .git/modules/vendor/plugins/demo, que vous êtes libre de purger quand bon vous semble.

Si jamais vous devez retirer un submodule initialisé avant Git 1.7.8, et donc avec un dépôt .git directement dans son working directory (au lieu d’un gitfile), c’est plus bourrin : il faut précéder les deux commandes ci-dessus d’une suppression manuelle du dossier du submodule, par exemple rm -fr vendor/plugins/demo, car ces commandes refuseront toujours de supprimer un dépôt.

Récapitulatif des bonnes pratiques (TL;DR)

En configuration

  • diff.submodule = log — pour y voir plus clair dans les diffs conteneurs impliquant des changements de commit référencé sur des submodules.
  • fetch.recurseSubmodules = on-demand — pour être sûrs de pré-récupérer les nouveaux commits référencés par les submodules déjà connus.
  • status.submoduleSummary = true — pour avoir un git status digne de ce nom lorsqu’un submodule change son commit référencé.

En opération

  • Ajout initial : git submodule add <url> <chemin>
  • Clone initial du conteneur : git clone --recursive <url> [<chemin>]
  • Récupérer une mise à jour au sein d’un submodule :
    1. cd path/to/module
    2. git fetch
    3. git checkout -q commit-sha1
    4. cd -
    5. git commit -am "Mise à jour du submodule blah : blah blah"
  • Mise à jour du conteneur :
    1. git pull
    2. git submodule sync --recursive
    3. git submodule update --init --recursive
  • Mise à jour d’un submodule au sein du conteneur
    1. git submodule update --remote --rebase -- path/to/module
    2. cd path/to/module
    3. Boulot local, staging
    4. git commit -am "Mise à jour au submodule central"
    5. git push
    6. cd -
    7. git commit -am "Mise à jour du submodule blah : blah blah"
  • Suppression définitive d’un submodule (post 1.7.8)
    1. git submodule deinit path/to/module
    2. git rm path/to/module
    3. git commit -am "Retrait du submodule blah"

Ce qui reste dans les coins

Commandes Git

  • git submodule foreach permet d’exécuter des commandes libres sur tous les submodules actuellement connus (initialisés), récursivement ou non ; les commandes ont accès à plusieurs variables leur fournissant notamment le chemin du submodule, son commit référencé et la racine du dépôt conteneur. Pratique pour des scripts personnalisés.
  • git submodule status est un affichage de statut dédié aux submodules présents dans le conteneur (voire récursivement), qui nous indique notamment les commits référencés, les écarts locaux éventuels, le statut initialisé ou non, voire des conflits de fusion. Plus rapide qu’une série de vérifs dans le working directory.
  • git submodule summary liste les écarts d’historique entre le dernier commit référencé par la submodule dans le HEAD ou l’index du projet conteneur, et celui effectivement présent dans son working directory. En fait, c’est ce qu’affichent git status ou git diff quand on a pensé à configurer le mode log.
  • git mv sur un dossier de submodule post 1.7.8 (avec un gitfile) fait normalement ce qu’il faut :
    1. Mise à jour du chemin relatif dans le gitfile
    2. Mise à jour du core.worktree dans le dépôt du submodule au sein de .git/modules
    3. Mise à jour du .gitmodules et staging de celui-ci

Options de ligne de commande

  • git diff --ignore-submodules, comme git status --ignore-submodules, suppriment toute info de submodules dans leurs affichages. Contre-productif, à mon sens.

Paramètres de configuration

  • diff.ignoreSubmodules rend permanent le fait de zapper toute info sur les submodules pendant les diffs. Très mauvaise idée selon moi.

Envie d’en savoir plus ?

Notre formation Git Total explore ces thématiques et bien d’autres pour vous donner une compréhension en profondeur de Git, vous transformant en experts en seulement 3 jours, pour un tarif très raisonnable ! Disponible en inter-entreprises tous les 2 mois (hors été) et en intra-entreprises sur demande.

Commentaires

Ils nous font confiance : Kelkoo, MisterGoodDeal, PriceMinister, Blablacar / Comuto, Sarenza, Voyages-SNCF, LeMonde.fr, Fnac DIRECT, 20minutes, Orange, l’OCDE, Cisco, Alcatel-Lucent, Dassault Systèmes, EADS, Atos, Lagardère Interactive, Lesieur, L’Occitane en Provence, Météo France, 4D, Securitas, Digitas, Vivaki, Fullsix, Ekino, TBWA \ Paris, Valtech, Is Cool Entertainment, Open Wide…