Skip to content

Commit f4dbe56

Browse files
hext-devclaude
andcommitted
fix: extract fragment from SCP-style git URLs
giturls.Parse doesn't handle fragments (#branch) in SCP-style URLs like git@github.com:user/repo.git#branch. This adds ExtractFragment() to manually extract the fragment before parsing, enabling branch/commit selection for SSH URLs. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8e5f81d commit f4dbe56

2 files changed

Lines changed: 93 additions & 2 deletions

File tree

git/git.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,30 @@ type CloneRepoOptions struct {
4343
ProxyOptions transport.ProxyOptions
4444
}
4545

46+
// ExtractFragment extracts a #fragment from a URL string before parsing.
47+
// This is needed because giturls.Parse doesn't handle fragments in SCP-style
48+
// URLs (e.g., git@github.com:user/repo.git#branch).
49+
func ExtractFragment(rawURL string) (urlWithoutFragment, fragment string) {
50+
// Only split on # if it appears after the host/path portion.
51+
// For SCP-style URLs (git@host:path), # is never part of the valid URL.
52+
// For standard URLs, # marks the fragment.
53+
if idx := strings.LastIndex(rawURL, "#"); idx != -1 {
54+
return rawURL[:idx], rawURL[idx+1:]
55+
}
56+
return rawURL, ""
57+
}
58+
4659
// CloneRepo will clone the repository at the given URL into the given path.
4760
// If a repository is already initialized at the given path, it will not
4861
// be cloned again.
4962
//
5063
// The bool returned states whether the repository was cloned or not.
5164
func CloneRepo(ctx context.Context, logf func(string, ...any), opts CloneRepoOptions) (bool, error) {
52-
parsed, err := giturls.Parse(opts.RepoURL)
65+
// Extract fragment before parsing, as giturls.Parse doesn't handle
66+
// fragments in SCP-style URLs (git@github.com:user/repo.git#branch).
67+
repoURL, manualFragment := ExtractFragment(opts.RepoURL)
68+
69+
parsed, err := giturls.Parse(repoURL)
5370
if err != nil {
5471
return false, fmt.Errorf("parse url %q: %w", opts.RepoURL, err)
5572
}
@@ -92,7 +109,11 @@ func CloneRepo(ctx context.Context, logf func(string, ...any), opts CloneRepoOpt
92109
if err != nil {
93110
return false, fmt.Errorf("mkdir %q: %w", opts.Path, err)
94111
}
95-
reference := parsed.Fragment
112+
// Use manually extracted fragment (for SCP-style URLs) or parsed fragment
113+
reference := manualFragment
114+
if reference == "" {
115+
reference = parsed.Fragment
116+
}
96117
if reference == "" && opts.SingleBranch {
97118
// When SingleBranch is true and no branch is specified, don't set a
98119
// default. go-git will automatically detect and use the remote's HEAD

git/git_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,76 @@ import (
2727
gossh "golang.org/x/crypto/ssh"
2828
)
2929

30+
func TestExtractFragment(t *testing.T) {
31+
t.Parallel()
32+
33+
tests := []struct {
34+
name string
35+
input string
36+
expectedURL string
37+
expectedFragment string
38+
}{
39+
{
40+
name: "HTTPS with branch",
41+
input: "https://github.com/user/repo#main",
42+
expectedURL: "https://github.com/user/repo",
43+
expectedFragment: "main",
44+
},
45+
{
46+
name: "HTTPS with refs/heads",
47+
input: "https://github.com/user/repo#refs/heads/feature",
48+
expectedURL: "https://github.com/user/repo",
49+
expectedFragment: "refs/heads/feature",
50+
},
51+
{
52+
name: "HTTPS with commit",
53+
input: "https://github.com/user/repo#a1b2c3d",
54+
expectedURL: "https://github.com/user/repo",
55+
expectedFragment: "a1b2c3d",
56+
},
57+
{
58+
name: "HTTPS no fragment",
59+
input: "https://github.com/user/repo",
60+
expectedURL: "https://github.com/user/repo",
61+
expectedFragment: "",
62+
},
63+
{
64+
name: "SCP-style with branch",
65+
input: "git@github.com:user/repo.git#main",
66+
expectedURL: "git@github.com:user/repo.git",
67+
expectedFragment: "main",
68+
},
69+
{
70+
name: "SCP-style with refs/heads",
71+
input: "git@github.com:user/repo.git#refs/heads/feature",
72+
expectedURL: "git@github.com:user/repo.git",
73+
expectedFragment: "refs/heads/feature",
74+
},
75+
{
76+
name: "SCP-style no fragment",
77+
input: "git@github.com:user/repo.git",
78+
expectedURL: "git@github.com:user/repo.git",
79+
expectedFragment: "",
80+
},
81+
{
82+
name: "SSH URL with branch",
83+
input: "ssh://git@github.com/user/repo.git#main",
84+
expectedURL: "ssh://git@github.com/user/repo.git",
85+
expectedFragment: "main",
86+
},
87+
}
88+
89+
for _, tc := range tests {
90+
tc := tc
91+
t.Run(tc.name, func(t *testing.T) {
92+
t.Parallel()
93+
url, fragment := git.ExtractFragment(tc.input)
94+
require.Equal(t, tc.expectedURL, url)
95+
require.Equal(t, tc.expectedFragment, fragment)
96+
})
97+
}
98+
}
99+
30100
func TestCloneRepo(t *testing.T) {
31101
t.Parallel()
32102

0 commit comments

Comments
 (0)