Git - Retravailler l'historique des commits

dim. 08 juil. 2018 by Marmotte

Git est un système de gestion de versions décentralisé. Le côté décentralisé implique que chaque client télécharge une copie complète du dépôt, la plupart des opérations étant donc réalisées directement sur la machine de l'utilisateur.

Cet article fait partie d'une série expliquant quelques fonctionnalités avancées que j'utilise régulièrement.

  1. Généralités et notations
  2. Quelques configurations
  3. Notes diverses
  4. Dépôts et répertoires de travail
  5. L'historique local
  6. Retravailler l'historique des commits
  7. Trouver le commit introduisant une régression

Git permet de retravailler l'historique au moyen de la commande git rebase. Les buts qui peuvent être atteints par l'utilisation de cette commande sont très divers, par exemple :

  • Déplacer une branche créée à partir du mauvais commit.
  • Résoudre les conflits d'un développement de longue durée au fur et à mesure plutôt que tous à la fin.
  • Nettoyer une branche de développement avant de la proposer pour validation.

Pour atteindre ces buts, la commande git rebase permet différentes opérations, dont :

  • Rejouer les derniers commits d'une branche en partant d'une autre branche.
  • Modifier le contenu et/ou le message d'un commit.
  • Supprimer un commit.
  • Diviser un commit en plusieurs.
  • Fusionner plusieurs commits en un seul.
  • Changer l'ordre des commits

Bien que cette fonctionnalité soit très intéressante, l'utiliser sans prendre de précaution peut poser différents problèmes. En effet, la réécriture d'une partie de l'historique implique que tous les commits, à partir du premier modifié, seront différents de ceux existant précédemment, même si les modifications qu'ils apportent sont identiques.

Note : L'utilisation de git rebase suppose que le développeur respecte déjà certaines bonnes pratiques, comme l'utilisation de commits atomiques. Il est en effet bien plus facile de modifier ou réorganiser les commits si chacun ne touche qu'à une partie précise du projet, plutôt qu'à plusieurs éléments sans lien entre eux.

Problèmes potentiels

Avant de décrire les différentes utilisations de la commande git rebase, il me semble important de faire un tour des problèmes les plus courants qui peuvent survenir suite à son utilisation. Dans tous les cas, plus le nombre de commits à rebaser est grand, plus les problèmes seront potentiellement nombreux et difficiles à résoudre.

Historique partagé entre plusieurs branches

TLDR : Il ne faut jamais réécrire l'historique d'une branche partagée avec d'autres personnes, et il faut éviter autant que possible de rebaser l'historique partagé entre plusieurs branches, même si elles ne sont présentes que dans le dépôt local.

Tant que les commits modifiés n'ont pas été envoyés dans un autre dépôt que celui dans lequel le rebase est effectué, les risques sont faibles. Si les commits modifiés sont présents dans plusieurs branches, il peut être difficile de mettre à jour ces branches, mais il n'y aura pas d'effet de bord sur les développements d'autres personnes.

Dans le cas où ces commits ont été envoyés dans un dépôt distant, s'il s'agit d'un dépôt personnel, auquel personne d'autre n'a accès, il y a là encore assez peu de risques. Les cas problématiques dans cette situation sont principalement dûs à des conflits lorsque l'utilisateur utilise git pull sans tenir compte du fait que l'historique a été réécrit, ce qui "duplique" les commits dans l'historique final. Il m'est ainsi arrivé de voir un historique contenant une même série de commits en cinq exemplaires, suite à de nombreux git rebase suivis de simples git pull. Dans ce cas, effectuer un nouveau rebase sur la branche obtenue depuis une branche dans un état cohérent permet de corriger l'historique erroné, sans refaire l'erreur du git pull ensuite.

Les problèmes les plus gênants arrivent lorsque les commits que l'on veut modifier ont été envoyés dans un dépôt distant accessibles à d'autres personnes, ou pire, récupérés depuis ce dépôt. En effet, dans ce cas, il est possible que les commits que l'on modifie aient été utilisés dans d'autres branches, ou le soient plus tard, puisque d'autres personnes peuvent les avoir dans leurs dépôts locaux. Il est en pratique impossible de demander à toutes les personnes y ayant eu accès de mettre à jour leur branche à partir de celle qui a été rebasée. De plus, pour pousser la branche réécrite sur le dépôt distant, il est nécessaire de forcer l'opération de push, ce qui peut mener à la perte de commits poussés par d'autres personnes dans cette branche entre temps. Si une personne ayant poussé des commits doit alors les pousser de nouveau, elle devra commencer par rebaser sa branche à son tour, ce qui pose de nouveau le même problème. Ce fonctionnement peut paraître acceptable lorsque le projet ne comporte que deux ou trois développeurs actifs, mais même dans ce cas, le risque d'erreurs et de confusion est trop grand pour se le permettre.

Merge de la branche d'origine avec la branche rebasée

Tenter un merge entre des branches contenant deux versions différentes du même commit d'origine est une opération à proscrire absolument. En effet, lors d'une telle opération, les deux (ou plus) commits apparaîtront comme des doublons dans l'historique, ce qui peut rendre sa lecture difficile.

De plus, si le contenu du commit a été modifié, des conflits difficiles à résoudre apparaîssent, puisque git verra deux commits modifiant la même portion de code, sans appliquer exactement la même modification. Comme pour chaque conflit, des erreurs supplémentaires peuvent apparaître lors de la résolution, d'autant plus que les deux blocs sont très proches, rendant la résolution du conflit très périlleuse.

Dans un cas comme celui-ci, il est préférable d'opter pour un rebase entre les branches contenant des versions différentes du même commit d'origine. Il y a cependant de grandes chances pour que des conflits soient générés à quasiment chaque commit ayant été modifié.

Perte d'information

Un autre problème souvent ignoré, est que réécrire l'historique peut faire perdre de l'information. Ainsi, rebaser une branche pour la mettre à jour par rapport à la branche upsteam fait perdre le point de départ de cette branche.

Si cela peut paraître anodin à première vue, connaître le point de départ d'une branche peut :

  • Permettre de connaître facilement quels commits ont été apportés par le merge d'une branche.
  • Expliquer la manière dont certains développements y ont été effectués : La "méthode magique" qui aurait pu être utilisée pour simplifier ce développement n'existait peut être pas avant le rebase, par exemple.
  • Expliquer l'apparente inutilité du développement d'une fonctionnalité en doublon : De la même manière, lors du développement initial dans cette branche, la fonctionnalité en doublon n'existait peut être pas encore.

Le premier point peut être résolu par l'utilisation du paramètre --no-ff de la commande git merge, afin de créer un commit de merge, permettant de conserver une trace des modifications apportées lors de ce merge.

Les deux autres nécessiteront par contre d'analyser et adapter le code de la branche rebasée. Dans ces deux cas, le refactoring de la partie du code devenue "inutile" est nécessaire, et l'effectuer lors d'une opération de rebase est donc une bonne idée. Cependant, même si cela permet de simplifier et nettoyer le code, l'analyse doit absolument être faite à ce moment, sous peine d'oublier, ou de la rendre plus complexe plus tard.

Retrouver les modifications en cours d'application

Lors du déroulement du rebase, il arrive qu'un conflit soit généré, et il est parfois nécessaire de retrouver la modification en cours d'application, pour savoir comment le résoudre.

Toutes les informations à propos du rebase en cours sont disponibles dans le répertoire .git/rebase-apply/, dont :

  • patch : Les modifications du commit en cours d'application.
  • original-commit : Le hash du commit en cours d'application.
  • onto : Le commit à partir duquel le rebase est lancé.
  • orig-head : Le commit de la branche à rebaser.

Note : Lors de l'utilisation de répertoires de travail secondaires, ces fichiers seront placés dans :

  .git/worktrees/<worktree_name>/rebase-apply/

Mettre à jour une branche depuis une autre

Cette utilisation est la plus simple, puisqu'elle consiste uniquement à rejouer les nouveaux commits d'une branche en partant d'une autre, en indiquant en paramètre le nom de la branche sur laquelle se rebaser.

$ git rebase <new-base>

Il est possible de lancer ce traitement sans être au préalable sur la branche à rebaser, en la précisant en second paramètre.

$ git rebase <new-base> <branch-to-rebase>

Rebaser une partie des modifications entre deux branches

Lorsqu'un développement a été fait en partant d'une mauvaise branche, il est nécessaire de refaire les commits de ce développement en partant de la branche qui aurait dû être celle de départ. Pour cela, il suffit d'indiquer à git la bonne branche source, la mauvaise branche source, et enfin le nom de la branche à rebaser.

$ git rebase --onto <good-base> <wrong-base> <branch-to-rebase>

Diviser, fusionner et réorganiser les commits

Lors d'un gros développement, il peut arriver :

  • Qu'une modification ait été intégrée dans le mauvais commit.
  • Qu'on ait une correction mineure à faire dans un autre commit que le dernier.
  • Que l'ordre des commits ne suive pas la logique du développement.

Pour tous ces cas, la commande git rebase --interactive (abrégé -i) permet de réécrire l'historique, en définissant manuellement les opérations à effectuer pour chaque commit. Cette commande prend en paramètre le commit sur lequel effectuer le rebase, et ouvre un éditeur de texte contenant la liste de tous les commits à rejouer, précédés par défaut de l'instruction pick.

On peut alors modifier le contenu du texte proposé, en modifiant l'instruction devant chaque commit en :

  • pick (abrégé p) : Conserver le commit, sans modification.
  • reword (abrégé r) : Ouvre un éditeur de texte pour modifier le message de commit.
  • edit (abrégé e) : Donne la main à l'utilisateur dans un shell pour effectuer des opérations manuelles.
  • squash (abrégé s) : Fusionne le commit avec le précédent, en permettant de définir le message de commit final.
  • fixup (abrégé f) : Fusionne le commit avec le précédent, en conservant le message de commit du précédent.
  • exec (abrégé x) : Exécute une commande arbitraire.
  • drop (abrégé d) : Supprime le commit. Il est aussi possible de simplement supprimer la ligne.

Ces instructions sont ensuite exécutées dans l'ordre défini. Pour changer l'emplacement d'un commit, il suffit donc de déplacer sa ligne dans le texte proposé.

Lors de l'exécution du rebase, il peut arriver qu'on se rende compte que les instructions données sont partiellement erronées. Dans ce cas, la commande git rebase --edit-todo permet de modifier la liste des instructions qui n'ont pas encore été exécutées, évitant ainsi d'annuler puis recommencer totalement le rebase.

Diviser un commit en plusieurs

Pour diviser un commit en plusieurs, il est nécessaire d'utiliser l'instruction edit. Une fois dans le shell, on utilisera ces commandes :

$ # Sélection des modifications à déplacer dans un commit séparé
$ git reset --patch HEAD~
$ # Suppression des modifications dans le commit courant
$ git commit --ammend
$ # Enregistrement des modifications mises de coté dans un nouveau commit
$ git add .
$ git commit -m "New commit message"

Note : Pour diviser un commit en plus que deux, il est plus simple de séparer la totalité des modifications à retirer du commit lors du premier passage, puis de recommencer la procédure jusqu'à avoir les commits voulus. Si seules les modifications d'un commit séparé sont sélectionnées, il sera nécessaire de terminer le rebase, puis de le relancer, pour séparer un autre bloc de modifications dans d'autres commits.

Astuce : Lorsqu'on a utilisé trop vite la commande git commit --amend, il est possible de rattraper l'erreur de manière très simple en exécutant la commande git rebase @{1} juste après, ce qui est bien plus simple qu'un rebase interactif.

Organiser le rebase au fur et à mesure du développement

Pouvoir créer des petits commits de correction pour les fusionner dans le commit à corriger plus tard est très pratique. Mais il devient vite fastidieux de trouver des messages pour décrire ces commits, et encore plus de les trouver, puis les déplacer, dans l'historique d'un rebase en mode interactif, afin de leur appliquer l'instruction squash ou fixup.

Heureusement, git permet de simplifier ce travail, grâce au mécanisme d'autosquash. Pour l'utiliser, il suffit de créer les commits qui seront à fusionner plus tard en indiquant avec quel autre commit ils devront être fusionnés, au moyen des paramètres --squash et --fixup. Ensuite, ajouter le paramètre --autosquash lors du lancement du rebase interactif modifie la liste de commits dans l'éditeur de texte de manière à placer chaque commit à fusionner juste après celui dans lequel il doit l'être. L'instruction pick est aussi remplacée automatiquement par une instruction squash ou fixup, selon le paramètre utilisé pour générer le commit de correction.

$ git commit --fixup=<broken-commit>
$ git commit --squash=<broken-commit>
$ git rebase --autosquash --interactive <old-commit>

Si le commit a été créé de manière habituelle (avec un message de commit écrit manuellement), ou si le commit à corriger spécifié n'était pas le bon, il suffit de le refaire, en corrigeant la commande et en ajoutant le paramètre --amend.

Il est aussi possible de l'activer par défaut avec le paramètre rebase.autosquash.

$ git config --global rebase.autosquash true

Exécuter automatiquement une commande à chaque commit

Lors de la réécriture de l'historique d'une branche de développement, on peut être amené à devoir exécuter une ou plusieurs commandes entre chaque commit. Cela se fait en utilisant le paramètre --exec. Pour exécuter plusieurs commandes entre chaque commit, on peut mettre plusieurs fois ce paramètre, l'ordre d'exécution des commandes étant l'ordre des paramètres dans la commande.

$ git rebase --exec 'make test' --exec 'make lint-check' <old-commit>

Ce paramètre peut aussi être utilisé avec --interactive. Dans ce cas, des lignes exec <some_command> seront ajoutées automatiquement entre chaque commit dans l'éditeur de texte. Il est bien évidemment possible de les modifier, supprimer ou déplacer, comme lors de n'importe quelle utilisation du mode interactif de git rebase.

$ git rebase --interactive --exec 'make test' <old-commit>

Note pour les commits ajoutés par git subtree

La commande git subtree permet de placer un dépôt git dans un sous-répertoire d'un autre dépôt. Les deux parents du commit généré contiennent alors un historique qui peut être vu comme incohérent, puisque la racine de leurs objets tree est différente. Cela pose problème en cas d'utilisation de la commande git rebase, puisque les commits sont alors rejoués sans tenir compte de cette particularité. Le contenu du subtree est alors déplacé partiellement ou totalement à la racine du dépôt global. En plus de casser la structure du dépôt, cette opération peut aussi générer des conflits difficiles à résoudre, dûs à cette même incohérence.

Afin d'éviter ces problèmes, il est nécessaire de lancer le rebase avec les paramètres --preserve-merges (abrégé -p) et --interactive. De cette manière, les commits générés par la commande git subtree seront affichés dans l'éditeur de texte, ce qui permet de les remplacer par la commande qui les a générés. Les commits générés par la commande git subtree add devront être remplacés par une ligne exec git subtree add ..., et les commits générés par une commande git subtree pull ou git subtree merge devront être remplacés par exec git subtree merge ....

# Generated using: git subtree add --prefix subtrees/1 subtree1 test
# pick 3f429c7 Add 'subtrees/1/' from commit '9a658787b85e3df9de74e6f0eca9d90c0170753a'
exec git subtree add --prefix subtrees/1 9a658787b85e3df9de74e6f0eca9d90c0170753a
# Generated using: git subtree add --prefix subtrees/2 subtree2 master
# pick d76c632 Add 'subtrees/2/' from commit 'b5ca06c57a385b10a2c684dea8c686d279078072'
exec git subtree add --prefix subtrees/2 b5ca06c57a385b10a2c684dea8c686d279078072
# Generated using: git subtree pull --prefix subtrees/1 subtree1 master
# pick 6051a41 Merge commit '152e67c942d27929aab9ed374bb7e24b02f569de'
exec git subtree merge --prefix subtrees/1 152e67c942d27929aab9ed374bb7e24b02f569de