feat: infer repo defaults from git remotes

This commit is contained in:
avivsinai
2025-10-27 12:06:49 +02:00
parent cc69e414fa
commit 9d9e6cca38
8 changed files with 548 additions and 18 deletions
+8 -1
View File
@@ -1,9 +1,12 @@
package bktcmd
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"syscall"
"github.com/avivsinai/bitbucket-cli/internal/build"
"github.com/avivsinai/bitbucket-cli/pkg/cmd/factory"
@@ -13,6 +16,9 @@ import (
// Main initialises CLI dependencies and executes the root command.
func Main() int {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
f, err := factory.New(build.Version)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to initialise factory: %v\n", err)
@@ -30,8 +36,9 @@ func Main() int {
_, _ = fmt.Fprintf(ios.ErrOut, "failed to create root command: %v\n", err)
return 1
}
rootCmd.SetContext(ctx)
if err := rootCmd.Execute(); err != nil {
if err := rootCmd.ExecuteContext(ctx); err != nil {
var exitErr *cmdutil.ExitError
if errors.As(err, &exitErr) {
if exitErr.Msg != "" {
+253 -4
View File
@@ -1,9 +1,18 @@
package remote
import "errors"
import (
"errors"
"fmt"
"net"
"net/url"
"os"
"os/exec"
"strings"
)
// ErrNotImplemented signals that remote detection is not yet implemented.
var ErrNotImplemented = errors.New("remote detection not implemented")
// ErrNoGitRemote indicates that the repository does not contain a Bitbucket
// remote we can infer defaults from.
var ErrNoGitRemote = errors.New("no Bitbucket git remote found")
// Locator represents a repository identifier derived from a git remote.
type Locator struct {
@@ -16,5 +25,245 @@ type Locator struct {
// Detect attempts to infer the locator from git remotes.
func Detect(repoPath string) (Locator, error) {
return Locator{}, ErrNotImplemented
repoPath = strings.TrimSpace(repoPath)
if repoPath == "" {
repoPath = "."
}
remotes, err := listGitRemotes(repoPath)
if err != nil {
return Locator{}, err
}
if len(remotes) == 0 {
return Locator{}, ErrNoGitRemote
}
var tried []string
appendIfMissing := func(values []string, candidate string) []string {
for _, v := range values {
if v == candidate {
return values
}
}
return append(values, candidate)
}
var urls []string
for _, name := range []string{"origin", "upstream"} {
if candidates, ok := remotes[name]; ok {
for _, candidate := range candidates {
urls = appendIfMissing(urls, candidate)
}
tried = append(tried, name)
}
}
for name, candidates := range remotes {
skipped := false
for _, t := range tried {
if t == name {
skipped = true
break
}
}
if skipped {
continue
}
for _, candidate := range candidates {
urls = appendIfMissing(urls, candidate)
}
}
for _, raw := range urls {
loc, err := parseLocator(raw)
if err != nil {
continue
}
if loc.RepoSlug == "" {
continue
}
return loc, nil
}
return Locator{}, ErrNoGitRemote
}
func listGitRemotes(repoPath string) (map[string][]string, error) {
args := []string{"remote", "-v"}
if repoPath != "." && repoPath != "" {
args = append([]string{"-C", repoPath}, args...)
} else {
args = append([]string{"-C", "."}, args...)
}
cmd := exec.Command("git", args...)
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
out, err := cmd.Output()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return nil, ErrNoGitRemote
}
if errors.Is(err, exec.ErrNotFound) {
return nil, fmt.Errorf("git executable not found: %w", err)
}
return nil, fmt.Errorf("git remote -v: %w", err)
}
lines := strings.Split(string(out), "\n")
result := make(map[string][]string)
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
name := fields[0]
u := fields[1]
existing := result[name]
already := false
for _, v := range existing {
if v == u {
already = true
break
}
}
if !already {
result[name] = append(result[name], u)
}
}
return result, nil
}
func parseLocator(raw string) (Locator, error) {
host, segments, err := dissectRemote(raw)
if err != nil {
return Locator{}, err
}
if len(segments) < 2 {
return Locator{}, fmt.Errorf("remote %q missing repository segments", raw)
}
loc := Locator{
Host: host,
}
if host == "bitbucket.org" {
loc.Kind = "cloud"
loc.Workspace = segments[0]
loc.RepoSlug = segments[1]
return loc, nil
}
project, repo := extractDCProjectRepo(segments)
if project == "" || repo == "" {
return Locator{}, fmt.Errorf("unable to parse Bitbucket Data Center remote %q", raw)
}
loc.Kind = "dc"
loc.ProjectKey = strings.ToUpper(project)
loc.RepoSlug = repo
return loc, nil
}
func dissectRemote(raw string) (string, []string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil, fmt.Errorf("empty remote URL")
}
if strings.Contains(raw, "://") {
u, err := url.Parse(raw)
if err != nil {
return "", nil, fmt.Errorf("parse remote: %w", err)
}
host := hostWithoutPort(u.Host)
path := cleanPath(u.Path)
segments := splitSegments(path)
return host, segments, nil
}
colon := strings.Index(raw, ":")
if colon == -1 {
return "", nil, fmt.Errorf("invalid remote URL %q", raw)
}
hostPart := raw[:colon]
pathPart := raw[colon+1:]
if at := strings.LastIndex(hostPart, "@"); at != -1 {
hostPart = hostPart[at+1:]
}
host := hostWithoutPort(hostPart)
segments := splitSegments(pathPart)
return host, segments, nil
}
func cleanPath(path string) string {
path = strings.TrimSpace(path)
path = strings.TrimPrefix(path, "/")
if idx := strings.Index(path, "?"); idx != -1 {
path = path[:idx]
}
if idx := strings.Index(path, "#"); idx != -1 {
path = path[:idx]
}
path = strings.TrimSuffix(path, "/")
return path
}
func splitSegments(path string) []string {
rawSegments := strings.FieldsFunc(path, func(r rune) bool { return r == '/' })
var segments []string
for _, seg := range rawSegments {
seg = strings.TrimSpace(seg)
if seg == "" {
continue
}
segments = append(segments, seg)
}
if len(segments) == 0 {
return segments
}
last := segments[len(segments)-1]
last = strings.TrimSuffix(last, ".git")
segments[len(segments)-1] = last
return segments
}
func extractDCProjectRepo(segments []string) (string, string) {
if len(segments) >= 4 && strings.EqualFold(segments[0], "projects") && strings.EqualFold(segments[2], "repos") {
return segments[1], segments[3]
}
if len(segments) >= 3 && strings.EqualFold(segments[0], "scm") {
return segments[1], segments[2]
}
if len(segments) >= 2 {
return segments[0], segments[1]
}
return "", ""
}
func hostWithoutPort(host string) string {
host = strings.TrimSpace(host)
host = strings.Trim(host, "[]")
if host == "" {
return host
}
if strings.Contains(host, ":") {
if parsed, _, err := net.SplitHostPort(host); err == nil {
host = parsed
}
}
return strings.ToLower(host)
}
+108
View File
@@ -0,0 +1,108 @@
package remote
import (
"errors"
"os"
"os/exec"
"testing"
)
func TestDetectCloudHTTPS(t *testing.T) {
dir := initGitRepo(t, "https://bitbucket.org/workspace/repo.git")
loc, err := Detect(dir)
if err != nil {
t.Fatalf("Detect() error = %v", err)
}
if loc.Kind != "cloud" {
t.Fatalf("kind = %q, want %q", loc.Kind, "cloud")
}
if loc.Workspace != "workspace" {
t.Fatalf("workspace = %q, want %q", loc.Workspace, "workspace")
}
if loc.RepoSlug != "repo" {
t.Fatalf("repo = %q, want %q", loc.RepoSlug, "repo")
}
if loc.Host != "bitbucket.org" {
t.Fatalf("host = %q, want %q", loc.Host, "bitbucket.org")
}
}
func TestDetectCloudSSH(t *testing.T) {
dir := initGitRepo(t, "git@bitbucket.org:workspace/repo.git")
loc, err := Detect(dir)
if err != nil {
t.Fatalf("Detect() error = %v", err)
}
if loc.Kind != "cloud" || loc.Workspace != "workspace" || loc.RepoSlug != "repo" {
t.Fatalf("locator = %+v", loc)
}
}
func TestDetectDataCenterScm(t *testing.T) {
dir := initGitRepo(t, "https://bitbucket.example.com/scm/PROJ/service.git")
loc, err := Detect(dir)
if err != nil {
t.Fatalf("Detect() error = %v", err)
}
if loc.Kind != "dc" {
t.Fatalf("kind = %q, want %q", loc.Kind, "dc")
}
if loc.ProjectKey != "PROJ" {
t.Fatalf("project = %q, want %q", loc.ProjectKey, "PROJ")
}
if loc.RepoSlug != "service" {
t.Fatalf("repo = %q, want %q", loc.RepoSlug, "service")
}
if loc.Host != "bitbucket.example.com" {
t.Fatalf("host = %q, want %q", loc.Host, "bitbucket.example.com")
}
}
func TestDetectDataCenterProjects(t *testing.T) {
dir := initGitRepo(t, "ssh://git@bitbucket.example.com:7999/projects/PROJ/repos/service.git")
loc, err := Detect(dir)
if err != nil {
t.Fatalf("Detect() error = %v", err)
}
if loc.Kind != "dc" || loc.ProjectKey != "PROJ" || loc.RepoSlug != "service" {
t.Fatalf("locator = %+v", loc)
}
}
func TestDetectNoRemote(t *testing.T) {
dir := initGitRepo(t, "")
_, err := Detect(dir)
if !errors.Is(err, ErrNoGitRemote) {
t.Fatalf("Detect() error = %v, want %v", err, ErrNoGitRemote)
}
}
func initGitRepo(t *testing.T, remoteURL string) string {
t.Helper()
dir := t.TempDir()
runGit(t, dir, "init", ".")
if remoteURL != "" {
runGit(t, dir, "remote", "add", "origin", remoteURL)
}
return dir
}
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmdArgs := append([]string{"-C", dir}, args...)
cmd := exec.Command("git", cmdArgs...)
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, output)
}
}
+10 -4
View File
@@ -768,16 +768,22 @@ func cloneLinksCloud(repo bbcloud.Repository) []string {
}
func selectCloneURLDC(repo bbdc.Repository, useSSH bool) (string, error) {
desired := "http"
if useSSH {
desired = "ssh"
for _, link := range repo.Links.Clone {
if strings.EqualFold(link.Name, "ssh") {
return link.Href, nil
}
}
return "", fmt.Errorf("no ssh clone URL available")
}
for _, link := range repo.Links.Clone {
if strings.EqualFold(link.Name, desired) {
name := strings.ToLower(strings.TrimSpace(link.Name))
if name == "https" || name == "http" {
return link.Href, nil
}
}
return "", fmt.Errorf("no %s clone URL available", desired)
return "", fmt.Errorf("no https clone URL available")
}
func selectCloneURLCloud(repo bbcloud.Repository, useSSH bool) (string, error) {
+79
View File
@@ -0,0 +1,79 @@
package repo
import (
"strings"
"testing"
"github.com/avivsinai/bitbucket-cli/pkg/bbdc"
)
func TestSelectCloneURLDCPrefersHTTPS(t *testing.T) {
var r bbdc.Repository
r.Links.Clone = []struct {
Href string `json:"href"`
Name string `json:"name"`
}{
{Href: "ssh://git@bitbucket.example.com:7999/PROJ/repo.git", Name: "ssh"},
{Href: "https://bitbucket.example.com/scm/PROJ/repo.git", Name: "https"},
}
got, err := selectCloneURLDC(r, false)
if err != nil {
t.Fatalf("selectCloneURLDC returned error: %v", err)
}
if got != "https://bitbucket.example.com/scm/PROJ/repo.git" {
t.Fatalf("got %q, want https link", got)
}
}
func TestSelectCloneURLDCHttpAlias(t *testing.T) {
var r bbdc.Repository
r.Links.Clone = []struct {
Href string `json:"href"`
Name string `json:"name"`
}{
{Href: "http://bitbucket.example.com/scm/PROJ/repo.git", Name: "http"},
}
got, err := selectCloneURLDC(r, false)
if err != nil {
t.Fatalf("selectCloneURLDC returned error: %v", err)
}
if got != "http://bitbucket.example.com/scm/PROJ/repo.git" {
t.Fatalf("got %q, want http link", got)
}
}
func TestSelectCloneURLDCSsh(t *testing.T) {
var r bbdc.Repository
r.Links.Clone = []struct {
Href string `json:"href"`
Name string `json:"name"`
}{
{Href: "ssh://git@bitbucket.example.com:7999/PROJ/repo.git", Name: "ssh"},
{Href: "https://bitbucket.example.com/scm/PROJ/repo.git", Name: "https"},
}
got, err := selectCloneURLDC(r, true)
if err != nil {
t.Fatalf("selectCloneURLDC returned error: %v", err)
}
if !strings.HasPrefix(got, "ssh://") {
t.Fatalf("got %q, want ssh link", got)
}
}
func TestSelectCloneURLDCMissing(t *testing.T) {
var r bbdc.Repository
r.Links.Clone = []struct {
Href string `json:"href"`
Name string `json:"name"`
}{
{Href: "https://bitbucket.example.com/scm/PROJ/repo.git", Name: "https"},
}
_, err := selectCloneURLDC(r, true)
if err == nil {
t.Fatalf("expected error when ssh clone missing")
}
}
-4
View File
@@ -1,8 +1,6 @@
package root
import (
"context"
"github.com/spf13/cobra"
"github.com/avivsinai/bitbucket-cli/pkg/cmd/admin"
@@ -42,8 +40,6 @@ Common flows:
},
}
root.SetContext(context.Background())
root.PersistentFlags().StringP("context", "c", "", "Active Bitbucket context name")
root.PersistentFlags().Bool("json", false, "Output in JSON format when supported")
root.PersistentFlags().Bool("yaml", false, "Output in YAML format when supported")
+85
View File
@@ -2,10 +2,15 @@ package cmdutil
import (
"fmt"
"net"
"net/url"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/avivsinai/bitbucket-cli/internal/config"
"github.com/avivsinai/bitbucket-cli/internal/remote"
)
// ResolveContext fetches the context and host configuration given an optional
@@ -40,6 +45,8 @@ func ResolveContext(f *Factory, cmd *cobra.Command, override string) (string, *c
return "", nil, nil, err
}
applyRemoteDefaults(ctx, host)
return contextName, ctx, host, nil
}
@@ -51,3 +58,81 @@ func FlagValue(cmd *cobra.Command, name string) string {
}
return flag.Value.String()
}
func applyRemoteDefaults(ctx *config.Context, host *config.Host) {
if ctx == nil || host == nil {
return
}
needsWorkspace := host.Kind == "cloud" && ctx.Workspace == ""
needsProject := host.Kind == "dc" && ctx.ProjectKey == ""
needsRepo := ctx.DefaultRepo == ""
if !needsWorkspace && !needsProject && !needsRepo {
return
}
wd, err := os.Getwd()
if err != nil {
return
}
loc, err := remote.Detect(wd)
if err != nil {
return
}
if !locatorMatchesHost(host, loc) {
return
}
if needsRepo && loc.RepoSlug != "" {
ctx.DefaultRepo = loc.RepoSlug
}
if needsWorkspace && loc.Workspace != "" {
ctx.Workspace = loc.Workspace
}
if needsProject && loc.ProjectKey != "" {
ctx.ProjectKey = loc.ProjectKey
}
}
func locatorMatchesHost(host *config.Host, loc remote.Locator) bool {
if host == nil {
return false
}
switch host.Kind {
case "cloud":
return loc.Kind == "cloud" && strings.EqualFold(loc.Host, "bitbucket.org")
case "dc":
if loc.Kind != "dc" {
return false
}
baseHost := hostHostname(host.BaseURL)
return baseHost != "" && strings.EqualFold(baseHost, loc.Host)
default:
return false
}
}
func hostHostname(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err == nil && u.Host != "" {
raw = u.Host
}
raw = strings.Trim(raw, "[]")
if raw == "" {
return ""
}
if strings.Contains(raw, ":") {
if host, _, err := net.SplitHostPort(raw); err == nil {
raw = host
}
}
return strings.ToLower(raw)
}
+5 -5
View File
@@ -21,10 +21,10 @@ type noopSpinner struct {
ios *iostreams.IOStreams
}
// NewSpinner constructs a terminal spinner when stdout is a TTY. Otherwise a
// NewSpinner constructs a terminal spinner when stderr is a TTY. Otherwise a
// newline-based fallback is returned.
func NewSpinner(ios *iostreams.IOStreams) Spinner {
if ios != nil && ios.IsStdoutTTY() {
if ios != nil && ios.IsStderrTTY() {
return newTTYSpinner(ios)
}
return &noopSpinner{ios: ios}
@@ -38,7 +38,7 @@ func (s *noopSpinner) write(msg string) {
if s.ios == nil || msg == "" {
return
}
fmt.Fprintln(s.ios.Out, msg)
fmt.Fprintln(s.ios.ErrOut, msg)
}
type ttySpinner struct {
@@ -71,7 +71,7 @@ func (s *ttySpinner) Start(msg string) {
case <-stop:
return
case <-ticker.C:
fmt.Fprintf(s.ios.Out, "\r%c %s", frames[idx], msg)
fmt.Fprintf(s.ios.ErrOut, "\r%c %s", frames[idx], msg)
idx = (idx + 1) % len(frames)
}
}
@@ -97,5 +97,5 @@ func (s *ttySpinner) endWithPrefix(prefix, msg string) {
if msg == "" {
return
}
fmt.Fprintf(s.ios.Out, "\r%s %s\n", prefix, msg)
fmt.Fprintf(s.ios.ErrOut, "\r%s %s\n", prefix, msg)
}