| Crates.io | gherrit |
| lib.rs | gherrit |
| version | 0.1.0-alpha |
| created_at | 2025-12-14 15:27:43.76692+00 |
| updated_at | 2025-12-14 15:27:43.76692+00 |
| description | Gerrit-style stacked diffs for GitHub |
| homepage | |
| repository | https://github.com/joshlf/gherrit |
| max_upload_size | |
| id | 1984626 |
| size | 200,358 |
Note: GHerrit is currently in alpha. You're welcome to use it, but please be aware that we may make breaking changes.
GHerrit is a tool that brings a Gerrit-style "Stacked Diffs" workflow to GitHub.
It allows you to maintain a single local branch containing a stack of commits
(e.g., feature-A -> feature-B -> feature-C) and automatically
synchronizes them to GitHub as a chain of dependent Pull Requests.
cargo).gh): GHerrit uses the gh tool to create and manage PRs. Ensure you are authenticated (gh auth login).Install the Binary:
cargo install --git https://github.com/joshlf/gherrit
Install Hooks: GHerrit relies on Git hooks to intercept branch creation, commits, and pushes. In the repository you wish to manage:
gherrit install
Setup GitHub Action (Optional but Recommended):
To enable automatic cascading merges (where merging a parent PR automatically rebases its child), add the following workflow to your repository at .github/workflows/gherrit-rebase-stack.yml:
name: Rebase Stack
on:
pull_request:
types: [closed]
permissions:
contents: write
pull-requests: write
jobs:
rebase-stack:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Run Gherrit Cascade
uses: joshlf/gherrit@main
with:
token: ${{ secrets.GITHUB_TOKEN }}
pr_body: ${{ github.event.pull_request.body }}
Once installed, simply work as if you were using Gerrit.
Create a branch to track your work, and create multiple commits.
git checkout -b api-endpoints
# Hack on feature A
git commit -m "optimize database query construction"
# Hack on feature B (which depends on A)
git commit -m "add api endpoints"
Note: The commit-msg hook automatically appends a unique gherrit-pr-id to every commit message.
When you are ready to upload your changes, simply push:
git push
GHerrit intercepts this push. Instead of pushing your local branch directly, it:
To modify a commit in the middle of the stack, use interactive rebase:
git rebase -i main
# (Edit, squash, or reword commits)
Then push again:
git push
GHerrit will detect the changes based on the persistent gherrit-pr-id in the commit trailers and update the corresponding PRs in place.
By default, GHerrit configures managed branches as Private Stacks. On git push, GHerrit will synchronize your stack to GitHub without actually pushing
your local branch tip to the remote server. This avoids cluttering the remote
repository with branches and avoids leaking the names of your local branches to
remote users.
If you wish to maintain a Public Stack (where your local branch is also pushed to origin for backup or collaboration), you can override this:
git config branch.<your-branch>.pushRemote origin
If you only intend to use GHerrit, and don't care about its internals, then you can stop reading now.
gherrit-pr-id Trailer and Phantom BranchesInspired by Gerrit, each commit managed by GHerrit includes a trailer line in its commit message, e.g., gherrit-pr-id: G847....
GitHub identifies PRs by branch name (specifically, a PR is a request to
merge the contents of one branch into another). A branch can contain multiple
commits, leading to a one-to-many relationship between PRs and commits. In the
Gerrit style, we want a one-to-one relationship between PRs and commits.
However, Git commits do not have stable identifiers – commit hashes change on
rebase, on git commit --amend, etc. The gerrit-pr-id trailer acts as a
stable key for the commit that survives rebases and other commit changes.
Since the user will have a single branch locally containing multiple commits, a
normal git push would simply result in a single PR for the whole branch.
Instead, GHerrit pushes changes by synthesizing "phantom" branches: Each commit
is pushed to a branch whose name matches that commit's gherrit-pr-id trailer.
GHerrit then uses the gh tool to create or update one PR for each commit,
setting the base and source branches to the appropriate phantom branches.
In addition to pushing branches, GHerrit pushes a lightweight tag for every
version of every commit in the stack, formatted as
refs/tags/gherrit/<id>/v<version>. Normally, force-push workflows destroy the
history of previous iterations. By tagging every version, GHerrit persists the
entire evolution of a PR. These version tags can be used to diff any two
versions of a PR – this is how GHerrit generates the Patch History Table in
the PR description.
GHerrit enforces optimistic locking to prevent race conditions when multiple
users update the same stack. When pushing a new version tag (e.g., v2),
GHerrit uses the atomic push option:
--force-with-lease=refs/tags/gherrit/<id>/v<ver>:.
The trailing colon (:) tells Git to ensure the ref does not already exist
on the remote. If another user has already pushed v2 in the interim, the
assertion fails, the push is rejected, and the user is forced to fetch and
rebase, preserving the integrity of the patch history.
pre-push HookGHerrit synchronizes changes with GitHub in a pre-push hook. This allows
users to use their normal git push flow instead of using a bespoke command
like (hypothetically) gherrit sync.
By default, GHerrit configures managed branches to treat the local repository as its own upstream. It sets:
branch.<name>.pushRemote = .branch.<name>.remote = .branch.<name>.merge = refs/heads/<name>This configuration has two benefits:
git push, once GHerrit's pre-push hook returns
(after synchronizing the stack to GitHub), Git will always complete the
push. Other than causing git push to fail with a user-visible error,
there is no way to for the pre-push hook to prevent the push from
completing. Setting pushRemote = . ensures that, when the push is
performed, it targets the local repository, which is a no-op.git push immediately after branch creation without seeing
"fatal: The current branch has no upstream branch" errors.Since Gerrit supports stacked commits, the Gerrit UI for a particular commit lists the other commits in that commit's stack:
GHerrit emulates this by rewriting each PR's message with links to other PRs in the same stack:
When managing a stack of PRs on GitHub, merging a parent PR (e.g., feature-A)
into main causes a problem for its child PR (feature-B). Since feature-B
was based on the branch feature-A, and feature-A has now been squashed
and merged into main, GitHub sees the commits in feature-B as "new"
relative to main, even if they are identical to the ones just merged. This
often results in "phantom diffs" or merge conflicts.
To solve this, GHerrit implements a Cascading Merge system:
gherrit-rebase-stack.yml) triggers whenever a PR is merged. It:
G...)main.main.This ensures that as soon as you merge the bottom of the stack, the next PR automatically updates and becomes ready for review/merge, keeping the entire chain healthy without manual intervention.
GHerrit is designed to work seamlessly with developers using other, non-GHerrit
workflows. In order to accomplish this, GHerrit tracks whether each local
branch is "managed" or "unmanaged". By default, branches created locally are
managed, while branches created remotely (and checked out locally) are
"unmanaged". A branch's management state can be changed with gherrit manage
or gherrit unmanage.
The commit-msg and pre-push hooks respect the management state – when
operating on an unmanaged branch, both are no-ops, allowing git commit and
git push to behave as though GHerrit didn't exist.