github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/pkg/git/v2/interactor.go (about)

     1  /*
     2  Copyright 2019 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package git
    18  
    19  import (
    20  	"bufio"
    21  	"bytes"
    22  	"errors"
    23  	"fmt"
    24  	"os"
    25  	"strings"
    26  	"time"
    27  
    28  	"github.com/sirupsen/logrus"
    29  )
    30  
    31  // Interactor knows how to operate on a git repository cloned from GitHub
    32  // using a local cache.
    33  type Interactor interface {
    34  	// Directory exposes the directory in which the repository has been cloned
    35  	Directory() string
    36  	// Clean removes the repository. It is up to the user to call this once they are done
    37  	Clean() error
    38  	// ResetHard runs `git reset --hard`
    39  	ResetHard(commitlike string) error
    40  	// IsDirty checks whether the repo is dirty or not
    41  	IsDirty() (bool, error)
    42  	// Checkout runs `git checkout`
    43  	Checkout(commitlike string) error
    44  	// RevParse runs `git rev-parse`
    45  	RevParse(commitlike string) (string, error)
    46  	// RevParseN runs `git rev-parse`, but takes a slice of git revisions, and
    47  	// returns a map of the git revisions as keys and the SHAs as values.
    48  	RevParseN(rev []string) (map[string]string, error)
    49  	// BranchExists determines if a branch with the name exists
    50  	BranchExists(branch string) bool
    51  	// ObjectExists determines if the Git object exists locally
    52  	ObjectExists(sha string) (bool, error)
    53  	// CheckoutNewBranch creates a new branch from HEAD and checks it out
    54  	CheckoutNewBranch(branch string) error
    55  	// Merge merges the commitlike into the current HEAD
    56  	Merge(commitlike string) (bool, error)
    57  	// MergeWithStrategy merges the commitlike into the current HEAD with the strategy
    58  	MergeWithStrategy(commitlike, mergeStrategy string, opts ...MergeOpt) (bool, error)
    59  	// MergeAndCheckout merges all commitlikes into the current HEAD with the appropriate strategy
    60  	MergeAndCheckout(baseSHA string, mergeStrategy string, headSHAs ...string) error
    61  	// Am calls `git am`
    62  	Am(path string) error
    63  	// Fetch calls `git fetch arg...`
    64  	Fetch(arg ...string) error
    65  	// FetchRef fetches the refspec
    66  	FetchRef(refspec string) error
    67  	// FetchFromRemote fetches the branch of the given remote
    68  	FetchFromRemote(remote RemoteResolver, branch string) error
    69  	// CheckoutPullRequest fetches and checks out the synthetic refspec from GitHub for a pull request HEAD
    70  	CheckoutPullRequest(number int) error
    71  	// Config runs `git config`
    72  	Config(args ...string) error
    73  	// Diff runs `git diff`
    74  	Diff(head, sha string) (changes []string, err error)
    75  	// MergeCommitsExistBetween determines if merge commits exist between target and HEAD
    76  	MergeCommitsExistBetween(target, head string) (bool, error)
    77  	// ShowRef returns the commit for a commitlike. Unlike rev-parse it does not require a checkout.
    78  	ShowRef(commitlike string) (string, error)
    79  }
    80  
    81  // cacher knows how to cache and update repositories in a central cache
    82  type cacher interface {
    83  	// MirrorClone sets up a mirror of the source repository.
    84  	MirrorClone() error
    85  	// RemoteUpdate fetches all updates from the remote.
    86  	RemoteUpdate() error
    87  	// FetchCommits fetches only the given commits.
    88  	FetchCommits([]string) error
    89  	// RetargetBranch moves the given branch to an already-existing commit.
    90  	RetargetBranch(string, string) error
    91  }
    92  
    93  // cloner knows how to clone repositories from a central cache
    94  type cloner interface {
    95  	// Clone clones the repository from a local path.
    96  	Clone(from string) error
    97  	CloneWithRepoOpts(from string, repoOpts RepoOpts) error
    98  }
    99  
   100  // MergeOpt holds options for git merge operations.
   101  // Currently only commit message option is supported.
   102  type MergeOpt struct {
   103  	CommitMessage string
   104  }
   105  
   106  type interactor struct {
   107  	executor executor
   108  	remote   RemoteResolver
   109  	dir      string
   110  	logger   *logrus.Entry
   111  }
   112  
   113  // Directory exposes the directory in which this repository has been cloned
   114  func (i *interactor) Directory() string {
   115  	return i.dir
   116  }
   117  
   118  // Clean cleans up the repository from the on-disk cache
   119  func (i *interactor) Clean() error {
   120  	return os.RemoveAll(i.dir)
   121  }
   122  
   123  // ResetHard runs `git reset --hard`
   124  func (i *interactor) ResetHard(commitlike string) error {
   125  	// `git reset --hard` doesn't cleanup untracked file
   126  	i.logger.Info("Clean untracked files and dirs.")
   127  	if out, err := i.executor.Run("clean", "-df"); err != nil {
   128  		return fmt.Errorf("error clean -df: %v. output: %s", err, string(out))
   129  	}
   130  	i.logger.WithField("commitlike", commitlike).Info("Reset hard.")
   131  	if out, err := i.executor.Run("reset", "--hard", commitlike); err != nil {
   132  		return fmt.Errorf("error reset hard %s: %v. output: %s", commitlike, err, string(out))
   133  	}
   134  	return nil
   135  }
   136  
   137  // IsDirty checks whether the repo is dirty or not
   138  func (i *interactor) IsDirty() (bool, error) {
   139  	i.logger.Info("Checking is dirty.")
   140  	b, err := i.executor.Run("status", "--porcelain")
   141  	if err != nil {
   142  		return false, fmt.Errorf("error add -A: %v. output: %s", err, string(b))
   143  	}
   144  	return len(b) > 0, nil
   145  }
   146  
   147  // Clone clones the repository from a local path.
   148  func (i *interactor) Clone(from string) error {
   149  	return i.CloneWithRepoOpts(from, RepoOpts{})
   150  }
   151  
   152  // CloneWithRepoOpts clones the repository from a local path, but additionally
   153  // use any repository options (RepoOpts) to customize the clone behavior.
   154  func (i *interactor) CloneWithRepoOpts(from string, repoOpts RepoOpts) error {
   155  	i.logger.Infof("Creating a clone of the repo at %s from %s", i.dir, from)
   156  	cloneArgs := []string{"clone"}
   157  
   158  	if repoOpts.ShareObjectsWithPrimaryClone {
   159  		cloneArgs = append(cloneArgs, "--shared")
   160  	}
   161  
   162  	// Handle sparse checkouts.
   163  	if repoOpts.SparseCheckoutDirs != nil {
   164  		cloneArgs = append(cloneArgs, "--sparse")
   165  	}
   166  
   167  	cloneArgs = append(cloneArgs, from, i.dir)
   168  
   169  	if out, err := i.executor.Run(cloneArgs...); err != nil {
   170  		return fmt.Errorf("error creating a clone: %w %v", err, string(out))
   171  	}
   172  
   173  	// For sparse checkouts, we have to do some additional housekeeping after
   174  	// the clone is completed. We use Git's global "-C <directory>" flag to
   175  	// switch to that directory before running the "sparse-checkout" command,
   176  	// because otherwise the command will fail (because it will try to run the
   177  	// command in the $PWD, which is not the same as the just-created clone
   178  	// directory (i.dir)).
   179  	if repoOpts.SparseCheckoutDirs != nil {
   180  		if len(repoOpts.SparseCheckoutDirs) == 0 {
   181  			return nil
   182  		}
   183  		sparseCheckoutArgs := []string{"-C", i.dir, "sparse-checkout", "set"}
   184  		sparseCheckoutArgs = append(sparseCheckoutArgs, repoOpts.SparseCheckoutDirs...)
   185  
   186  		timeBeforeSparseCheckout := time.Now()
   187  		if out, err := i.executor.Run(sparseCheckoutArgs...); err != nil {
   188  			return fmt.Errorf("error setting it to a sparse checkout: %w %v", err, string(out))
   189  		}
   190  		gitMetrics.sparseCheckoutDuration.Observe(time.Since(timeBeforeSparseCheckout).Seconds())
   191  	}
   192  	return nil
   193  }
   194  
   195  // MirrorClone sets up a mirror of the source repository.
   196  func (i *interactor) MirrorClone() error {
   197  	i.logger.Infof("Creating a mirror of the repo at %s", i.dir)
   198  	remote, err := i.remote()
   199  	if err != nil {
   200  		return fmt.Errorf("could not resolve remote for cloning: %w", err)
   201  	}
   202  	if out, err := i.executor.Run("clone", "--mirror", remote, i.dir); err != nil {
   203  		return fmt.Errorf("error creating a mirror clone: %w %v", err, string(out))
   204  	}
   205  	return nil
   206  }
   207  
   208  // Checkout runs git checkout.
   209  func (i *interactor) Checkout(commitlike string) error {
   210  	i.logger.Infof("Checking out %q", commitlike)
   211  	if out, err := i.executor.Run("checkout", commitlike); err != nil {
   212  		return fmt.Errorf("error checking out %q: %w %v", commitlike, err, string(out))
   213  	}
   214  	return nil
   215  }
   216  
   217  // RevParse runs git rev-parse.
   218  func (i *interactor) RevParse(commitlike string) (string, error) {
   219  	i.logger.Infof("Parsing revision %q", commitlike)
   220  	out, err := i.executor.Run("rev-parse", commitlike)
   221  	if err != nil {
   222  		return "", fmt.Errorf("error parsing %q: %w %v", commitlike, err, string(out))
   223  	}
   224  	return string(out), nil
   225  }
   226  
   227  func (i *interactor) RevParseN(revs []string) (map[string]string, error) {
   228  	if len(revs) == 0 {
   229  		return nil, errors.New("input revs must have at least 1 element")
   230  	}
   231  
   232  	i.logger.Infof("Parsing revisions %q", revs)
   233  
   234  	arg := append([]string{"rev-parse"}, revs...)
   235  
   236  	out, err := i.executor.Run(arg...)
   237  	if err != nil {
   238  		return nil, fmt.Errorf("error parsing %q: %w %v", revs, err, string(out))
   239  	}
   240  
   241  	ret := make(map[string]string)
   242  	got := strings.Split(string(out), "\n")
   243  
   244  	// We expect the length to be at least 2. This is because if we have the
   245  	// minimal number of elements (just 1), "got" should look like ["abcdef...",
   246  	// "\n"] because the trailing newline should be its own element.
   247  	if len(got) < 2 {
   248  		return nil, fmt.Errorf("expected parsed output to be at least 2 elements, got %d", len(got))
   249  	}
   250  	got = got[:len(got)-1] // Drop last element "\n".
   251  
   252  	for i, sha := range got {
   253  		ret[revs[i]] = sha
   254  	}
   255  
   256  	return ret, nil
   257  }
   258  
   259  // BranchExists returns true if branch exists in heads.
   260  func (i *interactor) BranchExists(branch string) bool {
   261  	i.logger.Infof("Checking if branch %q exists", branch)
   262  	_, err := i.executor.Run("ls-remote", "--exit-code", "--heads", "origin", branch)
   263  	return err == nil
   264  }
   265  
   266  func (i *interactor) ObjectExists(sha string) (bool, error) {
   267  	i.logger.WithField("SHA", sha).Info("Checking if Git object exists")
   268  	output, err := i.executor.Run("cat-file", "-e", sha)
   269  	// If the object does not exist, cat-file will exit with a non-zero exit
   270  	// code. This will make err non-nil. However this is a known behavior, so
   271  	// we just log it.
   272  	//
   273  	// We still have the error type as a return value because the v1 git client
   274  	// adapter needs to know that this operation is not supported there.
   275  	if err != nil {
   276  		i.logger.WithError(err).WithField("SHA", sha).Debugf("error from 'git cat-file -e': %s", string(output))
   277  		return false, nil
   278  	}
   279  	return true, nil
   280  }
   281  
   282  // CheckoutNewBranch creates a new branch and checks it out.
   283  func (i *interactor) CheckoutNewBranch(branch string) error {
   284  	i.logger.Infof("Checking out new branch %q", branch)
   285  	if out, err := i.executor.Run("checkout", "-b", branch); err != nil {
   286  		return fmt.Errorf("error checking out new branch %q: %w %v", branch, err, string(out))
   287  	}
   288  	return nil
   289  }
   290  
   291  // Merge attempts to merge commitlike into the current branch. It returns true
   292  // if the merge completes. It returns an error if the abort fails.
   293  func (i *interactor) Merge(commitlike string) (bool, error) {
   294  	return i.MergeWithStrategy(commitlike, "merge")
   295  }
   296  
   297  // MergeWithStrategy attempts to merge commitlike into the current branch given the merge strategy.
   298  // It returns true if the merge completes. if the merge does not complete successfully, we try to
   299  // abort it and return an error if the abort fails.
   300  func (i *interactor) MergeWithStrategy(commitlike, mergeStrategy string, opts ...MergeOpt) (bool, error) {
   301  	i.logger.Infof("Merging %q using the %q strategy", commitlike, mergeStrategy)
   302  	switch mergeStrategy {
   303  	case "merge":
   304  		return i.mergeMerge(commitlike, opts...)
   305  	case "squash":
   306  		return i.squashMerge(commitlike)
   307  	case "rebase":
   308  		return i.mergeRebase(commitlike)
   309  	case "ifNecessary":
   310  		return i.mergeIfNecessary(commitlike, opts...)
   311  	default:
   312  		return false, fmt.Errorf("merge strategy %q is not supported", mergeStrategy)
   313  	}
   314  }
   315  
   316  func (i *interactor) mergeHelper(args []string, commitlike string, opts ...MergeOpt) (bool, error) {
   317  	if len(opts) == 0 {
   318  		args = append(args, []string{"-m", "merge"}...)
   319  	} else {
   320  		for _, opt := range opts {
   321  			args = append(args, []string{"-m", opt.CommitMessage}...)
   322  		}
   323  	}
   324  
   325  	args = append(args, commitlike)
   326  
   327  	out, err := i.executor.Run(args...)
   328  	if err == nil {
   329  		return true, nil
   330  	}
   331  	i.logger.WithError(err).Infof("Error merging %q: %s", commitlike, string(out))
   332  	if out, err := i.executor.Run("merge", "--abort"); err != nil {
   333  		return false, fmt.Errorf("error aborting merge of %q: %w %v", commitlike, err, string(out))
   334  	}
   335  	return false, nil
   336  }
   337  
   338  func (i *interactor) mergeMerge(commitlike string, opts ...MergeOpt) (bool, error) {
   339  	args := []string{"merge", "--no-ff", "--no-stat"}
   340  	return i.mergeHelper(args, commitlike, opts...)
   341  }
   342  
   343  func (i *interactor) mergeIfNecessary(commitlike string, opts ...MergeOpt) (bool, error) {
   344  	args := []string{"merge", "--ff", "--no-stat"}
   345  	return i.mergeHelper(args, commitlike, opts...)
   346  }
   347  
   348  func (i *interactor) squashMerge(commitlike string) (bool, error) {
   349  	out, err := i.executor.Run("merge", "--squash", "--no-stat", commitlike)
   350  	if err != nil {
   351  		i.logger.WithError(err).Warnf("Error staging merge for %q: %s", commitlike, string(out))
   352  		if out, err := i.executor.Run("reset", "--hard", "HEAD"); err != nil {
   353  			return false, fmt.Errorf("error aborting merge of %q: %w %v", commitlike, err, string(out))
   354  		}
   355  		return false, nil
   356  	}
   357  	out, err = i.executor.Run("commit", "--no-stat", "-m", "merge")
   358  	if err != nil {
   359  		i.logger.WithError(err).Warnf("Error committing merge for %q: %s", commitlike, string(out))
   360  		if out, err := i.executor.Run("reset", "--hard", "HEAD"); err != nil {
   361  			return false, fmt.Errorf("error aborting merge of %q: %w %v", commitlike, err, string(out))
   362  		}
   363  		return false, nil
   364  	}
   365  	return true, nil
   366  }
   367  
   368  func (i *interactor) mergeRebase(commitlike string) (bool, error) {
   369  	if commitlike == "" {
   370  		return false, errors.New("branch must be set")
   371  	}
   372  
   373  	headRev, err := i.revParse("HEAD")
   374  	if err != nil {
   375  		i.logger.WithError(err).Infof("Failed to parse HEAD revision")
   376  		return false, err
   377  	}
   378  	headRev = strings.TrimSuffix(headRev, "\n")
   379  
   380  	b, err := i.executor.Run("rebase", "--no-stat", headRev, commitlike)
   381  	if err != nil {
   382  		i.logger.WithField("out", string(b)).WithError(err).Infof("Rebase failed.")
   383  		if b, err := i.executor.Run("rebase", "--abort"); err != nil {
   384  			return false, fmt.Errorf("error aborting after failed rebase for commitlike %s: %v. output: %s", commitlike, err, string(b))
   385  		}
   386  		return false, nil
   387  	}
   388  	return true, nil
   389  }
   390  
   391  func (i *interactor) revParse(args ...string) (string, error) {
   392  	fullArgs := append([]string{"rev-parse"}, args...)
   393  	b, err := i.executor.Run(fullArgs...)
   394  	if err != nil {
   395  		return "", errors.New(string(b))
   396  	}
   397  	return string(b), nil
   398  }
   399  
   400  // Only the `merge` and `squash` strategies are supported.
   401  func (i *interactor) MergeAndCheckout(baseSHA string, mergeStrategy string, headSHAs ...string) error {
   402  	if baseSHA == "" {
   403  		return errors.New("baseSHA must be set")
   404  	}
   405  	if err := i.Checkout(baseSHA); err != nil {
   406  		return err
   407  	}
   408  	for _, headSHA := range headSHAs {
   409  		ok, err := i.MergeWithStrategy(headSHA, mergeStrategy)
   410  		if err != nil {
   411  			return err
   412  		} else if !ok {
   413  			return fmt.Errorf("failed to merge %q", headSHA)
   414  		}
   415  	}
   416  	return nil
   417  }
   418  
   419  // Am tries to apply the patch in the given path into the current branch
   420  // by performing a three-way merge (similar to git cherry-pick). It returns
   421  // an error if the patch cannot be applied.
   422  func (i *interactor) Am(path string) error {
   423  	i.logger.Infof("Applying patch at %s", path)
   424  	out, err := i.executor.Run("am", "--3way", path)
   425  	if err == nil {
   426  		return nil
   427  	}
   428  	i.logger.WithError(err).Infof("Patch apply failed with output: %s", string(out))
   429  	if abortOut, abortErr := i.executor.Run("am", "--abort"); abortErr != nil {
   430  		i.logger.WithError(abortErr).Warningf("Aborting patch apply failed with output: %s", string(abortOut))
   431  	}
   432  	return errors.New(string(bytes.TrimPrefix(out, []byte("The copy of the patch that failed is found in: .git/rebase-apply/patch"))))
   433  }
   434  
   435  // FetchCommits only fetches those commits which we want, and only if they are
   436  // missing.
   437  func (i *interactor) FetchCommits(commitSHAs []string) error {
   438  	fetchArgs := []string{"--no-write-fetch-head", "--no-tags"}
   439  
   440  	// For each commit SHA, check if it already exists. If so, don't bother
   441  	// fetching it.
   442  	var missingCommits bool
   443  	for _, commitSHA := range commitSHAs {
   444  		if exists, _ := i.ObjectExists(commitSHA); exists {
   445  			continue
   446  		}
   447  
   448  		fetchArgs = append(fetchArgs, commitSHA)
   449  		missingCommits = true
   450  	}
   451  
   452  	// Skip the fetch operation altogether if nothing is missing (we already
   453  	// fetched everything previously at some point).
   454  	if !missingCommits {
   455  		return nil
   456  	}
   457  
   458  	if err := i.Fetch(fetchArgs...); err != nil {
   459  		return fmt.Errorf("failed to fetch %s: %v", fetchArgs, err)
   460  	}
   461  
   462  	return nil
   463  }
   464  
   465  // RetargetBranch moves the given branch to an already-existing commit.
   466  func (i *interactor) RetargetBranch(branch, sha string) error {
   467  	args := []string{"branch", "-f", branch, sha}
   468  	if out, err := i.executor.Run(args...); err != nil {
   469  		return fmt.Errorf("error retargeting branch: %w %v", err, string(out))
   470  	}
   471  
   472  	return nil
   473  }
   474  
   475  // RemoteUpdate fetches all updates from the remote.
   476  func (i *interactor) RemoteUpdate() error {
   477  	// We might need to refresh the token for accessing remotes in case of GitHub App auth (ghs tokens are only valid for
   478  	// 1 hour, see https://github.com/kubernetes/test-infra/issues/31182).
   479  	// Therefore, we resolve the remote again and update the clone's remote URL with a fresh token.
   480  	remote, err := i.remote()
   481  	if err != nil {
   482  		return fmt.Errorf("could not resolve remote for updating: %w", err)
   483  	}
   484  
   485  	i.logger.Info("Setting remote URL")
   486  	if out, err := i.executor.Run("remote", "set-url", "origin", remote); err != nil {
   487  		return fmt.Errorf("error setting remote URL: %w %v", err, string(out))
   488  	}
   489  
   490  	i.logger.Info("Updating from remote")
   491  	if out, err := i.executor.Run("remote", "update", "--prune"); err != nil {
   492  		return fmt.Errorf("error updating: %w %v", err, string(out))
   493  	}
   494  	return nil
   495  }
   496  
   497  // Fetch fetches all updates from the remote.
   498  func (i *interactor) Fetch(arg ...string) error {
   499  	remote, err := i.remote()
   500  	if err != nil {
   501  		return fmt.Errorf("could not resolve remote for fetching: %w", err)
   502  	}
   503  	arg = append([]string{"fetch", remote}, arg...)
   504  	i.logger.Infof("Fetching from %s", remote)
   505  	if out, err := i.executor.Run(arg...); err != nil {
   506  		return fmt.Errorf("error fetching: %w %v", err, string(out))
   507  	}
   508  	return nil
   509  }
   510  
   511  // FetchRef fetches a refspec from the remote and leaves it as FETCH_HEAD.
   512  func (i *interactor) FetchRef(refspec string) error {
   513  	remote, err := i.remote()
   514  	if err != nil {
   515  		return fmt.Errorf("could not resolve remote for fetching: %w", err)
   516  	}
   517  	i.logger.Infof("Fetching %q from %s", refspec, remote)
   518  	if out, err := i.executor.Run("fetch", remote, refspec); err != nil {
   519  		return fmt.Errorf("error fetching %q: %w %v", refspec, err, string(out))
   520  	}
   521  	return nil
   522  }
   523  
   524  // FetchFromRemote fetches all update from a specific remote and branch and leaves it as FETCH_HEAD.
   525  func (i *interactor) FetchFromRemote(remote RemoteResolver, branch string) error {
   526  	r, err := remote()
   527  	if err != nil {
   528  		return fmt.Errorf("couldn't get remote: %w", err)
   529  	}
   530  
   531  	i.logger.Infof("Fetching %s from %s", branch, r)
   532  	if out, err := i.executor.Run("fetch", r, branch); err != nil {
   533  		return fmt.Errorf("error fetching %s from %s: %w %v", branch, r, err, string(out))
   534  	}
   535  	return nil
   536  }
   537  
   538  // CheckoutPullRequest fetches the HEAD of a pull request using a synthetic refspec
   539  // available on GitHub remotes and creates a branch at that commit.
   540  func (i *interactor) CheckoutPullRequest(number int) error {
   541  	i.logger.Infof("Checking out pull request %d", number)
   542  	if err := i.FetchRef(fmt.Sprintf("pull/%d/head", number)); err != nil {
   543  		return err
   544  	}
   545  	if err := i.Checkout("FETCH_HEAD"); err != nil {
   546  		return err
   547  	}
   548  	if err := i.CheckoutNewBranch(fmt.Sprintf("pull%d", number)); err != nil {
   549  		return err
   550  	}
   551  	return nil
   552  }
   553  
   554  // Config runs git config.
   555  func (i *interactor) Config(args ...string) error {
   556  	i.logger.WithField("args", args).Info("Configuring.")
   557  	if out, err := i.executor.Run(append([]string{"config"}, args...)...); err != nil {
   558  		return fmt.Errorf("error configuring %v: %w %v", args, err, string(out))
   559  	}
   560  	return nil
   561  }
   562  
   563  // Diff lists the difference between the two references, returning the output
   564  // line by line.
   565  func (i *interactor) Diff(head, sha string) ([]string, error) {
   566  	i.logger.Infof("Finding the differences between %q and %q", head, sha)
   567  	out, err := i.executor.Run("diff", head, sha, "--name-only")
   568  	if err != nil {
   569  		return nil, err
   570  	}
   571  	var changes []string
   572  	scan := bufio.NewScanner(bytes.NewReader(out))
   573  	scan.Split(bufio.ScanLines)
   574  	for scan.Scan() {
   575  		changes = append(changes, scan.Text())
   576  	}
   577  	return changes, nil
   578  }
   579  
   580  // MergeCommitsExistBetween runs 'git log <target>..<head> --merged' to verify
   581  // if merge commits exist between "target" and "head".
   582  func (i *interactor) MergeCommitsExistBetween(target, head string) (bool, error) {
   583  	i.logger.Infof("Determining if merge commits exist between %q and %q", target, head)
   584  	out, err := i.executor.Run("log", fmt.Sprintf("%s..%s", target, head), "--oneline", "--merges")
   585  	if err != nil {
   586  		return false, fmt.Errorf("error verifying if merge commits exist between %q and %q: %v %s", target, head, err, string(out))
   587  	}
   588  	return len(out) != 0, nil
   589  }
   590  
   591  func (i *interactor) ShowRef(commitlike string) (string, error) {
   592  	i.logger.Infof("Getting the commit sha for commitlike %s", commitlike)
   593  	out, err := i.executor.Run("show-ref", "-s", commitlike)
   594  	if err != nil {
   595  		return "", fmt.Errorf("failed to get commit sha for commitlike %s: %w", commitlike, err)
   596  	}
   597  	return strings.TrimSpace(string(out)), nil
   598  }