402 lines
11 KiB
Go
402 lines
11 KiB
Go
package git
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/go-git/go-git/v5"
|
|
"github.com/go-git/go-git/v5/config"
|
|
"github.com/go-git/go-git/v5/plumbing"
|
|
"github.com/go-git/go-git/v5/plumbing/storer"
|
|
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
|
"github.com/mattn/go-isatty"
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"github.com/nektos/act/pkg/common"
|
|
)
|
|
|
|
var (
|
|
codeCommitHTTPRegex = regexp.MustCompile(`^https?://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`)
|
|
codeCommitSSHRegex = regexp.MustCompile(`ssh://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`)
|
|
githubHTTPRegex = regexp.MustCompile(`^https?://.*github.com.*/(.+)/(.+?)(?:.git)?$`)
|
|
githubSSHRegex = regexp.MustCompile(`github.com[:/](.+)/(.+?)(?:.git)?$`)
|
|
|
|
cloneLock sync.Mutex
|
|
|
|
ErrShortRef = errors.New("short SHA references are not supported")
|
|
ErrNoRepo = errors.New("unable to find git repo")
|
|
)
|
|
|
|
type Error struct {
|
|
err error
|
|
commit string
|
|
}
|
|
|
|
func (e *Error) Error() string {
|
|
return e.err.Error()
|
|
}
|
|
|
|
func (e *Error) Unwrap() error {
|
|
return e.err
|
|
}
|
|
|
|
func (e *Error) Commit() string {
|
|
return e.commit
|
|
}
|
|
|
|
// FindGitRevision get the current git revision
|
|
func FindGitRevision(ctx context.Context, file string) (shortSha string, sha string, err error) {
|
|
logger := common.Logger(ctx)
|
|
|
|
gitDir, err := git.PlainOpenWithOptions(
|
|
file,
|
|
&git.PlainOpenOptions{
|
|
DetectDotGit: true,
|
|
EnableDotGitCommonDir: true,
|
|
},
|
|
)
|
|
|
|
if err != nil {
|
|
logger.WithError(err).Error("path", file, "not located inside a git repository")
|
|
return "", "", err
|
|
}
|
|
|
|
head, err := gitDir.Reference(plumbing.HEAD, true)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
if head.Hash().IsZero() {
|
|
return "", "", fmt.Errorf("HEAD sha1 could not be resolved")
|
|
}
|
|
|
|
hash := head.Hash().String()
|
|
|
|
logger.Debugf("Found revision: %s", hash)
|
|
return hash[:7], strings.TrimSpace(hash), nil
|
|
}
|
|
|
|
// FindGitRef get the current git ref
|
|
func FindGitRef(ctx context.Context, file string) (string, error) {
|
|
logger := common.Logger(ctx)
|
|
|
|
logger.Debugf("Loading revision from git directory")
|
|
_, ref, err := FindGitRevision(ctx, file)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
logger.Debugf("HEAD points to '%s'", ref)
|
|
|
|
// Prefer the git library to iterate over the references and find a matching tag or branch.
|
|
var refTag = ""
|
|
var refBranch = ""
|
|
repo, err := git.PlainOpenWithOptions(
|
|
file,
|
|
&git.PlainOpenOptions{
|
|
DetectDotGit: true,
|
|
EnableDotGitCommonDir: true,
|
|
},
|
|
)
|
|
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
iter, err := repo.References()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// find the reference that matches the revision's has
|
|
err = iter.ForEach(func(r *plumbing.Reference) error {
|
|
/* tags and branches will have the same hash
|
|
* when a user checks out a tag, it is not mentioned explicitly
|
|
* in the go-git package, we must identify the revision
|
|
* then check if any tag matches that revision,
|
|
* if so then we checked out a tag
|
|
* else we look for branches and if matches,
|
|
* it means we checked out a branch
|
|
*
|
|
* If a branches matches first we must continue and check all tags (all references)
|
|
* in case we match with a tag later in the interation
|
|
*/
|
|
if r.Hash().String() == ref {
|
|
if r.Name().IsTag() {
|
|
refTag = r.Name().String()
|
|
}
|
|
if r.Name().IsBranch() {
|
|
refBranch = r.Name().String()
|
|
}
|
|
}
|
|
|
|
// we found what we where looking for
|
|
if refTag != "" && refBranch != "" {
|
|
return storer.ErrStop
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// order matters here see above comment.
|
|
if refTag != "" {
|
|
return refTag, nil
|
|
}
|
|
if refBranch != "" {
|
|
return refBranch, nil
|
|
}
|
|
|
|
return "", fmt.Errorf("failed to identify reference (tag/branch) for the checked-out revision '%s'", ref)
|
|
}
|
|
|
|
// FindGithubRepo get the repo
|
|
func FindGithubRepo(ctx context.Context, file, githubInstance, remoteName string) (string, error) {
|
|
if remoteName == "" {
|
|
remoteName = "origin"
|
|
}
|
|
|
|
url, err := findGitRemoteURL(ctx, file, remoteName)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
_, slug, err := findGitSlug(url, githubInstance)
|
|
return slug, err
|
|
}
|
|
|
|
func findGitRemoteURL(_ context.Context, file, remoteName string) (string, error) {
|
|
repo, err := git.PlainOpenWithOptions(
|
|
file,
|
|
&git.PlainOpenOptions{
|
|
DetectDotGit: true,
|
|
EnableDotGitCommonDir: true,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
remote, err := repo.Remote(remoteName)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if len(remote.Config().URLs) < 1 {
|
|
return "", fmt.Errorf("remote '%s' exists but has no URL", remoteName)
|
|
}
|
|
|
|
return remote.Config().URLs[0], nil
|
|
}
|
|
|
|
func findGitSlug(url string, githubInstance string) (string, string, error) {
|
|
if matches := codeCommitHTTPRegex.FindStringSubmatch(url); matches != nil {
|
|
return "CodeCommit", matches[2], nil
|
|
} else if matches := codeCommitSSHRegex.FindStringSubmatch(url); matches != nil {
|
|
return "CodeCommit", matches[2], nil
|
|
} else if matches := githubHTTPRegex.FindStringSubmatch(url); matches != nil {
|
|
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
|
} else if matches := githubSSHRegex.FindStringSubmatch(url); matches != nil {
|
|
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
|
} else if githubInstance != "github.com" {
|
|
gheHTTPRegex := regexp.MustCompile(fmt.Sprintf(`^https?://%s/(.+)/(.+?)(?:.git)?$`, githubInstance))
|
|
gheSSHRegex := regexp.MustCompile(fmt.Sprintf(`%s[:/](.+)/(.+?)(?:.git)?$`, githubInstance))
|
|
if matches := gheHTTPRegex.FindStringSubmatch(url); matches != nil {
|
|
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
|
} else if matches := gheSSHRegex.FindStringSubmatch(url); matches != nil {
|
|
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
|
}
|
|
}
|
|
return "", url, nil
|
|
}
|
|
|
|
// NewGitCloneExecutorInput the input for the NewGitCloneExecutor
|
|
type NewGitCloneExecutorInput struct {
|
|
URL string
|
|
Ref string
|
|
Dir string
|
|
Token string
|
|
}
|
|
|
|
// CloneIfRequired ...
|
|
func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, error) {
|
|
r, err := git.PlainOpen(input.Dir)
|
|
if err != nil {
|
|
var progressWriter io.Writer
|
|
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
|
|
if entry, ok := logger.(*log.Entry); ok {
|
|
progressWriter = entry.WriterLevel(log.DebugLevel)
|
|
} else if lgr, ok := logger.(*log.Logger); ok {
|
|
progressWriter = lgr.WriterLevel(log.DebugLevel)
|
|
} else {
|
|
log.Errorf("Unable to get writer from logger (type=%T)", logger)
|
|
progressWriter = os.Stdout
|
|
}
|
|
}
|
|
|
|
cloneOptions := git.CloneOptions{
|
|
URL: input.URL,
|
|
Progress: progressWriter,
|
|
}
|
|
if input.Token != "" {
|
|
cloneOptions.Auth = &http.BasicAuth{
|
|
Username: "token",
|
|
Password: input.Token,
|
|
}
|
|
}
|
|
|
|
r, err = git.PlainCloneContext(ctx, input.Dir, false, &cloneOptions)
|
|
if err != nil {
|
|
logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err)
|
|
return nil, err
|
|
}
|
|
|
|
if err = os.Chmod(input.Dir, 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return r, nil
|
|
}
|
|
|
|
func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.PullOptions) {
|
|
fetchOptions.RefSpecs = []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD"}
|
|
pullOptions.Force = true
|
|
|
|
if token != "" {
|
|
auth := &http.BasicAuth{
|
|
Username: "token",
|
|
Password: token,
|
|
}
|
|
fetchOptions.Auth = auth
|
|
pullOptions.Auth = auth
|
|
}
|
|
|
|
return fetchOptions, pullOptions
|
|
}
|
|
|
|
// NewGitCloneExecutor creates an executor to clone git repos
|
|
//
|
|
//nolint:gocyclo
|
|
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|
return func(ctx context.Context) error {
|
|
logger := common.Logger(ctx)
|
|
logger.Infof(" \u2601 git clone '%s' # ref=%s", input.URL, input.Ref)
|
|
logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
|
|
|
|
cloneLock.Lock()
|
|
defer cloneLock.Unlock()
|
|
|
|
refName := plumbing.ReferenceName(fmt.Sprintf("refs/heads/%s", input.Ref))
|
|
r, err := CloneIfRequired(ctx, refName, input, logger)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// fetch latest changes
|
|
fetchOptions, pullOptions := gitOptions(input.Token)
|
|
|
|
err = r.Fetch(&fetchOptions)
|
|
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
|
return err
|
|
}
|
|
|
|
var hash *plumbing.Hash
|
|
rev := plumbing.Revision(input.Ref)
|
|
if hash, err = r.ResolveRevision(rev); err != nil {
|
|
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
|
}
|
|
|
|
if hash.String() != input.Ref && strings.HasPrefix(hash.String(), input.Ref) {
|
|
return &Error{
|
|
err: ErrShortRef,
|
|
commit: hash.String(),
|
|
}
|
|
}
|
|
|
|
// At this point we need to know if it's a tag or a branch
|
|
// And the easiest way to do it is duck typing
|
|
//
|
|
// If err is nil, it's a tag so let's proceed with that hash like we would if
|
|
// it was a sha
|
|
refType := "tag"
|
|
rev = plumbing.Revision(path.Join("refs", "tags", input.Ref))
|
|
if _, err := r.Tag(input.Ref); errors.Is(err, git.ErrTagNotFound) {
|
|
rName := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref))
|
|
if _, err := r.Reference(rName, false); errors.Is(err, plumbing.ErrReferenceNotFound) {
|
|
refType = "sha"
|
|
rev = plumbing.Revision(input.Ref)
|
|
} else {
|
|
refType = "branch"
|
|
rev = plumbing.Revision(rName)
|
|
}
|
|
}
|
|
|
|
if hash, err = r.ResolveRevision(rev); err != nil {
|
|
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
|
return err
|
|
}
|
|
|
|
var w *git.Worktree
|
|
if w, err = r.Worktree(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// If the hash resolved doesn't match the ref provided in a workflow then we're
|
|
// using a branch or tag ref, not a sha
|
|
//
|
|
// Repos on disk point to commit hashes, and need to checkout input.Ref before
|
|
// we try and pull down any changes
|
|
if hash.String() != input.Ref && refType == "branch" {
|
|
logger.Debugf("Provided ref is not a sha. Checking out branch before pulling changes")
|
|
sourceRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref))
|
|
if err = w.Checkout(&git.CheckoutOptions{
|
|
Branch: sourceRef,
|
|
Force: true,
|
|
}); err != nil {
|
|
logger.Errorf("Unable to checkout %s: %v", sourceRef, err)
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err = w.Pull(&pullOptions); err != nil && err != git.NoErrAlreadyUpToDate {
|
|
logger.Debugf("Unable to pull %s: %v", refName, err)
|
|
}
|
|
logger.Debugf("Cloned %s to %s", input.URL, input.Dir)
|
|
|
|
if hash.String() != input.Ref && refType == "branch" {
|
|
logger.Debugf("Provided ref is not a sha. Updating branch ref after pull")
|
|
if hash, err = r.ResolveRevision(rev); err != nil {
|
|
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
|
return err
|
|
}
|
|
}
|
|
if err = w.Checkout(&git.CheckoutOptions{
|
|
Hash: *hash,
|
|
Force: true,
|
|
}); err != nil {
|
|
logger.Errorf("Unable to checkout %s: %v", *hash, err)
|
|
return err
|
|
}
|
|
|
|
if err = w.Reset(&git.ResetOptions{
|
|
Mode: git.HardReset,
|
|
Commit: *hash,
|
|
}); err != nil {
|
|
logger.Errorf("Unable to reset to %s: %v", hash.String(), err)
|
|
return err
|
|
}
|
|
|
|
logger.Debugf("Checked out %s", input.Ref)
|
|
return nil
|
|
}
|
|
}
|