Rebase vs merge, conflicts and safe force-pushes
- 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,addthen--continueor--abort— knowing thatoursandtheirsswap during a rebase - Choose how
git pullreconciles 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-leaseplus--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
Fast-forward, merge commit, rebase
Flagfeature 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.
git pull when both sides moved — and the golden rule
Flaggit 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 true | your local commits replayed on top of origin/main: linear |
git pull --no-rebase / pull.rebase false | a merge commit "Merge branch 'main' of …" |
git pull --ff-only / pull.ff only | refuses 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.
A squash merge under a stacked branch
FlagA 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-awas a single commit,Smakes 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
mainmergedfeature-awith a merge commit,A1is reachable frommainthrough that merge's second parent, andgit rebase mainreplays onlyB1.
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?
You rebased your already-pushed feature onto the latest main. Now git push is rejected as non-fast-forward. Why?
Mid-git rebase main, config.txt conflicts. You want your feature branch's version of the file. Which command takes it?
git pull prints a list of git config pull.… hints and fatal: Need to specify how to reconcile divergent branches. What's going on?
Which of these breaks the golden rule of rebasing?
FlagAlice 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?
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?
Same stack, but this time main merged feature-a with a merge commit. On feature-b, what does git rebase main replay?
Fill in the blank
You've fixed the conflict in config.txt. Mark it resolved and carry on with the rebase.
git config.txt git rebase
Show the common ancestor between the two sides in every conflict (Git 2.35+).
Flaggit config --global merge.conflictStyle Force-push your rebased branch without overwriting commits you've never had locally.
Flaggit push --force-if-includes
After feature-a was squash-merged, move feature-b onto main, replaying only the commits after feature-a.
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+).
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
# 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
# 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
Challenge
Work out which commits a rebase replays
FlagcommitsToReplay(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:
- "Upstream has what's on its first-parent line." Reachability goes through every parent.
A branch merged into
mainwith a merge commit is reachable frommainthrough that merge's second parent, so rebasing a branch stacked on it replays only the stacked commits. - "The list is like
git log."git logshows newest first; a rebase replays the oldest commit first, each one on top of the previous. - "Merges come along." A plain rebase drops merge commits and replays a linear history.
- "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
picklines - Merge commits are dropped
- A commit whose
patchupstream 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
Further reading
- Pro Git: Basic Branching and Merging
- Pro Git: Rebasing
- Git docs: git-merge
- Git docs: git-rebase
- Git 2.50 docs: git-rebase (the wording quoted in the readings)
- git/git commit 031e2f7: "pull: abort by default when fast-forwarding is not possible" (first released in Git 2.33.1)
- Git docs: git-pull
- Git docs: git-push (--force-with-lease, --force-if-includes)
- GitHub Docs: About comparing branches in pull requests