1 unstable release
Uses new Rust 2024
| 0.1.0-alpha | Dec 14, 2025 |
|---|
#147 in Development tools
82KB
1.5K
SLoC
GHerrit
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.
Installation
Prerequisites
- Rust: You must have a working Rust toolchain (
cargo). - GitHub CLI (
gh): GHerrit uses theghtool to create and manage PRs. Ensure you are authenticated (gh auth login).
Setup
-
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 }}
Usage
Once installed, simply work as if you were using Gerrit.
1. Creating a Stack
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.
2. Pushing
When you are ready to upload your changes, simply push:
git push
GHerrit intercepts this push. Instead of pushing your local branch directly, it:
- Analyzes your stack of commits.
- Pushes each commit to a dedicated "phantom branch" on GitHub.
- Creates or Updates a Pull Request for each commit.
- Updates the PR bodies to include navigation links.
- Injects a "Patch History" table into the PR description. Because GHerrit tracks every version of your commit, this table provides direct links to view the diff between versions (e.g., "Compare v3 vs v2"). This allows reviewers to immediately see what changed since their last review.
3. Updating the Stack
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.
Configuration
Public vs. Private Stacks
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
Design & Architecture
If you only intend to use GHerrit, and don't care about its internals, then you can stop reading now.
Core Architecture
gherrit-pr-id Trailer and Phantom Branches
Inspired 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.
Version Tags
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.
Optimistic Concurrency Control
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 Hook
GHerrit 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.
"Loopback" Interception Strategy
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:
- Interception: On
git push, once GHerrit'spre-pushhook returns (after synchronizing the stack to GitHub), Git will always complete the push. Other than causinggit pushto fail with a user-visible error, there is no way to for thepre-pushhook to prevent the push from completing. SettingpushRemote = .ensures that, when the push is performed, it targets the local repository, which is a no-op. - UX: This configuration satisfies Git's upstream requirements, allowing
users to run
git pushimmediately after branch creation without seeing "fatal: The current branch has no upstream branch" errors.
PR Rewriting
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:
Cascading Merge Automation
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:
- Metadata Injection: When pushing, GHerrit injects hidden metadata into the PR description (inside an HTML comment) containing the IDs of the parent and child PRs.
- Automated Rebase: A GitHub Action (
gherrit-rebase-stack.yml) triggers whenever a PR is merged. It:- Reads the metadata to find the child PR's ID.
- Finds the child PR by its synthesized branch name (e.g.,
G...) - Retargets the child PR to base off
main. - Rebases the child PR onto the new
main. - Force-pushes the updated child PR.
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.
Hybrid Workflow Support
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.
Dependencies
~25–34MB
~632K SLoC