Skip to content
49 changes: 34 additions & 15 deletions pkg/cli/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ func findGitRootForPath(path string) (string, error) {
}

// parseGitHubRepoSlugFromURL extracts owner/repo from a GitHub URL
// Supports HTTPS (https://github.com/owner/repo), SCP-style SSH (git@github.com:owner/repo),
// Supports HTTPS (https://github.com/owner/repo), SCP-style SSH ([user@]github.com:owner/repo),
// and SSH URL scheme (ssh://git@github.com/owner/repo) formats.
// Also supports GitHub Enterprise URLs.
// Also supports GitHub Enterprise URLs and non-standard SSH usernames (e.g. example@ghe.host:owner/repo).
func parseGitHubRepoSlugFromURL(url string) string {
gitLog.Printf("Parsing GitHub repo slug from URL: %s", url)

Expand All @@ -72,12 +72,22 @@ func parseGitHubRepoSlugFromURL(url string) string {
return slug
}

// Handle SCP-style SSH URLs: git@github.com:owner/repo or git@enterprise.github.com:owner/repo
sshPrefix := "git@" + githubHostWithoutScheme + ":"
if after, ok := strings.CutPrefix(url, sshPrefix); ok {
slug := after
gitLog.Printf("Extracted slug from SSH URL: %s", slug)
return slug
// Handle SCP-style SSH URLs with any username: git@github.com:owner/repo,
// or non-standard usernames like example@example.ghe.com:owner/repo (GHE),
// or username-less like github.com:owner/repo.
// Match: [user@]<host>:<owner>/<repo>
scpHostColon := githubHostWithoutScheme + ":"
// Try with username (user@host:path)
if _, afterAt, hasAt := strings.Cut(url, "@"); hasAt {
if after, ok := strings.CutPrefix(afterAt, scpHostColon); ok {
gitLog.Printf("Extracted slug from SCP-style SSH URL with username: %s", after)
return after
}
}
// Try without username (host:path)
if after, ok := strings.CutPrefix(url, scpHostColon); ok {
gitLog.Printf("Extracted slug from SCP-style SSH URL without username: %s", after)
return after
}

// Handle SSH URL scheme: ssh://git@github.com/owner/repo or ssh://github.com/owner/repo
Expand Down Expand Up @@ -124,13 +134,6 @@ func extractHostFromRemoteURL(remoteURL string) string {
}
}

// SSH scp-like format: git@host:path
if after, ok := strings.CutPrefix(remoteURL, "git@"); ok {
if host, _, found := strings.Cut(after, ":"); found {
return host
}
}

// SSH URL format: ssh://git@host/path or ssh://host/path
if after, ok := strings.CutPrefix(remoteURL, "ssh://"); ok {
// Strip optional user info (e.g. "git@")
Expand All @@ -143,6 +146,22 @@ func extractHostFromRemoteURL(remoteURL string) string {
return after
}

// SSH scp-like format: [user@]host:path — any username, not just git@
// Try with username first (user@host:path)
if _, afterAt, hasAt := strings.Cut(remoteURL, "@"); hasAt {
if host, _, found := strings.Cut(afterAt, ":"); found {
return host
}
}
// Try without username (host:path)
if host, _, found := strings.Cut(remoteURL, ":"); found {
// Make sure this looks like an scp-style URL (has no slashes before the colon)
// to avoid matching "ssh://host:port/path" or "https://host:port/path"
if !strings.Contains(host, "/") {
return host
}
}

return "github.com"
}

Expand Down
63 changes: 63 additions & 0 deletions pkg/cli/git_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ func TestParseGitHubRepoSlugFromURL(t *testing.T) {
url: "git@github.com:github/gh-aw",
expected: "github/gh-aw",
},
{
name: "SSH SCP-style URL without username with .git",
url: "github.com:github/gh-aw.git",
expected: "github/gh-aw",
},
{
name: "SSH SCP-style URL without username without .git",
url: "github.com:github/gh-aw",
expected: "github/gh-aw",
},
{
name: "SSH URL scheme with .git",
url: "ssh://git@github.com/github/gh-aw.git",
Expand Down Expand Up @@ -74,6 +84,59 @@ func TestParseGitHubRepoSlugFromURL(t *testing.T) {
}
}

// TestParseGitHubRepoSlugFromURLGHE verifies that GHE SSH URLs with non-standard
// usernames (e.g. example@example.ghe.com:owner/repo.git) are parsed correctly
// when GITHUB_SERVER_URL is configured to point at the GHE instance.
func TestParseGitHubRepoSlugFromURLGHE(t *testing.T) {
tests := []struct {
name string
url string
expected string
}{
{
name: "GHE SSH SCP-style with custom username and .git",
url: "example@example.ghe.com:example-org/example-repo.git",
expected: "example-org/example-repo",
},
{
name: "GHE SSH SCP-style with custom username without .git",
url: "example@example.ghe.com:example-org/example-repo",
expected: "example-org/example-repo",
},
{
name: "GHE SSH SCP-style with git username and .git",
url: "git@example.ghe.com:example-org/example-repo.git",
expected: "example-org/example-repo",
},
{
name: "GHE HTTPS URL",
url: "https://example.ghe.com/example-org/example-repo.git",
expected: "example-org/example-repo",
},
{
name: "GHE SSH URL scheme",
url: "ssh://example@example.ghe.com/example-org/example-repo.git",
expected: "example-org/example-repo",
},
{
name: "SSH SCP-style URL for a different host should not match",
url: "example@other.host.com:example-org/example-repo.git",
expected: "",
},
}

t.Setenv("GITHUB_SERVER_URL", "https://example.ghe.com")

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseGitHubRepoSlugFromURL(tt.url)
if result != tt.expected {
t.Errorf("parseGitHubRepoSlugFromURL(%q) = %q, expected %q", tt.url, result, tt.expected)
}
})
}
}

func TestGetRepositorySlugFromRemote(t *testing.T) {
// This test verifies that the function can execute without errors in a git repo
// The actual value will depend on the repository being tested
Expand Down
10 changes: 10 additions & 0 deletions pkg/cli/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,16 @@ func TestExtractHostFromRemoteURL(t *testing.T) {
url: "git@ghes.example.com:org/repo.git",
expected: "ghes.example.com",
},
{
name: "GHE SSH scp-like with custom username",
url: "example@example.ghe.com:example-org/example-repo.git",
expected: "example.ghe.com",
},
{
name: "SSH scp-like without username",
url: "github.com:owner/repo.git",
expected: "github.com",
},
{
name: "GHES HTTPS without .git suffix",
url: "https://ghes.example.com/org/repo",
Expand Down
Loading