mirror of https://gitlab.com/gitlab-org/cli.git
596 lines
14 KiB
Go
596 lines
14 KiB
Go
package git
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"gitlab.com/gitlab-org/cli/internal/run"
|
|
)
|
|
|
|
func GetRemoteURL(remoteAlias string) (string, error) {
|
|
return Config("remote." + remoteAlias + ".url")
|
|
}
|
|
|
|
// GetDefaultBranch finds and returns the remote's default branch
|
|
func GetDefaultBranch(remote string) (string, error) {
|
|
getDefBranch := exec.Command("git", "remote", "show", remote)
|
|
|
|
// Ensure output from git is in English
|
|
getDefBranch.Env = os.Environ()
|
|
getDefBranch.Env = append(getDefBranch.Env, "LC_ALL=C")
|
|
|
|
output, err := run.PrepareCmd(getDefBranch).Output()
|
|
if err != nil {
|
|
return "master", err
|
|
}
|
|
|
|
var headBranch string
|
|
|
|
for _, o := range strings.Split(string(output), "\n") {
|
|
o = strings.TrimSpace(o)
|
|
r, err := regexp.Compile(`(HEAD branch:)\s+`)
|
|
if err != nil {
|
|
return "master", err
|
|
}
|
|
if r.MatchString(o) {
|
|
headBranch = strings.TrimPrefix(o, "HEAD branch: ")
|
|
break
|
|
}
|
|
}
|
|
return headBranch, err
|
|
}
|
|
|
|
// ErrNotOnAnyBranch indicates that the users 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
|
|
}
|
|
|
|
// 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 := exec.Command("git", args...)
|
|
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
|
|
}
|
|
|
|
// CurrentBranch reads the checked-out branch for the git repository
|
|
func CurrentBranch() (string, error) {
|
|
refCmd := GitCommand("symbolic-ref", "--quiet", "--short", "HEAD")
|
|
|
|
output, err := run.PrepareCmd(refCmd).Output()
|
|
if err == nil {
|
|
// Found the branch name
|
|
return firstLine(output), nil
|
|
}
|
|
|
|
var cmdErr *run.CmdError
|
|
if errors.As(err, &cmdErr) {
|
|
if cmdErr.Stderr.Len() == 0 {
|
|
// Detached head
|
|
return "", ErrNotOnAnyBranch
|
|
}
|
|
}
|
|
|
|
// Unknown error
|
|
return "", err
|
|
}
|
|
|
|
func listRemotes() ([]string, error) {
|
|
remoteCmd := exec.Command("git", "remote", "-v")
|
|
output, err := run.PrepareCmd(remoteCmd).Output()
|
|
return outputLines(output), err
|
|
}
|
|
|
|
func Config(name string) (string, error) {
|
|
configCmd := exec.Command("git", "config", name)
|
|
output, err := run.PrepareCmd(configCmd).Output()
|
|
if err != nil {
|
|
return "", fmt.Errorf("unknown config key: %s", name)
|
|
}
|
|
|
|
return firstLine(output), nil
|
|
}
|
|
|
|
var GitCommand = func(args ...string) *exec.Cmd {
|
|
return exec.Command("git", args...)
|
|
}
|
|
|
|
func UncommittedChangeCount() (int, error) {
|
|
statusCmd := GitCommand("status", "--porcelain")
|
|
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
|
|
}
|
|
|
|
func LatestCommit(ref string) (*Commit, error) {
|
|
logCmd := GitCommand("show", "-s", "--format=%h %s", ref)
|
|
output, err := run.PrepareCmd(logCmd).Output()
|
|
if err != nil {
|
|
return &Commit{}, err
|
|
}
|
|
commit := &Commit{}
|
|
split := strings.SplitN(string(output), " ", 2)
|
|
if len(split) != 2 {
|
|
return commit, fmt.Errorf("could not find commit for %s", ref)
|
|
}
|
|
commit = &Commit{
|
|
Sha: split[0],
|
|
Title: split[1],
|
|
}
|
|
return commit, nil
|
|
}
|
|
|
|
func Commits(baseRef, headRef string) ([]*Commit, error) {
|
|
logCmd := GitCommand(
|
|
"-c", "log.ShowSignature=false",
|
|
"log", "--pretty=format:%H,%s",
|
|
"--cherry", fmt.Sprintf("%s...%s", baseRef, headRef))
|
|
output, err := run.PrepareCmd(logCmd).Output()
|
|
if err != nil {
|
|
return []*Commit{}, err
|
|
}
|
|
|
|
var 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 CommitBody(sha string) (string, error) {
|
|
showCmd := GitCommand("-c", "log.ShowSignature=false", "show", "-s", "--pretty=format:%b", sha)
|
|
output, err := run.PrepareCmd(showCmd).Output()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(output), nil
|
|
}
|
|
|
|
// Push publishes a git ref to a remote
|
|
func Push(remote string, ref string, cmdOut, cmdErr io.Writer) error {
|
|
pushCmd := GitCommand("push", remote, ref)
|
|
pushCmd.Stdout = cmdOut
|
|
pushCmd.Stderr = cmdErr
|
|
return run.PrepareCmd(pushCmd).Run()
|
|
}
|
|
|
|
// SetUpstream sets the upstream (tracking) of a branch
|
|
func SetUpstream(remote string, branch string, cmdOut, cmdErr io.Writer) error {
|
|
setCmd := GitCommand("branch", "--set-upstream-to", fmt.Sprintf("%s/%s", remote, branch))
|
|
setCmd.Stdout = cmdOut
|
|
setCmd.Stderr = cmdErr
|
|
return run.PrepareCmd(setCmd).Run()
|
|
}
|
|
|
|
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 := GitCommand("config", "--get-regexp", fmt.Sprintf("^%s(remote|merge)$", prefix))
|
|
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
|
|
}
|
|
|
|
func DeleteLocalBranch(branch string) error {
|
|
branchCmd := GitCommand("branch", "-D", branch)
|
|
err := run.PrepareCmd(branchCmd).Run()
|
|
return err
|
|
}
|
|
|
|
func HasLocalBranch(branch string) bool {
|
|
configCmd := GitCommand("rev-parse", "--verify", "refs/heads/"+branch)
|
|
_, err := run.PrepareCmd(configCmd).Output()
|
|
return err == nil
|
|
}
|
|
|
|
func CheckoutBranch(branch string) error {
|
|
configCmd := GitCommand("checkout", branch)
|
|
err := run.PrepareCmd(configCmd).Run()
|
|
return err
|
|
}
|
|
|
|
func parseCloneArgs(extraArgs []string) (args []string, target string) {
|
|
args, target = parseArgs(extraArgs)
|
|
return
|
|
}
|
|
|
|
func parseArgs(cmdWithArgs []string) (args []string, command string) {
|
|
args = cmdWithArgs
|
|
|
|
if len(args) > 0 {
|
|
if !strings.HasPrefix(args[0], "-") {
|
|
command, args = args[0], args[1:]
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
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"))
|
|
}
|
|
|
|
cloneArgs = append([]string{"clone"}, cloneArgs...)
|
|
|
|
cloneCmd := GitCommand(cloneArgs...)
|
|
cloneCmd.Stdin = os.Stdin
|
|
cloneCmd.Stdout = os.Stdout
|
|
cloneCmd.Stderr = os.Stderr
|
|
|
|
err = run.PrepareCmd(cloneCmd).Run()
|
|
return
|
|
}
|
|
|
|
func AddUpstreamRemote(upstreamURL, cloneDir string) error {
|
|
cloneCmd := GitCommand("-C", cloneDir, "remote", "add", "-f", "upstream", upstreamURL)
|
|
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
|
|
var ToplevelDir = func() (string, error) {
|
|
showCmd := exec.Command("git", "rev-parse", "--show-toplevel")
|
|
output, err := run.PrepareCmd(showCmd).Output()
|
|
return firstLine(output), err
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Remotes gets the git remotes set for the current repo
|
|
func Remotes() (RemoteSet, error) {
|
|
list, err := listRemotes()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
remotes := parseRemotes(list)
|
|
|
|
// this is affected by SetRemoteResolution
|
|
remoteCmd := exec.Command("git", "config", "--get-regexp", `^remote\..*\.glab-resolved$`)
|
|
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 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 := exec.Command("git", "remote", "add", "-f", name, u)
|
|
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
|
|
}
|
|
|
|
var SetRemoteResolution = func(name, resolution string) error {
|
|
return SetRemoteConfig(name, "glab-resolved", resolution)
|
|
}
|
|
|
|
func SetRemoteConfig(remote, key, value string) error {
|
|
return SetConfig(fmt.Sprintf("remote.%s.%s", remote, key), value)
|
|
}
|
|
|
|
func SetConfig(key, value string) error {
|
|
found, err := configValueExists(key, value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if found {
|
|
return nil
|
|
}
|
|
addCmd := GitCommand("config", "--add", key, value)
|
|
_, err = run.PrepareCmd(addCmd).Output()
|
|
if err != nil {
|
|
return fmt.Errorf("setting git config: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func configValueExists(key, value string) (bool, error) {
|
|
output, err := GetAllConfig(key)
|
|
if err == nil {
|
|
return outputContainsLine(output, value), nil
|
|
}
|
|
return false, err
|
|
}
|
|
|
|
// GetConfig returns the local config value associated with the provided key.
|
|
// If there are multiple values associated with the key, they are all returned.
|
|
func GetAllConfig(key string) ([]byte, error) {
|
|
err := assertValidConfigKey(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
gitCmd := GitCommand("config", "--get-all", key)
|
|
output, err := run.PrepareCmd(gitCmd).Output()
|
|
if err == nil {
|
|
return output, nil
|
|
}
|
|
|
|
// git-config will exit with 1 in almost all cases, but only when it prints
|
|
// out things it is an actual error that is worth mentioning.
|
|
// Therefore ignore errors that don't output to stderr.
|
|
var cmdErr *run.CmdError
|
|
if errors.As(err, &cmdErr) && cmdErr.Stderr.Len() == 0 {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("getting git config value cmd: %s: %w", gitCmd.String(), err)
|
|
}
|
|
|
|
func assertValidConfigKey(key string) error {
|
|
s := strings.Split(key, ".")
|
|
if len(s) < 2 {
|
|
return fmt.Errorf("incorrect git config key")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// outputContainsLine searches through each line in the command output
|
|
// and returns true if one matches the needle a.k.a. the search string.
|
|
func outputContainsLine(output []byte, needle string) bool {
|
|
for _, line := range outputLines(output) {
|
|
if line == needle {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func RunCmd(args []string) (err error) {
|
|
gitCmd := GitCommand(args...)
|
|
gitCmd.Stdout = os.Stdout
|
|
gitCmd.Stderr = os.Stderr
|
|
|
|
err = run.PrepareCmd(gitCmd).Run()
|
|
return
|
|
}
|
|
|
|
// DescribeByTags gives a description of the current object.
|
|
// Non-annotated tags are considered.
|
|
// Reference: https://git-scm.com/docs/git-describe
|
|
func DescribeByTags() (string, error) {
|
|
gitCmd := GitCommand("describe", "--tags")
|
|
|
|
output, err := run.PrepareCmd(gitCmd).Output()
|
|
if err != nil {
|
|
return "", fmt.Errorf("running cmd: %s out: %s: %w", gitCmd.String(), output, err)
|
|
}
|
|
|
|
return string(output), nil
|
|
}
|
|
|
|
// ListTags gives a slice of tags from the current repository.
|
|
func ListTags() ([]string, error) {
|
|
gitCmd := GitCommand("tag", "-l")
|
|
|
|
output, err := run.PrepareCmd(gitCmd).Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("running cmd: %s out: %s: %w", gitCmd.String(), output, err)
|
|
}
|
|
|
|
tagsStr := string(output)
|
|
if tagsStr == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
return strings.Fields(tagsStr), nil
|
|
}
|