Skip to content

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 pull from 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.

# Reviewer side
git fetch origin pr/42
git range-diff origin/pr/42@{1} origin/pr/42

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-diff uses 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 --message style.
  • Range-diff output can be very long. --no-patch for summary, | less for 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 if branches 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 show and git diff are simpler.
  • For commit graph differences (added/removed branches, diverged topology), use git log --graph --oneline on 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.