Put the remotes in the model before loading their branches

When lazygit starts up in a repo with many remote branches, the pull request
icons in the branches panel show up a good while after the branch list itself,
even though the pull requests come from the cache file and are in the model
before the first render. In the repo where this showed up (5400 remote
branches) the icons were up to half a second late.

The icons are rendered from Model.PullRequestsMap, and that map is built from
the remotes' URLs, because a branch's upstream remote tells us which repo
owner's pull requests to look for. The map therefore stays empty until the
remotes are in the model, and the remotes refresh doesn't put them there until
it has also enumerated and sorted all remote branches. In a big repo that takes
hundreds of milliseconds; reading the remotes themselves takes ten.

Load the two separately, and put the remotes in the model as soon as they have
been read from the git config. The branches refresh then finds them there, and
the pull request icons are part of the first render of the branch list.

Carry over the branches of the remotes we already have in the model in that
first update, so that the remote branches, and the branch counts in the remotes
panel, stay in place until the fresh ones are loaded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-09-22 10:48:22 +02:00
co-authored by Claude Opus 5
parent 01ce5b0800
commit 1b662a8ec3
2 changed files with 57 additions and 35 deletions
+8 -26
View File
@@ -5,10 +5,8 @@ import (
"maps"
"slices"
"strings"
"sync"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
@@ -20,30 +18,12 @@ func NewRemoteLoader(gitCommon *GitCommon) *RemoteLoader {
return &RemoteLoader{GitCommon: gitCommon}
}
func (self *RemoteLoader) GetRemotes() ([]*models.Remote, error) {
wg := sync.WaitGroup{}
wg.Add(1)
var remoteBranchesByRemoteName map[string][]*models.RemoteBranch
var remoteBranchesErr error
go utils.Safe(func() {
defer wg.Done()
remoteBranchesByRemoteName, remoteBranchesErr = self.getRemoteBranchesByRemoteName()
})
// GetRemotes returns the repo's remotes, without their branches; those are
// loaded separately with GetRemoteBranchesByRemoteName, which takes a lot longer
// in a repo with many remote branches.
func (self *RemoteLoader) GetRemotes() []*models.Remote {
remotes := self.getRemotesFromConfig()
wg.Wait()
if remoteBranchesErr != nil {
return nil, remoteBranchesErr
}
for _, remote := range remotes {
remote.Branches = remoteBranchesByRemoteName[remote.Name]
}
// now lets sort our remotes by name alphabetically
slices.SortFunc(remotes, func(a, b *models.Remote) int {
// we want origin at the top because we'll be most likely to want it
@@ -56,7 +36,7 @@ func (self *RemoteLoader) GetRemotes() ([]*models.Remote, error) {
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
})
return remotes, nil
return remotes
}
func (self *RemoteLoader) getRemotesFromConfig() []*models.Remote {
@@ -103,7 +83,9 @@ func (self *RemoteLoader) getRemotesFromConfig() []*models.Remote {
return slices.Collect(maps.Values(remotesByName))
}
func (self *RemoteLoader) getRemoteBranchesByRemoteName() (map[string][]*models.RemoteBranch, error) {
// GetRemoteBranchesByRemoteName returns all remote branches, keyed by the name
// of the remote they belong to.
func (self *RemoteLoader) GetRemoteBranchesByRemoteName() (map[string][]*models.RemoteBranch, error) {
remoteBranches, err := self.getRemoteBranches()
if err != nil {
return nil, err
+49 -9
View File
@@ -470,7 +470,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
}
branchesAndRemotesWg.Add(1)
refresh("remotes", func() {
loadedRemotes, _ = self.refreshRemotes(prevSelectedRemote, env)
loadedRemotes = self.refreshRemotes(prevSelectedRemote, env)
branchesAndRemotesWg.Done()
})
}
@@ -1544,14 +1544,16 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, en
return reflogCommits, nil
}
func (self *RefreshHelper) refreshRemotes(prevSelectedRemote *models.Remote, env refreshEnv) ([]*models.Remote, error) {
remotes, err := env.git.Loaders.RemoteLoader.GetRemotes()
if err != nil {
return nil, err
}
func (self *RefreshHelper) refreshRemotes(prevSelectedRemote *models.Remote, env refreshEnv) []*models.Remote {
remotes := env.git.Loaders.RemoteLoader.GetRemotes()
// Put the remotes in the model before loading their branches below. The map
// from branches to pull requests is built from the remotes' URLs (see
// GenerateGithubPullRequestMap), and loading the remote branches takes much
// longer than loading the remotes themselves. If the map had to wait for the
// branches, the pull request icons would show up long after the branch list.
self.onUIThreadUnlessRepoChanged(env, func() {
self.c.Model().Remotes = remotes
self.c.Model().Remotes = remotesWithCarriedOverBranches(remotes, self.c.Model().Remotes)
hadPrs := len(self.c.Model().PullRequestsMap) != 0
self.rebuildPullRequestsMap()
@@ -1559,11 +1561,31 @@ func (self *RefreshHelper) refreshRemotes(prevSelectedRemote *models.Remote, env
// if we didn't have PRs in the map before but now we do, we need to redraw the branches view
self.refreshView(self.c.Contexts().Branches, env)
}
})
self.refreshView(self.c.Contexts().Remotes, env)
remoteBranchesByRemoteName, err := env.git.Loaders.RemoteLoader.GetRemoteBranchesByRemoteName()
if err != nil {
// The remotes themselves are in the model already; log the failure and
// leave them there without their branches.
self.c.Log.Error(err)
return remotes
}
remotesWithBranches := lo.Map(remotes, func(remote *models.Remote, _ int) *models.Remote {
withBranches := *remote
withBranches.Branches = remoteBranchesByRemoteName[remote.Name]
return &withBranches
})
self.onUIThreadUnlessRepoChanged(env, func() {
self.c.Model().Remotes = remotesWithBranches
// we need to ensure our selected remote branches aren't now outdated
if prevSelectedRemote != nil && self.c.Model().RemoteBranches != nil {
// find remote now
for _, remote := range remotes {
for _, remote := range remotesWithBranches {
if remote.Name == prevSelectedRemote.Name {
self.c.Model().RemoteBranches = remote.Branches
break
@@ -1574,7 +1596,25 @@ func (self *RefreshHelper) refreshRemotes(prevSelectedRemote *models.Remote, env
self.refreshView(self.c.Contexts().Remotes, env)
self.refreshView(self.c.Contexts().RemoteBranches, env)
return remotes, nil
return remotesWithBranches
}
// remotesWithCarriedOverBranches returns copies of the freshly loaded remotes,
// each carrying the branches of the remote of the same name in the model. Those
// branches are the ones the views are showing right now, so carrying them over
// keeps the remote branches, and the branch counts in the remotes view, in place
// until the fresh ones are loaded. The copies also leave the remotes we were
// given untouched, so that the caller can go on reading them off the UI thread.
func remotesWithCarriedOverBranches(remotes []*models.Remote, modelRemotes []*models.Remote) []*models.Remote {
return lo.Map(remotes, func(remote *models.Remote, _ int) *models.Remote {
copied := *remote
if previous, found := lo.Find(modelRemotes, func(modelRemote *models.Remote) bool {
return modelRemote.Name == remote.Name
}); found {
copied.Branches = previous.Branches
}
return &copied
})
}
func (self *RefreshHelper) loadWorktrees(env refreshEnv) []*models.Worktree {