Skip to content

Commit cf65746

Browse files
authored
checkout: resolve remote stack by branch name (#477)
* checkout by remote branch name * address review comments
1 parent 1d9a20b commit cf65746

6 files changed

Lines changed: 295 additions & 21 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ A bare number is interpreted first as a stack or PR number (repo-scoped identifi
158158

159159
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.
160160

161-
When a branch name is provided, the command resolves it against locally tracked stacks only.
161+
When a branch name is provided, the command checks locally tracked stacks first. If the branch is not tracked locally, it looks for the branch on remote stacks and pulls down the matching stack. If more than one stack matches, use a stack or PR number to choose one explicitly.
162162

163163
When run without arguments in an interactive terminal, first checks whether the current branch belongs to a stack on remote that is not tracked locally, and offers to check it out. If there is no unique match or you decline, it 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.
164164

@@ -174,7 +174,7 @@ gh stack checkout 42
174174
# Check out a stack by PR URL
175175
gh stack checkout https://github.com/owner/repo/pull/42
176176

177-
# Check out a stack by branch name (local only)
177+
# Check out a stack by branch name
178178
gh stack checkout feature-auth
179179

180180
# Interactive — pick from all available stacks (local and remote)

cmd/checkout.go

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@ GitHub API to discover the stack, fetches the branches, and sets up
4141
the stack locally. If the stack already exists locally and matches,
4242
it simply switches to the branch.
4343
44-
When a branch name is provided, the command resolves it against
45-
locally tracked stacks only.
44+
When a branch name is provided, the command first checks locally tracked
45+
stacks. If the branch is not tracked locally, it looks for the branch on
46+
remote stacks and pulls down the matching stack.
4647
4748
When run without arguments, first checks whether the current branch belongs
4849
to a stack on remote that is not tracked locally, and offers to check
@@ -79,7 +80,7 @@ omitted.`,
7980
// runCheckout resolves a stack and checks out the target branch.
8081
// For numeric targets, it tries local lookup first, then falls back to
8182
// the GitHub API to discover remote stacks, then tries as a branch name.
82-
// Non-numeric targets use local resolution only.
83+
// Branch names resolve locally first and then against stacks on GitHub.
8384
func runCheckout(cfg *config.Config, opts *checkoutOptions) error {
8485
gitDir, err := git.GitDir()
8586
if err != nil {
@@ -125,14 +126,23 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error {
125126
return err
126127
}
127128
} else {
128-
// Non-numeric target — resolve against local stacks only
129+
// Non-numeric target — resolve locally before checking GitHub.
129130
var br *stack.BranchRef
130131
s, br, err = resolvePR(cfg, sf, opts.target)
131-
if err != nil {
132-
cfg.Errorf("%s", err)
133-
return ErrNotInStack
132+
if err == nil {
133+
targetBranch = br.Branch
134+
} else {
135+
s, targetBranch, err = checkoutRemoteStackByBranch(cfg, sf, gitDir, opts.target)
136+
if errors.Is(err, errRemoteBranchNotFound) {
137+
cfg.Errorf("no local or remote stack found for %q", opts.target)
138+
cfg.Printf("Try a stack or PR number with `%s`",
139+
cfg.ColorCyan("gh stack checkout <number>"))
140+
return ErrNotInStack
141+
}
142+
if err != nil {
143+
return err
144+
}
134145
}
135-
targetBranch = br.Branch
136146
}
137147

138148
currentBranch, _ := git.CurrentBranch()
@@ -188,7 +198,7 @@ func resolveNumericTarget(cfg *config.Config, sf *stack.StackFile, gitDir string
188198
// attempt — the user might have a numeric branch name.
189199
remoteErr := err
190200

191-
// 4. Fall back to branch name lookup (handles numeric branch names).
201+
// 4. Fall back to local branch name lookup (handles numeric branch names).
192202
stacks := sf.FindAllStacksForBranch(raw)
193203
if len(stacks) > 0 {
194204
s := stacks[0]
@@ -202,11 +212,77 @@ func resolveNumericTarget(cfg *config.Config, sf *stack.StackFile, gitDir string
202212
}
203213
}
204214

215+
// Only a definitive "not in a stack" result can be reinterpreted as a
216+
// remote branch name. Preserve API, conflict, and other actionable errors.
217+
if !errors.Is(remoteErr, ErrNotInStack) {
218+
return nil, "", remoteErr
219+
}
220+
221+
// Finally, try the numeric input as a branch in a stack on GitHub.
222+
s, targetBranch, err = checkoutRemoteStackByBranch(cfg, sf, gitDir, raw)
223+
if err == nil {
224+
return s, targetBranch, nil
225+
}
226+
if !errors.Is(err, errRemoteBranchNotFound) {
227+
return nil, "", err
228+
}
229+
205230
// Nothing worked — return the remote error which has the most
206231
// informative message for a numeric input
232+
cfg.Errorf("PR #%d is not part of a stack on GitHub", number)
207233
return nil, "", remoteErr
208234
}
209235

236+
var errRemoteBranchNotFound = errors.New("remote branch not found in a stack")
237+
238+
// checkoutRemoteStackByBranch finds the unique active stack on GitHub that
239+
// contains branch, then imports it through the existing PR checkout path.
240+
func checkoutRemoteStackByBranch(cfg *config.Config, sf *stack.StackFile, gitDir, branch string) (*stack.Stack, string, error) {
241+
client, err := cfg.GitHubClient()
242+
if err != nil {
243+
cfg.Errorf("failed to create GitHub client: %s", err)
244+
return nil, "", ErrAPIFailure
245+
}
246+
247+
remoteStacks, err := client.ListStacks()
248+
if err != nil {
249+
var httpErr *api.HTTPError
250+
if errors.As(err, &httpErr) && httpErr.StatusCode == 404 {
251+
warnStacksUnavailable(cfg)
252+
return nil, "", ErrStacksUnavailable
253+
}
254+
cfg.Errorf("failed to list stacks: %v", err)
255+
return nil, "", ErrAPIFailure
256+
}
257+
258+
matches := matchingRemoteStacksForBranch(remoteStacks, branch)
259+
if len(matches) == 0 {
260+
return nil, "", errRemoteBranchNotFound
261+
}
262+
if len(matches) > 1 {
263+
stackNumbers := make([]string, len(matches))
264+
for i, match := range matches {
265+
stackNumbers[i] = strconv.Itoa(match.Number)
266+
}
267+
cfg.Errorf("branch %q belongs to multiple stacks on GitHub (%s)",
268+
branch, strings.Join(stackNumbers, ", "))
269+
cfg.Printf("Use `%s` with a stack or PR number to choose one",
270+
cfg.ColorCyan("gh stack checkout <number>"))
271+
return nil, "", ErrDisambiguate
272+
}
273+
274+
for _, pr := range matches[0].PRDetails {
275+
if pr.Head.Ref == branch && pr.Number > 0 {
276+
s, targetBranch, err := checkoutRemoteStack(cfg, sf, gitDir, pr.Number)
277+
if errors.Is(err, ErrNotInStack) {
278+
cfg.Errorf("PR #%d is not part of a stack on GitHub", pr.Number)
279+
}
280+
return s, targetBranch, err
281+
}
282+
}
283+
return nil, "", errRemoteBranchNotFound
284+
}
285+
210286
// checkoutRemoteStack discovers a stack from GitHub for the given PR number,
211287
// reconciles it with any local state, and returns the resolved stack and
212288
// target branch name. The stack file is saved before returning.
@@ -224,13 +300,12 @@ func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string,
224300
var httpErr *api.HTTPError
225301
if errors.As(err, &httpErr) && httpErr.StatusCode == 404 {
226302
warnStacksUnavailable(cfg)
227-
return nil, "", ErrAPIFailure
303+
return nil, "", ErrStacksUnavailable
228304
}
229305
cfg.Errorf("failed to list stacks: %v", err)
230306
return nil, "", ErrAPIFailure
231307
}
232308
if remoteStack == nil {
233-
cfg.Errorf("PR #%d is not part of a stack on GitHub", prNumber)
234309
return nil, "", ErrNotInStack
235310
}
236311

0 commit comments

Comments
 (0)