Commits as Drafts: The Case for git --fixup and the Tools Built Around It
Source: lobsters
Most version control advice converges on the same mandate: make clean, atomic commits. One commit per logical change. Write the message as if you’re explaining it to a stranger. Keep the history readable.
That advice is correct. The problem is that it describes the desired output, not the process for getting there. When you’re in the middle of writing a feature, you’re not operating at the level of clean abstractions. You’re trying things, backing out, fixing a typo you spotted three files over, and stubbing in logic you’ll fill in later. Forcing that process into a sequence of pristine commits as you go is more discipline than most work actually supports.
Git has had a better answer since version 1.7.4, released in September 2011: git commit --fixup. Most developers have never used it.
What fixup actually does
The --fixup flag creates a commit whose message is prefixed with fixup! followed by the message of an earlier commit:
# You made a commit earlier
git log --oneline
# a3f9c12 Add OAuth token validation
# 8e1d004 Scaffold user authentication routes
# You noticed a bug in the token validation
git add src/auth/token.js
git commit --fixup=a3f9c12
# Creates: "fixup! Add OAuth token validation"
That prefix is not cosmetic. It is machine-readable metadata that git rebase knows how to use.
When you run:
git rebase -i --autosquash origin/main
Git opens the interactive rebase editor with your fixup commits already reordered to appear immediately after their targets, and already marked with f (fixup) instead of pick. In a typical interactive rebase, you’d have to manually reorder lines and change pick to fixup yourself. With --autosquash, the editor is pre-filled correctly. If you have rebase.autosquash = true in your config, you don’t even need to pass the flag:
git config --global rebase.autosquash true
After that, every git rebase -i gets autosquash behavior. The result is that when you’re satisfied with your branch, one command collapses all your incremental fixups into the commits they belong to, producing the clean history you’d want to push.
fixup versus squash
Git offers two related prefixes. --fixup produces fixup! <message> and, during rebase, silently folds the changes into the target commit and discards the fixup’s message. --squash produces squash! <message> and opens the commit message editor so you can write a combined message.
Use --fixup when the change is genuinely a correction with nothing worth saying (fixing a typo, correcting a logic error, adding a missed test). Use --squash when the later commit adds enough context that you want its content reflected in the final message.
Git 2.32 (released June 2021) extended the syntax further. --fixup=amend:<commit> behaves like --squash but pre-fills the editor with the original commit message instead of an empty buffer. --fixup=reword:<commit> creates a commit with no diff at all, purely to update the target commit’s message during the next autosquash rebase. These additions cover the cases that previously required manually editing the rebase todo list.
The ergonomics problem
The pure CLI workflow has a friction point that prevents most people from adopting it: you need the target commit’s hash. That means context-switching out to git log, identifying the right commit, copying or mentally noting the hash, and passing it to the command. For a fixup you’re making immediately after realizing a mistake, that’s a small interruption. For a fixup you’re making two hours and four commits later, it’s genuinely annoying enough to make people reach for git commit -m "fix" instead and deal with the mess at the end.
This is where the original article on git-fixup makes its sharpest point, and where Magit enters the picture.
Magit’s approach
Magit is a Git interface for Emacs that has a reputation for being the best Git UI that exists, independent of editor preference. Its fixup workflow illustrates why.
In Magit, you open the log buffer with l l. You navigate to the commit you want to fix. You press c f (commit, fixup). Magit stages your current changes and creates the fixup commit targeting the commit your cursor was on. No hash lookup. No copying. The commit you’re pointing at is the target.
The interactive rebase flow is equally direct. r i opens the rebase interface, and with autosquash enabled, the todo list is already sorted and pre-marked. Magit shows you a diff of what will happen before you confirm. You can review the plan, abort cleanly if something looks wrong, and proceed when satisfied.
What Magit provides is not a different model from the CLI workflow; it’s the same model with the friction removed. The hash lookup was always just an implementation detail of the command-line interface. Magit replaces it with cursor position, which is the natural way to express “this commit, right here.”
For Vim users, Fugitive covers similar ground, and lazygit provides a terminal UI that works across editors. Neither reaches Magit’s depth for the fixup-and-rebase workflow specifically, but both reduce the hash-lookup friction enough to make the pattern practical.
git-absorb: removing the decision entirely
git-absorb takes the automation further. It is a standalone tool, written in Rust, that examines your staged changes, runs git blame on the affected lines, and automatically determines which commit in your recent history each hunk belongs to. It then creates the appropriate fixup commits without asking you to specify a target.
git add src/auth/token.js
git absorb
# Automatically creates: "fixup! Add OAuth token validation"
If the tool can determine unambiguously which earlier commit introduced the lines you’re modifying, it creates the fixup. If it can’t, it leaves the hunk unstaged so you can handle it manually. You then run git rebase -i --autosquash as normal to collapse everything.
This works well for the most common case: you’re iterating on code you wrote in this branch, you’ve made a mistake or received review feedback, and the lines in question trace clearly to a specific earlier commit. Where it struggles is with refactors that move code around, or changes to lines that have been touched by multiple commits in sequence, where the blame attribution is ambiguous.
git-absorb is best understood as a tool that automates the easy 80% of fixup decisions. The remaining 20%, where you actually need to think about which commit a change conceptually belongs to, still requires the --fixup=<hash> approach.
The underlying shift in how you think about commits
What makes the fixup workflow worth adopting is not efficiency, though it is more efficient. It is the cognitive model it enables.
Without fixup, there are two stable positions: commit perfectly as you go (high discipline, high friction), or commit messily and squash everything at the end with a single git rebase -i cleanup session (lower friction, but you lose the intermediate structure). Both positions treat commits as either finished products or raw material to be discarded.
With fixup, commits become drafts. You make commits that are good enough to capture intent, knowing that corrections will be properly attributed later. The cleanup is incremental rather than batched at the end. When you finally run the autosquash rebase, the work of reorganizing history is already done; you’re just confirming a plan that accumulated naturally.
This also makes code review cleaner. A branch with one fix typo commit in the middle forces a reviewer to mentally subtract that commit when reading the sequence. A branch that has been autosquashed before the PR is opened tells its story directly.
Practical setup
The minimum configuration to get the full benefit:
git config --global rebase.autosquash true
For the hash-lookup problem on the CLI, Git supports partial hashes (the first 7 characters are almost always sufficient) and message-based selection:
git commit --fixup=:/OAuth
# Targets the most recent commit whose message contains "OAuth"
That :/<pattern> syntax searches commit messages with a case-sensitive substring match. It is not widely documented in tutorials but is part of Git’s commit revision syntax. Combining it with a consistent commit message convention means you can usually target the right commit by typing a distinctive word from its message, which is considerably faster than looking up a hash.
If you use Magit, the above config is still worth setting, but the hash problem largely disappears. If you write a lot of code in a single branch and want the most automated path, install git-absorb alongside these settings.
Why this pattern hasn’t spread further
git commit --fixup and --autosquash have existed for fifteen years. Interactive rebase is well known. Yet most developers still accumulate fixup commits with messages like “wip”, “fix”, or “address review feedback” and then spend time in a single, often confusing rebase session before merging.
Part of it is discoverability. git commit --help is long, and --fixup appears partway through a dense options list. Part of it is that the workflow requires knowing about both the commit flag and the rebase flag and how they interact. Neither is hard, but the connection between them is not obvious from either man page alone.
The tooling layer helps. Magit users tend to discover fixup naturally because it is a named, keyboard-accessible action in the commit menu. git-absorb reduces the workflow to two commands with no hash management. As these tools become more common, the pattern will spread.
The core idea, though, is just this: you don’t have to choose between writing commits as they come and having clean history. Git has supported the middle path since 2011. It is worth learning.