Comprendre et maîtriser les subtrees Git
Par Delicious Insights • Publié le 30 janv. 2015

Cet article est également disponible en anglais.

Il y a un mois, nous explorions en détail les submodules ; nous vous avions alors dit que notre prochain article de fond serait sur les subtrees, qui constituent l'alternative.

Comme précédemment, nous allons donc explorer le sujet en détail, en réalisant l'ensemble des manipulations courantes pas à pas, ensemble, pour en illustrer les bonnes pratiques.

Fondamentaux des subtrees

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.

Avec les subtrees, pas de dépôts imbriqués : on n'a qu'un dépôt, le conteneur, comme pour une codebase classique. Donc un seul cycle de vie, et aucune particularité à gérer pour les commandes et workflows habituels. Elle est pas belle la vie ?

Trois approches distinctes : choisissez !

Il existe trois manières de manipuler vos subtrees ; même s'il reste possible de mélanger certaines approches, d'une façon générale je vous recommande de partir avec une et de vous y tenir, pour éviter les problèmes.

« À la main »

Git ne fournit pas de commande native subtree, contrairement à ce qui se passe pour les submodules. Les subtrees ne sont pas tant une fonctionnalité qu'un concept, une approche de gestion fournie par Git. Ils reposent sur l'utilisation adéquate de commandes de porcelaine classiques (notamment merge et cherry-pick) et d'une commande de plomberie[^1] (read-tree).

Cette approche marche partout, elle est assez simple, finalement, mais nécessite une bonne compréhension du fond pour exécuter certaines démarches avancées. Nous l'utiliserons comme point de base, car en nous offrant le contrôle fin sur les opérations, elle nous donne toute liberté sur la gestion de nos historiques, branches et graphes…

Le script de contrib : git subtree

En juin 2012, dans la version 1.7.11, Git s'est mis à inclure un script de contrib tiers, git-subtree.sh, dans la distro officielle ; ils ont qui plus est calé leur installation binaire pour qu’une copie soit présente en git-subtree dans les binaires mis à disposition par l'install, de sorte que git subtree était utilisable, ressemblant à une commande « native ».

Ça s'arrête toutefois là, car la « documentation » n'est pas une page man, et n'est donc pas installée comme telle. Les appels usuels (man git-subtree, git help subtree ou git subtree --help) sont inopérants. Un git subtree tout court nous donne just le synopsis, sans plus d'informations. Seul le fichier texte lié en début de paragraphe fournit les infos, et il est planqué au fin fond du dossier contrib/ de votre installation Git.

Ce script, que j'appellerai par souci de concision git subtree dans la suite de cet article, a le mérite d'être robuste et de proposer des syntaxes familières (add, pull, push…) par-dessus des opérations parfois complexes. Il offre toutefois quelques opérations (genre split) et notions (genre --ignore-joins/--rejoin) qui sont assez déroutantes au premier abord, sans parler de son approche très personnelle du --squash

Mais surtout, il maintient une branche dédiée au subtree, qui va fusionner dans la branche courante à chaque git subtree pull ou git subtree merge. Ce qui veut dire qu'elle va nous pourrir le graphe de l'historique en permanence, et ça, moi, j'ai énormément de mal.

Autre inconvénient : elle ne permet pas de choisir les commits locaux sur le subtree qu'on veut backporter avec un git subtree push : c'est tout ou rien. Alors qu'un intérêt des subtrees c'est justement de pouvoir faire des modifs locales custom autant que des fixes à portée générale.

Toutefois, elle a le mérite d'être là depuis longtemps et d'être donc abondamment testée (et notamment battle-tested), ce qui n'est pas à négliger.

git-subrepo

Énervé depuis longtemps par cette alternative désagréable entre les commandes manuelles d'un côté, qui rebuttent pas mal de monde, et l'approche de git subtree, qui pourrit le graphe et requiert des CLIs à rallonge, j'ai fini par pondre mon propre script : git-stree. Celui-ci marchait plutôt pas mal, mais je n’ai jamais trouvé assez de temps pour le pousser aussi loin que je l'aurais voulu, et lui éviter quelques cas gênants à la marge.

Un peu plus tard, mon attention a été attirée par le projet git-subrepo, mené par l’incroyable Ingy döt Net. Ça ne couvre pas forcément toutes les fonctionnalités dont je rêverais, mais c'est ultra solide, ultra testé, et marche bien sur toutes les plates-formes. Pas de cas à la marge, du vrai boulot carré, et activement maintenu. J’ai fini par déprécier stree en faveur de subrepo.

Je vous laisse examiner la liste de ses avantages.

Les subtrees pas à pas

Nous allons à présent explorer chaque étape de l'utilisation de subtrees dans un projet collaboratif, avec le déroulé pour chacune des trois approches.

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 l’approche qui est illustrée et le dépôt dans lequel on se trouve.

Si vous voulez tester plusieurs approches en parallèle, je vous invite à dupliquer le dossier racine, git-subs, autant de fois que nécessaire, pour pouvoir comparer les résultats à la volée.

La structure de notre subtree

Elle est assez simple :

.
├── README.md
├── lib
│   └── index.js
└── plugin-config.json

À chaque fois, on va vouloir exploiter ce contenu au sein de notre codebase conteneur, dans le sous-dossier vendor/plugins/demo.

Ajouter un subtree

À la main

Commençons par nous épargner des CLIs atroces en définissant un remote pour le dépôt central du subtree, qu'on va récupérer dans le cache local ensuite :

a-la-main/main (master u=) $ git remote add plugin ../remotes/plugin
a-la-main/main (master u=) $ git fetch plugin
warning: no common commits
remote: Counting objects: 11, done.
remote: Compressing objects: 100% (9/9), done.
remote: Total 11 (delta 1), reused 0 (delta 0)
Unpacking objects: 100% (11/11), done.
From ../remotes/plugin
 * [new branch]      master     -> plugin/master
a-la-main/main (master u=) $

Il nous faut à présent retranscrire le contenu de la branche master du plugin dans le sous-dossier qui va bien, et ajouter ça à l'index au passage. C'est le rôle de read-tree, qui lit une arbo dans la base et la retranscrit dans l'index, en préfixant la racine si besoin. L'option -u est là pour mettre également à jour le working directory (ce qui est plutôt une bonne idée…).

a-la-main/main (master u=) $ git read-tree --prefix=vendor/plugins/demo -u plugin/master
a-la-main/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:   vendor/plugins/demo/README.md
  new file:   vendor/plugins/demo/lib/index.js
  new file:   vendor/plugins/demo/plugin-config.json

Impeccable. Finalisons le commit dédié :

a-la-main/main (master + u=) $ git commit -m "Ajout du plugin de démo en subtree dans vendor/plugins/demo"
[master 76b347a] Ajout du plugin de démo en subtree dans vendor/plugins/demo
 3 files changed, 19 insertions(+)
 create mode 100644 vendor/plugins/demo/README.md
 create mode 100644 vendor/plugins/demo/lib/index.js
 create mode 100644 vendor/plugins/demo/plugin-config.json
a-la-main/main (master u+1) $

Et voilà ! Rien de bien compliqué !

Avec git subtree

Là aussi, définir le remote raccourcit les CLI qui suivent, mais pas besoin du fetch manuel, git subtree le fera le moment venu. On utilise la sous-commande add :

git-subtree/main (master u=) $ git remote add plugin ../remotes/plugin/
git-subtree/main (master u=) $ git subtree add --prefix=vendor/plugins/demo plugin master
git fetch plugin master
warning: no common commits
remote: Counting objects: 11, done.
remote: Compressing objects: 100% (9/9), done.
remote: Total 11 (delta 1), reused 0 (delta 0)
Unpacking objects: 100% (11/11), done.
From ../remotes/plugin
 * branch            master     -> FETCH_HEAD
 * [new branch]      master     -> plugin/master
Added dir 'vendor/plugins/demo'
git-subtree/main (master u+4) $

OK, remarquez le dernier prompt : la commande a fusionné les historiques du plugin et du conteneur. Vérifions avec un log :

git-subtree/main (master u+4) $ git log --oneline --graph --decorate
*   32e539d (HEAD, master) Add 'vendor/plugins/demo/' from commit 'fe6479991d214f4d95ac2ae959d7252a866e01a3'
|\
| * fe64799 (plugin/master) Fix repo name for main project companion demo repo
| * 89d24ad Main files (incl. subdir) for plugin, to populate its tree.
| * cc88751 Initial commit
* b90985a (origin/master) Main files for the project, to populate its tree a bit.
* e052943 Initial import

Si vous êtes comme moi, vous n'aimez pas trop polluer votre historique conteneur avec le détail des commits centraux du subtree… On pourrait croire qu'on tient la solution avec l'option --squash que git subtree propose pour ses sous-commandes add, pull et merge. Après tout, git merge --squash produit un squash commit au lieu d'une fusion, ce qui correspond mieux à ce qu'on voudrait.

Oui, mais non :

git-subtree/main (master u+4) $ git reset --hard @{u}
HEAD is now at b90985a Main files for the project, to populate its tree a bit.
git-subtree/main (master u=) $ git subtree add --prefix=vendor/plugins/demo --squash plugin master
git fetch plugin master
From ../remotes/plugin
 * branch            master     -> FETCH_HEAD
Added dir 'vendor/plugins/demo'
git-subtree/main (master u+2) $

Vous avez vu le u+2 dans le prompt, au lieu d'un u+1 ? Voyons voir :

git-subtree/main (master u+2) $ git log --oneline --graph --decorate
*   352af7a (HEAD, master) Merge commit '03e04026fdba2ff1200a226c3dd8a4bb66c97a51' as 'vendor/plugins/demo'
|\
| * 03e0402 Squashed 'vendor/plugins/demo/' content from commit fe64799
* b90985a (origin/master) Main files for the project, to populate its tree a bit.
* e052943 Initial import

Et voilà… Au lieu de faire un squash commit, il squashe l’historique concerné du subtree, en fait un commit sur la « branche » dédiée (ce n'est pas à proprement parler une branche, mais ça y ressemble dans le graphe), et fusionne ça.

Vu son fonctionnement interne et les commandes qu'il propose, je comprends qu'il ait besoin de ça. C'est techniquement justifié pour la suite, mais personnellement ça me gêne.

Avec git subrepo

Dans main :

git-subrepo/main (master u=) $ git subrepo clone ../remotes/plugin vendor/plugins/demo
git-subrepo/main (master u+1) $ git push

Subrepo '../remotes/plugin' (master) cloned into 'vendor/plugins/demo'.

On voit dans notre historique qu’un commit a été généré avec le message reprenant la commande complète : git subrepo clone ../remotes/plugin vendor/plugins/demo.

Récupérer / mettre à jour un dépôt exploitant des subtrees

OK. À présent que nous avons vu comment ajouter un subtree, nos collègues doivent-ils faire quelque chose de particulier pour en profiter dans leurs dépôts locaux ?

Après tout, si on utilisait des submodules, ils auraient besoin soit d'un git clone --recursive pour récupérer le dépôt, soit d'une séquence git fetch + git submodule sync --recursive + git submodule update --init --recursive sur un dépôt déjà présent. La fête, quoi.

Et bien vous savez quoi ? Là, ils n'ont rien de plus à faire que d'habitude. La raison est simple : il y a un seul dépôt : le conteneur.

Vérifions ça. Commençons par partager le(s) commit(s) d'ajout du subtree, pour que nos collègues puissent cloner ou synchroniser leurs dépôts depuis le remote. Dans chaque dossier où vous avez fait une manip', faites un git push.

git-stree/main (master u+1) $ git push
…
git-subtree/main (master u+2) $ git push
…
a-la-main/main (master u+1) $ git push
…

Pour récupérer le dépôt à jour, il suffit de le cloner/puller normalement. La manip' est la même quelle que soit l'approche initiale, alors je ne vous la montre qu'une fois :

a-la-main/main (master u=) $ cd ..
a-la-main $ git clone remotes/main collegue
Cloning into 'collegue'...
done.
a-la-main $ cd collegue
a-la-main/collegue (master u=) $ tree vendor
vendor
└── plugins
    └── demo
        ├── README.md
        ├── lib
        │   └── index.js
        └── plugin-config.json

3 directories, 3 files

(Dans le Git Bash sur Windows, vous n'aurez pas la commande tree, pas plus que sur un OSX ou certains Linux bruts de décoffrage : il faut installer la commande. Ouvrez juste votre explorateur de fichiers, ou faites un ls -lR à la place.)

Si vous avez utilisé à la base git subrepo, en y regardant bien vous verrez qu’un fichier technique nommé .gitrepo a été placé dans le subtree vendor/plugin/demo/. Il définit la configuration du subrepo.

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

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

a-la-main/collegue (master u=) $ cd ../plugin
a-la-main/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 :

a-la-main/plugin (master u=) $ date > fake-work
a-la-main/plugin (master % u=) $ git add fake-work
a-la-main/plugin (master + u=) $ git commit -m "Pseudo-commit n°1"
[master 5048a7d] Pseudo-commit n°1
 1 file changed, 1 insertion(+)
 create mode 100644 fake-work

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

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

a-la-main/plugin (master u=) $ cd ../main
a-la-main/main (master u=) $

Pensez à répéter ces opérations sur les duplications que vous auriez mises en place pour d'autres approches (git subtree ou git stree)…

Imaginons à présent que nous souhaitions récupérer ces deux commits dans notre subtree.

À la main

C'est tout facile, en fait, il nous suffit de mettre à jour notre cache local sur la base du remote du plugin, et de faire un subtree merge (on fera un squash commit, en revanche, histoire d'éviter de fusionner les historiques). La plupart du temps, nous n'aurons même pas besoin de re-préciser le préfixe de chemin, il se débrouillera tout seul :

a-la-main/main (master u=) $ git fetch plugin
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 ../remotes/plugin
   fe64799..dc995bf  master     -> plugin/master

a-la-main/main (master u=) $ git merge -s subtree \
  --squash plugin/master --allow-unrelated-histories
Squash commit -- not updating HEAD
Automatic merge went well; stopped before committing as requested

a-la-main/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:   vendor/plugins/demo/fake-work

a-la-main/main (master + u=) $ git commit -m "Mise à jour du subtree du plugin de démo"
[master 4f9a839] Mise à jour du subtree du plugin de démo
 1 file changed, 2 insertions(+)
 create mode 100644 vendor/plugins/demo/fake-work
a-la-main/main (master u+1) $

Comme toujours, un squash merge ne finalise pas le commit ; et d'ailleurs, c'est pratique lorsque les mises à jour du subtree nécessitent un ajustement du code du conteneur pour que l'ensemble fonctionne : ça permet de ne faire qu'un commit, directement opérationnel.

Dans certains cas, il peut arriver que les heuristiques de détermination du préfixe de la stratégie de fusion subtree se paument ; il vous est alors possible de rester dans la stratégie de fusion par défaut (recursive), mais de préciser le préfixe via l'option de fusion subtree. On aurait alors démarré la séquence ci-avant comme ceci :

git merge -X subtree=vendor/plugins/demo --squash plugin/master

Un peu plus long, mais pratique pour les quelques cas où l'heuristique par défaut perd pied.

Avec git subtree

C'est le rôle de la sous-commande pull. Comme pour l'ajout initial, histoire de ne pas fusionner le détail de l'historique du plugin, on utilisera --squash. Et on doit répéter toute la config à chaque appel :

git-subtree/main (master u=) $ git subtree pull --prefix=vendor/plugins/demo --squash plugin master
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 ../remotes/plugin
 * branch            master     -> FETCH_HEAD
   fe64799..2872e5d  master     -> plugin/master
Merge made by the 'recursive' strategy.
 vendor/plugins/demo/fake-work | 2 ++
 1 file changed, 2 insertions(+)
 create mode 100644 vendor/plugins/demo/fake-work
git-subtree/main (master u+2) $

Mais ça va quand même maintenir la branche et nous pourrir le graphe :

git-subtree/main (master u+2) $ git log --oneline --graph --decorate
*   1782c08 (HEAD, master) Merge commit '636facf64d416210e90bb8b83396f573f89b20c5'
|\
| * 636facf Squashed 'vendor/plugins/demo/' changes from fe64799..2872e5d
* |   352af7a (origin/master) Merge commit '03e04026fdba2ff1200a226c3dd8a4bb66c97a51' as 'vendor/plugins/demo'
|\ \
| |/
| * 03e0402 Squashed 'vendor/plugins/demo/' content from commit fe64799
* b90985a Main files for the project, to populate its tree a bit.
* e052943 Initial import

Woh pinaise… Et souvenez-vous : le conteneur n’a à ce stade qu'une seule branche : master. Ça saute aux yeux en regardant ce graphe, non ? Pfff…

Avec git subrepo

git-subrepo/main (master u=) $ git subrepo pull vendor/plugins/demo

Subrepo 'vendor/plugins/demo' pulled from '../remotes/plugin' (master).

git-subrepo/main (master u+1) $ git push

Un nouveau commit dans notre projet conteneur fait automatiquement écho à la mise à jour du subtree. Son intitulé reprend à nouveau la commande complète : git subrepo pull vendor/plugins/demo.

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

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. Dans de tels cas, vous allez forcément le faire évoluer directement dans la codebase d'un conteneur, avant de backporter ça sur son remote central.

Il peut aussi arriver, et c'est là une possibilité des subtrees qu'on ne peut pas répliquer proprement avec les submodules, que vous souhaitiez personnaliser le code du subtree uniquement dans le cadre de votre projet conteneur, sans remonter ça upstream.

Il est important de bien découper les deux types de manipulation dans des commits distincts.

En revanche, dans le premier cas, si ça nécessite par-dessus le marché des modifs dans le code conteneur, il n'est pas obligatoire de faire 2 commits séparés (un pour le subtree, un pour le conteneur) : les commandes exploitées plus tard pour le backport devraient retrouver leurs petits, et ça vous évitera un commit intermédiaire qui fait échouer les tests…

Quelle que soit l'approche retenue, ces mises à jour se font librement dans la codebase conteneur, qui est l'unique dépôt contenant et le code conteneur, et celui des subtrees. Les collaborateurs n'ont aucune manip' particulière à faire : c'est comme si le subtree n'en était pas un. C'est là un énorme avantage sur les submodules, où cette section de l'article est beaucoup, beaucoup plus longue…

Déroulons un scénario qui mélangera quatre types de commits :

  • Commits sur le subtree uniquement, destinés au backport (fixes, etc.) ;
  • Commits sur le code conteneur uniquement ;
  • Commits sur le subtree et le conteneur, dont la partie subtree doit être backportée ;
  • Commits sur le subtree uniquement, spécifiques au conteneur et donc à ne pas backporter.
a-la-main/main (master u=) $ echo '// Now super fast' >> vendor/plugins/demo/lib/index.js
a-la-main/main (master * u=) $ git commit -am "[To backport] Plugin plus rapide"
a-la-main/main (master u+1) $ date >> main-file-1
a-la-main/main (master * u+1) $ git commit -am "Boulot conteneur seul"
a-la-main/main (master u+2) $ date >> vendor/plugins/demo/fake-work
a-la-main/main (master * u+2) $ date >> main-file-2
a-la-main/main (master * u+2) $ git commit -am "[To backport] Horodatage plugin (nécessite un ajustement conteneur)"
a-la-main/main (master u+3) $ echo '// This is just for this specific container' >> vendor/plugins/demo/lib/index.js
a-la-main/main (master * u+3) $ git commit -am "MàJ du plugin uniquement dans le contexte courant"

Comme précédemment, pensez à rejouer ce scénario sur les duplications que vous auriez faites pour tester les autres approches… Les manips seront identiques à chaque fois. Pour les fans du gros copier-coller qui tâche, voici la liste des commandes brutes ; mais prenez soin de vérifier que chacune a bien fonctionné comme prévu !

echo '// Now super fast' >> vendor/plugins/demo/lib/index.js
git commit -am "[To backport] Plugin plus rapide"
date >> main-file-1
git commit -am "Boulot conteneur seul"
date >> vendor/plugins/demo/fake-work
date >> main-file-2
git commit -am "[To backport] Horodatage plugin (nécessite un ajustement conteneur)"
echo '// This is just for this specific container' >> vendor/plugins/demo/lib/index.js
git commit -am "MàJ du plugin uniquement dans le contexte courant"

Backporter vers le dépôt central du subtree

À présent, nous allons voir comment backporter juste le nécessaire, approche par approche. Commençons par regarder nos commits récents, histoire de bien avoir l'historique en tête :

a-la-main/main (master u+4) $ git log --oneline --decorate --stat -5
28e310b (master) MàJ du plugin uniquement dans le contexte courant
 vendor/plugins/demo/lib/index.js | 1 +
 1 file changed, 1 insertion(+)
71d2d12 [To backport] Horodatage plugin (nécessite un ajustement conteneur)
 main-file-2                   | 1 +
 vendor/plugins/demo/fake-work | 1 +
 2 files changed, 2 insertions(+)
c693673 Boulot conteneur seul
 main-file-1 | 1 +
 1 file changed, 1 insertion(+)
92bc02d [To backport] Plugin plus rapide
 vendor/plugins/demo/lib/index.js | 1 +
 1 file changed, 1 insertion(+)
4f758af (origin/master) Mise à jour du subtree du plugin de démo
 vendor/plugins/demo/fake-work | 2 ++
 1 file changed, 2 insertions(+)

À la main

On pourrait créer des commits synthétiques au milieu de nulle part, mais c'est moche. Je préfère de loin créer une branche locale dédiée au backport, qui tracke la branche idoine du remote du plugin :

a-la-main/main (master u+4) $ git checkout -b backport-plugin plugin/master
a-la-main/main (backport-plugin u=) $

À présent, nous allons faire un cherry-pick des commits qui nous intéressent (en précisant -x tant qu'à faire, pour inclure les détails de l'origine dans le message de commit résultant).

a-la-main/main (backport-plugin u=) $ git cherry-pick -x master~3
[backport-plugin 953ec4d] [To backport] Plugin plus rapide
 Date: Thu Jan 29 21:54:45 2015 +0100
 1 file changed, 1 insertion(+)

a-la-main/main (backport-plugin u+1) $ git cherry-pick -x --strategy=subtree master^
[backport-plugin 34f50a4] [To backport] Horodatage plugin (nécessite un ajustement conteneur)
 Date: Thu Jan 29 21:55:00 2015 +0100
 1 file changed, 1 insertion(+)

a-la-main/main (backport-plugin u+1) $ git log --oneline --decorate --stat -2
34f50a4 (HEAD, backport-plugin) [To backport] Horodatage plugin (nécessite un ajustement conteneur)
 fake-work | 1 +
 1 file changed, 1 insertion(+)
953ec4d [To backport] Plugin plus rapide
 lib/index.js | 1 +
 1 file changed, 1 insertion(+)

a-la-main/main (backport-plugin u+2) $ git push plugin HEAD:master
Counting objects: 7, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (6/6), done.
Writing objects: 100% (7/7), 877 bytes | 0 bytes/s, done.
Total 7 (delta 2), reused 0 (delta 0)
To ../remotes/plugin
   dc995bf..34f50a4  backport-plugin -> master

Tout comme avec git merge -s subtree plugin/master un peu plus tôt, l'heuristique se débrouille généralement pour retrouver ses petits.

En fait, on remarque qu'il est même possible de ne pas avoir à préciser la stratégie, du moment que les fichiers concernés par le commit source ont des correspondances non ambigües dans le working directory (différent, non préfixé, et sans les contenus strictement conteneur) de la branche courante.

Toutefois, il est prudent de préciser --strategy=subtree (l'option -s signifie autre chose pour cherry-pick) pour s'assurer que le cherry-pick ignorera tranquillement les fichiers conteneur (hors subtree) du commit d'origine, comme par exemple main-file-2 dans master^. Si on oublie cette option, sur ce coup-là Git refuserait de finaliser le cherry-pick, en estimant que de notre côté (backport-plugin) on a supprimé le fichier (conflit de type deleted by us). Je vous encourage à le préciser tout le temps, par prudence.

Le log est là pour nous rassurer quant au chemin des fichiers backportés, qui est bien en « racine plugin ». Et le push final partage ce backport sur le remote central du plugin.

Avec git subtree

Alors OK, on a peut-être une jolie sous-commande git subtree push, mais elle a l'inconvénient de backporter tous les commits qui ont touché le subtree : il n'est pas possible de choisir ceux qu'on veut. Du coup, notre dernier commit, qui était de la personnalisation spécifique au conteneur, va partir aussi… Grmbl. Pas ce qu'on veut. Je vous montre quand même :

# Attention, ce n'est pas ce qu'on souhaite : ça backporte tout.  Pas le choix.
git-subtree/main (master u+4) $ git subtree push -P vendor/plugins/demo plugin master
git push using:  plugin master
-n 1/      10 (0)
-n 2/      10 (1)
-n 3/      10 (2)
-n 4/      10 (2)
-n 5/      10 (3)
-n 6/      10 (3)
-n 7/      10 (4)
-n 8/      10 (5)
-n 9/      10 (6)
-n 10/      10 (7)
Counting objects: 11, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (9/9), done.
Writing objects: 100% (11/11), 1.11 KiB | 0 bytes/s, done.
Total 11 (delta 4), reused 0 (delta 0)
To ../remotes/plugin/
   2872e5d..e857a74  e857a74119c3e1c1b237b367c4a6c8f79deca1a7 -> master

git-subtree/main (master u+4) $ git log --oneline --decorate -4 plugin/master
e857a74 (plugin/master) MàJ du plugin uniquement dans le contexte courant
ddabc13 [To backport] Horodatage plugin (nécessite un ajustement conteneur)
73a22ea [To backport] Plugin plus rapide
2872e5d Pseudo-commit n°2

Vous remarquez le dernier backport (le plus en haut), qui n'aurait pas dû être là…

Avec git subrepo

git-subrepo/main (master u+4) $ git subrepo push vendor/plugins/demo
git-subrepo/main (master u+4) $ cd ../plugin
git-subrepo/main (plugin u=) $ git pull
git-subrepo/main (plugin u=) $ git log --oneline --decorate -3

5a2c3cb (HEAD -> master, origin/master) MàJ du plugin uniquement dans le contexte courant
4f66abe [To backport] Horodatage plugin (nécessite un ajustement conteneur)
4a9762b [To backport] Plugin plus rapide

Vous remarquez le dernier backport (le plus en haut), qui n’aurait pas dû être là… En effet, git subrepo prend (pour le moment) toutes les modifications impactant le sous-répertoire. Il n’est donc pas possible de lui passer une syntaxe de révision ne désignant que l’intervalle de commits que nous aurions souhaité rapatrier vers le plugin.

Lister les subtrees

Ça ne concerne que les workflows à base de git subrepo. La commande idoine n’est pas focément explicite. On aurait bien vu un git subrepo list, au lieu de quoi on passera par :

git-subrepo/main (master u=) $ git subrepo status --verbose

1 subrepo:

Git subrepo 'vendor/plugins/demo':
  Subrepo Branch:  subrepo/vendor/plugins/demo
  Remote Name:     subrepo/vendor/plugins/demo
  Remote URL:      ../remotes/plugin
  Upstream Ref:    0691649
  Tracking Branch: master
  Pulled Commit:   0691649
  Pull Parent:     f82331f
  Refs:
    Commit Ref:    0691649 (refs/subrepo/vendor/plugins/demo/commit)
    Fetch Ref:     0691649 (refs/subrepo/vendor/plugins/demo/fetch)
    Pull Ref:      0691649 (refs/subrepo/vendor/plugins/demo/pull)
    Push Ref:      5a2c3cb (refs/subrepo/vendor/plugins/demo/push)

Retirer un subtree

C'est juste un répertoire dans le dépôt. Un bon vieux git rm fonctionne, comme d'habitude, et ce quelle que soit l'approche.

main (master u=) $ git rm -r vendor/plugins/demo
rm 'vendor/plugins/demo/README.md'
rm 'vendor/plugins/demo/fake-work'
rm 'vendor/plugins/demo/lib/index.js'
rm 'vendor/plugins/demo/plugin-config.json'

main (master + u=) $ git commit -m "Retrait du subtree vendor/plugins/demo"
[master 3893865] Retrait du subtree vendor/plugins/demo
 4 files changed, 24 deletions(-)
 delete mode 100644 vendor/plugins/demo/README.md
 delete mode 100644 vendor/plugins/demo/fake-work
 delete mode 100644 vendor/plugins/demo/lib/index.js
 delete mode 100644 vendor/plugins/demo/plugin-config.json
main (master u+1) $

Un mot sur git subrepo

Attention, ne confondez pas la suppression avec la commande clean qui n’a pour effet que de purger les références de branches dans $GITDIR/refs/heads/ (suivi d’un git update-ref -d $ref).

Le message en sortie n’est malheureusement pas suffisamment clair et pourrait nous faire croire que le subtree a été supprimé, mais ce n’est pas le cas !

git-subrepo/main (master u+4) $ git subrepo clean --force vendor/plugins/demo

Removed remote 'subrepo/vendor/plugins/demo'.

Transformer un répertoire en subtree

C'est le dernier cas fun : celui où le code était à la base une partie intégrante de notre dépôt, mais qu'on veut le sortir pour le partager entre plusieurs codebases.

Commençons par créer un dossier dans notre dépôt conteneur, et mélanger des commits le concernant et d'autres non. On va reprendre le script utilisé plus haut, en changeant les dossiers.

Copiez-collez les commandes suivantes dans le dépôt « à la main » et, si vous avez joué avec, le dépôt « git subtree » :

mkdir -p lib/plugins/myown/lib
echo '(function() { console.log("Yo"); })();' > lib/plugins/myown/lib/index.js
git add lib/plugins/myown
git commit -m "Plugin sez: Yo, dawg."
date >> main-file-1
git commit -am "Boulot conteneur seul"
echo '// Now super fast' > lib/plugins/myown/lib/index.js
date >> main-file-2
git commit -am "Plugin rapide (nécessite un ajustement conteneur)"

Ça devrait donc vous créer 3 commits, dont 2 touchent au dossier lib/plugins/myown. Finissez avec un git push pour recaler le remote.

On va également se préparer un pseudo-remote qui servirait à accueillir ce nouveau subtree (à dupliquer si besoin dans la copie pour l'approche git subtree) :

cd ..
mkdir remotes/myown
cd remotes/myown
git init --bare
cd ../../main

À la main

Le principe : on va créer une branche dédiée, et filtrer son historique pour ne garder que les commits concernant le sous-répertoire, en réécrivant l'arborescence au passage.

Ça a l'air bourrin comme ça, mais c'est en fait l'un des modes de la commande « bulldozer » git filter-branch. Il s'agit du --subdirectory-filter. Voyez plutôt :

a-la-main/main (master u=) $ git checkout -b split-plugin
a-la-main/main (split-plugin) $ git filter-branch --subdirectory-filter lib/plugins/myown
Rewrite 973cfacecb645f66b89accedac8780c19140401b (2/2)
Ref 'refs/heads/split-plugin' was rewritten

a-la-main/main (split-plugin) $ git log --oneline --decorate
5af0de1 (HEAD, split-plugin) Plugin rapide (nécessite un ajustement conteneur)
4fc711a Plugin sez: Yo, dawg.

a-la-main/main (split-plugin) $ tree
.
└── lib
    └── index.js

1 directory, 1 file

(Je rappelle que tree n'est pas forcément disponible sur votre système ; s'il est manquant, faites juste un ls -lR à la place.)

On n'a plus qu'à transmettre ça au remote concerné :

a-la-main/main (split-plugin) $ git remote add myown ../remotes/myown
a-la-main/main (split-plugin) $ git push -u myown split-plugin:master
Counting objects: 8, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (8/8), 617 bytes | 0 bytes/s, done.
Total 8 (delta 0), reused 0 (delta 0)
To ../remotes/myown
 * [new branch]      split-plugin -> master
Branch split-plugin set up to track remote branch master from myown.
a-la-main/main (split-plugin u=) $

À ce stade, vous pouvez virer la branche si vous estimez ne plus en avoir besoin par la suite pour des backports. Sinon, laissez-la, tant qu'à faire…

Inutile ensuite de remplacer le sous-répertoire dans master par un contenu obtenu via read-tree : de futurs merge -s subtree --squash marcheront sans souci, comme si vous l'aviez injecté depuis un subtree existant. Pratique, non ?

Avec git subtree

Une sous-commande split existe pour faire à peu près la même chose. En supposant que vous ayez fait les mêmes manip's de préparation que sur la copie « à la main », ça donnerait ceci :

git-subtree/main (master u=) $ git subtree split -P lib/plugins/myown -b split-plugin
-n 1/      14 (0)
-n 2/      14 (1)
-n 3/      14 (2)
-n 4/      14 (3)
-n 5/      14 (4)
-n 6/      14 (5)
-n 7/      14 (6)
-n 8/      14 (7)
-n 9/      14 (8)
-n 10/      14 (9)
-n 11/      14 (10)
-n 12/      14 (11)
-n 13/      14 (12)
-n 14/      14 (13)
Created branch 'split-plugin'
a54c695c65db858a68720dd9b93061ea28d13243

git-subtree/main (master u=) $

Vous pouvez alors répéter les mêmes manip's de création de remote et de push explicite que dans l'approche à la main :

git-subtree/main (split-plugin) $ git remote add myown ../remotes/myown
git-subtree/main (split-plugin) $ git push -u myown split-plugin:master
Counting objects: 8, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (8/8), 556 bytes | 0 bytes/s, done.
Total 8 (delta 1), reused 0 (delta 0)
To ../remotes/myown
 * [new branch]      split-plugin -> master
Branch split-plugin set up to track remote branch master from myown.
git-subtree/main (split-plugin u=) $

En revanche, git subtree refusera de faire des pull en mode squash par la suite, car il ne trouve pas trace d'un ajout précédent, dans la mesure où il ne se base pas sur les heuristiques de Git, mais sur sa propre implémentation technique des commits de fusion de subtree :

git-subtree/main (split-plugin u=) $ git checkout master
git-subtree/main (master u=) $ git subtree pull --squash -P lib/plugins/myown myown master
From ../remotes/myown
 * branch            master     -> FETCH_HEAD
Can't squash-merge: 'lib/plugins/myown' was never added.

En gros, soit vous oubliez les squashes et vous fusionnez l'historique du subtree à partir de maintenant (bleark !), soit vous remplacez le dossier historique par un ajout de subtree :

git-subtree/main (master u=) $ git rm -r lib/plugins/myown
rm 'lib/plugins/myown/lib/index.js'

git-subtree/main (master + u=) $ git commit -m "Retrait de lib/plugins/myown en prévision du subtree"
[master bf59e62] Retrait de lib/plugins/myown en prévision du subtree
 1 file changed, 1 deletion(-)
 delete mode 100644 lib/plugins/myown/lib/index.js

git-subtree/main (master u+1) $ git subtree add -P lib/plugins/myown --squash myown master
git fetch myown master
From ../remotes/myown
 * branch            master     -> FETCH_HEAD
Added dir 'lib/plugins/myown'

git-subtree/main (master u+3) $

À partir de quoi, les git subtree pull -P lib/plugins/myown --squash myown master marcheront… Mais bon, ça en fait des cerceaux en flamme…

Avec git subrepo

Il y a une commande dédiée là aussi, qui se contente de caler le .gitrepo dans le sous-répertoire, à partir de quoi il suffit de faire un push pour finaliser l’opération :

git-subrepo/main (master u=) $ git subrepo init lib/plugins/myown --remote=../remotes/myown
Subrepo created from 'lib/plugins/myown' with remote '../remotes/myown' (master).
git-subrepo/main (master u+1) $ git subrepo push lib/plugins/myown
Subrepo 'lib/plugins/myown' pushed to '../remotes/myown' (master).
git-subrepo/main (master u+1) $

Et voilà !

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.

[^1]: Pour en savoir plus sur la découpe porcelaine/plomberie, c'est par ici