Skip to content

Commit 95ae5d1

Browse files
committed
address review comments
1 parent 642e53d commit 95ae5d1

5 files changed

Lines changed: 227 additions & 11 deletions

File tree

cmd/checkout.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error {
110110
return err
111111
}
112112
} else if prNumber, parseErr := strconv.Atoi(opts.target); parseErr == nil && prNumber > 0 {
113-
// Target is a pure integer — try local PR, then remote API, then branch name
113+
// Target is a pure integer — try stack number, then PR, then branch name
114114
s, targetBranch, err = resolveNumericTarget(cfg, sf, gitDir, prNumber, opts.target)
115115
if err != nil {
116116
return err
@@ -145,18 +145,23 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error {
145145
return nil
146146
}
147147

148-
// resolveNumericTarget handles the case where the user passes a pure integer.
149-
// The integer is interpreted as, in order:
148+
// resolveNumericTarget handles the case where the user passes a pure integer or
149+
// a PR URL. The number is interpreted as, in order:
150150
// 1. A stack number (the primary identifier)
151151
// 2. A locally tracked PR number
152152
// 3. A PR number whose stack is discovered from GitHub
153153
// 4. A branch name (for numeric branch names like "123")
154+
//
155+
// Stack, PR, and issue numbers share a single repo-scoped numberspace,
156+
// so a given number is only ever one object type; a number that is not a stack
157+
// simply misses at step 1 and resolves at a later step.
154158
func resolveNumericTarget(cfg *config.Config, sf *stack.StackFile, gitDir string, number int, raw string) (*stack.Stack, string, error) {
155159
// 1. Try as a stack number (the primary identifier).
156160
if s, targetBranch, err := checkoutStackByNumber(cfg, sf, gitDir, number); err == nil {
157161
return s, targetBranch, nil
158162
} else if !errors.Is(err, errStackNumberNotFound) {
159-
// A real error (composition conflict, interrupted import, etc.) — surface it.
163+
// A real error during import/reconcile (composition conflict, interrupted
164+
// import, etc.) — surface it rather than trying other interpretations.
160165
return nil, "", err
161166
}
162167

@@ -254,7 +259,10 @@ var errStackNumberNotFound = errors.New("stack number not found")
254259
// checkoutStackByNumber discovers a stack from GitHub by its stack number,
255260
// reconciles it with any local state, and checks out the top-most unmerged
256261
// branch. It returns errStackNumberNotFound when the number does not resolve to
257-
// a stack so the caller can fall back to other interpretations.
262+
// a stack so the caller can fall back to other interpretations. Because stack,
263+
// PR, and issue numbers share one repo-scoped numberspace, a number that
264+
// belongs to a PR (or nothing) simply misses here and is resolved by the
265+
// caller's later steps.
258266
func checkoutStackByNumber(cfg *config.Config, sf *stack.StackFile, gitDir string, stackNumber int) (*stack.Stack, string, error) {
259267
client, err := cfg.GitHubClient()
260268
if err != nil {

cmd/checkout_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,53 @@ func TestCheckout_ByStackNumber(t *testing.T) {
354354
assert.Equal(t, 7, sf.Stacks[0].Number)
355355
}
356356

357+
func TestCheckout_ByStackNumber_404FallsThroughToPR(t *testing.T) {
358+
// A 404 from GetStack means no such stack, so the number is tried as a PR.
359+
gitDir := t.TempDir()
360+
var checkedOut string
361+
restore := git.SetOps(&git.MockOps{
362+
GitDirFn: func() (string, error) { return gitDir, nil },
363+
CurrentBranchFn: func() (string, error) { return "main", nil },
364+
BranchExistsFn: func(name string) bool { return name == "main" },
365+
FetchFn: func(string) error { return nil },
366+
CreateBranchFn: func(string, string) error { return nil },
367+
SetUpstreamTrackingFn: func(string, string) error { return nil },
368+
RevParseFn: func(string) (string, error) { return "abc123", nil },
369+
ResolveRemoteFn: func(string) (string, error) { return "origin", nil },
370+
CheckoutBranchFn: func(name string) error {
371+
checkedOut = name
372+
return nil
373+
},
374+
})
375+
defer restore()
376+
377+
writeStackFile(t, gitDir, stack.Stack{})
378+
379+
cfg, outR, errR := config.NewTestConfig()
380+
cfg.GitHubClientOverride = &github.MockClient{
381+
GetStackFn: func(int) (*github.RemoteStack, error) {
382+
return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"}
383+
},
384+
FindStackForPRFn: func(int) (*github.RemoteStack, error) {
385+
return &github.RemoteStack{ID: 1, Number: 1, PullRequests: []int{11, 12}}, nil
386+
},
387+
FindPRByNumberFn: func(n int) (*github.PullRequest, error) {
388+
prs := map[int]*github.PullRequest{
389+
11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "main", URL: "https://github.com/o/r/pull/11"},
390+
12: {ID: "PR_12", Number: 12, HeadRefName: "feat-3", BaseRefName: "feat-2", URL: "https://github.com/o/r/pull/12"},
391+
}
392+
return prs[n], nil
393+
},
394+
}
395+
396+
err := runCheckout(cfg, &checkoutOptions{target: "11"})
397+
output := collectOutput(cfg, outR, errR)
398+
399+
require.NoError(t, err)
400+
assert.Equal(t, "feat-2", checkedOut, "the number should resolve as PR #11 after a stack 404")
401+
assert.Contains(t, output, "Imported stack with 2 branches")
402+
}
403+
357404
func TestCheckout_NumericTarget_BranchExistsNoStack(t *testing.T) {
358405
gitDir := t.TempDir()
359406
var checkedOut string

cmd/unstack_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,3 +483,50 @@ func TestUnstack_ByStackNumber_NotTrackedLocally(t *testing.T) {
483483
require.NoError(t, err)
484484
require.Len(t, sf.Stacks, 1)
485485
}
486+
487+
func TestUnstack_ByStackNumber_LegacyStackResolvedByID(t *testing.T) {
488+
// A stack tracked before the number was recorded (Number == 0) is resolved
489+
// by mapping its internal ID to the remote stack number, and the backfilled
490+
// number is persisted.
491+
gitDir := t.TempDir()
492+
restore := git.SetOps(&git.MockOps{
493+
GitDirFn: func() (string, error) { return gitDir, nil },
494+
CurrentBranchFn: func() (string, error) { return "b1", nil },
495+
})
496+
defer restore()
497+
498+
writeStackFile(t, gitDir, stack.Stack{
499+
ID: "99", // legacy: internal ID present, Number unset (0)
500+
Trunk: stack.BranchRef{Branch: "main"},
501+
Branches: []stack.BranchRef{
502+
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}},
503+
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}},
504+
},
505+
})
506+
507+
var unstackedNumber int
508+
cfg, outR, errR := config.NewTestConfig()
509+
cfg.GitHubClientOverride = &github.MockClient{
510+
ListStacksFn: func() ([]github.RemoteStack, error) {
511+
return []github.RemoteStack{{ID: 99, Number: 7, PullRequests: []int{101, 102}}}, nil
512+
},
513+
UnstackFn: func(n int) (*github.RemoteStack, bool, error) {
514+
unstackedNumber = n
515+
// Some PRs remain stacked, so local tracking is kept.
516+
return &github.RemoteStack{ID: 99, Number: 7, PullRequests: []int{102}}, false, nil
517+
},
518+
}
519+
520+
err := runUnstack(cfg, &unstackOptions{stackNumber: 7})
521+
output := collectOutput(cfg, outR, errR)
522+
523+
require.NoError(t, err)
524+
assert.Equal(t, 7, unstackedNumber, "should resolve the legacy stack and unstack by its remote number")
525+
assert.Contains(t, output, "remain stacked on GitHub")
526+
527+
// The backfilled number is persisted to the stack file.
528+
sf, loadErr := stack.Load(gitDir)
529+
require.NoError(t, loadErr)
530+
require.Len(t, sf.Stacks, 1)
531+
assert.Equal(t, 7, sf.Stacks[0].Number, "the resolved stack number should be persisted")
532+
}

cmd/utils.go

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -260,8 +260,11 @@ func loadStack(cfg *config.Config, branch string) (*loadStackResult, error) {
260260
}
261261

262262
// loadStackByNumber loads the locally tracked stack whose stack number matches
263-
// the given value. It prints a helpful error and returns a non-nil error when
264-
// no local stack has that number.
263+
// the given value. Stack files created before the number was tracked store only
264+
// the internal ID (Number == 0); such legacy stacks are resolved by mapping
265+
// their ID to a remote stack number so they can still be targeted by number. It
266+
// prints a helpful error and returns a non-nil error when no local stack
267+
// resolves to that number.
265268
func loadStackByNumber(cfg *config.Config, number int) (*loadStackResult, error) {
266269
gitDir, err := git.GitDir()
267270
if err != nil {
@@ -275,6 +278,28 @@ func loadStackByNumber(cfg *config.Config, number int) (*loadStackResult, error)
275278
return nil, fmt.Errorf("failed to load stack state: %w", err)
276279
}
277280

281+
// Direct match on the tracked stack number.
282+
if result := stackResultByNumber(sf, gitDir, number); result != nil {
283+
return result, nil
284+
}
285+
286+
// No direct match — backfill legacy stacks' numbers from the remote and
287+
// retry, so `gh stack unstack <number>` also works for stacks tracked
288+
// before the number was recorded locally.
289+
if backfillLegacyStackNumbers(cfg, sf, gitDir) {
290+
if result := stackResultByNumber(sf, gitDir, number); result != nil {
291+
return result, nil
292+
}
293+
}
294+
295+
cfg.Errorf("stack #%d is not tracked locally", number)
296+
cfg.Printf("Run `%s` to check it out first", cfg.ColorCyan(fmt.Sprintf("gh stack checkout %d", number)))
297+
return nil, fmt.Errorf("stack #%d is not tracked locally", number)
298+
}
299+
300+
// stackResultByNumber returns a loadStackResult for the locally tracked stack
301+
// whose Number matches, or nil when none does.
302+
func stackResultByNumber(sf *stack.StackFile, gitDir string, number int) *loadStackResult {
278303
for i := range sf.Stacks {
279304
if sf.Stacks[i].Number == number {
280305
currentBranch, _ := git.CurrentBranch()
@@ -283,13 +308,59 @@ func loadStackByNumber(cfg *config.Config, number int) (*loadStackResult, error)
283308
StackFile: sf,
284309
Stack: &sf.Stacks[i],
285310
CurrentBranch: currentBranch,
286-
}, nil
311+
}
287312
}
288313
}
314+
return nil
315+
}
289316

290-
cfg.Errorf("stack #%d is not tracked locally", number)
291-
cfg.Printf("Run `%s` to check it out first", cfg.ColorCyan(fmt.Sprintf("gh stack checkout %d", number)))
292-
return nil, fmt.Errorf("stack #%d is not tracked locally", number)
317+
// backfillLegacyStackNumbers fills in the human-facing Number for locally
318+
// tracked stacks that predate it (Number == 0 but ID set) by mapping their
319+
// internal ID to the remote stack list, persisting any updates. Returns true
320+
// when at least one number was filled in. Best-effort: returns false on any
321+
// client or API error rather than failing the caller.
322+
func backfillLegacyStackNumbers(cfg *config.Config, sf *stack.StackFile, gitDir string) bool {
323+
needsResolve := false
324+
for i := range sf.Stacks {
325+
if sf.Stacks[i].Number == 0 && sf.Stacks[i].ID != "" {
326+
needsResolve = true
327+
break
328+
}
329+
}
330+
if !needsResolve {
331+
return false
332+
}
333+
334+
client, err := cfg.GitHubClient()
335+
if err != nil {
336+
return false
337+
}
338+
stacks, err := client.ListStacks()
339+
if err != nil {
340+
return false
341+
}
342+
numberByID := make(map[string]int, len(stacks))
343+
for _, rs := range stacks {
344+
numberByID[strconv.Itoa(rs.ID)] = rs.Number
345+
}
346+
347+
changed := false
348+
for i := range sf.Stacks {
349+
if sf.Stacks[i].Number != 0 || sf.Stacks[i].ID == "" {
350+
continue
351+
}
352+
if n, ok := numberByID[sf.Stacks[i].ID]; ok && n != 0 {
353+
sf.Stacks[i].Number = n
354+
changed = true
355+
}
356+
}
357+
if changed {
358+
if err := stack.Save(gitDir, sf); err != nil {
359+
// Non-fatal: the in-memory backfill still lets us resolve the target.
360+
cfg.Warningf("could not persist stack numbers: %v", err)
361+
}
362+
}
363+
return changed
293364
}
294365

295366
// handleSaveError translates a stack.Save error into the appropriate user
@@ -608,6 +679,12 @@ func syncStackPRsFromRemote(client github.ClientOps, s *stack.Stack) (map[string
608679
for _, rs := range stacks {
609680
if strconv.Itoa(rs.ID) == s.ID {
610681
remotePRNumbers = rs.PRNumbers()
682+
// Backfill the human-facing stack number for stack files created
683+
// before it was tracked, so callers (view, submit TUI) can display
684+
// it. Persisted by whichever command later saves the stack file.
685+
if s.Number == 0 {
686+
s.Number = rs.Number
687+
}
611688
break
612689
}
613690
}

cmd/utils_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,43 @@ func TestSyncStackPRs_RemoteStack_UsesStackAPI(t *testing.T) {
540540
assert.True(t, s.Branches[1].PullRequest.Merged)
541541
}
542542

543+
func TestSyncStackPRs_BackfillsStackNumber(t *testing.T) {
544+
// A stack tracked before the number was recorded (Number == 0) gets its
545+
// number backfilled from the remote during the shared sync, so callers can
546+
// display it.
547+
s := &stack.Stack{
548+
ID: "100", // legacy: Number unset
549+
Trunk: stack.BranchRef{Branch: "main"},
550+
Branches: []stack.BranchRef{
551+
{Branch: "b1"},
552+
{Branch: "b2"},
553+
},
554+
}
555+
556+
cfg, outR, errR := config.NewTestConfig()
557+
cfg.GitHubClientOverride = &github.MockClient{
558+
ListStacksFn: func() ([]github.RemoteStack, error) {
559+
return []github.RemoteStack{
560+
{ID: 100, Number: 5, PullRequests: []int{10, 11}},
561+
}, nil
562+
},
563+
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
564+
switch number {
565+
case 10:
566+
return &github.PullRequest{Number: 10, HeadRefName: "b1", State: "OPEN"}, nil
567+
case 11:
568+
return &github.PullRequest{Number: 11, HeadRefName: "b2", State: "OPEN"}, nil
569+
}
570+
return nil, nil
571+
},
572+
}
573+
574+
_ = syncStackPRs(cfg, s)
575+
collectOutput(cfg, outR, errR)
576+
577+
assert.Equal(t, 5, s.Number, "the stack number should be backfilled from the remote")
578+
}
579+
543580
func TestSyncStackPRs_RemoteStack_ClosedPRStaysAssociated(t *testing.T) {
544581
// When using the stack API, a closed (not merged) PR should remain
545582
// associated — the stack API is the source of truth, not PR state.

0 commit comments

Comments
 (0)