Skip to content

Commit e06284f

Browse files
authored
skip pulling merged branches during remote checkout (#16)
* skip pulling merged branches during remote checkout * show message if stack fully merged
1 parent b01754e commit e06284f

5 files changed

Lines changed: 133 additions & 12 deletions

File tree

cmd/checkout.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error {
9292
} else {
9393
// Non-numeric target — resolve against local stacks only
9494
var br *stack.BranchRef
95-
s, br, err = resolvePR(sf, opts.target)
95+
s, br, err = resolvePR(cfg, sf, opts.target)
9696
if err != nil {
9797
cfg.Errorf("%s", err)
9898
return ErrNotInStack
@@ -194,17 +194,26 @@ func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string,
194194
// Determine trunk (base branch of the first PR) and the target branch
195195
trunk := prs[0].BaseRefName
196196
var targetBranch string
197+
allMerged := true
197198
for _, pr := range prs {
198199
if pr.Number == prNumber {
199200
targetBranch = pr.HeadRefName
200-
break
201+
}
202+
if !pr.Merged {
203+
allMerged = false
201204
}
202205
}
203206
if targetBranch == "" {
204207
cfg.Errorf("could not determine branch for PR #%d", prNumber)
205208
return nil, "", ErrAPIFailure
206209
}
207210

211+
if allMerged {
212+
cfg.Infof("All PRs in this stack have been merged")
213+
cfg.Printf("To start a new stack, use `%s`", cfg.ColorCyan("gh stack init"))
214+
return nil, "", ErrSilent
215+
}
216+
208217
remoteStackID := strconv.Itoa(remoteStack.ID)
209218

210219
// Step 3: Check if the target branch is already in a local stack
@@ -461,14 +470,20 @@ func importRemoteStack(
461470
}
462471
}
463472

464-
// Create local branches for each PR's head branch
473+
// Create local branches for each PR's head branch.
474+
// Skip merged PRs whose branches were deleted from the remote —
475+
// these no longer exist upstream and can't be created locally.
465476
for _, pr := range prs {
466477
branch := pr.HeadRefName
467478
if git.BranchExists(branch) {
468479
continue
469480
}
470481
remoteRef := remote + "/" + branch
471482
if err := git.CreateBranch(branch, remoteRef); err != nil {
483+
if pr.Merged {
484+
cfg.Infof("Skipping merged branch %s", branch)
485+
continue
486+
}
472487
cfg.Errorf("failed to pull branch %s from %s: %v", branch, remoteRef, err)
473488
return nil, ErrSilent
474489
}

cmd/checkout_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,107 @@ func TestCheckout_NumericTarget_ClosedMergedPR(t *testing.T) {
618618
assert.False(t, sf.Stacks[0].Branches[1].PullRequest.Merged)
619619
}
620620

621+
func TestCheckout_NumericTarget_MergedBranchDeletedFromRemote(t *testing.T) {
622+
gitDir := t.TempDir()
623+
var checkedOut string
624+
625+
restore := git.SetOps(&git.MockOps{
626+
GitDirFn: func() (string, error) { return gitDir, nil },
627+
CurrentBranchFn: func() (string, error) { return "main", nil },
628+
BranchExistsFn: func(name string) bool {
629+
return name == "main"
630+
},
631+
FetchFn: func(remote string) error { return nil },
632+
CreateBranchFn: func(name, base string) error {
633+
// Simulate merged branch deleted from remote: origin/feat-1 doesn't exist
634+
if base == "origin/feat-1" {
635+
return fmt.Errorf("failed to run git: fatal: not a valid object name: 'origin/feat-1'")
636+
}
637+
return nil
638+
},
639+
SetUpstreamTrackingFn: func(branch, remote string) error { return nil },
640+
ResolveRemoteFn: func(branch string) (string, error) {
641+
return "origin", nil
642+
},
643+
CheckoutBranchFn: func(name string) error {
644+
checkedOut = name
645+
return nil
646+
},
647+
RevParseFn: func(ref string) (string, error) {
648+
return "abc123", nil
649+
},
650+
RevParseMultiFn: func(refs []string) ([]string, error) {
651+
shas := make([]string, len(refs))
652+
for i := range refs {
653+
shas[i] = "abc123"
654+
}
655+
return shas, nil
656+
},
657+
})
658+
defer restore()
659+
660+
require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}}))
661+
662+
cfg, outR, errR := config.NewTestConfig()
663+
cfg.GitHubClientOverride = &github.MockClient{
664+
ListStacksFn: func() ([]github.RemoteStack, error) {
665+
return []github.RemoteStack{
666+
{ID: 60, PullRequests: []int{10, 11}},
667+
}, nil
668+
},
669+
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
670+
prs := map[int]*github.PullRequest{
671+
10: {ID: "PR_10", Number: 10, HeadRefName: "feat-1", BaseRefName: "main", Merged: true, State: "MERGED", URL: "https://github.com/o/r/pull/10"},
672+
11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "feat-1", State: "OPEN", URL: "https://github.com/o/r/pull/11"},
673+
}
674+
return prs[number], nil
675+
},
676+
}
677+
678+
err := runCheckout(cfg, &checkoutOptions{target: "11"})
679+
output := collectOutput(cfg, outR, errR)
680+
681+
require.NoError(t, err)
682+
assert.Equal(t, "feat-2", checkedOut)
683+
assert.Contains(t, output, "Skipping merged branch feat-1")
684+
assert.Contains(t, output, "Imported stack with 2 branches")
685+
}
686+
687+
func TestCheckout_NumericTarget_AllPRsMerged(t *testing.T) {
688+
gitDir := t.TempDir()
689+
690+
restore := git.SetOps(&git.MockOps{
691+
GitDirFn: func() (string, error) { return gitDir, nil },
692+
CurrentBranchFn: func() (string, error) { return "main", nil },
693+
})
694+
defer restore()
695+
696+
require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}}))
697+
698+
cfg, outR, errR := config.NewTestConfig()
699+
cfg.GitHubClientOverride = &github.MockClient{
700+
ListStacksFn: func() ([]github.RemoteStack, error) {
701+
return []github.RemoteStack{
702+
{ID: 70, PullRequests: []int{10, 11}},
703+
}, nil
704+
},
705+
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
706+
prs := map[int]*github.PullRequest{
707+
10: {ID: "PR_10", Number: 10, HeadRefName: "feat-1", BaseRefName: "main", Merged: true, State: "MERGED", URL: "https://github.com/o/r/pull/10"},
708+
11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "feat-1", Merged: true, State: "MERGED", URL: "https://github.com/o/r/pull/11"},
709+
}
710+
return prs[number], nil
711+
},
712+
}
713+
714+
err := runCheckout(cfg, &checkoutOptions{target: "11"})
715+
output := collectOutput(cfg, outR, errR)
716+
717+
assert.ErrorIs(t, err, ErrSilent)
718+
assert.Contains(t, output, "All PRs in this stack have been merged")
719+
assert.Contains(t, output, "gh stack init")
720+
}
721+
621722
func TestCheckout_NumericTarget_APIError(t *testing.T) {
622723
gitDir := t.TempDir()
623724
restore := git.SetOps(&git.MockOps{

cmd/merge.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func runMerge(cfg *config.Config, target string) error {
4949
// Resolve which branch to operate on.
5050
var br *stack.BranchRef
5151
if target != "" {
52-
_, br, err = resolvePR(result.StackFile, target)
52+
_, br, err = resolvePR(cfg, result.StackFile, target)
5353
if err != nil {
5454
cfg.Errorf("%s", err)
5555
return ErrNotInStack

cmd/utils.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ func activeBranchNames(s *stack.Stack) []string {
319319

320320
// resolvePR resolves a user-provided target to a stack and branch using
321321
// waterfall logic: PR URL → PR number → branch name.
322-
func resolvePR(sf *stack.StackFile, target string) (*stack.Stack, *stack.BranchRef, error) {
322+
func resolvePR(cfg *config.Config, sf *stack.StackFile, target string) (*stack.Stack, *stack.BranchRef, error) {
323323
// Try parsing as a GitHub PR URL (e.g. https://github.com/owner/repo/pull/42).
324324
if prNumber, ok := parsePRURL(target); ok {
325325
s, b := sf.FindStackByPRNumber(prNumber)
@@ -352,9 +352,9 @@ func resolvePR(sf *stack.StackFile, target string) (*stack.Stack, *stack.BranchR
352352

353353
return nil, nil, fmt.Errorf(
354354
"no locally tracked stack found for %q\n"+
355-
"This command currently only works with stacks created locally.\n"+
356-
"Server-side stack discovery will be available in a future release.",
355+
"To pull down a stack from remote, use the PR number: `%s`",
357356
target,
357+
cfg.ColorCyan("gh stack checkout <pr-number>"),
358358
)
359359
}
360360

cmd/utils_test.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,8 @@ func TestResolvePR_ByPRNumber(t *testing.T) {
156156
},
157157
}
158158

159-
s, br, err := resolvePR(sf, "42")
159+
cfg, _, _ := config.NewTestConfig()
160+
s, br, err := resolvePR(cfg, sf, "42")
160161
assert.NoError(t, err)
161162
assert.Equal(t, "feat-1", br.Branch)
162163
assert.Equal(t, 42, br.PullRequest.Number)
@@ -176,7 +177,8 @@ func TestResolvePR_ByPRURL(t *testing.T) {
176177
},
177178
}
178179

179-
s, br, err := resolvePR(sf, "https://github.com/o/r/pull/42")
180+
cfg, _, _ := config.NewTestConfig()
181+
s, br, err := resolvePR(cfg, sf, "https://github.com/o/r/pull/42")
180182
assert.NoError(t, err)
181183
assert.Equal(t, "feat-1", br.Branch)
182184
assert.Equal(t, "main", s.Trunk.Branch)
@@ -196,7 +198,8 @@ func TestResolvePR_ByBranchName(t *testing.T) {
196198
},
197199
}
198200

199-
s, br, err := resolvePR(sf, "feat-2")
201+
cfg, _, _ := config.NewTestConfig()
202+
s, br, err := resolvePR(cfg, sf, "feat-2")
200203
assert.NoError(t, err)
201204
assert.Equal(t, "feat-2", br.Branch)
202205
assert.Equal(t, 43, br.PullRequest.Number)
@@ -214,7 +217,8 @@ func TestResolvePR_NotFound(t *testing.T) {
214217
},
215218
}
216219

217-
_, _, err := resolvePR(sf, "nonexistent")
220+
cfg, _, _ := config.NewTestConfig()
221+
_, _, err := resolvePR(cfg, sf, "nonexistent")
218222
assert.Error(t, err)
219223
assert.Contains(t, err.Error(), "no locally tracked stack found")
220224
}
@@ -234,7 +238,8 @@ func TestResolvePR_URLPrecedesNumber(t *testing.T) {
234238
},
235239
}
236240

237-
_, br, err := resolvePR(sf, "https://github.com/o/r/pull/99")
241+
cfg, _, _ := config.NewTestConfig()
242+
_, br, err := resolvePR(cfg, sf, "https://github.com/o/r/pull/99")
238243
assert.NoError(t, err)
239244
assert.Equal(t, 99, br.PullRequest.Number)
240245
}

0 commit comments

Comments
 (0)