github.com/abdfnx/gh-api@v0.0.0-20210414084727-f5432eec23b8/git/git.go (about)

     1  package git
     2  
     3  import (
     4  	"bytes"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"net/url"
     9  	"os"
    10  	"os/exec"
    11  	"path"
    12  	"regexp"
    13  	"runtime"
    14  	"strings"
    15  
    16  	"github.com/abdfnx/gh-api/internal/run"
    17  	"github.com/cli/safeexec"
    18  )
    19  
    20  // ErrNotOnAnyBranch indicates that the user is in detached HEAD state
    21  var ErrNotOnAnyBranch = errors.New("git: not on any branch")
    22  
    23  // Ref represents a git commit reference
    24  type Ref struct {
    25  	Hash string
    26  	Name string
    27  }
    28  
    29  // TrackingRef represents a ref for a remote tracking branch
    30  type TrackingRef struct {
    31  	RemoteName string
    32  	BranchName string
    33  }
    34  
    35  func (r TrackingRef) String() string {
    36  	return "refs/remotes/" + r.RemoteName + "/" + r.BranchName
    37  }
    38  
    39  // ShowRefs resolves fully-qualified refs to commit hashes
    40  func ShowRefs(ref ...string) ([]Ref, error) {
    41  	args := append([]string{"show-ref", "--verify", "--"}, ref...)
    42  	showRef, err := GitCommand(args...)
    43  	if err != nil {
    44  		return nil, err
    45  	}
    46  	output, err := run.PrepareCmd(showRef).Output()
    47  
    48  	var refs []Ref
    49  	for _, line := range outputLines(output) {
    50  		parts := strings.SplitN(line, " ", 2)
    51  		if len(parts) < 2 {
    52  			continue
    53  		}
    54  		refs = append(refs, Ref{
    55  			Hash: parts[0],
    56  			Name: parts[1],
    57  		})
    58  	}
    59  
    60  	return refs, err
    61  }
    62  
    63  // CurrentBranch reads the checked-out branch for the git repository
    64  func CurrentBranch() (string, error) {
    65  	refCmd, err := GitCommand("symbolic-ref", "--quiet", "HEAD")
    66  	if err != nil {
    67  		return "", err
    68  	}
    69  
    70  	stderr := bytes.Buffer{}
    71  	refCmd.Stderr = &stderr
    72  
    73  	output, err := run.PrepareCmd(refCmd).Output()
    74  	if err == nil {
    75  		// Found the branch name
    76  		return getBranchShortName(output), nil
    77  	}
    78  
    79  	if stderr.Len() == 0 {
    80  		// Detached head
    81  		return "", ErrNotOnAnyBranch
    82  	}
    83  
    84  	return "", fmt.Errorf("%sgit: %s", stderr.String(), err)
    85  }
    86  
    87  func listRemotes() ([]string, error) {
    88  	remoteCmd, err := GitCommand("remote", "-v")
    89  	if err != nil {
    90  		return nil, err
    91  	}
    92  	output, err := run.PrepareCmd(remoteCmd).Output()
    93  	return outputLines(output), err
    94  }
    95  
    96  func Config(name string) (string, error) {
    97  	configCmd, err := GitCommand("config", name)
    98  	if err != nil {
    99  		return "", err
   100  	}
   101  	output, err := run.PrepareCmd(configCmd).Output()
   102  	if err != nil {
   103  		return "", fmt.Errorf("unknown config key: %s", name)
   104  	}
   105  
   106  	return firstLine(output), nil
   107  
   108  }
   109  
   110  var GitCommand = func(args ...string) (*exec.Cmd, error) {
   111  	gitExe, err := safeexec.LookPath("git")
   112  	if err != nil {
   113  		programName := "git"
   114  		if runtime.GOOS == "windows" {
   115  			programName = "Git for Windows"
   116  		}
   117  		return nil, fmt.Errorf("unable to find git executable in PATH; please install %s before retrying", programName)
   118  	}
   119  	return exec.Command(gitExe, args...), nil
   120  }
   121  
   122  func UncommittedChangeCount() (int, error) {
   123  	statusCmd, err := GitCommand("status", "--porcelain")
   124  	if err != nil {
   125  		return 0, err
   126  	}
   127  	output, err := run.PrepareCmd(statusCmd).Output()
   128  	if err != nil {
   129  		return 0, err
   130  	}
   131  	lines := strings.Split(string(output), "\n")
   132  
   133  	count := 0
   134  
   135  	for _, l := range lines {
   136  		if l != "" {
   137  			count++
   138  		}
   139  	}
   140  
   141  	return count, nil
   142  }
   143  
   144  type Commit struct {
   145  	Sha   string
   146  	Title string
   147  }
   148  
   149  func Commits(baseRef, headRef string) ([]*Commit, error) {
   150  	logCmd, err := GitCommand(
   151  		"-c", "log.ShowSignature=false",
   152  		"log", "--pretty=format:%H,%s",
   153  		"--cherry", fmt.Sprintf("%s...%s", baseRef, headRef))
   154  	if err != nil {
   155  		return nil, err
   156  	}
   157  	output, err := run.PrepareCmd(logCmd).Output()
   158  	if err != nil {
   159  		return []*Commit{}, err
   160  	}
   161  
   162  	commits := []*Commit{}
   163  	sha := 0
   164  	title := 1
   165  	for _, line := range outputLines(output) {
   166  		split := strings.SplitN(line, ",", 2)
   167  		if len(split) != 2 {
   168  			continue
   169  		}
   170  		commits = append(commits, &Commit{
   171  			Sha:   split[sha],
   172  			Title: split[title],
   173  		})
   174  	}
   175  
   176  	if len(commits) == 0 {
   177  		return commits, fmt.Errorf("could not find any commits between %s and %s", baseRef, headRef)
   178  	}
   179  
   180  	return commits, nil
   181  }
   182  
   183  func lookupCommit(sha, format string) ([]byte, error) {
   184  	logCmd, err := GitCommand("-c", "log.ShowSignature=false", "show", "-s", "--pretty=format:"+format, sha)
   185  	if err != nil {
   186  		return nil, err
   187  	}
   188  	return run.PrepareCmd(logCmd).Output()
   189  }
   190  
   191  func LastCommit() (*Commit, error) {
   192  	output, err := lookupCommit("HEAD", "%H,%s")
   193  	if err != nil {
   194  		return nil, err
   195  	}
   196  
   197  	idx := bytes.IndexByte(output, ',')
   198  	return &Commit{
   199  		Sha:   string(output[0:idx]),
   200  		Title: strings.TrimSpace(string(output[idx+1:])),
   201  	}, nil
   202  }
   203  
   204  func CommitBody(sha string) (string, error) {
   205  	output, err := lookupCommit(sha, "%b")
   206  	return string(output), err
   207  }
   208  
   209  // Push publishes a git ref to a remote and sets up upstream configuration
   210  func Push(remote string, ref string, cmdOut, cmdErr io.Writer) error {
   211  	pushCmd, err := GitCommand("push", "--set-upstream", remote, ref)
   212  	if err != nil {
   213  		return err
   214  	}
   215  	pushCmd.Stdout = cmdOut
   216  	pushCmd.Stderr = cmdErr
   217  	return run.PrepareCmd(pushCmd).Run()
   218  }
   219  
   220  type BranchConfig struct {
   221  	RemoteName string
   222  	RemoteURL  *url.URL
   223  	MergeRef   string
   224  }
   225  
   226  // ReadBranchConfig parses the `branch.BRANCH.(remote|merge)` part of git config
   227  func ReadBranchConfig(branch string) (cfg BranchConfig) {
   228  	prefix := regexp.QuoteMeta(fmt.Sprintf("branch.%s.", branch))
   229  	configCmd, err := GitCommand("config", "--get-regexp", fmt.Sprintf("^%s(remote|merge)$", prefix))
   230  	if err != nil {
   231  		return
   232  	}
   233  	output, err := run.PrepareCmd(configCmd).Output()
   234  	if err != nil {
   235  		return
   236  	}
   237  	for _, line := range outputLines(output) {
   238  		parts := strings.SplitN(line, " ", 2)
   239  		if len(parts) < 2 {
   240  			continue
   241  		}
   242  		keys := strings.Split(parts[0], ".")
   243  		switch keys[len(keys)-1] {
   244  		case "remote":
   245  			if strings.Contains(parts[1], ":") {
   246  				u, err := ParseURL(parts[1])
   247  				if err != nil {
   248  					continue
   249  				}
   250  				cfg.RemoteURL = u
   251  			} else if !isFilesystemPath(parts[1]) {
   252  				cfg.RemoteName = parts[1]
   253  			}
   254  		case "merge":
   255  			cfg.MergeRef = parts[1]
   256  		}
   257  	}
   258  	return
   259  }
   260  
   261  func DeleteLocalBranch(branch string) error {
   262  	branchCmd, err := GitCommand("branch", "-D", branch)
   263  	if err != nil {
   264  		return err
   265  	}
   266  	return run.PrepareCmd(branchCmd).Run()
   267  }
   268  
   269  func HasLocalBranch(branch string) bool {
   270  	configCmd, err := GitCommand("rev-parse", "--verify", "refs/heads/"+branch)
   271  	if err != nil {
   272  		return false
   273  	}
   274  	_, err = run.PrepareCmd(configCmd).Output()
   275  	return err == nil
   276  }
   277  
   278  func CheckoutBranch(branch string) error {
   279  	configCmd, err := GitCommand("checkout", branch)
   280  	if err != nil {
   281  		return err
   282  	}
   283  	return run.PrepareCmd(configCmd).Run()
   284  }
   285  
   286  func parseCloneArgs(extraArgs []string) (args []string, target string) {
   287  	args = extraArgs
   288  
   289  	if len(args) > 0 {
   290  		if !strings.HasPrefix(args[0], "-") {
   291  			target, args = args[0], args[1:]
   292  		}
   293  	}
   294  	return
   295  }
   296  
   297  func RunClone(cloneURL string, args []string) (target string, err error) {
   298  	cloneArgs, target := parseCloneArgs(args)
   299  
   300  	cloneArgs = append(cloneArgs, cloneURL)
   301  
   302  	// If the args contain an explicit target, pass it to clone
   303  	//    otherwise, parse the URL to determine where git cloned it to so we can return it
   304  	if target != "" {
   305  		cloneArgs = append(cloneArgs, target)
   306  	} else {
   307  		target = path.Base(strings.TrimSuffix(cloneURL, ".git"))
   308  	}
   309  
   310  	cloneArgs = append([]string{"clone"}, cloneArgs...)
   311  
   312  	cloneCmd, err := GitCommand(cloneArgs...)
   313  	if err != nil {
   314  		return "", err
   315  	}
   316  	cloneCmd.Stdin = os.Stdin
   317  	cloneCmd.Stdout = os.Stdout
   318  	cloneCmd.Stderr = os.Stderr
   319  
   320  	err = run.PrepareCmd(cloneCmd).Run()
   321  	return
   322  }
   323  
   324  func AddUpstreamRemote(upstreamURL, cloneDir string, branches []string) error {
   325  	args := []string{"-C", cloneDir, "remote", "add"}
   326  	for _, branch := range branches {
   327  		args = append(args, "-t", branch)
   328  	}
   329  	args = append(args, "-f", "upstream", upstreamURL)
   330  	cloneCmd, err := GitCommand(args...)
   331  	if err != nil {
   332  		return err
   333  	}
   334  	cloneCmd.Stdout = os.Stdout
   335  	cloneCmd.Stderr = os.Stderr
   336  	return run.PrepareCmd(cloneCmd).Run()
   337  }
   338  
   339  func isFilesystemPath(p string) bool {
   340  	return p == "." || strings.HasPrefix(p, "./") || strings.HasPrefix(p, "/")
   341  }
   342  
   343  // ToplevelDir returns the top-level directory path of the current repository
   344  func ToplevelDir() (string, error) {
   345  	showCmd, err := GitCommand("rev-parse", "--show-toplevel")
   346  	if err != nil {
   347  		return "", err
   348  	}
   349  	output, err := run.PrepareCmd(showCmd).Output()
   350  	return firstLine(output), err
   351  
   352  }
   353  
   354  func outputLines(output []byte) []string {
   355  	lines := strings.TrimSuffix(string(output), "\n")
   356  	return strings.Split(lines, "\n")
   357  
   358  }
   359  
   360  func firstLine(output []byte) string {
   361  	if i := bytes.IndexAny(output, "\n"); i >= 0 {
   362  		return string(output)[0:i]
   363  	}
   364  	return string(output)
   365  }
   366  
   367  func getBranchShortName(output []byte) string {
   368  	branch := firstLine(output)
   369  	return strings.TrimPrefix(branch, "refs/heads/")
   370  }