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
33 changes: 31 additions & 2 deletions pkg/github/repositories.go
Original file line number Diff line number Diff line change
Expand Up @@ -1833,6 +1833,20 @@ func filterPaths(entries []*github.TreeEntry, path string, maxResults int) []str
return matchedPaths
}

// looksLikeSHA returns true if the string appears to be a Git commit SHA.
// A SHA is a 40-character hexadecimal string.
func looksLikeSHA(s string) bool {
if len(s) != 40 {
return false
}
for _, c := range s {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
return false
}
}
return true
}

// resolveGitReference takes a user-provided ref and sha and resolves them into a
// definitive commit SHA and its corresponding fully-qualified reference.
//
Expand All @@ -1841,8 +1855,11 @@ func filterPaths(entries []*github.TreeEntry, path string, maxResults int) []str
// 1. If a specific commit `sha` is provided, it takes precedence and is used directly,
// and all reference resolution is skipped.
//
// 2. If no `sha` is provided, the function resolves the `ref`
// string into a fully-qualified format (e.g., "refs/heads/main") by trying
// 1a. If `sha` is empty but `ref` looks like a commit SHA (40 hexadecimal characters),
// it is returned as-is without any API calls or reference resolution.
//
// 2. If no `sha` is provided and `ref` does not look like a SHA, the function resolves
// the `ref` string into a fully-qualified format (e.g., "refs/heads/main") by trying
// the following steps in order:
// a). **Empty Ref:** If `ref` is empty, the repository's default branch is used.
// b). **Fully-Qualified:** If `ref` already starts with "refs/", it's considered fully
Expand All @@ -1865,6 +1882,11 @@ func resolveGitReference(ctx context.Context, githubClient *github.Client, owner
return &raw.ContentOpts{Ref: "", SHA: sha}, nil
}

// 1a) If sha is empty but ref looks like a SHA, return it without changes
if looksLikeSHA(ref) {
return &raw.ContentOpts{Ref: "", SHA: ref}, nil
}

originalRef := ref // Keep original ref for clearer error messages down the line.

// 2) If no SHA is provided, we try to resolve the ref into a fully-qualified format.
Expand Down Expand Up @@ -1905,8 +1927,12 @@ func resolveGitReference(ctx context.Context, githubClient *github.Client, owner
// The tag lookup also failed. Check if it was a 404 Not Found error.
ghErr2, isGhErr2 := err.(*github.ErrorResponse)
if isGhErr2 && ghErr2.Response.StatusCode == http.StatusNotFound {
if originalRef == "main" {
return nil, fmt.Errorf("could not find branch or tag 'main'. Some repositories use 'master' as the default branch name")
}
return nil, fmt.Errorf("could not resolve ref %q as a branch or a tag", originalRef)
}

// The tag lookup failed for a different reason.
_, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (tag)", resp, err)
return nil, fmt.Errorf("failed to get reference for tag '%s': %w", originalRef, err)
Expand All @@ -1922,6 +1948,9 @@ func resolveGitReference(ctx context.Context, githubClient *github.Client, owner
if reference == nil {
reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, ref)
if err != nil {
if ref == "refs/heads/main" {
return nil, fmt.Errorf("could not find branch 'main'. Some repositories use 'master' as the default branch name")
}
_, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err)
return nil, fmt.Errorf("failed to get final reference for %q: %w", ref, err)
}
Expand Down
79 changes: 79 additions & 0 deletions pkg/github/repositories_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2889,6 +2889,72 @@ func Test_GetReleaseByTag(t *testing.T) {
}
}

func Test_looksLikeSHA(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{
name: "full 40-character SHA",
input: "abc123def456abc123def456abc123def456abc1",
expected: true,
},
{
name: "too short",
input: "abc123def456abc123def45",
expected: false,
},
{
name: "too long - 41 characters",
input: "abc123def456abc123def456abc123def456abc12",
expected: false,
},
{
name: "contains invalid character - space",
input: "abc123def456abc123def456 bc123def456abc1",
expected: false,
},
{
name: "contains invalid character - dash",
input: "abc123def456abc123d-f456abc123def456abc1",
expected: false,
},
{
name: "contains invalid character - g",
input: "abc123def456gbc123def456abc123def456abc1",
expected: false,
},
{
name: "branch name with slash",
input: "feature/branch",
expected: false,
},
{
name: "empty string",
input: "",
expected: false,
},
{
name: "all zeros SHA",
input: "0000000000000000000000000000000000000000",
expected: true,
},
{
name: "all f's SHA",
input: "ffffffffffffffffffffffffffffffffffffffff",
expected: true,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := looksLikeSHA(tc.input)
assert.Equal(t, tc.expected, result)
})
}
}

func Test_filterPaths(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -3203,6 +3269,19 @@ func Test_resolveGitReference(t *testing.T) {
},
expectError: false,
},
{
name: "ref looks like full SHA with empty sha parameter",
ref: "abc123def456abc123def456abc123def456abc1",
sha: "",
mockSetup: func() *http.Client {
// No API calls should be made when ref looks like SHA
return mock.NewMockedHTTPClient()
},
expectedOutput: &raw.ContentOpts{
SHA: "abc123def456abc123def456abc123def456abc1",
},
expectError: false,
},
}

for _, tc := range tests {
Expand Down