From 2944f7c3abac8db83f4354f0246ea32627e68d11 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Fri, 14 Oct 2022 10:47:03 +0300 Subject: [PATCH] Create git client (#6354) --- git/client.go | 611 ++++++++++++++++++++++++++ git/client_test.go | 365 +++++++++++++++ git/git.go | 452 ++++--------------- git/git_test.go | 213 --------- git/objects.go | 76 ++++ git/remote.go | 169 ------- git/remote_test.go | 35 -- pkg/cmd/auth/shared/git_credential.go | 9 +- pkg/cmd/factory/default.go | 13 + pkg/cmd/factory/default_test.go | 39 ++ pkg/cmd/pr/checkout/checkout.go | 29 +- pkg/cmd/release/create/create.go | 7 +- pkg/cmd/repo/create/create.go | 16 +- pkg/cmd/repo/create/create_test.go | 12 +- pkg/cmd/repo/fork/fork.go | 3 +- pkg/cmdutil/factory.go | 2 + 16 files changed, 1223 insertions(+), 828 deletions(-) create mode 100644 git/client.go create mode 100644 git/client_test.go delete mode 100644 git/git_test.go create mode 100644 git/objects.go delete mode 100644 git/remote.go delete mode 100644 git/remote_test.go diff --git a/git/client.go b/git/client.go new file mode 100644 index 000000000..7d8686f17 --- /dev/null +++ b/git/client.go @@ -0,0 +1,611 @@ +package git + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/url" + "os/exec" + "path" + "regexp" + "runtime" + "sort" + "strings" + "sync" + + "github.com/cli/cli/v2/internal/run" + "github.com/cli/safeexec" +) + +var remoteRE = regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`) + +// ErrNotOnAnyBranch indicates that the user is in detached HEAD state. +var ErrNotOnAnyBranch = errors.New("git: not on any branch") + +type NotInstalled struct { + message string + err error +} + +func (e *NotInstalled) Error() string { + return e.message +} + +func (e *NotInstalled) Unwrap() error { + return e.err +} + +type GitError struct { + stderr string + err error +} + +func (ge *GitError) Error() string { + stderr := ge.stderr + if stderr == "" { + var exitError *exec.ExitError + if errors.As(ge.err, &exitError) { + stderr = string(exitError.Stderr) + } + } + if stderr == "" { + return fmt.Sprintf("failed to run git: %v", ge.err) + } + return fmt.Sprintf("failed to run git: %s", stderr) +} + +func (ge *GitError) Unwrap() error { + return ge.err +} + +type gitCommand struct { + *exec.Cmd +} + +// This is a hack in order to not break the hundreds of +// existing tests that rely on `run.PrepareCmd` to be invoked. +func (gc *gitCommand) Run() error { + return run.PrepareCmd(gc.Cmd).Run() +} + +// This is a hack in order to not break the hundreds of +// existing tests that rely on `run.PrepareCmd` to be invoked. +func (gc *gitCommand) Output() ([]byte, error) { + return run.PrepareCmd(gc.Cmd).Output() +} + +type Client struct { + GhPath string + RepoDir string + GitPath string + Stderr io.Writer + Stdin io.Reader + Stdout io.Writer + + commandContext func(ctx context.Context, name string, args ...string) *exec.Cmd + mu sync.Mutex +} + +func (c *Client) Command(ctx context.Context, args ...string) (*gitCommand, error) { + if c.RepoDir != "" { + args = append([]string{"-C", c.RepoDir}, args...) + } + commandContext := exec.CommandContext + if c.commandContext != nil { + commandContext = c.commandContext + } + var err error + c.mu.Lock() + if c.GitPath == "" { + c.GitPath, err = resolveGitPath() + } + c.mu.Unlock() + if err != nil { + return nil, err + } + cmd := commandContext(ctx, c.GitPath, args...) + cmd.Stderr = c.Stderr + cmd.Stdin = c.Stdin + cmd.Stdout = c.Stdout + return &gitCommand{cmd}, nil +} + +func resolveGitPath() (string, error) { + path, err := safeexec.LookPath("git") + if err != nil { + if errors.Is(err, exec.ErrNotFound) { + programName := "git" + if runtime.GOOS == "windows" { + programName = "Git for Windows" + } + return "", &NotInstalled{ + message: fmt.Sprintf("unable to find git executable in PATH; please install %s before retrying", programName), + err: err, + } + } + return "", err + } + return path, nil +} + +// AuthenticatedCommand is a wrapper around Command that included configuration to use gh +// as the credential helper for git. +func (c *Client) AuthenticatedCommand(ctx context.Context, args ...string) (*gitCommand, error) { + preArgs := []string{} + preArgs = append(preArgs, "-c", "credential.helper=") + if c.GhPath == "" { + // Assumes that gh is in PATH. + c.GhPath = "gh" + } + credHelper := fmt.Sprintf("!%q auth git-credential", c.GhPath) + preArgs = append(preArgs, "-c", fmt.Sprintf("credential.helper=%s", credHelper)) + args = append(preArgs, args...) + return c.Command(ctx, args...) +} + +func (c *Client) Remotes(ctx context.Context) (RemoteSet, error) { + remoteArgs := []string{"remote", "-v"} + remoteCmd, err := c.Command(ctx, remoteArgs...) + if err != nil { + return nil, err + } + remoteOut, remoteErr := remoteCmd.Output() + if remoteErr != nil { + return nil, &GitError{err: remoteErr} + } + + configArgs := []string{"config", "--get-regexp", `^remote\..*\.gh-resolved$`} + configCmd, err := c.Command(ctx, configArgs...) + if err != nil { + return nil, err + } + configOut, configErr := configCmd.Output() + if configErr != nil { + return nil, &GitError{err: configErr} + } + + remotes := parseRemotes(outputLines(remoteOut)) + populateResolvedRemotes(remotes, outputLines(configOut)) + sort.Sort(remotes) + return remotes, nil +} + +func (c *Client) AddRemote(ctx context.Context, name, urlStr string, trackingBranches []string) (*Remote, error) { + args := []string{"remote", "add"} + for _, branch := range trackingBranches { + args = append(args, "-t", branch) + } + args = append(args, "-f", name, urlStr) + //TODO: Use AuthenticatedCommand + cmd, err := c.Command(ctx, args...) + if err != nil { + return nil, err + } + if err := cmd.Run(); err != nil { + return nil, err + } + var urlParsed *url.URL + if strings.HasPrefix(urlStr, "https") { + urlParsed, err = url.Parse(urlStr) + if err != nil { + return nil, err + } + } else { + urlParsed, err = ParseURL(urlStr) + if err != nil { + return nil, err + } + } + remote := &Remote{ + Name: name, + FetchURL: urlParsed, + PushURL: urlParsed, + } + return remote, nil +} + +func (c *Client) UpdateRemoteURL(ctx context.Context, name, url string) error { + args := []string{"remote", "set-url", name, url} + cmd, err := c.Command(ctx, args...) + if err != nil { + return err + } + return cmd.Run() +} + +func (c *Client) SetRemoteResolution(ctx context.Context, name, resolution string) error { + args := []string{"config", "--add", fmt.Sprintf("remote.%s.gh-resolved", name), resolution} + cmd, err := c.Command(ctx, args...) + if err != nil { + return err + } + return cmd.Run() +} + +// CurrentBranch reads the checked-out branch for the git repository. +func (c *Client) CurrentBranch(ctx context.Context) (string, error) { + args := []string{"symbolic-ref", "--quiet", "HEAD"} + cmd, err := c.Command(ctx, args...) + if err != nil { + return "", err + } + errBuf := bytes.Buffer{} + cmd.Stderr = &errBuf + out, err := cmd.Output() + if err != nil { + if errBuf.Len() == 0 { + return "", &GitError{err: err, stderr: "not on any branch"} + } + return "", &GitError{err: err, stderr: errBuf.String()} + } + branch := firstLine(out) + return strings.TrimPrefix(branch, "refs/heads/"), nil +} + +// ShowRefs resolves fully-qualified refs to commit hashes. +func (c *Client) ShowRefs(ctx context.Context, ref ...string) ([]Ref, error) { + args := append([]string{"show-ref", "--verify", "--"}, ref...) + cmd, err := c.Command(ctx, args...) + if err != nil { + return nil, err + } + out, err := cmd.Output() + if err != nil { + return nil, &GitError{err: err} + } + var refs []Ref + for _, line := range outputLines(out) { + parts := strings.SplitN(line, " ", 2) + if len(parts) < 2 { + continue + } + refs = append(refs, Ref{ + Hash: parts[0], + Name: parts[1], + }) + } + return refs, nil +} + +func (c *Client) Config(ctx context.Context, name string) (string, error) { + args := []string{"config", name} + cmd, err := c.Command(ctx, args...) + if err != nil { + return "", err + } + errBuf := bytes.Buffer{} + cmd.Stderr = &errBuf + out, err := cmd.Output() + if err != nil { + var exitError *exec.ExitError + if ok := errors.As(err, &exitError); ok && exitError.Error() == "1" { + return "", &GitError{err: err, stderr: fmt.Sprintf("unknown config key %s", name)} + } + return "", &GitError{err: err, stderr: errBuf.String()} + } + return firstLine(out), nil +} + +func (c *Client) UncommittedChangeCount(ctx context.Context) (int, error) { + args := []string{"status", "--porcelain"} + cmd, err := c.Command(ctx, args...) + if err != nil { + return 0, err + } + out, err := cmd.Output() + if err != nil { + return 0, &GitError{err: err} + } + lines := strings.Split(string(out), "\n") + count := 0 + for _, l := range lines { + if l != "" { + count++ + } + } + return count, nil +} + +func (c *Client) Commits(ctx context.Context, baseRef, headRef string) ([]*Commit, error) { + args := []string{"-c", "log.ShowSignature=false", "log", "--pretty=format:%H,%s", "--cherry", fmt.Sprintf("%s...%s", baseRef, headRef)} + cmd, err := c.Command(ctx, args...) + if err != nil { + return nil, err + } + out, err := cmd.Output() + if err != nil { + return nil, &GitError{err: err} + } + commits := []*Commit{} + sha := 0 + title := 1 + for _, line := range outputLines(out) { + split := strings.SplitN(line, ",", 2) + if len(split) != 2 { + continue + } + commits = append(commits, &Commit{ + Sha: split[sha], + Title: split[title], + }) + } + if len(commits) == 0 { + return nil, fmt.Errorf("could not find any commits between %s and %s", baseRef, headRef) + } + return commits, nil +} + +func (c *Client) lookupCommit(ctx context.Context, sha, format string) ([]byte, error) { + args := []string{"-c", "log.ShowSignature=false", "show", "-s", "--pretty=format:" + format, sha} + cmd, err := c.Command(ctx, args...) + if err != nil { + return nil, err + } + out, err := cmd.Output() + if err != nil { + return nil, &GitError{err: err} + } + return out, nil +} + +func (c *Client) LastCommit(ctx context.Context) (*Commit, error) { + output, err := c.lookupCommit(ctx, "HEAD", "%H,%s") + if err != nil { + return nil, err + } + idx := bytes.IndexByte(output, ',') + return &Commit{ + Sha: string(output[0:idx]), + Title: strings.TrimSpace(string(output[idx+1:])), + }, nil +} + +func (c *Client) CommitBody(ctx context.Context, sha string) (string, error) { + output, err := c.lookupCommit(ctx, sha, "%b") + return string(output), err +} + +// Push publishes a git ref to a remote and sets up upstream configuration. +func (c *Client) Push(ctx context.Context, remote string, ref string) error { + args := []string{"push", "--set-upstream", remote, ref} + //TODO: Use AuthenticatedCommand + cmd, err := c.Command(ctx, args...) + if err != nil { + return err + } + return cmd.Run() +} + +// ReadBranchConfig parses the `branch.BRANCH.(remote|merge)` part of git config. +func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (cfg BranchConfig) { + prefix := regexp.QuoteMeta(fmt.Sprintf("branch.%s.", branch)) + args := []string{"config", "--get-regexp", fmt.Sprintf("^%s(remote|merge)$", prefix)} + cmd, err := c.Command(ctx, args...) + if err != nil { + return + } + out, err := cmd.Output() + if err != nil { + return + } + for _, line := range outputLines(out) { + parts := strings.SplitN(line, " ", 2) + if len(parts) < 2 { + continue + } + keys := strings.Split(parts[0], ".") + switch keys[len(keys)-1] { + case "remote": + if strings.Contains(parts[1], ":") { + u, err := ParseURL(parts[1]) + if err != nil { + continue + } + cfg.RemoteURL = u + } else if !isFilesystemPath(parts[1]) { + cfg.RemoteName = parts[1] + } + case "merge": + cfg.MergeRef = parts[1] + } + } + return +} + +func (c *Client) DeleteLocalBranch(ctx context.Context, branch string) error { + args := []string{"branch", "-D", branch} + cmd, err := c.Command(ctx, args...) + if err != nil { + return err + } + return cmd.Run() +} + +func (c *Client) HasLocalBranch(ctx context.Context, branch string) bool { + args := []string{"rev-parse", "--verify", "refs/heads/" + branch} + cmd, err := c.Command(ctx, args...) + if err != nil { + return false + } + err = cmd.Run() + return err == nil +} + +func (c *Client) CheckoutBranch(ctx context.Context, branch string) error { + args := []string{"checkout", branch} + cmd, err := c.Command(ctx, args...) + if err != nil { + return err + } + return cmd.Run() +} + +func (c *Client) CheckoutNewBranch(ctx context.Context, remoteName, branch string) error { + track := fmt.Sprintf("%s/%s", remoteName, branch) + args := []string{"checkout", "-b", branch, "--track", track} + cmd, err := c.Command(ctx, args...) + if err != nil { + return err + } + return cmd.Run() +} + +func (c *Client) Pull(ctx context.Context, remote, branch string) error { + args := []string{"pull", "--ff-only", remote, branch} + //TODO: Use AuthenticatedCommand + cmd, err := c.Command(ctx, args...) + if err != nil { + return err + } + return cmd.Run() +} + +func (c *Client) Clone(ctx context.Context, cloneURL string, args []string) (target string, err error) { + cloneArgs, target := parseCloneArgs(args) + cloneArgs = append(cloneArgs, cloneURL) + // If the args contain an explicit target, pass it to clone + // otherwise, parse the URL to determine where git cloned it to so we can return it + if target != "" { + cloneArgs = append(cloneArgs, target) + } else { + target = path.Base(strings.TrimSuffix(cloneURL, ".git")) + } + cloneArgs = append([]string{"clone"}, cloneArgs...) + //TODO: Use AuthenticatedCommand + cmd, err := c.Command(ctx, cloneArgs...) + if err != nil { + return "", err + } + err = cmd.Run() + return +} + +// ToplevelDir returns the top-level directory path of the current repository. +func (c *Client) ToplevelDir(ctx context.Context) (string, error) { + args := []string{"rev-parse", "--show-toplevel"} + cmd, err := c.Command(ctx, args...) + if err != nil { + return "", err + } + out, err := cmd.Output() + if err != nil { + return "", &GitError{err: err} + } + return firstLine(out), nil +} + +func (c *Client) GitDir(ctx context.Context) (string, error) { + args := []string{"rev-parse", "--git-dir"} + cmd, err := c.Command(ctx, args...) + if err != nil { + return "", err + } + out, err := cmd.Output() + if err != nil { + return "", &GitError{err: err} + } + return firstLine(out), nil +} + +// Show current directory relative to the top-level directory of repository. +func (c *Client) PathFromRoot(ctx context.Context) string { + args := []string{"rev-parse", "--show-prefix"} + cmd, err := c.Command(ctx, args...) + if err != nil { + return "" + } + out, err := cmd.Output() + if err != nil { + return "" + } + if path := firstLine(out); path != "" { + return path[:len(path)-1] + } + return "" +} + +func isFilesystemPath(p string) bool { + return p == "." || strings.HasPrefix(p, "./") || strings.HasPrefix(p, "/") +} + +func outputLines(output []byte) []string { + lines := strings.TrimSuffix(string(output), "\n") + return strings.Split(lines, "\n") +} + +func firstLine(output []byte) string { + if i := bytes.IndexAny(output, "\n"); i >= 0 { + return string(output)[0:i] + } + return string(output) +} + +func parseCloneArgs(extraArgs []string) (args []string, target string) { + args = extraArgs + if len(args) > 0 { + if !strings.HasPrefix(args[0], "-") { + target, args = args[0], args[1:] + } + } + return +} + +func parseRemotes(remotesStr []string) RemoteSet { + remotes := RemoteSet{} + for _, r := range remotesStr { + match := remoteRE.FindStringSubmatch(r) + if match == nil { + continue + } + name := strings.TrimSpace(match[1]) + urlStr := strings.TrimSpace(match[2]) + urlType := strings.TrimSpace(match[3]) + + url, err := ParseURL(urlStr) + if err != nil { + continue + } + + var rem *Remote + if len(remotes) > 0 { + rem = remotes[len(remotes)-1] + if name != rem.Name { + rem = nil + } + } + if rem == nil { + rem = &Remote{Name: name} + remotes = append(remotes, rem) + } + + switch urlType { + case "fetch": + rem.FetchURL = url + case "push": + rem.PushURL = url + } + } + return remotes +} + +func populateResolvedRemotes(remotes RemoteSet, resolved []string) { + for _, l := range resolved { + parts := strings.SplitN(l, " ", 2) + if len(parts) < 2 { + continue + } + rp := strings.SplitN(parts[0], ".", 3) + if len(rp) < 2 { + continue + } + name := rp[1] + for _, r := range remotes { + if r.Name == name { + r.Resolved = parts[1] + break + } + } + } +} diff --git a/git/client_test.go b/git/client_test.go new file mode 100644 index 000000000..d8cff126b --- /dev/null +++ b/git/client_test.go @@ -0,0 +1,365 @@ +package git + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + "github.com/cli/cli/v2/internal/run" + "github.com/stretchr/testify/assert" +) + +func TestClientCommand(t *testing.T) { + tests := []struct { + name string + repoDir string + gitPath string + wantExe string + wantArgs []string + }{ + { + name: "creates command", + gitPath: "path/to/git", + wantExe: "path/to/git", + wantArgs: []string{"path/to/git", "ref-log"}, + }, + { + name: "adds repo directory configuration", + repoDir: "path/to/repo", + gitPath: "path/to/git", + wantExe: "path/to/git", + wantArgs: []string{"path/to/git", "-C", "path/to/repo", "ref-log"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + in, out, errOut := &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{} + client := Client{ + Stdin: in, + Stdout: out, + Stderr: errOut, + RepoDir: tt.repoDir, + GitPath: tt.gitPath, + } + cmd, err := client.Command(context.Background(), "ref-log") + assert.NoError(t, err) + assert.Equal(t, tt.wantExe, cmd.Path) + assert.Equal(t, tt.wantArgs, cmd.Args) + assert.Equal(t, in, cmd.Stdin) + assert.Equal(t, out, cmd.Stdout) + assert.Equal(t, errOut, cmd.Stderr) + }) + } +} + +func TestClientAuthenticatedCommand(t *testing.T) { + tests := []struct { + name string + path string + wantArgs []string + }{ + { + name: "adds credential helper config options", + path: "path/to/gh", + wantArgs: []string{"git", "-c", "credential.helper=", "-c", "credential.helper=!\"path/to/gh\" auth git-credential", "fetch"}, + }, + { + name: "fallback when GhPath is not set", + wantArgs: []string{"git", "-c", "credential.helper=", "-c", "credential.helper=!\"gh\" auth git-credential", "fetch"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := Client{ + GhPath: tt.path, + GitPath: "git", + } + cmd, err := client.AuthenticatedCommand(context.Background(), "fetch") + assert.NoError(t, err) + assert.Equal(t, tt.wantArgs, cmd.Args) + }) + } +} + +func TestClientRemotes(t *testing.T) { + tempDir := t.TempDir() + initRepo(t, tempDir) + gitDir := filepath.Join(tempDir, ".git") + remoteFile := filepath.Join(gitDir, "config") + remotes := ` +[remote "origin"] + url = git@example.com:monalisa/origin.git +[remote "test"] + url = git://github.com/hubot/test.git + gh-resolved = other +[remote "upstream"] + url = https://github.com/monalisa/upstream.git + gh-resolved = base +[remote "github"] + url = git@github.com:hubot/github.git +` + f, err := os.OpenFile(remoteFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0755) + assert.NoError(t, err) + _, err = f.Write([]byte(remotes)) + assert.NoError(t, err) + err = f.Close() + assert.NoError(t, err) + client := Client{ + RepoDir: tempDir, + } + rs, err := client.Remotes(context.Background()) + assert.NoError(t, err) + assert.Equal(t, 4, len(rs)) + assert.Equal(t, "upstream", rs[0].Name) + assert.Equal(t, "base", rs[0].Resolved) + assert.Equal(t, "github", rs[1].Name) + assert.Equal(t, "", rs[1].Resolved) + assert.Equal(t, "origin", rs[2].Name) + assert.Equal(t, "", rs[2].Resolved) + assert.Equal(t, "test", rs[3].Name) + assert.Equal(t, "other", rs[3].Resolved) +} + +func TestParseRemotes(t *testing.T) { + remoteList := []string{ + "mona\tgit@github.com:monalisa/myfork.git (fetch)", + "origin\thttps://github.com/monalisa/octo-cat.git (fetch)", + "origin\thttps://github.com/monalisa/octo-cat-push.git (push)", + "upstream\thttps://example.com/nowhere.git (fetch)", + "upstream\thttps://github.com/hubot/tools (push)", + "zardoz\thttps://example.com/zed.git (push)", + "koke\tgit://github.com/koke/grit.git (fetch)", + "koke\tgit://github.com/koke/grit.git (push)", + } + + r := parseRemotes(remoteList) + assert.Equal(t, 5, len(r)) + + assert.Equal(t, "mona", r[0].Name) + assert.Equal(t, "ssh://git@github.com/monalisa/myfork.git", r[0].FetchURL.String()) + assert.Nil(t, r[0].PushURL) + + assert.Equal(t, "origin", r[1].Name) + assert.Equal(t, "/monalisa/octo-cat.git", r[1].FetchURL.Path) + assert.Equal(t, "/monalisa/octo-cat-push.git", r[1].PushURL.Path) + + assert.Equal(t, "upstream", r[2].Name) + assert.Equal(t, "example.com", r[2].FetchURL.Host) + assert.Equal(t, "github.com", r[2].PushURL.Host) + + assert.Equal(t, "zardoz", r[3].Name) + assert.Nil(t, r[3].FetchURL) + assert.Equal(t, "https://example.com/zed.git", r[3].PushURL.String()) + + assert.Equal(t, "koke", r[4].Name) + assert.Equal(t, "/koke/grit.git", r[4].FetchURL.Path) + assert.Equal(t, "/koke/grit.git", r[4].PushURL.Path) +} + +func TestClientLastCommit(t *testing.T) { + client := Client{ + RepoDir: "./fixtures/simple.git", + } + c, err := client.LastCommit(context.Background()) + assert.NoError(t, err) + assert.Equal(t, "6f1a2405cace1633d89a79c74c65f22fe78f9659", c.Sha) + assert.Equal(t, "Second commit", c.Title) +} + +func TestClientCommitBody(t *testing.T) { + client := Client{ + RepoDir: "./fixtures/simple.git", + } + body, err := client.CommitBody(context.Background(), "6f1a2405cace1633d89a79c74c65f22fe78f9659") + assert.NoError(t, err) + assert.Equal(t, "I'm starting to get the hang of things\n", body) +} + +func TestClientUncommittedChangeCount(t *testing.T) { + tests := []struct { + name string + expected int + output string + }{ + { + name: "no changes", + expected: 0, + output: "", + }, + { + name: "one change", + expected: 1, + output: " M poem.txt", + }, + { + name: "untracked file", + expected: 2, + output: " M poem.txt\n?? new.txt", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cs, restore := run.Stub() + defer restore(t) + cs.Register(`git status --porcelain`, 0, tt.output) + client := Client{} + ucc, err := client.UncommittedChangeCount(context.Background()) + assert.NoError(t, err) + assert.Equal(t, tt.expected, ucc) + }) + } +} + +func TestClientCurrentBranch(t *testing.T) { + tests := []struct { + name string + stub string + expected string + }{ + { + name: "branch name", + stub: "branch-name\n", + expected: "branch-name", + }, + { + name: "ref", + stub: "refs/heads/branch-name\n", + expected: "branch-name", + }, + { + name: "escaped ref", + stub: "refs/heads/branch\u00A0with\u00A0non\u00A0breaking\u00A0space\n", + expected: "branch\u00A0with\u00A0non\u00A0breaking\u00A0space", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cs, teardown := run.Stub() + defer teardown(t) + cs.Register(`git symbolic-ref --quiet HEAD`, 0, tt.stub) + client := Client{} + branch, err := client.CurrentBranch(context.Background()) + assert.NoError(t, err) + assert.Equal(t, tt.expected, branch) + }) + } +} + +func TestClientCurrentBranch_detached_head(t *testing.T) { + cs, teardown := run.Stub() + defer teardown(t) + cs.Register(`git symbolic-ref --quiet HEAD`, 1, "") + client := Client{} + _, err := client.CurrentBranch(context.Background()) + assert.EqualError(t, err, "failed to run git: not on any branch") +} + +func TestParseCloneArgs(t *testing.T) { + type wanted struct { + args []string + dir string + } + tests := []struct { + name string + args []string + want wanted + }{ + { + name: "args and target", + args: []string{"target_directory", "-o", "upstream", "--depth", "1"}, + want: wanted{ + args: []string{"-o", "upstream", "--depth", "1"}, + dir: "target_directory", + }, + }, + { + name: "only args", + args: []string{"-o", "upstream", "--depth", "1"}, + want: wanted{ + args: []string{"-o", "upstream", "--depth", "1"}, + dir: "", + }, + }, + { + name: "only target", + args: []string{"target_directory"}, + want: wanted{ + args: []string{}, + dir: "target_directory", + }, + }, + { + name: "no args", + args: []string{}, + want: wanted{ + args: []string{}, + dir: "", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args, dir := parseCloneArgs(tt.args) + got := wanted{args: args, dir: dir} + assert.Equal(t, got, tt.want) + }) + } +} + +func TestClientAddRemote(t *testing.T) { + tests := []struct { + title string + name string + url string + dir string + branches []string + want string + }{ + { + title: "fetch all", + name: "test", + url: "URL", + dir: "DIRECTORY", + branches: []string{}, + want: "git -C DIRECTORY remote add -f test URL", + }, + { + title: "fetch specific branches only", + name: "test", + url: "URL", + dir: "DIRECTORY", + branches: []string{"trunk", "dev"}, + want: "git -C DIRECTORY remote add -t trunk -t dev -f test URL", + }, + } + for _, tt := range tests { + t.Run(tt.title, func(t *testing.T) { + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + cs.Register(tt.want, 0, "") + client := Client{ + RepoDir: tt.dir, + } + _, err := client.AddRemote(context.Background(), tt.name, tt.url, tt.branches) + assert.NoError(t, err) + }) + } +} + +func initRepo(t *testing.T, dir string) { + errBuf := &bytes.Buffer{} + inBuf := &bytes.Buffer{} + outBuf := &bytes.Buffer{} + client := Client{ + RepoDir: dir, + Stderr: errBuf, + Stdin: inBuf, + Stdout: outBuf, + } + cmd, err := client.Command(context.Background(), []string{"init", "--quiet"}...) + assert.NoError(t, err) + err = cmd.Run() + assert.NoError(t, err) +} diff --git a/git/git.go b/git/git.go index defeae713..5b934175b 100644 --- a/git/git.go +++ b/git/git.go @@ -1,442 +1,154 @@ package git import ( - "bytes" - "errors" - "fmt" + "context" "io" - "net/url" "os" - "os/exec" - "path" - "regexp" - "runtime" - "strings" - - "github.com/cli/cli/v2/internal/run" - "github.com/cli/safeexec" ) -// ErrNotOnAnyBranch indicates that the user is in detached HEAD state -var ErrNotOnAnyBranch = errors.New("git: not on any branch") - -// Ref represents a git commit reference -type Ref struct { - Hash string - Name string +func GitCommand(args ...string) (*gitCommand, error) { + c := &Client{} + return c.Command(context.Background(), args...) } -// TrackingRef represents a ref for a remote tracking branch -type TrackingRef struct { - RemoteName string - BranchName string -} - -func (r TrackingRef) String() string { - return "refs/remotes/" + r.RemoteName + "/" + r.BranchName -} - -// ShowRefs resolves fully-qualified refs to commit hashes func ShowRefs(ref ...string) ([]Ref, error) { - args := append([]string{"show-ref", "--verify", "--"}, ref...) - showRef, err := GitCommand(args...) - if err != nil { - return nil, err - } - output, err := run.PrepareCmd(showRef).Output() - - var refs []Ref - for _, line := range outputLines(output) { - parts := strings.SplitN(line, " ", 2) - if len(parts) < 2 { - continue - } - refs = append(refs, Ref{ - Hash: parts[0], - Name: parts[1], - }) - } - - return refs, err + c := &Client{} + return c.ShowRefs(context.Background(), ref...) } -// CurrentBranch reads the checked-out branch for the git repository func CurrentBranch() (string, error) { - refCmd, err := GitCommand("symbolic-ref", "--quiet", "HEAD") - if err != nil { - return "", err - } - - stderr := bytes.Buffer{} - refCmd.Stderr = &stderr - - output, err := run.PrepareCmd(refCmd).Output() - if err == nil { - // Found the branch name - return getBranchShortName(output), nil - } - - if stderr.Len() == 0 { - // Detached head - return "", ErrNotOnAnyBranch - } - - return "", fmt.Errorf("%sgit: %s", stderr.String(), err) -} - -func listRemotesForPath(path string) ([]string, error) { - remoteCmd, err := GitCommand("-C", path, "remote", "-v") - if err != nil { - return nil, err - } - output, err := run.PrepareCmd(remoteCmd).Output() - return outputLines(output), err -} - -func listRemotes() ([]string, error) { - remoteCmd, err := GitCommand("remote", "-v") - if err != nil { - return nil, err - } - output, err := run.PrepareCmd(remoteCmd).Output() - return outputLines(output), err + c := &Client{} + return c.CurrentBranch(context.Background()) } func Config(name string) (string, error) { - configCmd, err := GitCommand("config", name) - if err != nil { - return "", err - } - output, err := run.PrepareCmd(configCmd).Output() - if err != nil { - return "", fmt.Errorf("unknown config key: %s", name) - } - - return firstLine(output), nil - -} - -type NotInstalled struct { - message string - error -} - -func (e *NotInstalled) Error() string { - return e.message -} - -func GitCommand(args ...string) (*exec.Cmd, error) { - gitExe, err := safeexec.LookPath("git") - if err != nil { - if errors.Is(err, exec.ErrNotFound) { - programName := "git" - if runtime.GOOS == "windows" { - programName = "Git for Windows" - } - return nil, &NotInstalled{ - message: fmt.Sprintf("unable to find git executable in PATH; please install %s before retrying", programName), - error: err, - } - } - return nil, err - } - return exec.Command(gitExe, args...), nil + c := &Client{} + return c.Config(context.Background(), name) } func UncommittedChangeCount() (int, error) { - statusCmd, err := GitCommand("status", "--porcelain") - if err != nil { - return 0, err - } - output, err := run.PrepareCmd(statusCmd).Output() - if err != nil { - return 0, err - } - lines := strings.Split(string(output), "\n") - - count := 0 - - for _, l := range lines { - if l != "" { - count++ - } - } - - return count, nil -} - -type Commit struct { - Sha string - Title string + c := &Client{} + return c.UncommittedChangeCount(context.Background()) } func Commits(baseRef, headRef string) ([]*Commit, error) { - logCmd, err := GitCommand( - "-c", "log.ShowSignature=false", - "log", "--pretty=format:%H,%s", - "--cherry", fmt.Sprintf("%s...%s", baseRef, headRef)) - if err != nil { - return nil, err - } - output, err := run.PrepareCmd(logCmd).Output() - if err != nil { - return []*Commit{}, err - } - - commits := []*Commit{} - sha := 0 - title := 1 - for _, line := range outputLines(output) { - split := strings.SplitN(line, ",", 2) - if len(split) != 2 { - continue - } - commits = append(commits, &Commit{ - Sha: split[sha], - Title: split[title], - }) - } - - if len(commits) == 0 { - return commits, fmt.Errorf("could not find any commits between %s and %s", baseRef, headRef) - } - - return commits, nil -} - -func lookupCommit(sha, format string) ([]byte, error) { - logCmd, err := GitCommand("-c", "log.ShowSignature=false", "show", "-s", "--pretty=format:"+format, sha) - if err != nil { - return nil, err - } - return run.PrepareCmd(logCmd).Output() + c := &Client{} + return c.Commits(context.Background(), baseRef, headRef) } func LastCommit() (*Commit, error) { - output, err := lookupCommit("HEAD", "%H,%s") - if err != nil { - return nil, err - } - - idx := bytes.IndexByte(output, ',') - return &Commit{ - Sha: string(output[0:idx]), - Title: strings.TrimSpace(string(output[idx+1:])), - }, nil + c := &Client{} + return c.LastCommit(context.Background()) } func CommitBody(sha string) (string, error) { - output, err := lookupCommit(sha, "%b") - return string(output), err + c := &Client{} + return c.CommitBody(context.Background(), sha) } -// Push publishes a git ref to a remote and sets up upstream configuration func Push(remote string, ref string, cmdIn io.ReadCloser, cmdOut, cmdErr io.Writer) error { - pushCmd, err := GitCommand("push", "--set-upstream", remote, ref) - if err != nil { - return err + //TODO: Replace with factory GitClient and use AuthenticatedCommand + c := &Client{ + Stdin: cmdIn, + Stdout: cmdOut, + Stderr: cmdErr, } - pushCmd.Stdin = cmdIn - pushCmd.Stdout = cmdOut - pushCmd.Stderr = cmdErr - return run.PrepareCmd(pushCmd).Run() + return c.Push(context.Background(), remote, ref) } -type BranchConfig struct { - RemoteName string - RemoteURL *url.URL - MergeRef string -} - -// ReadBranchConfig parses the `branch.BRANCH.(remote|merge)` part of git config func ReadBranchConfig(branch string) (cfg BranchConfig) { - prefix := regexp.QuoteMeta(fmt.Sprintf("branch.%s.", branch)) - configCmd, err := GitCommand("config", "--get-regexp", fmt.Sprintf("^%s(remote|merge)$", prefix)) - if err != nil { - return - } - output, err := run.PrepareCmd(configCmd).Output() - if err != nil { - return - } - for _, line := range outputLines(output) { - parts := strings.SplitN(line, " ", 2) - if len(parts) < 2 { - continue - } - keys := strings.Split(parts[0], ".") - switch keys[len(keys)-1] { - case "remote": - if strings.Contains(parts[1], ":") { - u, err := ParseURL(parts[1]) - if err != nil { - continue - } - cfg.RemoteURL = u - } else if !isFilesystemPath(parts[1]) { - cfg.RemoteName = parts[1] - } - case "merge": - cfg.MergeRef = parts[1] - } - } - return + c := &Client{} + return c.ReadBranchConfig(context.Background(), branch) } func DeleteLocalBranch(branch string) error { - branchCmd, err := GitCommand("branch", "-D", branch) - if err != nil { - return err - } - return run.PrepareCmd(branchCmd).Run() + c := &Client{} + return c.DeleteLocalBranch(context.Background(), branch) } func HasLocalBranch(branch string) bool { - configCmd, err := GitCommand("rev-parse", "--verify", "refs/heads/"+branch) - if err != nil { - return false - } - _, err = run.PrepareCmd(configCmd).Output() - return err == nil + c := &Client{} + return c.HasLocalBranch(context.Background(), branch) } func CheckoutBranch(branch string) error { - configCmd, err := GitCommand("checkout", branch) - if err != nil { - return err - } - return run.PrepareCmd(configCmd).Run() + c := &Client{} + return c.CheckoutBranch(context.Background(), branch) } func CheckoutNewBranch(remoteName, branch string) error { - track := fmt.Sprintf("%s/%s", remoteName, branch) - configCmd, err := GitCommand("checkout", "-b", branch, "--track", track) - if err != nil { - return err - } - return run.PrepareCmd(configCmd).Run() + c := &Client{} + return c.CheckoutNewBranch(context.Background(), remoteName, branch) } -// pull changes from remote branch without version history func Pull(remote, branch string) error { - pullCmd, err := GitCommand("pull", "--ff-only", remote, branch) - if err != nil { - return err + //TODO: Replace with factory GitClient and use AuthenticatedCommand + c := &Client{ + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, } - - pullCmd.Stdout = os.Stdout - pullCmd.Stderr = os.Stderr - pullCmd.Stdin = os.Stdin - return run.PrepareCmd(pullCmd).Run() -} - -func parseCloneArgs(extraArgs []string) (args []string, target string) { - args = extraArgs - - if len(args) > 0 { - if !strings.HasPrefix(args[0], "-") { - target, args = args[0], args[1:] - } - } - return + return c.Pull(context.Background(), remote, branch) } func RunClone(cloneURL string, args []string) (target string, err error) { - cloneArgs, target := parseCloneArgs(args) - - cloneArgs = append(cloneArgs, cloneURL) - - // If the args contain an explicit target, pass it to clone - // otherwise, parse the URL to determine where git cloned it to so we can return it - if target != "" { - cloneArgs = append(cloneArgs, target) - } else { - target = path.Base(strings.TrimSuffix(cloneURL, ".git")) + //TODO: Replace with factory GitClient and use AuthenticatedCommand + c := &Client{ + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, } - - cloneArgs = append([]string{"clone"}, cloneArgs...) - - cloneCmd, err := GitCommand(cloneArgs...) - if err != nil { - return "", err - } - cloneCmd.Stdin = os.Stdin - cloneCmd.Stdout = os.Stdout - cloneCmd.Stderr = os.Stderr - - err = run.PrepareCmd(cloneCmd).Run() - return + return c.Clone(context.Background(), cloneURL, args) } -func AddNamedRemote(url, name, dir string, branches []string) error { - args := []string{"-C", dir, "remote", "add"} - for _, branch := range branches { - args = append(args, "-t", branch) - } - args = append(args, "-f", name, url) - cloneCmd, err := GitCommand(args...) - if err != nil { - return err - } - cloneCmd.Stdout = os.Stdout - cloneCmd.Stderr = os.Stderr - return run.PrepareCmd(cloneCmd).Run() -} - -func isFilesystemPath(p string) bool { - return p == "." || strings.HasPrefix(p, "./") || strings.HasPrefix(p, "/") -} - -// ToplevelDir returns the top-level directory path of the current repository func ToplevelDir() (string, error) { - showCmd, err := GitCommand("rev-parse", "--show-toplevel") - if err != nil { - return "", err - } - output, err := run.PrepareCmd(showCmd).Output() - return firstLine(output), err - + c := &Client{} + return c.ToplevelDir(context.Background()) } -// ToplevelDirFromPath returns the top-level given path of the current repository -func GetDirFromPath(p string) (string, error) { - showCmd, err := GitCommand("-C", p, "rev-parse", "--git-dir") - if err != nil { - return "", err +func GetDirFromPath(repoDir string) (string, error) { + c := &Client{ + RepoDir: repoDir, } - output, err := run.PrepareCmd(showCmd).Output() - return firstLine(output), err + return c.GitDir(context.Background()) } func PathFromRepoRoot() string { - showCmd, err := GitCommand("rev-parse", "--show-prefix") - if err != nil { - return "" - } - output, err := run.PrepareCmd(showCmd).Output() - if err != nil { - return "" - } - if path := firstLine(output); path != "" { - return path[:len(path)-1] - } - return "" + c := &Client{} + return c.PathFromRoot(context.Background()) } -func outputLines(output []byte) []string { - lines := strings.TrimSuffix(string(output), "\n") - return strings.Split(lines, "\n") - +func Remotes() (RemoteSet, error) { + c := &Client{} + return c.Remotes(context.Background()) } -func firstLine(output []byte) string { - if i := bytes.IndexAny(output, "\n"); i >= 0 { - return string(output)[0:i] +func RemotesForPath(repoDir string) (RemoteSet, error) { + c := &Client{ + RepoDir: repoDir, } - return string(output) + return c.Remotes(context.Background()) } -func getBranchShortName(output []byte) string { - branch := firstLine(output) - return strings.TrimPrefix(branch, "refs/heads/") +func AddRemote(name, url string) (*Remote, error) { + c := &Client{} + return c.AddRemote(context.Background(), name, url, []string{}) +} + +func AddNamedRemote(url, name, repoDir string, branches []string) error { + c := &Client{ + RepoDir: repoDir, + } + _, err := c.AddRemote(context.Background(), name, url, branches) + return err +} + +func UpdateRemoteURL(name, url string) error { + c := &Client{} + return c.UpdateRemoteURL(context.Background(), name, url) +} + +func SetRemoteResolution(name, resolution string) error { + c := &Client{} + return c.SetRemoteResolution(context.Background(), name, resolution) } diff --git a/git/git_test.go b/git/git_test.go deleted file mode 100644 index b5812af9e..000000000 --- a/git/git_test.go +++ /dev/null @@ -1,213 +0,0 @@ -package git - -import ( - "reflect" - "testing" - - "github.com/cli/cli/v2/internal/run" -) - -func TestLastCommit(t *testing.T) { - t.Setenv("GIT_DIR", "./fixtures/simple.git") - c, err := LastCommit() - if err != nil { - t.Fatalf("LastCommit error: %v", err) - } - if c.Sha != "6f1a2405cace1633d89a79c74c65f22fe78f9659" { - t.Errorf("expected sha %q, got %q", "6f1a2405cace1633d89a79c74c65f22fe78f9659", c.Sha) - } - if c.Title != "Second commit" { - t.Errorf("expected title %q, got %q", "Second commit", c.Title) - } -} - -func TestCommitBody(t *testing.T) { - t.Setenv("GIT_DIR", "./fixtures/simple.git") - body, err := CommitBody("6f1a2405cace1633d89a79c74c65f22fe78f9659") - if err != nil { - t.Fatalf("CommitBody error: %v", err) - } - if body != "I'm starting to get the hang of things\n" { - t.Errorf("expected %q, got %q", "I'm starting to get the hang of things\n", body) - } -} - -/* - NOTE: below this are stubbed git tests, i.e. those that do not actually invoke `git`. If possible, utilize - `setGitDir()` to allow new tests to interact with `git`. For write operations, you can use `t.TempDir()` to - host a temporary git repository that is safe to be changed. -*/ - -func Test_UncommittedChangeCount(t *testing.T) { - type c struct { - Label string - Expected int - Output string - } - cases := []c{ - {Label: "no changes", Expected: 0, Output: ""}, - {Label: "one change", Expected: 1, Output: " M poem.txt"}, - {Label: "untracked file", Expected: 2, Output: " M poem.txt\n?? new.txt"}, - } - - for _, v := range cases { - t.Run(v.Label, func(t *testing.T) { - cs, restore := run.Stub() - defer restore(t) - cs.Register(`git status --porcelain`, 0, v.Output) - - ucc, _ := UncommittedChangeCount() - if ucc != v.Expected { - t.Errorf("UncommittedChangeCount() = %d, expected %d", ucc, v.Expected) - } - }) - } -} - -func Test_CurrentBranch(t *testing.T) { - type c struct { - Stub string - Expected string - } - cases := []c{ - { - Stub: "branch-name\n", - Expected: "branch-name", - }, - { - Stub: "refs/heads/branch-name\n", - Expected: "branch-name", - }, - { - Stub: "refs/heads/branch\u00A0with\u00A0non\u00A0breaking\u00A0space\n", - Expected: "branch\u00A0with\u00A0non\u00A0breaking\u00A0space", - }, - } - - for _, v := range cases { - cs, teardown := run.Stub() - cs.Register(`git symbolic-ref --quiet HEAD`, 0, v.Stub) - - result, err := CurrentBranch() - if err != nil { - t.Errorf("got unexpected error: %v", err) - } - if result != v.Expected { - t.Errorf("unexpected branch name: %s instead of %s", result, v.Expected) - } - teardown(t) - } -} - -func Test_CurrentBranch_detached_head(t *testing.T) { - cs, teardown := run.Stub() - defer teardown(t) - cs.Register(`git symbolic-ref --quiet HEAD`, 1, "") - - _, err := CurrentBranch() - if err == nil { - t.Fatal("expected an error, got nil") - } - if err != ErrNotOnAnyBranch { - t.Errorf("got unexpected error: %s instead of %s", err, ErrNotOnAnyBranch) - } -} - -func TestParseExtraCloneArgs(t *testing.T) { - type Wanted struct { - args []string - dir string - } - tests := []struct { - name string - args []string - want Wanted - }{ - { - name: "args and target", - args: []string{"target_directory", "-o", "upstream", "--depth", "1"}, - want: Wanted{ - args: []string{"-o", "upstream", "--depth", "1"}, - dir: "target_directory", - }, - }, - { - name: "only args", - args: []string{"-o", "upstream", "--depth", "1"}, - want: Wanted{ - args: []string{"-o", "upstream", "--depth", "1"}, - dir: "", - }, - }, - { - name: "only target", - args: []string{"target_directory"}, - want: Wanted{ - args: []string{}, - dir: "target_directory", - }, - }, - { - name: "no args", - args: []string{}, - want: Wanted{ - args: []string{}, - dir: "", - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - args, dir := parseCloneArgs(tt.args) - got := Wanted{ - args: args, - dir: dir, - } - - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("got %#v want %#v", got, tt.want) - } - }) - } -} - -func TestAddNamedRemote(t *testing.T) { - tests := []struct { - title string - name string - url string - dir string - branches []string - want string - }{ - { - title: "fetch all", - name: "test", - url: "URL", - dir: "DIRECTORY", - branches: []string{}, - want: "git -C DIRECTORY remote add -f test URL", - }, - { - title: "fetch specific branches only", - name: "test", - url: "URL", - dir: "DIRECTORY", - branches: []string{"trunk", "dev"}, - want: "git -C DIRECTORY remote add -t trunk -t dev -f test URL", - }, - } - for _, tt := range tests { - t.Run(tt.title, func(t *testing.T) { - cs, cmdTeardown := run.Stub() - defer cmdTeardown(t) - - cs.Register(tt.want, 0, "") - - err := AddNamedRemote(tt.url, tt.name, tt.dir, tt.branches) - if err != nil { - t.Fatalf("error running command `git remote add -f`: %v", err) - } - }) - } -} diff --git a/git/objects.go b/git/objects.go new file mode 100644 index 000000000..952b6c335 --- /dev/null +++ b/git/objects.go @@ -0,0 +1,76 @@ +package git + +import ( + "net/url" + "strings" +) + +// RemoteSet is a slice of git remotes. +type RemoteSet []*Remote + +func (r RemoteSet) Len() int { return len(r) } +func (r RemoteSet) Swap(i, j int) { r[i], r[j] = r[j], r[i] } +func (r RemoteSet) Less(i, j int) bool { + return remoteNameSortScore(r[i].Name) > remoteNameSortScore(r[j].Name) +} + +func remoteNameSortScore(name string) int { + switch strings.ToLower(name) { + case "upstream": + return 3 + case "github": + return 2 + case "origin": + return 1 + default: + return 0 + } +} + +// Remote is a parsed git remote. +type Remote struct { + Name string + Resolved string + FetchURL *url.URL + PushURL *url.URL +} + +func (r *Remote) String() string { + return r.Name +} + +func NewRemote(name string, u string) *Remote { + pu, _ := url.Parse(u) + return &Remote{ + Name: name, + FetchURL: pu, + PushURL: pu, + } +} + +// Ref represents a git commit reference. +type Ref struct { + Hash string + Name string +} + +// TrackingRef represents a ref for a remote tracking branch. +type TrackingRef struct { + RemoteName string + BranchName string +} + +func (r TrackingRef) String() string { + return "refs/remotes/" + r.RemoteName + "/" + r.BranchName +} + +type Commit struct { + Sha string + Title string +} + +type BranchConfig struct { + RemoteName string + RemoteURL *url.URL + MergeRef string +} diff --git a/git/remote.go b/git/remote.go deleted file mode 100644 index bea81da90..000000000 --- a/git/remote.go +++ /dev/null @@ -1,169 +0,0 @@ -package git - -import ( - "fmt" - "net/url" - "regexp" - "strings" - - "github.com/cli/cli/v2/internal/run" -) - -var remoteRE = regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`) - -// RemoteSet is a slice of git remotes -type RemoteSet []*Remote - -func NewRemote(name string, u string) *Remote { - pu, _ := url.Parse(u) - return &Remote{ - Name: name, - FetchURL: pu, - PushURL: pu, - } -} - -// Remote is a parsed git remote -type Remote struct { - Name string - Resolved string - FetchURL *url.URL - PushURL *url.URL -} - -func (r *Remote) String() string { - return r.Name -} - -func remotes(path string, remoteList []string) (RemoteSet, error) { - remotes := parseRemotes(remoteList) - - // this is affected by SetRemoteResolution - remoteCmd, err := GitCommand("-C", path, "config", "--get-regexp", `^remote\..*\.gh-resolved$`) - if err != nil { - return nil, err - } - output, _ := run.PrepareCmd(remoteCmd).Output() - for _, l := range outputLines(output) { - parts := strings.SplitN(l, " ", 2) - if len(parts) < 2 { - continue - } - rp := strings.SplitN(parts[0], ".", 3) - if len(rp) < 2 { - continue - } - name := rp[1] - for _, r := range remotes { - if r.Name == name { - r.Resolved = parts[1] - break - } - } - } - - return remotes, nil -} - -func RemotesForPath(path string) (RemoteSet, error) { - list, err := listRemotesForPath(path) - if err != nil { - return nil, err - } - return remotes(path, list) -} - -// Remotes gets the git remotes set for the current repo -func Remotes() (RemoteSet, error) { - list, err := listRemotes() - if err != nil { - return nil, err - } - return remotes(".", list) -} - -func parseRemotes(gitRemotes []string) (remotes RemoteSet) { - for _, r := range gitRemotes { - match := remoteRE.FindStringSubmatch(r) - if match == nil { - continue - } - name := strings.TrimSpace(match[1]) - urlStr := strings.TrimSpace(match[2]) - urlType := strings.TrimSpace(match[3]) - - var rem *Remote - if len(remotes) > 0 { - rem = remotes[len(remotes)-1] - if name != rem.Name { - rem = nil - } - } - if rem == nil { - rem = &Remote{Name: name} - remotes = append(remotes, rem) - } - - u, err := ParseURL(urlStr) - if err != nil { - continue - } - - switch urlType { - case "fetch": - rem.FetchURL = u - case "push": - rem.PushURL = u - } - } - return -} - -// AddRemote adds a new git remote and auto-fetches objects from it -func AddRemote(name, u string) (*Remote, error) { - addCmd, err := GitCommand("remote", "add", "-f", name, u) - if err != nil { - return nil, err - } - err = run.PrepareCmd(addCmd).Run() - if err != nil { - return nil, err - } - - var urlParsed *url.URL - if strings.HasPrefix(u, "https") { - urlParsed, err = url.Parse(u) - if err != nil { - return nil, err - } - - } else { - urlParsed, err = ParseURL(u) - if err != nil { - return nil, err - } - - } - - return &Remote{ - Name: name, - FetchURL: urlParsed, - PushURL: urlParsed, - }, nil -} - -func UpdateRemoteURL(name, u string) error { - addCmd, err := GitCommand("remote", "set-url", name, u) - if err != nil { - return err - } - return run.PrepareCmd(addCmd).Run() -} - -func SetRemoteResolution(name, resolution string) error { - addCmd, err := GitCommand("config", "--add", fmt.Sprintf("remote.%s.gh-resolved", name), resolution) - if err != nil { - return err - } - return run.PrepareCmd(addCmd).Run() -} diff --git a/git/remote_test.go b/git/remote_test.go deleted file mode 100644 index 382896590..000000000 --- a/git/remote_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package git - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func Test_parseRemotes(t *testing.T) { - remoteList := []string{ - "mona\tgit@github.com:monalisa/myfork.git (fetch)", - "origin\thttps://github.com/monalisa/octo-cat.git (fetch)", - "origin\thttps://github.com/monalisa/octo-cat-push.git (push)", - "upstream\thttps://example.com/nowhere.git (fetch)", - "upstream\thttps://github.com/hubot/tools (push)", - "zardoz\thttps://example.com/zed.git (push)", - } - r := parseRemotes(remoteList) - assert.Equal(t, 4, len(r)) - - assert.Equal(t, "mona", r[0].Name) - assert.Equal(t, "ssh://git@github.com/monalisa/myfork.git", r[0].FetchURL.String()) - if r[0].PushURL != nil { - t.Errorf("expected no PushURL, got %q", r[0].PushURL) - } - assert.Equal(t, "origin", r[1].Name) - assert.Equal(t, "/monalisa/octo-cat.git", r[1].FetchURL.Path) - assert.Equal(t, "/monalisa/octo-cat-push.git", r[1].PushURL.Path) - - assert.Equal(t, "upstream", r[2].Name) - assert.Equal(t, "example.com", r[2].FetchURL.Host) - assert.Equal(t, "github.com", r[2].PushURL.Host) - - assert.Equal(t, "zardoz", r[3].Name) -} diff --git a/pkg/cmd/auth/shared/git_credential.go b/pkg/cmd/auth/shared/git_credential.go index 9c9a1cf6b..491e2c82f 100644 --- a/pkg/cmd/auth/shared/git_credential.go +++ b/pkg/cmd/auth/shared/git_credential.go @@ -10,7 +10,6 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghinstance" - "github.com/cli/cli/v2/internal/run" "github.com/google/shlex" ) @@ -82,7 +81,7 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s configErr = err break } - if err = run.PrepareCmd(preConfigureCmd).Run(); err != nil { + if err = preConfigureCmd.Run(); err != nil { configErr = err break } @@ -96,7 +95,7 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s if err != nil { configErr = err } else { - configErr = run.PrepareCmd(configureCmd).Run() + configErr = configureCmd.Run() } } @@ -114,7 +113,7 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s host=%s `, hostname)) - err = run.PrepareCmd(rejectCmd).Run() + err = rejectCmd.Run() if err != nil { return err } @@ -131,7 +130,7 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s password=%s `, hostname, username, password)) - err = run.PrepareCmd(approveCmd).Run() + err = approveCmd.Run() if err != nil { return err } diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 4f05b7c03..77552c977 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -35,6 +35,7 @@ func New(appVersion string) *cmdutil.Factory { f.BaseRepo = BaseRepoFunc(f) // Depends on Remotes f.Prompter = newPrompter(f) // Depends on Config and IOStreams f.Browser = newBrowser(f) // Depends on Config, and IOStreams + f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable f.ExtensionManager = extensionManager(f) // Depends on Config, HttpClient, and IOStreams return f @@ -106,6 +107,18 @@ func httpClientFunc(f *cmdutil.Factory, appVersion string) func() (*http.Client, } } +func newGitClient(f *cmdutil.Factory) *git.Client { + io := f.IOStreams + ghPath := f.Executable() + client := &git.Client{ + GhPath: ghPath, + Stderr: io.ErrOut, + Stdin: io.In, + Stdout: io.Out, + } + return client +} + func newBrowser(f *cmdutil.Factory) browser.Browser { io := f.IOStreams return browser.New("", io.Out, io.ErrOut) diff --git a/pkg/cmd/factory/default_test.go b/pkg/cmd/factory/default_test.go index feef3e81f..2ba35b427 100644 --- a/pkg/cmd/factory/default_test.go +++ b/pkg/cmd/factory/default_test.go @@ -4,6 +4,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "path/filepath" "testing" "github.com/cli/cli/v2/git" @@ -435,6 +436,44 @@ func TestSSOURL(t *testing.T) { } } +func TestNewGitClient(t *testing.T) { + tests := []struct { + name string + config config.Config + executable string + wantAuthHosts []string + wantGhPath string + }{ + { + name: "creates git client", + config: defaultConfig(), + executable: filepath.Join("path", "to", "gh"), + wantAuthHosts: []string{"nonsense.com"}, + wantGhPath: filepath.Join("path", "to", "gh"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := New("1") + f.Config = func() (config.Config, error) { + if tt.config == nil { + return config.NewBlankConfig(), nil + } else { + return tt.config, nil + } + } + f.ExecutableName = tt.executable + ios, _, _, _ := iostreams.Test() + f.IOStreams = ios + c := newGitClient(f) + assert.Equal(t, tt.wantGhPath, c.GhPath) + assert.Equal(t, ios.In, c.Stdin) + assert.Equal(t, ios.Out, c.Stdout) + assert.Equal(t, ios.ErrOut, c.Stderr) + }) + } +} + func defaultConfig() *config.ConfigMock { cfg := config.NewFromString("") cfg.Set("nonsense.com", "oauth_token", "BLAH") diff --git a/pkg/cmd/pr/checkout/checkout.go b/pkg/cmd/pr/checkout/checkout.go index 1063d9f36..73c8f40d1 100644 --- a/pkg/cmd/pr/checkout/checkout.go +++ b/pkg/cmd/pr/checkout/checkout.go @@ -1,22 +1,19 @@ package checkout import ( + "context" "fmt" "net/http" - "os" - "os/exec" "strings" "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/context" + cliContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/safeexec" "github.com/spf13/cobra" ) @@ -24,7 +21,7 @@ type CheckoutOptions struct { HttpClient func() (*http.Client, error) Config func() (config.Config, error) IO *iostreams.IOStreams - Remotes func() (context.Remotes, error) + Remotes func() (cliContext.Remotes, error) Branch func() (string, error) Finder shared.PRFinder @@ -131,7 +128,7 @@ func checkoutRun(opts *CheckoutOptions) error { cmdQueue = append(cmdQueue, []string{"git", "submodule", "update", "--init", "--recursive"}) } - err = executeCmds(cmdQueue) + err = executeCmds(cmdQueue, opts.IO) if err != nil { return err } @@ -139,7 +136,7 @@ func checkoutRun(opts *CheckoutOptions) error { return nil } -func cmdsForExistingRemote(remote *context.Remote, pr *api.PullRequest, opts *CheckoutOptions) [][]string { +func cmdsForExistingRemote(remote *cliContext.Remote, pr *api.PullRequest, opts *CheckoutOptions) [][]string { var cmds [][]string remoteBranch := fmt.Sprintf("%s/%s", remote.Name, pr.HeadRefName) @@ -241,17 +238,19 @@ func localBranchExists(b string) bool { return err == nil } -func executeCmds(cmdQueue [][]string) error { +func executeCmds(cmdQueue [][]string, ios *iostreams.IOStreams) error { + //TODO: Replace with factory GitClient + //TODO: Use AuthenticatedCommand + client := git.Client{ + Stdout: ios.Out, + Stderr: ios.ErrOut, + } for _, args := range cmdQueue { - // TODO: reuse the result of this lookup across loop iteration - exe, err := safeexec.LookPath(args[0]) + cmd, err := client.Command(context.Background(), args[1:]...) if err != nil { return err } - cmd := exec.Command(exe, args[1:]...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := run.PrepareCmd(cmd).Run(); err != nil { + if err := cmd.Run(); err != nil { return err } } diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 73c74e18f..e6f97dd9f 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -13,7 +13,6 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -475,7 +474,7 @@ func gitTagInfo(tagName string) (string, error) { if err != nil { return "", err } - b, err := run.PrepareCmd(cmd).Output() + b, err := cmd.Output() return string(b), err } @@ -484,7 +483,7 @@ func detectPreviousTag(headRef string) (string, error) { if err != nil { return "", err } - b, err := run.PrepareCmd(cmd).Output() + b, err := cmd.Output() return strings.TrimSpace(string(b)), err } @@ -498,7 +497,7 @@ func changelogForRange(refRange string) ([]logEntry, error) { if err != nil { return nil, err } - b, err := run.PrepareCmd(cmd).Output() + b, err := cmd.Output() if err != nil { return nil, err } diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index b1eb2318e..e311ad727 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -13,7 +13,6 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmd/repo/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -552,7 +551,7 @@ func createFromLocal(opts *CreateOptions) error { if err != nil { return err } - err = run.PrepareCmd(repoPush).Run() + err = repoPush.Run() if err != nil { return err } @@ -574,7 +573,7 @@ func sourceInit(io *iostreams.IOStreams, remoteURL, baseRemote, repoPath string) return err } - err = run.PrepareCmd(remoteAdd).Run() + err = remoteAdd.Run() if err != nil { return fmt.Errorf("%s Unable to add remote %q", cs.FailureIcon(), baseRemote) } @@ -590,8 +589,7 @@ func hasCommits(repoPath string) (bool, error) { if err != nil { return false, err } - prepareCmd := run.PrepareCmd(hasCommitsCmd) - err = prepareCmd.Run() + err = hasCommitsCmd.Run() if err == nil { return true, nil } @@ -636,7 +634,7 @@ func localInit(io *iostreams.IOStreams, remoteURL, path, checkoutBranch string) gitInit.Stdout = io.Out } gitInit.Stderr = io.ErrOut - err = run.PrepareCmd(gitInit).Run() + err = gitInit.Run() if err != nil { return err } @@ -647,7 +645,7 @@ func localInit(io *iostreams.IOStreams, remoteURL, path, checkoutBranch string) } gitRemoteAdd.Stdout = io.Out gitRemoteAdd.Stderr = io.ErrOut - err = run.PrepareCmd(gitRemoteAdd).Run() + err = gitRemoteAdd.Run() if err != nil { return err } @@ -662,7 +660,7 @@ func localInit(io *iostreams.IOStreams, remoteURL, path, checkoutBranch string) } gitFetch.Stdout = io.Out gitFetch.Stderr = io.ErrOut - err = run.PrepareCmd(gitFetch).Run() + err = gitFetch.Run() if err != nil { return err } @@ -673,7 +671,7 @@ func localInit(io *iostreams.IOStreams, remoteURL, path, checkoutBranch string) } gitCheckout.Stdout = io.Out gitCheckout.Stderr = io.ErrOut - return run.PrepareCmd(gitCheckout).Run() + return gitCheckout.Run() } func interactiveGitIgnore(client *http.Client, hostname string, prompter iprompter) (string, error) { diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go index 6c7c515d3..f8849afd1 100644 --- a/pkg/cmd/repo/create/create_test.go +++ b/pkg/cmd/repo/create/create_test.go @@ -492,18 +492,18 @@ func Test_createRun(t *testing.T) { return config.NewBlankConfig(), nil } - cs, restoreRun := run.Stub() - defer restoreRun(t) - if tt.execStubs != nil { - tt.execStubs(cs) - } - ios, _, stdout, stderr := iostreams.Test() ios.SetStdinTTY(tt.tty) ios.SetStdoutTTY(tt.tty) tt.opts.IO = ios t.Run(tt.name, func(t *testing.T) { + cs, restoreRun := run.Stub() + defer restoreRun(t) + if tt.execStubs != nil { + tt.execStubs(cs) + } + defer reg.Verify(t) err := createRun(tt.opts) if tt.wantErr { diff --git a/pkg/cmd/repo/fork/fork.go b/pkg/cmd/repo/fork/fork.go index e3fe80247..9416ba4b2 100644 --- a/pkg/cmd/repo/fork/fork.go +++ b/pkg/cmd/repo/fork/fork.go @@ -13,7 +13,6 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmd/repo/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -279,7 +278,7 @@ func forkRun(opts *ForkOptions) error { if err != nil { return err } - err = run.PrepareCmd(renameCmd).Run() + err = renameCmd.Run() if err != nil { return err } diff --git a/pkg/cmdutil/factory.go b/pkg/cmdutil/factory.go index d7357f1fa..e00e1a89a 100644 --- a/pkg/cmdutil/factory.go +++ b/pkg/cmdutil/factory.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" @@ -19,6 +20,7 @@ type Factory struct { IOStreams *iostreams.IOStreams Prompter prompter.Prompter Browser browser.Browser + GitClient *git.Client HttpClient func() (*http.Client, error) BaseRepo func() (ghrepo.Interface, error)