Skip to content

Fork unmerged branches into new stack#154

Open
skarim wants to merge 2 commits into
skarim/submit-existing-stackfrom
skarim/submit-merged-stack
Open

Fork unmerged branches into new stack#154
skarim wants to merge 2 commits into
skarim/submit-existing-stackfrom
skarim/submit-merged-stack

Conversation

@skarim

@skarim skarim commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

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 submit surfaced this 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

The interactive view and modify TUIs had related rough edges on a fully merged stack: view still drew a cursor on a branch that couldn't be selected, and modify opened 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 submit detects 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.

  • Triggers only when every PR in the remote stack is merged. Membership is read from the stacks API, so open PRs that aren't part of the remote stack don't count — and, critically, a normal partial, bottom-up merge (where the remote stack still lists an open PR) is left completely alone, so everyday workflows are unaffected.
  • The original local stack is kept only if at least one of its branches still exists locally; otherwise it is dropped. No data is lost — those PRs are already merged on GitHub.
  • Softens the partial-merge case that does not fork: when an UpdateStack call 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 / mergedPRNumbers helpers): 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 into runSubmit after 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 before AddStack / RemoveStack reallocate the Stacks slice.

Scope is limited to submit. add and checkout keep their existing "refuse and suggest gh 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 — only q quit stays 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 with gh stack init" and exiting cleanly, mirroring submit's "nothing to submit" path.

internal/tui/stackview/model.go:

  • New starts 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. buildHeaderConfig marks the cursor-dependent shortcuts Disabled in that state.

cmd/modify.go:

  • runModify returns early when s.IsFullyMerged(), after preconditions pass, before launching the TUI.

Docs

The 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.

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: runModify short-circuits on a fully merged stack without launching the TUI.

Stack created with GitHub Stacks CLIGive Feedback 💬

skarim added 2 commits June 29, 2026 11:29
…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.
GitHub Advanced Security started work on behalf of skarim June 29, 2026 16:11 View session
GitHub Advanced Security finished work on behalf of skarim June 29, 2026 16:11
@skarim skarim marked this pull request as ready for review June 29, 2026 16:18
Copilot AI review requested due to automatic review settings June 29, 2026 16:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/mergedPRNumbers helpers, IndexOfStack in the stack package); report the broken-chain 422 calmly when merged branches are present.
  • TUI: view hides the cursor and dims cursor-dependent shortcuts when every branch is merged; modify short-circuits before launching the TUI on a fully merged stack.
  • Docs: README, CLI reference, stacked-PRs guide, FAQ, and SKILL.md document 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 ktravers left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Appreciate the quick turnaround on this!

Comment thread cmd/submit.go
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kind of enjoying the dramatic description of these operations 😄 Accurate and reads like an action thriller

Comment thread cmd/submit.go
// 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants