~rom1v/blog { un blog libre }

GIT : squasher des merges

gitmerge

Supposons que je souhaite ajouter une fonctionnalité à un projet sur GIT.

Je prends la version actuelle de la branche master (A), puis ajoute sur ma branche topic les commits X et Y.

    X---Y  topic
   /
--A  master

Je propose la fonctionnalité upstream (par un git request-pull ou une pull request), qui met un peu de temps à être revue.

Pendant ce temps, la branche master a avancé, et malheureusement les modifications effectuées entrent en conflit avec mon travail sur topic.

    X---Y  topic
   /
--A---B---C  master

Une fois mon code revu et accepté, les mainteneurs vont alors me demander de résoudre les conflits avec la branche master avant de merger ma branche topic.

Si j’avais eu à prendre en compte les mises à jour de master avant d’avoir rendu public mon topic, j’aurais simplement rebasé mon travail par-dessus master. Mais là, impossible.

Je dois donc merger. Très bien. Je merge et je résous les conflits.

    X---Y---M  topic
   /       /
--A---B---C  master

Mais, alors que je n’ai pas encore rendu M public, je m’aperçois qu’il y a un nouveau commit D sur master, que je veux intégrer dans topic.

    X---Y---M  topic
   /       /
--A---B---C---D  master

La solution la plus évidente est de merger à nouveau.

    X---Y---M---N  topic
   /       /   /
--A---B---C---D  master

Mais je voudrais éviter un commit de merge inutile. Pour un seul, ce n’est pas très gênant, mais si on maintient une branche suffisamment longtemps avant qu’elle ne soit mergée, ces commits inutiles vont se multiplier.

Une solution serait de revenir à Y et de le merger avec D :

git checkout topic
git reset --hard Y
git merge master

Ce qui donne :

    X---Y---M'  topic
   /         \
--A---B---C---D  master

Mais dans ce cas, pour créer M', je vais devoir résoudre à nouveau les conflits que j’avais déjà résolu en créant M.

Comment éviter ce problème ?

rerere

Une solution est d’avoir activé rerere avant d’avoir résolu les conflits de M :

git config rerere.enabled true

Ainsi, lorsque je tenterai de merger à nouveau Y et D, les conflits entre Y et C seront automatiquement résolus de la même manière que précédemment.

Cependant, cette méthode a ses inconvénients.

Tout d’abord, il ne s’agit que d’un cache local de résolutions des conflits, stocké pendant une durée déterminée (par défaut à 60 jours pour les conflits résolus), ce qui est peu pratique si on clone son dépôt sur plusieurs machines (les conflits ne seront résolus automatiquement que sur certaines).

Ensuite, elle est inutilisable lorsqu’on souhaite squasher un merge conflictuel alors que rerere était désactivé lors de sa création.

Enfin, cette fonctionnalité est encore récente, et la fonction git rerere forget (pour permettre de résoudre autrement des conflits déjà résolus), a la fâcheuse tendance à segfaulter (un patch a été proposé).

Rebranchement

La solution que j’utilise est donc la suivante.

    X---Y---M---N  topic
   /       /   /
--A---B---C---D  master

Une fois obtenus les deux merges M et N, le principe est de remplacer le parent de N, qui était M, par Y, sans rien changer d’autre au contenu.

          -----
         /     \
    X---Y---M   N' topic
   /       /   /
--A---B---C---D  master

Ainsi, M devient inatteignable, et c’est exactement le résultat souhaité :

    X---Y-------N' topic
   /           /
--A---B---C---D  master

Pour faire cela, il faut déplacer le HEAD (pointant vers topic) sur Y, faire croire à GIT qu’on est en phase de merge avec D en modifiant la référence MERGE_HEAD, puis commiter :

git checkout N
git reset --soft Y
git update-ref MERGE_HEAD D
git commit -eF <(git log ..HEAD@{1} ^master --pretty='# %H%n%s%n%n%b')

Il n’y a plus qu’à éditer le message de commit de merge.

La fin de la ligne du git commit permet de concaténer l’historique des commits intermédiaires (a priori uniquement des merges) comme lors d’un squash avec git rebase (pour pouvoir conserver les messages de merges intermédiaires, contenant nontamment les conflits).

En utilisant les références plutôt que les numéros de commit, cela donne :

git checkout feature
git reset --soft HEAD~2
git update-ref MERGE_HEAD master
git commit -eF <(git log ..HEAD@{1} ^master --pretty='# %H%n%s%n%n%b')

Si vous avez plus simple, je suis preneur…

Merci aux membres de stackoverflow.

Commentaires

G-rom

Je vois souvent passer ce genre d’article autour de GIT. Je n’ai jamais compris pourquoi certain se donnait autant de mal pour “cacher” des choses réelles. Pour moi il ne faut pas chercher à tordre GIT et l ‘historique de nos actions sur une branche à tout prix. Ce n’est pas parce qu’on peut le faire qu’il faut le faire. Gardez les choses simples et acceptez d’avoir des graph, des logs, des historiques un peu “complet” de temps en temps, plutôt que de vouloir nettoyer, renommer, rebase, à posteriori tout le temps.

En tant que chef de projet je préfère 100 fois relire un historique complet mais réel, que de repasser derrière quelqu’un qui m’a tout manipulé et transformé, et qui 1 fois sur 2 va juste se planter et me foutre un gros bordel.

bochecha

En fait, je comprends pas pourquoi ne pas simplement rebaser ta branche topic sur le nouveau master.

Si tu en est à faire une demande de pull/merge, c’est que tu as commit/push dans ton propre dépôt, pas dans le dépôt officiel du projet.

En conséquence, ceux qui te clonent doivent s’attendre à ce que tu fasses des rebases réguliers sur le dépôt officiel, et à ce que leurs clones cassent.

C’est dans un dépôt vraiment public, comme le dépôt officiel d’un projet, que les rebases sont une mauvaise chose. Mais un dépôt personnel pour implémenter des features avant de les faire merger peut-être considére comme privé : certes il est visible de tous, mais c’est ton terrain de jeu personnel.

®om

@G-rom C’est un grand débat… Pour moi, cela dépend du contexte.

Dans le cas d’une pull request, il est préférable d’avoir un historique propre. D’abord parce que les mainteneurs du projet n’en ont rien à faire de ton historique local crade, ensuite parce que ça rend plus difficile la revue de code.

L’historique propre (dans la mesure du possible) permet de mieux cerner ce que le développeur propose comme fonctionnalité, comment il l’a fait, quelles sont les différentes parties de son implémentation…

@bochecha Même si le code est dans ton propre dépôt, tu as fait une pull request publique. Des utilisateurs qui suivent le projet officiel peuvent l’avoir récupérée pour la tester et éventuellement déjà l’utiliser, avant même l’intégration “officielle”.

Tu ne peux pas te permettre de casser tout ça à chaque fois que tu modifies quelque chose. Ce n’est plus “ton terrain de jeu personnel”.

Linus Torvalds :

once you’ve published your history in some public site, other people may be using it, and so now it’s clearly not your _private_ history any more.

D’ailleurs, il me semble que sur github, le fait de faire un rebase et de pusher (avec -f) ferme automatiquement la pull request (en fait, non).

EDIT by ®om_2015 : je suis d’accord avec toi @bochecha, sur une PR c’est mieux de rebaser. N’écoute pas ®om_2013, il dit n’importe quoi ;-)

bochecha

Des utilisateurs qui suivent le projet officiel peuvent l’avoir récupérée pour la tester et éventuellement déjà l’utiliser, avant même l’intégration “officielle”.

Et ceux-ci doivent s’attendre à ce que le code que tu as soumis ne soit pas accepté tel quel.

Prenons un exemple quelque peut différent, sans merge.

Je prends la version actuelle de la branche master (A), puis ajoute sur ma branche topic les commits X et Y, et je soumets ça upstream. (pour l’instant, c’est ton exemple)

Là, upstream review mon code, et me dit qu’ils aiment bien la feature, mais me demandent quelques changements.

Il va donc falloir que j’édite mon changement de manière à leur plaire.

Je vais donc faire un changement Z qui apporte les corrections demandées par upstream.

Je pourrais éditer la pull-request pour simplement y ajouter Z, mais dans ce cas, ceux qui reviewent les changements vont devoir lire les 3, s’apercevoir que le premier est mauvais, puis voir la correction dans le troisième.

Ou alors, je peux rebaser mes 3 changements en 2, la correction du Z étant par exemple mergée avec X en un X’, et donc ne soumettre que 2 changements.

Dans le second cas, la review sera plus facile pour upstream, ainsi que pour ceux qui suivent d’à côté.

Et non, un force-push ne ferme pas une pull-request sur Github, il la met à jour simplement avec les nouveaux commits.

Enfin, pour ce qui est de Linus, tu prends son commentaire trop littéralement justement, comme j’essayais de le dire dans mon premier commentaire.

Regarde le workflow de contribution au kernel :

  • dev A envoie une série de 5 patches
  • mainteneur B dit que le patch 3 est mauvais parce que bla blah blah
  • dev A renvoie la même série de 5 patches, avec juste le patch 3 de change pour y intégrer les corrections demandées

C’est exactement la même chose que ce que je disais au-dessus. La seule différence est que les changements soumis au kernel sont envoyés par email sur une mailing-list, plutôt que par des pull-requests sous github.

Mais dans les deux cas, le dev a fait un rebase, et ceux qui avaient appliqué la série de patches originelle, devront aussi se farcir de réappliquer la nouvelle série après corrections.

Et c’est quelque chose à quoi les gens qui utilisent du code hors des dépôts officiels s’attendent.

®om

@bochecha

Et ceux-ci doivent s’attendre à ce que le code que tu as soumis ne soit pas accepté tel quel.

Ce n’est pas parce que le code n’est pas accepté tel quel que l’historique doit être changé.

®om_2015 n’est pas d’accord ;-)

Ce que tu dis est vrai, tout dépend où l’on place le curseur entre conserver l’historique et faire des commits propres.

Tu peux conserver l’historique à tout prix (plutôt la position de @G-rom), tant pis pour les commits sales.

Tu peux faire des commits propres à tout prix (plutôt ta position), tant pis pour l’historique.

Mais personnellement, étant donné que la perte de l’historique est surtout problématique lorsque d’autres ont déjà pris ton code, je préfère un compromis entre les deux : réécrire l’histoire pour faire des commits propres si et seulement s’ils n’ont pas été publiés.

G-rom

Et pourquoi juste ne pas être minutieux et faire des commits propre dès le début ? Ok c’est impossible de faire du 100%, mais ça évite de modifier l’historique et de perdre du temps et d’introduire du risque.

®om

@G-rom Ce n’est pas une question de minutie.

Par exemple, tu implémentes une nouvelle fonctionnalité, que tu souhaites séparer en étapes successives bien distinctes (1 commit par étape).

Lors de la troisième étape, tu te rends compte qu’il y a un bug dans la première : tu veux corriger le bug dans le premier commit… pas autre part.

Jean

Une question que je me pose, c’est plutôt que de faire croire à git qu’on fait un merge, pourquoi ne pas vraiment faire un merge ? En l’occurrence, pas besoin de créer le commit N, on annule le merge de M, et on rejoue sur le master à jour :

git checkout topic  # (ou git checkout M)
git reset --hard Y
git merge master

Si on a activé rerere, le conflits de M devraient être résolus automatiquement aussi.

®om

@Jean C’est exactement ce que j’explique dans le billet (avec les mêmes commandes en plus) ;-)

Jean

Rhaa je suis con, je suis passé trop vite sur le milieu de l’article ;)

Les commentaires sont fermés.