A pull request with working code and a commit history like this are two different things:
fix typo fix broken test actually fix it try again move buttonThe code may be correct, but the history makes it difficult for reviewers to understand what changed and why, and harder to trace regressions later with tools like
git bisect. Interactive rebasing solves this: it lets you reorganize, rename, and consolidate commits before a branch merges, so the history tells a coherent story rather than documenting every false start.This guide covers the interactive rebase workflow from start to finish, including squash, fixup, reword, and how to push the cleaned history back to a pull request safely.
What this covers:
Viewing commit history before cleaning up
Running an interactive rebase
Squashing, rewording, and dropping commits
Writing better commit messages
Using
fixupfor quick cleanup without promptsForce-pushing safely with
--force-with-lease
Why Commit History Matters
A clean commit history is not an aesthetic preference. It has practical consequences:
Reviewers can understand changes faster when commits are logical and well-described
git bisectworks more reliably when commits represent discrete, complete changes rather than incremental fixesThe main branch stays readable over time, which matters when tracking down when and why a change was introduced
Merge conflicts are easier to resolve when the intent behind each commit is clear
Cleaning up history before merging is a low-effort step that benefits everyone who works on the codebase after the fact.
Step 1: View the Current Commit History
Before rebasing, review what needs cleaning up:
git log --oneline
This produces output like:
a1b2c3e fix layout issue
3f4g5h6 add error handling
7h8i9j0 temporary fix for test
b2c3d4f wip
The goal is to turn a sequence like this into one or two commits that clearly describe what the feature or fix actually does, with the intermediate work-in-progress commits collapsed.
Step 2: Start an Interactive Rebase
Run interactive rebase against the branch the PR targets, typically main or develop:
git rebase -i origin/main
This opens a text editor with a list of commits not yet in the target branch, ordered oldest to newest:
pick b2c3d4f wip
pick 3f4g5h6 add error handling
pick 7h8i9j0 temporary fix for test
pick a1b2c3e fix layout issue
Each line represents a commit and begins with an instruction. The default is pick, which keeps the commit unchanged. Changing the instruction controls what happens to that commit during the rebase.
The available instructions:
Instruction | Shorthand | Effect |
|---|---|---|
|
| Keep the commit as-is |
|
| Merge into the previous commit, combine messages |
|
| Merge into the previous commit, discard this message |
|
| Keep the commit but edit its message |
|
| Remove the commit entirely |
|
| Pause the rebase to amend the commit |
Step 3: Squash Work-in-Progress Commits
To collapse the four commits above into one, change the instructions for all but the first:
pick b2c3d4f wip
squash 3f4g5h6 add error handling
squash 7h8i9j0 temporary fix for test
reword a1b2c3e fix layout issue
Save and close the editor. Git processes the instructions in order:
The three
squashinstructions merge those commits intob2c3d4fand open an editor to write a combined commit messageThe
rewordon the final commit opens another editor to rename it
Write a single clear commit message for the combined changes:
feat(auth): add error handling for invalid token responses
Then write a clear message for the rewarded commit:
fix(layout): correct button alignment on mobile breakpoints
The result is two commits that describe what was done, rather than four that document how it was done iteratively.
Step 4: Write Better Commit Messages
The commit message is the primary documentation for why a change was made. A well-written message is more valuable than a comment in the code, because it is preserved in the repository history with the exact context of when and why it was written.
A useful format is Conventional Commits:
type(scope): short description
Optional longer explanation of what changed and why.
Common types: feat, fix, refactor, docs, test, chore.
The short description should complete the sentence "This commit will..." without including the words "this commit will". Keep it under 72 characters so it displays cleanly in git log --oneline output.
Step 5: Use fixup for Quick Cleanup
When squashing commits where the intermediate messages are not worth preserving, fixup is more efficient than squash. It merges the commit into the previous one without opening an editor for the combined message.
pick 3f4g5h6 add error handling
fixup 7h8i9j0 temporary fix for test
fixup b2c3d4f wip
For a more automated workflow, commit work-in-progress changes with the --fixup flag while working:
git commit --fixup 3f4g5h6
This creates a commit prefixed with fixup! add error handling. When running the interactive rebase, pass --autosquash to automatically position and mark these fixup commits correctly:
git rebase -i --autosquash origin/main
This removes the manual step of editing the instruction list when the fixups are already tagged.
Step 6: Force-Push Safely
After rebasing, the local branch history has diverged from the remote branch. A regular push will be rejected. Push with --force-with-lease:
git push --force-with-lease
The --force-with-lease flag checks that no new commits have been added to the remote branch since the last fetch before overwriting it. If another contributor has pushed to the branch in the meantime, the push is rejected rather than overwriting their work. This is a meaningful safety guarantee that --force does not provide.
After the push, the pull request on GitHub or GitLab will reflect the updated, clean commit history.
Common Issues and Fixes
Rebase conflicts: If a conflict occurs during the rebase, Git pauses and marks the conflicting files. Resolve the conflicts manually, stage the resolved files with git add, and continue with git rebase --continue. To abandon the rebase entirely and return to the original state, run git rebase --abort.
Accidentally dropped a commit: The commit is not lost. Run git reflog to find the commit hash and use git cherry-pick <hash> to restore it.
Interactive rebase opens in the wrong editor: Set the preferred editor with git config --global core.editor "code --wait" for VS Code or git config --global core.editor "nano" for Nano.
Key Takeaways
git rebase -i origin/mainopens the interactive rebase editor with all commits not yet in the target branch.squashmerges a commit into the previous one and combines their messages;fixupdoes the same but discards the commit's message without prompting.rewordkeeps a commit's changes but opens an editor to rename the message.dropremoves a commit entirely. Usegit reflogto recover it if needed.git push --force-with-leaseoverwrites the remote branch only if no new commits have been added since the last fetch, which prevents accidentally overwriting a collaborator's work.The
--autosquashflag combined withgit commit --fixupautomates fixup positioning during the rebase.
Conclusion
Interactive rebasing is one of the higher-leverage Git skills available. The commands are simple once the mental model is clear: the interactive editor is a list of instructions Git executes in order, and changing those instructions before saving controls exactly what the history looks like.
The workflow described here, squash working commits into logical units, write clear messages, and push with --force-with-lease, produces pull requests that are easier to review and a main branch history that is easier to work with over the long term.
Have a Git history cleanup approach that works well for your team? Share it in the comments.




