Reviewing a force-push with git range-diff Jump to heading

An author addresses your comments, rebases, and force-pushes. The interface now shows a branch with no relationship to the one you read yesterday, and the honest options are to re-read all of it or to approve on trust. git range-diff gives you the third option: it pairs up commits between the two versions and shows how each one changed, so a rebase plus one fixed typo reads as exactly that. This recipe covers using it and keeping the old tip available to compare against, within code review workflow engineering.

When to use this approach Jump to heading

  • A branch you are reviewing has been rebased or amended.
  • You need to know whether a restack changed anything besides the base.
  • An author says “just a rebase” and you would like to verify that cheaply.
  • You maintain a project where contributors routinely amend rather than add commits.
  • If the author added a new commit instead of rewriting, an ordinary diff of the new commit is simpler and enough.

Step 1 — Keep the old tip before it disappears Jump to heading

Range-diff needs both versions. The remote no longer has the old one after a force-push, so something must have recorded it.

# Manually, before the author pushes again
git branch review/pr-812-v1 origin/pr/812
# Automatically: the remote-tracking reflog remembers where the ref was
git reflog show origin/pr/812 | head -5
git rev-parse 'origin/pr/812@{1}'          # the previous value
# Verification: both versions resolve to different commits
git rev-parse --short 'origin/pr/812@{1}' 'origin/pr/812'

The reflog entry is created by the fetch that brought in the new version, so this works without any preparation — provided you fetched before and after. Setting fetch.writeCommitGraph and leaving reflog expiry at its default gives you about ninety days of history to compare against.

Where the previous version of a branch survivesA force-push replaces the remote ref, but your local remote-tracking ref keeps its old value in the reflog until it expires. That entry is what range-diff compares against, so no preparation is required beyond having fetched before the rewrite.Fetch (day 1)origin/pr/812 = a1b2Author rewritesforce-pushFetch (day 2)origin/pr/812 = f9e8reflog keeps a1b2range-diffa1b2 vs f9e8the old tip is gone from the server and still present locally

Step 2 — Run the comparison Jump to heading

# The full form: both ranges spelled out
git range-diff origin/main..'origin/pr/812@{1}' origin/main..origin/pr/812
# The shorthand, when both ranges share a base
git range-diff 'origin/pr/812@{1}'...origin/pr/812
# Verification: a pure rebase should show every commit as unchanged
git range-diff 'origin/pr/812@{1}'...origin/pr/812 | grep -c '^ *[0-9]*: *[0-9a-f]* = '

A line beginning with = means the commit is identical apart from its parent — in other words, moved by a rebase and otherwise untouched. If every line is =, the author’s “just a rebase” was accurate and there is nothing to re-read.

Step 3 — Read the markers correctly Jump to heading

Range-diff has four states, and knowing them turns the output from noise into a summary.

The four range-diff markersAn equals sign means the commit is unchanged apart from its parent. An exclamation mark means the content changed and the diff of the diff follows. A less-than sign means the commit was dropped, and a greater-than sign means it is new in this version.=unchangedrebased onlyskip it!content changeddiff of the diffread this<droppedin the old version only>newadded in this versionin a well-behaved revision most lines are equals signs and one or two carry an exclamation mark
# Show only the commits that actually changed
git range-diff 'origin/pr/812@{1}'...origin/pr/812 | grep -E '^ *[0-9]+: .* [!<>] '
# Then read just those, in detail
git range-diff --creation-factor=95 'origin/pr/812@{1}'...origin/pr/812

The creation factor controls how eagerly Git pairs commits between the versions. Raising it makes pairing stricter, which is useful when a heavily rewritten branch produces confusing matches; lowering it pairs more aggressively when commits were substantially rewritten but are still recognisably the same step.

Step 4 — Use it on your own work before pushing Jump to heading

The same command answers a question authors should ask themselves: did my rebase change anything I did not intend?

# Record the tip before rewriting
git branch backup/pre-rebase HEAD

git rebase -i origin/main
git range-diff backup/pre-rebase...HEAD
# Verification: intended changes only
git range-diff backup/pre-rebase...HEAD | grep -E '^ *[0-9]+: .* ! ' || echo "nothing changed but the base"
git branch -D backup/pre-rebase

SAFETY WARNING — an interactive rebase that hits a conflict can silently drop a hunk if the resolution goes wrong, and the result still builds and still passes tests because the lost code was not covered. Range-diff against the pre-rebase tip is the only cheap check that catches it. Run it before every force-push of a branch that resolved conflicts during the rebase; the wider set of precautions is in safe git rebase -i for shared branches.

Step 5 — Give the reviewer the comparison Jump to heading

Do not make the reviewer reconstruct which version they read. Post it.

git range-diff 'origin/pr/812@{1}'...origin/pr/812 > /tmp/rd.txt
{
  echo 'Rebased onto the current default branch; one fixup folded into the retry commit.'
  echo
  echo '<details><summary>range-diff</summary>'
  echo
  echo '```'
  head -60 /tmp/rd.txt
  echo '```'
  echo
  echo '</details>'
} | gh pr comment 812 --body-file -
# Verification: the comment renders and the detail is collapsed
gh pr view 812 --json comments --jq '.comments[-1].body' | head -5
Re-reading a branch against reading a range-diffRe-reading treats the second version as new work, which costs the same as the original review. A range-diff shows only the commits whose content changed, so a rebase with one fixup is a two-minute confirmation rather than an hour.Re-read the branchRead the range-difftime for a pure rebasefull review againsecondsrisk of missing a changehigh — it all looks newlow — changes are markedauthor has to explainin prosethe output shows itneeds the old tipnoyes, from the reflogthe only cost is remembering to fetch before the rewrite, which the reflog handles

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

What if I never fetched the old version? Jump to heading

Then it exists only on the author’s machine, and the honest answer is to ask them for the range-diff. Authors have the pre-rebase state in their own reflog, so it costs them one command. For future reviews, fetching when you start reading is enough to give you the anchor.

Does this work across a squash merge? Jump to heading

Not usefully. Squashing collapses many commits into one, so there are no pairs to compare and the output degrades into one added and many dropped commits. Range-diff is for rewrites that preserve the shape of the history.

Why does it show a commit as changed when only the base moved? Jump to heading

Usually because the rebase resolved a conflict, which genuinely changed the commit’s content relative to its parent. That is exactly the case worth reading: the resolution is where mistakes hide, and it is invisible in a normal diff of the new branch.