Version Control

Rebase vs Merge: A Practical Decision Framework

A practical decision framework for choosing between git rebase and merge, with real-world scenarios and team workflow patterns.

Rebase vs Merge: A Practical Decision Framework

The rebase-vs-merge debate has wasted more engineering hours than any actual merge conflict. The answer is not one or the other — it depends on context. This guide gives you a concrete decision framework based on branch ownership, team size, and history cleanliness requirements so you stop debating and start shipping.

Prerequisites

  • Git 2.20+ installed
  • Comfort with basic git operations (commit, branch, push, pull)
  • Understanding of what a commit SHA is
  • Access to a test repository to practice (do not learn rebase on your production repo)

How Merge Works

A merge combines two branches by creating a new commit that has two parents. Git supports two merge strategies.

Fast-Forward Merge

When the target branch has no new commits since the feature branch diverged, git just moves the pointer forward:

Before:
main:    A --- B --- C
                      \
feature:               D --- E

After (git merge feature):
main:    A --- B --- C --- D --- E

No merge commit is created. The history is perfectly linear. This happens automatically when there is nothing to merge — git just fast-forwards.

$ git checkout main
$ git merge feature
Updating c3d2e1f..a9b8c7d
Fast-forward
 src/auth.js | 45 +++++++++++++++++++++++++++++++++
 1 file changed, 45 insertions(+)

Three-Way Merge

When both branches have new commits, git creates a merge commit with two parents:

Before:
main:    A --- B --- C --- F
                      \
feature:               D --- E

After (git merge feature):
main:    A --- B --- C --- F --- M
                      \         /
feature:               D --- E

M is the merge commit. It records that the two lines of development were combined.

$ git checkout main
$ git merge feature
Merge made by the 'ort' strategy.
 src/auth.js   | 45 +++++++++++++++++++++++++++++++++
 src/config.js | 12 +++++++++
 2 files changed, 57 insertions(+)

How Rebase Works

Rebase replays your commits on top of another branch. It rewrites commit history by creating new commits with new SHAs.

Before:
main:    A --- B --- C --- F
                      \
feature:               D --- E

After (git rebase main from feature):
main:    A --- B --- C --- F
                            \
feature:                     D' --- E'

D' and E' have the same changes as D and E, but they are entirely new commits with new SHAs. The original D and E are orphaned.

$ git checkout feature
$ git rebase main
First, rewinding head to replay your work on top of it...
Applying: Add authentication module
Applying: Add session management

After rebasing, a merge from main becomes a fast-forward:

$ git checkout main
$ git merge feature
Updating f1e2d3c..e5d4c3b
Fast-forward
 src/auth.js    | 45 +++++++++++++++++++++++++++++++++
 src/session.js | 28 +++++++++++++++++++++
 2 files changed, 73 insertions(+)

Result: perfectly linear history with no merge commits.

The Decision Framework

Use this table. It covers 90% of real situations:

Scenario Use Why
Local feature branch, not pushed Rebase Clean history, no risk to anyone
Feature branch, only you work on it Rebase Safe to rewrite, cleaner PR history
Shared branch, multiple contributors Merge Rebase rewrites SHAs, breaks collaborators
Integrating main into feature branch Rebase Keeps your commits on top, clean diff
Merging feature into main (final) Merge (or squash-merge) Records the integration point
Hotfix branch Merge Preserve the fix history for auditing
Long-running release branch Merge Too many commits to rebase safely

The Golden Rule

Never rebase commits that have been pushed to a shared branch. Rebase rewrites commit SHAs. If someone else has based work on the original SHAs, their history diverges from yours and they get conflicts on every pull.

# THIS DESTROYS COLLABORATION:
$ git checkout shared-feature
$ git rebase main
$ git push --force  # Everyone else's work is now broken

Interactive Rebase for History Cleanup

Before merging a feature branch, use interactive rebase to clean up your commit history:

$ git rebase -i main

This opens your editor with:

pick a1b2c3d Add user model
pick d4e5f6a Fix typo in user model
pick 7g8h9i0 Add user controller
pick j1k2l3m WIP: debugging auth
pick n4o5p6q Fix auth bug
pick r7s8t9u Add user routes

Clean it up:

pick a1b2c3d Add user model
fixup d4e5f6a Fix typo in user model
pick 7g8h9i0 Add user controller
squash j1k2l3m WIP: debugging auth
squash n4o5p6q Fix auth bug
pick r7s8t9u Add user routes

Commands:

  • pick: keep the commit as-is
  • squash: combine with previous commit, merge messages
  • fixup: combine with previous commit, discard this message
  • reword: keep changes, edit commit message
  • drop: remove the commit entirely

Result: 6 messy commits become 3 clean ones. Your PR reviewer sees logical units of work, not your debugging journey.

Conflict Handling Differences

Merge Conflicts

With merge, you resolve conflicts once in the merge commit:

$ git merge feature
Auto-merging src/config.js
CONFLICT (content): Merge conflict in src/config.js
Automatic merge failed; fix conflicts and then commit the result.

$ vim src/config.js  # resolve conflicts
$ git add src/config.js
$ git commit  # creates merge commit

Rebase Conflicts

With rebase, you may resolve conflicts for each commit being replayed:

$ git rebase main
Applying: Add authentication module
Applying: Add session management
CONFLICT (content): Merge conflict in src/config.js
error: could not apply d4e5f6a... Add session management

Resolve all conflicts manually, mark them as resolved with
"git add/rm <conflicted_files>", then run "git rebase --continue".

$ vim src/config.js  # resolve conflicts
$ git add src/config.js
$ git rebase --continue
Applying: Add session management
Applying: Add user routes

If your feature branch has 20 commits and the conflict exists in 5 of them, you resolve the same conflict 5 times. This is where people abandon rebase.

Tip: Use git rerere (reuse recorded resolution) to automate repeated conflict resolution:

$ git config --global rerere.enabled true

Git remembers how you resolved a conflict and automatically applies the same resolution next time.

Team Workflow Patterns

Trunk-Based Development

Everyone commits to main (or short-lived branches merged within a day).

# Start work
$ git checkout -b feature/add-auth main

# Stay current with main (rebase, since branch is short-lived and yours)
$ git fetch origin
$ git rebase origin/main

# Merge back (squash merge for single logical commit)
$ git checkout main
$ git merge --squash feature/add-auth
$ git commit -m "Add authentication module"

Best for: Small teams, high trust, continuous deployment.

GitHub Flow

Feature branches with pull requests, merged into main.

# Create feature branch
$ git checkout -b feature/user-dashboard main

# During development, rebase on main to stay current
$ git fetch origin
$ git rebase origin/main

# Push (force-with-lease since you rebased)
$ git push --force-with-lease origin feature/user-dashboard

# PR gets squash-merged or merge-committed via GitHub UI

Best for: Most teams. Clean PRs, simple branching.

GitFlow

Long-running develop and release branches.

# Feature branches merge into develop
$ git checkout develop
$ git merge --no-ff feature/user-dashboard

# Release branches merge into both main and develop
$ git checkout main
$ git merge --no-ff release/2.1.0
$ git checkout develop
$ git merge --no-ff release/2.1.0

Always use --no-ff (no fast-forward) in GitFlow. You want merge commits to mark integration points.

Best for: Teams with formal release cycles, multiple versions in production.

Configuring Git Pull Behavior

By default, git pull does a fetch + merge. You can change this:

# Make git pull rebase by default (recommended for feature branches)
$ git config --global pull.rebase true

# Or per-branch
$ git config branch.feature/auth.rebase true

# Use --ff-only to prevent accidental merge commits on main
$ git config --global pull.ff only

With pull.ff only, pulling on main fails if a fast-forward is not possible, forcing you to explicitly choose merge or rebase:

$ git pull
fatal: Not possible to fast-forward, aborting.

# Now explicitly choose:
$ git pull --rebase  # or
$ git pull --no-rebase

Complete Working Example

Let us walk through a feature branch lifecycle using both approaches.

Setup

$ mkdir git-demo && cd git-demo && git init
$ echo "# App" > README.md && git add . && git commit -m "Initial commit"
$ echo "var config = { port: 3000 };" > config.js
$ git add . && git commit -m "Add config"

Approach A: Rebase Workflow

# Create feature branch
$ git checkout -b feature/auth

# Make commits on feature
$ echo "var auth = require('./auth');" >> app.js
$ git add . && git commit -m "Add auth import"

$ echo "function authenticate(req) { return true; }" > auth.js
$ git add . && git commit -m "Add auth module"

# Meanwhile, main has moved forward
$ git checkout main
$ echo "var logger = require('./logger');" >> app.js
$ git add . && git commit -m "Add logger"

# Back to feature, rebase onto main
$ git checkout feature/auth
$ git rebase main
First, rewinding head to replay your work on top of it...
Applying: Add auth import
Applying: Add auth module

# History is linear
$ git log --oneline --graph
* e5d4c3b (HEAD -> feature/auth) Add auth module
* a9b8c7d Add auth import
* f1e2d3c (main) Add logger
* c3d2e1f Add config
* 1a2b3c4 Initial commit

# Merge back to main (fast-forward)
$ git checkout main
$ git merge feature/auth
Fast-forward

$ git log --oneline --graph
* e5d4c3b (HEAD -> main, feature/auth) Add auth module
* a9b8c7d Add auth import
* f1e2d3c Add logger
* c3d2e1f Add config
* 1a2b3c4 Initial commit

Approach B: Merge Workflow

# Same setup, but merge instead
$ git checkout feature/auth
$ git merge main
Merge made by the 'ort' strategy.

$ git log --oneline --graph
*   m3r4g5e (HEAD -> feature/auth) Merge branch 'main' into feature/auth
|\
| * f1e2d3c (main) Add logger
* | b2c3d4e Add auth module
* | a1b2c3d Add auth import
|/
* c3d2e1f Add config
* 1a2b3c4 Initial commit

# Merge back to main
$ git checkout main
$ git merge feature/auth

$ git log --oneline --graph
*   x9y8z7w (HEAD -> main) Merge branch 'feature/auth'
|\
| *   m3r4g5e Merge branch 'main' into feature/auth
| |\
| |/
|/|
* | f1e2d3c Add logger
| * b2c3d4e Add auth module
| * a1b2c3d Add auth import
|/
* c3d2e1f Add config
* 1a2b3c4 Initial commit

The merge approach preserves the full branch topology. The rebase approach gives you a clean line.

Common Issues and Troubleshooting

1. Rebase Causes Duplicate Commits After Push

$ git log --oneline
a1b2c3d Add feature   # your commit
f4e5d6c Add feature   # same change, different SHA!

Cause: You rebased after pushing, then pulled (which merged your old commits with the rebased ones).

Fix: After rebasing a pushed branch, always use --force-with-lease:

$ git push --force-with-lease origin feature/auth

Never use --force (it overwrites without checking). --force-with-lease fails if someone else pushed in the meantime.

2. Rebase Conflict Loop

$ git rebase main
CONFLICT in file.js
$ # resolve, add, continue
$ git rebase --continue
CONFLICT in file.js  # same conflict again!

Cause: Multiple commits touch the same lines, causing repeated conflicts.

Fix: Enable rerere or squash your commits first:

$ git config rerere.enabled true
# Or squash before rebasing:
$ git rebase -i HEAD~5  # squash related commits
$ git rebase main       # now fewer conflicts

Or just abort and merge instead: git rebase --abort && git merge main.

3. Lost Commits After Rebase

$ git rebase -i main
# Accidentally dropped a commit
$ git log --oneline
# commit is gone!

Fix: Use git reflog to find the lost commit:

$ git reflog
a1b2c3d HEAD@{0}: rebase (finish): returning to refs/heads/feature
f4e5d6c HEAD@{1}: rebase (pick): Add routes
c7d8e9f HEAD@{2}: rebase (start): checkout main
b2c3d4e HEAD@{3}: commit: Add auth module   # <-- the lost one

$ git cherry-pick b2c3d4e

Reflog keeps everything for 90 days by default. Nothing is truly lost.

4. Merge Conflicts on git pull

$ git pull origin main
Auto-merging src/config.js
CONFLICT (content): Merge conflict in src/config.js
Automatic merge failed; fix conflicts and then commit the result.

Cause: git pull defaults to merge. If you have local commits and remote has diverged, you get merge commits in your feature branch.

Fix: Use git pull --rebase instead:

$ git pull --rebase origin main
# Or set it as default:
$ git config --global pull.rebase true

Best Practices

  • Rebase local, merge shared. Rebase your own unpushed commits freely. Never rebase commits others have pulled.
  • Use --force-with-lease, never --force. It prevents overwriting a collaborator's work by checking remote state before pushing.
  • Clean up with interactive rebase before opening a PR. Squash WIP commits, fixup typos, reword vague messages. Reviewers should see logical commits, not your stream of consciousness.
  • Enable rerere globally. It records conflict resolutions and replays them automatically. One-time setup saves hours: git config --global rerere.enabled true.
  • Set pull.ff only on main/master. Prevents accidental merge commits when pulling the main branch. Forces explicit merge or rebase decisions.
  • Use squash-merge for feature PRs on small teams. Each feature becomes one commit on main. Simple history, easy reverts. git merge --squash feature/auth.
  • Document your team's strategy. Put "we rebase feature branches and squash-merge to main" in your CONTRIBUTING.md. Consistency matters more than which strategy you pick.
  • When in doubt, merge. Merge is always safe. Rebase requires understanding what you are doing. If a junior developer asks which to use, tell them merge.

References

Powered by Contentful