Skip to content

feat: add fork remote command #4831

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
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 @@ -639,6 +639,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 @@ -308,6 +308,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. |
| `` 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 @@ -334,6 +334,7 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味
| `` n `` | 新しいリモート | |
| `` d `` | 削除 | 選択したリモートを削除します。そのリモートからのリモートブランチを追跡しているローカルブランチは影響を受けません。 |
| `` e `` | 編集 | 選択したリモートの名前またはURLを編集します。 |
| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL. |
| `` f `` | フェッチ | リモートリポジトリから更新をフェッチします。これにより、ローカルブランチにマージせずに新しいコミットとブランチを取得します。 |
| `` / `` | 現在のビューをテキストでフィルタリング | |

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 @@ -258,6 +258,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. |
| `` 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 @@ -286,6 +286,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. |
| `` 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 @@ -382,6 +382,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. |
| `` 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 @@ -317,6 +317,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. |
| `` 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 @@ -354,6 +354,7 @@ _Связки клавиш_
| `` n `` | Добавить новую удалённую ветку | |
| `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. |
| `` e `` | Edit | Редактировать удалённый репозитории |
| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL. |
| `` f `` | Получить изменения | Получение изменения из удалённого репозитория |
| `` / `` | Filter the current view by text | |

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 @@ -382,6 +382,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. |
| `` 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 @@ -382,6 +382,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. |
| `` 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 @@ -509,6 +509,7 @@ type KeybindingBranchesConfig struct {
PushTag string `yaml:"pushTag"`
SetUpstream string `yaml:"setUpstream"`
FetchRemote string `yaml:"fetchRemote"`
AddForkRemote string `yaml:"AddForkRemote"`
SortOrder string `yaml:"sortOrder"`
}

Expand Down Expand Up @@ -971,6 +972,7 @@ func GetDefaultConfig() *UserConfig {
PushTag: "P",
SetUpstream: "u",
FetchRemote: "f",
AddForkRemote: "F",
SortOrder: "s",
},
Worktrees: KeybindingWorktreesConfig{
Expand Down
162 changes: 142 additions & 20 deletions pkg/gui/controllers/remotes_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package controllers

import (
"fmt"
"net/url"
"strings"

"github.com/jesseduffield/gocui"
Expand Down Expand Up @@ -70,6 +71,14 @@ func (self *RemotesController) GetKeybindings(opts types.KeybindingsOpts) []*typ
Tooltip: self.c.Tr.EditRemoteTooltip,
DisplayOnScreen: true,
},
{
Key: opts.GetKey(opts.Config.Branches.AddForkRemote),
Handler: self.withItem(self.addFork),
GetDisabledReason: self.require(self.singleItemSelected()),
Description: self.c.Tr.AddForkRemote,
Tooltip: self.c.Tr.AddForkRemoteTooltip,
DisplayOnScreen: true,
},
{
Key: opts.GetKey(opts.Config.Branches.FetchRemote),
Handler: self.withItem(self.fetch),
Expand Down Expand Up @@ -133,36 +142,149 @@ func (self *RemotesController) enter(remote *models.Remote) error {
return nil
}

func (self *RemotesController) addRemoteHelper(remoteName string, remoteUrl string) error {
self.c.LogAction(self.c.Tr.Actions.AddRemote)
if err := self.c.Git().Remote.AddRemote(remoteName, remoteUrl); err != nil {
return err
}

// Do a sync refresh of the remotes so that we can select
// the new one. Loading remotes is not expensive, so we can
// afford it.
self.c.Refresh(types.RefreshOptions{
Scope: []types.RefreshableView{types.REMOTES},
Mode: types.SYNC,
})

// Select the new remote
for idx, remote := range self.c.Model().Remotes {
if remote.Name == remoteName {
self.c.Contexts().Remotes.SetSelection(idx)
break
}
}

// Fetch the new remote
return self.fetch(self.c.Contexts().Remotes.GetSelected())
}

func (self *RemotesController) add() error {
self.c.Prompt(types.PromptOpts{
Title: self.c.Tr.NewRemoteName,
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
}
return self.addRemoteHelper(remoteName, remoteUrl)
},
})

return nil
},
})

return nil
}

// replaceForkUsername replaces the "owner" part of a git remote URL with forkUsername,
// preserving the repo name (last path segment) and everything else (host, scheme, port, .git suffix).
// Supported forms:
// - SSH scp-like: git@host:owner[/subgroups]/repo(.git)
// - HTTPS/HTTP: https://host/owner[/subgroups]/repo(.git)
//
// Rules:
// - If there are fewer than 2 path segments (i.e., no clear owner+repo), return an error.
// - For multi-segment paths (e.g., group/subgroup/repo), the entire prefix is replaced by forkUsername.
func replaceForkUsername(remoteUrl, forkUsername string) (string, error) {
if forkUsername == "" {
return "", fmt.Errorf("Fork username cannot be empty")
}
if remoteUrl == "" {
return "", fmt.Errorf("Remote url cannot be empty")
}

// Do a sync refresh of the remotes so that we can select
// the new one. Loading remotes is not expensive, so we can
// afford it.
self.c.Refresh(types.RefreshOptions{
Scope: []types.RefreshableView{types.REMOTES},
Mode: types.SYNC,
})

// Select the new remote
for idx, remote := range self.c.Model().Remotes {
if remote.Name == remoteName {
self.c.Contexts().Remotes.SetSelection(idx)
break
}
// SSH scp-like (most common): git@host:path
if isScpLikeSSH(remoteUrl) {
colon := strings.IndexByte(remoteUrl, ':')
if colon == -1 {
return "", fmt.Errorf("Invalid SSH remote URL (missing ':'): %s", remoteUrl)
}
path := remoteUrl[colon+1:] // e.g. owner/repo(.git) or group/sub/repo(.git)
segments := splitNonEmpty(path, "/")
if len(segments) < 2 {
return "", fmt.Errorf("Remote URL must include owner and repo: %s", remoteUrl)
}
last := segments[len(segments)-1] // repo(.git)
newPath := forkUsername + "/" + last
return remoteUrl[:colon+1] + newPath, nil
}

// Try URL parsing for http(s) (and reject anything else).
u, err := url.Parse(remoteUrl)
if err != nil {
return "", fmt.Errorf("Invalid remote URL: %w", err)
}
if u.Scheme != "https" && u.Scheme != "http" {
return "", fmt.Errorf("Unsupported remote URL scheme: %s", u.Scheme)
}

// u.Path like "/owner[/subgroups]/repo(.git)" or "" or "/"
path := strings.Trim(u.Path, "/")
segments := splitNonEmpty(path, "/")
if len(segments) < 2 {
return "", fmt.Errorf("Remote URL must include owner and repo: %s", remoteUrl)
}

last := segments[len(segments)-1] // repo(.git)
u.Path = "/" + forkUsername + "/" + last

// Preserve trailing slash only if it existed and wasn't empty
// (remotes rarely care, but we'll avoid adding one)
return u.String(), nil
}

func isScpLikeSSH(s string) bool {
// Minimal heuristic: "<user>@<host>:<path>"
at := strings.IndexByte(s, '@')
colon := strings.IndexByte(s, ':')
return at > 0 && colon > at
}

func splitNonEmpty(s, sep string) []string {
raw := strings.Split(s, sep)
out := make([]string, 0, len(raw))
for _, p := range raw {
if p != "" {
out = append(out, p)
}
}
return out
}

func (self *RemotesController) addFork(baseRemote *models.Remote) error {
self.c.Prompt(types.PromptOpts{
Title: self.c.Tr.AddForkRemoteUsername,
HandleConfirm: func(forkUsername string) error {
self.c.Prompt(types.PromptOpts{
Title: self.c.Tr.NewRemoteName,
InitialContent: forkUsername,
HandleConfirm: func(remoteName string) error {
if forkUsername == "" {
return fmt.Errorf("Fork username cannot be empty")
}
if len(baseRemote.Urls) == 0 {
return fmt.Errorf("Base remote must have url")
}
url := baseRemote.Urls[0]
if url == "" {
return fmt.Errorf("Base remote url cannot be empty")
}
remoteUrl, err := replaceForkUsername(url, forkUsername)
if err != nil {
return fmt.Errorf("Failed to replace fork username in remote URL: `%w`, make sure it's a valid url", err)
}

// Fetch the new remote
return self.fetch(self.c.Contexts().Remotes.GetSelected())
return self.addRemoteHelper(remoteName, remoteUrl)
},
})

Expand Down
Loading