gherrit

Crates.iogherrit
lib.rsgherrit
version0.1.0-alpha
created_at2025-12-14 15:27:43.76692+00
updated_at2025-12-14 15:27:43.76692+00
descriptionGerrit-style stacked diffs for GitHub
homepage
repositoryhttps://github.com/joshlf/gherrit
max_upload_size
id1984626
size200,358
Joshua Liebow-Feeser (joshlf)

documentation

README

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 the gh tool to create and manage PRs. Ensure you are authenticated (gh auth login).

Setup

  1. Install the Binary:

    cargo install --git https://github.com/joshlf/gherrit
    
  2. Install Hooks: GHerrit relies on Git hooks to intercept branch creation, commits, and pushes. In the repository you wish to manage:

    gherrit install
    
  3. 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:

  1. Analyzes your stack of commits.
  2. Pushes each commit to a dedicated "phantom branch" on GitHub.
  3. Creates or Updates a Pull Request for each commit.
  4. Updates the PR bodies to include navigation links.
  5. 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.
Screenshot 2025-12-05 at 1 13 16 PM

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:

  1. Interception: On 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.
  2. UX: This configuration satisfies Git's upstream requirements, allowing users to run git push immediately 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:

image

 

GHerrit emulates this by rewriting each PR's message with links to other PRs in the same stack:

Screenshot 2025-12-02 at 6 46 15 PM

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:

  1. 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.
  2. 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.

Commit count: 0

cargo fmt