package cmd import ( "fmt" "io" "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" ) // newPushMock creates a MockOps pre-configured for push tests. func newPushMock(tmpDir string, currentBranch string) *git.MockOps { return &git.MockOps{ GitDirFn: func() (string, error) { return tmpDir, nil }, CurrentBranchFn: func() (string, error) { return currentBranch, nil }, ResolveRemoteFn: func(string) (string, error) { return "origin", nil }, PushFn: func(string, []string, bool, bool) error { return nil }, } } func TestPush_PushesAllBranches(t *testing.T) { s := stack.Stack{ Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ {Branch: "b1"}, {Branch: "b2"}, }, } tmpDir := t.TempDir() writeStackFile(t, tmpDir, s) var pushCalls []pushCall mock := newPushMock(tmpDir, "b1") mock.PushFn = func(remote string, branches []string, force, atomic bool) error { pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic}) return nil } restore := git.SetOps(mock) defer restore() cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{} cmd := PushCmd(cfg) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) err := cmd.Execute() cfg.Err.Close() errOut, _ := io.ReadAll(errR) output := string(errOut) assert.NoError(t, err) require.Len(t, pushCalls, 1) assert.Equal(t, "origin", pushCalls[0].remote) assert.Equal(t, []string{"b1", "b2"}, pushCalls[0].branches) assert.True(t, pushCalls[0].force) assert.True(t, pushCalls[0].atomic) assert.Contains(t, output, "Pushed 2 branches") assert.Contains(t, output, "gh stack submit", "should hint about submit when branches have no PRs") } func TestPush_NoSubmitHintWhenPRsExist(t *testing.T) { s := stack.Stack{ Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}}, {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}}, }, } tmpDir := t.TempDir() writeStackFile(t, tmpDir, s) mock := newPushMock(tmpDir, "b1") restore := git.SetOps(mock) defer restore() cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{} cmd := PushCmd(cfg) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) err := cmd.Execute() cfg.Err.Close() errOut, _ := io.ReadAll(errR) output := string(errOut) assert.NoError(t, err) assert.Contains(t, output, "Pushed 2 branches") assert.NotContains(t, output, "gh stack submit", "should not hint about submit when all branches have PRs") } func TestPush_SkipsMergedBranches(t *testing.T) { s := stack.Stack{ Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, {Branch: "b2"}, {Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 3, Merged: true}}, }, } tmpDir := t.TempDir() writeStackFile(t, tmpDir, s) var pushCalls []pushCall mock := newPushMock(tmpDir, "b2") mock.PushFn = func(remote string, branches []string, force, atomic bool) error { pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic}) return nil } restore := git.SetOps(mock) defer restore() cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{} cmd := PushCmd(cfg) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) err := cmd.Execute() cfg.Err.Close() _, _ = io.ReadAll(errR) assert.NoError(t, err) require.Len(t, pushCalls, 1) assert.Equal(t, []string{"b2"}, pushCalls[0].branches) } func TestPush_PushFailure(t *testing.T) { s := stack.Stack{ Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ {Branch: "b1"}, }, } tmpDir := t.TempDir() writeStackFile(t, tmpDir, s) mock := newPushMock(tmpDir, "b1") mock.PushFn = func(string, []string, bool, bool) error { return fmt.Errorf("remote rejected") } restore := git.SetOps(mock) defer restore() cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{} cmd := PushCmd(cfg) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) err := cmd.Execute() cfg.Err.Close() errOut, _ := io.ReadAll(errR) output := string(errOut) assert.ErrorIs(t, err, ErrSilent) assert.Contains(t, output, "failed to push") } func TestPush_DoesNotCreatePRs(t *testing.T) { s := stack.Stack{ Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ {Branch: "b1"}, {Branch: "b2"}, }, } tmpDir := t.TempDir() writeStackFile(t, tmpDir, s) mock := newPushMock(tmpDir, "b1") restore := git.SetOps(mock) defer restore() createPRCalled := false cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ CreatePRFn: func(string, string, string, string, bool) (*github.PullRequest, error) { createPRCalled = true return nil, nil }, } cmd := PushCmd(cfg) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) err := cmd.Execute() cfg.Err.Close() _, _ = io.ReadAll(errR) assert.NoError(t, err) assert.False(t, createPRCalled, "push should not create PRs") }