Easier Rebases with Git

At work, we have a policy of "no long-running feature branches." Features branches are expected to have a lifetime of no greater than a week or two, maximum; after which they are squashed into a single commit and fast-forward merged onto master.

However, rebases can be an imposing process: the thought of losing a weeks work (or worse, breaking the build!) makes folks trepidatious. To that end, here's two habits I've picked up over the years to make this workflow less painful.

Reflog has your back.

Git only very rarely deletes information associated with a hash. Branches may come and go, but commits, trees, blobs, and tag objects are as close to immortal as data on your hard drive will get. Git will only clean up those objects periodically, once the number of "unreferenced" objects gets above a certain threshold — this is known as "garbage collection".

What this means for you is that you can rely on a handy tool called the "reflog": the reflog will give you the history of how your branches have changed over time. This includes what hashed object the branch was pointing at, an alias to reach that state, and the action that caused the change. Let's take a look:

c278df2 HEAD@{0}: commit: add bloop
e33d5aa HEAD@{1}: checkout: moving from JIRA-999 to master
e33d5aa HEAD@{2}: rebase finished: returning to refs/heads/JIRA-999
e33d5aa HEAD@{3}: rebase: [JIRA-999] add busey tracker
39bfe97 HEAD@{4}: checkout: moving from JIRA-999 to 39bfe9747a317cae40000000b06b8d02ec0690d9^0
cd9dd16 HEAD@{5}: rebase -i (finish): returning to refs/heads/JIRA-999
cd9dd16 HEAD@{6}: rebase -i (squash): [JIRA-999] add busey tracker
1a8dbcf HEAD@{7}: rebase -i (squash): updating HEAD
6bf8e60 HEAD@{8}: rebase -i (squash): # This is a combination of 5 commits.
1a8dbcf HEAD@{9}: rebase -i (squash): updating HEAD
c3b1ad2 HEAD@{10}: rebase -i (squash): # This is a combination of 4 commits.
1a8dbcf HEAD@{11}: rebase -i (squash): updating HEAD
9ae823f HEAD@{12}: rebase -i (squash): # This is a combination of 3 commits.
1a8dbcf HEAD@{13}: rebase -i (squash): updating HEAD
5cf43ff HEAD@{14}: rebase -i (squash): # This is a combination of 2 commits.
1a8dbcf HEAD@{15}: rebase -i (squash): updating HEAD
3540783 HEAD@{16}: checkout: moving from JIRA-999 to 3540783
bf475c1 HEAD@{17}: checkout: moving from master to JIRA-999

The history goes from most recent to least recent ("reverse chronological"). We can see that most recently, I've committed something nonsensical (HEAD@{0}: commit: add bloop) to master. Prior to that (HEAD@{1}), I switched from JIRA-999 to master. Prior to this, everything from HEAD@{2} toHEAD@{16}is automatically created from one command:git rebase`.

So, if git rebase goes horribly awry, I can go back to HEAD@{17}. Specifically, I can do the following:

$ git checkout JIRA-999
$ git reset --hard bf475c1

... And my JIRA-999 branch will be back in the state it was before the bad rebase. Nothing is ever lost!

Squash your commits before rebasing.

Now that we can breathe easy about not losing work, let's make the actual rebase process less error prone.

One of the biggest problems I run into when rebasing is that I'm essentially replaying my commits against a much newer master, one by one. If one commit doesn't work, then I'm faced with a dilemma: which version of events should I prefer when I'm resolving the conflict? This is made worse by the knowledge that if I've chosen wrong, git will bring up merge conflicts for each subsequent commit that I'm rebasing. Each one is more stressful than the last: even if I finish the rebase, I don't trust that the code coming out of the rebase bears any resemblance to the code that was going in.

I've largely resolved this workflow by doing the following before rebasing onto master:

$ git rebase -i $(git merge-base HEAD master)

This opens up the tradiitonal rebase UI, but instead of rebasing on where master is now, I'm rebasing on top of where my branch diverged from master. This gives me the opportunity to squash my commits into as few atomic changes as are necessary (and in my situation, this usually ends up being a single outgoing commit!)

I simply choose to squash every commit past the first commit — this should always run without complaining -- and when it's done, I rebase the result onto master:

$ git rebase master

And with that, I have a commit that can be cleanly fast-forward merged onto master.

Remember, git very rarely deletes hashed data, and it keeps a breadcrumb trail for you to follow to get back to a good state, so if things go wrong you always have a way out. In addition, the result of your rebase should be only a few commits, so do yourself a favor and coalesce them before changing the base of your branch.