Files
2026-04-09 21:47:55 -04:00

831 lines
25 KiB
Go

package cmd
import (
"fmt"
"testing"
"github.com/cli/go-gh/v2/pkg/api"
"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/git"
"github.com/github/gh-stack/internal/github"
"github.com/github/gh-stack/internal/stack"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCheckout_ByBranchName(t *testing.T) {
gitDir := t.TempDir()
var checkedOut string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
CheckoutBranchFn: func(name string) error {
checkedOut = name
return nil
},
})
defer restore()
writeStackFile(t, gitDir, stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
},
})
cfg, outR, errR := config.NewTestConfig()
err := runCheckout(cfg, &checkoutOptions{target: "b2"})
output := collectOutput(cfg, outR, errR)
require.NoError(t, err)
assert.Equal(t, "b2", checkedOut)
assert.Contains(t, output, "Switched to b2")
}
func TestCheckout_ByPRNumber_Local(t *testing.T) {
// When a PR number exists locally, no API call should be made
gitDir := t.TempDir()
var checkedOut string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
CheckoutBranchFn: func(name string) error {
checkedOut = name
return nil
},
})
defer restore()
writeStackFile(t, gitDir, stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 42, URL: "https://github.com/o/r/pull/42"}},
},
})
cfg, outR, errR := config.NewTestConfig()
// No GitHubClientOverride — should resolve locally without API
err := runCheckout(cfg, &checkoutOptions{target: "42"})
output := collectOutput(cfg, outR, errR)
require.NoError(t, err)
assert.Equal(t, "b1", checkedOut)
assert.Contains(t, output, "Switched to b1")
}
func TestCheckout_AlreadyOnTarget(t *testing.T) {
gitDir := t.TempDir()
checkoutCalled := false
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "b1", nil },
CheckoutBranchFn: func(name string) error {
checkoutCalled = true
return nil
},
})
defer restore()
writeStackFile(t, gitDir, stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
},
})
cfg, outR, errR := config.NewTestConfig()
err := runCheckout(cfg, &checkoutOptions{target: "b1"})
output := collectOutput(cfg, outR, errR)
require.NoError(t, err)
assert.False(t, checkoutCalled, "CheckoutBranch should not be called when already on target")
assert.Contains(t, output, "Already on b1")
}
func TestCheckout_NoStacks_NonInteractive(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
})
defer restore()
// Write an empty stack file (no stacks)
require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}}))
cfg, outR, errR := config.NewTestConfig()
err := runCheckout(cfg, &checkoutOptions{}) // no target arg
output := collectOutput(cfg, outR, errR)
assert.Error(t, err)
assert.Contains(t, output, "no target specified")
}
func TestCheckout_BranchNotFound(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
})
defer restore()
writeStackFile(t, gitDir, stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
},
})
cfg, outR, errR := config.NewTestConfig()
err := runCheckout(cfg, &checkoutOptions{target: "nonexistent"})
output := collectOutput(cfg, outR, errR)
assert.ErrorIs(t, err, ErrNotInStack)
assert.Contains(t, output, "no locally tracked stack found")
}
// --- Remote checkout tests (numeric target, local miss → API fallback) ---
func TestCheckout_NumericTarget_StacksNotAvailable(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
})
defer restore()
require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}}))
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"}
},
}
err := runCheckout(cfg, &checkoutOptions{target: "123"})
output := collectOutput(cfg, outR, errR)
assert.ErrorIs(t, err, ErrAPIFailure)
assert.Contains(t, output, "not enabled")
}
func TestCheckout_NumericTarget_PRNotInStack(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
})
defer restore()
require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}}))
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 1, PullRequests: []int{10, 11}},
}, nil
},
}
err := runCheckout(cfg, &checkoutOptions{target: "99"})
output := collectOutput(cfg, outR, errR)
assert.ErrorIs(t, err, ErrNotInStack)
assert.Contains(t, output, "PR #99 is not part of a stack")
}
func TestCheckout_NumericTarget_NewStack(t *testing.T) {
gitDir := t.TempDir()
var checkedOut string
var createdBranches []string
var trackingSet []string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
BranchExistsFn: func(name string) bool {
return name == "main" // only trunk exists
},
FetchFn: func(remote string) error { return nil },
CreateBranchFn: func(name, base string) error {
createdBranches = append(createdBranches, name)
return nil
},
SetUpstreamTrackingFn: func(branch, remote string) error {
trackingSet = append(trackingSet, branch)
return nil
},
ResolveRemoteFn: func(branch string) (string, error) {
return "origin", nil
},
CheckoutBranchFn: func(name string) error {
checkedOut = name
return nil
},
RevParseFn: func(ref string) (string, error) {
return "abc123", nil
},
RevParseMultiFn: func(refs []string) ([]string, error) {
shas := make([]string, len(refs))
for i := range refs {
shas[i] = "abc123"
}
return shas, nil
},
})
defer restore()
require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}}))
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 42, PullRequests: []int{10, 11, 12}},
}, nil
},
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
prs := map[int]*github.PullRequest{
10: {ID: "PR_10", Number: 10, HeadRefName: "feat-1", BaseRefName: "main", URL: "https://github.com/o/r/pull/10"},
11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "feat-1", URL: "https://github.com/o/r/pull/11"},
12: {ID: "PR_12", Number: 12, HeadRefName: "feat-3", BaseRefName: "feat-2", URL: "https://github.com/o/r/pull/12"},
}
pr, ok := prs[number]
if !ok {
return nil, fmt.Errorf("PR #%d not found", number)
}
return pr, nil
},
}
err := runCheckout(cfg, &checkoutOptions{target: "11"})
output := collectOutput(cfg, outR, errR)
require.NoError(t, err)
// Should create the 3 branches (trunk "main" already exists)
assert.Equal(t, []string{"feat-1", "feat-2", "feat-3"}, createdBranches)
assert.Equal(t, []string{"feat-1", "feat-2", "feat-3"}, trackingSet)
// Should checkout the target PR's branch
assert.Equal(t, "feat-2", checkedOut)
assert.Contains(t, output, "Imported stack with 3 branches")
assert.Contains(t, output, "Switched to feat-2")
// Verify stack was saved
sf, loadErr := stack.Load(gitDir)
require.NoError(t, loadErr)
require.Len(t, sf.Stacks, 1)
assert.Equal(t, "42", sf.Stacks[0].ID)
assert.Equal(t, "main", sf.Stacks[0].Trunk.Branch)
assert.Len(t, sf.Stacks[0].Branches, 3)
assert.Equal(t, 10, sf.Stacks[0].Branches[0].PullRequest.Number)
assert.Equal(t, 11, sf.Stacks[0].Branches[1].PullRequest.Number)
assert.Equal(t, 12, sf.Stacks[0].Branches[2].PullRequest.Number)
}
func TestCheckout_NumericTarget_BranchExistsNoStack(t *testing.T) {
gitDir := t.TempDir()
var checkedOut string
var createdBranches []string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
BranchExistsFn: func(name string) bool {
// feat-1 exists locally but feat-2 does not
return name == "main" || name == "feat-1"
},
FetchFn: func(remote string) error { return nil },
CreateBranchFn: func(name, base string) error {
createdBranches = append(createdBranches, name)
return nil
},
SetUpstreamTrackingFn: func(branch, remote string) error { return nil },
ResolveRemoteFn: func(branch string) (string, error) {
return "origin", nil
},
CheckoutBranchFn: func(name string) error {
checkedOut = name
return nil
},
RevParseFn: func(ref string) (string, error) {
return "abc123", nil
},
RevParseMultiFn: func(refs []string) ([]string, error) {
shas := make([]string, len(refs))
for i := range refs {
shas[i] = "abc123"
}
return shas, nil
},
})
defer restore()
// No stacks exist locally
require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}}))
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 99, PullRequests: []int{10, 11}},
}, nil
},
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
prs := map[int]*github.PullRequest{
10: {ID: "PR_10", Number: 10, HeadRefName: "feat-1", BaseRefName: "main", URL: "https://github.com/o/r/pull/10"},
11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "feat-1", URL: "https://github.com/o/r/pull/11"},
}
return prs[number], nil
},
}
err := runCheckout(cfg, &checkoutOptions{target: "11"})
output := collectOutput(cfg, outR, errR)
require.NoError(t, err)
// Only feat-2 should be created (feat-1 and main already exist)
assert.Equal(t, []string{"feat-2"}, createdBranches)
assert.Equal(t, "feat-2", checkedOut)
assert.Contains(t, output, "Imported stack with 2 branches")
}
func TestCheckout_NumericTarget_AlreadyInMatchingStack(t *testing.T) {
gitDir := t.TempDir()
var checkedOut string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
CheckoutBranchFn: func(name string) error {
checkedOut = name
return nil
},
RevParseFn: func(ref string) (string, error) {
return "abc123", nil
},
RevParseMultiFn: func(refs []string) ([]string, error) {
shas := make([]string, len(refs))
for i := range refs {
shas[i] = "abc123"
}
return shas, nil
},
})
defer restore()
// Stack already exists locally with matching PRs
writeStackFile(t, gitDir, stack.Stack{
ID: "42",
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "feat-1", PullRequest: &stack.PullRequestRef{Number: 10, URL: "https://github.com/o/r/pull/10"}},
{Branch: "feat-2", PullRequest: &stack.PullRequestRef{Number: 11, URL: "https://github.com/o/r/pull/11"}},
},
})
cfg, outR, errR := config.NewTestConfig()
// PR 10 is found locally → no API call needed
// No GitHubClientOverride means API calls would panic
err := runCheckout(cfg, &checkoutOptions{target: "10"})
output := collectOutput(cfg, outR, errR)
require.NoError(t, err)
assert.Equal(t, "feat-1", checkedOut)
assert.Contains(t, output, "Switched to feat-1")
}
func TestCheckout_NumericTarget_LocalMiss_RemoteMatch(t *testing.T) {
// PR 11 is NOT in any local stack, but IS in a remote stack.
// The API should be called as a fallback.
gitDir := t.TempDir()
var checkedOut string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
BranchExistsFn: func(name string) bool {
return name == "main"
},
FetchFn: func(remote string) error { return nil },
CreateBranchFn: func(name, base string) error { return nil },
SetUpstreamTrackingFn: func(branch, remote string) error { return nil },
ResolveRemoteFn: func(branch string) (string, error) { return "origin", nil },
CheckoutBranchFn: func(name string) error {
checkedOut = name
return nil
},
RevParseFn: func(ref string) (string, error) {
return "abc123", nil
},
RevParseMultiFn: func(refs []string) ([]string, error) {
shas := make([]string, len(refs))
for i := range refs {
shas[i] = "abc123"
}
return shas, nil
},
})
defer restore()
// Local stack has PR 42 only — PR 11 is not tracked
writeStackFile(t, gitDir, stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "other-branch", PullRequest: &stack.PullRequestRef{Number: 42}},
},
})
apiCalled := false
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
apiCalled = true
return []github.RemoteStack{
{ID: 99, PullRequests: []int{10, 11}},
}, nil
},
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
prs := map[int]*github.PullRequest{
10: {ID: "PR_10", Number: 10, HeadRefName: "feat-1", BaseRefName: "main", URL: "https://github.com/o/r/pull/10"},
11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "feat-1", URL: "https://github.com/o/r/pull/11"},
}
return prs[number], nil
},
}
err := runCheckout(cfg, &checkoutOptions{target: "11"})
_ = collectOutput(cfg, outR, errR)
require.NoError(t, err)
assert.True(t, apiCalled, "should have called ListStacks API when local miss")
assert.Equal(t, "feat-2", checkedOut)
}
func TestCheckout_NumericTarget_FallbackToBranchName(t *testing.T) {
// PR 999 is not in any local stack and not in any remote stack,
// but "999" happens to be a branch name in a local stack
gitDir := t.TempDir()
var checkedOut string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
CheckoutBranchFn: func(name string) error {
checkedOut = name
return nil
},
})
defer restore()
writeStackFile(t, gitDir, stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "999", PullRequest: &stack.PullRequestRef{Number: 50}},
},
})
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil // no remote stacks
},
}
err := runCheckout(cfg, &checkoutOptions{target: "999"})
output := collectOutput(cfg, outR, errR)
require.NoError(t, err)
assert.Equal(t, "999", checkedOut)
assert.Contains(t, output, "Switched to 999")
}
func TestCheckout_NumericTarget_CompositionMismatch_NonInteractive(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
})
defer restore()
// Local stack has PRs 10, 11
writeStackFile(t, gitDir, stack.Stack{
ID: "42",
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "feat-1", PullRequest: &stack.PullRequestRef{Number: 10}},
{Branch: "feat-2", PullRequest: &stack.PullRequestRef{Number: 11}},
},
})
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
// Remote stack has PRs 10, 11, 12 (extra PR added)
return []github.RemoteStack{
{ID: 42, PullRequests: []int{10, 11, 12}},
}, nil
},
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
prs := map[int]*github.PullRequest{
10: {ID: "PR_10", Number: 10, HeadRefName: "feat-1", BaseRefName: "main"},
11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "feat-1"},
12: {ID: "PR_12", Number: 12, HeadRefName: "feat-3", BaseRefName: "feat-2"},
}
return prs[number], nil
},
}
// PR 12 not found locally → remote lookup → finds stack → mismatch with local
err := runCheckout(cfg, &checkoutOptions{target: "12"})
output := collectOutput(cfg, outR, errR)
assert.ErrorIs(t, err, ErrConflict)
assert.Contains(t, output, "local stack composition differs from remote")
assert.Contains(t, output, "Local:")
assert.Contains(t, output, "Remote:")
}
func TestCheckout_NumericTarget_ClosedMergedPR(t *testing.T) {
gitDir := t.TempDir()
var checkedOut string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
BranchExistsFn: func(name string) bool {
return name == "main"
},
FetchFn: func(remote string) error { return nil },
CreateBranchFn: func(name, base string) error { return nil },
SetUpstreamTrackingFn: func(branch, remote string) error { return nil },
ResolveRemoteFn: func(branch string) (string, error) {
return "origin", nil
},
CheckoutBranchFn: func(name string) error {
checkedOut = name
return nil
},
RevParseFn: func(ref string) (string, error) {
return "abc123", nil
},
RevParseMultiFn: func(refs []string) ([]string, error) {
shas := make([]string, len(refs))
for i := range refs {
shas[i] = "abc123"
}
return shas, nil
},
})
defer restore()
require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}}))
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 50, PullRequests: []int{10, 11}},
}, nil
},
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
prs := map[int]*github.PullRequest{
10: {ID: "PR_10", Number: 10, HeadRefName: "feat-1", BaseRefName: "main", Merged: true, State: "MERGED", URL: "https://github.com/o/r/pull/10"},
11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "feat-1", State: "OPEN", URL: "https://github.com/o/r/pull/11"},
}
return prs[number], nil
},
}
err := runCheckout(cfg, &checkoutOptions{target: "11"})
output := collectOutput(cfg, outR, errR)
require.NoError(t, err)
assert.Equal(t, "feat-2", checkedOut)
assert.Contains(t, output, "Imported stack with 2 branches")
// Verify merged state is saved
sf, loadErr := stack.Load(gitDir)
require.NoError(t, loadErr)
require.Len(t, sf.Stacks, 1)
assert.True(t, sf.Stacks[0].Branches[0].PullRequest.Merged)
assert.False(t, sf.Stacks[0].Branches[1].PullRequest.Merged)
}
func TestCheckout_NumericTarget_APIError(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
})
defer restore()
require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}}))
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return nil, fmt.Errorf("network error")
},
}
err := runCheckout(cfg, &checkoutOptions{target: "123"})
output := collectOutput(cfg, outR, errR)
assert.ErrorIs(t, err, ErrAPIFailure)
assert.Contains(t, output, "failed to list stacks")
}
func TestCheckout_NumericTarget_SyncsState(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
CheckoutBranchFn: func(name string) error {
return nil
},
RevParseFn: func(ref string) (string, error) {
return "abc123", nil
},
RevParseMultiFn: func(refs []string) ([]string, error) {
shas := make([]string, len(refs))
for i := range refs {
shas[i] = "abc123"
}
return shas, nil
},
})
defer restore()
// Existing stack with stale PR data — PR 10 found locally
writeStackFile(t, gitDir, stack.Stack{
ID: "42",
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "feat-1", PullRequest: &stack.PullRequestRef{Number: 10, URL: "old-url"}},
{Branch: "feat-2", PullRequest: &stack.PullRequestRef{Number: 11, URL: "old-url"}},
},
})
cfg, outR, errR := config.NewTestConfig()
// PR 10 is found locally → no API call needed, resolved directly
err := runCheckout(cfg, &checkoutOptions{target: "10"})
_ = collectOutput(cfg, outR, errR)
require.NoError(t, err)
}
func TestCheckout_NumericTarget_EmptyStacks(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
})
defer restore()
require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}}))
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil // no stacks at all
},
}
err := runCheckout(cfg, &checkoutOptions{target: "123"})
output := collectOutput(cfg, outR, errR)
assert.ErrorIs(t, err, ErrNotInStack)
assert.Contains(t, output, "PR #123 is not part of a stack")
}
func TestCheckout_NumericTarget_AlreadyOnTarget(t *testing.T) {
gitDir := t.TempDir()
checkoutCalled := false
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "feat-1", nil },
CheckoutBranchFn: func(name string) error {
checkoutCalled = true
return nil
},
})
defer restore()
writeStackFile(t, gitDir, stack.Stack{
ID: "42",
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "feat-1", PullRequest: &stack.PullRequestRef{Number: 10, URL: "https://github.com/o/r/pull/10"}},
{Branch: "feat-2", PullRequest: &stack.PullRequestRef{Number: 11, URL: "https://github.com/o/r/pull/11"}},
},
})
cfg, outR, errR := config.NewTestConfig()
// PR 10 found locally → resolved without API
err := runCheckout(cfg, &checkoutOptions{target: "10"})
output := collectOutput(cfg, outR, errR)
require.NoError(t, err)
assert.False(t, checkoutCalled, "should not call CheckoutBranch when already on target")
assert.Contains(t, output, "Already on feat-1")
}
// --- Helper tests ---
func TestStackCompositionMatches(t *testing.T) {
tests := []struct {
name string
local *stack.Stack
remote []int
matches bool
}{
{
name: "exact match",
local: &stack.Stack{
Branches: []stack.BranchRef{
{Branch: "a", PullRequest: &stack.PullRequestRef{Number: 10}},
{Branch: "b", PullRequest: &stack.PullRequestRef{Number: 11}},
},
},
remote: []int{10, 11},
matches: true,
},
{
name: "different order",
local: &stack.Stack{
Branches: []stack.BranchRef{
{Branch: "a", PullRequest: &stack.PullRequestRef{Number: 11}},
{Branch: "b", PullRequest: &stack.PullRequestRef{Number: 10}},
},
},
remote: []int{10, 11},
matches: false,
},
{
name: "remote has more",
local: &stack.Stack{
Branches: []stack.BranchRef{
{Branch: "a", PullRequest: &stack.PullRequestRef{Number: 10}},
},
},
remote: []int{10, 11},
matches: false,
},
{
name: "local has branch without PR",
local: &stack.Stack{
Branches: []stack.BranchRef{
{Branch: "a", PullRequest: &stack.PullRequestRef{Number: 10}},
{Branch: "b"}, // no PR
},
},
remote: []int{10, 11},
matches: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := stackCompositionMatches(tt.local, tt.remote)
assert.Equal(t, tt.matches, result)
})
}
}
func TestFindRemoteStackForPR(t *testing.T) {
mock := &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 1, PullRequests: []int{10, 11}},
{ID: 2, PullRequests: []int{20, 21, 22}},
}, nil
},
}
// Found in first stack
rs, err := findRemoteStackForPR(mock, 11)
require.NoError(t, err)
require.NotNil(t, rs)
assert.Equal(t, 1, rs.ID)
// Found in second stack
rs, err = findRemoteStackForPR(mock, 21)
require.NoError(t, err)
require.NotNil(t, rs)
assert.Equal(t, 2, rs.ID)
// Not found
rs, err = findRemoteStackForPR(mock, 99)
require.NoError(t, err)
assert.Nil(t, rs)
}