mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-04 07:33:47 +00:00
Without a docker daemon six `act/runner` tests failed instead of passing. A dry run still created and removed job networks, the job teardown dropped the dry-run flag along with every other context value, and the container mocks asked the daemon for the runner arch. This also speeds up the tests and CI without dropping assertions. Most of the time went to git and docker round trips, which are now pinned, batched or coalesced, and the dind and e2e steps cache or overlap more of their setup. On CI, `act/runner` went from 77s to 55s and `test-dind` from 108s to 55s. Locally, `act/runner` without docker went from 53s to 7s. Split out of https://gitea.com/gitea/runner/pulls/1348, which adds Windows CI on top. The go cache changes moved to https://gitea.com/gitea/runner/pulls/1350. Written by Claude Code. --------- Co-authored-by: bircni <bircni@icloud.com> Reviewed-on: https://gitea.com/gitea/runner/pulls/1349 Reviewed-by: bircni <bircni@icloud.com> Co-authored-by: silverwind <me@silverwind.io>
692 lines
23 KiB
Go
692 lines
23 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"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"testing"
|
|
"testing/synctest"
|
|
"time"
|
|
|
|
"gitea.com/gitea/runner/act/common/logging"
|
|
"gitea.com/gitea/runner/act/common/logging/logtest"
|
|
"gitea.com/gitea/runner/internal/pkg/lock"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
var gitFixtureDir string
|
|
|
|
func TestMain(m *testing.M) {
|
|
var err error
|
|
gitFixtureDir, err = os.MkdirTemp("", "runner-git-fixture-")
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
code := 1
|
|
if err = gitCmd("init", "--quiet", "--template=", "--bare", "--initial-branch=main", filepath.Join(gitFixtureDir, "remote")); err == nil {
|
|
err = gitCmd("init", "--quiet", "--template=", "--initial-branch=main", filepath.Join(gitFixtureDir, "work"))
|
|
}
|
|
if err == nil {
|
|
code = m.Run()
|
|
} else {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
}
|
|
if err := os.RemoveAll(gitFixtureDir); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
code = 1
|
|
}
|
|
os.Exit(code)
|
|
}
|
|
|
|
func TestFindGitSlug(t *testing.T) {
|
|
for _, testcase := range []struct {
|
|
remoteURL string
|
|
slug string
|
|
}{
|
|
{"https://git-codecommit.us-east-1.amazonaws.com/v1/repos/my-repo-name", "my-repo-name"},
|
|
{"ssh://git-codecommit.us-west-2.amazonaws.com/v1/repos/my-repo", "my-repo"},
|
|
{"git@github.com:nektos/act.git", "nektos/act"},
|
|
{"git@github.com:nektos/act", "nektos/act"},
|
|
{"https://github.com/nektos/act.git", "nektos/act"},
|
|
{"http://github.com/nektos/act.git", "nektos/act"},
|
|
{"https://github.com/nektos/act", "nektos/act"},
|
|
{"http://github.com/nektos/act", "nektos/act"},
|
|
{"git+ssh://git@github.com/owner/repo.git", "owner/repo"},
|
|
{"https://example.com:3000/gitea/owner/repo.git/", "owner/repo"},
|
|
{"ssh://git@example.com:2222/gitea/owner/repo.git", "owner/repo"},
|
|
{"example.com:owner/repo.git", "owner/repo"},
|
|
{"http://myotherrepo.com/act.git", ""},
|
|
{"https://user:secret@example.com/act.git", ""},
|
|
{"/tmp/owner/repo.git", ""},
|
|
{"./owner/repo:branch", ""},
|
|
} {
|
|
t.Run(testcase.remoteURL, func(t *testing.T) {
|
|
slug, err := findGitSlug(testcase.remoteURL)
|
|
assert.Equal(t, testcase.slug == "", err != nil)
|
|
assert.NotContains(t, fmt.Sprint(err), "secret")
|
|
assert.Equal(t, testcase.slug, slug)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestErrorWrapsCommitAndCause(t *testing.T) {
|
|
err := &Error{err: ErrShortRef, commit: "abc123"}
|
|
require.Equal(t, ErrShortRef.Error(), err.Error())
|
|
require.ErrorIs(t, err, ErrShortRef)
|
|
require.Equal(t, "abc123", err.Commit())
|
|
}
|
|
|
|
func TestFindGitMetadataOfSHA256RepositoryUsesOrigin(t *testing.T) {
|
|
t.Parallel()
|
|
basedir := t.TempDir()
|
|
const remoteURL = "https://github.com/owner/repo.git"
|
|
require.NoError(t, gitCmd("init", "--object-format=sha256", "--initial-branch=main", basedir))
|
|
require.NoError(t, os.RemoveAll(filepath.Join(basedir, ".git", "hooks")))
|
|
require.NoError(t, gitCmd("-C", basedir, "commit", "--allow-empty", "-m", "init"))
|
|
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", remoteURL))
|
|
require.NoError(t, gitCmd("-C", basedir, "config", "--add", "remote.origin.url", "https://github.com/other/repo.git"))
|
|
|
|
sha, err := FindGitRevision(t.Context(), basedir)
|
|
require.NoError(t, err)
|
|
require.Equal(t, gitRevParse(t, basedir, "HEAD"), sha)
|
|
require.Len(t, sha, 64)
|
|
|
|
ref, err := FindGitRef(t.Context(), basedir)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "refs/heads/main", ref)
|
|
|
|
url, err := findGitRemoteURL(t.Context(), basedir)
|
|
require.NoError(t, err)
|
|
require.Equal(t, remoteURL, url)
|
|
|
|
slug, err := FindGithubRepo(t.Context(), basedir)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "owner/repo", slug)
|
|
}
|
|
|
|
func TestGitFindRef(t *testing.T) {
|
|
t.Parallel()
|
|
basedir := t.TempDir()
|
|
|
|
for name, tt := range map[string]struct {
|
|
commands [][]string
|
|
ref string
|
|
}{
|
|
"new_repo": {},
|
|
"new_repo_with_commit": {
|
|
commands: [][]string{{"commit", "--allow-empty", "-m", "msg"}},
|
|
ref: "refs/heads/master",
|
|
},
|
|
"current_head_is_tag": {
|
|
commands: [][]string{
|
|
{"commit", "--allow-empty", "-m", "commit msg"},
|
|
{"tag", "v1.2.3"},
|
|
{"checkout", "v1.2.3"},
|
|
},
|
|
ref: "refs/tags/v1.2.3",
|
|
},
|
|
"current_head_is_same_as_tag": {
|
|
commands: [][]string{
|
|
{"commit", "--allow-empty", "-m", "1.4.2 release"},
|
|
{"tag", "v1.4.2"},
|
|
},
|
|
ref: "refs/tags/v1.4.2",
|
|
},
|
|
"current_head_is_not_tag": {
|
|
commands: [][]string{
|
|
{"commit", "--allow-empty", "-m", "msg"},
|
|
{"tag", "v1.4.2"},
|
|
{"commit", "--allow-empty", "-m", "msg2"},
|
|
},
|
|
ref: "refs/heads/master",
|
|
},
|
|
"current_head_is_another_branch": {
|
|
commands: [][]string{
|
|
{"checkout", "-b", "mybranch"},
|
|
{"commit", "--allow-empty", "-m", "msg"},
|
|
},
|
|
ref: "refs/heads/mybranch",
|
|
},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
dir := filepath.Join(basedir, name)
|
|
require.NoError(t, os.CopyFS(dir, os.DirFS(filepath.Join(gitFixtureDir, "work"))))
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, ".git", "HEAD"), []byte("ref: refs/heads/master\n"), 0o644))
|
|
for _, command := range tt.commands {
|
|
require.NoError(t, gitCmd(append([]string{"-C", dir}, command...)...))
|
|
}
|
|
ref, err := FindGitRef(t.Context(), dir)
|
|
if tt.ref == "" {
|
|
require.Error(t, err)
|
|
} else {
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.ref, ref)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGitCloneExecutor(t *testing.T) {
|
|
remoteDir, workDir := newGitRemote(t)
|
|
require.NoError(t, os.WriteFile(filepath.Join(workDir, "action.sh"), []byte("#!/bin/sh\necho hi\n"), 0o755))
|
|
require.NoError(t, gitCmd("-C", workDir, "add", "action.sh"))
|
|
require.NoError(t, gitCmd("-C", workDir, "commit", "-m", "initial"))
|
|
require.NoError(t, gitCmd("-C", workDir, "tag", "v2"))
|
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v2"))
|
|
|
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "act-fails"))
|
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "branch-commit"))
|
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "act-fails"))
|
|
|
|
fullSha := gitRevParse(t, workDir, "main")
|
|
|
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:refs/heads/"+fullSha[:4]))
|
|
|
|
hostConfig := filepath.Join(t.TempDir(), "gitconfig")
|
|
require.NoError(t, os.WriteFile(hostConfig, []byte("[core]\n\tautocrlf = true\n[url \""+remoteDir+"\"]\n\tinsteadOf = https://example.invalid/action\n"), 0o644))
|
|
t.Setenv("GIT_CONFIG_GLOBAL", hostConfig)
|
|
t.Setenv("GIT_DIR", t.TempDir())
|
|
|
|
var clones sync.WaitGroup
|
|
for name, tt := range map[string]struct {
|
|
Err error
|
|
Ref string
|
|
}{
|
|
"tag": {
|
|
Err: nil,
|
|
Ref: "v2",
|
|
},
|
|
"branch": {
|
|
Err: nil,
|
|
Ref: "act-fails",
|
|
},
|
|
"sha": {
|
|
Err: nil,
|
|
Ref: fullSha,
|
|
},
|
|
"short-sha": {
|
|
Err: &Error{ErrShortRef, fullSha},
|
|
Ref: fullSha[:7],
|
|
},
|
|
"HEAD": {
|
|
Ref: "HEAD",
|
|
},
|
|
"hex branch named like its own commit": {
|
|
Ref: fullSha[:4],
|
|
},
|
|
} {
|
|
clones.Go(func() {
|
|
t.Run(name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
|
URL: "https://example.invalid/action",
|
|
Ref: tt.Ref,
|
|
Dir: dir,
|
|
})
|
|
|
|
err := clone(t.Context())
|
|
if tt.Err != nil {
|
|
require.Error(t, err)
|
|
assert.Equal(t, tt.Err, err)
|
|
assert.Equal(t, tt.Err, clone(t.Context()), "a retry on the cache keeps the error")
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
marker := filepath.Join(dir, "marker")
|
|
require.NoError(t, os.WriteFile(marker, nil, 0o644))
|
|
require.NoError(t, clone(t.Context()))
|
|
assert.FileExists(t, marker, "an insteadOf rewrite must not evict the cache")
|
|
script, err := os.ReadFile(filepath.Join(dir, "action.sh"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "#!/bin/sh\necho hi\n", string(script), "host core.autocrlf must not convert action files")
|
|
})
|
|
})
|
|
}
|
|
clones.Wait()
|
|
}
|
|
|
|
func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
|
|
t.Parallel()
|
|
createRemote := func(message string) string {
|
|
remoteDir, workDir := newGitRemote(t, message)
|
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
|
|
|
return remoteDir
|
|
}
|
|
|
|
oldRemoteDir := createRemote("old-action")
|
|
newRemoteDir := createRemote("new-action")
|
|
cacheDir := t.TempDir()
|
|
|
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
|
URL: oldRemoteDir,
|
|
Ref: "main",
|
|
Dir: cacheDir,
|
|
})(t.Context()))
|
|
|
|
nested := filepath.Join(cacheDir, "nested")
|
|
require.NoError(t, os.Mkdir(nested, 0o755))
|
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{URL: oldRemoteDir, Ref: "main", Dir: nested})(t.Context()))
|
|
assert.DirExists(t, filepath.Join(nested, ".git"), "an empty cache dir inside a clone of the same URL gets its own repository")
|
|
|
|
markerPath := filepath.Join(cacheDir, "stale-marker")
|
|
require.NoError(t, os.WriteFile(markerPath, []byte("stale"), 0o644))
|
|
|
|
cancelled, cancel := context.WithCancel(t.Context())
|
|
cancel()
|
|
_, err := CloneIfRequired(cancelled, NewGitCloneExecutorInput{URL: oldRemoteDir, Ref: "main", Dir: cacheDir}, logging.New(logtest.New()))
|
|
require.ErrorIs(t, err, context.Canceled)
|
|
require.FileExists(t, markerPath)
|
|
|
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
|
URL: newRemoteDir,
|
|
Ref: "main",
|
|
Dir: cacheDir,
|
|
})(t.Context()))
|
|
|
|
originURL, err := findGitRemoteURL(t.Context(), cacheDir)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, newRemoteDir, originURL)
|
|
|
|
out, err := exec.Command("git", "-C", cacheDir, "log", "--oneline", "-1", "--format=%s").Output()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "new-action", strings.TrimSpace(string(out)))
|
|
|
|
_, err = os.Stat(markerPath)
|
|
require.True(t, os.IsNotExist(err), "stale cached directory should be removed before recloning")
|
|
}
|
|
|
|
func TestGitCloneExecutorFollowsRemoteRefChanges(t *testing.T) {
|
|
t.Parallel()
|
|
remoteDir, workDir := newGitRemote(t, "initial", "feature-1")
|
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
|
|
|
dir := t.TempDir()
|
|
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remoteDir, Ref: "main", Dir: dir})
|
|
require.NoError(t, clone(t.Context()))
|
|
require.NoError(t, gitCmd("-C", dir, "config", "fetch.prune", "true"))
|
|
require.NoError(t, clone(t.Context()))
|
|
|
|
require.NoError(t, gitCmd("-C", workDir, "reset", "--hard", "HEAD~1"))
|
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "feature-rewritten"))
|
|
require.NoError(t, gitCmd("-C", workDir, "push", "--force", "origin", "main"))
|
|
require.NoError(t, clone(t.Context()), "a non-fast-forward ref must update")
|
|
assert.Equal(t, "feature-rewritten", gitHeadSubject(t, dir))
|
|
|
|
tagDir := t.TempDir()
|
|
tagClone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remoteDir, Ref: "v9", Dir: tagDir})
|
|
require.Error(t, tagClone(t.Context()))
|
|
require.NoError(t, gitCmd("-C", workDir, "tag", "v9"))
|
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v9"))
|
|
require.NoError(t, tagClone(t.Context()), "a tag published after a failed lookup must be fetched")
|
|
|
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "tag-moved"))
|
|
require.NoError(t, gitCmd("-C", workDir, "tag", "--force", "v9"))
|
|
require.NoError(t, gitCmd("-C", workDir, "push", "--force", "origin", "v9"))
|
|
for range 2 {
|
|
require.NoError(t, tagClone(t.Context()), "a moved tag must update and stay current")
|
|
assert.Equal(t, "tag-moved", gitHeadSubject(t, tagDir))
|
|
}
|
|
}
|
|
|
|
func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
|
t.Parallel()
|
|
remoteDir, workDir := newGitRemote(t, "initial")
|
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
|
|
|
cacheDir := t.TempDir()
|
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
|
URL: remoteDir,
|
|
Ref: "main",
|
|
Dir: cacheDir,
|
|
})(t.Context()))
|
|
|
|
t.Run("cached branch resolves without fetching", func(t *testing.T) {
|
|
err := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
|
URL: remoteDir,
|
|
Ref: "main",
|
|
Dir: cacheDir,
|
|
OfflineMode: true,
|
|
})(t.Context())
|
|
require.NoError(t, err)
|
|
|
|
out, err := exec.Command("git", "-C", cacheDir, "log", "--oneline", "-1", "--format=%s").Output()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "initial", strings.TrimSpace(string(out)))
|
|
})
|
|
|
|
t.Run("unresolvable cached ref returns error", func(t *testing.T) {
|
|
err := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
|
URL: remoteDir,
|
|
Ref: "never-fetched",
|
|
Dir: cacheDir,
|
|
OfflineMode: true,
|
|
})(t.Context())
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
|
|
func TestGitCloneExecutorShallow(t *testing.T) {
|
|
t.Parallel()
|
|
remoteDir, workDir := newGitRemote(t, "c1", "c2", "c3")
|
|
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
|
unadvertisedSHA := gitRevParse(t, workDir, "HEAD~1")
|
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
|
|
|
|
shallowMarker := func(dir string) string { return filepath.Join(dir, ".git", "shallow") }
|
|
|
|
for name, ref := range map[string]string{"branch is cloned shallowly": "main", "tag is cloned shallowly": "v1"} {
|
|
t.Run(name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
|
URL: remoteDir, Ref: ref, Dir: dir, Depth: 1,
|
|
})(t.Context()))
|
|
assert.FileExists(t, shallowMarker(dir), "clone should be shallow")
|
|
assert.Equal(t, 1, gitRevCount(t, dir), "only the tip commit should be present")
|
|
assert.Equal(t, "c3", gitHeadSubject(t, dir))
|
|
})
|
|
}
|
|
|
|
unadvertisedRemote := func(t *testing.T, allowUnadvertised bool) string {
|
|
remote := filepath.Join(t.TempDir(), "remote.git")
|
|
require.NoError(t, gitCmd("clone", "--bare", remoteDir, remote))
|
|
require.NoError(t, gitCmd("-C", remote, "config", "uploadpack.allowAnySHA1InWant", strconv.FormatBool(allowUnadvertised)))
|
|
return remote
|
|
}
|
|
|
|
t.Run("commit hash resolves when the remote accepts a direct shallow fetch", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
|
URL: unadvertisedRemote(t, false), Ref: unadvertisedSHA, Dir: dir, Depth: 1,
|
|
})(t.Context()))
|
|
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
|
|
})
|
|
|
|
t.Run("commit hash is fetched shallowly, moved to another hash, then reused without reaching the remote", func(t *testing.T) {
|
|
remote := unadvertisedRemote(t, true)
|
|
dir := t.TempDir()
|
|
cloneAt := func(ref string) error {
|
|
return NewGitCloneExecutor(NewGitCloneExecutorInput{
|
|
URL: remote, Ref: ref, Dir: dir, Depth: 1,
|
|
})(t.Context())
|
|
}
|
|
|
|
require.NoError(t, cloneAt(unadvertisedSHA))
|
|
assert.FileExists(t, shallowMarker(dir))
|
|
assert.Equal(t, 1, gitRevCount(t, dir))
|
|
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
|
|
|
|
olderSHA := gitRevParse(t, workDir, "HEAD~2")
|
|
require.NoError(t, cloneAt(olderSHA))
|
|
assert.Equal(t, olderSHA, gitRevParse(t, dir, "HEAD"))
|
|
|
|
require.NoError(t, os.RemoveAll(remote))
|
|
require.NoError(t, cloneAt(olderSHA))
|
|
assert.Equal(t, olderSHA, gitRevParse(t, dir, "HEAD"))
|
|
})
|
|
|
|
t.Run("moving hexadecimal-named branch updates while staying shallow", func(t *testing.T) {
|
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef"))
|
|
dir := t.TempDir()
|
|
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
|
URL: remoteDir, Ref: "deadbeef", Dir: dir, Depth: 1,
|
|
})
|
|
require.NoError(t, clone(t.Context()))
|
|
require.Equal(t, "c3", gitHeadSubject(t, dir))
|
|
|
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c4"))
|
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef"))
|
|
|
|
require.NoError(t, clone(t.Context()))
|
|
assert.Equal(t, "c4", gitHeadSubject(t, dir), "reused shallow clone should update to the new tip")
|
|
assert.FileExists(t, shallowMarker(dir), "repo should remain shallow after update")
|
|
assert.Equal(t, 1, gitRevCount(t, dir))
|
|
})
|
|
|
|
t.Run("sha256 full hash is cloned shallowly, reused without the remote, and its 40-character prefix is short", func(t *testing.T) {
|
|
remote := filepath.Join(t.TempDir(), "remote.git")
|
|
require.NoError(t, gitCmd("init", "--bare", "--object-format=sha256", "--initial-branch=main", remote))
|
|
work := t.TempDir()
|
|
require.NoError(t, gitCmd("init", "--object-format=sha256", "--initial-branch=main", work))
|
|
require.NoError(t, gitCmd("-C", work, "remote", "add", "origin", remote))
|
|
require.NoError(t, gitCmd("-C", work, "commit", "--allow-empty", "-m", "pinned"))
|
|
require.NoError(t, gitCmd("-C", work, "push", "origin", "main"))
|
|
sha := gitRevParse(t, work, "HEAD")
|
|
dir := t.TempDir()
|
|
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remote, Ref: sha, Dir: dir, Depth: 1})
|
|
require.NoError(t, clone(t.Context()))
|
|
assert.FileExists(t, shallowMarker(dir))
|
|
require.ErrorIs(t, NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remote, Ref: sha[:40], Dir: dir, Depth: 1, OfflineMode: true})(t.Context()), ErrShortRef)
|
|
require.NoError(t, os.RemoveAll(remote))
|
|
require.NoError(t, clone(t.Context()))
|
|
assert.Equal(t, sha, gitRevParse(t, dir, "HEAD"))
|
|
})
|
|
}
|
|
|
|
func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) {
|
|
t.Parallel()
|
|
remoteDir, workDir := newGitRemote(t, "pinned")
|
|
pinned := gitRevParse(t, workDir, "HEAD")
|
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "decoy"))
|
|
require.NoError(t, gitCmd("-C", workDir, "tag", pinned))
|
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", pinned))
|
|
|
|
for name, depth := range map[string]int{"shallow": 1, "full clone": 0} {
|
|
t.Run(name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
|
URL: remoteDir, Ref: pinned, Dir: dir, Depth: depth,
|
|
})(t.Context()))
|
|
assert.Equal(t, pinned, gitRevParse(t, dir, "HEAD"))
|
|
})
|
|
}
|
|
}
|
|
|
|
func newGitRemote(t *testing.T, messages ...string) (string, string) {
|
|
t.Helper()
|
|
remoteDir, workDir := t.TempDir(), t.TempDir()
|
|
require.NoError(t, os.CopyFS(remoteDir, os.DirFS(filepath.Join(gitFixtureDir, "remote"))))
|
|
require.NoError(t, os.CopyFS(workDir, os.DirFS(filepath.Join(gitFixtureDir, "work"))))
|
|
config, err := os.ReadFile(filepath.Join(workDir, ".git", "config"))
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(filepath.Join(workDir, ".git", "config"),
|
|
fmt.Appendf(config, "\n[remote \"origin\"]\n\turl = %s\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n", strconv.Quote(remoteDir)), 0o644))
|
|
for _, message := range messages {
|
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", message))
|
|
}
|
|
return remoteDir, workDir
|
|
}
|
|
|
|
func gitRevParse(t *testing.T, dir, rev string) string {
|
|
t.Helper()
|
|
out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output()
|
|
require.NoError(t, err)
|
|
return strings.TrimSpace(string(out))
|
|
}
|
|
|
|
func gitRevCount(t *testing.T, dir string) int {
|
|
t.Helper()
|
|
out, err := exec.Command("git", "-C", dir, "rev-list", "--count", "HEAD").Output()
|
|
require.NoError(t, err)
|
|
n, err := strconv.Atoi(strings.TrimSpace(string(out)))
|
|
require.NoError(t, err)
|
|
return n
|
|
}
|
|
|
|
func gitHeadSubject(t *testing.T, dir string) string {
|
|
t.Helper()
|
|
out, err := exec.Command("git", "-C", dir, "log", "-1", "--format=%s").Output()
|
|
require.NoError(t, err)
|
|
return strings.TrimSpace(string(out))
|
|
}
|
|
|
|
func gitCmd(args ...string) error {
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
cmd.Env = append(os.Environ(),
|
|
"GIT_AUTHOR_NAME=Unit Test",
|
|
"GIT_AUTHOR_EMAIL=test@test.com",
|
|
"GIT_COMMITTER_NAME=Unit Test",
|
|
"GIT_COMMITTER_EMAIL=test@test.com",
|
|
"GIT_CONFIG_GLOBAL=/dev/null",
|
|
"GIT_CONFIG_SYSTEM=/dev/null",
|
|
)
|
|
|
|
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("blocks on a lock held by another process despite a trailing separator", func(t *testing.T) {
|
|
dir := filepath.Join(t.TempDir(), "cache")
|
|
release, err := lock.TryLock(dir)
|
|
require.NoError(t, err)
|
|
defer func() { require.NoError(t, release()) }()
|
|
synctest.Test(t, func(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
|
|
defer cancel()
|
|
_, err := AcquireCloneLock(ctx, dir+string(filepath.Separator))
|
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
|
})
|
|
})
|
|
|
|
t.Run("read-only parent falls back to an in-process lock", func(t *testing.T) {
|
|
parent := t.TempDir()
|
|
dir := filepath.Join(parent, "cache")
|
|
require.NoError(t, os.Mkdir(dir, 0o755))
|
|
require.NoError(t, os.Chmod(parent, 0o555))
|
|
defer func() { require.NoError(t, os.Chmod(parent, 0o755)) }()
|
|
unlock, err := AcquireCloneLock(t.Context(), dir)
|
|
require.NoError(t, err)
|
|
unlock()
|
|
})
|
|
|
|
t.Run("same directory serializes", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
unlock1, err := AcquireCloneLock(t.Context(), dir)
|
|
require.NoError(t, err)
|
|
|
|
secondAcquired := make(chan struct{})
|
|
go func() {
|
|
unlock, err := AcquireCloneLock(t.Context(), dir)
|
|
if !assert.NoError(t, err) {
|
|
return
|
|
}
|
|
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, err := AcquireCloneLock(t.Context(), dirA)
|
|
require.NoError(t, err)
|
|
defer unlockA()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
unlock, err := AcquireCloneLock(t.Context(), dirB)
|
|
if !assert.NoError(t, err) {
|
|
return
|
|
}
|
|
unlock()
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("acquire on a different directory must not block")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestNewGitCloneExecutorFetchSendsTokenAndHonoursContext(t *testing.T) {
|
|
t.Parallel()
|
|
block := make(chan struct{})
|
|
reached := make(chan struct{})
|
|
var once sync.Once
|
|
var authorization string
|
|
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) {
|
|
once.Do(func() {
|
|
authorization = request.Header.Get("Authorization")
|
|
close(reached)
|
|
})
|
|
<-block
|
|
}))
|
|
t.Cleanup(func() {
|
|
close(block)
|
|
server.Close()
|
|
})
|
|
|
|
dir := filepath.Join(t.TempDir(), "cached-action")
|
|
require.NoError(t, gitCmd("init", dir))
|
|
require.NoError(t, gitCmd("-C", dir, "remote", "add", "origin", server.URL))
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
defer cancel()
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- NewGitCloneExecutor(NewGitCloneExecutorInput{URL: server.URL, Ref: "main", Dir: dir, Token: "secret"})(ctx)
|
|
}()
|
|
|
|
select {
|
|
case <-reached:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("the executor never reached the remote")
|
|
}
|
|
assert.Equal(t, "Basic "+base64.StdEncoding.EncodeToString([]byte("token:secret")), authorization)
|
|
cancel()
|
|
|
|
select {
|
|
case err := <-done:
|
|
require.Error(t, err)
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("fetch ignored context cancellation")
|
|
}
|
|
}
|