Jan 13, 2023 Viewing a pull request's changes since your last review, even in the face of a rebase

GitHub’s pull request user interface has a handy little button you can click labelled Changes since last review, which—like it says on the tin—will show you the diff of all commits made to a PR since the last time you reviewed it. It’s really useful when you review a really big PR and request a handful of really small changes; you can verify the changes you asked for have been made, and make sure those changes don’t introduce any new issues, without having to review the entire diff again.

Unfortunately, all it does is the equivalent of a git diff between the PR’s head commit when you reviewed it and its new head commit now. If your team uses a rebase-heavy workflow, odds are the PR author will do a rebase between your first and subsequent review, and that button will give you a rather generic error message instead of anything useful.

Fixup commits

One solution is fixup commits. These are just ordinary commits with a magic string (usually fixup!) at the start of the commit message, but git rebase -i --autosquash will recognise them and rearrange them automatically for you.

This means that if your coworkers use fixup commits, you can review their PRs with the “changes since last review” feature. Then after the PR is accepted but before they hit the merge button, they can use git rebase -i --autosquash to make the history tidier.

There’s two downsides to this approach, though:

  1. It requires your coworkers to write their PRs a certain way, which means you have to convince them to change their habits.
  2. If they have an even slightly long-running PR, they’re going to want to rebase their work on top of the latest main branch, and there’s no way to do that with fixup commits.

git range-diff

In version 2.19 (way back in 2018), Git added a new subcommand range-diff, which solves this problem really nicely!

You can think of range-diff as doing a “diff of a diff”. The docs explain the algorithm it uses and how to interpret the output in excruciating detail, but the short version is this: you supply it two commit ranges, and it will show you what’s different about the changes introduced by the commits in the second compared to the first.

So, if the main branch of your repository is called main, and you have a PR that was last reviewed at $PR_LAST_REVIEW_SHA and is now at $PR_HEAD_SHA, you can see all the changes since your last review by running git range-diff main $PR_LAST_REVIEW_SHA $PR_HEAD_SHA.

Tying it all together

Of course, in order to run that command, you first need to know the right values for $PR_LAST_REVIEW_SHA and $PR_HEAD_SHA, and you need to ensure those commits are present in your local repository’s object store.

Fortunately, the GitHub CLI lets us really easily do a whole bunch of things with our GitHub repos, including running arbitrary API calls. So let’s tie it all together in a shell script!

#!/usr/bin/env zsh
set -euo pipefail

# Based on https://daisy.wtf/writing/github-changes-since-last-review

# Use the GitHub API to get the references we need
MY_USER_ID=$(gh api "/user" -q '.id')
PR_HEAD_SHA=$(gh api "/repos/{owner}/{repo}/pulls/$PR_NUMBER" -q '.head.sha')
PR_LAST_REVIEW_SHA=$(gh api "/repos/{owner}/{repo}/pulls/$PR_NUMBER/reviews" \
                     -q "map(select(.user.id == $MY_USER_ID) | .commit_id)[-1]")

# Fetch the commits we want to compare, unless we already have
if \
    test $(git cat-file -t $PR_LAST_REVIEW_SHA || echo 'none') != commit \
    -o $(git cat-file -t $PR_HEAD_SHA || echo 'none') != commit
    git fetch origin $PR_LAST_REVIEW_SHA $PR_HEAD_SHA

# Compare the ranges
git range-diff main $PR_LAST_REVIEW_SHA $PR_HEAD_SHA

(One thing to note: $MY_USER_ID will always return the same thing, so you can just run gh api "/user" -q '.id' once, edit its output into the script, and save yourself a network round-trip.)

Pop that into a file called gh-diff-pr your $PATH, chmod +x it, then run gh-diff-pr [PR number] and you’ll get a “changes since last review” that tolerates all kinds of rebasing shenanigans.