Rebase, Fast-Forward, and Merge: What Git Actually Does
The PR page offers three buttons: Create a merge commit, Squash and merge, Rebase and merge. You pick one out of habit.
They aren't styles. They produce different history, and that difference is what you'll be reading a month from now when something breaks.
A Branch Is Just a Pointer
Everything else follows from this.
A commit records a snapshot plus a link to its parent. A branch isn't a container holding commits. It is a sticky note with one commit's ID on it. Add a commit and Git moves the note forward.
Merging asks one question: how do we get main to include D? There's more than one honest answer.
Fast-Forward: Move the Note
If main hasn't moved since you branched, it's already an ancestor of D. Nothing to combine, so Git just slides the note forward:
No new commit, no conflicts. The result is one straight line. It also erases the fact that C and D were ever a branch.
It's what Git does when a merge turns out to be unnecessary. An optimization, not a decision. If main has moved on, Git can't fast-forward no matter how much you'd like it to.
Three-Way Merge: A Commit With Two Parents
Now main moved while you worked. The histories diverged, so nothing can slide. Git makes a commit with two parents:
To build M, Git finds the common ancestor (B) and compares it against both tips. That's the "three-way" part: three snapshots, one result. Where both sides touched the same lines, it stops and asks you.
Nothing is rewritten. C and D keep their IDs, parents, and timestamps.
You can force this even when a fast-forward was possible, which is how teams keep every PR visible in git log --graph:
Rebase: Move Your Commits Instead
Rebase attacks the same divergence from the other end. Rather than joining the two lines, it moves yours:
Git sets your changes aside, moves feature to point at E, and replays each one on top.
Note the primes. C' and D' are new commits: same author, same message, same content, different parent, so different IDs. The originals are unreferenced and eventually collected.
feature now sits directly on main, so merging it back is a fast-forward. That's the whole point: rebase linearizes history before the merge happens.
Replaying C may conflict, then D may conflict again on the result. A ten-commit branch can mean ten rounds. git rebase --abort puts everything back exactly as it was, so use it freely.
Because rebase rewrites commits anyway, it can rewrite more than the parent. git rebase -i HEAD~4 opens a list where reword fixes a message, squash folds a commit into the one above, and drop deletes it. That's how "fix typo" and "wip" become one clean commit before anyone sees them.
Watch It Happen
Same starting point, three different endings. Step through each one and watch where your commits land.
git merge feature
main is still on B. The feature branch added C and D on top of it, and main never moved.
The One Hard Rule
Rebasing rewrites commit IDs. If a teammate already has the old commits, their history and yours no longer agree. Their next pull merges the old and new versions of the same work, which produces duplicate commits and nonsense conflicts.
Rebase your own unpushed work freely. Never rebase main, or a branch someone else is building on.
If you rebased a pushed branch and you're certain it's yours alone, push with --force-with-lease. It refuses if the remote moved since your last fetch, meaning someone else pushed. Plain --force overwrites their work without asking.
Which One, When
| Situation | Use |
|---|---|
Small branch, main unchanged | Fast-forward, since a merge commit would be noise |
| Feature branch worth seeing in the graph | --no-ff merge |
Your branch fell behind main | git rebase main, catch up with no extra commit |
| Messy WIP before opening a PR | git rebase -i while it's still private |
| Branch is pushed and shared | Merge, since rewriting breaks their clone |
Squash is the third button: it flattens the branch into one commit on main. You get a changelog-shaped history and one-commit reverts, at the cost of losing the intermediate steps. Good when those steps were scaffolding, bad when they told a story.
git pull merges by default, sprinkling "Merge branch 'main' of origin/main" into your history for no reason. git config --global pull.rebase true replays your local commits on top instead.
The Takeaway
- Fast-forward moves a pointer. It happens when there's nothing to merge.
- Merge creates a commit with two parents. It records what happened, honestly, forever.
- Rebase rewrites your commits onto a new base. A cleaner story, at the cost of the original one.
Pick a default and hold it. The worst history isn't the merge-heavy one or the linear one. It's the one where nobody can tell which you were going for.