github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/git/client.go (about)

     1  package git
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"errors"
     7  	"fmt"
     8  	"io"
     9  	"net/url"
    10  	"os/exec"
    11  	"path"
    12  	"regexp"
    13  	"runtime"
    14  	"sort"
    15  	"strings"
    16  	"sync"
    17  
    18  	"github.com/cli/safeexec"
    19  )
    20  
    21  var remoteRE = regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`)
    22  
    23  type Client struct {
    24  	GhPath  string
    25  	RepoDir string
    26  	GitPath string
    27  	Stderr  io.Writer
    28  	Stdin   io.Reader
    29  	Stdout  io.Writer
    30  
    31  	commandContext commandCtx
    32  	mu             sync.Mutex
    33  }
    34  
    35  func (c *Client) Command(ctx context.Context, args ...string) (*gitCommand, error) {
    36  	if c.RepoDir != "" {
    37  		args = append([]string{"-C", c.RepoDir}, args...)
    38  	}
    39  	commandContext := exec.CommandContext
    40  	if c.commandContext != nil {
    41  		commandContext = c.commandContext
    42  	}
    43  	var err error
    44  	c.mu.Lock()
    45  	if c.GitPath == "" {
    46  		c.GitPath, err = resolveGitPath()
    47  	}
    48  	c.mu.Unlock()
    49  	if err != nil {
    50  		return nil, err
    51  	}
    52  	cmd := commandContext(ctx, c.GitPath, args...)
    53  	cmd.Stderr = c.Stderr
    54  	cmd.Stdin = c.Stdin
    55  	cmd.Stdout = c.Stdout
    56  	return &gitCommand{cmd}, nil
    57  }
    58  
    59  // AuthenticatedCommand is a wrapper around Command that included configuration to use gh
    60  // as the credential helper for git.
    61  func (c *Client) AuthenticatedCommand(ctx context.Context, args ...string) (*gitCommand, error) {
    62  	preArgs := []string{"-c", "credential.helper="}
    63  	if c.GhPath == "" {
    64  		// Assumes that gh is in PATH.
    65  		c.GhPath = "gh"
    66  	}
    67  	credHelper := fmt.Sprintf("!%q auth git-credential", c.GhPath)
    68  	preArgs = append(preArgs, "-c", fmt.Sprintf("credential.helper=%s", credHelper))
    69  	args = append(preArgs, args...)
    70  	return c.Command(ctx, args...)
    71  }
    72  
    73  func (c *Client) Remotes(ctx context.Context) (RemoteSet, error) {
    74  	remoteArgs := []string{"remote", "-v"}
    75  	remoteCmd, err := c.Command(ctx, remoteArgs...)
    76  	if err != nil {
    77  		return nil, err
    78  	}
    79  	remoteOut, remoteErr := remoteCmd.Output()
    80  	if remoteErr != nil {
    81  		return nil, remoteErr
    82  	}
    83  
    84  	configArgs := []string{"config", "--get-regexp", `^remote\..*\.gh-resolved$`}
    85  	configCmd, err := c.Command(ctx, configArgs...)
    86  	if err != nil {
    87  		return nil, err
    88  	}
    89  	configOut, configErr := configCmd.Output()
    90  	if configErr != nil {
    91  		// Ignore exit code 1 as it means there are no resolved remotes.
    92  		var gitErr *GitError
    93  		if ok := errors.As(configErr, &gitErr); ok && gitErr.ExitCode != 1 {
    94  			return nil, gitErr
    95  		}
    96  	}
    97  
    98  	remotes := parseRemotes(outputLines(remoteOut))
    99  	populateResolvedRemotes(remotes, outputLines(configOut))
   100  	sort.Sort(remotes)
   101  	return remotes, nil
   102  }
   103  
   104  func (c *Client) UpdateRemoteURL(ctx context.Context, name, url string) error {
   105  	args := []string{"remote", "set-url", name, url}
   106  	cmd, err := c.Command(ctx, args...)
   107  	if err != nil {
   108  		return err
   109  	}
   110  	_, err = cmd.Output()
   111  	if err != nil {
   112  		return err
   113  	}
   114  	return nil
   115  }
   116  
   117  func (c *Client) SetRemoteResolution(ctx context.Context, name, resolution string) error {
   118  	args := []string{"config", "--add", fmt.Sprintf("remote.%s.gh-resolved", name), resolution}
   119  	cmd, err := c.Command(ctx, args...)
   120  	if err != nil {
   121  		return err
   122  	}
   123  	_, err = cmd.Output()
   124  	if err != nil {
   125  		return err
   126  	}
   127  	return nil
   128  }
   129  
   130  // CurrentBranch reads the checked-out branch for the git repository.
   131  func (c *Client) CurrentBranch(ctx context.Context) (string, error) {
   132  	args := []string{"symbolic-ref", "--quiet", "HEAD"}
   133  	cmd, err := c.Command(ctx, args...)
   134  	if err != nil {
   135  		return "", err
   136  	}
   137  	out, err := cmd.Output()
   138  	if err != nil {
   139  		var gitErr *GitError
   140  		if ok := errors.As(err, &gitErr); ok && len(gitErr.Stderr) == 0 {
   141  			gitErr.Stderr = "not on any branch"
   142  			return "", gitErr
   143  		}
   144  		return "", err
   145  	}
   146  	branch := firstLine(out)
   147  	return strings.TrimPrefix(branch, "refs/heads/"), nil
   148  }
   149  
   150  // ShowRefs resolves fully-qualified refs to commit hashes.
   151  func (c *Client) ShowRefs(ctx context.Context, refs []string) ([]Ref, error) {
   152  	args := append([]string{"show-ref", "--verify", "--"}, refs...)
   153  	cmd, err := c.Command(ctx, args...)
   154  	if err != nil {
   155  		return nil, err
   156  	}
   157  	// This functionality relies on parsing output from the git command despite
   158  	// an error status being returned from git.
   159  	out, err := cmd.Output()
   160  	var verified []Ref
   161  	for _, line := range outputLines(out) {
   162  		parts := strings.SplitN(line, " ", 2)
   163  		if len(parts) < 2 {
   164  			continue
   165  		}
   166  		verified = append(verified, Ref{
   167  			Hash: parts[0],
   168  			Name: parts[1],
   169  		})
   170  	}
   171  	return verified, err
   172  }
   173  
   174  func (c *Client) Config(ctx context.Context, name string) (string, error) {
   175  	args := []string{"config", name}
   176  	cmd, err := c.Command(ctx, args...)
   177  	if err != nil {
   178  		return "", err
   179  	}
   180  	out, err := cmd.Output()
   181  	if err != nil {
   182  		var gitErr *GitError
   183  		if ok := errors.As(err, &gitErr); ok && gitErr.ExitCode == 1 {
   184  			gitErr.Stderr = fmt.Sprintf("unknown config key %s", name)
   185  			return "", gitErr
   186  		}
   187  		return "", err
   188  	}
   189  	return firstLine(out), nil
   190  }
   191  
   192  func (c *Client) UncommittedChangeCount(ctx context.Context) (int, error) {
   193  	args := []string{"status", "--porcelain"}
   194  	cmd, err := c.Command(ctx, args...)
   195  	if err != nil {
   196  		return 0, err
   197  	}
   198  	out, err := cmd.Output()
   199  	if err != nil {
   200  		return 0, err
   201  	}
   202  	lines := strings.Split(string(out), "\n")
   203  	count := 0
   204  	for _, l := range lines {
   205  		if l != "" {
   206  			count++
   207  		}
   208  	}
   209  	return count, nil
   210  }
   211  
   212  func (c *Client) Commits(ctx context.Context, baseRef, headRef string) ([]*Commit, error) {
   213  	args := []string{"-c", "log.ShowSignature=false", "log", "--pretty=format:%H,%s", "--cherry", fmt.Sprintf("%s...%s", baseRef, headRef)}
   214  	cmd, err := c.Command(ctx, args...)
   215  	if err != nil {
   216  		return nil, err
   217  	}
   218  	out, err := cmd.Output()
   219  	if err != nil {
   220  		return nil, err
   221  	}
   222  	commits := []*Commit{}
   223  	sha := 0
   224  	title := 1
   225  	for _, line := range outputLines(out) {
   226  		split := strings.SplitN(line, ",", 2)
   227  		if len(split) != 2 {
   228  			continue
   229  		}
   230  		commits = append(commits, &Commit{
   231  			Sha:   split[sha],
   232  			Title: split[title],
   233  		})
   234  	}
   235  	if len(commits) == 0 {
   236  		return nil, fmt.Errorf("could not find any commits between %s and %s", baseRef, headRef)
   237  	}
   238  	return commits, nil
   239  }
   240  
   241  func (c *Client) LastCommit(ctx context.Context) (*Commit, error) {
   242  	output, err := c.lookupCommit(ctx, "HEAD", "%H,%s")
   243  	if err != nil {
   244  		return nil, err
   245  	}
   246  	idx := bytes.IndexByte(output, ',')
   247  	return &Commit{
   248  		Sha:   string(output[0:idx]),
   249  		Title: strings.TrimSpace(string(output[idx+1:])),
   250  	}, nil
   251  }
   252  
   253  func (c *Client) CommitBody(ctx context.Context, sha string) (string, error) {
   254  	output, err := c.lookupCommit(ctx, sha, "%b")
   255  	return string(output), err
   256  }
   257  
   258  func (c *Client) lookupCommit(ctx context.Context, sha, format string) ([]byte, error) {
   259  	args := []string{"-c", "log.ShowSignature=false", "show", "-s", "--pretty=format:" + format, sha}
   260  	cmd, err := c.Command(ctx, args...)
   261  	if err != nil {
   262  		return nil, err
   263  	}
   264  	out, err := cmd.Output()
   265  	if err != nil {
   266  		return nil, err
   267  	}
   268  	return out, nil
   269  }
   270  
   271  // ReadBranchConfig parses the `branch.BRANCH.(remote|merge)` part of git config.
   272  func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (cfg BranchConfig) {
   273  	prefix := regexp.QuoteMeta(fmt.Sprintf("branch.%s.", branch))
   274  	args := []string{"config", "--get-regexp", fmt.Sprintf("^%s(remote|merge)$", prefix)}
   275  	cmd, err := c.Command(ctx, args...)
   276  	if err != nil {
   277  		return
   278  	}
   279  	out, err := cmd.Output()
   280  	if err != nil {
   281  		return
   282  	}
   283  	for _, line := range outputLines(out) {
   284  		parts := strings.SplitN(line, " ", 2)
   285  		if len(parts) < 2 {
   286  			continue
   287  		}
   288  		keys := strings.Split(parts[0], ".")
   289  		switch keys[len(keys)-1] {
   290  		case "remote":
   291  			if strings.Contains(parts[1], ":") {
   292  				u, err := ParseURL(parts[1])
   293  				if err != nil {
   294  					continue
   295  				}
   296  				cfg.RemoteURL = u
   297  			} else if !isFilesystemPath(parts[1]) {
   298  				cfg.RemoteName = parts[1]
   299  			}
   300  		case "merge":
   301  			cfg.MergeRef = parts[1]
   302  		}
   303  	}
   304  	return
   305  }
   306  
   307  func (c *Client) DeleteLocalBranch(ctx context.Context, branch string) error {
   308  	args := []string{"branch", "-D", branch}
   309  	cmd, err := c.Command(ctx, args...)
   310  	if err != nil {
   311  		return err
   312  	}
   313  	_, err = cmd.Output()
   314  	if err != nil {
   315  		return err
   316  	}
   317  	return nil
   318  }
   319  
   320  func (c *Client) HasLocalBranch(ctx context.Context, branch string) bool {
   321  	args := []string{"rev-parse", "--verify", "refs/heads/" + branch}
   322  	cmd, err := c.Command(ctx, args...)
   323  	if err != nil {
   324  		return false
   325  	}
   326  	_, err = cmd.Output()
   327  	return err == nil
   328  }
   329  
   330  func (c *Client) CheckoutBranch(ctx context.Context, branch string) error {
   331  	args := []string{"checkout", branch}
   332  	cmd, err := c.Command(ctx, args...)
   333  	if err != nil {
   334  		return err
   335  	}
   336  	_, err = cmd.Output()
   337  	if err != nil {
   338  		return err
   339  	}
   340  	return nil
   341  }
   342  
   343  func (c *Client) CheckoutNewBranch(ctx context.Context, remoteName, branch string) error {
   344  	track := fmt.Sprintf("%s/%s", remoteName, branch)
   345  	args := []string{"checkout", "-b", branch, "--track", track}
   346  	cmd, err := c.Command(ctx, args...)
   347  	if err != nil {
   348  		return err
   349  	}
   350  	_, err = cmd.Output()
   351  	if err != nil {
   352  		return err
   353  	}
   354  	return nil
   355  }
   356  
   357  // ToplevelDir returns the top-level directory path of the current repository.
   358  func (c *Client) ToplevelDir(ctx context.Context) (string, error) {
   359  	args := []string{"rev-parse", "--show-toplevel"}
   360  	cmd, err := c.Command(ctx, args...)
   361  	if err != nil {
   362  		return "", err
   363  	}
   364  	out, err := cmd.Output()
   365  	if err != nil {
   366  		return "", err
   367  	}
   368  	return firstLine(out), nil
   369  }
   370  
   371  func (c *Client) GitDir(ctx context.Context) (string, error) {
   372  	args := []string{"rev-parse", "--git-dir"}
   373  	cmd, err := c.Command(ctx, args...)
   374  	if err != nil {
   375  		return "", err
   376  	}
   377  	out, err := cmd.Output()
   378  	if err != nil {
   379  		return "", err
   380  	}
   381  	return firstLine(out), nil
   382  }
   383  
   384  // Show current directory relative to the top-level directory of repository.
   385  func (c *Client) PathFromRoot(ctx context.Context) string {
   386  	args := []string{"rev-parse", "--show-prefix"}
   387  	cmd, err := c.Command(ctx, args...)
   388  	if err != nil {
   389  		return ""
   390  	}
   391  	out, err := cmd.Output()
   392  	if err != nil {
   393  		return ""
   394  	}
   395  	if path := firstLine(out); path != "" {
   396  		return path[:len(path)-1]
   397  	}
   398  	return ""
   399  }
   400  
   401  // Below are commands that make network calls and need authentication credentials supplied from gh.
   402  
   403  func (c *Client) Fetch(ctx context.Context, remote string, refspec string, mods ...CommandModifier) error {
   404  	args := []string{"fetch", remote, refspec}
   405  	// TODO: Use AuthenticatedCommand
   406  	cmd, err := c.Command(ctx, args...)
   407  	if err != nil {
   408  		return err
   409  	}
   410  	for _, mod := range mods {
   411  		mod(cmd)
   412  	}
   413  	return cmd.Run()
   414  }
   415  
   416  func (c *Client) Pull(ctx context.Context, remote, branch string, mods ...CommandModifier) error {
   417  	args := []string{"pull", "--ff-only", remote, branch}
   418  	// TODO: Use AuthenticatedCommand
   419  	cmd, err := c.Command(ctx, args...)
   420  	if err != nil {
   421  		return err
   422  	}
   423  	for _, mod := range mods {
   424  		mod(cmd)
   425  	}
   426  	return cmd.Run()
   427  }
   428  
   429  func (c *Client) Push(ctx context.Context, remote string, ref string, mods ...CommandModifier) error {
   430  	args := []string{"push", "--set-upstream", remote, ref}
   431  	// TODO: Use AuthenticatedCommand
   432  	cmd, err := c.Command(ctx, args...)
   433  	if err != nil {
   434  		return err
   435  	}
   436  	for _, mod := range mods {
   437  		mod(cmd)
   438  	}
   439  	return cmd.Run()
   440  }
   441  
   442  func (c *Client) Clone(ctx context.Context, cloneURL string, args []string, mods ...CommandModifier) (string, error) {
   443  	cloneArgs, target := parseCloneArgs(args)
   444  	cloneArgs = append(cloneArgs, cloneURL)
   445  	// If the args contain an explicit target, pass it to clone otherwise,
   446  	// parse the URL to determine where git cloned it to so we can return it.
   447  	if target != "" {
   448  		cloneArgs = append(cloneArgs, target)
   449  	} else {
   450  		target = path.Base(strings.TrimSuffix(cloneURL, ".git"))
   451  	}
   452  	cloneArgs = append([]string{"clone"}, cloneArgs...)
   453  	// TODO: Use AuthenticatedCommand
   454  	cmd, err := c.Command(ctx, cloneArgs...)
   455  	if err != nil {
   456  		return "", err
   457  	}
   458  	for _, mod := range mods {
   459  		mod(cmd)
   460  	}
   461  	err = cmd.Run()
   462  	if err != nil {
   463  		return "", err
   464  	}
   465  	return target, nil
   466  }
   467  
   468  func (c *Client) AddRemote(ctx context.Context, name, urlStr string, trackingBranches []string, mods ...CommandModifier) (*Remote, error) {
   469  	args := []string{"remote", "add"}
   470  	for _, branch := range trackingBranches {
   471  		args = append(args, "-t", branch)
   472  	}
   473  	args = append(args, "-f", name, urlStr)
   474  	// TODO: Use AuthenticatedCommand
   475  	cmd, err := c.Command(ctx, args...)
   476  	if err != nil {
   477  		return nil, err
   478  	}
   479  	for _, mod := range mods {
   480  		mod(cmd)
   481  	}
   482  	if _, err := cmd.Output(); err != nil {
   483  		return nil, err
   484  	}
   485  	var urlParsed *url.URL
   486  	if strings.HasPrefix(urlStr, "https") {
   487  		urlParsed, err = url.Parse(urlStr)
   488  		if err != nil {
   489  			return nil, err
   490  		}
   491  	} else {
   492  		urlParsed, err = ParseURL(urlStr)
   493  		if err != nil {
   494  			return nil, err
   495  		}
   496  	}
   497  	remote := &Remote{
   498  		Name:     name,
   499  		FetchURL: urlParsed,
   500  		PushURL:  urlParsed,
   501  	}
   502  	return remote, nil
   503  }
   504  
   505  func resolveGitPath() (string, error) {
   506  	path, err := safeexec.LookPath("git")
   507  	if err != nil {
   508  		if errors.Is(err, exec.ErrNotFound) {
   509  			programName := "git"
   510  			if runtime.GOOS == "windows" {
   511  				programName = "Git for Windows"
   512  			}
   513  			return "", &NotInstalled{
   514  				message: fmt.Sprintf("unable to find git executable in PATH; please install %s before retrying", programName),
   515  				err:     err,
   516  			}
   517  		}
   518  		return "", err
   519  	}
   520  	return path, nil
   521  }
   522  
   523  func isFilesystemPath(p string) bool {
   524  	return p == "." || strings.HasPrefix(p, "./") || strings.HasPrefix(p, "/")
   525  }
   526  
   527  func outputLines(output []byte) []string {
   528  	lines := strings.TrimSuffix(string(output), "\n")
   529  	return strings.Split(lines, "\n")
   530  }
   531  
   532  func firstLine(output []byte) string {
   533  	if i := bytes.IndexAny(output, "\n"); i >= 0 {
   534  		return string(output)[0:i]
   535  	}
   536  	return string(output)
   537  }
   538  
   539  func parseCloneArgs(extraArgs []string) (args []string, target string) {
   540  	args = extraArgs
   541  	if len(args) > 0 {
   542  		if !strings.HasPrefix(args[0], "-") {
   543  			target, args = args[0], args[1:]
   544  		}
   545  	}
   546  	return
   547  }
   548  
   549  func parseRemotes(remotesStr []string) RemoteSet {
   550  	remotes := RemoteSet{}
   551  	for _, r := range remotesStr {
   552  		match := remoteRE.FindStringSubmatch(r)
   553  		if match == nil {
   554  			continue
   555  		}
   556  		name := strings.TrimSpace(match[1])
   557  		urlStr := strings.TrimSpace(match[2])
   558  		urlType := strings.TrimSpace(match[3])
   559  
   560  		url, err := ParseURL(urlStr)
   561  		if err != nil {
   562  			continue
   563  		}
   564  
   565  		var rem *Remote
   566  		if len(remotes) > 0 {
   567  			rem = remotes[len(remotes)-1]
   568  			if name != rem.Name {
   569  				rem = nil
   570  			}
   571  		}
   572  		if rem == nil {
   573  			rem = &Remote{Name: name}
   574  			remotes = append(remotes, rem)
   575  		}
   576  
   577  		switch urlType {
   578  		case "fetch":
   579  			rem.FetchURL = url
   580  		case "push":
   581  			rem.PushURL = url
   582  		}
   583  	}
   584  	return remotes
   585  }
   586  
   587  func populateResolvedRemotes(remotes RemoteSet, resolved []string) {
   588  	for _, l := range resolved {
   589  		parts := strings.SplitN(l, " ", 2)
   590  		if len(parts) < 2 {
   591  			continue
   592  		}
   593  		rp := strings.SplitN(parts[0], ".", 3)
   594  		if len(rp) < 2 {
   595  			continue
   596  		}
   597  		name := rp[1]
   598  		for _, r := range remotes {
   599  			if r.Name == name {
   600  				r.Resolved = parts[1]
   601  				break
   602  			}
   603  		}
   604  	}
   605  }