mirror of
https://github.com/github/gh-stack.git
synced 2026-09-14 20:26:28 +08:00
382 lines
12 KiB
Go
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)
|
|
}
|
|
}
|