EthicsComputerCourses
Join training +91 90532 10052

Interactive Rebase & History Cleanup

Git · Lesson 17 of 42 Intermediate 10 min read Updated

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":

Terminal
git log --oneline main..
Output
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:

Text
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 link

Four 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.

Terminal
git rebase -i main

When 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:

Output
[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:

Terminal
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 --continue
Output
Unstaged 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:

Terminal
git log --oneline main..
Output
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}:

Terminal
git range-diff main feature/invoice@{1} feature/invoice
Output
1:  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:

Terminal
git rebase -x "sh check.sh" main
Output
Executing: 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):

Text
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.sh

This 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:

Terminal
git switch feature/invoice-pdf
git rebase --update-refs main
Output
Successfully 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:

Terminal
git commit --fixup=reword:HEAD~1
git config set rebase.autoSquash true
git rebase -i main

In 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:

Terminal
git reflog -3 feature/invoice-pdf
git reset --hard feature/invoice-pdf@{1}
Output
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 link

The 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 edit stops after the commit. Use git reset HEAD^ to split it, or git commit --amend to change it.
  • Reordering commits that touch the same lines. Expect conflicts, or squash them instead.
  • Not checking the result. Run git range-diff and your tests before force-pushing.

Key points

  • In the to-do list, reword, drop, edit and moving lines change messages, remove commits, stop for changes and reorder.
  • Split a commit by marking it edit, running git 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 (or rebase.updateRefs) keeps stacked branches in step during a rebase.
  • git range-diff main old new shows exactly what a rewrite changed; the branch reflog holds the old tip.
  • --fixup=amend: and --fixup=reword: commits are applied by --autosquash or rebase.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
Terminal
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} practice

In 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?

2. What does git rebase -x "npm test" main do?

3. You rebased the top branch of two stacked branches. Which option also moves the lower branch?

4. In git range-diff output, what does < mean?

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