Skip to content
Merged
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
42 changes: 21 additions & 21 deletions docs/quality/coverage-report.json
Original file line number Diff line number Diff line change
@@ -1,40 +1,40 @@
{
"version": 2,
"coverage": {
"unit_raw_percent": 18.401332223147378,
"unit_scoped_percent": 87.78656126482214,
"live_raw_percent": 18.552336681331454,
"live_scoped_percent": 51.591089896579156,
"combined_raw_percent": 26.429195182876057,
"combined_scoped_percent": 89.77272727272727,
"patch_percent": 91.17647058823529,
"unit_raw_percent": 18.977955104179262,
"unit_scoped_percent": 87.96763445978105,
"live_raw_percent": 18.798509459988058,
"live_scoped_percent": 51.0295948663921,
"combined_raw_percent": 27.054366662636518,
"combined_scoped_percent": 89.91908614945264,
"patch_percent": 88.37209302325581,
"unit_statements": {
"covered": 9061,
"total": 49241
"covered": 9418,
"total": 49626
},
"live_statements": {
"covered": 8940,
"total": 48188
"covered": 9131,
"total": 48573
},
"combined_statements": {
"covered": 13014,
"total": 49241
"covered": 13426,
"total": 49626
},
"unit_scoped_statements": {
"covered": 8884,
"total": 10120
"covered": 9241,
"total": 10505
},
"live_scoped_statements": {
"covered": 5188,
"total": 10056
"covered": 5328,
"total": 10441
},
"combined_scoped_statements": {
"covered": 9085,
"total": 10120
"covered": 9446,
"total": 10505
},
"patch_lines": {
"covered": 62,
"total": 68
"covered": 38,
"total": 43
},
"scope": {
"include_prefixes": [
Expand Down
1,572 changes: 918 additions & 654 deletions docs/quality/coverage.combined.raw.out

Large diffs are not rendered by default.

1,496 changes: 880 additions & 616 deletions docs/quality/coverage.combined.scoped.out

Large diffs are not rendered by default.

169 changes: 169 additions & 0 deletions internal/cli/cmd/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -884,6 +884,16 @@ func TestAuthAliasCommandsAndDiscovery(t *testing.T) {
if !strings.Contains(loginOut.String(), "aliases") {
t.Fatalf("expected aliases in login json output, got: %s", loginOut.String())
}
var loginPayload struct {
Host string `json:"host"`
Aliases []string `json:"aliases"`
}
if err := decodeJSONEnvelopeData(loginOut.Bytes(), &loginPayload); err != nil {
t.Fatalf("decode login json: %v", err)
}
if loginPayload.Aliases == nil {
t.Fatal("expected login aliases json field to be a non-nil array")
}

listCmd := New(Dependencies{
JSONEnabled: func() bool { return true },
Expand All @@ -900,6 +910,16 @@ func TestAuthAliasCommandsAndDiscovery(t *testing.T) {
if !strings.Contains(listOut.String(), "git.company.org:7999") {
t.Fatalf("expected discovered alias in list output, got: %s", listOut.String())
}
var listPayload struct {
Host string `json:"host"`
Aliases []string `json:"aliases"`
}
if err := decodeJSONEnvelopeData(listOut.Bytes(), &listPayload); err != nil {
t.Fatalf("decode alias list json: %v", err)
}
if listPayload.Aliases == nil {
t.Fatal("expected alias list json aliases to be a non-nil array")
}

addCmd := New(Dependencies{
JSONEnabled: func() bool { return true },
Expand Down Expand Up @@ -953,6 +973,155 @@ func TestAuthAliasCommandsAndDiscovery(t *testing.T) {
}
}

func TestAuthJSONOutputsUseEmptyAliasArrays(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "bb", "config.yaml")
t.Setenv("BB_CONFIG_PATH", configPath)
t.Setenv("BB_DISABLE_STORED_CONFIG", "")

cmd := New(Dependencies{
JSONEnabled: func() bool { return true },
LoadConfig: func() (config.AppConfig, error) { return config.LoadFromEnv() },
WriteJSON: func(writer io.Writer, payload any) error { return jsonoutput.Write(writer, payload) },
})

loginOut := &bytes.Buffer{}
cmd.SetOut(loginOut)
cmd.SetErr(loginOut)
cmd.SetArgs([]string{"login", "https://empty-array.company.org", "--token", "tok", "--discover-aliases=false"})
if err := cmd.Execute(); err != nil {
t.Fatalf("login failed: %v", err)
}
var loginPayload struct {
Aliases []string `json:"aliases"`
}
if err := decodeJSONEnvelopeData(loginOut.Bytes(), &loginPayload); err != nil {
t.Fatalf("decode login payload: %v", err)
}
if loginPayload.Aliases == nil || len(loginPayload.Aliases) != 0 {
t.Fatalf("expected empty login aliases array, got %+v", loginPayload.Aliases)
}

aliasListCmd := New(Dependencies{
JSONEnabled: func() bool { return true },
LoadConfig: func() (config.AppConfig, error) { return config.LoadFromEnv() },
WriteJSON: func(writer io.Writer, payload any) error { return jsonoutput.Write(writer, payload) },
})
aliasListOut := &bytes.Buffer{}
aliasListCmd.SetOut(aliasListOut)
aliasListCmd.SetErr(aliasListOut)
aliasListCmd.SetArgs([]string{"alias", "list", "--host", "https://empty-array.company.org"})
if err := aliasListCmd.Execute(); err != nil {
t.Fatalf("alias list failed: %v", err)
}
var aliasListPayload struct {
Aliases []string `json:"aliases"`
}
if err := decodeJSONEnvelopeData(aliasListOut.Bytes(), &aliasListPayload); err != nil {
t.Fatalf("decode alias list payload: %v", err)
}
if aliasListPayload.Aliases == nil || len(aliasListPayload.Aliases) != 0 {
t.Fatalf("expected empty alias list array, got %+v", aliasListPayload.Aliases)
}

serverListCmd := New(Dependencies{
JSONEnabled: func() bool { return true },
LoadConfig: func() (config.AppConfig, error) { return config.LoadFromEnv() },
WriteJSON: func(writer io.Writer, payload any) error { return jsonoutput.Write(writer, payload) },
})
serverListOut := &bytes.Buffer{}
serverListCmd.SetOut(serverListOut)
serverListCmd.SetErr(serverListOut)
serverListCmd.SetArgs([]string{"server", "list"})
if err := serverListCmd.Execute(); err != nil {
t.Fatalf("server list failed: %v", err)
}
var serverListPayload struct {
Servers []struct {
Aliases []string `json:"aliases"`
} `json:"servers"`
}
if err := decodeJSONEnvelopeData(serverListOut.Bytes(), &serverListPayload); err != nil {
t.Fatalf("decode server list payload: %v", err)
}
if len(serverListPayload.Servers) != 1 {
t.Fatalf("expected one server, got %d", len(serverListPayload.Servers))
}
if serverListPayload.Servers[0].Aliases == nil || len(serverListPayload.Servers[0].Aliases) != 0 {
t.Fatalf("expected empty server aliases array, got %+v", serverListPayload.Servers[0].Aliases)
}

removeCmd := New(Dependencies{
JSONEnabled: func() bool { return true },
LoadConfig: func() (config.AppConfig, error) { return config.LoadFromEnv() },
WriteJSON: func(writer io.Writer, payload any) error { return jsonoutput.Write(writer, payload) },
})
removeOut := &bytes.Buffer{}
removeCmd.SetOut(removeOut)
removeCmd.SetErr(removeOut)
removeCmd.SetArgs([]string{"alias", "add", "--host", "https://empty-array.company.org", "git.company.org:22"})
if err := removeCmd.Execute(); err != nil {
t.Fatalf("alias add failed: %v", err)
}

removeCmd = New(Dependencies{
JSONEnabled: func() bool { return true },
LoadConfig: func() (config.AppConfig, error) { return config.LoadFromEnv() },
WriteJSON: func(writer io.Writer, payload any) error { return jsonoutput.Write(writer, payload) },
})
removeOut = &bytes.Buffer{}
removeCmd.SetOut(removeOut)
removeCmd.SetErr(removeOut)
removeCmd.SetArgs([]string{"alias", "remove", "--host", "https://empty-array.company.org", "git.company.org:22"})
if err := removeCmd.Execute(); err != nil {
t.Fatalf("alias remove failed: %v", err)
}
var removePayload struct {
Aliases []string `json:"aliases"`
}
if err := decodeJSONEnvelopeData(removeOut.Bytes(), &removePayload); err != nil {
t.Fatalf("decode alias remove payload: %v", err)
}
if removePayload.Aliases == nil || len(removePayload.Aliases) != 0 {
t.Fatalf("expected empty alias remove array, got %+v", removePayload.Aliases)
}

discoverCmd := New(Dependencies{
JSONEnabled: func() bool { return true },
LoadConfig: func() (config.AppConfig, error) { return config.LoadFromEnv() },
WriteJSON: func(writer io.Writer, payload any) error { return jsonoutput.Write(writer, payload) },
NewReposClient: func(cfg config.AppConfig) (repositoriesClient, error) {
recent := &openapigenerated.GetRepositoriesRecentlyAccessedResponse{
HTTPResponse: &http.Response{StatusCode: 200},
ApplicationjsonCharsetUTF8200: &struct {
IsLastPage *bool `json:"isLastPage,omitempty"`
Limit *float32 `json:"limit,omitempty"`
NextPageStart *int32 `json:"nextPageStart,omitempty"`
Size *float32 `json:"size,omitempty"`
Start *int32 `json:"start,omitempty"`
Values *[]openapigenerated.RestRepository `json:"values,omitempty"`
}{Values: &[]openapigenerated.RestRepository{}},
}
return &fakeReposClient{recent: recent, all: recentResponseToAll(recent)}, nil
},
})
discoverOut := &bytes.Buffer{}
discoverCmd.SetOut(discoverOut)
discoverCmd.SetErr(discoverOut)
discoverCmd.SetArgs([]string{"alias", "discover", "--host", "https://empty-array.company.org"})
if err := discoverCmd.Execute(); err != nil {
t.Fatalf("alias discover failed: %v", err)
}
var discoverPayload struct {
Aliases []string `json:"aliases"`
}
if err := decodeJSONEnvelopeData(discoverOut.Bytes(), &discoverPayload); err != nil {
t.Fatalf("decode alias discover payload: %v", err)
}
if discoverPayload.Aliases == nil || len(discoverPayload.Aliases) != 0 {
t.Fatalf("expected empty alias discover array, got %+v", discoverPayload.Aliases)
}
}

func TestDiscoverAliasesEdgeCases(t *testing.T) {
t.Run("deduplicates duplicate clone aliases", func(t *testing.T) {
cloneLinks := map[string]interface{}{
Expand Down
50 changes: 36 additions & 14 deletions internal/cli/repo_clone.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,10 +274,16 @@ func cloneRepositoryWithAuthFallback(
return "", sshErr
}

cloneAuth, hasStoredHTTPAuth, err := resolveCloneHTTPAuth(cfg, cloneHost)
cloneAuth, resolvedHTTPCloneHost, hasStoredHTTPAuth, err := resolveCloneHTTPAuth(cfg, cloneHost)
if err != nil {
return "", err
}
if hasStoredHTTPAuth && !sameCloneHost(httpCloneURL, resolvedHTTPCloneHost) {
httpCloneURL, err = buildBitbucketCloneURL(normalizeHTTPCloneBaseURL(resolvedHTTPCloneHost), repo.ProjectKey, repo.Slug)
if err != nil {
return "", err
}
}
Comment on lines +281 to +286
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

The decision to rebuild httpCloneURL only checks sameCloneHost(httpCloneURL, resolvedHTTPCloneHost), which ignores the URL path. If the canonical/stored Bitbucket URL includes a context path (e.g. https://host/context) but httpCloneURL was built without it (because normalizeHTTPCloneHost clears Path), this condition will be true and the rebuild will be skipped, producing an HTTPS clone URL missing the context path. Consider comparing normalized base URLs including the path (e.g., host endpoint + trimmed base path) and rebuild when the base path differs, not just when the host:port differs.

Copilot uses AI. Check for mistakes.

if hasStoredHTTPAuth {
authenticatedCloneURL, err := buildAuthenticatedCloneURL(httpCloneURL, cloneAuth)
Expand Down Expand Up @@ -314,37 +320,38 @@ func cloneRepositoryWithAuthFallback(
return httpCloneURL, nil
}

func resolveCloneHTTPAuth(cfg config.AppConfig, cloneHost string) (config.AppConfig, bool, error) {
// Match on host (ignoring scheme) so http↔https variants of the same server both hit.
func resolveCloneHTTPAuth(cfg config.AppConfig, cloneHost string) (config.AppConfig, string, bool, error) {
// Match on the normalized network endpoint so explicit ports and http↔https variants
// of the same server can reuse the same stored credentials.
if sameCloneHost(cfg.BitbucketURL, cloneHost) && cfg.AuthMode() != "none" {
return cfg, true, nil
return cfg, cfg.BitbucketURL, true, nil
}

if os.Getenv("BB_DISABLE_STORED_CONFIG") == "1" {
return config.AppConfig{}, false, nil
return config.AppConfig{}, "", false, nil
}

if matched, ok, err := config.MatchStoredHost(cloneHost); err != nil {
return config.AppConfig{}, false, err
return config.AppConfig{}, "", false, err
} else if ok {
storedAuth, found, err := config.LoadStoredAuthForHost(matched.Host)
if err != nil {
return config.AppConfig{}, false, err
return config.AppConfig{}, "", false, err
}
if found && storedAuth.AuthMode() != "none" {
return storedAuth, true, nil
return storedAuth, matched.Host, true, nil
}
}

storedAuth, ok, err := config.LoadStoredAuthForHost(cloneHost)
if err != nil {
return config.AppConfig{}, false, err
return config.AppConfig{}, "", false, err
}
if !ok || storedAuth.AuthMode() == "none" {
return config.AppConfig{}, false, nil
return config.AppConfig{}, "", false, nil
}

return storedAuth, true, nil
return storedAuth, cloneHost, true, nil
}

func promptForCloneLogin(cmd *cobra.Command, cfg config.AppConfig, cloneHost string, attemptedSSH bool) (config.AppConfig, bool, error) {
Expand Down Expand Up @@ -473,9 +480,8 @@ func isExplicitHTTPCloneURL(rawInput string) bool {
return strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://")
}

// sameCloneHost returns true when left and right resolve to the same host:port,
// regardless of scheme. This allows http↔https variants of the same server to match,
// consistent with the stored-credential cross-scheme fallback in config.
// sameCloneHost returns true when left and right normalize to the same network
// endpoint. Schemes are ignored, so http and https variants of the same host match.
func sameCloneHost(left string, right string) bool {
leftNormalized := normalizeHostEndpointLoose(left)
rightNormalized := normalizeHostEndpointLoose(right)
Expand Down Expand Up @@ -540,6 +546,22 @@ func normalizeHTTPCloneHost(cloneHost string) string {
return strings.TrimSuffix(parsed.String(), "/")
}

func normalizeHTTPCloneBaseURL(baseURL string) string {
parsed, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil || strings.TrimSpace(parsed.Host) == "" {
return baseURL
}

parsed.User = nil
if !strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https") {
parsed.Scheme = "https"
}
parsed.RawQuery = ""
parsed.Fragment = ""

return strings.TrimSuffix(parsed.String(), "/")
}

func normalizeCloneExtraArgs(extra []string) ([]string, error) {
if len(extra) == 0 {
return nil, nil
Expand Down
Loading
Loading