@@ -2,7 +2,7 @@ package controllers
22
33import (
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.
198202func 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
264222func (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 )
0 commit comments