1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2025-12-07 23:22:40 +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

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