github.com/nektos/act@v0.2.63/pkg/common/git/git.go (about)

     1  package git
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"os"
     9  	"path"
    10  	"regexp"
    11  	"strings"
    12  	"sync"
    13  
    14  	"github.com/go-git/go-git/v5"
    15  	"github.com/go-git/go-git/v5/config"
    16  	"github.com/go-git/go-git/v5/plumbing"
    17  	"github.com/go-git/go-git/v5/plumbing/storer"
    18  	"github.com/go-git/go-git/v5/plumbing/transport/http"
    19  	"github.com/mattn/go-isatty"
    20  	log "github.com/sirupsen/logrus"
    21  
    22  	"github.com/nektos/act/pkg/common"
    23  )
    24  
    25  var (
    26  	codeCommitHTTPRegex = regexp.MustCompile(`^https?://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`)
    27  	codeCommitSSHRegex  = regexp.MustCompile(`ssh://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`)
    28  	githubHTTPRegex     = regexp.MustCompile(`^https?://.*github.com.*/(.+)/(.+?)(?:.git)?$`)
    29  	githubSSHRegex      = regexp.MustCompile(`github.com[:/](.+)/(.+?)(?:.git)?$`)
    30  
    31  	cloneLock sync.Mutex
    32  
    33  	ErrShortRef = errors.New("short SHA references are not supported")
    34  	ErrNoRepo   = errors.New("unable to find git repo")
    35  )
    36  
    37  type Error struct {
    38  	err    error
    39  	commit string
    40  }
    41  
    42  func (e *Error) Error() string {
    43  	return e.err.Error()
    44  }
    45  
    46  func (e *Error) Unwrap() error {
    47  	return e.err
    48  }
    49  
    50  func (e *Error) Commit() string {
    51  	return e.commit
    52  }
    53  
    54  // FindGitRevision get the current git revision
    55  func FindGitRevision(ctx context.Context, file string) (shortSha string, sha string, err error) {
    56  	logger := common.Logger(ctx)
    57  
    58  	gitDir, err := git.PlainOpenWithOptions(
    59  		file,
    60  		&git.PlainOpenOptions{
    61  			DetectDotGit:          true,
    62  			EnableDotGitCommonDir: true,
    63  		},
    64  	)
    65  
    66  	if err != nil {
    67  		logger.WithError(err).Error("path", file, "not located inside a git repository")
    68  		return "", "", err
    69  	}
    70  
    71  	head, err := gitDir.Reference(plumbing.HEAD, true)
    72  	if err != nil {
    73  		return "", "", err
    74  	}
    75  
    76  	if head.Hash().IsZero() {
    77  		return "", "", fmt.Errorf("HEAD sha1 could not be resolved")
    78  	}
    79  
    80  	hash := head.Hash().String()
    81  
    82  	logger.Debugf("Found revision: %s", hash)
    83  	return hash[:7], strings.TrimSpace(hash), nil
    84  }
    85  
    86  // FindGitRef get the current git ref
    87  func FindGitRef(ctx context.Context, file string) (string, error) {
    88  	logger := common.Logger(ctx)
    89  
    90  	logger.Debugf("Loading revision from git directory")
    91  	_, ref, err := FindGitRevision(ctx, file)
    92  	if err != nil {
    93  		return "", err
    94  	}
    95  
    96  	logger.Debugf("HEAD points to '%s'", ref)
    97  
    98  	// Prefer the git library to iterate over the references and find a matching tag or branch.
    99  	var refTag = ""
   100  	var refBranch = ""
   101  	repo, err := git.PlainOpenWithOptions(
   102  		file,
   103  		&git.PlainOpenOptions{
   104  			DetectDotGit:          true,
   105  			EnableDotGitCommonDir: true,
   106  		},
   107  	)
   108  
   109  	if err != nil {
   110  		return "", err
   111  	}
   112  
   113  	iter, err := repo.References()
   114  	if err != nil {
   115  		return "", err
   116  	}
   117  
   118  	// find the reference that matches the revision's has
   119  	err = iter.ForEach(func(r *plumbing.Reference) error {
   120  		/* tags and branches will have the same hash
   121  		 * when a user checks out a tag, it is not mentioned explicitly
   122  		 * in the go-git package, we must identify the revision
   123  		 * then check if any tag matches that revision,
   124  		 * if so then we checked out a tag
   125  		 * else we look for branches and if matches,
   126  		 * it means we checked out a branch
   127  		 *
   128  		 * If a branches matches first we must continue and check all tags (all references)
   129  		 * in case we match with a tag later in the iteration
   130  		 */
   131  		if r.Hash().String() == ref {
   132  			if r.Name().IsTag() {
   133  				refTag = r.Name().String()
   134  			}
   135  			if r.Name().IsBranch() {
   136  				refBranch = r.Name().String()
   137  			}
   138  		}
   139  
   140  		// we found what we where looking for
   141  		if refTag != "" && refBranch != "" {
   142  			return storer.ErrStop
   143  		}
   144  
   145  		return nil
   146  	})
   147  
   148  	if err != nil {
   149  		return "", err
   150  	}
   151  
   152  	// order matters here see above comment.
   153  	if refTag != "" {
   154  		return refTag, nil
   155  	}
   156  	if refBranch != "" {
   157  		return refBranch, nil
   158  	}
   159  
   160  	return "", fmt.Errorf("failed to identify reference (tag/branch) for the checked-out revision '%s'", ref)
   161  }
   162  
   163  // FindGithubRepo get the repo
   164  func FindGithubRepo(ctx context.Context, file, githubInstance, remoteName string) (string, error) {
   165  	if remoteName == "" {
   166  		remoteName = "origin"
   167  	}
   168  
   169  	url, err := findGitRemoteURL(ctx, file, remoteName)
   170  	if err != nil {
   171  		return "", err
   172  	}
   173  	_, slug, err := findGitSlug(url, githubInstance)
   174  	return slug, err
   175  }
   176  
   177  func findGitRemoteURL(_ context.Context, file, remoteName string) (string, error) {
   178  	repo, err := git.PlainOpenWithOptions(
   179  		file,
   180  		&git.PlainOpenOptions{
   181  			DetectDotGit:          true,
   182  			EnableDotGitCommonDir: true,
   183  		},
   184  	)
   185  	if err != nil {
   186  		return "", err
   187  	}
   188  
   189  	remote, err := repo.Remote(remoteName)
   190  	if err != nil {
   191  		return "", err
   192  	}
   193  
   194  	if len(remote.Config().URLs) < 1 {
   195  		return "", fmt.Errorf("remote '%s' exists but has no URL", remoteName)
   196  	}
   197  
   198  	return remote.Config().URLs[0], nil
   199  }
   200  
   201  func findGitSlug(url string, githubInstance string) (string, string, error) {
   202  	if matches := codeCommitHTTPRegex.FindStringSubmatch(url); matches != nil {
   203  		return "CodeCommit", matches[2], nil
   204  	} else if matches := codeCommitSSHRegex.FindStringSubmatch(url); matches != nil {
   205  		return "CodeCommit", matches[2], nil
   206  	} else if matches := githubHTTPRegex.FindStringSubmatch(url); matches != nil {
   207  		return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
   208  	} else if matches := githubSSHRegex.FindStringSubmatch(url); matches != nil {
   209  		return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
   210  	} else if githubInstance != "github.com" {
   211  		gheHTTPRegex := regexp.MustCompile(fmt.Sprintf(`^https?://%s/(.+)/(.+?)(?:.git)?$`, githubInstance))
   212  		gheSSHRegex := regexp.MustCompile(fmt.Sprintf(`%s[:/](.+)/(.+?)(?:.git)?$`, githubInstance))
   213  		if matches := gheHTTPRegex.FindStringSubmatch(url); matches != nil {
   214  			return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
   215  		} else if matches := gheSSHRegex.FindStringSubmatch(url); matches != nil {
   216  			return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
   217  		}
   218  	}
   219  	return "", url, nil
   220  }
   221  
   222  // NewGitCloneExecutorInput the input for the NewGitCloneExecutor
   223  type NewGitCloneExecutorInput struct {
   224  	URL         string
   225  	Ref         string
   226  	Dir         string
   227  	Token       string
   228  	OfflineMode bool
   229  }
   230  
   231  // CloneIfRequired ...
   232  func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, error) {
   233  	// If the remote URL has changed, remove the directory and clone again.
   234  	if r, err := git.PlainOpen(input.Dir); err == nil {
   235  		if remote, err := r.Remote("origin"); err == nil {
   236  			if len(remote.Config().URLs) > 0 && remote.Config().URLs[0] != input.URL {
   237  				_ = os.RemoveAll(input.Dir)
   238  			}
   239  		}
   240  	}
   241  
   242  	r, err := git.PlainOpen(input.Dir)
   243  	if err != nil {
   244  		var progressWriter io.Writer
   245  		if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
   246  			if entry, ok := logger.(*log.Entry); ok {
   247  				progressWriter = entry.WriterLevel(log.DebugLevel)
   248  			} else if lgr, ok := logger.(*log.Logger); ok {
   249  				progressWriter = lgr.WriterLevel(log.DebugLevel)
   250  			} else {
   251  				log.Errorf("Unable to get writer from logger (type=%T)", logger)
   252  				progressWriter = os.Stdout
   253  			}
   254  		}
   255  
   256  		cloneOptions := git.CloneOptions{
   257  			URL:      input.URL,
   258  			Progress: progressWriter,
   259  		}
   260  		if input.Token != "" {
   261  			cloneOptions.Auth = &http.BasicAuth{
   262  				Username: "token",
   263  				Password: input.Token,
   264  			}
   265  		}
   266  
   267  		r, err = git.PlainCloneContext(ctx, input.Dir, false, &cloneOptions)
   268  		if err != nil {
   269  			logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err)
   270  			return nil, err
   271  		}
   272  
   273  		if err = os.Chmod(input.Dir, 0o755); err != nil {
   274  			return nil, err
   275  		}
   276  	}
   277  
   278  	return r, nil
   279  }
   280  
   281  func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.PullOptions) {
   282  	fetchOptions.RefSpecs = []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD"}
   283  	pullOptions.Force = true
   284  
   285  	if token != "" {
   286  		auth := &http.BasicAuth{
   287  			Username: "token",
   288  			Password: token,
   289  		}
   290  		fetchOptions.Auth = auth
   291  		pullOptions.Auth = auth
   292  	}
   293  
   294  	return fetchOptions, pullOptions
   295  }
   296  
   297  // NewGitCloneExecutor creates an executor to clone git repos
   298  //
   299  //nolint:gocyclo
   300  func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
   301  	return func(ctx context.Context) error {
   302  		logger := common.Logger(ctx)
   303  		logger.Infof("  \u2601  git clone '%s' # ref=%s", input.URL, input.Ref)
   304  		logger.Debugf("  cloning %s to %s", input.URL, input.Dir)
   305  
   306  		cloneLock.Lock()
   307  		defer cloneLock.Unlock()
   308  
   309  		refName := plumbing.ReferenceName(fmt.Sprintf("refs/heads/%s", input.Ref))
   310  		r, err := CloneIfRequired(ctx, refName, input, logger)
   311  		if err != nil {
   312  			return err
   313  		}
   314  
   315  		isOfflineMode := input.OfflineMode
   316  
   317  		// fetch latest changes
   318  		fetchOptions, pullOptions := gitOptions(input.Token)
   319  
   320  		if !isOfflineMode {
   321  			err = r.Fetch(&fetchOptions)
   322  			if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
   323  				return err
   324  			}
   325  		}
   326  
   327  		var hash *plumbing.Hash
   328  		rev := plumbing.Revision(input.Ref)
   329  		if hash, err = r.ResolveRevision(rev); err != nil {
   330  			logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
   331  		}
   332  
   333  		if hash.String() != input.Ref && strings.HasPrefix(hash.String(), input.Ref) {
   334  			return &Error{
   335  				err:    ErrShortRef,
   336  				commit: hash.String(),
   337  			}
   338  		}
   339  
   340  		// At this point we need to know if it's a tag or a branch
   341  		// And the easiest way to do it is duck typing
   342  		//
   343  		// If err is nil, it's a tag so let's proceed with that hash like we would if
   344  		// it was a sha
   345  		refType := "tag"
   346  		rev = plumbing.Revision(path.Join("refs", "tags", input.Ref))
   347  		if _, err := r.Tag(input.Ref); errors.Is(err, git.ErrTagNotFound) {
   348  			rName := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref))
   349  			if _, err := r.Reference(rName, false); errors.Is(err, plumbing.ErrReferenceNotFound) {
   350  				refType = "sha"
   351  				rev = plumbing.Revision(input.Ref)
   352  			} else {
   353  				refType = "branch"
   354  				rev = plumbing.Revision(rName)
   355  			}
   356  		}
   357  
   358  		if hash, err = r.ResolveRevision(rev); err != nil {
   359  			logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
   360  			return err
   361  		}
   362  
   363  		var w *git.Worktree
   364  		if w, err = r.Worktree(); err != nil {
   365  			return err
   366  		}
   367  
   368  		// If the hash resolved doesn't match the ref provided in a workflow then we're
   369  		// using a branch or tag ref, not a sha
   370  		//
   371  		// Repos on disk point to commit hashes, and need to checkout input.Ref before
   372  		// we try and pull down any changes
   373  		if hash.String() != input.Ref && refType == "branch" {
   374  			logger.Debugf("Provided ref is not a sha. Checking out branch before pulling changes")
   375  			sourceRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref))
   376  			if err = w.Checkout(&git.CheckoutOptions{
   377  				Branch: sourceRef,
   378  				Force:  true,
   379  			}); err != nil {
   380  				logger.Errorf("Unable to checkout %s: %v", sourceRef, err)
   381  				return err
   382  			}
   383  		}
   384  		if !isOfflineMode {
   385  			if err = w.Pull(&pullOptions); err != nil && err != git.NoErrAlreadyUpToDate {
   386  				logger.Debugf("Unable to pull %s: %v", refName, err)
   387  			}
   388  		}
   389  		logger.Debugf("Cloned %s to %s", input.URL, input.Dir)
   390  
   391  		if hash.String() != input.Ref && refType == "branch" {
   392  			logger.Debugf("Provided ref is not a sha. Updating branch ref after pull")
   393  			if hash, err = r.ResolveRevision(rev); err != nil {
   394  				logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
   395  				return err
   396  			}
   397  		}
   398  		if err = w.Checkout(&git.CheckoutOptions{
   399  			Hash:  *hash,
   400  			Force: true,
   401  		}); err != nil {
   402  			logger.Errorf("Unable to checkout %s: %v", *hash, err)
   403  			return err
   404  		}
   405  
   406  		if err = w.Reset(&git.ResetOptions{
   407  			Mode:   git.HardReset,
   408  			Commit: *hash,
   409  		}); err != nil {
   410  			logger.Errorf("Unable to reset to %s: %v", hash.String(), err)
   411  			return err
   412  		}
   413  
   414  		logger.Debugf("Checked out %s", input.Ref)
   415  		return nil
   416  	}
   417  }