Building a single feature locally leaves a history like this:
f16a902 Add logout feature
fbfb5b2 Fix login button style
6cc4fec Add missing import
cacd382 Fix typo in login
0559677 Add login feature
Fix typo, Add missing import, and Fix login button style all belong inside Add login feature. Committing often while you work is fine: nothing gets lost and you have plenty of rollback points. But pushing it as-is forces your reviewer to read four meaningless commits.
Commit freely while you work and tidy up right before pushing. Four tools handle the tidying.
Absorbing junk commits with fixup
To clean up the history above, open an interactive rebase.
git rebase -i HEAD~5
When the editor opens, change pick to fixup on the commits you want to absorb. fixup folds a commit’s changes into the one above it and discards its message. Use squash to keep the message.
pick 0559677 Add login feature
fixup cacd382 Fix typo in login
fixup 6cc4fec Add missing import
fixup fbfb5b2 Fix login button style
pick f16a902 Add logout feature
Save and close, and two commits remain.
$ git log --oneline
f65953c Add logout feature
b5d8028 Add login feature
These are all the commands you use in the rebase editor.
| Command | What it does |
|---|---|
pick | Keep the commit as-is |
reword | Edit the message only |
squash | Fold into the commit above and merge messages |
fixup | Fold into the commit above but drop the message |
drop | Delete the commit |
Scheduling the cleanup with autosquash
When you add a small fix to a commit that’s already pushed or under review, dragging lines around in the rebase editor every time is tedious. --fixup marks up front which commit a change belongs to.
# A fix that belongs on the original commit (e.g. fb36099)
git add auth.js
git commit --fixup=fb36099
This creates a commit titled fixup! Add user authentication. When you later rebase with --autosquash, Git moves that commit right below its target and marks it fixup.
git rebase -i --autosquash HEAD~3
The editor opens pre-sorted, with nothing to reorder.
pick fb36099 Add user authentication
fixup 282ffc9 fixup! Add user authentication
pick 39fbb15 Add settings page
Save, and the fixup! commit is absorbed without a trace.
$ git log --oneline
57b4bd0 Add settings page
d9b5470 Add user authentication
db15ca9 Initial commit
Set git config --global rebase.autosquash true and you won’t have to pass --autosquash every time.
Squashing without a rebase using reset –soft
To collapse N consecutive commits into one, you don’t need a rebase. reset --soft is simpler: it moves HEAD back N commits and leaves every change staged.
$ git log --oneline
d7cd134 WIP: Add error handling
d8c29c3 WIP: Connect login to API
67df8a4 WIP: Add login form skeleton
a088330 Initial commit
git reset --soft HEAD~3
The three commits are gone and the changes sit in staging, untouched.
$ git status
On branch main
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
new file: a
new file: b
new file: c
Commit once and you’re done.
git commit -m "feat: add complete login flow"
The three reset modes differ only in how far they reach into staging and the working tree.
| Option | HEAD | Staging | Working tree |
|---|---|---|---|
--soft | moves | kept | kept |
--mixed (default) | moves | reset | kept |
--hard | moves | reset | reset |
--hard actually throws your changes away. It’s for discarding work, not squashing it.
Fixing the commit you just made with amend
Forgot a file, or made a typo in the commit message? Don’t stack another commit on top; amend the last one.
# Add the forgotten file to the last commit (keep the message)
git add helper.js
git commit --amend --no-edit
$ git show --stat --oneline HEAD
6d52075 feat: add auth
auth.js | 1 +
helper.js | 1 +
2 files changed, 2 insertions(+)
helper.js went into the existing commit without a new one. To change only the message, use git commit --amend -m "new message".
If you amended a pushed commit
amend, rebase, and reset all change a commit’s hash; they replace the old commit with a new one. Before you’ve pushed, that’s harmless. Once a commit is on the remote, rewriting it makes your local and remote histories diverge and a normal push gets rejected.
That’s when you need a force push. Use --force-with-lease instead of --force.
git push --force-with-lease origin feature/my-branch
--force-with-lease refuses the push if someone has pushed to that branch since you last fetched, which stops you from silently clobbering a teammate’s commits. The rule is to rewrite history only on branches you own, never on shared ones (main, develop).