Introduction
spr is a command line tool for using a stacked-diff workflow with GitHub.
The idea behind spr is that your local branch management should not be dictated by your code-review tool. You should be able to send out code for review in individual commits, not branches. You make branches only when you want to, not because you have to for every code review.
If you’ve used Phabricator and its command-line tool arc, you’ll find spr very familiar.
To get started, see the installation instructions, and the first-time setup. (You’ll need to go through setup in each repo where you want to use spr.)
Workflow overview
In spr’s workflow, you send out individual commits for review, not entire branches. This is the most basic version:
-
Make your change as a single commit, directly on your local
main1 branch. -
Run
spr diffto send out your commit for review on GitHub. -
If you need to make updates in response to feedback, amend your commit, and run
spr diffagain to send those updates to GitHub.Similarly, you can rebase onto newer upstream
mainand runspr diffto reflect any resulting changes to your commit. -
Once reviewers have approved, run
spr land. This will squash-merge the PR, so your change lands on top of the latest upstreammainas a single commit.
In practice, you’re likely to have more complex situations: multiple commits being reviewed, and possibly in-review commits that depend on others. You may need to make updates to any of these commits, or land them in any order.
spr can handle all of that, without requiring any particular way of organizing your local repo. See the guides in the “How To” section for instructions on using spr in those situations:
- Simple PRs: no more than one review in flight on any branch.
- Stacked PRs: multiple reviews in flight at once on your local
main.
Rationale
The reason to use spr is that it allows you to use whatever local branching scheme you want, instead of being forced to create a branch for every review. In particular, you can commit everything directly on your local main. This greatly simplifies rebasing: rather than rebasing every review branch individually, you can simply rebase your local main onto upstream main.
Nor do you need your whole team to adopt spr. To your colleagues, the PRs that spr creates are ordinary GitHub PRs, which they review as usual. If your team merges PRs with merge commits rather than squash-merging them, spr can do that, too (see Choose a Merge Method and Stacking Mode).
You can make branches locally if you want, and it’s not uncommon for spr users to do so. You could even make a branch for every review if you don’t want to use the stacked-PR workflow. It doesn’t matter to spr.
One reasonable position is to make small changes directly on main, but make branches for larger, more complex changes. The branch keeps the work-in-progress isolated while you get it to a reviewable state, making lots of small commits that aren’t individually reviewable. Once the branch as a whole is reviewable, you can squash it down to a single commit, which you can send out for review (either from the branch or cherry-picked onto main).
Why Review Commits?
The principle behind spr is one commit per logical change. Each commit should be able to stand on its own: it should have a coherent thesis and be a complete change in and of itself. It should have a clear title and description. It should leave the codebase in a consistent state: building and passing tests, etc.
In addition, ideally, it shouldn’t be possible to further split a commit into multiple commits that each stand on their own. If you can split a commit that way, you should.
What follows from those principles is the idea that commits, not branches, should be the unit of code review. The above description of a commit also describes the ideal code review: a single, well-described change that leaves the codebase in a consistent state, and that cannot be subdivided further.
If the commit is the unit of code review, then, why should the code review tool require that you make branches? spr’s answer is: it shouldn’t.
Following the one-commit-per-change principle maintains the invariant that checking out any commit on main gives you a codebase that has been reviewed in that state, and that builds and passes tests, etc. This makes it easy to revert changes, and to bisect.
This closes the circle: a change is one commit on your local branch while you work on it, amending and rebasing it as needed. spr turns it into a PR, where each update becomes an additional commit, so reviewers can see what changed. And when it lands, it’s squash-merged into one commit on main again.
-
Git’s default branch name is
master, but GitHub’s is nowmain, so we’ll usemainthroughout this documentation. ↩
Installation
Binary Installation
Using Homebrew
brew install spr
Using Nix
spr is available in nixpkgs
nix run nixpkgs#spr
For the latest development version, use the flake in this repository:
nix run github:spacedentist/spr
Using Cargo
If you have Cargo installed (the Rust build tool), you can install spr by running cargo install spr.
Install from Source
spr is written in Rust. You need a Rust toolchain, version 1.91 or newer, to build from source. See rustup.rs for information on how to install Rust if you have not got a Rust toolchain on your system already.
With Rust all set up, clone this repository and run cargo build --release. The spr binary will be in the target/release directory.
Set up spr
In the repo you want to use spr in, run spr init; this will ask you several questions.
First, spr init asks for the GitHub repository. It suggests the one that your origin remote points to; if your repository has no such remote, enter the repository as owner/repo (for one on github.com), or as github://HOST/owner/repo for one on GitHub Enterprise.
You’ll need to authorise spr with your GitHub account. spr init will guide you through the process (see Authentication below).
The rest of the settings that spr init asks for have sensible defaults, so almost all users can simply accept the defaults.
spr init doesn’t ask how pull requests get merged or stacked. The defaults are spr’s original workflow: squash-merging, and synthetic base branches for stacked pull requests. If your team merges pull requests with merge commits, or you want to use GitHub’s stacked pull requests, see Choose a Merge Method and Stacking Mode.
See the Configuration reference page for full details about the available settings.
After initial setup, you can update your settings in several ways:
-
Simply rerun
spr init. The defaults it suggests will be your existing settings, so you can easily change only what you need to. -
Use
git config spr.<key> <value>, e.g.git config spr.mergeMethod merge(docs here). -
Edit the
[spr]section of.git/configdirectly.
Authentication
spr needs a GitHub token to talk to GitHub on your behalf. It’s stored in the spr.auth.github.token setting (for GitHub Enterprise, spr.auth.github.HOST.token).
On github.com, spr init asks how to get one: by logging you in with GitHub (the default), or by pasting a token (see Using your own token). Logging in with GitHub works like this: it shows a link and a code to enter there. (It also tries to open the link in your web browser, but you can open it anywhere, e.g. when you run spr init on a server via SSH.) This authorises spr’s OAuth app with these scopes:
repo: pushing pull request branches, and creating, updating and merging pull requests. GitHub has no narrower scope for this, which is why the authorisation page lists access to much more (e.g. wikis) — spr doesn’t use that.read:org: looking up teams named as reviewers (Reviewers: #team).workflow: pushing pull request branches that change GitHub Actions workflows (.github/workflows/). GitHub refuses those pushes without it.
Older development versions of spr also asked for the user scope (“Update all user data”), which spr doesn’t need. To get rid of it, revoke spr under Authorized OAuth Apps in your GitHub settings, and run spr init again.
Using your own token
You can also give spr a token of your own, e.g. if your organisation restricts access by OAuth apps: paste it when spr init asks for it, or configure it before running spr init — if a token is configured already, spr init keeps using it, as long as it works and has the scopes above. To use the same token in all your repositories, configure it globally:
-
The token of the GitHub CLI has the right scopes:
git config --global spr.auth.github.token "$(gh auth token)" -
A classic personal access token with the scopes
repo,read:organdworkflow. -
A fine-grained personal access token, which lets you limit access to particular repositories and permissions. spr needs these repository permissions: Contents, Pull requests and Workflows (read and write); and for team reviewers, the organisation permission Members (read). GitHub doesn’t tell spr the permissions of such a token, so
spr initcan’t check them.
GitHub Enterprise
spr works with GitHub Enterprise Server, and with GitHub Enterprise Cloud with data residency (SUBDOMAIN.ghe.com). Repositories there are written with their host, e.g. github://ghe.example.com/owner/repo (spr init suggests that, if your origin remote points there), and spr’s target is then e.g. github://ghe.example.com/owner/repo:main. spr talks to the host’s API (https://HOST/api/v3 and https://HOST/api/graphql for GitHub Enterprise Server, https://api.SUBDOMAIN.ghe.com for GHE.com).
Logging in with GitHub only works on github.com, so spr init asks you for a token instead. It shows a link that creates a classic personal access token with the right scopes (https://HOST/settings/tokens/new?scopes=repo,read:org,workflow&description=spr). If you use the GitHub CLI with your GitHub Enterprise host, you can take its token instead (gh auth token --hostname HOST). The token is stored in spr.auth.github.HOST.token, e.g.:
git config --global spr.auth.github.ghe.example.com.token "$(gh auth token --hostname ghe.example.com)"
GitHub’s stacked pull requests are only on github.com so far, so the stacking mode github-stack doesn’t work with GitHub Enterprise; the other stacking modes do.
Support for GitHub Enterprise is new, and tested against a simulation of its API only. If something doesn’t work, please open an issue.
Create and Land a Simple PR
This section details the process of putting a single commit up for review, and landing it (pushing it upstream). It assumes you don’t have multiple reviews in flight at the same time. That situation is covered in another guide, but you should be familiar with this single-review workflow before reading that one.
-
Pull
mainfrom upstream, and check it out. -
Make your change, and run
git commit. See this guide for what to put in your commit message. -
Run
spr diff. This will create a PR for your HEAD commit. -
Wait for reviewers to approve. If you need to make changes:
-
Make whatever changes you need in your working copy.
-
Amend them into your HEAD commit with
git commit --amend. -
Run
spr diff. If you changed the commit message in the previous step, you will need to add the flag--update-message; see this guide for more detail.This will update the PR with the new version of your HEAD commit. spr will prompt you for a short message that describes what you changed. You can also pass the update message on the command line using the
--message/-mflag ofspr diff.If you want to see first what
spr diffwould do, runspr diff --dry-run: it shows what it would change, without changing anything.
-
-
Once your PR is approved, run
spr landto push it upstream.
The above instructions have you committing directly to your local main. Doing so will keep things simpler when you have multiple reviews in flight. However, spr does not require that you commit directly to main. You can make branches if you prefer. spr land will always push your commit to upstream main, regardless of which local branch it was on. Note that spr land won’t delete your feature branch.
When you update
When you run spr diff to update an existing PR, your update will be added to the PR as a new commit, so that reviewers can see exactly what changed. The new commit’s message will be what you entered in step 4.3 of the instructions above.
The individual commits that you see in the PR are solely for the benefit of reviewers; they will not be reflected in the commit history when the PR is landed. The commit that eventually lands on upstream main will always be a single commit, whose message is the title and description from the PR.
(That’s with the default merge method, squash-merging. If your repository is set up to merge PRs with merge commits (spr.mergeMethod is merge), spr land does that, and the commits of the PR become part of the history of main. See Choose a Merge Method and Stacking Mode.)
When others push to your PR
spr diff updates the PR to match your local commit. So if someone else pushed changes to your PR (a colleague fixing a typo, a CI bot fixing the formatting), your next spr diff reverts them.
If others work on your PR, give your commit a Spr-Id with spr diff --spr-id. Then spr diff stops instead of reverting their changes, and spr pull applies them to your local commit. See Pulling changes others pushed.
Updating before landing
If you amend your local commit before landing, you must run spr diff to update the PR before landing, or else spr land will fail.
This is because spr land checks to make sure that the following two operations result in exactly the same tree:
- Merging the PR directly into upstream
main. - Cherry-picking your HEAD commit onto upstream
main.
This check prevents spr land from either landing or silently dropping unreviewed changes.
Conflicts on landing
spr land may fail with conflicts; for example, there may have been new changes pushed to upstream main since you last rebased, and those changes conflict with your PR. In this case:
-
Rebase your PR onto latest upstream
main, resolving conflicts in the process. -
Run
spr diffto update the PR. -
Run
spr landagain.
Note that even if your local commit (and your PR) is not based on the latest upstream main, landing will still succeed as long as there are no conflicts with the actual latest upstream main.
Stack Multiple PRs
The differences between spr’s commit-based workflow and GitHub’s default branch-based workflow are most apparent when you have multiple reviews in flight at the same time.
This guide assumes you’re already familiar with the workflow for simple, non-stacked PRs.
You’ll use Git’s interactive rebase quite often in managing stacked-PR situations. It’s a very powerful tool for reordering and combining commits in a series.
This is the workflow for creating multiple PRs at the same time. This example only creates two, but the workflow works for arbitrarily deep stacks.
-
Make a change and commit it on
main. We’ll call this commit A. -
Make another change and commit it on top of commit A. We’ll call this commit B.
-
Run
spr diff --all. This is equivalent to callingspr diffon each commit of your local branch that isn’t on upstreammainyet, starting with the bottom one and going up toHEAD. Thus, it will create a PR for each of commits A and B. -
Suppose you need to update commit A in response to review feedback. You would:
-
Make the change and commit it on top of commit B, with a throwaway message.
-
Run
git rebase --interactive. This will bring up an editor that looks like this:pick 0a0a0a Commit A pick 1b1b1b Commit B pick 2c2c2c throwawayModify it to look like this1:
pick 0a0a0a Commit A fixup 2c2c2c throwaway exec spr diff pick 1b1b1b Commit BThis will (1) amend your latest commit into commit A, discarding the throwaway message and using commit A’s message for the combined result; (2) run
spr diffon the combined result; and (3) put commit B on top of the combined result.
-
-
You must land commit A before commit B. (See the next section for what to do if you want to be able to land B first. With the
github-stackstacking mode, runningspr landon B lands A and B together.) To land commit A, you would:-
Run
git rebase --interactive. The editor will start with this:pick 3a3a3a Commit A pick 4b4b4b Commit BModify it to look like this:
pick 3a3a3a Commit A exec spr land pick 4b4b4b Commit B
-
-
Now you’re left with just commit B on top of upstream
main, and you can use the non-stacked workflow to update and land it.
There are a few possible variations to note:
-
Instead of a single run of
spr diff --allat the beginning, you could run plainspr diffright after making each commit. -
Instead of step 4, you could use interactive rebase to swap the order of commits A and B (as long as B doesn’t depend on A), and then simply use the non-stacked workflow to amend A and update the PR.
-
In step 4.2, if you want to update the commit message of commit A, you could instead do the following interactive rebase:
pick 0a0a0a Commit A squash 2c2c2c throwaway exec spr diff --update-message pick 1b1b1b Commit BThe
squashcommand will open an editor, where you can edit the message of the combined commit. The--update-messageflag on the next line is important; see this guide for more detail.
Cherry-picking
In the above example, you would not be able to land commit B before landing commit A, even if they were totally independent of each other.
First, some behind-the-scenes explanation. When you create the PR for commit B, spr diff will create a PR whose base branch is not main, so that the PR for B only shows the changes in B itself, rather than the entire difference between main and B. Depending on the stacking mode, that base is a synthetic branch that contains the difference between main and B’s parent, or the PR branch of commit A.
When you run spr land, it checks that each of these two operations would produce exactly the same tree:
- Merging the PR directly into upstream
main. - Cherry-picking the local commit onto upstream
main.
If those operations wouldn’t result in the same tree, spr land fails. This is to prevent you from landing a commit whose contents aren’t the same as what reviewers have seen.
In the above example, then, the PR for commit B is based on the changes in commit A. Thus, if you tried to land B before A, spr land’s “merge PR vs. cherry-pick” check would fail.
If you want to be able to land commit B before A, do this:
-
Make commit A on top of
mainas before, and runspr diff. -
Make commit B on top of A as before, and run
spr diff --cherry-pick. The flag causesspr diffto create the PR as if B were cherry-picked onto upstreammain, targetingmaindirectly. (This step will fail if B does not cherry-pick cleanly onto upstreammain, which would imply that A and B are not truly independent.) -
Once B is ready to land, you can do one of two things:
-
Run
spr land --cherry-pick. (By default,spr landrefuses to land a commit whose parent is not on upstreammain; the flag makes it skip that check.) -
Do an interactive rebase that puts B directly on top of upstream
main, then runsspr land, then puts A on top of B.
-
Stacking modes
The spr.stackingMode setting determines how the PRs of stacked commits are set up. Choose a Merge Method and Stacking Mode explains the options and their trade-offs; How it works - Stacked PRs describes what happens behind the scenes. In day-to-day use, these are the differences:
-
base-branches(the default with squash-merging): each PR of a stacked commit targets a synthetic base branch. Don’t merge such PRs in the GitHub UI, as that would merge them into their base branch; usespr land. Once the commits below have landed and you have rebased your local branch,spr diffchanges the PR to targetmaindirectly. -
chain(the default with merge commits): each PR of a stacked commit targets the PR branch of its parent commit. The PR of the parent commit must be up to date before you can runspr diffon a commit;spr diff --allupdates the whole stack from the bottom up, so this is taken care of.spr landlands the bottom PR, and the PRs stacked on it are changed to targetmain. -
github-stack: likechain, and the PRs are linked as a stack on GitHub.spr diffcreates and updates the stack.spr landlands the PR of the current commit together with all PRs below it, so to land a whole stack, runspr landon its top commit.
Reordering commits in a stack works in all modes. But spr never rewrites PR branches, so in the chain and github-stack modes, a PR branch that once had another PR’s changes merged in keeps them in its history, even though they are no longer part of its changes. With merge commits, those commits end up in the history of main (their changes don’t).
You can switch between the stacking modes at any time. spr diff changes existing PRs over to the configured mode as it updates them.
Rebasing the whole stack
One of the major advantages of committing everything to local main is that rebasing your work onto new upstream main commits is much simpler than if you had a branch for every in-flight review. The difference is especially pronounced if some of your reviews depend on others, which would entail dependent feature branches in a branch-based workflow.
Rebasing all your in-flight reviews and updating their PRs is as simple as:
-
Run
git pull --rebaseonmain, resolving conflicts along the way as needed. -
Run
spr diff --all.
-
You can shorten
exectox,fixuptof, andsquashtos; they are spelled out here for clarity. ↩
Choose a Merge Method and Stacking Mode
spr has two settings that shape how your pull requests look on GitHub and how they end up on main: the merge method (spr.mergeMethod) and the stacking mode (spr.stackingMode). This page explains the options, why they exist, and what their trade-offs are. For the settings themselves, see Configuration.
If you’re unsure: the defaults (squash and base-branches) are spr’s original workflow. If your team merges pull requests with merge commits, use merge (which implies chain).
One commit per unit of work
spr’s original workflow, modelled after Phabricator, revolves around one idea: a unit of work is one commit.
- While you develop a change, it is a single commit on your local branch. You amend it and rebase it as needed.
spr diffturns that commit into a pull request. Every update you make becomes an additional commit on the pull request branch, so reviewers can follow what changed.spr landsquash-merges the pull request, so the change becomes a single commit onmainagain, with the title and description of the pull request.
The commits on the pull request branch are scaffolding for the review. What remains in the history of main is exactly one commit per reviewed change. That’s why squash-merging is spr’s default merge method.
Merge methods
squash (default)
Every pull request becomes a single commit on main. This completes the one-commit-per-change cycle described above, and keeps the history of main linear and easy to bisect and revert.
The catch comes with stacked pull requests: the squash commit on main is a new commit, unrelated to the commits of the pull request branch it was made from. So for a pull request stacked on top, GitHub still sees all the commits of the landed pull request as part of its own history. How spr deals with that is what the stacking modes are about.
merge
Pull requests are merged with a merge commit, so all the commits of the pull request branch become part of the history of main.
Some teams have decided to merge pull requests this way. One of spr’s selling points is that you don’t need your whole team to adopt it: to your colleagues, your pull requests are ordinary pull requests. With mergeMethod = merge, you can use spr’s workflow and still blend in with the conventions of your repository.
The merge method also records the convention of your repository for spr: pull requests may be merged in the GitHub UI, too, not only with spr land.
Stacking modes
A commit that is directly based on main gets a pull request that targets main. The stacking mode determines what happens for a commit that is stacked on other commits that haven’t landed yet.
Synthetic base branches (base-branches)
The pull request targets a synthetic base branch that spr creates for it. That branch contains the changes of all commits below, so the pull request only shows the changes of its own commit.
The purpose of synthetic base branches is to engineer a readable pull request timeline when pull requests are squash-merged. Consider a stack of commits A, B and C, each with a pull request. Without synthetic base branches, once A has been squash-merged, the pull requests of B and C show all the commits of A’s pull request in their timelines: “initial version”, every update in response to review, every rebase. Once B is squash-merged too, C’s timeline shows B’s commits as well. In a big stack, that quickly becomes confusing.
With synthetic base branches, changes coming from the commits below show up on the base branch as commits labelled “[𝘀𝗽𝗿] changes to main this commit is based on” and “[𝘀𝗽𝗿] changes introduced through rebase”, which are easy to put into context. And there are fewer of them: if you make changes all over a stack and then update the whole stack with spr diff --all, each pull request gets a single synthetic commit for the whole update, even the one at the top.
Synthetic base branches have one more advantage: since spr constructs the base branch anyway, a pull request can be based on anything. You can submit a commit from the middle of a local branch for review, without submitting the commits below it. Before such a pull request can land, its base and main have to agree: either you rebase the commit locally onto main, or the commits it depends on land first. Once the commit is directly based on main, spr diff changes the pull request to target main and deletes the synthetic base branch.
The downsides:
- A pull request with a synthetic base branch must not be merged in the GitHub UI: that would merge it into its base branch, not into
main. Usespr land. - With merge commits, the synthetic commits would become part of the history of
main. That’s why spr doesn’t allowbase-branchestogether withmergeMethod = merge.
Chained pull requests (chain)
The pull request targets the pull request branch of its parent commit. The pull requests of a stack form a chain, with the bottom one targeting main. This is the classic way of stacking pull requests on GitHub, and it’s what the GitHub UI and other tools expect.
With merge commits, chaining is the natural choice: once the bottom pull request is merged, its commits are part of the history of main, so the next pull request’s timeline only contains its own commits. The need for synthetic base branches goes away. (Their other advantage, submitting any commit on its own, remains, but most local branches are based on main and get submitted in full anyway.)
With squash-merging, chaining gives the correct result on main, but the timelines of the pull requests further up a stack show the commits of the pull requests below them once those have been squash-merged, as described above.
GitHub stacks (github-stack)
This is chain mode, and on top of that, spr links the pull requests of a chain as a stack using GitHub’s stacked pull requests feature, which is in public preview. You have to opt in to this mode.
The advantages:
- GitHub shows the stack on each pull request page.
- Branch protection and CI are evaluated for every pull request in the stack against the stack’s base branch.
spr landcan land several pull requests at once: it lands the pull request of the current commit together with all pull requests below it.- With squash-merging, the pull request timelines stay readable, because GitHub rebases the remaining pull requests after each merge.
The downside is how GitHub achieves that last point: after merging a pull request of a stack, GitHub rebases the branches of the remaining pull requests and force-pushes them, with both merge methods. spr itself never force-pushes pull request branches, to keep their history intact. GitHub’s force-pushes don’t cause problems for spr, though, and in our tests review comments survived them. See How it works - Stacked PRs for details.
Choosing
base-branches | chain | github-stack | |
|---|---|---|---|
squash | Default. Readable timelines, one commit per change on main. Land with spr land only. | Correct result, but cluttered timelines after landing. | Readable timelines, stack shown on GitHub; GitHub force-pushes branches. |
merge | Not allowed. | Default for merge. Clean timelines and history. | Stack shown on GitHub; GitHub force-pushes branches. |
- You want spr’s original workflow:
squashwithbase-branches(the defaults). - Your team uses merge commits:
merge, which defaults tochain. - You want GitHub’s stack UI and landing whole stacks at once, and don’t mind GitHub rewriting pull request branches:
github-stack, with either merge method.
You can change the stacking mode at any time. spr diff switches existing pull requests over to the configured mode as it updates them.
Format and Update Commit Messages
You should format your commit messages like this:
One-line title
Then a description, which may be multiple lines long.
This describes the change you are making with this commit.
Reviewers: github-username-a, github-username-b
The first line will be the title of the PR created by spr diff, and the rest of the message except for the Reviewers line will be the PR description (i.e. the content of the first comment). The GitHub users named on the Reviewers line will be added to the PR as reviewers. That line is a git trailer: a key-value line in the last paragraph of the commit message.
Updating the commit message
When you create a PR with spr diff, the PR becomes the source of truth for the title and description. When you land a commit with spr land, its commit message will be amended to match the PR’s title and description, regardless of what is in your local repo.
If you want to update the title or description, there are two ways to do so:
-
Modify the PR through GitHub’s UI.
-
Amend the commit message locally, then run
spr diff --update-message. Note that this does not update reviewers; that must be done in the GitHub UI. If you amend the commit message but don’t include the--update-messageflag,spr diffleaves the title and description on GitHub as they are, and warns you that they differ from your local commit message.
If you want to go the other way — that is, make your local commit message match the PR’s title and description — you can run spr amend.
Further information
Trailers added by spr
At various stages of a commit’s lifecycle, spr will add trailers to the commit message:
-
After first creating a PR,
spr diffwill amend the commit message to include a trailer like this:Pull-request: https://github.com/example/project/pull/123The presence or absence of this trailer is how
spr diffknows whether a commit already has a PR created for it, and thus whether it should create a new PR or update an existing one. -
spr landwill amend the commit message to exactly match the title/description of the PR (just asspr amenddoes), as well as adding a trailer like this:Reviewed-by: github-username-aThis trailer names the GitHub users who approved the PR.
-
spr diff --spr-id(or anyspr diff, if thespr.sprIdsconfig option is set) adds a trailer like this, giving the local commit an ID:Spr-Id: 7f3a91c0d2e84b5f9a6c1e0b3d7f2a48It gives the commit a stable identity across amends and rebases (like Gerrit’s
Change-Id), which spr uses to keep track of the PR’s state locally. It only appears in your local commit message: not in the PR, and not in the commit that lands. See Identify Commits with Spr-Ids. -
spr --target BRANCH diffadds a trailer like this to commits for another target than the default one (thespr.targetconfig option):Spr-Target: github:example/project:release/1.2It tells spr where the commit’s PR gets merged, so later commands don’t need
--target. You can also write it yourself, with a branch name alone for a branch in the default target’s repository (Spr-Target: release/1.2). LikeSpr-Id, it only appears in your local commit message. See Target Another Branch.
All of these (Pull-request, Reviewers, Reviewed-by, Spr-Id, Spr-Target) follow git’s trailer conventions, so you can also read and edit them with tools like git interpret-trailers.
Backwards compatibility
Older versions of spr didn’t use trailers, and wrote these lines with spaces instead of hyphens (Pull Request: instead of Pull-request:, and Reviewed By: instead of Reviewed-by:). Current versions of spr can read commit messages written by older versions and will automatically recognize these old-style lines. When spr updates a commit message (e.g., when running spr diff or spr land), it will rewrite them as trailers.
If you have old commits in the previous format, they will continue to work seamlessly — spr will read them correctly and update them to the new format when it next modifies the commit message.
Example commit message lifecycle
This is what a commit message should look like when you first commit it, before running spr at all:
Add feature
This is a really cool feature! It's going to be great.
Reviewers: user-a, coworker-b
After running spr diff to create a PR, the local commit message will be amended to include a link to the PR:
Add feature
This is a really cool feature! It's going to be great.
Reviewers: user-a, coworker-b
Pull-request: https://github.com/example/my-thing/pull/123
In this state, running spr diff again will update PR 123.
Running spr land will amend the commit message to have the exact title/description of PR 123, add the list of users who approved the PR, then land the commit. In this case, suppose only coworker-b approved:
Add feature
This is a really cool feature! It's going to be great.
Pull-request: https://github.com/example/my-thing/pull/123
Reviewers: user-a, coworker-b
Reviewed-by: coworker-b
Reformatting the commit message
spr is fairly permissive in parsing your commit message: it is case-insensitive, and it mostly ignores whitespace. You can run spr format to rewrite your HEAD commit’s message to be in a canonical format.
This command does not touch GitHub; it doesn’t matter whether the commit has a PR created for it or not.
Note that spr land will write the message of the commit it lands in the canonical format; you don’t need to do so yourself before landing.
Check Out Someone Else’s PR
While reviewing someone else’s pull request, it may be useful to pull their changes to your local repo, so you can run their code, or view it in your editor/IDE, etc.
To do so, get the number of the PR you want to pull, and run spr patch <number>. This creates a local branch named PR-<number>, and checks it out.
The head of this new local branch is the PR commit itself. The branch is based on the main commit that was closest to the PR commit in the creator’s local repo. In between:
-
If the PR commit was directly on top of a
maincommit, then the PR commit will be the only one on the branch. -
If there were commits between the PR commit and the nearest
maincommit, they will be squashed into a single commit in your new local branch.
Thus, the new local branch always has either one or two commits on it, before joining main.
Updating the PR
You can amend the head commit of the PR-<number> branch locally, and run spr diff to update the PR; it doesn’t matter that you didn’t create the PR. However, doing so will overwrite the contents of the PR on GitHub with what you have locally. You should coordinate with the PR creator before doing so.
If you’re going to work on the PR, use spr patch --spr-id <number>: it gives the local commit a Spr-Id, and records the PR’s current state. If others push changes to the PR after that, spr diff stops instead of overwriting them (see Spr-Ids).
Identify Commits with Spr-Ids
spr can give a local commit a stable ID, a line like this at the end of its commit message:
Spr-Id: 7f3a91c0d2e84b5f9a6c1e0b3d7f2a48
This page explains what the ID is, what spr uses it for, and when you might want it.
A stable identity for a unit of work
In spr’s workflow, a change is one commit on your local branch, which you amend and rebase as often as needed (see One commit per unit of work). Each time, Git creates a new commit, with a new hash. So what makes it “the same” change?
Once the change has a pull request, the Pull-request trailer links the local commit to it. But that’s the identity of the pull request on GitHub, not of your local commit: it only exists once the pull request does, and pull request numbers are only unique within one repository.
A Spr-Id is the local commit’s own identity:
- Stable. It’s part of the commit message, so it survives amending, rebasing and cherry-picking, and stays the same for the whole life of the change, until it lands.
- Random. It isn’t derived from the commit’s content (which changes), but generated when the ID is added: 128 random bits, as 32 hex digits.
- Local. It only appears in your local commit message. It isn’t part of the pull request’s description, nor of the commits spr pushes to the pull request branch, nor of the commit that lands on
main.
If you know Gerrit, this is its Change-Id; Jujutsu has change IDs for the same reason.
Local state for a pull request in flight
With a stable ID, spr can address an in-flight pull request locally, and keep information about it in your repository. It stores it in Git refs under refs/spr/<id>/.
Currently, that’s one ref: refs/spr/<id>/head, the head of the pull request branch that your local commit is known to match: the head spr last pushed when updating the pull request, or the head of the pull request when spr patch checked it out.
This is different from the remote-tracking branch (refs/remotes/origin/…): that one moves whenever you fetch, whether or not your local commit has taken in what changed. The ref under refs/spr/ only moves when spr has acted on the pull request.
Refs are a natural place for this: they’re per repository (shared by all its worktrees), they keep the commit they point to from being garbage-collected, and you can look at them with ordinary Git commands:
git for-each-ref refs/spr
spr land and spr close delete the refs of the pull requests they land or close. Otherwise, spr leaves them alone: a commit with the ID might still exist on another branch, in a stash or in another worktree, and spr can’t know when it’s gone for good. Refs are tiny, so leftovers do no harm.
What the record is for: changes others push to your pull request
spr never force-pushes: each update of a pull request is a new commit on top of its current head. So if someone else pushes to your pull request — a colleague fixing a typo, a CI bot fixing the formatting, GitHub’s “Update branch” button — their commits stay in the pull request’s history.
But spr’s next commit gets the tree of your local commit, which doesn’t contain their changes. So your next spr diff would quietly revert them.
To notice that, spr needs to know which state of the pull request your local commit corresponds to — and that’s what the record is. If the pull request’s head is still the one recorded, nothing happened that your local commit doesn’t know about. If it moved, spr works out whether the changes are already contained locally: e.g. GitHub only rebased the branch, or you already applied the same changes to your local commit.
If they aren’t, spr diff stops, and leaves the pull request as it is:
Pull Request #123 has changes that aren't in your local commit (e.g. somebody else pushed to it). Updating it would revert them.
Then you can:
- Pull their changes.
spr pullapplies them to your local commit (see below). - Overwrite them.
spr diff --forceupdates the pull request with your local commit anyway, as if you force-pushed your local commit to the pull request. (spr still doesn’t force-push: their commits stay in the pull request’s history, and the new commit reverts their changes.)
With spr diff --all, it stops at the first commit whose pull request has changes, and spr diff --dry-run tells you which ones would stop.
A special case is a pull request that is based on a newer main than your local commit, e.g. after somebody used GitHub’s “Update branch” button. spr diff then asks you to rebase your local commit onto the current main first. --force doesn’t help here: the pull request would then show your change and the reversal of everything that’s new on main. After rebasing, spr diff checks again, and only stops if there are other changes, too.
Pulling changes others pushed: spr pull
spr pull takes what changed on the pull request since spr last pushed it, and applies it to your local commit, with a three-way merge. Like with git pull, you end up with their changes in your local branch — but no remote commits end up on it: your local commit is amended instead, as if you had made their changes yourself. Changes in your local commit that you haven’t pushed yet are kept.
$ spr pull
1a2b3c4 Add feature
⬇️ Pulling changes from Pull Request #123:
src/feature.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
✅ Pulled the changes into your local branch
↩️ To undo:
git reset --keep 1a2b3c4…
git update-ref refs/spr/7f3a…/head 9f8e7d6…
It records the pull request’s head, so the next spr diff goes ahead and updates the pull request with your local commit, which now contains their changes.
Like spr diff, it works on the HEAD commit, or with --all on all commits of your branch, from the bottom up (rebasing the commits above an amended one). --dry-run shows what it would pull. It needs commits with an ID (and a record), and a clean working tree.
spr pull is meant for the common case: somebody made a small fix to your pull request. It stops at the first sign of trouble, without changing anything:
- Conflicts. If their changes conflict with your local commit,
spr pullcreates a commit with just their changes, for you to cherry-pick, and tells you how:git cherry-pick --no-commit <commit>, resolve the conflicts with your usual Git tools,git commit --amend --no-edit, and update the pull request withspr diff --force. (With--all, if the conflict is in a commit below the HEAD commit, it tells you how to apply them there withgit rebase -i.) - Merges or rewritten history. spr only pulls on its own if the pull request’s history since spr last pushed it is a chain of plain commits — then it knows exactly what changed. If it has merge commits (e.g. from GitHub’s “Update branch”) or was rewritten (force-pushed, e.g. by GitHub after landing a stacked pull request below it),
spr pullstops. If the pull request is based on a newermainthan your local commit, rebase your local branch onto thatmaincommit first. Thenspr pull --forcepulls anyway; check the result.
If something unexpected came in, the diffstat shows it, and the commands under “To undo” restore your local commit and spr’s record.
And if a pull request is in a state where you’d rather sort things out by hand, spr patch <number> checks out its current state as a new local branch.
When to use Spr-Ids
Without a Spr-Id, spr works as it always has: nothing is recorded, nothing is checked, and your next spr diff overwrites changes that others pushed to your pull request.
IDs are opt-in, because the trailer is clutter in your commit messages if you work on your pull requests alone. You can add one to just the pull requests that others work on, too:
spr diff --spr-idadds an ID to the commit, if it doesn’t have one yet, when creating or updating its pull request. With--all, it adds one to every commit it works on.spr patch --spr-idgives the commit it creates an ID. That’s useful when you check out a colleague’s pull request to work on it, or your own pull request on another machine.- With
git config spr.sprIds true,spr diffandspr patchalways add an ID.
Once a commit has an ID, spr uses it, whatever the setting. It’s kept by spr amend and spr format, and when you amend or rebase the commit with Git. To stop using it, delete the line from the commit message.
Lifecycle
| command | Spr-Id trailer | refs/spr/<id>/head |
|---|---|---|
spr diff | added with --spr-id or if spr.sprIds is true, unless present | set to the head it pushed, or the current head if the pull request needed no update |
spr diff --dry-run | unchanged | unchanged |
spr patch | the new commit gets an ID with --spr-id or if spr.sprIds is true | set to the head of the pull request |
spr pull | kept | set to the head of the pull request, once its changes are in the local commit |
spr amend, spr format | kept | unchanged |
spr close | removed | deleted |
spr land | gone with the local commit | deleted |
The ref only exists for commits that have an ID.
A Spr-Id that isn’t 32 lowercase hex digits (e.g. after editing it by hand) is ignored, as if there was none. When spr adds an ID, it replaces it.
Target Another Branch
PRs get merged into the default target, the branch configured as spr.target (e.g. github:example/project:main). To create PRs for another branch, e.g. a fix for a release branch, give it with --target:
git switch -c fix-for-release origin/release/1.2
# ... make your change, and commit
spr --target release/1.2 diff
A branch name alone means that branch in the default target’s repository. The full notation also works, for a branch in another repository on github.com: --target github:example/other-project:main.
The Spr-Target trailer
spr diff records the target in the commit message:
Spr-Target: github:example/project:release/1.2
So later commands know it without --target: spr diff to update the PR, spr land to land it into release/1.2, and so on. Each command works for the target of the HEAD commit: the one in its Spr-Target trailer, or the default target if it has none.
You can also write the trailer yourself, e.g. with git commit --amend, in either notation. Like the Spr-Id trailer, it’s only in your local commit: not in the PR’s description, and not in the commit that lands.
Stacks
All commits of a stack have the same target: commits on top of the HEAD commit’s target branch, up to the HEAD commit, form the stack.
- spr stops (in
spr diff,spr land,spr amend, …) if a commit in the stack has aSpr-Targettrailer for another target, or a PR for another target. (Commits with neither take the target of the stack.) spr diff --all(and--refs) stops if any of the commits it works on has another target. To give them all the trailer, runspr --target BRANCH diff --all.--targetmust not contradict the HEAD commit’sSpr-Targettrailer.- spr doesn’t change the target of an existing PR: if the trailer doesn’t match the PR’s base branch,
spr diffstops. Change the base on GitHub, or remove thePull-requesttrailer to create a new PR.
Cherry-picking
With spr diff --cherry-pick, each commit goes to its own target, so one run can create PRs for different targets:
spr diff --cherry-pick --refs HEAD~2..HEAD
Each commit is cherry-picked onto the commit of its target branch that the local branch is based on (the merge base). --cherry-pick can’t be combined with --all: use --refs. spr land --cherry-pick lands the HEAD commit into its target, whatever the targets of the commits below it.
Configuration
The recommended way to configure spr is to run spr init, rather than setting config values manually. You can rerun spr init to update config at any time.
spr uses the following Git configuration values:
| config key | CLI flag | description | default1 | default in spr init2 |
|---|---|---|---|---|
auth.github.token3 | --auth-token4 | The token for accessing GitHub (github.com); for GitHub Enterprise, auth.github.HOST.token | obtained by logging in with GitHub, or entered (GitHub Enterprise) | |
target | --target5 | Where pull requests get merged: forge, repository and branch, e.g. github:owner/repo:master6 | the repository that the origin remote points to, and its default branch | |
branchPrefix | --branch-prefix | String used to prefix autogenerated names of pull request branches | spr/GITHUB_USERNAME/ | |
requireApproval | If true, spr land will refuse to land a pull request that is not accepted | false | not set | |
mergeMethod | How pull requests are merged: squash or merge7 | squash | not set | |
stackingMode | How pull requests of stacked commits are set up: base-branches, chain or github-stack8 | base-branches with squash, chain with merge | not set | |
useCommitTitleForInitialCommit | If true, uses the actual commit title for the initial PR commit message. If false, uses [𝘀𝗽𝗿] initial version. | false | not set | |
sprIds | If true, spr diff and spr patch give every local commit an ID (Spr-Id trailer); otherwise only with --spr-id9 | false | not set |
-
The config keys are all in the
sprsection; for example,spr.mergeMethod. -
Values passed on the command line take precedence over values set in Git configuration.
-
Values are read from Git configuration as if by
git config --get, and thus follow its order of precedence in reading from local and global config files. See the git-config docs for details. -
spr initwrites configured values into.git/configin the local repo. (It must be run inside a Git repo.)
-
Value used by
sprif not set in configuration. ↩ -
Value suggested by
spr initif not previously configured. ↩ -
The token for a forge and host:
auth.github.tokenfor github.com,auth.github.HOST.tokenfor GitHub Enterprise onHOST(e.g.spr.auth.github.ghe.example.com.token; the host in lower case, with the port if it has one). Set it in the repository, or globally for all repositories (git config --global …). Before spr supported other hosts, the token wasgithubAuthToken. spr still reads it as the token for github.com ifauth.github.tokenisn’t set, but it’s deprecated and will be removed in a future version: spr shows a note on how to replace it.spr initreplaces it in the repository’s.git/config. ↩ -
For the forge and host of the target. Be careful using this: your auth token will be in your shell history. The deprecated option
--github-auth-tokenstill works. ↩ -
--targettakes a full target, or a branch name alone for a branch in the repository oftarget. It applies to commits without aSpr-Targettrailer, andspr diffgives commits for other targets thantargetsuch a trailer. See Target Another Branch. The deprecated options--github-repository(owner/repo) and--github-master-branchstill work, overriding the repository or the branch oftarget. ↩ -
The notation is
FORGE:REPOSITORY:BRANCH, where the repository may start with//HOST/for another host than the forge’s public one. For now, spr supports only GitHub (github:), with the repository asowner/repo: on github.com (github:owner/repo:main), or on GitHub Enterprise (github://ghe.example.com/owner/repo:main; see GitHub Enterprise). The branch is everything after the last colon (branch names can’t contain colons), e.g.github:acme/project:release/1.2. Beforetarget, spr had two options,githubRepository(owner/repo) andgithubMasterBranch(default:master). spr still reads them iftargetisn’t set, but they’re deprecated and will be removed in a future version: spr shows a note on how to replace them.spr initreplaces them in the repository’s.git/config(and keeps a branch configured there). ↩ -
spr landsquash-merges pull requests, or merges them with a merge commit. The setting also records the convention of the repository, as pull requests may be merged in the GitHub UI, too. See Choose a Merge Method and Stacking Mode. ↩ -
base-branches: the pull request targets a synthetic base branch created by spr.chain: the pull request targets the pull request branch of the parent commit.github-stack: likechain, and the pull requests are linked as a stack on GitHub (GitHub’s stacked pull requests feature, in public preview). The combination ofmergeMethodmergeandstackingModebase-branchesis not allowed. See Choose a Merge Method and Stacking Mode for the trade-offs. ↩ -
The ID identifies a local commit and its pull request across amends and rebases, like Gerrit’s
Change-Id. It only appears in local commit messages — not in pull requests, nor in the commits that land. The setting only controls whether IDs are added automatically: commits that have an ID keep it either way. See Identify Commits with Spr-Ids. ↩
How it works - Simple PR
This section describes how spr works from a git perspective.
This is not required to use spr,
but more to understand how it works.
It follows the simple PR workflow. Understanding that workflow will help understand the decisions made here.
Creating the PR
Let’s say you have a repo with a main branch:
gitGraph
commit id: "A"
commit id: "B"
Now you want to make a change.
You make a commit on the main branch with your change and the commit id is C1:
gitGraph
commit id: "A"
commit id: "B"
commit id: "C1"
When you are ready to submit a PR, you run spr diff from the head commit of the main branch (C1).
This will create a transient branch that is only used to create a PR on GitHub:
gitGraph
commit id: "A"
commit id: "B"
branch spr/username/commit-title-of-C1
checkout main
commit id: "C1"
checkout spr/username/commit-title-of-C1
commit id: "B->C1"
This spr/username/commit-title-of-C1 branch is pushed to GitHub and used to open a PR against the main branch.
The transient branch is not something you really need to directly interact with;
spr takes care of keeping it up to date, creating the correct commits, etc.
All you need to do is continue working on the main branch.
The message of the C1 commit gets a Pull-request trailer
that links to the PR that was created.
This allows spr to know which branch/PR to update from only the commit.
Amending the commit
Let’s say your PR needed some changes.
What you’d do is make the changes to the commit that the PR was created from (in this case C1)
amending the changes to the commit.
gitGraph
commit id: "A"
commit id: "B"
branch spr/username/commit-title-of-C1
checkout main
commit id: "C2"
checkout spr/username/commit-title-of-C1
commit id: "B->C1"
The next time that you use spr diff,
it will compute the diff from C1 to C2,
and push that to GitHub as an additional commit:
gitGraph
commit id: "A"
commit id: "B"
branch spr/username/commit-title-of-C1
checkout main
commit id: "C2"
checkout spr/username/commit-title-of-C1
commit id: "B->C1"
commit id: "C1->C2"
Pushing additional commits to the PR rather than rebasing the commits that are already on the PR works better with GitHub (discussions stay intact, commits aren’t lost in the UI, changes between requests can be tracked, etc.).
If you make more changes, it continues along this path:
gitGraph
commit id: "A"
commit id: "B"
branch spr/username/commit-title-of-C1
checkout main
commit id: "C3"
checkout spr/username/commit-title-of-C1
commit id: "B->C1"
commit id: "C1->C2"
commit id: "C2->C3"
Landing the change
Once you’re ready to merge the PR,
you would use spr land to merge the PR.
This will perform a squash merge on GitHub for the PR (or merge it with a merge commit, if spr.mergeMethod is merge).
Once the branch has been merged on GitHub,
it will update the local main branch and delete the transient branch:
gitGraph
commit id: "A"
commit id: "B"
commit id: "C3"
How it works - Stacked PRs
This section describes what spr does behind the scenes when you have a stack of commits on your local branch, each of which gets its own pull request. It builds on How it works - Simple PR.
The local branch is the source of truth
With spr, your pull requests are defined by the commits on your local branch. The only thing spr adds to a commit is the Pull-request trailer in its commit message, which links the commit to its pull request.
Every time you run spr diff, it projects the current state of each commit onto the pull request branches on GitHub: it adds a commit to the pull request branch that makes the branch reflect the local commit, and, if necessary, merges in whatever the local commit is now based on (a newer master commit, or the updated pull request of its parent commit).
spr never rewrites the history of a pull request branch. That way, reviewers can see every revision of a pull request, and what changed between revisions. But spr also doesn’t rely on the branches staying unchanged between two runs of spr diff: it always looks at the current state of a pull request on GitHub, and adds to it whatever it takes to reflect the local commit.
Stacking modes
A commit that is directly based on the master branch gets a pull request that targets the master branch. For a commit that is stacked on other commits, there are three options (the spr.stackingMode config option). Choose a Merge Method and Stacking Mode explains the motivation behind them and their trade-offs.
-
base-branches:sprcreates a synthetic base branch for the pull request, which contains the changes of all commits below. The pull request targets that base branch, so it only shows the changes of its own commit. Changes to the commits below appear on the base branch as commits titled “[𝘀𝗽𝗿] changes introduced through rebase”. This keeps pull request timelines readable when pull requests are squash-merged, but doesn’t work with merge commits: the synthetic commits would end up in the history of the master branch. -
chain: the pull request targets the pull request branch of its parent commit, andsprmerges the head of that pull request into it. This is the classic way of stacking pull requests on GitHub, and works well with merge commits. With squash-merging, a pull request’s timeline shows the commits of the pull requests below it once those have been squash-merged, because the squash commit on master is unrelated to the original commits. -
github-stack: likechain, and the pull requests are also linked as a stack using GitHub’s stacked pull requests feature.
What GitHub does when a pull request in a stack is merged
The following is based on experiments with GitHub’s stacked pull requests preview, and may change.
When the bottom pull request of a stack on GitHub is merged (by spr land, or in the GitHub UI):
-
GitHub changes the base of the next pull request to the stack’s base branch (master).
-
GitHub rebases the branches of all remaining pull requests onto the result of the merge and force-pushes them. This happens with both squash-merging and merge commits. Like
git rebase, it drops merge commits, so the history of those branches becomes linear. Review comments move to the rebased commits and are not marked as outdated. -
If rebasing a branch has conflicts (e.g. because the master branch has changed in the meantime in a way that conflicts with a pull request further up the stack), GitHub leaves that branch alone. Its pull request then shows the changes of the merged pull request, too, and has conflicts. Once you rebase your local branch and resolve the conflicts,
spr diffmerges the new master commit into the pull request branch, which brings the pull request back into a clean state.
Rebasing and force-pushing is not what spr would do, as it loses the history of the pull request branches. But it’s not a problem for spr either: as described above, spr doesn’t need the pull request branches to be unchanged. After rebasing your local branch, spr diff finds the rebased pull requests up to date, or adds to them as usual.
When spr land lands a stack with merge commits, GitHub creates a single merge commit on master for all the pull requests it lands at once.
Changing the stack
GitHub doesn’t allow changing the base of a pull request that is part of a stack. When a commit’s parent changes (e.g. you reordered commits, dropped one, or it’s now directly based on master), spr diff dissolves the stack before changing the base of the pull request, and in the github-stack mode creates a new stack at the end.
Plumbing Commands
Like Git, spr has two kinds of commands. The porcelain commands (spr diff, spr land, …) do everything for you: they look at your local commits, talk to GitHub, push branches and update pull requests. The plumbing commands expose the building blocks the porcelain uses, for your own scripts, for example to use spr’s way of constructing pull requests with another forge.
Experimental: the plumbing commands are new, and their interface may still change.
Principles
- Plumbing commands only work on Git objects. They don’t move any refs (branches,
HEAD), don’t push or fetch, and don’t talk to GitHub. Fetch before, and push afterwards. - They don’t need spr to be configured: they work in any Git repository, without
spr init. - They know nothing about pull request numbers or trailers in commit messages beyond passing the
Pull-requesttrailer through inspr plumbing stack. Rewrite commit messages however you like, e.g. withgit interpret-trailers. - Commits and trees can be given in any form Git understands: commit hashes, branch names,
HEAD~2, and for trees also<commit>:(the tree of a commit). - Output is plain text by default (object IDs, one per line), or JSON with
--json.
Exit status
| status | kind | meaning |
|---|---|---|
| 0 | success | |
| 1 | error | any other error |
| 2 | (command-specific) | base-outdated, head-moved, merge-commit or mismatch, see the commands below |
| 3 | message-required | commit-pr would create a head commit, but no message was given with -m |
| 4 | conflict | a change can’t be applied without conflicts |
Errors are printed to stderr. With --json, an error object is printed to stdout as well, so scripts parsing stdout always get JSON:
{ "error": { "kind": "base-outdated", "message": "…" } }
spr plumbing commit-pr
Creates the commits that make a pull request reflect a local commit, and prints the new head and base commit of the pull request (each is the input commit if it didn’t need changing).
spr plumbing commit-pr --base <commit> --target <commit> [--head <commit>]
(--local <commit> | --tree <tree> --base-tree <tree>)
[--cherry-pick] [--fixed-base] [--expected-head <commit>]
[-m <message>] [--base-message <message>]
[--author-from <commit>] [--plan] [--json]
--head: the current head of the pull request branch. Leave it out for a new pull request: the head then starts off at--base.--base: the commit the pull request is currently based on.--target: the commit on the target branch (e.g.main) the change is based on. The base must contain it.--local <commit>: the local commit. A shorthand for--tree <commit>: --base-tree <commit>~:: the head of the pull request should have the tree of the local commit, and its base the tree of the local commit’s parent.--cherry-pick: apply the change (from the base tree to the tree) onto--targetinstead. The base tree becomes the target’s tree. Fails withconflictif the change doesn’t apply cleanly.--fixed-base: don’t add commits to the base. If the base doesn’t have the base tree, or doesn’t contain the target, fail withbase-outdated.--expected-head <commit>: the head the pull request is expected to have, e.g. the one you pushed last — likegit push --force-with-lease. If--headis a different commit, fail withhead-moved, unless all its changes are contained in the tree already. That’s the case if the head only moved because the target branch was merged into it or it was rebased onto the target branch (e.g. by GitHub), with changes the local commit is based on, too; or if its changes were applied to the local commit already. It fails if somebody else pushed changes to the pull request, or if the head is based on a newer target commit than--target(then rebase the local commit first).
What happens:
- If the base doesn’t have the base tree or doesn’t contain the target (and
--fixed-baseisn’t given), a new base commit is created on top of the base, with the base tree. If the target isn’t contained in the base yet, it’s merged in as a second parent. - If the head doesn’t contain the (new) base, or doesn’t have the tree, a new head commit is created on top of the head, with the tree, merging in the base if necessary.
-m gives the message of a new head commit, which is required if one is created. The new commits credit the author of --local (or --author-from), or the current user.
--plan doesn’t create anything, but prints the commits that would be created, with their parents (new-base stands for the new base commit):
new-base <parent> [<parent>]
new-head <parent> [<parent>]
With --json, the output is {"head": …, "base": …, "head_created": …, "base_created": …}, or with --plan, {"new_base_parents": …, "new_head_parents": …, "merges": …, "tree": …, "base_tree": …}.
This is how spr diff uses it in its stacking modes:
| situation | --base | --fixed-base |
|---|---|---|
commit directly on the target, or --cherry-pick | the target commit | yes |
synthetic base branch (base-branches) | merge base of the PR head and the base branch | no |
chained PRs (chain, github-stack) | the head of the parent commit’s PR | yes |
spr plumbing stack
Lists the commits between the target and a commit (default: HEAD), from bottom to top: each commit, its parent, and its Pull-request trailer (or -).
spr plumbing stack --target <commit> [<commit>] [--json]
With --json, it prints an array of objects with the fields commit, parent, title, pull_request and trailers. Fails with merge-commit if there are merge commits in the range, which spr can’t handle.
spr plumbing land-check
Checks that merging a pull request into the target gives the same tree as applying the local commits to it, and prints that tree. That’s the check spr land does before landing, to make sure it lands exactly the changes of the local commits, which reviewers have seen.
spr plumbing land-check --target <commit> --pr-head <commit> --local <commit>
[--since <commit>] [--json]
The changes of <since>..<local> are applied (default: <local>’s parent, i.e. just <local>). Fails with mismatch if the trees differ, or with conflict if either can’t be done without conflicts.
Example
Updating the pull requests of a stack on another forge could look like this:
git fetch origin
target=$(git rev-parse origin/main)
spr plumbing stack --target "$target" | while read commit parent pr; do
# Look up the head and base of the pull request `$pr` on your forge
# ($pr_head, $pr_base, $branch), then:
{ read head; read base; } < <(spr plumbing commit-pr --target "$target" \
--head "$pr_head" --base "$pr_base" --local "$commit" -m "Update")
git push origin "$head:refs/heads/$branch"
done
Testing
spr has four kinds of tests. Most of spr’s logic is tested without GitHub, by tests that run anywhere, in seconds, and in CI. What only GitHub can show — how spr’s commands work with real pull requests — is tested by live tests against a GitHub repository, which need a token and run on demand. What only a person can do, like logging in through the browser, is covered by guided manual tests.
| kind | where | needs | run with | in CI |
|---|---|---|---|---|
| unit tests | src/ (in the modules) | nothing | cargo test | yes |
| integration tests | tests/ | nothing | cargo test | yes |
| live tests | livetest/ (spr-github-livetest) | a GitHub token and a test repository | cargo run -p spr-livetest -- --repo OWNER/REPO live | no |
| manual tests | livetest/ (spr-github-livetest) | a GitHub token, and you | cargo run -p spr-livetest -- --repo OWNER/REPO manual init | no |
Unit tests
The unit tests sit next to the code they test, in #[cfg(test)] modules. They cover everything that doesn’t need GitHub:
- Commit messages (
message.rs): parsing and writing titles, descriptions and trailers, including messages written by older spr versions, and which trailers end up in pull requests and landed commits. - Constructing pull requests (
pr_commits.rs): which commits spr creates on a pull request branch and its base for a given local commit, in all the situations the stacking modes lead to; and the check whether a pull request has changes that aren’t in the local commit. - Landing (
land_check.rs): the check that merging a pull request gives the same result as applying the local commits. - Pulling changes others pushed (
remote_changes.rs): whatspr pulldoes with which history of a pull request. - Local Git operations (
git.rs): rewriting commit messages safely, and the records spr keeps underrefs/spr/. - Configuration, branch names,
spr init‘s checks, the plumbing commands’ options, and smaller helpers.
Tests that need commits build them in a temporary repository with TestRepo (src/test_utils.rs), directly as Git objects: commit, commit_tree, tree_with and friends make commit graphs of any shape in a few lines.
The rule of thumb for new code: logic that doesn’t need GitHub goes into a function that works on Git objects (like the plumbing commands), so it can be unit-tested. The porcelain commands then only connect it to GitHub.
Integration tests
The tests in tests/ run the spr binary itself. So far they cover the plumbing commands: that they work in a repository without any spr configuration, and their exit codes and JSON error output.
Live tests
The live tests (livetest/, the program spr-github-livetest) run spr against a real GitHub repository, the way you use it: they make commits, run spr commands, and check the results on GitHub and in the local repository. There are scenarios for:
- creating, updating and closing a pull request (
basic) - landing, by squash-merging and with a merge commit (
land,land-merge) - changes others push to a pull request:
spr diffstopping,--force,spr pull, conflicts, and GitHub’s “Update branch” (remote-changes,pull,pull-conflict,update-branch) - stacked pull requests in the three stacking modes (
stack-base-branches,stack-chain,stack-github) spr patch,spr amend,spr listandspr diff --dry-run(patch,amend,dry-run)
A full run takes about five minutes.
What they need
- A GitHub repository meant for testing. The tests create and close pull requests and branches in it, so they refuse to run unless its default branch contains a file
.spr-livetest. Use a dedicated repository, not a real project. - A GitHub token, in the environment variable
SPR_GITHUB_LIVETEST_TOKEN. The tests never use a token on their own (e.g. the GitHub CLI’s), so you always decide which account they act as. To use the GitHub CLI’s token:
export SPR_GITHUB_LIVETEST_TOKEN=$(gh auth token)
Running them
cargo run -p spr-livetest -- --repo OWNER/REPO live # all of them
cargo run -p spr-livetest -- --repo OWNER/REPO live pull land # some of them
cargo run -p spr-livetest -- --repo OWNER/REPO live --list # what there is
cargo run -p spr-livetest -- --help
They build and test spr from your working copy. To test another spr binary, e.g. a release, pass --spr PATH.
On GitHub Enterprise
For a test repository on GitHub Enterprise, give it with its host, and a token for that host:
export SPR_GITHUB_LIVETEST_TOKEN=$(gh auth token --hostname ghe.example.com)
cargo run -p spr-livetest -- --repo github://ghe.example.com/OWNER/REPO live
The tests then talk to that host’s API, clone from it, and configure spr for it (spr.target, spr.auth.github.HOST.token). Tests of what only github.com has are skipped, with the reason: GitHub’s stacked pull requests (stack-github), and the options from before spr supported other hosts (old-config).
What they do in the repository
Each run gets an ID (e.g. 20261008-153012-1a2b), and works on branches of its own:
- its pull requests’ branches are under
spr/livetest/<run-id>/; - instead of the default branch, its pull requests target
livetest/<run-id>/main, created from the default branch at the start. So landing never touches the default branch.
At the end of a run, the tests close its pull requests and delete its branches. If a test fails, --keep leaves everything in place (and the test’s local directory) for a closer look. To remove what interrupted or kept runs left behind:
cargo run -p spr-livetest -- --repo OWNER/REPO cleanup [RUN-ID]
Cleanup only ever touches branches and pull requests of test runs.
The controlled environment
Your own Git configuration must not influence the tests (e.g. a spr.auth.github.token in your global configuration). So each test runs spr in a fresh clone, in a temporary directory with a home directory of its own, and with a cleared environment. Before each test, it checks that spr sees no spr.* setting from outside that clone. The token never appears on a command line or in the output.
Manual tests
Some things need a person: logging in with GitHub in a web browser, and checking what GitHub’s pages show. Manual tests guide you through them:
cargo run -p spr-livetest -- --repo OWNER/REPO manual init
manual init runs spr init in a fresh clone without any spr settings, so it starts with logging in (on GitHub Enterprise: with asking for a token). It explains beforehand what you need to do and what to look out for. Afterwards, it checks what it can on its own (the settings spr init wrote, and that the new token works and has the scopes spr asks for), asks you about what only you could see (the permissions GitHub’s authorisation page listed), tries some variants without your help, and prints a report. The variants run spr init in a pseudo-terminal and answer its questions: a configured token is kept, a broken one replaced (by logging in without a browser, and by pasting a token), and spr.githubAuthToken replaced by spr.auth.github.token.
It needs the token like the live tests (for cloning the repository and its checks), but doesn’t change anything in the repository, so it doesn’t require the .spr-livetest file.
What runs in CI
CI (.github/workflows/test.yml) runs on every push to master and on every pull request:
cargo clippy --workspace --all-targets --all-features, with all warnings turned into errorscargo fmt --all --checkcargo test --workspace --all-features: the unit and integration tests, and the unit tests of the live tests’ own helpers- a check that spr builds with the minimum supported Rust version (
rust-versioninCargo.toml)
--workspace includes livetest/: CI doesn’t run the live tests, but checks that they build, are formatted and have no warnings. Plain cargo test only runs spr’s own tests.
The live and manual tests don’t run in CI: they need a token and a test repository, and the manual ones a person. Run the live tests when you change how spr works with GitHub, and before a release; run manual init when you change spr init or authentication.
The pre-commit hooks (.pre-commit-config.yaml) run the formatters and clippy locally, before each commit. Building the documentation is a separate workflow (book.yml), which publishes it from master.
Writing tests
- Unit tests: add them to the
testsmodule of the code you change. For anything with commits, useTestRepo. - Live tests: each scenario is a function in
livetest/src/scenarios/, registered inall()inscenarios/mod.rs. It gets aCtxwith the test’s clone (ctx.env, to run spr and Git), the GitHub API (ctx.api) and the run (ctx.run), and helpers likecommit,amend,pr_number,check_pr_matches(the pull request has the local commit’s tree),checkandcheck_output. To simulate somebody else pushing to a pull request, change a file through the API (ctx.api.put_file), like editing it in GitHub’s web interface. GitHub takes a moment to reflect some changes; wait for them withwait_forinstead of sleeping.