Squashing Git commits is like performing a surgical cleanup on your branch’s history, consolidating multiple small, incremental changes into a single, coherent commit before merging it into a main branch.

Let’s see this in action. Imagine you’ve been working on a feature and made several commits:

git log --oneline
a1b2c3d (HEAD -> feature-branch) Add login button
e4f5g6h Fix button alignment
i7j8k9l Add basic styling for login
m0n1o2p Initial commit for login feature

You want to merge feature-branch into main, but a1b2c3d, e4f5g6h, and i7j8k9l are too granular. You’d prefer a single commit like "Implement Login Feature."

To achieve this, you’ll use git rebase -i. The -i flag stands for interactive, meaning Git will prompt you for instructions on how to rewrite history.

First, determine how many commits you want to squash. In our example, we want to squash the last three commits into the first one (m0n1o2p). So, we’ll start an interactive rebase from the commit before the oldest one we want to modify. If m0n1o2p is the oldest commit we want to change, we’ll rebase from its parent. A common way to do this is by referencing the number of commits you want to include. Since we want to include 4 commits (the initial commit plus the three subsequent ones), we’ll run:

git rebase -i HEAD~4

This will open your default text editor with a list of the commits you’re about to rebase, with pick as the default action for each:

pick m0n1o2p Initial commit for login feature
pick i7j8k9l Add basic styling for login
pick e4f5g6h Fix button alignment
pick a1b2c3d Add login button

# Rebase 1234567..abcdef0 onto 1234567 (4 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 <commit> = like "squash", but discard this commit's log message
# x, exec <command> = run command (the rest of the line)
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <commit> = reset HEAD to <commit>
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# .       create a merge commit with the given details
#
# 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.
#

Your goal is to have the first commit you want to keep (m0n1o2p in our example) set to pick, and all subsequent commits you want to merge into it set to squash (or fixup). squash will allow you to combine the commit messages, while fixup will discard the message of the commit being squashed.

Modify the file to look like this:

pick m0n1o2p Initial commit for login feature
squash i7j8k9l Add basic styling for login
squash e4f5g6h Fix button alignment
squash a1b2c3d Add login button

Save and close the editor. Git will then process these instructions. For the squash commands, it will stop and present you with a new editor containing the combined commit messages from the pick and squash commits.

# This is a combination of 4 commits.
# The first commit's message is:
Initial commit for login feature
# This is the 2nd commit message:
Add basic styling for login
# This is the 3rd commit message:
Fix button alignment
# This is the 4th commit message:
Add login button

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

Here, you can edit the messages to create a single, clean, and descriptive commit message for your feature. For instance, you could change it to:

Implement Login Feature

This commit includes all necessary changes for the user login functionality,
from the initial setup to the final button addition and minor styling fixes.

Save and close this editor. Git will then apply this new, single commit. Your branch history will now look like this:

git log --oneline
abcdef0 (HEAD -> feature-branch) Implement Login Feature
m0n1o2p Initial commit for login feature # This is now the parent of abcdef0

Wait, that’s not right. m0n1o2p is still there. This is because git rebase -i HEAD~4 means "rebase the last 4 commits onto the commit before the oldest of those 4." So, m0n1o2p itself was part of the rebase operation.

To squash the last three commits into the first one, you need to rebase from the commit before the oldest commit you want to keep as the base. If m0n1o2p is the oldest commit you want to keep, you need to specify the rebase starting point as its parent. A more robust way to do this is by referencing the commit hash of the parent of the oldest commit you want to modify. Let’s say the commit before m0n1o2p has hash z9y8x7w. Then you’d run:

git rebase -i z9y8x7w

Or, if you know m0n1o2p is the oldest commit you want to keep, and you want to squash the subsequent commits into it, you can reference its parent. If you’re unsure, git reflog can help you find the hash of the commit before m0n1o2p.

Assuming m0n1o2p is the commit you want to retain as the base, and you want to squash the three commits after it into it, your interactive rebase file should look like this:

pick m0n1o2p Initial commit for login feature
squash i7j8k9l Add basic styling for login
squash e4f5g6h Fix button alignment
squash a1b2c3d Add login button

This is the correct setup for squashing commits into the first one listed. The previous example was illustrative of the process, but the outcome of git rebase -i HEAD~4 when m0n1o2p is the oldest of those 4 is that m0n1o2p itself is being replayed, and you can choose to squash into it.

The common mistake is misunderstanding HEAD~N. HEAD~N refers to the Nth ancestor of the current commit. So HEAD~4 means the commit 4 steps back from your current HEAD. If you want to squash the last 3 commits into the first commit of those 3, you’d use HEAD~3.

Let’s correct the example: you have 4 commits, and you want to squash the last 3 into the first one.

a1b2c3d (HEAD -> feature-branch) Add login button
e4f5g6h Fix button alignment
i7j8k9l Add basic styling for login
m0n1o2p Initial commit for login feature

To squash i7j8k9l, e4f5g6h, and a1b2c3d into m0n1o2p, you start the rebase from the commit before m0n1o2p. If m0n1o2p is the very first commit on this branch, you’d rebase from its parent (which might be a commit on main).

A simpler way to think about it: if you want to squash N commits into the first of those N, you run git rebase -i HEAD~N. The first commit in the list will be pick, and the subsequent N-1 commits will be squash.

So, for our 4 commits, if we want to squash the last 3 into the first:

git rebase -i HEAD~4

The editor will show:

pick m0n1o2p Initial commit for login feature
pick i7j8k9l Add basic styling for login
pick e4f5g6h Fix button alignment
pick a1b2c3d Add login button

Change it to:

pick m0n1o2p Initial commit for login feature
squash i7j8k9l Add basic styling for login
squash e4f5g6h Fix button alignment
squash a1b2c3d Add login button

This is correct. The first commit (m0n1o2p) is pick, and the next three are squash. This will result in a single commit with the message you construct from the combined messages.

Common Causes for Issues:

  1. Incorrect Rebase Range: You specified HEAD~N but meant to exclude the base commit or include more commits.

    • Diagnosis: Run git log --oneline to count the commits. Check the hash of the commit you intended to be the base.
    • Fix: Adjust the HEAD~N number or use the parent hash of the base commit. For example, if m0n1o2p is the base and it’s the 5th commit back from HEAD, you’d use HEAD~5 to include its parent, then set the first line to pick and the rest to squash.
    • Why it works: Git rebaselines the specified range onto a target commit. Correctly defining the range ensures all desired commits are included and acted upon.
  2. Merge Conflicts During Rebase: Changes in the squashed commits conflict with each other or with the base commit.

    • Diagnosis: Git will stop with a message like "CONFLICT (content): Merge conflict in ". git status will show conflicted files.
    • Fix: Manually resolve the conflicts in the files, then run git add <resolved_file> for each. Finally, run git rebase --continue.
    • Why it works: Resolving conflicts tells Git how to integrate the conflicting lines, allowing the rebase process to proceed with the unified history.
  3. Accidentally Dropping Commits: You deleted a line from the interactive rebase file instead of changing it to squash or pick.

    • Diagnosis: After the rebase, git log shows fewer commits than expected, and the desired changes are gone.
    • Fix: Abort the rebase with git rebase --abort and start over. Alternatively, use git reflog to find the commit hash before the rebase and reset to it (git reset --hard <commit-hash>), then re-attempt the rebase.
    • Why it works: Aborting or resetting discards the incomplete rebase operation, returning your branch to its state before the rebase attempt.
  4. Using fixup Instead of squash: You intended to combine messages but used fixup for all but the first commit, resulting in only the first commit’s message being kept.

    • Diagnosis: The rebase completes, but the final commit message is only from the first commit, not a combination.
    • Fix: Abort the rebase (git rebase --abort) and restart, this time using squash for commits whose messages you want to incorporate.
    • Why it works: squash prompts for a combined message, while fixup discards the message of the commit being fixed up.
  5. Forgetting to Push with --force: You’ve successfully squashed commits on a branch that has already been pushed to a remote.

    • Diagnosis: Pushing again results in an error like "rejected non-fast-forward".
    • Fix: Force-push the rewritten history: git push origin <branch-name> --force (or git push origin <branch-name> -f). Caution: This rewrites history and can cause problems for collaborators.
    • Why it works: Force-pushing overwrites the remote branch’s history with your local, rewritten history, aligning the remote with your changes.

The next error you’ll hit after successfully squashing and pushing is likely a merge conflict on your pull request if someone else has merged changes into the target branch since you last rebased.

Want structured learning?

Take the full Git course →