blob: e950fdfdc826374099adfe12bae8432ab0b8927e [file] [log] [blame] [edit]
package git
import (
"context"
"fmt"
"io/fs"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"go.skia.org/infra/go/exec"
"go.skia.org/infra/go/git/git_common"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/go/vfs"
)
const (
// MasterBranch is the name of the default branch for most repositories.
MasterBranch = git_common.MasterBranch
// MainBranch is the name of the default branch for some
// repositories which don't use MasterBranch.
// TODO(rmistry): Delete this after http://skbug.com/11842 is resolved.
MainBranch = git_common.MainBranch
// DefaultRef is the fully-qualified ref name of the default branch for most
// repositories.
DefaultRef = git_common.DefaultRef
// DefaultRemote is the name of the default remote repository.
DefaultRemote = git_common.DefaultRemote
// DefaultRemoteBranch is the name of the default branch in the default
// remote repository, for most repos.
DefaultRemoteBranch = git_common.DefaultRemoteBranch
)
var (
commitHashRegex = regexp.MustCompile(`^[a-f0-9]+$`)
// This regex is taken from:
// https://source.chromium.org/chromium/infra/infra/+/master:go/src/go.chromium.org/luci/common/git/footer/footer.go?q=%22%5E%5Cs*(%5B%5Cw-%5D%2B):%20*(.*)$%22&ss=chromium
trailerRegex = regexp.MustCompile(`^\s*([\w-]+): *(.*)$`)
)
// Types of git objects.
const (
ObjectTypeBlob ObjectType = "blob"
ObjectTypeCommit ObjectType = "commit"
ObjectTypeTree ObjectType = "tree"
)
// ObjectType represents a Git object type.
type ObjectType string
// Clone runs "git clone" into the given destination directory. Most callers
// should use NewRepo or NewCheckout instead.
func Clone(ctx context.Context, repoUrl, dest string, mirror bool) error {
git, err := Executable(ctx)
if err != nil {
return skerr.Wrap(err)
}
if mirror {
// We don't use a "real" mirror, since that syncs ALL refs,
// including every patchset of every CL that gets uploaded. Instead,
// we use a bare clone and then add the "mirror" config after
// cloning. It would be equivalent to use --mirror and then update
// the refspec to only sync the branches, but that would force the
// initial clone step to sync every ref.
if _, err := exec.RunCwd(ctx, ".", git, "clone", "--bare", repoUrl, dest); err != nil {
return skerr.Fmt("failed to clone repo: %s", err)
}
if _, err := exec.RunCwd(ctx, dest, git, "--git-dir=.", "config", "remote.origin.mirror", "true"); err != nil {
return skerr.Fmt("failed to set git mirror config: %s", err)
}
if _, err := exec.RunCwd(ctx, dest, git, "--git-dir=.", "config", "remote.origin.fetch", "refs/heads/*:refs/heads/*"); err != nil {
return skerr.Fmt("failed to set git mirror config: %s", err)
}
if _, err := exec.RunCwd(ctx, dest, git, "--git-dir=.", "fetch", "--force", "--all"); err != nil {
return skerr.Fmt("failed to set git mirror config: %s", err)
}
} else {
if _, err := exec.RunCwd(ctx, ".", git, "clone", repoUrl, dest); err != nil {
return skerr.Fmt("failed to clone repo: %s", err)
}
}
return nil
}
// LogFromTo returns a string which is used to log from one commit to another.
// It is important to note that:
// - The results may include the second commit but will not include the first.
// - The results include all commits reachable from the first commit which are
// not reachable from the second, ie. if there is a merge in the given
// range, the results will include that line of history and not just the
// commits which are descendants of the first commit. If you want only commits
// which are ancestors of the second commit AND descendants of the first, you
// should use LogLinear, but note that the results will be empty if the first
// commit is not an ancestor of the second, ie. they're on different branches.
func LogFromTo(from, to string) string {
return fmt.Sprintf("%s..%s", from, to)
}
// NormalizeURL strips everything from the URL except for the host and the path.
// A trailing ".git" is also stripped. The purpose is to allow for small
// variations in repo URL to be recognized as the same repo. The URL needs to
// contain a valid transport protocol, e.g. https, ssh.
// These URLs will all return 'github.com/skia-dev/textfiles':
//
// "https://github.com/skia-dev/textfiles.git"
// "ssh://git@github.com/skia-dev/textfiles"
// "ssh://git@github.com:skia-dev/textfiles.git"
func NormalizeURL(inputURL string) (string, error) {
// If the scheme is ssh we have to account for the scp-like syntax with a ':'
const ssh = "ssh://"
if strings.HasPrefix(inputURL, ssh) {
inputURL = ssh + strings.Replace(inputURL[len(ssh):], ":", "/", 1)
}
parsedURL, err := url.Parse(inputURL)
if err != nil {
return "", skerr.Wrapf(err, "parsing inputURL")
}
host := parsedURL.Host
// Trim trailing slashes and the ".git" extension.
path := strings.TrimRight(strings.TrimSuffix(parsedURL.Path, ".git"), "/")
path = "/" + strings.TrimLeft(path, "/:")
return host + path, nil
}
// DeleteLockFiles finds and deletes Git lock files within the given workdir.
func DeleteLockFiles(ctx context.Context, workdir string) error {
sklog.Infof("Looking for git lockfiles in %s", workdir)
knownLockFiles := []string{"index.lock", "HEAD.lock", "commit-graph.lock"}
foundLockFiles := []string{}
for _, lockFile := range knownLockFiles {
output, err := exec.RunCwd(ctx, workdir, "find", ".", "-name", lockFile)
if err != nil {
return err
}
output = strings.TrimSpace(output)
if output != "" {
foundLockFiles = append(foundLockFiles, strings.Split(output, "\n")...)
}
}
if len(foundLockFiles) > 0 {
for _, f := range foundLockFiles {
fp := filepath.Join(workdir, f)
sklog.Warningf("Removing git lockfile: %s", fp)
if err := os.Remove(fp); err != nil {
return err
}
}
} else {
sklog.Info("No lockfiles found.")
}
return nil
}
// ParseDir parses the contents of a directory. Expects the contents to be in
// the format used by git, ie. lines taking the form:
//
// mode tree|blob hash name
func ParseDir(contents []byte) ([]fs.FileInfo, error) {
rv := []fs.FileInfo{}
for _, line := range strings.Split(strings.TrimSpace(string(contents)), "\n") {
if line == "" {
continue
}
// Lines are formatted as follows:
// mode tree|blob hash name
fields := strings.Fields(line)
if len(fields) != 4 {
return nil, skerr.Fmt("Expected format \"mode tree|blob hash name\" but got:\n %s", contents)
}
// We can't know the size of the directory contents with the information
// we're given.
size := 0
fi, err := MakeFileInfo(fields[3], fields[0], ObjectType(fields[1]), size)
if err != nil {
return nil, skerr.Wrap(err)
}
rv = append(rv, fi)
}
return rv, nil
}
// MakeFileInfo returns an fs.FileInfo with the given information.
func MakeFileInfo(name, mode string, typ ObjectType, size int) (fs.FileInfo, error) {
modeInt, err := strconv.ParseUint(mode, 8, 32)
if err != nil {
return nil, skerr.Wrapf(err, "invalid file mode %q", mode)
}
fileMode := os.FileMode(modeInt)
isDir := false
if typ == ObjectTypeTree {
isDir = true
fileMode = fileMode | os.ModeDir
}
if typ != ObjectTypeTree && typ != ObjectTypeBlob && typ != ObjectTypeCommit {
return nil, skerr.Fmt("Invalid file type %q", typ)
}
return vfs.FileInfo{
Name: path.Base(name),
Size: int64(size),
Mode: fileMode,
// Gitiles doesn't give us the modification timestamp. We could make
// one up using the timestamp of the commit which last touched the
// file, but that would require an extra request.
ModTime: time.Time{},
IsDir: isDir,
Sys: nil,
}.Get(), nil
}
// SplitTrailers splits a commit message into a main commit message body and
// trailers footer. Assumes that the commit message is already well-formed with
// respect to trailers, ie. there is an empty line between the last body
// paragraph and the single trailers paragraph, which contains only lines in
// "key: value" format.
func SplitTrailers(commitMsg string) ([]string, []string) {
// Split the commit message into paragraphs.
if commitMsg == "" {
return []string{}, []string{}
}
lines := strings.Split(strings.TrimSpace(commitMsg), "\n")
paragraphs := [][]string{}
var paragraph []string
for _, line := range lines {
paragraph = append(paragraph, line)
if line == "" {
if len(paragraph) > 0 {
paragraphs = append(paragraphs, paragraph)
}
paragraph = []string{}
}
}
if len(paragraph) > 0 {
paragraphs = append(paragraphs, paragraph)
}
// If the last paragraph consists of valid trailers, split off those lines,
// otherwise include them as part of the main commit message body.
if len(paragraphs) < 1 {
return lines, []string{}
}
trailerLines := paragraphs[len(paragraphs)-1]
for _, line := range trailerLines {
if !trailerRegex.MatchString(line) {
// At least one line in the last paragraph does not fit the trailer
// format; assume there are no trailers.
return lines, []string{}
}
}
bodyLines := make([]string, 0, len(paragraphs[0]))
for _, paragraph := range paragraphs[:len(paragraphs)-1] {
bodyLines = append(bodyLines, paragraph...)
}
return bodyLines, trailerLines
}
// JoinTrailers joins a main commit message body with a trailers footer.
func JoinTrailers(bodyLines, trailers []string) string {
commitMsg := make([]string, 0, len(bodyLines)+len(trailers)+1)
commitMsg = append(commitMsg, bodyLines...)
if len(commitMsg) > 0 && commitMsg[len(commitMsg)-1] != "" {
commitMsg = append(commitMsg, "")
}
commitMsg = append(commitMsg, trailers...)
return strings.Join(commitMsg, "\n")
}
// AddTrailer adds a trailer to the given commit message.
func AddTrailer(commitMsg, trailer string) (string, error) {
if !trailerRegex.MatchString(trailer) {
return "", skerr.Fmt("%q is not a valid git trailer", trailer)
}
body, trailers := SplitTrailers(commitMsg)
trailers = append(trailers, trailer)
return JoinTrailers(body, trailers), nil
}
// FullyQualifiedBranchName ensures that the branch has the refs/heads/ prefix.
func FullyQualifiedBranchName(branch string) string {
if strings.HasPrefix(branch, git_common.RefsHeadsPrefix) {
return branch
}
return git_common.RefsHeadsPrefix + branch
}
// BranchBaseName ensures that the branch does not have the refs/heads/ prefix.
func BranchBaseName(branch string) string {
return strings.TrimPrefix(branch, git_common.RefsHeadsPrefix)
}
// GetFootersMap parses the specified commit msg and returns it's footers.
// Invalid footer formats are logged.
// Eg: commit msg: "test test\n\nBug: skia:123\nTested: true" will return
// {"skia": "123", "Tested": "true"}.
func GetFootersMap(commitMsg string) map[string]string {
footersMap := map[string]string{}
_, footers := SplitTrailers(commitMsg)
for _, f := range footers {
rs := trailerRegex.FindStringSubmatch(f)
if len(rs) != 3 {
sklog.Errorf("Could not parse footer %s from the commitMsg %s", f, commitMsg)
continue
}
footersMap[rs[1]] = rs[2]
}
return footersMap
}
// GetBoolFooterVal looks for the specified footer in the footersMap and returns
// it's boolean value. If the footer is not found then false is returned.
// If the value is not boolean then false is returned and an error is logged.
func GetBoolFooterVal(footersMap map[string]string, footer string, issue int64) bool {
if val, ok := footersMap[string(footer)]; ok {
b, err := strconv.ParseBool(val)
if err != nil {
sklog.Errorf("Could not parse bool value out of \"%s: %s\" in %d", footer, val, issue)
return false
} else {
if b {
return b
}
}
}
return false
}
// GetStringFooterVal looks for the specified footer in the footersMap and returns
// it's strings value. If the footer is not found then an empty string is
// returned.
func GetStringFooterVal(footersMap map[string]string, footer string) string {
if val, ok := footersMap[string(footer)]; ok {
return val
}
return ""
}
// IsFullCommitHash returns true if the given string looks like a full 40-
// character Git commit hash.
func IsFullCommitHash(s string) bool {
return IsCommitHash(s) && len(s) == 40
}
// IsCommitHash returns true if the given string looks like a valid (possibly
// shortened) Git commit hash.
func IsCommitHash(s string) bool {
return commitHashRegex.MatchString(s) && len(s) >= 4 && len(s) <= 40
}