Terminal, Git & GitHub
Terminal, Git & GitHub

Rebase vs merge, conflicts and safe force-pushes

Full · ~45 min
After this unit you can
  • Predict whether a merge fast-forwards or makes a merge commit, and explain what a rebase replays (upstream..branch, as new commits with new ids)
  • Resolve a conflict in a merge or a rebase — markers, zdiff3, add then --continue or --abort — knowing that ours and theirs swap during a rebase
  • Choose how git pull reconciles divergent branches (--rebase, --ff-only, a merge) and apply the golden rule: don't rebase commits others have
  • Force-push a rewritten branch with --force-with-lease plus --force-if-includes, and explain how a background fetch defeats a bare lease
  • Move a branch stacked on a squash-merged base with rebase --onto, and keep a stack current with --update-refs

Read

Simple
1 / 2

Fast-forward, merge commit, rebase

Flag

feature has one commit, Feature 1. How it joins main depends on whether main moved in the meantime, and on which tool you use. Real git log --oneline --graph output (Git 2.50) for each.

main didn't move → fast-forward

% git merge feature
Updating 92a04c5..74a1861
Fast-forward
% git log --oneline --graph
* 74a1861 Feature 1
* 92a04c5 Base

main is an ancestor of feature, so "merging" just slides the main pointer forward. No new commit. (git merge --no-ff would make a merge commit anyway, to record that a branch happened.)

main moved → a merge commit

% git merge feature
Merge made by the 'ort' strategy.
% git log --oneline --graph
*   f7a5219 Merge branch 'feature'
|\
| * 74a1861 Feature 1
* | 01426a6 Main 1
|/
* 92a04c5 Base

The histories diverged, so git makes a merge commit with two parents. Every existing commit keeps its id; the graph shows exactly what happened, branches and all.

Rebase → replay on top, then fast-forward

% git switch feature && git rebase main
Successfully rebased and updated refs/heads/feature.
% git log --oneline --graph
* ede9e65 Feature 1          # was 74a1861
* 01426a6 Main 1
* 92a04c5 Base
% git switch main && git merge feature
Fast-forward

Rebase takes the commits on feature that main doesn't have, and replays each one on top of main: it applies the same change to a new parent. A commit's id is a hash of everything in it — the snapshot, the parent id, author, committer and message — so every replayed commit is a new commit with a new id: 74a1861 became ede9e65. The old one still exists (the reflog can find it); the branch just doesn't point at it any more.

Which to use

  • Merge keeps history true: what happened, in parallel, including the merge. Never rewrites.
  • Rebase keeps history linear: as if you'd started from today's main. It rewrites your branch's commits — fine while they're only yours, a problem once someone else has them.

Many teams do both: rebase your own branch onto main to stay current, and let the pull request merge it in. The rest of this unit is about doing each without losing work.

Medium
1 / 2

git pull when both sides moved — and the golden rule

Flag

git pull is git fetch followed by integrating the upstream into your branch. When only the remote moved, integration is a fast-forward and nobody has to decide anything. When both moved — you committed, a teammate pushed — something has to give. Real output (Git 2.50, no pull.* config):

% git pull
hint: You have divergent branches and need to specify how to reconcile them.
hint: You can do so by running one of the following commands sometime before
hint: your next pull:
hint:
hint:   git config pull.rebase false  # merge
hint:   git config pull.rebase true   # rebase
hint:   git config pull.ff only       # fast-forward only
...
fatal: Need to specify how to reconcile divergent branches.

The fetch still happened (origin/main moved); only the integration was refused. Git 2.27 started warning about this choice, and since Git 2.33.1 it refuses until you make it. Your options, per pull or as config:

Result on your branch
git pull --rebase / pull.rebase trueyour local commits replayed on top of origin/main: linear
git pull --no-rebase / pull.rebase falsea merge commit "Merge branch 'main' of …"
git pull --ff-only / pull.ff onlyrefuses unless it's a fast-forward — you decide each time
% git pull --rebase
Successfully rebased and updated refs/heads/main.
% git log --oneline
46685db Alice's change        # replayed: new id
96891bc Bob's change          # from the remote, untouched
dd891e4 Base

For a branch you share with others but haven't pushed your new commits on, --rebase is usually what you want: your unpublished commits get new ids, and nobody else has them yet. Divergence also blocks the other direction: git push is rejected as non-fast-forward until you've integrated.

The golden rule

Pro Git puts it in one line: "Do not rebase commits that exist outside your repository and that people may have based work on."

Rebasing your unpublished commits is free. Rebase commits that others have pulled, and the remote now has copies with new ids while their clones still hold the originals. A merge-style pull ties the two together, and the history shows the same changes twice — Pro Git walks through exactly this mess. In practice:

  • Your own feature branch, even pushed, while nobody else builds on it: rebase freely, then force-push carefully (next example).
  • main, or any branch others commit to: never rebase it; integrate with merge, or revert.
Complex
1 / 2

A squash merge under a stacked branch

Flag

A stack: feature-b was branched from feature-a before feature-a merged, so b contains a's commits plus its own. Then feature-a's pull request is squash-merged: GitHub (or git merge --squash) writes one brand-new commit on main with all of a's changes.

main:       Base ── S "Feature A (#1)"          ← a new commit, not A1 or A2
feature-b:  Base ── A1 ── A2 ── B1

A1 and A2 are not in main's history — S has the same changes but is a different commit. So, to git, feature-b still has three commits main lacks:

% git log --oneline main..feature-b
f41646c B1: add b
ef2efb3 A2: tweak a
7cf7928 A1: add a
% git rebase main
CONFLICT (add/add): Merge conflict in a.txt
error: could not apply 7cf7928... A1: add a

git rebase main replays A1 onto a main that already contains its final result, and they collide (real run, Git 2.50). The pull request has the same problem: if feature-b's PR targets main, GitHub's three-dot diff starts at the merge base, Base, so a's changes show up again as if they were b's.

The fix: name the old base as the upstream

git rebase --onto <newbase> <upstream> replays only upstream..branch — so pass the old base:

% git rebase --abort
% git rebase --onto main feature-a          # replay feature-a..feature-b = just B1
Successfully rebased and updated refs/heads/feature-b.
% git log --oneline
929c58c B1: add b
6f0090e Feature A (#1)
2955620 Base

That needs feature-a (or the id of its last commit) still around locally — another reason not to delete a base branch before you've moved what's stacked on it.

Two cases where it just works

  • A one-commit base. If feature-a was a single commit, S makes exactly the same change, and rebase skips it on its own: warning: skipped previously applied commit bb52381. It compares each commit's patch with the commits upstream gained, so only an exact match is dropped.
  • A merge commit instead of a squash. If main merged feature-a with a merge commit, A1 is reachable from main through that merge's second parent, and git rebase main replays only B1.

Keeping a stack current before anything merges

When main moves under a stack, rebase the top branch with --update-refs (Git 2.38+): it moves every branch pointer it passes, so the lower branches follow.

% git switch feature-b
% git rebase --update-refs main
Successfully rebased and updated refs/heads/feature-b.
Updated the following refs with --update-refs:
	refs/heads/feature-a

Then force-push each branch with --force-with-lease. (git config --global rebase.updateRefs true makes it the default.) The pull-request unit covers the GitHub side: retargeting each PR before its base branch is deleted.

Quiz

Nobody has committed to main since you branched feature from it. You run git switch main && git merge feature. What happens?

Flag

You rebased your already-pushed feature onto the latest main. Now git push is rejected as non-fast-forward. Why?

Flag

Mid-git rebase main, config.txt conflicts. You want your feature branch's version of the file. Which command takes it?

Flag

git pull prints a list of git config pull.… hints and fatal: Need to specify how to reconcile divergent branches. What's going on?

Flag

Which of these breaks the golden rule of rebasing?

Flag

Alice rewrote her pushed feature. Bob pushed a fix to it an hour ago. Alice's editor auto-fetched since, and she runs git push --force-with-lease. What happens?

Flag

feature-b was stacked on feature-a (commits A1, A2). feature-a was squash-merged into main. On feature-b, git rebase main conflicts while applying A1. What's the right move?

Flag

Same stack, but this time main merged feature-a with a merge commit. On feature-b, what does git rebase main replay?

Flag

Fill in the blank

You've fixed the conflict in config.txt. Mark it resolved and carry on with the rebase.

Flag
git  config.txt
git rebase 

Show the common ancestor between the two sides in every conflict (Git 2.35+).

Flag
git config --global merge.conflictStyle 

Force-push your rebased branch without overwriting commits you've never had locally.

Flag
git push  --force-if-includes

After feature-a was squash-merged, move feature-b onto main, replaying only the commits after feature-a.

Flag
git rebase  main feature-a feature-b

main moved under your stack (feature-a ← feature-b). From the top branch, rebase onto main and move feature-a's pointer along with it (Git 2.38+).

Flag
git switch feature-b
git rebase  main

Predict the output

# feature-b = Base → A1 → A2 → B1 (stacked on feature-a = Base → A1 → A2)
git switch main
git merge --squash feature-a && git commit -m "Feature A (#1)"
git log --oneline main..feature-b
Flag
# main: timeout = 45.  feature's commit: timeout = 60.
git switch feature
git rebase main                 # stops on a conflict in config.txt
git checkout --ours config.txt
cat config.txt
Flag
# feature-a was ONE commit (A1); main squash-merged it as "Feature A (#1)"
# feature-b = A1 → B1
git switch feature-b
git rebase main
git log --oneline
Flag

Challenge

Work out which commits a rebase replays

Flag

commitsToReplay(history, upstream, branch) should return what git rebase <upstream> <branch> replays — the pick lines git rebase -i would show, in order. The Git 2.50 git-rebase manual defines the set as the commits git log <upstream>..<branch> shows, minus merge commits, minus any commit that "introduce[s] the same textual changes" as a commit upstream gained since the two split (<branch>..<upstream>).

history.ts models a commit graph: each commit has parents (two for a merge) and a patch string standing in for git's patch-id — two commits with the same patch make the same change.

The starter gets four things wrong, each a common way of thinking about rebase:

  1. "Upstream has what's on its first-parent line." Reachability goes through every parent. A branch merged into main with a merge commit is reachable from main through that merge's second parent, so rebasing a branch stacked on it replays only the stacked commits.
  2. "The list is like git log." git log shows newest first; a rebase replays the oldest commit first, each one on top of the previous.
  3. "Merges come along." A plain rebase drops merge commits and replays a linear history.
  4. "Every commit gets replayed." A commit whose change is already upstream — cherry-picked, or squash-merged as a one-commit branch — is skipped (warning: skipped previously applied commit).

Three tests already pass on the starter. One guards your fix for 4: a change only counts as already upstream if upstream gained it — a commit that re-applies something from the history both sides share (a reverted change, say) is still replayed. The last two are the point of the exercise: a squash merge creates a brand-new commit, so a branch stacked on a multi-commit base still contains the base's original commits — and git rebase main tries to replay them, usually into conflicts. Naming the old base as the upstream (git rebase --onto main feature-a) leaves them out.

Your task

Edit replay.ts so the tests pass:

  • A commit upstream can reach through any parent — including a merge commit's second parent — is not replayed
  • The commits come back oldest first, the order of the pick lines
  • Merge commits are dropped
  • A commit whose patch upstream already has (a cherry-pick, or a one-commit squash merge) is skipped
  • Only commits upstream gained since the two sides split count as "already upstream": re-applying a change from the shared history is still replayed
  • After a multi-commit squash merge, the old base's commits are replayed again
  • With the old base as upstream (rebase --onto main feature-a), only the branch's own commit is replayed

Loading the in-browser editor…

Further reading