Practical Git Workflows #3: Interactive Rebase — Cleaning Up Commits with squash and fixup
In “Practical Git Workflows #2: rebase vs merge — Decision Criteria and the Golden Rule” we established the principle that a branch only you use is free to rebase. This post is about the most productive way to spend that freedom. Commits on a working branch get messy — that is simply how work goes. “Add feature” is followed by “fix typo”, then “address review” and “really final fix”. Put these into a PR as they are and the reviewer has to read meaningless intermediate steps, and they remain as noise whenever someone queries the history later. Interactive rebase is the tool that regroups those commits into readable units before you open the PR.
It runs in seven parts.
- #1 Branching strategies — GitHub Flow and trunk-based
- #2 rebase vs merge — decision criteria and the golden rule
- #3 Interactive rebase — cleaning up commits with squash and fixup ← this post
- #4 Resolving conflicts — structure, mergetool, and rerere
- #5 Running Pull Requests — review size, commit messages, and draft PRs
- #6 stash, cherry-pick, bisect — everyday tools
- #7 Monorepos and Git — sparse-checkout, submodules, and LFS
This post starts with reading the interactive rebase todo list, walks through folding four commits into two, combines fixup with autosquash into a workflow, and finishes with pushing the cleaned-up branch to the remote safely.
The todo list — interactive rebase’s work order #
git rebase -i opens the commits in the range you specify as a list in your editor, takes your instructions for each commit, and executes them in one pass. Let’s open the last four commits for cleanup.
git rebase -i HEAD~4The editor opens with a todo list like this.
pick a1b2c3d feat: add profile page
pick d4e5f6a fix typo
pick 9f8e7d6 address review
pick 3c2b1a0 feat: add profile image upload
# Rebase 7f6e5d4..3c2b1a0 onto 7f6e5d4 (4 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup [-C | -c] <commit> = like "squash" but keep only the
# previous commit's log message
# ...One thing to note right away: git log shows the newest commit at the top, but the todo list puts the oldest commit at the top. Read the list as instructions executed from top to bottom and the order feels natural.
Changing the command at the start of each line is the entire cleanup operation. Six commands cover almost everything.
| Command | Effect |
|---|---|
pick | Use the commit as is (default) |
reword | Keep the commit, rewrite only its message |
edit | Stop at this commit and let you amend its contents |
squash | Meld into the previous commit, combining both messages for editing |
fixup | Meld into the previous commit, keeping only its message |
drop | Delete the commit (removing the line does the same) |
Hands-on — four commits into two #
In the scenario above, “fix typo” and “address review” are follow-up touches to the first commit, “feat: add profile page”. There is no reason for them to survive as independent commits, so we fold them into the first one. Edit the todo list as follows and save.
pick a1b2c3d feat: add profile page
fixup d4e5f6a fix typo
fixup 9f8e7d6 address review
pick 3c2b1a0 feat: add profile image uploadSave, close the editor, and Git rebuilds the commits as instructed. The before and after comparison makes the effect obvious.
# Before
$ git log --oneline
3c2b1a0 feat: add profile image upload
9f8e7d6 address review
d4e5f6a fix typo
a1b2c3d feat: add profile page
# After
$ git log --oneline
8d7c6b5 feat: add profile image upload
5f4e3d2 feat: add profile pageOnly the two meaningful commits remain. Note that every hash changed — rebase does not modify commits, it replaces them with new ones.
The only difference between squash and fixup is message handling. squash opens the editor with both messages so you can write a combined one; fixup keeps the previous commit’s message without asking. A follow-up touch like “fix typo” has no message worth keeping, so fixup is the right call. If you are merging two units of real work into a new one and want to redesign the message, squash is the right call.
The fixup workflow — mark it when you commit #
Instead of cleaning everything up at the end, you can mark a follow-up commit with which commit it belongs to at the moment you create it. Pass the target commit to the --fixup option.
$ git commit --fixup=a1b2c3d
$ git log --oneline
f1e2d3c fixup! feat: add profile page
3c2b1a0 feat: add profile image upload
a1b2c3d feat: add profile pageThe message automatically becomes fixup! plus the target commit’s message. Fixup commits stacked this way get cleaned up in one shot with the --autosquash option.
git rebase -i --autosquash HEAD~5The todo list still opens, but each fixup commit is already moved directly under its target with the fixup command filled in. Review it and save, and the cleanup is done. Rather than passing the option every time, you can make it the default.
git config --global rebase.autoSquash truegit commit --fixup=<original commit>. After approval, a single rebase makes the branch look as if it had been written cleanly from the start.reword, edit, drop — the other three #
reword— use when only the message is the problem, not the code. Change the line to reword and the message editor opens when that commit’s turn comes.edit— the rebase stops at that commit. Modify files, amend withgit commit --amend, then resume withgit rebase --continue. This is also the command for splitting one commit into two.drop— removes the commit from history. Useful for throwing away a commit that only contains debug code. Just remember that the commit’s changes themselves disappear.
Pushing the cleaned-up branch — force-with-lease #
If the branch was already pushed, the push after cleanup gets rejected — the remote’s commits and your new local commits differ from the hashes up. This is where force push comes in, and you must know the difference between its two forms.
# Overwrite no matter what the remote has — do not use
git push --force
# Overwrite only if the remote is in the state I last saw — use this
git push --force-with-lease--force overwrites the remote with your branch regardless of what is there. If a teammate pushed a commit to the same branch after your last fetch, that commit vanishes without a trace. --force-with-lease overwrites only when the remote branch is exactly in the state you last observed, and aborts the push if there are commits you do not know about. Make it a habit to always reach for this form when a force push is required.
Wrap-up #
Three takeaways from this post.
- Interactive rebase regroups commits through the todo list, and in practice most cleanups need only two commands: pick and fixup.
- Mark follow-up touches with
git commit --fixupas you go and clean them all up at once with--autosquash. - Push a cleaned-up branch with
git push --force-with-lease. Do not use--force— it can erase teammates’ commits.
The more you rewrite commits, the more chances you get to meet conflicts. In the next post, “Practical Git Workflows #4: Resolving Conflicts — Structure, mergetool, and rerere”, we make sense of conflicts structurally — from why ours and theirs look flipped during a rebase to rerere, which keeps you from solving the same conflict twice.