From 89c3950da731399b14781d8b622adacf69f7d661 Mon Sep 17 00:00:00 2001 From: Karol Zwolak Date: Sun, 17 Aug 2025 19:18:45 +0200 Subject: [PATCH 1/5] 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 +++++++++++++++--- .../controllers/remotes_controller_test.go | 162 ++++++++++++++++++ pkg/i18n/english.go | 8 + schema/config.json | 4 + 15 files changed, 323 insertions(+), 25 deletions(-) create mode 100644 pkg/gui/controllers/remotes_controller_test.go diff --git a/docs/Config.md b/docs/Config.md index 614d8d79a64..8214e91b81c 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -703,6 +703,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 cf78b8574a8..f1f886fd9e8 100644 --- a/docs/keybindings/Keybindings_en.md +++ b/docs/keybindings/Keybindings_en.md @@ -316,6 +316,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 and optionally check out a branch from new remote. | | `` 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 198578d0929..74490c5a567 100644 --- a/docs/keybindings/Keybindings_ja.md +++ b/docs/keybindings/Keybindings_ja.md @@ -342,6 +342,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 and optionally check out a branch from new remote. | | `` f `` | フェッチ | リモートリポジトリから更新をフェッチします。これにより、ローカルブランチにマージせずに新しいコミットとブランチを取得します。 | | `` / `` | 現在のビューをテキストでフィルタリング | | diff --git a/docs/keybindings/Keybindings_ko.md b/docs/keybindings/Keybindings_ko.md index 5c4f1ad7b3a..eb71a5ba8a7 100644 --- a/docs/keybindings/Keybindings_ko.md +++ b/docs/keybindings/Keybindings_ko.md @@ -266,6 +266,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 and optionally check out a branch from new remote. | | `` f `` | Fetch | 원격을 업데이트 | | `` / `` | Filter the current view by text | | diff --git a/docs/keybindings/Keybindings_nl.md b/docs/keybindings/Keybindings_nl.md index d74247d717d..37a5f0a67ef 100644 --- a/docs/keybindings/Keybindings_nl.md +++ b/docs/keybindings/Keybindings_nl.md @@ -294,6 +294,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 and optionally check out a branch from new remote. | | `` 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 5d5d53609d3..bec1f655a3a 100644 --- a/docs/keybindings/Keybindings_pl.md +++ b/docs/keybindings/Keybindings_pl.md @@ -390,6 +390,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 and optionally check out a branch from new remote. | | `` 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 34094ba2bb2..7434c0504de 100644 --- a/docs/keybindings/Keybindings_pt.md +++ b/docs/keybindings/Keybindings_pt.md @@ -325,6 +325,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 and optionally check out a branch from new remote. | | `` 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 c1cd6f53c13..7eb4b72e63c 100644 --- a/docs/keybindings/Keybindings_ru.md +++ b/docs/keybindings/Keybindings_ru.md @@ -362,6 +362,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 and optionally check out a branch from new remote. | | `` f `` | Получить изменения | Получение изменения из удалённого репозитория | | `` / `` | Filter the current view by text | | diff --git a/docs/keybindings/Keybindings_zh-CN.md b/docs/keybindings/Keybindings_zh-CN.md index 799d7e511b2..a7ba4c1e225 100644 --- a/docs/keybindings/Keybindings_zh-CN.md +++ b/docs/keybindings/Keybindings_zh-CN.md @@ -390,6 +390,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 and optionally check out a branch from new remote. | | `` f `` | 抓取 | 抓取远程仓库 | | `` / `` | 通过文本过滤当前视图 | | diff --git a/docs/keybindings/Keybindings_zh-TW.md b/docs/keybindings/Keybindings_zh-TW.md index 9c9f2e913e8..15ca9ae45c1 100644 --- a/docs/keybindings/Keybindings_zh-TW.md +++ b/docs/keybindings/Keybindings_zh-TW.md @@ -390,6 +390,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 and optionally check out a branch from new remote. | | `` f `` | 擷取 | 擷取遠端 | | `` / `` | 搜尋 | | diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index f0b41603ce2..dfe81f468fe 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -529,6 +529,7 @@ type KeybindingBranchesConfig struct { PushTag string `yaml:"pushTag"` SetUpstream string `yaml:"setUpstream"` FetchRemote string `yaml:"fetchRemote"` + AddForkRemote string `yaml:"AddForkRemote"` SortOrder string `yaml:"sortOrder"` } @@ -985,6 +986,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..4d27ba1ab3f 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -2,6 +2,8 @@ package controllers import ( "fmt" + "regexp" + "slices" "strings" "github.com/jesseduffield/gocui" @@ -70,6 +72,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 +143,67 @@ func (self *RemotesController) enter(remote *models.Remote) error { return nil } +// Adds a new remote and refreshes the list of remotes. +func (self *RemotesController) addRemoteAndRefresh(remoteName string, remoteUrl string) error { + self.c.LogAction(self.c.Tr.Actions.AddRemote) + err := self.c.Git().Remote.AddRemote(remoteName, remoteUrl) + if 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, + }) + return nil +} + +// Selects the given remote in the UI, fetches it, and checks out the specified branch if profided. +func (self *RemotesController) selectRemoteAndCheckout(remoteName string, remoteUrl string, branchToCheckout string) error { + // Select the remote + for idx, remote := range self.c.Model().Remotes { + if remote.Name == remoteName { + self.c.Contexts().Remotes.SetSelection(idx) + break + } + } + + // Fetch the remote + return self.fetchAndCheckout(self.c.Contexts().Remotes.GetSelected(), branchToCheckout) +} + +// Adds a new remote, refreshes and selects it, then fetches and checks out the specified branch if provided. +func (self *RemotesController) addAndCheckoutRemote(remoteName string, remoteUrl string, branchToCheckout string) error { + self.addRemoteAndRefresh(remoteName, remoteUrl) + + return self.selectRemoteAndCheckout(remoteName, remoteUrl, branchToCheckout) +} + +// Ensures the fork remote exists (matching the given URL). +// If it exists and matches, it’s selected and fetched; otherwise, it’s created and then fetched and checked out. +// If it does exist but with a different URL, an error is returned. +func (self *RemotesController) ensureForkRemoteAndCheckout(remoteName string, remoteUrl string, branchToCheckout string) error { + for idx, remote := range self.c.Model().Remotes { + if remote.Name == remoteName { + hasTheSameUrl := slices.Contains(remote.Urls, remoteUrl) + if !hasTheSameUrl { + return fmt.Errorf("%s", utils.ResolvePlaceholderString( + self.c.Tr.IncompatibleForkAlreadyExistsError, + map[string]string{ + "remoteName": remoteName, + }, + )) + } + self.c.Contexts().Remotes.SetSelection(idx) + return self.fetchAndCheckout(remote, branchToCheckout) + } + } + return self.addAndCheckoutRemote(remoteName, remoteUrl, branchToCheckout) +} + func (self *RemotesController) add() error { self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.NewRemoteName, @@ -140,29 +211,7 @@ 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 - } - - // 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()) + return self.addAndCheckoutRemote(remoteName, remoteUrl, "") }, }) @@ -173,6 +222,63 @@ func (self *RemotesController) add() error { return nil } +var ( + // 1. SCP-like SSH: git@host:owner[/subgroups]/repo(.git) + sshScpRegex = regexp.MustCompile(`^(git@[^:]+:)([^/]+(?:/[^/]+)*)/([^/]+?)(\.git)?$`) + + // 2. SSH URL style: ssh://user@host[:port]/owner[/subgroups]/repo(.git) + sshUrlRegex = regexp.MustCompile(`^(ssh://[^/]+/)([^/]+(?:/[^/]+)*)/([^/]+?)(\.git)?$`) + + // 3. HTTPS: https://host/owner[/subgroups]/repo(.git) + httpRegex = regexp.MustCompile(`^(https?://[^/]+/)([^/]+(?:/[^/]+)*)/([^/]+?)(\.git)?$`) +) + +// Rewrites a Git remote URL to use the given fork username, +// keeping the repo name and host intact. Supports SCP-like SSH, SSH URL style, and HTTPS. +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") + } + + switch { + case sshScpRegex.MatchString(remoteUrl): + return sshScpRegex.ReplaceAllString(remoteUrl, "${1}"+forkUsername+"/$3$4"), nil + case sshUrlRegex.MatchString(remoteUrl): + return sshUrlRegex.ReplaceAllString(remoteUrl, "${1}"+forkUsername+"/$3$4"), nil + case httpRegex.MatchString(remoteUrl): + return httpRegex.ReplaceAllString(remoteUrl, "${1}"+forkUsername+"/$3$4"), nil + default: + return "", fmt.Errorf("unsupported or invalid remote URL: %s", remoteUrl) + } +} + +func (self *RemotesController) addFork(baseRemote *models.Remote) error { + self.c.Prompt(types.PromptOpts{ + Title: self.c.Tr.AddForkRemoteUsername, + HandleConfirm: func(forkUsername string) error { + branchToCheckout := "" + + parts := strings.SplitN(forkUsername, ":", 2) + if len(parts) == 2 { + forkUsername = parts[0] + branchToCheckout = parts[1] + } + baseUrl := baseRemote.Urls[0] + remoteUrl, err := replaceForkUsername(baseUrl, forkUsername) + if err != nil { + return err + } + + return self.ensureForkRemoteAndCheckout(forkUsername, remoteUrl, branchToCheckout) + }, + }) + + return nil +} + func (self *RemotesController) remove(remote *models.Remote) error { self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.RemoveRemote, @@ -244,16 +350,22 @@ func (self *RemotesController) edit(remote *models.Remote) error { } func (self *RemotesController) fetch(remote *models.Remote) error { + return self.fetchAndCheckout(remote, "") +} + +func (self *RemotesController) fetchAndCheckout(remote *models.Remote, branchName string) error { return self.c.WithInlineStatus(remote, types.ItemOperationFetching, context.REMOTES_CONTEXT_KEY, func(task gocui.Task) error { err := self.c.Git().Sync.FetchRemote(task, remote.Name) if err != nil { return err } - + if branchName != "" { + err = self.c.Git().Branch.New(branchName, remote.Name+"/"+branchName) + } self.c.Refresh(types.RefreshOptions{ Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}, Mode: types.ASYNC, }) - return nil + return err }) } diff --git a/pkg/gui/controllers/remotes_controller_test.go b/pkg/gui/controllers/remotes_controller_test.go new file mode 100644 index 00000000000..0a8f4277aad --- /dev/null +++ b/pkg/gui/controllers/remotes_controller_test.go @@ -0,0 +1,162 @@ +package controllers + +import ( + "testing" +) + +func TestReplaceForkUsername_SSH_OK(t *testing.T) { + cases := []struct { + name string + in string + forkUser string + expected string + }{ + { + name: "github ssh scp-like basic", + in: "git@github.com:old/repo.git", + forkUser: "new", + expected: "git@github.com:new/repo.git", + }, + { + name: "ssh scp-like no .git", + in: "git@github.com:old/repo", + forkUser: "new", + expected: "git@github.com:new/repo", + }, + { + name: "gitlab subgroup ssh scp-like", + in: "git@gitlab.com:group/sub/repo.git", + forkUser: "alice", + expected: "git@gitlab.com:alice/repo.git", + }, + { + name: "ssh url style basic", + in: "ssh://git@github.com/old/repo.git", + forkUser: "new", + expected: "ssh://git@github.com/new/repo.git", + }, + { + name: "ssh url style with port", + in: "ssh://git@github.com:2222/old/repo.git", + forkUser: "bob", + expected: "ssh://git@github.com:2222/bob/repo.git", + }, + { + name: "ssh url style multi subgroup", + in: "ssh://git@gitlab.com/group/sub/repo.git", + forkUser: "alice", + expected: "ssh://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: "ftp://github.com/old/repo.git", + 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") + } + }) + } +} diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index cb070b89207..e2f1479624b 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -525,6 +525,10 @@ type TranslationSet struct { NewRemote string NewRemoteName string NewRemoteUrl string + AddForkRemote string + AddForkRemoteUsername string + AddForkRemoteTooltip string + IncompatibleForkAlreadyExistsError string ViewBranches string EditRemoteName string EditRemoteUrl string @@ -1622,6 +1626,10 @@ func EnglishTranslationSet() *TranslationSet { NewRemote: `New remote`, NewRemoteName: `New remote name:`, NewRemoteUrl: `New remote url:`, + AddForkRemoteUsername: `Fork owner (username/org). Use username:branch to check out a branch`, + AddForkRemote: `Add fork remote`, + AddForkRemoteTooltip: `Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote.`, + IncompatibleForkAlreadyExistsError: `Remote {{.remoteName}} already exists and has different 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 4aaa7a655e4..fbfaf3d13a3 100644 --- a/schema/config.json +++ b/schema/config.json @@ -888,6 +888,10 @@ "type": "string", "default": "f" }, + "AddForkRemote": { + "type": "string", + "default": "F" + }, "sortOrder": { "type": "string", "default": "s" From d5758df5fc37bef2efb68722af4faa606f57a4e6 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 13 Oct 2025 11:42:18 +0200 Subject: [PATCH 2/5] refactor add fork functionality Inline addRemoteAndRefresh and selectRemoteAndCheckout into addAndCheckoutRemote. It's not worth having them as separate helper functions since they are only called from within addAndCheckoutRemote, and that function is not overly long. This fixes two issues: - selectRemoteAndCheckout had an unused parameter remoteUrl - the error return value from addRemoteAndRefresh was not checked Prefer errors.New over fmt.Errorf when there are no placeholders. Combine the three regex's into one. While it makes the regex itself more complex, it simplifies the code. --- pkg/gui/controllers/remotes_controller.go | 50 +++++++---------------- 1 file changed, 15 insertions(+), 35 deletions(-) diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index 4d27ba1ab3f..da97d71dc38 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -1,6 +1,7 @@ package controllers import ( + "errors" "fmt" "regexp" "slices" @@ -143,8 +144,8 @@ func (self *RemotesController) enter(remote *models.Remote) error { return nil } -// Adds a new remote and refreshes the list of remotes. -func (self *RemotesController) addRemoteAndRefresh(remoteName string, remoteUrl string) error { +// Adds a new remote, refreshes and selects it, then fetches and checks out the specified branch if provided. +func (self *RemotesController) addAndCheckoutRemote(remoteName string, remoteUrl string, branchToCheckout string) error { self.c.LogAction(self.c.Tr.Actions.AddRemote) err := self.c.Git().Remote.AddRemote(remoteName, remoteUrl) if err != nil { @@ -158,11 +159,7 @@ func (self *RemotesController) addRemoteAndRefresh(remoteName string, remoteUrl Scope: []types.RefreshableView{types.REMOTES}, Mode: types.SYNC, }) - return nil -} -// Selects the given remote in the UI, fetches it, and checks out the specified branch if profided. -func (self *RemotesController) selectRemoteAndCheckout(remoteName string, remoteUrl string, branchToCheckout string) error { // Select the remote for idx, remote := range self.c.Model().Remotes { if remote.Name == remoteName { @@ -175,13 +172,6 @@ func (self *RemotesController) selectRemoteAndCheckout(remoteName string, remote return self.fetchAndCheckout(self.c.Contexts().Remotes.GetSelected(), branchToCheckout) } -// Adds a new remote, refreshes and selects it, then fetches and checks out the specified branch if provided. -func (self *RemotesController) addAndCheckoutRemote(remoteName string, remoteUrl string, branchToCheckout string) error { - self.addRemoteAndRefresh(remoteName, remoteUrl) - - return self.selectRemoteAndCheckout(remoteName, remoteUrl, branchToCheckout) -} - // Ensures the fork remote exists (matching the given URL). // If it exists and matches, it’s selected and fetched; otherwise, it’s created and then fetched and checked out. // If it does exist but with a different URL, an error is returned. @@ -190,7 +180,7 @@ func (self *RemotesController) ensureForkRemoteAndCheckout(remoteName string, re if remote.Name == remoteName { hasTheSameUrl := slices.Contains(remote.Urls, remoteUrl) if !hasTheSameUrl { - return fmt.Errorf("%s", utils.ResolvePlaceholderString( + return errors.New(utils.ResolvePlaceholderString( self.c.Tr.IncompatibleForkAlreadyExistsError, map[string]string{ "remoteName": remoteName, @@ -222,37 +212,27 @@ func (self *RemotesController) add() error { return nil } -var ( - // 1. SCP-like SSH: git@host:owner[/subgroups]/repo(.git) - sshScpRegex = regexp.MustCompile(`^(git@[^:]+:)([^/]+(?:/[^/]+)*)/([^/]+?)(\.git)?$`) - - // 2. SSH URL style: ssh://user@host[:port]/owner[/subgroups]/repo(.git) - sshUrlRegex = regexp.MustCompile(`^(ssh://[^/]+/)([^/]+(?:/[^/]+)*)/([^/]+?)(\.git)?$`) - - // 3. HTTPS: https://host/owner[/subgroups]/repo(.git) - httpRegex = regexp.MustCompile(`^(https?://[^/]+/)([^/]+(?:/[^/]+)*)/([^/]+?)(\.git)?$`) -) +// Regex to match and capture parts of a Git remote URL. Supports the following formats: +// 1. SCP-like SSH: git@host:owner[/subgroups]/repo(.git) +// 2. SSH URL style: ssh://user@host[:port]/owner[/subgroups]/repo(.git) +// 3. HTTPS: https://host/owner[/subgroups]/repo(.git) +var urlRegex = regexp.MustCompile(`^(git@[^:]+:|ssh://[^/]+/|https?://[^/]+/)([^/]+(?:/[^/]+)*)/([^/]+?)(\.git)?$`) // Rewrites a Git remote URL to use the given fork username, // keeping the repo name and host intact. Supports SCP-like SSH, SSH URL style, and HTTPS. func replaceForkUsername(remoteUrl, forkUsername string) (string, error) { if forkUsername == "" { - return "", fmt.Errorf("fork username cannot be empty") + return "", errors.New("fork username cannot be empty") } if remoteUrl == "" { - return "", fmt.Errorf("remote URL cannot be empty") + return "", errors.New("remote URL cannot be empty") } - switch { - case sshScpRegex.MatchString(remoteUrl): - return sshScpRegex.ReplaceAllString(remoteUrl, "${1}"+forkUsername+"/$3$4"), nil - case sshUrlRegex.MatchString(remoteUrl): - return sshUrlRegex.ReplaceAllString(remoteUrl, "${1}"+forkUsername+"/$3$4"), nil - case httpRegex.MatchString(remoteUrl): - return httpRegex.ReplaceAllString(remoteUrl, "${1}"+forkUsername+"/$3$4"), nil - default: - return "", fmt.Errorf("unsupported or invalid remote URL: %s", remoteUrl) + if urlRegex.MatchString(remoteUrl) { + return urlRegex.ReplaceAllString(remoteUrl, "${1}"+forkUsername+"/$3$4"), nil } + + return "", fmt.Errorf("unsupported or invalid remote URL: %s", remoteUrl) } func (self *RemotesController) addFork(baseRemote *models.Remote) error { From 069f5819706c49f46788d3ca747f1b3133037b8e Mon Sep 17 00:00:00 2001 From: Karol Zwolak Date: Sun, 26 Oct 2025 09:02:30 +0100 Subject: [PATCH 3/5] treat origin as base remote --- pkg/gui/controllers/remotes_controller.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index da97d71dc38..fa8f1107a25 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -75,7 +75,7 @@ func (self *RemotesController) GetKeybindings(opts types.KeybindingsOpts) []*typ }, { Key: opts.GetKey(opts.Config.Branches.AddForkRemote), - Handler: self.withItem(self.addFork), + Handler: self.addFork, GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.AddForkRemote, Tooltip: self.c.Tr.AddForkRemoteTooltip, @@ -235,7 +235,18 @@ func replaceForkUsername(remoteUrl, forkUsername string) (string, error) { return "", fmt.Errorf("unsupported or invalid remote URL: %s", remoteUrl) } -func (self *RemotesController) addFork(baseRemote *models.Remote) error { +func (self *RemotesController) getOrigin() *models.Remote { + for _, remote := range self.c.Model().Remotes { + if remote.Name == "origin" { + return remote + } + } + return nil +} + +func (self *RemotesController) addFork() error { + baseRemote := self.getOrigin() + self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.AddForkRemoteUsername, HandleConfirm: func(forkUsername string) error { From c3e56f7c9b46f68aff0b0f006a69daf387592c14 Mon Sep 17 00:00:00 2001 From: Karol Zwolak Date: Sun, 26 Oct 2025 09:05:37 +0100 Subject: [PATCH 4/5] require origin remote --- pkg/gui/controllers/remotes_controller.go | 12 +++++++++++- pkg/i18n/english.go | 2 ++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index fa8f1107a25..02c8f6f758d 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -76,7 +76,7 @@ func (self *RemotesController) GetKeybindings(opts types.KeybindingsOpts) []*typ { Key: opts.GetKey(opts.Config.Branches.AddForkRemote), Handler: self.addFork, - GetDisabledReason: self.require(self.singleItemSelected()), + GetDisabledReason: self.hasOriginRemote(), Description: self.c.Tr.AddForkRemote, Tooltip: self.c.Tr.AddForkRemoteTooltip, DisplayOnScreen: true, @@ -244,6 +244,16 @@ func (self *RemotesController) getOrigin() *models.Remote { return nil } +func (self *RemotesController) hasOriginRemote() func() *types.DisabledReason { + return func() *types.DisabledReason { + if self.getOrigin() == nil { + return &types.DisabledReason{Text: self.c.Tr.NoOriginRemote} + } + + return nil + } +} + func (self *RemotesController) addFork() error { baseRemote := self.getOrigin() diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index e2f1479624b..5888107f692 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -529,6 +529,7 @@ type TranslationSet struct { AddForkRemoteUsername string AddForkRemoteTooltip string IncompatibleForkAlreadyExistsError string + NoOriginRemote string ViewBranches string EditRemoteName string EditRemoteUrl string @@ -1630,6 +1631,7 @@ func EnglishTranslationSet() *TranslationSet { AddForkRemote: `Add fork remote`, AddForkRemoteTooltip: `Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote.`, IncompatibleForkAlreadyExistsError: `Remote {{.remoteName}} already exists and has different URL`, + NoOriginRemote: "Action needs 'origin' remote", ViewBranches: "View branches", EditRemoteName: `Enter updated remote name for {{.remoteName}}:`, EditRemoteUrl: `Enter updated remote url for {{.remoteName}}:`, From a5fa422a856832af64d82d810fcdb3ad9116d34b Mon Sep 17 00:00:00 2001 From: Karol Zwolak Date: Sun, 26 Oct 2025 09:52:42 +0100 Subject: [PATCH 5/5] consistently use 'origin' over 'base' or 'remote' --- pkg/gui/controllers/remotes_controller.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index 02c8f6f758d..40296d465b1 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -220,19 +220,19 @@ var urlRegex = regexp.MustCompile(`^(git@[^:]+:|ssh://[^/]+/|https?://[^/]+/)([^ // Rewrites a Git remote URL to use the given fork username, // keeping the repo name and host intact. Supports SCP-like SSH, SSH URL style, and HTTPS. -func replaceForkUsername(remoteUrl, forkUsername string) (string, error) { +func replaceForkUsername(originUrl, forkUsername string) (string, error) { if forkUsername == "" { return "", errors.New("fork username cannot be empty") } - if remoteUrl == "" { + if originUrl == "" { return "", errors.New("remote URL cannot be empty") } - if urlRegex.MatchString(remoteUrl) { - return urlRegex.ReplaceAllString(remoteUrl, "${1}"+forkUsername+"/$3$4"), nil + if urlRegex.MatchString(originUrl) { + return urlRegex.ReplaceAllString(originUrl, "${1}"+forkUsername+"/$3$4"), nil } - return "", fmt.Errorf("unsupported or invalid remote URL: %s", remoteUrl) + return "", fmt.Errorf("unsupported or invalid remote URL: %s", originUrl) } func (self *RemotesController) getOrigin() *models.Remote { @@ -255,7 +255,7 @@ func (self *RemotesController) hasOriginRemote() func() *types.DisabledReason { } func (self *RemotesController) addFork() error { - baseRemote := self.getOrigin() + origin := self.getOrigin() self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.AddForkRemoteUsername, @@ -267,8 +267,8 @@ func (self *RemotesController) addFork() error { forkUsername = parts[0] branchToCheckout = parts[1] } - baseUrl := baseRemote.Urls[0] - remoteUrl, err := replaceForkUsername(baseUrl, forkUsername) + originUrl := origin.Urls[0] + remoteUrl, err := replaceForkUsername(originUrl, forkUsername) if err != nil { return err }