Squashing Git Commits: Interactive Rebase, Amend, and Autosquash
A hands-on tutorial: cleaning history with interactive rebase, folding a forgotten change into the last commit with amend, and fixing an older commit with fixup and autosquash.
Three ways to squash Git commits, from most common to most surgical: interactive rebase (git rebase -i) to merge several recent commits into one, git commit --amend --no-edit to fold a forgotten change into the very last commit, and git commit --fixup plus git rebase -i --autosquash to fix a commit buried deep in history without touching everything above it.
Rewriting history changes commit hashes, so anything already pushed needs git push --force-with-lease afterward, never a blind --force.
Where this fits in what I already knew
When I started using Git, my toolbox was small: git commit to save work, git branch to list branches, git branch <name> to create one, git checkout -b <name> to create a branch and switch to it in one step, and git merge when it was time to bring work back together. I had also heard git rebase mentioned by more experienced coworkers, without really understanding what it did under the hood. That one might become its own post someday.
This post lives in a corner of that rebase family: squashing. If you tend to commit often, typos, "WIP", small fixes as you go, your history ends up noisy. Squashing lets you group several small commits into one clean commit before you share the branch, without losing any of the actual code changes. And if you already squashed once and later realize an older commit needs a fix, you do not have to redo the whole squash by hand: that's what Part 3 is for.
Three ways to get there, roughly from most common to most surgical:
- Interactive rebase: merge several recent commits into one.
- Amend: fold a change into the very last commit.
- Fixup plus autosquash: target one specific commit anywhere in history.
Before diving in, a quick reminder since HEAD shows up everywhere below.
Quick reminder: what is HEAD?
HEAD is a pointer to whatever commit your working directory currently sits on. On a normal branch, that also means HEAD matches the branch tip, its most recent commit. In the history below, HEAD points at ddd:
ddd Add login unit tests <- HEAD
ccc Fix missing CSS import
bbb Fix typo in email label
aaa Add login form
HEAD~N is a relative reference: "N commits before HEAD, following the first parent". HEAD~1 is ddd's parent (ccc), HEAD~2 is bbb, HEAD~4 is aaa. This tutorial leans on that notation throughout so you never need to copy-paste a hash.
Part 1: classic squash with interactive rebase
Imagine the following history on a feature/login branch. bbb and ccc are two small fixes to fold into aaa to keep a clean history, without touching ddd.
1. Look at the history
git log --oneline
git log always lists commits newest first (top) to oldest last (bottom). So ddd is the most recent commit (HEAD), and aaa is the oldest one in this range:
ddd Add login unit tests <- most recent commit (HEAD)
ccc Fix missing CSS import
bbb Fix typo in email label
aaa Add login form <- oldest commit
2. Start the interactive rebase
The goal is to go back to just before aaa, 4 commits back, or directly onto aaa's parent with git rebase -i aaa~1:
git rebase -i HEAD~4
HEAD~4 means "the commit 4 steps before HEAD", here aaa. This reference is only the starting boundary of the rebase and is excluded from the editable list: Git replays every commit that comes after it, up to HEAD. So git rebase -i HEAD~4 (same target as git rebase -i aaa~1) lets you edit aaa, bbb, ccc, ddd, but not the commit before aaa. Targeting aaa's grandparent would mean going one commit further back, HEAD~5.
Git opens an editor with the list of commits, this time in the opposite order of git log: oldest first (top) to newest last (bottom). That is because Git replays the commits in that same order, top to bottom, to rebuild the history:
pick aaa Add login form <- oldest, replayed first
pick bbb Fix typo in email label
pick ccc Fix missing CSS import
pick ddd Add login unit tests <- most recent, replayed last (HEAD)
3. Squash bbb and ccc into aaa
Replace pick with squash (or s) for the commits to merge into the commit above them. ddd stays pick since it is not being touched:
pick aaa Add login form <- oldest, target of the squash
squash bbb Fix typo in email label
squash ccc Fix missing CSS import
pick ddd Add login unit tests <- most recent (HEAD), left untouched
Save and quit. Git then opens a second screen to write the commit message for the merged commit, defaulting to a concatenation of the 3 messages:
# This is a combination of 3 commits (aaa + bbb + ccc)
Add login form
Clear everything and write a single clean message, then save.
4. Verify with git log
git log --oneline
Back to git log's usual newest first order:
ddd' Add login unit tests <- most recent commit (HEAD)
aaa' Add login form <- oldest commit, now contains bbb + ccc
The hashes changed (aaa', ddd') because a rebase rewrites history from the divergence point onward. Visually, 4 commits collapse into 2:
gitGraph
commit id: "aaa"
commit id: "bbb"
commit id: "ccc"
commit id: "ddd"
gitGraph
commit id: "aaa-rewritten"
commit id: "ddd-rewritten"
Before (top): 4 commits, bbb and ccc are small fixes. After (bottom): 2 commits, bbb and ccc are folded into a rewritten aaa, and ddd is replayed on top with a new hash even though its content did not change.
5. Push the result
- If
aaa,bbb,ccc,dddwere never pushed (fully local branch): nothing special, a plaingit pushis enough since no one else has these commits. - If the branch was already pushed (
origin/feature/loginexists with the old hashes): the rebase rewrote history, so the push needs to be forced.
Use git push --force-with-lease, not a blind git push --force:
git push --force-with-lease
--force-with-lease only forces the push if no one else updated the remote branch since your last fetch. --force overwrites blindly and can erase a colleague's work pushed in the meantime. This same caveat applies to every rewrite in this post (amend in Part 2, autosquash in Part 3).
Part 2: folding changes into the last commit with amend
When a commit was just made and something was forgotten, a file, a small fix, there is no need to make a new commit and squash it later: it can be injected directly into the last commit.
git add forgotten_file.js
git commit --amend --no-edit
What each part does
commit: the base command to create a commit.--amend: instead of creating a new commit, rewrites (replaces) the last commit (HEAD), incorporating whatever is currently staged (git add). The message can be changed during this process.--no-edit: keeps the previous commit's message as-is, without opening the editor. Without this flag,--amendalone opens the editor to rewrite the message.
Just like with rebase, --amend rewrites the commit's hash. If this commit was already pushed, the same force-push caveat from Part 1 applies: use git push --force-with-lease. If the commit is still local, a normal git push works fine, or no push at all if nothing was pushed yet.
Part 3: fixing an older commit with fixup and autosquash
Part 2 only works for the last commit (HEAD). Often the commit that needs a fix is not HEAD~1 but HEAD~5, several commits back. That is where fixup plus rebase --autosquash comes in.
Example
History (git log --oneline, newest first):
hhh Add docs <- most recent commit (HEAD)
ggg Fix settings CSS
fff Add settings page
eee Add profile page
aaa Add login form <- oldest commit, HEAD~4
aaa, far back in history at HEAD~4, turns out to be missing an email validation.
1. Stage the relevant changes
git add email-validation.js
2. Create a fixup commit targeting aaa
git commit --fixup aaa
This creates a new commit with the message fixup! Add login form (Git reuses the message of the targeted commit aaa, prefixed with fixup!). This commit is not attached to aaa right away, it is only a marker for the next step.
History after this commit, still newest first:
iii fixup! Add login form <- most recent commit (HEAD), the fixup marker
hhh Add docs
ggg Fix settings CSS
fff Add settings page
eee Add profile page
aaa Add login form <- oldest commit, the real target of the fixup
3. Interactive rebase with autosquash
git rebase -i --autosquash aaa~1
aaa~1 targets aaa's parent as the starting boundary, so aaa itself is included in the editable list (the reference passed to rebase -i is always excluded, so the target is always one commit further back than the commit that needs editing).
Git opens the editor in its usual oldest to newest order. Thanks to --autosquash, it also automatically reorders the list to place iii (the fixup) right after aaa, and swaps pick for fixup:
pick aaa Add login form <- oldest, target of the fixup
fixup iii fixup! Add login form <- merged right after aaa, message discarded
pick eee Add profile page
pick fff Add settings page
pick ggg Fix settings CSS
pick hhh Add docs <- most recent (HEAD), left untouched
Unlike squash, fixup merges the commit into the one above without asking for a message edit, it keeps aaa's message as-is. Save and close the editor: the rebase runs on its own.
4. Verify
git log --oneline
Back to git log's newest first order:
hhh' Add docs <- most recent commit (HEAD)
ggg' Fix settings CSS
fff' Add settings page
eee' Add profile page
aaa' Add login form <- oldest commit, now contains the email validation
gitGraph
commit id: "aaa"
commit id: "eee"
commit id: "fff"
commit id: "ggg"
commit id: "hhh"
commit id: "iii"
gitGraph
commit id: "aaa-rewritten"
commit id: "eee-rewritten"
commit id: "fff-rewritten"
commit id: "ggg-rewritten"
commit id: "hhh-rewritten"
Before (top): 6 commits, iii is still a separate fixup marker. After (bottom): 5 commits, iii is folded into a rewritten aaa, and every descendant commit is replayed with a new hash even though only aaa's content changed.
5. Push if needed
Same rule as always: if these commits were already on the remote, git push --force-with-lease.
Tip: enable autosquash by default
To avoid typing --autosquash every time:
git config --global rebase.autosquash true
Summary of the advanced workflow
| Step | Command |
|---|---|
| Stage the change | git add <file> |
| Create the targeted fixup | git commit --fixup <hash-or-ref> |
| Auto-organized rebase | git rebase -i --autosquash <hash-or-ref>~1 |
| Verify | git log --oneline |
| Push, if already on remote | git push --force-with-lease |
Spotted a mistake or have a cleaner way to explain one of these steps? Use [email protected].