Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/Config.md
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,7 @@ keybinding:
pushTag: P
setUpstream: u
fetchRemote: f
AddForkRemote: F
sortOrder: s
worktrees:
viewWorktreeOptions: w
Expand Down
1 change: 1 addition & 0 deletions docs/keybindings/Keybindings_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-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 | |

Expand Down
1 change: 1 addition & 0 deletions docs/keybindings/Keybindings_ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `` | フェッチ | リモートリポジトリから更新をフェッチします。これにより、ローカルブランチにマージせずに新しいコミットとブランチを取得します。 |
| `` / `` | 現在のビューをテキストでフィルタリング | |

Expand Down
1 change: 1 addition & 0 deletions docs/keybindings/Keybindings_ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-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 | |

Expand Down
1 change: 1 addition & 0 deletions docs/keybindings/Keybindings_nl.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-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 | |

Expand Down
1 change: 1 addition & 0 deletions docs/keybindings/Keybindings_pl.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ _Legenda: `<c-b>` oznacza ctrl+b, `<a-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 | |

Expand Down
1 change: 1 addition & 0 deletions docs/keybindings/Keybindings_pt.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-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 | |

Expand Down
1 change: 1 addition & 0 deletions docs/keybindings/Keybindings_ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | |

Expand Down
1 change: 1 addition & 0 deletions docs/keybindings/Keybindings_zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ _图例:`<c-b>` 意味着ctrl+b, `<a-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 `` | 抓取 | 抓取远程仓库 |
| `` / `` | 通过文本过滤当前视图 | |

Expand Down
1 change: 1 addition & 0 deletions docs/keybindings/Keybindings_zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ _說明:`<c-b>` 表示 Ctrl+B、`<a-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 `` | 擷取 | 擷取遠端 |
| `` / `` | 搜尋 | |

Expand Down
2 changes: 2 additions & 0 deletions pkg/config/user_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down Expand Up @@ -985,6 +986,7 @@ func GetDefaultConfig() *UserConfig {
PushTag: "P",
SetUpstream: "u",
FetchRemote: "f",
AddForkRemote: "F",
SortOrder: "s",
},
Worktrees: KeybindingWorktreesConfig{
Expand Down
163 changes: 138 additions & 25 deletions pkg/gui/controllers/remotes_controller.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package controllers

import (
"errors"
"fmt"
"regexp"
"slices"
"strings"

"github.com/jesseduffield/gocui"
Expand Down Expand Up @@ -70,6 +73,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.addFork,
GetDisabledReason: self.hasOriginRemote(),
Description: self.c.Tr.AddForkRemote,
Tooltip: self.c.Tr.AddForkRemoteTooltip,
DisplayOnScreen: true,
},
{
Key: opts.GetKey(opts.Config.Branches.FetchRemote),
Handler: self.withItem(self.fetch),
Expand Down Expand Up @@ -133,36 +144,64 @@ 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 {
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,
})

// 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,
HandleConfirm: func(remoteName string) 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, "")
},
})

Expand All @@ -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)
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(originUrl, forkUsername string) (string, error) {
if forkUsername == "" {
return "", errors.New("fork username cannot be empty")
}
if originUrl == "" {
return "", errors.New("remote URL cannot be empty")
}

if urlRegex.MatchString(originUrl) {
return urlRegex.ReplaceAllString(originUrl, "${1}"+forkUsername+"/$3$4"), 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)
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,
Expand Down Expand Up @@ -244,16 +351,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
})
}
Loading