From 329aef4a78d52e60bd1dc366837ceabee2a645ed Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Sun, 12 Jul 2026 17:26:50 -0400 Subject: [PATCH 1/4] Support addressing a stack by its stack number checkout now interprets a bare integer as a stack number first (the identifier shown in the github.com stack UI), falling back to a locally tracked PR number, then a PR number discovered from GitHub, then a branch name. A new checkoutStackByNumber resolves the stack via GetStack and checks out its top-most unmerged branch; the reconcile/import logic is shared with the PR-number path. unstack gains an optional positional argument to unstack a specific locally tracked stack instead of the current one. Copilot-Session: 03673c26-a245-42da-93ed-dfcebc92a740 --- cmd/checkout.go | 118 ++++++++++++++++++++++++++++++++++--------- cmd/checkout_test.go | 70 +++++++++++++++++++++++++ cmd/unstack.go | 41 ++++++++++++--- cmd/unstack_test.go | 80 +++++++++++++++++++++++++++++ cmd/utils.go | 33 ++++++++++++ 5 files changed, 311 insertions(+), 31 deletions(-) diff --git a/cmd/checkout.go b/cmd/checkout.go index 368e1ed..ceb2fce 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -23,9 +23,14 @@ func CheckoutCmd(cfg *config.Config) *cobra.Command { opts := &checkoutOptions{} cmd := &cobra.Command{ - Use: "checkout [ | | ]", - Short: "Checkout a stack from a PR number, PR URL, or branch name", - Long: `Check out a stack from a pull request number, PR URL, or branch name. + Use: "checkout [ | | | ]", + Short: "Checkout a stack by stack number, PR number, PR URL, or branch name", + Long: `Check out a stack by stack number, pull request number, PR URL, or branch name. + +A bare number is interpreted first as a stack number (the identifier shown in +the GitHub stack UI). If no stack has that number, it is then tried as a +locally tracked PR number, then a PR number whose stack is discovered from +GitHub, and finally a branch name. When a PR number or PR URL is provided (e.g. 123 or https://github.com/owner/repo/pull/123), the command first checks @@ -39,7 +44,10 @@ locally tracked stacks only. When run without arguments, shows a menu of all locally available stacks to choose from.`, - Example: ` # Check out a stack by PR number + Example: ` # Check out a stack by its stack number + $ gh stack checkout 7 + + # Check out a stack by PR number $ gh stack checkout 42 # Check out a stack by PR URL @@ -138,28 +146,35 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { } // resolveNumericTarget handles the case where the user passes a pure integer. -// It tries, in order: -// 1. Local stack lookup by PR number -// 2. Remote API discovery (ListStacks → find → import) -// 3. Local stack lookup by branch name (for numeric branch names like "123") -func resolveNumericTarget(cfg *config.Config, sf *stack.StackFile, gitDir string, prNumber int, raw string) (*stack.Stack, string, error) { - // 1. Try local PR number lookup - if s, br := sf.FindStackByPRNumber(prNumber); s != nil && br != nil { +// The integer is interpreted as, in order: +// 1. A stack number (the primary identifier) +// 2. A locally tracked PR number +// 3. A PR number whose stack is discovered from GitHub +// 4. A branch name (for numeric branch names like "123") +func resolveNumericTarget(cfg *config.Config, sf *stack.StackFile, gitDir string, number int, raw string) (*stack.Stack, string, error) { + // 1. Try as a stack number (the primary identifier). + if s, targetBranch, err := checkoutStackByNumber(cfg, sf, gitDir, number); err == nil { + return s, targetBranch, nil + } else if !errors.Is(err, errStackNumberNotFound) { + // A real error (composition conflict, interrupted import, etc.) — surface it. + return nil, "", err + } + + // 2. Try a locally tracked PR number. + if s, br := sf.FindStackByPRNumber(number); s != nil && br != nil { return s, br.Branch, nil } - // 2. Try remote API - s, targetBranch, err := checkoutRemoteStack(cfg, sf, gitDir, prNumber) + // 3. Try a PR number whose stack is on GitHub. + s, targetBranch, err := checkoutRemoteStack(cfg, sf, gitDir, number) if err == nil { return s, targetBranch, nil } - // If the API returned a definitive "not in a stack" or a real error, - // fall through to the branch-name attempt only for "not in stack". - // For API failures (404, network errors), still fall through — - // the user might have a numeric branch name. + // For API failures or "not in a stack", still fall through to the branch-name + // attempt — the user might have a numeric branch name. remoteErr := err - // 3. Fall back to branch name lookup (handles numeric branch names) + // 4. Fall back to branch name lookup (handles numeric branch names). stacks := sf.FindAllStacksForBranch(raw) if len(stacks) > 0 { s := stacks[0] @@ -212,16 +227,14 @@ func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string, return nil, "", ErrAPIFailure } - // Determine trunk (base branch of the first PR) and the target branch + // Determine trunk (base branch of the first PR) and the target branch (the + // branch for the requested PR). trunk := prs[0].BaseRefName var targetBranch string - allMerged := true for _, pr := range prs { if pr.Number == prNumber { targetBranch = pr.HeadRefName - } - if !pr.Merged { - allMerged = false + break } } if targetBranch == "" { @@ -229,6 +242,63 @@ func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string, return nil, "", ErrAPIFailure } + return reconcileAndImportRemoteStack(cfg, client, sf, gitDir, remoteStack, prs, trunk, targetBranch) +} + +// errStackNumberNotFound is returned by checkoutStackByNumber when a numeric +// argument does not resolve to a stack (no such stack, stacks unavailable, or +// any lookup failure), signalling the caller to try interpreting the argument +// as a PR number or branch name instead. +var errStackNumberNotFound = errors.New("stack number not found") + +// checkoutStackByNumber discovers a stack from GitHub by its stack number, +// reconciles it with any local state, and checks out the top-most unmerged +// branch. It returns errStackNumberNotFound when the number does not resolve to +// a stack so the caller can fall back to other interpretations. +func checkoutStackByNumber(cfg *config.Config, sf *stack.StackFile, gitDir string, stackNumber int) (*stack.Stack, string, error) { + client, err := cfg.GitHubClient() + if err != nil { + return nil, "", errStackNumberNotFound + } + + remoteStack, err := client.GetStack(stackNumber) + if err != nil || remoteStack == nil || len(remoteStack.PullRequests) == 0 { + // No such stack, stacks unavailable, or a transient failure — let the + // caller try the number as a PR number or branch name. + return nil, "", errStackNumberNotFound + } + + prs, err := fetchStackPRDetails(client, remoteStack.PRNumbers()) + if err != nil { + cfg.Errorf("failed to fetch PR details: %v", err) + return nil, "", ErrAPIFailure + } + + trunk := prs[0].BaseRefName + // Target the top-most unmerged branch, falling back to the very top. + targetBranch := prs[len(prs)-1].HeadRefName + for i := len(prs) - 1; i >= 0; i-- { + if !prs[i].Merged { + targetBranch = prs[i].HeadRefName + break + } + } + + return reconcileAndImportRemoteStack(cfg, client, sf, gitDir, remoteStack, prs, trunk, targetBranch) +} + +// reconcileAndImportRemoteStack reconciles a resolved remote stack with local +// state — adopting a matching local stack, resolving composition conflicts, or +// importing the stack from the remote — and returns the resolved local stack +// and the branch to check out. +func reconcileAndImportRemoteStack(cfg *config.Config, client github.ClientOps, sf *stack.StackFile, gitDir string, remoteStack *github.RemoteStack, prs []*github.PullRequest, trunk, targetBranch string) (*stack.Stack, string, error) { + allMerged := true + for _, pr := range prs { + if !pr.Merged { + allMerged = false + break + } + } if allMerged { cfg.Infof("All PRs in this stack have been merged") cfg.Printf("To start a new stack, use `%s`", cfg.ColorCyan("gh stack init")) @@ -237,7 +307,7 @@ func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string, remoteStackID := strconv.Itoa(remoteStack.ID) - // Step 3: Check if the target branch is already in a local stack + // Check if the target branch is already in a local stack. localStack := findLocalStackForRemotePRs(sf, prs) if localStack != nil { diff --git a/cmd/checkout_test.go b/cmd/checkout_test.go index d7fbcef..4725ff0 100644 --- a/cmd/checkout_test.go +++ b/cmd/checkout_test.go @@ -284,6 +284,76 @@ func TestCheckout_NumericTarget_NewStack(t *testing.T) { assert.Equal(t, 12, sf.Stacks[0].Branches[2].PullRequest.Number) } +func TestCheckout_ByStackNumber(t *testing.T) { + gitDir := t.TempDir() + var checkedOut string + var createdBranches []string + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + BranchExistsFn: func(name string) bool { return name == "main" }, + FetchFn: func(remote string) error { return nil }, + CreateBranchFn: func(name, base string) error { + createdBranches = append(createdBranches, name) + return nil + }, + SetUpstreamTrackingFn: func(branch, remote string) error { return nil }, + ResolveRemoteFn: func(branch string) (string, error) { return "origin", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return nil + }, + RevParseFn: func(ref string) (string, error) { return "abc123", nil }, + RevParseMultiFn: func(refs []string) ([]string, error) { + shas := make([]string, len(refs)) + for i := range refs { + shas[i] = "abc123" + } + return shas, nil + }, + }) + defer restore() + + require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}})) + + var gotStackNumber int + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { + gotStackNumber = n + if n == 7 { + return &github.RemoteStack{ID: 42, Number: 7, PullRequests: []int{10, 11, 12}}, nil + } + return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"} + }, + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + prs := map[int]*github.PullRequest{ + 10: {ID: "PR_10", Number: 10, HeadRefName: "feat-1", BaseRefName: "main", URL: "https://github.com/o/r/pull/10"}, + 11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "feat-1", URL: "https://github.com/o/r/pull/11"}, + 12: {ID: "PR_12", Number: 12, HeadRefName: "feat-3", BaseRefName: "feat-2", URL: "https://github.com/o/r/pull/12"}, + } + return prs[number], nil + }, + } + + err := runCheckout(cfg, &checkoutOptions{target: "7"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, 7, gotStackNumber, "should look the stack up by its number") + // The top-most (last) branch of the stack is checked out. + assert.Equal(t, "feat-3", checkedOut) + assert.Contains(t, output, "Imported stack with 3 branches") + + // Verify the stack was imported with both its internal id and stack number. + sf, loadErr := stack.Load(gitDir) + require.NoError(t, loadErr) + require.Len(t, sf.Stacks, 1) + assert.Equal(t, "42", sf.Stacks[0].ID) + assert.Equal(t, 7, sf.Stacks[0].Number) +} + func TestCheckout_NumericTarget_BranchExistsNoStack(t *testing.T) { gitDir := t.TempDir() var checkedOut string diff --git a/cmd/unstack.go b/cmd/unstack.go index 3354217..04c8a25 100644 --- a/cmd/unstack.go +++ b/cmd/unstack.go @@ -2,6 +2,7 @@ package cmd import ( "errors" + "strconv" "github.com/cli/go-gh/v2/pkg/api" "github.com/github/gh-stack/internal/config" @@ -11,24 +12,44 @@ import ( ) type unstackOptions struct { - local bool + local bool + stackNumber int } func UnstackCmd(cfg *config.Config) *cobra.Command { opts := &unstackOptions{} cmd := &cobra.Command{ - Use: "unstack", + Use: "unstack []", Aliases: []string{"delete"}, - Short: "Delete a stack locally and on GitHub", - Long: "Remove the current active stack from local tracking and delete it on GitHub. Use --local to only remove local tracking. Full unstack is blocked when every pull request is queued for merge, merging, or already merged", - Example: ` # Delete the stack locally and on GitHub + Short: "Remove a stack locally and on GitHub", + Long: `Remove a stack from local tracking and unstack it on GitHub. + +With no argument, the current active stack is used. Provide a stack number (the +identifier shown in the github.com stack UI) to unstack a specific locally +tracked stack instead. Use --local to only remove local tracking. + +GitHub decides which pull requests can be unstacked: PRs that are queued for +merge or have auto-merge enabled are left stacked. When some pull requests +remain stacked, local tracking is kept.`, + Example: ` # Unstack the current stack locally and on GitHub $ gh stack unstack + # Unstack a specific stack by its stack number + $ gh stack unstack 7 + # Only remove local tracking (keep the stack on GitHub) $ gh stack unstack --local`, - Args: cobra.NoArgs, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 1 { + n, err := strconv.Atoi(args[0]) + if err != nil || n <= 0 { + cfg.Errorf("invalid stack number %q", args[0]) + return ErrInvalidArgs + } + opts.stackNumber = n + } return runUnstack(cfg, opts) }, } @@ -39,7 +60,13 @@ func UnstackCmd(cfg *config.Config) *cobra.Command { } func runUnstack(cfg *config.Config, opts *unstackOptions) error { - result, err := loadStack(cfg, "") + var result *loadStackResult + var err error + if opts.stackNumber > 0 { + result, err = loadStackByNumber(cfg, opts.stackNumber) + } else { + result, err = loadStack(cfg, "") + } if err != nil { return ErrNotInStack } diff --git a/cmd/unstack_test.go b/cmd/unstack_test.go index 74e9128..998086f 100644 --- a/cmd/unstack_test.go +++ b/cmd/unstack_test.go @@ -403,3 +403,83 @@ func TestUnstack_NumberLookupFailure_StopsDeletion(t *testing.T) { require.NoError(t, loadErr) require.Len(t, sf.Stacks, 1) } + +func TestUnstack_ByStackNumber(t *testing.T) { + // Target a specific stack by its number, regardless of the current branch. + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + }) + defer restore() + + s1 := stack.Stack{ + ID: "42", + Number: 42, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + } + s2 := stack.Stack{ + ID: "99", + Number: 7, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b3"}, {Branch: "b4"}}, + } + writeTwoStacks(t, gitDir, s1, s2) + + var unstackedNumber int + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + UnstackFn: func(n int) (*github.RemoteStack, bool, error) { + unstackedNumber = n + return nil, true, nil + }, + } + err := runUnstack(cfg, &unstackOptions{stackNumber: 7}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, 7, unstackedNumber) + assert.Contains(t, output, "Stack removed from local tracking") + + // The targeted stack (number 7 / b3,b4) is removed; the other is kept. + sf, err := stack.Load(gitDir) + require.NoError(t, err) + require.Len(t, sf.Stacks, 1) + assert.Equal(t, []string{"b1", "b2"}, sf.Stacks[0].BranchNames()) +} + +func TestUnstack_ByStackNumber_NotTrackedLocally(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + ID: "42", + Number: 42, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + }) + + unstackCalled := false + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + UnstackFn: func(int) (*github.RemoteStack, bool, error) { + unstackCalled = true + return nil, true, nil + }, + } + err := runUnstack(cfg, &unstackOptions{stackNumber: 999}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrNotInStack) + assert.False(t, unstackCalled, "should not unstack when the number isn't tracked locally") + assert.Contains(t, output, "stack #999 is not tracked locally") + + sf, err := stack.Load(gitDir) + require.NoError(t, err) + require.Len(t, sf.Stacks, 1) +} diff --git a/cmd/utils.go b/cmd/utils.go index 16d524d..e96f94a 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -249,6 +249,39 @@ func loadStack(cfg *config.Config, branch string) (*loadStackResult, error) { }, nil } +// loadStackByNumber loads the locally tracked stack whose stack number matches +// the given value. It prints a helpful error and returns a non-nil error when +// no local stack has that number. +func loadStackByNumber(cfg *config.Config, number int) (*loadStackResult, error) { + gitDir, err := git.GitDir() + if err != nil { + cfg.Errorf("not a git repository") + return nil, fmt.Errorf("not a git repository") + } + + sf, err := stack.Load(gitDir) + if err != nil { + cfg.Errorf("failed to load stack state: %s", err) + return nil, fmt.Errorf("failed to load stack state: %w", err) + } + + for i := range sf.Stacks { + if sf.Stacks[i].Number == number { + currentBranch, _ := git.CurrentBranch() + return &loadStackResult{ + GitDir: gitDir, + StackFile: sf, + Stack: &sf.Stacks[i], + CurrentBranch: currentBranch, + }, nil + } + } + + cfg.Errorf("stack #%d is not tracked locally", number) + cfg.Printf("Run `%s` to check it out first", cfg.ColorCyan(fmt.Sprintf("gh stack checkout %d", number))) + return nil, fmt.Errorf("stack #%d is not tracked locally", number) +} + // handleSaveError translates a stack.Save error into the appropriate user // message and exit error. Lock contention and stale-file detection both // return ErrLockFailed (exit 8); other write failures return ErrSilent (exit 1). From 531a959b2b557d3437053292aa9f9582d54ba53f Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Sun, 12 Jul 2026 17:36:12 -0400 Subject: [PATCH 2/4] Surface the stack number in output and TUIs Show the human-facing stack number wherever it is known: - Append a "(stack #N)" label to submit, link, checkout, and unstack success messages. - Add a "Stack #N" header line to the view command (short and static) and the stackview TUI header. - Add a "Stack #N" info line to the submit TUI header when submitting an already-created stack. Copilot-Session: 03673c26-a245-42da-93ed-dfcebc92a740 --- cmd/checkout.go | 4 +- cmd/link.go | 10 ++-- cmd/submit.go | 9 ++-- cmd/unstack.go | 2 +- cmd/utils.go | 10 ++++ cmd/view.go | 10 +++- internal/tui/stackview/model.go | 54 ++++++++++++-------- internal/tui/stackview/model_test.go | 73 +++++++++++++++++---------- internal/tui/submitview/model.go | 8 +++ internal/tui/submitview/model_test.go | 43 +++++++++++++++- internal/tui/submitview/render.go | 9 ++-- internal/tui/submitview/screen.go | 6 ++- 12 files changed, 171 insertions(+), 67 deletions(-) diff --git a/cmd/checkout.go b/cmd/checkout.go index ceb2fce..3d6eb91 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -327,7 +327,7 @@ func reconcileAndImportRemoteStack(cfg *config.Config, client github.ClientOps, if err := stack.Save(gitDir, sf); err != nil { return nil, "", handleSaveError(cfg, err) } - cfg.Successf("Local stack matches remote — switching to branch") + cfg.Successf("Local stack matches remote — switching to branch%s", stackLabel(remoteStack.Number)) return localStack, targetBranch, nil } @@ -592,7 +592,7 @@ func importRemoteStack( // Update base SHAs from actual local refs updateBaseSHAs(s) - cfg.Successf("Imported stack with %d branches from GitHub", len(prs)) + cfg.Successf("Imported stack with %d branches from GitHub%s", len(prs), stackLabel(remoteStackNumber)) return s, nil } diff --git a/cmd/link.go b/cmd/link.go index 0fc9aa7..c585cf1 100644 --- a/cmd/link.go +++ b/cmd/link.go @@ -551,7 +551,8 @@ func findMatchingStack(stacks []github.RemoteStack, prNumbers []int) (*github.Re // createLink creates a new stack with the given PR numbers. func createLink(cfg *config.Config, client github.ClientOps, prNumbers []int) error { - if _, err := client.CreateStack(prNumbers); err != nil { + rs, err := client.CreateStack(prNumbers) + if err != nil { var httpErr *api.HTTPError if errors.As(err, &httpErr) { switch httpErr.StatusCode { @@ -570,7 +571,7 @@ func createLink(cfg *config.Config, client github.ClientOps, prNumbers []int) er return ErrAPIFailure } - cfg.Successf("Created stack with %d PRs", len(prNumbers)) + cfg.Successf("Created stack with %d PRs%s", len(prNumbers), stackLabel(rs.Number)) return nil } @@ -617,7 +618,8 @@ func updateLink(cfg *config.Config, client github.ClientOps, existing *github.Re return ErrInvalidArgs } - if _, err := client.AddToStack(existing.Number, delta); err != nil { + rs, err := client.AddToStack(existing.Number, delta) + if err != nil { var httpErr *api.HTTPError if errors.As(err, &httpErr) { switch httpErr.StatusCode { @@ -637,7 +639,7 @@ func updateLink(cfg *config.Config, client github.ClientOps, existing *github.Re return ErrAPIFailure } - cfg.Successf("Updated stack to %d PRs", len(prNumbers)) + cfg.Successf("Updated stack to %d PRs%s", len(prNumbers), stackLabel(rs.Number)) return nil } diff --git a/cmd/submit.go b/cmd/submit.go index 919054d..3c98ee4 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -295,6 +295,7 @@ func collectPRDrafts(cfg *config.Config, client github.ClientOps, s *stack.Stack RepoLabel: repoLabel, Version: Version, CanCreateStack: canCreateStack, + StackNumber: s.Number, }) // Use cell-motion mouse mode (clicks, drag, and wheel) rather than all-motion. @@ -780,7 +781,7 @@ func reconcileUntrackedStack(cfg *config.Config, client github.ClientOps, s *sta s.Number = matched.Number if slicesEqual(matched.PRNumbers(), prNumbers) { - cfg.Successf("Linked to the existing stack on GitHub (%d PRs, already up to date)", len(prNumbers)) + cfg.Successf("Linked to the existing stack on GitHub (%d PRs, already up to date)%s", len(prNumbers), stackLabel(matched.Number)) return true } @@ -836,7 +837,7 @@ func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, pr if slicesEqual(current, prNumbers) { s.ID = strconv.Itoa(remote.ID) s.Number = remote.Number - cfg.Successf("Stack on GitHub is up to date with %d PRs", len(prNumbers)) + cfg.Successf("Stack on GitHub is up to date with %d PRs%s", len(prNumbers), stackLabel(remote.Number)) return true } @@ -886,7 +887,7 @@ func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, pr s.ID = strconv.Itoa(rs.ID) s.Number = rs.Number - cfg.Successf("Stack updated on GitHub with %d PRs", len(prNumbers)) + cfg.Successf("Stack updated on GitHub with %d PRs%s", len(prNumbers), stackLabel(rs.Number)) return true } @@ -898,7 +899,7 @@ func createNewStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, if err == nil { s.ID = strconv.Itoa(rs.ID) s.Number = rs.Number - cfg.Successf("Stack created on GitHub with %d PRs", len(prNumbers)) + cfg.Successf("Stack created on GitHub with %d PRs%s", len(prNumbers), stackLabel(rs.Number)) return true } diff --git a/cmd/unstack.go b/cmd/unstack.go index 04c8a25..2100661 100644 --- a/cmd/unstack.go +++ b/cmd/unstack.go @@ -130,7 +130,7 @@ func runUnstack(cfg *config.Config, opts *unstackOptions) error { cfg.Printf("The stack was left in place — local tracking is unchanged") return nil } else { - cfg.Successf("Stack removed on GitHub") + cfg.Successf("Stack removed on GitHub%s", stackLabel(number)) } } } diff --git a/cmd/utils.go b/cmd/utils.go index e96f94a..0634cb6 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -78,6 +78,16 @@ func warnStacksUnavailable(cfg *config.Config) { cfg.Warningf("Stacked PRs are not enabled for this repository") } +// stackLabel returns a " (stack #N)" suffix for appending to user-facing +// messages when the human-facing stack number is known, or an empty string +// otherwise. +func stackLabel(number int) string { + if number <= 0 { + return "" + } + return fmt.Sprintf(" (stack #%d)", number) +} + // stackNumberByID resolves an internal stack ID (as stored in the local stack // file) to its human-facing stack number by consulting the remote stack list. // Returns ok=false when no remote stack matches the ID (e.g. it was deleted). diff --git a/cmd/view.go b/cmd/view.go index 2613f05..b4466a7 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -144,6 +144,10 @@ func viewShort(cfg *config.Config, s *stack.Stack, currentBranch string) error { repoName = repo.Name } + if s.Number > 0 { + cfg.Outf("%s\n", cfg.ColorBold(fmt.Sprintf("Stack #%d", s.Number))) + } + for i := len(s.Branches) - 1; i >= 0; i-- { b := s.Branches[i] merged := b.IsMerged() @@ -311,7 +315,7 @@ func viewFullTUI(cfg *config.Config, s *stack.Stack, currentBranch string, prDet reversed[len(nodes)-1-i] = n } - model := stackview.New(reversed, s.Trunk, Version) + model := stackview.New(reversed, s.Trunk, Version, s.Number) p := tea.NewProgram( model, @@ -351,6 +355,10 @@ func viewFullStatic(cfg *config.Config, s *stack.Stack, currentBranch string) er var buf bytes.Buffer + if s.Number > 0 { + fmt.Fprintf(&buf, "%s\n\n", cfg.ColorBold(fmt.Sprintf("Stack #%d", s.Number))) + } + for i := len(s.Branches) - 1; i >= 0; i-- { b := s.Branches[i] diff --git a/internal/tui/stackview/model.go b/internal/tui/stackview/model.go index 2ed2be8..3c74c51 100644 --- a/internal/tui/stackview/model.go +++ b/internal/tui/stackview/model.go @@ -63,13 +63,14 @@ var keys = keyMap{ // Model is the Bubbletea model for the interactive stack view. type Model struct { - nodes []BranchNode - trunk stack.BranchRef - version string - cursor int // index into nodes (displayed top-down, so 0 = top of stack) - help help.Model - width int - height int + nodes []BranchNode + trunk stack.BranchRef + version string + stackNumber int + cursor int // index into nodes (displayed top-down, so 0 = top of stack) + help help.Model + width int + height int // scrollOffset tracks vertical scroll position for tall stacks. scrollOffset int @@ -78,8 +79,9 @@ type Model struct { checkoutBranch string } -// New creates a new stack view model. -func New(nodes []BranchNode, trunk stack.BranchRef, version string) Model { +// New creates a new stack view model. stackNumber is the human-facing stack +// number shown in the header; pass 0 when it is not known. +func New(nodes []BranchNode, trunk stack.BranchRef, version string, stackNumber int) Model { h := help.New() h.ShowAll = true @@ -103,11 +105,12 @@ func New(nodes []BranchNode, trunk stack.BranchRef, version string) Model { } return Model{ - nodes: nodes, - trunk: trunk, - version: version, - cursor: cursor, - help: h, + nodes: nodes, + trunk: trunk, + version: version, + stackNumber: stackNumber, + cursor: cursor, + help: h, } } @@ -443,15 +446,22 @@ func (m Model) buildHeaderConfig() shared.HeaderConfig { // is hidden and the actions that depend on it are dimmed; only quit works. allMerged := branchCount > 0 && mergedCount == branchCount + infoLines := make([]shared.HeaderInfoLine, 0, 3) + if m.stackNumber > 0 { + infoLines = append(infoLines, shared.HeaderInfoLine{Icon: "◆", Label: fmt.Sprintf("Stack #%d", m.stackNumber)}) + } else { + infoLines = append(infoLines, shared.HeaderInfoLine{Icon: "✓", Label: "Stack initialized"}) + } + infoLines = append(infoLines, + shared.HeaderInfoLine{Icon: "◼", Label: "Base: " + m.trunk.Branch}, + shared.HeaderInfoLine{Icon: branchIcon, Label: branchInfo}, + ) + return shared.HeaderConfig{ - ShowArt: true, - Title: "View Stack", - Subtitle: "v" + m.version, - InfoLines: []shared.HeaderInfoLine{ - {Icon: "✓", Label: "Stack initialized"}, - {Icon: "◆", Label: "Base: " + m.trunk.Branch}, - {Icon: branchIcon, Label: branchInfo}, - }, + ShowArt: true, + Title: "View Stack", + Subtitle: "v" + m.version, + InfoLines: infoLines, ShortcutColumns: 1, Shortcuts: []shared.ShortcutEntry{ {Key: "↑↓", Desc: "navigate", Disabled: allMerged}, diff --git a/internal/tui/stackview/model_test.go b/internal/tui/stackview/model_test.go index ea4111f..ab81d87 100644 --- a/internal/tui/stackview/model_test.go +++ b/internal/tui/stackview/model_test.go @@ -46,7 +46,7 @@ func TestNew_CursorAtCurrentBranch(t *testing.T) { nodes := makeNodes("b1", "b2", "b3") nodes[1].IsCurrent = true - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) assert.Equal(t, 1, m.cursor) } @@ -54,14 +54,14 @@ func TestNew_CursorAtCurrentBranch(t *testing.T) { func TestNew_CursorAtZeroWhenNoCurrent(t *testing.T) { nodes := makeNodes("b1", "b2", "b3") - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) assert.Equal(t, 0, m.cursor) } func TestUpdate_KeyboardNavigation(t *testing.T) { nodes := makeNodes("b1", "b2", "b3") - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) assert.Equal(t, 0, m.cursor) // Down @@ -98,7 +98,7 @@ func TestUpdate_KeyboardNavigation(t *testing.T) { func TestUpdate_ToggleCommits(t *testing.T) { nodes := makeNodes("b1", "b2") nodes[0].Commits = []git.CommitInfo{{SHA: "abc", Subject: "test"}} - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) assert.False(t, m.nodes[0].CommitsExpanded) @@ -114,7 +114,7 @@ func TestUpdate_ToggleCommits(t *testing.T) { func TestUpdate_ToggleFiles(t *testing.T) { nodes := makeNodes("b1", "b2") - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) assert.False(t, m.nodes[0].FilesExpanded) @@ -130,7 +130,7 @@ func TestUpdate_ToggleFiles(t *testing.T) { func TestUpdate_Quit(t *testing.T) { nodes := makeNodes("b1") - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) quitKeys := []string{"q", "esc", "ctrl+c"} for _, k := range quitKeys { @@ -145,7 +145,7 @@ func TestUpdate_CheckoutOnEnter(t *testing.T) { nodes := makeNodes("b1", "b2") nodes[0].IsCurrent = true nodes[1].PR = &ghapi.PRDetails{Number: 42, URL: "https://github.com/pr/42"} - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) // Move to b2 (non-current) updated, _ := m.Update(keyMsg("down")) @@ -163,7 +163,7 @@ func TestUpdate_CheckoutOnEnter(t *testing.T) { func TestUpdate_EnterOnCurrentDoesNothing(t *testing.T) { nodes := makeNodes("b1", "b2") nodes[0].IsCurrent = true - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) assert.Equal(t, 0, m.cursor) // Press enter on current node @@ -176,7 +176,7 @@ func TestUpdate_EnterOnCurrentDoesNothing(t *testing.T) { func TestView_HeaderShownWhenTallEnough(t *testing.T) { nodes := makeNodes("b1", "b2") - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) // Simulate a tall and wide terminal updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) @@ -195,7 +195,7 @@ func TestView_HeaderShownWhenTallEnough(t *testing.T) { func TestView_HeaderHiddenWhenShort(t *testing.T) { nodes := makeNodes("b1") - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) // Simulate a short terminal (below minHeightForHeader) updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) @@ -211,7 +211,7 @@ func TestView_HeaderHiddenWhenShort(t *testing.T) { func TestView_HeaderHiddenWhenNarrow(t *testing.T) { nodes := makeNodes("b1") - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) // Tall but too narrow for header (below minWidthForHeader) updated, _ := m.Update(tea.WindowSizeMsg{Width: 35, Height: 40}) @@ -224,7 +224,7 @@ func TestView_HeaderHiddenWhenNarrow(t *testing.T) { func TestView_HeaderShortcutsAlwaysVisible(t *testing.T) { nodes := makeNodes("b1", "b2") - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) // Even at medium width, shortcuts should still be visible updated, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 40}) @@ -238,7 +238,7 @@ func TestView_HeaderShortcutsAlwaysVisible(t *testing.T) { func TestView_HeaderShowsMergedCount(t *testing.T) { nodes := makeNodes("b1", "b2", "b3") nodes[0].Ref.PullRequest = &stack.PullRequestRef{Merged: true} - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) m = updated.(Model) @@ -251,7 +251,7 @@ func TestView_HeaderShowsQueuedCount(t *testing.T) { nodes := makeNodes("b1", "b2", "b3") nodes[1].Ref.Queued = true nodes[1].Ref.PullRequest = &stack.PullRequestRef{Number: 10} - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) m = updated.(Model) @@ -263,7 +263,7 @@ func TestView_HeaderShowsQueuedCount(t *testing.T) { func TestView_QueuedPRShowsQueuedLabel(t *testing.T) { nodes := makeNodes("b1") nodes[0].PR = &ghapi.PRDetails{Number: 99, IsQueued: true} - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) m = updated.(Model) @@ -294,7 +294,7 @@ func TestView_BranchProgressIcon(t *testing.T) { for _, idx := range tt.merged { nodes[idx].Ref.PullRequest = &stack.PullRequestRef{Merged: true} } - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) m = updated.(Model) @@ -306,7 +306,7 @@ func TestView_BranchProgressIcon(t *testing.T) { func TestMouseClick_HeaderAreaIgnored(t *testing.T) { nodes := makeNodes("b1", "b2") - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) m = updated.(Model) @@ -323,7 +323,7 @@ func TestMouseClick_HeaderAreaIgnored(t *testing.T) { func TestScrollClamp_CannotScrollPastContent(t *testing.T) { nodes := makeNodes("b1", "b2") - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) // Tall terminal with plenty of room for content updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 40}) @@ -346,7 +346,7 @@ func TestScrollClamp_CannotScrollPastContent(t *testing.T) { func TestUpdate_CursorSkipsMergedBranches(t *testing.T) { nodes := makeNodes("b1", "b2", "b3") nodes[1].Ref.PullRequest = &stack.PullRequestRef{Number: 2, Merged: true} - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) assert.Equal(t, 0, m.cursor, "cursor should start on first non-merged branch") // Down should skip b2 (merged) and land on b3 @@ -363,7 +363,7 @@ func TestUpdate_CursorSkipsMergedBranches(t *testing.T) { func TestNew_CursorSkipsMergedBranch(t *testing.T) { nodes := makeNodes("b1", "b2", "b3") nodes[0].Ref.PullRequest = &stack.PullRequestRef{Number: 1, Merged: true} - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) assert.Equal(t, 1, m.cursor, "cursor should skip merged b1 and start on b2") } @@ -371,7 +371,7 @@ func TestNew_CursorSkipsMergedCurrentBranch(t *testing.T) { nodes := makeNodes("b1", "b2", "b3") nodes[0].IsCurrent = true nodes[0].Ref.PullRequest = &stack.PullRequestRef{Number: 1, Merged: true} - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) assert.Equal(t, 1, m.cursor, "cursor should not start on merged current branch") } @@ -380,7 +380,7 @@ func TestUpdate_EnterOnMergedDoesNothing(t *testing.T) { // by having b1 active and b2 merged and b3 active. nodes := makeNodes("b1", "b2") nodes[0].Ref.PullRequest = &stack.PullRequestRef{Number: 1, Merged: true} - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) // Cursor is on b2 (first non-merged). Manually set to b1 to test guard. m.cursor = 0 @@ -400,12 +400,12 @@ func makeAllMergedNodes(branches ...string) []BranchNode { } func TestNew_CursorHiddenWhenAllMerged(t *testing.T) { - m := New(makeAllMergedNodes("b1", "b2"), testTrunk, "0.0.1") + m := New(makeAllMergedNodes("b1", "b2"), testTrunk, "0.0.1", 0) assert.Equal(t, -1, m.cursor, "cursor should be hidden when every branch is merged") } func TestUpdate_AllMergedCursorStaysHidden(t *testing.T) { - m := New(makeAllMergedNodes("b1", "b2", "b3"), testTrunk, "0.0.1") + m := New(makeAllMergedNodes("b1", "b2", "b3"), testTrunk, "0.0.1", 0) updated, _ := m.Update(keyMsg("down")) m = updated.(Model) @@ -422,7 +422,7 @@ func TestUpdate_AllMergedCursorStaysHidden(t *testing.T) { } func TestView_AllMergedRendersWithoutPanic(t *testing.T) { - m := New(makeAllMergedNodes("b1", "b2"), testTrunk, "0.0.1") + m := New(makeAllMergedNodes("b1", "b2"), testTrunk, "0.0.1", 0) updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) m = updated.(Model) // Should not panic with a hidden (-1) cursor. @@ -431,7 +431,7 @@ func TestView_AllMergedRendersWithoutPanic(t *testing.T) { } func TestBuildHeaderConfig_DisablesShortcutsWhenAllMerged(t *testing.T) { - m := New(makeAllMergedNodes("b1", "b2"), testTrunk, "0.0.1") + m := New(makeAllMergedNodes("b1", "b2"), testTrunk, "0.0.1", 0) cfg := m.buildHeaderConfig() require.NotEmpty(t, cfg.Shortcuts) @@ -445,7 +445,7 @@ func TestBuildHeaderConfig_DisablesShortcutsWhenAllMerged(t *testing.T) { } func TestBuildHeaderConfig_ShortcutsEnabledWithActiveBranches(t *testing.T) { - m := New(makeNodes("b1", "b2"), testTrunk, "0.0.1") + m := New(makeNodes("b1", "b2"), testTrunk, "0.0.1", 0) cfg := m.buildHeaderConfig() require.NotEmpty(t, cfg.Shortcuts) @@ -453,3 +453,22 @@ func TestBuildHeaderConfig_ShortcutsEnabledWithActiveBranches(t *testing.T) { assert.False(t, sc.Disabled, "%q should be enabled when there are active branches", sc.Desc) } } + +func TestBuildHeaderConfig_ShowsStackNumber(t *testing.T) { + m := New(makeNodes("b1", "b2"), testTrunk, "0.0.1", 7) + cfg := m.buildHeaderConfig() + + found := false + for _, line := range cfg.InfoLines { + if line.Label == "Stack #7" { + found = true + } + } + assert.True(t, found, "header should show the stack number when known") + + // When the number is unknown (0), no stack-number line is shown. + m0 := New(makeNodes("b1", "b2"), testTrunk, "0.0.1", 0) + for _, line := range m0.buildHeaderConfig().InfoLines { + assert.NotContains(t, line.Label, "Stack #") + } +} diff --git a/internal/tui/submitview/model.go b/internal/tui/submitview/model.go index 2537a83..b1264fc 100644 --- a/internal/tui/submitview/model.go +++ b/internal/tui/submitview/model.go @@ -50,6 +50,9 @@ type Options struct { // true, and once the user has deselected all new PRs, the TUI offers a // "STACK N PRs" action to link the existing open PRs into a stack. CanCreateStack bool + // StackNumber is the human-facing stack number shown in the header. Zero + // when the local stack has not yet been created on GitHub. + StackNumber int } // Model is the Bubble Tea model backing the interactive `gh stack submit` TUI. @@ -63,6 +66,10 @@ type Model struct { // remote stack object yet, but one could be created. canCreateStack bool + // stackNumber is the human-facing stack number shown in the header (0 when + // the stack has not been created on GitHub yet). + stackNumber int + cursor int // index into nodes (the focused branch) width, height int @@ -149,6 +156,7 @@ func New(opts Options) Model { cursor: cursor, canCreateStack: opts.CanCreateStack, + stackNumber: opts.StackNumber, titleArea: tia, descArea: ta, diff --git a/internal/tui/submitview/model_test.go b/internal/tui/submitview/model_test.go index d411892..11c8630 100644 --- a/internal/tui/submitview/model_test.go +++ b/internal/tui/submitview/model_test.go @@ -292,9 +292,9 @@ func TestHeaderConfig_InfoLines(t *testing.T) { // Repo and base are the first two info lines, in order. cfg := testModel(t, newNodes()).buildHeaderConfig() require.GreaterOrEqual(t, len(cfg.InfoLines), 3) - assert.Equal(t, "○", cfg.InfoLines[0].Icon) + assert.Equal(t, "◆", cfg.InfoLines[0].Icon) assert.Equal(t, "Repo: myorg/myrepo", cfg.InfoLines[0].Label) - assert.Equal(t, "◆", cfg.InfoLines[1].Icon) + assert.Equal(t, "○", cfg.InfoLines[1].Icon) assert.Equal(t, "Base: main", cfg.InfoLines[1].Label) // Two included NEW branches -> solid (styled) square, pluralized. @@ -320,6 +320,45 @@ func TestHeaderConfig_InfoLines(t *testing.T) { assert.Nil(t, noneLast.IconStyle, "the empty line uses the default icon style") } +func TestHeaderConfig_StackNumberInRepoLine(t *testing.T) { + // When the stack has a number, it is folded into the first info line + // alongside the repo; otherwise the line shows just the repo. + m := New(Options{ + Nodes: newNodes(), + Trunk: stack.BranchRef{Branch: "main"}, + RepoLabel: "myorg/myrepo", + Version: "1.0.0", + StackNumber: 7, + }) + assert.Equal(t, "Stack #7 • myorg/myrepo", m.buildHeaderConfig().InfoLines[0].Label) + + // Without a stack number, the first line is just the repo. + assert.Equal(t, "Repo: myorg/myrepo", testModel(t, newNodes()).buildHeaderConfig().InfoLines[0].Label) +} + +func TestLeftPanel_HeaderShowsStackNumber(t *testing.T) { + // The left-panel "STACK" header includes the stack number when known. + withNum := New(Options{ + Nodes: newNodes(), + Trunk: stack.BranchRef{Branch: "main"}, + RepoLabel: "myorg/myrepo", + Version: "1.0.0", + StackNumber: 42, + }) + sized, _ := withNum.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + withNum = sized.(Model) + leftW, _ := withNum.panelWidths() + header := withNum.buildLeftRows(leftW - 2)[0].text + assert.Contains(t, header, "STACK #42") + + // Without a stack number, the header is the bare "STACK" label. + noNum := testModel(t, newNodes()) + leftW2, _ := noNum.panelWidths() + header2 := noNum.buildLeftRows(leftW2 - 2)[0].text + assert.Contains(t, header2, "STACK") + assert.NotContains(t, header2, "STACK #") +} + func TestView_ClosedBanner(t *testing.T) { nodes := newNodes() nodes = append(nodes, newNode("feat/auth/legacy", StateClosed)) diff --git a/internal/tui/submitview/render.go b/internal/tui/submitview/render.go index e1d8597..a3107e2 100644 --- a/internal/tui/submitview/render.go +++ b/internal/tui/submitview/render.go @@ -42,10 +42,13 @@ func (m Model) buildHeaderConfig() shared.HeaderConfig { repo = "unknown" } - infoLines := []shared.HeaderInfoLine{ - {Icon: "○", Label: "Repo: " + repo}, - {Icon: "◆", Label: "Base: " + m.trunk.Branch}, + infoLines := make([]shared.HeaderInfoLine, 0, 3) + if m.stackNumber > 0 { + infoLines = append(infoLines, shared.HeaderInfoLine{Icon: "◆", Label: fmt.Sprintf("Stack #%d • %s", m.stackNumber, repo)}) + } else { + infoLines = append(infoLines, shared.HeaderInfoLine{Icon: "◆", Label: "Repo: " + repo}) } + infoLines = append(infoLines, shared.HeaderInfoLine{Icon: "○", Label: "Base: " + m.trunk.Branch}) // Third line mirrors the modify header's pending line: a solid yellow square // with the count when PRs will be created, or an empty square otherwise. diff --git a/internal/tui/submitview/screen.go b/internal/tui/submitview/screen.go index a208855..6f2e88d 100644 --- a/internal/tui/submitview/screen.go +++ b/internal/tui/submitview/screen.go @@ -765,8 +765,12 @@ func leftPanelBox(content string, width, height int) string { // nodes/cursor/width, so the mouse layer can recompute it to resolve clicks. func (m Model) buildLeftRows(fullW int) []leftRow { cur := m.cursor + stackLabel := "STACK" + if m.stackNumber > 0 { + stackLabel = fmt.Sprintf("STACK #%d", m.stackNumber) + } rows := []leftRow{ - {text: pad(1, false) + sectionLabelStyle.Render("STACK"), branch: -1}, + {text: pad(1, false) + sectionLabelStyle.Render(stackLabel), branch: -1}, {text: m.gapRow(fullW, false, cur == 0), branch: -1}, // blank under STACK; top pad for branch 0 } for i := range m.nodes { From ac6079abb713ac8b0bb3402222022ec67e90f413 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Sun, 12 Jul 2026 17:39:19 -0400 Subject: [PATCH 3/4] Update docs and agent instructions for the new API - cli.md: document checkout/unstack by stack number and drop the "PATs are not supported" note (any gh-authenticated user can now run stack operations). - quick-start.md: drop the PAT-not-supported note. - AGENTS.md / copilot-instructions.md: ClientOps is now 13 methods over the public Stacks REST API; remove the TokenForHostFn test hook; note the stack file's id/number identity. - SKILL.md: add checkout/unstack-by-stack-number quick references. Copilot-Session: 03673c26-a245-42da-93ed-dfcebc92a740 --- .github/copilot-instructions.md | 2 +- AGENTS.md | 7 +++-- .../docs/getting-started/quick-start.md | 4 --- docs/src/content/docs/reference/cli.md | 30 ++++++++++++------- skills/gh-stack/SKILL.md | 4 ++- 5 files changed, 27 insertions(+), 20 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3c239e4..d564e18 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -17,7 +17,7 @@ No Makefile, no code generation, no external linter config. Standard Go toolchai - `cmd/`: One Cobra command per file. Each exports `Cmd(cfg *config.Config)` with logic in `run()`. - `internal/git/`: `Ops` interface (52 methods) wrapping git CLI. `MockOps` for tests. Package-level functions delegate to swappable `ops` variable. -- `internal/github/`: `ClientOps` interface (11 methods) for GitHub API. `MockClient` for tests. +- `internal/github/`: `ClientOps` interface (13 methods) for GitHub API. `MockClient` for tests. Stack operations use the public Stacks REST API (`/repos/{owner}/{repo}/stacks`). - `internal/config/`: `Config` struct passed to all commands. Holds I/O, colors, and test hooks (`SelectFn`, `ConfirmFn`, `InputFn`, `GitHubClientOverride`). - `internal/stack/`: Stack file (`.git/gh-stack`, JSON) management with file locking. - `internal/tui/`: bubbletea views (`stackview`, `modifyview`). diff --git a/AGENTS.md b/AGENTS.md index 953438b..fc81dd5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ internal/ gitops.go # Ops interface (52 methods) mock_ops.go # MockOps. Each method has a corresponding *Fn field. github/ # github.ClientOps interface + real Client - client_interface.go # ClientOps interface (11 methods) + client_interface.go # ClientOps interface (13 methods) mock_client.go # MockClient. Uses function-pointer fields for testing. stack/ # stack file (.git/gh-stack) management, JSON schema, locking schema.json # JSON Schema for the stack file format @@ -109,13 +109,14 @@ if errors.As(err, &exitErr) { ... } ### Key interfaces - **`git.Ops`** (`internal/git/gitops.go`): 52 methods wrapping git CLI calls. The production implementation uses `cli/go-gh`'s `client.Command()` via `run()` and `runSilent()` helpers. Package-level functions (e.g., `git.CurrentBranch()`) delegate to a swappable package-level `ops` variable. -- **`github.ClientOps`** (`internal/github/client_interface.go`): 11 methods for GitHub API (PRs, stacks). Injected via `cfg.GitHubClientOverride` in tests. -- **`config.Config`** (`internal/config/config.go`): Central configuration passed to all commands. Holds I/O streams, color functions, and test hook fields (`SelectFn`, `ConfirmFn`, `InputFn`, `TokenForHostFn`, `RepoOverride`). +- **`github.ClientOps`** (`internal/github/client_interface.go`): 13 methods for GitHub API (PRs, stacks). Stack operations use the public Stacks REST API (`/repos/{owner}/{repo}/stacks`): `ListStacks`, `FindStackForPR`, `GetStack`, `CreateStack`, `AddToStack` (delta append), `Unstack`. Injected via `cfg.GitHubClientOverride` in tests. +- **`config.Config`** (`internal/config/config.go`): Central configuration passed to all commands. Holds I/O streams, color functions, and test hook fields (`SelectFn`, `ConfirmFn`, `InputFn`, `RepoOverride`). ### Stack file - **Location:** `.git/gh-stack` (JSON format, schema version 1). - **Schema:** `internal/stack/schema.json`. +- **Identity:** each stack stores GitHub's global `id` (string) and repo-scoped `number` (int, shown in the GitHub UI and used as the primary way to reference a stack, e.g. `gh stack checkout `). `number` may be `0` for stack files created before it was tracked; it is backfilled from the API on the next stack operation. - **Locking:** Exclusive file lock at `.git/gh-stack.lock` with 5-second timeout. Errors surface as `LockError`. - **Staleness:** Concurrent modifications detected via `StaleError`. diff --git a/docs/src/content/docs/getting-started/quick-start.md b/docs/src/content/docs/getting-started/quick-start.md index 340856a..4c942d4 100644 --- a/docs/src/content/docs/getting-started/quick-start.md +++ b/docs/src/content/docs/getting-started/quick-start.md @@ -19,10 +19,6 @@ Stacked PRs is currently in private preview. This feature will **not work** unle gh extension install github/gh-stack ``` -:::note[Authentication] -The `gh stack` CLI requires OAuth authentication via `gh auth login`. Personal access tokens (PATs) are not supported. -::: - ## Set Up AI Agent Integration If you use AI coding agents (like GitHub Copilot), install the gh-stack skill so they know how to work with Stacked PRs: diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 6a1bc3a..7f6b10a 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -12,7 +12,7 @@ gh extension install github/gh-stack Requires the [GitHub CLI](https://cli.github.com/) (`gh`) v2.0+. :::note[Authentication] -The `gh stack` CLI requires OAuth authentication via `gh auth login`. Personal access tokens (PATs) are not supported. +The `gh stack` CLI uses your GitHub CLI authentication — run `gh auth login` if you haven't already. ::: --- @@ -135,13 +135,15 @@ gh stack view --json ### `gh stack checkout` -Check out a stack from a pull request number, URL, or branch name. +Check out a stack by its stack number, a pull request number, a PR URL, or a branch name. ```sh -gh stack checkout [ | | ] +gh stack checkout [ | | | ] ``` -When a PR number or URL is provided (e.g., `123` or `https://github.com/owner/repo/pull/123`), the command fetches the stack on GitHub, pulls the branches, and sets up the stack locally. If the stack already exists locally and matches, it switches to the branch. If the local and remote stacks have different compositions, you'll be prompted to resolve the conflict. +A bare number is interpreted first as a stack or PR number (repo-scoped identifiers shown in the GitHub UI). If nothing matches the number, it is tried as a branch name. + +When a remote stack is referenced, the command fetches the stack on GitHub, pulls the branches, and sets up the stack locally. If the stack already exists locally and matches, it switches to the branch. If the local and remote stacks have different compositions, you'll be prompted to resolve the conflict. When a branch name is provided, the command resolves it against locally tracked stacks only. @@ -150,6 +152,9 @@ When run without arguments in an interactive terminal, shows a menu of all local **Examples:** ```sh +# Check out a stack by its stack number +gh stack checkout 7 + # Check out a stack by PR number gh stack checkout 42 @@ -229,28 +234,31 @@ gh stack modify --abort ### `gh stack unstack` -Remove a stack from local tracking and delete it on GitHub. Also available as `gh stack delete`. +Remove a stack from local tracking and unstack it on GitHub. Also available as `gh stack delete`. ```sh -gh stack unstack [flags] +gh stack unstack [] [flags] ``` -You must have a branch from the stack checked out locally. The command targets the active stack — the one that contains the currently checked out branch. +With no argument, the command targets the active stack — the one that contains the currently checked out branch. Provide a stack number (the identifier shown in the github.com stack UI) to target a specific locally tracked stack instead. -Deletes the stack on GitHub first, if it exists, then removes it from local tracking. If the remote deletion fails, the local state is left untouched so you can retry. Use `--local` to skip the remote deletion and only remove local tracking. +PRs that are merged, merging, or queued for merge cannot be removed from a stack on GitHub and are left part of the stack. When every pull request is removed, the stack is dissolved and local tracking is removed; when some pull requests remain stacked, local tracking is kept. Use `--local` to skip the remote operation and only remove local tracking. This is useful when you need to restructure a stack — remove a branch, insert a branch, reorder branches, rename branches, or make other large changes. After unstacking, use `gh stack init` to re-create the stack with the desired structure — existing branches are adopted automatically. | Flag | Description | |------|-------------| -| `--local` | Only delete the stack locally (keep it on GitHub) | +| `--local` | Only remove the stack locally (keep it on GitHub) | **Examples:** ```sh -# Delete the stack on GitHub and remove local tracking +# Unstack the current stack on GitHub and remove local tracking gh stack unstack +# Unstack a specific stack by its stack number +gh stack unstack 7 + # Only remove local tracking gh stack unstack --local ``` @@ -317,7 +325,7 @@ Performs a synchronization of the entire stack: A clean remote-ahead update (PRs added on top of your local stack) is pulled down automatically without prompting, so `sync` is safe to run in automation. Sync only prompts when the stacks have truly diverged. -#### Diverged stacks +**Diverged stacks** When neither stack is a clean prefix of the other — for example, you added a branch locally while separate PRs were added to the same stack on GitHub — sync cannot merge the two automatically. In an interactive terminal it offers three choices: diff --git a/skills/gh-stack/SKILL.md b/skills/gh-stack/SKILL.md index aca1176..2fdc274 100644 --- a/skills/gh-stack/SKILL.md +++ b/skills/gh-stack/SKILL.md @@ -161,9 +161,11 @@ Small, incidental fixes (e.g., fixing a typo you noticed) can go in the current | View stack details (JSON) | `gh stack view --json` | | Switch branches up/down in stack | `gh stack up [n]` / `gh stack down [n]` | | Switch to top/bottom branch | `gh stack top` / `gh stack bottom` | +| Check out by stack number | `gh stack checkout 7` | | Check out by PR | `gh stack checkout 42` | | Check out by branch (local only) | `gh stack checkout feature-auth` | -| Tear down a stack to restructure it | `gh stack unstack` | +| Tear down the current stack to restructure it | `gh stack unstack` | +| Tear down a specific stack by number | `gh stack unstack 7` | --- From ec712bc9398f497d6ab4521af73bb417946e2b50 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 14 Jul 2026 08:45:45 -0400 Subject: [PATCH 4/4] address review comments --- cmd/checkout.go | 18 ++++++--- cmd/checkout_test.go | 47 +++++++++++++++++++++++ cmd/unstack_test.go | 47 +++++++++++++++++++++++ cmd/utils.go | 89 +++++++++++++++++++++++++++++++++++++++++--- cmd/utils_test.go | 37 ++++++++++++++++++ 5 files changed, 227 insertions(+), 11 deletions(-) diff --git a/cmd/checkout.go b/cmd/checkout.go index 3d6eb91..5fb20c0 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -110,7 +110,7 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { return err } } else if prNumber, parseErr := strconv.Atoi(opts.target); parseErr == nil && prNumber > 0 { - // Target is a pure integer — try local PR, then remote API, then branch name + // Target is a pure integer — try stack number, then PR, then branch name s, targetBranch, err = resolveNumericTarget(cfg, sf, gitDir, prNumber, opts.target) if err != nil { return err @@ -145,18 +145,23 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { return nil } -// resolveNumericTarget handles the case where the user passes a pure integer. -// The integer is interpreted as, in order: +// resolveNumericTarget handles the case where the user passes a pure integer or +// a PR URL. The number is interpreted as, in order: // 1. A stack number (the primary identifier) // 2. A locally tracked PR number // 3. A PR number whose stack is discovered from GitHub // 4. A branch name (for numeric branch names like "123") +// +// Stack, PR, and issue numbers share a single repo-scoped numberspace, +// so a given number is only ever one object type; a number that is not a stack +// simply misses at step 1 and resolves at a later step. func resolveNumericTarget(cfg *config.Config, sf *stack.StackFile, gitDir string, number int, raw string) (*stack.Stack, string, error) { // 1. Try as a stack number (the primary identifier). if s, targetBranch, err := checkoutStackByNumber(cfg, sf, gitDir, number); err == nil { return s, targetBranch, nil } else if !errors.Is(err, errStackNumberNotFound) { - // A real error (composition conflict, interrupted import, etc.) — surface it. + // A real error during import/reconcile (composition conflict, interrupted + // import, etc.) — surface it rather than trying other interpretations. return nil, "", err } @@ -254,7 +259,10 @@ var errStackNumberNotFound = errors.New("stack number not found") // checkoutStackByNumber discovers a stack from GitHub by its stack number, // reconciles it with any local state, and checks out the top-most unmerged // branch. It returns errStackNumberNotFound when the number does not resolve to -// a stack so the caller can fall back to other interpretations. +// a stack so the caller can fall back to other interpretations. Because stack, +// PR, and issue numbers share one repo-scoped numberspace, a number that +// belongs to a PR (or nothing) simply misses here and is resolved by the +// caller's later steps. func checkoutStackByNumber(cfg *config.Config, sf *stack.StackFile, gitDir string, stackNumber int) (*stack.Stack, string, error) { client, err := cfg.GitHubClient() if err != nil { diff --git a/cmd/checkout_test.go b/cmd/checkout_test.go index 4725ff0..1cfb808 100644 --- a/cmd/checkout_test.go +++ b/cmd/checkout_test.go @@ -354,6 +354,53 @@ func TestCheckout_ByStackNumber(t *testing.T) { assert.Equal(t, 7, sf.Stacks[0].Number) } +func TestCheckout_ByStackNumber_404FallsThroughToPR(t *testing.T) { + // A 404 from GetStack means no such stack, so the number is tried as a PR. + gitDir := t.TempDir() + var checkedOut string + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + BranchExistsFn: func(name string) bool { return name == "main" }, + FetchFn: func(string) error { return nil }, + CreateBranchFn: func(string, string) error { return nil }, + SetUpstreamTrackingFn: func(string, string) error { return nil }, + RevParseFn: func(string) (string, error) { return "abc123", nil }, + ResolveRemoteFn: func(string) (string, error) { return "origin", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return nil + }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{}) + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(int) (*github.RemoteStack, error) { + return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"} + }, + FindStackForPRFn: func(int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 1, Number: 1, PullRequests: []int{11, 12}}, nil + }, + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + prs := map[int]*github.PullRequest{ + 11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "main", URL: "https://github.com/o/r/pull/11"}, + 12: {ID: "PR_12", Number: 12, HeadRefName: "feat-3", BaseRefName: "feat-2", URL: "https://github.com/o/r/pull/12"}, + } + return prs[n], nil + }, + } + + err := runCheckout(cfg, &checkoutOptions{target: "11"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, "feat-2", checkedOut, "the number should resolve as PR #11 after a stack 404") + assert.Contains(t, output, "Imported stack with 2 branches") +} + func TestCheckout_NumericTarget_BranchExistsNoStack(t *testing.T) { gitDir := t.TempDir() var checkedOut string diff --git a/cmd/unstack_test.go b/cmd/unstack_test.go index 998086f..d2ac26d 100644 --- a/cmd/unstack_test.go +++ b/cmd/unstack_test.go @@ -483,3 +483,50 @@ func TestUnstack_ByStackNumber_NotTrackedLocally(t *testing.T) { require.NoError(t, err) require.Len(t, sf.Stacks, 1) } + +func TestUnstack_ByStackNumber_LegacyStackResolvedByID(t *testing.T) { + // A stack tracked before the number was recorded (Number == 0) is resolved + // by mapping its internal ID to the remote stack number, and the backfilled + // number is persisted. + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + ID: "99", // legacy: internal ID present, Number unset (0) + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}}, + }, + }) + + var unstackedNumber int + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{{ID: 99, Number: 7, PullRequests: []int{101, 102}}}, nil + }, + UnstackFn: func(n int) (*github.RemoteStack, bool, error) { + unstackedNumber = n + // Some PRs remain stacked, so local tracking is kept. + return &github.RemoteStack{ID: 99, Number: 7, PullRequests: []int{102}}, false, nil + }, + } + + err := runUnstack(cfg, &unstackOptions{stackNumber: 7}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, 7, unstackedNumber, "should resolve the legacy stack and unstack by its remote number") + assert.Contains(t, output, "remain stacked on GitHub") + + // The backfilled number is persisted to the stack file. + sf, loadErr := stack.Load(gitDir) + require.NoError(t, loadErr) + require.Len(t, sf.Stacks, 1) + assert.Equal(t, 7, sf.Stacks[0].Number, "the resolved stack number should be persisted") +} diff --git a/cmd/utils.go b/cmd/utils.go index 0634cb6..c57567d 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -260,8 +260,11 @@ func loadStack(cfg *config.Config, branch string) (*loadStackResult, error) { } // loadStackByNumber loads the locally tracked stack whose stack number matches -// the given value. It prints a helpful error and returns a non-nil error when -// no local stack has that number. +// the given value. Stack files created before the number was tracked store only +// the internal ID (Number == 0); such legacy stacks are resolved by mapping +// their ID to a remote stack number so they can still be targeted by number. It +// prints a helpful error and returns a non-nil error when no local stack +// resolves to that number. func loadStackByNumber(cfg *config.Config, number int) (*loadStackResult, error) { gitDir, err := git.GitDir() if err != nil { @@ -275,6 +278,28 @@ func loadStackByNumber(cfg *config.Config, number int) (*loadStackResult, error) return nil, fmt.Errorf("failed to load stack state: %w", err) } + // Direct match on the tracked stack number. + if result := stackResultByNumber(sf, gitDir, number); result != nil { + return result, nil + } + + // No direct match — backfill legacy stacks' numbers from the remote and + // retry, so `gh stack unstack ` also works for stacks tracked + // before the number was recorded locally. + if backfillLegacyStackNumbers(cfg, sf, gitDir) { + if result := stackResultByNumber(sf, gitDir, number); result != nil { + return result, nil + } + } + + cfg.Errorf("stack #%d is not tracked locally", number) + cfg.Printf("Run `%s` to check it out first", cfg.ColorCyan(fmt.Sprintf("gh stack checkout %d", number))) + return nil, fmt.Errorf("stack #%d is not tracked locally", number) +} + +// stackResultByNumber returns a loadStackResult for the locally tracked stack +// whose Number matches, or nil when none does. +func stackResultByNumber(sf *stack.StackFile, gitDir string, number int) *loadStackResult { for i := range sf.Stacks { if sf.Stacks[i].Number == number { currentBranch, _ := git.CurrentBranch() @@ -283,13 +308,59 @@ func loadStackByNumber(cfg *config.Config, number int) (*loadStackResult, error) StackFile: sf, Stack: &sf.Stacks[i], CurrentBranch: currentBranch, - }, nil + } } } + return nil +} - cfg.Errorf("stack #%d is not tracked locally", number) - cfg.Printf("Run `%s` to check it out first", cfg.ColorCyan(fmt.Sprintf("gh stack checkout %d", number))) - return nil, fmt.Errorf("stack #%d is not tracked locally", number) +// backfillLegacyStackNumbers fills in the human-facing Number for locally +// tracked stacks that predate it (Number == 0 but ID set) by mapping their +// internal ID to the remote stack list, persisting any updates. Returns true +// when at least one number was filled in. Best-effort: returns false on any +// client or API error rather than failing the caller. +func backfillLegacyStackNumbers(cfg *config.Config, sf *stack.StackFile, gitDir string) bool { + needsResolve := false + for i := range sf.Stacks { + if sf.Stacks[i].Number == 0 && sf.Stacks[i].ID != "" { + needsResolve = true + break + } + } + if !needsResolve { + return false + } + + client, err := cfg.GitHubClient() + if err != nil { + return false + } + stacks, err := client.ListStacks() + if err != nil { + return false + } + numberByID := make(map[string]int, len(stacks)) + for _, rs := range stacks { + numberByID[strconv.Itoa(rs.ID)] = rs.Number + } + + changed := false + for i := range sf.Stacks { + if sf.Stacks[i].Number != 0 || sf.Stacks[i].ID == "" { + continue + } + if n, ok := numberByID[sf.Stacks[i].ID]; ok && n != 0 { + sf.Stacks[i].Number = n + changed = true + } + } + if changed { + if err := stack.Save(gitDir, sf); err != nil { + // Non-fatal: the in-memory backfill still lets us resolve the target. + cfg.Warningf("could not persist stack numbers: %v", err) + } + } + return changed } // handleSaveError translates a stack.Save error into the appropriate user @@ -608,6 +679,12 @@ func syncStackPRsFromRemote(client github.ClientOps, s *stack.Stack) (map[string for _, rs := range stacks { if strconv.Itoa(rs.ID) == s.ID { remotePRNumbers = rs.PRNumbers() + // Backfill the human-facing stack number for stack files created + // before it was tracked, so callers (view, submit TUI) can display + // it. Persisted by whichever command later saves the stack file. + if s.Number == 0 { + s.Number = rs.Number + } break } } diff --git a/cmd/utils_test.go b/cmd/utils_test.go index d2b510f..feb89fc 100644 --- a/cmd/utils_test.go +++ b/cmd/utils_test.go @@ -540,6 +540,43 @@ func TestSyncStackPRs_RemoteStack_UsesStackAPI(t *testing.T) { assert.True(t, s.Branches[1].PullRequest.Merged) } +func TestSyncStackPRs_BackfillsStackNumber(t *testing.T) { + // A stack tracked before the number was recorded (Number == 0) gets its + // number backfilled from the remote during the shared sync, so callers can + // display it. + s := &stack.Stack{ + ID: "100", // legacy: Number unset + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + }, + } + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + {ID: 100, Number: 5, PullRequests: []int{10, 11}}, + }, nil + }, + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + switch number { + case 10: + return &github.PullRequest{Number: 10, HeadRefName: "b1", State: "OPEN"}, nil + case 11: + return &github.PullRequest{Number: 11, HeadRefName: "b2", State: "OPEN"}, nil + } + return nil, nil + }, + } + + _ = syncStackPRs(cfg, s) + collectOutput(cfg, outR, errR) + + assert.Equal(t, 5, s.Number, "the stack number should be backfilled from the remote") +} + func TestSyncStackPRs_RemoteStack_ClosedPRStaysAssociated(t *testing.T) { // When using the stack API, a closed (not merged) PR should remain // associated — the stack API is the source of truth, not PR state.