Fast-Forward vs Merge Commits vs Rebase
Three engineers are debating a PR merge strategy in a channel. One says "always squash-merge." Another says "always rebase; merges pollute history." A third says "fast-forward everything." They all have opinions, none of them have explained what each actually does to the graph. The debate goes in circles. Eventually someone draws the three resulting histories on a whiteboard, and the argument resolves in five minutes: one shape preserves branch context, one looks linear with all commits visible, one looks linear with grouped commits, and the choice is about what story you want
git logto tell — not about correctness.This lesson is the visual walkthrough. Given the same two branches, you see three different end-states based on which integration strategy you use. Once you can draw them by hand, every team's merge-strategy debate is easier to navigate.
The Setup
Start with one branch ahead of another:
main: A --- B --- C
\
feature: D --- E --- F
main has commits A, B, C. feature branched off at B (its merge base with main) and added D, E, F. We want to integrate feature into main.
We have three options. Let us see each.
Option 1: Fast-Forward Merge
When main has not advanced since feature branched, Git can fast-forward: the main ref simply moves to point at feature's tip.
Setup
main: A --- B
\
feature: D --- E --- F
git checkout main && git merge feature
main: A --- B --- D --- E --- F <- main now points here
↑ same as feature
No merge commit. main just advances. This is cleanest when:
- The feature branch has only your commits (nothing happened on main during your work).
- You want linear history with no "merged feature X" context.
When fast-forward is allowed
git merge feature # will fast-forward if possible
git merge --ff-only feature # error if fast-forward impossible (safer in scripts)
git merge --no-ff feature # force a merge commit even if fast-forward is possible
On GitHub, "Rebase and merge" button does (effectively) rebase onto main + ff. "Merge commit" button does --no-ff.
Fast-forward is the default when possible. It produces the cleanest history but loses the "this came from branch X" semantic. Whether you want that information is the team choice.
Option 2: Merge Commit (True Merge)
When main has advanced since feature branched, a fast-forward is no longer possible — there is no linear path from main's tip to feature's tip. Git creates a merge commit with two parents.
Setup
main: A --- B --- C
\
feature: D --- E --- F
git checkout main && git merge feature (or --no-ff)
main: A --- B --- C --------- M <- merge commit, parents = C and F
\ /
feature: D --- E --- F
M is a new commit with:
- Two parents: C (the previous tip of main) and F (the tip of feature).
- Tree: the combined content of both branches (possibly with conflict resolutions if the merge required them — see Lesson 3.2).
- Message: default "Merge branch 'feature' into main" (edit to taste).
git log --first-parent main walks only C → B → A, ignoring the merged-in branch commits. git log (default) walks all of them in topological order.
When merge commits are right
- When the branch represents a coherent feature and you want history to show "this was a feature branch, merged on this date."
- When preserving the exact commits as authored (no rebase rewrites) matters for audit or blame.
- When bisect needs the full commit graph to find regressions.
Option 3: Rebase
Instead of merging, you can replay feature's commits on top of main. This produces a linear history without a merge commit.
Setup
main: A --- B --- C
\
feature: D --- E --- F
git checkout feature && git rebase main
main: A --- B --- C <- unchanged
feature: A --- B --- C --- D' --- E' --- F' <- new commits (primed SHAs)
D, E, F are replayed on top of C, producing new commits D', E', F' with new SHAs. The original D, E, F are unreferenced (but still in .git/objects/ and reflog).
Then you typically fast-forward main into feature:
git checkout main && git merge feature # now fast-forwards
Final state
main: A --- B --- C --- D' --- E' --- F'
feature: (same)
Linear. No merge commit. Every commit is visible on git log --oneline as a single line of development.
Trade-offs
- Pro: cleaner history, better for
git bisect, easier to read. - Con: the original feature commits have different SHAs — if others had pulled the original feature branch, they now disagree with yours. Force-push is required to update the remote feature branch. Never rebase commits that are on a shared long-lived branch.
The Three Shapes Side by Side
The GitHub "Squash and Merge" Button
There is a fourth option on most PR-based workflows: squash merge. It combines all of feature's commits into one commit on main, with a merge-style message.
Before
main: A --- B --- C
\
feature: D --- E --- F
After GitHub "Squash and merge"
main: A --- B --- C --- S <- one commit with the combined diff
Where S is a new commit whose tree = feature's tip, parent = main's previous tip, author = feature branch author(s).
Pros and cons
- Pro: main's history is one commit per PR — very clean.
- Pro: the PR description doubles as the commit message.
- Pro: fixups and messy commit history inside the branch is erased.
- Con: individual commit-level attribution and progression is lost.
- Con:
git bisecthas coarser granularity (one commit per PR, not one per logical change). - Con:
git blameloses the per-commit attribution within the PR.
Many teams (especially GitHub-hosted ones) default to squash-merge for PR size 1-5 commits, and merge commits for larger / multi-author PRs. Some use rebase-and-merge for the cleanest linear history with per-commit granularity preserved.
Team Conventions
A few real-world conventions:
| Convention | Main history looks like | Trade-off |
|---|---|---|
| "Always merge commit" (classic) | Branchy — every PR creates a merge commit | Full history; --first-parent gives PR-level view |
| "Squash everything" (startup default) | Linear, one commit per PR | Simple; loses per-commit granularity inside PRs |
| "Rebase and FF" (Linux kernel style) | Linear, all commits preserved | Requires rebase discipline; no merge commits |
| "Merge commits for multi-commit PRs, squash for singles" | Mix | Pragmatic; small PRs simple, big PRs detailed |
Pick one and make it automatic in your platform settings. Flipping per-PR causes confusion.
Doing Each By Hand
Fast-forward
git checkout main
git merge feature # ff if possible
git merge --ff-only feature # fail if ff not possible
Merge commit
git checkout main
git merge --no-ff feature # force a merge commit
# Editor opens for the merge message; usually keep default
Rebase + fast-forward (linear with preserved commits)
git checkout feature
git rebase main
# conflicts may arise; resolve, git rebase --continue
git checkout main
git merge feature # now fast-forwards (feature is on top of main)
Squash (command-line equivalent of GitHub's button)
git checkout main
git merge --squash feature
# Staging area now has all of feature's changes; working tree has them too
git commit -m "feat: add entire feature X"
# One new commit on main; feature branch not touched
Choosing the Right Strategy in Scenarios
"I'm the only one on this branch, and no one's pushed to main"
Fast-forward or rebase are both fine. Fast-forward is simpler.
"I'm on a team, PR under review, main moves fast"
Rebase your feature onto latest main periodically (git fetch && git rebase origin/main), then when approved, merge (whichever your team uses). This keeps your commits on top of the latest main and resolves conflicts one at a time as they arise, not in a pile at the end.
"My PR has 15 commits of wip / fix / typo"
Clean up with git rebase -i origin/main before asking for review. Squash typos into their logical commits; reword messages. Reviewer sees clean, logical commits. Or skip cleanup and let GitHub squash-merge.
"I'm rescuing commits from a wrong base"
Use git rebase --onto:
# I branched `feature` off `develop`, but should have been off `main`
git rebase --onto main develop feature
# Now feature's commits are replayed on top of main, ignoring develop's history
"I messed up a rebase and want to undo"
Reflog is your friend:
git reflog
# find the pre-rebase HEAD entry
git reset --hard HEAD@{7} # or whatever entry the reflog shows
Module 4 covers this in detail.
Practical Advice
- Rebase your local feature branch often to stay current with main. Conflicts are much easier to resolve one commit at a time than in a big end-of-branch merge.
- Do not rebase shared branches. If more than one person pulls from a branch, never rebase it.
- For linear history teams, default to rebase-merge or squash-merge on the PR platform.
- For branching-history teams, default to --no-ff merges so each PR produces a visible merge commit.
- Use
--first-parentingit logwhen history is branching and you want the main development story. - When in doubt, make a backup branch before anything that rewrites history.
Key Concepts Summary
- Fast-forward: main's ref moves forward to feature's tip. No merge commit. Only possible when main has not advanced.
- Merge commit: a new commit with 2+ parents, preserving branch history. Default for non-ff merges.
- Rebase: replay feature's commits on top of main. New SHAs; linear history.
- Squash-merge: collapse feature's commits into one commit on main. GitHub's default button for many repos.
--first-parentshows main's view at the PR level (ignoring internal branch commits).- Trade-offs: FF simplest, merge preserves branch context, rebase is clean but rewrites SHAs, squash is clean but loses per-commit granularity.
- Rebase before pushing to keep shared history clean. Never rebase after pushing to a shared branch.
- Use
--force-with-leaseinstead of--forcewhen force-pushing rebased branches. - Team convention > personal preference. Standardize and automate via platform settings.
Common Mistakes
- Merging main into feature (instead of rebasing) to "update" the feature branch. Works, but pollutes history with many "Merge main" commits.
- Rebasing a branch that others have pulled, then force-pushing without coordination. Other people's pulls break.
- Using
--forcewhen you should use--force-with-lease. - Switching merge strategies mid-PR (rebase one, merge another) without a team convention. History becomes inconsistent.
- Rebasing merge commits without
--rebase-merges. The merge structure is lost; rebase linearizes. - Using GitHub's "Create a merge commit" button when main history is supposed to be linear. Convention matters — set the default in repo settings.
- Thinking squash-merge preserves individual commits. It does not — they are combined into one commit on main.
- Running
git merge --no-ffon a branch with a single commit, creating a pointless merge commit (parent of merge is feature's only commit, which has main as its parent). - Forgetting that after a rebase, your feature branch and origin/feature differ — you need to force-push.
- Not using
--first-parenton branchy projects;git log --onelinebecomes noise with hundreds of merged commits interleaved.
Your team uses 'squash and merge' as the default PR strategy. Main looks linear with one commit per PR. A teammate wants to cherry-pick commit `a1b2c3d` (a squashed merge from last month) onto a hotfix branch based off an older release tag. When they cherry-pick, they get unexpected conflicts even though they expect the commit to apply cleanly. What is probably happening?