The Universe of Discourse

Sun, 31 May 2020

Reordering git commits (not patches) with interactive rebase

This is the third article in a series. ([1] [2]) You may want to reread the earlier ones, which were in 2015. I'll try to summarize.

The original issue considered the implementation of some program feature X. In commit A, the feature had not yet been implemented. In the next commit C it had been implemented, and was enabled. Then there was a third commit, B, that left feature X implemented but disabled it:

  no X     X on     X off

    A ------ C ------ B

but what I wanted was to have the commits in this order:

  no X     X off     X on

    A ------ B ------ C

so that when X first appeared in the history, it was disabled, and then a following commit enabled it.

The first article in the series began:

I know, you want to say “Why didn't you just use git-rebase?” Because git-rebase wouldn't work here, that's why.

Using interactive rebase here “to reorder B and C” will not work because git-rebase reorders patches, not commits. It will attempt to apply the BC diff as a patch to A, and will fail, because the patch is attempting to disable a feature that isn't implemented in commit A.

My original articles described a way around this, using the plumbing command git-commit-tree to construct the desired commits with the desired parents. I also proposed that one could write a git-reorder-commits command to automate the process, but my proposal gave it a clumsy and bizarre argument convention.

Recently, Curtis Dunham wrote to me with a much better idea that uses the interactive rebase UI to accomplish the same thing much more cleanly. If we had B checked out and we tried git rebase -i A, we would get a little menu like this:

    pick ccccccc implement feature X
    pick bbbbbbb disable feature X

As I said before, just switching the order of these two pick commands doesn't work, because the bbbbbbb diff can't be applied on the base commit A.

M. Dunham's suggestion is to use git-rebase -i as usual, but instead of simply reversing the order of the two pick commands, which doesn't work, also change them to exec git snap:

    exec git snap bbbbbbb disable feature X
    exec git snap ccccccc implement feature X

But what's git snap? Whereas pick means

run git show to construct a patch from the next commit,
then apply that patch to the current tree

git snap means:

get the complete tree from the next commit,
and commit it unchanged

That is, “take a snapshot of that commit”.

It's simple to implement:

    # read the tree from the some commit and store it in the index
    git read-tree $SHA^{tree}

    # then commit the index, re-using the old commit message
    git commit -C $SHA

There needs to be a bit of cleanup to get the working tree back into sync with the new index. M. Dunham's actual implementation does this with git-reset (which I'm not sure is quite sufficient), and has some argument checking, but that's the main idea.

I hadn't know about the exec command in a git-rebase script, but it seems like it could do all sorts of useful things. The git-rebase man page suggests inserting exec make at points in your script, to check that your reordering hasn't broken the build along the way.

Thank you again, M. Dunham!

[Other articles in category /prog] permanent link