Fork unmerged branches into new stack#154
Conversation
…merged
Once every PR that is officially part of a stack on GitHub has been merged
-- especially after the merged branches are deleted upstream -- you can no
longer add to that stack. A new PR on top would target the trunk directly
instead of chaining onto the merged PRs, so the remote stack's "each PR's
base ref is the previous PR's head ref" invariant no longer holds. On the
next `gh stack submit`, the stack update was rejected and surfaced as a
confusing, dead-end warning:
Failed to update stack on GitHub: Pull requests must form a stack,
where each PR's base ref is the previous PR's head ref
`submit` had no handling for this: `syncStack` always sent the full PR list
(including the merged-and-deleted ones), so the API rejected the broken
chain even though the new PRs had already been created with correct bases.
Fork the survivors into a fresh stack instead of failing. After syncing PR
state and before pushing, `runSubmit` now calls `maybeForkFromMergedBase`:
- It triggers only when every PR officially part of the tracked remote
stack (`s.ID`) has merged. Membership is read from the stacks API, so
open PRs that are not part of the remote stack do not count, and -- this
is the key guard -- a normal partial, bottom-up merge (where the remote
stack still lists an open PR) is left completely untouched. A cheap
pre-check (the local stack must have at least one merged branch) avoids
an extra ListStacks call on the common path.
- The local branches are partitioned: those still in the merged remote
stack stay behind; everything else (new branches, plus open PRs that were
never part of that remote stack) is lifted into a brand-new stack rooted
at the original trunk, with an empty remote ID. The bottom survivor is
re-based onto the trunk.
- `runSubmit` continues with the new stack, so the push loop, PR creation,
and `syncStack` all operate on it; the empty ID routes `syncStack` through
the adopt/create path and a fresh stack is created on GitHub.
- The original, fully merged stack is left untouched on GitHub. Locally it
is kept as a record only if at least one of its branches still exists in
the working copy; otherwise it is dropped. No data is lost -- those PRs
are already merged on GitHub.
To restructure the stack file safely, add `StackFile.IndexOfStack`, which
locates a stack by pointer identity so the fork can capture what it needs
before `AddStack`/`RemoveStack` reallocate the underlying slice.
Also soften the partial-merge case that does not fork: when an `UpdateStack`
call fails with the "must form a stack" 422 and the stack still contains
merged branches, report it as an informational note (the unmerged PRs were
pushed and re-based onto the trunk) rather than a scary failure warning.
Scope is limited to `submit`. `add` and `checkout` keep their existing
"refuse and suggest `gh stack init`" behavior on fully merged stacks.
Tests:
- cmd/submit_test.go: TestSubmit_ForksWhenRemoteStackFullyMerged covers both
disposition variants (the old stack is removed when its merged branches
are gone locally, kept when they still exist) and asserts that only the
new branches are pushed, the fork message is printed, a fresh stack is
created, and the local stack file is split into two stacks.
TestSubmit_NoForkWhenRemoteStackHasOpenPR verifies the everyday bottom-up
merge is not forked and that the broken-chain 422 is reported calmly.
TestUpdateStack_BrokenChainAfterMerge checks the calm-vs-warn branch.
- internal/stack/stack_test.go: TestIndexOfStack covers identity lookup and
the not-found case.
Docs: README, the CLI reference, the stacked-PRs guide, the FAQ, and the
agent SKILL.md note that submitting onto a fully merged stack starts a new
stack rooted at the trunk.
Merged branches (and their PRs) are not selectable, so once an entire stack has landed there is nothing to act on -- yet the TUIs did not reflect that: - `gh stack view` still drew a highlighted cursor on the top branch even though it could not be selected. Navigation, checkout, and the per-branch toggles all silently did nothing, with no indication of why. - `gh stack modify` opened its full editor on a stack with nothing left to restructure, instead of short-circuiting like `gh stack submit` does when there is nothing to submit. Reflect the "nothing actionable" state in both TUIs. View (internal/tui/stackview/model.go): - Hide the cursor when every branch is merged. `New` now starts the cursor at -1 and only lands it on the current or first non-merged branch; when none exists the cursor stays hidden, so no row is rendered as focused. The existing `m.cursor >= 0` guards and merged-skipping `moveCursor` already make every cursor action a no-op in that state, and mouse-wheel scrolling still works for tall merged stacks. - Dim the shortcuts that depend on the cursor. `buildHeaderConfig` marks navigate, commits, files, open PR, and checkout as `Disabled` (rendered gray via the existing ShortcutEntry.Disabled styling) when all branches are merged, leaving only `q quit` active. Modify (cmd/modify.go): - Short-circuit before opening the TUI. After preconditions pass and PR state is synced, `runModify` now returns early when the stack is fully merged, printing "All branches in this stack have been merged" and pointing at `gh stack init`, exiting cleanly (exit 0) like submit's "nothing to submit" path. The linearity and merge-queue precondition checks already skip merged branches, so they do not fire spuriously. Tests: - internal/tui/stackview/model_test.go: the cursor is hidden (-1) when all branches are merged; up/down/enter do not move it or trigger a checkout; View renders without panicking on a hidden cursor; buildHeaderConfig disables every cursor-dependent shortcut (and only those) when all merged, and leaves them all enabled when active branches remain. - cmd/modify_test.go: runModify short-circuits on a fully merged stack, printing the message and returning no error without launching the TUI.
There was a problem hiding this comment.
Pull request overview
This PR addresses the dead-end that occurs when every PR officially part of a remote stack has merged and the user adds new branches on top. Previously gh stack submit surfaced a confusing 422 "must form a stack" warning. The change teaches submit to fork the surviving local branches into a brand-new stack rooted at the trunk (with no remote ID, so a fresh stack is created on GitHub), while leaving the fully merged stack untouched. It also softens the partial-merge 422 into an informational note and improves the view/modify TUIs for fully merged stacks.
Changes:
submit: detect a fully merged remote stack and fork the remaining branches into a new trunk-rooted stack (maybeForkFromMergedBase+remoteStackPRs/mergedPRNumbershelpers,IndexOfStackin the stack package); report the broken-chain 422 calmly when merged branches are present.- TUI:
viewhides the cursor and dims cursor-dependent shortcuts when every branch is merged;modifyshort-circuits before launching the TUI on a fully merged stack. - Docs: README, CLI reference, stacked-PRs guide, FAQ, and
SKILL.mddocument the fork-onto-new-stack behavior.
Show a summary per file
| File | Description |
|---|---|
cmd/submit.go |
Adds the fork-from-merged-base flow, wires it into runSubmit, and adds calm 422 handling in updateStack. |
cmd/submit_test.go |
Tests forking when fully merged (old stack kept/dropped), no-fork on partial merge, and the calm 422 path. |
internal/stack/stack.go |
Adds IndexOfStack to locate a stack by pointer identity before mutating the Stacks slice. |
internal/stack/stack_test.go |
Tests IndexOfStack including the not-in-file case. |
cmd/modify.go |
Short-circuits runModify on a fully merged stack with a friendly message. |
cmd/modify_test.go |
Tests the fully merged short-circuit without launching the TUI. |
internal/tui/stackview/model.go |
Starts the cursor hidden (-1) when all branches are merged and disables cursor-dependent shortcuts. |
internal/tui/stackview/model_test.go |
Tests hidden cursor, no-op navigation, panic-free render, and shortcut disabling. |
README.md, docs/**, skills/gh-stack/SKILL.md |
Document the fork-onto-new-stack behavior on a fully merged stack. |
Review details
- Files reviewed: 13/13 changed files
- Comments generated: 0
- Review effort level: Medium
ktravers
left a comment
There was a problem hiding this comment.
Appreciate the quick turnaround on this!
| cfg.Printf("Adding to a fully merged stack isn't supported — starting a new stack for your %d unmerged %s based on %s", | ||
| len(forkBranches), plural(len(forkBranches), "branch", "branches"), cfg.ColorCyan(trunk.Branch)) | ||
|
|
||
| // Decide the fate of the original (fully merged) stack: keep it as a record |
There was a problem hiding this comment.
Kind of enjoying the dramatic description of these operations 😄 Accurate and reads like an action thriller
| // expected once part of the stack has landed; the unmerged PRs | ||
| // were still pushed and re-based, so explain it calmly rather | ||
| // than alarming the user with a raw API error. | ||
| if strings.Contains(httpErr.Message, "must form a stack") && len(s.MergedBranches()) > 0 { |
There was a problem hiding this comment.
Instead of string matching, would be a bit more maintainable to match on error type. But that's just a nit thought. Would require a refactor that doesn't seem worth the effort rn
There was a problem hiding this comment.
Yup, the plan is to have better, more descriptive error types with the upcoming REST API for stacks. We should definitely update this when moving to the new API.
Once every PR that is officially part of a stack on GitHub has been merged — especially after the merged branches are deleted upstream — you can no longer add to that stack. A new PR on top targets the trunk directly instead of chaining onto the merged PRs, so the stack's "each PR's base ref is the previous PR's head ref" invariant no longer holds.
gh stack submitsurfaced this as a confusing, dead-end warning:The interactive
viewandmodifyTUIs had related rough edges on a fully merged stack:viewstill drew a cursor on a branch that couldn't be selected, andmodifyopened its editor on a stack with nothing left to restructure.This PR addresses all three commands across two commits.
Fork onto a new stack when the base stack is fully merged (
submit)When
submitdetects that every PR officially part of the tracked remote stack has merged, it forks the remaining local branches — new branches, or open PRs that were never part of that remote stack — into a brand-new stack rooted at the trunk, with no remote ID, so a fresh stack is created on GitHub. The original, fully merged stack is left untouched on GitHub.UpdateStackcall fails with the "must form a stack" 422 and the stack still has merged branches, it's reported as an informational note (the unmerged PRs were still pushed and re-based onto the trunk) rather than a scary failure warning.cmd/submit.go:maybeForkFromMergedBase(+remoteStackPRs/mergedPRNumbershelpers): detects the fully-merged remote stack, lifts the surviving branches into a new stack rooted at the trunk, disposes of the old stack per the rule above, and saves. Wired intorunSubmitafter PR-state sync and before pushing, reassigning the active stack so the push loop, PR creation, and stack sync all operate on the forked stack.updateStack: a "must form a stack" 422 with merged branches present is now an informational message rather than a warning.internal/stack/stack.go:IndexOfStack: locates a stack by pointer identity so the fork can capture what it needs beforeAddStack/RemoveStackreallocate theStacksslice.Scope is limited to
submit.addandcheckoutkeep their existing "refuse and suggestgh stack init" behavior on fully merged stacks.Reflect the "nothing actionable" state in the view and modify TUIs
Merged branches (and their PRs) aren't selectable, so once a stack has fully landed there's nothing to act on — the TUIs now say so.
gh stack view: the cursor is hidden when every branch is merged, so no row is drawn as selectable, and the cursor-dependent shortcuts (navigate, commits, files, open PR, checkout) are dimmed — onlyq quitstays active. Mouse-wheel scrolling still works for tall merged stacks.gh stack modify: short-circuits before opening the TUI on a fully merged stack, printing "All branches in this stack have been merged — start a new stack withgh stack init" and exiting cleanly, mirroring submit's "nothing to submit" path.internal/tui/stackview/model.go:Newstarts the cursor hidden (-1) and only lands it on the current or first non-merged branch, so it stays hidden when every branch is merged.buildHeaderConfigmarks the cursor-dependent shortcutsDisabledin that state.cmd/modify.go:runModifyreturns early whens.IsFullyMerged(), after preconditions pass, before launching the TUI.Docs
The README, the CLI reference, the stacked-PRs guide, the FAQ, and the agent
SKILL.mdnote that submitting onto a fully merged stack starts a new stack rooted at the trunk.Testing
cmd/submit_test.go: forking a fully merged remote stack (old stack removed when its branches are gone locally, kept when they still exist; only the new branches are pushed; a fresh stack is created); a normal partially merged stack is not forked; the broken-chain 422 is reported calmly.internal/stack/stack_test.go:IndexOfStack.internal/tui/stackview/model_test.go: cursor hidden when all merged; up/down/enter are no-ops;View()renders without panicking; shortcuts disabled-vs-enabled.cmd/modify_test.go:runModifyshort-circuits on a fully merged stack without launching the TUI.Stack created with GitHub Stacks CLI • Give Feedback 💬