Skip to main content

Rebase, Fast-Forward, and Merge: What Git Actually Does

Published on 5 min read

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.

commit graph
1main    A --- B
2               \
3feature         C --- D

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:

terminal
1main/feature    A --- B --- C --- D

No new commit, no conflicts. The result is one straight line. It also erases the fact that C and D were ever a branch.

Fast-Forward Is Not a Strategy

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:

commit graph
1main    A --- B --- E --- M
2               \         /
3feature         C ----- D

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:

terminal
1git merge --no-ff feature

Rebase: Move Your Commits Instead

Rebase attacks the same divergence from the other end. Rather than joining the two lines, it moves yours:

commit graph
1# git switch feature && git rebase main
2
3main    A --- B --- E
4                     \
5feature               C' --- D'

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.

Conflicts Arrive One at a Time

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.

See what each button does to history
Pick a strategy, then step or play through it. Commits keep their color, so watch the branch tags move.

git merge feature

ABCDmainfeature
commit on maincommit on featureleft behind

main is still on B. The feature branch added C and D on top of it, and main never moved.

Step 1 of 4

The One Hard Rule

Don't Rebase Shared History

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

SituationUse
Small branch, main unchangedFast-forward, since a merge commit would be noise
Feature branch worth seeing in the graph--no-ff merge
Your branch fell behind maingit rebase main, catch up with no extra commit
Messy WIP before opening a PRgit rebase -i while it's still private
Branch is pushed and sharedMerge, 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.

Make Pull Rebase by Default

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.

All articles