Tuesday, March 11, 2014

git ir: an alternative to git rebase --interactive

My name is Steve and I'm a gitaholic. Git it pervasive in my workflow, both in my job and in my hobby projects. I'm also addicted to rebasing. At work, I tend to have ten to twenty branches open at a time. Many of these are chained in a giant stack, waiting for code reviews one after another. A few are branched out directly from the master branch. When I sync my repository or submit one of my changes, I then go about rebasing all the rest of the outstanding branches on top of that.

Unfortunately, git rebase just doesn't cut it for this. A while back I wrote git rebase-tree that would rebase a whole branched tree from one root to another (bringing all the branches along with), but it had a black eye in conflict handling, requiring a prompt for "continue/skip/abort". So I would need to open up a second terminal to git status and resolve the conflict, etc (or more typically, run git conflicts in emacs). I had been meaning to redo git rebase-tree with its own --continue flag, but my experience with another custom function (git diff-mail, which I use at work to prevent the changelist mailer from counting all the earlier changes that were already counted in a previous changelist) taught me that this can cause problems, as I often needed to git rebase --continue; diff-mail --continue, plus --abort often got confusing and sometimes actually clobbered my real changes (fortunately they've been easy to reconstruct so far). But it occurred to me that git rebase already has a queue, and with some clever manipulation, I can make it do what I want.

Enter git ir.

This function takes a list of branches, a commit to rebase them onto, and optionally a commit to rebase them from (in case they're already on top of the destination). It sets up an interactive rebase session but completely ignores the plan git prepares, instead using its own plan that includes a few additional commands in the rebase plan.
# Extended Commands:
# !, exec!  = mandatory command (the rest of the line), reinserted on failure
# b, branch = sets the named branch to the current commit
# (, push   = pushes the current commit onto the stack
# ), pop    = pops the current commit from the stack
The initial plan is effectively equivalent to git rebase-tree (though slightly more permissive). But with some quick edits (adding or removing parentheses, for instance) a tree of single-commit branches all off the same base can be instantly converted into a chain of dependent branches, and vice versa.

Once the initial plan is complete, it's ordinary git rebase the rest of the way, so when conflicts arise, it's back to the normal git rebase --continue (or --skip or --abort) workflow to handle them! Aborting happens for free. Moreover, no branches are moved until all commits have been successful rebased, so aborting before that brings everything exactly back to where it started. Finally, if you don't want it to be interactive, just call it with :: (which I alias to 'EDITOR=: ').


David Alan Hjelle said...

Let me see if I understand what's going on.

Let's say my repo has a master branch, as well as A, B, and C branches, and then A.1 and A.2 based off of A, and A.1.a and A.1.b based off of A.1.

To sync up B with upstream, you'd pull origin from master, checkout B, and then git rebase master.

You can do that with A, too, but then you run into problems with A.1, etc., as all of their commits are based off of the original A and not the rebased A.

Does git ir --onto master while on A automatically rebase all of its children? Or is the syntax different…the two usage strings in your code don't match :-), so I'm a bit confused.

David Alan Hjelle said...

It also appears that git imerge has a potential solution to this problem (search for "rebase with history"), though it isn't its main focus, and I've not used it.

Patrick Oppenlander said...

Great work.

Only problem I ran into is that it barfs with "Could not execute editor." if the GIT_EDITOR environment variable isn't set.

Steve said...

@David - I'm not sure what things looked like back in March, but now I would write `git ir --onto master --from master@{1} --all`.

@Patrick - Git tries to be smart with $EDITOR - it has at least three different environment variables it uses, with some priority ordering (GIT_EDITOR, EDITOR, and something else), and so I haven't found a good way to reproduce this faithfully - I'm open to suggestions.