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.
Keep a name for the original
Section titled “Keep a name for the original”Before rewriting, give the current tip a name:
git branch before-rebaseThat 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.
A reproducible before-and-after
Section titled “A reproducible before-and-after”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:
WORK=$(mktemp -d)export HOME="$WORK" GIT_CONFIG_NOSYSTEM=1 GIT_EDITOR=truecd "$WORK"git init -q -b main demo && cd demogit config user.email lab@example.comgit 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;}JScat > 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); });JSgit add . && git commit -q -m "Add version helpers"Three commits on a feature branch — a new test, a behaviour change, a style change:
git switch -qc feature/patch-comparecat >> version.test.mjs <<'JS'test('patch level decides when major and minor match', () => { assert.ok(compareVersions('1.2.3', '1.2.10') < 0); });JSgit commit -qam "Test: patch level comparison"sed -i 's/throw new Error(/throw new TypeError(/' version.mjsgit 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.mjsgit commit -qam "Style: one declaration per line"node --test version.test.mjsCaptured: pass 3, fail 0. Meanwhile main changed the return line — the line directly
below the one the style commit touches:
git switch -q mainsed -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.mjsgit 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 helpersName the branch as it stands, then rebase:
git switch -q feature/patch-comparegit branch before-rebasegit rebase mainCaptured output (abridged to the lines that matter):
Rebasing (3/3)Auto-merging version.mjsCONFLICT (content): Merge conflict in version.mjserror: could not apply ceef1bf... Style: one declaration per lineAdjacent 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:
git add version.mjsgit rebase --continuegit log --oneline main..feature/patch-compareSuccessfully rebased and updated refs/heads/feature/patch-compare.065381b Style: one declaration per line84e3b5d Throw TypeError for a malformed versionaa82d8e Test: patch level comparisonThree commits, same subjects, no merge commit. Clean.
The comparison
Section titled “The comparison”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”:
git range-diff main before-rebase feature/patch-compareRead 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 comparison2: 72f9746 = 2: 84e3b5d Throw TypeError for a malformed version3: 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:
| Mark | Meaning |
|---|---|
= | 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 removesmain’s parenthesised line. Expected: the new commit sits on top ofmain’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 onmain.++ 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:
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:
node --test version.test.mjs✖ patch level decides when major and minor matchℹ pass 2ℹ fail 1That 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-lease →
git branch -D before-rebase.
Limitations
Section titled “Limitations”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.
Common mistakes
Section titled “Common mistakes”- 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?
Unexpected output. Compare your output with the expected block line by line; the first differing line is usually the cause. Then check the version-sensitive notes.
Missing prerequisite. Most "stuck" moments here come from one earlier concept. The prerequisites list names it; the learning path puts it in order.
Unclear explanation. Read the explanation section, then the mental model, then retry the step — the command usually makes sense once the model is in place.
Instructions did not match my environment. Check `git --version` and your shell (Git Bash vs PowerShell vs zsh). Platform notes on the page name the differences that matter; the verification step confirms you are back to a known state.
Good. The next step is to do it without the page open — or move to the recommended next activity below.
All help on this page: Git troubleshooting decision tree · Find the lesson that comes before this one · How Git works — the model behind the commands · Recover a lost commit (lab)