github.com/zaquestion/lab@v0.25.1/internal/git/git.go (about)

     1  package git
     2  
     3  import (
     4  	"bytes"
     5  	"fmt"
     6  	"os"
     7  	"os/exec"
     8  	"path/filepath"
     9  	"regexp"
    10  	"strings"
    11  	"time"
    12  
    13  	retry "github.com/avast/retry-go"
    14  	"github.com/pkg/errors"
    15  	gitconfig "github.com/tcnksm/go-gitconfig"
    16  	giturls "github.com/whilp/git-urls"
    17  	"github.com/zaquestion/lab/internal/logger"
    18  )
    19  
    20  // Get internal lab logger instance
    21  var log = logger.GetInstance()
    22  
    23  // New looks up the git binary and returns a cmd which outputs to stdout
    24  func New(args ...string) *exec.Cmd {
    25  	gitPath, err := exec.LookPath("git")
    26  	if err != nil {
    27  		log.Fatal(err)
    28  	}
    29  
    30  	cmd := exec.Command(gitPath, args...)
    31  	cmd.Stdin = os.Stdin
    32  	cmd.Stdout = os.Stdout
    33  	cmd.Stderr = os.Stderr
    34  	return cmd
    35  }
    36  
    37  // Dir returns the full path to the .git directory
    38  func Dir() (string, error) {
    39  	cmd := New("rev-parse", "-q", "--git-dir")
    40  	cmd.Stdout = nil
    41  	cmd.Stderr = nil
    42  	d, err := cmd.Output()
    43  	if err != nil {
    44  		return "", err
    45  	}
    46  	dir := string(d)
    47  	dir = strings.TrimSpace(dir)
    48  	if !filepath.IsAbs(dir) {
    49  		dir, err = filepath.Abs(dir)
    50  		if err != nil {
    51  			return "", err
    52  		}
    53  	}
    54  
    55  	return filepath.Clean(dir), nil
    56  }
    57  
    58  // WorkingDir returns the full path to the root of the current git repository
    59  func WorkingDir() (string, error) {
    60  	cmd := New("rev-parse", "--show-toplevel")
    61  	cmd.Stdout = nil
    62  	d, err := cmd.Output()
    63  	if err != nil {
    64  		return "", err
    65  	}
    66  	return strings.TrimSpace(string(d)), nil
    67  }
    68  
    69  // CommentChar returns active comment char and defaults to '#'
    70  func CommentChar() string {
    71  	char, err := gitconfig.Entire("core.commentchar")
    72  	if err == nil {
    73  		return char
    74  	}
    75  	return "#"
    76  }
    77  
    78  // PagerCommand returns the commandline and environment for the pager
    79  func PagerCommand() (string, []string) {
    80  	// Set up environment for common pagers, see the documentation
    81  	// for "core.pager" in git-config(1)
    82  	env := os.Environ()
    83  	if _, ok := os.LookupEnv("LESS"); !ok {
    84  		env = append(env, "LESS=FRX")
    85  	}
    86  	if _, ok := os.LookupEnv("LESSSECURE"); !ok {
    87  		env = append(env, "LESSSECURE=1")
    88  	}
    89  	if _, ok := os.LookupEnv("LV"); !ok {
    90  		env = append(env, "LV=-c")
    91  	}
    92  
    93  	// Find an appropriate pager command, following git's preference
    94  	cmd, ok := os.LookupEnv("GIT_PAGER")
    95  	if ok {
    96  		return cmd, env
    97  	}
    98  	cmd, err := gitconfig.Entire("core.pager")
    99  	if err == nil {
   100  		return cmd, env
   101  	}
   102  	cmd, ok = os.LookupEnv("PAGER")
   103  	if ok {
   104  		return cmd, env
   105  	}
   106  	return "less", env
   107  }
   108  
   109  // LastCommitMessage returns the last commits message as one line
   110  func LastCommitMessage(sha string) (string, error) {
   111  	cmd := New("show", "-s", "--format=%s%n%+b", sha)
   112  	cmd.Stdout = nil
   113  	msg, err := cmd.Output()
   114  	if err != nil {
   115  		return "", err
   116  	}
   117  	return strings.TrimSpace(string(msg)), nil
   118  }
   119  
   120  // Log produces a formatted gitlog between 2 git shas
   121  func Log(sha1, sha2 string) (string, error) {
   122  	cmd := New("-c", "log.showSignature=false",
   123  		"log",
   124  		"--no-color",
   125  		"--format=%h (%aN)%n%w(78,3,3)%s%n",
   126  		"--cherry",
   127  		fmt.Sprintf("%s..%s", sha1, sha2))
   128  	cmd.Stdout = nil
   129  	outputs, err := cmd.Output()
   130  	if err != nil {
   131  		return "", errors.Errorf("Can't load git log %s..%s", sha1, sha2)
   132  	}
   133  
   134  	diffCmd := New("diff", "--stat", fmt.Sprintf("%s...%s", sha1, sha2))
   135  	diffCmd.Stdout = nil
   136  	diffOutput, err := diffCmd.Output()
   137  	if err != nil {
   138  		return "", errors.Errorf("Can't load diffstat")
   139  	}
   140  
   141  	return string(outputs) + string(diffOutput), nil
   142  }
   143  
   144  // CurrentBranch returns the currently checked out branch
   145  func CurrentBranch() (string, error) {
   146  	cmd := New("rev-parse", "--abbrev-ref", "HEAD")
   147  	cmd.Stdout = nil
   148  	branch, err := cmd.Output()
   149  	if err != nil {
   150  		return "", err
   151  	}
   152  	return strings.TrimSpace(string(branch)), nil
   153  }
   154  
   155  // RevParse returns the output of "git rev-parse".
   156  func RevParse(args ...string) (string, error) {
   157  	cmd := New(append([]string{"rev-parse"}, args...)...)
   158  	cmd.Stdout = nil
   159  	d, err := cmd.Output()
   160  	if err != nil {
   161  		return "", err
   162  	}
   163  	return strings.TrimSpace(string(d)), nil
   164  }
   165  
   166  // UpstreamBranch returns the upstream of the specified branch
   167  func UpstreamBranch(branch string) (string, error) {
   168  	upstreamBranch, err := gitconfig.Local("branch." + branch + ".merge")
   169  	if err != nil {
   170  		return "", errors.Errorf("No upstream for branch '%s'", branch)
   171  	}
   172  	return strings.TrimPrefix(upstreamBranch, "refs/heads/"), nil
   173  }
   174  
   175  // PathWithNamespace returns the owner/repository for the current repo
   176  // Such as zaquestion/lab
   177  // Respects GitLab subgroups (https://docs.gitlab.com/ce/user/group/subgroups/)
   178  func PathWithNamespace(remote string) (string, error) {
   179  	remoteURL, err := gitconfig.Local("remote." + remote + ".pushurl")
   180  	if err != nil || remoteURL == "" {
   181  		remoteURL, err = gitconfig.Local("remote." + remote + ".url")
   182  		if err != nil {
   183  			return "", err
   184  		}
   185  		if remoteURL == "" {
   186  			// Branches can track remote based on ther URL, thus we don't
   187  			// really have a remote entity in the git config, but only the
   188  			// URL of the remote.
   189  			// https://git-scm.com/docs/git-push#Documentation/git-push.txt-ltrepositorygt
   190  			remoteURL = remote
   191  		}
   192  	}
   193  
   194  	u, err := giturls.Parse(remoteURL)
   195  	if err != nil {
   196  		return "", err
   197  	}
   198  
   199  	// remote URLs can't refer to other files or local paths, ie., other remote
   200  	// names.
   201  	if u.Scheme == "file" {
   202  		return "", errors.Errorf("invalid remote URL format for %s", remote)
   203  	}
   204  
   205  	path := strings.TrimPrefix(u.Path, "/")
   206  	path = strings.TrimSuffix(path, ".git")
   207  	return path, nil
   208  }
   209  
   210  // RepoName returns the name of the repository, such as "lab"
   211  func RepoName() (string, error) {
   212  	o, err := PathWithNamespace("origin")
   213  	if err != nil {
   214  		return "", err
   215  	}
   216  	parts := strings.Split(o, "/")
   217  	return parts[len(parts)-1:][0], nil
   218  }
   219  
   220  // RemoteAdd both adds a remote and fetches it
   221  func RemoteAdd(name, url, dir string) error {
   222  	cmd := New("remote", "add", name, url)
   223  	cmd.Dir = dir
   224  	if err := cmd.Run(); err != nil {
   225  		return err
   226  	}
   227  	fmt.Println("Updating", name)
   228  
   229  	err := retry.Do(func() error {
   230  		cmd = New("fetch", name)
   231  		cmd.Dir = dir
   232  		return cmd.Run()
   233  	}, retry.Attempts(3), retry.Delay(time.Second))
   234  	if err != nil {
   235  		return err
   236  	}
   237  	fmt.Println("new remote:", name)
   238  	return nil
   239  }
   240  
   241  // Remotes get the list of remotes available in the current repo dir
   242  func Remotes() ([]string, error) {
   243  	cmd := New("remote")
   244  	cmd.Stderr = nil
   245  	cmd.Stdout = nil
   246  	out, err := cmd.Output()
   247  	if err != nil {
   248  		return nil, err
   249  	}
   250  
   251  	names := strings.Split(string(out), "\n")
   252  
   253  	return names, nil
   254  }
   255  
   256  // RemoteBranches get the list of branches the specified remote has
   257  func RemoteBranches(remote string) ([]string, error) {
   258  	cmd := New("for-each-ref", "refs/remotes/")
   259  	cmd.Stderr = nil
   260  	cmd.Stdout = nil
   261  	out, err := cmd.Output()
   262  	if err != nil {
   263  		return nil, err
   264  	}
   265  
   266  	refsData := strings.Split(string(out), "\n")
   267  	re := regexp.MustCompile(`^refs/remotes/[^/]+/`)
   268  
   269  	names := []string{}
   270  	for _, refData := range refsData {
   271  		// refData = <sha> <objtype>\t<refname>
   272  		dataParts := strings.Split(refData, "\t")
   273  		refname := dataParts[len(dataParts)-1]
   274  		if strings.HasPrefix(refname, "refs/remotes/"+remote) {
   275  			names = append(names, re.ReplaceAllString(refname, ""))
   276  		}
   277  	}
   278  
   279  	return names, nil
   280  }
   281  
   282  // IsRemote returns true when passed a valid remote in the git repo
   283  func IsRemote(remote string) (bool, error) {
   284  	cmd := New("remote")
   285  	cmd.Stdout = nil
   286  	cmd.Stderr = nil
   287  	remotes, err := cmd.Output()
   288  	if err != nil {
   289  		return false, err
   290  	}
   291  
   292  	return bytes.Contains(remotes, []byte(remote+"\n")), nil
   293  }
   294  
   295  // InsideGitRepo returns true when the current working directory is inside the
   296  // working tree of a git repo
   297  func InsideGitRepo() bool {
   298  	cmd := New("rev-parse", "--is-inside-work-tree")
   299  	cmd.Stdout = nil
   300  	cmd.Stderr = nil
   301  	out, _ := cmd.CombinedOutput()
   302  	return bytes.Contains(out, []byte("true\n"))
   303  }
   304  
   305  // Fetch a commit from a given remote
   306  func Fetch(remote, commit string) error {
   307  	gitcmd := []string{"fetch", remote, commit}
   308  	cmd := New(gitcmd...)
   309  	cmd.Stdout = nil
   310  	cmd.Stderr = nil
   311  	err := cmd.Run()
   312  	if err != nil {
   313  		return errors.Errorf("Can't fetch git commit %s from remote %s", commit, remote)
   314  	}
   315  	return nil
   316  }
   317  
   318  // Show all the commits between 2 git commits
   319  func Show(commit1, commit2 string, reverse bool) {
   320  	gitcmd := []string{"show"}
   321  	if reverse {
   322  		gitcmd = append(gitcmd, "--reverse")
   323  	}
   324  	gitcmd = append(gitcmd, fmt.Sprintf("%s..%s", commit1, commit2))
   325  	New(gitcmd...).Run()
   326  }
   327  
   328  // GetLocalRemotes returns a string of local remote names and URLs
   329  func GetLocalRemotes() (string, error) {
   330  	cmd := New("remote", "-v")
   331  	cmd.Stdout = nil
   332  	remotes, err := cmd.Output()
   333  	if err != nil {
   334  		return "", err
   335  	}
   336  
   337  	return string(remotes), nil
   338  }
   339  
   340  // GetLocalRemotesFromFile returns a string of local remote names and URLs based
   341  // on their placement within .git/config file, which holds a different ordering
   342  // compared to the alternatives presented by Remotes() and GetLocalRemotes().
   343  func GetLocalRemotesFromFile() (string, error) {
   344  	cmd := New("config", "--local", "--name-only", "--get-regex", "^remote.*")
   345  	cmd.Stdout = nil
   346  	remotes, err := cmd.Output()
   347  	if err != nil {
   348  		return "", err
   349  	}
   350  
   351  	return string(remotes), nil
   352  }
   353  
   354  // NumberCommits returns the number of commits between two commit refs
   355  func NumberCommits(sha1, sha2 string) int {
   356  	cmd := New("log", "--oneline", fmt.Sprintf("%s..%s", sha1, sha2))
   357  	cmd.Stdout = nil
   358  	cmd.Stderr = nil
   359  	CmdOut, err := cmd.Output()
   360  	if err != nil {
   361  		// silently fail and handle the return of 0 at caller
   362  		return 0
   363  	}
   364  	numLines := strings.Count(string(CmdOut), "\n")
   365  	return numLines
   366  }