Files
lazygit/pkg/gui/controllers/remotes_controller.go
Stefan HallerandClaude Opus 4.8 080542c9fb Capture the commits refresh's inputs on the UI thread
A commits refresh does its git work on a worker and then reads the model,
the contexts, and the modes for that work directly from there:
LocalCommits.GetSelectionRangeAndMode/GetLimitCommits/GetShowWholeGitGraph,
Model.Commits/MainBranches/HashPool, the filtering path/author. Those are
owned by the UI thread, which is concurrently running the cursor and render
code, so the reads race it — the dominant, confirmed source of the
commits-scope flakes (the startup ClampSelection vs GetSelectionRangeAndMode
race, for one).

Gather them into an immutable capturedCommitState on the UI thread, before
the git work is dispatched, and have refreshCommitsWithLimit compute from
that snapshot. UI-thread callers capture inline; worker callers can't (a
SYNC/BLOCK_UI refresh parks the UI thread at wg.Wait, so hopping from a
scope sub-worker would deadlock), so the capture is lifted out of the scope
worker into the refresh orchestration, and worker callers announce
themselves with a new RefreshFromWorker entry point that hops the capture to
the UI thread and blocks for it (OnUIThreadAndWait). BLOCK_UI runs the whole
refresh on the UI thread regardless of the caller, so it captures inline
too.

Every refresh issued from a worker that reaches the commits (or branches,
which pulls in commits) scope is converted: the fast-forward, branch/tag
delete, worktree remove/detach, push, reword-via-rebase, author edits,
custom-command, hard-reset-with-autostash, reset-to-ref, fetch-and-checkout,
gpg-stream, post-fetch, and external-change-poller refreshes, plus the
branch checkout and move-commits-to-new-branch refreshes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:10:45 +02:00

398 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, its selected and fetched; otherwise, its 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.RefreshFromWorker(refreshOptions)
return err
})
}