Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
browse: disambiguate decimal-only short SHAs from issue numbers
When a selector argument passed to gh browse consists entirely of
decimal digits and is at least 7 characters long, it satisfies both
isNumber and isCommit. Previously isNumber was always checked first,
so gh browse 3096289 would open an issues URL even when that string
referred to an actual commit SHA.

Only in the ambiguous case (decimal-only, no # prefix, passes both
checks), call the commits API to determine whether the ref resolves to
a real commit. If it does, open the commit URL; otherwise fall back to
the issues URL. No API call is made for unambiguous inputs.

Fixes #12357
  • Loading branch information
SebTardif committed May 4, 2026
commit d0de0edf7380d08a63452ab1e237b706cf7628aa
20 changes: 20 additions & 0 deletions api/queries_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -1685,6 +1685,26 @@ func RepoExists(client *Client, repo ghrepo.Interface) (bool, error) {
}
}

func CommitExists(client *Client, repo ghrepo.Interface, ref string) (bool, error) {
path := fmt.Sprintf("repos/%s/%s/commits/%s", repo.RepoOwner(), repo.RepoName(), ref)

resp, err := client.HTTP().Head(ghinstance.RESTPrefix(repo.RepoHost()) + path)
if err != nil {
return false, err
}

defer resp.Body.Close()

switch resp.StatusCode {
case 200:
return true, nil
case 404, 422:
return false, nil
default:
return false, ghAPI.HandleHTTPError(resp)
Comment on lines +1688 to +1704
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed — added direct unit tests for CommitExists in api/queries_repo_test.go (second commit). Covers all four status code paths: 200 (exists), 404 (not found), 422 (unprocessable ref), and 500 (server error).

}
}

// RepoLicenses fetches available repository licenses.
// It uses API v3 because licenses are not supported by GraphQL.
func RepoLicenses(httpClient *http.Client, hostname string) ([]License, error) {
Expand Down
16 changes: 16 additions & 0 deletions pkg/cmd/browse/browse.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,22 @@ func parseSection(baseRepo ghrepo.Interface, opts *BrowseOptions) (string, error
return "", nil
}
if isNumber(opts.SelectorArg) {
// A selector without a "#" prefix that satisfies both isNumber and
// isCommit is ambiguous (e.g. "309628980"). Disambiguate via API.
if !strings.HasPrefix(opts.SelectorArg, "#") && isCommit(opts.SelectorArg) {
httpClient, err := opts.HttpClient()
if err != nil {
return "", err
}
apiClient := api.NewClientFromHTTP(httpClient)
exists, err := api.CommitExists(apiClient, baseRepo, opts.SelectorArg)
if err != nil {
return "", err
}
if exists {
return fmt.Sprintf("commit/%s", opts.SelectorArg), nil
}
Comment on lines +255 to +267
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional. The maintainer explicitly chose correctness over graceful degradation in the issue discussion:

I think it's rare enough that we should prioritise correctness here and hit the API.

The issue author agreed. Silently guessing the wrong URL during an outage would be a worse user experience than surfacing the error — especially since gh browse already makes API calls on the common path (e.g. to resolve the default branch).

}
return fmt.Sprintf("issues/%s", strings.TrimPrefix(opts.SelectorArg, "#")), nil
}
if isCommit(opts.SelectorArg) {
Expand Down
39 changes: 39 additions & 0 deletions pkg/cmd/browse/browse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,45 @@ func Test_runBrowse(t *testing.T) {
expectedURL: "https://github.com/owner/repo/blame/abc123/src/app.js#L50",
wantsErr: false,
},
{
name: "decimal-only short SHA that is a valid commit",
opts: BrowseOptions{
SelectorArg: "309628980",
},
httpStub: func(r *httpmock.Registry) {
r.Register(
httpmock.REST("HEAD", "repos/cli/cli/commits/309628980"),
httpmock.StatusStringResponse(200, ""),
)
},
baseRepo: ghrepo.New("cli", "cli"),
expectedURL: "https://github.com/cli/cli/commit/309628980",
wantsErr: false,
},
{
name: "decimal-only string that is not a valid commit",
opts: BrowseOptions{
SelectorArg: "1234567",
},
httpStub: func(r *httpmock.Registry) {
r.Register(
httpmock.REST("HEAD", "repos/cli/cli/commits/1234567"),
httpmock.StatusStringResponse(404, ""),
)
},
baseRepo: ghrepo.New("cli", "cli"),
expectedURL: "https://github.com/cli/cli/issues/1234567",
wantsErr: false,
},
{
name: "decimal-only short SHA with # prefix forces issue",
opts: BrowseOptions{
SelectorArg: "#309628980",
},
baseRepo: ghrepo.New("cli", "cli"),
expectedURL: "https://github.com/cli/cli/issues/309628980",
wantsErr: false,
},
}

for _, tt := range tests {
Expand Down
Loading