Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
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
  • Loading branch information
skarim committed Jun 29, 2026
commit c3653a20c611469e80ccfb57b99291f9a4e2b73a
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,8 @@ Performs a safe, non-interactive synchronization of the entire stack:
3. **Cascade rebase** — rebases all stack branches onto their updated parents (only if trunk moved). If a conflict is detected, all branches are restored to their original state and you are advised to run `gh stack rebase` to resolve conflicts interactively
4. **Push** — pushes all branches (uses `--force-with-lease` if a rebase occurred)
5. **Sync PRs** — syncs PR state from GitHub and reports the status of each PR
6. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to prune automatically
6. **Sync the stack** — links the stack's open PRs into a stack on GitHub, creating the remote stack object if it doesn't exist yet or updating it if it's partially formed. Only happens when two or more PRs exist; sync never opens PRs (use `gh stack submit` for that)
7. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to prune automatically

| Flag | Description |
|------|-------------|
Expand Down
97 changes: 57 additions & 40 deletions cmd/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -684,42 +684,50 @@ func clearPendingModifyState(cfg *config.Config, gitDir string) {
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).
func syncStack(cfg *config.Config, client github.ClientOps, s *stack.Stack) {
// 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.
// stackPRNumbers returns the PR numbers for a stack in order (bottom to top),
// including merged PRs. The stacks API expects the full list — omitting merged
// PRs causes a "Stack contents have changed" rejection.
func stackPRNumbers(s *stack.Stack) []int {
var prNumbers []int
for _, b := range s.Branches {
if b.PullRequest != nil {
prNumbers = append(prNumbers, b.PullRequest.Number)
}
}
return prNumbers
}

// 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 {
prNumbers := stackPRNumbers(s)

// The API requires at least 2 PRs to form a stack.
if len(prNumbers) < 2 {
return
return false
}

if s.ID != "" {
updateStack(cfg, client, s, prNumbers)
return
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.
if adoptRemoteStack(cfg, client, s, prNumbers) {
return
if handled, synced := adoptRemoteStack(cfg, client, s, prNumbers); handled {
return synced
}

createNewStack(cfg, client, s, prNumbers)
return createNewStack(cfg, client, s, prNumbers)
}

// adoptRemoteStack reconciles a locally untracked stack (s.ID == "") with the
Expand All @@ -728,16 +736,17 @@ func syncStack(cfg *config.Config, client github.ClientOps, s *stack.Stack) {
// adopt that stack rather than POST a new one (which the API rejects because
// the PRs are already stacked).
//
// It returns true when it has fully handled the sync — either by adopting and
// updating the existing stack, or by intentionally refusing to modify a
// divergent remote stack — and false when no matching remote stack exists and
// the caller should create a new one.
func adoptRemoteStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) bool {
// It returns (handled, synced). handled is true when it has fully handled the
// sync — either by adopting and updating the existing stack, or by
// intentionally refusing to modify a divergent remote stack — and false when no
// matching remote stack exists and the caller should create a new one. synced
// is true only when the remote stack object now reflects the local stack.
func adoptRemoteStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) (bool, 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 false
return false, false
}

matched, err := findMatchingStack(stacks, prNumbers)
Expand All @@ -748,12 +757,12 @@ func adoptRemoteStack(cfg *config.Config, client github.ClientOps, s *stack.Stac
cfg.Warningf("Your PRs belong to multiple stacks on GitHub — reconcile them before submitting")
cfg.Printf(" Run `%s` to import a stack, or unstack the PRs from the web",
cfg.ColorCyan("gh stack checkout <pr>"))
return true
return true, false
}

if matched == nil {
// No existing stack contains any of our PRs — create a new one.
return false
return false, false
}

// A remote stack already contains some of our PRs. Refuse to silently drop
Expand All @@ -763,7 +772,7 @@ func adoptRemoteStack(cfg *config.Config, client github.ClientOps, s *stack.Stac
formatPRList(dropped), plural(len(dropped), "is", "are"))
cfg.Printf(" Run `%s` to import the full stack, then `%s`",
cfg.ColorCyan("gh stack checkout <pr>"), cfg.ColorCyan("gh stack submit"))
return true
return true, false
}

// Every PR in the remote stack is tracked locally (and we may have added
Expand All @@ -773,12 +782,11 @@ func adoptRemoteStack(cfg *config.Config, client github.ClientOps, s *stack.Stac

if slicesEqual(matched.PullRequests, prNumbers) {
cfg.Successf("Linked to the existing stack on GitHub (%d PRs, already up to date)", len(prNumbers))
return true
return true, true
}

cfg.Infof("Found the stack on GitHub — updating it to match your local stack")
updateStack(cfg, client, s, prNumbers)
return true
return true, updateStack(cfg, client, s, prNumbers)
}

// prsMissingFrom returns the numbers in remote that do not appear in local,
Expand All @@ -800,7 +808,8 @@ func prsMissingFrom(remote, local []int) []int {
// 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.
func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) {
// 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 {

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.

is bool like this an expected pattern in Go for the mutative actions? I guess I assumed it should return a tuple response that I see elsewhere in Go.

func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) (someVal: SomeObj, err error)
_, err := updateStack(args*)

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.

The tuple pattern is good when we need to bubble up an error message. In the case of updateStack, it doesn't make sense to do that vs. just printing the messages directly inside the func (none of which is an error/blocking). So the bool is just easier so we know if we're working with a stack and can proceed accordingly.

if err := client.UpdateStack(s.ID, prNumbers); err != nil {
var httpErr *api.HTTPError
if errors.As(err, &httpErr) {
Expand All @@ -809,7 +818,7 @@ func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, pr
// Stack was deleted on GitHub — clear the stale ID and
// immediately try to re-create it.
s.ID = ""
createNewStack(cfg, client, s, prNumbers)
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
Expand All @@ -818,7 +827,7 @@ func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, pr
// 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
return false
}
cfg.Warningf("Failed to update stack on GitHub: %s", httpErr.Message)
default:
Expand All @@ -827,34 +836,38 @@ func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, pr
} else {
cfg.Warningf("Failed to update stack on GitHub: %v", err)
}
return
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.
func createNewStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) {
// 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
return true
}

var httpErr *api.HTTPError
if !errors.As(err, &httpErr) {
cfg.Warningf("Failed to create stack on GitHub: %v", err)
return
return false
}

switch httpErr.StatusCode {
case 422:
handleCreate422(cfg, httpErr, prNumbers)
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
}
}

Expand All @@ -863,7 +876,10 @@ func createNewStack(cfg *config.Config, client github.ClientOps, s *stack.Stack,
// - "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"
func handleCreate422(cfg *config.Config, httpErr *api.HTTPError, prNumbers []int) {
//
// 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) {
Expand All @@ -872,22 +888,23 @@ func handleCreate422(cfg *config.Config, httpErr *api.HTTPError, prNumbers []int
// 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
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
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
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
Expand Down
70 changes: 69 additions & 1 deletion cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ package cmd
import (
"errors"
"fmt"
"strconv"
"strings"

"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/stack"
"github.com/spf13/cobra"
Expand All @@ -33,11 +35,20 @@ This command performs a safe, non-interactive synchronization:
3. Cascade-rebases stack branches onto their updated parents
4. Pushes all branches atomically (using --force-with-lease --atomic)
5. Syncs PR state from GitHub
6. Links the stack's open PRs into a stack on GitHub (creating or updating
the remote stack object) when two or more PRs exist

If a rebase conflict is detected, all branches are restored to their
original state and you are advised to run "gh stack rebase" to resolve
conflicts interactively.

Sync never opens pull requests — use "gh stack submit" for that. It only
links PRs that already exist. The final message reflects what happened:
"Stack synced" means the stack object on GitHub now matches your local
stack, while "Branches synced" means the branches were rebased and pushed
but no remote stack object was created or updated (for example, when fewer
than two PRs exist yet).

Use --prune to delete local branches for merged PRs. Stack metadata is
preserved so that rebase and display logic continue to work correctly.
If you are on a branch that would be pruned, your checkout is moved to
Expand Down Expand Up @@ -220,6 +231,18 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
cfg.Printf("Merged: %s", strings.Join(names, ", "))
}

// --- Step 5b: Reconcile the remote stack object ---
// syncStackPRs above only refreshes local PR associations; it does not touch
// the stack object on GitHub. When the branches have open PRs, link them into
// a stack so the remote reflects the local stack. This never opens PRs — that
// is still `gh stack submit`'s job. stackSynced records whether the remote
// stack object actually reflects the local stack, which determines the final
// summary message below.
stackSynced := false
if client, err := cfg.GitHubClient(); err == nil {
stackSynced = syncRemoteStack(cfg, client, s)
}

// --- Step 6: Prune merged branches (optional) ---
doPrune := opts.prune
if !doPrune {
Expand Down Expand Up @@ -316,10 +339,55 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
}

cfg.Printf("")
cfg.Successf("Stack synced")
if stackSynced {
cfg.Successf("Stack synced")
} else {
// The branches were fetched, rebased, and pushed, but no stack object on
// GitHub was created or updated (no PRs, fewer than two PRs, stacked PRs
// unavailable, or a divergence). Report only what actually happened.
cfg.Successf("Branches synced")
}
return nil
}

// syncRemoteStack reconciles the stack object on GitHub with the local stack's
// open PRs. It only links existing PRs into a stack — it never opens PRs (use
// `gh stack submit` for that). It returns true when the remote stack object now
// reflects the local stack (created, updated, adopted, or already in sync), and
// false when there is nothing to sync or the remote stack could not be
// reconciled (fewer than two PRs, stacked PRs unavailable, a divergence across
// multiple stacks, or an API failure).
//
// A stack on GitHub requires at least two open PRs, so a single-PR or PR-less
// stack reconciles to false and the caller reports only the branches as synced.
func syncRemoteStack(cfg *config.Config, client github.ClientOps, s *stack.Stack) bool {
prNumbers := stackPRNumbers(s)
if len(prNumbers) < 2 {
return false
}

// Inspect the remote stacks first so a routine sync that has not changed the
// PR membership does not issue a redundant — and misleading — update.
stacks, err := client.ListStacks()
if err != nil {
// Couldn't inspect remote state; let syncStack attempt the operation and
// surface its own availability/PAT/create errors.
return syncStack(cfg, client, s)
}
Comment thread
skarim marked this conversation as resolved.
Outdated

if matched, mErr := findMatchingStack(stacks, prNumbers); mErr == nil &&
matched != nil && slicesEqual(matched.PullRequests, prNumbers) {
// The remote stack already lists exactly these PRs — record its ID so
// future operations stay cheap and report it as in sync.
s.ID = strconv.Itoa(matched.ID)
cfg.Successf("Stack already up to date on GitHub")
return true
}

// Membership differs (or no stack exists yet): create, adopt, or update.
return syncStack(cfg, client, s)
Comment thread
skarim marked this conversation as resolved.
Outdated
}

// restoreBranches resets each branch to its original SHA, collecting any errors.
func restoreBranches(originalRefs map[string]string) []string {
var errors []string
Expand Down
Loading
Loading