Files
github__gh-stack/cmd/submit.go
T
Sameen Karim e208dfc488 Create/update stack on remote during sync (#156)
* Create/update the remote stack on sync and fix false "Stack synced"

`gh stack sync` reported "Stack synced" even when it had not created or
updated the stack object on GitHub. After running `gh stack init` to
adopt existing branches and then opening PRs outside the CLI, `gh stack
sync` detected the open PRs and printed "Stack synced" — but no stack had
ever been created on the server.

There were two distinct bugs:

1. Sync never reconciled the remote stack object. `runSync` called
   `syncStackPRs`, which only *reads* PR state and links PRs to local
   branches; it never called the create/update path. So the branches were
   rebased and pushed and the PRs were detected, but the stack on GitHub
   was never created.

2. The final message was unconditional. `runSync` always printed "Stack
   synced", which is supposed to mean "the stack object on GitHub now
   reflects the local stack" — something that can only be true when two or
   more open PRs exist and the remote stack was actually created/updated.

Fix

Reconcile the remote stack from sync, and make the closing message reflect
what actually happened.

* cmd/sync.go
  - Add a reconciliation step (5b) after PR-state sync: when the stack has
    two or more open PRs, link them into a stack on GitHub via the new
    `syncRemoteStack` helper. It inspects existing stacks first and:
      - short-circuits quietly when a remote stack already lists exactly
        these PRs (records the ID, prints "Stack already up to date on
        GitHub") so routine syncs don't issue a redundant, misleading
        update;
      - otherwise delegates to `syncStack` to create a new stack, adopt an
        untracked one, or update a partially-formed one.
    Sync never opens PRs — that remains `gh stack submit`'s job.
  - Replace the unconditional "Stack synced" with a result-driven message:
    "Stack synced" when the remote stack object was created/updated/in
    sync, otherwise "Branches synced" (fewer than two PRs, stacked PRs
    unavailable, a cross-stack divergence, or no GitHub client).
  - Update the command's long description to document the stack-object
    step and the two possible closing messages.

* cmd/submit.go
  - Thread a `synced bool` return through the existing, tested stack
    helpers so sync can tell whether the remote stack object now matches
    local: `syncStack`, `createNewStack`, and `updateStack` now return
    `bool`; `adoptRemoteStack` returns `(handled, synced)`; and
    `handleCreate422` returns `bool` (true only when the PRs are already
    stacked together). Extract the shared `stackPRNumbers` helper.
  - This is additive: submit's single call site ignores the new return
    value, so submit's behavior, output, and tests are unchanged. Reusing
    these helpers (instead of duplicating the 404/422 handling in sync)
    keeps the create/adopt/update logic in one tested place.

Tests

* cmd/sync_test.go — six new cases covering the reconciliation matrix:
  - TestSync_CreatesRemoteStackWhenPRsExist: open PRs but no remote stack
    -> CreateStack is called and the new ID is persisted to the stack file;
    output contains "Stack created on GitHub" and "Stack synced".
  - TestSync_AdoptsExistingEqualRemoteStack: a matching remote stack ->
    no create/update, ID recorded, "Stack synced".
  - TestSync_UpdatesPartialRemoteStack: a subset stack -> UpdateStack with
    the full PR list, "Stack synced".
  - TestSync_FewerThanTwoPRs_BranchesSynced: one PR -> no stack API calls,
    "Branches synced", not "Stack synced".
  - TestSync_StacksUnavailable_BranchesSynced: 404 on create -> warns,
    "Branches synced".
  - TestSync_PRsSpanMultipleStacks_BranchesSynced: PRs across two stacks ->
    divergence warning, no create/update, "Branches synced".

Docs

Document the new stack-object step and the "Stack synced" vs "Branches
synced" distinction in:
  - README.md
  - docs/src/content/docs/reference/cli.md
  - skills/gh-stack/SKILL.md
  - docs/src/content/docs/introduction/overview.md
  - docs/src/content/docs/guides/stacked-prs.md
  - docs/src/content/docs/guides/workflows.md

* Address PR review: one ListStacks per sync, command-neutral guidance

Two follow-ups from the #156 review (both flagged optional / non-blocking).

1. Remove the redundant ListStacks round-trip on sync's create path.
   syncRemoteStack fetched the stack list for its already-up-to-date
   short-circuit, then delegated to syncStack -> adoptRemoteStack, which
   listed the stacks again — two GETs on the first-sync-create and
   membership-changed paths. Refactor adoptRemoteStack into a list-accepting
   reconcileUntrackedStack(cfg, client, s, prNumbers, stacks): syncStack now
   fetches the list once and passes it down, and syncRemoteStack reuses the
   list it already fetched. Net: exactly one ListStacks per sync. This also
   drops the (handled, synced) tuple. Submit's behavior is unchanged.

2. Make the divergence / dropped-PR guidance command-neutral. The shared
   helper emitted submit-specific wording ("reconcile them before
   submitting", "...then `gh stack submit`") that is now reachable from
   `gh stack sync`. Reword to "reconcile them first" and drop the trailing
   `gh stack submit` so it reads correctly from either command.

Tests: assert exactly one ListStacks on the create path and that the
divergence guidance is not submit-specific.

* increment skill file version

* Simplify sync reconciliation: reuse syncStack instead of a parallel path

Review feedback noted the change felt heavier than the fix warranted.
The weight came from `syncRemoteStack` (cmd/sync.go), a near-duplicate of
submit's `syncStack` — same <2-PR guard, ListStacks, and update/create
dispatch — that existed only to add an "already up to date" short-circuit.
That one optimization is what spawned the second entry point, the
pre-fetched-list threading, and the double-ListStacks it then required.

Collapse it to a single reconciliation path:

- Remove `syncRemoteStack`; `gh stack sync` now calls the shared
  `syncStack` directly. One path, one ListStacks per sync.
- Fold `createNewStack` into `reconcileUntrackedStack` (renamed from
  `adoptRemoteStack`) so it returns a single `synced bool` instead of a
  `(handled, synced)` tuple and owns its own ListStacks again.
- Inline `stackPRNumbers` back into `syncStack` (it was only extracted to
  share with the now-removed `syncRemoteStack`).
- Drop the now-unused `strconv`/`github` imports from cmd/sync.go.

Behavior note: a routine re-sync of an already-tracked stack now prints
"Stack updated on GitHub with N PRs" instead of "Stack already up to date
on GitHub". This is accurate (sync does PUT the current state) and matches
submit. The "Stack synced" / "Branches synced" summary is unchanged, and
submit's behavior is unchanged.
2026-06-29 20:11:10 -04:00

919 lines
32 KiB
Go

package cmd
import (
"errors"
"fmt"
"strconv"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/cli/go-gh/v2/pkg/api"
"github.com/cli/go-gh/v2/pkg/prompter"
"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/git"
"github.com/github/gh-stack/internal/github"
"github.com/github/gh-stack/internal/modify"
"github.com/github/gh-stack/internal/pr"
"github.com/github/gh-stack/internal/stack"
"github.com/github/gh-stack/internal/tui/stackview"
"github.com/github/gh-stack/internal/tui/submitview"
"github.com/spf13/cobra"
)
type submitOptions struct {
auto bool
open bool
remote string
}
func SubmitCmd(cfg *config.Config) *cobra.Command {
opts := &submitOptions{}
cmd := &cobra.Command{
Use: "submit",
Short: "Create a stack of PRs on GitHub",
Long: `Push all branches and create or update a stack of PRs on GitHub.
In an interactive terminal, a single-screen editor opens. Every branch without a
PR is included by default; deselect any you don't want with the checkbox or ^x,
and draft each PR's title, description, and draft state, then submit them all at
once with Ctrl+S. Pass --auto (or run in a non-interactive terminal) to skip the
editor and use auto-generated titles.
This command performs several steps:
1. Pushes all branches to the remote
2. Creates new PRs for the included branches
3. Updates base branches for existing PRs
4. Creates or updates the stack on GitHub
In the editor, new PRs default to ready for review; switch any to draft with the
"CREATE AS" toggle. With --auto, new PRs are created as drafts unless you pass
--open.`,
Example: ` # Push and create/update PRs (opens the interactive editor)
$ gh stack submit
# Skip the editor and use auto-generated PR titles
$ gh stack submit --auto
# Mark new and existing PRs as ready for review
$ gh stack submit --open`,
RunE: func(cmd *cobra.Command, args []string) error {
return runSubmit(cfg, opts)
},
}
cmd.Flags().BoolVar(&opts.auto, "auto", false, "Use auto-generated PR titles without prompting")
cmd.Flags().BoolVar(&opts.open, "open", false, "Mark new and existing PRs as ready for review")
cmd.Flags().StringVar(&opts.remote, "remote", "", "Remote to push to (defaults to auto-detected remote)")
return cmd
}
func runSubmit(cfg *config.Config, opts *submitOptions) error {
gitDir, err := git.GitDir()
if err != nil {
cfg.Errorf("not a git repository")
return ErrNotInStack
}
sf, err := stack.Load(gitDir)
if err != nil {
cfg.Errorf("failed to load stack state: %s", err)
return ErrNotInStack
}
currentBranch, err := git.CurrentBranch()
if err != nil {
cfg.Errorf("failed to get current branch: %s", err)
return ErrNotInStack
}
cfg.Printf("Checking stack state...")
// Find the stack for the current branch without switching branches.
// Submit should never change the user's checked-out branch.
stacks := sf.FindAllStacksForBranch(currentBranch)
if len(stacks) == 0 {
cfg.Errorf("current branch %q is not part of a stack", currentBranch)
return ErrNotInStack
}
if len(stacks) > 1 {
cfg.Errorf("branch %q belongs to multiple stacks; checkout a non-trunk branch first", currentBranch)
return ErrDisambiguate
}
s := stacks[0]
client, err := cfg.GitHubClient()
if err != nil {
cfg.Errorf("failed to create GitHub client: %s", err)
return ErrAPIFailure
}
// Pre-flight: abort early if the user is authenticating with a PAT.
if cfg.WarnIfPAT() {
return ErrStacksUnavailable
}
// Verify that the repository has stacked PRs enabled.
stacksAvailable := s.ID != ""
if !stacksAvailable {
if _, err := client.ListStacks(); err != nil {
warnStacksUnavailableOrPAT(cfg)
if cfg.IsInteractive() {
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
proceed, promptErr := p.Confirm("Would you still like to create regular PRs?", false)
if promptErr != nil {
if isInterruptError(promptErr) {
printInterrupt(cfg)
return ErrSilent
}
return ErrStacksUnavailable
}
if !proceed {
return ErrStacksUnavailable
}
} else {
return ErrStacksUnavailable
}
} else {
stacksAvailable = true
}
}
// Sync PR state to detect merged/queued PRs before pushing.
prDetails := syncStackPRs(cfg, s)
// If the active branches now sit on top of a fully-merged base, they can no
// longer extend the existing remote stack. Fork them into a fresh stack
// rooted at the trunk and continue the submit with that new stack.
if stacksAvailable {
s = maybeForkFromMergedBase(cfg, client, sf, s, gitDir)
}
// Resolve remote for pushing
remote, err := pickRemote(cfg, currentBranch, opts.remote)
if err != nil {
if !errors.Is(err, errInterrupt) {
cfg.Errorf("%s", err)
}
return ErrSilent
}
merged := s.MergedBranches()
if len(merged) > 0 {
cfg.Printf("Skipping %d merged %s", len(merged), plural(len(merged), "branch", "branches"))
}
queued := s.QueuedBranches()
if len(queued) > 0 {
cfg.Printf("Skipping %d queued %s", len(queued), plural(len(queued), "branch", "branches"))
}
activeBranches := activeBranchNames(s)
if len(activeBranches) == 0 {
cfg.Printf("All branches are merged or queued, nothing to submit")
return nil
}
// If a modification is pending, delete the old remote stack first so that
// PR base updates are allowed and force-pushes don't trigger auto-merges.
if stacksAvailable {
if err := handlePendingModify(cfg, client, s, gitDir); err != nil {
if errors.Is(err, errInterrupt) {
return ErrSilent
}
// DeleteStack or other failure — don't continue with stale state
return ErrSilent
}
}
// Best-effort fetch to update tracking refs (helps --force-with-lease
// in shallow clones). Silently ignored if branches don't exist on the
// remote yet.
_ = git.FetchBranches(remote, activeBranches)
// Look up the repository's PR template once before creating any PRs.
var templateContent string
if repoRoot, err := git.RootDir(); err == nil {
templateContent = pr.FindTemplate(repoRoot)
}
// In an interactive terminal, open the TUI so the user can pick which new
// branches become PRs and draft each PR's title, description, and draft
// state. The drafts feed the create path below. On the --auto /
// non-interactive path drafts stays nil and ensurePR/createPR fall back to
// auto-generated titles and bodies (today's behavior).
var drafts map[string]*submitview.PRDraft
if cfg.IsInteractive() && !opts.auto {
collected, cancelled, tuiErr := collectPRDrafts(cfg, client, s, currentBranch, prDetails, templateContent)
if tuiErr != nil {
cfg.Errorf("failed to run the submit editor: %s", tuiErr)
return ErrSilent
}
if cancelled {
cfg.Printf("Submit cancelled — no branches were pushed")
return nil
}
drafts = collected
}
// Push each branch and create/update its PR in stack order (bottom to top).
// Sequential pushing ensures each branch's base is up-to-date on the
// remote before the next branch is pushed, preventing race conditions.
cfg.Printf("Pushing to %s...", remote)
for i, b := range s.Branches {
if s.Branches[i].IsMerged() || s.Branches[i].IsQueued() {
continue
}
// Push this branch
if err := git.Push(remote, []string{b.Branch}, true, false); err != nil {
cfg.Errorf("failed to push %s: %s", b.Branch, err)
return ErrSilent
}
// Find or create PR, and fix base if needed
baseBranch := s.ActiveBaseBranch(b.Branch)
if err := ensurePR(cfg, client, s, i, baseBranch, opts, templateContent, drafts); err != nil {
if errors.Is(err, errInterrupt) {
printInterrupt(cfg)
return ErrSilent
}
// Non-fatal — continue with remaining branches
}
}
// Create or update the stack on GitHub
if stacksAvailable {
syncStack(cfg, client, s)
clearPendingModifyState(cfg, gitDir)
}
// Update base commit hashes and sync PR state
updateBaseSHAs(s)
_ = syncStackPRs(cfg, s)
if err := stack.Save(gitDir, sf); err != nil {
return handleSaveError(cfg, err)
}
cfg.Successf("Pushed and synced %d branches", len(s.ActiveBranches()))
return nil
}
// collectPRDrafts loads branch display data and runs the interactive submit TUI
// so the user can choose which new branches become PRs and draft each one. It
// returns the per-branch overrides, whether the user cancelled, and any error.
// When the stack contains no branches without a PR, it skips the TUI and
// returns nil drafts so the normal push/relink path runs.
func collectPRDrafts(cfg *config.Config, client github.ClientOps, s *stack.Stack, currentBranch string, prDetails map[string]*github.PRDetails, templateContent string) (map[string]*submitview.PRDraft, bool, error) {
// Fill in the real title/description for existing PRs that were synced
// without them (e.g. merged branches) so the read-only cards show API data.
enrichPRContent(client, prDetails)
fmt.Fprintf(cfg.Err, "Loading stack...")
viewNodes := stackview.LoadBranchNodes(cfg, s, currentBranch, prDetails)
fmt.Fprintf(cfg.Err, "\r\033[2K")
// Reverse so index 0 = top of stack (matches the visual order).
reversed := make([]stackview.BranchNode, len(viewNodes))
for i, n := range viewNodes {
reversed[len(viewNodes)-1-i] = n
}
nodes := submitview.NewSubmitNodes(reversed, templateContent)
// Nothing to create — skip the TUI and run the normal push/relink path.
if submitview.CountNew(nodes) == 0 {
return nil, false, nil
}
repoLabel := ""
if repo, err := cfg.Repo(); err == nil {
repoLabel = repo.Owner + "/" + repo.Name
}
model := submitview.New(submitview.Options{
Nodes: nodes,
Trunk: s.Trunk,
RepoLabel: repoLabel,
Version: Version,
})
// Use cell-motion mouse mode (clicks, drag, and wheel) rather than all-motion.
// All-motion (mode 1003) reports an event on every pointer move, flooding the
// input; under that volume bubbletea can split an SGR mouse sequence across
// reads, leaking its bytes as text into a focused title/description field
// while scrolling. We don't use idle-hover, so cell-motion loses nothing.
p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion())
final, err := p.Run()
if err != nil {
return nil, false, fmt.Errorf("running submit TUI: %w", err)
}
m, ok := final.(submitview.Model)
if !ok {
return nil, false, fmt.Errorf("unexpected model type %T", final)
}
if m.Cancelled() || !m.SubmitRequested() {
return nil, true, nil
}
return submitview.BuildDrafts(m.Nodes()), false, nil
}
// ensurePR finds or creates a PR for the branch at index i, and updates
// its base branch if needed. This is the single place where PR state is
// reconciled during submit.
//
// drafts holds optional per-branch overrides from the interactive editor. When
// a NEW branch has been deselected in the editor, it is pushed for stack
// consistency but no PR is created for it.
func ensurePR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int, baseBranch string, opts *submitOptions, templateContent string, drafts map[string]*submitview.PRDraft) error {
b := s.Branches[i]
pr, err := client.FindPRForBranch(b.Branch)
if err != nil {
cfg.Warningf("failed to check PR for %s: %v", b.Branch, err)
return nil
}
if pr == nil {
// A NEW branch the user deselected in the editor: pushed for stack
// consistency, but intentionally left without a PR.
if d := drafts[b.Branch]; d != nil && !d.Include {
return nil
}
return createPR(cfg, client, s, i, baseBranch, opts, templateContent, drafts)
}
// PR exists — record it and fix base if needed.
if s.Branches[i].PullRequest == nil {
s.Branches[i].PullRequest = &stack.PullRequestRef{
Number: pr.Number,
ID: pr.ID,
URL: pr.URL,
}
}
// Disable auto-merge before adding this PR to a stack. A PR with
// auto-merge enabled would merge on its own, breaking the stack.
if pr.IsAutoMergeEnabled() {
if err := client.DisableAutoMerge(pr.ID); err != nil {
cfg.Warningf("failed to disable auto-merge for PR %s: %v",
cfg.PRLink(pr.Number, pr.URL), err)
} else {
cfg.Warningf("Disabled auto-merge for PR %s (incompatible with stacked PRs)",
cfg.PRLink(pr.Number, pr.URL))
}
}
if pr.BaseRefName != baseBranch {
if s.ID != "" {
// Stack API owns base relationships — can't update directly.
cfg.Warningf("PR %s has base %q (expected %q) but cannot update while stacked",
cfg.PRLink(pr.Number, pr.URL), pr.BaseRefName, baseBranch)
} else {
if err := client.UpdatePRBase(pr.Number, baseBranch); err != nil {
cfg.Warningf("failed to update base branch for PR %s: %v",
cfg.PRLink(pr.Number, pr.URL), err)
} else {
cfg.Successf("Updated base branch for PR %s to %s",
cfg.PRLink(pr.Number, pr.URL), baseBranch)
}
}
} else {
cfg.Printf("PR %s for %s is up to date", cfg.PRLink(pr.Number, pr.URL), b.Branch)
}
// Convert draft PR to ready for review when --open is set.
if opts.open && pr.IsDraft {
if err := client.MarkPRReadyForReview(pr.ID); err != nil {
cfg.Warningf("failed to mark PR %s as ready for review: %v",
cfg.PRLink(pr.Number, pr.URL), err)
} else {
cfg.Successf("Marked PR %s as ready for review",
cfg.PRLink(pr.Number, pr.URL))
}
}
return nil
}
// createPR creates a new PR for the branch at index i.
//
// When the interactive editor has supplied a draft override for this branch
// (drafts[branch] != nil), its title, body, and draft state are used verbatim
// — the attribution footer is appended via generatePRBody. Otherwise the
// auto-generated title/body path (with an optional line prompt in interactive
// mode) is used, preserving today's --auto / non-interactive behavior.
func createPR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int, baseBranch string, opts *submitOptions, templateContent string, drafts map[string]*submitview.PRDraft) error {
b := s.Branches[i]
var title, body string
isDraft := !opts.open
if d := drafts[b.Branch]; d != nil {
// Interactive editor override. The user already edited the description
// in the TUI (prefilled from the repo template when one exists), so
// d.Body is the final body. Pass no template so generatePRBody keeps the
// user's text and only appends the attribution footer, rather than
// discarding their edits in favor of the raw template.
title = d.Title
body = generatePRBody(d.Body, "")
isDraft = d.Draft
} else {
// Auto / non-interactive default path: an auto-generated title and a
// body built from the branch's commits (the interactive title is
// drafted in the submit TUI instead).
var commitBody string
title, commitBody = defaultPRTitleBody(baseBranch, b.Branch)
body = generatePRBody(commitBody, templateContent)
}
newPR, createErr := client.CreatePR(baseBranch, b.Branch, title, body, isDraft)
if createErr != nil {
cfg.Warningf("failed to create PR for %s: %v", b.Branch, createErr)
return nil
}
cfg.Successf("Created PR %s for %s", cfg.PRLink(newPR.Number, newPR.URL), b.Branch)
s.Branches[i].PullRequest = &stack.PullRequestRef{
Number: newPR.Number,
ID: newPR.ID,
URL: newPR.URL,
}
return nil
}
// defaultPRTitleBody generates a PR title and body from the branch's commits.
// If there is exactly one commit, use its subject as the title and its body
// (if any) as the PR body. Otherwise, humanize the branch name for the title.
func defaultPRTitleBody(base, head string) (string, string) {
commits, err := git.LogRange(base, head)
if err == nil && len(commits) == 1 {
return commits[0].Subject, strings.TrimSpace(commits[0].Body)
}
return humanize(head), ""
}
// generatePRBody builds a PR description. When a templateContent is provided,
// it is used as the body and the attribution footer is omitted. Otherwise the
// body is built from the commit body with a footer linking to the CLI.
func generatePRBody(commitBody string, templateContent string) string {
if templateContent != "" {
return templateContent
}
var parts []string
if commitBody != "" {
parts = append(parts, commitBody)
}
footer := fmt.Sprintf(
"<sub>Stack created with <a href=\"https://github.com/github/gh-stack\">GitHub Stacks CLI</a> • <a href=\"%s\">Give Feedback 💬</a></sub>",
feedbackURL,
)
parts = append(parts, footer)
return strings.Join(parts, "\n\n---\n\n")
}
// humanize replaces hyphens and underscores with spaces.
func humanize(s string) string {
return strings.Map(func(r rune) rune {
if r == '-' || r == '_' {
return ' '
}
return r
}, s)
}
// maybeForkFromMergedBase detects when every PR that is officially part of the
// stack on GitHub has already been merged, and 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.
//
// Once all of a stack's remote PRs are merged — especially after their branches
// are deleted upstream — you can no longer add to that stack: a new PR on top
// would target the trunk, breaking the remote stack's "each PR's base ref ==
// previous PR's head ref" chain. Rather than failing the stack update on GitHub,
// we lift the survivors into a new local stack with no remote ID so the
// subsequent submit creates a fresh stack on GitHub. The original (fully merged)
// remote stack is left untouched on GitHub.
//
// It returns the stack submit should continue with: the new forked stack when a
// fork happens, or the original stack otherwise.
func maybeForkFromMergedBase(cfg *config.Config, client github.ClientOps, sf *stack.StackFile, s *stack.Stack, gitDir string) *stack.Stack {
// Only meaningful when there is a tracked remote stack to evaluate. A fork
// can only happen if every remote-stack PR is merged, which implies at least
// one locally tracked branch is merged — checking that first avoids an extra
// ListStacks call on the common path.
if s.ID == "" || len(s.MergedBranches()) == 0 {
return s
}
remotePRs := remoteStackPRs(client, s.ID)
if len(remotePRs) == 0 {
return s
}
// Every PR officially in the remote stack must be merged. Open PRs that are
// not part of the remote stack do not count.
merged := mergedPRNumbers(s)
for _, n := range remotePRs {
if !merged[n] {
return s // a remote-stack PR is still open — not a fork situation
}
}
stackIdx := sf.IndexOfStack(s)
if stackIdx < 0 {
return s
}
// Partition the local branches: those that are part of the merged remote
// stack stay behind; everything else (new branches and open PRs that were
// never part of the remote stack) is forked into a new stack.
remoteSet := make(map[int]bool, len(remotePRs))
for _, n := range remotePRs {
remoteSet[n] = true
}
var keepBranches, forkBranches []stack.BranchRef
for _, b := range s.Branches {
if b.PullRequest != nil && remoteSet[b.PullRequest.Number] {
keepBranches = append(keepBranches, b)
} else {
forkBranches = append(forkBranches, b)
}
}
if len(forkBranches) == 0 {
return s // nothing new to fork — the whole stack is merged and done
}
// Capture trunk/prefix before mutating sf.Stacks (RemoveStack/AddStack can
// reallocate the slice and invalidate the s pointer).
trunk := s.Trunk
prefix := s.Prefix
numbered := s.Numbered
// The bottom surviving branch re-bases onto the trunk.
if base, err := git.MergeBase(forkBranches[0].Branch, trunk.Branch); err == nil {
forkBranches[0].Base = base
}
cfg.Warningf("Every PR in this stack has already been merged on GitHub")
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
// only if at least one of its branches still exists locally; otherwise drop
// it. The merged stack is left intact on GitHub either way.
removeOld := true
for _, b := range keepBranches {
if git.BranchExists(b.Branch) {
removeOld = false
break
}
}
if removeOld {
sf.RemoveStack(stackIdx)
} else {
sf.Stacks[stackIdx].Branches = keepBranches
}
sf.AddStack(stack.Stack{
Prefix: prefix,
Numbered: numbered,
Trunk: trunk,
Branches: forkBranches,
})
if err := stack.Save(gitDir, sf); err != nil {
// Persisting the split failed, but the in-memory model is correct;
// surface the error and continue so the PRs still get submitted.
_ = handleSaveError(cfg, err)
}
return &sf.Stacks[len(sf.Stacks)-1]
}
// remoteStackPRs returns the PR numbers that are officially part of the remote
// stack identified by stackID, or nil if it can't be determined.
func remoteStackPRs(client github.ClientOps, stackID string) []int {
stacks, err := client.ListStacks()
if err != nil {
return nil
}
for _, rs := range stacks {
if strconv.Itoa(rs.ID) == stackID {
return rs.PullRequests
}
}
return nil
}
// mergedPRNumbers returns the set of PR numbers whose local branch is marked
// merged. Call after syncStackPRs so the merge flags reflect the remote state.
func mergedPRNumbers(s *stack.Stack) map[int]bool {
merged := make(map[int]bool)
for i := range s.Branches {
b := &s.Branches[i]
if b.IsMerged() && b.PullRequest != nil {
merged[b.PullRequest.Number] = true
}
}
return merged
}
// handlePendingModify handles the stack recreation after a modify operation.
// It deletes the old remote stack and clears s.ID so syncStack creates a new
// one. The state file is NOT cleared here — it is cleared after syncStack
// succeeds, ensuring retry safety.
func handlePendingModify(cfg *config.Config, client github.ClientOps, s *stack.Stack, gitDir string) error {
state, err := modify.LoadState(gitDir)
if err != nil || state == nil {
return nil // No modify state — nothing to do
}
if state.Phase != modify.PhasePendingSubmit {
return nil // Not in pending_submit phase
}
// Prompt for confirmation before overwriting the remote stack
if cfg.IsInteractive() {
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
proceed, promptErr := p.Confirm("The local stack has been modified. Overwrite the existing stack on GitHub?", true)
if promptErr != nil {
if isInterruptError(promptErr) {
printInterrupt(cfg)
return errInterrupt
}
return promptErr
}
if !proceed {
cfg.Printf("Skipping stack recreation — run `%s` when ready",
cfg.ColorCyan("gh stack submit"))
return errInterrupt
}
}
// Delete the old remote stack
if state.PriorRemoteStackID != "" {
if err := client.DeleteStack(state.PriorRemoteStackID); err != nil {
var httpErr *api.HTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == 404 {
cfg.Printf("Previous stack already deleted on GitHub")
} else {
cfg.Warningf("Failed to delete existing stack: %v", err)
cfg.Printf("Run `%s` again to retry", cfg.ColorCyan("gh stack submit"))
return err
}
} else {
cfg.Successf("Cleared existing stack on GitHub")
}
// Clear the old stack ID so syncStack creates a new one
s.ID = ""
}
return nil
}
// clearPendingModifyState clears the modify state file after a successful submit.
// Called after syncStack succeeds to ensure retry safety.
func clearPendingModifyState(cfg *config.Config, gitDir string) {
if !modify.StateExists(gitDir) {
return
}
modify.ClearState(gitDir)
cfg.Successf("Stack recreated on GitHub to match local state")
}
// syncStack creates or updates a stack on GitHub from the active PRs.
// If the stack already exists (s.ID is set), it calls the PUT endpoint with
// the full list of PRs to keep the remote stack in sync. If no stack exists
// yet, it calls POST to create one.
// This is a best-effort operation: failures are reported as warnings but do
// not cause the submit command to fail (the PRs are already created).
//
// It returns true when the remote stack object reflects the local stack
// (created, updated, or already in sync) and false otherwise (fewer than two
// PRs, an unresolved divergence, stacked PRs unavailable, or an API failure).
func syncStack(cfg *config.Config, client github.ClientOps, s *stack.Stack) bool {
// Collect PR numbers in stack order (bottom to top), including merged PRs.
// The API expects the full list — omitting merged PRs causes a
// "Stack contents have changed" rejection.
var prNumbers []int
for _, b := range s.Branches {
if b.PullRequest != nil {
prNumbers = append(prNumbers, b.PullRequest.Number)
}
}
// The API requires at least 2 PRs to form a stack.
if len(prNumbers) < 2 {
return false
}
if s.ID != "" {
return updateStack(cfg, client, s, prNumbers)
}
// No locally tracked stack ID. The stack may already exist on GitHub
// (created from the web UI or another clone) without being recorded
// locally. Adopt it instead of blindly creating a new one, which the API
// rejects because the PRs are already part of a stack.
return reconcileUntrackedStack(cfg, client, s, prNumbers)
}
// reconcileUntrackedStack reconciles a locally untracked stack (s.ID == "")
// with the stacks that already exist on GitHub. The PRs in s may already belong
// to a remote stack created from the web UI or another clone; in that case we
// adopt that stack rather than POST a new one (which the API rejects because the
// PRs are already stacked). It creates a new stack when none match, refuses to
// modify a divergent or PR-dropping stack, adopts a matching stack, or updates a
// partially-formed one. It returns true when the remote stack object now
// reflects the local stack.
func reconcileUntrackedStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) bool {
stacks, err := client.ListStacks()
if err != nil {
// Couldn't inspect remote state — fall back to the create path, which
// reports its own errors (handleCreate422 covers "already stacked").
return createNewStack(cfg, client, s, prNumbers)
}
matched, err := findMatchingStack(stacks, prNumbers)
if err != nil {
// Our PRs are spread across more than one remote stack. A PR can only
// belong to one stack, so this is a genuine divergence we can't resolve
// automatically.
cfg.Warningf("Your PRs belong to multiple stacks on GitHub — reconcile them first")
cfg.Printf(" Run `%s` to import a stack, or unstack the PRs from the web",
cfg.ColorCyan("gh stack checkout <pr>"))
return false
}
if matched == nil {
// No existing stack contains any of our PRs — create a new one.
return createNewStack(cfg, client, s, prNumbers)
}
// A remote stack already contains some of our PRs. Refuse to silently drop
// any PRs it holds that we aren't tracking locally; let the user reconcile.
if dropped := prsMissingFrom(matched.PullRequests, prNumbers); len(dropped) > 0 {
cfg.Warningf("A stack on GitHub already contains %s, which %s not in your local stack",
formatPRList(dropped), plural(len(dropped), "is", "are"))
cfg.Printf(" Run `%s` to import the full stack",
cfg.ColorCyan("gh stack checkout <pr>"))
return false
}
// Every PR in the remote stack is tracked locally (and we may have added
// more on top). Adopt the remote stack ID — recording it locally — and
// update the stack with our full, ordered PR list to append any new PRs.
s.ID = strconv.Itoa(matched.ID)
if slicesEqual(matched.PullRequests, prNumbers) {
cfg.Successf("Linked to the existing stack on GitHub (%d PRs, already up to date)", len(prNumbers))
return true
}
cfg.Infof("Found the stack on GitHub — updating it to match your local stack")
return updateStack(cfg, client, s, prNumbers)
}
// prsMissingFrom returns the numbers in remote that do not appear in local,
// preserving remote order.
func prsMissingFrom(remote, local []int) []int {
localSet := make(map[int]bool, len(local))
for _, n := range local {
localSet[n] = true
}
var missing []int
for _, n := range remote {
if !localSet[n] {
missing = append(missing, n)
}
}
return missing
}
// updateStack calls the PUT endpoint to sync the full PR list for an existing stack.
// If the remote stack was deleted (404), it clears the local ID and falls through
// to createNewStack so the user doesn't need to re-run the command.
// Returns true when the remote stack was updated (or recreated) successfully.
func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) bool {
if err := client.UpdateStack(s.ID, prNumbers); err != nil {
var httpErr *api.HTTPError
if errors.As(err, &httpErr) {
switch httpErr.StatusCode {
case 404:
// Stack was deleted on GitHub — clear the stale ID and
// immediately try to re-create it.
s.ID = ""
return createNewStack(cfg, client, s, prNumbers)
case 422:
// A merged branch whose ref has been deleted upstream breaks the
// stack's base→head chain, so the update is rejected. This is
// 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 {
cfg.Infof("Merged PRs have left the stack on GitHub, so it wasn't updated — your unmerged PRs were pushed and re-based onto the trunk")
return false
}
cfg.Warningf("Failed to update stack on GitHub: %s", httpErr.Message)
default:
cfg.Warningf("Failed to update stack on GitHub: %s", httpErr.Message)
}
} else {
cfg.Warningf("Failed to update stack on GitHub: %v", err)
}
return false
}
cfg.Successf("Stack updated on GitHub with %d PRs", len(prNumbers))
return true
}
// createNewStack calls the POST endpoint to create a new stack, handling the
// three types of 422 errors the API may return.
// Returns true when the stack was created or is confirmed already in sync.
func createNewStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) bool {
stackID, err := client.CreateStack(prNumbers)
if err == nil {
s.ID = strconv.Itoa(stackID)
cfg.Successf("Stack created on GitHub with %d PRs", len(prNumbers))
return true
}
var httpErr *api.HTTPError
if !errors.As(err, &httpErr) {
cfg.Warningf("Failed to create stack on GitHub: %v", err)
return false
}
switch httpErr.StatusCode {
case 422:
return handleCreate422(cfg, httpErr, prNumbers)
case 404:
warnStacksUnavailableOrPAT(cfg)
return false
default:
cfg.Warningf("Failed to create stack on GitHub: %s", httpErr.Message)
return false
}
}
// handleCreate422 handles 422 errors from the create stack endpoint.
// The three known error messages are:
// - "Stack must contain at least two pull requests"
// - "Pull requests must form a stack, where each PR's base ref is the previous PR's head ref"
// - "Pull requests #123, #124, #125 are already stacked"
//
// Returns true only when the PRs are already stacked together (i.e. the remote
// stack already matches), which counts as in sync.
func handleCreate422(cfg *config.Config, httpErr *api.HTTPError, prNumbers []int) bool {
msg := httpErr.Message
if isAlreadyStackedError(msg) {
// Check if the error lists exactly the same PRs we're trying to
// stack. If so, they're already in a stack together — nothing to do.
// If only a subset matches, the PRs are in a different stack.
if allPRsInMessage(msg, prNumbers) {
cfg.Successf("Stack with %d PRs is up to date", len(prNumbers))
return true
}
cfg.Warningf("One or more PRs are already part of a different stack on GitHub")
cfg.Printf(" Run `%s` to import the existing stack, or unstack the PRs from the web",
cfg.ColorCyan("gh stack checkout <pr>"))
return false
}
if strings.Contains(msg, "must form a stack") {
cfg.Warningf("Cannot create stack: %s", msg)
cfg.Printf(" Each PR's base branch must match the previous PR's head branch.")
return false
}
// "at least two" or any other validation error
cfg.Warningf("Could not create stack: %s", msg)
return false
}
// allPRsInMessage checks whether every PR number in prNumbers appears
// in the error message (e.g. as "#65"). This distinguishes "our PRs are
// already stacked together" from "some PRs are in a different stack."
func allPRsInMessage(msg string, prNumbers []int) bool {
for _, n := range prNumbers {
if !strings.Contains(msg, fmt.Sprintf("#%d", n)) {
return false
}
}
return true
}
// isAlreadyStackedError reports whether a create-stack 422 message indicates
// the PRs already belong to a stack. The server has used more than one phrasing
// ("Pull requests #1, #2 are already stacked", "Pull requests are already part
// of a stack"), so match on the stable substrings rather than an exact string.
func isAlreadyStackedError(msg string) bool {
m := strings.ToLower(msg)
return strings.Contains(m, "already stacked") ||
strings.Contains(m, "already part of a stack")
}