Stacked Changes with Jujutsu: The Local Graph GitHub Misses
In this piece
A feature request says, “Add request IDs to every HTTP response.”
An hour later, the work has three parts. The first adds a RequestID type. The second puts that type in middleware. The third sends it to the logger.
@ Log request IDs
○ Add request-ID middleware
○ Introduce RequestID
◆ mainThe stack stays local until a layer needs review.
The first article in this series introduced GitHub’s stacked-pull-request preview. The GitHub-native walkthrough used the same request-ID chain. In that locally tracked stack, gh stack sync rebased the remaining branches after a partial merge; authors still need to resolve any conflicts and inspect the rewritten diffs.
That workflow still puts the unfinished structure of your work in the remote branch graph.
Git users can create and push branches before review. Jujutsu lets you build a local graph of changes, then attach Git branches when a layer needs to leave your machine.
The stack starts before review
A stack is an ordered dependency chain. The middleware needs the request-ID type. The logger needs the middleware. A reviewer can understand each layer on its own, and the bottom layer can merge before the rest.
A useful stack contains pieces of work with a real order.
A Git-first workflow encourages a branch as soon as you see that order:
request-id
└── request-id-middleware
└── request-id-loggingEach branch gains a remote counterpart, a pull request, checks, review state, and a place in someone else’s browser. Those artifacts serve a finished review unit. They cost time when you later split, squash, or abandon the thought behind the branch.
Jujutsu separates the work from the publication decision.
Create the changes on your machine:
jj new main -m "Introduce RequestID"
# add the type and its tests
jj new -m "Add request-ID middleware"
# add middleware and its tests
jj new -m "Log request IDs"
# add logging and its testsjj new creates a new change on top of the current one. Jujutsu treats the working copy as a commit and captures your edits without a staging step. jj log shows the local dependency chain.
The local graph records dependencies.
You make a separate decision about review readiness.
GitHub sees pushed branches
Jujutsu uses a Git backend. When you push a branch, GitHub sees the commits reachable from that branch as it sees commits pushed with the Git CLI.
GitHub has no representation for the rest of Jujutsu’s local graph.
Unpushed changes have no ref on the remote. GitHub organizes pull requests around Git branches, not Jujutsu change IDs, and cannot tell whether five local changes form one feature, one experiment, or three review layers.
GitHub stores shared review state. Keep work that needs a shape in the local graph.
Jujutsu keeps the full change graph on your machine. Bookmarks publish the layers that are ready for GitHub review.
The logging change remains part of the local graph until it has a review claim of its own.
Jujutsu calls its named pointers bookmarks. A bookmark maps to a Git branch when you push it. Create one when a change is ready to become a shared review unit:
jj bookmark create request-id -r <change-id>
jj git push --bookmark request-id<change-id> is the Jujutsu identifier shown by jj log. It normally survives amendments and rebases, while the bookmark follows the current revision. Open a pull request from request-id to main; keep the middleware and logging changes local while you finish them.
Treat a branch as a publishing choice.
Publish review-ready layers
Publish the middleware layer as its own branch:
jj bookmark create request-id-middleware -r <middleware-change-id>
jj git push --bookmark request-id-middlewareOpen its pull request with request-id as the base. When logging is ready, create and push request-id-logging, then open its pull request with request-id-middleware as the base.
Dependent base branches alone do not create GitHub’s native stack metadata. Link the published branches from bottom to top:
gh stack link request-id request-id-middleware request-id-loggingGitHub can now display and operate on the pull requests as one stack:
GitHub PR 3: Log request IDs
↓
GitHub PR 2: Add request-ID middleware
↓
GitHub PR 1: Introduce RequestID
↓
mainJujutsu shapes the local changes; bookmarks publish Git branches; gh stack link creates the shared stack relationship that GitHub manages.
What the bridge looks like in a real repository
I ran this boundary in a small public Go repository using Jujutsu 0.43.0, GitHub CLI 2.97.0, and gh stack 0.1.0. The repository and pull requests are public. The point was not to build a useful request-ID package. It was to keep the same small dependency chain visible while the tools changed.
The foundation already existed on main. I created a local middleware change, then a local logging change:
@ Log request IDs
○ Add request-ID middleware
◆ main Introduce RequestIDNeither new change had a remote branch. GitHub could not see either one. jj log could.
Then I edited the middleware change. I added RequestIDFromContext, which the logging layer could use. Jujutsu reported:
Rebased 1 descendant commits onto updated working copyThe middleware change ID remained nnnrquvn; its Git commit changed from ccd590b0 to cb754880. The logging change ID remained zvumnspr; its Git commit changed from 80e33203 to d886c486. That is the useful observation. A change ID does not prevent a rewrite. It gives the rewrite a stable name.
The logging change had no GitHub branch, pull request, check run, reviewer, or notification. Its parent relationship to middleware remained visible in jj log.
When the two changes had separate claims, I exported them deliberately:
jj bookmark create jj-local-middleware -r nnnrquvn
jj bookmark create jj-local-logging -r zvumnspr
jj git push --bookmark jj-local-middleware --bookmark jj-local-logging
gh stack link jj-local-middleware jj-local-logging --openjj bookmark create named two revisions. jj git push made ordinary Git refs. gh stack link then created pull requests, set their bases, and created GitHub stack metadata. GitHub created a middleware pull request based on main and a logging pull request based on the middleware branch.
Each command owns a different boundary:
| Tool | Object it changes | What it does not create |
|---|---|---|
jj | Local changes and their parent relationship | GitHub branches or pull requests |
jj bookmark | A named pointer to one Jujutsu revision | A remote ref |
jj git push | A Git branch on the remote | A pull request or stack |
gh stack link | Pull requests, bases, and GitHub stack relationship | Jujutsu changes |
The table matters when debugging. If jj log looks right but GitHub shows no branch, the bookmark was not pushed. If the branch exists but the pull request compares against main, the GitHub review relationship is missing or wrong. If the pull requests have correct bases but GitHub does not treat them as a native stack, run gh stack link with the layers in bottom-to-top order.
Do not use branch names as proof that the graph is correct. A bookmark can be moved, and a Git branch can be rebased, while retaining a familiar name. Inspect the local relationship with jj log and the remote relationship with the pull request base. Those are two facts, maintained by two systems.
If a local rewrite creates a conflict in the logging change, Jujutsu marks it and preserves the graph. Resolve the conflict, inspect the diff, and move the bookmark only after the revised change still makes the same review claim.
After a partial merge, fetch the remote tips, rebase the unmerged local changes onto main@origin, and push the changed bookmarks. Inspect each open pull request with gh pr view <branch> --json baseRefName and correct a stale base with gh pr edit <branch> --base <base-branch>. gh stack link does not track the local stack for you.
You can use GitHub’s native stack tooling without making Jujutsu the team’s shared interface. Reviewers see branches, pull requests, checks, and merge state. Authors who need to reshape work locally gain a graph that can stay private until one part becomes reviewable.
Do not infer more automation than those commands provide. Jujutsu does not discover a reviewer, decide that a change deserves a separate pull request, assign a pull-request base by itself, or merge a GitHub stack. gh stack link can create the remote review relationship, but it does not turn all local changes into pull requests. You pass the branch names that you decided were ready.
Suppose the logging work turns out to need a different field name or no log field at all. Keep it as a local Jujutsu change, revise or abandon it, and leave the middleware pull request alone. The remote graph contains only decisions other people can make; the local graph retains work that still needs discovery.
Let unfinished work stay unfinished
Suppose the middleware layer begins with a helper named requestIDFromContext. A test can show that the handler also needs to reject malformed incoming IDs. You may move validation into the type, split a helper out of the middleware change, or put the generated ID in a different package.
Use jj diff to inspect a change against its parent and jj log to check the order after a split or rebase. Once the type’s API settles, bookmark the bottom change. Keep the middleware and logging changes local until each has a review question of its own. None of those private edits needs to notify a reviewer.
Rewrite the work while it is still cheap
Bottom-layer edits test this model.
Imagine a reviewer asks for RequestID to become a string alias instead of a struct. In a branch-first stack, changing the bottom layer means rewriting its commit and every descendant commit, updating branches, and checking each pull request against its intended base.
Jujutsu also rewrites the descendants, but their change IDs normally remain stable. It records any resulting conflicts in the affected commits so you can resolve them before pushing updated bookmarks. The graph preserves the dependency relationship; you still verify correctness.
Use these commands after your first cut proves wrong:
jj splitturns an overgrown change into smaller changes.jj squashmoves a fix into the change where it belongs.jj rebasemoves a change and its descendants onto a new base.jj undoreverses a Jujutsu operation when the experiment was a mistake.
A bookmark has a different job
Git branches and Jujutsu bookmarks are both named pointers. Give a bookmark a name when you need an audience for the change.
Use a bookmark as an export from the local graph. It marks the version you want to discuss, supplies the branch name GitHub needs, and gives the remote a ref for a pull request.
local · Jujutsu
Jujutsu · localRevise RequestIDpublish · Jujutsu
Jujutsu · publishSet request-id bookmarkreview · GitHub
GitHub · reviewOpen pull requestshared base · GitHub
GitHub · shared baseMerge RequestID
A Jujutsu change can evolve locally. A bookmark exports a revision to GitHub, where a pull request carries review and merge state.
Keep a draft layer local until its diff gives a reviewer a question to answer. Create the bookmark and push it once you can state that question.
Use jj abandon for a local experiment that no longer pays for itself. Close or merge a published layer with intent because it carries a pull request, comments, checks, and dependent work.
You can keep the local graph as detailed as the code needs and the remote branch graph as small as the team needs.
A local graph needs a publishing rule
Publish a reviewable layer before the whole feature is complete when review can catch a wrong abstraction, an API problem, or an ownership concern.
Use this rule for each local change:
Publish it when a reviewer can understand the diff, test its claim, and make a useful decision without reading the layers above it.
The rule rejects cosmetic and hidden-dependency stacks.
Three pull requests for a helper move, a rename, and a caller update offer no separate review decision.
Use one pull request when the request-ID type makes sense with the middleware that consumes it. Splitting that implementation creates three URLs without three review units.
The request-ID example works because each layer carries its own claim:
| Layer | Review question |
|---|---|
RequestID | Does this type represent and validate the value? |
| Middleware | Does the server create and attach the value at the right boundary? |
| Logging | Does the logger record the value without changing request behavior? |
The answers can arrive in order. The bottom layer can merge while the logging design remains open.
After a lower layer merges
Merging the bottom pull request changes the base beneath the remaining work. Fetch the remote state and inspect both the local and remote bookmarks:
jj git fetch --remote origin
jj logGitHub may already have rebased published branches. Jujutsu shows fetched remote tips as bookmarks such as main@origin. Identify the first unmerged change, then rebase it and its descendants onto the updated base:
jj rebase -s <first-unmerged-change-id> -o main@originInspect every rewritten diff and resolve conflicts where the merged foundation changed an assumption. Move bookmarks for the revisions you intend to publish, then push every published bookmark that moved. For example, if both remaining layers have open pull requests:
jj git push --bookmark request-id-middleware --bookmark request-id-loggingCheck the base of each open pull request against the new graph. If GitHub still compares the middleware pull request with the merged branch, change its base to main with gh pr edit request-id-middleware --base main. Do not use gh stack sync here: gh stack link did not establish local tracking, and re-linking is additive rather than a way to remove merged pull requests.
Treat the merge as a fresh review point. A small diff can still encode a changed claim when the foundation’s API moves. Keep, revise, or collapse the layer before publishing it again.
The limits remain ordinary Git limits
Remote stacks need one branch per published layer, and each pull request needs the correct base branch. CI can run once per layer. Reviewers lose context when the stack grows too deep.
Use Jujutsu for mutations in a colocated workspace. Its documentation recommends Git as an inspection tool because interleaving mutations can create confusing ref states.
Choose a normal pull request for one coherent review and merge unit. Choose independent pull requests for work with no dependency order. Use a local Jujutsu stack when the code has dependency order and the publishing schedule still belongs to you.
Try it on a small change
Pick a feature with one clear foundation and one consumer. Make the foundation change with Jujutsu. Create the consumer on top. Then change the foundation after both layers exist.
Run:
jj log
jj diff -r <change-id>Check two things. The consumer should descend from the foundation, and its change ID should remain the same after the rewrite. Publish the foundation as a bookmark and open the first pull request after the graph holds together.
Sources
Filed under
Explore this subject