Mert Özen Further With Every Line
DevOps Git & GitHub — #21

Git Interactive Rebase and Squash: Tidying History Before a PR

Mert Özen Nov 6, 2026 10 min 2 views
Git Interactive Rebase and Squash: Tidying History Before a PR

We turn a messy feature branch into three clean commits with git rebase -i: the todo list, pick, reword, edit, squash, fixup, drop and reordering, the commit --fixup plus --autosquash flow, how it differs from GitHub's squash merge, and the experimental git history reword in Git 2.54.

Version note: The commands in this post were tested with Git 2.54; git history is an experimental command introduced in Git 2.54.

Guess First

Ada has finished the discount feature and looks at her branch before opening a PR:

$ git log --oneline main..feature/discount
b1010e0 (HEAD -> feature/discount) Document discounts in README
e43df45 add tests
69c0579 Add console.log for debugging
8a70245 Fix typo in discount
b03b5bd wip
7dd1804 Add discount function

We have all produced a history like this. A wip, a commit fixing a typo from an earlier commit, a console.log committed by accident. The reviewer has to read six commits one by one, and the first one is even broken on its own: the export line says applyDiscont. The question: can you turn these six commits into three clean ones without editing a single line of code by hand? And when you remove the console.log commit along the way, what happens to the code?

Reading the Todo List

Interactive rebase is the same rebase as in the previous post; the only difference is that it hands you a list before applying the commits. You edit the list, save and close, and Git applies it from top to bottom.

$ git rebase -i main

We gave main as the base, meaning "the commits added to this branch after main". You could also write it as git rebase -i HEAD~6. This opens in the editor:

pick 7dd1804 # Add discount function
pick b03b5bd # wip
pick 8a70245 # Fix typo in discount
pick 69c0579 # Add console.log for debugging
pick e43df45 # add tests
pick b1010e0 # Document discounts in README

# Rebase 84281c4..b1010e0 onto 84281c4 (6 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# 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, unless -C is used, in which case
#                    keep only this commit's message; -c is same as -C but
#                    opens the editor
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
...
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.

The first thing you notice is that the order is the reverse of git log. git log shows the newest on top; the todo list starts with the oldest, because that is the order in which Git will apply the commits. The list is like a script: each line is a command, and Git plays the lines in order.

The Commands: pick, reword, edit, squash, fixup, drop

Ada edited the list like this:

pick 7dd1804 # Add discount function
fixup 8a70245 # Fix typo in discount
squash b03b5bd # wip
drop 69c0579 # Add console.log for debugging
edit b1010e0 # Document discounts in README
reword e43df45 # add tests

Let's read it line by line. pick uses the commit as it is. fixup melts the commit into the one above it and throws its message away; that is exactly what the typo fix is for. But the fix was two lines below the first commit in the list. Ada moved the line up, because fixup and squash always combine with the line directly above. squash combines too, but offers you both messages and asks you to write a common one. drop removes the commit completely; deleting the line does the same, but writing drop makes the intent visible. edit applies the commit and stops there so you can change its content. reword leaves the content alone and only opens an editor for the message.

Ada also swapped the last two lines: the README commit now comes before the tests. Moving lines changes the order of the commits. That is painless for commits touching independent files; if you swap commits that touch the same lines, be ready for a conflict.

While the Rebase Runs: Squash, Edit, Reword

When you save and close, Git reaches the first stop. For the squash line, the combined message editor opens:

# This is a combination of 3 commits.
# This is the 1st commit message:

Add discount function

# The commit message #2 will be skipped:

# Fix typo in discount

# This is the commit message #3:

wip

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
...

Here you can see why the fixup message is "skipped". Ada deleted the wip line and wrote a body instead:

Add discount function

Rates outside 0..1 throw a RangeError.

Then came the edit stop:

[detached HEAD d1877a1] Add discount function
 Date: Mon Nov 9 10:05:00 2026 +0300
 1 file changed, 8 insertions(+)
 create mode 100644 src/discount.js
Stopped at b1010e0...  # Document discounts in README
You can amend the commit now, with

  git commit --amend 

Once you are satisfied with your changes, run

  git rebase --continue
$ git log --oneline -3
5107617 (HEAD) Document discounts in README
d1877a1 Add discount function
84281c4 (main) Add .gitignore

Git applied the README commit and stepped back. At this point the repo is yours; you can change the file and amend the commit, or even add new commits in between. Ada added a line to the README:

$ git add README.md
$ git commit --amend --no-edit
[detached HEAD 6572a90] Document discounts in README
 Date: Mon Nov 9 10:10:00 2026 +0300
 1 file changed, 5 insertions(+)
$ git rebase --continue
[detached HEAD 12d9fc9] Add tests for applyDiscount
 Date: Mon Nov 9 10:09:00 2026 +0300
 1 file changed, 5 insertions(+)
 create mode 100644 test/discount.test.js
Successfully rebased and updated refs/heads/feature/discount.

After --continue the editor opened for reword, Ada changed the message to Add tests for applyDiscount, and the rebase finished. The result:

$ git log --oneline main..feature/discount
12d9fc9 (HEAD -> feature/discount) Add tests for applyDiscount
6572a90 Document discounts in README
d1877a1 Add discount function
$ git show --stat HEAD~2
commit d1877a194f5e2c55ef71b8f665ed04e314d225c2
Author: Ada Lovelace <ada@example.com>
Date:   Mon Nov 9 10:05:00 2026 +0300

    Add discount function
    
    Rates outside 0..1 throw a RangeError.

 src/discount.js | 8 ++++++++
 1 file changed, 8 insertions(+)

The second half of the guessing question: dropping the console.log commit removed that line from the code too, because the change it introduced was never applied. drop doesn't undo anything; it pretends the commit never happened. Three commits are left, and each makes sense on its own. The first one is no longer broken: the typo fix melted into it.

commit --fixup and --autosquash

In the review, Alan asked for the discounted amount to be rounded to two decimals. Logically this change belongs to the first commit. Instead of making a new "Address review" commit, you can tell Git which commit this fixes:

$ git diff
diff --git a/src/discount.js b/src/discount.js
index ed645ba..e2fd69e 100644
--- a/src/discount.js
+++ b/src/discount.js
@@ -2,7 +2,7 @@ function applyDiscount(total, rate) {
   if (rate < 0 || rate > 1) {
     throw new RangeError("rate must be between 0 and 1");
   }
-  return total - total * rate;
+  return Math.round((total - total * rate) * 100) / 100;
 }
 
 module.exports = { applyDiscount };
$ git add src/discount.js
$ git commit --fixup=d1877a1
[feature/discount d5ad5ce] fixup! Add discount function
 1 file changed, 1 insertion(+), 1 deletion(-)

Git wrote the message itself: the fixup! prefix and the target commit's subject line. When you are ready, --autosquash puts these commits in the right spot in the list on its own:

$ git rebase -i --autosquash main
pick d1877a1 # Add discount function
fixup d5ad5ce # fixup! Add discount function
pick 6572a90 # Document discounts in README
pick 12d9fc9 # Add tests for applyDiscount
...

The list arrives ready; just save and close:

Successfully rebased and updated refs/heads/feature/discount.
$ git log --oneline main..feature/discount
e1032a5 (HEAD -> feature/discount) Add tests for applyDiscount
89f2e5a Document discounts in README
a92c61e Add discount function

The beauty of this flow: you collect --fixup commits throughout the day, and before the PR's final round you melt them all into place with a single --autosquash. If you don't want to type it every time, make it the default for interactive rebases with git config set rebase.autoSquash true. If you have already pushed the branch, the rule from the previous post applies: --force-with-lease --force-if-includes.

Compared with GitHub's Squash Merge

When merging a PR, GitHub offers three methods: merge commit, squash and rebase. "Squash and merge" turns all the commits of the PR into a single commit on main. So why bother with interactive rebase?

Because the two answer different questions. Squash merge answers "how many commits should this PR be on main?", and the answer is always one. Interactive rebase answers "which logical steps does this PR consist of?". In a big PR, keeping the "refactor first, then the feature, then the tests" split is gold for git bisect and git revert. Squash buries that in one commit. For small, single-purpose PRs squash merge is practical; for multi-step PRs, tidying history with rebase and merging with a merge commit or the rebase method leaves more information behind.

Squash also has a side effect locally. Try the same thing locally with git merge --squash and you will see it:

$ git switch main
Switched to branch 'main'
$ git merge --squash feature/discount
Updating 84281c4..223b19e
Fast-forward
Squash commit -- not updating HEAD
...
$ git commit -m "Add discounts (#12)"
[main c44843c] Add discounts (#12)
 3 files changed, 18 insertions(+)
 create mode 100644 src/discount.js
 create mode 100644 test/discount.test.js
$ git branch -d feature/discount
error: the branch 'feature/discount' is not fully merged
hint: If you are sure you want to delete it, run 'git branch -D feature/discount'
hint: Disable this message with "git config set advice.forceDeleteBranch false"

The new commit on main is none of the commits on the branch; it is a copy of their sum. That is why Git cannot tell that the branch was merged. You get the same warning when you delete the local branch of a PR that was squash merged on GitHub; if you are sure the content is on main, use -D.

New in Git 2.54: git history

Interactive rebase is powerful, but opening a whole todo list just to fix one message can feel heavy. For this, Git 2.54 introduced the experimental git history command. For now it has two subcommands: reword and split.

$ git history reword HEAD~1
$ git log --oneline main..feature/discount
223b19e (HEAD -> feature/discount) Add tests for applyDiscount
ed0e65c Document discount rules in README
a92c61e Add discount function

The command opened only that commit's message in the editor. After saving, that commit and the ones after it were rewritten, and the branch moved to the new tip. Unlike rebase, it never touches the working tree or the index; it works even while you have unfinished changes. split splits a commit into two by letting you choose its hunks. Since the command is experimental, its behaviour may change. It does not work on histories with merges or for operations that could produce conflicts. Later releases add new subcommands; we will cover them in the versions block.

Common Pitfalls

Reading the todo list in git log order. The list goes from oldest to newest. fixup and squash combine with the line above; if you put fixup on the top line, Git has nothing to combine with and errors out.

Losing a commit by deleting a line. A line removed from the list means drop. If you deleted one by mistake, get out with git rebase --abort before the rebase finishes; if it already finished, bring it back the way we will see in the reflog post.

Tidying a shared branch. Interactive rebase is still a rebase; the golden rule applies as it is. If a teammate has built work on top of your branch, don't rewrite history without talking to them.

Forgetting --continue at an edit stop. After you amend the commit, the rebase is still running. git status tells you where you are.

Squashing everything into one commit. Clean history doesn't mean few commits; it means every commit has a single, understandable job.

A Small Experiment

★☆☆ Make three commits on a branch and leave a deliberate typo in the second one's message. Fix it first with reword in git rebase -i, then do the same with git history reword, and compare the two experiences.

★★☆ On a four-commit branch, create two fix commits with git commit --fixup: one for the first commit and one for the third. Look at how the git rebase -i --autosquash list arrives and verify the result with git log -p.

★★★ In the todo list, swap two commits that touch the same line of the same file. In the conflict that follows, what code is on the HEAD side? After resolving it, add an exec node test/discount.test.js line after every pick and verify that the tests pass at every commit.

You deleted a line in an interactive rebase by mistake, or lost a commit with reset --hard. No need to panic: Git notes down every place HEAD has been. In the next post we bring back lost commits and deleted branches with git reflog.