Git fixup is magic (and Magit is too)

Arialdo Martini — 9/06/2024 — git emacs

We all know how to use git commit --amend to fix up the very last commit.
How beautiful would it be to have a command like:

git commit --amend=3

to extend this power to fix up the 3rd to last — or any other past — commit?

Enter git fixup.

TL;DR

Add this alias to your .gitconfig:

[alias]
  fixup = "!f() { TARGET=$(git rev-parse $1); git commit --fixup=$TARGET ${@:2} && GIT_SEQUENCE_EDITOR=true git rebase -i --autostash --autosquash $TARGET^; }; f"

Then, use it as follows:

  • Make a change.
  • Stage files with git add .
  • Fix up the desider commit (git fixup <SHA-1>).
  • Profit.

How it works

Rebase

Some devs are scared by the very notion of rebasing, but in fact a rebase is an embarassingly simple thing: just a series of cherry-picks.

With a tree such as:

* 84831ab  (HEAD -> branch) branch 3
* 65b297f  branch 2
* 66fe73c  branch 1
| * 7208745  (master) five
| * 6f3ab93  four
|/
* 85f4b7e  three
* 7011e1f  two
* 76ec393  one

rebasing branch on top of master with:

git rebase master

is just equivalent to:

git reset --hard master

git cherry-pick 66fe73c  # 1st commit
git cherry-pick 65b297f  # 2nd commit
git cherry-pick 84831ab  # 3rd commit

Interactive rebase

Adding the --interactive flag to git rebase lets you fine tune the series of cherry-picks before their execution.

If instead of the previous:

git rebase master

you run:

git rebase master --interactive

your preferred editor will pop-up, with this content:

pick 66fe73c 1st commit
pick 65b297f 2nd commit
pick 84831ab 3rd commit

This gives you the chance to do all sorts of magic before rebasing, such as

  • re-arranging the cherry-picks in a different order
  • squashing two of them together
  • splitting commits
  • modifying their messages

and the like.
In other words, --interactive creates a transient todo-list file, a little script of commands for Git to execute.

This is the first key to understanding how git fixup works.

Message-based commands

The second key revolves around a little known option of the git rebase command: --autosquash.

As a matter of fact, not only can the user edit the todo-list file, but Git itself also interprets the commit messages in search of commands. This is similar to the functionality often used with GitHub to close an issue by mentioning its number with a special syntax such as #fixes 42 in a commit message.

Indeed, during an interactive rebase:

If a commit message starts with “squash! “, “fixup! “ or “amend! “, the remainder of the subject line is taken as a commit specifier, which matches a previous commit if it matches the subject line or the hash of that commit.

In order to instruct Git to interpret those commands, you shall use the (confusingly named) --autosquash option.

So, the trick for fixing up the 3rd to last commit is to add a new commit with a properly forged message mentioning its message:

pick 3732234 fixup! 3rd commit
pick 66fe73c 1st commit
pick 65b297f 2nd commit
pick 84831ab 3rd commit

and then running an interactive rebase with the --autosquash option.

Letting Git forge the proper commit message

We are almost there. Let’s fine tune this idea. As a matter of fact, you don’t even have to type that message. Just type:

git commit --fixup 84831ab

and Git will diligently copy the 3rd to last commit message, prefixing it with fixup!.

Executing the rebase

Now it’s a matter of executing the interactive rebase, with this magic --autosquash option.
The only outstanding problem is that for this fixup to work, the rebase must be interactive and this is not what you wished: you don’t really want to be asked for any input. You prefer having a git fixup command completing all the necessary tasks without bothering you.

The trick is to use true as the interactive editor.
true is a little tiny program that does nothing, successfully (it takes 80 lines to do so).

Interpreting the alias

This should give you all the ingredients to interpret the alias.
If you are one of those horrible developers who refuse to copy-paste a snippet of code before they really understand it, here’s a breakdown.

Part Meaning
!f() { ... }; f Define a shell function named f; at the end, execute it.
TARGET=$(git rev-parse $1); Get the full commit hash of the target commit, specified in the first Bash argument ($1).
git commit --fixup=$TARGET Create a new commit setting the commit message to fixup! <commit message of the target commit>.
${@:2} Optionally, allows you to pass additional arguments to commit. ${@:2} in Bash refers to all the command line arguments starting from the second one.
&& This is a shell operator that executes the next command only if the previous one succeeded.
GIT_SEQUENCE_EDITOR=true Tell Git to use true as the dummy editor for the interactive rebase. true always succeedes, actually turning the interactive rebase as non-interactive
git rebase Start the rebase…
-i interactively…
--autostash stashing any uncommitted changes before rebasing…
--autosquash squashing the fixup commit into the target commit…
$TARGET^; from the commit before (^) the target commit ($TARGET).

It’s Magit!

Magit, the amazing Git client running on top of Emacs, offers a very convenient way for selecting the past commit to modify. Just make your change. Then, instead of committing on top of the last commit, run magit-commit-instant-fixup and select the commit you want to amend. That simple.

The feeling is like having a non-read-only Git history. So sweet!
If this is not enough, Magit offers a command called magit-rebase-edit-commit that lets you mark a past commit as editable and perform whatever change you desire.

Yes, Magit is magic. But, of course, under the hood, there’s nothing but a simple, fixup rebase.

Enjoy rewriting history!

References