blob: 985017fabd42e61525a9b1eb03d6c2f8f39f2f55 [file] [log] [blame]
// Copyright 2015 The Vanadium 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 gitutil
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"v.io/jiri/runutil"
)
// PlatformSpecificGitArgs returns a git command line with platform specific,
// if any, modifications. The code is duplicated here because of the dependency
// structure in the jiri tool.
// TODO(cnicolaou,bprosnitz): remove this once ssl certs are installed.
func platformSpecificGitArgs(args ...string) []string {
if os.Getenv("FNL_SYSTEM") != "" {
// TODO(bprosnitz) Remove this after certificates are installed on FNL
// Disable SSL verification because certificates are not present on FNL.func
return append([]string{"-c", "http.sslVerify=false"}, args...)
}
return args
}
type GitError struct {
args []string
output string
errorOutput string
}
func Error(output, errorOutput string, args ...string) GitError {
return GitError{
args: args,
output: output,
errorOutput: errorOutput,
}
}
func (ge GitError) Error() string {
result := "'git "
result += strings.Join(ge.args, " ")
result += "' failed:\n"
result += ge.errorOutput
return result
}
type Git struct {
s runutil.Sequence
opts map[string]string
rootDir string
}
type gitOpt interface {
gitOpt()
}
type AuthorDateOpt string
type CommitterDateOpt string
type RootDirOpt string
func (AuthorDateOpt) gitOpt() {}
func (CommitterDateOpt) gitOpt() {}
func (RootDirOpt) gitOpt() {}
// New is the Git factory.
func New(s runutil.Sequence, opts ...gitOpt) *Git {
rootDir := ""
env := map[string]string{}
for _, opt := range opts {
switch typedOpt := opt.(type) {
case AuthorDateOpt:
env["GIT_AUTHOR_DATE"] = string(typedOpt)
case CommitterDateOpt:
env["GIT_COMMITTER_DATE"] = string(typedOpt)
case RootDirOpt:
rootDir = string(typedOpt)
}
}
return &Git{
s: s,
opts: env,
rootDir: rootDir,
}
}
// Add adds a file to staging.
func (g *Git) Add(file string) error {
return g.run("add", file)
}
// AddRemote adds a new remote with the given name and path.
func (g *Git) AddRemote(name, path string) error {
return g.run("remote", "add", name, path)
}
// BranchExists tests whether a branch with the given name exists in
// the local repository.
func (g *Git) BranchExists(branch string) bool {
return g.run("show-branch", branch) == nil
}
// BranchesDiffer tests whether two branches have any changes between them.
func (g *Git) BranchesDiffer(branch1, branch2 string) (bool, error) {
out, err := g.runOutput("--no-pager", "diff", "--name-only", branch1+".."+branch2)
if err != nil {
return false, err
}
// If output is empty, then there is no difference.
if len(out) == 0 {
return false, nil
}
// Otherwise there is a difference.
return true, nil
}
// CheckoutBranch checks out the given branch.
func (g *Git) CheckoutBranch(branch string, opts ...CheckoutOpt) error {
args := []string{"checkout"}
force := false
for _, opt := range opts {
switch typedOpt := opt.(type) {
case ForceOpt:
force = bool(typedOpt)
}
}
if force {
args = append(args, "-f")
}
args = append(args, branch)
return g.run(args...)
}
// Clone clones the given repository to the given local path.
func (g *Git) Clone(repo, path string) error {
return g.run("clone", repo, path)
}
// CloneRecursive clones the given repository recursively to the given local path.
func (g *Git) CloneRecursive(repo, path string) error {
return g.run("clone", "--recursive", repo, path)
}
// Commit commits all files in staging with an empty message.
func (g *Git) Commit() error {
return g.run("commit", "--allow-empty", "--allow-empty-message", "--no-edit")
}
// CommitAmend amends the previous commit with the currently staged
// changes. Empty commits are allowed.
func (g *Git) CommitAmend() error {
return g.run("commit", "--amend", "--allow-empty", "--no-edit")
}
// CommitAmendWithMessage amends the previous commit with the
// currently staged changes, and the given message. Empty commits are
// allowed.
func (g *Git) CommitAmendWithMessage(message string) error {
return g.run("commit", "--amend", "--allow-empty", "-m", message)
}
// CommitAndEdit commits all files in staging and allows the user to
// edit the commit message.
func (g *Git) CommitAndEdit() error {
args := []string{"commit", "--allow-empty"}
return g.runInteractive(args...)
}
// CommitFile commits the given file with the given commit message.
func (g *Git) CommitFile(fileName, message string) error {
if err := g.Add(fileName); err != nil {
return err
}
return g.CommitWithMessage(message)
}
// CommitMessages returns the concatenation of all commit messages on
// <branch> that are not also on <baseBranch>.
func (g *Git) CommitMessages(branch, baseBranch string) (string, error) {
out, err := g.runOutput("log", "--no-merges", baseBranch+".."+branch)
if err != nil {
return "", err
}
return strings.Join(out, "\n"), nil
}
// CommitNoVerify commits all files in staging with the given
// message and skips all git-hooks.
func (g *Git) CommitNoVerify(message string) error {
return g.run("commit", "--allow-empty", "--allow-empty-message", "--no-verify", "-m", message)
}
// CommitWithMessage commits all files in staging with the given
// message.
func (g *Git) CommitWithMessage(message string) error {
return g.run("commit", "--allow-empty", "--allow-empty-message", "-m", message)
}
// CommitWithMessage commits all files in staging and allows the user
// to edit the commit message. The given message will be used as the
// default.
func (g *Git) CommitWithMessageAndEdit(message string) error {
args := []string{"commit", "--allow-empty", "-e", "-m", message}
return g.runInteractive(args...)
}
// Committers returns a list of committers for the current repository
// along with the number of their commits.
func (g *Git) Committers() ([]string, error) {
out, err := g.runOutput("shortlog", "-s", "-n", "-e")
if err != nil {
return nil, err
}
return out, nil
}
// CountCommits returns the number of commits on <branch> that are not
// on <base>.
func (g *Git) CountCommits(branch, base string) (int, error) {
args := []string{"rev-list", "--count", branch}
if base != "" {
args = append(args, "^"+base)
}
args = append(args, "--")
out, err := g.runOutput(args...)
if err != nil {
return 0, err
}
if got, want := len(out), 1; got != want {
return 0, fmt.Errorf("unexpected length of %v: got %v, want %v", out, got, want)
}
count, err := strconv.Atoi(out[0])
if err != nil {
return 0, fmt.Errorf("Atoi(%v) failed: %v", out[0], err)
}
return count, nil
}
// CreateBranch creates a new branch with the given name.
func (g *Git) CreateBranch(branch string) error {
return g.run("branch", branch)
}
// CreateAndCheckoutBranch creates a new branch with the given name
// and checks it out.
func (g *Git) CreateAndCheckoutBranch(branch string) error {
return g.run("checkout", "-b", branch)
}
// CreateBranchWithUpstream creates a new branch and sets the upstream
// repository to the given upstream.
func (g *Git) CreateBranchWithUpstream(branch, upstream string) error {
return g.run("branch", branch, upstream)
}
// CurrentBranchName returns the name of the current branch.
func (g *Git) CurrentBranchName() (string, error) {
out, err := g.runOutput("rev-parse", "--abbrev-ref", "HEAD")
if err != nil {
return "", err
}
if got, want := len(out), 1; got != want {
return "", fmt.Errorf("unexpected length of %v: got %v, want %v", out, got, want)
}
return out[0], nil
}
// CurrentRevision returns the current revision.
func (g *Git) CurrentRevision() (string, error) {
return g.CurrentRevisionOfBranch("HEAD")
}
// CurrentRevisionOfBranch returns the current revision of the given branch.
func (g *Git) CurrentRevisionOfBranch(branch string) (string, error) {
out, err := g.runOutput("rev-parse", branch)
if err != nil {
return "", err
}
if got, want := len(out), 1; got != want {
return "", fmt.Errorf("unexpected length of %v: got %v, want %v", out, got, want)
}
return out[0], nil
}
// DeleteBranch deletes the given branch.
func (g *Git) DeleteBranch(branch string, opts ...DeleteBranchOpt) error {
args := []string{"branch"}
force := false
for _, opt := range opts {
switch typedOpt := opt.(type) {
case ForceOpt:
force = bool(typedOpt)
}
}
if force {
args = append(args, "-D")
} else {
args = append(args, "-d")
}
args = append(args, branch)
return g.run(args...)
}
// Fetch fetches refs and tags from the given remote.
func (g *Git) Fetch(remote string) error {
return g.run("fetch", remote)
}
// FetchRefspec fetches refs and tags from the given remote for a particular refspec.
func (g *Git) FetchRefspec(remote, refspec string) error {
return g.run("fetch", remote, refspec)
}
// FilesWithUncommittedChanges returns the list of files that have
// uncommitted changes.
func (g *Git) FilesWithUncommittedChanges() ([]string, error) {
out, err := g.runOutput("diff", "--name-only", "--no-ext-diff")
if err != nil {
return nil, err
}
out2, err := g.runOutput("diff", "--cached", "--name-only", "--no-ext-diff")
if err != nil {
return nil, err
}
return append(out, out2...), nil
}
// GetBranches returns a slice of the local branches of the current
// repository, followed by the name of the current branch. The
// behavior can be customized by providing optional arguments
// (e.g. --merged).
func (g *Git) GetBranches(args ...string) ([]string, string, error) {
args = append([]string{"branch"}, args...)
out, err := g.runOutput(args...)
if err != nil {
return nil, "", err
}
branches, current := []string{}, ""
for _, branch := range out {
if strings.HasPrefix(branch, "*") {
branch = strings.TrimSpace(strings.TrimPrefix(branch, "*"))
current = branch
}
branches = append(branches, strings.TrimSpace(branch))
}
return branches, current, nil
}
// HasUncommittedChanges checks whether the current branch contains
// any uncommitted changes.
func (g *Git) HasUncommittedChanges() (bool, error) {
out, err := g.FilesWithUncommittedChanges()
if err != nil {
return false, err
}
return len(out) != 0, nil
}
// HasUntrackedFiles checks whether the current branch contains any
// untracked files.
func (g *Git) HasUntrackedFiles() (bool, error) {
out, err := g.UntrackedFiles()
if err != nil {
return false, err
}
return len(out) != 0, nil
}
// Init initializes a new git repository.
func (g *Git) Init(path string) error {
return g.run("init", path)
}
// IsFileCommitted tests whether the given file has been committed to
// the repository.
func (g *Git) IsFileCommitted(file string) bool {
// Check if file is still in staging enviroment.
if out, _ := g.runOutput("status", "--porcelain", file); len(out) > 0 {
return false
}
// Check if file is unknown to git.
return g.run("ls-files", file, "--error-unmatch") == nil
}
// LatestCommitMessage returns the latest commit message on the
// current branch.
func (g *Git) LatestCommitMessage() (string, error) {
out, err := g.runOutput("log", "-n", "1", "--format=format:%B")
if err != nil {
return "", err
}
return strings.Join(out, "\n"), nil
}
// Log returns a list of commits on <branch> that are not on <base>,
// using the specified format.
func (g *Git) Log(branch, base, format string) ([][]string, error) {
n, err := g.CountCommits(branch, base)
if err != nil {
return nil, err
}
result := [][]string{}
for i := 0; i < n; i++ {
skipArg := fmt.Sprintf("--skip=%d", i)
formatArg := fmt.Sprintf("--format=%s", format)
branchArg := fmt.Sprintf("%v..%v", base, branch)
out, err := g.runOutput("log", "-1", skipArg, formatArg, branchArg)
if err != nil {
return nil, err
}
result = append(result, out)
}
return result, nil
}
// Merge merges all commits from <branch> to the current branch. If
// <squash> is set, then all merged commits are squashed into a single
// commit.
func (g *Git) Merge(branch string, opts ...MergeOpt) error {
args := []string{"merge"}
squash := false
strategy := ""
resetOnFailure := true
for _, opt := range opts {
switch typedOpt := opt.(type) {
case SquashOpt:
squash = bool(typedOpt)
case StrategyOpt:
strategy = string(typedOpt)
case ResetOnFailureOpt:
resetOnFailure = bool(typedOpt)
}
}
if squash {
args = append(args, "--squash")
} else {
args = append(args, "--no-squash")
}
if strategy != "" {
args = append(args, fmt.Sprintf("--strategy=%v", strategy))
}
args = append(args, branch)
if out, err := g.runOutput(args...); err != nil {
if resetOnFailure {
if err2 := g.run("reset", "--merge"); err2 != nil {
return fmt.Errorf("%v\nCould not git reset while recovering from error: %v", err, err2)
}
}
return fmt.Errorf("%v\n%v", err, strings.Join(out, "\n"))
}
return nil
}
// MergeInProgress returns a boolean flag that indicates if a merge
// operation is in progress for the current repository.
func (g *Git) MergeInProgress() (bool, error) {
repoRoot, err := g.TopLevel()
if err != nil {
return false, err
}
mergeFile := filepath.Join(repoRoot, ".git", "MERGE_HEAD")
if _, err := g.s.Stat(mergeFile); err != nil {
if runutil.IsNotExist(err) {
return false, nil
}
return false, err
}
return true, nil
}
// ModifiedFiles returns a slice of filenames that have changed
// between <baseBranch> and <currentBranch>.
func (g *Git) ModifiedFiles(baseBranch, currentBranch string) ([]string, error) {
out, err := g.runOutput("diff", "--name-only", baseBranch+".."+currentBranch)
if err != nil {
return nil, err
}
return out, nil
}
// Pull pulls the given branch from the given remote.
func (g *Git) Pull(remote, branch string) error {
if out, err := g.runOutput("pull", remote, branch); err != nil {
g.run("reset", "--merge")
return fmt.Errorf("%v\n%v", err, strings.Join(out, "\n"))
}
major, minor, err := g.Version()
if err != nil {
return err
}
// Starting with git 1.8, "git pull <remote> <branch>" does not
// create the branch "<remote>/<branch>" locally. To avoid the need
// to account for this, run "git pull", which fails but creates the
// missing branch, for git 1.7 and older.
if major < 2 && minor < 8 {
// This command is expected to fail (with desirable side effects).
// Use exec.Command instead of runner to prevent this failure from
// showing up in the console and confusing people.
command := exec.Command("git", "pull")
command.Run()
}
return nil
}
// Push pushes the given branch to the given remote.
func (g *Git) Push(remote, branch string, opts ...PushOpt) error {
args := []string{"push"}
verify := true
for _, opt := range opts {
switch typedOpt := opt.(type) {
case VerifyOpt:
verify = bool(typedOpt)
}
}
if verify {
args = append(args, "--verify")
} else {
args = append(args, "--no-verify")
}
args = append(args, remote, branch)
return g.run(args...)
}
// Rebase rebases to a particular upstream branch.
func (g *Git) Rebase(upstream string) error {
return g.run("rebase", upstream)
}
// RebaseAbort aborts an in-progress rebase operation.
func (g *Git) RebaseAbort() error {
return g.run("rebase", "--abort")
}
// RefExistsOnBranch returns true if the given ref exists on the given branch.
func (g *Git) RefExistsOnBranch(ref, branch string) bool {
args := []string{"merge-base", "--is-ancestor", ref, branch}
if err := g.run(args...); err != nil {
return false
}
return true
}
// Remove removes the given files.
func (g *Git) Remove(fileNames ...string) error {
args := []string{"rm"}
args = append(args, fileNames...)
return g.run(args...)
}
// RemoteUrl gets the url of the remote with the given name.
func (g *Git) RemoteUrl(name string) (string, error) {
configKey := fmt.Sprintf("remote.%s.url", name)
out, err := g.runOutput("config", "--get", configKey)
if err != nil {
return "", err
}
if got, want := len(out), 1; got != want {
return "", fmt.Errorf("RemoteUrl: unexpected length of remotes %v: got %v, want %v", out, got, want)
}
return out[0], nil
}
// RemoveUntrackedFiles removes untracked files and directories.
func (g *Git) RemoveUntrackedFiles() error {
return g.run("clean", "-d", "-f")
}
// Reset resets the current branch to the target, discarding any
// uncommitted changes.
func (g *Git) Reset(target string, opts ...ResetOpt) error {
args := []string{"reset"}
mode := "hard"
for _, opt := range opts {
switch typedOpt := opt.(type) {
case ModeOpt:
mode = string(typedOpt)
}
}
args = append(args, fmt.Sprintf("--%v", mode), target, "--")
return g.run(args...)
}
// SetRemoteUrl sets the url of the remote with given name to the given url.
func (g *Git) SetRemoteUrl(name, url string) error {
return g.run("remote", "set-url", name, url)
}
// Stash attempts to stash any unsaved changes. It returns true if
// anything was actually stashed, otherwise false. An error is
// returned if the stash command fails.
func (g *Git) Stash() (bool, error) {
oldSize, err := g.StashSize()
if err != nil {
return false, err
}
if err := g.run("stash", "save"); err != nil {
return false, err
}
newSize, err := g.StashSize()
if err != nil {
return false, err
}
return newSize > oldSize, nil
}
// StashSize returns the size of the stash stack.
func (g *Git) StashSize() (int, error) {
out, err := g.runOutput("stash", "list")
if err != nil {
return 0, err
}
// If output is empty, then stash is empty.
if len(out) == 0 {
return 0, nil
}
// Otherwise, stash size is the length of the output.
return len(out), nil
}
// StashPop pops the stash into the current working tree.
func (g *Git) StashPop() error {
return g.run("stash", "pop")
}
// TopLevel returns the top level path of the current repository.
func (g *Git) TopLevel() (string, error) {
// TODO(sadovsky): If g.rootDir is set, perhaps simply return that?
out, err := g.runOutput("rev-parse", "--show-toplevel")
if err != nil {
return "", err
}
return strings.Join(out, "\n"), nil
}
// TrackedFiles returns the list of files that are tracked.
func (g *Git) TrackedFiles() ([]string, error) {
out, err := g.runOutput("ls-files")
if err != nil {
return nil, err
}
return out, nil
}
// UntrackedFiles returns the list of files that are not tracked.
func (g *Git) UntrackedFiles() ([]string, error) {
out, err := g.runOutput("ls-files", "--others", "--directory", "--exclude-standard")
if err != nil {
return nil, err
}
return out, nil
}
// Version returns the major and minor git version.
func (g *Git) Version() (int, int, error) {
out, err := g.runOutput("version")
if err != nil {
return 0, 0, err
}
if got, want := len(out), 1; got != want {
return 0, 0, fmt.Errorf("unexpected length of %v: got %v, want %v", out, got, want)
}
words := strings.Split(out[0], " ")
if got, want := len(words), 3; got < want {
return 0, 0, fmt.Errorf("unexpected length of %v: got %v, want at least %v", words, got, want)
}
version := strings.Split(words[2], ".")
if got, want := len(version), 3; got < want {
return 0, 0, fmt.Errorf("unexpected length of %v: got %v, want at least %v", version, got, want)
}
major, err := strconv.Atoi(version[0])
if err != nil {
return 0, 0, fmt.Errorf("failed parsing %q to integer", major)
}
minor, err := strconv.Atoi(version[1])
if err != nil {
return 0, 0, fmt.Errorf("failed parsing %q to integer", minor)
}
return major, minor, nil
}
func (g *Git) run(args ...string) error {
var stdout, stderr bytes.Buffer
capture := func(s runutil.Sequence) runutil.Sequence { return s.Capture(&stdout, &stderr) }
if err := g.runWithFn(capture, args...); err != nil {
return Error(stdout.String(), stderr.String(), args...)
}
return nil
}
func trimOutput(o string) []string {
output := strings.TrimSpace(o)
if len(output) == 0 {
return nil
}
return strings.Split(output, "\n")
}
func (g *Git) runOutput(args ...string) ([]string, error) {
var stdout, stderr bytes.Buffer
fn := func(s runutil.Sequence) runutil.Sequence { return s.Capture(&stdout, &stderr) }
if err := g.runWithFn(fn, args...); err != nil {
return nil, Error(stdout.String(), stderr.String(), args...)
}
return trimOutput(stdout.String()), nil
}
func (g *Git) runInteractive(args ...string) error {
var stderr bytes.Buffer
// In order for the editing to work correctly with
// terminal-based editors, notably "vim", use os.Stdout.
capture := func(s runutil.Sequence) runutil.Sequence { return s.Capture(os.Stdout, &stderr) }
if err := g.runWithFn(capture, args...); err != nil {
return Error("", stderr.String(), args...)
}
return nil
}
func (g *Git) runWithFn(fn func(s runutil.Sequence) runutil.Sequence, args ...string) error {
g.s.Dir(g.rootDir)
args = platformSpecificGitArgs(args...)
if fn == nil {
fn = func(s runutil.Sequence) runutil.Sequence { return s }
}
return fn(g.s).Env(g.opts).Last("git", args...)
}
// Committer encapsulates the process of create a commit.
type Committer struct {
commit func() error
commitWithMessage func(message string) error
}
// Commit creates a commit.
func (c *Committer) Commit(message string) error {
if len(message) == 0 {
// No commit message supplied, let git supply one.
return c.commit()
}
return c.commitWithMessage(message)
}
// NewCommitter is the Committer factory. The boolean <edit> flag
// determines whether the commit commands should prompt users to edit
// the commit message. This flag enables automated testing.
func (g *Git) NewCommitter(edit bool) *Committer {
if edit {
return &Committer{
commit: g.CommitAndEdit,
commitWithMessage: g.CommitWithMessageAndEdit,
}
} else {
return &Committer{
commit: g.Commit,
commitWithMessage: g.CommitWithMessage,
}
}
}