Skip to content

Adopt existing remote stacks on submit#153

Open
skarim wants to merge 1 commit into
skarim/tui-colorsfrom
skarim/submit-existing-stack
Open

Adopt existing remote stacks on submit#153
skarim wants to merge 1 commit into
skarim/tui-colorsfrom
skarim/submit-existing-stack

Conversation

@skarim

@skarim skarim commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

When a stack exists on GitHub but isn't recorded in the local tracking file (.git/gh-stack, i.e. s.ID == "") — for example a stack created from the web UI or in another clone — gh stack submit blindly called the create-stack API. The server rejected it because the PRs were already stacked, surfacing a confusing, dead-end error:

⚠ Could not create stack: Pull requests are already part of a stack

syncStack() only ever consulted the local s.ID, so when it was empty it always tried to create a stack. The lone fallback (handleCreate422) parsed the error string and at best printed "up to date" — it never imported the remote stack ID, so the mismatch was never resolved and recurred on every submit.

Behavior

When s.ID is empty and there are at least two PRs, submit now lists the repository's stacks and reconciles before creating, mirroring the patterns already used by checkout and link:

  • No remote match — create a new stack (unchanged).
  • Remote PRs are all tracked locally (an exact match, or e.g. two of three already stacked with a third added on top) — adopt the remote stack ID into local tracking and update it with the full ordered PR list, appending any new PRs to the top.
  • Remote stack has PRs we don't track locally — refuse and warn instead of silently dropping them, pointing the user to gh stack checkout <pr> to import the full stack first.
  • PRs span multiple remote stacks — an unresolvable divergence (a PR can belong to only one stack), so warn and skip.

Adoption is best-effort: if listing stacks fails, submit falls back to the previous create path and never fails the submit (the PRs are already pushed and created). The 422 fallback is also broadened to recognize the "already part of a stack" phrasing in addition to "already stacked", so even that path stays actionable.

Changes

cmd/submit.go:

  • syncStack: when no stack is tracked locally (s.ID == ""), try to adopt an existing remote stack before falling back to createNewStack.
  • adoptRemoteStack (new): lists stacks and reconciles via the existing findMatchingStack helper — adopting and updating, or refusing/skipping on divergence. The adopted ID is persisted by the existing stack.Save in runSubmit.
  • prsMissingFrom (new): returns the remote PRs not tracked locally, used to detect the refuse case.
  • isAlreadyStackedError (new): broadens handleCreate422 to also match the "already part of a stack" rejection so the fallback message stays actionable.

cmd/submit_test.go:

  • Six new tests: exact-match adoption (ID imported, no create/update call), additive two-of-three adoption, remote-superset refusal, PRs spanning multiple stacks, the ListStacks-error fallthrough, and the broadened fallback phrasing.

Stack created with GitHub Stacks CLIGive Feedback 💬

When a stack existed on GitHub but was not recorded in the local tracking
file (`.git/gh-stack`, i.e. `s.ID == ""`) -- for example a stack created
from the web UI or in another clone -- `gh stack submit` blindly called the
create-stack API. The server rejected the request because the PRs were
already stacked, surfacing a confusing, dead-end error:

    Could not create stack: Pull requests are already part of a stack.

`syncStack()` only ever consulted the local `s.ID`. When it was empty it
always POSTed to create, and the only handling for the rejection
(`handleCreate422`) parsed the error string and at best printed "up to
date" -- it never imported the remote stack ID, so the local/remote
mismatch was never resolved and recurred on every submit.

Detect and adopt the existing stack before creating one. When `s.ID` is
empty and there are at least two PRs, `syncStack` now lists the
repository's stacks and reconciles, mirroring the patterns already used by
`checkout` and `link`:

- No remote stack contains our PRs -> create a new one (unchanged).
- The remote stack's PRs are all tracked locally (an exact match, or e.g.
  two of three already stacked with a third added on top) -> adopt the
  remote stack ID into local tracking and PUT the full, ordered PR list,
  appending any new PRs to the top. The adopted ID is persisted by the
  existing stack.Save in runSubmit.
- The remote stack contains PRs we are not tracking locally -> refuse and
  warn rather than silently dropping them, pointing the user to
  `gh stack checkout <pr>` to import the full stack first.
- Our PRs are spread across multiple remote stacks (an unresolvable
  divergence, since a PR can belong to only one stack) -> warn and skip.

The new logic reuses `findMatchingStack`, `formatPRList`, and `slicesEqual`
from the link command; `syncStack`'s signature is unchanged, and adoption
is best-effort: listing failures degrade to the previous create path and
never fail the submit, since the PRs are already pushed and created.

Also broaden the create-stack 422 fallback (`handleCreate422`) to recognize
the server's "already part of a stack" phrasing in addition to "already
stacked", so that if the stack listing is unavailable the message is still
actionable instead of the raw "Could not create stack" error above.

Tests: six new cases in cmd/submit_test.go cover exact-match adoption (ID
imported, no create/update call), additive two-of-three adoption (update
with the full list), remote-superset refusal, PRs spanning multiple stacks,
the ListStacks-error fallthrough to create, and the broadened fallback
phrasing. Existing syncStack tests are unchanged.
GitHub Advanced Security started work on behalf of skarim June 29, 2026 13:38 View session
GitHub Advanced Security finished work on behalf of skarim June 29, 2026 13:38
@skarim skarim marked this pull request as ready for review June 29, 2026 13:48
Copilot AI review requested due to automatic review settings June 29, 2026 13:48

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 fixes a confusing dead-end error in gh stack submit that occurred when a stack already existed on GitHub (created via the web UI or another clone) but wasn't recorded in the local tracking file (.git/gh-stack, i.e. s.ID == ""). Previously, syncStack() blindly called the create-stack API, which the server rejected with "Pull requests are already part of a stack", and the error never resolved itself on subsequent submits. The change introduces a reconciliation step that adopts the existing remote stack ID before creating, mirroring the patterns already used by checkout and link (via the shared findMatchingStack helper).

Changes:

  • syncStack now attempts adoptRemoteStack when no local stack ID exists, before falling back to createNewStack.
  • New adoptRemoteStack lists remote stacks and reconciles: adopt+update on match, refuse+warn when remote holds untracked PRs or PRs span multiple stacks, and fall through to create otherwise (best-effort, never fails the submit).
  • New prsMissingFrom helper plus a broadened isAlreadyStackedError so the 422 fallback also recognizes the "already part of a stack" phrasing, and the fallback message now points to gh stack checkout.
Show a summary per file
File Description
cmd/submit.go Adds remote-stack adoption logic to syncStack via new adoptRemoteStack/prsMissingFrom helpers, broadens 422 phrasing matching with isAlreadyStackedError, and updates the fallback guidance message.
cmd/submit_test.go Adds six tests covering exact-match adoption, additive adoption, remote-superset refusal, multi-stack divergence, ListStacks error fallthrough, and the broadened 422 fallback phrasing.

I traced all helper functions (findMatchingStack, slicesEqual, formatPRList, plural), the RemoteStack/ListStacks types, the config output methods, and confirmed adoption persists because FindAllStacksForBranch returns *Stack pointers that stack.Save writes back. The logic correctly handles the no-match, exact-match, additive, refuse, and multi-stack-divergence cases, and pre-existing tests still pass because the mock's ListStacks returns (nil, nil) when unset. I found no correctness, naming, or test-coverage issues to flag.

Review details

  • Files reviewed: 2/2 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.

These improvements seem really helpful. Imagine we may need to incorporate some of this logic into the web stack editor too 👀

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