diff --git a/context/context.go b/context/context.go index 568e3e623..b5ed6dcbb 100644 --- a/context/context.go +++ b/context/context.go @@ -5,6 +5,7 @@ import ( "context" "errors" "sort" + "strings" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" @@ -85,36 +86,18 @@ func (r *ResolvedRemotes) BaseRepo(io *iostreams.IOStreams, p iprompter) (ghrepo return r.remotes[0], nil } - // from here on, consult the API - if r.network == nil { - err := resolveNetwork(r) - if err != nil { - return nil, err - } + repos, err := r.NetworkRepos() + if err != nil { + return nil, err + } + + if len(repos) == 0 { + return r.remotes[0], nil } var repoNames []string - repoMap := map[string]*api.Repository{} - add := func(r *api.Repository) { - fn := ghrepo.FullName(r) - if _, ok := repoMap[fn]; !ok { - repoMap[fn] = r - repoNames = append(repoNames, fn) - } - } - - for _, repo := range r.network.Repositories { - if repo == nil { - continue - } - if repo.Parent != nil { - add(repo.Parent) - } - add(repo) - } - - if len(repoNames) == 0 { - return r.remotes[0], nil + for _, r := range repos { + repoNames = append(repoNames, ghrepo.FullName(r)) } baseName := repoNames[0] @@ -130,7 +113,8 @@ func (r *ResolvedRemotes) BaseRepo(io *iostreams.IOStreams, p iprompter) (ghrepo } // determine corresponding git remote - selectedRepo := repoMap[baseName] + owner, repo, _ := strings.Cut(baseName, "/") + selectedRepo := ghrepo.New(owner, repo) resolution := "base" remote, _ := r.RemoteForRepo(selectedRepo) if remote == nil { @@ -140,7 +124,7 @@ func (r *ResolvedRemotes) BaseRepo(io *iostreams.IOStreams, p iprompter) (ghrepo // cache the result to git config c := &git.Client{} - err := c.SetRemoteResolution(context.Background(), remote.Name, resolution) + err = c.SetRemoteResolution(context.Background(), remote.Name, resolution) return selectedRepo, err } @@ -161,6 +145,38 @@ func (r *ResolvedRemotes) HeadRepos() ([]*api.Repository, error) { return results, nil } +func (r *ResolvedRemotes) NetworkRepos() ([]*api.Repository, error) { + if r.network == nil { + err := resolveNetwork(r) + if err != nil { + return nil, err + } + } + + var repos []*api.Repository + repoMap := map[string]bool{} + + add := func(r *api.Repository) { + fn := ghrepo.FullName(r) + if _, ok := repoMap[fn]; !ok { + repoMap[fn] = true + repos = append(repos, r) + } + } + + for _, repo := range r.network.Repositories { + if repo == nil { + continue + } + if repo.Parent != nil { + add(repo.Parent) + } + add(repo) + } + + return repos, nil +} + // RemoteForRepo finds the git remote that points to a repository func (r *ResolvedRemotes) RemoteForRepo(repo ghrepo.Interface) (*Remote, error) { for _, remote := range r.remotes { diff --git a/context/remote.go b/context/remote.go index 708dee31d..3540ad95d 100644 --- a/context/remote.go +++ b/context/remote.go @@ -21,7 +21,7 @@ func (r Remotes) FindByName(names ...string) (*Remote, error) { } } } - return nil, fmt.Errorf("no GitHub remotes found") + return nil, fmt.Errorf("no matching remote found") } // FindByRepo returns the first Remote that points to a specific GitHub repository @@ -34,6 +34,29 @@ func (r Remotes) FindByRepo(owner, name string) (*Remote, error) { return nil, fmt.Errorf("no matching remote found") } +// Filter remotes by given hostnames, maintains original order +func (r Remotes) FilterByHosts(hosts []string) Remotes { + filtered := make(Remotes, 0) + for _, rr := range r { + for _, host := range hosts { + if strings.EqualFold(rr.RepoHost(), host) { + filtered = append(filtered, rr) + break + } + } + } + return filtered +} + +func (r Remotes) ResolvedRemote() (*Remote, error) { + for _, rr := range r { + if rr.Resolved != "" { + return rr, nil + } + } + return nil, fmt.Errorf("no resolved remote found") +} + func remoteNameSortScore(name string) int { switch strings.ToLower(name) { case "upstream": @@ -54,20 +77,6 @@ func (r Remotes) Less(i, j int) bool { return remoteNameSortScore(r[i].Name) > remoteNameSortScore(r[j].Name) } -// Filter remotes by given hostnames, maintains original order -func (r Remotes) FilterByHosts(hosts []string) Remotes { - filtered := make(Remotes, 0) - for _, rr := range r { - for _, host := range hosts { - if strings.EqualFold(rr.RepoHost(), host) { - filtered = append(filtered, rr) - break - } - } - } - return filtered -} - // Remote represents a git remote mapped to a GitHub repository type Remote struct { *git.Remote diff --git a/git/client.go b/git/client.go index 343d1300d..c9533094f 100644 --- a/git/client.go +++ b/git/client.go @@ -489,6 +489,36 @@ func (c *Client) AddRemote(ctx context.Context, name, urlStr string, trackingBra return remote, nil } +func (c *Client) InGitDirectory(ctx context.Context) bool { + showCmd, err := c.Command(ctx, "rev-parse", "--is-inside-work-tree") + if err != nil { + return false + } + out, err := showCmd.Output() + if err != nil { + return false + } + + split := strings.Split(string(out), "\n") + if len(split) > 0 { + return split[0] == "true" + } + return false +} + +func (c *Client) UnsetRemoteResolution(ctx context.Context, name string) error { + args := []string{"config", "--unset", fmt.Sprintf("remote.%s.gh-resolved", name)} + cmd, err := c.Command(ctx, args...) + if err != nil { + return err + } + _, err = cmd.Output() + if err != nil { + return err + } + return nil +} + func resolveGitPath() (string, error) { path, err := safeexec.LookPath("git") if err != nil { diff --git a/pkg/cmd/repo/repo.go b/pkg/cmd/repo/repo.go index e8696d7d9..bfaaf2ce4 100644 --- a/pkg/cmd/repo/repo.go +++ b/pkg/cmd/repo/repo.go @@ -13,6 +13,7 @@ import ( gardenCmd "github.com/cli/cli/v2/pkg/cmd/repo/garden" repoListCmd "github.com/cli/cli/v2/pkg/cmd/repo/list" repoRenameCmd "github.com/cli/cli/v2/pkg/cmd/repo/rename" + repoDefaultCmd "github.com/cli/cli/v2/pkg/cmd/repo/setdefault" repoSyncCmd "github.com/cli/cli/v2/pkg/cmd/repo/sync" repoViewCmd "github.com/cli/cli/v2/pkg/cmd/repo/view" "github.com/cli/cli/v2/pkg/cmdutil" @@ -52,6 +53,7 @@ func NewCmdRepo(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(repoRenameCmd.NewCmdRename(f, nil)) cmd.AddCommand(repoDeleteCmd.NewCmdDelete(f, nil)) cmd.AddCommand(repoArchiveCmd.NewCmdArchive(f, nil)) + cmd.AddCommand(repoDefaultCmd.NewCmdSetDefault(f, nil)) return cmd } diff --git a/pkg/cmd/repo/setdefault/setdefault.go b/pkg/cmd/repo/setdefault/setdefault.go new file mode 100644 index 000000000..a86ca8960 --- /dev/null +++ b/pkg/cmd/repo/setdefault/setdefault.go @@ -0,0 +1,232 @@ +package base + +import ( + "errors" + "fmt" + "net/http" + "sort" + "strings" + + ctx "context" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +func explainer() string { + return heredoc.Doc(` + This command sets the default remote repository to use when querying the + GitHub API for the locally cloned repository. + + gh uses the default repository for things like: + + - viewing and creating pull requests + - viewing and creating issues + - viewing and creating releases + - working with Actions + - adding repository and environment secrets`) +} + +type iprompter interface { + Select(string, string, []string) (int, error) +} + +type SetDefaultOptions struct { + IO *iostreams.IOStreams + Remotes func() (context.Remotes, error) + HttpClient func() (*http.Client, error) + Prompter iprompter + GitClient *git.Client + + Repo ghrepo.Interface + ViewMode bool +} + +func NewCmdSetDefault(f *cmdutil.Factory, runF func(*SetDefaultOptions) error) *cobra.Command { + opts := &SetDefaultOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Remotes: f.Remotes, + Prompter: f.Prompter, + GitClient: f.GitClient, + } + + cmd := &cobra.Command{ + Use: "set-default []", + Short: "Configure default repository for this directory", + Long: explainer(), + Example: heredoc.Doc(` + Interactively select a default repository: + $ gh repo default + + Set a repository explicitly: + $ gh repo default owner/repo + + View the current default repository: + $ gh repo default --view + + Show more repository options in the interactive picker: + $ git remote add newrepo https://github.com/owner/repo + $ gh repo default + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + var err error + opts.Repo, err = ghrepo.FromFullName(args[0]) + if err != nil { + return err + } + } + + if !opts.IO.CanPrompt() && opts.Repo == nil { + return cmdutil.FlagErrorf("repository required when not running interactively") + } + + c := &git.Client{} + + if !c.InGitDirectory(ctx.Background()) { + return errors.New("must be run from inside a git repository") + } + + if runF != nil { + return runF(opts) + } + + return setDefaultRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.ViewMode, "view", "v", false, "view the current default repository") + + return cmd +} + +func setDefaultRun(opts *SetDefaultOptions) error { + remotes, err := opts.Remotes() + if err != nil { + return err + } + + currentDefaultRepo, _ := remotes.ResolvedRemote() + + if opts.ViewMode { + if currentDefaultRepo == nil { + fmt.Fprintln(opts.IO.Out, "no default repo has been set; use `gh repo default` to select one") + } else { + fmt.Fprintln(opts.IO.Out, displayRemoteRepoName(currentDefaultRepo)) + } + return nil + } + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + resolvedRemotes, err := context.ResolveRemotesToRepos(remotes, apiClient, "") + if err != nil { + return err + } + + knownRepos, err := resolvedRemotes.NetworkRepos() + if err != nil { + return err + } + if len(knownRepos) == 0 { + return errors.New("none of the git remotes correspond to a valid remote repository") + } + + var selectedRepo ghrepo.Interface + + if opts.Repo != nil { + for _, knownRepo := range knownRepos { + if ghrepo.IsSame(opts.Repo, knownRepo) { + selectedRepo = opts.Repo + break + } + } + if selectedRepo == nil { + return fmt.Errorf("%s does not correspond to any git remotes", ghrepo.FullName(opts.Repo)) + } + } + cs := opts.IO.ColorScheme() + + if selectedRepo == nil { + if len(knownRepos) == 1 { + selectedRepo = knownRepos[0] + + fmt.Fprintf(opts.IO.Out, "Found only one known remote repo, %s on %s.\n", + cs.Bold(ghrepo.FullName(selectedRepo)), + cs.Bold(selectedRepo.RepoHost())) + } else { + var repoNames []string + current := "" + if currentDefaultRepo != nil { + current = ghrepo.FullName(currentDefaultRepo) + } + + for _, knownRepo := range knownRepos { + repoNames = append(repoNames, ghrepo.FullName(knownRepo)) + } + + fmt.Fprintln(opts.IO.Out, explainer()) + fmt.Fprintln(opts.IO.Out) + + selected, err := opts.Prompter.Select("Which repository should be the default?", current, repoNames) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + selectedName := repoNames[selected] + + owner, repo, _ := strings.Cut(selectedName, "/") + selectedRepo = ghrepo.New(owner, repo) + } + } + + resolution := "base" + selectedRemote, _ := resolvedRemotes.RemoteForRepo(selectedRepo) + if selectedRemote == nil { + sort.Stable(remotes) + selectedRemote = remotes[0] + resolution = ghrepo.FullName(selectedRepo) + } + + if currentDefaultRepo != nil { + if err := opts.GitClient.UnsetRemoteResolution( + ctx.Background(), currentDefaultRepo.Name); err != nil { + return err + } + } + if err = opts.GitClient.SetRemoteResolution( + ctx.Background(), selectedRemote.Name, resolution); err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Out, "%s Set %s as the default repository for the current directory\n", cs.SuccessIcon(), ghrepo.FullName(selectedRepo)) + } + + return nil +} + +func displayRemoteRepoName(remote *context.Remote) string { + if remote.Resolved == "" || remote.Resolved == "base" { + return ghrepo.FullName(remote) + } + + repo, err := ghrepo.FromFullName(remote.Resolved) + if err != nil { + return ghrepo.FullName(remote) + } + + return ghrepo.FullName(repo) +} diff --git a/pkg/cmd/repo/setdefault/setdefault_test.go b/pkg/cmd/repo/setdefault/setdefault_test.go new file mode 100644 index 000000000..707edf5b6 --- /dev/null +++ b/pkg/cmd/repo/setdefault/setdefault_test.go @@ -0,0 +1,405 @@ +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) + input string + output SetDefaultOptions + wantErr bool + errMsg string + }{ + { + name: "no argument", + gitStubs: func(cs *run.CommandStubber) { + cs.Register(`git rev-parse --is-inside-work-tree`, 0, "true") + }, + input: "", + output: SetDefaultOptions{}, + }, + { + name: "repo argument", + gitStubs: func(cs *run.CommandStubber) { + cs.Register(`git rev-parse --is-inside-work-tree`, 0, "true") + }, + input: "cli/cli", + output: SetDefaultOptions{Repo: ghrepo.New("cli", "cli")}, + }, + { + name: "invalid repo argument", + gitStubs: func(cs *run.CommandStubber) {}, + input: "some_invalid_format", + wantErr: true, + errMsg: `expected the "[HOST/]OWNER/REPO" format, got "some_invalid_format"`, + }, + { + name: "view flag", + gitStubs: func(cs *run.CommandStubber) { + cs.Register(`git rev-parse --is-inside-work-tree`, 0, "true") + }, + input: "--view", + output: SetDefaultOptions{ViewMode: true}, + }, + { + name: "run from non-git directory", + gitStubs: func(cs *run.CommandStubber) { + cs.Register(`git rev-parse --is-inside-work-tree`, 1, "") + }, + input: "", + wantErr: true, + errMsg: "must be run from inside a git repository", + }, + } + + for _, tt := range tests { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + io.SetStderrTTY(true) + f := &cmdutil.Factory{ + IOStreams: io, + } + + 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") + + 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 + wantErr bool + errMsg string + }{ + { + name: "view mode no current default", + opts: SetDefaultOptions{ViewMode: true}, + remotes: []*context.Remote{ + { + Remote: &git.Remote{Name: "origin"}, + Repo: repo1, + }, + }, + wantStdout: "no default repo has been set; use `gh repo default` to select one\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 Actions\n - adding 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", + }, + } + + 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, _ := 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) + assert.Equal(t, tt.wantStdout, stdout.String()) + }) + } +}