diff --git a/docs-master/Config.md b/docs-master/Config.md index 67198627d34..da9926b141b 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -703,6 +703,7 @@ keybinding: pushTag: P setUpstream: u fetchRemote: f + addForkRemote: F sortOrder: s worktrees: viewWorktreeOptions: w diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index f604bbf6ea8..bbbd3e2e8b7 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -317,6 +317,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` 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 `` | Fetch | Fetch updates from the remote repository. This retrieves new commits and branches without merging them into your local branches. | +| `` 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. | | `` / `` | Filter the current view by text | | ## Secondary diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index 121435fb34a..79cf196fe69 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -343,6 +343,7 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | `` d `` | 削除 | 選択したリモートを削除します。そのリモートからのリモートブランチを追跡しているローカルブランチは影響を受けません。 | | `` e `` | 編集 | 選択したリモートの名前またはURLを編集します。 | | `` f `` | フェッチ | リモートリポジトリから更新をフェッチします。これにより、ローカルブランチにマージせずに新しいコミットとブランチを取得します。 | +| `` 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. | | `` / `` | 現在のビューをテキストでフィルタリング | | ## リモートブランチ diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index b338df06ecd..330c080eeab 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -267,6 +267,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. | | `` e `` | Edit | Remote를 수정 | | `` f `` | Fetch | 원격을 업데이트 | +| `` 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. | | `` / `` | Filter the current view by text | | ## 원격 브랜치 diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index 9d412388644..f738be1b274 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -295,6 +295,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. | | `` e `` | Edit | Wijzig remote | | `` f `` | Fetch | Fetch 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. | | `` / `` | Filter the current view by text | | ## Secondary diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index 3bf21981244..7bf17f18baa 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -391,6 +391,7 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | `` 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 `` | Pobierz | Pobierz aktualizacje z zdalnego repozytorium. Pobiera nowe commity i gałęzie bez scalania ich z lokalnymi gałęziami. | +| `` 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. | | `` / `` | Filtruj bieżący widok po tekście | | ## Zdalne gałęzie diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index 4da1b2d7264..54b1f1871db 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -326,6 +326,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` 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 `` | Buscar | Fetch updates from the remote repository. This retrieves new commits and branches without merging them into your local branches. | +| `` 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. | | `` / `` | Filter the current view by text | | ## Secundário diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 7b0aa79e298..cee2366b421 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -363,6 +363,7 @@ _Связки клавиш_ | `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. | | `` e `` | Edit | Редактировать удалённый репозитории | | `` f `` | Получить изменения | Получение изменения из удалённого репозитория | +| `` 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. | | `` / `` | Filter the current view by text | | ## Файлы diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index 57346740d53..f0a9d3a4c55 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -391,6 +391,7 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | `` d `` | 删除 | 删除选中的远程。从远程跟踪远程分支的任何本地分支都不会受到影响。 | | `` e `` | 编辑 | 编辑远程仓库 | | `` f `` | 抓取 | 抓取远程仓库 | +| `` 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. | | `` / `` | 通过文本过滤当前视图 | | ## 远程分支 diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index 8b80b74bcbc..59d6d74c90d 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -391,6 +391,7 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. | | `` e `` | 編輯 | 編輯遠端 | | `` f `` | 擷取 | 擷取遠端 | +| `` 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. | | `` / `` | 搜尋 | | ## 遠端分支 diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index f0b41603ce2..324e425fd8d 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..bd88b1d583f 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -1,7 +1,10 @@ package controllers import ( + "errors" "fmt" + "regexp" + "slices" "strings" "github.com/jesseduffield/gocui" @@ -78,6 +81,14 @@ func (self *RemotesController) GetKeybindings(opts types.KeybindingsOpts) []*typ Tooltip: self.c.Tr.FetchRemoteTooltip, DisplayOnScreen: true, }, + { + Key: opts.GetKey(opts.Config.Branches.AddForkRemote), + Handler: self.addFork, + GetDisabledReason: self.hasOriginRemote(), + Description: self.c.Tr.AddForkRemote, + Tooltip: self.c.Tr.AddForkRemoteTooltip, + DisplayOnScreen: true, + }, } return bindings @@ -133,6 +144,55 @@ func (self *RemotesController) enter(remote *models.Remote) error { return nil } +// 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 { + 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, + }) + + // 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) +} + +// 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 errors.New(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, @@ -141,28 +201,7 @@ func (self *RemotesController) add() error { 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 +212,74 @@ func (self *RemotesController) add() error { return nil } +// 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) +// 4. Only for integration tests: ../repo_name +var ( + urlRegex = regexp.MustCompile(`^(git@[^:]+:|ssh://[^/]+/|https?://[^/]+/)([^/]+(?:/[^/]+)*)/([^/]+?)(\.git)?$`) + integrationTestUrlRegex = regexp.MustCompile(`^\.\./.+$`) +) + +// 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(originUrl, forkUsername string, isIntegrationTest bool) (string, error) { + if urlRegex.MatchString(originUrl) { + return urlRegex.ReplaceAllString(originUrl, "${1}"+forkUsername+"/$3$4"), nil + } else if isIntegrationTest && integrationTestUrlRegex.MatchString(originUrl) { + return "../" + forkUsername, nil + } + + return "", fmt.Errorf("unsupported or invalid remote URL: %s", originUrl) +} + +func (self *RemotesController) getOrigin() *models.Remote { + for _, remote := range self.c.Model().Remotes { + if remote.Name == "origin" { + return 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 { + origin := self.getOrigin() + + 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] + } + originUrl := origin.Urls[0] + remoteUrl, err := replaceForkUsername(originUrl, forkUsername, self.c.RunningIntegrationTest()) + if err != nil { + return err + } + + self.c.LogAction(self.c.Tr.Actions.AddForkRemote) + 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 +351,28 @@ 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 } - - self.c.Refresh(types.RefreshOptions{ + refreshOptions := types.RefreshOptions{ Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}, Mode: types.ASYNC, - }) - return nil + } + if branchName != "" { + err = self.c.Git().Branch.New(branchName, remote.Name+"/"+branchName) + if err == nil { + self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{}) + self.c.Contexts().Branches.SetSelection(0) + refreshOptions.KeepBranchSelectionIndex = true + } + } + self.c.Refresh(refreshOptions) + 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..05586329789 --- /dev/null +++ b/pkg/gui/controllers/remotes_controller_test.go @@ -0,0 +1,165 @@ +package controllers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +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, false) + assert.NoError(t, err) + assert.Equal(t, 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, false) + assert.NoError(t, err) + assert.Equal(t, c.expected, got) + }) + } +} + +func TestReplaceForkUsername_IntegrationTest_OK(t *testing.T) { + got, err := replaceForkUsername("../origin", "bob", true) + assert.NoError(t, err) + assert.Equal(t, "../bob", got) +} + +func TestReplaceForkUsername_Errors(t *testing.T) { + cases := []struct { + name string + in string + forkUser string + }{ + { + 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", + }, + { + name: "empty url", + in: "", + forkUser: "x", + }, + { + name: "integration test URL outside of integration test", + in: "../origin", + forkUser: "x", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := replaceForkUsername(c.in, c.forkUser, false) + assert.EqualError(t, err, "unsupported or invalid remote URL: "+c.in) + }) + } +} diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index c9ca0327592..acc9e1efe00 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -525,6 +525,11 @@ type TranslationSet struct { NewRemote string NewRemoteName string NewRemoteUrl string + AddForkRemote string + AddForkRemoteUsername string + AddForkRemoteTooltip string + IncompatibleForkAlreadyExistsError string + NoOriginRemote string ViewBranches string EditRemoteName string EditRemoteUrl string @@ -1021,6 +1026,7 @@ type Actions struct { DeleteRemoteBranch string SetBranchUpstream string AddRemote string + AddForkRemote string RemoveRemote string UpdateRemote string ApplyPatch string @@ -1622,6 +1628,11 @@ 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`, + NoOriginRemote: "Action needs 'origin' remote", ViewBranches: "View branches", EditRemoteName: `Enter updated remote name for {{.remoteName}}:`, EditRemoteUrl: `Enter updated remote url for {{.remoteName}}:`, @@ -2077,6 +2088,7 @@ func EnglishTranslationSet() *TranslationSet { DeleteRemoteBranch: "Delete remote branch", SetBranchUpstream: "Set branch upstream", AddRemote: "Add remote", + AddForkRemote: "Add fork remote", RemoveRemote: "Remove remote", UpdateRemote: "Update remote", ApplyPatch: "Apply patch", diff --git a/pkg/integration/components/shell.go b/pkg/integration/components/shell.go index b89e08d2bf3..2e5fa01cece 100644 --- a/pkg/integration/components/shell.go +++ b/pkg/integration/components/shell.go @@ -392,6 +392,12 @@ func (self *Shell) SetBranchUpstream(branch string, upstream string) *Shell { return self } +func (self *Shell) RemoveBranch(branch string) *Shell { + self.RunCommand([]string{"git", "branch", "-d", branch}) + + return self +} + func (self *Shell) RemoveRemoteBranch(remoteName string, branch string) *Shell { self.RunCommand([]string{"git", "-C", "../" + remoteName, "branch", "-d", branch}) diff --git a/pkg/integration/tests/remote/add_fork_remote.go b/pkg/integration/tests/remote/add_fork_remote.go new file mode 100644 index 00000000000..430e0c4ddd6 --- /dev/null +++ b/pkg/integration/tests/remote/add_fork_remote.go @@ -0,0 +1,47 @@ +package remote + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var AddForkRemote = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Use the 'Add fork remote' command to add a fork remote and check out a branch from it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("commit") + shell.CloneIntoRemote("origin") + shell.NewBranch("feature") + shell.Clone("fork") + shell.Checkout("master") + shell.RemoveBranch("feature") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Remotes(). + Focus(). + Lines( + Contains("origin").IsSelected(), + ). + Press(keys.Branches.AddForkRemote) + + t.ExpectPopup().Prompt(). + Title(Equals("Fork owner (username/org). Use username:branch to check out a branch")). + Type("fork:feature"). + Confirm() + + t.Views().Remotes(). + Lines( + Contains("origin"), + Contains("fork").IsSelected(), + ) + + t.Views().Branches(). + IsFocused(). + Lines( + Contains("feature ✓"), + Contains("master"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index fb8d0060f7f..d619ccc75b2 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -21,6 +21,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/integration/tests/misc" "github.com/jesseduffield/lazygit/pkg/integration/tests/patch_building" "github.com/jesseduffield/lazygit/pkg/integration/tests/reflog" + "github.com/jesseduffield/lazygit/pkg/integration/tests/remote" "github.com/jesseduffield/lazygit/pkg/integration/tests/shell_commands" "github.com/jesseduffield/lazygit/pkg/integration/tests/staging" "github.com/jesseduffield/lazygit/pkg/integration/tests/stash" @@ -351,6 +352,7 @@ var tests = []*components.IntegrationTest{ reflog.DoNotShowBranchMarkersInReflogSubcommits, reflog.Patch, reflog.Reset, + remote.AddForkRemote, shell_commands.BasicShellCommand, shell_commands.ComplexShellCommand, shell_commands.DeleteFromHistory, diff --git a/schema-master/config.json b/schema-master/config.json index 4aaa7a655e4..a808c3c54b2 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -888,6 +888,10 @@ "type": "string", "default": "f" }, + "addForkRemote": { + "type": "string", + "default": "F" + }, "sortOrder": { "type": "string", "default": "s"