Do you even rerere?

January 19, 2015Tristan Roussel11 min read

TL;DR: git hidden gem to remember how to solve conflicts

Hello my dearest git geeks! Do you know about git-rerere? It's a little thing that saved my life once and I want to explain to you how it can help you too in your workflow.

First, let me explain what is rerere:

REuse REcorded REsolution

Gosh, I hate git conflicts. They can arise when making a merge commit, but also when applying a stash state or cherry-picking a commit. Sometimes, it's easy to resolve, and sometimes, well... it can be a nightmare. Now, imagine you just resolved the hardest conflicts ever and committed a super nice merge commit.

What if some of these conflicts happen again in the future? Scary, right?

That's where git-rerere comes to shine: in this unlikely but unfortunate case, git can reuse your super-resolution to automatically avoid the conflict. Sweet! As usual, git has amazing documentation and even an awesome blog entry for this tool, check it out! Now, let me show you this in action.

First thing first, activate rerere:

git config rerere.enabled true

You can pass --global if you want to set it for every project on your machine.

A code base is worth a thousand words

I wrote up a little story to explain everything. So here is my shining repository with one awesome lyrics file. Clone it if you want to follow me more easily!

I have a development branch going strong with some commits:

* commit 72ea7a30f814ec1552eea1af44631bb67ab97ad8 (origin/rockify-a-lot, rockify-a-lot)
| Author: Tristan Roussel <super@disney.fan>
| Date:   Mon Jan 5 21:49:23 2015 +0100
|
|       Queenify these lyrics!
|
* commit b5f87ebdb50da772c4bea483878775fc0631effd
| Author: Tristan Roussel <super@disney.fan>
| Date:   Mon Jan 5 21:58:37 2015 +0100
|
|     Nickelbackify the lyrics :/
|
* commit a15f60e322646347443548bbc6d017c5ae070d88 (origin/master, master)
Author: Tristan Roussel <super@disney.fan>
Date:   Mon Jan 5 21:47:52 2015 +0100

Let it go

My good friend Frank wants to add a commit to this development branch, and he sent me a Pull Request. Unfortunately, there are conflicts and we'll have to do this manually.

So let's give it a try with git checkout rockify-a-lot && git merge feature/pink-floydify. Conflicts, indeed. Git being amazing, it takes you by the hand and tells you exactly what to do.

Auto-merging ice.txt
CONFLICT (content): Merge conflict in ice.txt
Recorded preimage for 'ice.txt'
Automatic merge failed; fix conflicts and then commit the result.

Notice the strange third line. In case you missed it, git status still gives you all the information you need:

On branch rockify-a-lot
You have unmerged paths.
(fix conflicts and run "git commit")

Unmerged paths:
(use "git add <file>..." to mark resolution)

both modified:   ice.txt

no changes added to commit (use "git add" and/or "git commit -a")

Here is the result of git diff:

diff --cc ice.txt
index 888de14,606a0d8..0000000
--- a/ice.txt
+++ b/ice.txt
@@@ -1,6 -1,6 +1,10 @@@
The snow glows white on the mountain tonight
Not a footprint to be seen.
++<<<<<<< HEAD
+The show must go on!
++=======
+ Come on you stranger, you legend, you martyr, and shine!
++>>>>>>> feature/pink-floydify
and it looks like I'm the Queen
Cause living with me must have damn near killed you

Ok, so here's my resolution, you can check it on branch rockify-everything if you want:

commit 5677725c7d9f8fa6c97de4f79ee9d772c8d6af6d
Merge: 72ea7a3 9965c4c
Author: Tristan Roussel <super@disney.fan>
Date:   Thu Jan 15 22:47:06 2015 +0100

Merge remote-tracking branch 'feature/pink-floydify' into rockify-everything

Conflicts:
ice.txt

diff --cc ice.txt
index 888de14,606a0d8..2a16731
--- a/ice.txt
+++ b/ice.txt
@@@ -1,6 -1,6 +1,7 @@@
The snow glows white on the mountain tonight
Not a footprint to be seen.
+The show must go on!
+ Come on you stranger, you legend, you martyr, and shine!
and it looks like I'm the Queen
Cause living with me must have damn near killed you

I enjoin you to commit a resolution yourself too so that you can see this message appear after the commit:

Recorded resolution for 'ice.txt'.
[rockify-a-lot 5677725] Merge branch 'feature/pink-floydify' into rockify-a-lot

The resolution is now saved by git, and every time it does occur again in the future, the saved resolution will be applied automatically!

The prestige

You don't believe me (hint: you should not until you see for yourself)? I've set up two branches just for that on top of our previous work:

  * commit 88c2ce424a96566089597f653fc9d51f82a169ff (origin/feature/pink-floydify-again, feature/pink-floydify-again)
  | Author: Jim Morrison <another@legend.com>
  | Date:   Mon Jan 5 23:06:57 2015 +0100
  |
  |     And again for Pink Floyd
  |
* | commit cca913282eea558f84eaf59e084eea9024fd6e04 (origin/rockify-again, rockify-again)
|/  Author: Tristan Roussel <super@disney.fan>
|   Date:   Mon Jan 5 23:01:39 2015 +0100
|
|       I've already seen that
|
* commit 9a4788c3697d60014d3bb8a2a63fab9605d7acc4
| Author: Tristan Roussel <super@disney.fan>
| Date:   Mon Jan 5 22:55:03 2015 +0100
|
|     A brand new start for lyrics
|
* commit 5677725c7d9f8fa6c97de4f79ee9d772c8d6af6d (origin/rockify-everything, rockify-everything)

Now, if I do git checkout rockify-again && git merge feature/pink-floydify-again:

Auto-merging ice-without-nickelback.txt
CONFLICT (content): Merge conflict in ice-without-nickelback.txt
Resolved 'ice-without-nickelback.txt' using previous resolution.
Automatic merge failed; fix conflicts and then commit the result.

Still failed, but there is a big difference, with git diff:

diff --cc ice-without-nickelback.txt
index 815a5ee,78d453f..0000000
--- a/ice-without-nickelback.txt
+++ b/ice-without-nickelback.txt
@@@ -1,5 -1,5 +1,6 @@@
The snow glows white on the mountain tonight
Not a footprint to be seen.
+The show must go on!
+ Come on you stranger, you legend, you martyr, and shine!
and it looks like I'm the Queen

The conflict is resolved using previous knowledge! Just need to add the file and we're good to go. But I'm lazy, and I want to remove this one more step. Let's add another option in our configuration git config rerere.autoupdate true and try again with git reset --hard origin/rockify-again && git merge feature/pink-floidify-again:

Auto-merging ice-without-nickelback.txt
CONFLICT (content): Merge conflict in ice-without-nickelback.txt
Staged 'ice-without-nickelback.txt' using previous resolution.
Automatic merge failed; fix conflicts and then commit the result.

A subtle difference in the message here. Now no more output with git diff, if I try git status:

On branch rockify-again
All conflicts fixed but you are still merging.
(use "git commit" to conclude merge)

Changes to be committed:

modified:   ice-without-nickelback.txt

And with git diff --cached:

diff --git a/ice-without-nickelback.txt b/ice-without-nickelback.txt
index 815a5ee..5fa0715 100644
--- a/ice-without-nickelback.txt
+++ b/ice-without-nickelback.txt
@@ -1,5 +1,6 @@
The snow glows white on the mountain tonight
Not a footprint to be seen.
The show must go on!
+Come on you stranger, you legend, you martyr, and shine!
and it looks like I'm the Queen

Already staged, voilà!

Realistic use case

That was awesome, but I already hear you, this never happens in real life. Ok, let's show a more real use case.

This time I'm mistaken

Remember our strange lone commit at the beginning? Well, after thinking about it a lot, I've decided it wasn't rocky enough, I want to remove it from my branch rockify-everything and release the branch to master without it. Sounds simple, right? Actually... what is the git command to erase an old commit, but preserve everything else in place again?

Let's do a git rebase --interactive --preserve-merge! A warning first! You have to be aware that we are rewriting history with git rebase so this should absolutely not be done on a branch available to other contributors.

The option --preserve-merge here is to ensure we keep the same branch structure, but it can be quite chaotic to use, especially with --interactive. Expect hiccups from time to time.

Okay, so our command git checkout rockify-everything && git rebase --interactive --preserve-merge master outputs this:

pick b5f87eb Nickelbackify the lyrics :/
pick 72ea7a3 Queenify these lyrics!
pick 9965c4c Pink Floyd FTW \o/
pick 5677725 Merge remote-tracking branch 'feature/pink-floydify' into rockify-everything

# Rebase a15f60e..5677725 onto a15f60e
#
# 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.
#
# Note that empty commits are commented out

Let's delete the first commit and see the result (cross fingers!):

error: could not apply 9965c4c... Pink Floyd FTW \o/

When you have resolved this problem, run "git rebase --continue".
If you prefer to skip this patch, run "git rebase --skip" instead.
To check out the original branch and stop rebasing, run "git rebase --abort".

Staged 'ice.txt' using previous resolution.
Could not pick 9965c4ca08bf8a1fdd6b9f89325faa77c9103786

Hit hard

What happened? Oh, I forgot that by replaying every commit with git rebase, we have to redo the merge commit, and rebase does not care about old conflicts, it just vomits them to you. Imagine doing that and stumbling on a very old merge commit with conflicts and having no idea how to resolve them again? Thank god, rerere saves the day! Resolution being already staged, we just go with git rebase --continue. The result is visible on this branch:

*   commit 29d2f062660e11952126112a92afe927bf8957dd (origin/rockify-everything-without-nickelback, rockify-everything-without-nickelback)
|\  Merge: febe698 7792e85
| | Author: Tristan Roussel <super@disney.fan>
| | Date:   Thu Jan 15 22:47:06 2015 +0100
| |
| |     Merge remote-tracking branch 'feature/pink-floydify' into rockify-everything
| |
| |     Conflicts:
| |             ice.txt
| |
| * commit 7792e85579fc86581ba293dda49f6b0223370a5a
|/  Author: Frank Zappa <legend@rock.com>
|   Date:   Mon Jan 5 22:37:39 2015 +0100
|
|       Pink Floyd FTW \o/
|
* commit febe698833759656bd27e4a79a3343958ba491d4
| Author: Tristan Roussel <super@disney.fan>
| Date:   Mon Jan 5 21:49:23 2015 +0100
|
|     Queenify these lyrics!
|
* commit a15f60e322646347443548bbc6d017c5ae070d88 (origin/master, master)
Author: Tristan Roussel <super@disney.fan>
Date:   Mon Jan 5 21:47:52 2015 +0100

Let it go

End of the story?

I still have a few more points to tell.

Can't stop off the train

When you enable rerere, it starts recording resolutions.
However, it will not use other conflicts resolutions from when rerere was not enabled and/or from conflicts resolved by other contributors. So my last story might end up being a nightmare again? Fear not! rerere-train is here to save you! It's a very nice script used to teach conflicts resolutions to git-rerere from existing merge commits.

Let's dig in the mud to see it in action. First, let's wipe out our previous recorded resolutions (it's for the example, don't play with .git/ on a real project!):

rm -r .git/rr-cache/*

If you now retry the super rebase exercise we just did before git checkout rockify-everything && git reset --hard origin/rockify-everything && git rebase --interactive --preserve-merge master, you no longer have your conflicts resolved.
Let's teach that again to rerere!
Download rerere-train.sh, and now git reset --hard origin/rockify-everything && ./rerere-train.sh HEAD:

Learning from 5677725 Merge remote-tracking branch 'feature/pink-floydify' into rockify-everything
Recorded resolution for 'ice.txt'.
Previous HEAD position was 72ea7a3... Queenify these lyrics!
Switched to branch 'rockify-everything'
Your branch is up-to-date with 'origin/rockify-everything'.

In this simplest form, the script starts from the commit you specified and go through every parent commit to look for conflicts.

Revert the resolutions \o/

What if the resolution given by rerere doesn't suit you? If you have resolutions already staged for a merge commit on my-file.ext for example, there is a nice trick to unresolve them: git checkout --conflict merge my-file.ext. You can now teach a new resolution to rerere. This is, by the way, useful outside rerere use cases.

Lastly, I told you git rebase --preserve-merge --interactive is chaotic, so let's try not to anger the beast. To ease your job with it, try to select a root commit that is a parent of every branch involved in the rebase. In my case, origin/master was in a sweet position for that.

When rewriting history, you might come across empty commits, either because you are provoking them right now with the changes, or because they already existed. This will stop the rebase process in the middle.

To avoid that, add the option --keep-empty to rebase and the empty commits will be accepted during the workflow.

Bonus question (I still do not have a simple answer for that yet, so please tell me if you know!)

Depending on your workflow, if you use --no-ff with merge, you may create real merge commits where a fast-forward merge could have occurred. In this case, git rebase --preserve-merge --interactive will fail miserably on these commits and tell you something like that:

error: Commit 16118aede40d66e6dfe039d7a99d84b3da8224c6 is a merge but no -m option was given.
fatal: cherry-pick failed
Could not pick 16118aede40d66e6dfe039d7a99d84b3da8224c6

So, my question: how to tune the rebase command to smoothly process those commits too?

T

Tristan Roussel

Web Developer at Theodo