blob: e0618d5e63c11e2e09bd0bf58634d0f748a316f6 [file] [log] [blame]
// Copyright 2021 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Package git provides functionality for interacting with
// local and remote git repositories.
package git
import (
"bytes"
"context"
"fmt"
"path/filepath"
"regexp"
"strings"
"infra/cros/internal/cmd"
"go.chromium.org/luci/common/errors"
)
var (
// CommandRunnerImpl exists for testing purposes.
CommandRunnerImpl cmd.CommandRunner = cmd.RealCommandRunner{}
)
// CommandOutput contains stdout/stderr for a command.
type CommandOutput struct {
Stdout string
Stderr string
}
// RemoteRef is a strcut representing a remote ref.
type RemoteRef struct {
Remote string
Ref string
}
// RunGit the specified git command in the specified repo. It returns
// stdout and stderr.
func RunGit(gitRepo string, cmd []string) (CommandOutput, error) {
ctx := context.Background()
var stdoutBuf, stderrBuf bytes.Buffer
err := CommandRunnerImpl.RunCommand(ctx, &stdoutBuf, &stderrBuf, gitRepo, "git", cmd...)
cmdOutput := CommandOutput{stdoutBuf.String(), stderrBuf.String()}
return cmdOutput, errors.Annotate(err, cmdOutput.Stderr).Err()
}
// RunGitIgnoreOutput runs the specified git command in the specified repo a
// and returns only an error, not the command output.
func RunGitIgnoreOutput(gitRepo string, cmd []string) error {
_, err := RunGit(gitRepo, cmd)
return err
}
// GetCurrentBranch returns current branch of a repo, and an empty string
// if repo is on detached HEAD.
func GetCurrentBranch(cwd string) string {
output, err := RunGit(cwd, []string{"symbolic-ref", "-q", "HEAD"})
if err != nil {
return ""
}
return StripRefsHead(strings.TrimSpace(output.Stdout))
}
// MatchBranchName returns the names of branches who match the specified
// regular expression.
func MatchBranchName(gitRepo string, pattern *regexp.Regexp) ([]string, error) {
// MatchBranchWithNamespace trims the namespace off the branches it returns.
// Here, we need a namespace that matches every string but doesn't match any character
// (so that nothing is trimmed).
nullNamespace := regexp.MustCompile("")
return MatchBranchNameWithNamespace(gitRepo, pattern, nullNamespace)
}
// MatchBranchNameWithNamespace returns the names of branches who match the specified
// pattern and start with the specified namespace.
func MatchBranchNameWithNamespace(gitRepo string, pattern, namespace *regexp.Regexp) ([]string, error) {
// Regex should be case insensitive.
namespace = regexp.MustCompile("(?i)^" + namespace.String())
pattern = regexp.MustCompile("(?i)" + pattern.String())
output, err := RunGit(gitRepo, []string{"show-ref"})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
// Not a fatal error, just no branches.
return []string{}, nil
}
// Could not read branches.
return []string{}, fmt.Errorf("git error: %s\nstdout: %s stderr: %s", err.Error(), output.Stdout, output.Stderr)
}
// Find all branches that match the pattern.
branches := strings.Split(output.Stdout, "\n")
matchedBranches := []string{}
for _, branch := range branches {
branch = strings.TrimSpace(branch)
if branch == "" {
continue
}
branch = strings.Fields(branch)[1]
// Only look at branches which match the namespace.
if !namespace.Match([]byte(branch)) {
continue
}
branch = namespace.ReplaceAllString(branch, "")
if pattern.Match([]byte(branch)) {
matchedBranches = append(matchedBranches, branch)
}
}
return matchedBranches, nil
}
// IsSHA checks whether or not the given ref is a SHA.
func IsSHA(ref string) bool {
shaRegexp := regexp.MustCompile("^[0-9a-f]{40}$")
return shaRegexp.MatchString(ref)
}
// GetGitRepoRevision finds and returns the revision of a branch.
func GetGitRepoRevision(cwd, branch string) (string, error) {
if branch == "" {
branch = "HEAD"
} else if branch != "HEAD" {
branch = NormalizeRef(branch)
}
output, err := RunGit(cwd, []string{"rev-parse", branch})
return strings.TrimSpace(output.Stdout), errors.Annotate(err, output.Stderr).Err()
}
// IsReachable determines whether one commit ref is reachable from another.
func IsReachable(cwd, toRef, fromRef string) (bool, error) {
_, err := RunGit(cwd, []string{"merge-base", "--is-ancestor", toRef, fromRef})
if err != nil {
if strings.Contains(err.Error(), "exit status 1") {
return false, nil
}
return false, err
}
return true, nil
}
// StripRefsHead removes leading 'refs/heads/' from a ref name.
func StripRefsHead(ref string) string {
return strings.TrimPrefix(ref, "refs/heads/")
}
// NormalizeRef converts git branch refs into fully qualified form.
func NormalizeRef(ref string) string {
if ref == "" || strings.HasPrefix(ref, "refs/") {
return ref
}
return fmt.Sprintf("refs/heads/%s", ref)
}
// StripRefs removes leading 'refs/heads/', 'refs/remotes/[^/]+/' from a ref name.
func StripRefs(ref string) string {
ref = StripRefsHead(ref)
// If the ref starts with ref/remotes/, then we want the part of the string
// that comes after the third "/".
// Example: refs/remotes/origin/master --> master
// Example: refs/remotes/origin/foo/bar --> foo/bar
if strings.HasPrefix(ref, "refs/remotes/") {
refParts := strings.SplitN(ref, "/", 4)
return refParts[len(refParts)-1]
}
return ref
}
// CreateBranch creates a branch.
func CreateBranch(gitRepo, branch string) error {
output, err := RunGit(gitRepo, []string{"checkout", "-B", branch})
if err != nil {
if strings.Contains(output.Stderr, "not a valid branch name") {
return fmt.Errorf("%s is not a valid branch name", branch)
}
return fmt.Errorf(output.Stderr)
}
return err
}
// CreateTrackingBranch creates a tracking branch.
func CreateTrackingBranch(gitRepo, branch string, remoteRef RemoteRef) error {
refspec := fmt.Sprintf("%s/%s", remoteRef.Remote, remoteRef.Ref)
output, err := RunGit(gitRepo, []string{"fetch", remoteRef.Remote, remoteRef.Ref})
if err != nil {
return fmt.Errorf("could not fetch %s: %s", refspec, output.Stderr)
}
output, err = RunGit(gitRepo, []string{"checkout", "-b", branch, "-t", refspec})
if err != nil {
if strings.Contains(output.Stderr, "not a valid branch name") {
return fmt.Errorf("%s is not a valid branch name", branch)
}
return fmt.Errorf(output.Stderr)
}
return err
}
// CommitAll adds all local changes and commits them.
// Returns the sha1 of the commit.
func CommitAll(gitRepo, commitMsg string) (string, error) {
if output, err := RunGit(gitRepo, []string{"add", "-A"}); err != nil {
return "", fmt.Errorf(output.Stderr)
}
if output, err := RunGit(gitRepo, []string{"commit", "-m", commitMsg}); err != nil {
if strings.Contains(output.Stdout, "nothing to commit") {
return "", fmt.Errorf(output.Stdout)
}
return "", fmt.Errorf(output.Stderr)
}
output, err := RunGit(gitRepo, []string{"rev-parse", "HEAD"})
if err != nil {
return "", err
}
return strings.TrimSpace(output.Stdout), nil
}
// CommitEmpty makes an empty commit (assuming nothing is staged).
// Returns the sha1 of the commit.
func CommitEmpty(gitRepo, commitMsg string) (string, error) {
if output, err := RunGit(gitRepo, []string{"commit", "-m", commitMsg, "--allow-empty"}); err != nil {
return "", fmt.Errorf(output.Stderr)
}
output, err := RunGit(gitRepo, []string{"rev-parse", "HEAD"})
if err != nil {
return "", err
}
return strings.TrimSpace(output.Stdout), nil
}
// PushRef pushes the specified local ref to the specified remote ref.
func PushRef(gitRepo, localRef string, pushTo RemoteRef, opts ...pushRefOpt) error {
ref := fmt.Sprintf("%s:%s", localRef, pushTo.Ref)
cmd := []string{"push", pushTo.Remote, ref}
for _, opt := range opts {
cmd = append(cmd, opt.pushRefOptArgs()...)
}
_, err := RunGit(gitRepo, cmd)
return err
}
// Init initializes a repo.
func Init(gitRepo string, bare bool) error {
cmd := []string{"init"}
if bare {
cmd = append(cmd, "--bare")
}
_, err := RunGit(gitRepo, cmd)
return err
}
// GetRemotes returns the remotes for a repository.
func GetRemotes(gitRepo string) ([]string, error) {
output, err := RunGit(gitRepo, []string{"remote"})
if err != nil {
return nil, err
}
remotes := strings.Split(strings.TrimSpace(output.Stdout), "\n")
for i := range remotes {
remotes[i] = strings.TrimSpace(remotes[i])
}
return remotes, nil
}
// AddRemote adds a remote.
func AddRemote(gitRepo, remote, remoteLocation string) error {
output, err := RunGit(gitRepo, []string{"remote", "add", remote, remoteLocation})
if err != nil {
if strings.Contains(output.Stderr, "already exists") {
return fmt.Errorf("remote already exists")
}
}
return err
}
// Checkout checkouts a branch.
func Checkout(gitRepo, branch string) error {
output, err := RunGit(gitRepo, []string{"checkout", branch})
if err != nil {
return fmt.Errorf(output.Stderr)
}
return err
}
// DeleteBranch checks out to master and then deletes the current branch.
func DeleteBranch(gitRepo, branch string, force bool) error {
cmd := []string{"branch"}
if force {
cmd = append(cmd, "-D")
} else {
cmd = append(cmd, "-d")
}
cmd = append(cmd, branch)
output, err := RunGit(gitRepo, cmd)
if err != nil {
if strings.Contains(output.Stderr, "checked out at") {
return fmt.Errorf(output.Stderr)
}
if strings.Contains(output.Stderr, "not fully merged") {
return fmt.Errorf("branch %s is not fully merged. use the force parameter if you wish to proceed", branch)
}
}
return err
}
// Clone clones the remote into the specified dir.
func Clone(remote, dir string, opts ...cloneOpt) error {
cmd := []string{"clone", remote, filepath.Base(dir)}
for _, opt := range opts {
cmd = append(cmd, opt.cloneOptArgs()...)
}
output, err := RunGit(filepath.Dir(dir), cmd)
if err != nil {
return fmt.Errorf(output.Stderr)
}
return nil
}
// Fetch fetches refspec in gitRepo.
func Fetch(gitRepo, remote, refspec string, opts ...fetchOpt) error {
cmd := []string{"fetch", remote, refspec}
for _, opt := range opts {
cmd = append(cmd, opt.fetchOptArgs()...)
}
output, err := RunGit(gitRepo, cmd)
if err != nil {
return fmt.Errorf(output.Stderr)
}
return nil
}
// RemoteBranches returns a list of branches on the specified remote.
func RemoteBranches(gitRepo, remote string) ([]string, error) {
output, err := RunGit(gitRepo, []string{"ls-remote", remote})
if err != nil {
if strings.Contains(output.Stderr, "not appear to be a git repository") {
return []string{}, fmt.Errorf("%s is not a valid remote", remote)
}
return []string{}, fmt.Errorf(output.Stderr)
}
remotes := []string{}
for _, line := range strings.Split(strings.TrimSpace(output.Stdout), "\n") {
if line == "" {
continue
}
remotes = append(remotes, StripRefs(strings.Fields(line)[1]))
}
return remotes, nil
}
// RemoteHasBranch checks whether or not a branch exists on a remote.
func RemoteHasBranch(gitRepo, remote, branch string) (bool, error) {
output, err := RunGit(gitRepo, []string{"ls-remote", remote, branch})
if err != nil {
if strings.Contains(output.Stderr, "not appear to be a git repository") {
return false, fmt.Errorf("%s is not a valid remote", remote)
}
return false, fmt.Errorf(output.Stderr)
}
return output.Stdout != "", nil
}
// ResolveRemoteSymbolicRef resolves the target of a symbolic ref.
func ResolveRemoteSymbolicRef(gitRepo, remote string, ref string) (string, error) {
output, err := RunGit(gitRepo, []string{"ls-remote", "-q", "--symref", "--exit-code", remote, ref})
if err != nil {
if strings.Contains(output.Stderr, "not appear to be a git repository") {
return "", fmt.Errorf("%s is not a valid remote", remote)
}
return "", fmt.Errorf(output.Stderr)
}
// The output will look like (NB: tabs are separators):
// ref: refs/heads/main HEAD
// 5f6803b100bb3cd0f534e96e88c91373e8ed1c44 HEAD
for _, line := range strings.Split(output.Stdout, "\n") {
parts := strings.SplitN(line, "\t", 2)
if strings.HasPrefix(parts[0], "ref:") && parts[1] == ref {
return strings.TrimSpace(parts[0][4:]), nil
}
}
return "", fmt.Errorf("unable to resolve %s", ref)
}
// Refs returns all of the refs in a repository, mapped to the corresponding
// SHAs.
func Refs(gitRepo string) (map[string]string, error) {
output, err := RunGit(gitRepo, []string{"show-ref"})
if err != nil {
return nil, err
}
refMap := make(map[string]string)
for _, line := range strings.Split(output.Stdout, "\n") {
toks := strings.Fields(line)
if len(toks) >= 2 {
refMap[toks[1]] = toks[0]
}
}
return refMap, nil
}