diff --git a/pkg/cmd/auth/auth.go b/pkg/cmd/auth/auth.go index 99d335a8d..e5ae67fa2 100644 --- a/pkg/cmd/auth/auth.go +++ b/pkg/cmd/auth/auth.go @@ -5,6 +5,7 @@ import ( authLoginCmd "github.com/cli/cli/v2/pkg/cmd/auth/login" authLogoutCmd "github.com/cli/cli/v2/pkg/cmd/auth/logout" authRefreshCmd "github.com/cli/cli/v2/pkg/cmd/auth/refresh" + authSetupGitCmd "github.com/cli/cli/v2/pkg/cmd/auth/setupgit" authStatusCmd "github.com/cli/cli/v2/pkg/cmd/auth/status" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" @@ -24,6 +25,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(authStatusCmd.NewCmdStatus(f, nil)) cmd.AddCommand(authRefreshCmd.NewCmdRefresh(f, nil)) cmd.AddCommand(gitCredentialCmd.NewCmdCredential(f, nil)) + cmd.AddCommand(authSetupGitCmd.NewCmdSetupGit(f, nil)) return cmd } diff --git a/pkg/cmd/auth/setupgit/setupgit.go b/pkg/cmd/auth/setupgit/setupgit.go new file mode 100644 index 000000000..5295ff424 --- /dev/null +++ b/pkg/cmd/auth/setupgit/setupgit.go @@ -0,0 +1,100 @@ +package setupgit + +import ( + "fmt" + "strings" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/pkg/cmd/auth/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type gitConfigurator interface { + Setup(hostname, username, authToken string) error +} + +type SetupGitOptions struct { + IO *iostreams.IOStreams + Config func() (config.Config, error) + Hostname string + gitConfigure gitConfigurator +} + +func NewCmdSetupGit(f *cmdutil.Factory, runF func(*SetupGitOptions) error) *cobra.Command { + opts := &SetupGitOptions{ + IO: f.IOStreams, + Config: f.Config, + } + + cmd := &cobra.Command{ + Short: "Configure git to use GitHub CLI as a credential helper", + Use: "setup-git", + RunE: func(cmd *cobra.Command, args []string) error { + opts.gitConfigure = &shared.GitCredentialFlow{ + Executable: f.Executable(), + } + + if runF != nil { + return runF(opts) + } + return setupGitRun(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname to configure git for") + + return cmd +} + +func setupGitRun(opts *SetupGitOptions) error { + cfg, err := opts.Config() + if err != nil { + return err + } + + hostnames, err := cfg.Hosts() + if err != nil { + return err + } + + stderr := opts.IO.ErrOut + cs := opts.IO.ColorScheme() + + if len(hostnames) == 0 { + fmt.Fprintf( + stderr, + "You are not logged into any GitHub hosts. Run %s to authenticate.\n", + cs.Bold("gh auth login"), + ) + + return cmdutil.SilentError + } + + hostnamesToSetup := hostnames + + if opts.Hostname != "" { + if !has(opts.Hostname, hostnames) { + return fmt.Errorf("You are not logged into the GitHub host %q\n", opts.Hostname) + } + hostnamesToSetup = []string{opts.Hostname} + } + + for _, hostname := range hostnamesToSetup { + if err := opts.gitConfigure.Setup(hostname, "", ""); err != nil { + return fmt.Errorf("failed to set up git credential helper: %w", err) + } + } + + return nil +} + +func has(needle string, haystack []string) bool { + for _, s := range haystack { + if strings.EqualFold(s, needle) { + return true + } + } + return false +} diff --git a/pkg/cmd/auth/setupgit/setupgit_test.go b/pkg/cmd/auth/setupgit/setupgit_test.go new file mode 100644 index 000000000..52bc3a5b0 --- /dev/null +++ b/pkg/cmd/auth/setupgit/setupgit_test.go @@ -0,0 +1,122 @@ +package setupgit + +import ( + "fmt" + "testing" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockGitConfigurer struct { + setupErr error +} + +func (gf *mockGitConfigurer) Setup(hostname, username, authToken string) error { + return gf.setupErr +} + +func Test_setupGitRun(t *testing.T) { + tests := []struct { + name string + opts *SetupGitOptions + expectedErr string + expectedErrOut string + }{ + { + name: "opts.Config returns an error", + opts: &SetupGitOptions{ + Config: func() (config.Config, error) { + return nil, fmt.Errorf("oops") + }, + }, + expectedErr: "oops", + }, + { + name: "no authenticated hostnames", + opts: &SetupGitOptions{}, + expectedErr: "SilentError", + expectedErrOut: "You are not logged into any GitHub hosts. Run gh auth login to authenticate.\n", + }, + { + name: "not authenticated with the hostname given as flag", + opts: &SetupGitOptions{ + Hostname: "foo", + Config: func() (config.Config, error) { + cfg := config.NewBlankConfig() + require.NoError(t, cfg.Set("bar", "", "")) + return cfg, nil + }, + }, + expectedErr: "You are not logged into the GitHub host \"foo\"\n", + expectedErrOut: "", + }, + { + name: "error setting up git for hostname", + opts: &SetupGitOptions{ + gitConfigure: &mockGitConfigurer{ + setupErr: fmt.Errorf("broken"), + }, + Config: func() (config.Config, error) { + cfg := config.NewBlankConfig() + require.NoError(t, cfg.Set("bar", "", "")) + return cfg, nil + }, + }, + expectedErr: "failed to set up git credential helper: broken", + expectedErrOut: "", + }, + { + name: "no hostname option given. Setup git for each hostname in config", + opts: &SetupGitOptions{ + gitConfigure: &mockGitConfigurer{}, + Config: func() (config.Config, error) { + cfg := config.NewBlankConfig() + require.NoError(t, cfg.Set("bar", "", "")) + return cfg, nil + }, + }, + }, + { + name: "setup git for the hostname given via options", + opts: &SetupGitOptions{ + Hostname: "yes", + gitConfigure: &mockGitConfigurer{}, + Config: func() (config.Config, error) { + cfg := config.NewBlankConfig() + require.NoError(t, cfg.Set("bar", "", "")) + require.NoError(t, cfg.Set("yes", "", "")) + return cfg, nil + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.opts.Config == nil { + tt.opts.Config = func() (config.Config, error) { + return config.NewBlankConfig(), nil + } + } + + io, _, _, stderr := iostreams.Test() + + io.SetStdinTTY(true) + io.SetStderrTTY(true) + io.SetStdoutTTY(true) + tt.opts.IO = io + + err := setupGitRun(tt.opts) + if tt.expectedErr != "" { + assert.EqualError(t, err, tt.expectedErr) + } else { + assert.NoError(t, err) + } + + assert.Equal(t, tt.expectedErrOut, stderr.String()) + }) + } +} diff --git a/pkg/cmd/auth/shared/git_credential.go b/pkg/cmd/auth/shared/git_credential.go index 1a8b2747c..9e47bcc7e 100644 --- a/pkg/cmd/auth/shared/git_credential.go +++ b/pkg/cmd/auth/shared/git_credential.go @@ -64,7 +64,7 @@ func (flow *GitCredentialFlow) Setup(hostname, username, authToken string) error func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password string) error { if flow.helper == "" { // first use a blank value to indicate to git we want to sever the chain of credential helpers - preConfigureCmd, err := git.GitCommand("config", "--global", gitCredentialHelperKey(hostname), "") + preConfigureCmd, err := git.GitCommand("config", "--global", "--replace-all", gitCredentialHelperKey(hostname), "") if err != nil { return err } diff --git a/pkg/cmd/auth/shared/git_credential_test.go b/pkg/cmd/auth/shared/git_credential_test.go index 58fe3988a..dfdb72db9 100644 --- a/pkg/cmd/auth/shared/git_credential_test.go +++ b/pkg/cmd/auth/shared/git_credential_test.go @@ -25,7 +25,7 @@ func TestGitCredentialSetup_configureExisting(t *testing.T) { func TestGitCredentialSetup_setOurs(t *testing.T) { cs, restoreRun := run.Stub() defer restoreRun(t) - cs.Register(`git config --global credential\.`, 0, "", func(args []string) { + cs.Register(`git config --global --replace-all credential\.`, 0, "", func(args []string) { if key := args[len(args)-2]; key != "credential.https://example.com.helper" { t.Errorf("git config key was %q", key) }