git range-diff: Compare Commit Ranges Across Rebases¶
git range-diff shows the differences between two commit ranges, comparing each
commit on the "old" side with its corresponding commit on the "new" side. Where
git diff compares the trees of two commits, range-diff compares the patch
series itself: it tells you whether a rebase, an upstream sync, or a cherry-pick
batch actually changed behaviour, or just shuffled the line numbers.
For DevOps this is the only practical way to validate that a long-running feature branch, a backport batch, or a fork sync preserved intent without re-reviewing every commit by hand.
What it answers¶
"I rebased my feature branch on top of
main. Did the commits do the same thing, or did anything actually change?""I ran
git subtree pullfrom upstream. What did the merge introduce beyond the upstream's commits?""I cherry-picked twelve commits onto a release branch. Are any of them now doing different work than they did on
main?""A contributor force-pushed after review. Did their fix land, or did they rebase away my feedback?"
Syntax¶
git range-diff <old-base>..<old-tip> <new-base>..<new-tip>
git range-diff <old-range> <new-range> # implicit HEAD on the new side
git range-diff A B # compare HEAD's history with B's
The two arguments are commit ranges. Range-diff pairs each commit on the old side with the commit on the new side that produced the most similar patch, and shows the diff between those two patches. If a commit on one side has no counterpart on the other, it is shown as added or removed.
Read the output¶
$ git range-diff main..@{1} main..@{2}
-: ------- > 1: 7d3f9a2 feat(api): add /v2/orders handler
1: c0ffee1 = 2: b1a7c80 feat(api): add /v2/orders handler
2: 8a4f123 ! 3: 9e21c44 fix(auth): handle expired tokens
@@ -12,7 +12,9 @@
...
- return jsonify(error="token expired", retry=False)
+ if token.is_expired():
+ return jsonify(error="token expired", retry=True, hint="reauth")
+ return call_next()
3: deadbea < -: ------- chore(deps): bump sqlalchemy 2.0
=means the commit is byte-identical (same tree, same message).!means the commit exists on both sides but the patch changed.<means the commit was removed on the new side.>means the commit is new on the new side.
A clean rebase produces a wall of = lines. Any !, <, or > is a place
to look.
DevOps use cases¶
1. Validate a feature-branch rebase before review¶
# Before the rebase: capture the pre-rebase tip
git checkout feature/payments
git rev-parse HEAD # note the SHA, e.g. abc1234
git rebase origin/main
# After the rebase: compare the two ranges
git range-diff origin/main@{pre-rebase-abc1234} origin/main..feature/payments
If the output is all = lines, the rebase was a no-op and review can focus on
the new commits on main. If there are ! entries, those commits changed and
need re-review.
2. Verify an upstream sync on a vendor subtree¶
# Vendor pulled new commits from upstream; the local branch sits 200 commits behind
git checkout vendor/library
git rev-parse HEAD > /tmp/vendor-pre.txt
git subtree pull --prefix=lib/vendor library-repo main --squash
git range-diff $(cat /tmp/vendor-pre.txt)..HEAD^2 HEAD^2..HEAD
This isolates the content delta from the merge commit noise. Anything not
flagged = came from the upstream's history that you should inspect.
3. Audit a cherry-pick batch onto a release branch¶
# Twelve commits were cherry-picked from main to release/2.x.
# Did they all apply cleanly, or did resolution drift introduce behaviour changes?
git range-diff main..@{yesterday} release/2.x..release/2.x@{0}
Pairs commits on the main side with their cherry-picked twins and shows any
conflict-resolution drift. Pair with git cherry-pick -x so each side has
matching <mainline> annotations in the commit message.
4. Validate a force-push on a stacked PR¶
Stacked branches (pr/1, pr/2 on top of pr/1) get rebased every time the
base moves. Reviewers want to see: did the second PR's commits actually change?
# Reviewer workflow: fetch the contributor's force-pushed branch
git fetch origin pr/2
# Compare the two versions (reflog retains pre-push SHAs if you use --force-with-lease)
git range-diff origin/pr/2@{1} origin/pr/2
Flags worth knowing¶
# Show only summary lines (one line per commit pair), no patch diffs
git range-diff --no-patch main..@{1} main..@{2}
# Limit output to changes that are not pure-whitespace or line-shift
git range-diff --creation-factor=50 main..@{1} main..@{2}
# Higher factor = more aggressive about matching a new commit to an old one.
# Useful when commit messages changed.
# Compare against a different ref entirely (CI pre-merge validation)
git range-diff $PR_BASE..$PR_HEAD origin/main..$MERGED_SHA
# Machine-readable output for CI bots
git range-diff --format='%(color:trailing)%(-1)%(color:reset) %(color:old)%(old:short)%(color:reset) %(color:new)%(new:short)%(color:reset) %(subject)' ...
Pre-merge CI gate pattern¶
The most useful DevOps application: gate PR merges on a "range diff has no behavioural changes" check.
# .forgejo/workflows/range-diff-gate.yaml
name: range-diff-gate
on:
pull_request:
types: [opened, synchronize]
jobs:
range-diff:
runs-on: ubuntu-latest
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
with:
fetch-depth: 0
- name: Compare against base
run: |
git fetch origin "${{ github.base_ref }}"
# Skip identical commits, fail on substantive changes
! git range-diff --no-patch \
origin/"${{ github.base_ref }}"..HEAD \
origin/"${{ github.base_ref }}"..HEAD | grep -E '^[<>!]' | grep -v '^[0-9]\+:'
The grep -v '^[0-9]\+:' strips 1: style summary lines and leaves only the
!, <, > markers that mean "something actually changed."
Pairing with force-with-lease¶
git range-diff works best when the force-push you are reviewing left
something to compare against. --force-with-lease (or --force-if-includes)
makes the contributor's history recoverable in your local reflog before you
fetch the replacement, which is exactly what range-diff needs.
If the contributor used plain --force instead, the pre-push SHA may be gone
on your side; in that case ask them to push with --force-with-lease (or
configure the upstream to deny force pushes outright via receive.denyNonFastForwards).
Gotchas¶
git range-diffuses an internal similarity heuristic (--creation-factor, default 60) to pair old commits to new commits. A commit that was split into two, or merged from two into one, may not pair cleanly and will show as both a removal and an addition.- Identity pairing prefers commit message, then author/committer timestamp,
then patch similarity. Renaming a commit message mid-rebase will break the
pairing unless you use the same
--messagestyle. - Range-diff output can be very long.
--no-patchfor summary,| lessfor paging, or--format='%(subject)'for one-liners. - Range-diff compares patches, not behaviour. A patch that changed but whose
effect is identical (e.g., reordering two
ifbranches that the optimiser flattens) will still show!. Combine with the test suite: range-diff is a review tool, not a correctness proof.
When NOT to use it¶
- For single-commit comparisons,
git showandgit diffare simpler. - For commit graph differences (added/removed branches, diverged topology),
use
git log --graph --onelineon both sides. - For end-to-end behaviour validation, run the test suite. Range-diff is a fast triage signal that points at the commits worth running the suite on.