Skip to content

Commit 0b9f511

Browse files
committed
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.
1 parent c3653a2 commit 0b9f511

3 files changed

Lines changed: 50 additions & 38 deletions

File tree

cmd/submit.go

Lines changed: 28 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -721,58 +721,56 @@ func syncStack(cfg *config.Config, client github.ClientOps, s *stack.Stack) bool
721721

722722
// No locally tracked stack ID. The stack may already exist on GitHub
723723
// (created from the web UI or another clone) without being recorded
724-
// locally. Adopt it instead of blindly creating a new one, which the API
725-
// rejects because the PRs are already part of a stack.
726-
if handled, synced := adoptRemoteStack(cfg, client, s, prNumbers); handled {
727-
return synced
728-
}
729-
730-
return createNewStack(cfg, client, s, prNumbers)
731-
}
732-
733-
// adoptRemoteStack reconciles a locally untracked stack (s.ID == "") with the
734-
// stacks that already exist on GitHub. The PRs in s may already belong to a
735-
// remote stack created from the web UI or another clone; in that case we must
736-
// adopt that stack rather than POST a new one (which the API rejects because
737-
// the PRs are already stacked).
738-
//
739-
// It returns (handled, synced). handled is true when it has fully handled the
740-
// sync — either by adopting and updating the existing stack, or by
741-
// intentionally refusing to modify a divergent remote stack — and false when no
742-
// matching remote stack exists and the caller should create a new one. synced
743-
// is true only when the remote stack object now reflects the local stack.
744-
func adoptRemoteStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) (bool, bool) {
724+
// locally. Inspect the remote stacks and adopt a match instead of blindly
725+
// creating a new one, which the API rejects because the PRs are already
726+
// part of a stack.
745727
stacks, err := client.ListStacks()
746728
if err != nil {
747729
// Couldn't inspect remote state — fall back to the create path, which
748730
// reports its own errors (handleCreate422 covers "already stacked").
749-
return false, false
731+
return createNewStack(cfg, client, s, prNumbers)
750732
}
751733

734+
return reconcileUntrackedStack(cfg, client, s, prNumbers, stacks)
735+
}
736+
737+
// reconcileUntrackedStack reconciles a locally untracked stack (s.ID == "")
738+
// against the already-fetched remote stacks. The PRs in s may already belong to
739+
// a remote stack created from the web UI or another clone; in that case we adopt
740+
// that stack rather than POST a new one (which the API rejects because the PRs
741+
// are already stacked). It creates a new stack when none match, refuses to
742+
// modify a divergent or PR-dropping stack, adopts a matching stack, or updates a
743+
// partially-formed one.
744+
//
745+
// Callers pass the pre-fetched stack list so a single ListStacks round-trip is
746+
// shared across the reconciliation flow (sync fetches the list once for its
747+
// already-up-to-date short-circuit and reuses it here). It returns true when the
748+
// remote stack object now reflects the local stack.
749+
func reconcileUntrackedStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int, stacks []github.RemoteStack) bool {
752750
matched, err := findMatchingStack(stacks, prNumbers)
753751
if err != nil {
754752
// Our PRs are spread across more than one remote stack. A PR can only
755753
// belong to one stack, so this is a genuine divergence we can't resolve
756754
// automatically.
757-
cfg.Warningf("Your PRs belong to multiple stacks on GitHub — reconcile them before submitting")
755+
cfg.Warningf("Your PRs belong to multiple stacks on GitHub — reconcile them first")
758756
cfg.Printf(" Run `%s` to import a stack, or unstack the PRs from the web",
759757
cfg.ColorCyan("gh stack checkout <pr>"))
760-
return true, false
758+
return false
761759
}
762760

763761
if matched == nil {
764762
// No existing stack contains any of our PRs — create a new one.
765-
return false, false
763+
return createNewStack(cfg, client, s, prNumbers)
766764
}
767765

768766
// A remote stack already contains some of our PRs. Refuse to silently drop
769767
// any PRs it holds that we aren't tracking locally; let the user reconcile.
770768
if dropped := prsMissingFrom(matched.PullRequests, prNumbers); len(dropped) > 0 {
771769
cfg.Warningf("A stack on GitHub already contains %s, which %s not in your local stack",
772770
formatPRList(dropped), plural(len(dropped), "is", "are"))
773-
cfg.Printf(" Run `%s` to import the full stack, then `%s`",
774-
cfg.ColorCyan("gh stack checkout <pr>"), cfg.ColorCyan("gh stack submit"))
775-
return true, false
771+
cfg.Printf(" Run `%s` to import the full stack",
772+
cfg.ColorCyan("gh stack checkout <pr>"))
773+
return false
776774
}
777775

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

783781
if slicesEqual(matched.PullRequests, prNumbers) {
784782
cfg.Successf("Linked to the existing stack on GitHub (%d PRs, already up to date)", len(prNumbers))
785-
return true, true
783+
return true
786784
}
787785

788786
cfg.Infof("Found the stack on GitHub — updating it to match your local stack")
789-
return true, updateStack(cfg, client, s, prNumbers)
787+
return updateStack(cfg, client, s, prNumbers)
790788
}
791789

792790
// prsMissingFrom returns the numbers in remote that do not appear in local,

cmd/sync.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -366,13 +366,17 @@ func syncRemoteStack(cfg *config.Config, client github.ClientOps, s *stack.Stack
366366
return false
367367
}
368368

369-
// Inspect the remote stacks first so a routine sync that has not changed the
370-
// PR membership does not issue a redundant — and misleading — update.
369+
// Inspect the remote stacks once so a routine sync that has not changed the
370+
// PR membership does not issue a redundant — and misleading — update, and so
371+
// the create/adopt path can reuse the same list (one ListStacks per sync).
371372
stacks, err := client.ListStacks()
372373
if err != nil {
373-
// Couldn't inspect remote state; let syncStack attempt the operation and
374-
// surface its own availability/PAT/create errors.
375-
return syncStack(cfg, client, s)
374+
// Couldn't inspect remote state; attempt a direct create/update and let
375+
// those helpers surface their own availability/PAT/create errors.
376+
if s.ID != "" {
377+
return updateStack(cfg, client, s, prNumbers)
378+
}
379+
return createNewStack(cfg, client, s, prNumbers)
376380
}
377381

378382
if matched, mErr := findMatchingStack(stacks, prNumbers); mErr == nil &&
@@ -384,8 +388,12 @@ func syncRemoteStack(cfg *config.Config, client github.ClientOps, s *stack.Stack
384388
return true
385389
}
386390

387-
// Membership differs (or no stack exists yet): create, adopt, or update.
388-
return syncStack(cfg, client, s)
391+
// Membership differs (or no stack exists yet). Reuse the list we already
392+
// fetched for the create/adopt/update path.
393+
if s.ID != "" {
394+
return updateStack(cfg, client, s, prNumbers)
395+
}
396+
return reconcileUntrackedStack(cfg, client, s, prNumbers, stacks)
389397
}
390398

391399
// restoreBranches resets each branch to its original SHA, collecting any errors.

cmd/sync_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1673,9 +1673,13 @@ func TestSync_CreatesRemoteStackWhenPRsExist(t *testing.T) {
16731673
writeStackFile(t, tmpDir, s)
16741674

16751675
var createdWith []int
1676+
var listCalls int
16761677
ghMock := &github.MockClient{
16771678
FindPRForBranchFn: openPRFinder(map[string]int{"b1": 101, "b2": 102}),
1678-
ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil },
1679+
ListStacksFn: func() ([]github.RemoteStack, error) {
1680+
listCalls++
1681+
return nil, nil
1682+
},
16791683
CreateStackFn: func(prNumbers []int) (int, error) {
16801684
createdWith = prNumbers
16811685
return 7, nil
@@ -1689,6 +1693,7 @@ func TestSync_CreatesRemoteStackWhenPRsExist(t *testing.T) {
16891693
output := runSyncWithGitHub(t, newSyncMockNoRebase(tmpDir, "b1"), ghMock)
16901694

16911695
assert.Equal(t, []int{101, 102}, createdWith, "should create the stack from both PR numbers")
1696+
assert.Equal(t, 1, listCalls, "should issue exactly one ListStacks on the create path (no redundant round-trip)")
16921697
assert.Contains(t, output, "Stack created on GitHub with 2 PRs")
16931698
assert.Contains(t, output, "Stack synced")
16941699
assert.NotContains(t, output, "Branches synced")
@@ -1863,6 +1868,7 @@ func TestSync_PRsSpanMultipleStacks_BranchesSynced(t *testing.T) {
18631868
assert.False(t, createCalled, "CreateStack should not be called on divergence")
18641869
assert.False(t, updateCalled, "UpdateStack should not be called on divergence")
18651870
assert.Contains(t, output, "multiple stacks")
1871+
assert.NotContains(t, output, "submitting", "divergence guidance should be command-neutral, not submit-specific")
18661872
assert.Contains(t, output, "Branches synced")
18671873
assert.NotContains(t, output, "Stack synced")
18681874
}

0 commit comments

Comments
 (0)