Files
2026-04-08 12:44:11 -04:00

382 lines
12 KiB
Go

package cmd
import (
"fmt"
"io"
"os"
"testing"
"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"
)
// collectOutput closes the write ends of the test config pipes and returns
// the captured stderr content. Shared across cmd test files.
func collectOutput(cfg *config.Config, outR, errR *os.File) string {
cfg.Out.Close()
cfg.Err.Close()
stderr, _ := io.ReadAll(errR)
outR.Close()
errR.Close()
return string(stderr)
}
func TestInit_CreatesStackWithCorrectTrunk(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
})
defer restore()
cfg, outR, errR := config.NewTestConfig()
runInit(cfg, &initOptions{branches: []string{"myBranch"}})
output := collectOutput(cfg, outR, errR)
require.NotContains(t, output, "\u2717", "unexpected error in output")
sf, err := stack.Load(gitDir)
require.NoError(t, err, "loading stack")
require.Len(t, sf.Stacks, 1)
s := sf.Stacks[0]
assert.Equal(t, "main", s.Trunk.Branch)
names := s.BranchNames()
require.Len(t, names, 1)
assert.Equal(t, "myBranch", names[0])
}
func TestInit_CustomTrunk(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()
cfg, outR, errR := config.NewTestConfig()
runInit(cfg, &initOptions{branches: []string{"myBranch"}, base: "develop"})
output := collectOutput(cfg, outR, errR)
require.NotContains(t, output, "\u2717", "unexpected error")
sf, err := stack.Load(gitDir)
require.NoError(t, err, "loading stack")
assert.Equal(t, "develop", sf.Stacks[0].Trunk.Branch)
}
func TestInit_AdoptExistingBranches(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
BranchExistsFn: func(string) bool { return true },
})
defer restore()
cfg, outR, errR := config.NewTestConfig()
runInit(cfg, &initOptions{
branches: []string{"b1", "b2", "b3"},
adopt: true,
})
output := collectOutput(cfg, outR, errR)
require.NotContains(t, output, "\u2717", "unexpected error")
sf, err := stack.Load(gitDir)
require.NoError(t, err, "loading stack")
names := sf.Stacks[0].BranchNames()
assert.Equal(t, []string{"b1", "b2", "b3"}, names)
}
func TestInit_PrefixStoredInStack(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
})
defer restore()
cfg, outR, errR := config.NewTestConfig()
runInit(cfg, &initOptions{branches: []string{"myBranch"}, prefix: "feat"})
collectOutput(cfg, outR, errR)
sf, err := stack.Load(gitDir)
require.NoError(t, err, "loading stack")
assert.Equal(t, "feat", sf.Stacks[0].Prefix)
}
func TestInit_PrefixAppliedToExplicitBranches(t *testing.T) {
gitDir := t.TempDir()
var created []string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
CreateBranchFn: func(name, base string) error {
created = append(created, name)
return nil
},
})
defer restore()
cfg, outR, errR := config.NewTestConfig()
err := runInit(cfg, &initOptions{branches: []string{"b1", "b2"}, prefix: "feat"})
output := collectOutput(cfg, outR, errR)
require.NoError(t, err, "runInit should succeed")
require.NotContains(t, output, "\u2717", "unexpected error")
assert.Equal(t, []string{"feat/b1", "feat/b2"}, created, "branches should be created with prefix")
sf, err := stack.Load(gitDir)
require.NoError(t, err, "loading stack")
names := sf.Stacks[0].BranchNames()
assert.Equal(t, []string{"feat/b1", "feat/b2"}, names, "stack should store prefixed branch names")
}
func TestInit_InvalidPrefixRejectedBeforeBranchCreation(t *testing.T) {
gitDir := t.TempDir()
var created []string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
ValidateRefNameFn: func(name string) error {
return fmt.Errorf("invalid ref name: %s", name)
},
CreateBranchFn: func(name, base string) error {
created = append(created, name)
return nil
},
})
defer restore()
cfg, outR, errR := config.NewTestConfig()
err := runInit(cfg, &initOptions{branches: []string{"mybranch"}, prefix: "bad..prefix"})
output := collectOutput(cfg, outR, errR)
assert.ErrorIs(t, err, ErrInvalidArgs, "should reject invalid prefix")
assert.Contains(t, output, "invalid prefix")
assert.Empty(t, created, "no branches should be created when prefix is invalid")
}
func TestInit_AdoptRejectsPrefix(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
})
defer restore()
cfg, outR, errR := config.NewTestConfig()
err := runInit(cfg, &initOptions{adopt: true, branches: []string{"b1"}, prefix: "feat"})
output := collectOutput(cfg, outR, errR)
assert.ErrorIs(t, err, ErrInvalidArgs)
assert.Contains(t, output, "--adopt cannot be combined with --prefix or --numbered")
}
func TestInit_AdoptRejectsNumbered(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
})
defer restore()
cfg, outR, errR := config.NewTestConfig()
err := runInit(cfg, &initOptions{adopt: true, branches: []string{"b1"}, numbered: true})
output := collectOutput(cfg, outR, errR)
assert.ErrorIs(t, err, ErrInvalidArgs)
assert.Contains(t, output, "--adopt cannot be combined with --prefix or --numbered")
}
func TestInit_RerereAlreadyEnabled(t *testing.T) {
gitDir := t.TempDir()
enableRerereCalled := false
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
IsRerereEnabledFn: func() (bool, error) { return true, nil },
EnableRerereFn: func() error {
enableRerereCalled = true
return nil
},
})
defer restore()
cfg, outR, errR := config.NewTestConfig()
runInit(cfg, &initOptions{branches: []string{"b1"}})
collectOutput(cfg, outR, errR)
assert.False(t, enableRerereCalled, "EnableRerere should not be called when rerere is already enabled")
}
func TestInit_RefuseIfBranchAlreadyInStack(t *testing.T) {
gitDir := t.TempDir()
// Pre-create stack file with "feature-1" as a non-trunk branch
sf := &stack.StackFile{
SchemaVersion: 1,
Stacks: []stack.Stack{{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "feature-1"}},
}},
}
require.NoError(t, stack.Save(gitDir, sf), "saving seed stack")
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "feature-1", nil },
})
defer restore()
cfg, outR, errR := config.NewTestConfig()
runInit(cfg, &initOptions{branches: []string{"newBranch"}})
output := collectOutput(cfg, outR, errR)
assert.Contains(t, output, "already part of a stack")
}
func TestInit_AdoptNonexistentBranch(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
BranchExistsFn: func(string) bool { return false },
})
defer restore()
cfg, outR, errR := config.NewTestConfig()
runInit(cfg, &initOptions{branches: []string{"nonexistent"}, adopt: true})
output := collectOutput(cfg, outR, errR)
assert.Contains(t, output, "does not exist")
}
func TestInit_MultipleBranches_CreatesAll(t *testing.T) {
gitDir := t.TempDir()
var created []string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
CreateBranchFn: func(name, base string) error {
created = append(created, name)
return nil
},
})
defer restore()
cfg, outR, errR := config.NewTestConfig()
runInit(cfg, &initOptions{branches: []string{"b1", "b2", "b3"}})
output := collectOutput(cfg, outR, errR)
require.NotContains(t, output, "\u2717", "unexpected error")
sf, err := stack.Load(gitDir)
require.NoError(t, err, "loading stack")
names := sf.Stacks[0].BranchNames()
assert.Equal(t, []string{"b1", "b2", "b3"}, names)
}
func TestInit_AdoptWithExistingOpenPR(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
BranchExistsFn: func(string) bool { return true },
})
defer restore()
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
FindPRForBranchFn: func(branch string) (*github.PullRequest, error) {
if branch == "b1" {
return &github.PullRequest{
Number: 42,
ID: "PR_42",
URL: "https://github.com/owner/repo/pull/42",
State: "OPEN",
HeadRefName: "b1",
}, nil
}
return nil, nil
},
}
err := runInit(cfg, &initOptions{
branches: []string{"b1", "b2"},
adopt: true,
})
output := collectOutput(cfg, outR, errR)
require.NoError(t, err, "adopt should succeed even when branch has an open PR")
require.NotContains(t, output, "\u2717", "unexpected error in output")
sf, err := stack.Load(gitDir)
require.NoError(t, err, "loading stack")
require.Len(t, sf.Stacks, 1)
// b1 should have the open PR recorded
b1 := sf.Stacks[0].Branches[0]
require.NotNil(t, b1.PullRequest, "open PR should be recorded")
assert.Equal(t, 42, b1.PullRequest.Number)
assert.Equal(t, "https://github.com/owner/repo/pull/42", b1.PullRequest.URL)
// b2 should have no PR
b2 := sf.Stacks[0].Branches[1]
assert.Nil(t, b2.PullRequest, "branch without PR should have nil PullRequest")
}
func TestInit_AdoptIgnoresClosedAndMergedPRs(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
BranchExistsFn: func(string) bool { return true },
})
defer restore()
cfg, outR, errR := config.NewTestConfig()
// FindPRForBranch only returns OPEN PRs — closed/merged PRs won't be
// returned by the API, so the mock returns nil for all branches.
cfg.GitHubClientOverride = &github.MockClient{
FindPRForBranchFn: func(branch string) (*github.PullRequest, error) {
return nil, nil
},
}
err := runInit(cfg, &initOptions{
branches: []string{"b1", "b2"},
adopt: true,
})
output := collectOutput(cfg, outR, errR)
require.NoError(t, err, "adopt should succeed when branches have closed/merged PRs")
require.NotContains(t, output, "\u2717", "unexpected error in output")
sf, err := stack.Load(gitDir)
require.NoError(t, err, "loading stack")
require.Len(t, sf.Stacks, 1)
// Neither branch should have a PR recorded (closed/merged are filtered out)
for _, b := range sf.Stacks[0].Branches {
assert.Nil(t, b.PullRequest, "closed/merged PRs should not be recorded for branch %s", b.Branch)
}
}