From cffa4785f6cbfba6584da772b537a3c548d3883e Mon Sep 17 00:00:00 2001 From: Mathieu Velten Date: Tue, 25 Aug 2026 14:31:57 +0200 Subject: [PATCH 1/4] Handles SCP-like URLs that don't begin with git@ --- forge.go | 57 ++++++++++++++++++++++++++++++++++++++++---------- forges_test.go | 8 +++++++ 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/forge.go b/forge.go index cffd986..97e64c1 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,23 +327,20 @@ 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:] + // Handle scp-like syntax: user@host:owner/repo (e.g. git@github.com:user/repo). + scpCandidate := rawURL + if i := strings.Index(scpCandidate, "://"); i >= 0 { + scpCandidate = scpCandidate[i+len("://"):] + } + if domain, path, ok := splitSCPLike(scpCandidate); ok { return splitOwnerRepo(domain, path) } @@ -362,6 +360,43 @@ 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 := strings.Index(rawURL, ":") + if colonIdx < 0 { + return "", "", false + } + slashIdx := strings.Index(rawURL, "/") + if slashIdx >= 0 && slashIdx < colonIdx { + return "", "", false + } + + // A colon immediately followed by digits is a port, not a separator. + portEnd := len(rawURL) + if slashIdx >= 0 { + portEnd = slashIdx + } + if port := rawURL[colonIdx+1 : portEnd]; port != "" { + if _, err := strconv.Atoi(port); err == nil { + return "", "", false + } + } + + path = rawURL[colonIdx+1:] + if path == "" { + return "", "", false + } + + host := rawURL[:colonIdx] + if at := strings.LastIndex(host, "@"); at >= 0 { + host = host[at+1:] + } + 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..a8aef73 100644 --- a/forges_test.go +++ b/forges_test.go @@ -72,6 +72,14 @@ 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@gitlab.com:group/project.git", domain: "gitlab.com", owner: "group", repo: "project", From 199d03d71c28b8f82cee0b6990706896ac7b7de5 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Wed, 2 Sep 2026 13:23:32 +0100 Subject: [PATCH 2/4] Fix SCP-like URL regressions --- forge.go | 22 ++++++------- forges_test.go | 12 +++++++ internal/resolve/resolve_test.go | 54 ++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 13 deletions(-) diff --git a/forge.go b/forge.go index 97e64c1..29b831f 100644 --- a/forge.go +++ b/forge.go @@ -336,16 +336,10 @@ func ParseRepoURL(rawURL string) (domain, owner, repo string, err error) { } // Handle scp-like syntax: user@host:owner/repo (e.g. git@github.com:user/repo). - scpCandidate := rawURL - if i := strings.Index(scpCandidate, "://"); i >= 0 { - scpCandidate = scpCandidate[i+len("://"):] - } - if domain, path, ok := splitSCPLike(scpCandidate); ok { - return splitOwnerRepo(domain, path) - } - - // Add scheme if missing if !strings.Contains(rawURL, "://") { + if domain, path, ok := splitSCPLike(rawURL); ok { + return splitOwnerRepo(domain, path) + } rawURL = "https://" + rawURL } @@ -374,12 +368,15 @@ func splitSCPLike(rawURL string) (domain, path string, ok bool) { return "", "", false } - // A colon immediately followed by digits is a port, not a separator. + host := rawURL[:colonIdx] + at := strings.LastIndex(host, "@") + + // Without explicit userinfo, a colon immediately followed by digits is a port. portEnd := len(rawURL) if slashIdx >= 0 { portEnd = slashIdx } - if port := rawURL[colonIdx+1 : portEnd]; port != "" { + if port := rawURL[colonIdx+1 : portEnd]; at < 0 && port != "" { if _, err := strconv.Atoi(port); err == nil { return "", "", false } @@ -390,8 +387,7 @@ func splitSCPLike(rawURL string) (domain, path string, ok bool) { return "", "", false } - host := rawURL[:colonIdx] - if at := strings.LastIndex(host, "@"); at >= 0 { + if at >= 0 { host = host[at+1:] } return host, path, true diff --git a/forges_test.go b/forges_test.go index a8aef73..03dfcda 100644 --- a/forges_test.go +++ b/forges_test.go @@ -80,6 +80,18 @@ func TestParseRepoURL(t *testing.T) { 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@gitlab.com:group/project.git", domain: "gitlab.com", owner: "group", repo: "project", diff --git a/internal/resolve/resolve_test.go b/internal/resolve/resolve_test.go index a9f2bd0..2f2ee69 100644 --- a/internal/resolve/resolve_test.go +++ b/internal/resolve/resolve_test.go @@ -597,6 +597,60 @@ 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 + }{ + {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"}, + } + + 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 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") From f4904c55957fb64f629559652f79f0e5fbf05343 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Wed, 2 Sep 2026 13:38:07 +0100 Subject: [PATCH 3/4] Reject empty SCP-like hosts --- forge.go | 3 +++ forges_test.go | 4 ++++ internal/resolve/resolve_test.go | 8 ++++++++ 3 files changed, 15 insertions(+) diff --git a/forge.go b/forge.go index 29b831f..42ca852 100644 --- a/forge.go +++ b/forge.go @@ -390,6 +390,9 @@ func splitSCPLike(rawURL string) (domain, path string, ok bool) { if at >= 0 { host = host[at+1:] } + if host == "" { + return "", "", false + } return host, path, true } diff --git a/forges_test.go b/forges_test.go index 03dfcda..769e9e7 100644 --- a/forges_test.go +++ b/forges_test.go @@ -120,6 +120,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 2f2ee69..7e4a3ae 100644 --- a/internal/resolve/resolve_test.go +++ b/internal/resolve/resolve_test.go @@ -623,11 +623,13 @@ func TestRepoResolvesGitRemoteURLs(t *testing.T) { 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: "empty host", remoteURL: ":owner/repo", wantErr: true}, } for _, tt := range tests { @@ -635,6 +637,12 @@ func TestRepoResolvesGitRemoteURLs(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) } From 4c1349e7f12b7fba43f700d97bb6cbb0d916f959 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Wed, 2 Sep 2026 15:15:30 +0100 Subject: [PATCH 4/4] Handle bracketed IPv6 SCP URLs --- forge.go | 36 ++++++++++++++++++++++++-------- forges_test.go | 8 +++++++ internal/resolve/resolve_test.go | 1 + 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/forge.go b/forge.go index 42ca852..7e8eec8 100644 --- a/forge.go +++ b/forge.go @@ -359,24 +359,39 @@ func ParseRepoURL(rawURL string) (domain, owner, repo string, err error) { // 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 := strings.Index(rawURL, ":") + 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 := strings.Index(rawURL, "/") - if slashIdx >= 0 && slashIdx < colonIdx { - 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. - portEnd := len(rawURL) - if slashIdx >= 0 { - portEnd = slashIdx - } - if port := rawURL[colonIdx+1 : portEnd]; at < 0 && port != "" { + if port := rawURL[colonIdx+1 : slashIdx]; at < 0 && port != "" { if _, err := strconv.Atoi(port); err == nil { return "", "", false } @@ -390,6 +405,9 @@ func splitSCPLike(rawURL string) (domain, path string, ok bool) { if at >= 0 { host = host[at+1:] } + if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { + host = host[1 : len(host)-1] + } if host == "" { return "", "", false } diff --git a/forges_test.go b/forges_test.go index 769e9e7..a767d28 100644 --- a/forges_test.go +++ b/forges_test.go @@ -92,6 +92,14 @@ func TestParseRepoURL(t *testing.T) { 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", diff --git a/internal/resolve/resolve_test.go b/internal/resolve/resolve_test.go index 7e4a3ae..5bf4d89 100644 --- a/internal/resolve/resolve_test.go +++ b/internal/resolve/resolve_test.go @@ -629,6 +629,7 @@ func TestRepoResolvesGitRemoteURLs(t *testing.T) { {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}, }