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

Outcome

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.