Git rebase : qu'est-ce que c'est ? Comment s'en servir ?

J'utilise git au quotidien depuis plus de 10 ans maintenent et git-rebase est tout simplement l'une de mes fonctionnalités préférée. Pourtant, lorsque je donne des formations sur Git, je m'aperçois que cette commande est souvent mal comprise et mal utilisée. Nous allons donc étudier en détail la commande git-rebase : À quoi elle sert vraiment, et comment bien l'utiliser.

Un panier de Basket fort disputé

Prérequis

Ce billet présuppose que vous avez déjà une certaine connaissance des fondamentaux et du fonctionnement de Git, notamment sur le fonctionnement des branches. Si ce n'est pas votre cas, vous pouvez commencer par lire cet autre billet qui devrait vous mettre le Git à l'étrier.

Vous êtes également sensible aux bonnes pratiques en ce qui concerne la gestion de votre historique : commits atomiques, bonne gestion des branches, etc.

Git rebase, qu'est-ce que c'est ?

Votre historique git est un peu comme un arbre, avec son tronc (la branche master), ses branches et ses sous-branches.

La commande git-rebase est comme une tronçonneuse : elle permet de couper une branche pour la regreffer à un autre endroit sur l'arbre.

Git rebase illustré

Ok, pour que ça soit plus clair, je vais vous faire un dessin. Je vais partir du cas classique ou, depuis ma branche de développement principale (master), j'ai créé une branche discussion, parce que je dois implémenter un système de discussion quelconque.

Voici à quoi peut ressembler l'historique en question, d'un point de vue schématique.

A---B---C---D ← master
     \
      E---F---G ← discussion

Si je réalise une fusion classique au moyen de la commande git merge, j'obtiendrai le nouvel historique suivant :

A---B---C---D---H ← master
     \         /
      E---F---G ← discussion

Une autre solution est de transplanter ma branche discussion sur la pointe de master. Je prends la branche et je la recolle plus loin. On obtient alors :

A---B---C---D ← master
             \
              E---F---G ← discussion

Notez que rebaser discussion n'a strictement aucun effet sur master. En revanche, la fusion de discussion dans master est maintenant triviale (fast forward). On obtient enfin :

A---B---C---D---E---F---G ← master
                         \
                          discussion

Avant de se demander à quoi ça sert, soyez certain de bien comprendre ce que ça fait. On prend une branche, et on la fout autre part. Pas très compliqué, finalement, n'est-ce pas ?

À quoi sert git rebase ?

Git rebase peut être utilisé dans de nombreux cas :

  • conserver un historique propre ;
  • corriger des erreurs de fusion ;
  • faciliter le travail collaboratif ;
  • faciliter les fusions sur les branches qui nécessitent un très long développement.

Étudions quelques cas pratiques.

git rebase permet d'éviter les commits de fusion pour les branches triviales

Quand on travaille avec Git, on a tendance à créer beaucoup de petites branches, ce qui est une bonne chose. Par contre, fusionner des branches créé des commits de fusion, comme dans notre exemple précédent.

Si vous créez beaucoup de petites branches, vous allez obtenir beaucoup de commits de fusion. Dans la mesure ou ces commits n'apportent pas d'information utile, ils polluent votre historique.

En rebasant (vous devrez me pardonner les anglicismes barbares) vos branches avant de les fusionner, vous obtiendrez un historique tout plat et bien plus agréable à parcourir. Prenons un exemple.

          F---G ← bug2
         /
A---B---E---H---I ← master
     \
      C---D ← bug1

En utilisant un rebase avant chaque fusion, on obtient l'historique suivant :

A---B---E---H---I---C---D---F---G ← master

Les commandes pour parvenir à ce résultat sont les suivantes, explications juste après.

  1. git rebase master bug1
  2. git checkout master
  3. git merge bug1
  4. git branch -d bug1
  5. git rebase master bug2
  6. git checkout master
  7. git merge bug2
  8. git branch -d bug2

Et le détail des commandes.

  1. Transplante bug1 sur l'actuelle branche master. Si on est déjà en train de bosser sur bug1 on peut se contenter de taper git rebase master
  2. Switche sur master
  3. Fusionne bug1 dans master
  4. Supprime la branche bug1 devenue inutile
  5. Transplante bug2 sur la branche master
  6. Switche sur master
  7. Fusionne bug2 dans master
  8. Supprime bug2 devenue inutile.

Et voilà un bel historique bien propre, exempt de commits de fusion inutiles.

Ça paraît laborieux mais avec l'habitude, ça se fait tout seul et c'est même plutôt amusant (je sais, un rien m'amuse).

git rebase permet de fusionner les branches en série

Prenons exactement le même exemple que précédemment, sauf que cette fois, nous ne fusionnons pas des petites branches triviales mais de vraies fonctionnalités.

          F---G ← newsletter
         /
A---B---E---H---I ← master
     \
      C---D ← password_reset

Un historique plat, c'est bien, mais on perd de l'information. Plus moyen de savoir en un coup d'œil que telle liste de commits a été réalisée sur une branche spécifique.

Pour pallier à ce problème, on va utilise une option de merge : git merge --no-ff (pour « no fast forward »).

D'abord, les commandes.

git rebase master password_reset
git checkout master
git merge password_reset --no-ff
git branch -d password_reset
git rebase master newsletter
git checkout master
git merge newsletter --no-ff
git branch -d newsletter

On obtient alors l'historique suivant, bien plus clair. De plus, les commits « J » et « K » afficheront un message « branch machin was merged into master », ce qui fait que, même si les branches ont effectivement été supprimées, l'historique conserve une trace de leur existence.

A---B---E---H---I-------J-------K ← master
                 \     / \     /
                  C---D   F---G

Voici une capture d'un vrai projet, pour que vous puissiez vous extasier sur la beauté de la chose.

Un historique Git bien propre

C'est-y pas beau ? Pour un peu, je me ferais tatouer ça sur le corps.

git rebase permet d'éviter les commits de fusion de git pull

Lorsque vous tapez git pull pour mettre à jour votre dépôt avec les derniers commits présents sur le serveur, Git va réaliser un merge pour fusionner vos modifications et celles que vous venez de récupérer.

Sur le serveur.

A---B---C---D---E ← master

Sur votre machine.

          origin/master
         /
A---B---C---F ← master

Après un git pull.

          D---E ← origin/master
         /     \
A---B---C---F---G ← master

Vous pourrez alors envoyer votre travail sur le serveur avec un git push.

Imaginez maintenant dix personnes qui travaillent sur la même branche (c'est très mal) et qui pushent et pullent toutes les cinq minutes. Vous imaginez la gueule de l'historique ?

Pour éviter ce problème, on va utiliser l'option git pull --rebase, qui produira le résultat suivant.

                  origin/master
                 /
A---B---C---D---E---F ← master

Et hop ! Encore une fois, un bel historique nickel. Merci Git !

Réparer un mauvais historique grâce à git rebase --onto

Scénario : j'ai créé une branche newsletter pour travailler sur la fonctionnalité correspondante. J'ai également créé une branche bug_urgent pour corriger un bug qui doit être fixé urgemment, comme son nom l'indique.

Sauf que, au moment de fusionner ma branche bug_urgent, horreur ! malheur ! je m'aperçois que je n'ai pas créé ma branche au bon endroit. Mon historique ressemble à ça.

A---B---H---I---J ← master
     \
      C---D---G ← newsletter
           \
            E---F ← bug_urgent

Catastrophe ! Ma branche newsletter est un travail en cours, mais bug_urgent doit absolument être fusionnée dans master, le commercial a une démo dans 5 minutes. Comment faire ?!

Git rebase à la rescousse ! Nous allons simplement transplanter bug_urgent sur master, et le tour est joué.

git rebase newsletter bug_urgent --onto master
git checkout master
git merge bug_urgent
A---B---H---I---J---E---F ← master
     \
      C---D---G ← newsletter

Bingo ! Alors, merci qui ?

Vous noterez que l'appel de la commande rebase est ici un poil plus compliqué. Si nous nous étions contenté de la syntaxe habituelle git rebase master, nous aurions transplanté tous les commits de la branche bug_urgent en remontant jusqu'à master, c'est à dire les commits E et F, mais aussi C et D, ce qui n'est clairement pas le but.

La commande git rebase newsletter bug_urgent --onto master signifie « arrache la branche qui part de newsletter jusqu'à bug_urgent, et fous la sur master », ou encore « transplante sur master tous les commits qui sont sur bug_urgent:postlink: mais pas sur newsletter ». Pfiou !

Réparer un mauvais historique, bis

Autre exemple d'historique généré un soir de bourre.

A---B---H---I ← master
     \
      C---D---G ← bug1
       \
        E---F ← bug2

Or, il se trouve que les branches bug1 et bug2 sont totalement indépendantes. L'une sera peut-être fusionnée avant l'autre, ou abandonnée, on ne sait pas. Nous allons donc réparer cette bévue prestement.

git rebase bug1 bug2 --onto B
      E---F ← bug2
     /
A---B---H---I ← production
     \
      C---D---G ← bug1

Et voilà ! Notez qu'on peut transplanter à n'importe quel endroit, pas forcément sur une branche (ce qui est normal, puisqu'une branche n'est rien d'autre qu'une étiquette pointant sur un commit).

git rebase permet de faciliter l'intégration de longues branches

Scénario : je travaille sur une fonctionnalité qui nécessite plusieurs semaines de dev. J'ai donc une branche qui va évoluer pendant un loooooooooooong moment avant d'être fusionnée.

A---B---H---I--- … ---J---E---F ← master
      \
       C---D---G--- … ---H---I ← newsletter

Si les branches divergent suffisamment, il est probable que le moment de la fusion va être assez pénible, avec des conflits à résoudre en pagaille. Une mauvaise journée en perspective.

Sauf si, malin, j'ai rebasé ma branche tous les matins en buvant mon café. Je corrige ainsi les conflits au fil de l'eau. Au bout de trois mois, voici mon historique :

A---B---H---I--- … ---J---E---F ← master
                               \
                                C---D---G--- … ---H---I ← newsletter

J'ai beau avoir trois mois de dev dans les pattes, la fusion est triviale et ne prends pas plus d'un quart de secondes. Merci git rebase.

Réécrire l'historique avec git rebase interactif

Allez, on va arrêter de rigoler et sortir la grosse artillerie. Parce que git rebase dispose d'une petite option sympathique : l'option interactive. Quand je lui passe cette option, l'éditeur s'ouvre et je peux éditer un fichier en précisant ce que je veux faire de chaque commit en moment de son application. Exemple.

Voici un exemple d'historique standard (les commits les plus récents en haut).

* 4baf2db - Write tests for discussion
* 0fadd04 - Implement discussion
* 8be3c7e - Write tests for newsletter
* bce2851 - Implement newsletter
* 6477e21 - …

Tout va bien dans le meilleur des mondes. Quand tout à coup ! on m'annonce qu'un audit va être réalisé pour vérifier si les employé·e·s respectent bien la politique qualité de l'entreprise. Cette politique stipule que je suis censé commiter les tests d'une feature avant de commiter le code correspondant.

Et mince ! adieu ma prime ! Git rebase interactif à la rescousse.

git rebase -i HEAD~4

Cette commande signifie « arrache les quatre derniers commits et transplante les au même endroit ». En théorie, c'est une opération nulle SAUF qu'on va le faire de manière interactive.

Immédiatement après avoir tapé la commande, l'éditeur s'ouvre et affiche quelque chose comme ça :

pick 4baf2db Write tests for discussion
pick 0fadd04 Implement discussion
pick 8be3c7e Write tests for newsletter
pick bce2851 Implement newsletter

Les modifications que je vais réaliser dans ce fichier vont influer sur la manière dont mon rebase va se passer. En l'occurence, je vais effectuer cette simple modification.

pick 0fadd04 Implement discussion
pick 4baf2db Write tests for discussion
pick bce2851 Implement newsletter
pick 8be3c7e Write tests for newsletter

J'ai simplement modifié l'ordre des lignes. J'enregistre et quitte l'éditeur. Et magie ! Mes commits ont été réappliqués dans l'ordre indiqué. À moi la prime ! Notez que de nombreuses possibilités s'offrent à moi. J'aurais pu découper un commit en plusieurs ou au contraire en rassembler plusieurs en un seul ; ignorer des commits ; récupérer des commits tels quels mais modifier le message ; etc.

Pièges à éviter

WARNING ATTENTION ACHTUNG

Si vous voulez vous essayer au git rebase, lisez bien attentivement les paragraphes qui suivent. À peu près aucune commande de l'environnement Git n'a un plus grand pouvoir de nuisance que git rebase.

Parce qu'un rebase réécrit votre historique, et que ce n'est pas toujours une opération triviale. Si vous foirez votre coup, vous avez les moyens de franchement flinguer votre historique — rien d'irrécupérable, mais quand même de quoi passer un mauvais moment.

Git rebase ne déplace pas vraiment des commits

Quand je vous ai dit que rebase déplaçait des commits, j'ai menti (c'était pour votre bien). Parce que si vous avez lu mon très détaillé tutoriel sur git, vous savez qu'il est strictement impossible de modifier un commit existant, pour la pure et simple raison qu'un commit est indexé par le hash de son contenu.

Reprenons la métaphore de l'arbre, toujours avec ses branches. Scannez une branche et utilisez une imprimante 3d pour en effectuer une copie la plus fidèle possible. Collez cette copie quelque part sur le tronc. Utilisez une cape d'invisibilité pour masquer la branche d'origine. Voilà, en vrai, c'est ça git rebase.

Ce que fait git rebase, c'est qu'il copie tous les commits transplantés et les réapplique un par un à l'endroit indiqués. Mais il s'agit bel et bien de nouveaux commits, avec des identifiants différents, même si le contenu est le même.

Reprenons notre exemple de tout à l'heure.

A---B---C---D ← master
     \
      E---F---G ← discussion

Si je rebase discussion sur master, ce qu'il se passe réellement ressemble plutôt à ça :

              E'---F'---G' ← discussion
             /
A---B---C---D ← master
     \
      E---F---G

Ainsi, les commits de l'ancienne branche discussion existent toujours, même s'ils sont invisibles car pointés par aucune branche. Les commits de la nouvelle branche discussion sont bels et biens différents.

L'historique partagé jamais tu ne rebaseras

Git rebase disaster girl

Tant que vous rebasez vos petites branches en local, tout va bien. Mais attention, si vous rebasez une branche qui se trouve déjà sur le serveur, c'est la catastrophe. Vous allez pourrir l'historique de tous vos coworkeurs, qui s'empresseront de vous couvrir de goudron et de plume avant d'essayer de vous vendre aux abattoirs d'un KFC. Illustration.

Sur le serveur.

A---B---C---D---E ← master
         \
          F---G---H ← feature

Sur votre machine.

A---B---C---D---E ← master

Vous rebasez les 4 derniers commits de master (comble de l'horreur).

A---B'---C'---D'---E' ← master

Vous poushez. Git affiche un message d'erreur, mais vous utilisez l'option « force » pour pousher quand même. Ni Dieu Ni Maître !

Sur le serveur.

  B'---C'---D'---E' ← master
 /
A---B---C---D---E
         \
          F---G---H ← feature

Wat ?! Imaginez le résultat quand vos collègues essayeront de récupérer les données du serveur. Ça ne va pas être joli et très franchement, je vous souhaite bon courage.

Notez donc bien soigneusement cette règle d'or de l'utilisation de rebase :

L'historique partagé jamais tu ne modifieras, sinon l'ire de ton équipe tu subiras.

Essayer

Pour ceux qui veulent s'amuser, voici le dépôt Git que j'utilise pour mes formations.

git clone https://github.com/thibault/iwantyoursocks.git

Vous pouvez par exemple essayer de rebaser puis fusionner les branches colors et material sur master.

Conclusion

Et voilà. Si vous êtes déjà un aficionado de Git, j'espère vous avoir communiqué un peu de mon enthousiasme pour git rebase, et si vous êtes novice, j'espère vous avoir mis le pied à l'étrier. Sur ce, je vous laisse, j'ai un historique à réécrire.