Skip to content

Commit

Permalink
feat(3796): add cmd for setting up gh-cli as git cred helper
Browse files Browse the repository at this point in the history
Adds a new command `gh auth setup-git [<hostname>]` that sets up git to
use the GH CLI as a credential helper.

The gist is that it runs these two git commands for each hostname the
user is authenticated with.

```
git config --global 'credential.https://github.com.helper' ''
git config --global --add 'credential.https://github.com.helper' '!gh auth git-credential'
```

If a hostname flag is given, it'll setup GH CLI as a credential helper
for only that hostname.

If the user is not authenticated with any git hostnames, or the user is
not authenticated with the hostname given as a flag, it'll print an
error and return a SilentError.

Closes cli#3796
  • Loading branch information
despreston committed Sep 3, 2021
1 parent e6ff77c commit 2c41882
Show file tree
Hide file tree
Showing 5 changed files with 306 additions and 0 deletions.
2 changes: 2 additions & 0 deletions pkg/cmd/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
authLoginCmd "github.com/cli/cli/pkg/cmd/auth/login"
authLogoutCmd "github.com/cli/cli/pkg/cmd/auth/logout"
authRefreshCmd "github.com/cli/cli/pkg/cmd/auth/refresh"
authSetupGitCmd "github.com/cli/cli/pkg/cmd/auth/setupgit"
authStatusCmd "github.com/cli/cli/pkg/cmd/auth/status"
"github.com/cli/cli/pkg/cmdutil"
"github.com/spf13/cobra"
Expand All @@ -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
}
109 changes: 109 additions & 0 deletions pkg/cmd/auth/setupgit/setupgit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package setupgit

import (
"fmt"

"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/cmd/auth/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)

type gitConfigurator interface {
Setup(hostname, username, authToken string) error
}

type SetupGitOptions struct {
IO *iostreams.IOStreams
// Hostname is the host to setup as git credential helper for. This is set via command flag.
Hostname string
Config func() (config.Config, error)
gitConfigure gitConfigurator
}

func NewCmdSetupGit(f *cmdutil.Factory, runF func(*SetupGitOptions) error) *cobra.Command {
opts := &SetupGitOptions{
IO: f.IOStreams,
Config: f.Config,
gitConfigure: &shared.GitCredentialFlow{},
}

cmd := &cobra.Command{
Short: "Setup GH CLI as a git credential helper",
Long: "Setup GH CLI as a git credential helper for all authenticated hostnames.",
Use: "setup-git [<hostname>]",
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return setupGitRun(opts)
},
}

cmd.Flags().StringVarP(
&opts.Hostname,
"hostname",
"h",
"",
"Setup git credential helper for a specific hostname",
)

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 !utils.Has(opts.Hostname, hostnames) {
fmt.Fprintf(
stderr,
"You are not logged into any Github host with the hostname %s\n",
opts.Hostname,
)
return cmdutil.SilentError
}
hostnamesToSetup = []string{opts.Hostname}
}

if err := setupGitForHostnames(hostnamesToSetup, opts.gitConfigure); err != nil {
fmt.Fprintln(stderr, err.Error())
return cmdutil.SilentError
}

return nil
}

func setupGitForHostnames(hostnames []string, gitCfg gitConfigurator) error {
for _, hostname := range hostnames {
if err := gitCfg.Setup(hostname, "", ""); err != nil {
return fmt.Errorf("failed to setup git credential helper: %w", err)
}
}
return nil
}
178 changes: 178 additions & 0 deletions pkg/cmd/auth/setupgit/setupgit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package setupgit

import (
"bytes"
"fmt"
"regexp"
"testing"

"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/google/shlex"
"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_NewCmdSetupGit(t *testing.T) {
tests := []struct {
name string
cli string
wants SetupGitOptions
wantsErr bool
}{
{
name: "no arguments",
cli: "",
wants: SetupGitOptions{Hostname: ""},
},
{
name: "hostname argument",
cli: "--hostname whatever",
wants: SetupGitOptions{Hostname: "whatever"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := &cmdutil.Factory{}

argv, err := shlex.Split(tt.cli)
assert.NoError(t, err)

var gotOpts *SetupGitOptions
cmd := NewCmdSetupGit(f, func(opts *SetupGitOptions) error {
gotOpts = opts
return nil
})

// TODO cobra hack-around
cmd.Flags().BoolP("help", "x", false, "")

cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})

_, err = cmd.ExecuteC()
assert.NoError(t, err)

assert.Equal(t, tt.wants.Hostname, gotOpts.Hostname)
})
}
}

func Test_setupGitRun(t *testing.T) {
tests := []struct {
name string
opts *SetupGitOptions
expectedErr string
expectedErrOut *regexp.Regexp
}{
{
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: regexp.MustCompile("You are not logged into any GitHub hosts."),
},
{
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: "SilentError",
expectedErrOut: regexp.MustCompile("You are not logged into any Github host with the hostname foo"),
},
{
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: "SilentError",
expectedErrOut: regexp.MustCompile("failed to setup git credential helper"),
},
{
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)
}

if tt.expectedErrOut == nil {
assert.Equal(t, "", stderr.String())
} else {
assert.True(t, tt.expectedErrOut.MatchString(stderr.String()))
}
})
}
}
10 changes: 10 additions & 0 deletions utils/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,13 @@ func DisplayURL(urlStr string) string {
func ValidURL(urlStr string) bool {
return len(urlStr) < 8192
}

// Has returns true if the given string is in the list.
func Has(x string, list []string) bool {
for _, y := range list {
if x == y {
return true
}
}
return false
}
7 changes: 7 additions & 0 deletions utils/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package utils
import (
"testing"
"time"

"github.com/stretchr/testify/assert"
)

func TestFuzzyAgo(t *testing.T) {
Expand Down Expand Up @@ -61,3 +63,8 @@ func TestFuzzyAgoAbbr(t *testing.T) {
}
}
}

func TestHas(t *testing.T) {
assert.True(t, Has("foo", []string{"foo", "bar"}))
assert.False(t, Has("zing", []string{"foo", "bar"}))
}

0 comments on commit 2c41882

Please sign in to comment.