Interactive Rebase & History Cleanup
In Rebasing (and When Not To) you met interactive rebase and used fixup with --autosquash to fold a small fix into the right commit. That covers the most common case. Real branches get messier: a commit called "wip", a debug line that must not ship, one commit that mixes two unrelated changes, and a second branch stacked on top of the first.
This lesson works through a full cleanup with real commands and results. You will reword, drop, reorder and split commits, run a check on every commit, keep stacked branches in step, compare the branch before and after, and undo the whole thing if you change your mind. Everything here rewrites commits, so the golden rule still applies: only do it to commits nobody else has built on.
The messy branch
Priya built an invoice page for the sweet shop on feature/invoice. Before opening a pull request she lists the commits that are not on main yet. main.. means "commits on my branch that main does not have":
git log --oneline main..9072dd2 (HEAD -> feature/invoice) Add print button 7d09cad Add invoice styles and fix footer link 73ed835 Add debug log 1de955e wip 64f5669 Add invoice page
A reviewer would struggle with this. "wip" says nothing, the debug log must not ship, and 7d09cad mixes invoice styling with an unrelated footer fix on the home page. Priya wants five clear commits, each doing one thing.
Reword, drop and reorder in one pass
She starts an interactive rebase onto main. Git opens the to-do list in her editor, oldest commit first. She edits it to look like this and saves:
pick 64f5669 # Add invoice page
reword 1de955e # wip
drop 73ed835 # Add debug log
pick 9072dd2 # Add print button
edit 7d09cad # Add invoice styles and fix footer linkFour changes in one file: reword will ask for a better message, drop deletes the debug commit, the print button line was moved up (moving lines reorders commits), and edit will stop at the mixed commit so she can split it. Reordering is safe here because the print button and the styles touch different lines. When two reordered commits change the same lines, expect a conflict.
git rebase -i mainWhen Git reaches the reword line it opens the editor again with the old message. Priya replaces "wip" with "Add 5% GST row to invoice", saves, and Git carries on until the edit line:
[detached HEAD 18884ac] Add 5% GST row to invoice Date: Mon Nov 2 02:17:33 2026 +0530 1 file changed, 1 insertion(+) Stopped at 7d09cad... # Add invoice styles and fix footer link You can amend the commit now, with git commit --amend Once you are satisfied with your changes, run git rebase --continue
"detached HEAD" is normal during a rebase: Git is building the new commits one by one and only moves the branch when it finishes.
Splitting one commit into two
Git has stopped after applying the mixed commit. To split it, undo that commit but keep its changes in the working tree with a mixed reset, then commit the pieces separately:
git reset HEAD^
git status -s
git add invoice.css
git commit -m "Style the invoice box"
git add index.html
git commit -m "Fix footer link to contact page"
git rebase --continueUnstaged changes after reset: M index.html M index.html ?? invoice.css [detached HEAD 898102f] Style the invoice box 1 file changed, 1 insertion(+) create mode 100644 invoice.css [detached HEAD ba723d1] Fix footer link to contact page 1 file changed, 1 insertion(+), 1 deletion(-) Successfully rebased and updated refs/heads/feature/invoice.
If the two changes were mixed inside one file, you would use git add -p to stage only some hunks, as in status, add & commit. The result:
git log --oneline main..ba723d1 (HEAD -> feature/invoice) Fix footer link to contact page 898102f Style the invoice box 18f1daa Add print button 18884ac Add 5% GST row to invoice 64f5669 Add invoice page
Notice that 64f5669 kept its hash. Its parent did not change, so Git reused it instead of making a copy. Every commit after the first change got a new hash.
Checking the rewrite with range-diff
How do you know the rewrite did only what you meant? git range-diff compares two versions of the same series of commits. The branch's own reflog remembers where it pointed before the rebase as feature/invoice@{1}:
git range-diff main feature/invoice@{1} feature/invoice1: 64f5669 = 1: 64f5669 Add invoice page
2: 1de955e ! 2: 18884ac wip
@@ Metadata
Author: Priya Sharma <priya.sharma@example.com>
## Commit message ##
- wip
+ Add 5% GST row to invoice
## invoice.html ##
@@
3: 73ed835 < -: ------- Add debug log
5: 9072dd2 = 3: 18f1daa Add print button
-: ------- > 4: 898102f Style the invoice box
4: 7d09cad ! 5: ba723d1 Add invoice styles and fix footer link
@@ Metadata
Author: Priya Sharma <priya.sharma@example.com>
## Commit message ##
- Add invoice styles and fix footer link
+ Fix footer link to contact page
## index.html ##
@@
@@ index.html
+ <footer><a href="contact-us.html">Contact</a></footer>
</body>
</html>
-
- ## invoice.css (new) ##
-@@
-+.invoice { border: 1px solid #ccc; }Read it line by line: = means the commit is unchanged apart from its hash, ! means it changed (the diff below shows how), < means it exists only in the old version (the dropped debug commit) and > only in the new one (the new styling commit). Reviewers can run the same command after you force-push, so they only re-read what actually changed. In PowerShell, put names containing @{ in quotes: "feature/invoice@{1}".
Running a check on every commit
A good commit should work on its own, so that git bisect can test any of them later. -x (or --exec) adds a command after every commit in the to-do list and stops if it fails. Priya's main now has a small check.sh that fails when a page loads a script file that does not exist. She has added two more commits, and main has moved, so the hashes are new:
git rebase -x "sh check.sh" mainExecuting: sh check.sh Executing: sh check.sh Executing: sh check.sh Executing: sh check.sh Executing: sh check.sh Executing: sh check.sh missing file: print.js warning: execution failed: sh check.sh You can fix the problem, and then run git rebase --continue
The commit "Load print script on invoice page" adds <script src="print.js">, but print.js only arrives in the next commit. Priya runs git rebase --abort, then git rebase -i -x "sh check.sh" main. The to-do list now has an exec line after each pick. She swaps the last two commits so the script file comes first (only the end of the list is shown):
pick 8ae22e4 # Fix footer link to contact page
exec sh check.sh
pick a7855c8 # Add print script
exec sh check.sh
pick e658f33 # Load print script on invoice page
exec sh check.shThis time all seven checks pass and Git prints Successfully rebased and updated refs/heads/feature/invoice. In a real project the command would be your test suite, for example -x "npm test".
Stacked branches with --update-refs
Priya has started feature/invoice-pdf on top of feature/invoice, so the second pull request can be reviewed while the first is still open. When main gets a new commit, rebasing the top branch normally leaves the lower branch behind on the old commits. --update-refs moves every branch that points into the rebased commits:
git switch feature/invoice-pdf
git rebase --update-refs mainSuccessfully rebased and updated refs/heads/feature/invoice-pdf. Updated the following refs with --update-refs: refs/heads/feature/invoice
Both branches now sit on the new main and still stack correctly. To make this the default, run git config set --global rebase.updateRefs true. Remember to force-push both branches with --force-with-lease.
Rewording without stopping: amend! commits
Sometimes you only notice a bad message after more work. git commit --fixup=reword:<commit> creates an empty "amend!" commit holding the new message; --fixup=amend:<commit> does the same and also includes your staged changes. Git opens the editor so you can write the new message. Priya also sets rebase.autoSquash so every interactive rebase applies these automatically:
git commit --fixup=reword:HEAD~1
git config set rebase.autoSquash true
git rebase -i mainIn the to-do list the amend commit appears as fixup -C right after its target, and after saving, the log shows the new message, "Add PDF download button to invoice page", in place of the old one.
Undoing a whole rebase
If a rebase went wrong, the branch reflog shows every position the branch had:
git reflog -3 feature/invoice-pdf
git reset --hard feature/invoice-pdf@{1}c6770e0 (HEAD -> feature/invoice-pdf) feature/invoice-pdf@{0}: rebase (finish): refs/heads/feature/invoice-pdf onto 199902d629fe055c7b9224aef1a6377de14a0aaf
092fcc2 feature/invoice-pdf@{1}: commit: amend! Add PDF download link
42e0f54 feature/invoice-pdf@{2}: rebase (finish): refs/heads/feature/invoice-pdf onto 199902d629fe055c7b9224aef1a6377de14a0aaf
HEAD is now at 092fcc2 amend! Add PDF download linkThe branch is exactly where it was before the last rebase. More recovery tricks are in reflog & Recovering Lost Work. To rewrite all the way back to the very first commit of a repository, use git rebase -i --root.
Common mistakes
- Rewriting commits that are already on
main. Only clean up your own branch before it is merged. - Forgetting that
editstops after the commit. Usegit reset HEAD^to split it, orgit commit --amendto change it. - Reordering commits that touch the same lines. Expect conflicts, or squash them instead.
- Not checking the result. Run
git range-diffand your tests before force-pushing.
Key points
- In the to-do list,
reword,drop,editand moving lines change messages, remove commits, stop for changes and reorder. - Split a commit by marking it
edit, runninggit reset HEAD^, and committing the pieces separately. git rebase -x "<command>"runs a check after every commit and stops at the first failure.--update-refs(orrebase.updateRefs) keeps stacked branches in step during a rebase.git range-diff main old newshows exactly what a rewrite changed; the branch reflog holds the old tip.--fixup=amend:and--fixup=reword:commits are applied by--autosquashorrebase.autoSquash.
Exercise
Make a practice branch with four commits: "Add contact page", "wip", "Add debug print" (a separate file) and "Add map and fix menu typo" (two files). Use one interactive rebase to give "wip" a real message, drop the debug commit and split the last commit into two. Then compare the old and new branch with git range-diff.
Show solution
git rebase -i main
git reset HEAD^
git add map.html
git commit -m "Add map to contact page"
git add menu.html
git commit -m "Fix typo in menu"
git rebase --continue
git range-diff main practice@{1} practiceIn the to-do list, change pick to reword for "wip", to drop for the debug commit and to edit for the last one. The commands above run at the edit stop. In the range-diff, the debug commit shows <, the reworded commit ! and the new split commit >.
Quick Quiz
1. During an interactive rebase you marked a commit edit. What does git reset HEAD^ do at that stop?
A mixed reset moves the branch back one commit and leaves the changes unstaged.
2. What does git rebase -x "npm test" main do?
-x adds an exec line after every pick in the to-do list.
3. You rebased the top branch of two stacked branches. Which option also moves the lower branch?
It force-updates branches that point to commits being rebased.
4. In git range-diff output, what does < mean?
< marks commits that were removed; > marks new ones and ! changed ones.
Learn Git with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
