mirror of
https://github.com/jesseduffield/lazygit.git
synced 2025-11-23 16:22:24 +03:00
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.
379 lines
11 KiB
Go
379 lines
11 KiB
Go
package controllers
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"regexp"
|
||
"slices"
|
||
"strings"
|
||
|
||
"github.com/jesseduffield/gocui"
|
||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||
)
|
||
|
||
type RemotesController struct {
|
||
baseController
|
||
*ListControllerTrait[*models.Remote]
|
||
c *ControllerCommon
|
||
|
||
setRemoteBranches func([]*models.RemoteBranch)
|
||
}
|
||
|
||
var _ types.IController = &RemotesController{}
|
||
|
||
func NewRemotesController(
|
||
c *ControllerCommon,
|
||
setRemoteBranches func([]*models.RemoteBranch),
|
||
) *RemotesController {
|
||
return &RemotesController{
|
||
baseController: baseController{},
|
||
ListControllerTrait: NewListControllerTrait(
|
||
c,
|
||
c.Contexts().Remotes,
|
||
c.Contexts().Remotes.GetSelected,
|
||
c.Contexts().Remotes.GetSelectedItems,
|
||
),
|
||
c: c,
|
||
setRemoteBranches: setRemoteBranches,
|
||
}
|
||
}
|
||
|
||
func (self *RemotesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
||
bindings := []*types.Binding{
|
||
{
|
||
Key: opts.GetKey(opts.Config.Universal.GoInto),
|
||
Handler: self.withItem(self.enter),
|
||
GetDisabledReason: self.require(self.singleItemSelected()),
|
||
Description: self.c.Tr.ViewBranches,
|
||
DisplayOnScreen: true,
|
||
},
|
||
{
|
||
Key: opts.GetKey(opts.Config.Universal.New),
|
||
Handler: self.add,
|
||
Description: self.c.Tr.NewRemote,
|
||
DisplayOnScreen: true,
|
||
},
|
||
{
|
||
Key: opts.GetKey(opts.Config.Universal.Remove),
|
||
Handler: self.withItem(self.remove),
|
||
GetDisabledReason: self.require(self.singleItemSelected()),
|
||
Description: self.c.Tr.Remove,
|
||
Tooltip: self.c.Tr.RemoveRemoteTooltip,
|
||
DisplayOnScreen: true,
|
||
},
|
||
{
|
||
Key: opts.GetKey(opts.Config.Universal.Edit),
|
||
Handler: self.withItem(self.edit),
|
||
GetDisabledReason: self.require(self.singleItemSelected()),
|
||
Description: self.c.Tr.Edit,
|
||
Tooltip: self.c.Tr.EditRemoteTooltip,
|
||
DisplayOnScreen: true,
|
||
},
|
||
{
|
||
Key: opts.GetKey(opts.Config.Branches.FetchRemote),
|
||
Handler: self.withItem(self.fetch),
|
||
GetDisabledReason: self.require(self.singleItemSelected()),
|
||
Description: self.c.Tr.Fetch,
|
||
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
|
||
}
|
||
|
||
func (self *RemotesController) context() *context.RemotesContext {
|
||
return self.c.Contexts().Remotes
|
||
}
|
||
|
||
func (self *RemotesController) GetOnRenderToMain() func() {
|
||
return func() {
|
||
self.c.Helpers().Diff.WithDiffModeCheck(func() {
|
||
var task types.UpdateTask
|
||
remote := self.context().GetSelected()
|
||
if remote == nil {
|
||
task = types.NewRenderStringTask("No remotes")
|
||
} else {
|
||
task = types.NewRenderStringTask(fmt.Sprintf("%s\nUrls:\n%s", style.FgGreen.Sprint(remote.Name), strings.Join(remote.Urls, "\n")))
|
||
}
|
||
|
||
self.c.RenderToMainViews(types.RefreshMainOpts{
|
||
Pair: self.c.MainViewPairs().Normal,
|
||
Main: &types.ViewUpdateOpts{
|
||
Title: "Remote",
|
||
Task: task,
|
||
},
|
||
})
|
||
})
|
||
}
|
||
}
|
||
|
||
func (self *RemotesController) GetOnClick() func() error {
|
||
return self.withItemGraceful(self.enter)
|
||
}
|
||
|
||
func (self *RemotesController) enter(remote *models.Remote) error {
|
||
// naive implementation: get the branches from the remote and render them to the list, change the context
|
||
self.setRemoteBranches(remote.Branches)
|
||
|
||
newSelectedLine := 0
|
||
if len(remote.Branches) == 0 {
|
||
newSelectedLine = -1
|
||
}
|
||
remoteBranchesContext := self.c.Contexts().RemoteBranches
|
||
remoteBranchesContext.SetSelection(newSelectedLine)
|
||
remoteBranchesContext.SetTitleRef(remote.Name)
|
||
remoteBranchesContext.SetParentContext(self.Context())
|
||
remoteBranchesContext.GetView().TitlePrefix = self.Context().GetView().TitlePrefix
|
||
|
||
self.c.PostRefreshUpdate(remoteBranchesContext)
|
||
|
||
self.c.Context().Push(remoteBranchesContext, types.OnFocusOpts{})
|
||
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,
|
||
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)
|
||
return self.addAndCheckoutRemote(remoteName, remoteUrl, "")
|
||
},
|
||
})
|
||
|
||
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 {
|
||
self.c.Confirm(types.ConfirmOpts{
|
||
Title: self.c.Tr.RemoveRemote,
|
||
Prompt: self.c.Tr.RemoveRemotePrompt + " '" + remote.Name + "'?",
|
||
HandleConfirm: func() error {
|
||
self.c.LogAction(self.c.Tr.Actions.RemoveRemote)
|
||
if err := self.c.Git().Remote.RemoveRemote(remote.Name); err != nil {
|
||
return err
|
||
}
|
||
|
||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}})
|
||
return nil
|
||
},
|
||
})
|
||
|
||
return nil
|
||
}
|
||
|
||
func (self *RemotesController) edit(remote *models.Remote) error {
|
||
editNameMessage := utils.ResolvePlaceholderString(
|
||
self.c.Tr.EditRemoteName,
|
||
map[string]string{
|
||
"remoteName": remote.Name,
|
||
},
|
||
)
|
||
|
||
self.c.Prompt(types.PromptOpts{
|
||
Title: editNameMessage,
|
||
InitialContent: remote.Name,
|
||
HandleConfirm: func(updatedRemoteName string) error {
|
||
if updatedRemoteName != remote.Name {
|
||
self.c.LogAction(self.c.Tr.Actions.UpdateRemote)
|
||
if err := self.c.Git().Remote.RenameRemote(remote.Name, updatedRemoteName); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
editUrlMessage := utils.ResolvePlaceholderString(
|
||
self.c.Tr.EditRemoteUrl,
|
||
map[string]string{
|
||
"remoteName": updatedRemoteName,
|
||
},
|
||
)
|
||
|
||
urls := remote.Urls
|
||
url := ""
|
||
if len(urls) > 0 {
|
||
url = urls[0]
|
||
}
|
||
|
||
self.c.Prompt(types.PromptOpts{
|
||
Title: editUrlMessage,
|
||
InitialContent: url,
|
||
HandleConfirm: func(updatedRemoteUrl string) error {
|
||
self.c.LogAction(self.c.Tr.Actions.UpdateRemote)
|
||
if err := self.c.Git().Remote.UpdateRemoteUrl(updatedRemoteName, updatedRemoteUrl); err != nil {
|
||
return err
|
||
}
|
||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}})
|
||
return nil
|
||
},
|
||
})
|
||
|
||
return nil
|
||
},
|
||
})
|
||
|
||
return nil
|
||
}
|
||
|
||
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
|
||
}
|
||
refreshOptions := types.RefreshOptions{
|
||
Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES},
|
||
Mode: types.ASYNC,
|
||
}
|
||
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
|
||
})
|
||
}
|