mirror of
https://github.com/hanzoai/deploy.git
synced 2026-10-07 08:44:50 +00:00
go.mod still declared `module github.com/hanzoai/deploy` after the repo was renamed. A Go module path is not cosmetic — it is the resolvable location. So `go get github.com/hanzoai/cd` could not work, and anything importing this module had to spell a name that no longer exists at that address. The build log of v3.6.0 is where it surfaced: the compiler was still emitting github.com/hanzoai/deploy/cmd/cd/commands. 3541 import paths across 690 .go, 19 .proto, 4 .yaml, 3 .sh, 2 .mod, Procfile and Makefile. Plus the 62 ghcr.io/hanzoai/deploy image references, moved to ghcr.io/hanzoai/cd to match hanzo.yml's build target — a distinct string from the module path, replaced separately so neither could clobber the other. go mod tidy clean, CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build ./... clean. Co-authored-by: Hanzo Dev <[email protected]>
2130 lines
69 KiB
Go
2130 lines
69 KiB
Go
package git
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/mail"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
"golang.org/x/crypto/ssh"
|
|
|
|
"github.com/go-git/go-git/v5/plumbing"
|
|
"github.com/go-git/go-git/v5/plumbing/transport"
|
|
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/hanzoai/cd/util/workloadidentity"
|
|
"github.com/hanzoai/cd/util/workloadidentity/mocks"
|
|
)
|
|
|
|
func runCmd(ctx context.Context, workingDir string, name string, args ...string) error {
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
cmd.Dir = workingDir
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
func outputCmd(ctx context.Context, workingDir string, name string, args ...string) ([]byte, error) {
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
cmd.Dir = workingDir
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Output()
|
|
}
|
|
|
|
func _createEmptyGitRepo(ctx context.Context) (string, error) {
|
|
tempDir, err := os.MkdirTemp("", "")
|
|
if err != nil {
|
|
return tempDir, err
|
|
}
|
|
|
|
err = runCmd(ctx, tempDir, "git", "init")
|
|
if err != nil {
|
|
return tempDir, err
|
|
}
|
|
|
|
err = runCmd(ctx, tempDir, "git", "commit", "-m", "Initial commit", "--allow-empty")
|
|
return tempDir, err
|
|
}
|
|
|
|
// fakeGitRefCache is an in-memory stand-in for the reposerver git ref cache. It
|
|
// lets a test steer which branch of getRefs runs and records how the cache was
|
|
// used. Its GetOrLockGitReferences mirrors the real contract (see
|
|
// reposerver/cache/cache.go): on a cache hit it fills references and returns an
|
|
// empty lock id; when the caller wins the lock it echoes the caller's lockId
|
|
// back; on error it returns the error.
|
|
type fakeGitRefCache struct {
|
|
mu sync.Mutex
|
|
|
|
// Behavior knobs for GetOrLockGitReferences.
|
|
cachedRefs []*plumbing.Reference // non-nil => simulate a cache hit
|
|
getLockErr error // non-nil => return this error (get/lock failure)
|
|
// When cachedRefs is nil and getLockErr is nil, the caller becomes the lock
|
|
// owner (its lockId is echoed back) and is expected to populate the cache.
|
|
|
|
// Behavior knob for SetGitReferences.
|
|
setErr error
|
|
|
|
// Call recording.
|
|
getOrLockCalls int
|
|
setCalls int
|
|
setRefs []*plumbing.Reference
|
|
unlockCalls int
|
|
}
|
|
|
|
func (f *fakeGitRefCache) GetOrLockGitReferences(_ string, lockId string, references *[]*plumbing.Reference) (string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.getOrLockCalls++
|
|
if f.getLockErr != nil {
|
|
// Foreign (empty) lock id + error => getRefs takes its error path.
|
|
return "", f.getLockErr
|
|
}
|
|
if f.cachedRefs != nil {
|
|
// Cache hit: fill the caller's slice and return an empty lock id so the
|
|
// caller is not the lock owner and returns the cached value directly.
|
|
*references = f.cachedRefs
|
|
return "", nil
|
|
}
|
|
// No cached data and no error => caller wins the lock (owns population).
|
|
return lockId, nil
|
|
}
|
|
|
|
func (f *fakeGitRefCache) SetGitReferences(_ string, references []*plumbing.Reference) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.setCalls++
|
|
f.setRefs = references
|
|
return f.setErr
|
|
}
|
|
|
|
func (f *fakeGitRefCache) UnlockGitReferences(_ string, _ string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.unlockCalls++
|
|
return nil
|
|
}
|
|
|
|
// newSpiedClient builds a nativeGitClient wired to the given cache and counts
|
|
// how many times the remote-listing path (OnLsRemote) is entered, which is the
|
|
// step immediately before the actual network fetch in getRefs. A count of zero
|
|
// therefore proves the cache short-circuited before any remote call.
|
|
func newSpiedClient(t *testing.T, repoURL string, cache gitRefCache, loadRefFromCache bool) (*nativeGitClient, *int) {
|
|
t.Helper()
|
|
lsRemoteCalls := 0
|
|
return &nativeGitClient{
|
|
EventHandlers: EventHandlers{
|
|
OnLsRemote: func(string) func() {
|
|
lsRemoteCalls++
|
|
return func() {}
|
|
},
|
|
},
|
|
repoURL: repoURL,
|
|
creds: NopCreds{},
|
|
gitRefCache: cache,
|
|
loadRefFromCache: loadRefFromCache,
|
|
}, &lsRemoteCalls
|
|
}
|
|
|
|
func Test_nativeGitClient_getRefs(t *testing.T) {
|
|
// A synthetic ref set used by the pure-cache paths, which never touch a real
|
|
// repository.
|
|
cachedRefs := []*plumbing.Reference{
|
|
plumbing.NewHashReference("refs/heads/master", plumbing.NewHash("abcdef0123456789abcdef0123456789abcdef01")),
|
|
}
|
|
|
|
t.Run("cache hit returns cached refs without any remote call", func(t *testing.T) {
|
|
cache := &fakeGitRefCache{cachedRefs: cachedRefs}
|
|
// A deliberately unreachable URL: if getRefs fell through to a fetch it
|
|
// would fail, so a clean return proves the cache short-circuited.
|
|
client, lsRemoteCalls := newSpiedClient(t, "file:///does-not-exist", cache, true)
|
|
|
|
refs, err := client.getRefs()
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, cachedRefs, refs)
|
|
assert.Equal(t, 0, *lsRemoteCalls, "must not hit the remote on a cache hit")
|
|
assert.Equal(t, 0, cache.setCalls, "must not re-populate the cache on a hit")
|
|
assert.Equal(t, 0, cache.unlockCalls, "must not unlock when not the lock owner")
|
|
})
|
|
|
|
t.Run("get/lock error is propagated without a remote call", func(t *testing.T) {
|
|
wantErr := errors.New("cache exploded")
|
|
cache := &fakeGitRefCache{getLockErr: wantErr}
|
|
client, lsRemoteCalls := newSpiedClient(t, "file:///does-not-exist", cache, true)
|
|
|
|
refs, err := client.getRefs()
|
|
|
|
require.ErrorIs(t, err, wantErr)
|
|
assert.Nil(t, refs)
|
|
assert.Equal(t, 0, *lsRemoteCalls, "must not hit the remote when the cache errors")
|
|
assert.Equal(t, 0, cache.setCalls)
|
|
})
|
|
|
|
t.Run("lock owner fetches, populates cache, and does not unlock", func(t *testing.T) {
|
|
tempDir, err := _createEmptyGitRepo(t.Context())
|
|
require.NoError(t, err)
|
|
cache := &fakeGitRefCache{} // no cached data, no error => caller owns the lock
|
|
client, lsRemoteCalls := newSpiedClient(t, "file://"+tempDir, cache, true)
|
|
|
|
refs, err := client.getRefs()
|
|
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, refs, "a real repo must advertise at least one ref")
|
|
assert.Equal(t, 1, *lsRemoteCalls, "lock owner must perform the remote listing")
|
|
assert.Equal(t, 1, cache.setCalls, "lock owner must store the fetched refs")
|
|
assert.Equal(t, refs, cache.setRefs, "the stored refs must match what was returned")
|
|
// SetGitReferences overwrites the lock with real data, so the deferred
|
|
// unlock must be skipped (needsUnlock=false).
|
|
assert.Equal(t, 0, cache.unlockCalls, "must not unlock after a successful store")
|
|
})
|
|
|
|
t.Run("failed cache store still returns refs and releases the lock", func(t *testing.T) {
|
|
tempDir, err := _createEmptyGitRepo(t.Context())
|
|
require.NoError(t, err)
|
|
cache := &fakeGitRefCache{setErr: errors.New("redis down")}
|
|
client, lsRemoteCalls := newSpiedClient(t, "file://"+tempDir, cache, true)
|
|
|
|
refs, err := client.getRefs()
|
|
|
|
// A store failure is logged, not surfaced: the caller still gets the refs.
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, refs)
|
|
assert.Equal(t, 1, *lsRemoteCalls)
|
|
assert.Equal(t, 1, cache.setCalls)
|
|
// Store failed => needsUnlock stayed true => the deferred unlock must fire
|
|
// so the lock is not left stranded.
|
|
assert.Equal(t, 1, cache.unlockCalls, "must release the lock when the store fails")
|
|
})
|
|
|
|
t.Run("loadRefFromCache=false skips the lock but still stores fetched refs", func(t *testing.T) {
|
|
tempDir, err := _createEmptyGitRepo(t.Context())
|
|
require.NoError(t, err)
|
|
cache := &fakeGitRefCache{}
|
|
client, lsRemoteCalls := newSpiedClient(t, "file://"+tempDir, cache, false)
|
|
|
|
refs, err := client.getRefs()
|
|
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, refs)
|
|
assert.Equal(t, 1, *lsRemoteCalls)
|
|
// The get/lock block is guarded by loadRefFromCache and is skipped...
|
|
assert.Equal(t, 0, cache.getOrLockCalls, "must not read or lock the cache when loadRefFromCache is false")
|
|
// ...but the store block is guarded only by gitRefCache != nil, so it runs.
|
|
assert.Equal(t, 1, cache.setCalls, "results are still written back to the cache")
|
|
assert.Equal(t, 0, cache.unlockCalls)
|
|
})
|
|
|
|
t.Run("no cache configured fetches directly", func(t *testing.T) {
|
|
tempDir, err := _createEmptyGitRepo(t.Context())
|
|
require.NoError(t, err)
|
|
client, lsRemoteCalls := newSpiedClient(t, "file://"+tempDir, nil, false)
|
|
|
|
refs, err := client.getRefs()
|
|
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, refs)
|
|
assert.Equal(t, 1, *lsRemoteCalls)
|
|
})
|
|
}
|
|
|
|
func Test_nativeGitClient_cleanupOrphanedTempPackfiles(t *testing.T) {
|
|
root := t.TempDir()
|
|
packDir := filepath.Join(root, ".git", "objects", "pack")
|
|
require.NoError(t, os.MkdirAll(packDir, 0o755))
|
|
// gitCleanupGracePeriod defaults to 2 * 90s = 3m, so an hour-old file is
|
|
// safely stale and a just-written one is safely fresh.
|
|
old := time.Now().Add(-time.Hour)
|
|
|
|
// Stale temp files git's index-pack can strand when a fetch is killed before
|
|
// it finalizes the pack. All must be removed once past the grace window.
|
|
stale := []string{"tmp_pack_deadbeef", "tmp_idx_deadbeef", "tmp_rev_deadbeef", "tmp_mtimes_deadbeef"}
|
|
for _, name := range stale {
|
|
p := filepath.Join(packDir, name)
|
|
require.NoError(t, os.WriteFile(p, []byte("partial data"), 0o644))
|
|
require.NoError(t, os.Chtimes(p, old, old))
|
|
}
|
|
|
|
// A temp file still within the grace window may belong to a concurrent fetch
|
|
// (for example another replica) and must be preserved.
|
|
fresh := filepath.Join(packDir, "tmp_pack_inflight")
|
|
require.NoError(t, os.WriteFile(fresh, []byte("in-progress"), 0o644))
|
|
|
|
// Finalized pack files (even when old) and a push-path quarantine directory
|
|
// must be kept.
|
|
keep := []string{
|
|
"pack-0123456789abcdef0123456789abcdef01234567.pack",
|
|
"pack-0123456789abcdef0123456789abcdef01234567.idx",
|
|
"pack-0123456789abcdef0123456789abcdef01234567.rev",
|
|
}
|
|
for _, name := range keep {
|
|
p := filepath.Join(packDir, name)
|
|
require.NoError(t, os.WriteFile(p, []byte("real data"), 0o644))
|
|
require.NoError(t, os.Chtimes(p, old, old))
|
|
}
|
|
quarantine := filepath.Join(packDir, "tmp_objdir-incoming")
|
|
require.NoError(t, os.MkdirAll(quarantine, 0o755))
|
|
require.NoError(t, os.Chtimes(quarantine, old, old))
|
|
|
|
client := &nativeGitClient{root: root, repoURL: "https://example.com/repo.git"}
|
|
client.cleanupOrphanedTempPackfiles()
|
|
|
|
for _, name := range stale {
|
|
assert.NoFileExists(t, filepath.Join(packDir, name), "stale temp file %s should be removed", name)
|
|
}
|
|
assert.FileExists(t, fresh, "temp file within the grace window must be preserved")
|
|
for _, name := range keep {
|
|
assert.FileExists(t, filepath.Join(packDir, name), "finalized file %s must be preserved", name)
|
|
}
|
|
assert.DirExists(t, quarantine, "push-path quarantine directory must not be touched")
|
|
}
|
|
|
|
func Test_nativeGitClient_cleanupOrphanedTempPackfiles_noPackDir(t *testing.T) {
|
|
// A repository whose pack directory does not exist yet must not panic or error.
|
|
client := &nativeGitClient{root: t.TempDir(), repoURL: "https://example.com/repo.git"}
|
|
assert.NotPanics(t, client.cleanupOrphanedTempPackfiles)
|
|
}
|
|
|
|
func Test_nativeGitClient_Fetch_cleansOrphanedTempPacksOnError(t *testing.T) {
|
|
ctx := t.Context()
|
|
root := t.TempDir()
|
|
|
|
require.NoError(t, runCmd(ctx, root, "git", "init"))
|
|
// Point origin at a non-existent path so the fetch fails deterministically.
|
|
badRemote := filepath.Join(root, "does-not-exist")
|
|
require.NoError(t, runCmd(ctx, root, "git", "remote", "add", "origin", "file://"+badRemote))
|
|
|
|
// A real index-pack killed mid-stream cannot be reproduced deterministically
|
|
// in a unit test, so stand in for the stranded files with synthetic temp
|
|
// pack/index files, aged past the grace window, and assert the failed fetch
|
|
// removes them.
|
|
packDir := filepath.Join(root, ".git", "objects", "pack")
|
|
require.NoError(t, os.MkdirAll(packDir, 0o755))
|
|
old := time.Now().Add(-time.Hour)
|
|
orphanPack := filepath.Join(packDir, "tmp_pack_orphan")
|
|
orphanIdx := filepath.Join(packDir, "tmp_idx_orphan")
|
|
for _, p := range []string{orphanPack, orphanIdx} {
|
|
require.NoError(t, os.WriteFile(p, []byte("partial data"), 0o644))
|
|
require.NoError(t, os.Chtimes(p, old, old))
|
|
}
|
|
|
|
client := &nativeGitClient{root: root, repoURL: "file://" + badRemote, creds: NopCreds{}}
|
|
|
|
err := client.Fetch(t.Context(), "", 0)
|
|
require.Error(t, err, "fetch against a missing remote must fail")
|
|
assert.NoFileExists(t, orphanPack, "orphaned temp pack should be cleaned up after a failed fetch")
|
|
assert.NoFileExists(t, orphanIdx, "orphaned temp index should be cleaned up after a failed fetch")
|
|
}
|
|
|
|
func Test_nativeGitClient_Fetch(t *testing.T) {
|
|
tempDir, err := _createEmptyGitRepo(t.Context())
|
|
require.NoError(t, err)
|
|
|
|
client, err := NewClient("file://"+tempDir, NopCreds{}, true, false, "", "")
|
|
require.NoError(t, err)
|
|
|
|
err = client.Init()
|
|
require.NoError(t, err)
|
|
|
|
err = client.Fetch(t.Context(), "", 0)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func Test_nativeGitClient_Fetch_Prune(t *testing.T) {
|
|
ctx := t.Context()
|
|
tempDir, err := _createEmptyGitRepo(ctx)
|
|
require.NoError(t, err)
|
|
|
|
client, err := NewClient("file://"+tempDir, NopCreds{}, true, false, "", "")
|
|
require.NoError(t, err)
|
|
|
|
err = client.Init()
|
|
require.NoError(t, err)
|
|
|
|
err = runCmd(ctx, tempDir, "git", "branch", "test/foo")
|
|
require.NoError(t, err)
|
|
|
|
err = client.Fetch(t.Context(), "", 0)
|
|
require.NoError(t, err)
|
|
|
|
err = runCmd(ctx, tempDir, "git", "branch", "-d", "test/foo")
|
|
require.NoError(t, err)
|
|
err = runCmd(ctx, tempDir, "git", "branch", "test/foo/bar")
|
|
require.NoError(t, err)
|
|
|
|
err = client.Fetch(t.Context(), "", 0)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func Test_IsAnnotatedTag(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
ctx := t.Context()
|
|
client, err := NewClient("file://"+tempDir, NopCreds{}, true, false, "", "")
|
|
require.NoError(t, err)
|
|
|
|
err = client.Init()
|
|
require.NoError(t, err)
|
|
|
|
p := path.Join(client.Root(), "README")
|
|
f, err := os.Create(p)
|
|
require.NoError(t, err)
|
|
_, err = f.WriteString("Hello.")
|
|
require.NoError(t, err)
|
|
err = f.Close()
|
|
require.NoError(t, err)
|
|
|
|
err = runCmd(ctx, client.Root(), "git", "add", "README")
|
|
require.NoError(t, err)
|
|
|
|
err = runCmd(ctx, client.Root(), "git", "commit", "-m", "Initial commit", "-a")
|
|
require.NoError(t, err)
|
|
|
|
err = runCmd(ctx, client.Root(), "git", "tag", "annot-tag", "-a", "-m", "Create annotated tag")
|
|
require.NoError(t, err)
|
|
|
|
err = runCmd(ctx, client.Root(), "git", "tag", "light-tag")
|
|
require.NoError(t, err)
|
|
|
|
atag := client.IsAnnotatedTag(t.Context(), "HEAD")
|
|
assert.False(t, atag)
|
|
|
|
atag = client.IsAnnotatedTag(t.Context(), "master")
|
|
assert.False(t, atag)
|
|
|
|
atag = client.IsAnnotatedTag(t.Context(), "blorp")
|
|
assert.False(t, atag)
|
|
|
|
sha, err := client.CommitSHA(t.Context())
|
|
require.NoError(t, err)
|
|
atag = client.IsAnnotatedTag(t.Context(), sha)
|
|
assert.False(t, atag)
|
|
|
|
atag = client.IsAnnotatedTag(t.Context(), "annot-tag")
|
|
assert.True(t, atag)
|
|
|
|
atag = client.IsAnnotatedTag(t.Context(), "light-tag")
|
|
assert.False(t, atag)
|
|
}
|
|
|
|
func Test_resolveTagReference(t *testing.T) {
|
|
// Setup
|
|
commitHash := plumbing.NewHash("0123456789abcdef0123456789abcdef01234567")
|
|
tagRef := plumbing.NewReferenceFromStrings("refs/tags/v1.0.0", "sometaghash")
|
|
|
|
// Test single function
|
|
resolvedRef := plumbing.NewHashReference(tagRef.Name(), commitHash)
|
|
|
|
// Verify
|
|
assert.Equal(t, commitHash, resolvedRef.Hash())
|
|
assert.Equal(t, tagRef.Name(), resolvedRef.Name())
|
|
}
|
|
|
|
func Test_ChangedFiles(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
ctx := t.Context()
|
|
|
|
client, err := NewClientExt("file://"+tempDir, tempDir, NopCreds{}, true, false, "", "")
|
|
require.NoError(t, err)
|
|
|
|
err = client.Init()
|
|
require.NoError(t, err)
|
|
|
|
err = runCmd(ctx, client.Root(), "git", "commit", "-m", "Initial commit", "--allow-empty")
|
|
require.NoError(t, err)
|
|
|
|
// Create a tag to have a second ref
|
|
err = runCmd(ctx, client.Root(), "git", "tag", "some-tag")
|
|
require.NoError(t, err)
|
|
|
|
p := path.Join(client.Root(), "README")
|
|
f, err := os.Create(p)
|
|
require.NoError(t, err)
|
|
_, err = f.WriteString("Hello.")
|
|
require.NoError(t, err)
|
|
err = f.Close()
|
|
require.NoError(t, err)
|
|
|
|
err = runCmd(ctx, client.Root(), "git", "add", "README")
|
|
require.NoError(t, err)
|
|
|
|
err = runCmd(ctx, client.Root(), "git", "commit", "-m", "Changes", "-a")
|
|
require.NoError(t, err)
|
|
|
|
previousSHA, err := client.LsRemote("some-tag")
|
|
require.NoError(t, err)
|
|
|
|
commitSHA, err := client.LsRemote("HEAD")
|
|
require.NoError(t, err)
|
|
|
|
// Invalid commits, error
|
|
_, err = client.ChangedFiles(t.Context(), "0000000000000000000000000000000000000000", "1111111111111111111111111111111111111111")
|
|
require.Error(t, err)
|
|
|
|
// Not SHAs, error
|
|
_, err = client.ChangedFiles(t.Context(), previousSHA, "HEAD")
|
|
require.Error(t, err)
|
|
|
|
// Same commit, no changes
|
|
changedFiles, err := client.ChangedFiles(t.Context(), commitSHA, commitSHA)
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, []string{}, changedFiles)
|
|
|
|
// Different ref, with changes
|
|
changedFiles, err = client.ChangedFiles(t.Context(), previousSHA, commitSHA)
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, []string{"README"}, changedFiles)
|
|
}
|
|
|
|
func Test_SemverTags(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
ctx := t.Context()
|
|
|
|
client, err := NewClientExt("file://"+tempDir, tempDir, NopCreds{}, true, false, "", "")
|
|
require.NoError(t, err)
|
|
|
|
err = client.Init()
|
|
require.NoError(t, err)
|
|
|
|
mapTagRefs := map[string]string{}
|
|
for _, tag := range []string{
|
|
"v1.0.0-rc1",
|
|
"v1.0.0-rc2",
|
|
"v1.0.0",
|
|
"v1.0",
|
|
"v1.0.1",
|
|
"v1.1.0",
|
|
"2024-apple",
|
|
"2024-banana",
|
|
} {
|
|
err = runCmd(ctx, client.Root(), "git", "commit", "-m", tag+" commit", "--allow-empty")
|
|
require.NoError(t, err)
|
|
|
|
// Create an rc semver tag
|
|
err = runCmd(ctx, client.Root(), "git", "tag", tag)
|
|
require.NoError(t, err)
|
|
|
|
sha, err := client.LsRemote("HEAD")
|
|
require.NoError(t, err)
|
|
|
|
mapTagRefs[tag] = sha
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
ref string
|
|
expected string
|
|
error bool
|
|
}{{
|
|
name: "pinned rc version",
|
|
ref: "v1.0.0-rc1",
|
|
expected: mapTagRefs["v1.0.0-rc1"],
|
|
}, {
|
|
name: "lt rc constraint",
|
|
ref: "< v1.0.0-rc3",
|
|
expected: mapTagRefs["v1.0.0-rc2"],
|
|
}, {
|
|
name: "pinned major version",
|
|
ref: "v1.0.0",
|
|
expected: mapTagRefs["v1.0.0"],
|
|
}, {
|
|
name: "pinned patch version",
|
|
ref: "v1.0.1",
|
|
expected: mapTagRefs["v1.0.1"],
|
|
}, {
|
|
name: "pinned minor version",
|
|
ref: "v1.1.0",
|
|
expected: mapTagRefs["v1.1.0"],
|
|
}, {
|
|
name: "patch wildcard constraint",
|
|
ref: "v1.0.*",
|
|
expected: mapTagRefs["v1.0.1"],
|
|
}, {
|
|
name: "patch tilde constraint",
|
|
ref: "~v1.0.0",
|
|
expected: mapTagRefs["v1.0.1"],
|
|
}, {
|
|
name: "minor wildcard constraint",
|
|
ref: "v1.*",
|
|
expected: mapTagRefs["v1.1.0"],
|
|
}, {
|
|
// The semver library allows for using both * and x as the wildcard modifier.
|
|
name: "alternative minor wildcard constraint",
|
|
ref: "v1.x",
|
|
expected: mapTagRefs["v1.1.0"],
|
|
}, {
|
|
name: "minor gte constraint",
|
|
ref: ">= v1.0.0",
|
|
expected: mapTagRefs["v1.1.0"],
|
|
}, {
|
|
name: "multiple constraints",
|
|
ref: "> v1.0.0 < v1.1.0",
|
|
expected: mapTagRefs["v1.0.1"],
|
|
}, {
|
|
// We treat non-specific semver versions as regular tags, rather than constraints.
|
|
name: "non-specific version",
|
|
ref: "v1.0",
|
|
expected: mapTagRefs["v1.0"],
|
|
}, {
|
|
// Which means a missing tag will raise an error.
|
|
name: "missing non-specific version",
|
|
ref: "v1.1",
|
|
error: true,
|
|
}, {
|
|
// This is NOT a semver constraint, so it should always resolve to itself - because specifying a tag should
|
|
// return the commit for that tag.
|
|
// semver/v3 has the unfortunate semver-ish behaviour where any tag starting with a number is considered to be
|
|
// "semver-ish", where that number is the semver major version, and the rest then gets coerced into a beta
|
|
// version string. This can cause unexpected behaviour with constraints logic.
|
|
// In this case, if the tag is being incorrectly coerced into semver (for being semver-ish), it will incorrectly
|
|
// return the commit for the 2024-banana tag; which we want to avoid.
|
|
name: "apple non-semver tag",
|
|
ref: "2024-apple",
|
|
expected: mapTagRefs["2024-apple"],
|
|
}, {
|
|
name: "banana non-semver tag",
|
|
ref: "2024-banana",
|
|
expected: mapTagRefs["2024-banana"],
|
|
}, {
|
|
// A semver version (without constraints) should ONLY match itself.
|
|
// We do not want "2024-apple" to get "semver-ish'ed" into matching "2024.0.0-apple"; they're different tags.
|
|
name: "no semver tag coercion",
|
|
ref: "2024.0.0-apple",
|
|
error: true,
|
|
}, {
|
|
// No minor versions are specified, so we would expect a major version of 2025 or more.
|
|
// This is because if we specify > 11 in semver, we would not expect 11.1.0 to pass; it should be 12.0.0 or more.
|
|
// Similarly, if we were to specify > 11.0, we would expect 11.1.0 or more.
|
|
name: "semver constraints on non-semver tags",
|
|
ref: "> 2024-apple",
|
|
error: true,
|
|
}, {
|
|
// However, if one specifies the minor/patch versions, semver constraints can be used to match non-semver tags.
|
|
// 2024-banana is considered as "2024.0.0-banana" in semver-ish, and banana > apple, so it's a match.
|
|
// Note: this is more for documentation and future reference than real testing, as it seems like quite odd behaviour.
|
|
name: "semver constraints on semver tags",
|
|
ref: "> 2024.0.0-apple",
|
|
expected: mapTagRefs["2024-banana"],
|
|
}} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
commitSHA, err := client.LsRemote(tc.ref)
|
|
if tc.error {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
assert.True(t, IsCommitSHA(commitSHA))
|
|
assert.Equal(t, tc.expected, commitSHA)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_SemverTagsWithPrefix(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
ctx := t.Context()
|
|
|
|
client, err := NewClientExt("file://"+tempDir, tempDir, NopCreds{}, true, false, "", "")
|
|
require.NoError(t, err)
|
|
|
|
err = client.Init()
|
|
require.NoError(t, err)
|
|
|
|
// Helper to run git commands without editor prompts and without signing
|
|
runGitCmd := func(args ...string) error {
|
|
cmd := exec.CommandContext(ctx, "git", args...)
|
|
cmd.Dir = client.Root()
|
|
cmd.Env = append(os.Environ(), "GIT_EDITOR=true", "GIT_TERMINAL_PROMPT=0")
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
// Disable tag signing for this test
|
|
err = runGitCmd("config", "tag.gpgSign", "false")
|
|
require.NoError(t, err)
|
|
|
|
mapTagRefs := map[string]string{}
|
|
for _, tag := range []string{
|
|
"prod/v1.0.0-rc1",
|
|
"prod/v1.0.0-rc2",
|
|
"prod/v1.0.0",
|
|
"prod/v1.0",
|
|
"prod/v1.0.1",
|
|
"prod/v1.1.0",
|
|
"staging/v1.0.0",
|
|
"staging/v2.0.0",
|
|
"foo/bar/v1.0.0",
|
|
"foo/bar/v1.0.1",
|
|
"foo/baz/v1.0.0",
|
|
"v2.0.0",
|
|
} {
|
|
err = runGitCmd("commit", "-m", tag+" commit", "--allow-empty")
|
|
require.NoError(t, err)
|
|
|
|
// Create lightweight tag (no -a flag, no editor)
|
|
err = runGitCmd("tag", tag)
|
|
require.NoError(t, err)
|
|
|
|
sha, err := client.LsRemote("HEAD")
|
|
require.NoError(t, err)
|
|
|
|
mapTagRefs[tag] = sha
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
ref string
|
|
tagPrefix string
|
|
expected string
|
|
error bool
|
|
}{{
|
|
name: "pinned version with prefix",
|
|
ref: "v1.0.0",
|
|
tagPrefix: "prod/",
|
|
expected: mapTagRefs["prod/v1.0.0"],
|
|
}, {
|
|
name: "pinned rc version with prefix",
|
|
ref: "v1.0.0-rc1",
|
|
tagPrefix: "prod/",
|
|
expected: mapTagRefs["prod/v1.0.0-rc1"],
|
|
}, {
|
|
name: "lt rc constraint with prefix",
|
|
ref: "< v1.0.0-rc3",
|
|
tagPrefix: "prod/",
|
|
expected: mapTagRefs["prod/v1.0.0-rc2"],
|
|
}, {
|
|
name: "patch wildcard with prefix",
|
|
ref: "v1.0.*",
|
|
tagPrefix: "prod/",
|
|
expected: mapTagRefs["prod/v1.0.1"],
|
|
}, {
|
|
name: "minor wildcard with prefix",
|
|
ref: "v1.*",
|
|
tagPrefix: "prod/",
|
|
expected: mapTagRefs["prod/v1.1.0"],
|
|
}, {
|
|
name: "patch tilde constraint with prefix",
|
|
ref: "~v1.0.0",
|
|
tagPrefix: "prod/",
|
|
expected: mapTagRefs["prod/v1.0.1"],
|
|
}, {
|
|
name: "gte constraint with prefix",
|
|
ref: ">= v1.0.0",
|
|
tagPrefix: "prod/",
|
|
expected: mapTagRefs["prod/v1.1.0"],
|
|
}, {
|
|
name: "range constraint with prefix",
|
|
ref: "> v1.0.0 < v1.1.0",
|
|
tagPrefix: "prod/",
|
|
expected: mapTagRefs["prod/v1.0.1"],
|
|
}, {
|
|
name: "staging gt constraint",
|
|
ref: "> v1.0.0",
|
|
tagPrefix: "staging/",
|
|
expected: mapTagRefs["staging/v2.0.0"],
|
|
}, {
|
|
name: "staging wildcard",
|
|
ref: "v*",
|
|
tagPrefix: "staging/",
|
|
expected: mapTagRefs["staging/v2.0.0"],
|
|
}, {
|
|
name: "deep nested prefix patch wildcard",
|
|
ref: "v1.0.*",
|
|
tagPrefix: "foo/bar/",
|
|
expected: mapTagRefs["foo/bar/v1.0.1"],
|
|
}, {
|
|
name: "deep nested prefix exact",
|
|
ref: "v1.0.0",
|
|
tagPrefix: "foo/baz/",
|
|
expected: mapTagRefs["foo/baz/v1.0.0"],
|
|
}, {
|
|
name: "non-specific version with prefix",
|
|
ref: "v1.0",
|
|
tagPrefix: "prod/",
|
|
expected: mapTagRefs["prod/v1.0"],
|
|
}, {
|
|
name: "missing non-specific version with prefix",
|
|
ref: "v1.1",
|
|
tagPrefix: "prod/",
|
|
error: true,
|
|
}, {
|
|
name: "non-matching prefix returns error",
|
|
ref: "v1.0.*",
|
|
tagPrefix: "dev/",
|
|
error: true,
|
|
}, {
|
|
name: "no prefix still works",
|
|
ref: "v2.*",
|
|
expected: mapTagRefs["v2.0.0"],
|
|
}} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
testClient, err := NewClientExt("file://"+tempDir, tempDir, NopCreds{}, true, false, "", "", WithTagPrefix(tc.tagPrefix))
|
|
require.NoError(t, err)
|
|
commitSHA, err := testClient.LsRemote(tc.ref)
|
|
if tc.error {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
assert.True(t, IsCommitSHA(commitSHA))
|
|
assert.Equal(t, tc.expected, commitSHA)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_nativeGitClient_Submodule(t *testing.T) {
|
|
tempDir, err := os.MkdirTemp("", "")
|
|
require.NoError(t, err)
|
|
ctx := t.Context()
|
|
|
|
foo := filepath.Join(tempDir, "foo")
|
|
err = os.Mkdir(foo, 0o755)
|
|
require.NoError(t, err)
|
|
|
|
err = runCmd(ctx, foo, "git", "init")
|
|
require.NoError(t, err)
|
|
|
|
bar := filepath.Join(tempDir, "bar")
|
|
err = os.Mkdir(bar, 0o755)
|
|
require.NoError(t, err)
|
|
|
|
err = runCmd(ctx, bar, "git", "init")
|
|
require.NoError(t, err)
|
|
|
|
err = runCmd(ctx, bar, "git", "commit", "-m", "Initial commit", "--allow-empty")
|
|
require.NoError(t, err)
|
|
|
|
// Embed repository bar into repository foo
|
|
t.Setenv("GIT_ALLOW_PROTOCOL", "file")
|
|
err = runCmd(ctx, foo, "git", "submodule", "add", bar)
|
|
require.NoError(t, err)
|
|
|
|
err = runCmd(ctx, foo, "git", "commit", "-m", "Initial commit")
|
|
require.NoError(t, err)
|
|
|
|
tempDir, err = os.MkdirTemp("", "")
|
|
require.NoError(t, err)
|
|
|
|
// Clone foo
|
|
err = runCmd(ctx, tempDir, "git", "clone", foo)
|
|
require.NoError(t, err)
|
|
|
|
client, err := NewClient("file://"+foo, NopCreds{}, true, false, "", "")
|
|
require.NoError(t, err)
|
|
|
|
err = client.Init()
|
|
require.NoError(t, err)
|
|
|
|
err = client.Fetch(t.Context(), "", 0)
|
|
require.NoError(t, err)
|
|
|
|
commitSHA, err := client.LsRemote("HEAD")
|
|
require.NoError(t, err)
|
|
|
|
// Call Checkout() with submoduleEnabled=false.
|
|
_, err = client.Checkout(t.Context(), commitSHA, false, true)
|
|
require.NoError(t, err)
|
|
|
|
// Check if submodule url does not exist in .git/config
|
|
err = runCmd(ctx, client.Root(), "git", "config", "submodule.bar.url")
|
|
require.Error(t, err)
|
|
|
|
// Call Submodule() via Checkout() with submoduleEnabled=true.
|
|
_, err = client.Checkout(t.Context(), commitSHA, true, true)
|
|
require.NoError(t, err)
|
|
|
|
// Check if the .gitmodule URL is reflected in .git/config
|
|
cmd := exec.CommandContext(ctx, "git", "config", "submodule.bar.url")
|
|
cmd.Dir = client.Root()
|
|
result, err := cmd.Output()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, bar+"\n", string(result))
|
|
|
|
// Change URL of submodule bar
|
|
err = runCmd(ctx, client.Root(), "git", "config", "--file=.gitmodules", "submodule.bar.url", bar+"baz")
|
|
require.NoError(t, err)
|
|
|
|
// Call Submodule()
|
|
err = client.Submodule(t.Context())
|
|
require.NoError(t, err)
|
|
|
|
// Check if the URL change in .gitmodule is reflected in .git/config
|
|
cmd = exec.CommandContext(ctx, "git", "config", "submodule.bar.url")
|
|
cmd.Dir = client.Root()
|
|
result, err = cmd.Output()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, bar+"baz\n", string(result))
|
|
}
|
|
|
|
func TestNewClient_invalidSSHURL(t *testing.T) {
|
|
client, err := NewClient("ssh://bitbucket.org:org/repo", NopCreds{}, false, false, "", "")
|
|
assert.Nil(t, client)
|
|
assert.ErrorIs(t, err, ErrInvalidRepoURL)
|
|
}
|
|
|
|
func Test_IsRevisionPresent(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
ctx := t.Context()
|
|
|
|
client, err := NewClientExt("file://"+tempDir, tempDir, NopCreds{}, true, false, "", "")
|
|
require.NoError(t, err)
|
|
|
|
err = client.Init()
|
|
require.NoError(t, err)
|
|
|
|
p := path.Join(client.Root(), "README")
|
|
f, err := os.Create(p)
|
|
require.NoError(t, err)
|
|
_, err = f.WriteString("Hello.")
|
|
require.NoError(t, err)
|
|
err = f.Close()
|
|
require.NoError(t, err)
|
|
|
|
err = runCmd(ctx, client.Root(), "git", "add", "README")
|
|
require.NoError(t, err)
|
|
|
|
err = runCmd(ctx, client.Root(), "git", "commit", "-m", "Initial Commit", "-a")
|
|
require.NoError(t, err)
|
|
|
|
commitSHA, err := client.LsRemote("HEAD")
|
|
require.NoError(t, err)
|
|
|
|
// Ensure revision for HEAD is present locally.
|
|
revisionPresent := client.IsRevisionPresent(t.Context(), commitSHA)
|
|
assert.True(t, revisionPresent)
|
|
|
|
// Ensure invalid revision is not returned.
|
|
revisionPresent = client.IsRevisionPresent(t.Context(), "invalid-revision")
|
|
assert.False(t, revisionPresent)
|
|
}
|
|
|
|
func Test_nativeGitClient_RevisionMetadata(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
client, err := NewClient("file://"+tempDir, NopCreds{}, true, false, "", "")
|
|
require.NoError(t, err)
|
|
|
|
err = client.Init()
|
|
require.NoError(t, err)
|
|
|
|
p := path.Join(client.Root(), "README")
|
|
f, err := os.Create(p)
|
|
require.NoError(t, err)
|
|
_, err = f.WriteString("Hello.")
|
|
require.NoError(t, err)
|
|
err = f.Close()
|
|
require.NoError(t, err)
|
|
|
|
ctx := t.Context()
|
|
|
|
err = runCmd(ctx, client.Root(), "git", "config", "user.name", "FooBar ||| something\nelse")
|
|
require.NoError(t, err)
|
|
err = runCmd(ctx, client.Root(), "git", "config", "user.email", "[email protected]")
|
|
require.NoError(t, err)
|
|
|
|
err = runCmd(ctx, client.Root(), "git", "add", "README")
|
|
require.NoError(t, err)
|
|
now := time.Now()
|
|
err = runCmd(ctx, client.Root(), "git", "commit", "--date=\"Sat Jun 5 20:00:00 2021 +0000 UTC\"", "-m", `| Initial commit |
|
|
|
|
|
|
(╯°□°)╯︵ ┻━┻
|
|
`, "-a",
|
|
"--trailer", "Argocd-reference-commit-author: test-author <[email protected]>",
|
|
"--trailer", "Argocd-reference-commit-date: "+now.Format(time.RFC3339),
|
|
"--trailer", "Argocd-reference-commit-subject: chore: make a change",
|
|
"--trailer", "Argocd-reference-commit-sha: abc123",
|
|
"--trailer", "Argocd-reference-commit-repourl: https://git.example.com/test/repo.git",
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
metadata, err := client.RevisionMetadata(t.Context(), "HEAD")
|
|
require.NoError(t, err)
|
|
require.Equal(t, &RevisionMetadata{
|
|
Author: `FooBar ||| somethingelse <[email protected]>`,
|
|
Date: time.Date(2021, time.June, 5, 20, 0, 0, 0, time.UTC).Local(),
|
|
Tags: []string{},
|
|
Message: fmt.Sprintf(`| Initial commit |
|
|
|
|
(╯°□°)╯︵ ┻━┻
|
|
|
|
Argocd-reference-commit-author: test-author <[email protected]>
|
|
Argocd-reference-commit-date: %s
|
|
Argocd-reference-commit-subject: chore: make a change
|
|
Argocd-reference-commit-sha: abc123
|
|
Argocd-reference-commit-repourl: https://git.example.com/test/repo.git`, now.Format(time.RFC3339)),
|
|
References: []RevisionReference{
|
|
{
|
|
Commit: &CommitMetadata{
|
|
Author: mail.Address{
|
|
Name: "test-author",
|
|
Address: "[email protected]",
|
|
},
|
|
Date: now.Format(time.RFC3339),
|
|
Subject: "chore: make a change",
|
|
SHA: "abc123",
|
|
RepoURL: "https://git.example.com/test/repo.git",
|
|
},
|
|
},
|
|
},
|
|
}, metadata)
|
|
}
|
|
|
|
func Test_nativeGitClient_SetAuthor(t *testing.T) {
|
|
expectedName := "Tester"
|
|
expectedEmail := "[email protected]"
|
|
ctx := t.Context()
|
|
|
|
tempDir, err := _createEmptyGitRepo(ctx)
|
|
require.NoError(t, err)
|
|
|
|
client, err := NewClient("file://"+tempDir, NopCreds{}, true, false, "", "")
|
|
require.NoError(t, err)
|
|
|
|
err = client.Init()
|
|
require.NoError(t, err)
|
|
|
|
out, err := client.SetAuthor(t.Context(), expectedName, expectedEmail)
|
|
require.NoError(t, err, "error output: ", out)
|
|
|
|
// Check git user.name
|
|
gitUserName, err := outputCmd(ctx, client.Root(), "git", "config", "--local", "user.name")
|
|
require.NoError(t, err)
|
|
actualName := strings.TrimSpace(string(gitUserName))
|
|
require.Equal(t, expectedName, actualName)
|
|
|
|
// Check git user.email
|
|
gitUserEmail, err := outputCmd(ctx, client.Root(), "git", "config", "--local", "user.email")
|
|
require.NoError(t, err)
|
|
actualEmail := strings.TrimSpace(string(gitUserEmail))
|
|
require.Equal(t, expectedEmail, actualEmail)
|
|
}
|
|
|
|
func Test_nativeGitClient_CheckoutOrOrphan(t *testing.T) {
|
|
t.Run("checkout to an existing branch", func(t *testing.T) {
|
|
// not main or master
|
|
expectedBranch := "feature"
|
|
ctx := t.Context()
|
|
|
|
tempDir, err := _createEmptyGitRepo(ctx)
|
|
require.NoError(t, err)
|
|
|
|
client, err := NewClientExt("file://"+tempDir, tempDir, NopCreds{}, true, false, "", "")
|
|
require.NoError(t, err)
|
|
|
|
err = client.Init()
|
|
require.NoError(t, err)
|
|
|
|
// set the author for the initial commit of the orphan branch
|
|
out, err := client.SetAuthor(t.Context(), "test", "[email protected]")
|
|
require.NoError(t, err, "error output: %s", out)
|
|
|
|
// get base branch
|
|
gitCurrentBranch, err := outputCmd(ctx, tempDir, "git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
require.NoError(t, err)
|
|
baseBranch := strings.TrimSpace(string(gitCurrentBranch))
|
|
|
|
// get base commit
|
|
gitCurrentCommitHash, err := outputCmd(ctx, tempDir, "git", "rev-parse", "HEAD")
|
|
require.NoError(t, err)
|
|
expectedCommitHash := strings.TrimSpace(string(gitCurrentCommitHash))
|
|
|
|
// make expected branch
|
|
err = runCmd(ctx, tempDir, "git", "checkout", "-b", expectedBranch)
|
|
require.NoError(t, err)
|
|
|
|
// checkout to base branch, ready to test
|
|
err = runCmd(ctx, tempDir, "git", "checkout", baseBranch)
|
|
require.NoError(t, err)
|
|
|
|
out, err = client.CheckoutOrOrphan(t.Context(), expectedBranch, false)
|
|
require.NoError(t, err, "error output: ", out)
|
|
|
|
// get current branch, verify current branch
|
|
gitCurrentBranch, err = outputCmd(ctx, tempDir, "git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
require.NoError(t, err)
|
|
actualBranch := strings.TrimSpace(string(gitCurrentBranch))
|
|
require.Equal(t, expectedBranch, actualBranch)
|
|
|
|
// get current commit hash, verify current commit hash
|
|
// equal -> not orphan
|
|
gitCurrentCommitHash, err = outputCmd(ctx, tempDir, "git", "rev-parse", "HEAD")
|
|
require.NoError(t, err)
|
|
actualCommitHash := strings.TrimSpace(string(gitCurrentCommitHash))
|
|
require.Equal(t, expectedCommitHash, actualCommitHash)
|
|
})
|
|
|
|
t.Run("orphan", func(t *testing.T) {
|
|
// not main or master
|
|
expectedBranch := "feature"
|
|
ctx := t.Context()
|
|
|
|
// make origin git repository
|
|
tempDir, err := _createEmptyGitRepo(ctx)
|
|
require.NoError(t, err)
|
|
originGitRepoURL := "file://" + tempDir
|
|
err = runCmd(ctx, tempDir, "git", "commit", "-m", "Second commit", "--allow-empty")
|
|
require.NoError(t, err)
|
|
|
|
// get base branch
|
|
gitCurrentBranch, err := outputCmd(ctx, tempDir, "git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
require.NoError(t, err)
|
|
baseBranch := strings.TrimSpace(string(gitCurrentBranch))
|
|
|
|
// make test dir
|
|
tempDir, err = os.MkdirTemp("", "")
|
|
require.NoError(t, err)
|
|
|
|
client, err := NewClientExt(originGitRepoURL, tempDir, NopCreds{}, true, false, "", "")
|
|
require.NoError(t, err)
|
|
|
|
err = client.Init()
|
|
require.NoError(t, err)
|
|
|
|
// set the author for the initial commit of the orphan branch
|
|
out, err := client.SetAuthor(t.Context(), "test", "[email protected]")
|
|
require.NoError(t, err, "error output: %s", out)
|
|
|
|
err = client.Fetch(t.Context(), "", 0)
|
|
require.NoError(t, err)
|
|
|
|
// checkout to origin base branch
|
|
err = runCmd(ctx, tempDir, "git", "checkout", baseBranch)
|
|
require.NoError(t, err)
|
|
|
|
// get base commit
|
|
gitCurrentCommitHash, err := outputCmd(ctx, tempDir, "git", "rev-parse", "HEAD")
|
|
require.NoError(t, err)
|
|
baseCommitHash := strings.TrimSpace(string(gitCurrentCommitHash))
|
|
|
|
out, err = client.CheckoutOrOrphan(t.Context(), expectedBranch, false)
|
|
require.NoError(t, err, "error output: ", out)
|
|
|
|
// get current branch, verify current branch
|
|
gitCurrentBranch, err = outputCmd(ctx, tempDir, "git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
require.NoError(t, err)
|
|
actualBranch := strings.TrimSpace(string(gitCurrentBranch))
|
|
require.Equal(t, expectedBranch, actualBranch)
|
|
|
|
// check orphan branch
|
|
|
|
// get current commit hash, verify current commit hash
|
|
// not equal -> orphan
|
|
gitCurrentCommitHash, err = outputCmd(ctx, tempDir, "git", "rev-parse", "HEAD")
|
|
require.NoError(t, err)
|
|
currentCommitHash := strings.TrimSpace(string(gitCurrentCommitHash))
|
|
require.NotEqual(t, baseCommitHash, currentCommitHash)
|
|
|
|
gitCurrentCommitMessage, err := outputCmd(ctx, tempDir, "git", "log", "--format=%B", "-n", "1", "HEAD")
|
|
require.NoError(t, err)
|
|
currentCommitMessage := strings.TrimSpace(string(gitCurrentCommitMessage))
|
|
require.Contains(t, currentCommitMessage, expectedBranch)
|
|
|
|
// get commit count on current branch, verify 1 -> orphan
|
|
gitCommitCount, err := outputCmd(ctx, tempDir, "git", "rev-list", "--count", actualBranch)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "1", strings.TrimSpace(string(gitCommitCount)))
|
|
})
|
|
}
|
|
|
|
func Test_nativeGitClient_CheckoutOrNew(t *testing.T) {
|
|
t.Run("checkout to an existing branch", func(t *testing.T) {
|
|
// Example status
|
|
// * 57aef63 (feature) Second commit
|
|
// * a4fad22 (main) Initial commit
|
|
|
|
// Test scenario
|
|
// given : main branch (w/ Initial commit)
|
|
// when : try to check out [main -> feature]
|
|
// then : feature branch (w/ Second commit)
|
|
|
|
// not main or master
|
|
expectedBranch := "feature"
|
|
ctx := t.Context()
|
|
|
|
tempDir, err := _createEmptyGitRepo(ctx)
|
|
require.NoError(t, err)
|
|
|
|
client, err := NewClientExt("file://"+tempDir, tempDir, NopCreds{}, true, false, "", "")
|
|
require.NoError(t, err)
|
|
|
|
err = client.Init()
|
|
require.NoError(t, err)
|
|
|
|
out, err := client.SetAuthor(t.Context(), "test", "[email protected]")
|
|
require.NoError(t, err, "error output: %s", out)
|
|
|
|
// get base branch
|
|
gitCurrentBranch, err := outputCmd(ctx, tempDir, "git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
require.NoError(t, err)
|
|
baseBranch := strings.TrimSpace(string(gitCurrentBranch))
|
|
|
|
// make expected branch
|
|
err = runCmd(ctx, tempDir, "git", "checkout", "-b", expectedBranch)
|
|
require.NoError(t, err)
|
|
|
|
// make expected commit
|
|
err = runCmd(ctx, tempDir, "git", "commit", "-m", "Second commit", "--allow-empty")
|
|
require.NoError(t, err)
|
|
|
|
// get expected commit
|
|
expectedCommitHash, err := client.CommitSHA(t.Context())
|
|
require.NoError(t, err)
|
|
|
|
// checkout to base branch, ready to test
|
|
err = runCmd(ctx, tempDir, "git", "checkout", baseBranch)
|
|
require.NoError(t, err)
|
|
|
|
out, err = client.CheckoutOrNew(t.Context(), expectedBranch, baseBranch, false)
|
|
require.NoError(t, err, "error output: ", out)
|
|
|
|
// get current branch, verify current branch
|
|
gitCurrentBranch, err = outputCmd(ctx, tempDir, "git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
require.NoError(t, err)
|
|
actualBranch := strings.TrimSpace(string(gitCurrentBranch))
|
|
require.Equal(t, expectedBranch, actualBranch)
|
|
|
|
// get current commit hash, verify current commit hash
|
|
actualCommitHash, err := client.CommitSHA(t.Context())
|
|
require.NoError(t, err)
|
|
require.Equal(t, expectedCommitHash, actualCommitHash)
|
|
})
|
|
|
|
t.Run("new", func(t *testing.T) {
|
|
// Test scenario
|
|
// given : main branch (w/ Initial commit)
|
|
// * a4fad22 (main) Initial commit
|
|
// when : try to check out [main -> feature]
|
|
// then : feature branch (w/ Initial commit)
|
|
// * a4fad22 (feature, main) Initial commit
|
|
|
|
// not main or master
|
|
expectedBranch := "feature"
|
|
ctx := t.Context()
|
|
|
|
tempDir, err := _createEmptyGitRepo(ctx)
|
|
require.NoError(t, err)
|
|
|
|
client, err := NewClientExt("file://"+tempDir, tempDir, NopCreds{}, true, false, "", "")
|
|
require.NoError(t, err)
|
|
|
|
err = client.Init()
|
|
require.NoError(t, err)
|
|
|
|
out, err := client.SetAuthor(t.Context(), "test", "[email protected]")
|
|
require.NoError(t, err, "error output: %s", out)
|
|
|
|
// get base branch
|
|
gitCurrentBranch, err := outputCmd(ctx, tempDir, "git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
require.NoError(t, err)
|
|
baseBranch := strings.TrimSpace(string(gitCurrentBranch))
|
|
|
|
// get expected commit
|
|
expectedCommitHash, err := client.CommitSHA(t.Context())
|
|
require.NoError(t, err)
|
|
|
|
out, err = client.CheckoutOrNew(t.Context(), expectedBranch, baseBranch, false)
|
|
require.NoError(t, err, "error output: ", out)
|
|
|
|
// get current branch, verify current branch
|
|
gitCurrentBranch, err = outputCmd(ctx, tempDir, "git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
require.NoError(t, err)
|
|
actualBranch := strings.TrimSpace(string(gitCurrentBranch))
|
|
require.Equal(t, expectedBranch, actualBranch)
|
|
|
|
// get current commit hash, verify current commit hash
|
|
actualCommitHash, err := client.CommitSHA(t.Context())
|
|
require.NoError(t, err)
|
|
require.Equal(t, expectedCommitHash, actualCommitHash)
|
|
})
|
|
}
|
|
|
|
func Test_nativeGitClient_RemoveContents_SpecificPath(t *testing.T) {
|
|
// given
|
|
ctx := t.Context()
|
|
tempDir, err := _createEmptyGitRepo(ctx)
|
|
require.NoError(t, err)
|
|
|
|
client, err := NewClient("file://"+tempDir, NopCreds{}, true, false, "", "")
|
|
require.NoError(t, err)
|
|
|
|
err = client.Init()
|
|
require.NoError(t, err)
|
|
|
|
_, err = client.SetAuthor(t.Context(), "test", "[email protected]")
|
|
require.NoError(t, err)
|
|
|
|
err = runCmd(ctx, client.Root(), "touch", "README.md")
|
|
require.NoError(t, err)
|
|
|
|
err = runCmd(ctx, client.Root(), "mkdir", "scripts")
|
|
require.NoError(t, err)
|
|
err = runCmd(ctx, client.Root(), "touch", "scripts/startup.sh")
|
|
require.NoError(t, err)
|
|
|
|
err = runCmd(ctx, client.Root(), "git", "add", "--all")
|
|
require.NoError(t, err)
|
|
err = runCmd(ctx, client.Root(), "git", "commit", "-m", "Make files")
|
|
require.NoError(t, err)
|
|
|
|
// when: remove only "scripts" directory
|
|
_, err = client.RemoveContents(t.Context(), []string{"scripts"})
|
|
require.NoError(t, err)
|
|
|
|
// then: "scripts" should be gone, "README.md" should still exist
|
|
_, err = os.Stat(filepath.Join(client.Root(), "README.md"))
|
|
require.NoError(t, err, "README.md should not be removed")
|
|
|
|
_, err = os.Stat(filepath.Join(client.Root(), "scripts"))
|
|
require.Error(t, err, "scripts directory should be removed")
|
|
|
|
// and: listing should only show README.md
|
|
ls, err := outputCmd(ctx, client.Root(), "ls")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "README.md", strings.TrimSpace(string(ls)))
|
|
}
|
|
|
|
func Test_nativeGitClient_CommitAndPush(t *testing.T) {
|
|
ctx := t.Context()
|
|
tempDir, err := _createEmptyGitRepo(ctx)
|
|
require.NoError(t, err)
|
|
|
|
// config receive.denyCurrentBranch updateInstead
|
|
// because local git init make a non-bare repository which cannot be pushed normally
|
|
err = runCmd(ctx, tempDir, "git", "config", "--local", "receive.denyCurrentBranch", "updateInstead")
|
|
require.NoError(t, err)
|
|
|
|
// get branch
|
|
gitCurrentBranch, err := outputCmd(ctx, tempDir, "git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
require.NoError(t, err)
|
|
branch := strings.TrimSpace(string(gitCurrentBranch))
|
|
|
|
client, err := NewClient("file://"+tempDir, NopCreds{}, true, false, "", "")
|
|
require.NoError(t, err)
|
|
|
|
err = client.Init()
|
|
require.NoError(t, err)
|
|
|
|
out, err := client.SetAuthor(t.Context(), "test", "[email protected]")
|
|
require.NoError(t, err, "error output: ", out)
|
|
|
|
err = client.Fetch(t.Context(), branch, 0)
|
|
require.NoError(t, err)
|
|
|
|
out, err = client.Checkout(t.Context(), branch, false, true)
|
|
require.NoError(t, err, "error output: ", out)
|
|
|
|
// make a file then commit and push
|
|
err = runCmd(ctx, client.Root(), "touch", "README.md")
|
|
require.NoError(t, err)
|
|
|
|
out, err = client.CommitAndPush(t.Context(), branch, "docs: README")
|
|
require.NoError(t, err, "error output: %s", out)
|
|
|
|
// get current commit hash of the cloned repository
|
|
expectedCommitHash, err := client.CommitSHA(t.Context())
|
|
require.NoError(t, err)
|
|
|
|
// get origin repository's current commit hash
|
|
gitCurrentCommitHash, err := outputCmd(ctx, tempDir, "git", "rev-parse", "HEAD")
|
|
require.NoError(t, err)
|
|
actualCommitHash := strings.TrimSpace(string(gitCurrentCommitHash))
|
|
require.Equal(t, expectedCommitHash, actualCommitHash)
|
|
}
|
|
|
|
func Test_newAuth_AzureWorkloadIdentity(t *testing.T) {
|
|
tokenprovider := new(mocks.TokenProvider)
|
|
tokenprovider.EXPECT().GetToken(azureDevopsEntraResourceId).Return(&workloadidentity.Token{AccessToken: "accessToken"}, nil).Maybe()
|
|
|
|
creds := AzureWorkloadIdentityCreds{store: NoopCredsStore{}, tokenProvider: tokenprovider}
|
|
|
|
auth, err := newAuth("", creds)
|
|
require.NoError(t, err)
|
|
_, ok := auth.(*githttp.TokenAuth)
|
|
require.Truef(t, ok, "expected TokenAuth but got %T", auth)
|
|
}
|
|
|
|
// Test_newAuth_SSH_HostKeyAlgorithms verifies that for SSH creds with a known
|
|
// host key, newAuth populates HostKeyAlgorithms on both PublicKeysWithOptions
|
|
// and the embedded gitssh.PublicKeys.HostKeyCallbackHelper. The latter is
|
|
// required because go-git's SetHostKeyCallback overwrites the resolved
|
|
// ssh.ClientConfig.HostKeyAlgorithms with the embedded helper's value, so if
|
|
// the helper field is empty the algorithms get clobbered (go-git/go-git#1551).
|
|
func Test_newAuth_SSH_HostKeyAlgorithms(t *testing.T) {
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
require.NoError(t, err)
|
|
|
|
pemBlock, err := ssh.MarshalPrivateKey(priv, "")
|
|
require.NoError(t, err)
|
|
privPEM := pem.EncodeToMemory(pemBlock)
|
|
|
|
sshPub, err := ssh.NewPublicKey(pub)
|
|
require.NoError(t, err)
|
|
authorizedKey := ssh.MarshalAuthorizedKey(sshPub)
|
|
|
|
tmpDir := t.TempDir()
|
|
knownHostsPath := filepath.Join(tmpDir, "ssh_known_hosts")
|
|
knownHostsLine := "example.com " + strings.TrimSpace(string(authorizedKey))
|
|
require.NoError(t, os.WriteFile(knownHostsPath, []byte(knownHostsLine+"\n"), 0o600))
|
|
|
|
t.Setenv("CD_SSH_DATA_PATH", tmpDir)
|
|
|
|
auth, err := newAuth("[email protected]:org/repo.git", SSHCreds{sshPrivateKey: string(privPEM)})
|
|
require.NoError(t, err)
|
|
|
|
pk, ok := auth.(*PublicKeysWithOptions)
|
|
require.Truef(t, ok, "expected *PublicKeysWithOptions but got %T", auth)
|
|
|
|
require.NotEmpty(t, pk.HostKeyAlgorithms, "wrapper HostKeyAlgorithms should be populated from known_hosts")
|
|
assert.Contains(t, pk.HostKeyAlgorithms, ssh.KeyAlgoED25519)
|
|
assert.Equal(t, pk.HostKeyAlgorithms, pk.PublicKeys.HostKeyAlgorithms,
|
|
"embedded helper HostKeyAlgorithms must mirror the wrapper field, otherwise go-git's SetHostKeyCallback clobbers the ClientConfig value")
|
|
|
|
cfg, err := pk.ClientConfig()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, pk.HostKeyAlgorithms, cfg.HostKeyAlgorithms,
|
|
"resolved ssh.ClientConfig must carry the configured HostKeyAlgorithms")
|
|
}
|
|
|
|
func Test_newAuth_SSH_InsecureSkipsKnownHosts(t *testing.T) {
|
|
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
require.NoError(t, err)
|
|
pemBlock, err := ssh.MarshalPrivateKey(priv, "")
|
|
require.NoError(t, err)
|
|
privPEM := pem.EncodeToMemory(pemBlock)
|
|
|
|
auth, err := newAuth("[email protected]:org/repo.git", SSHCreds{sshPrivateKey: string(privPEM), insecure: true})
|
|
require.NoError(t, err)
|
|
|
|
pk, ok := auth.(*PublicKeysWithOptions)
|
|
require.Truef(t, ok, "expected *PublicKeysWithOptions but got %T", auth)
|
|
assert.Empty(t, pk.HostKeyAlgorithms)
|
|
assert.Empty(t, pk.PublicKeys.HostKeyAlgorithms)
|
|
assert.NotNil(t, pk.HostKeyCallback, "insecure mode should still install an InsecureIgnoreHostKey callback")
|
|
}
|
|
|
|
// Test_newAuth_SSH_HostNotInKnownHosts verifies that when the target host is
|
|
// not represented in ssh_known_hosts we still return a usable AuthMethod with
|
|
// a HostKeyCallback wired up, and HostKeyAlgorithms is left empty (so go-git
|
|
// falls back to its default algorithm list rather than advertising algorithms
|
|
// that belong to an unrelated host).
|
|
func Test_newAuth_SSH_HostNotInKnownHosts(t *testing.T) {
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
require.NoError(t, err)
|
|
|
|
pemBlock, err := ssh.MarshalPrivateKey(priv, "")
|
|
require.NoError(t, err)
|
|
privPEM := pem.EncodeToMemory(pemBlock)
|
|
|
|
sshPub, err := ssh.NewPublicKey(pub)
|
|
require.NoError(t, err)
|
|
authorizedKey := ssh.MarshalAuthorizedKey(sshPub)
|
|
|
|
tmpDir := t.TempDir()
|
|
knownHostsPath := filepath.Join(tmpDir, "ssh_known_hosts")
|
|
// Populate ssh_known_hosts with a different host than the one we query.
|
|
knownHostsLine := "other.example.com " + strings.TrimSpace(string(authorizedKey))
|
|
require.NoError(t, os.WriteFile(knownHostsPath, []byte(knownHostsLine+"\n"), 0o600))
|
|
|
|
t.Setenv("CD_SSH_DATA_PATH", tmpDir)
|
|
|
|
auth, err := newAuth("[email protected]:org/repo.git", SSHCreds{sshPrivateKey: string(privPEM)})
|
|
require.NoError(t, err)
|
|
|
|
pk, ok := auth.(*PublicKeysWithOptions)
|
|
require.Truef(t, ok, "expected *PublicKeysWithOptions but got %T", auth)
|
|
assert.NotNil(t, pk.HostKeyCallback, "callback should still be wired even when target host is absent from known_hosts")
|
|
assert.Empty(t, pk.HostKeyAlgorithms, "no algorithms should be advertised when the target host is not in known_hosts")
|
|
assert.Empty(t, pk.PublicKeys.HostKeyAlgorithms)
|
|
}
|
|
|
|
// Test_newAuth_SSH_MalformedPrivateKey verifies that an unparseable SSH
|
|
// private key surfaces as an error from newAuth rather than a partially
|
|
// constructed AuthMethod.
|
|
func Test_newAuth_SSH_MalformedPrivateKey(t *testing.T) {
|
|
// Make sure the known_hosts lookup succeeds so the error originates from
|
|
// ParsePrivateKey, not from the known_hosts setup.
|
|
tmpDir := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "ssh_known_hosts"), nil, 0o600))
|
|
t.Setenv("CD_SSH_DATA_PATH", tmpDir)
|
|
|
|
auth, err := newAuth("[email protected]:org/repo.git", SSHCreds{sshPrivateKey: "not a valid PEM-encoded private key"})
|
|
require.Error(t, err)
|
|
assert.Nil(t, auth)
|
|
}
|
|
|
|
// Test_newAuth_SSH_KnownHostsUnreadable verifies that when the known_hosts
|
|
// file cannot be read (here: it does not exist) we surface an error from the
|
|
// SSHCreds path rather than returning an AuthMethod with no host-key
|
|
// verification configured.
|
|
func Test_newAuth_SSH_KnownHostsUnreadable(t *testing.T) {
|
|
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
require.NoError(t, err)
|
|
pemBlock, err := ssh.MarshalPrivateKey(priv, "")
|
|
require.NoError(t, err)
|
|
privPEM := pem.EncodeToMemory(pemBlock)
|
|
|
|
// Point CD_SSH_DATA_PATH at a directory that does not contain an
|
|
// ssh_known_hosts file. skeema/knownhosts NewDB returns an error when
|
|
// the file is missing.
|
|
t.Setenv("CD_SSH_DATA_PATH", t.TempDir())
|
|
|
|
auth, err := newAuth("[email protected]:org/repo.git", SSHCreds{sshPrivateKey: string(privPEM)})
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, "could not set up SSH known hosts callback")
|
|
assert.Nil(t, auth)
|
|
}
|
|
|
|
// Test_newAuth_SSH_NoCreds_AgentUnavailable verifies that when no Argo CD
|
|
// credentials are configured for an SSH URL and ssh-agent is unreachable, we
|
|
// return (nil, nil) rather than an error — matching the pre-bump fallback
|
|
// behavior where go-git's DefaultAuthBuilder would have been used.
|
|
func Test_newAuth_SSH_NoCreds_AgentUnavailable(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "ssh_known_hosts"), nil, 0o600))
|
|
t.Setenv("CD_SSH_DATA_PATH", tmpDir)
|
|
// Force ssh-agent setup to fail by pointing SSH_AUTH_SOCK at a path that
|
|
// cannot be dialed.
|
|
t.Setenv("SSH_AUTH_SOCK", filepath.Join(tmpDir, "nonexistent-agent.sock"))
|
|
|
|
auth, err := newAuth("[email protected]:org/repo.git", nil)
|
|
require.NoError(t, err, "missing ssh-agent should not bubble up as an error from newAuth")
|
|
assert.Nil(t, auth, "with no agent reachable, newAuth should fall through to go-git's DefaultAuthBuilder by returning nil")
|
|
}
|
|
|
|
func TestNewAuth(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
repoURL string
|
|
creds Creds
|
|
expected transport.AuthMethod
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "HTTPSCreds with bearer token",
|
|
repoURL: "https://github.com/org/repo.git",
|
|
creds: HTTPSCreds{
|
|
bearerToken: "test-token",
|
|
},
|
|
expected: &githttp.TokenAuth{Token: "test-token"},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "HTTPSCreds with basic auth",
|
|
repoURL: "https://github.com/org/repo.git",
|
|
creds: HTTPSCreds{
|
|
username: "test-user",
|
|
password: "test-password",
|
|
},
|
|
expected: &githttp.BasicAuth{Username: "test-user", Password: "test-password"},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "HTTPSCreds with basic auth no username",
|
|
repoURL: "https://github.com/org/repo.git",
|
|
creds: HTTPSCreds{
|
|
password: "test-password",
|
|
},
|
|
expected: &githttp.BasicAuth{Username: "x-access-token", Password: "test-password"},
|
|
wantErr: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
auth, err := newAuth(tt.repoURL, tt.creds)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("newAuth() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
assert.Equal(t, tt.expected, auth)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_nativeGitClient_runCredentialedCmd(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
creds Creds
|
|
environ []string
|
|
expectedArgs []string
|
|
expectedEnv []string
|
|
expectedErr bool
|
|
}{
|
|
{
|
|
name: "basic auth header set",
|
|
creds: &mockCreds{
|
|
environ: []string{forceBasicAuthHeaderEnv + "=Basic dGVzdDp0ZXN0"},
|
|
},
|
|
expectedArgs: []string{"--config-env", "http.extraHeader=" + forceBasicAuthHeaderEnv, "status"},
|
|
expectedEnv: []string{forceBasicAuthHeaderEnv + "=Basic dGVzdDp0ZXN0"},
|
|
expectedErr: false,
|
|
},
|
|
{
|
|
name: "bearer auth header set",
|
|
creds: &mockCreds{
|
|
environ: []string{bearerAuthHeaderEnv + "=Bearer test-token"},
|
|
},
|
|
expectedArgs: []string{"--config-env", "http.extraHeader=" + bearerAuthHeaderEnv, "status"},
|
|
expectedEnv: []string{bearerAuthHeaderEnv + "=Bearer test-token"},
|
|
expectedErr: false,
|
|
},
|
|
{
|
|
name: "no auth header set",
|
|
creds: &mockCreds{
|
|
environ: []string{},
|
|
},
|
|
expectedArgs: []string{"status"},
|
|
expectedEnv: []string{},
|
|
expectedErr: false,
|
|
},
|
|
{
|
|
name: "error getting environment",
|
|
creds: &mockCreds{
|
|
environErr: true,
|
|
},
|
|
expectedArgs: []string{},
|
|
expectedEnv: []string{},
|
|
expectedErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
client := &nativeGitClient{
|
|
creds: tt.creds,
|
|
}
|
|
ctx := t.Context()
|
|
|
|
err := client.runCredentialedCmd(ctx, "status")
|
|
if (err != nil) != tt.expectedErr {
|
|
t.Errorf("runCredentialedCmd() error = %v, expectedErr %v", err, tt.expectedErr)
|
|
return
|
|
}
|
|
|
|
if tt.expectedErr {
|
|
return
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, "git", tt.expectedArgs...)
|
|
cmd.Env = append(os.Environ(), tt.expectedEnv...)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Errorf("runCredentialedCmd() command error = %v, output = %s", err, output)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_LsFiles_RaceCondition(t *testing.T) {
|
|
// Create two temporary directories and initialize them as git repositories
|
|
tempDir1 := t.TempDir()
|
|
tempDir2 := t.TempDir()
|
|
ctx := t.Context()
|
|
|
|
client1, err := NewClient("file://"+tempDir1, NopCreds{}, true, false, "", "")
|
|
require.NoError(t, err)
|
|
client2, err := NewClient("file://"+tempDir2, NopCreds{}, true, false, "", "")
|
|
require.NoError(t, err)
|
|
|
|
err = client1.Init()
|
|
require.NoError(t, err)
|
|
err = client2.Init()
|
|
require.NoError(t, err)
|
|
|
|
// Add different files to each repository
|
|
file1 := filepath.Join(client1.Root(), "file1.txt")
|
|
err = os.WriteFile(file1, []byte("content1"), 0o644)
|
|
require.NoError(t, err)
|
|
err = runCmd(ctx, client1.Root(), "git", "add", "file1.txt")
|
|
require.NoError(t, err)
|
|
err = runCmd(ctx, client1.Root(), "git", "commit", "-m", "Add file1")
|
|
require.NoError(t, err)
|
|
|
|
file2 := filepath.Join(client2.Root(), "file2.txt")
|
|
err = os.WriteFile(file2, []byte("content2"), 0o644)
|
|
require.NoError(t, err)
|
|
err = runCmd(ctx, client2.Root(), "git", "add", "file2.txt")
|
|
require.NoError(t, err)
|
|
err = runCmd(ctx, client2.Root(), "git", "commit", "-m", "Add file2")
|
|
require.NoError(t, err)
|
|
|
|
// Assert that LsFiles returns the correct files when called sequentially
|
|
files1, err := client1.LsFiles(t.Context(), "*", true)
|
|
require.NoError(t, err)
|
|
require.Contains(t, files1, "file1.txt")
|
|
|
|
files2, err := client2.LsFiles(t.Context(), "*", true)
|
|
require.NoError(t, err)
|
|
require.Contains(t, files2, "file2.txt")
|
|
|
|
// Define a function to call LsFiles multiple times in parallel
|
|
var wg sync.WaitGroup
|
|
callLsFiles := func(client Client, expectedFile string) {
|
|
defer wg.Done()
|
|
for range 100 {
|
|
files, err := client.LsFiles(t.Context(), "*", true)
|
|
require.NoError(t, err)
|
|
require.Contains(t, files, expectedFile)
|
|
}
|
|
}
|
|
|
|
// Call LsFiles in parallel for both clients
|
|
wg.Add(2)
|
|
go callLsFiles(client1, "file1.txt")
|
|
go callLsFiles(client2, "file2.txt")
|
|
wg.Wait()
|
|
}
|
|
|
|
type mockCreds struct {
|
|
environ []string
|
|
environErr bool
|
|
}
|
|
|
|
func (m *mockCreds) Environ() (io.Closer, []string, error) {
|
|
if m.environErr {
|
|
return nil, nil, errors.New("error getting environment")
|
|
}
|
|
return io.NopCloser(nil), m.environ, nil
|
|
}
|
|
|
|
func (m *mockCreds) GetUserInfo(_ context.Context) (string, string, error) {
|
|
return "", "", nil
|
|
}
|
|
|
|
func Test_GetReferences(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Now()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expectedReferences []RevisionReference
|
|
expectedMessage string
|
|
}{
|
|
{
|
|
name: "No trailers",
|
|
input: "This is a commit message without trailers.",
|
|
expectedReferences: nil,
|
|
expectedMessage: "This is a commit message without trailers.\n",
|
|
},
|
|
{
|
|
name: "Invalid trailers",
|
|
input: `Argocd-reference-commit-repourl: % invalid %
|
|
Argocd-reference-commit-date: invalid-date
|
|
Argocd-reference-commit-sha: xyz123
|
|
Argocd-reference-commit-body: this isn't json
|
|
Argocd-reference-commit-author: % not email %
|
|
Argocd-reference-commit-bogus:`,
|
|
expectedReferences: nil,
|
|
expectedMessage: `Argocd-reference-commit-repourl: % invalid %
|
|
Argocd-reference-commit-date: invalid-date
|
|
Argocd-reference-commit-sha: xyz123
|
|
Argocd-reference-commit-body: this isn't json
|
|
Argocd-reference-commit-author: % not email %
|
|
Argocd-reference-commit-bogus:
|
|
`,
|
|
},
|
|
{
|
|
name: "Unknown trailers",
|
|
input: "Argocd-reference-commit-unknown: foobar",
|
|
expectedReferences: nil,
|
|
expectedMessage: "Argocd-reference-commit-unknown: foobar\n",
|
|
},
|
|
{
|
|
name: "Some valid and Invalid trailers",
|
|
input: `Argocd-reference-commit-sha: abc123
|
|
Argocd-reference-commit-repourl: % invalid %
|
|
Argocd-reference-commit-date: invalid-date`,
|
|
expectedReferences: []RevisionReference{
|
|
{
|
|
Commit: &CommitMetadata{
|
|
SHA: "abc123",
|
|
},
|
|
},
|
|
},
|
|
expectedMessage: `Argocd-reference-commit-repourl: % invalid %
|
|
Argocd-reference-commit-date: invalid-date
|
|
`,
|
|
},
|
|
{
|
|
name: "Valid trailers",
|
|
input: fmt.Sprintf(`Argocd-reference-commit-repourl: https://github.com/org/repo.git
|
|
Argocd-reference-commit-author: John Doe <[email protected]>
|
|
Argocd-reference-commit-date: %s
|
|
Argocd-reference-commit-subject: Fix bug
|
|
Argocd-reference-commit-body: "Fix bug\n\nSome: trailer"
|
|
Argocd-reference-commit-sha: abc123`, now.Format(time.RFC3339)),
|
|
expectedReferences: []RevisionReference{
|
|
{
|
|
Commit: &CommitMetadata{
|
|
Author: mail.Address{
|
|
Name: "John Doe",
|
|
Address: "[email protected]",
|
|
},
|
|
Date: now.Format(time.RFC3339),
|
|
Body: "Fix bug\n\nSome: trailer",
|
|
Subject: "Fix bug",
|
|
SHA: "abc123",
|
|
RepoURL: "https://github.com/org/repo.git",
|
|
},
|
|
},
|
|
},
|
|
expectedMessage: "",
|
|
},
|
|
{
|
|
name: "Duplicate trailers",
|
|
input: `Argocd-reference-commit-repourl: https://github.com/org/repo.git
|
|
Argocd-reference-commit-repourl: https://github.com/another/repo.git`,
|
|
expectedReferences: []RevisionReference{
|
|
{
|
|
Commit: &CommitMetadata{
|
|
RepoURL: "https://github.com/another/repo.git",
|
|
},
|
|
},
|
|
},
|
|
expectedMessage: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
logCtx := log.WithFields(log.Fields{})
|
|
result, message := GetReferences(logCtx, tt.input)
|
|
assert.Equal(t, tt.expectedReferences, result)
|
|
assert.Equal(t, tt.expectedMessage, message)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_BuiltinConfig(t *testing.T) {
|
|
ctx := t.Context()
|
|
tempDir := t.TempDir()
|
|
for _, enabled := range []bool{false, true} {
|
|
client, err := NewClientExt("file://"+tempDir, tempDir, NopCreds{}, true, false, "", "", WithBuiltinGitConfig(enabled))
|
|
require.NoError(t, err)
|
|
native := client.(*nativeGitClient)
|
|
|
|
configOut, err := native.config(ctx, "--list", "--show-origin")
|
|
require.NoError(t, err)
|
|
for k, v := range builtinGitConfig {
|
|
r := regexp.MustCompile(fmt.Sprintf("(?m)^command line:\\s+%s=%s$", strings.ToLower(k), regexp.QuoteMeta(v)))
|
|
matches := r.FindString(configOut)
|
|
if enabled {
|
|
assert.NotEmpty(t, matches, "missing builtin configuration option: %s=%s", k, v)
|
|
} else {
|
|
assert.Empty(t, matches, "unexpected builtin configuration when builtin config is disabled: %s=%s", k, v)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func Test_GitNoDetachedMaintenance(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
ctx := t.Context()
|
|
|
|
client, err := NewClientExt("file://"+tempDir, tempDir, NopCreds{}, true, false, "", "")
|
|
require.NoError(t, err)
|
|
native := client.(*nativeGitClient)
|
|
|
|
err = client.Init()
|
|
require.NoError(t, err)
|
|
|
|
cmd := exec.CommandContext(ctx, "git", "fetch")
|
|
// trace execution of Git subcommands and their arguments to stderr
|
|
cmd.Env = append(cmd.Env, "GIT_TRACE=true")
|
|
// Ignore system config in case it disables auto maintenance
|
|
cmd.Env = append(cmd.Env, "GIT_CONFIG_NOSYSTEM=true")
|
|
output, err := native.runCmdOutput(cmd, runOpts{CaptureStderr: true})
|
|
require.NoError(t, err)
|
|
|
|
lines := strings.SplitSeq(output, "\n")
|
|
for line := range lines {
|
|
if strings.Contains(line, "git maintenance run") {
|
|
assert.NotContains(t, output, "--detach", "Unexpected --detach when running git maintenance")
|
|
return
|
|
}
|
|
}
|
|
assert.Fail(t, "Expected to see `git maintenance` run after `git fetch`")
|
|
}
|
|
|
|
func Test_nativeGitClient_GetCommitNote(t *testing.T) {
|
|
ctx := t.Context()
|
|
tempDir, err := _createEmptyGitRepo(ctx)
|
|
require.NoError(t, err)
|
|
|
|
// Allow pushing to the same local repo (non-bare)
|
|
err = runCmd(ctx, tempDir, "git", "config", "--local", "receive.denyCurrentBranch", "updateInstead")
|
|
require.NoError(t, err)
|
|
|
|
// Get the current branch name
|
|
gitCurrentBranch, err := outputCmd(ctx, tempDir, "git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
require.NoError(t, err)
|
|
branch := strings.TrimSpace(string(gitCurrentBranch))
|
|
|
|
// Initialize client that uses this same repo as origin
|
|
client, err := NewClient("file://"+tempDir, NopCreds{}, true, false, "", "")
|
|
require.NoError(t, err)
|
|
|
|
err = client.Init()
|
|
require.NoError(t, err)
|
|
|
|
out, err := client.SetAuthor(t.Context(), "test", "[email protected]")
|
|
require.NoError(t, err, "error output: ", out)
|
|
|
|
err = client.Fetch(t.Context(), branch, 0)
|
|
require.NoError(t, err)
|
|
|
|
out, err = client.Checkout(t.Context(), branch, false, true)
|
|
require.NoError(t, err, "error output: ", out)
|
|
|
|
// Create and commit a test file
|
|
err = os.WriteFile(filepath.Join(client.Root(), "README.md"), []byte("content"), 0o644)
|
|
require.NoError(t, err)
|
|
out, err = client.CommitAndPush(t.Context(), branch, "initial commit")
|
|
require.NoError(t, err, "error output: %s", out)
|
|
|
|
// Get the latest commit SHA
|
|
sha, err := client.CommitSHA(t.Context())
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, sha)
|
|
|
|
// No note found, should return ErrNoNoteFound
|
|
got, err := client.GetCommitNote(t.Context(), sha, "")
|
|
require.Empty(t, got)
|
|
unwrappedError := errors.Unwrap(err)
|
|
require.ErrorIs(t, unwrappedError, ErrNoNoteFound)
|
|
|
|
// Add a git note for this commit manually
|
|
noteMsg := "this is a test note"
|
|
err = runCmd(ctx, client.Root(), "git", "notes", "--ref=commit", "add", "-m", noteMsg, sha)
|
|
require.NoError(t, err)
|
|
|
|
// Call the method under test
|
|
got, err = client.GetCommitNote(t.Context(), sha, "")
|
|
require.NoError(t, err)
|
|
require.Equal(t, noteMsg, got)
|
|
}
|
|
|
|
func Test_nativeGitClient_AddAndPushNote(t *testing.T) {
|
|
ctx := t.Context()
|
|
tempDir, err := _createEmptyGitRepo(ctx)
|
|
require.NoError(t, err)
|
|
|
|
// Allow pushing to the same local repo (non-bare)
|
|
err = runCmd(ctx, tempDir, "git", "config", "--local", "receive.denyCurrentBranch", "updateInstead")
|
|
require.NoError(t, err)
|
|
|
|
// Get the current branch name
|
|
gitCurrentBranch, err := outputCmd(ctx, tempDir, "git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
require.NoError(t, err)
|
|
branch := strings.TrimSpace(string(gitCurrentBranch))
|
|
|
|
// Initialize client that uses this same repo as origin
|
|
client, err := NewClient("file://"+tempDir, NopCreds{}, true, false, "", "")
|
|
require.NoError(t, err)
|
|
|
|
err = client.Init()
|
|
require.NoError(t, err)
|
|
|
|
out, err := client.SetAuthor(t.Context(), "test", "[email protected]")
|
|
require.NoError(t, err, "error output: ", out)
|
|
|
|
err = client.Fetch(t.Context(), branch, 0)
|
|
require.NoError(t, err)
|
|
|
|
out, err = client.Checkout(t.Context(), branch, false, true)
|
|
require.NoError(t, err, "error output: ", out)
|
|
|
|
// Create and commit a test file
|
|
err = os.WriteFile(filepath.Join(client.Root(), "README.md"), []byte("content"), 0o644)
|
|
require.NoError(t, err)
|
|
out, err = client.CommitAndPush(t.Context(), branch, "initial commit")
|
|
require.NoError(t, err, "error output: %s", out)
|
|
|
|
// Get current commit SHA
|
|
sha, err := client.CommitSHA(t.Context())
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, sha)
|
|
|
|
// Add and push a note (to the same repo acting as its own origin)
|
|
note := "this is a test note"
|
|
err = client.AddAndPushNote(t.Context(), sha, "", note)
|
|
require.NoError(t, err)
|
|
|
|
// Verify the note exists
|
|
outBytes, err := outputCmd(ctx, client.Root(), "git", "notes", "--ref=commit", "show", sha)
|
|
require.NoError(t, err)
|
|
require.Equal(t, note, strings.TrimSpace(string(outBytes)))
|
|
|
|
// test with a custom namespace too
|
|
t.Run("custom namespace", func(t *testing.T) {
|
|
customNS := "source-hydrator"
|
|
customNote := "custom namespace note"
|
|
err = client.AddAndPushNote(t.Context(), sha, customNS, customNote)
|
|
require.NoError(t, err)
|
|
|
|
outBytes, err := outputCmd(ctx, client.Root(), "git", "notes", "--ref="+customNS, "show", sha)
|
|
require.NoError(t, err)
|
|
require.Equal(t, customNote, strings.TrimSpace(string(outBytes)))
|
|
})
|
|
}
|
|
|
|
func Test_isRetryableNotePushError(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
errStr string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "fetch first",
|
|
errStr: "Updates were rejected because the remote contains work that you do not have locally. fetch first",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "reference already exists",
|
|
errStr: "failed to push some refs: reference already exists",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "incorrect old value",
|
|
errStr: "update_ref failed for ref 'refs/notes/hydrator.metadata': cannot update the ref: incorrect old value",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "failed to update ref",
|
|
errStr: "error: failed to update ref",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "cannot lock ref from concurrent shard",
|
|
errStr: " ! [remote rejected] refs/notes/hydrator.metadata -> refs/notes/hydrator.metadata " +
|
|
"(cannot lock ref 'refs/notes/hydrator.metadata': is at 477134654bdd5531fc0f76bf026be86515e8685f " +
|
|
"but expected 30b2c9044a27f1b853fee454ab9bfef183fb9e0d)\n" +
|
|
"error: failed to push some refs to 'https://github.example.com/org/deployments.git'",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "non-retryable error",
|
|
errStr: "fatal: Authentication failed for 'https://github.example.com/org/deployments.git'",
|
|
want: false,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assert.Equal(t, tt.want, isRetryableNotePushError(tt.errStr))
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_nativeGitClient_HasFileChanged(t *testing.T) {
|
|
ctx := t.Context()
|
|
tempDir, err := _createEmptyGitRepo(ctx)
|
|
require.NoError(t, err)
|
|
|
|
// Allow pushing to the same local repo (non-bare)
|
|
err = runCmd(ctx, tempDir, "git", "config", "--local", "receive.denyCurrentBranch", "updateInstead")
|
|
require.NoError(t, err)
|
|
|
|
// Get the current branch name
|
|
gitCurrentBranch, err := outputCmd(ctx, tempDir, "git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
require.NoError(t, err)
|
|
branch := strings.TrimSpace(string(gitCurrentBranch))
|
|
|
|
// Initialize client that uses this same repo as origin
|
|
client, err := NewClient("file://"+tempDir, NopCreds{}, true, false, "", "")
|
|
require.NoError(t, err)
|
|
|
|
err = client.Init()
|
|
require.NoError(t, err)
|
|
|
|
out, err := client.SetAuthor(t.Context(), "test", "[email protected]")
|
|
require.NoError(t, err, "error output: ", out)
|
|
|
|
err = client.Fetch(t.Context(), branch, 0)
|
|
require.NoError(t, err)
|
|
|
|
out, err = client.Checkout(t.Context(), branch, false, true)
|
|
require.NoError(t, err, "error output: ", out)
|
|
|
|
// Create the file inside repo root
|
|
fileName := "sample.txt"
|
|
filePath := filepath.Join(client.Root(), fileName)
|
|
|
|
err = os.WriteFile(filePath, []byte("first version"), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
// Untracked file, should be reported as changed
|
|
changed, err := client.HasFileChanged(t.Context(), filePath)
|
|
require.NoError(t, err)
|
|
require.True(t, changed, "expected untracked file to be reported as changed")
|
|
|
|
// After commit, should NOT be changed
|
|
out, err = client.CommitAndPush(t.Context(), branch, "add sample.txt")
|
|
require.NoError(t, err, "error output: %s", out)
|
|
changed, err = client.HasFileChanged(t.Context(), filePath)
|
|
require.NoError(t, err)
|
|
require.False(t, changed, "expected committed file to not be changed")
|
|
|
|
// Modify the file should be reported as changed
|
|
err = os.WriteFile(filePath, []byte("modified content"), 0o644)
|
|
require.NoError(t, err)
|
|
changed, err = client.HasFileChanged(t.Context(), filePath)
|
|
require.NoError(t, err)
|
|
require.True(t, changed, "expected modified file to be reported as changed")
|
|
}
|
|
|
|
func Test_LsSignatures_Error(t *testing.T) {
|
|
ctx := t.Context()
|
|
tempDir, err := _createEmptyGitRepo(ctx)
|
|
require.NoError(t, err)
|
|
client, err := NewClient("file://"+tempDir, NopCreds{}, true, false, "", "")
|
|
require.NoError(t, err)
|
|
require.NoError(t, client.Init())
|
|
out, err := client.SetAuthor(t.Context(), "test", "[email protected]")
|
|
require.NoError(t, err, "error output: %s", out)
|
|
|
|
err = runCmd(ctx, tempDir, "git", "log")
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
revision string
|
|
deep bool
|
|
expectedMsg string
|
|
}{
|
|
{
|
|
revision: "5555.*",
|
|
deep: false,
|
|
expectedMsg: "version matching constraint not found",
|
|
},
|
|
{
|
|
revision: "5555.*",
|
|
deep: true,
|
|
expectedMsg: "version matching constraint not found",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
signatures, legacy, err := client.LsSignatures(t.Context(), tt.revision, tt.deep)
|
|
require.ErrorContains(t, err, tt.expectedMsg)
|
|
assert.Nil(t, signatures)
|
|
assert.Empty(t, legacy)
|
|
}
|
|
}
|