Practical Git Workflows #2: rebase vs merge — Decision Criteria and the Golden Rule

7 min read

As a way to combine two diverged histories, Git Basics #3: Branching and Merging — Fast-forward and 3-way covered merge: when a fast-forward is not possible you get a merge commit, and the fork-and-join shape stays in the history. Yet look at real-world repositories and you often find histories tidied into a straight line with no merge commits at all. The tool behind them is rebase. Rebase is powerful, but it is also the one basic command that, used wrongly, can tangle the history of an entire team. This post covers how it works, how to decide, and the one hard rule — all in one place.

It runs in seven parts.

  • #1 Branching strategies — GitHub Flow and trunk-based
  • #2 rebase vs merge — decision criteria and the golden rule ← this post
  • #3 Interactive rebase — cleaning up commits with squash and fixup
  • #4 Resolving conflicts — structure, mergetool, and rerere
  • #5 Running Pull Requests — review size, commit messages, and draft PRs
  • #6 stash, cherry-pick, bisect — everyday tools
  • #7 Monorepos and Git — sparse-checkout, submodules, and LFS

How rebase works — not moved, but re-created #

Start from the same situation as a merge. The branches have diverged and both sides have advanced.

Before the rebase — diverged branches
A --- B --- C --- F        ← main
              \
                D --- E    ← feature (HEAD)

Run git rebase main on feature and the history changes like this.

Rebase feature onto main
git switch feature
git rebase main
# Successfully rebased and updated refs/heads/feature.
After the rebase — a straight-line history
A --- B --- C --- F                ← main
                    \
                      D' --- E'    ← feature (HEAD)

It looks as if D and E were moved to the tip of main, but what Git actually did is different. It re-applied the changes from D and E on top of F, one by one, creating new commits D’ and E’. A commit carries a pointer to its parent, so a commit with a different parent cannot be the same commit. Even though D’ contains the same changes as D, it is a completely new commit with a different hash. The original D and E linger, pointed to by no branch, until they are eventually cleaned up.

From this state, switch to main and merge: a fast-forward is now possible, so the pointer simply advances with no merge commit. Straight-line histories are made exactly this way.

merge and rebase — a difference in what they leave behind #

The difference between the two is not capability but history philosophy.

Aspectmergerebase
Historypreserves the fork and the join as they happenedrewritten into a straight line
Commit hashesexisting commits keptevery re-applied commit gets a new hash
Merge commitcreated (in the 3-way case)none
Graph readabilitycomplex with many branchessimple
Factual recordactual order of workreconstructed into a tidied order

merge records what actually happened; rebase records what the work would look like if it had been done tidily. Neither is always right — the choice follows how the team wants to read its history.

The golden rule — never rebase a shared branch #

Rebase comes with one rule that allows no exceptions. Never rebase a branch that has already been pushed and that others are using as their base. Here is the structural reason.

What happens when you rebase a shared branch
[Before the rebase — a teammate pulled up to C and works on top of it]
A --- B --- C              ← origin/feature
A --- B --- C --- G        ← teammate's local feature

[After I rebase C into C' and force-push]
A --- B --- C'             ← origin/feature (history rewritten)
A --- B --- C --- G        ← teammate's local (diverged from origin)

The C in your teammate’s local repository has become a commit that no longer exists on the remote. The moment they pull, C and C’ collide as different commits with identical content; merging to untangle it creates duplicate commits, and pushing that tangles the next person’s history in turn. One rebase cascades into forced pushes across the whole team.

By contrast, rebase causes no problem at all for commits you have not pushed yet, or a branch only you use — nobody is standing on the commits being rewritten. The golden rule compresses into one sentence: rewriting your own commits is your business, but commits someone else has built on are off limits.

Three situations where rebase is safe #

The safe and productive uses of rebase in practice are clear-cut.

First, re-seating your feature branch on top of the latest main. Running git rebase main before opening a PR puts your branch back on top of current code: the reviewer sees a diff against the latest main, and merge-time conflicts get resolved in advance. If you are the only one using the branch, this is safe whether or not it has been pushed. If it has already been pushed, the push after a rebase is rejected — the force push you then need, and its safety catch --force-with-lease, are covered in detail in the next post.

Second, removing the noise merges that pull creates. Run git pull while you have local commits and the default behavior is a merge, producing commits like “Merge branch ‘main’ of …”. git pull --rebase, which re-applies your commits on top of the remote ones, makes that noise disappear.

Make pull use rebase
# just this once
git pull --rebase

# set as the default
git config --global pull.rebase true

# auto-stash work in progress before the rebase, restore it after
git config --global rebase.autoStash true

With rebase.autoStash switched on as well, pull passes smoothly even in the middle of work. What gets re-applied is only your local, unpushed commits, so the golden rule is not violated.

Third, cleaning up local commits before pushing. Interactive rebase — merging and reshaping scrappy commits like typo fixes and removed debug output into meaningful units — is exactly this use. It is the subject of the next post, so here we only note that it exists.

Tip
When a conflict appears during a rebase, resolve it the same way as a merge conflict, but the finishing command differs. Clean up the markers, run git add, then continue the re-application with git rebase --continue, or abandon the whole thing with git rebase --abort to return to the pre-rebase state. Because rebase re-applies commits one at a time, conflicts also arrive broken up per commit — in that sense they are often easier to handle than one big merge conflict.

Decision criteria #

Everything so far compresses into three rules of thumb.

  • Local-only branches used by you alone — rebase freely. Polish the history as much as you like.
  • Shared and protected branches (main and the like) — no rebase. Land changes through merge or the PR merge button.
  • Feature branches landed via PR — tidy up with git rebase main before opening, and follow the team’s PR policy (merge commit or squash) for the landing itself.

As the last item suggests, the team’s history policy outranks personal rebase preference. If the team wants a straight-line history, the PR’s Squash and merge or Rebase and merge often produces the same result more safely.

Wrap-up #

Three takeaways from this post.

  • rebase does not move commits — it re-creates them as new commits and re-applies them, which is why every hash changes.
  • merge preserves history; rebase rewrites it. How the team wants to read its history is the deciding criterion.
  • The golden rule is single: never rebase a shared branch that others use as their base. Your local commits and personal branches are yours to rewrite.

In the next post, “Practical Git Workflows #3: Interactive Rebase — Cleaning Up Commits with squash and fixup”, we turn to the most productive use of rebase: shaping messy local commits into review-ready units, and pushing a rewritten branch safely with --force-with-lease.

X