mirror of
https://code.forgejo.org/forgejo/runner.git
synced 2026-10-24 16:59:19 +03:00
In testing of 5 popular action repos, this change uses 100x less disk space for the actions repo cache and makes initial repo cloning 12x faster. It does this by only fetching 1 commit and 0-1 refs from the remote whereas current `main` code unconditionally fetches all refs including tags, branches and the full commit history associated with each ref and any other reachable objects. Reviewed-on: https://code.forgejo.org/forgejo/runner/pulls/1722 Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.code.forgejo.org> Reviewed-by: Mathieu Fenniak <mfenniak@noreply.code.forgejo.org>
1484 lines
46 KiB
Go
1484 lines
46 KiB
Go
package git
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"code.forgejo.org/forgejo/runner/v13/act/common"
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestFindSlug(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"},
|
|
{"ssh://git@example.com/forgejo/act.git", "GitHubEnterprise", "forgejo/act"},
|
|
{"ssh://git@example.com:2222/forgejo/act.git", "GitHubEnterprise", "forgejo/act"},
|
|
{"ssh://git@example.com:forgejo/act.git", "GitHubEnterprise", "forgejo/act"},
|
|
{"ssh://git@example.com:/forgejo/act.git", "GitHubEnterprise", "forgejo/act"},
|
|
}
|
|
|
|
for _, tt := range slugTests {
|
|
instance := "example.com"
|
|
if tt.provider == "GitHub" {
|
|
instance = "github.com"
|
|
}
|
|
|
|
provider, slug, err := findSlug(tt.url, instance)
|
|
|
|
assert.NoError(err)
|
|
assert.Equal(tt.provider, provider)
|
|
assert.Equal(tt.slug, slug)
|
|
}
|
|
}
|
|
|
|
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 TestGetRemoteURL(t *testing.T) {
|
|
assert := assert.New(t)
|
|
|
|
basedir := t.TempDir()
|
|
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 := getRemoteURL(t.Context(), 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 = getRemoteURL(t.Context(), basedir, "upstream")
|
|
assert.NoError(err)
|
|
assert.Equal(remoteURL, u)
|
|
}
|
|
|
|
func TestDescribeHead(t *testing.T) {
|
|
basedir := t.TempDir()
|
|
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, gitCmd("-C", dir, "config", "user.name", "user@example.com"))
|
|
require.NoError(t, gitCmd("-C", dir, "config", "user.email", "user@example.com"))
|
|
require.NoError(t, cleanGitHooks(dir))
|
|
tt.Prepare(t, dir)
|
|
ref, err := DescribeHead(t.Context(), dir)
|
|
tt.Assert(t, ref, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClone(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",
|
|
},
|
|
"annotated-tag": {
|
|
Err: nil,
|
|
URL: "https://github.com/Swatinem/rust-cache",
|
|
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
|
|
},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test")
|
|
}
|
|
|
|
wt, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: t.TempDir(),
|
|
URL: tt.URL,
|
|
Ref: tt.Ref,
|
|
})
|
|
if tt.Err != nil {
|
|
assert.Error(t, err)
|
|
assert.Equal(t, tt.Err, err)
|
|
} else {
|
|
require.NoError(t, err)
|
|
wt.Close(t.Context())
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("Does not resolve cached short SHAs", func(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
remoteDir := makeTestRepo(t)
|
|
fullSHA := makeTestCommit(t, remoteDir, "initial commit")
|
|
|
|
// Pull the full 5a4ac90 commit into the cache.
|
|
_, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: fullSHA,
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: fullSHA[:7], // v2
|
|
})
|
|
// Verify short prefix does not resolve.
|
|
assert.Error(t, err)
|
|
assert.Equal(t, &Error{ErrShortRef, fullSHA}, err)
|
|
})
|
|
|
|
t.Run("Does not resolve remote commit via short SHA", func(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
remoteDir := makeTestRepo(t)
|
|
|
|
fullSHA := makeTestCommit(t, remoteDir, "initial commit")
|
|
|
|
_, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: fullSHA[:7],
|
|
})
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, fmt.Sprintf("couldn't find remote ref %s", fullSHA[:7]))
|
|
})
|
|
|
|
t.Run("Fetches New Commit", func(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
|
|
// Create a local repo that will act as the remote to be cloned.
|
|
remoteDir := makeTestRepo(t)
|
|
|
|
// Create a commit and get its full SHA
|
|
firstCommit := makeTestCommit(t, remoteDir, "initial commit")
|
|
|
|
// Clone the repo, referencing firstCommit
|
|
wt1, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: firstCommit,
|
|
})
|
|
require.NoError(t, err)
|
|
defer wt1.Close(t.Context())
|
|
|
|
// Verify firstCommit is the HEAD of the clone.
|
|
firstHead := getTestRepoHead(t, wt1.WorktreeDir())
|
|
assert.Equal(t, firstCommit, firstHead)
|
|
|
|
// Create a new commit in the "remote".
|
|
secondCommit := makeTestCommit(t, remoteDir, "second commit")
|
|
|
|
// Run the clone again, this time referencing the new commit.
|
|
wt2, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: secondCommit,
|
|
})
|
|
require.NoError(t, err)
|
|
defer wt2.Close(t.Context())
|
|
|
|
// The clone should have the new commit as its HEAD
|
|
secondHead := getTestRepoHead(t, wt2.WorktreeDir())
|
|
assert.Equal(t, secondCommit, secondHead)
|
|
})
|
|
|
|
t.Run("sha256/Fetches New Commit", func(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
|
|
remoteDir := makeTestRepo(t, "--object-format=sha256")
|
|
|
|
// Create a commit and get its full SHA
|
|
firstCommit := makeTestCommit(t, remoteDir, "initial commit")
|
|
|
|
// Clone the repo, referencing firstCommit
|
|
wt1, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: firstCommit,
|
|
})
|
|
require.NoError(t, err)
|
|
defer wt1.Close(t.Context())
|
|
|
|
// Verify firstCommit is the HEAD of the clone.
|
|
firstHead := getTestRepoHead(t, wt1.WorktreeDir())
|
|
assert.Equal(t, firstCommit, firstHead)
|
|
|
|
// Create a new commit in the "remote".
|
|
secondCommit := makeTestCommit(t, remoteDir, "second commit")
|
|
|
|
// Run the clone again, this time referencing the new commit.
|
|
wt2, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: secondCommit,
|
|
})
|
|
require.NoError(t, err)
|
|
defer wt2.Close(t.Context())
|
|
|
|
// The clone should have the new commit as its HEAD
|
|
secondHead := getTestRepoHead(t, wt2.WorktreeDir())
|
|
assert.Equal(t, secondCommit, secondHead)
|
|
})
|
|
|
|
t.Run("Skips Fetch on Present Full SHA", func(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
|
|
// Create a local repo that will act as the remote to be cloned.
|
|
remoteDir := makeTestRepo(t)
|
|
|
|
// Create a commit and get its full SHA
|
|
fullSHA := makeTestCommit(t, remoteDir, "initial commit")
|
|
|
|
// Clone the repo by fullSHA
|
|
wt1, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: fullSHA,
|
|
})
|
|
require.NoError(t, err)
|
|
defer wt1.Close(t.Context())
|
|
|
|
// Verify that the head in cloneDir is correct.
|
|
clonedSHA := getTestRepoHead(t, wt1.WorktreeDir())
|
|
assert.Equal(t, fullSHA, clonedSHA)
|
|
|
|
// Create a new commit in the "remote".
|
|
newCommitSHA := makeTestCommit(t, remoteDir, "second commit")
|
|
|
|
// Run the clone again, still targeting the first SHA.
|
|
wt2, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: fullSHA,
|
|
})
|
|
require.NoError(t, err)
|
|
defer wt2.Close(t.Context())
|
|
|
|
// The clone should still have the original fullSHA as its HEAD...
|
|
clonedSHA2 := getTestRepoHead(t, wt2.WorktreeDir())
|
|
assert.Equal(t, fullSHA, clonedSHA2)
|
|
|
|
// And we can be sure that the clone operation didn't do a fetch if the second commit, `newCommitSHA`, isn't present:
|
|
cmd := exec.Command("git", "-C", wt2.WorktreeDir(), "log", newCommitSHA)
|
|
output, err := cmd.CombinedOutput()
|
|
require.Error(t, err)
|
|
errorOutput := strings.TrimSpace(string(output))
|
|
assert.Contains(t, errorOutput, "bad object") // eg. "fatal: bad object f543870e42c0a04594770e8ecca8d259a35fa627"
|
|
})
|
|
|
|
t.Run("Refetches Tag Fast-Forward", func(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
|
|
// Create a local repo that will act as the remote to be cloned.
|
|
remoteDir := makeTestRepo(t)
|
|
|
|
// Create a tag
|
|
fullSHA := makeTestCommit(t, remoteDir, "initial commit")
|
|
makeTestTag(t, remoteDir, fullSHA, "tag-1")
|
|
|
|
// Clone the repo by tag
|
|
wt1, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: "tag-1",
|
|
})
|
|
require.NoError(t, err)
|
|
defer wt1.Close(t.Context())
|
|
|
|
// Verify that the head in cloneDir is correct.
|
|
clonedSHA := getTestRepoHead(t, wt1.WorktreeDir())
|
|
assert.Equal(t, fullSHA, clonedSHA)
|
|
|
|
// Create a new commit in the "remote", and move the tag
|
|
newCommitSHA := makeTestCommit(t, remoteDir, "second commit")
|
|
makeTestTag(t, remoteDir, newCommitSHA, "tag-1")
|
|
|
|
// Run the clone again
|
|
wt2, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: "tag-1",
|
|
})
|
|
require.NoError(t, err)
|
|
defer wt2.Close(t.Context())
|
|
|
|
// The clone should be updated to the new tag ref
|
|
clonedSHA = getTestRepoHead(t, wt2.WorktreeDir())
|
|
assert.Equal(t, newCommitSHA, clonedSHA)
|
|
})
|
|
|
|
t.Run("Ref is required", func(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
remoteDir := makeTestRepo(t)
|
|
_, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: "",
|
|
})
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "missing Ref")
|
|
})
|
|
|
|
t.Run("Clones Annotated Tag", func(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
|
|
// Create a local repo that will act as the remote to be cloned.
|
|
remoteDir := makeTestRepo(t)
|
|
|
|
// Create a tag
|
|
fullSHA := makeTestCommit(t, remoteDir, "initial commit")
|
|
makeTestAnnotatedTag(t, remoteDir, fullSHA, "tag-1")
|
|
|
|
// Clone the repo by tag
|
|
wt1, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: "tag-1",
|
|
})
|
|
require.NoError(t, err)
|
|
defer wt1.Close(t.Context())
|
|
|
|
// Verify that the head in cloneDir is correct.
|
|
clonedSHA := getTestRepoHead(t, wt1.WorktreeDir())
|
|
assert.Equal(t, fullSHA, clonedSHA)
|
|
})
|
|
|
|
t.Run("Refetches Tag Force-Push", func(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
|
|
// Create a local repo that will act as the remote to be cloned.
|
|
remoteDir := makeTestRepo(t)
|
|
|
|
// Create a couple commits and then a tag; the history will be used to create a force-push situation.
|
|
commit1 := makeTestCommit(t, remoteDir, "commit 1")
|
|
commit2 := makeTestCommit(t, remoteDir, "commit 2")
|
|
makeTestTag(t, remoteDir, commit2, "tag-2")
|
|
|
|
// Clone the repo by tag
|
|
wt1, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: "tag-2",
|
|
})
|
|
require.NoError(t, err)
|
|
defer wt1.Close(t.Context())
|
|
|
|
// Verify that the head in cloneDir is correct.
|
|
clonedSHA := getTestRepoHead(t, wt1.WorktreeDir())
|
|
assert.Equal(t, commit2, clonedSHA)
|
|
|
|
// Do a `git reset` to revert the remoteDir back to the initial commit, then add a new commit, then move the tag
|
|
// to it.
|
|
require.NoError(t, gitCmd("-C", remoteDir, "reset", "--hard", commit1))
|
|
commit3 := makeTestCommit(t, remoteDir, "commit 3")
|
|
require.NotEqual(t, commit1, commit3) // seems like dumb assertions but just safety checks for the test
|
|
require.NotEqual(t, commit2, commit3)
|
|
makeTestTag(t, remoteDir, commit3, "tag-2")
|
|
|
|
// Run the clone again
|
|
wt2, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: "tag-2",
|
|
})
|
|
require.NoError(t, err)
|
|
defer wt2.Close(t.Context())
|
|
|
|
// The clone should be updated to the new tag ref
|
|
clonedSHA = getTestRepoHead(t, wt2.WorktreeDir())
|
|
assert.Equal(t, commit3, clonedSHA)
|
|
})
|
|
|
|
t.Run("Refetches Branch Fast-Forward", func(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
|
|
// Create a local repo that will act as the remote to be cloned.
|
|
remoteDir := makeTestRepo(t)
|
|
|
|
// Create a commit on main
|
|
fullSHA := makeTestCommit(t, remoteDir, "initial commit")
|
|
|
|
// Clone the repo by branch, main
|
|
wt1, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: "main",
|
|
})
|
|
require.NoError(t, err)
|
|
defer wt1.Close(t.Context())
|
|
|
|
// Verify that the head in cloneDir is correct
|
|
clonedSHA := getTestRepoHead(t, wt1.WorktreeDir())
|
|
assert.Equal(t, fullSHA, clonedSHA)
|
|
|
|
// Create a new commit in the "remote", moving the branch forward
|
|
newCommitSHA := makeTestCommit(t, remoteDir, "second commit")
|
|
|
|
// Run the clone again
|
|
wt2, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: "main",
|
|
})
|
|
require.NoError(t, err)
|
|
defer wt2.Close(t.Context())
|
|
|
|
// The clone should be updated to the new branch ref
|
|
clonedSHA = getTestRepoHead(t, wt2.WorktreeDir())
|
|
assert.Equal(t, newCommitSHA, clonedSHA)
|
|
})
|
|
|
|
t.Run("Refetches Branch Force-Push", func(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
|
|
// Create a local repo that will act as the remote to be cloned.
|
|
remoteDir := makeTestRepo(t)
|
|
|
|
// Create a couple commits on the main branch; the history will be used to create a force-push situation.
|
|
commit1 := makeTestCommit(t, remoteDir, "commit 1")
|
|
commit2 := makeTestCommit(t, remoteDir, "commit 2")
|
|
|
|
// Clone the repo by branch, main
|
|
wt1, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: "main",
|
|
})
|
|
require.NoError(t, err)
|
|
defer wt1.Close(t.Context())
|
|
|
|
// Verify that the head in cloneDir is correct.
|
|
clonedSHA := getTestRepoHead(t, wt1.WorktreeDir())
|
|
assert.Equal(t, commit2, clonedSHA)
|
|
|
|
// Do a `git reset` to revert the remoteDir back to the initial commit, then add a new commit, moving `main` in
|
|
// a non-fast-forward way.
|
|
require.NoError(t, gitCmd("-C", remoteDir, "reset", "--hard", commit1))
|
|
commit3 := makeTestCommit(t, remoteDir, "commit 3")
|
|
require.NotEqual(t, commit1, commit3) // seems like dumb assertions but just safety checks for the test
|
|
require.NotEqual(t, commit2, commit3)
|
|
|
|
// Run the clone again
|
|
wt2, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: "main",
|
|
})
|
|
require.NoError(t, err)
|
|
defer wt2.Close(t.Context())
|
|
|
|
// The clone should be updated to the new tag ref
|
|
clonedSHA = getTestRepoHead(t, wt2.WorktreeDir())
|
|
assert.Equal(t, commit3, clonedSHA)
|
|
})
|
|
|
|
t.Run("Performs update to resolve newly added branch", func(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
|
|
// Create a local repo that will act as the remote to be cloned.
|
|
remoteDir := makeTestRepo(t)
|
|
|
|
// Create some commits
|
|
commit1 := makeTestCommit(t, remoteDir, "commit 1")
|
|
commit2 := makeTestCommit(t, remoteDir, "commit 2")
|
|
|
|
// Clone the repo
|
|
wt1, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: "main",
|
|
})
|
|
require.NoError(t, err)
|
|
defer wt1.Close(t.Context())
|
|
|
|
// Verify that the head in cloneDir is correct.
|
|
clonedSHA := getTestRepoHead(t, wt1.WorktreeDir())
|
|
assert.Equal(t, commit2, clonedSHA)
|
|
|
|
// Create a branch that points to commit1
|
|
makeTestBranch(t, remoteDir, commit1, "test-branch")
|
|
|
|
// Clone test-branch
|
|
wt2, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: "test-branch",
|
|
})
|
|
require.NoError(t, err)
|
|
defer wt2.Close(t.Context())
|
|
|
|
// The clone should point to commit1 as we should now be on `test-branch`
|
|
clonedSHA = getTestRepoHead(t, wt2.WorktreeDir())
|
|
assert.Equal(t, commit1, clonedSHA)
|
|
})
|
|
|
|
t.Run("Fetches only requested ref", func(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
remoteDir := makeTestRepo(t)
|
|
remoteURL := "file://" + remoteDir
|
|
|
|
_ = makeTestCommit(t, remoteDir, "initial commit")
|
|
wt1, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteURL,
|
|
Ref: "main",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, wt1.Close(t.Context()))
|
|
|
|
// Advance main and created an unrelated branch.
|
|
require.NoError(t, gitCmd("-C", remoteDir, "checkout", "main"))
|
|
requestedCommit := makeTestCommit(t, remoteDir, "requested commit")
|
|
require.NoError(t, gitCmd("-C", remoteDir, "checkout", "-b", "unrelated"))
|
|
unrelatedCommit := makeTestCommit(t, remoteDir, "unrelated commit")
|
|
|
|
wt2, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteURL,
|
|
Ref: "main",
|
|
})
|
|
require.NoError(t, err)
|
|
defer wt2.Close(t.Context())
|
|
|
|
// Verify that HEAD is the requested commit.
|
|
assert.Equal(t, requestedCommit, getTestRepoHead(t, wt2.WorktreeDir()))
|
|
// Verify unrequested commit was not fetched.
|
|
exists, err := objectExists(t.Context(), wt2.WorktreeDir(), unrelatedCommit)
|
|
require.NoError(t, err)
|
|
assert.False(t, exists)
|
|
})
|
|
|
|
t.Run("Fetches only the requested shallow history", func(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
remoteDir := makeTestRepo(t)
|
|
remoteURL := "file://" + remoteDir
|
|
|
|
commits := make([]string, 5)
|
|
for i := range commits {
|
|
commits[i] = makeTestCommit(t, remoteDir, fmt.Sprintf("commit %d", i+1))
|
|
}
|
|
makeTestBranch(t, remoteDir, commits[1], "unrelated")
|
|
makeTestTag(t, remoteDir, commits[2], "unrelated-tag")
|
|
|
|
wt, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteURL,
|
|
Ref: "main",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, commits[4], getTestRepoHead(t, wt.WorktreeDir()))
|
|
require.NoError(t, wt.Close(t.Context()))
|
|
|
|
remoteURLHash := common.Sha256(remoteURL)
|
|
repoDir := filepath.Join(cacheDir, remoteURLHash[:2], remoteURLHash[2:])
|
|
|
|
objects, err := gitCmdWithStdout(
|
|
"-C", repoDir, "cat-file", "--batch-all-objects", "--batch-check=%(objecttype)",
|
|
)
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, []string{"commit", "tree"}, strings.Fields(string(objects)))
|
|
|
|
refs, err := gitCmdWithStdout("-C", repoDir, "for-each-ref", "--format=%(refname)")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"refs/heads/main"}, strings.Fields(string(refs)))
|
|
|
|
commitCount, err := gitCmdWithStdout("-C", repoDir, "rev-list", "--all", "--count")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "1", strings.TrimSpace(string(commitCount)))
|
|
})
|
|
|
|
t.Run("Does not create spurious refs", func(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
|
|
// Create a local repo that will act as the remote to be cloned.
|
|
remoteDir := makeTestRepo(t)
|
|
|
|
// Create a tag
|
|
fullSHA := makeTestCommit(t, remoteDir, "initial commit")
|
|
makeTestTag(t, remoteDir, fullSHA, "tag-1")
|
|
|
|
// Clone the repo by tag
|
|
wt1, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: "tag-1",
|
|
})
|
|
require.NoError(t, err)
|
|
defer wt1.Close(t.Context())
|
|
|
|
// Verify that the head in cloneDir is correct.
|
|
clonedSHA := getTestRepoHead(t, wt1.WorktreeDir())
|
|
assert.Equal(t, fullSHA, clonedSHA)
|
|
|
|
// Verify no spurious branches were created during cloning due to an invalid refspec.
|
|
branches := getTestRepoBranches(t, wt1.WorktreeDir())
|
|
assert.Equal(t, []string{"* (no branch)"}, branches)
|
|
|
|
// Verify no spurious tags were created during cloning due to an invalid refspec.
|
|
tags := getTestRepoTags(t, wt1.WorktreeDir())
|
|
assert.Equal(t, []string{"tag-1"}, tags)
|
|
})
|
|
|
|
t.Run("Removes non-empty clone target directory", func(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
|
|
// Create a local repo that will act as the remote to be cloned.
|
|
remoteDir := makeTestRepo(t)
|
|
|
|
// `git clone` is happy to clone into empty directories. Add a file to the directory that the repository will be
|
|
// cloned to prevent it from succeeding.
|
|
remoteDirHash := common.Sha256(remoteDir)
|
|
repoDir := filepath.Join(cacheDir, remoteDirHash[:2], remoteDirHash[2:])
|
|
err := os.MkdirAll(repoDir, 0o755)
|
|
require.NoError(t, err)
|
|
|
|
obstacleFilePath := filepath.Join(repoDir, ".test.txt")
|
|
err = os.WriteFile(obstacleFilePath, []byte("Lorem ipsum"), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
// Create some commits
|
|
_ = makeTestCommit(t, remoteDir, "commit 1")
|
|
commit2 := makeTestCommit(t, remoteDir, "commit 2")
|
|
|
|
// Clone the repo
|
|
wt1, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: "main",
|
|
})
|
|
require.NoError(t, err)
|
|
defer wt1.Close(t.Context())
|
|
|
|
// Verify that the repository was cloned successfully.
|
|
clonedSHA := getTestRepoHead(t, wt1.WorktreeDir())
|
|
assert.Equal(t, commit2, clonedSHA)
|
|
|
|
// Ensure that the obstacle file was removed.
|
|
_, err = os.Stat(obstacleFilePath)
|
|
assert.Error(t, err)
|
|
})
|
|
|
|
t.Run("Offline mode tag/branch/sha works after cloning", func(t *testing.T) {
|
|
remoteDir := makeTestRepo(t)
|
|
fullSHA := makeTestCommit(t, remoteDir, "initial commit")
|
|
makeTestTag(t, remoteDir, fullSHA, "tag-1")
|
|
makeTestBranch(t, remoteDir, fullSHA, "branch-1")
|
|
|
|
for name, ref := range map[string]string{
|
|
"tag": "tag-1",
|
|
"branch": "branch-1",
|
|
"sha": fullSHA,
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
|
|
wt, err := Clone(t.Context(), CloneInput{CacheDir: cacheDir, URL: remoteDir, Ref: ref})
|
|
require.NoError(t, err)
|
|
require.NoError(t, wt.Close(t.Context()))
|
|
|
|
wt, err = Clone(t.Context(), CloneInput{CacheDir: cacheDir, URL: remoteDir, Ref: ref, OfflineMode: true})
|
|
require.NoError(t, err)
|
|
require.NoError(t, wt.Close(t.Context()))
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("Updates cached ref", func(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
remoteDir := makeTestRepo(t)
|
|
firstCommit := makeTestCommit(t, remoteDir, "initial commit")
|
|
|
|
wt, err := Clone(t.Context(), CloneInput{CacheDir: cacheDir, URL: remoteDir, Ref: "main"})
|
|
require.NoError(t, err)
|
|
require.NoError(t, wt.Close(t.Context()))
|
|
|
|
remoteURLHash := common.Sha256(remoteDir)
|
|
repoDir := filepath.Join(cacheDir, remoteURLHash[:2], remoteURLHash[2:])
|
|
|
|
secondCommit := makeTestCommit(t, remoteDir, "second commit")
|
|
require.NotEqual(t, firstCommit, secondCommit)
|
|
wt, err = Clone(t.Context(), CloneInput{CacheDir: cacheDir, URL: remoteDir, Ref: "main"})
|
|
require.NoError(t, err)
|
|
require.NoError(t, wt.Close(t.Context()))
|
|
|
|
resolved, err := ResolveRevision(t.Context(), repoDir, "main^{commit}")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, secondCommit, resolved)
|
|
})
|
|
|
|
// These tests protects against a potential security vulnerability in pinned actions references. If an action
|
|
// reference is pinned such as `uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98`, it is possible
|
|
// that the remote `actions/checkout` repository is compromised, and a reference (branch or tag) could be created
|
|
// that is named `0c366fd6a839edf440554fa01a7085ccba70ac98`. Runner must treat this as a commit ID, not a branch,
|
|
// not a tag, in order to prevent a substitution from occurring on a compromised repository.
|
|
refTypes := []struct {
|
|
refType string
|
|
refCreator func(t *testing.T, repoPath, commitSHA, tag string)
|
|
refChecker func(t *testing.T, repoPath, commitSHA, tag string)
|
|
}{
|
|
{
|
|
refType: "branch",
|
|
refCreator: makeTestBranch,
|
|
},
|
|
{
|
|
refType: "tag",
|
|
refCreator: makeTestTag,
|
|
},
|
|
{
|
|
refType: "annotated-tag",
|
|
refCreator: makeTestAnnotatedTag,
|
|
},
|
|
}
|
|
for _, ref := range refTypes {
|
|
t.Run(fmt.Sprintf("%s shadows a commit", ref.refType), func(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
|
|
// Create a local repo that will act as the remote to be cloned.
|
|
remoteDir := makeTestRepo(t)
|
|
|
|
// Create a test commit that we'll try to clone
|
|
originalSHA := makeTestCommit(t, remoteDir, "initial commit")
|
|
|
|
// Now we're going to create a second commit, and we're going to put a branch-or-tag on that commit where
|
|
// the name is the same as the git SHA from the first commit.
|
|
secondSHA := makeTestCommit(t, remoteDir, "second commit")
|
|
ref.refCreator(t, remoteDir, secondSHA /* commit */, originalSHA /* tag name */)
|
|
|
|
// Clone the repo by the first commit's specific SHA:
|
|
wt1, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: originalSHA,
|
|
})
|
|
require.NoError(t, err)
|
|
defer wt1.Close(t.Context())
|
|
|
|
// Verify that the head in cloneDir is correct.
|
|
clonedSHA := getTestRepoHead(t, wt1.WorktreeDir())
|
|
assert.Equal(t, originalSHA, clonedSHA)
|
|
})
|
|
|
|
t.Run(fmt.Sprintf("%s shadows a non-existent/deleted commit", ref.refType), func(t *testing.T) {
|
|
// Even if the targetSHA doesn't exist in remote or was deleted, clone should still refuse to
|
|
// use it as a valid reference.
|
|
cacheDir := t.TempDir()
|
|
|
|
// Create a local repo that will act as the remote to be cloned.
|
|
remoteDir := makeTestRepo(t)
|
|
|
|
// Create a commit that stands in as our "malicious content"
|
|
maliciousSHA := makeTestCommit(t, remoteDir, "malicious content")
|
|
|
|
// Tag it with something that looks like a git commit
|
|
targetSHA := "024ee462437e33b6ea5a7a850679708b906387a0"
|
|
ref.refCreator(t, remoteDir, maliciousSHA /* commit */, targetSHA /* tag name */)
|
|
|
|
// Clone the repo by the target SHA, which is actually a git branch/tag in this repo...
|
|
_, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: targetSHA,
|
|
})
|
|
require.Error(t, err)
|
|
|
|
// Verify that Git fetch does not implicitly resolve refs/.../targetSHA.
|
|
assert.ErrorContains(t, err, "could not fetch remote 'origin'")
|
|
assert.ErrorContains(t, err, fmt.Sprintf("not our ref %s", targetSHA))
|
|
})
|
|
}
|
|
|
|
// Similar to the `refTypes` tests above, an attacker who controls the action repository can serve arbitrary content
|
|
// by publishing a `refs/replace/<pinned-sha>` ref that points at a different commit. We must ensure that we check
|
|
// out the target commit and not a substitute from a replace ref.
|
|
t.Run("refs/replace substitutes a commit", func(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
|
|
remoteDir := makeTestRepo(t)
|
|
remoteURL := "file://" + remoteDir // file:// is needed to avoid git optimizations on local clones which invalidate the test
|
|
|
|
// Legitimate commit, target of the pin:
|
|
targetSHA := makeTestCommit(t, remoteDir, "legitimate content")
|
|
|
|
// Unrelated commit carrying malicious content becomes the sole commit on `main`, leaving the legitimate commit
|
|
// unreachable from any branch or tag:
|
|
require.NoError(t, gitCmd("-C", remoteDir, "checkout", "--orphan", "evil"))
|
|
maliciousSHA := makeTestCommit(t, remoteDir, "malicious content")
|
|
require.NoError(t, gitCmd("-C", remoteDir, "branch", "-D", "main"))
|
|
require.NoError(t, gitCmd("-C", remoteDir, "branch", "-m", "main"))
|
|
|
|
// Add a replace reference from the old to the new malicious commit:
|
|
require.NoError(t, gitCmd("-C", remoteDir, "replace", targetSHA, maliciousSHA))
|
|
|
|
// Clone the repo by the pinned full SHA.
|
|
wt, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteURL,
|
|
Ref: targetSHA,
|
|
})
|
|
require.NoError(t, err)
|
|
defer wt.Close(t.Context())
|
|
clonedSHA := getTestRepoHead(t, wt.WorktreeDir())
|
|
require.Equal(t, targetSHA, clonedSHA)
|
|
assert.NotEqual(t, maliciousSHA, clonedSHA)
|
|
|
|
// Verify the resolved HEAD is the legitimate commit.
|
|
_, resolvedSHA, err := ResolveHead(t.Context(), wt.WorktreeDir())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, targetSHA, resolvedSHA)
|
|
assert.NotEqual(t, maliciousSHA, resolvedSHA)
|
|
|
|
// Verify the replacement ref was not loaded.
|
|
replaceRefs, err := gitCmdWithStdout("-C", wt.WorktreeDir(), "replace", "--list")
|
|
require.NoError(t, err)
|
|
assert.Empty(t, strings.TrimSpace(string(replaceRefs)))
|
|
})
|
|
|
|
t.Run("cached refs/replace substitutes a commit", func(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
|
|
remoteDir := makeTestRepo(t)
|
|
remoteURL := "file://" + remoteDir // file:// is needed to avoid git optimizations on local clones which invalidate the test
|
|
|
|
// Legitimate commit, target of the pin:
|
|
targetSHA := makeTestCommit(t, remoteDir, "legitimate content")
|
|
|
|
// Unrelated commit carrying malicious content becomes the sole commit on `main`, leaving the legitimate commit
|
|
// unreachable from any branch or tag:
|
|
require.NoError(t, gitCmd("-C", remoteDir, "checkout", "--orphan", "evil"))
|
|
maliciousSHA := makeTestCommit(t, remoteDir, "malicious content")
|
|
require.NoError(t, gitCmd("-C", remoteDir, "branch", "-D", "main"))
|
|
require.NoError(t, gitCmd("-C", remoteDir, "branch", "-m", "main"))
|
|
|
|
// Add a replace reference from the old to the new malicious commit:
|
|
require.NoError(t, gitCmd("-C", remoteDir, "replace", targetSHA, maliciousSHA))
|
|
|
|
// Populate the bare cache and simulate the legacy broad fetch which imported refs/replace/*.
|
|
wt, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteURL,
|
|
Ref: "main",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, wt.Close(t.Context()))
|
|
remoteURLHash := common.Sha256(remoteURL)
|
|
repoDir := filepath.Join(cacheDir, remoteURLHash[:2], remoteURLHash[2:])
|
|
require.NoError(t, Fetch(t.Context(), FetchInput{
|
|
repoPath: repoDir,
|
|
remote: "origin",
|
|
refspec: "+refs/*:refs/*",
|
|
remoteURL: remoteURL,
|
|
}))
|
|
|
|
// Clone to targetSHA with malicious replace ref.
|
|
wt, err = Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteURL,
|
|
Ref: targetSHA,
|
|
})
|
|
require.NoError(t, err)
|
|
defer wt.Close(t.Context())
|
|
|
|
// Verify the malicious replace ref is present.
|
|
replaceRefs, err := gitCmdWithStdout("-C", repoDir, "replace", "--list")
|
|
require.NoError(t, err)
|
|
require.Equal(t, targetSHA, strings.TrimSpace(string(replaceRefs)))
|
|
|
|
// Verify the resolved HEAD is the legitimate commit.
|
|
_, resolvedSHA, err := ResolveHead(t.Context(), wt.WorktreeDir())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, targetSHA, resolvedSHA)
|
|
assert.NotEqual(t, maliciousSHA, resolvedSHA)
|
|
})
|
|
}
|
|
|
|
func makeTestRepo(t *testing.T, initOptions ...string) string {
|
|
t.Helper()
|
|
repoPath := t.TempDir()
|
|
args := []string{
|
|
"-C", repoPath, "init", "--initial-branch=main",
|
|
}
|
|
args = append(args, initOptions...)
|
|
require.NoError(t, gitCmd(args...))
|
|
require.NoError(t, gitCmd("-C", repoPath, "config", "user.name", "test"))
|
|
require.NoError(t, gitCmd("-C", repoPath, "config", "user.email", "test@test.com"))
|
|
return repoPath
|
|
}
|
|
|
|
func makeTestCommit(t *testing.T, repoPath, comment string) string {
|
|
t.Helper()
|
|
require.NoError(t, gitCmd("-C", repoPath, "commit", "--allow-empty", "-m", comment))
|
|
cmd := exec.Command("git", "-C", repoPath, "rev-parse", "HEAD")
|
|
output, err := cmd.Output()
|
|
require.NoError(t, err)
|
|
fullSHA := strings.TrimSpace(string(output))
|
|
return fullSHA
|
|
}
|
|
|
|
func makeTestTag(t *testing.T, repoPath, commitSHA, tag string) {
|
|
t.Helper()
|
|
require.NoError(t, gitCmd("-C", repoPath, "tag", "--force", tag, commitSHA))
|
|
verifyTag(t, repoPath, commitSHA, tag)
|
|
}
|
|
|
|
func makeTestAnnotatedTag(t *testing.T, repoPath, commitSHA, tag string) {
|
|
t.Helper()
|
|
require.NoError(t, gitCmd("-C", repoPath, "tag", "--force", tag, "-m", tag, commitSHA))
|
|
verifyTag(t, repoPath, commitSHA, tag)
|
|
}
|
|
|
|
func verifyTag(t *testing.T, repoPath, commitSHA, tag string) {
|
|
t.Helper()
|
|
output, err := gitCmdWithStdout("-C", repoPath, "tag", "--list", "--points-at", commitSHA, tag)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, fmt.Sprintf("%s\n", tag), string(output))
|
|
}
|
|
|
|
func makeTestBranch(t *testing.T, repoPath, commitSHA, branchName string) {
|
|
t.Helper()
|
|
require.NoError(t, gitCmd("-C", repoPath, "branch", branchName, commitSHA))
|
|
}
|
|
|
|
func getTestRepoHead(t *testing.T, repoPath string) string {
|
|
t.Helper()
|
|
cmd := exec.Command("git", "-C", repoPath, "rev-parse", "HEAD")
|
|
output, err := cmd.Output()
|
|
require.NoError(t, err)
|
|
clonedSHA := strings.TrimSpace(string(output))
|
|
return clonedSHA
|
|
}
|
|
|
|
func getTestRepoBranches(t *testing.T, repoPath string) []string {
|
|
t.Helper()
|
|
|
|
cmd := exec.Command("git", "-C", repoPath, "branch")
|
|
output, err := cmd.Output()
|
|
require.NoError(t, err)
|
|
|
|
var branches []string
|
|
for line := range strings.Lines(string(output)) {
|
|
branches = append(branches, strings.TrimSpace(line))
|
|
}
|
|
|
|
return branches
|
|
}
|
|
|
|
func getTestRepoTags(t *testing.T, repoPath string) []string {
|
|
t.Helper()
|
|
|
|
cmd := exec.Command("git", "-C", repoPath, "tag")
|
|
output, err := cmd.Output()
|
|
require.NoError(t, err)
|
|
|
|
var tags []string
|
|
for line := range strings.Lines(string(output)) {
|
|
tags = append(tags, strings.TrimSpace(line))
|
|
}
|
|
|
|
return tags
|
|
}
|
|
|
|
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 {
|
|
_, err := gitCmdWithStdout(args...)
|
|
return err
|
|
}
|
|
|
|
func gitCmdWithStdout(args ...string) ([]byte, error) {
|
|
var stdoutBuffer bytes.Buffer
|
|
stdout := bufio.NewWriter(&stdoutBuffer)
|
|
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Stdout = stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
err := cmd.Run()
|
|
if exitError, ok := err.(*exec.ExitError); ok {
|
|
if waitStatus, ok := exitError.Sys().(syscall.WaitStatus); ok {
|
|
return nil, fmt.Errorf("Exit error %d", waitStatus.ExitStatus())
|
|
}
|
|
return nil, exitError
|
|
}
|
|
|
|
return stdoutBuffer.Bytes(), nil
|
|
}
|
|
|
|
func TestInitRepoIfRequired(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test")
|
|
}
|
|
|
|
tempDir := t.TempDir()
|
|
ctx := t.Context()
|
|
|
|
t.Run("clone", func(t *testing.T) {
|
|
err := initRepoIfRequired(t.Context(), CloneInput{
|
|
URL: "https://github.com/actions/checkout",
|
|
}, common.Logger(ctx), tempDir)
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
t.Run("clone different remote", func(t *testing.T) {
|
|
err := initRepoIfRequired(t.Context(), CloneInput{
|
|
URL: "https://github.com/actions/setup-go",
|
|
}, common.Logger(ctx), tempDir)
|
|
require.NoError(t, err)
|
|
|
|
remoteURL, err := getRemoteURL(t.Context(), tempDir, "origin")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "https://github.com/actions/setup-go", remoteURL)
|
|
})
|
|
}
|
|
|
|
func TestClone_UsesTokenForHTTPAuth_NoInteractivePrompt(t *testing.T) {
|
|
// Make sure git will not try to interactively prompt (and hang the test).
|
|
t.Setenv("GIT_TERMINAL_PROMPT", "0")
|
|
|
|
token := "test-token-value"
|
|
|
|
var sawTokenAuth atomic.Bool
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
auth := r.Header.Get("Authorization")
|
|
|
|
// Expect: Authorization: Basic base64("<user>:<token>")
|
|
if strings.HasPrefix(auth, "Basic ") {
|
|
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(auth, "Basic "))
|
|
if err == nil && strings.HasSuffix(string(raw), ":"+token) {
|
|
sawTokenAuth.Store(true)
|
|
// We don't serve a real git backend; just fail after proving auth was sent.
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Challenge for Basic auth.
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="forgejo-runner-test"`)
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
url := srv.URL + "/org/repo"
|
|
|
|
_, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: t.TempDir(),
|
|
URL: url,
|
|
Ref: "main",
|
|
Token: token,
|
|
})
|
|
require.Error(t, err)
|
|
|
|
// The whole point: token must be used (git should send Authorization), and it must not try to prompt.
|
|
assert.True(t, sawTokenAuth.Load(), "expected git to send Authorization header when Token is provided")
|
|
|
|
// These are the typical symptoms of the bug when credentials are not picked up.
|
|
assert.NotContains(t, err.Error(), "could not read Username")
|
|
assert.NotContains(t, err.Error(), "terminal prompts disabled")
|
|
}
|
|
|
|
func TestResolveHead(t *testing.T) {
|
|
t.Run("on created repo", func(t *testing.T) {
|
|
remoteDir := makeTestRepo(t)
|
|
|
|
fullSHA := makeTestCommit(t, remoteDir, "initial commit")
|
|
|
|
short, sha, err := ResolveHead(t.Context(), remoteDir)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, fullSHA, sha)
|
|
assert.Equal(t, fullSHA[:7], short)
|
|
})
|
|
|
|
t.Run("on cloned repo", func(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
remoteDir := makeTestRepo(t)
|
|
|
|
fullSHA := makeTestCommit(t, remoteDir, "initial commit")
|
|
|
|
wt, err := Clone(t.Context(), CloneInput{
|
|
CacheDir: cacheDir,
|
|
URL: remoteDir,
|
|
Ref: fullSHA,
|
|
})
|
|
require.NoError(t, err)
|
|
defer wt.Close(t.Context())
|
|
|
|
short, sha, err := ResolveHead(t.Context(), wt.WorktreeDir())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, fullSHA, sha)
|
|
assert.Equal(t, fullSHA[:7], short)
|
|
})
|
|
}
|
|
|
|
func TestResolveRevision(t *testing.T) {
|
|
repoPath := makeTestRepo(t)
|
|
|
|
t.Run("fails-without-commits", func(t *testing.T) {
|
|
commitID, err := ResolveRevision(t.Context(), repoPath, "HEAD")
|
|
assert.Empty(t, commitID)
|
|
assert.ErrorContains(t, err, "could not determine the commit ID of HEAD: fatal: ambiguous argument 'HEAD':")
|
|
})
|
|
|
|
t.Run("resolves-head", func(t *testing.T) {
|
|
fullSHA := makeTestCommit(t, repoPath, "initial commit")
|
|
commitID, err := ResolveRevision(t.Context(), repoPath, "HEAD")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, fullSHA, commitID)
|
|
})
|
|
|
|
t.Run("resolves-tag", func(t *testing.T) {
|
|
fullSHA := makeTestCommit(t, repoPath, "initial commit")
|
|
makeTestTag(t, repoPath, fullSHA, "tag-1")
|
|
|
|
commitID, err := ResolveRevision(t.Context(), repoPath, "tag-1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, fullSHA, commitID)
|
|
})
|
|
|
|
t.Run("resolves-branch", func(t *testing.T) {
|
|
fullSHA := makeTestCommit(t, repoPath, "initial commit")
|
|
makeTestBranch(t, repoPath, fullSHA, "branch-1")
|
|
|
|
commitID, err := ResolveRevision(t.Context(), repoPath, "branch-1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, fullSHA, commitID)
|
|
})
|
|
|
|
t.Run("resolves-commit-id-to-itself", func(t *testing.T) {
|
|
fullSHA := makeTestCommit(t, repoPath, "initial commit")
|
|
|
|
commitID, err := ResolveRevision(t.Context(), repoPath, fullSHA)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, fullSHA, commitID)
|
|
})
|
|
}
|
|
|
|
func TestFetch(t *testing.T) {
|
|
cloneDir := t.TempDir()
|
|
remoteDir := makeTestRepo(t)
|
|
|
|
commitOne := makeTestCommit(t, remoteDir, "Create first commit")
|
|
|
|
err := gitCmd("clone", "--bare", remoteDir, cloneDir)
|
|
require.NoError(t, err)
|
|
|
|
t.Run("Remote is mandatory", func(t *testing.T) {
|
|
input := FetchInput{
|
|
repoPath: cloneDir,
|
|
remote: "",
|
|
refspec: "",
|
|
}
|
|
err := Fetch(t.Context(), input)
|
|
assert.ErrorContains(t, err, "mandatory argument remote is empty")
|
|
})
|
|
|
|
t.Run("Refspec is optional", func(t *testing.T) {
|
|
exists, err := objectExists(t.Context(), cloneDir, commitOne)
|
|
require.NoError(t, err)
|
|
assert.True(t, exists)
|
|
|
|
commitTwo := makeTestCommit(t, remoteDir, "Create second commit")
|
|
|
|
exists, err = objectExists(t.Context(), cloneDir, commitTwo)
|
|
require.NoError(t, err)
|
|
assert.False(t, exists)
|
|
|
|
input := FetchInput{
|
|
repoPath: cloneDir,
|
|
remote: "origin",
|
|
refspec: "",
|
|
}
|
|
err = Fetch(t.Context(), input)
|
|
require.NoError(t, err)
|
|
|
|
exists, err = objectExists(t.Context(), cloneDir, commitTwo)
|
|
require.NoError(t, err)
|
|
assert.True(t, exists)
|
|
})
|
|
|
|
t.Run("Fetch with refspec", func(t *testing.T) {
|
|
headAfterClone := getTestRepoHead(t, cloneDir)
|
|
assert.Equal(t, commitOne, headAfterClone)
|
|
|
|
// Add another commit that can be fast-forwarded.
|
|
commitTwo := makeTestCommit(t, remoteDir, "Create second commit")
|
|
|
|
// Without having fetched the latest changes, HEAD should still point to the first commit.
|
|
headBeforeFetch := getTestRepoHead(t, cloneDir)
|
|
assert.Equal(t, commitOne, headBeforeFetch)
|
|
|
|
input := FetchInput{
|
|
repoPath: cloneDir,
|
|
remote: "origin",
|
|
refspec: "+refs/*:refs/*",
|
|
}
|
|
err := Fetch(t.Context(), input)
|
|
require.NoError(t, err)
|
|
|
|
// Ensure that HEAD was moved forward.
|
|
headAfterFetch := getTestRepoHead(t, cloneDir)
|
|
assert.Equal(t, commitTwo, headAfterFetch)
|
|
})
|
|
}
|
|
|
|
func TestObjectExists(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
|
|
// Create a local repo that will act as the remote to be cloned.
|
|
remoteDir := makeTestRepo(t)
|
|
|
|
// Create a commit and get its full SHA
|
|
firstCommit := makeTestCommit(t, remoteDir, "initial commit")
|
|
|
|
// Clone the repo.
|
|
err := gitCmd("clone", remoteDir, cacheDir)
|
|
require.NoError(t, err)
|
|
|
|
// Verify that firstCommit is actually present.
|
|
exists, err := objectExists(t.Context(), cacheDir, firstCommit)
|
|
require.NoError(t, err)
|
|
assert.True(t, exists)
|
|
|
|
// Verify that short references work.
|
|
exists, err = objectExists(t.Context(), cacheDir, firstCommit[:7])
|
|
require.NoError(t, err)
|
|
assert.True(t, exists)
|
|
|
|
// Imaginary commits should not exist.
|
|
exists, err = objectExists(t.Context(), cacheDir, "4e035bc55a3eb3bd670fcfef0dbd3c19c50e11de")
|
|
require.NoError(t, err)
|
|
assert.False(t, exists)
|
|
|
|
// Create a second commit.
|
|
secondCommit := makeTestCommit(t, remoteDir, "second commit")
|
|
|
|
// The second commit should not exist without `git fetch`.
|
|
exists, err = objectExists(t.Context(), cacheDir, secondCommit)
|
|
require.NoError(t, err)
|
|
assert.False(t, exists)
|
|
|
|
// Fetch the remote to make the second known.
|
|
err = gitCmd("-C", cacheDir, "fetch")
|
|
require.NoError(t, err)
|
|
|
|
// The second commit should exist now.
|
|
exists, err = objectExists(t.Context(), cacheDir, secondCommit)
|
|
require.NoError(t, err)
|
|
assert.True(t, exists)
|
|
|
|
// Malformed object names should result in an error.
|
|
_, err = objectExists(t.Context(), cacheDir, "malformed-name")
|
|
assert.ErrorContains(t, err, "could not determine whether malformed-name exists")
|
|
}
|
|
|
|
// Clone of `TestCancelLongRunningCommand` which verifies that git processes, when their contexts are cancelled, are
|
|
// fully killed and all their child/grandchild processes are killed as well.
|
|
func TestGitContextCancelKillsSubprocesses(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
// TestCancelLongRunningCommand contains Windows-specific tests for `common.RunCmdInGroup`, which is the
|
|
// underlying mechanism in-use in this test. That test doesn't need to be repeated here, as the only important
|
|
// thing to assert is that `common.RunCmdInGroup` is being used in the git mechanisms -- if it's being used in
|
|
// Linux/other it will be used in Windows.
|
|
t.Skip()
|
|
}
|
|
|
|
gitCloneDir := t.TempDir()
|
|
remoteDir := makeTestRepo(t)
|
|
makeTestCommit(t, remoteDir, "initial commit")
|
|
err := gitCmd("clone", remoteDir, gitCloneDir)
|
|
require.NoError(t, err)
|
|
|
|
scriptDir := t.TempDir()
|
|
checkFilePath := filepath.Join(scriptDir, "check_file")
|
|
endlessScriptPath := filepath.Join(scriptDir, "evil.sh")
|
|
contents := fmt.Sprintf(`#!/bin/sh
|
|
(
|
|
nohup sh <<-EOF >/dev/null 2>/dev/null &
|
|
while true; do
|
|
touch %s
|
|
sleep 0.1
|
|
done
|
|
EOF
|
|
while true; do sleep 1; done
|
|
)`, checkFilePath)
|
|
_ = os.WriteFile(endlessScriptPath, []byte(contents), 0o700)
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
defer cancel()
|
|
|
|
execTime := time.Now()
|
|
execResult := make(chan error)
|
|
go func() {
|
|
output, err := git(
|
|
ctx, &gitOptions{},
|
|
"-c", fmt.Sprintf("alias.my-fake-command=!%s", endlessScriptPath),
|
|
"-C", gitCloneDir,
|
|
"my-fake-command",
|
|
)
|
|
t.Logf("my-fake-command had output: %q", output)
|
|
t.Logf("my-fake-command had err: %q", err.Error())
|
|
if ee, ok := err.(*exec.ExitError); ok {
|
|
t.Logf("my-fake-command had stderr = %q", string(ee.Stderr))
|
|
}
|
|
execResult <- err
|
|
}()
|
|
|
|
// Verify that the check_file exists...
|
|
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
|
assert.FileExists(c, checkFilePath)
|
|
}, 10*time.Second, 100*time.Millisecond)
|
|
|
|
// Terminate the subcommand by terminating the git command's context, triggering `common.RunCmdInGroup'`s subprocess
|
|
// handling:
|
|
cancel()
|
|
runTime := time.Since(execTime)
|
|
|
|
err = <-execResult
|
|
require.ErrorContains(t, err, "signal: killed")
|
|
|
|
// The child has been killed, so if we delete 'check_file', it should never return.
|
|
_ = os.Remove(checkFilePath)
|
|
// On a system under heavy load, using `runTime` here is a good heuristic for how long we might have to wait before
|
|
// the child gets another chance to write the file.
|
|
time.Sleep(runTime)
|
|
assert.NoFileExists(t, checkFilePath)
|
|
}
|