diff options
Diffstat (limited to 'pkg/common/git')
-rw-r--r-- | pkg/common/git/git.go | 418 | ||||
-rw-r--r-- | pkg/common/git/git_test.go | 249 |
2 files changed, 667 insertions, 0 deletions
diff --git a/pkg/common/git/git.go b/pkg/common/git/git.go new file mode 100644 index 0000000..c7ee889 --- /dev/null +++ b/pkg/common/git/git.go @@ -0,0 +1,418 @@ +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 + OfflineMode bool + + // For Gitea + InsecureSkipTLS bool +} + +// 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, + + InsecureSkipTLS: input.InsecureSkipTLS, // For Gitea + } + 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 + } + + isOfflineMode := input.OfflineMode + + // fetch latest changes + fetchOptions, pullOptions := gitOptions(input.Token) + + if input.InsecureSkipTLS { // For Gitea + fetchOptions.InsecureSkipTLS = true + pullOptions.InsecureSkipTLS = true + } + + if !isOfflineMode { + 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 !isOfflineMode { + 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 + } +} diff --git a/pkg/common/git/git_test.go b/pkg/common/git/git_test.go new file mode 100644 index 0000000..6ad66b6 --- /dev/null +++ b/pkg/common/git/git_test.go @@ -0,0 +1,249 @@ +package git + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindGitSlug(t *testing.T) { + assert := assert.New(t) + + var slugTests = []struct { + url string // input + provider string // expected result + slug string // expected result + }{ + {"https://git-codecommit.us-east-1.amazonaws.com/v1/repos/my-repo-name", "CodeCommit", "my-repo-name"}, + {"ssh://git-codecommit.us-west-2.amazonaws.com/v1/repos/my-repo", "CodeCommit", "my-repo"}, + {"git@github.com:nektos/act.git", "GitHub", "nektos/act"}, + {"git@github.com:nektos/act", "GitHub", "nektos/act"}, + {"https://github.com/nektos/act.git", "GitHub", "nektos/act"}, + {"http://github.com/nektos/act.git", "GitHub", "nektos/act"}, + {"https://github.com/nektos/act", "GitHub", "nektos/act"}, + {"http://github.com/nektos/act", "GitHub", "nektos/act"}, + {"git+ssh://git@github.com/owner/repo.git", "GitHub", "owner/repo"}, + {"http://myotherrepo.com/act.git", "", "http://myotherrepo.com/act.git"}, + } + + for _, tt := range slugTests { + provider, slug, err := findGitSlug(tt.url, "github.com") + + assert.NoError(err) + assert.Equal(tt.provider, provider) + assert.Equal(tt.slug, slug) + } +} + +func testDir(t *testing.T) string { + basedir, err := os.MkdirTemp("", "act-test") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(basedir) }) + return basedir +} + +func cleanGitHooks(dir string) error { + hooksDir := filepath.Join(dir, ".git", "hooks") + files, err := os.ReadDir(hooksDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + for _, f := range files { + if f.IsDir() { + continue + } + relName := filepath.Join(hooksDir, f.Name()) + if err := os.Remove(relName); err != nil { + return err + } + } + return nil +} + +func TestFindGitRemoteURL(t *testing.T) { + assert := assert.New(t) + + basedir := testDir(t) + gitConfig() + err := gitCmd("init", basedir) + assert.NoError(err) + err = cleanGitHooks(basedir) + assert.NoError(err) + + remoteURL := "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/my-repo-name" + err = gitCmd("-C", basedir, "remote", "add", "origin", remoteURL) + assert.NoError(err) + + u, err := findGitRemoteURL(context.Background(), basedir, "origin") + assert.NoError(err) + assert.Equal(remoteURL, u) + + remoteURL = "git@github.com/AwesomeOwner/MyAwesomeRepo.git" + err = gitCmd("-C", basedir, "remote", "add", "upstream", remoteURL) + assert.NoError(err) + u, err = findGitRemoteURL(context.Background(), basedir, "upstream") + assert.NoError(err) + assert.Equal(remoteURL, u) +} + +func TestGitFindRef(t *testing.T) { + basedir := testDir(t) + gitConfig() + + for name, tt := range map[string]struct { + Prepare func(t *testing.T, dir string) + Assert func(t *testing.T, ref string, err error) + }{ + "new_repo": { + Prepare: func(t *testing.T, dir string) {}, + Assert: func(t *testing.T, ref string, err error) { + require.Error(t, err) + }, + }, + "new_repo_with_commit": { + Prepare: func(t *testing.T, dir string) { + require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg")) + }, + Assert: func(t *testing.T, ref string, err error) { + require.NoError(t, err) + require.Equal(t, "refs/heads/master", ref) + }, + }, + "current_head_is_tag": { + Prepare: func(t *testing.T, dir string) { + require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "commit msg")) + require.NoError(t, gitCmd("-C", dir, "tag", "v1.2.3")) + require.NoError(t, gitCmd("-C", dir, "checkout", "v1.2.3")) + }, + Assert: func(t *testing.T, ref string, err error) { + require.NoError(t, err) + require.Equal(t, "refs/tags/v1.2.3", ref) + }, + }, + "current_head_is_same_as_tag": { + Prepare: func(t *testing.T, dir string) { + require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "1.4.2 release")) + require.NoError(t, gitCmd("-C", dir, "tag", "v1.4.2")) + }, + Assert: func(t *testing.T, ref string, err error) { + require.NoError(t, err) + require.Equal(t, "refs/tags/v1.4.2", ref) + }, + }, + "current_head_is_not_tag": { + Prepare: func(t *testing.T, dir string) { + require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg")) + require.NoError(t, gitCmd("-C", dir, "tag", "v1.4.2")) + require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg2")) + }, + Assert: func(t *testing.T, ref string, err error) { + require.NoError(t, err) + require.Equal(t, "refs/heads/master", ref) + }, + }, + "current_head_is_another_branch": { + Prepare: func(t *testing.T, dir string) { + require.NoError(t, gitCmd("-C", dir, "checkout", "-b", "mybranch")) + require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg")) + }, + Assert: func(t *testing.T, ref string, err error) { + require.NoError(t, err) + require.Equal(t, "refs/heads/mybranch", ref) + }, + }, + } { + tt := tt + name := name + t.Run(name, func(t *testing.T) { + dir := filepath.Join(basedir, name) + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, gitCmd("-C", dir, "init", "--initial-branch=master")) + require.NoError(t, cleanGitHooks(dir)) + tt.Prepare(t, dir) + ref, err := FindGitRef(context.Background(), dir) + tt.Assert(t, ref, err) + }) + } +} + +func TestGitCloneExecutor(t *testing.T) { + for name, tt := range map[string]struct { + Err error + URL, Ref string + }{ + "tag": { + Err: nil, + URL: "https://github.com/actions/checkout", + Ref: "v2", + }, + "branch": { + Err: nil, + URL: "https://github.com/anchore/scan-action", + Ref: "act-fails", + }, + "sha": { + Err: nil, + URL: "https://github.com/actions/checkout", + Ref: "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f", // v2 + }, + "short-sha": { + Err: &Error{ErrShortRef, "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f"}, + URL: "https://github.com/actions/checkout", + Ref: "5a4ac90", // v2 + }, + } { + t.Run(name, func(t *testing.T) { + clone := NewGitCloneExecutor(NewGitCloneExecutorInput{ + URL: tt.URL, + Ref: tt.Ref, + Dir: testDir(t), + }) + + err := clone(context.Background()) + if tt.Err != nil { + assert.Error(t, err) + assert.Equal(t, tt.Err, err) + } else { + assert.Empty(t, err) + } + }) + } +} + +func gitConfig() { + if os.Getenv("GITHUB_ACTIONS") == "true" { + var err error + if err = gitCmd("config", "--global", "user.email", "test@test.com"); err != nil { + log.Error(err) + } + if err = gitCmd("config", "--global", "user.name", "Unit Test"); err != nil { + log.Error(err) + } + } +} + +func gitCmd(args ...string) error { + cmd := exec.Command("git", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + err := cmd.Run() + if exitError, ok := err.(*exec.ExitError); ok { + if waitStatus, ok := exitError.Sys().(syscall.WaitStatus); ok { + return fmt.Errorf("Exit error %d", waitStatus.ExitStatus()) + } + return exitError + } + return nil +} |