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
82 changes: 67 additions & 15 deletions forge.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/url"
"strconv"
"strings"

"github.com/git-pkgs/purl"
Expand Down Expand Up @@ -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
}

Expand All @@ -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) {
Expand Down
32 changes: 32 additions & 0 deletions forges_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -100,6 +128,10 @@ func TestParseRepoURL(t *testing.T) {
input: "git@github.com",
wantErr: true,
},
{
input: ":owner/repo",
wantErr: true,
},
}

for _, tt := range tests {
Expand Down
63 changes: 63 additions & 0 deletions internal/resolve/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down