Create git client (#6354)

This commit is contained in:
Sam Coe 2022-10-14 10:47:03 +03:00 committed by GitHub
parent 4c3b123db6
commit 2944f7c3ab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 1223 additions and 828 deletions

611
git/client.go Normal file
View file

@ -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
}
}
}
}

365
git/client_test.go Normal file
View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}
})
}
}

76
git/objects.go Normal file
View file

@ -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
}

View file

@ -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()
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)

View file

@ -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")

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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) {

View file

@ -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 {

View file

@ -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
}

View file

@ -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)