Skip to content

Commit 07ea259

Browse files
committed
checkout for local stacks
1 parent 9c8d2fb commit 07ea259

3 files changed

Lines changed: 163 additions & 22 deletions

File tree

README.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -103,26 +103,27 @@ gh stack add # prompts for name
103103

104104
### `gh stack checkout`
105105

106-
Discover and check out an entire stack from a pull request or branch.
106+
Check out a locally tracked stack from a pull request number or branch name.
107107

108108
```
109-
gh stack checkout <pr-or-branch> [flags]
109+
gh stack checkout [<pr-or-branch>]
110110
```
111111

112-
Accepts a PR number, PR URL, or branch name. Traces the chain of PRs to discover the full stack, fetches all branches, and saves the stack to local tracking.
112+
Resolves the target against stacks stored in local tracking (`.git/gh-stack`). Accepts a PR number (e.g. `42`) or a branch name that belongs to a locally tracked stack. When run without arguments in an interactive terminal, shows a menu of all locally available stacks to choose from.
113113

114-
> **Note:** This command is not yet implemented. Running it prints a notice.
115-
116-
| Flag | Description |
117-
|------|-------------|
118-
| `--no-switch` | Fetch and track the stack without switching to the target branch |
114+
> **Note:** Server-side stack discovery is not yet implemented. This command currently only works with stacks that have been created locally (via `gh stack init`). Checking out a stack that is not tracked locally will require passing in an explicit branch name or PR number once the server API is available.
119115
120116
**Examples:**
121117

122118
```sh
119+
# Check out a stack by PR number
123120
gh stack checkout 42
121+
122+
# Check out a stack by branch name
124123
gh stack checkout feature-auth
125-
gh stack checkout https://github.com/owner/repo/pull/42
124+
125+
# Interactive — select from locally tracked stacks
126+
gh stack checkout
126127
```
127128

128129
### `gh stack rebase`

cmd/checkout.go

Lines changed: 139 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,169 @@
11
package cmd
22

33
import (
4+
"fmt"
5+
"strconv"
6+
7+
"github.com/cli/go-gh/v2/pkg/prompter"
48
"github.com/github/gh-stack/internal/config"
9+
"github.com/github/gh-stack/internal/git"
10+
"github.com/github/gh-stack/internal/stack"
511
"github.com/spf13/cobra"
612
)
713

814
type checkoutOptions struct {
9-
target string
10-
noSwitch bool
15+
target string
1116
}
1217

1318
func CheckoutCmd(cfg *config.Config) *cobra.Command {
1419
opts := &checkoutOptions{}
1520

1621
cmd := &cobra.Command{
17-
Use: "checkout <pr-or-branch>",
22+
Use: "checkout [<pr-or-branch>]",
1823
Short: "Checkout a stack from a PR number or branch name",
19-
Long: "Discover and check out an entire stack from a pull request number, URL, or branch name.",
20-
Args: cobra.ExactArgs(1),
24+
Long: `Check out a stack from a pull request number or branch name.
25+
26+
Currently resolves stacks from local tracking only (.git/gh-stack).
27+
Accepts a PR number (e.g. 42) or a branch name that belongs to
28+
a locally tracked stack. When run without arguments, shows a menu of
29+
all locally available stacks to choose from.
30+
31+
Server-side stack discovery will be added in a future release.`,
32+
Args: cobra.MaximumNArgs(1),
2133
RunE: func(cmd *cobra.Command, args []string) error {
22-
opts.target = args[0]
34+
if len(args) > 0 {
35+
opts.target = args[0]
36+
}
2337
return runCheckout(cfg, opts)
2438
},
2539
}
2640

27-
cmd.Flags().BoolVar(&opts.noSwitch, "no-switch", false, "Fetch and track the stack without switching branches")
28-
2941
return cmd
3042
}
3143

32-
// runCheckout is a placeholder for the stack checkout workflow.
44+
// runCheckout resolves a stack from local tracking and checks out the target branch.
3345
//
34-
// The intended behavior is:
35-
// 1. Resolve the target (PR number, URL, or branch name) to a PR
46+
// Future behavior (once the server API is available):
47+
// 1. Resolve the target (PR number, URL, or branch name) to a PR via the API
3648
// 2. If the PR is part of a stack, discover the full set of PRs in the stack
3749
// 3. Fetch and create local tracking branches for every branch in the stack
3850
// 4. Save the stack to local tracking (.git/gh-stack, similar to gh stack init --adopt)
39-
// 5. Switch to the target branch (unless --no-switch is set)
51+
// 5. Switch to the target branch
4052
func runCheckout(cfg *config.Config, opts *checkoutOptions) error {
41-
cfg.Warningf("gh stack checkout is not yet implemented")
53+
gitDir, err := git.GitDir()
54+
if err != nil {
55+
cfg.Errorf("not a git repository")
56+
return nil
57+
}
58+
59+
sf, err := stack.Load(gitDir)
60+
if err != nil {
61+
cfg.Errorf("failed to load stack state: %s", err)
62+
return nil
63+
}
64+
65+
var s *stack.Stack
66+
var targetBranch string
67+
68+
if opts.target == "" {
69+
// Interactive picker mode
70+
s, err = interactiveStackPicker(cfg, sf)
71+
if err != nil {
72+
cfg.Errorf("%s", err)
73+
return nil
74+
}
75+
if s == nil {
76+
return nil
77+
}
78+
// Check out the top active branch of the selected stack
79+
if idx := s.FirstActiveBranchIndex(); idx >= 0 {
80+
targetBranch = s.Branches[len(s.Branches)-1].Branch
81+
} else {
82+
targetBranch = s.Branches[len(s.Branches)-1].Branch
83+
}
84+
} else {
85+
// Resolve target against local stacks
86+
s, targetBranch, err = findStackByTarget(sf, opts.target)
87+
if err != nil {
88+
cfg.Errorf("%s", err)
89+
return nil
90+
}
91+
}
92+
93+
currentBranch, _ := git.CurrentBranch()
94+
if targetBranch == currentBranch {
95+
cfg.Infof("Already on %s", targetBranch)
96+
cfg.Printf("Stack: %s", s.DisplayName())
97+
return nil
98+
}
99+
100+
if err := git.CheckoutBranch(targetBranch); err != nil {
101+
cfg.Errorf("failed to checkout %s: %v", targetBranch, err)
102+
return nil
103+
}
104+
105+
cfg.Successf("Switched to %s", targetBranch)
106+
cfg.Printf("Stack: %s", s.DisplayName())
42107
return nil
43108
}
109+
110+
// findStackByTarget resolves a target string against locally tracked stacks.
111+
// It tries PR number first (integer), then branch name.
112+
func findStackByTarget(sf *stack.StackFile, target string) (*stack.Stack, string, error) {
113+
// Try parsing as a PR number
114+
if prNumber, err := strconv.Atoi(target); err == nil && prNumber > 0 {
115+
s, b := sf.FindStackByPRNumber(prNumber)
116+
if s != nil && b != nil {
117+
return s, b.Branch, nil
118+
}
119+
}
120+
121+
// Try matching as a branch name
122+
stacks := sf.FindAllStacksForBranch(target)
123+
if len(stacks) == 1 {
124+
return stacks[0], target, nil
125+
}
126+
if len(stacks) > 1 {
127+
// Target is in multiple stacks (e.g. a trunk branch) — return the first one.
128+
// A future improvement could prompt for disambiguation here.
129+
return stacks[0], target, nil
130+
}
131+
132+
return nil, "", fmt.Errorf(
133+
"no locally tracked stack found for %q\n"+
134+
"This command currently only works with stacks created locally.\n"+
135+
"Server-side stack discovery will be available in a future release.",
136+
target,
137+
)
138+
}
139+
140+
// interactiveStackPicker shows a menu of all locally tracked stacks and returns
141+
// the one the user selects. Returns nil, nil if the user has no stacks.
142+
func interactiveStackPicker(cfg *config.Config, sf *stack.StackFile) (*stack.Stack, error) {
143+
if !cfg.IsInteractive() {
144+
return nil, fmt.Errorf("no target specified; provide a branch name or PR number, or run interactively to select a stack")
145+
}
146+
147+
if len(sf.Stacks) == 0 {
148+
cfg.Infof("No locally tracked stacks found")
149+
cfg.Printf("Create a stack with %s or check out a specific branch/PR once server-side discovery is available.", cfg.ColorCyan("gh stack init"))
150+
return nil, nil
151+
}
152+
153+
options := make([]string, len(sf.Stacks))
154+
for i := range sf.Stacks {
155+
options[i] = sf.Stacks[i].DisplayName()
156+
}
157+
158+
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
159+
selected, err := p.Select(
160+
"Select a stack to check out (showing locally tracked stacks only)",
161+
"",
162+
options,
163+
)
164+
if err != nil {
165+
return nil, fmt.Errorf("stack selection: %w", err)
166+
}
167+
168+
return &sf.Stacks[selected], nil
169+
}

internal/stack/stack.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,20 @@ func (sf *StackFile) FindAllStacksForBranch(branch string) []*Stack {
167167
return stacks
168168
}
169169

170+
// FindStackByPRNumber returns the first stack and branch whose PR number matches.
171+
// Returns nil, nil if no match is found.
172+
func (sf *StackFile) FindStackByPRNumber(prNumber int) (*Stack, *BranchRef) {
173+
for i := range sf.Stacks {
174+
for j := range sf.Stacks[i].Branches {
175+
b := &sf.Stacks[i].Branches[j]
176+
if b.PullRequest != nil && b.PullRequest.Number == prNumber {
177+
return &sf.Stacks[i], b
178+
}
179+
}
180+
}
181+
return nil, nil
182+
}
183+
170184
// ValidateNoDuplicateBranch checks that the branch is not already in any stack.
171185
func (sf *StackFile) ValidateNoDuplicateBranch(branch string) error {
172186
for _, s := range sf.Stacks {

0 commit comments

Comments
 (0)