Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 71 additions & 5 deletions pkg/common/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/storer"
"github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/mattn/go-isatty"
Expand Down Expand Up @@ -294,6 +295,72 @@ func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.Pu
return fetchOptions, pullOptions
}

// maxTagDepth is the maximum number of nested tags to resolve.
// This prevents infinite loops from circular tag references.
const maxTagDepth = 10

// resolveTagToCommit resolves a tag reference to its final commit hash,
// handling nested/chained annotated tags (e.g., tag -> tag -> commit).
func resolveTagToCommit(r *git.Repository, tagName string) (*plumbing.Hash, error) {
tagRef, err := r.Tag(tagName)
if err != nil {
return nil, err
}

// Try to get the tag object (annotated tag)
tagObj, err := r.TagObject(tagRef.Hash())
if err != nil {
// If it's not an annotated tag, return the hash directly
hash := tagRef.Hash()
return &hash, nil
}

// Recursively resolve until we get a commit
for depth := 0; depth < maxTagDepth; depth++ {
switch tagObj.TargetType {
case plumbing.CommitObject:
return &tagObj.Target, nil
case plumbing.TagObject:
// Nested tag - resolve the next level
tagObj, err = r.TagObject(tagObj.Target)
if err != nil {
return nil, fmt.Errorf("failed to resolve nested tag: %w", err)
}
default:
return nil, fmt.Errorf("unsupported tag target type: %s", tagObj.TargetType)
}
}

return nil, fmt.Errorf("exceeded maximum tag depth (%d) - possible circular reference", maxTagDepth)
}

// resolveRefToCommit attempts to resolve a reference to a commit hash.
// It first tries the standard ResolveRevision, and if that fails with
// an unsupported object type (nested tags), it manually resolves the tag chain.
func resolveRefToCommit(r *git.Repository, ref string, rev plumbing.Revision, isTag bool) (*plumbing.Hash, error) {
hash, err := r.ResolveRevision(rev)
if err == nil {
return hash, nil
}

// If this is a tag and we got an error, try to resolve nested tags
if isTag {
if commitHash, tagErr := resolveTagToCommit(r, ref); tagErr == nil {
return commitHash, nil
}
}

// Check if this might be a nested tag by trying to resolve it directly
if tagHash, tagErr := resolveTagToCommit(r, ref); tagErr == nil {
// Verify this is actually a commit
if _, commitErr := object.GetCommit(r.Storer, *tagHash); commitErr == nil {
return tagHash, nil
}
}

return nil, err
}

// NewGitCloneExecutor creates an executor to clone git repos
//
//nolint:gocyclo
Expand Down Expand Up @@ -326,11 +393,10 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {

var hash *plumbing.Hash
rev := plumbing.Revision(input.Ref)
if hash, err = r.ResolveRevision(rev); err != nil {
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
}
// First attempt to resolve - this may fail for nested tags, which is handled later
hash, _ = r.ResolveRevision(rev)

if hash.String() != input.Ref && len(input.Ref) >= 4 && strings.HasPrefix(hash.String(), input.Ref) {
if hash != nil && hash.String() != input.Ref && len(input.Ref) >= 4 && strings.HasPrefix(hash.String(), input.Ref) {
return &Error{
err: ErrShortRef,
commit: hash.String(),
Expand All @@ -355,7 +421,7 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
}
}

if hash, err = r.ResolveRevision(rev); err != nil {
if hash, err = resolveRefToCommit(r, input.Ref, rev, refType == "tag"); err != nil {
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
return err
}
Expand Down
87 changes: 87 additions & 0 deletions pkg/common/git/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"syscall"
"testing"

"github.com/go-git/go-git/v5"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -202,6 +203,13 @@ func TestGitCloneExecutor(t *testing.T) {
URL: "https://github.com/actions/checkout",
Ref: "5a4ac90", // v2
},
"nested-tag": {
// gradle/actions@v4 is a nested annotated tag: v4 -> v4.x.x -> commit
// This tests that we can resolve chained/nested annotated tags
Err: nil,
URL: "https://github.com/gradle/actions",
Ref: "v4",
},
} {
t.Run(name, func(t *testing.T) {
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
Expand Down Expand Up @@ -248,6 +256,85 @@ func gitCmd(args ...string) error {
return nil
}

func TestResolveTagToCommit(t *testing.T) {
basedir := testDir(t)
gitConfig()

for name, tt := range map[string]struct {
Prepare func(t *testing.T, dir string)
TagName string
ExpectError bool
}{
"simple_annotated_tag": {
Prepare: func(t *testing.T, dir string) {
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "initial commit"))
require.NoError(t, gitCmd("-C", dir, "tag", "-a", "v1.0.0", "-m", "version 1.0.0"))
},
TagName: "v1.0.0",
ExpectError: false,
},
"lightweight_tag": {
Prepare: func(t *testing.T, dir string) {
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "initial commit"))
require.NoError(t, gitCmd("-C", dir, "tag", "v1.0.0"))
},
TagName: "v1.0.0",
ExpectError: false,
},
"nested_annotated_tag": {
Prepare: func(t *testing.T, dir string) {
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "initial commit"))
// Create first annotated tag pointing to commit
require.NoError(t, gitCmd("-C", dir, "tag", "-a", "v1.0.0", "-m", "version 1.0.0"))
// Create second annotated tag pointing to the first tag
// This simulates the gradle/actions@v4 scenario where v4 -> v4.4.4 -> commit
require.NoError(t, gitCmd("-C", dir, "tag", "-a", "v1", "v1.0.0", "-m", "major version 1"))
},
TagName: "v1",
ExpectError: false,
},
"triple_nested_tag": {
Prepare: func(t *testing.T, dir string) {
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "initial commit"))
require.NoError(t, gitCmd("-C", dir, "tag", "-a", "v1.0.0", "-m", "version 1.0.0"))
require.NoError(t, gitCmd("-C", dir, "tag", "-a", "v1.0", "v1.0.0", "-m", "minor version"))
require.NoError(t, gitCmd("-C", dir, "tag", "-a", "v1", "v1.0", "-m", "major version"))
},
TagName: "v1",
ExpectError: false,
},
"nonexistent_tag": {
Prepare: func(t *testing.T, dir string) {
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "initial commit"))
},
TagName: "nonexistent",
ExpectError: true,
},
} {
t.Run(name, func(t *testing.T) {
dir := filepath.Join(basedir, name)
require.NoError(t, os.MkdirAll(dir, 0o755))
require.NoError(t, gitCmd("-C", dir, "init", "--initial-branch=master"))
require.NoError(t, cleanGitHooks(dir))
tt.Prepare(t, dir)

repo, err := git.PlainOpen(dir)
require.NoError(t, err)

hash, err := resolveTagToCommit(repo, tt.TagName)
if tt.ExpectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.NotNil(t, hash)
// Verify the resolved hash is actually a commit
_, err := repo.CommitObject(*hash)
assert.NoError(t, err, "resolved hash should be a commit")
}
})
}
}

func TestCloneIfRequired(t *testing.T) {
tempDir := t.TempDir()
ctx := context.Background()
Expand Down