1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2025-11-25 22:32:13 +02:00

feat: add fork remote command

The command allows you to quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote.
This commit is contained in:
Karol Zwolak
2025-08-17 19:18:45 +02:00
committed by Stefan Haller
parent ba632d4064
commit 3892c40666
18 changed files with 393 additions and 26 deletions

View File

@@ -703,6 +703,7 @@ keybinding:
pushTag: P pushTag: P
setUpstream: u setUpstream: u
fetchRemote: f fetchRemote: f
addForkRemote: F
sortOrder: s sortOrder: s
worktrees: worktrees:
viewWorktreeOptions: w viewWorktreeOptions: w

View File

@@ -317,6 +317,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-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. | | `` 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. | | `` 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 `` | 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 | | | `` / `` | Filter the current view by text | |
## Secondary ## Secondary

View File

@@ -343,6 +343,7 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味
| `` d `` | 削除 | 選択したリモートを削除します。そのリモートからのリモートブランチを追跡しているローカルブランチは影響を受けません。 | | `` d `` | 削除 | 選択したリモートを削除します。そのリモートからのリモートブランチを追跡しているローカルブランチは影響を受けません。 |
| `` e `` | 編集 | 選択したリモートの名前またはURLを編集します。 | | `` e `` | 編集 | 選択したリモートの名前またはURLを編集します。 |
| `` f `` | フェッチ | リモートリポジトリから更新をフェッチします。これにより、ローカルブランチにマージせずに新しいコミットとブランチを取得します。 | | `` 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. |
| `` / `` | 現在のビューをテキストでフィルタリング | | | `` / `` | 現在のビューをテキストでフィルタリング | |
## リモートブランチ ## リモートブランチ

View File

@@ -267,6 +267,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-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. | | `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. |
| `` e `` | Edit | Remote를 수정 | | `` e `` | Edit | Remote를 수정 |
| `` f `` | Fetch | 원격을 업데이트 | | `` 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 | | | `` / `` | Filter the current view by text | |
## 원격 브랜치 ## 원격 브랜치

View File

@@ -295,6 +295,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-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. | | `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. |
| `` e `` | Edit | Wijzig remote | | `` e `` | Edit | Wijzig remote |
| `` f `` | Fetch | Fetch 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 | | | `` / `` | Filter the current view by text | |
## Secondary ## Secondary

View File

@@ -391,6 +391,7 @@ _Legenda: `<c-b>` oznacza ctrl+b, `<a-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. | | `` 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. | | `` 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 `` | 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 | | | `` / `` | Filtruj bieżący widok po tekście | |
## Zdalne gałęzie ## Zdalne gałęzie

View File

@@ -326,6 +326,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-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. | | `` 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. | | `` 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 `` | 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 | | | `` / `` | Filter the current view by text | |
## Secundário ## Secundário

View File

@@ -363,6 +363,7 @@ _Связки клавиш_
| `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. | | `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. |
| `` e `` | Edit | Редактировать удалённый репозитории | | `` e `` | Edit | Редактировать удалённый репозитории |
| `` f `` | Получить изменения | Получение изменения из удалённого репозитория | | `` 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 | | | `` / `` | Filter the current view by text | |
## Файлы ## Файлы

View File

@@ -391,6 +391,7 @@ _图例:`<c-b>` 意味着ctrl+b, `<a-b>意味着Alt+b, `B` 意味着shift+b_
| `` d `` | 删除 | 删除选中的远程。从远程跟踪远程分支的任何本地分支都不会受到影响。 | | `` d `` | 删除 | 删除选中的远程。从远程跟踪远程分支的任何本地分支都不会受到影响。 |
| `` e `` | 编辑 | 编辑远程仓库 | | `` e `` | 编辑 | 编辑远程仓库 |
| `` f `` | 抓取 | 抓取远程仓库 | | `` 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. |
| `` / `` | 通过文本过滤当前视图 | | | `` / `` | 通过文本过滤当前视图 | |
## 远程分支 ## 远程分支

View File

@@ -391,6 +391,7 @@ _說明:`<c-b>` 表示 Ctrl+B、`<a-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. | | `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. |
| `` e `` | 編輯 | 編輯遠端 | | `` e `` | 編輯 | 編輯遠端 |
| `` f `` | 擷取 | 擷取遠端 | | `` 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. |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |
## 遠端分支 ## 遠端分支

View File

@@ -529,6 +529,7 @@ type KeybindingBranchesConfig struct {
PushTag string `yaml:"pushTag"` PushTag string `yaml:"pushTag"`
SetUpstream string `yaml:"setUpstream"` SetUpstream string `yaml:"setUpstream"`
FetchRemote string `yaml:"fetchRemote"` FetchRemote string `yaml:"fetchRemote"`
AddForkRemote string `yaml:"addForkRemote"`
SortOrder string `yaml:"sortOrder"` SortOrder string `yaml:"sortOrder"`
} }
@@ -985,6 +986,7 @@ func GetDefaultConfig() *UserConfig {
PushTag: "P", PushTag: "P",
SetUpstream: "u", SetUpstream: "u",
FetchRemote: "f", FetchRemote: "f",
AddForkRemote: "F",
SortOrder: "s", SortOrder: "s",
}, },
Worktrees: KeybindingWorktreesConfig{ Worktrees: KeybindingWorktreesConfig{

View File

@@ -1,7 +1,10 @@
package controllers package controllers
import ( import (
"errors"
"fmt" "fmt"
"regexp"
"slices"
"strings" "strings"
"github.com/jesseduffield/gocui" "github.com/jesseduffield/gocui"
@@ -78,6 +81,14 @@ func (self *RemotesController) GetKeybindings(opts types.KeybindingsOpts) []*typ
Tooltip: self.c.Tr.FetchRemoteTooltip, Tooltip: self.c.Tr.FetchRemoteTooltip,
DisplayOnScreen: true, 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 return bindings
@@ -133,6 +144,55 @@ func (self *RemotesController) enter(remote *models.Remote) error {
return nil 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 { func (self *RemotesController) add() error {
self.c.Prompt(types.PromptOpts{ self.c.Prompt(types.PromptOpts{
Title: self.c.Tr.NewRemoteName, Title: self.c.Tr.NewRemoteName,
@@ -141,28 +201,7 @@ func (self *RemotesController) add() error {
Title: self.c.Tr.NewRemoteUrl, Title: self.c.Tr.NewRemoteUrl,
HandleConfirm: func(remoteUrl string) error { HandleConfirm: func(remoteUrl string) error {
self.c.LogAction(self.c.Tr.Actions.AddRemote) self.c.LogAction(self.c.Tr.Actions.AddRemote)
if err := self.c.Git().Remote.AddRemote(remoteName, remoteUrl); err != nil { return self.addAndCheckoutRemote(remoteName, remoteUrl, "")
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())
}, },
}) })
@@ -173,6 +212,74 @@ func (self *RemotesController) add() error {
return nil 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 { func (self *RemotesController) remove(remote *models.Remote) error {
self.c.Confirm(types.ConfirmOpts{ self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.RemoveRemote, 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 { 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 { 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) err := self.c.Git().Sync.FetchRemote(task, remote.Name)
if err != nil { if err != nil {
return err return err
} }
refreshOptions := types.RefreshOptions{
self.c.Refresh(types.RefreshOptions{
Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES},
Mode: types.ASYNC, 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
}) })
} }

View File

@@ -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)
})
}
}

View File

@@ -525,6 +525,11 @@ type TranslationSet struct {
NewRemote string NewRemote string
NewRemoteName string NewRemoteName string
NewRemoteUrl string NewRemoteUrl string
AddForkRemote string
AddForkRemoteUsername string
AddForkRemoteTooltip string
IncompatibleForkAlreadyExistsError string
NoOriginRemote string
ViewBranches string ViewBranches string
EditRemoteName string EditRemoteName string
EditRemoteUrl string EditRemoteUrl string
@@ -1021,6 +1026,7 @@ type Actions struct {
DeleteRemoteBranch string DeleteRemoteBranch string
SetBranchUpstream string SetBranchUpstream string
AddRemote string AddRemote string
AddForkRemote string
RemoveRemote string RemoveRemote string
UpdateRemote string UpdateRemote string
ApplyPatch string ApplyPatch string
@@ -1622,6 +1628,11 @@ func EnglishTranslationSet() *TranslationSet {
NewRemote: `New remote`, NewRemote: `New remote`,
NewRemoteName: `New remote name:`, NewRemoteName: `New remote name:`,
NewRemoteUrl: `New remote url:`, 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", ViewBranches: "View branches",
EditRemoteName: `Enter updated remote name for {{.remoteName}}:`, EditRemoteName: `Enter updated remote name for {{.remoteName}}:`,
EditRemoteUrl: `Enter updated remote url for {{.remoteName}}:`, EditRemoteUrl: `Enter updated remote url for {{.remoteName}}:`,
@@ -2077,6 +2088,7 @@ func EnglishTranslationSet() *TranslationSet {
DeleteRemoteBranch: "Delete remote branch", DeleteRemoteBranch: "Delete remote branch",
SetBranchUpstream: "Set branch upstream", SetBranchUpstream: "Set branch upstream",
AddRemote: "Add remote", AddRemote: "Add remote",
AddForkRemote: "Add fork remote",
RemoveRemote: "Remove remote", RemoveRemote: "Remove remote",
UpdateRemote: "Update remote", UpdateRemote: "Update remote",
ApplyPatch: "Apply patch", ApplyPatch: "Apply patch",

View File

@@ -392,6 +392,12 @@ func (self *Shell) SetBranchUpstream(branch string, upstream string) *Shell {
return self 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 { func (self *Shell) RemoveRemoteBranch(remoteName string, branch string) *Shell {
self.RunCommand([]string{"git", "-C", "../" + remoteName, "branch", "-d", branch}) self.RunCommand([]string{"git", "-C", "../" + remoteName, "branch", "-d", branch})

View File

@@ -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"),
)
},
})

View File

@@ -21,6 +21,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/integration/tests/misc" "github.com/jesseduffield/lazygit/pkg/integration/tests/misc"
"github.com/jesseduffield/lazygit/pkg/integration/tests/patch_building" "github.com/jesseduffield/lazygit/pkg/integration/tests/patch_building"
"github.com/jesseduffield/lazygit/pkg/integration/tests/reflog" "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/shell_commands"
"github.com/jesseduffield/lazygit/pkg/integration/tests/staging" "github.com/jesseduffield/lazygit/pkg/integration/tests/staging"
"github.com/jesseduffield/lazygit/pkg/integration/tests/stash" "github.com/jesseduffield/lazygit/pkg/integration/tests/stash"
@@ -351,6 +352,7 @@ var tests = []*components.IntegrationTest{
reflog.DoNotShowBranchMarkersInReflogSubcommits, reflog.DoNotShowBranchMarkersInReflogSubcommits,
reflog.Patch, reflog.Patch,
reflog.Reset, reflog.Reset,
remote.AddForkRemote,
shell_commands.BasicShellCommand, shell_commands.BasicShellCommand,
shell_commands.ComplexShellCommand, shell_commands.ComplexShellCommand,
shell_commands.DeleteFromHistory, shell_commands.DeleteFromHistory,

View File

@@ -888,6 +888,10 @@
"type": "string", "type": "string",
"default": "f" "default": "f"
}, },
"addForkRemote": {
"type": "string",
"default": "F"
},
"sortOrder": { "sortOrder": {
"type": "string", "type": "string",
"default": "s" "default": "s"