Skip to content

Commit a09cc33

Browse files
committed
use regex replace instead of parsing urls and fix replacing username
1 parent 28dd369 commit a09cc33

File tree

2 files changed

+51
-81
lines changed

2 files changed

+51
-81
lines changed

pkg/gui/controllers/remotes_controller.go

Lines changed: 29 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ package controllers
22

33
import (
44
"fmt"
5-
"net/url"
5+
"regexp"
66
"strings"
77

88
"github.com/jesseduffield/gocui"
@@ -186,79 +186,37 @@ func (self *RemotesController) add() error {
186186
return nil
187187
}
188188

189-
// replaceForkUsername replaces the "owner" part of a git remote URL with forkUsername,
190-
// preserving the repo name (last path segment) and everything else (host, scheme, port, .git suffix).
191-
// Supported forms:
192-
// - SSH scp-like: git@host:owner[/subgroups]/repo(.git)
193-
// - HTTPS/HTTP: https://host/owner[/subgroups]/repo(.git)
194-
//
195-
// Rules:
196-
// - If there are fewer than 2 path segments (i.e., no clear owner+repo), return an error.
197-
// - For multi-segment paths (e.g., group/subgroup/repo), the entire prefix is replaced by forkUsername.
189+
var (
190+
// 1. SCP-like SSH: git@host:owner[/subgroups]/repo(.git)
191+
sshScpRegex = regexp.MustCompile(`^(git@[^:]+:)([^/]+(?:/[^/]+)*)/([^/]+?)(\.git)?$`)
192+
193+
// 2. SSH URL style: ssh://user@host[:port]/owner[/subgroups]/repo(.git)
194+
sshUrlRegex = regexp.MustCompile(`^(ssh://[^/]+/)([^/]+(?:/[^/]+)*)/([^/]+?)(\.git)?$`)
195+
196+
// 3. HTTPS: https://host/owner[/subgroups]/repo(.git)
197+
httpRegex = regexp.MustCompile(`^(https?://[^/]+/)([^/]+(?:/[^/]+)*)/([^/]+?)(\.git)?$`)
198+
)
199+
200+
// replaceForkUsername rewrites a Git remote URL to use the given fork username,
201+
// keeping the repo name and host intact. Supports SCP-like SSH, SSH URL style, and HTTPS.
198202
func replaceForkUsername(remoteUrl, forkUsername string) (string, error) {
199203
if forkUsername == "" {
200-
return "", fmt.Errorf("Fork username cannot be empty")
204+
return "", fmt.Errorf("fork username cannot be empty")
201205
}
202206
if remoteUrl == "" {
203-
return "", fmt.Errorf("Remote url cannot be empty")
204-
}
205-
206-
// SSH scp-like (most common): git@host:path
207-
if isScpLikeSSH(remoteUrl) {
208-
colon := strings.IndexByte(remoteUrl, ':')
209-
if colon == -1 {
210-
return "", fmt.Errorf("Invalid SSH remote URL (missing ':'): %s", remoteUrl)
211-
}
212-
path := remoteUrl[colon+1:] // e.g. owner/repo(.git) or group/sub/repo(.git)
213-
segments := splitNonEmpty(path, "/")
214-
if len(segments) < 2 {
215-
return "", fmt.Errorf("Remote URL must include owner and repo: %s", remoteUrl)
216-
}
217-
last := segments[len(segments)-1] // repo(.git)
218-
newPath := forkUsername + "/" + last
219-
return remoteUrl[:colon+1] + newPath, nil
220-
}
221-
222-
// Try URL parsing for http(s) (and reject anything else).
223-
u, err := url.Parse(remoteUrl)
224-
if err != nil {
225-
return "", fmt.Errorf("Invalid remote URL: %w", err)
226-
}
227-
if u.Scheme != "https" && u.Scheme != "http" {
228-
return "", fmt.Errorf("Unsupported remote URL scheme: %s", u.Scheme)
207+
return "", fmt.Errorf("remote URL cannot be empty")
229208
}
230209

231-
// u.Path like "/owner[/subgroups]/repo(.git)" or "" or "/"
232-
path := strings.Trim(u.Path, "/")
233-
segments := splitNonEmpty(path, "/")
234-
if len(segments) < 2 {
235-
return "", fmt.Errorf("Remote URL must include owner and repo: %s", remoteUrl)
210+
switch {
211+
case sshScpRegex.MatchString(remoteUrl):
212+
return sshScpRegex.ReplaceAllString(remoteUrl, "${1}"+forkUsername+"/$3$4"), nil
213+
case sshUrlRegex.MatchString(remoteUrl):
214+
return sshUrlRegex.ReplaceAllString(remoteUrl, "${1}"+forkUsername+"/$3$4"), nil
215+
case httpRegex.MatchString(remoteUrl):
216+
return httpRegex.ReplaceAllString(remoteUrl, "${1}"+forkUsername+"/$3$4"), nil
217+
default:
218+
return "", fmt.Errorf("unsupported or invalid remote URL: %s", remoteUrl)
236219
}
237-
238-
last := segments[len(segments)-1] // repo(.git)
239-
u.Path = "/" + forkUsername + "/" + last
240-
241-
// Preserve trailing slash only if it existed and wasn't empty
242-
// (remotes rarely care, but we'll avoid adding one)
243-
return u.String(), nil
244-
}
245-
246-
func isScpLikeSSH(s string) bool {
247-
// Minimal heuristic: "<user>@<host>:<path>"
248-
at := strings.IndexByte(s, '@')
249-
colon := strings.IndexByte(s, ':')
250-
return at > 0 && colon > at
251-
}
252-
253-
func splitNonEmpty(s, sep string) []string {
254-
raw := strings.Split(s, sep)
255-
out := make([]string, 0, len(raw))
256-
for _, p := range raw {
257-
if p != "" {
258-
out = append(out, p)
259-
}
260-
}
261-
return out
262220
}
263221

264222
func (self *RemotesController) addFork(baseRemote *models.Remote) error {
@@ -269,19 +227,13 @@ func (self *RemotesController) addFork(baseRemote *models.Remote) error {
269227
Title: self.c.Tr.NewRemoteName,
270228
InitialContent: forkUsername,
271229
HandleConfirm: func(remoteName string) error {
272-
if forkUsername == "" {
273-
return fmt.Errorf("Fork username cannot be empty")
274-
}
275230
if len(baseRemote.Urls) == 0 {
276-
return fmt.Errorf("Base remote must have url")
231+
return fmt.Errorf("base remote must have url")
277232
}
278-
url := baseRemote.Urls[0]
279-
if url == "" {
280-
return fmt.Errorf("Base remote url cannot be empty")
281-
}
282-
remoteUrl, err := replaceForkUsername(url, forkUsername)
233+
baseUrl := baseRemote.Urls[0]
234+
remoteUrl, err := replaceForkUsername(baseUrl, forkUsername)
283235
if err != nil {
284-
return fmt.Errorf("Failed to replace fork username in remote URL: `%w`, make sure it's a valid url", err)
236+
return err
285237
}
286238

287239
return self.addRemoteHelper(remoteName, remoteUrl)

pkg/gui/controllers/remotes_controller_test.go

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,41 @@ func TestReplaceForkUsername_SSH_OK(t *testing.T) {
1212
expected string
1313
}{
1414
{
15-
name: "github ssh basic",
15+
name: "github ssh scp-like basic",
1616
in: "[email protected]:old/repo.git",
1717
forkUser: "new",
1818
expected: "[email protected]:new/repo.git",
1919
},
2020
{
21-
name: "ssh no .git",
21+
name: "ssh scp-like no .git",
2222
in: "[email protected]:old/repo",
2323
forkUser: "new",
2424
expected: "[email protected]:new/repo",
2525
},
2626
{
27-
name: "gitlab subgroup ssh",
27+
name: "gitlab subgroup ssh scp-like",
2828
in: "[email protected]:group/sub/repo.git",
2929
forkUser: "alice",
3030
expected: "[email protected]:alice/repo.git",
3131
},
32+
{
33+
name: "ssh url style basic",
34+
in: "ssh://[email protected]/old/repo.git",
35+
forkUser: "new",
36+
expected: "ssh://[email protected]/new/repo.git",
37+
},
38+
{
39+
name: "ssh url style with port",
40+
in: "ssh://[email protected]:2222/old/repo.git",
41+
forkUser: "bob",
42+
expected: "ssh://[email protected]:2222/bob/repo.git",
43+
},
44+
{
45+
name: "ssh url style multi subgroup",
46+
in: "ssh://[email protected]/group/sub/repo.git",
47+
forkUser: "alice",
48+
expected: "ssh://[email protected]/alice/repo.git",
49+
},
3250
}
3351

3452
for _, c := range cases {
@@ -128,7 +146,7 @@ func TestReplaceForkUsername_Errors(t *testing.T) {
128146
},
129147
{
130148
name: "unsupported scheme",
131-
in: "ssh://git@github.com/old/repo.git", // explicit ssh:// not supported here
149+
in: "ftp://github.com/old/repo.git",
132150
forkUser: "x",
133151
},
134152
}

0 commit comments

Comments
 (0)