Run the repo switch on the UI thread

DispatchSwitchTo wrapped its whole body in WithWaitingStatus, so the
switch ran on a worker: it chdirs, reassigns gui.git, and swaps gui.State
(in resetState), all of which the UI thread also reads. The generation
guard prevents the refresh-in-flight logical corruption but not this
pointer data race on gui.State.

Run the switch synchronously on the UI thread instead. Every caller is
already a UI-thread handler except NewWorktreeCheckout, which must create
the worktree (git work) on a worker first; it now dispatches only the
switch via OnUIThread. The heavy data loading still happens
asynchronously via the refresh that onNewRepo triggers, so the
synchronous part is small (a couple of git rev-parse plus direnv).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-07-03 20:46:51 +02:00
parent 3103fe97ea
commit cf7c3d82e6
2 changed files with 43 additions and 37 deletions

View file

@ -13,7 +13,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/direnv"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/env"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
"github.com/jesseduffield/lazygit/pkg/gui/style"
@ -145,52 +144,53 @@ func (self *ReposHelper) DispatchSwitchToRepo(path string, contextKey types.Cont
return self.DispatchSwitchTo(path, self.c.Tr.ErrRepositoryMovedOrDeleted, contextKey)
}
// DispatchSwitchTo switches lazygit to the repository (or worktree) at the
// given path. It runs synchronously on the UI thread: the switch swaps
// gui.State (in resetState) and reassigns gui.git and the process cwd, all of
// which the UI thread also reads, so doing it here rather than on a worker
// avoids racing those reads. The heavy data loading is still dispatched
// asynchronously by the refresh that onNewRepo kicks off.
func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey types.ContextKey) error {
return self.c.WithWaitingStatus(self.c.Tr.Switching, func(gocui.Task) error {
env.UnsetGitLocationEnvVars()
originalPath, err := os.Getwd()
if err != nil {
return nil
env.UnsetGitLocationEnvVars()
originalPath, err := os.Getwd()
if err != nil {
return nil
}
msg := utils.ResolvePlaceholderString(self.c.Tr.ChangingDirectoryTo, map[string]string{"path": path})
self.c.LogCommand(msg, false)
if err := os.Chdir(path); err != nil {
if os.IsNotExist(err) {
return errors.New(errMsg)
}
return err
}
msg := utils.ResolvePlaceholderString(self.c.Tr.ChangingDirectoryTo, map[string]string{"path": path})
self.c.LogCommand(msg, false)
if err := os.Chdir(path); err != nil {
if os.IsNotExist(err) {
return errors.New(errMsg)
}
if err := commands.VerifyInGitRepo(self.c.OS()); err != nil {
if err := os.Chdir(originalPath); err != nil {
return err
}
if err := commands.VerifyInGitRepo(self.c.OS()); err != nil {
if err := os.Chdir(originalPath); err != nil {
return err
}
return err
}
return err
}
direnvResult := self.logDirenvResult(direnv.Load(self.c.OS().Cmd))
direnvResult := self.logDirenvResult(direnv.Load(self.c.OS().Cmd))
if err := self.recordDirectoryHelper.RecordCurrentDirectory(); err != nil {
self.c.Log.Errorf("error recording current directory: %v", err)
}
if err := self.recordDirectoryHelper.RecordCurrentDirectory(); err != nil {
self.c.Log.Errorf("error recording current directory: %v", err)
}
if err := self.onNewRepo(appTypes.StartArgs{}, contextKey); err != nil {
return err
}
if err := self.onNewRepo(appTypes.StartArgs{}, contextKey); err != nil {
return err
}
if direnvResult.Blocked {
self.promptDirenvApproval(direnvResult.EnvrcPath)
return nil
}
if direnvResult.Blocked {
self.c.OnUIThread(func() error {
self.promptDirenvApproval(direnvResult.EnvrcPath)
return nil
})
return nil
}
return direnvResult.Err
})
return direnvResult.Err
}
// logDirenvResult writes whatever direnv emitted to the command log and the

View file

@ -432,6 +432,12 @@ func (self *WorktreeHelper) createWorktree(opts git_commands.NewWorktreeOpts, co
return err
}
return self.reposHelper.DispatchSwitchTo(opts.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, contextKey)
// The switch swaps gui.State and must run on the UI thread, but
// we're on a worker here (creating the worktree is git work), so
// dispatch it rather than calling it directly.
self.c.OnUIThread(func() error {
return self.reposHelper.DispatchSwitchTo(opts.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, contextKey)
})
return nil
})
}