N’arbitrez vos conflits Git qu’une fois grâce à rerere
Par Christophe Porteneuve • Publié le 4 novembre 2014
• 8 min
(English version of this article here)
Il vous est sûrement déjà arriver d’arbitrer un conflit quelque part dans votre dépôt, pour retomber sur exactement le même plus loin (à l’occasion d’un autre merge, par exemple). Et hop, il a fallu refaire l’arbitrage.
C’est nul.
Pourtant, Git est tellement gentil qu’il offre un mécanisme pour vous éviter ça, au moins une partie du temps : rerere. D’accord, le nom n’est pas terrible, mais ça veut quand même dire Reuse Recorded Resolution, hein…
Dans cette article, on va explorer ensemble comment ça marche, quelles en sont les limites, et comment s’en servir au mieux.
Le souci classique : les merges de contrôle
Une situation qui bénéficie particulièrement de rerere, ce sont les merges de contrôle.
Vous avez une branche qui dure, ou va durer, assez longtemps ; par exemple, une branche fonctionnelle lourde. Appelons-là long-lived
. Et naturellement, plus le temps passe, plus vous angoissez à l’idée de la fusion qui intègrera, à terme, cette branche dans la principale (généralement master
), car au fur et à mesure, la divergence avec celle-ci s’épaissit…
Du coup, pour alléger la tension et faciliter l’intégration à terme, vous décidez de procéder de temps à autre à un merge de contrôle : un merge de master
dans votre branche, qui sans polluer master
va vous permettre de savoir « où vous en êtes » en termes de conflits potentiels.
C’est en effet pratique, et histoire de ne pas avoir à vous farcir les mêmes conflits à l’avenir, vous serez sans doute tentés de laisser le merge de contrôle une fois achevé, plutôt que de procéder, par exemple, à un git reset --hard ORIG_HEAD
pour le retirer de l’historique (et du graphe).
Du coup, au fil du temps, vous obtenez ce qu’on appelle un graphe en nervures ou en feuille d’arbre :
C’est assez moche et ça pollue le graphe de vos branches. Après tout, normalement un merge ne doit survenir dans le graphe que pour intégrer une branche finalisée.
Mais si vous annulez le merge une fois celui-ci bouclé, vous allez devoir vous re-farcir ses arbitrages au prochain contrôle. Alors comment faire ?
rerere à la rescousse
C’est justement le rôle de rerere. Cette fonctionnalité de Git prend une empreinte de chaque conflit lorsqu’il survient, et prend une seconde empreinte pour votre arbitrage (résolution) du conflit lorsque le commit problématique est finalisé.
Par la suite, si la même empreinte de conflit survient, rerere auto-résoudra celui-ci grâce à l’empreinte de résolution associée.
Activer rerere
rerere n’est pas seulement une commande, mais un comportement transverse de Git. Pour qu’il soit actif, il faut qu’au moins une des deux conditions suivantes soit remplie :
- La configuration en vigueur indique
rerere.enabled
àtrue
- Le dépôt contient un référentiel rerere (le dossier
.git/rr-cache
existe)
Je ne vois pas de cas où disposer de rerere est une mauvaise idée, aussi je vous invite à définir tout de suite ça en configuration globale :
git config --global rerere.enabled true
Apparition d’un conflit
Imaginons maintenant que nous avons une divergence porteuse de conflits, par exemple master
a fait évoluer le <title>
de notre index.html
d’une certaine façon, tandis que long-lived
l’a modifié différemment.
Tentons un merge de contrôle :
(long-lived) $ git merge master
Ça ressemble à un conflit classique, mais notez bien l’avant-dernière ligne :
Recorded preimage for ‘index.html’
C’est elle qui nous indique que rerere
a mémorisé l’empreinte du conflit. Et de fait, si on lui demande de quels fichiers il se préoccupe sur ce coup, il nous le dira :
(long-lived *+|MERGING) $ git rerere status
index.html
Si on examine notre dépôt, on trouve en effet une empreinte :
$ tree .git/rr-cache
.git/rr-cache
└── f08b1f478ffc13763d006460a3cc892fa3cc9b73
└── preimage
Ce fichier preimage
contient en fait l’empreinte complète du fichier et de son conflit (le blob intégral, quoi).
Enregistrer la résolution
OK, arbitrons le conflit. Par exemple, je vais mettre un <title>
combiné :
…
<head>
<meta charset="utf-8" />
<title>20% cooler and more solid title</title>
</head>
…
Je peux alors vérifier la résolution que rerere
va retenir une fois mon merge terminé :
$ git rerere diff
--- a/index.html
+++ b/index.html
@@ -2,11 +2,7 @@
<html>
<head>
<meta charset="utf-8">
-<<<<<<<
- <title>20% cooler title</title>
-=======
- <title>More solid title</title>
->>>>>>>
+ <title>20% cooler and more solid title</title>
</head>
<body>
<h1>Base title</h1>
Je peux alors marquer ma résolution de la façon habituelle, avec git add
. Après quoi git rerere remaining
m’indiquera la liste des fichiers qu’il suit et que je n’ai pas encore résolus (en l’occurrence, aucun).
En tous les cas, pour que rerere
prenne effectivement l’empreinte, je dois finaliser le commit en cours. Puisque je suis sur un merge
, il m’appartient d’exécuter le commit manuellement :
(long-lived +|MERGING) $ git commit --no-edit
Recorded resolution for 'index.html'.
[long-lived fcd883f] Merge branch 'master' into long-lived
(long-lived) $
Remarquez bien la ligne :
Recorded resolution for ‘index.html’.
Et de fait, le snapshot du fichier est désormais présent en postimage
dans le référentiel :
$ tree .git/rr-cache
.git/rr-cache
└── f08b1f478ffc13763d006460a3cc892fa3cc9b73
├── postimage
└── preimage
Je peux donc me permettre d’annuler ce merge de contrôle, que je ne souhaite pas laisser dans mon graphe :
(long-lived) $ git reset --hard HEAD^
HEAD is now at b8dd02b 20% cooler title
(long-lived) $
Réapparition du conflit
Supposons à présent que tant long-lived
que master
continuent à évoluer. Par exemple, dans la première, une CSS fait son apparition. Dans la seconde, la même CSS débarque (mais différente), ainsi qu’un fichier JS.
Arrive le moment où un nouveau merge de contrôle semble nécessaire. C’est reparti :
(long-lived) $ git merge master
Auto-merging style.css
CONFLICT (add/add): Merge conflict in style.css
Auto-merging index.html
CONFLICT (content): Merge conflict in index.html
Recorded preimage for 'style.css'
Resolved 'index.html' using previous resolution.
Automatic merge failed; fix conflicts and then commit the result.
(long-lived *+|MERGING) $
On a donc un conflit d’ajout simultané (add/add
) pour la CSS, et le conflit déjà connu sur index.html
. Mais regardez bien vers la fin :
Recorded preimage for ‘style.css’
Resolved ‘index.html’ using previous resolution.
Comme vous pouvez le voir, comme le conflit sur index.html
est celui déjà connu, il a été résolu. D’ailleurs, git rerere remaining
nous indique bien que seul style.css
pose encore problème.
Commençons donc pas retranscrire le fait que index.html
est au point, en le plaçant dans le stage :
$ git add index.html
Notez que si vous préférez que rerere
auto-stage les fichiers intégralement résolus, c’est possible : il vous suffit d’ajouter ceci à votre configuration :
$ git config --global rerere.autoupdate true
À partir de maintenant, je considère que c’est le cas. Comme tout à l’heure, on arbitre donc le conflit restant, puis :
(long-lived *+|MERGING) $ git commit -a --no-edit
Recorded resolution for 'style.css'.
[long-lived d6eea3e] Merge branch 'master' into long-lived
(long-lived) $
On a désormais une deuxième empreinte de résolution disponible, cette fois-ci basée sur style.css
:
$ tree .git/rr-cache
.git/rr-cache
├── d8cd8c78a005709a8aac404d46f23d6e82b12aee
│ ├── postimage
│ └── preimage
└── f08b1f478ffc13763d006460a3cc892fa3cc9b73
├── postimage
└── preimage
Pour finir, supposons que nous effectuons une dernière modification à notre index.html
, en rajoutant du contenu plus bas dans le <body>
. On en fait un commit.
C’est là le dernier commit nécessaire à long-lived
, et plutôt qu’un merge de contrôle, on décide directement de faire le merge terminal dans master
:
(master) $ git merge long-lived
Auto-merging style.css
CONFLICT (add/add): Merge conflict in style.css
Auto-merging index.html
CONFLICT (content): Merge conflict in index.html
Staged 'index.html' using previous resolution.
Staged 'style.css' using previous resolution.
Automatic merge failed; fix conflicts and then commit the result.
(master +|MERGING) $
Remarquez qu’au lieu du message « Resolved … using previous resolution », vu qu’on a demandé à rerere
de mettre automatiquement dans le stage tout fichier entièrement résolu, on a cette fois :
Staged ‘index.html’ using previous resolution.
Staged ‘style.css’ using previous resolution.
Et de fait, mon prompt ne mentionne que +
(staged), aucun *
(modified), ce qui me laisse à penser que je n’ai aucun conflit restant. Et de fait, git rerere remaining
ne m’affichera plus rien.
Il ne faut donc pas vous laisser abattre par le « Automatic merge failed » à la fin, qui indique juste que la fusion n’a pas pu s’effectuer par la simple stratégie de fusion ; mais en ayant recours à rerere
par-dessus, elle a pu aller au bout. Simplement, comme il n’est pas 100% garanti que la résolution de rerere
soit bien celle que vous vouliez (il se peut que le contexte ait changé…), Git refusera de finaliser le commit tout seul.
Pour vous assurer que la résolution est adéquate, si vous avez un doute, un simple git diff --staged
sur le fichier vous éclairera.
En tous les cas, il vous appartient donc de finaliser le commit :
(master +|MERGING) $ git commit
Indépendant du contexte
Il faut savoir que les empreintes enregistrées sont indépendantes du contexte :
- Peu importe la commande qui a pris l’empreinte (
merge
,rebase
,cherry-pick
,stash apply/pop
,checkout -m
, etc.) - Peu importe la commande qui s’en sert (idem)
- Peu importe le chemin du fichier (c’est le contenu du snapshot qui compte)
En revanche, une empreinte n’est utilisable que si son contexte immédiat de diff est préservé, comme pour tous les conflits de fusion. Si vous avez modifié une ligne trop proche de celles du diff de l’empreinte pré-résolution, rerere
refusera de la considérer comme toujours pertinente, et n’appliquera pas la résolution qu’il avait enregistrée.
Par ailleurs, si un nouveau conflit apparaît dans un fichier bénéficiant d’empreintes pour des conflits précédents, rerere
semble encore plus strict sur ses règles d’application de ces empreintes antérieures. Il est difficile de déterminer quels seuils il applique, mais il peut arriver qu’il ignore ses empreintes existantes pour en créer une supplémentaire dédiée au nouvel ensemble de conflits.
Seulement en local ?
Comme les hooks, le référentiel rerere
est stocké uniquement dans le dépôt (local) : il n’est pas transmis au remote lors des pushes (quelles qu’en soient les options).
Tout comme les hooks, ça ne veut pas dire que vous êtes fichus si vous souhaitez partager avec vos collègues votre référentiel d’empreintes (ce qui serait sans doute une bonne idée). On a plusieurs options, toutes basées sur des liens symboliques ou montages.
Option 1 : intégré au working directory
Il est possible de prévoir un dossier dans le WD dédié au partage d’éléments normalement locaux du dépôt, tels que rr-cache
et hooks
, par exemple.
Je propose généralement d’utiliser un dossier à la racine de l’arborescence, appelé .git-system
, dans lequel on aurait ces sous-dossiers. Et du coup, dans .git
, rr-cache
est un lien symbolique vers ../.git-system/rr-cache
. Sur OSX/Linux, ça se ferait comme ceci :
# Créer le dossier
mkdir .git-system
# Si le répertoire existe déjà dans le dépôt, le déplacer ;
# sinon, le créer à son emplacement final
[ -d .git/rr-cache ] && mv .git/rr-cache .git-system/ || mkdir .git-system/rr-cache
# Créer le lien symbolique relatif
ln -nfs ../.git-system/rr-cache
Sur un Windows, ça ressemblerait plus à ceci[^1] :
mkdir .git-system
if exist .git\rr-cache (move .git\rr-cache .git-system) else mkdir .git-system\rr-cache
mklink /d .git\rr-cache ..\.git-system\rr-cache
Dans un tel cas, l’idée est de ne pas inclure la pré-empreinte qui apparaît dans .git-system
lors du commit de résolution, et de faire un commit dédié juste après, avec tout le .git-system
dans le stage (git add .git-system
), dont le message indique que c’est du partage de résolutions rerere
.
Cette hygiène de découpe des commits, qui implique notamment d’éviter le git add .
en fin de résolution, est le principal inconvénient de cette approche, l’avantage étant qu’elle ne nécessite pas de dépôt supplémentaire.
Et puis vous allez devoir joingler un poil avec les commits pour ne garder à terme que celui des empreintes, et pas celui du merge de contrôle. Un rebase tri-partite peut aider, ça donnerait en fait ceci :
# 1. Je m’assure de ne pas committer .git-system par erreur
(long-lived *+|MERGING) $ git reset -- .git-system
(long-lived *+|MERGING) $ git commit --no-edit
# 2. Je committe juste .git-system
(long-lived *) $ git add .git-system
(long-lived +) $ git commit -m "Fix fingerprints for control merge"
# 3. Je réécris l’historique pour ne garder que le dernier des deux commits
(long-lived) $ git rebase --onto HEAD~2 HEAD^
Option 2 : avec un dépôt dédié aux partages
L’autre approche passe par un dépôt dédié aux partages des éléments normalement locaux (hooks, référentiel rerere
, etc.), dont chacun a une copie locale et utilise le remote pour partager tout ça entre collègues.
Seule la cible du lien symbolique change, pour quelque chose de fixe et absolu, idéalement un sous-répertoire spécifique à votre dépôt, à l’intérieur d’un dépôt central, genre :
~/.git-shared-locals/your-project/rr-cache
sur OSX/Linux, ouC:\Users\votre-nom\git-shared-locals\your-project\rr-cache
, sur Windows.
Du coup vous n’introduisez aucun contenu supplémentaire dans votre working directory lors des empreintes. Simplement, pour les partager, il faudra penser de temps en temps à aller dans l’autre dépôt, celui de partage, faire le ou les commits qui s’imposent, faire un git pull --rebase
pour récupérer les partages des copains et rejouer les vôtres par-dessus, puis git push
pour envoyer vos derniers partages.
C’est là aussi en deux temps, sur deux dépôts distincts (celui de votre projet et le dépôt central de partage), mais les risques de commits foireux mélangeant la résolution et les empreintes rerere
disparaissent.
[^1]: Depuis Windows Vista, la commande mklink
permet de faire un lien symbolique, mais dans la plupart des versions, il vous faudra soit le faire depuis une console lancée en privilèges admin (même si vous êtes admin, une console classique ne suffira pas), soit que la politique de sécurité locale de votre machine vous y autorise spécifiquement (votre compte utilisateur, pas un groupe). Parce que faire un lien symbolique c’est clairement un gros truc de pirate… Plus d’infos sur mklink. Pour Windows XP, vous pouvez utiliser les commandes Bash du script précédent depuis le Git Bash fourni par l’installeur.