From b9f5c84452d54c313fe3abb2dbc270a3b22ab896 Mon Sep 17 00:00:00 2001 From: Karol Zwolak Date: Sun, 17 Aug 2025 19:18:45 +0200 Subject: [PATCH 1/2] feat: add fork remote command The command allows you to quickly add a new 'fork' remote with replaced owner in the origin URL of the selected remote. For example: given url: https://github.com/jesseduffield/lazygit.git and username: karolzwolak adds a new remote with url: https://github.com/karolzwolak/lazygit.git --- docs/Config.md | 1 + docs/keybindings/Keybindings_en.md | 1 + docs/keybindings/Keybindings_ja.md | 1 + docs/keybindings/Keybindings_ko.md | 1 + docs/keybindings/Keybindings_nl.md | 1 + docs/keybindings/Keybindings_pl.md | 1 + docs/keybindings/Keybindings_pt.md | 1 + docs/keybindings/Keybindings_ru.md | 1 + docs/keybindings/Keybindings_zh-CN.md | 1 + docs/keybindings/Keybindings_zh-TW.md | 1 + pkg/config/user_config.go | 2 + pkg/gui/controllers/remotes_controller.go | 162 +++++++++++++++++++--- pkg/i18n/english.go | 6 + schema/config.json | 4 + 14 files changed, 164 insertions(+), 20 deletions(-) diff --git a/docs/Config.md b/docs/Config.md index 079ff5ce9af..084cbee8c80 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -639,6 +639,7 @@ keybinding: pushTag: P setUpstream: u fetchRemote: f + AddForkRemote: F sortOrder: s worktrees: viewWorktreeOptions: w diff --git a/docs/keybindings/Keybindings_en.md b/docs/keybindings/Keybindings_en.md index f759f093da1..5056ba9aa73 100644 --- a/docs/keybindings/Keybindings_en.md +++ b/docs/keybindings/Keybindings_en.md @@ -308,6 +308,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` n `` | New remote | | | `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. | | `` e `` | Edit | Edit the selected remote's name or URL. | +| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL. | | `` f `` | Fetch | Fetch updates from the remote repository. This retrieves new commits and branches without merging them into your local branches. | | `` / `` | Filter the current view by text | | diff --git a/docs/keybindings/Keybindings_ja.md b/docs/keybindings/Keybindings_ja.md index 76d269ea59c..33f72b4f4c7 100644 --- a/docs/keybindings/Keybindings_ja.md +++ b/docs/keybindings/Keybindings_ja.md @@ -334,6 +334,7 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | `` n `` | 新しいリモート | | | `` d `` | 削除 | 選択したリモートを削除します。そのリモートからのリモートブランチを追跡しているローカルブランチは影響を受けません。 | | `` e `` | 編集 | 選択したリモートの名前またはURLを編集します。 | +| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL. | | `` f `` | フェッチ | リモートリポジトリから更新をフェッチします。これにより、ローカルブランチにマージせずに新しいコミットとブランチを取得します。 | | `` / `` | 現在のビューをテキストでフィルタリング | | diff --git a/docs/keybindings/Keybindings_ko.md b/docs/keybindings/Keybindings_ko.md index a97fda88952..aa8b012e248 100644 --- a/docs/keybindings/Keybindings_ko.md +++ b/docs/keybindings/Keybindings_ko.md @@ -258,6 +258,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` n `` | 새로운 Remote 추가 | | | `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. | | `` e `` | Edit | Remote를 수정 | +| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL. | | `` f `` | Fetch | 원격을 업데이트 | | `` / `` | Filter the current view by text | | diff --git a/docs/keybindings/Keybindings_nl.md b/docs/keybindings/Keybindings_nl.md index 37482fb7496..4d7847fd4d9 100644 --- a/docs/keybindings/Keybindings_nl.md +++ b/docs/keybindings/Keybindings_nl.md @@ -286,6 +286,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` n `` | Voeg een nieuwe remote toe | | | `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. | | `` e `` | Edit | Wijzig remote | +| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL. | | `` f `` | Fetch | Fetch remote | | `` / `` | Filter the current view by text | | diff --git a/docs/keybindings/Keybindings_pl.md b/docs/keybindings/Keybindings_pl.md index 6595f2f3cb3..d7b8eb3941e 100644 --- a/docs/keybindings/Keybindings_pl.md +++ b/docs/keybindings/Keybindings_pl.md @@ -382,6 +382,7 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | `` n `` | Nowy zdalny | | | `` d `` | Usuń | Usuń wybrany zdalny. Wszelkie lokalne gałęzie śledzące gałąź zdalną z tego zdalnego nie zostaną dotknięte. | | `` e `` | Edytuj | Edytuj nazwę lub URL wybranego zdalnego. | +| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL. | | `` f `` | Pobierz | Pobierz aktualizacje z zdalnego repozytorium. Pobiera nowe commity i gałęzie bez scalania ich z lokalnymi gałęziami. | | `` / `` | Filtruj bieżący widok po tekście | | diff --git a/docs/keybindings/Keybindings_pt.md b/docs/keybindings/Keybindings_pt.md index 9eac7beb0f7..3f8ad5722e3 100644 --- a/docs/keybindings/Keybindings_pt.md +++ b/docs/keybindings/Keybindings_pt.md @@ -317,6 +317,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` n `` | Novo controle | | | `` d `` | Remover | Remover o controle remoto. Quaisquer ramificações locais de rastreamento de um ramo remoto do controle não serão afetadas. | | `` e `` | Editar | Edit the selected remote's name or URL. | +| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL. | | `` f `` | Buscar | Fetch updates from the remote repository. This retrieves new commits and branches without merging them into your local branches. | | `` / `` | Filter the current view by text | | diff --git a/docs/keybindings/Keybindings_ru.md b/docs/keybindings/Keybindings_ru.md index 753d74c3687..68602c32b4f 100644 --- a/docs/keybindings/Keybindings_ru.md +++ b/docs/keybindings/Keybindings_ru.md @@ -354,6 +354,7 @@ _Связки клавиш_ | `` n `` | Добавить новую удалённую ветку | | | `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. | | `` e `` | Edit | Редактировать удалённый репозитории | +| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL. | | `` f `` | Получить изменения | Получение изменения из удалённого репозитория | | `` / `` | Filter the current view by text | | diff --git a/docs/keybindings/Keybindings_zh-CN.md b/docs/keybindings/Keybindings_zh-CN.md index 185a6abb452..0d3261b63f3 100644 --- a/docs/keybindings/Keybindings_zh-CN.md +++ b/docs/keybindings/Keybindings_zh-CN.md @@ -382,6 +382,7 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | `` n `` | 添加新的远程仓库 | | | `` d `` | 删除 | 删除选中的远程。从远程跟踪远程分支的任何本地分支都不会受到影响。 | | `` e `` | 编辑 | 编辑远程仓库 | +| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL. | | `` f `` | 抓取 | 抓取远程仓库 | | `` / `` | 通过文本过滤当前视图 | | diff --git a/docs/keybindings/Keybindings_zh-TW.md b/docs/keybindings/Keybindings_zh-TW.md index b91243c7493..04795b63e3c 100644 --- a/docs/keybindings/Keybindings_zh-TW.md +++ b/docs/keybindings/Keybindings_zh-TW.md @@ -382,6 +382,7 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | `` n `` | 新增遠端 | | | `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. | | `` e `` | 編輯 | 編輯遠端 | +| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL. | | `` f `` | 擷取 | 擷取遠端 | | `` / `` | 搜尋 | | diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 0fa706bb242..ce865ed0088 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -509,6 +509,7 @@ type KeybindingBranchesConfig struct { PushTag string `yaml:"pushTag"` SetUpstream string `yaml:"setUpstream"` FetchRemote string `yaml:"fetchRemote"` + AddForkRemote string `yaml:"AddForkRemote"` SortOrder string `yaml:"sortOrder"` } @@ -971,6 +972,7 @@ func GetDefaultConfig() *UserConfig { PushTag: "P", SetUpstream: "u", FetchRemote: "f", + AddForkRemote: "F", SortOrder: "s", }, Worktrees: KeybindingWorktreesConfig{ diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index 10f15d457a1..c93b9b868a6 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -2,6 +2,7 @@ package controllers import ( "fmt" + "net/url" "strings" "github.com/jesseduffield/gocui" @@ -70,6 +71,14 @@ func (self *RemotesController) GetKeybindings(opts types.KeybindingsOpts) []*typ Tooltip: self.c.Tr.EditRemoteTooltip, DisplayOnScreen: true, }, + { + Key: opts.GetKey(opts.Config.Branches.AddForkRemote), + Handler: self.withItem(self.addFork), + GetDisabledReason: self.require(self.singleItemSelected()), + Description: self.c.Tr.AddForkRemote, + Tooltip: self.c.Tr.AddForkRemoteTooltip, + DisplayOnScreen: true, + }, { Key: opts.GetKey(opts.Config.Branches.FetchRemote), Handler: self.withItem(self.fetch), @@ -133,6 +142,32 @@ func (self *RemotesController) enter(remote *models.Remote) error { return nil } +func (self *RemotesController) addRemoteHelper(remoteName string, remoteUrl string) error { + self.c.LogAction(self.c.Tr.Actions.AddRemote) + if err := self.c.Git().Remote.AddRemote(remoteName, remoteUrl); err != nil { + return err + } + + // Do a sync refresh of the remotes so that we can select + // the new one. Loading remotes is not expensive, so we can + // afford it. + self.c.Refresh(types.RefreshOptions{ + Scope: []types.RefreshableView{types.REMOTES}, + Mode: types.SYNC, + }) + + // Select the new remote + for idx, remote := range self.c.Model().Remotes { + if remote.Name == remoteName { + self.c.Contexts().Remotes.SetSelection(idx) + break + } + } + + // Fetch the new remote + return self.fetch(self.c.Contexts().Remotes.GetSelected()) +} + func (self *RemotesController) add() error { self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.NewRemoteName, @@ -140,29 +175,116 @@ func (self *RemotesController) add() error { self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.NewRemoteUrl, HandleConfirm: func(remoteUrl string) error { - self.c.LogAction(self.c.Tr.Actions.AddRemote) - if err := self.c.Git().Remote.AddRemote(remoteName, remoteUrl); err != nil { - return err - } + return self.addRemoteHelper(remoteName, remoteUrl) + }, + }) + + return nil + }, + }) + + return nil +} + +// replaceForkUsername replaces the "owner" part of a git remote URL with forkUsername, +// preserving the repo name (last path segment) and everything else (host, scheme, port, .git suffix). +// Supported forms: +// - SSH scp-like: git@host:owner[/subgroups]/repo(.git) +// - HTTPS/HTTP: https://host/owner[/subgroups]/repo(.git) +// +// Rules: +// - If there are fewer than 2 path segments (i.e., no clear owner+repo), return an error. +// - For multi-segment paths (e.g., group/subgroup/repo), the entire prefix is replaced by forkUsername. +func replaceForkUsername(remoteUrl, forkUsername string) (string, error) { + if forkUsername == "" { + return "", fmt.Errorf("Fork username cannot be empty") + } + if remoteUrl == "" { + return "", fmt.Errorf("Remote url cannot be empty") + } - // Do a sync refresh of the remotes so that we can select - // the new one. Loading remotes is not expensive, so we can - // afford it. - self.c.Refresh(types.RefreshOptions{ - Scope: []types.RefreshableView{types.REMOTES}, - Mode: types.SYNC, - }) - - // Select the new remote - for idx, remote := range self.c.Model().Remotes { - if remote.Name == remoteName { - self.c.Contexts().Remotes.SetSelection(idx) - break - } + // SSH scp-like (most common): git@host:path + if isScpLikeSSH(remoteUrl) { + colon := strings.IndexByte(remoteUrl, ':') + if colon == -1 { + return "", fmt.Errorf("Invalid SSH remote URL (missing ':'): %s", remoteUrl) + } + path := remoteUrl[colon+1:] // e.g. owner/repo(.git) or group/sub/repo(.git) + segments := splitNonEmpty(path, "/") + if len(segments) < 2 { + return "", fmt.Errorf("Remote URL must include owner and repo: %s", remoteUrl) + } + last := segments[len(segments)-1] // repo(.git) + newPath := forkUsername + "/" + last + return remoteUrl[:colon+1] + newPath, nil + } + + // Try URL parsing for http(s) (and reject anything else). + u, err := url.Parse(remoteUrl) + if err != nil { + return "", fmt.Errorf("Invalid remote URL: %w", err) + } + if u.Scheme != "https" && u.Scheme != "http" { + return "", fmt.Errorf("Unsupported remote URL scheme: %s", u.Scheme) + } + + // u.Path like "/owner[/subgroups]/repo(.git)" or "" or "/" + path := strings.Trim(u.Path, "/") + segments := splitNonEmpty(path, "/") + if len(segments) < 2 { + return "", fmt.Errorf("Remote URL must include owner and repo: %s", remoteUrl) + } + + last := segments[len(segments)-1] // repo(.git) + u.Path = "/" + forkUsername + "/" + last + + // Preserve trailing slash only if it existed and wasn't empty + // (remotes rarely care, but we'll avoid adding one) + return u.String(), nil +} + +func isScpLikeSSH(s string) bool { + // Minimal heuristic: "@:" + at := strings.IndexByte(s, '@') + colon := strings.IndexByte(s, ':') + return at > 0 && colon > at +} + +func splitNonEmpty(s, sep string) []string { + raw := strings.Split(s, sep) + out := make([]string, 0, len(raw)) + for _, p := range raw { + if p != "" { + out = append(out, p) + } + } + return out +} + +func (self *RemotesController) addFork(baseRemote *models.Remote) error { + self.c.Prompt(types.PromptOpts{ + Title: self.c.Tr.AddForkRemoteUsername, + HandleConfirm: func(forkUsername string) error { + self.c.Prompt(types.PromptOpts{ + Title: self.c.Tr.NewRemoteName, + InitialContent: forkUsername, + HandleConfirm: func(remoteName string) error { + if forkUsername == "" { + return fmt.Errorf("Fork username cannot be empty") + } + if len(baseRemote.Urls) == 0 { + return fmt.Errorf("Base remote must have url") + } + url := baseRemote.Urls[0] + if url == "" { + return fmt.Errorf("Base remote url cannot be empty") + } + remoteUrl, err := replaceForkUsername(url, forkUsername) + if err != nil { + return fmt.Errorf("Failed to replace fork username in remote URL: `%w`, make sure it's a valid url", err) } - // Fetch the new remote - return self.fetch(self.c.Contexts().Remotes.GetSelected()) + return self.addRemoteHelper(remoteName, remoteUrl) }, }) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index ee3c11deabc..cbcd81e4f40 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -522,6 +522,9 @@ type TranslationSet struct { NewRemote string NewRemoteName string NewRemoteUrl string + AddForkRemote string + AddForkRemoteUsername string + AddForkRemoteTooltip string ViewBranches string EditRemoteName string EditRemoteUrl string @@ -1602,6 +1605,9 @@ func EnglishTranslationSet() *TranslationSet { NewRemote: `New remote`, NewRemoteName: `New remote name:`, NewRemoteUrl: `New remote url:`, + AddForkRemoteUsername: `Enter the owner of the fork (username or org):`, + AddForkRemote: `Add fork remote`, + AddForkRemoteTooltip: `Quickly add a fork remote by replacing the owner in the origin URL.`, ViewBranches: "View branches", EditRemoteName: `Enter updated remote name for {{.remoteName}}:`, EditRemoteUrl: `Enter updated remote url for {{.remoteName}}:`, diff --git a/schema/config.json b/schema/config.json index db9c796386a..781465ecb62 100644 --- a/schema/config.json +++ b/schema/config.json @@ -880,6 +880,10 @@ "type": "string", "default": "f" }, + "AddForkRemote": { + "type": "string", + "default": "F" + }, "sortOrder": { "type": "string", "default": "s" From 28dd369ed53d57115a3b0a24541c488f400f58c7 Mon Sep 17 00:00:00 2001 From: Karol Zwolak Date: Sun, 17 Aug 2025 21:33:53 +0200 Subject: [PATCH 2/2] add tests for replaceForkUsername --- .../controllers/remotes_controller_test.go | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 pkg/gui/controllers/remotes_controller_test.go diff --git a/pkg/gui/controllers/remotes_controller_test.go b/pkg/gui/controllers/remotes_controller_test.go new file mode 100644 index 00000000000..83139101589 --- /dev/null +++ b/pkg/gui/controllers/remotes_controller_test.go @@ -0,0 +1,144 @@ +package controllers + +import ( + "testing" +) + +func TestReplaceForkUsername_SSH_OK(t *testing.T) { + cases := []struct { + name string + in string + forkUser string + expected string + }{ + { + name: "github ssh basic", + in: "git@github.com:old/repo.git", + forkUser: "new", + expected: "git@github.com:new/repo.git", + }, + { + name: "ssh no .git", + in: "git@github.com:old/repo", + forkUser: "new", + expected: "git@github.com:new/repo", + }, + { + name: "gitlab subgroup ssh", + in: "git@gitlab.com:group/sub/repo.git", + forkUser: "alice", + expected: "git@gitlab.com:alice/repo.git", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := replaceForkUsername(c.in, c.forkUser) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != c.expected { + t.Fatalf("expected %q, got %q", c.expected, got) + } + }) + } +} + +func TestReplaceForkUsername_HTTPS_OK(t *testing.T) { + cases := []struct { + name string + in string + forkUser string + expected string + }{ + { + name: "github https basic", + in: "https://github.com/old/repo.git", + forkUser: "new", + expected: "https://github.com/new/repo.git", + }, + { + name: "https no .git", + in: "https://github.com/old/repo", + forkUser: "new", + expected: "https://github.com/new/repo", + }, + { + name: "https with port", + in: "https://git.example.com:8443/group/repo", + forkUser: "me", + expected: "https://git.example.com:8443/me/repo", + }, + { + name: "gitlab multi subgroup https", + in: "https://gitlab.com/group/sub/sub2/repo", + forkUser: "bob", + expected: "https://gitlab.com/bob/repo", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := replaceForkUsername(c.in, c.forkUser) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != c.expected { + t.Fatalf("expected %q, got %q", c.expected, got) + } + }) + } +} + +func TestReplaceForkUsername_Errors(t *testing.T) { + cases := []struct { + name string + in string + forkUser string + }{ + { + name: "empty fork user", + in: "git@github.com:old/repo.git", + forkUser: "", + }, + { + name: "https host only", + in: "https://github.com", + forkUser: "x", + }, + { + name: "https host slash only", + in: "https://github.com/", + forkUser: "x", + }, + { + name: "https only repo (no owner)", + in: "https://github.com/repo.git", + forkUser: "x", + }, + { + name: "ssh missing path", + in: "git@github.com", + forkUser: "x", + }, + { + name: "ssh one segment only", + in: "git@github.com:repo.git", + forkUser: "x", + }, + { + name: "unsupported scheme", + in: "ssh://git@github.com/old/repo.git", // explicit ssh:// not supported here + forkUser: "x", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := replaceForkUsername(c.in, c.forkUser) + if err == nil { + t.Fatalf("expected error but got nil") + } + }) + } +}