mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-04-30 16:00:38 +08:00
Old `cloneLock` is a package-level `sync.Mutex` that serialized every action clone across all goroutines, regardless of target directory. This PR replaces it with a `sync.Map` of per-directory mutexes keyed by `input.Dir`. Same-directory operations still serialize; different directories now clone in parallel. Reviewed-on: https://gitea.com/gitea/act_runner/pulls/866 Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: Zettat123 <zettat123@gmail.com> Co-committed-by: Zettat123 <zettat123@gmail.com>
366 lines
11 KiB
Go
366 lines
11 KiB
Go
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package git
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestFindGitSlug(t *testing.T) {
|
|
assert := assert.New(t)
|
|
|
|
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) //nolint:testifylint // pre-existing issue from nektos/act
|
|
assert.Equal(tt.provider, provider)
|
|
assert.Equal(tt.slug, slug)
|
|
}
|
|
}
|
|
|
|
func testDir(t *testing.T) string {
|
|
return t.TempDir()
|
|
}
|
|
|
|
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) //nolint:testifylint // pre-existing issue from nektos/act
|
|
err = cleanGitHooks(basedir)
|
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
|
|
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) //nolint:testifylint // pre-existing issue from nektos/act
|
|
|
|
u, err := findGitRemoteURL(context.Background(), basedir, "origin")
|
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
assert.Equal(remoteURL, u)
|
|
|
|
remoteURL = "git@github.com/AwesomeOwner/MyAwesomeRepo.git"
|
|
err = gitCmd("-C", basedir, "remote", "add", "upstream", remoteURL)
|
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
u, err = findGitRemoteURL(context.Background(), basedir, "upstream")
|
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
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)
|
|
},
|
|
},
|
|
} {
|
|
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) //nolint:testifylint // pre-existing issue from nektos/act
|
|
assert.Equal(t, tt.Err, err)
|
|
} else {
|
|
assert.Empty(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
|
|
// Simulate the scenario where a remote ref (e.g. a GitHub PR head ref) changes
|
|
// non-fast-forward between two fetches. Before the fix, the fetch used Force=false,
|
|
// causing go-git to return ErrForceNeeded and short-circuit the checkout.
|
|
|
|
gitConfig()
|
|
|
|
// Create a bare "remote" repo with an initial commit on main and a feature branch.
|
|
remoteDir := t.TempDir()
|
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
|
|
|
// We need a working clone to push commits from.
|
|
workDir := t.TempDir()
|
|
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
|
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
|
|
|
// Create a feature branch (simulates refs/pull/N/head).
|
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "feature"))
|
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "feature-1"))
|
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "feature"))
|
|
|
|
// First clone via the executor — should succeed and cache the repo.
|
|
cloneDir := t.TempDir()
|
|
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
|
URL: remoteDir,
|
|
Ref: "main",
|
|
Dir: cloneDir,
|
|
})
|
|
require.NoError(t, clone(context.Background()))
|
|
|
|
// Now force-push the feature branch to a non-fast-forward commit (simulates
|
|
// a PR rebase). This makes refs/heads/feature non-fast-forward.
|
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "main"))
|
|
require.NoError(t, gitCmd("-C", workDir, "branch", "-D", "feature"))
|
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "feature"))
|
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "feature-rewritten"))
|
|
require.NoError(t, gitCmd("-C", workDir, "push", "--force", "origin", "feature"))
|
|
|
|
// Also advance main so we can verify the clone picks up the new commit.
|
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "main"))
|
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "second"))
|
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main"))
|
|
|
|
// Second clone to the same directory — before the fix this returned ErrForceNeeded
|
|
// and left the working tree at the old commit.
|
|
err := clone(context.Background())
|
|
require.NoError(t, err, "fetch with non-fast-forward refs must not fail when Force=true")
|
|
|
|
// Verify the working tree was actually updated to the latest main commit.
|
|
out, err := exec.Command("git", "-C", cloneDir, "log", "--oneline", "-1", "--format=%s").Output()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "second", strings.TrimSpace(string(out)), "working tree should be at the latest commit")
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func TestAcquireCloneLock(t *testing.T) {
|
|
t.Run("same directory serializes", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
unlock1 := acquireCloneLock(dir)
|
|
|
|
secondAcquired := make(chan struct{})
|
|
go func() {
|
|
unlock := acquireCloneLock(dir)
|
|
close(secondAcquired)
|
|
unlock()
|
|
}()
|
|
|
|
select {
|
|
case <-secondAcquired:
|
|
t.Fatal("second acquire should block while first holds the lock")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
unlock1()
|
|
|
|
select {
|
|
case <-secondAcquired:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("second acquire should proceed after first releases the lock")
|
|
}
|
|
})
|
|
|
|
t.Run("different directories do not block", func(t *testing.T) {
|
|
dirA := t.TempDir()
|
|
dirB := t.TempDir()
|
|
|
|
unlockA := acquireCloneLock(dirA)
|
|
defer unlockA()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
unlock := acquireCloneLock(dirB)
|
|
unlock()
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("acquire on a different directory must not block")
|
|
}
|
|
})
|
|
|
|
t.Run("same directory reuses the same mutex", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
v1, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
|
|
v2, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
|
|
require.Same(t, v1, v2)
|
|
})
|
|
}
|