@@ -2,7 +2,7 @@ package controllers
2
2
3
3
import (
4
4
"fmt"
5
- "net/url "
5
+ "regexp "
6
6
"strings"
7
7
8
8
"github.com/jesseduffield/gocui"
@@ -186,79 +186,37 @@ func (self *RemotesController) add() error {
186
186
return nil
187
187
}
188
188
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.
198
202
func replaceForkUsername (remoteUrl , forkUsername string ) (string , error ) {
199
203
if forkUsername == "" {
200
- return "" , fmt .Errorf ("Fork username cannot be empty" )
204
+ return "" , fmt .Errorf ("fork username cannot be empty" )
201
205
}
202
206
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" )
229
208
}
230
209
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 )
236
219
}
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
262
220
}
263
221
264
222
func (self * RemotesController ) addFork (baseRemote * models.Remote ) error {
@@ -269,19 +227,13 @@ func (self *RemotesController) addFork(baseRemote *models.Remote) error {
269
227
Title : self .c .Tr .NewRemoteName ,
270
228
InitialContent : forkUsername ,
271
229
HandleConfirm : func (remoteName string ) error {
272
- if forkUsername == "" {
273
- return fmt .Errorf ("Fork username cannot be empty" )
274
- }
275
230
if len (baseRemote .Urls ) == 0 {
276
- return fmt .Errorf ("Base remote must have url" )
231
+ return fmt .Errorf ("base remote must have url" )
277
232
}
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 )
283
235
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
285
237
}
286
238
287
239
return self .addRemoteHelper (remoteName , remoteUrl )
0 commit comments