mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 15:46:26 -04:00
Operations that show an inline status ("Pushing", "Fast-forwarding",
"Fetching", …) removed it by relying on the async refresh they trigger
to redraw the view after the item operation had been cleared. That
ordering was never guaranteed: the item operation is cleared on the
worker once the operation's function returns, while the refresh redraws
the item from the UI thread whenever its (asynchronous) git work
happens to finish. If the refresh redrew before the clear, the status
was left on screen with no later redraw to remove it, so the branch (or
tag/remote) stayed stuck showing e.g. "Pushing" indefinitely even though
the operation had completed. This is timing-dependent, which is why it
surfaced as rare, hard-to-reproduce reports and as flaky CI failures.
Fix it by re-rendering in stop() right after clearing the operation,
and by making these refreshes synchronous rather than async. Because a
synchronous refresh has already updated the model and queued its own
redraw by the time stop() runs, and UI-thread callbacks run in order,
the redraw we queue here runs last and draws the up-to-date model with
the status removed. An async refresh couldn't give that guarantee: its
model update might not have landed yet, so the redraw could briefly
flash the pre-operation status.
Pull refreshes through the shared CheckMergeOrRebaseAndSelectHeadCommit,
so that helper becomes synchronous too; its only other caller,
RegularMerge, thereby also refreshes synchronously, which is fine: a
synchronous on-worker refresh is what we want anyway.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
385 lines
12 KiB
Go
385 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
|
||
}
|
||
|
||
// 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.SYNC,
|
||
}
|
||
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.Helpers().Refs.SelectFirstBranchAndFirstCommit()
|
||
refreshOptions.KeepBranchSelectionIndex = true
|
||
refreshOptions.CommitSelection = types.KeepCommitSelectionIndex
|
||
}
|
||
}
|
||
self.c.Refresh(refreshOptions)
|
||
return err
|
||
})
|
||
}
|