Git Internals for Engineers

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 log to 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.

KEY CONCEPT

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

Fast-ForwardMerge CommitRebase + FFABDEFAmain,featureLinear. No merge commit.main's ref just moved forward.ABCMmergeDEFmainMerge commit M has two parents (C, F).Branch context preserved.ABCD'E'main, featureLinear. New SHAs for replayed commits.No branch context in the graph.Choosing between themFast-Forward• Linear, minimal history• No "branch X merged" context• Only works if main has not advancedGood for: solo work, tiny featuresMerge Commit• Two-parent merge commit preserved• Branch lifecycle visible• Safer: no SHA rewritesGood for: team PRs, audit trailsRebase• Linear after merge• Commits keep their individual message• New SHAs; force-push neededGood for: clean history teams, bisect

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 bisect has coarser granularity (one commit per PR, not one per logical change).
  • Con: git blame loses 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:

ConventionMain history looks likeTrade-off
"Always merge commit" (classic)Branchy — every PR creates a merge commitFull history; --first-parent gives PR-level view
"Squash everything" (startup default)Linear, one commit per PRSimple; loses per-commit granularity inside PRs
"Rebase and FF" (Linux kernel style)Linear, all commits preservedRequires rebase discipline; no merge commits
"Merge commits for multi-commit PRs, squash for singles"MixPragmatic; 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

  1. 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.
  2. Do not rebase shared branches. If more than one person pulls from a branch, never rebase it.
  3. For linear history teams, default to rebase-merge or squash-merge on the PR platform.
  4. For branching-history teams, default to --no-ff merges so each PR produces a visible merge commit.
  5. Use --first-parent in git log when history is branching and you want the main development story.
  6. 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-parent shows 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-lease instead of --force when 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 --force when 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-ff on 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-parent on branchy projects; git log --oneline becomes noise with hundreds of merged commits interleaved.

KNOWLEDGE CHECK

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?