Maintaining shadow branches for GitHub PRs

Source: maskray.me
17 points by MaskRay a day ago on lobsters | 24 comments

I've created pr-shadow with vibe coding, a tool that maintains a shadow branch for GitHub pull requests (PR) that never requires force-pushing. This addresses pain points I described in Reflections on LLVM's switch to GitHub pull requests#Patch evolution.

The problem

GitHub structures pull requests around branches, enforcing a branch-centric workflow. When you force-push a branch after a rebase, the UI displays "force-pushed the BB branch from X to Y". Clicking "compare" shows git diff X..Y, which includes unrelated upstream commits—not the actual patch difference. For a project like LLVM with 100+ commits daily, this makes the comparison essentially useless.

Inline comments suffer too: they may become "outdated" or misplaced after force pushes.

Additionally, if your commit message references an issue or another PR, each force push creates a new link on the referenced page, cluttering it with duplicate mentions. (Adding backticks around the link text works around this, but it's not ideal.)

These difficulties lead to recommendations favoring less flexible workflows that only append commits (including merge commits) and discourage rebases. However, this means working with an outdated base, and switching between the main branch and PR branches causes numerous rebuilds-especially painful for large repositories like llvm-project.

1
2
3
4
5
6
7
8
9
10
11
git switch main; git pull; ninja -C build


git switch feature0
git merge origin/main
ninja -C out/release


git switch feature1
git merge origin/main
ninja -C out/release

In a large repository, avoiding rebases isn't realistic—other commits frequently modify nearby lines, and rebasing is often the only way to discover that your patch needs adjustments due to interactions with other landed changes.

The solution

If I updated my main branch and plan to switch a feature branch, I always run

1
git rebase main feature

to minimize the number of modified files. To avoid the force-push problems, I use pr-shadow to maintain a shadow PR branch (e.g., pr/feature) that only receives fast-forward commits (including merge commits).

I work freely on my local branch (rebase, amend, squash), then sync to the PR branch using git commit-tree to create a commit with the same tree but parented to the previous PR HEAD.

1
2
3
4
5
6
Local branch (feature)     PR branch (pr/feature)
A A (init)
| |
B (amend) C1 "Fix bug"
| |
C (rebase) C2 "Address review"

Reviewers see clean diffs between C1 and C2, even though the underlying commits were rewritten.

When a rebase is detected (git merge-base with main/master changed), the new PR commit is created as a merge commit with the new merge-base as the second parent. GitHub displays these as "condensed" merges, preserving the diff view for reviewers.

Usage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

git switch -c feature
prs init



git rebase main
git commit --amend


prs push "Fix bug"
prs push --force "Rewrite"


prs desc


prs gh view
prs gh checks

The tool supports both fork-based workflows (pushing to your fork) and same-repo workflows (for branches like user/<name>/feature). It also works with GitHub Enterprise, auto-detecting the host from the repository URL.

The name "prs" is a tribute to spr, which implements a similar shadow branch concept. However, spr pushes user branches to the main repository rather than a personal fork. While necessary for stacked pull requests, this approach is discouraged for single PRs as it clutters the upstream repository. pr-shadow avoids this by pushing to your fork by default.

I owe an apology to folks who receive users/MaskRay/feature branches (if they use the default fetch = +refs/heads/*:refs/remotes/origin/* to receive user branches). I had been abusing spr for a long time after LLVM's GitHub transition to avoid unnecessary rebuilds when switching between the main branch and PR branches.

Additionally, spr embeds a PR URL in commit messages (e.g., Pull Request: https://github.com/llvm/llvm-project/pull/150816), which can cause downstream forks to add unwanted backlinks to the original PR.

If I need stacked pull requests, I will probably use pr-shadow with the base patch and just rebase stacked ones - it's unclear how spr handles stacked PRs.