Skip to content

Instantly share code, notes, and snippets.

@geelen
Created September 22, 2010 05:42
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save geelen/591209 to your computer and use it in GitHub Desktop.
Save geelen/591209 to your computer and use it in GitHub Desktop.

Rebasing Merge Commits in Git

This morning I discovered a nasty little problem with git-rebase that can have pretty unexpected and unwanted results - how it handles a merge commit.

The TL;DR version is this: Always use git rebase -p

Why I use pull --rebase

I think a lot of people are using git pull --rebase as their default to avoid unnecessary merge commits when fetching the latest code from master. There are a few blog posts on the matter, such as [1] [2]

For me, it boils down to two simple cases:

1. You haven't made any changes to your branch

In this case, pull and pull --rebase will simply fast-forward. No problems.

2. You've got one or two small changes you forgot to push

In this case, the default pull will actually merge the remote changes into your branch, making a merge commit. This is bad for a couple of reasons, messiness is one, but I actually consider the problems it causes for git bisect more compelling (I must remember to write about that one day).

With git pull --rebase, you simply replay those commits on top of the new head. Now, if you push, you have linear history, rather than a divergence/merge. I'm a big fan of the 'always work on a branch' and 'merges are meaningful and good' mindset (partly inspired by [3]), but I believe linear history is exactly appropriate when you're talking about working with remotes - I consider master and origin/master to be semantically the same thing.

By and large, git pull --rebase is what you want. To make it the default, see [4].

Rebase does not play well with merge commits

This is a bit of an understatement. But let's work through an example.

First, one that doesn't fail

Given this simple repo:

Initial state

[master] git pull --rebase
First, rewinding head to replay your work on top of it...
Applying: little fix
Applying: forgot to push this

After pull

[master] git push
Counting objects: 6, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (4/4), done.
Writing objects: 100% (5/5), 526 bytes, done.
Total 5 (delta 0), reused 0 (delta 0)
Unpacking objects: 100% (5/5), done.
To /Users/glen/envato/demo-origin
   f9c3cb8..e4a2e92  master -> master

After push

Easy!

All aboard the failboat

Say you've been working on your little feature for a while, like this:

Initial state

Then you merge to master (using --no-ff, of course)

[master] git merge --no-ff feature  
Merge made by recursive.                          
 b |    3 +++                                     
 1 files changed, 3 insertions(+), 0 deletions(-) 
 create mode 100644 b               

Merged

Then you go to push, but somebody got in there first (origin/master has moved on)

[master] git push                   
To /Users/glen/envato/demo-origin                 
 ! [rejected]        master -> master (non-fast-forward)
error: failed to push some refs to '/Users/glen/envato/demo-origin'
To prevent you from losing history, non-fast-forward updates were rejected
Merge the remote changes (e.g. 'git pull') before pushing again.  See the
'Note about fast-forwards' section of 'git push --help' for details.

Of course, trying to push hasn't updated our reference to origin/master, we need to git fetch to see the full picture

[master] git fetch
remote: Counting objects: 5, done.
remote: Total 3 (delta 0), reused 0 (delta 0)
Unpacking objects: 100% (3/3), done.
From /Users/glen/envato/demo-origin
   49ab1cf..9f3e34d  master     -> origin/master

Fetched

If we pull --rebase here, DOOM OCCURS

[master] git pull --rebase
First, rewinding head to replay your work on top of it...
Applying: my work
Applying: my work
Applying: my work

Doom

Our merge commit has disappeared!

This is bad for a whole lot of reasons. For one, the feature commits are actually duplicated, when really I only wanted to rebase the merge. If you later merge the feature branch in, both commits will be in the history of master. And origin/feature, which supposed to be finished and in master, is left dangling. Unlike the awesome history that you get from following a good branching/merging model, you've actually got misleading history.

Worst of all, you did everything 'right'. You used merge --no-ff and git pull --rebase. Sad face.

In case it's not obvious, this is the ideal outcome:

Ideal outcome

In the past, I've noticed the three 'Applying:' lines in the log of a pull -rebase and realised something was up. Then I'd reset and reapply the merge:

[master] git reset --hard origin/master
HEAD is now at 9f3e34d sneaky extra commit
[master] git merge --no-ff feature
Merge made by recursive.
 b |    3 +++
 1 files changed, 3 insertions(+), 0 deletions(-)
 create mode 100644 b

There's an easier way!

In the manpage for git-rebase

-p
--preserve-merges
Instead of ignoring merges, try to recreate them.

This uses the --interactive machinery internally, but combining it with the --interactive option explicitly
is generally not a good idea unless you know what you are doing (see BUGS below).

Or, to put it another way:

AWESOME

So, instead of using git pull --rebase, use a git fetch origin and git rebase -p origin/master

[master] git rebase -p origin/master
Successfully rebased and updated refs/heads/master.

And it does exactly what we want!

Ideal outcome

Downsides

Git pull is dead

The -p flag doesn't apply to git pull --rebase, so you have to start explicitly fetching and rebasing. To be honest, I think this is more an upside. Fetching explicitly is good, since it refreshes your entire copy of the remote, and lists what branches have moved on (handy on a fast-moving project). But for those used to a single-step pull, this is slightly more work.

ORIG_HEAD is no longer preserved

ORIG_HEAD, once you get used to using it, is really handy to undo a destructive operation. Sadly, something within git rebase -p sets ORIG_HEAD more than once, so you can't use it to quickly undo a rebase, something I ran in to working on this post.

Branch tracking is no used

Unlike git pull --rebase, which will fetch changes from the branch your current branch is tracking, git rebase -p doesn't have a sensible default to work from. You have to give it a branch to rebase onto.

Aliases

So, how about some aliases to make this all idiot-proof? I like gup, and I've got it in a gist for bash, fish and zsh here

It'll do a fetch of origin and rebase -p of the branch on origin with the same name as the current branch. For 99% of cases, this is exactly what I want.

Conclusion

This morning, I had no idea about the --preserve-merges flag on rebase, and was about ready to cry foul on using rebase at all, considering how bad this problem can get on a big project. But, as with everything Git, once you understand it a bit better, there's usually a more complex way that sucks a whole lot less. Which is aliases like gup are really handy.

I'd welcome comments and suggestions, you can either respond to this Gist or hit me up on twitter - @glenmaddern

Cheers,

-geelen.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment