08 May 2014
git SHAmend! a quick way to amend changes into an older commit
When I work on projects with slow test suites, my workflow often ends up looking sort of like this: I make some changes on a branch, run the tests that seem likely to be relevant locally, and then push the branch off to tddium (or whatever) to see if any of the other tests fail unexpectedly.
I like each of my commits to be clean and green in isolation, to make digging through project history easier in the future. To that end, when I make a bunch of commits before running all my tests, I often end up fixing bugs and using interactive rebase to merge the fixes into earlier commits before merging my branch in. (So long as I’m the only person working on that branch, at least!)
It’s a reasonable enough process - stage my changes for the fix, commit them as a WIP, then open up interactive rebase to amend them as a fixup to the right commit from earlier. That’s so many steps, though! And I’m so lazy! I really just want to be able to run something like git shamend SHA_FOR_EARLIER_COMMIT instead. (Or better yet, git-smend or git-sm for short!)
So, I wrote git-shamend to solve this problem for me. The full script is available here - just copy it to /usr/local/bin (or wherever you prefer to keep such things) and you’ll be able to use it with git shamend SHA_TO_AMEND.
I initially planned on writing SHAmend! using git’s low-level (“plumbing”) commands, but all my twitter buddies told me I shouldn’t feel guilty about building on top of porcelain (git’s high-level commands) instead if I wanted to.
The meat of how it works is like this:
First, we get the SHA for the reference you pass in. This avoids problems that could come up if you pass in something like HEAD^, whose meaning changes whenever you add a new commit (as we do later on).
SHA_TO_AMEND=$(git rev-parse "$@")
If the reference you pass in is a commit that’s in your current branch…
if git merge-base --is-ancestor $SHA_TO_AMEND HEAD
…then git-shamend commits your staged changes, marked as a fixup (which is an amendment that retains the original commit message) to that earlier commit…
git commit --fixup $SHA_TO_AMEND
…and if you have any remaining unstaged changes…
git diff-index --quiet HEAD NOTHING_TO_STASH=$?
…stashes them so they don’t interfere with the upcoming rebase…
…and runs an interactive rebase automatically to get that fixup amended properly to the earlier commit you specified.
GIT_EDITOR=true git rebase -i --autosquash "$SHA_TO_AMEND^"
Wait, it runs an interactive rebase automatically? That sounds kinda weird. It’s interactive, but it’s not!
Git uses the environment variable $GIT_EDITOR to figure out which editor open up to allow you to move commits around when running an interactive rebase. Setting that to “true” here causes git to use true as your editor for this command, where true is just a tiny unix program that does nothing except exit with a successful exit code.
So from git’s perspective, it runs an interactive rebase and opens up an editor for you to move around commits, which ‘you’ close successfully. Great, that’s all git-rebase needs from, er, ‘you’ - assuming there are no conflicts, it can handle the rest on its own!
This works because the --autosquash flag tells git-rebase to put fixup commits in the right place for you before opening up the editor, so there’s really nothing you need to do to get things sorted out right.
From your perspective, git just kinda does its thing without bothering you. Gotta love that.
But what if something does go wrong? If the rebase exits unsuccessfully (that is, with a non-zero exit code)…
if [ $? -ne 0 ]
…then git-shamend aborts the rebase and resets that fixup commit it created earlier, to clean up after itself. (And echos a warning, natch. All that stuff is in the actual git-shamend script.)
git rebase --abort git reset --soft HEAD^
And at the very end, if you did have any unstaged changes that were stashed earlier…
if [ $NOTHING_TO_STASH -ne 0 ]
…they’re popped from the stash, to make return your working directory to its pre-SHAmend!ing state.
git stash pop
Now I can smend smend smend as much as I want, way more efficiently!