Skip to content

Commit 1d9a20b

Browse files
authored
checkout: detect remote stack from current branch (#475)
* detect remote stack from current branch * clean up phrasing
1 parent 39528e6 commit 1d9a20b

6 files changed

Lines changed: 449 additions & 14 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ When a remote stack is referenced, the command fetches the stack on GitHub, pull
160160

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

163-
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.
163+
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

165165
**Examples:**
166166

cmd/checkout.go

Lines changed: 99 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,12 @@ it simply switches to the branch.
4444
When a branch name is provided, the command resolves it against
4545
locally tracked stacks only.
4646
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.`,
47+
When run without arguments, first checks whether the current branch belongs
48+
to a stack on remote that is not tracked locally, and offers to check
49+
it out. Otherwise, it opens an interactive picker listing every stack available
50+
to you — both the stacks tracked locally and the stacks that exist only on
51+
GitHub — so you can search, filter, and check one out. Fully merged stacks are
52+
omitted.`,
5153
Example: ` # Check out a stack by its stack number
5254
$ gh stack checkout 7
5355
@@ -643,7 +645,22 @@ func interactiveCheckout(cfg *config.Config, sf *stack.StackFile, gitDir string)
643645
return nil, "", fmt.Errorf("no target specified; provide a branch name or PR number, or run interactively to select a stack")
644646
}
645647

646-
rows := gatherCheckoutRows(cfg, sf)
648+
rows, remoteStacks := gatherCheckoutRows(cfg, sf)
649+
if currentBranch, branchErr := git.CurrentBranch(); branchErr == nil {
650+
stackNumber, confirmed, confirmErr := offerRemoteStackForBranch(cfg, sf, remoteStacks, currentBranch)
651+
if confirmErr != nil {
652+
// The confirmation helper only returns an error for an explicit
653+
// interrupt and has already printed the friendly message.
654+
return nil, "", ErrSilent
655+
}
656+
if confirmed {
657+
return resolveCheckoutSelection(cfg, sf, gitDir, checkoutview.StackRow{
658+
Number: stackNumber,
659+
Type: checkoutview.TypeRemote,
660+
})
661+
}
662+
}
663+
647664
if len(rows) == 0 {
648665
cfg.Infof("No stacks available to check out")
649666
cfg.Printf("Create a stack with `%s` or check out a stack by number with `%s`",
@@ -668,14 +685,89 @@ func interactiveCheckout(cfg *config.Config, sf *stack.StackFile, gitDir string)
668685
// with the local stacks into the picker's rows. Any GitHub failure (stacks not
669686
// enabled for the repo, no auth, network error) gracefully degrades to a
670687
// local-only list.
671-
func gatherCheckoutRows(cfg *config.Config, sf *stack.StackFile) []checkoutview.StackRow {
688+
func gatherCheckoutRows(cfg *config.Config, sf *stack.StackFile) ([]checkoutview.StackRow, []github.RemoteStack) {
672689
var remote []github.RemoteStack
673690
if client, err := cfg.GitHubClient(); err == nil {
674691
if stacks, err := client.ListStacks(); err == nil {
675692
remote = stacks
676693
}
677694
}
678-
return checkoutview.BuildRows(sf.Stacks, remote)
695+
return checkoutview.BuildRows(sf.Stacks, remote), remote
696+
}
697+
698+
// offerRemoteStackForBranch asks to check out the unique active remote stack
699+
// containing branch when the branch is not already associated with a local
700+
// stack. Every outcome except confirmation or Ctrl+C falls through to the
701+
// existing picker.
702+
func offerRemoteStackForBranch(cfg *config.Config, sf *stack.StackFile, remote []github.RemoteStack, branch string) (int, bool, error) {
703+
if branch == "" || len(sf.FindAllStacksForBranch(branch)) > 0 {
704+
return 0, false, nil
705+
}
706+
707+
matches := matchingRemoteStacksForBranch(remote, branch)
708+
if len(matches) != 1 {
709+
return 0, false, nil
710+
}
711+
712+
stackNumber := matches[0].Number
713+
prompt := fmt.Sprintf("Found stack #%d that includes branch %q. Check out stack #%d?", stackNumber, branch, stackNumber)
714+
confirmed, err := confirmRemoteStackCheckout(cfg, prompt)
715+
if err != nil {
716+
if errors.Is(err, errInterrupt) {
717+
return 0, false, err
718+
}
719+
return 0, false, nil
720+
}
721+
if !confirmed {
722+
return 0, false, nil
723+
}
724+
return stackNumber, true, nil
725+
}
726+
727+
// matchingRemoteStacksForBranch returns picker-eligible remote stacks that
728+
// contain branch exactly once per stack. Empty and fully merged stacks are not
729+
// actionable and are omitted, matching the picker.
730+
func matchingRemoteStacksForBranch(remote []github.RemoteStack, branch string) []*github.RemoteStack {
731+
var matches []*github.RemoteStack
732+
for i := range remote {
733+
rs := &remote[i]
734+
if rs.Number <= 0 || len(rs.PRDetails) == 0 {
735+
continue
736+
}
737+
738+
containsBranch := false
739+
hasUnmergedPR := false
740+
for _, pr := range rs.PRDetails {
741+
if pr.Head.Ref == branch {
742+
containsBranch = true
743+
}
744+
if !pr.IsMerged() {
745+
hasUnmergedPR = true
746+
}
747+
}
748+
if containsBranch && hasUnmergedPR {
749+
matches = append(matches, rs)
750+
}
751+
}
752+
return matches
753+
}
754+
755+
func confirmRemoteStackCheckout(cfg *config.Config, prompt string) (bool, error) {
756+
var (
757+
confirmed bool
758+
err error
759+
)
760+
if cfg.ConfirmFn != nil {
761+
confirmed, err = cfg.ConfirmFn(prompt, true)
762+
} else {
763+
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
764+
confirmed, err = p.Confirm(prompt, true)
765+
}
766+
if isInterruptError(err) {
767+
printInterrupt(cfg)
768+
return false, errInterrupt
769+
}
770+
return confirmed, err
679771
}
680772

681773
// resolveCheckoutSelection resolves a picker selection to a local stack and the

0 commit comments

Comments
 (0)