gherrit 0.1.0-alpha

Gerrit-style stacked diffs for GitHub
# 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:**

    ```bash
    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:

    ```bash
    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`:

    ```yaml
    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.

```bash
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:

```bash
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.

<img width="918" height="575" alt="Screenshot 2025-12-05 at 1 13 16 PM" src="https://github.com/user-attachments/assets/97d59a3d-0697-4c74-a833-9cc6da2089ee" />

### 3\. Updating the Stack

To modify a commit in the middle of the stack, use interactive rebase:

```bash
git rebase -i main
# (Edit, squash, or reword commits)
```

Then push again:

```bash
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:
```bash
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:

<img width="1440" height="374" alt="image" src="https://github.com/user-attachments/assets/4a393bca-e839-4d1f-9092-fc8d69e2edd6" />

&nbsp;

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

<img width="915" height="317" alt="Screenshot 2025-12-02 at 6 46 15 PM" src="https://github.com/user-attachments/assets/6ee80641-af67-4b37-9f57-797207637bbe" />

#### 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.