012 T9: `dev.py rebase`, in the shape T8's verdict names nf-758.9 ← Beads

in_progress priority 2 task unassigned

From 012-jj-and-the-merge-pipeline.md's task table (design 012). - Real dependency: T8 - Note: Aedan's reversal: confusing for hand-stacked PRs today, may get way easier with gh stacks

Dependencies

Comments

jcantsp-bot · 2026-08-20T08:33:47Z

Holding this one, not assigning — @aedanpope wants to discuss first, since T8's verdict effectively makes this a design amendment rather than a straight build against the T9 card as written.

What changed: the original T9 card (design_docs/012-jj-and-the-merge-pipeline.md §8/§4) was written with the shape genuinely open — "wrapper over gh stack rebase" if the T8 trial adopted native stacks, or "the full 006a-T2 refusal-guarded restack" if it reverted. T8's trial (nf-758.8, GitHub #1117, closed) landed a clear verdict: extend, don't adopt fleet-wide yet — merge_pr.py cannot merge any native-stack PR member today (two hard refusals, detailed in #1117), so stacks and this repo's merge safety model don't currently coexist. The trial's own recommendation for T9: build the full 006a-T2 restack, not the wrapper — that shape was contingent on stacks being adoptable end-to-end including merge, which they're not (yet).

Why this isn't just "follow the verdict and go": the verdict also surfaced a real follow-up need — a human-reviewed merge_pr.py extension onto GitHub's async stack-merge REST path (filed unclaimed at GitHub #1189) — which could eventually flip T9's shape again if it lands. Given that, and that T9 touches the PR-stack ritual devs rely on daily, this reads as a decision worth Aedan's steer before a dev builds it, not a mechanical pickup.

File overlap note for whoever picks this up next: T9 shares notes/merging.md with T6 (nf-758.6, GitHub #1115) — hold until T6 lands to avoid a collision.

Note: this was originally posted as a comment on GitHub issue #1118, which a reconciliation process then closed (stateReason COMPLETED) and redirected to this bd tracking page — mirroring here since bd is the live source of truth and the GitHub comment thread is no longer where this gets read. T9's actual work is NOT complete; only its GitHub mirror issue was closed.

@aedanpope — flagging per your ask; let me know if you want this routed through /spec for a dev to scope, or if you want to just settle the shape here and I'll assign it as-is.

jcantsp-bot · 2026-08-20T15:04:30Z

My preference is we find a way to use github stacked PRs, as a prereq to building a proper dev.py rebase command.

So I would like us to dig into why stacked PRs are incompatible with our merge ritual, I find this a bit tricky to believe it's not possible to come up with some decent options to get us the best of both worlds.

jcantsp-bot · 2026-08-20T15:04:32Z

My preference is we find a way to use github stacked PRs, as a prereq to building a proper dev.py rebase command.

So I would like us to dig into why stacked PRs are incompatible with our merge ritual, I find this a bit tricky to believe it's not possible to come up with some decent options to get us the best of both worlds.

jcantsp-bot · 2026-08-20T15:04:34Z

My preference is we find a way to use github stacked PRs, as a prereq to building a proper dev.py rebase command.

So I would like us to dig into why stacked PRs are incompatible with our merge ritual, I find this a bit tricky to believe it's not possible to come up with some decent options to get us the best of both worlds.

root · 2026-08-20T15:14:21Z

Spec round ran on Aedan's comment above (routed via /spec). Outcome:

The prescriptive card went to nf-oqmx — the already-filed, unclaimed discovery covering exactly this ("merge_pr.py cannot merge any native-stack PR member"), now retitled imperative and carrying the full spec: https://nfold.dev/issue/nf-oqmx

**Why stacks and the merge ritual conflict today — the dig Aedan asked for, short version:** it is not fundamental. (1) merge_pr.py's unconditional child-retarget 422s on stack members because GitHub's cascade owns retargeting; (2) Gh.merge uses the synchronous merge endpoint, which GitHub refuses for stack members — the async stack-merge REST API (POST …/pulls/{n}/merge-async + poll) is the sanctioned path, and it is what gh stack merge itself calls; (3) checks/approvals vanish on the cascade only because the cascade mints new shas — the sha-staleness class design 012 R1's contribution-id carry (T3/T4, landed) already re-certifies for free. All three close with one bounded merge_pr.py extension, specced on nf-oqmx.

Resequencing, per Aedan's ask ("as a prereq"): this issue (T9, dev.py rebase) now depends on nf-oqmx in bd. Once nf-oqmx lands and a second real stack runs clean, T9's shape gets re-read — likely the thin gh stack rebase wrapper plus the single-branch replay core (design 012 §8 T9 shape (a)/(c)), rather than the full 006a-T2 restack the T8 verdict provisionally named while stacks were unmergeable.

Both issues remain unclaimed; /triage activates when ready.

jcantsp-bot · 2026-08-21T07:35:24Z

Live rehearsal complete — the missing verification this task's shape decision was waiting on. Full detail on bd nf-oqmx (two scratch stacks, round 2 clean): merge_stack() lands a real GitHub-native stack end-to-end through every gate (ledger, presubmit-at-head, authority, UI probe gate), and design 012 R1's T3/T4 carry restamps for free on a genuine cascade-minted head — confirmed live, not just in unit-test fakes:

#1217: delta re-verified (10ef6e77... -> c723d03, content identical) — no review spawned ✓ pure rebase — the existing verdict was restamped for the new head, no re-review needed

Per the spec round's resequencing: T9's shape is now settled as (a)+(c) — single-branch replay is the common core (fetch origin, identify the branch's own commits, replay onto fresh base, refuse loudly on real conflicts, delegate to push with a lease), wrapped by a thin dispatch to gh stack rebase when the branch is part of a GitHub-native stack. NOT the full 006a-T2 refusal-guarded restack T8's verdict provisionally named — that shape was contingent on stacks being unmergeable, which nf-oqmx just closed. Building this now in tools/nflib/devcli.py.

jcantsp-bot · 2026-08-21T08:12:08Z

dev.py rebase is up: https://github.com/cfsh/ninefold/pull/1220 — shape (a)+(c) per the live rehearsal (bd nf-oqmx), requesting aedanpope's review (GOVERNANCE-perimeter, tools/nflib/*, cannot self-merge on approval:auto).

jcantsp-bot · 2026-08-21T10:50:37Z

Live rehearsal of dev.py rebase itself — the "Done when" criterion (real branch, real rebase, real conflict aborted and retried), which the reviewer on PR #1220 correctly flagged as missing (the merge_stack() rehearsal quoted earlier is a rehearsal of a DIFFERENT thing).

Setup, two independent branches off the same origin/main tip:

1. First attempt — real conflict, stops loudly:

$ ./dev.py rebase dev.py rebase: replaying trial/rebase-live-conflict onto origin/main (no open PR — assuming this was cut from main). $ git rebase origin/main $ git status --porcelain dev.py rebase: stopped on a real conflict — games/hello_world/probes/smoke-hello-world.json resolve it, git add the fixed files, then: ./dev.py rebase --continue or give up and restore the branch exactly as it was: ./dev.py rebase --abort (exit 3)

2. --abort — clean restore:

$ ./dev.py rebase --abort $ git rebase --abort dev.py rebase: aborted — the branch is back where it was.

git status afterward: clean, git log back to the single original commit — no residue.

3. Reproduced identically — ran ./dev.py rebase again from the restored state; same conflict, same file, same instructions, byte-for-byte.

4. Resolved for real and continued:

$ ./dev.py rebase --continue $ git rebase --continue dev.py rebase: conflict resolved — continuing on trial/rebase-live-conflict. $ ./presubmit.py --all ... + dotnet pass 13.8s + python pass 116.0s + shellcheck pass 0.2s + layout pass 3.6s PASS $ git push --force-with-lease $ ./tools/pr/rebuild_f5.py ... + f5/integration = main ...

Gated, pushed with a lease, f5 rebuilt — the full delegation to push's own tail, exercised for real, not through fakes.

One honest note: because I resolved the conflict by taking main's wording outright (the scratch edit was throwaway by design), the replayed commit ended up with an EMPTY diff against its new parent — git rebase --continue correctly dropped it rather than creating a no-op commit, so the branch post-rebase is byte-identical to origin/main. That's git rebase's own standard behaviour, not a dev.py rebase bug, and it doesn't weaken what the mechanism proved: the conflict was real, the stop/abort/retry/continue/push/gate sequence all worked exactly as designed.

Scratch branches and their remote refs cleaned up afterward. PR #1220 (the actual dev.py rebase code) has this quoted in reply to the reviewer's GAPS verdict, alongside a notes/graphite.md fix for the same review's other finding.

Add a comment