Git Merge, Rebase & Squash — Part 3: Rebase — Rewriting History Safely

On this page
Abstract
Managing a coherent project history in version control requires techniques that go beyond simply joining divergent lines of development. While a standard merge preserves the branching shape of the history, it can produce a complicated commit graph, and a squash collapses multiple changes. A rebase operation addresses this by recreating the commits of a current branch directly on top of a new base, facilitating a clean fast-forward merge by flattening the history into a straight line. Because the parent of the original commits changes, Git must generate new commit hashes, meaning a rebase fundamentally rewrites history rather than moving existing snapshots. Consequently, developers must adhere to the golden rule of never rebasing public or shared branches, restricting this strictly to private feature branches or local commits. When updating a remote repository, developers must utilize the --force-with-lease flag instead of a plain --force to prevent destroying a colleague's work. This article elucidates the underlying mechanics of rebasing, contrasts it with merging, and establishes essential safety protocols.

In Parts 1 and 2 we saw that a merge joins two lines of history and that a squash collapses many commits into a single one. Part 3 is about rebase — the most powerful and the most demanding of the three, because it does not merely join history, it rewrites it.
By the end of this part you will understand how rebase works at the level of the commit graph, why commit hashes change, how rebase differs from merge, and — most important of all — the safety rule for which branches you may rebase and which you must never touch, together with how to bring a feature branch up to date with main correctly using --force-with-lease.
What rebase is: replaying commits onto a new base
Rebase means:
Take the commits of your current branch and recreate them on top of a new base.
Suppose we are in a situation where both the feature branch and main have moved forward independently:
D---E feature/login
/
A---B---C---F---G main
While we were building D and E on feature/login, main gained F and G. If we want our feature to sit on top of main's latest work without creating a merge commit, we use rebase, standing on the feature branch:
git switch feature/login
git rebase main
Git takes the changes in D and E and recreates them, one at a time, as new commits on top of G:
We can then bring the feature into main as a fast-forward, because the feature now continues directly from main's tip:
git switch main
git merge --ff-only feature/login
The result:
A---B---C---F---G---D'---E' main
The history is now a straight line — no branching, and no merge commit.
Why D becomes D′
The point that trips many people up is this: why, after a rebase, do the commits become D′ and E′ rather than the original D and E? The answer lies in how Git computes a commit hash.
A commit hash is computed from several ingredients together, including:
- the contents (the snapshot of the files)
- the parent commit
- the author
- the commit message
- the timestamp
When rebase changes the parent of D from C to G:
before: parent(D) = C
after: parent(D') = G
Once the parent changes, one of the hash's ingredients changes, so the hash must change too:
D ≠ D'
E ≠ E'
This is why rebase counts as a rewrite of history — Git does not move the old commits to a new location; it creates a fresh set of commits with the same content in their place. The original commits (D, E) become commits that no branch points to any more, and are eventually garbage-collected.
Merge vs Rebase
Merge and rebase both solve the same problem — "how do I bring two lines of work together?" — but they produce completely different graphs.
Merge — keeps the real graph
A merge creates a merge commit and preserves the branching shape of the history:
main through the two-parent merge commit M.You can still clearly see that D and E once formed a separate feature branch that was merged back at M.
Rebase — flattens into a line
A rebase moves the commits into a single straight line, with no merge commit:
Side-by-side comparison
| Aspect | Merge | Rebase |
|---|---|---|
| Keeps the original commits | Yes | No |
| Commit hash | Unchanged | Changed |
| History shape | Branched | Linear |
| Creates a merge commit | May create one | Not from the rebase itself |
| Suitable for a shared branch | Yes | Use with caution |
| Conflicts | Usually resolved once | May be resolved commit by commit |
| Visibility of feature history | Clear | Less clear than merge |
The golden rule of rebase
Do not rebase commits that other people are already building on — unless the team has agreed to it.
Because rebase creates a new set of commit hashes on top of the old ones, if someone else is referencing or has branched off the original commits, their history stops matching yours the moment you rebase. Here is an example of what you should not do:
git switch main
git rebase some-branch
git push --force
If main is a public/shared branch, doing this throws everyone's history out of sync and is hard to recover from.
Generally safe to rebase
- a private feature branch
- local commits you have not pushed yet
- a branch nobody else is using yet
Generally should not rebase
mainmasterproduction- a shared release branch
- a branch that a colleague has already branched off
Rebasing a feature branch onto main
This is the most common use case for rebase: updating your feature branch so it sits on top of main's latest work before you open or merge a PR. The steps are — bring main up to date first, then rebase the feature on top of it:
git switch main
git pull
git switch feature/login
git rebase main
Once the rebase is done, the feature's commit hashes have changed. If you had pushed this branch to the remote earlier, this push is no longer a fast-forward, so it has to overwrite — but it must overwrite safely:
git push --force-with-lease
You should not use:
git push --force
The difference matters a great deal: --force-with-lease first checks whether the remote has new work from someone else that you have not seen yet; if it does, it refuses the push so you cannot accidentally overwrite their work. A plain --force overwrites immediately without checking anything, which risks silently destroying a colleague's commits. So on any branch you have already pushed, make --force-with-lease your default.
Key takeaways
- Rebase rewrites history — it recreates your current branch's commits on a new base, giving you a straight-line history with no merge commit.
- Commit hashes must change because the parent changes (
C→G), soD ≠ D'every time — the old commits are not moved, they are recreated. - Merge keeps the real graph; rebase flattens it into a line — choose merge when you want to see the feature's boundaries, choose rebase when you want a clean linear history.
- Only rebase private history (your own feature branch, local commits), and never rebase public branches such as
main,master,production, or a shared release branch. - After rebasing a branch you have already pushed, always push with
--force-with-lease, not--force, because it checks for other people's new work on the remote before overwriting.