mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
Operations that check something out (checkout, create branch, move commits to a new branch, fetch-and-checkout) selected the newly checked-out branch by calling SelectFirstBranchAndFirstCommit() before the refresh and passing KeepBranchSelectionIndex so the refresh wouldn't override it. That set the selection directly, usually from a worker goroutine (WithWaitingStatus/WithInlineStatus). Now that the refresh's own selection write is bounced onto the UI thread, the two writes could land in either order, and under load the refresh's "restore the previously-selected branch" write would win — leaving the old branch selected instead of the new one (flaky move_commits_to_new_branch_from_base_branch). Replace it with declarative selection intents applied inside the refresh's own bounce, so the selection is set on the UI thread and atomically with the list write (no off-thread write, and no BLOCK_UI needed to avoid a flicker): - BranchSelection: SelectCheckedOutBranch selects the checked-out branch (top of the list). The default, KeepBranchSelectionByName, restores the previously-selected branch by name as before. This replaces the KeepBranchSelectionIndex bool. - CommitSelection: SelectHeadCommit (already existed) for the commit. - SelectTopReflogCommit selects the top reflog entry, since a checkout adds a new entry there (reflog/checkout relies on this). SelectFirstBranchAndFirstCommit is gone. The previously-selected branch is now read at the top of the branches bounce, before the list is overwritten, so that read moves onto the UI thread too. fetchAndCheckout's refresh changes from ASYNC to SYNC so its post-refresh focus switch can run in Then on the UI thread; SYNC keeps the inline fetch spinner spinning (only BLOCK_UI would freeze it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
398 lines
12 KiB
Go
398 lines
12 KiB
Go
package controllers
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"regexp"
|
||
"slices"
|
||
"strings"
|
||
|
||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||
"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{
|
||
{
|
||
Keys: opts.GetKeys(opts.Config.Universal.GoInto),
|
||
Handler: self.withItem(self.enter),
|
||
GetDisabledReason: self.require(self.singleItemSelected()),
|
||
Description: self.c.Tr.ViewBranches,
|
||
DisplayOnScreen: true,
|
||
},
|
||
{
|
||
Keys: opts.GetKeys(opts.Config.Universal.New),
|
||
Handler: self.add,
|
||
Description: self.c.Tr.NewRemote,
|
||
DisplayOnScreen: true,
|
||
},
|
||
{
|
||
Keys: opts.GetKeys(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,
|
||
},
|
||
{
|
||
Keys: opts.GetKeys(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,
|
||
},
|
||
{
|
||
Keys: opts.GetKeys(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,
|
||
},
|
||
{
|
||
Keys: opts.GetKeys(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 {
|
||
content := fmt.Sprintf("%s\nUrls:\n%s", style.FgGreen.Sprint(remote.Name), strings.Join(remote.Urls, "\n"))
|
||
if len(remote.PushUrls) > 0 {
|
||
content += fmt.Sprintf("\nPush Urls:\n%s", strings.Join(remote.PushUrls, "\n"))
|
||
}
|
||
task = types.NewRenderStringTask(content)
|
||
}
|
||
|
||
self.c.RenderToMainViews(types.RefreshMainOpts{
|
||
Pair: self.c.MainViewPairs().Normal,
|
||
Main: &types.ViewUpdateOpts{
|
||
Title: "Remote",
|
||
Task: task,
|
||
},
|
||
})
|
||
})
|
||
}
|
||
}
|
||
|
||
func (self *RemotesController) GetOnDoubleClick() 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.SetWindowName(self.Context().GetWindowName())
|
||
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
|
||
}
|
||
|
||
// Refresh the remotes so that we can select the new one. The remotes model
|
||
// update is bounced onto the UI thread, so the selection (which reads
|
||
// Model.Remotes) has to run in Then; reading it inline here would see the
|
||
// previous model. Loading remotes is not expensive, so a sync refresh is
|
||
// affordable.
|
||
self.c.Refresh(types.RefreshOptions{
|
||
Scope: []types.RefreshableView{types.REMOTES},
|
||
Mode: types.SYNC,
|
||
Then: func() error {
|
||
// 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)
|
||
},
|
||
})
|
||
return nil
|
||
}
|
||
|
||
// 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.SYNC,
|
||
}
|
||
if branchName != "" {
|
||
err = self.c.Git().Branch.New(branchName, remote.Name+"/"+branchName)
|
||
if err == nil {
|
||
// Branch.New checks the new branch out, so HEAD moves: refresh the
|
||
// reflog (and, via scope expansion, the commits) as well, and select
|
||
// the newly checked-out branch and its head commit.
|
||
refreshOptions.Scope = append(refreshOptions.Scope, types.REFLOG)
|
||
refreshOptions.BranchSelection = types.SelectCheckedOutBranch
|
||
refreshOptions.CommitSelection = types.SelectHeadCommit
|
||
refreshOptions.SelectTopReflogCommit = true
|
||
// Focus the branches panel on the UI thread once the refresh has
|
||
// selected the newly checked-out branch.
|
||
refreshOptions.Then = func() error {
|
||
self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{})
|
||
return nil
|
||
}
|
||
}
|
||
}
|
||
self.c.Refresh(refreshOptions)
|
||
return err
|
||
})
|
||
}
|