Rewriting History with Rebase
What rebase actually does to your commits, merge versus rebase as a real trade-off, the ours/theirs inversion, rebase --onto, and force-pushing with --force-with-lease.
What rebase actually does to your commits, merge versus rebase as a real trade-off, the ours/theirs inversion, rebase --onto, and force-pushing with --force-with-lease.
Your feature branch has been open for three days, and main has
moved on without you. Merging works, but it leaves a knot in the
history: a merge commit that says nothing, and your five careful
commits shuffled among everyone else's by date. There is another
way to catch up.
Rebase replays your commits on top of the latest main, as if
you had started this morning. By the end of this lesson you can
rebase a branch onto an updated base with confidence, name the
cases where you must not, and get back out when it goes wrong.
A rebase answers one question: what would my work look like if I had started it from a different commit? Git answers it in the most literal way available — by doing the work again.
Start from a branch that split off two commits ago, with main
one commit ahead — the same divergence you met in the merge
lesson, with the same merge base of B:
A---B---C---G main
\
D---E featureRebasing feature onto main runs five steps. Git lists the
commits reachable from feature but not from main — D and
E, exactly what git log main..feature prints. It saves each
as a patch. It checks out main's tip, G. It applies each
patch in order, making a brand-new commit for each. It moves the
feature pointer to the last of them.
A---B---C---G main
\
D'--E' featureD' carries the same change, message, and author as D, and is
still a different commit: its parent is G instead of B, so
its hash differs, and so does every hash after it. Think of
re-recording a guitar part over a new backing track — same notes,
new take, new tape.
The two commands solve one problem and disagree about what history is for:
merge rebase
A---B---C---G---M main A---B---C---G main
\ / \
D-------E feature D'--E' featureRecords what actually happened.
Hashes survive, the branch point stays visible, nothing is rewritten — so it is always safe, including on branches other people have pulled.
The cost is a braided history: git log becomes a wall of
merge commits and git bisect has more shapes to walk.
Produces a straight line.
Every commit has one parent, git log reads like a
changelog, and reverting a feature means reverting a
contiguous run rather than untangling a merge.
The cost is that it is a fiction — those commits were never
tested on top of G — and the rewrite makes the branch
unsafe to share.
A rule you can defend in review: rebase your own unpublished
branch to keep it current, and merge it into main so the
integration point stays recorded. Reasonable teams disagree; some
rebase everything, some ban it outside local work. Learn your
team's convention before you change anyone's history.
Update main first, since you are about to replay onto whatever
it points at:
git switch main
git pull
git switch feature
git rebase maingit rebase main means "replay the current branch onto main."
The two-argument form saves you the switch — Git checks out the
named branch for you:
git rebase main feature # same as the four commands aboveAfterwards git log --graph --oneline main feature shows one
straight line, with new hashes.
Long before you rebase a branch on purpose, you meet rebase
through git pull. The default pull fetches and merges, so when
you and a colleague both commit to main you get a merge commit
reading "Merge branch 'main' of github.com/…" for no reason but
timing. Multiply by a team and history fills with noise.
git pull --rebaseThis fetches, then replays your local commits on top of the updated remote branch: no merge commit appears. Those commits were unpublished, so rewriting them costs nothing.
A merge conflict arrives all at once. A rebase conflict arrives one commit at a time, because Git applies your patches in order and stops the moment one does not fit. Resolving is the work you already know — open the file, deal with the markers, stage the result. Only what you type next differs:
git status # shows which commit is replaying
# edit the conflicted files
git add <file>
git rebase --continue # commit this one, replay the nextTwo escapes. git rebase --skip discards the commit being
replayed — for when its change is already on the new base, not
merely inconvenient. git rebase --abort stops everything and
restores the branch exactly as it was: the right move whenever
you feel lost.
If a long rebase makes you resolve the same clash over and over,
Git can memorise resolutions for you with rerere.enabled — a
feature worth a lesson of its own, later.
This is the most confusing thing about rebase, and it catches
experienced people. ours always means "whatever HEAD is" —
and a rebase checks out the new base before replaying anything.
ours is your branch.
HEAD is where you were standing when you ran git merge,
so the labels match everybody's intuition.
ours is the branch you are replaying onto.
Git checked out main before replaying, so HEAD is
main's tip and your commits are the incoming patches.
Reaching for --ours to mean "my work" silently throws your
change away, and the rebase finishes with no error at all.
git restore --ours <file> # take main's version
git restore --theirs <file> # take your replayed commit's versionOlder guides spell these git checkout --ours; git restore is
the modern form, and it only ever touches files.
Sooner or later you branch off the wrong thing. You start
payments-ui from payments-api because you needed its
endpoints, then the API branch is rejected in review and your UI
work has to sit on main instead:
A---B---C main
\
P1--P2 payments-api
\
U1--U2--U3 payments-uigit rebase main payments-ui will not do it: everything on
payments-ui that is not on main includes P1 and P2, so
the API work comes along. You need to name the commits and the
destination separately. That is the three-argument form:
git rebase --onto main payments-api payments-uiThree slots: newbase is where commits land, upstream is
the cut line (anything already in it is excluded), branch is
what gets replayed and moved. Take the commits in payments-ui
that are not in payments-api, and put them on main.
A---B---C main
\ \
\ U1'--U2'--U3' payments-ui
P1--P2 payments-apipayments-api has not moved, and your UI branch now depends on
nothing but main. Once you see the three slots the simple form
stops looking special: git rebase main feature is exactly
git rebase --onto main main feature, cut line and destination
happening to be the same branch.
Stated precisely: do not rebase commits that other people have based work on. Not "never rebase pushed commits" — plenty of teams rebase their own pushed feature branches all day. The line is whether someone else's work sits on top of yours.
Here is what breaks. Your colleague holds D and E. You
rebase into D' and E' and force-push. They pull, and Git sees
four unrelated commits, because a hash is the only identity a
commit has. Their pull merges the two lines, so every change now
appears twice and they hit conflicts between a commit and its own
copy — and if they push that, it is everyone's problem.
Rebase a branch you have already pushed and your history and the
remote's have diverged, so a normal git push is rejected. You
need a force push, and which one matters:
git push --force-with-leasePlain --force says "make the remote equal mine, whatever is
there". If a teammate pushed to your branch ten minutes ago, that
commit is gone silently. --force-with-lease sends the value
your origin/feature tracking ref holds, and the server refuses
the push unless the branch still points there.
While a rebase is in progress — conflict markers on screen, a detached prompt — one command undoes all of it:
git rebase --abortBranch, working tree, and index return to their exact state before you started.
After a rebase finishes, the escape hatch is that the originals
still exist. Git records where HEAD was before the rebase:
git reset --hard ORIG_HEADIf another operation has overwritten ORIG_HEAD, find the old
tip yourself. git reflog lists every position HEAD has held,
newest first; the entry just before rebase (start) is your
pre-rebase branch:
git reflog # find the hash you want
git reset --hard <hash>git reset --hard discards uncommitted work in the working tree
and index without asking, so commit or stash first. The reflog is
a serious safety net and gets a full lesson of its own later.
git rebase main # replay this branch onto main
git rebase main feature # same, without switching first
git rebase --onto main old new # replay new's commits, not old's
git rebase --continue # commit the fix, replay the next
git rebase --skip # drop the commit being replayed
git rebase --abort # cancel, restore the branch
git rebase --update-refs # carry stacked branches along
git pull --rebase # fetch, then replay, no merge
git restore --ours <file> # rebase: the new base's copy
git restore --theirs <file> # rebase: your own commit's copy
git push --force-with-lease # publish a rebased branch safely
git reset --hard ORIG_HEAD # undo a rebase that finished
git reflog # find any commit you have lost
git config --global pull.rebase true # rebase on every pull
git config --global rebase.autoStash true # stash around rebases
git config --global rebase.updateRefs true # move stacked branchesYou can now bring a branch up to date without a merge commit, move one off the wrong base, and undo either. That is rebase as a navigation tool: same commits, new location.
The next lesson turns it into an editing tool. Interactive
rebase, git rebase -i, stops at each commit so you can
reorder, combine, split, and reword — turning a trail of "wip",
"fix typo", and "actually fix typo" into three commits a reviewer
can read. Every mechanic here still holds.
Before then, spend twenty minutes in a throwaway repository. Diverge two branches, rebase one onto the other, force a conflict and resolve it, abort halfway through, then recover a finished rebase with the reflog. Rebase stops being frightening the moment you have undone one on purpose.