diff --git a/forge.go b/forge.go index cffd986..7e8eec8 100644 --- a/forge.go +++ b/forge.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/url" + "strconv" "strings" "github.com/git-pkgs/purl" @@ -326,28 +327,19 @@ func (c *Client) FetchTagsFromPURL(ctx context.Context, p *purl.PURL) ([]Tag, er } // ParseRepoURL extracts the domain, owner, and repo from a repository URL. -// It handles https://, schemeless, and git@host:owner/repo SSH URLs, and -// strips .git suffixes and extra path segments. +// It handles https://, schemeless, and scp-like SSH URLs (sshuser@host:owner/repo +// or host:owner/repo), and strips .git suffixes and extra path segments. func ParseRepoURL(rawURL string) (domain, owner, repo string, err error) { rawURL = strings.TrimSpace(rawURL) if rawURL == "" { return "", "", "", fmt.Errorf("empty URL") } - // Handle git@ SSH URLs: git@github.com:owner/repo.git - if after, found := strings.CutPrefix(rawURL, "git@"); found { - rawURL = after - colonIdx := strings.Index(rawURL, ":") - if colonIdx < 0 { - return "", "", "", fmt.Errorf("invalid SSH URL: missing colon") - } - domain = rawURL[:colonIdx] - path := rawURL[colonIdx+1:] - return splitOwnerRepo(domain, path) - } - - // Add scheme if missing + // Handle scp-like syntax: user@host:owner/repo (e.g. git@github.com:user/repo). if !strings.Contains(rawURL, "://") { + if domain, path, ok := splitSCPLike(rawURL); ok { + return splitOwnerRepo(domain, path) + } rawURL = "https://" + rawURL } @@ -362,6 +354,66 @@ func ParseRepoURL(rawURL string) (domain, owner, repo string, err error) { return splitOwnerRepo(domain, u.Path) } +// splitSCPLike recognizes git's scp-like remote syntax host:path, optionally +// with userinfo (sshuser@host:path). It returns the host with any userinfo +// stripped, and the path. ok is false when rawURL isn't scp-like, for example +// when the colon is a numeric port (host:2222/path). +func splitSCPLike(rawURL string) (domain, path string, ok bool) { + colonIdx := -1 + inBrackets := false +findSeparator: + for i := 0; i < len(rawURL); i++ { + switch rawURL[i] { + case '[': + inBrackets = true + case ']': + inBrackets = false + case '/': + if !inBrackets { + return "", "", false + } + case ':': + if !inBrackets { + colonIdx = i + break findSeparator + } + } + } + if colonIdx < 0 { + return "", "", false + } + slashIdx := len(rawURL) + if slashOffset := strings.Index(rawURL[colonIdx+1:], "/"); slashOffset >= 0 { + slashIdx = colonIdx + 1 + slashOffset + } + + host := rawURL[:colonIdx] + at := strings.LastIndex(host, "@") + + // Without explicit userinfo, a colon immediately followed by digits is a port. + if port := rawURL[colonIdx+1 : slashIdx]; at < 0 && port != "" { + if _, err := strconv.Atoi(port); err == nil { + return "", "", false + } + } + + path = rawURL[colonIdx+1:] + if path == "" { + return "", "", false + } + + if at >= 0 { + host = host[at+1:] + } + if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { + host = host[1 : len(host)-1] + } + if host == "" { + return "", "", false + } + return host, path, true +} + const minOwnerRepoParts = 2 func splitOwnerRepo(domain, path string) (string, string, string, error) { diff --git a/forges_test.go b/forges_test.go index 0981ee8..a767d28 100644 --- a/forges_test.go +++ b/forges_test.go @@ -72,6 +72,34 @@ func TestParseRepoURL(t *testing.T) { input: "git@github.com:user/repo.git", domain: "github.com", owner: "user", repo: "repo", }, + { + input: "github.com:owner/repo.git", + domain: "github.com", owner: "owner", repo: "repo", + }, + { + input: "sshuser@github.com:owner/repo.git", + domain: "github.com", owner: "owner", repo: "repo", + }, + { + input: "git@github.com:123/repo.git", + domain: "github.com", owner: "123", repo: "repo", + }, + { + input: "https://user:token@github.com/owner/repo.git", + domain: "github.com", owner: "owner", repo: "repo", + }, + { + input: "ssh://git@[2001:db8::1]:2222/owner/repo.git", + domain: "2001:db8::1", owner: "owner", repo: "repo", + }, + { + input: "git@[2001:db8::1]:owner/repo.git", + domain: "2001:db8::1", owner: "owner", repo: "repo", + }, + { + input: "[2001:db8::1]:owner/repo.git", + domain: "2001:db8::1", owner: "owner", repo: "repo", + }, { input: "git@gitlab.com:group/project.git", domain: "gitlab.com", owner: "group", repo: "project", @@ -100,6 +128,10 @@ func TestParseRepoURL(t *testing.T) { input: "git@github.com", wantErr: true, }, + { + input: ":owner/repo", + wantErr: true, + }, } for _, tt := range tests { diff --git a/internal/resolve/resolve_test.go b/internal/resolve/resolve_test.go index a9f2bd0..5bf4d89 100644 --- a/internal/resolve/resolve_test.go +++ b/internal/resolve/resolve_test.go @@ -597,6 +597,69 @@ func TestRemoteSelectsCorrectGitURL(t *testing.T) { } } +func TestRepoResolvesGitRemoteURLs(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + + dir := t.TempDir() + t.Chdir(dir) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + config.ResetCache() + t.Cleanup(config.ResetCache) + if err := os.WriteFile(filepath.Join(dir, ".forge"), []byte("[2001:db8::1]\ntype = github\n"), 0o600); err != nil { + t.Fatal(err) + } + + mustGit(t, "init", "-q") + mustGit(t, "remote", "add", "origin", "git@github.com:owner/repo.git") + + old := remoteName + t.Cleanup(func() { remoteName = old }) + SetRemote("origin") + + tests := []struct { + name string + remoteURL string + domain string + owner string + wantErr bool + }{ + {name: "arbitrary SSH user", remoteURL: "org-12345@github.com:owner/repo.git", domain: "github.com", owner: "owner"}, + {name: "numeric owner", remoteURL: "git@github.com:123/repo.git", domain: "github.com", owner: "123"}, + {name: "HTTPS userinfo", remoteURL: "https://user:token@github.com/owner/repo.git", domain: "github.com", owner: "owner"}, + {name: "bracketed IPv6", remoteURL: "ssh://git@[2001:db8::1]:2222/owner/repo.git", domain: "2001:db8::1", owner: "owner"}, + {name: "bracketed IPv6 SCP", remoteURL: "git@[2001:db8::1]:owner/repo.git", domain: "2001:db8::1", owner: "owner"}, + {name: "empty host", remoteURL: ":owner/repo", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mustGit(t, "remote", "set-url", "origin", tt.remoteURL) + + _, owner, repo, domain, err := Repo("", "") + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("Repo: %v", err) + } + if domain != tt.domain { + t.Errorf("domain = %q, want %q", domain, tt.domain) + } + if owner != tt.owner { + t.Errorf("owner = %q, want %q", owner, tt.owner) + } + if repo != "repo" { + t.Errorf("repo = %q, want repo", repo) + } + }) + } +} + func TestRemoteUnknownNameError(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not installed")