Skip to content

Commit e208dfc

Browse files
authored
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.
1 parent 754d190 commit e208dfc

9 files changed

Lines changed: 357 additions & 50 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,8 @@ Performs a safe, non-interactive synchronization of the entire stack:
323323
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
324324
4. **Push** — pushes all branches (uses `--force-with-lease` if a rebase occurred)
325325
5. **Sync PRs** — syncs PR state from GitHub and reports the status of each PR
326-
6. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to prune automatically
326+
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)
327+
7. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to prune automatically
327328

328329
| Flag | Description |
329330
|------|-------------|

cmd/submit.go

Lines changed: 46 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -690,7 +690,11 @@ func clearPendingModifyState(cfg *config.Config, gitDir string) {
690690
// yet, it calls POST to create one.
691691
// This is a best-effort operation: failures are reported as warnings but do
692692
// not cause the submit command to fail (the PRs are already created).
693-
func syncStack(cfg *config.Config, client github.ClientOps, s *stack.Stack) {
693+
//
694+
// It returns true when the remote stack object reflects the local stack
695+
// (created, updated, or already in sync) and false otherwise (fewer than two
696+
// PRs, an unresolved divergence, stacked PRs unavailable, or an API failure).
697+
func syncStack(cfg *config.Config, client github.ClientOps, s *stack.Stack) bool {
694698
// Collect PR numbers in stack order (bottom to top), including merged PRs.
695699
// The API expects the full list — omitting merged PRs causes a
696700
// "Stack contents have changed" rejection.
@@ -703,67 +707,60 @@ func syncStack(cfg *config.Config, client github.ClientOps, s *stack.Stack) {
703707

704708
// The API requires at least 2 PRs to form a stack.
705709
if len(prNumbers) < 2 {
706-
return
710+
return false
707711
}
708712

709713
if s.ID != "" {
710-
updateStack(cfg, client, s, prNumbers)
711-
return
714+
return updateStack(cfg, client, s, prNumbers)
712715
}
713716

714717
// No locally tracked stack ID. The stack may already exist on GitHub
715718
// (created from the web UI or another clone) without being recorded
716719
// locally. Adopt it instead of blindly creating a new one, which the API
717720
// rejects because the PRs are already part of a stack.
718-
if adoptRemoteStack(cfg, client, s, prNumbers) {
719-
return
720-
}
721-
722-
createNewStack(cfg, client, s, prNumbers)
721+
return reconcileUntrackedStack(cfg, client, s, prNumbers)
723722
}
724723

725-
// adoptRemoteStack reconciles a locally untracked stack (s.ID == "") with the
726-
// stacks that already exist on GitHub. The PRs in s may already belong to a
727-
// remote stack created from the web UI or another clone; in that case we must
728-
// adopt that stack rather than POST a new one (which the API rejects because
729-
// the PRs are already stacked).
730-
//
731-
// It returns true when it has fully handled the sync — either by adopting and
732-
// updating the existing stack, or by intentionally refusing to modify a
733-
// divergent remote stack — and false when no matching remote stack exists and
734-
// the caller should create a new one.
735-
func adoptRemoteStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) bool {
724+
// reconcileUntrackedStack reconciles a locally untracked stack (s.ID == "")
725+
// with the stacks that already exist on GitHub. The PRs in s may already belong
726+
// to a remote stack created from the web UI or another clone; in that case we
727+
// adopt that stack rather than POST a new one (which the API rejects because the
728+
// PRs are already stacked). It creates a new stack when none match, refuses to
729+
// modify a divergent or PR-dropping stack, adopts a matching stack, or updates a
730+
// partially-formed one. It returns true when the remote stack object now
731+
// reflects the local stack.
732+
func reconcileUntrackedStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) bool {
736733
stacks, err := client.ListStacks()
737734
if err != nil {
738735
// Couldn't inspect remote state — fall back to the create path, which
739736
// reports its own errors (handleCreate422 covers "already stacked").
740-
return false
737+
return createNewStack(cfg, client, s, prNumbers)
741738
}
742739

743740
matched, err := findMatchingStack(stacks, prNumbers)
744741
if err != nil {
745742
// Our PRs are spread across more than one remote stack. A PR can only
746743
// belong to one stack, so this is a genuine divergence we can't resolve
747744
// automatically.
748-
cfg.Warningf("Your PRs belong to multiple stacks on GitHub — reconcile them before submitting")
745+
cfg.Warningf("Your PRs belong to multiple stacks on GitHub — reconcile them first")
749746
cfg.Printf(" Run `%s` to import a stack, or unstack the PRs from the web",
750747
cfg.ColorCyan("gh stack checkout <pr>"))
751-
return true
748+
return false
752749
}
753750

754751
if matched == nil {
755752
// No existing stack contains any of our PRs — create a new one.
756-
return false
753+
return createNewStack(cfg, client, s, prNumbers)
757754
}
758755

759756
// A remote stack already contains some of our PRs. Refuse to silently drop
760757
// any PRs it holds that we aren't tracking locally; let the user reconcile.
761758
if dropped := prsMissingFrom(matched.PullRequests, prNumbers); len(dropped) > 0 {
762759
cfg.Warningf("A stack on GitHub already contains %s, which %s not in your local stack",
763760
formatPRList(dropped), plural(len(dropped), "is", "are"))
764-
cfg.Printf(" Run `%s` to import the full stack, then `%s`",
765-
cfg.ColorCyan("gh stack checkout <pr>"), cfg.ColorCyan("gh stack submit"))
766-
return true
761+
cfg.Printf(" Run `%s` to import the full stack",
762+
cfg.ColorCyan("gh stack checkout <pr>"))
763+
return false
767764
}
768765

769766
// Every PR in the remote stack is tracked locally (and we may have added
@@ -777,8 +774,7 @@ func adoptRemoteStack(cfg *config.Config, client github.ClientOps, s *stack.Stac
777774
}
778775

779776
cfg.Infof("Found the stack on GitHub — updating it to match your local stack")
780-
updateStack(cfg, client, s, prNumbers)
781-
return true
777+
return updateStack(cfg, client, s, prNumbers)
782778
}
783779

784780
// prsMissingFrom returns the numbers in remote that do not appear in local,
@@ -800,7 +796,8 @@ func prsMissingFrom(remote, local []int) []int {
800796
// updateStack calls the PUT endpoint to sync the full PR list for an existing stack.
801797
// If the remote stack was deleted (404), it clears the local ID and falls through
802798
// to createNewStack so the user doesn't need to re-run the command.
803-
func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) {
799+
// Returns true when the remote stack was updated (or recreated) successfully.
800+
func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) bool {
804801
if err := client.UpdateStack(s.ID, prNumbers); err != nil {
805802
var httpErr *api.HTTPError
806803
if errors.As(err, &httpErr) {
@@ -809,7 +806,7 @@ func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, pr
809806
// Stack was deleted on GitHub — clear the stale ID and
810807
// immediately try to re-create it.
811808
s.ID = ""
812-
createNewStack(cfg, client, s, prNumbers)
809+
return createNewStack(cfg, client, s, prNumbers)
813810
case 422:
814811
// A merged branch whose ref has been deleted upstream breaks the
815812
// stack's base→head chain, so the update is rejected. This is
@@ -818,7 +815,7 @@ func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, pr
818815
// than alarming the user with a raw API error.
819816
if strings.Contains(httpErr.Message, "must form a stack") && len(s.MergedBranches()) > 0 {
820817
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")
821-
return
818+
return false
822819
}
823820
cfg.Warningf("Failed to update stack on GitHub: %s", httpErr.Message)
824821
default:
@@ -827,34 +824,38 @@ func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, pr
827824
} else {
828825
cfg.Warningf("Failed to update stack on GitHub: %v", err)
829826
}
830-
return
827+
return false
831828
}
832829
cfg.Successf("Stack updated on GitHub with %d PRs", len(prNumbers))
830+
return true
833831
}
834832

835833
// createNewStack calls the POST endpoint to create a new stack, handling the
836834
// three types of 422 errors the API may return.
837-
func createNewStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) {
835+
// Returns true when the stack was created or is confirmed already in sync.
836+
func createNewStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) bool {
838837
stackID, err := client.CreateStack(prNumbers)
839838
if err == nil {
840839
s.ID = strconv.Itoa(stackID)
841840
cfg.Successf("Stack created on GitHub with %d PRs", len(prNumbers))
842-
return
841+
return true
843842
}
844843

845844
var httpErr *api.HTTPError
846845
if !errors.As(err, &httpErr) {
847846
cfg.Warningf("Failed to create stack on GitHub: %v", err)
848-
return
847+
return false
849848
}
850849

851850
switch httpErr.StatusCode {
852851
case 422:
853-
handleCreate422(cfg, httpErr, prNumbers)
852+
return handleCreate422(cfg, httpErr, prNumbers)
854853
case 404:
855854
warnStacksUnavailableOrPAT(cfg)
855+
return false
856856
default:
857857
cfg.Warningf("Failed to create stack on GitHub: %s", httpErr.Message)
858+
return false
858859
}
859860
}
860861

@@ -863,7 +864,10 @@ func createNewStack(cfg *config.Config, client github.ClientOps, s *stack.Stack,
863864
// - "Stack must contain at least two pull requests"
864865
// - "Pull requests must form a stack, where each PR's base ref is the previous PR's head ref"
865866
// - "Pull requests #123, #124, #125 are already stacked"
866-
func handleCreate422(cfg *config.Config, httpErr *api.HTTPError, prNumbers []int) {
867+
//
868+
// Returns true only when the PRs are already stacked together (i.e. the remote
869+
// stack already matches), which counts as in sync.
870+
func handleCreate422(cfg *config.Config, httpErr *api.HTTPError, prNumbers []int) bool {
867871
msg := httpErr.Message
868872

869873
if isAlreadyStackedError(msg) {
@@ -872,22 +876,23 @@ func handleCreate422(cfg *config.Config, httpErr *api.HTTPError, prNumbers []int
872876
// If only a subset matches, the PRs are in a different stack.
873877
if allPRsInMessage(msg, prNumbers) {
874878
cfg.Successf("Stack with %d PRs is up to date", len(prNumbers))
875-
return
879+
return true
876880
}
877881
cfg.Warningf("One or more PRs are already part of a different stack on GitHub")
878882
cfg.Printf(" Run `%s` to import the existing stack, or unstack the PRs from the web",
879883
cfg.ColorCyan("gh stack checkout <pr>"))
880-
return
884+
return false
881885
}
882886

883887
if strings.Contains(msg, "must form a stack") {
884888
cfg.Warningf("Cannot create stack: %s", msg)
885889
cfg.Printf(" Each PR's base branch must match the previous PR's head branch.")
886-
return
890+
return false
887891
}
888892

889893
// "at least two" or any other validation error
890894
cfg.Warningf("Could not create stack: %s", msg)
895+
return false
891896
}
892897

893898
// allPRsInMessage checks whether every PR number in prNumbers appears

cmd/sync.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,20 @@ This command performs a safe, non-interactive synchronization:
3333
3. Cascade-rebases stack branches onto their updated parents
3434
4. Pushes all branches atomically (using --force-with-lease --atomic)
3535
5. Syncs PR state from GitHub
36+
6. Links the stack's open PRs into a stack on GitHub (creating or updating
37+
the remote stack object) when two or more PRs exist
3638
3739
If a rebase conflict is detected, all branches are restored to their
3840
original state and you are advised to run "gh stack rebase" to resolve
3941
conflicts interactively.
4042
43+
Sync never opens pull requests — use "gh stack submit" for that. It only
44+
links PRs that already exist. The final message reflects what happened:
45+
"Stack synced" means the stack object on GitHub now matches your local
46+
stack, while "Branches synced" means the branches were rebased and pushed
47+
but no remote stack object was created or updated (for example, when fewer
48+
than two PRs exist yet).
49+
4150
Use --prune to delete local branches for merged PRs. Stack metadata is
4251
preserved so that rebase and display logic continue to work correctly.
4352
If you are on a branch that would be pruned, your checkout is moved to
@@ -220,6 +229,18 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
220229
cfg.Printf("Merged: %s", strings.Join(names, ", "))
221230
}
222231

232+
// --- Step 5b: Reconcile the remote stack object ---
233+
// syncStackPRs above only refreshes local PR associations; it does not touch
234+
// the stack object on GitHub. When the branches have open PRs, link them into
235+
// a stack so the remote reflects the local stack. This never opens PRs — that
236+
// is still `gh stack submit`'s job. stackSynced records whether the remote
237+
// stack object actually reflects the local stack, which determines the final
238+
// summary message below.
239+
stackSynced := false
240+
if client, err := cfg.GitHubClient(); err == nil {
241+
stackSynced = syncStack(cfg, client, s)
242+
}
243+
223244
// --- Step 6: Prune merged branches (optional) ---
224245
doPrune := opts.prune
225246
if !doPrune {
@@ -316,7 +337,14 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
316337
}
317338

318339
cfg.Printf("")
319-
cfg.Successf("Stack synced")
340+
if stackSynced {
341+
cfg.Successf("Stack synced")
342+
} else {
343+
// The branches were fetched, rebased, and pushed, but no stack object on
344+
// GitHub was created or updated (no PRs, fewer than two PRs, stacked PRs
345+
// unavailable, or a divergence). Report only what actually happened.
346+
cfg.Successf("Branches synced")
347+
}
320348
return nil
321349
}
322350

0 commit comments

Comments
 (0)