package base import ( "bytes" "net/http" "testing" "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" "github.com/stretchr/testify/assert" ) func TestNewCmdSetDefault(t *testing.T) { tests := []struct { name string gitStubs func(*run.CommandStubber) remotes func() (context.Remotes, error) input string output SetDefaultOptions wantErr bool errMsg string }{ { name: "no argument", gitStubs: func(cs *run.CommandStubber) { cs.Register(`git rev-parse --git-dir`, 0, ".git") }, input: "", output: SetDefaultOptions{}, }, { name: "repo argument", gitStubs: func(cs *run.CommandStubber) { cs.Register(`git rev-parse --git-dir`, 0, ".git") }, input: "cli/cli", output: SetDefaultOptions{Repo: ghrepo.New("cli", "cli")}, }, { name: "invalid repo argument", gitStubs: func(cs *run.CommandStubber) { cs.Register(`git rev-parse --git-dir`, 0, ".git") }, input: "some_invalid_format", wantErr: true, errMsg: `given arg is not a valid repo or git remote: expected the "[HOST/]OWNER/REPO" format, got "some_invalid_format"`, }, { name: "view flag", gitStubs: func(cs *run.CommandStubber) { cs.Register(`git rev-parse --git-dir`, 0, ".git") }, input: "--view", output: SetDefaultOptions{ViewMode: true}, }, { name: "unset flag", gitStubs: func(cs *run.CommandStubber) { cs.Register(`git rev-parse --git-dir`, 0, ".git") }, input: "--unset", output: SetDefaultOptions{UnsetMode: true}, }, { name: "run from non-git directory", gitStubs: func(cs *run.CommandStubber) { cs.Register(`git rev-parse --git-dir`, 128, "") }, input: "", wantErr: true, errMsg: "must be run from inside a git repository", }, { name: "remote name argument", gitStubs: func(cs *run.CommandStubber) { cs.Register(`git rev-parse --git-dir`, 0, ".git") }, remotes: func() (context.Remotes, error) { return context.Remotes{ { Remote: &git.Remote{Name: "origin"}, Repo: ghrepo.New("OWNER", "REPO"), }, }, nil }, input: "origin", output: SetDefaultOptions{Repo: ghrepo.New("OWNER", "REPO")}, }, { name: "repo argument despite remote name matching owner/repo", gitStubs: func(cs *run.CommandStubber) { cs.Register(`git rev-parse --git-dir`, 0, ".git") }, remotes: func() (context.Remotes, error) { return context.Remotes{ { Remote: &git.Remote{Name: "OWNER/REPO"}, Repo: ghrepo.New("OTHER", "REPO"), }, }, nil }, input: "OWNER/REPO", output: SetDefaultOptions{Repo: ghrepo.New("OWNER", "REPO")}, }, } for _, tt := range tests { io, _, _, _ := iostreams.Test() io.SetStdoutTTY(true) io.SetStdinTTY(true) io.SetStderrTTY(true) remotesFunc := tt.remotes if remotesFunc == nil { remotesFunc = func() (context.Remotes, error) { return context.Remotes{}, nil } } f := &cmdutil.Factory{ IOStreams: io, GitClient: &git.Client{GitPath: "/fake/path/to/git"}, Remotes: remotesFunc, } var gotOpts *SetDefaultOptions cmd := NewCmdSetDefault(f, func(opts *SetDefaultOptions) error { gotOpts = opts return nil }) cmd.SetIn(&bytes.Buffer{}) cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&bytes.Buffer{}) t.Run(tt.name, func(t *testing.T) { argv, err := shlex.Split(tt.input) assert.NoError(t, err) cmd.SetArgs(argv) cs, teardown := run.Stub() defer teardown(t) tt.gitStubs(cs) _, err = cmd.ExecuteC() if tt.wantErr { assert.EqualError(t, err, tt.errMsg) return } assert.NoError(t, err) assert.Equal(t, tt.output.Repo, gotOpts.Repo) assert.Equal(t, tt.output.ViewMode, gotOpts.ViewMode) }) } } func TestDefaultRun(t *testing.T) { repo1, _ := ghrepo.FromFullName("OWNER/REPO") repo2, _ := ghrepo.FromFullName("OWNER2/REPO2") repo3, _ := ghrepo.FromFullName("OWNER3/REPO3") repo4, _ := ghrepo.FromFullName("OWNER4/REPO4") repo5, _ := ghrepo.FromFullName("OWNER5/REPO5") repo6, _ := ghrepo.FromFullName("OWNER6/REPO6") tests := []struct { name string tty bool opts SetDefaultOptions remotes []*context.Remote httpStubs func(*httpmock.Registry) gitStubs func(*run.CommandStubber) prompterStubs func(*prompter.PrompterMock) wantStdout string wantStderr string wantErr bool errMsg string }{ { name: "unset mode with base resolved current default", tty: true, opts: SetDefaultOptions{UnsetMode: true}, remotes: []*context.Remote{ { Remote: &git.Remote{Name: "origin", Resolved: "base"}, Repo: repo1, }, }, gitStubs: func(cs *run.CommandStubber) { cs.Register(`git config --unset remote.origin.gh-resolved`, 0, "") }, wantStdout: "✓ Unset OWNER/REPO as default repository\n", }, { name: "unset mode no current default", tty: true, opts: SetDefaultOptions{UnsetMode: true}, remotes: []*context.Remote{ { Remote: &git.Remote{Name: "origin"}, Repo: repo1, }, }, wantStdout: "no default repository has been set\n", }, { name: "tty view mode no current default", tty: true, opts: SetDefaultOptions{ViewMode: true}, remotes: []*context.Remote{ { Remote: &git.Remote{Name: "origin"}, Repo: repo1, }, }, wantStderr: "X No default remote repository has been set. To learn more about the default repository, run: gh repo set-default --help\n", }, { name: "view mode no current default", tty: false, opts: SetDefaultOptions{ViewMode: true}, remotes: []*context.Remote{ { Remote: &git.Remote{Name: "origin"}, Repo: repo1, }, }, wantStderr: "X No default remote repository has been set. To learn more about the default repository, run: gh repo set-default --help\n", }, { name: "view mode with base resolved current default", opts: SetDefaultOptions{ViewMode: true}, remotes: []*context.Remote{ { Remote: &git.Remote{Name: "origin", Resolved: "base"}, Repo: repo1, }, }, wantStdout: "OWNER/REPO\n", }, { name: "view mode with non-base resolved current default", opts: SetDefaultOptions{ViewMode: true}, remotes: []*context.Remote{ { Remote: &git.Remote{Name: "origin", Resolved: "PARENT/REPO"}, Repo: repo1, }, }, wantStdout: "PARENT/REPO\n", }, { name: "tty non-interactive mode no current default", tty: true, opts: SetDefaultOptions{Repo: repo2}, remotes: []*context.Remote{ { Remote: &git.Remote{Name: "origin"}, Repo: repo1, }, { Remote: &git.Remote{Name: "upstream"}, Repo: repo2, }, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query RepositoryNetwork\b`), httpmock.StringResponse(`{"data":{"repo_000":{"name":"REPO2","owner":{"login":"OWNER2"}}}}`), ) }, gitStubs: func(cs *run.CommandStubber) { cs.Register(`git config --add remote.upstream.gh-resolved base`, 0, "") }, wantStdout: "✓ Set OWNER2/REPO2 as the default repository for the current directory\n", }, { name: "tty non-interactive mode set non-base default", tty: true, opts: SetDefaultOptions{Repo: repo2}, remotes: []*context.Remote{ { Remote: &git.Remote{Name: "origin"}, Repo: repo1, }, { Remote: &git.Remote{Name: "upstream"}, Repo: repo3, }, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query RepositoryNetwork\b`), httpmock.StringResponse(`{"data":{"repo_000":{"name":"REPO","owner":{"login":"OWNER"},"parent":{"name":"REPO2","owner":{"login":"OWNER2"}}}}}`), ) }, gitStubs: func(cs *run.CommandStubber) { cs.Register(`git config --add remote.upstream.gh-resolved OWNER2/REPO2`, 0, "") }, wantStdout: "✓ Set OWNER2/REPO2 as the default repository for the current directory\n", }, { name: "non-tty non-interactive mode no current default", opts: SetDefaultOptions{Repo: repo2}, remotes: []*context.Remote{ { Remote: &git.Remote{Name: "origin"}, Repo: repo1, }, { Remote: &git.Remote{Name: "upstream"}, Repo: repo2, }, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query RepositoryNetwork\b`), httpmock.StringResponse(`{"data":{"repo_000":{"name":"REPO2","owner":{"login":"OWNER2"}}}}`), ) }, gitStubs: func(cs *run.CommandStubber) { cs.Register(`git config --add remote.upstream.gh-resolved base`, 0, "") }, wantStdout: "", }, { name: "non-interactive mode with current default", tty: true, opts: SetDefaultOptions{Repo: repo2}, remotes: []*context.Remote{ { Remote: &git.Remote{Name: "origin", Resolved: "base"}, Repo: repo1, }, { Remote: &git.Remote{Name: "upstream"}, Repo: repo2, }, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query RepositoryNetwork\b`), httpmock.StringResponse(`{"data":{"repo_000":{"name":"REPO2","owner":{"login":"OWNER2"}}}}`), ) }, gitStubs: func(cs *run.CommandStubber) { cs.Register(`git config --unset remote.origin.gh-resolved`, 0, "") cs.Register(`git config --add remote.upstream.gh-resolved base`, 0, "") }, wantStdout: "✓ Set OWNER2/REPO2 as the default repository for the current directory\n", }, { name: "non-interactive mode no known hosts", opts: SetDefaultOptions{Repo: repo2}, remotes: []*context.Remote{ { Remote: &git.Remote{Name: "origin"}, Repo: repo1, }, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query RepositoryNetwork\b`), httpmock.StringResponse(`{"data":{}}`), ) }, wantErr: true, errMsg: "none of the git remotes correspond to a valid remote repository", }, { name: "non-interactive mode no matching remotes", opts: SetDefaultOptions{Repo: repo2}, remotes: []*context.Remote{ { Remote: &git.Remote{Name: "origin"}, Repo: repo1, }, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query RepositoryNetwork\b`), httpmock.StringResponse(`{"data":{"repo_000":{"name":"REPO","owner":{"login":"OWNER"}}}}`), ) }, wantErr: true, errMsg: "OWNER2/REPO2 does not correspond to any git remotes", }, { name: "interactive mode", tty: true, opts: SetDefaultOptions{}, remotes: []*context.Remote{ { Remote: &git.Remote{Name: "origin"}, Repo: repo1, }, { Remote: &git.Remote{Name: "upstream"}, Repo: repo2, }, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query RepositoryNetwork\b`), httpmock.StringResponse(`{"data":{"repo_000":{"name":"REPO","owner":{"login":"OWNER"}},"repo_001":{"name":"REPO2","owner":{"login":"OWNER2"}}}}`), ) }, gitStubs: func(cs *run.CommandStubber) { cs.Register(`git config --add remote.upstream.gh-resolved base`, 0, "") }, prompterStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(p, d string, opts []string) (int, error) { switch p { case "Which repository should be the default?": prompter.AssertOptions(t, []string{"OWNER/REPO", "OWNER2/REPO2"}, opts) return prompter.IndexFor(opts, "OWNER2/REPO2") default: return -1, prompter.NoSuchPromptErr(p) } } }, wantStdout: "This command sets the default remote repository to use when querying the\nGitHub API for the locally cloned repository.\n\ngh uses the default repository for things like:\n\n - viewing and creating pull requests\n - viewing and creating issues\n - viewing and creating releases\n - working with GitHub Actions\n\n### NOTE: gh does not use the default repository for managing repository and environment secrets.\n\n✓ Set OWNER2/REPO2 as the default repository for the current directory\n", }, { name: "interactive mode only one known host", tty: true, opts: SetDefaultOptions{}, remotes: []*context.Remote{ { Remote: &git.Remote{Name: "origin"}, Repo: repo1, }, { Remote: &git.Remote{Name: "upstream"}, Repo: repo2, }, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query RepositoryNetwork\b`), httpmock.StringResponse(`{"data":{"repo_000":{"name":"REPO2","owner":{"login":"OWNER2"}}}}`), ) }, gitStubs: func(cs *run.CommandStubber) { cs.Register(`git config --add remote.upstream.gh-resolved base`, 0, "") }, wantStdout: "Found only one known remote repo, OWNER2/REPO2 on github.com.\n✓ Set OWNER2/REPO2 as the default repository for the current directory\n", }, { name: "interactive mode more than five remotes", tty: true, opts: SetDefaultOptions{}, remotes: []*context.Remote{ {Remote: &git.Remote{Name: "origin"}, Repo: repo1}, {Remote: &git.Remote{Name: "upstream"}, Repo: repo2}, {Remote: &git.Remote{Name: "other1"}, Repo: repo3}, {Remote: &git.Remote{Name: "other2"}, Repo: repo4}, {Remote: &git.Remote{Name: "other3"}, Repo: repo5}, {Remote: &git.Remote{Name: "other4"}, Repo: repo6}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query RepositoryNetwork\b`), httpmock.GraphQLQuery(`{"data":{ "repo_000":{"name":"REPO","owner":{"login":"OWNER"}}, "repo_001":{"name":"REPO2","owner":{"login":"OWNER2"}}, "repo_002":{"name":"REPO3","owner":{"login":"OWNER3"}}, "repo_003":{"name":"REPO4","owner":{"login":"OWNER4"}}, "repo_004":{"name":"REPO5","owner":{"login":"OWNER5"}}, "repo_005":{"name":"REPO6","owner":{"login":"OWNER6"}} }}`, func(query string, inputs map[string]interface{}) { assert.Contains(t, query, "repo_000") assert.Contains(t, query, "repo_001") assert.Contains(t, query, "repo_002") assert.Contains(t, query, "repo_003") assert.Contains(t, query, "repo_004") assert.Contains(t, query, "repo_005") }), ) }, gitStubs: func(cs *run.CommandStubber) { cs.Register(`git config --add remote.upstream.gh-resolved base`, 0, "") }, prompterStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(p, d string, opts []string) (int, error) { switch p { case "Which repository should be the default?": prompter.AssertOptions(t, []string{"OWNER/REPO", "OWNER2/REPO2", "OWNER3/REPO3", "OWNER4/REPO4", "OWNER5/REPO5", "OWNER6/REPO6"}, opts) return prompter.IndexFor(opts, "OWNER2/REPO2") default: return -1, prompter.NoSuchPromptErr(p) } } }, wantStdout: "This command sets the default remote repository to use when querying the\nGitHub API for the locally cloned repository.\n\ngh uses the default repository for things like:\n\n - viewing and creating pull requests\n - viewing and creating issues\n - viewing and creating releases\n - working with GitHub Actions\n\n### NOTE: gh does not use the default repository for managing repository and environment secrets.\n\n✓ Set OWNER2/REPO2 as the default repository for the current directory\n", }, } for _, tt := range tests { reg := &httpmock.Registry{} if tt.httpStubs != nil { tt.httpStubs(reg) } tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } io, _, stdout, stderr := iostreams.Test() io.SetStdinTTY(tt.tty) io.SetStdoutTTY(tt.tty) io.SetStderrTTY(tt.tty) tt.opts.IO = io tt.opts.Remotes = func() (context.Remotes, error) { return tt.remotes, nil } tt.opts.GitClient = &git.Client{} pm := &prompter.PrompterMock{} if tt.prompterStubs != nil { tt.prompterStubs(pm) } tt.opts.Prompter = pm t.Run(tt.name, func(t *testing.T) { cs, teardown := run.Stub() defer teardown(t) if tt.gitStubs != nil { tt.gitStubs(cs) } defer reg.Verify(t) err := setDefaultRun(&tt.opts) if tt.wantErr { assert.EqualError(t, err, tt.errMsg) return } assert.NoError(t, err) if tt.wantStdout != "" { assert.Equal(t, tt.wantStdout, stdout.String()) } else { assert.Equal(t, tt.wantStderr, stderr.String()) } }) } }