1
0
mirror of https://code.forgejo.org/forgejo/runner.git synced 2026-10-24 16:59:19 +03:00
Files
runner/act/common/git/git_test.go
Shane Creighton-Young 61e691e4de feat: efficient actions repo fetching (#1722)
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>
2026-09-11 21:35:47 +00:00

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)
}