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.
- Rust: You must have a working Rust toolchain (
cargo). - GitHub CLI (
gh): GHerrit uses theghtool to authenticate to GitHub so it can create and manage PRs. Ensure you are authenticated (gh auth login).
-
Install the Binary:
cargo install --git https://github.com/joshlf/gherrit 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: GHerrit's 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 pushGHerrit 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.
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 pushGHerrit 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:
gherrit manage --publicIf you only intend to use GHerrit, and don't care about its internals, then you can stop reading now.
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 GitHub API 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.
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.
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.
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:
- 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.
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.