Linux Embedded

Le blog des technologies libres et embarquées

Réorganiser ses commits avant un merge dans git

Lors du développement d'une fonctionnalité compliquée l'historique des modifications a tendance à se remplir de petits commits qui ne sont pas véritablement utiles (correction de bug n'ayant jamais été publiés, réorganisation de code...). Ces évolutions sont inévitables mais elles rendent la relecture du code très difficile voire impossible. Cette difficulté de relecture peut être un véritable obstacle lorsque l'on veut soumettre de grosses modifications à un projet upstream. Il vous sera souvent demandé de réorganiser vos modifications en une série de commits sur la branche master bien différenciés, dichotomisables et facilement lisibles individuellement.

Nous allons voire comment utiliser les différentes commandes de git pour y parvenir.

Un peu de préparation

La commande qui sert de fondation à la réorganisation de l'historique de code est la commande git rebase <master> <feature>. Cette commande va rechercher tous les commits qui sont des ancêtres de feature mais pas de master et les appliquer un à un sur master. Git déplacera ensuite la branche feature à la fin de la nouvelle suite de commits. Si aucune autre référence ne pointe sur l'ancienne position de feature, l'ancienne version de feature disparaîtra naturellement.

En d'autre termes, git rebase sert à transformer ceci :

en cela :

Ce genre de manipulation ne se fait généralement pas sans générer un grand nombre de conflits. Git est particulièrement intelligent pour les résoudre seul mais il y a un certain nombre de cas qu'il n'arrive pas à gérer.

Lors d'un rebase la cause la plus fréquente de conflits est la suivante :

Ici nous supposons que les modifications du commit 1 sont en conflit avec les modifications du commit 2. Le problème a été réglé lors du merge de master suivant, c'est à dire dans le commit 3. Lors du rebase git va tenter d'appliquer le commit 1 sur la pointe de master c'est à dire après le commit 2 ce qui causera un conflit. Git arrêtera le rebase immédiatement et vous laissera régler vous même le conflit. Une fois le conflit réglé git pourra poursuivre le rebase.

Git est assez intelligent pour ne pas appliquer le commit 3 (qui n'a plus lieu d'être) et les commits après le commit 3 ne devraient pas poser de problèmes car ils tiennent déjà compte de la modification introduite par 2.

Il est un peu dommage de devoir régler ce conflit manuellement. En effet le conflit entre 1 et 2 a déjà été réglé dans 3 mais git ne s'en apercevra que lorsqu'il arrivera au commit 3.

Pour réutiliser les informations de résolution de conflits git dispose d'une fonctionnalité appelée git rerere (reuse recorded resolution). Pour l'activer il faut utiliser une option de configuration de git :

$ git config rerere.enabled true

(vous pouvez utiliser l'option --global de git config pour l'activer pour tous vos dépôts)

Désormais git enregistrera toutes les résolutions de conflits et les utilisera si nécessaire. Pour apprendre à git à réutiliser les résolutions qui ont déjà eu lieu il faut utiliser un script fourni par le projet git mais qui n'est pas toujours disponible. Vous le trouverez ici.

Ce script sert à apprendre à git-rerere à utiliser des résolutions. Il s'utilise comme suit :

$ rerere-train.sh master..feature

Git relit alors tous les commits entre master et feature pour mémoriser les résolutions de conflits.

Avec ces préparatifs il est temps de déplacer notre branche vers sa nouvelle position basée sur master.

Votre premier rebase

Pour transformer notre branche en une branche basée sur master, la commande à effectuer est la suivante :

git rebase  master feature

Cette manipulation déplace un grand nombre de commit. Si les commits s'appliquent sans conflits git ne vous demandera pas d'intervenir.

La sortie standard de git lors d'un rebase est assez explicite. Un rebase typique aura une sortie similaire à celle ci :

First, rewinding head to replay your work on top of it...
Applying: add lua library detection, unused at this point
Applying: replace vimkeys with lua, implement quit() as a test
Using index info to reconstruct a base tree...
Falling back to patching base and 3-way merge...
Auto-merging src/control/control.c
Auto-merging src/common/darktable.h
Auto-merging src/common/darktable.c
Auto-merging src/CMakeLists.txt
CONFLICT (content): Merge conflict in src/CMakeLists.txt
Failed to merge in the changes.
Patch failed at 0002 replace vimkeys with lua, implement quit() as a test
When you have resolved this problem run "git rebase --continue".
If you would prefer to skip this patch, instead run "git rebase --skip".
To check out the original branch and stop rebasing run "git rebase --abort".


Comme souvent avec git, toutes les informations utiles sont là. Nous allons revoir ensemble ce qui s'est passé et ce que nous devons faire.

Tout d'abord git construit la liste des commits à appliquer puis les ajoute un à un sur master. Le premier patch ne cause pas de conflit (la premiere ligne Applying: ) mais le deuxième patch ne peut pas être appliqué proprement. Il va falloir régler le conflit sur le fichier src/CMakelist.txt

La command git status nous permet de retrouver la liste des fichiers en conflit :

# Not currently on any branch.
# Changes to be committed:
#   (use "git reset HEAD <file>..." to unstage)
#
#	modified:   src/common/darktable.c
#	modified:   src/common/darktable.h
#	new file:   src/common/dt_lua.c
#	new file:   src/common/dt_lua.h
#	modified:   src/control/control.c
#
# Unmerged paths:
#   (use "git reset HEAD <file>..." to unstage)
#   (use "git add/rm <file>..." as appropriate to mark resolution)
#
#	both modified:      src/CMakeLists.txt
#
# Untracked files:
#   (use "git add <file>..." to include in what will be committed)
#
#	my_build.sh

Nous voyons ici que les fichiers qui ne posent pas de problèmes ont déjà été ajoutés à l'index et que le fichier en conflit est modifié mais pas encore indexé. Git refusera de reprendre le rebase tant que le conflit n'aura pas été réglé. Notons également que git  indique les commandes les plus pertinentes pour la suite du rebase.
Tout d'abord, il faut régler le conflit dans le fichier indiqué. Le fichier contient des marqueurs de conflits classiques indiquant les zones de code à corriger

  "common/darktable.c"
  "common/database.c"
<<<<<<< HEAD
  "common/dbus.c"
=======
  "common/dt_lua.c"
>>>>>>> replace vimkeys with lua, implement quit() as a test
  "common/exif.cc"

Ici la branche master a ajouté une directive pour common/dbus.c exactement à l'endroit où notre patch ajoute une directive pour common/dt_lua.c Le conflit est facile à régler, il suffit d'utiliser les deux directives.
Une fois le fichier réparé et les modifications testées, nous indiquons à git que le conflit est résolu avec

git add src/CMakelist.txt

puis

git rebase --continue

Si vous êtes perdu et que vous voulez tout arrêter c'est possible également. il suffit de faire

git rebase --abort

et git remettra en place la branche feature comme si le rebase n'avait jamais eu lieu.

Il peut arriver que git rebase s'arrète sur un conflit avec un message similaire au message ci-dessous

First, rewinding head to replay your work on top of it...
Applying: add lua library detection, unused at this point
Applying: replace vimkeys with lua, implement quit() as a test
Using index info to reconstruct a base tree...
Falling back to patching base and 3-way merge...
Auto-merging src/control/control.c
Auto-merging src/common/darktable.h
Auto-merging src/common/darktable.c
Auto-merging src/CMakeLists.txt
CONFLICT (content): Merge conflict in src/CMakeLists.txt
Resolved 'src/CMakeLists.txt' using previous resolution.
Failed to merge in the changes.
Patch failed at 0002 replace vimkeys with lua, implement quit() as a test
When you have resolved this problem run "git rebase --continue".
If you would prefer to skip this patch, instead run "git rebase --skip".
To check out the original branch and stop rebasing run "git rebase --abort".

Ici, git a retrouvé une résolution de conflit pré-enregistrée et l'a utilisée pour résoudre le conflit. Il n'y a donc plus de fichiers en conflits mais git a tout de même interrompu le rebase pour permettre une vérification manuel. Une fois les modifications ajoutées le rebase peut reprendre.

Les autres rebase

Avoir notre branche à jour et basée sur la branche master n'est que la première étape. Nous allons maintenant voire comment réorganiser les commits pour simplifier la relecture de notre branche. Pour cela nous allons à nouveau utiliser git rebase de façon répétée.

Pour rappel, notre historique a maintenant l'allure suivant :

Nous allons à nouveau demander à git de faire un rebase de feature sur master mais nous allons également demander à git de s’arrêter sur certains commits pour nous permettre d'intervenir et de les modifier, les supprimer, les réordonner ou les fusionner.

En faisant ces rebase de façon répétée nous allons démêler petit à petit les dépendance entre les commits pour arriver à une branche propre et acceptable pour un relecteur.

Pour faire un rebase permettant d'intervenir sur les commits il faut utiliser la commande suivante :

git rebase -i master feature

Cette commande demande à git d'ouvrir un éditeur de texte avec un pseudo-fichier nous permettant de lui préciser l'action à effectuer pour chaque commit. Une fois le fichier édité git commencera le rebase en tenant compte des instructions qui lui ont été données.

Le pseudo fichier aura un contenu similaire à l'exemple ci-dessous :

pick d984258 add lua library detection, unused at this point
pick 1632c91 replace vimkeys with lua, implement quit() as a test
pick 75d27ec port to lua 5.2 before anything serious is developed
pick 116a2bb first take at indexing images
pick 298aaf5 images var can now be iterated, the general stmt API is still a work in progress
pick 9bbc0cd code reorganisation
pick a5ebe4d first useful field in image : exif_exposure
pick a073c31 do proper handling of the image cache.

# Rebase 1e300df..5aeee1d onto 1e300df
#
# Commands:
#  p, pick = use commit
#  r, reword = use commit, but edit the commit message
#  e, edit = use commit, but stop for amending
#  s, squash = use commit, but meld into previous commit
#  f, fixup = like "squash", but discard this commit's log message
#  x, exec = run command (the rest of the line) using shell
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
# However, if you remove everything, the rebase will be aborted.
#

Le contenu du fichier est assez clair et le petit texte à la fin nous indique (une fois de plus) ce que nous avons à savoir.

Chaque ligne représente un commit. Ces lignes commencent par un mot clé indiquant l'action à effectuer pour ce commit, le SHA du commit et enfin la première ligne du message du commit.

En éditant ces lignes, nous pouvons facilement

  • réordonner des commits
  • éliminer des commits
  • fusionner des commits
  • éditer des commits au milieu de la liste
  • exécuter une commande entre deux commits (en particulier make) et interrompre le rebase si la commande échoue

Lorsqu'un commit est marqué avec le mot clé edit git interrompt le rebase après l'avoir appliqué et commité  pour vous permettre d'intervenir.

  • Si vous faites des modifications dans cet état et que vous les commitez, cela insérera un nouveau commit après celui que vous avez marqué pour l'édition.
  • Si vous commitez avec le paramètre --amend vous modifierez le commit que vous avez marqué au lieu de créer un nouveau commit.
  • En mélangeant ces deux méthodes vous pouvez scinder le commit marqué en plusieurs commits.

L'outil git gui est particulièrement utile pour visualiser les changements effectués par le dernier commit.

Ma méthode personnelle pour traiter un commit consiste à mettre git gui en mode amend, à désindexer toutes les modifications puis à les ré-indexer après relecture. Lorsque plusieurs modifications logiques sont mélangées, j'en choisis une, j'index toutes les lignes correspondantes, je commit, puis je recommence avec la modification logique suivante jusqu'à ne plus avoir de modifications non-indexées. Cela permet de créer facilement plusieurs commit à partir du commit original.

Git gui permet facilement d'indexer une partie des modifications d'un fichier en sélectionnant les modifications dans la fenêtre principale puis en faisant clic droit => indexer les lignes

Quelques outils pour analyser l'historique de votre branche

Pour être efficace dans le genre d'exercices que nous décrivons ici il est important de bien connaitre les outils que git fournit pour analyser l'historique des branches et retrouver la trace des modifications. Petite revue des outils les plus utiles

  • git log master..feature [ -- <fichier>] permet de lister les commits affectant un fichier donné. La commande git log comporte beaucoup d'options pour choisir ce qui est ou n'est pas affiché.
  • git show <commit> permet d'afficher un commit donné sous forme de diff. C'est utile pour regarder ce que fait un commit sans passer par gitk.
  • gitk master..feature [ -- <fichiers>] l'outil graphique le plus courant pour visualiser l'arbre des versions. La commande ci-dessus permet de visualiser la même information que git log mais l'interface graphique est plus pratique lorsque l'on cherche à avoir une vue d'ensemble des modifications.
  • git blame <fichier> Affiche le contenu d'un fichier en précédant chaque ligne d'information sur le dernier commit ayant affecté cette ligne. Cette commande est utile lors d'un rebase pour retrouver quel commit a affecté une zone problématique.
  • git gui blame <fichier> Affiche les informations de git blame dans un navigateur graphique. Je trouve cela plus pratique que la ligne de commande mais l'information affichée est la même. Git gui blame permet de naviguer facilement dans l'historique d'un fichier donné.
  • git mergetool demande à git de lancer un outil graphique pour régler les conflits. La présentation exacte dépends de l'outil que git trouvera sur votre système, mais git sait utiliser araxis, bc3, diffuse, ecmerge, emerge, gvimdiff, kdiff3, meld, opendiff, p4merge, tkdiff, tortoisemerge, vimdiff et xxdiff. Git appellera l'outil avec les bons paramètres pour visualiser le conflit et indexera les fichiers après modification. Il ne vous restera plus qu'à vérifier que tout est correct et invoquer git rebase --continue pour continuer le rebase.
  • git bisect permet de rechercher un bug par dichotomie. C'est utile lorsque l'une de vos modifications rend certaines étapes de votre branche  non compilables. Il faut l'utiliser avec prudence car, souvent, l'erreur est réparée par un commit ultérieur et cela complique le bisect. Cela reste un outil très utile.
  • graph_git.pl master..feature est un petit script indispensable. Il analyse les commits passés en paramètre et crée un fichier SVG décrivant les interdépendances. Cela permets de repérer des commits intéressant à regrouper ou à scinder.

Stratégies de réorganisation

Nous venons d'étudier git rebase qui est le principal outil pour réorganiser notre branche et nous avons également vu d'autres outils pour analyser l'historique des modifications. Nous allons maintenant réfléchir à ce que nous cherchons à obtenir lorsque nous réorganisons l'historique d'une branche.

Tant qu'une branche n'a pas été fusionnée et publiée son historique n'a pas de valeur. L'historique des modifications sert principalement à tracer les évolutions entre versions et à trouver la source d'un bug. L'historique "véritable" d'une branche n'a pas d’intérêt pour ces use-cases.

L'historique d'une branche est plus utile si elle permet de séparer le code en une suite de modifications simples et compréhensibles qui facilitent la relecture et l'analyse à posteriori.

Malheureusement le développement logiciel ne fonctionne pas ainsi et a tendance à produire un grand nombre de commits faisant des allers et retours entre fonctionnalités, des corrections de bugs et autres retouches de présentation du code.

Il va donc falloir réorganiser les commits de façon à faciliter la relecture plutôt que de garder la liste des modifications contradictoires que génère un processus normal de développement.

Notre but premier est donc la lisibilité. Il s'agit de séparer les modifications de notre branche en un  ensemble de commits logiquement indépendants et dichotomisables.

L'historique d'une branche de travail contient donc un certain nombre de commits dont le contenu doit être dans l'historique propre mais qui ne respectent pas les règles que nous nous sommes fixés.

  • Les commits de correction de bug : Si il s'agit d'un bug présent dans master il n'a rien à faire dans notre branche. Le mieux est de le soumettre séparément et/ou de le mettre en tête de notre branche pour qu'il soit relu indépendamment de nos modifications. Si il s'agit d'un bug présent uniquement dans la nouvelle fonctionnalité il vaut mieux fusionner la correction avec le commit ayant introduit le bug. Cela facilite la relecture et évite que le relecteur rapporte un bug dans un commit qui est corrigé par un commit ultérieur.
  • Les petites réparations dans des commits n'ayant rien à voire : typiquement des instructions de debug oubliées et supprimées discrètement dans un commit ultérieur, des petits changements de présentation, des corrections d'espace, d'indentation ou de blancs en fin de ligne. Il faut séparer en plusieurs commits, un commit principal avec le changement fonctionnel et de petits commits pour chacune des corrections. Lors du rebase suivant les petits commits seront déplacés puis fusionnés avec les commits dont ils font logiquement partie.
  • Les changements logique étalés sur plusieurs commits : il suffit généralement de les fusionner, mais il peut y avoir des commits n'ayant rien à voire au milieu. Il faudra donc déplacer ces derniers.
  • Les mauvaises idées qui ont été supprimés par un commit ultérieur. En fusionnant le commit et son inverse on fait disparaître cette information inintéressante et qui occuperait inutilement un relecteur.

Dans un monde idéal, les réorganisations ne causent pas de conflits. En pratique vous aurez beaucoup de conflits à régler lors de vos différents rebase et même si la plupart d'entre eux sont triviaux voici quelques conseils pratiques qui vous feront gagner beaucoup de temps.

  • Ne faites qu'une modification à chaque cycle de rebase. Une séparation, un déplacement, une fusion, mais pas plus d'une action.
  • git rebase --abort est une commande très sécurisante. Savoir qu'elle existe permet de tenter des manipulations en sachant que l'on peut facilement revenir en arrière. N'hésitez pas à vous en servir si vous n'êtes pas certain de ce qui se passe.
  • ne soyez pas paresseux. Testez vos changements à chaque étape. Généralement une recompilation suffit, mais n'oubliez pas de la faire. Vous pouvez automatiser partiellement cette vérification avec la commande exec de git rebase
  • Vous vous retrouverez souvent avec des commits temporaires qui ont été créées en scindant d'autres commits. Marquez les pour ne pas les perdre. Je préfixe leurs messages de commit par tmp: pour les retrouver facilement dans la liste de git rebase.
  • Utilisez graph_git régulièrement. Cet outil permet de repérer les suites de commit affectant les mêmes fichiers (généralement une seule modification logique sur plusieurs commits) ou les commits modifiant des fichiers qui n'ont rien à voire avec leur fonctionnalité principale (généralement un bug corrigé en douce et donc un commit à scinder)

Le gros de la réorganisation dépend évidemment de votre travail mais si vous vous mettez à la place de votre relecteur vous devriez facilement trouver comment simplifier et organiser votre branche pour rendre l'évolution du code de votre fonctionnalité plus lisible et le travail de relecture moins pénible.

Pour conclure

Le genre de réorganisation de code que nous venons de décrire peut paraître effrayant mais avec les outils que git nous fournit cela devient une tâche possible et un exercice assez intéressant. J'ai observé que réorganiser le code ainsi permet de repérer et réparer un certain nombre de bugs et permet d'avoir du code plus clair et bien compris de tous lors de la fusion. C'est un travail que je recommande de faire et qui serait sans doute impossible sans un outil tels que git. De plus un certain nombre de projets open-source (en particulier le kernel linux) exigent ce genre de travail avant toute fusion. C'est d'ailleurs une politique que je recommande pour vos projets.

    • le 27 juin 2016 à 15:19

      Je trouve que cet article est très instructif

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée.