Software Engineering · Version Control Practice
Rebase as Patch Replay and Why Every Id Changes
How rebase computes the commits to replay, why the new commits are not the old ones, and what information the replay cannot carry across.
A merge joins two lines and keeps both. A rebase takes one line and rewrites it as though it had started somewhere else. The distinction sounds cosmetic — the resulting tree is usually the same either way — but the objects produced are entirely different, and everything downstream that depends on commit ids, from review links to signed tags to other people’s clones, sees that difference.
Selecting the commits to move
The first step is deciding the range. Given a branch checked out at topic and an upstream main, git rebase main computes the commits reachable from topic but not from main, then replays them in order onto main. The excluded set is not simply the merge base; it is everything reachable from the upstream, which means that if main already contains a merge of an earlier version of the topic, some of the topic’s commits are excluded even though they were authored on the topic.
The commit list is produced by a traversal with the upstream’s reachability marked, then filtered to remove merges by default. Merge commits are dropped because replaying a merge means replaying a join, and the usual rebase has no second line to join. --rebase-merges changes that by rebuilding the topology with labelled temporary branches, at a substantial cost in complexity and in the chance of a conflict at an unexpected point.
--onto decouples the two roles. git rebase --onto newbase oldbase topic replays the commits in oldbase..topic onto newbase, which allows transplanting a range whose original parent is not the current upstream. This is the mechanism for moving a branch off a stale base, and also for splitting a branch that accidentally contains unrelated commits.
The replay itself
Each commit is applied as a patch relative to its original parent, not as a tree replacement. That distinction matters. Replaying a tree would overwrite whatever the new base has done to those files; replaying a patch means the change is computed as base-relative, then applied to the new base, and any part of the patch that no longer applies cleanly becomes a conflict.
The mechanics of a single step: check out the new parent, compute the diff between the old commit and its old parent, attempt to apply that diff, and if it applies, create a new commit whose tree is the result, whose parent is the new parent, whose author line is copied verbatim, and whose committer line records the current identity and time. The message is preserved unless the operation is interactive.
Because the commit body includes the parent hash and the committer timestamp, the new commit’s hash differs from the old one even in the degenerate case where the tree is identical and the author line is untouched. This is not incidental; it is the direct consequence of content addressing described in what a commit contains. A rebase of ten commits creates ten new objects and leaves the ten old ones in place, reachable only through the reflog until garbage collection.
Conflicts are resolved per commit, not per range. A resolution written during replay becomes part of the tree of that new commit, which means the resolution is now a change in the history that was never reviewed as a change. This is the sharpest difference from merging: in a merge, conflict resolution appears in the merge commit and is visible as a single event; in a rebase, the resolution is folded into an ordinary-looking commit whose diff differs from what its author originally wrote.
$ git rebase --onto main v1.2 topic
# replays v1.2..topic onto main; the old parent of the range is discarded
Interactive rebase as history editing
git rebase -i exposes the commit list as an editable sequence with one command per line. The commands are operations on the replay, not on the objects: pick replays unchanged, reword replays with a new message, edit stops after replaying so the tree can be amended, squash and fixup combine a commit into its predecessor, drop omits it, and exec runs a shell command between steps.
The list is presented oldest-first, which is the reverse of git log. Every line is anchored to a commit id, and removing a line drops that commit. Because the whole list is rewritten before any replay happens, the sequence can be reordered arbitrarily; the replay then applies each patch to a base that may not be its original parent, and conflicts become likely precisely in proportion to how far commits move relative to each other.
Two properties make interactive rebase safe enough to use routinely. The old commits remain reachable via the branch’s reflog, so git reset --hard <old-id> restores the pre-rebase state; and the operation is atomic with respect to the branch ref, since the ref is moved only after the full sequence succeeds or the user aborts. A failed replay in the middle leaves the branch where it was and a detached HEAD at the partially built result, from which --abort returns cleanly.
squash is worth separating from fixup. Squash combines the changes and concatenates the messages for editing; fixup combines the changes and discards the later message. For a series that accumulated “fix typo” and “address review” commits, fixup is the operation that erases the intermediate noise without asking for editorial input. --autosquash automates the pairing by matching a commit whose message begins with fixup! or squash! followed by another commit’s subject, and reordering it into place.
What replay cannot preserve
Signatures are the clearest casualty. A commit signature covers the commit body, including the parent hash, so a replayed commit has a body that no existing signature covers. A rebase of signed commits yields unsigned commits unless each is re-signed, and -S on the rebase does that at the cost of an interactive prompt per commit or a configured signing key.
Merge topology is the second casualty. Dropping merge commits flattens the branch’s internal structure: if the topic contained a merge of another branch, that join is gone and the other branch’s commits either disappear from the range or arrive as unrelated patches. --rebase-merges reconstructs the topology with label and merge commands in the todo list, but the reconstructed merge has a new id and a new committer, and its resolution has to be reproduced by hand if it was non-trivial.
Notes and other refs outside the commit body do not follow the commits at all. A note attached to an old commit stays attached to the old id; after a rebase, the new commits have no notes. The same is true of any external system that stored a commit id: a link in a review tool, a reference in a bug tracker, a line in a changelog.
Third, the rebase cannot know which parts of the original diff were incidental. If a commit was authored against a base where a helper function existed, and the new base removed it, the replay will produce a conflict or, worse, apply cleanly to a coincidentally similar region and produce a wrong result. Automated patch application is textual, and the same weakness that makes three-way merge miss semantic conflicts applies here with no base to compare against beyond the commit’s own parent.
Deciding to rewrite
The operational rule that follows is about ownership. Rewriting commits that no one else has fetched is cheap and reversible through the reflog. Rewriting commits that others have based work on forces everyone to re-integrate against a different history, and their own commits then need rebasing onto the new ids. Git detects the common case on push: a non-fast-forward update is rejected unless forced, and --force-with-lease narrows the rejection to cases where the remote moved since the last fetch.
For a shared branch, the alternative is to merge rather than rebase, accepting the merge commit as the record of integration. For a personal branch, rebase is the tool that keeps the series readable and the base current. The dividing line is fetchability, not branch naming.
There is a subtlety in the interaction with other rewrites. If a branch is rebased twice, the second rebase computes its range against the new upstream and replays the commits produced by the first. The ids change again, and the reflog for the branch retains each intermediate state, so recovery is possible but requires knowing which state to return to. Each rewrite also adds to the reflog the record of the ref move, which is why reflog entries accumulate on actively rebased branches and why git reflog expire exists.
Finally, replay interacts with patch identity. Two commits that introduce the same textual change onto different bases are different objects, and a rebase of one onto the other’s base produces a commit whose diff may be empty if the change is already present. Git detects this and, by default, drops the commit rather than creating an empty one. That behaviour is the same mechanism that makes cherry-pick produce duplicates across branches, examined in cherry-pick, patch identity, and duplicate commits. The composition question — how many commits a branch should present before it is integrated — is treated in squash, fixup, and series composition.
References
- Git documentation — git-rebase — Git project
- Pro Git, 2nd edition — Rebasing — Chacon & Straub
- Git documentation — git-cherry-pick — Git project