jjstack skill
Fast path landing of a 32-PR stack: what went wrong
Notes from one real landing, written so the skill can be fixed. Every problem below was hit, not hypothesized; each says what the skill said, what happened, and what would have avoided it.
Setup
- Repository:
scode/devbox-mcp, 32 stacked draft PRs, #1 (based onmain) through #32, one bookmark per PR, built and maintained with jjstack. - Request: "jjstack land the stack bottoms up using the fast path flow dont wait on CI". Later, after the fallback: "still bypass waiting on ci, just get them landed".
- The repository has
delete_branch_on_mergeon. - Nearly every PR in the stack appends to one shared work log (
lore/YYYY-MM-DD-log.md), and many edit the same spec files, because the project's rules ask for that.
Outcome
- Fast path landed #1 and #2 in about a minute, then #3 came back
CONFLICTINGand the loop took the exit-42 switch to normal mode. - Normal mode (adapted to skip CI, see problem 3) landed #3, stopped once at #4 on a false head mismatch (problem 4), and after a fix landed #4 through #32, about 25 seconds per PR, roughly 12 minutes for 29 PRs.
- End state verified: all 32 PRs
MERGED, exactly 32 squash commits onmain, andmain's tree hash identical to the tree of the original top-of-stack commit, so the rebases lost nothing. No PR was merged wrongly at any point.
1. Why the fast path fell back at PR #3
#2 created .github/workflows/ci.yml (52 lines). #3 added 7 lines to that file and changed one line
of Cargo.toml, which #1 created. Reproduced locally with git merge-tree --write-tree
between main after #2's squash and #3's head:
merge base of main (after #2's squash) and #3's head: 960ad89 (main's tip before the whole stack)
commits between that base and #3's head: #1's original commit, #2's original commit, #3's commit
CONFLICT (add/add): Merge conflict in .github/workflows/ci.yml
CONFLICT (add/add): Merge conflict in Cargo.toml
is #2's squash commit an ancestor of #3's head? no
Here's the TLDR of the mechanics. A squash merge puts a new commit on main whose tree equals the PR's,
but it is not an ancestor of the next PR's head. Retargeting the next PR to main changes nothing about
history, so for the entire fast-path landing the merge base stays at the pre-stack tip of main.
Relative to that base, every file the stack created exists on both sides: on main as squashed by the
earlier PRs, and in the next PR's head with that PR's edits on top. Git sees two independent additions of the same
file with different content (add/add) and cannot tell that one is a prefix of the other. #2 was clean only because
the files #1 added were byte-identical on both sides and ci.yml existed on #2's side alone.
The general rule: in the fast path, PR k conflicts whenever it edits a file that an earlier PR in the stack created, or touches hunks an earlier PR changed. The skill's text says the fast path "can conflict when the child edits a hunk introduced or changed by the parent", which is true but understates it: the base never advances, so it is not just the parent, it is every earlier PR, and file creation counts. For this stack, where almost every PR appends to the same log file, the fallback was certain from the third PR on.
Suggested fix: predict before choosing. A local simulation is cheap and exact: for each PR in
order, make a commit with the previous PR's head tree on top of the base (git commit-tree
<prev-head>^{tree} -p <simulated main>, which is what the squash produces), then run
git merge-tree --write-tree of that against the next head. If any step conflicts, say so in the plan
announcement ("the fast path will switch to normal mode at PR #N") or go straight to normal mode. Here it would
have predicted the switch at #3 before anything merged.
2. Retarget race at #2 (handled)
The base PATCH for #2 returned 422 A pull request already exists for base branch 'main' and head branch
'pr/ci', because GitHub had already moved #2 to main itself after deleting #1's branch. The
skill documents this and its tolerance (re-read baseRefName) worked. Listed only to confirm it happens
in practice on the very first child.
3. Normal mode has no "don't wait on CI" variant
After the switch, the skill's normal flow says to mark the lowest PR ready, wait for its checks, and merge only
when mergeable=MERGEABLE and mergeStateStatus=CLEAN. The user had asked twice not to wait
on CI. The skill gives no sanctioned way to honor that in normal mode, so the landing was adapted ad hoc: merge as
soon as mergeable=MERGEABLE, ignoring check state.
Suggested fix: an explicit "no CI wait" option for normal mode (or for the fallback from the fast
path, since the user who asked for the fast path already said they do not want the CI cycle), stating which guard
replaces CLEAN.
4. GitHub's PR head lags the skill's own push
In the first normal-mode run, the cycle for #4 rebased, pushed, and then read the PR. gh pr view --json
headRefOid still showed the previous head (4fa48fa) while the remote branch and the local
bookmark were already 7a7e9f9. The head guard stopped the run, which was correct. A second later
GitHub showed 7a7e9f9. The worse half: mergeable read in that window describes the old
head, so a loop that checks mergeability before the head could act on stale state.
The skill's guards compare against a planned or local SHA, but nowhere wait for GitHub's view to converge after
the skill's own push. It does say "no checks means pending" for CI, but not that headRefOid and
mergeable lag too.
Suggested fix: after every push of a bookmark that is about to be merged, poll
headRefOid until it equals the pushed SHA (seconds, cap around a minute), and only then read
mergeable and run the merge guard.
5. Re-pushing landed bookmarks
The adapted loop rebased the remaining stack and pushed "every bookmark the rebase moved" from one fixed list of
the stack's bookmarks. That list still contained landed PRs. With delete_branch_on_merge on, pushing a
landed bookmark could recreate a branch GitHub just deleted. It did not happen here only because jj's fetch at the
top of each cycle had already dropped the local bookmarks GitHub deleted. Restricted the push to the current PR and
those above it.
Suggested fix: say explicitly that the push set is the pending suffix only, never a landed bookmark, and that on auto-delete repositories a landed branch must not be pushed again.
6. One branch was not auto-deleted
With auto-delete on, every landed branch disappeared except pr/ci (#2's). It was still the base of
open PR #3 when #2 merged, so GitHub kept it. The final cleanup deleted it after its guards passed. The skill's
cleanup handles this; noted because it is the expected exception on auto-delete repositories with a fast-path
fallback.
7. Normal mode is quadratic in pushes
Each normal-mode cycle fetches, rebases the whole remaining suffix, and pushes every remaining bookmark, as the skill asks. For 30 PRs that is about 30 + 29 + ... + 1, roughly 465 branch updates, and GitHub recomputes every open PR in the stack each time. It worked, at about 25 seconds per PR, but for long stacks it is slow and noisy.
Suggested fix: when nobody is reviewing the stack mid-landing (no CI wait, user asked to just land it), push only the bookmark that is about to merge. The others get pushed in their own cycle anyway, and their GitHub diffs being briefly stale costs nothing while the landing runs. Keep pushing everything when PRs stay open for review between merges.
8. Environment friction (not the skill's fault)
The agent ran under a rule that forbids $ expansions and globs in composed shell commands, so each
skill snippet ran as a script file, and the plan's values passed between steps through a file rather than being
re-assigned as literals as the skill describes. That worked, but a skill that shipped its snippets as script files
with arguments would need no adaptation.