Git Attitude

La gestion de sources qui fait du bien

N’arbitrez vos conflits Git qu’une fois grâce à rerere

| Commentaires

(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 :

"Graphe en nervures à coup de merges de contrôle"

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 :

1
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 :

1
(long-lived) $ git merge master

"Premier conflit avec rerere actif"

Ç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 :

1
2
(long-lived *+|MERGING) $ git rerere status
index.html

Si on examine notre dépôt, on trouve en effet une empreinte :

1
2
3
4
$ 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é :

1
2
3
4
5
6
<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é :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ 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 :

1
2
3
4
(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 :

1
2
3
4
5
$ 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 :

1
2
3
(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 :

1
2
3
4
5
6
7
8
9
(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 :

1
$ 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 :

1
$ 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 :

1
2
3
4
(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 :

1
2
3
4
5
6
7
8
$ 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 :

1
2
3
4
5
6
7
8
9
(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 :

1
(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 :

1
2
3
4
5
6
7
8
9
# 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 à ceci1 :

1
2
3
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.

"Partage par lien symbolique vers le working directory"

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
2
3
4
5
6
7
8
9
10
# 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, ou
  • C:\Users\votre-nom\git-shared-locals\your-project\rr-cache, sur Windows.

"Partage par lien symbolique vers un dépôt de partage dédié"

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.

Envie d’en savoir plus ?

Déjà, vous pouvez voir par le menu tout ce qui est apparu d’intéressant dans Git depuis la 1.7, grâce à notre article dédié. Vous pouvez aussi aller fouiller dans le détail la bonne utilisation de merge vs. rebase, si ce n’est déjà fait.

Mais surtout, notre formation Git Total explore ces thématiques et bien d’autres pour vous donner une compréhension en profondeur de Git, vous transformant en experts en seulement 3 jours, pour un tarif très raisonnable ! Disponible en inter-entreprises tous les 2 mois (hors été) et en intra-entreprises sur demande.


  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.

Commentaires

Ils nous font confiance : Kelkoo, MisterGoodDeal, PriceMinister, Blablacar / Comuto, Sarenza, Voyages-SNCF, LeMonde.fr, Fnac DIRECT, 20minutes, Orange, l’OCDE, Cisco, Alcatel-Lucent, Dassault Systèmes, EADS, Atos, Lagardère Interactive, Lesieur, L’Occitane en Provence, Météo France, 4D, Securitas, Digitas, Vivaki, Fullsix, Ekino, TBWA \ Paris, Valtech, Is Cool Entertainment, Open Wide…