Skip to content

Did Your Rebase Change the Code? Review Rewritten Commits with Git Range-Diff

You rebased a three-commit branch onto main. One commit conflicted; you resolved it by hand. Now git log --oneline main..feature shows three commits with the same subjects as before, no merge commit, a clean line. It looks like nothing but history changed.

The problem: every commit has a new id, so nothing in git log can tell you whether the content of those commits is what it was. If your hand-resolution introduced a change — a swapped operand, a dropped line, an extra one — it is now inside a commit whose message says “Style: one declaration per line”, and the pull request diff against main will show it mixed in with your intended changes.

git range-diff answers the question directly: given the old series and the new one, which commits are unchanged, which changed, and how did each patch change. This article builds the situation with a planted mistake, asks you to find it in the range-diff output, then explains what range-diff can and cannot prove.

Why a clean-looking history still deserves review

Section titled “Why a clean-looking history still deserves review”

A rebase re-applies each commit’s patch on top of a new parent. When that applies cleanly, the new commit’s patch is identical to the old one — only the parent and the id differ. When it does not apply cleanly, Git stops and you edit the file. Whatever you write becomes that commit’s new patch. There is no separate record of “the resolution” versus “the original change”: they are one commit now.

The same is true of git rebase -i edits, git commit --amend, fixup, squash and a cherry-pick with conflicts. All of them produce commits whose ids changed and whose contents may have. Reviewing such a branch by reading git log verifies the shape of history, not its content.

Before rewriting, give the current tip a name:

Terminal window
git branch before-rebase

That is the whole trick. ORIG_HEAD and the reflog also remember the old tip, but ORIG_HEAD is overwritten by the next reset, merge or rebase, and reflog entries expire. A branch (or a tag) is explicit, survives anything, and reads well in the comparison later. Delete it when you are done.

Everything below runs in a temporary directory with an isolated HOME; the fixture script scripts/articles/range-diff-demo.sh in the site repository is the same sequence. One module with two functions, and a test file:

Terminal window
WORK=$(mktemp -d)
export HOME="$WORK" GIT_CONFIG_NOSYSTEM=1 GIT_EDITOR=true
cd "$WORK"
git init -q -b main demo && cd demo
git config user.email lab@example.com
git config user.name "Lab User"
cat > version.mjs <<'JS'
export function parseVersion(s) {
const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(s);
if (!m) throw new Error(`not a semver: ${s}`);
return { major: +m[1], minor: +m[2], patch: +m[3] };
}
export function compareVersions(a, b) {
const x = parseVersion(a), y = parseVersion(b);
return x.major - y.major || x.minor - y.minor || x.patch - y.patch;
}
JS
cat > version.test.mjs <<'JS'
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { compareVersions } from './version.mjs';
test('numeric, not lexical', () => { assert.ok(compareVersions('1.10.0', '1.9.0') > 0); });
test('equal', () => { assert.equal(compareVersions('2.0.0', '2.0.0'), 0); });
JS
git add . && git commit -q -m "Add version helpers"

Three commits on a feature branch — a new test, a behaviour change, a style change:

Terminal window
git switch -qc feature/patch-compare
cat >> version.test.mjs <<'JS'
test('patch level decides when major and minor match', () => { assert.ok(compareVersions('1.2.3', '1.2.10') < 0); });
JS
git commit -qam "Test: patch level comparison"
sed -i 's/throw new Error(/throw new TypeError(/' version.mjs
git commit -qam "Throw TypeError for a malformed version"
sed -i 's/ const x = parseVersion(a), y = parseVersion(b);/ const x = parseVersion(a);\n const y = parseVersion(b);/' version.mjs
git commit -qam "Style: one declaration per line"
node --test version.test.mjs

Captured: pass 3, fail 0. Meanwhile main changed the return line — the line directly below the one the style commit touches:

Terminal window
git switch -q main
sed -i 's/ return x.major - y.major || x.minor - y.minor || x.patch - y.patch;/ return (x.major - y.major) || (x.minor - y.minor) || (x.patch - y.patch);/' version.mjs
git commit -qam "compareVersions: parenthesise for readability"
git log --oneline --all --graph
* ceef1bf Style: one declaration per line
* 72f9746 Throw TypeError for a malformed version
* 61300cc Test: patch level comparison
| * f62825b compareVersions: parenthesise for readability
|/
* c4f2da3 Add version helpers

Name the branch as it stands, then rebase:

Terminal window
git switch -q feature/patch-compare
git branch before-rebase
git rebase main

Captured output (abridged to the lines that matter):

Rebasing (3/3)Auto-merging version.mjs
CONFLICT (content): Merge conflict in version.mjs
error: could not apply ceef1bf... Style: one declaration per line

Adjacent lines changed on both sides, so the third commit conflicts:

export function compareVersions(a, b) {
<<<<<<< HEAD
const x = parseVersion(a), y = parseVersion(b);
return (x.major - y.major) || (x.minor - y.minor) || (x.patch - y.patch);
=======
const x = parseVersion(a);
const y = parseVersion(b);
return x.major - y.major || x.minor - y.minor || x.patch - y.patch;
>>>>>>> ceef1bf (Style: one declaration per line)
}

The correct resolution is obvious: two declaration lines from the feature side, the parenthesised return from main. The fixture resolves it “by hand” — and, as people do when retyping a line at the end of a long rebase, gets one thing wrong. Then:

Terminal window
git add version.mjs
git rebase --continue
git log --oneline main..feature/patch-compare
Successfully rebased and updated refs/heads/feature/patch-compare.
065381b Style: one declaration per line
84e3b5d Throw TypeError for a malformed version
aa82d8e Test: patch level comparison

Three commits, same subjects, no merge commit. Clean.

git range-diff takes two ranges. The convenient three-argument form is git range-diff <base> <old-tip> <new-tip>, meaning “compare base..old-tip with base..new-tip”:

Terminal window
git range-diff main before-rebase feature/patch-compare
Read the captured output below and find the unintended modification before opening the explanation.

There is exactly one. It is not the parentheses — those were main’s change and belong in the resolution. Look at the lines marked with two leading characters.

Captured output:

1: 61300cc = 1: aa82d8e Test: patch level comparison
2: 72f9746 = 2: 84e3b5d Throw TypeError for a malformed version
3: ceef1bf ! 3: 065381b Style: one declaration per line
@@ version.mjs: export function parseVersion(s) {
export function compareVersions(a, b) {
- const x = parseVersion(a), y = parseVersion(b);
+- return (x.major - y.major) || (x.minor - y.minor) || (x.patch - y.patch);
+ const x = parseVersion(a);
+ const y = parseVersion(b);
- return x.major - y.major || x.minor - y.minor || x.patch - y.patch;
++ return (x.major - y.major) || (x.minor - y.minor) || (y.patch - x.patch);
}

Reading matched, changed and unmatched commits

Section titled “Reading matched, changed and unmatched commits”

The first column pairs commits between the two series and marks the relationship:

MarkMeaning
=matched, and the patch is identical (only parent and id differ)
!matched, but the patch differs — the interdiff follows, indented
<present only in the old series (dropped by the rewrite)
>present only in the new series (added by the rewrite)

Commits 1 and 2 are =: the rebase re-applied them unchanged. Commit 3 is !. What follows is a diff of the two patches. Each line has two leading characters: the outer one says whether the line is in the old patch (-), the new patch (+) or both (space); the inner one is the line’s sign within the patch.

So:

  • - const x = parseVersion(a), y = parseVersion(b); — in both patches, removed by both. Same.
  • +- return (x.major ...)(x.patch - y.patch); — the new patch additionally removes main’s parenthesised line. Expected: the new commit sits on top of main’s change, so its patch has to replace that line.
  • + const x = parseVersion(a); and + const y = parseVersion(b); — added by both. Same.
  • - return x.major - y.major || ... — only the old patch added the unparenthesised return. Expected: it no longer exists on main.
  • ++ return (x.major - y.major) || (x.minor - y.minor) || (y.patch - x.patch); — only the new patch adds this line. And there it is: y.patch - x.patch. The operands are swapped.
Explanation

The intended resolution was (x.patch - y.patch)main’s parenthesised line, unchanged. The resolution retyped it with y and x reversed, so compareVersions('1.2.3', '1.2.10') now returns a positive number: 1.2.3 sorts after 1.2.10. The two ++ characters are the tell: a line the new patch introduces that the old patch never had. Everything else in the interdiff is the expected consequence of moving onto a new base.

Complementing range-diff with diffs and tests

Section titled “Complementing range-diff with diffs and tests”

range-diff told us which commit changed and which line. Two more views confirm and contain it.

The ordinary end-state diff — what the pull request will show:

Terminal window
git diff main feature/patch-compare -- version.mjs
export function compareVersions(a, b) {
- const x = parseVersion(a), y = parseVersion(b);
- return (x.major - y.major) || (x.minor - y.minor) || (x.patch - y.patch);
+ const x = parseVersion(a);
+ const y = parseVersion(b);
+ return (x.major - y.major) || (x.minor - y.minor) || (y.patch - x.patch);

The mistake is visible here too — but as a change from main, indistinguishable from an intended one. A reviewer who did not know the branch’s intent could accept it. range-diff showed it as a change from the branch’s own earlier self, which is the question a rebase raises.

The tests — because the branch added a test for exactly this:

Terminal window
node --test version.test.mjs
✖ patch level decides when major and minor match
ℹ pass 2
ℹ fail 1

That test existed only because the first commit on the branch added it. Without it, the two original tests pass and the regression ships. range-diff finds what changed; only a test can find what broke.

The habit, in order: git branch before-rebase → rebase → git range-diff and read every ! → run the tests → git diff main.. for the reviewer’s view → push with --force-with-leasegit branch -D before-rebase.

Comparison is not proof of behaviour. Three = marks mean the patches are identical, not that the program is. The same patch on a different base is a different program; main’s changes now sit underneath yours. Tests answer that, range-diff does not.

Matching is heuristic. range-diff pairs commits by patch similarity (the --creation-factor option tunes the threshold). Heavily reworked commits can show as one < and one > instead of a !, and a squash of two commits shows as <, <, >. That is still information — the shape of the rewrite — but the interdiff is only shown for ! pairs.

It reads content, not intent. The ++ line above is only “wrong” because you know what the resolution was meant to be. On a branch whose rebase legitimately reworked a commit, the same output is correct. range-diff shows you the change; judging it is still the reviewer’s job.

The base must be common. With main before-rebase feature/patch-compare, both ranges start at main. If main itself moved between the two snapshots, use the four-argument form (old-base..old-tip new-base..new-tip) so each series is measured against its own base.

  • No reference to the original. Without before-rebase, you are comparing against the reflog by hand. Make the branch first; it costs nothing.
  • Reading only the summary lines. ! is the start of the review, not the result. Read the interdiff.
  • Confusing the two sign columns. Outer = which patch; inner = the patch’s own +/-. ++ and -- are the lines to slow down on.
  • Treating = everywhere as done. Run the tests. Read the end-state diff.
  • Force-pushing without the lease. After any rewrite, git push --force-with-lease, never --force — see When not to rebase.

Try it: clean up a branch, then prove you changed nothing

Section titled “Try it: clean up a branch, then prove you changed nothing”

The interactive-rebase lab produces exactly the situation above — five messy commits squashed to two, with a chance to introduce a change while editing. Do it with a before-rebase branch, then range-diff the result.

How this article was verified

Every command was run on 17 September 2026 in a freshly created temporary repository with an isolatedHOME, a throwaway identity and hooks disabled — never against a real repository. Output blocks labelled captured are pasted from that run; blocks labelled illustrativeare described rather than pasted. Versions: git 2.43.0 and Node.js 24.19.0 on Ubuntu 24.04. The fixture script is scripts/articles/range-diff-demo.sh in the site repository; it rebuilds the example from scratch. Primary reference: git-scm.com/docs/git-range-diff.

How did this go?