Skip to content

Commit ceffffc

Browse files
authored
Interactive stack picker for checkout (#179)
* Add an interactive stack picker for checkout Running `gh stack checkout` with no argument previously showed a plain text prompt of locally tracked stacks only. It could not surface stacks that exist only on the remote, gave no sense of a stack's state, and listed fully merged stacks that can no longer be added to. Replace it with an interactive picker (new package internal/tui/checkoutview) that lists every stack available to you, reconciling the local stack file with the Stacks REST API: - Merges local and remote stacks, matched by stack id/number, and labels each Local (present locally, even if also tracked on the remote) or Remote (only on GitHub). Fully merged stacks are filtered out. - Shows compact columns: stack number, first...last branch, base branch, a muted status bar summarizing merged/open/closed/unpushed PRs, type, and relative created time. - Offers All / Local / Remote tabs and `/` type-to-filter search. The picker renders inline rather than taking over the screen: it shows up to ten rows with a scroll indicator, shrinks to fit short terminals, and clears itself on exit. Selecting a locally available stack checks out its top unmerged branch; selecting a remote-only stack clones it down through the existing checkout-by-number import flow. When the Stacks API is unavailable (stacks not enabled, no auth, or a network error), the picker degrades gracefully to a local-only list. Also update the README, overview, and CLI reference for the new behavior. * search by entire branch list * address review comments
1 parent f880f0d commit ceffffc

10 files changed

Lines changed: 2178 additions & 40 deletions

File tree

README.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -165,21 +165,26 @@ gh stack add -m "Refactor utils" cleanup-layer
165165

166166
### `gh stack checkout`
167167

168-
Check out a stack from a pull request number, URL, or branch name.
168+
Check out a stack by its stack number, a pull request number, a PR URL, or a branch name.
169169

170170
```
171-
gh stack checkout [<pr-number> | <pr-url> | <branch>]
171+
gh stack checkout [<stack-number> | <pr-number> | <pr-url> | <branch>]
172172
```
173173

174-
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.
174+
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.
175+
176+
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.
175177

176178
When a branch name is provided, the command resolves it against locally tracked stacks only.
177179

178-
When run without arguments in an interactive terminal, shows a menu of all locally available stacks to choose from.
180+
When run without arguments in an interactive terminal, opens a searchable picker listing every stack available to you — both the stacks tracked locally and the stacks that exist only on GitHub. Each row shows the stack number, its bottom and top branch, base branch, a status bar summarizing how many of its pull requests are merged, open, closed, or not yet pushed, and whether the stack is available locally or only on the remote. Filter with the All / Local / Remote tabs or type `/` to search; fully merged stacks are omitted. Selecting a remote-only stack clones it locally before switching to it.
179181

180182
**Examples:**
181183

182184
```sh
185+
# Check out a stack by its stack number
186+
gh stack checkout 7
187+
183188
# Check out a stack by PR number
184189
gh stack checkout 42
185190

@@ -189,7 +194,7 @@ gh stack checkout https://github.com/owner/repo/pull/42
189194
# Check out a stack by branch name (local only)
190195
gh stack checkout feature-auth
191196

192-
# Interactive — select from locally tracked stacks
197+
# Interactive — pick from all available stacks (local and remote)
193198
gh stack checkout
194199
```
195200

cmd/checkout.go

Lines changed: 100 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ import (
66
"strconv"
77
"strings"
88

9+
tea "github.com/charmbracelet/bubbletea"
910
"github.com/cli/go-gh/v2/pkg/api"
1011
"github.com/cli/go-gh/v2/pkg/prompter"
1112
"github.com/github/gh-stack/internal/config"
1213
"github.com/github/gh-stack/internal/git"
1314
"github.com/github/gh-stack/internal/github"
1415
"github.com/github/gh-stack/internal/stack"
16+
"github.com/github/gh-stack/internal/tui/checkoutview"
1517
"github.com/spf13/cobra"
1618
)
1719

@@ -42,8 +44,10 @@ it simply switches to the branch.
4244
When a branch name is provided, the command resolves it against
4345
locally tracked stacks only.
4446
45-
When run without arguments, shows a menu of all locally available
46-
stacks to choose from.`,
47+
When run without arguments, opens an interactive picker listing every
48+
stack available to you — both the stacks tracked locally and the stacks
49+
that exist only on GitHub — so you can search, filter, and check one out.
50+
Fully merged stacks are omitted.`,
4751
Example: ` # Check out a stack by its stack number
4852
$ gh stack checkout 7
4953
@@ -56,7 +60,7 @@ stacks to choose from.`,
5660
# Check out a stack by branch name
5761
$ gh stack checkout feat/api-routes
5862
59-
# Show a menu of all locally tracked stacks
63+
# Open the interactive picker of all available stacks (local and remote)
6064
$ gh stack checkout`,
6165
Args: cobra.MaximumNArgs(1),
6266
RunE: func(cmd *cobra.Command, args []string) error {
@@ -91,18 +95,21 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error {
9195
var targetBranch string
9296

9397
if opts.target == "" {
94-
// Interactive picker mode
95-
s, err = interactiveStackPicker(cfg, sf)
98+
// Interactive picker mode (local + remote stacks).
99+
s, targetBranch, err = interactiveCheckout(cfg, sf, gitDir)
96100
if err != nil {
97-
if !errors.Is(err, errInterrupt) {
98-
cfg.Errorf("%s", err)
101+
var exitErr *ExitError
102+
if errors.As(err, &exitErr) {
103+
// The callee already printed a message and chose an exit code.
104+
return err
99105
}
106+
cfg.Errorf("%s", err)
100107
return ErrSilent
101108
}
102109
if s == nil {
110+
// No stacks available, or the user cancelled.
103111
return nil
104112
}
105-
targetBranch = s.Branches[len(s.Branches)-1].Branch
106113
} else if prNumber, ok := parsePRURL(opts.target); ok {
107114
// Target is a PR URL — extract number and resolve like a numeric target
108115
s, targetBranch, err = resolveNumericTarget(cfg, sf, gitDir, prNumber, opts.target)
@@ -625,40 +632,101 @@ func syncRemotePRState(s *stack.Stack, prs []*github.PullRequest) {
625632
}
626633
}
627634

628-
// interactiveStackPicker shows a menu of all locally tracked stacks and returns
629-
// the one the user selects. Returns nil, nil if the user has no stacks.
630-
func interactiveStackPicker(cfg *config.Config, sf *stack.StackFile) (*stack.Stack, error) {
635+
// interactiveCheckout opens the interactive stack picker, which lists every
636+
// stack available to the user (locally tracked and remote-only, reconciled and
637+
// with fully-merged stacks filtered out), and resolves the user's choice to a
638+
// stack and the branch to check out. It returns (nil, "", nil) when the user
639+
// cancels or has no stacks. Remote-only selections are cloned down through the
640+
// same path as `gh stack checkout <number>`.
641+
func interactiveCheckout(cfg *config.Config, sf *stack.StackFile, gitDir string) (*stack.Stack, string, error) {
631642
if !cfg.IsInteractive() {
632-
return nil, fmt.Errorf("no target specified; provide a branch name or PR number, or run interactively to select a stack")
643+
return nil, "", fmt.Errorf("no target specified; provide a branch name or PR number, or run interactively to select a stack")
633644
}
634645

635-
if len(sf.Stacks) == 0 {
636-
cfg.Infof("No locally tracked stacks found")
637-
cfg.Printf("Create a stack with `%s` or check out a remote stack with `%s`",
646+
rows := gatherCheckoutRows(cfg, sf)
647+
if len(rows) == 0 {
648+
cfg.Infof("No stacks available to check out")
649+
cfg.Printf("Create a stack with `%s` or check out a stack by number with `%s`",
638650
cfg.ColorCyan("gh stack init"),
639-
cfg.ColorCyan("gh stack checkout 123"))
640-
return nil, nil
651+
cfg.ColorCyan("gh stack checkout <number>"))
652+
return nil, "", nil
641653
}
642654

643-
options := make([]string, len(sf.Stacks))
644-
for i := range sf.Stacks {
645-
options[i] = sf.Stacks[i].DisplayChain()
655+
selected, ok, err := launchCheckoutPicker(rows)
656+
if err != nil {
657+
return nil, "", err
658+
}
659+
if !ok {
660+
// The user dismissed the picker without selecting.
661+
return nil, "", nil
646662
}
647663

648-
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
649-
selected, err := p.Select(
650-
"Select a stack to check out (showing locally tracked stacks only)",
651-
"",
652-
options,
653-
)
664+
return resolveCheckoutSelection(cfg, sf, gitDir, selected)
665+
}
666+
667+
// gatherCheckoutRows fetches the remote stacks (best-effort) and reconciles them
668+
// with the local stacks into the picker's rows. Any GitHub failure (stacks not
669+
// enabled for the repo, no auth, network error) gracefully degrades to a
670+
// local-only list.
671+
func gatherCheckoutRows(cfg *config.Config, sf *stack.StackFile) []checkoutview.StackRow {
672+
var remote []github.RemoteStack
673+
if client, err := cfg.GitHubClient(); err == nil {
674+
if stacks, err := client.ListStacks(); err == nil {
675+
remote = stacks
676+
}
677+
}
678+
return checkoutview.BuildRows(sf.Stacks, remote)
679+
}
680+
681+
// resolveCheckoutSelection resolves a picker selection to a local stack and the
682+
// branch to check out. Locally available stacks are used directly; remote-only
683+
// stacks are cloned down by stack number through the same import/reconcile flow
684+
// as `gh stack checkout <number>`.
685+
func resolveCheckoutSelection(cfg *config.Config, sf *stack.StackFile, gitDir string, selected checkoutview.StackRow) (*stack.Stack, string, error) {
686+
if selected.Type == checkoutview.TypeLocal && selected.LocalStack != nil {
687+
return selected.LocalStack, topUnmergedBranch(selected.LocalStack), nil
688+
}
689+
690+
s, targetBranch, err := checkoutStackByNumber(cfg, sf, gitDir, selected.Number)
654691
if err != nil {
655-
if isInterruptError(err) {
656-
clearSelectPrompt(cfg, len(options))
657-
printInterrupt(cfg)
658-
return nil, errInterrupt
692+
if errors.Is(err, errStackNumberNotFound) {
693+
cfg.Errorf("stack #%d could not be loaded from GitHub", selected.Number)
694+
return nil, "", ErrAPIFailure
659695
}
660-
return nil, fmt.Errorf("stack selection: %w", err)
696+
return nil, "", err
661697
}
698+
return s, targetBranch, nil
699+
}
662700

663-
return &sf.Stacks[selected], nil
701+
// launchCheckoutPicker runs the Bubble Tea stack picker and returns the selected
702+
// row (and whether one was chosen). It renders inline (no alt-screen) so the
703+
// picker occupies only a few lines and leaves the surrounding terminal output
704+
// intact. Mouse motion is intentionally not enabled so the search field never
705+
// receives stray mouse bytes.
706+
func launchCheckoutPicker(rows []checkoutview.StackRow) (checkoutview.StackRow, bool, error) {
707+
p := tea.NewProgram(checkoutview.New(rows))
708+
finalModel, err := p.Run()
709+
if err != nil {
710+
return checkoutview.StackRow{}, false, fmt.Errorf("running stack picker: %w", err)
711+
}
712+
m, ok := finalModel.(checkoutview.Model)
713+
if !ok {
714+
return checkoutview.StackRow{}, false, nil
715+
}
716+
row, selected := m.Result()
717+
return row, selected, nil
718+
}
719+
720+
// topUnmergedBranch returns the top-most branch of a local stack that has not
721+
// been merged, falling back to the very top branch when every branch is merged.
722+
func topUnmergedBranch(s *stack.Stack) string {
723+
if len(s.Branches) == 0 {
724+
return ""
725+
}
726+
for i := len(s.Branches) - 1; i >= 0; i-- {
727+
if !s.Branches[i].IsMerged() {
728+
return s.Branches[i].Branch
729+
}
730+
}
731+
return s.Branches[len(s.Branches)-1].Branch
664732
}

0 commit comments

Comments
 (0)