blob: 9cefddd6ec6804297a8af3b9f5dcda6a8782cbda [file] [log] [blame] [edit]
package git
/*
Common utils used by Repo and Checkout.
*/
import (
"context"
"fmt"
"os"
"path"
"regexp"
"strconv"
"strings"
"time"
"go.skia.org/infra/go/git/git_common"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/vcsinfo"
)
const gitlinkMode = "160000"
var ErrorNotSubmodule = skerr.Fmt("not a submodule")
var ErrorNotFound = skerr.Fmt("file not found")
var lsTreeRe = regexp.MustCompile(`(\d+) (\w+) ([[:xdigit:]]+)\t(.*)`)
// Branch describes a Git branch.
type Branch struct {
// The human-readable name of the branch.
Name string `json:"name"`
// The commit hash pointed to by this branch.
Head string `json:"head"`
}
// BranchList is a slice of Branch objects which implements sort.Interface.
type BranchList []*Branch
func (bl BranchList) Len() int { return len(bl) }
func (bl BranchList) Less(a, b int) bool { return bl[a].Name < bl[b].Name }
func (bl BranchList) Swap(a, b int) { bl[a], bl[b] = bl[b], bl[a] }
// Executable returns the path to Git.
func Executable(ctx context.Context) (string, error) {
git, _, _, err := git_common.FindGit(ctx)
return git, err
}
// GitDir is a directory in which one may run Git commands.
type GitDir interface {
// Dir returns the working directory of the GitDir.
Dir() string
// Git runs the given git command in the GitDir.
Git(ctx context.Context, cmd ...string) (string, error)
// Details returns a vcsinfo.LongCommit instance representing the given commit.
Details(ctx context.Context, name string) (*vcsinfo.LongCommit, error)
// RevParse runs "git rev-parse <name>" and returns the result.
RevParse(ctx context.Context, args ...string) (string, error)
// RevList runs "git rev-list <name>" and returns a slice of commit hashes.
RevList(ctx context.Context, args ...string) ([]string, error)
// GetBranchHead returns the commit hash at the HEAD of the given branch.
GetBranchHead(ctx context.Context, branchName string) (string, error)
// Branches runs "git branch" and returns a slice of Branch instances.
Branches(ctx context.Context) ([]*Branch, error)
// GetFile returns the contents of the given file at the given commit.
GetFile(ctx context.Context, fileName, commit string) (string, error)
// IsSubmodule returns true if the given path is submodule, ie contains gitlink.
IsSubmodule(ctx context.Context, path, commit string) (bool, error)
// ReadSubmodule returns commit hash of the given path, if the path is git
// submodule. ErrorNotFound is returned if path is not found in the git
// worktree. ErrorNotSubmodule is returned if path exists, but it's not a
// submodule.
ReadSubmodule(ctx context.Context, path, commit string) (string, error)
// UpdateSubmodule updates git submodule of the given path to the given commit.
// If submodule doesn't exist, it returns ErrorNotFound since it doesn't have
// all necessary information to create a valid submodule (requires an entry in
// .gitmodules).
UpdateSubmodule(ctx context.Context, path, commit string) error
// NumCommits returns the number of commits in the repo.
NumCommits(ctx context.Context) (int64, error)
// IsAncestor returns true iff A is an ancestor of B.
IsAncestor(ctx context.Context, a, b string) (bool, error)
// Version returns the Git version.
Version(ctx context.Context) (int, int, error)
// FullHash gives the full commit hash for the given ref.
FullHash(ctx context.Context, ref string) (string, error)
// CatFile runs "git cat-file -p <ref>:<path>".
CatFile(ctx context.Context, ref, path string) ([]byte, error)
// ReadDir is analogous to os.File.Readdir for a particular ref.
ReadDir(ctx context.Context, ref, path string) ([]os.FileInfo, error)
// GetRemotes returns a mapping of remote repo name to URL.
GetRemotes(ctx context.Context) (map[string]string, error)
// VFS returns a vfs.FS using Git for the given revision.
VFS(ctx context.Context, ref string) (*FS, error)
}
type gitRunner interface {
Git(ctx context.Context, cmd ...string) (string, error)
}
// newGitDir creates a GitDir instance based in the given directory.
func newGitDir(ctx context.Context, repoUrl, workdir string, mirror bool) (string, error) {
dest := path.Join(workdir, strings.TrimSuffix(path.Base(repoUrl), ".git"))
if _, err := os.Stat(dest); err != nil {
if os.IsNotExist(err) {
if err := Clone(ctx, repoUrl, dest, mirror); err != nil {
return "", skerr.Wrap(err)
}
} else {
return "", skerr.Wrapf(err, "there is a problem with the git directory")
}
}
return dest, nil
}
// Details returns a vcsinfo.LongCommit instance representing the given commit.
func gitRunner_Details(ctx context.Context, g gitRunner, name string) (*vcsinfo.LongCommit, error) {
output, err := g.Git(ctx, "log", "-n", "1", "--format=format:%H%n%P%n%an%x20(%ae)%n%s%n%ct%n%b", name)
if err != nil {
return nil, skerr.Wrap(err)
}
lines := strings.SplitN(output, "\n", 6)
if len(lines) != 6 {
return nil, skerr.Fmt("failed to parse output of 'git log'")
}
var parents []string
if lines[1] != "" {
parents = strings.Split(lines[1], " ")
}
ts, err := strconv.ParseInt(lines[4], 10, 64)
if err != nil {
return nil, skerr.Wrap(err)
}
return &vcsinfo.LongCommit{
ShortCommit: &vcsinfo.ShortCommit{
Hash: lines[0],
Author: lines[2],
Subject: lines[3],
},
Parents: parents,
Body: strings.TrimRight(lines[5], "\n"),
Timestamp: time.Unix(ts, 0).UTC(),
}, nil
}
// RevParse runs "git rev-parse <name>" and returns the result.
func gitRunner_RevParse(ctx context.Context, g gitRunner, args ...string) (string, error) {
out, err := g.Git(ctx, append([]string{"rev-parse"}, args...)...)
if err != nil {
return "", skerr.Wrap(err)
}
// Ensure that we got a single, 40-character commit hash.
split := strings.Fields(out)
if len(split) != 1 {
return "", skerr.Fmt("unable to parse commit hash from output: %s", out)
}
if len(split[0]) != 40 {
return "", skerr.Fmt("rev-parse returned invalid commit hash: %s", out)
}
return split[0], nil
}
// RevList runs "git rev-list <name>" and returns a slice of commit hashes.
func gitRunner_RevList(ctx context.Context, g gitRunner, args ...string) ([]string, error) {
out, err := g.Git(ctx, append([]string{"rev-list"}, args...)...)
if err != nil {
return nil, skerr.Wrap(err)
}
return strings.Fields(out), nil
}
// GetBranchHead returns the commit hash at the HEAD of the given branch.
func gitRunner_GetBranchHead(ctx context.Context, g gitRunner, branchName string) (string, error) {
return gitRunner_RevParse(ctx, g, "--verify", fmt.Sprintf("refs/heads/%s^{commit}", branchName))
}
// Branches runs "git branch" and returns a slice of Branch instances.
func gitRunner_Branches(ctx context.Context, g gitRunner) ([]*Branch, error) {
out, err := g.Git(ctx, "branch")
if err != nil {
return nil, skerr.Wrap(err)
}
branchNames := strings.Fields(out)
branches := make([]*Branch, 0, len(branchNames))
for _, name := range branchNames {
if name == "*" {
continue
}
head, err := gitRunner_GetBranchHead(ctx, g, name)
if err != nil {
return nil, skerr.Wrap(err)
}
branches = append(branches, &Branch{
Head: head,
Name: name,
})
}
return branches, nil
}
// GetFile returns the contents of the given file at the given commit.
func gitRunner_GetFile(ctx context.Context, g gitRunner, fileName, commit string) (string, error) {
return g.Git(ctx, "show", commit+":"+fileName)
}
// IsSubmodule returns true if the given path is submodule, ie contains gitlink.
func gitRunner_IsSubmodule(ctx context.Context, g gitRunner, path, commit string) (bool, error) {
_, err := gitRunner_ReadSubmodule(ctx, g, path, commit)
switch skerr.Unwrap(err) {
case ErrorNotSubmodule:
return false, nil
case nil:
return true, nil
default:
return false, skerr.Wrap(err)
}
}
// ReadSubmodule returns commit hash of the given path, if the path is git
// submodule. ErrorNotFound is returned if path is not found in the git
// worktree. ErrorNotSubmodule is returned if path exists, but it's not a
// submodule.
func gitRunner_ReadSubmodule(ctx context.Context, g gitRunner, path, commit string) (string, error) {
// Detect if we are dealing with submodules or regular files.
// Expected output for submodules:
// <mode> SP <type> SP <object> TAB <file>
out, err := g.Git(ctx, "ls-tree", commit, "--", path)
if err != nil {
return "", skerr.Wrap(err)
}
matches := lsTreeRe.FindAllStringSubmatch(out, -1)
if len(matches) != 1 {
// We expect one match. If is non one, it's either not found or
// it's a tree. In either case, we return not found.
return "", skerr.Wrap(ErrorNotFound)
}
if matches[0][1] != gitlinkMode {
return "", skerr.Wrap(ErrorNotSubmodule)
}
return matches[0][3], nil
}
// UpdateSubmodule updates git submodule of the given path to the given commit.
// If submodule doesn't exist, it returns ErrorNotFound since it doesn't have
// all necessary information to create a valid submodule (requires an entry in
// .gitmodules).
func gitRunner_UpdateSubmodule(ctx context.Context, g gitRunner, path, commit string) error {
if _, err := gitRunner_ReadSubmodule(ctx, g, path, "HEAD"); err != nil {
return skerr.Wrap(err)
}
cacheInfo := fmt.Sprintf("%s,%s,%s", gitlinkMode, commit, path)
_, err := g.Git(ctx, "update-index", "--add", "--cacheinfo", cacheInfo)
return skerr.Wrap(err)
}
// NumCommits returns the number of commits in the repo.
func gitRunner_NumCommits(ctx context.Context, g gitRunner) (int64, error) {
out, err := g.Git(ctx, "rev-list", "--all", "--count")
if err != nil {
return 0, skerr.Wrap(err)
}
return strconv.ParseInt(strings.TrimSpace(out), 10, 64)
}
// IsAncestor returns true iff A is an ancestor of B.
func gitRunner_IsAncestor(ctx context.Context, g gitRunner, a, b string) (bool, error) {
out, err := g.Git(ctx, "merge-base", "--is-ancestor", a, b)
if err != nil {
// Either a is not an ancestor of b, or we got a real error. If
// the output is empty, assume it's the former case.
if out == "" {
return false, nil
}
// "Not a valid commit name" indicates that the given commit does not
// exist and thus history was probably changed upstream.
// A non-existent commit cannot be an ancestor of one which does exist.
if strings.Contains(out, fmt.Sprintf("Not a valid commit name %s", a)) {
return false, nil
}
// Otherwise, return the presumably real error.
return false, skerr.Wrap(err)
}
return true, nil
}
// Version returns the Git version.
func gitRunner_Version(ctx context.Context) (int, int, error) {
_, maj, min, err := git_common.FindGit(ctx)
return maj, min, skerr.Wrap(err)
}
// FullHash gives the full commit hash for the given ref.
func gitRunner_FullHash(ctx context.Context, g gitRunner, ref string) (string, error) {
output, err := gitRunner_RevParse(ctx, g, fmt.Sprintf("%s^{commit}", ref))
if err != nil {
return "", skerr.Wrapf(err, "failed to obtain full hash")
}
return output, nil
}
// CatFile runs "git cat-file -p <ref>:<path>".
func gitRunner_CatFile(ctx context.Context, g gitRunner, ref, path string) ([]byte, error) {
output, err := g.Git(ctx, "cat-file", "-p", fmt.Sprintf("%s:%s", ref, path))
if err != nil {
return nil, skerr.Wrap(err)
}
return []byte(output), nil
}
// ReadDir is analogous to os.File.Readdir for a particular ref.
func gitRunner_ReadDir(ctx context.Context, g gitRunner, ref, path string) ([]os.FileInfo, error) {
contents, err := gitRunner_CatFile(ctx, g, ref, path)
if err != nil {
return nil, skerr.Wrap(err)
}
return ParseDir(contents)
}
// GetRemotes returns a mapping of remote repo name to URL.
func gitRunner_GetRemotes(ctx context.Context, g gitRunner) (map[string]string, error) {
output, err := g.Git(ctx, "remote", "-v")
if err != nil {
return nil, skerr.Wrap(err)
}
lines := strings.Split(strings.TrimSpace(output), "\n")
rv := make(map[string]string, len(lines))
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) != 3 {
return nil, skerr.Fmt("Got invalid output from `git remote -v`:\n%s", output)
}
// First field is the remote name, second is the URL. The third field
// indicates whether the URL is used for fetching or pushing. In some
// cases the same remote name might use different URLs for fetching and
// pushing, in which case the return value will be incorrect. For our
// use cases this implementation is enough, but if that changes we may
// need to return a slice of structs containing the remote name and the
// fetch and push URLs.
rv[fields[0]] = fields[1]
}
return rv, nil
}