From dd1576138a248b8d643921d7f6d6d5ad7c3fa238 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Jul 2026 22:44:07 +0200 Subject: [PATCH 01/59] AGENTS.md additions --- AGENTS.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 46ad3e506..6aa8f7017 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,9 @@ while still being meaningful and self-contained. commits that leave the tree broken and rely on a follow-up to fix it. - **Every commit must be `gofumpt`-formatted.** Run `just format` before committing. +- **Every commit must be lint-clean.** Run `just lint` before committing — + don't introduce a lint warning in one commit and rely on a later commit + (or the user) to clean it up. - **Commit messages explain _why_, not _what_.** The diff already shows what changed; the message should capture the motivation, the constraint, or the bug being fixed. If the reason is obvious from a one-line subject, no body @@ -157,6 +160,16 @@ genuine forks — the ones where a reasonable person might pick differently, or where you'd be trading away something the plan assumed (scope, UX, performance, reload behavior, …). When in doubt, surface it. +This applies with equal force to unforeseen _discoveries_, not just to +decisions you set out to make. If you find something the plan didn't account +for — a latent bug, a race, a wrong assumption, a case that turns out +unhandled — stop and raise it before designing or writing a fix, even when the +fix seems obvious and even when it's "just correctness." Finding the problem is +itself the fork: whether to fix it here or in a separate change, how generally +to solve it, and whether it reshapes the current work are all calls for me to +make with you. Don't quietly fold a self-directed fix for a newly-found problem +into the branch and let me discover it in the diff. + ## Prefer the cleaner design over the smaller diff When a task could be implemented either by tacking onto existing code or by From 5350b6c37b20258f9024be038f5b3c488534c606 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Jul 2026 22:09:25 +0200 Subject: [PATCH 02/59] Remove unused CommitFileTreeViewModel.RWMutex --- pkg/gui/filetree/commit_file_tree_view_model.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/gui/filetree/commit_file_tree_view_model.go b/pkg/gui/filetree/commit_file_tree_view_model.go index c2e7e74e4..e33316788 100644 --- a/pkg/gui/filetree/commit_file_tree_view_model.go +++ b/pkg/gui/filetree/commit_file_tree_view_model.go @@ -2,7 +2,6 @@ package filetree import ( "strings" - "sync" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/common" @@ -26,7 +25,6 @@ type ICommitFileTreeViewModel interface { } type CommitFileTreeViewModel struct { - sync.RWMutex types.IListCursor ICommitFileTree From b54d4c369bb64415b79e89ad0c5c275a971fd8dc Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Jul 2026 21:45:02 +0200 Subject: [PATCH 03/59] Remove unused IsRefreshingFiles state GetIsRefreshingFiles() is never called anywhere in the codebase, so the flag serves no purpose. Remove it from Gui, StateAccessor, and IStateAccessor, and drop the two SetIsRefreshingFiles calls in refreshFilesAndSubmodules that maintained it. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 6 +----- pkg/gui/gui.go | 10 ---------- pkg/gui/types/common.go | 2 -- 3 files changed, 1 insertion(+), 17 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 3dbd19674..866ec90c3 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -735,11 +735,7 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele func (self *RefreshHelper) refreshFilesAndSubmodules(background bool) error { self.c.Mutexes().RefreshingFilesMutex.Lock() - self.c.State().SetIsRefreshingFiles(true) - defer func() { - self.c.State().SetIsRefreshingFiles(false) - self.c.Mutexes().RefreshingFilesMutex.Unlock() - }() + defer self.c.Mutexes().RefreshingFilesMutex.Unlock() if err := self.refreshStateSubmoduleConfigs(); err != nil { return err diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index e23afd124..87a87f7b2 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -111,8 +111,6 @@ type Gui struct { PopupHandler types.IPopupHandler - IsRefreshingFiles bool - // we use this to decide whether we'll return to the original directory that // lazygit was opened in, or if we'll retain the one we're currently in. RetainOriginalDir bool @@ -175,14 +173,6 @@ func (self *StateAccessor) GetPagerConfig() *config.PagerConfig { return self.gui.pagerConfig } -func (self *StateAccessor) GetIsRefreshingFiles() bool { - return self.gui.IsRefreshingFiles -} - -func (self *StateAccessor) SetIsRefreshingFiles(value bool) { - self.gui.IsRefreshingFiles = value -} - func (self *StateAccessor) GetShowExtrasWindow() bool { return self.gui.ShowExtrasWindow } diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 7621f8686..ad28972ae 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -377,8 +377,6 @@ type IStateAccessor interface { // tells us whether we're currently updating lazygit GetUpdating() bool SetUpdating(bool) - SetIsRefreshingFiles(bool) - GetIsRefreshingFiles() bool GetShowExtrasWindow() bool SetShowExtrasWindow(bool) GetRetainOriginalDir() bool From 717448f105589033f562e17ac195b1a9e4725110 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Jul 2026 22:33:33 +0200 Subject: [PATCH 04/59] Make RefreshOptions.Then a func() error, queue it via OnUIThread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is preparation for upcoming commits that will bounce refresh-scope model updates (e.g. Model.Files) onto the UI thread by enqueuing the write via OnUIThread instead of applying it directly on the worker goroutine. Once that lands, a Then callback that reads the model must run after that queued write has been processed, not synchronously at wg.Wait() time — at that point the workers have returned, but a bounce they queued may not have been processed yet. Queuing Then via OnUIThread here, ahead of that change, guarantees the right ordering once it lands: a bounce queued earlier in the same refresh is already sitting in the channel by the time wg.Wait() returns, so Then enqueued after it will always be processed after, and see the post-refresh model. The signature change to func() error lets Then propagate errors through gocui's normal error handler (the same path key-handler errors take). Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/bisect_controller.go | 7 +++++-- pkg/gui/controllers/filtering_menu_action.go | 3 ++- pkg/gui/controllers/helpers/mode_helper.go | 3 ++- pkg/gui/controllers/helpers/refresh_helper.go | 8 +++++++- pkg/gui/controllers/local_commits_controller.go | 8 +++----- pkg/gui/types/refresh.go | 2 +- 6 files changed, 20 insertions(+), 11 deletions(-) diff --git a/pkg/gui/controllers/bisect_controller.go b/pkg/gui/controllers/bisect_controller.go index 1066237c1..eb568240b 100644 --- a/pkg/gui/controllers/bisect_controller.go +++ b/pkg/gui/controllers/bisect_controller.go @@ -274,10 +274,11 @@ func (self *BisectController) afterMark(selectCurrent bool, waitToReselect bool) } func (self *BisectController) afterBisectMarkRefresh(selectCurrent bool, waitToReselect bool) error { - selectFn := func() { + selectFn := func() error { if selectCurrent { self.selectCurrentBisectCommit() } + return nil } if waitToReselect { @@ -285,7 +286,9 @@ func (self *BisectController) afterBisectMarkRefresh(selectCurrent bool, waitToR return nil } - selectFn() + if err := selectFn(); err != nil { + return err + } self.c.Helpers().Bisect.PostBisectCommandRefresh() return nil diff --git a/pkg/gui/controllers/filtering_menu_action.go b/pkg/gui/controllers/filtering_menu_action.go index 01a236f7a..7ae26c4ef 100644 --- a/pkg/gui/controllers/filtering_menu_action.go +++ b/pkg/gui/controllers/filtering_menu_action.go @@ -122,9 +122,10 @@ func (self *FilteringMenuAction) setFiltering() error { self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) - self.c.Refresh(types.RefreshOptions{Scope: helpers.ScopesToRefreshWhenFilteringModeChanges(), Then: func() { + self.c.Refresh(types.RefreshOptions{Scope: helpers.ScopesToRefreshWhenFilteringModeChanges(), Then: func() error { self.c.Contexts().LocalCommits.SetSelection(0) self.c.Contexts().LocalCommits.HandleFocus(types.OnFocusOpts{}) + return nil }}) return nil diff --git a/pkg/gui/controllers/helpers/mode_helper.go b/pkg/gui/controllers/helpers/mode_helper.go index e44d7b01b..4947e42d1 100644 --- a/pkg/gui/controllers/helpers/mode_helper.go +++ b/pkg/gui/controllers/helpers/mode_helper.go @@ -191,7 +191,7 @@ func (self *ModeHelper) ClearFiltering() error { self.c.Refresh(types.RefreshOptions{ Scope: ScopesToRefreshWhenFilteringModeChanges(), - Then: func() { + Then: func() error { // Find the commit that was last selected in filtering mode, and select it again after refreshing if !self.c.Contexts().LocalCommits.SelectCommitByHash(selectedCommitHash) { // If we couldn't find it (either because no commit was selected @@ -202,6 +202,7 @@ func (self *ModeHelper) ClearFiltering() error { } self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits) + return nil }, }) return nil diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 866ec90c3..2933d9378 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -255,7 +255,13 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { wg.Wait() if options.Then != nil { - options.Then() + // Queue Then via OnUIThread so it runs *after* the refresh-scope + // functions' model-update bounces (which are already queued by + // now), not synchronously here — at this point the workers have + // returned but their bounces haven't been processed yet, so + // invoking Then synchronously would run it on a model that's + // still pre-refresh. + self.c.OnUIThread(options.Then) } } diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 9f3acb1d2..5150e8fba 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -616,7 +616,7 @@ func (self *LocalCommitsController) startInteractiveRebaseWithEdit( err := self.c.Git().Rebase.EditRebase(commitsToEdit[len(commitsToEdit)-1].Hash()) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( err, - types.RefreshOptions{Mode: types.BLOCK_UI, Then: func() { + types.RefreshOptions{Mode: types.BLOCK_UI, Then: func() error { todos := make([]*models.Commit, 0, len(commitsToEdit)-1) for _, c := range commitsToEdit[:len(commitsToEdit)-1] { // Merge commits can't be set to "edit", so just skip them @@ -625,11 +625,9 @@ func (self *LocalCommitsController) startInteractiveRebaseWithEdit( } } if len(todos) > 0 { - err := self.updateTodos(todo.Edit, todos) - if err != nil { - self.c.Log.Errorf("error when updating todos: %v", err) - } + return self.updateTodos(todo.Edit, todos) } + return nil }}) }) } diff --git a/pkg/gui/types/refresh.go b/pkg/gui/types/refresh.go index 8d9704d55..591aff5f3 100644 --- a/pkg/gui/types/refresh.go +++ b/pkg/gui/types/refresh.go @@ -56,7 +56,7 @@ const ( ) type RefreshOptions struct { - Then func() + Then func() error Scope []RefreshableView // e.g. []RefreshableView{COMMITS, BRANCHES}. Leave empty to refresh everything Mode RefreshMode // one of SYNC (default), ASYNC, and BLOCK_UI From ea83f50dc35f5fac893a8bb641efe4b86995f2ee Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Jul 2026 22:01:39 +0200 Subject: [PATCH 05/59] Move post-FILES-refresh model reads into Then PromptToContinueRebase and WithEnsureCommittableFiles both read Model.Files right after a SYNC FILES refresh. This works today because the model write currently happens synchronously in the worker before Refresh's wg.Wait() returns, but an upcoming commit will bounce that write onto the UI thread instead, at which point wg.Wait() no longer guarantees it's been applied. Move both reads into Then ahead of that change. Then is already queued via OnUIThread (previous commit), so this is a behavior-preserving refactor on its own: the model is fully written by the time Then runs either way, whether that write is still synchronous or gets bounced later. As part of restructuring WithEnsureCommittableFiles, prepareFilesForCommit and syncRefresh are inlined into their single call sites. Co-Authored-By: Claude Sonnet 5 --- .../helpers/merge_and_rebase_helper.go | 41 ++++++++++--------- .../helpers/working_tree_helper.go | 39 +++++++----------- 2 files changed, 36 insertions(+), 44 deletions(-) diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 847b89893..c25416a3b 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -309,27 +309,30 @@ func (self *MergeAndRebaseHelper) PromptToContinueRebase() error { // but this is not supported by all terminals or on all platforms. self.c.Refresh(types.RefreshOptions{ Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}, + Then: func() error { + unstagedFiles := GetUnstagedFilesExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) + if len(unstagedFiles) > 0 { + self.c.Confirm(types.ConfirmOpts{ + Title: self.c.Tr.Continue, + Prompt: self.c.Tr.UnstagedFilesAfterConflictsResolved, + HandleConfirm: func() error { + self.c.LogAction(self.c.Tr.Actions.StageAllFiles) + if err := self.c.Git().WorkingTree.StageFiles(unstagedFiles, []string{}); err != nil { + return err + } + + return self.genericMergeCommand(REBASE_OPTION_CONTINUE) + }, + }) + + return nil + } + + return self.genericMergeCommand(REBASE_OPTION_CONTINUE) + }, }) - unstagedFiles := GetUnstagedFilesExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) - if len(unstagedFiles) > 0 { - self.c.Confirm(types.ConfirmOpts{ - Title: self.c.Tr.Continue, - Prompt: self.c.Tr.UnstagedFilesAfterConflictsResolved, - HandleConfirm: func() error { - self.c.LogAction(self.c.Tr.Actions.StageAllFiles) - if err := self.c.Git().WorkingTree.StageFiles(unstagedFiles, []string{}); err != nil { - return err - } - - return self.genericMergeCommand(REBASE_OPTION_CONTINUE) - }, - }) - - return nil - } - - return self.genericMergeCommand(REBASE_OPTION_CONTINUE) + return nil }, }) diff --git a/pkg/gui/controllers/helpers/working_tree_helper.go b/pkg/gui/controllers/helpers/working_tree_helper.go index 7e070ba31..7e321854b 100644 --- a/pkg/gui/controllers/helpers/working_tree_helper.go +++ b/pkg/gui/controllers/helpers/working_tree_helper.go @@ -222,15 +222,24 @@ func (self *WorkingTreeHelper) HandleCommitPress() error { } func (self *WorkingTreeHelper) WithEnsureCommittableFiles(handler func() error) error { - if err := self.prepareFilesForCommit(); err != nil { - return err - } - if len(self.c.Model().Files) == 0 { return errors.New(self.c.Tr.NoFilesStagedTitle) } if !self.AnyStagedFiles() { + if self.c.UserConfig().Gui.SkipNoStagedFilesWarning { + self.c.LogAction(self.c.Tr.Actions.StageAllFiles) + if err := self.c.Git().WorkingTree.StageAll(false); err != nil { + return err + } + self.c.Refresh(types.RefreshOptions{ + Mode: types.SYNC, + Scope: []types.RefreshableView{types.FILES}, + Then: handler, + }) + return nil + } + return self.promptToStageAllAndRetry(handler) } @@ -246,7 +255,7 @@ func (self *WorkingTreeHelper) promptToStageAllAndRetry(retry func() error) erro if err := self.c.Git().WorkingTree.StageAll(false); err != nil { return err } - self.syncRefresh() + self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}}) return retry() }, @@ -255,26 +264,6 @@ func (self *WorkingTreeHelper) promptToStageAllAndRetry(retry func() error) erro return nil } -// for when you need to refetch files before continuing an action. Runs synchronously. -func (self *WorkingTreeHelper) syncRefresh() { - self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}}) -} - -func (self *WorkingTreeHelper) prepareFilesForCommit() error { - noStagedFiles := !self.AnyStagedFiles() - if noStagedFiles && self.c.UserConfig().Gui.SkipNoStagedFilesWarning { - self.c.LogAction(self.c.Tr.Actions.StageAllFiles) - err := self.c.Git().WorkingTree.StageAll(false) - if err != nil { - return err - } - - self.syncRefresh() - } - - return nil -} - func (self *WorkingTreeHelper) commitPrefixConfigsForRepo() []config.CommitPrefixConfig { cfg, ok := self.c.UserConfig().Git.CommitPrefixes[self.c.Git().RepoPaths.RepoName()] if ok { From be897ce55e5466eec66e703d18abd2b213376188 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 14:01:00 +0200 Subject: [PATCH 06/59] Bounce FILES model updates onto the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refreshStateFiles now does its git work on the worker and enqueues a single OnUIThread closure that writes Model.Submodules, Model.Files, and the FileTreeViewModel state together, instead of writing them directly from the worker goroutine. refreshStateSubmoduleConfigs becomes a pure getter (returns the configs; no model write) so the result can be threaded into that same bounce. The STAGING handler wraps RefreshStagingPanel in OnUIThread after fileWg.Wait() so it sees the post-bounce file model rather than the stale pre-refresh one — without this it would race the files bounce queued just above it. Bouncing the write opens a hazard the old synchronous write didn't have: if the user switches repos while this refresh is in flight, the queued closure would fire after resetState has replaced the model with a fresh one for the new repo, silently overwriting it with the previous repo's files. Guard against this with a repo generation: resetState bumps a counter on every switch, refreshStateFiles captures it before its git work, and onUIThreadUnlessRepoChanged drops the bounce if the generation has moved on. This one helper is the general mechanism the remaining scopes' bounces will use too; the same guard covers the rebase-continue prompt, which reads Model.Files right after. A generation counter, not a comparison of the *Model pointer: switching away from and back to a repo reuses that repo's cached state (the same Model pointer), which a pointer comparison would wrongly accept even though the in-flight data is stale. PromptToContinueRebase's Then callback (previous commit) now gets an explanatory comment, since this is the commit that makes it necessary. The explicit locking around these writes (RefreshingFilesMutex in refreshFilesAndSubmodules, FileTreeViewModel.RWMutex around the write in refreshStateFiles) is left in place for now even though it's becoming redundant, to keep this commit focused on the bounce itself; it's removed next. Co-Authored-By: Claude Sonnet 5 --- .../helpers/merge_and_rebase_helper.go | 4 + pkg/gui/controllers/helpers/refresh_helper.go | 84 +++++++++++-------- pkg/gui/gui.go | 15 ++++ pkg/gui/types/common.go | 7 ++ 4 files changed, 77 insertions(+), 33 deletions(-) diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index c25416a3b..51488a922 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -307,6 +307,10 @@ func (self *MergeAndRebaseHelper) PromptToContinueRebase() error { // Need to refresh the files to be really sure if this is the case. // We would otherwise be relying on lazygit's auto-refresh on focus, // but this is not supported by all terminals or on all platforms. + // + // The model.Files update is bounced onto the UI thread, so we have + // to read it in Then; reading it inline here would see the previous + // model. self.c.Refresh(types.RefreshOptions{ Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}, Then: func() error { diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 2933d9378..2f2210f49 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -238,7 +238,14 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { if scopeSet.Includes(types.STAGING) { refresh("staging", func() { fileWg.Wait() - self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{}) + // Bounce onto the UI thread so this runs after the files + // scope's model-update bounce — RefreshStagingPanel reads + // Model.Files (via Files.GetSelected) and would otherwise + // see the pre-refresh model. + self.c.OnUIThread(func() error { + self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{}) + return nil + }) }) } @@ -667,15 +674,8 @@ func (self *RefreshHelper) refreshTags() error { return nil } -func (self *RefreshHelper) refreshStateSubmoduleConfigs() error { - configs, err := self.c.Git().Submodule.GetConfigs(nil) - if err != nil { - return err - } - - self.c.Model().Submodules = configs - - return nil +func (self *RefreshHelper) refreshStateSubmoduleConfigs() ([]*models.SubmoduleConfig, error) { + return self.c.Git().Submodule.GetConfigs(nil) } // self.refreshStatus is called at the end of this because that's when we can @@ -743,25 +743,40 @@ func (self *RefreshHelper) refreshFilesAndSubmodules(background bool) error { self.c.Mutexes().RefreshingFilesMutex.Lock() defer self.c.Mutexes().RefreshingFilesMutex.Unlock() - if err := self.refreshStateSubmoduleConfigs(); err != nil { + configs, err := self.refreshStateSubmoduleConfigs() + if err != nil { return err } - if err := self.refreshStateFiles(background); err != nil { + if err := self.refreshStateFiles(background, configs); err != nil { return err } - self.c.OnUIThread(func() error { - self.refreshView(self.c.Contexts().Submodules) - self.refreshView(self.c.Contexts().Files) - return nil - }) + self.refreshView(self.c.Contexts().Submodules) + self.refreshView(self.c.Contexts().Files) return nil } -func (self *RefreshHelper) refreshStateFiles(background bool) error { +// onUIThreadUnlessRepoChanged bounces a refresh's model/view update onto the UI +// thread, but drops it if the repo was switched while the refresh was in flight. +// Refresh workers do their git work off the UI thread and enqueue their model +// writes here; a repo switch (which replaces the whole model and context tree) +// bumps the generation, so a write captured under the old generation must not +// clobber the new repo's state. Callers capture the generation with +// State().GetRepoGeneration() before doing their git work and pass it in. +func (self *RefreshHelper) onUIThreadUnlessRepoChanged(generation int, f func() error) { + self.c.OnUIThread(func() error { + if self.c.State().GetRepoGeneration() != generation { + return nil + } + return f() + }) +} + +func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs []*models.SubmoduleConfig) error { fileTreeViewModel := self.c.Contexts().Files.FileTreeViewModel + generation := self.c.State().GetRepoGeneration() prevConflictFileCount := 0 if self.c.UserConfig().Git.AutoStageResolvedConflicts { @@ -822,7 +837,9 @@ func (self *RefreshHelper) refreshStateFiles(background bool) error { // (e.g. in the user's editor). Offer to continue it. We only do this // for operations we started ourselves; prompting for one that was // started outside lazygit (e.g. by a coding agent) would be confusing. - self.c.OnUIThread(func() error { return self.mergeAndRebaseHelper.PromptToContinueRebase() }) + self.onUIThreadUnlessRepoChanged(generation, func() error { + return self.mergeAndRebaseHelper.PromptToContinueRebase() + }) } } else { // Either there's no operation in progress any more, or new conflicts have @@ -835,22 +852,23 @@ func (self *RefreshHelper) refreshStateFiles(background bool) error { }) } - fileTreeViewModel.RWMutex.Lock() - - // only taking over the filter if it hasn't already been set by the user. - if conflictFileCount > 0 && prevConflictFileCount == 0 { - if fileTreeViewModel.GetStatusFilter() == filetree.DisplayAll { - fileTreeViewModel.SetStatusFilter(filetree.DisplayConflicted) - self.c.Contexts().Files.GetView().Subtitle = self.c.Tr.FilterLabelConflictingFiles + self.onUIThreadUnlessRepoChanged(generation, func() error { + // only taking over the filter if it hasn't already been set by the user. + if conflictFileCount > 0 && prevConflictFileCount == 0 { + if fileTreeViewModel.GetStatusFilter() == filetree.DisplayAll { + fileTreeViewModel.SetStatusFilter(filetree.DisplayConflicted) + self.c.Contexts().Files.GetView().Subtitle = self.c.Tr.FilterLabelConflictingFiles + } + } else if conflictFileCount == 0 && fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted { + fileTreeViewModel.SetStatusFilter(filetree.DisplayAll) + self.c.Contexts().Files.GetView().Subtitle = "" } - } else if conflictFileCount == 0 && fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted { - fileTreeViewModel.SetStatusFilter(filetree.DisplayAll) - self.c.Contexts().Files.GetView().Subtitle = "" - } - self.c.Model().Files = files - fileTreeViewModel.SetTree() - fileTreeViewModel.RWMutex.Unlock() + self.c.Model().Submodules = submoduleConfigs + self.c.Model().Files = files + fileTreeViewModel.SetTree() + return nil + }) return nil } diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 87a87f7b2..834cf1e2d 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -12,6 +12,7 @@ import ( "sort" "strings" "sync" + "sync/atomic" "time" "github.com/jesseduffield/lazycore/pkg/boxlayout" @@ -111,6 +112,11 @@ type Gui struct { PopupHandler types.IPopupHandler + // Bumped every time we switch to a different repository (in resetState). + // Used to drop refresh results that were computed for a repo we've since + // navigated away from. See RefreshHelper.onUIThreadUnlessRepoChanged. + repoGeneration atomic.Int32 + // we use this to decide whether we'll return to the original directory that // lazygit was opened in, or if we'll retain the one we're currently in. RetainOriginalDir bool @@ -169,6 +175,10 @@ func (self *StateAccessor) GetRepoState() types.IRepoStateAccessor { return self.gui.State } +func (self *StateAccessor) GetRepoGeneration() int { + return int(self.gui.repoGeneration.Load()) +} + func (self *StateAccessor) GetPagerConfig() *config.PagerConfig { return self.gui.pagerConfig } @@ -575,6 +585,11 @@ func (gui *Gui) checkForChangedConfigsThatDontAutoReload(oldConfig *config.UserC // resetState reuses the repo state from our repo state map, if the repo was // open before; otherwise it creates a new one. func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context { + // Bump the repo generation so that any refresh still in flight for the + // previous repo drops its model update instead of applying it here (see + // RefreshHelper.onUIThreadUnlessRepoChanged). + gui.repoGeneration.Add(1) + // Un-highlight the current view if there is one. The reason we do this is // that the repo we are switching to might have a different view focused, // and would then show an inactive highlight for the previous view. diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index ad28972ae..7fc75aee0 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -384,6 +384,13 @@ type IStateAccessor interface { GetItemOperation(item HasUrn) ItemOperation SetItemOperation(item HasUrn, operation ItemOperation) ClearItemOperation(item HasUrn) + + // A counter that is bumped every time we switch to a different repository + // (see Gui.resetState). Refresh workers capture it before doing their git + // work and pass it to onUIThreadUnlessRepoChanged, so that a model update + // computed for one repo can be dropped rather than applied to another if the + // user switched repos while the refresh was in flight. + GetRepoGeneration() int } type IRepoStateAccessor interface { From 2c139b6ac173842f88d03392017a3eecd5f05cc5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 14:01:45 +0200 Subject: [PATCH 07/59] Remove RefreshingFilesMutex/FileTreeViewModel.RWMutex, dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FileTreeViewModel.RWMutex is removed along with the withFileTreeViewModelMutex wrapper in FilesController that RLocked it: every writer (the bounce closure, previous commit) and every reader (key handlers, disabled-reason callbacks) now runs on the UI thread, so the mutex is redundant. RefreshingFilesMutex is removed entirely, including its last use in repos_helper's DispatchSwitchTo. That use predates the bounce and was never about FilesController's optimistic-rendering concern; it serialized a repo switch's onNewRepo() against an in-flight FILES refresh for the repo being switched away from, so that a slow refresh from the old repo couldn't write into the freshly-reset model for the new one. Bouncing the write already broke that guarantee on its own terms — the mutex's critical section never covered the bounced closure's actual execution, only the (now-removed) code that enqueued it — so by this point it was only still locked here without protecting anything real; the previous commit's repo-generation guard is what now actually closes that race, making this lock fully redundant rather than just relocated. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/files_controller.go | 25 +++---------------- pkg/gui/controllers/helpers/refresh_helper.go | 3 --- pkg/gui/controllers/helpers/repos_helper.go | 3 --- pkg/gui/filetree/file_tree_view_model.go | 2 -- pkg/gui/types/common.go | 1 - 5 files changed, 4 insertions(+), 30 deletions(-) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index a63c6a15a..d7720ee34 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -44,7 +44,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types { Keys: opts.GetKeys(opts.Config.Universal.Select), Handler: self.withItems(self.press), - GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected(self.canStageSelection))), + GetDisabledReason: self.require(self.itemsSelected(self.canStageSelection)), Description: self.c.Tr.Stage, Tooltip: self.c.Tr.StageTooltip, DisplayOnScreen: true, @@ -91,7 +91,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types { Keys: opts.GetKeys(opts.Config.Universal.Edit), Handler: self.withItems(self.edit), - GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected(self.canEditFiles))), + GetDisabledReason: self.require(self.itemsSelected(self.canEditFiles)), Description: self.c.Tr.Edit, Tooltip: self.c.Tr.EditFileTooltip, DisplayOnScreen: true, @@ -145,7 +145,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types { Keys: opts.GetKeys(opts.Config.Universal.Remove), Handler: self.withItems(self.remove), - GetDisabledReason: self.withFileTreeViewModelMutex(self.require(self.itemsSelected(self.canRemove))), + GetDisabledReason: self.require(self.itemsSelected(self.canRemove)), Description: self.c.Tr.Discard, Tooltip: self.c.Tr.DiscardFileChangesTooltip, OpensMenu: true, @@ -182,7 +182,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types Handler: self.withItems(self.openMergeConflictMenu), Description: self.c.Tr.ViewMergeConflictOptions, Tooltip: self.c.Tr.ViewMergeConflictOptionsTooltip, - GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected(self.canOpenMergeConflictMenu))), + GetDisabledReason: self.require(self.itemsSelected(self.canOpenMergeConflictMenu)), OpensMenu: true, DisplayOnScreen: true, }, @@ -209,15 +209,6 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types } } -func (self *FilesController) withFileTreeViewModelMutex(callback func() *types.DisabledReason) func() *types.DisabledReason { - return func() *types.DisabledReason { - self.c.Contexts().Files.FileTreeViewModel.RWMutex.RLock() - defer self.c.Contexts().Files.FileTreeViewModel.RWMutex.RUnlock() - - return callback() - } -} - func (self *FilesController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { return []*gocui.ViewMouseBinding{ { @@ -574,11 +565,6 @@ func (self *FilesController) toggleStaged( } func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) error { - // Obtaining this lock because optimistic rendering requires us to mutate - // the files in our model. - self.c.Mutexes().RefreshingFilesMutex.Lock() - defer self.c.Mutexes().RefreshingFilesMutex.Unlock() - // When filtering, expand directory nodes to individual visible file paths // so that only filtered files are staged/unstaged. toPaths := func(nodes []*filetree.FileNode) []string { @@ -942,9 +928,6 @@ func (self *FilesController) toggleStagedAll() error { } func (self *FilesController) toggleStagedAllWithLock() error { - self.c.Mutexes().RefreshingFilesMutex.Lock() - defer self.c.Mutexes().RefreshingFilesMutex.Unlock() - root := self.context().FileTreeViewModel.GetRoot() stage := func(unstagedNodes []*filetree.FileNode) error { diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 2f2210f49..875f55a30 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -740,9 +740,6 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele } func (self *RefreshHelper) refreshFilesAndSubmodules(background bool) error { - self.c.Mutexes().RefreshingFilesMutex.Lock() - defer self.c.Mutexes().RefreshingFilesMutex.Unlock() - configs, err := self.refreshStateSubmoduleConfigs() if err != nil { return err diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index bde1c47c6..94c9e4368 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -177,9 +177,6 @@ func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey self.c.Log.Errorf("error recording current directory: %v", err) } - self.c.Mutexes().RefreshingFilesMutex.Lock() - defer self.c.Mutexes().RefreshingFilesMutex.Unlock() - if err := self.onNewRepo(appTypes.StartArgs{}, contextKey); err != nil { return err } diff --git a/pkg/gui/filetree/file_tree_view_model.go b/pkg/gui/filetree/file_tree_view_model.go index 741550c19..aabbbce7f 100644 --- a/pkg/gui/filetree/file_tree_view_model.go +++ b/pkg/gui/filetree/file_tree_view_model.go @@ -2,7 +2,6 @@ package filetree import ( "strings" - "sync" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/common" @@ -22,7 +21,6 @@ type IFileTreeViewModel interface { // which item is selected. It also contains logic for repositioning that cursor // after the files are refreshed type FileTreeViewModel struct { - sync.RWMutex types.IListCursor IFileTree searchHistory *utils.HistoryBuffer[string] diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 7fc75aee0..2b7dc2312 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -338,7 +338,6 @@ type Model struct { } type Mutexes struct { - RefreshingFilesMutex deadlock.Mutex RefreshingBranchesMutex deadlock.Mutex RefreshingStatusMutex deadlock.Mutex RefreshingPullRequestsMutex deadlock.Mutex From 6203a4e41119d04c738a5f2aa8fca7e2de71904d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Jul 2026 22:07:16 +0200 Subject: [PATCH 08/59] Move post-COMMIT_FILES-refresh work into Then SwitchToDiffFilesController.enter calls SelectPath and Context.Push right after a (SYNC, by default) COMMIT_FILES refresh. This works today because the model write currently happens synchronously in the worker before Refresh's wg.Wait() returns, but an upcoming commit will bounce that write onto the UI thread instead, at which point wg.Wait() no longer guarantees it's been applied, and SelectPath would operate on a stale tree. Move both calls into Then ahead of that change, for the same reason as the earlier FILES-scope commit: Then is already queued via OnUIThread, so this is behavior-preserving on its own. Co-Authored-By: Claude Sonnet 5 --- .../switch_to_diff_files_controller.go | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/pkg/gui/controllers/switch_to_diff_files_controller.go b/pkg/gui/controllers/switch_to_diff_files_controller.go index c2ff4d674..afdf92c80 100644 --- a/pkg/gui/controllers/switch_to_diff_files_controller.go +++ b/pkg/gui/controllers/switch_to_diff_files_controller.go @@ -90,18 +90,19 @@ func (self *SwitchToDiffFilesController) enter() error { self.c.Refresh(types.RefreshOptions{ Scope: []types.RefreshableView{types.COMMIT_FILES}, + Then: func() error { + if filterPath := self.c.Modes().Filtering.GetPath(); filterPath != "" { + path, err := filepath.Rel(self.c.Git().RepoPaths.RepoPath(), filterPath) + if err != nil { + path = filterPath + } + commitFilesContext.CommitFileTreeViewModel.SelectPath( + filepath.ToSlash(path), self.c.UserConfig().Gui.ShowRootItemInFileTree) + } + self.c.Context().Push(commitFilesContext, types.OnFocusOpts{}) + return nil + }, }) - - if filterPath := self.c.Modes().Filtering.GetPath(); filterPath != "" { - path, err := filepath.Rel(self.c.Git().RepoPaths.RepoPath(), filterPath) - if err != nil { - path = filterPath - } - commitFilesContext.CommitFileTreeViewModel.SelectPath( - filepath.ToSlash(path), self.c.UserConfig().Gui.ShowRootItemInFileTree) - } - - self.c.Context().Push(commitFilesContext, types.OnFocusOpts{}) return nil } From b203ec57acdc8c1bd930c23c459f587626dcfee3 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 14:04:44 +0200 Subject: [PATCH 09/59] Bounce COMMIT_FILES model updates onto the UI thread refreshCommitFilesContext now enqueues the Model.CommitFiles write and CommitFileTreeViewModel.SetTree() call via OnUIThread, instead of running them directly on the worker goroutine that drives async refreshes. This is what makes moving SwitchToDiffFilesController's post-refresh work into Then (previous commit) actually necessary, rather than just future-proofing. Same repo-switch hazard as the FILES bounce, closed the same way: it captures the repo generation before the git work and bounces through onUIThreadUnlessRepoChanged, so the write is dropped if the user switched repos while it was in flight. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 875f55a30..2fda422bc 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -635,14 +635,17 @@ func (self *RefreshHelper) RefreshAuthors(commits []*models.Commit) { func (self *RefreshHelper) refreshCommitFilesContext() error { from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) + generation := self.c.State().GetRepoGeneration() files, err := self.c.Git().Loaders.CommitFileLoader.GetFilesInDiff(from, to, reverse) if err != nil { return err } - self.c.Model().CommitFiles = files - self.c.Contexts().CommitFiles.CommitFileTreeViewModel.SetTree() - + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.Model().CommitFiles = files + self.c.Contexts().CommitFiles.CommitFileTreeViewModel.SetTree() + return nil + }) self.refreshView(self.c.Contexts().CommitFiles) return nil } From 21f1dc336670da1c678369a1aa09fe867b2ffb48 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 14:21:31 +0200 Subject: [PATCH 10/59] Bounce TAGS model updates onto the UI thread refreshTags now captures the repo generation, loads the tags on the worker, and writes Model.Tags in an onUIThreadUnlessRepoChanged bounce rather than directly from the worker goroutine. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 2fda422bc..e610fef13 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -666,12 +666,17 @@ func (self *RefreshHelper) refreshRebaseCommits() error { } func (self *RefreshHelper) refreshTags() error { + generation := self.c.State().GetRepoGeneration() + tags, err := self.c.Git().Loaders.TagLoader.GetTags() if err != nil { return err } - self.c.Model().Tags = tags + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.Model().Tags = tags + return nil + }) self.refreshView(self.c.Contexts().Tags) return nil From ff7ecf2d2a427f030da7b80e6e3307461ea975be Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 14:21:51 +0200 Subject: [PATCH 11/59] Bounce STASH model updates onto the UI thread refreshStashEntries now loads the stash entries on the worker and writes Model.StashEntries in an onUIThreadUnlessRepoChanged bounce. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index e610fef13..2076e45ee 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -979,9 +979,16 @@ func (self *RefreshHelper) refreshWorktrees() { } func (self *RefreshHelper) refreshStashEntries() { - self.c.Model().StashEntries = self.c.Git().Loaders.StashLoader. + generation := self.c.State().GetRepoGeneration() + + stashEntries := self.c.Git().Loaders.StashLoader. GetStashEntries(self.c.Modes().Filtering.GetPath()) + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.Model().StashEntries = stashEntries + return nil + }) + self.refreshView(self.c.Contexts().Stash) } From d6f6d0ceba534cd904773b26aaba6c8e5d40fec4 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 14:23:22 +0200 Subject: [PATCH 12/59] Bounce WORKTREES model updates onto the UI thread refreshWorktrees now writes Model.Worktrees in an onUIThreadUnlessRepoChanged bounce. loadWorktrees becomes a pure loader that returns the worktrees instead of writing them, since it's shared with refreshBranches; refreshWorktrees bounces the result, and the branches call site writes it directly for now (that write moves into refreshBranches's own bounce when that scope is migrated). Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 2076e45ee..d1c1091da 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -719,7 +719,10 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele self.rebuildPullRequestsMap() if refreshWorktrees { - self.loadWorktrees() + // TODO: this synchronous worker write goes away when refreshBranches is + // itself migrated to bouncing; for now it matches the rest of this + // not-yet-bounced function. + self.c.Model().Worktrees = self.loadWorktrees() self.refreshView(self.c.Contexts().Worktrees) } @@ -959,18 +962,24 @@ func (self *RefreshHelper) refreshRemotes() error { return nil } -func (self *RefreshHelper) loadWorktrees() { +func (self *RefreshHelper) loadWorktrees() []*models.Worktree { worktrees, err := self.c.Git().Loaders.Worktrees.GetWorktrees() if err != nil { self.c.Log.Error(err) - self.c.Model().Worktrees = []*models.Worktree{} - } else { - self.c.Model().Worktrees = worktrees + return []*models.Worktree{} } + return worktrees } func (self *RefreshHelper) refreshWorktrees() { - self.loadWorktrees() + generation := self.c.State().GetRepoGeneration() + + worktrees := self.loadWorktrees() + + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.Model().Worktrees = worktrees + return nil + }) // need to refresh branches because the branches view shows worktrees against // branches From 559b4bf298268871a8fcd07a4bedc7fad9a6756e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 14:24:20 +0200 Subject: [PATCH 13/59] Bounce REBASE_COMMITS model updates onto the UI thread refreshRebaseCommits now computes the merged rebasing commits and working tree state on the worker and writes Model.Commits / WorkingTreeStateAtLastCommitRefresh in an onUIThreadUnlessRepoChanged bounce. LocalCommitsMutex is left in place for now; it's shared with the commits and branches refreshes and comes out once they're all bounced. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index d1c1091da..4d511ed78 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -654,12 +654,19 @@ func (self *RefreshHelper) refreshRebaseCommits() error { self.c.Mutexes().LocalCommitsMutex.Lock() defer self.c.Mutexes().LocalCommitsMutex.Unlock() + generation := self.c.State().GetRepoGeneration() + updatedCommits, err := self.c.Git().Loaders.CommitLoader.MergeRebasingCommits(self.c.Model().HashPool, self.c.Model().Commits) if err != nil { return err } - self.c.Model().Commits = updatedCommits - self.c.Model().WorkingTreeStateAtLastCommitRefresh = self.c.Git().Status.WorkingTreeState() + workingTreeState := self.c.Git().Status.WorkingTreeState() + + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.Model().Commits = updatedCommits + self.c.Model().WorkingTreeStateAtLastCommitRefresh = workingTreeState + return nil + }) self.refreshView(self.c.Contexts().LocalCommits) return nil From 0f85c2b2b4085d718ba5abd2d207a4f0f5a7eff9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 14:25:10 +0200 Subject: [PATCH 14/59] Bounce SUB_COMMITS model updates onto the UI thread refreshSubCommitsWithLimit now loads the sub-commits on the worker and writes Model.SubCommits (and folds their authors into Model.Authors via RefreshAuthors) inside an onUIThreadUnlessRepoChanged bounce. SubCommitsMutex and AuthorsMutex are left in place: the former is shared with setSubCommits, the latter with the commits refresh's RefreshAuthors call, so both come out only once those other writers are on the UI thread too. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 4d511ed78..67b8e86f1 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -594,6 +594,8 @@ func (self *RefreshHelper) refreshSubCommitsWithLimit() error { self.c.Mutexes().SubCommitsMutex.Lock() defer self.c.Mutexes().SubCommitsMutex.Unlock() + generation := self.c.State().GetRepoGeneration() + commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( git_commands.GetCommitsOptions{ Limit: self.c.Contexts().SubCommits.GetLimitCommits(), @@ -610,8 +612,11 @@ func (self *RefreshHelper) refreshSubCommitsWithLimit() error { if err != nil { return err } - self.c.Model().SubCommits = commits - self.RefreshAuthors(commits) + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.Model().SubCommits = commits + self.RefreshAuthors(commits) + return nil + }) self.refreshView(self.c.Contexts().SubCommits) return nil From db5eb6fd3964cd5a5b9fad5d7fe59d9c49797407 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 14:27:35 +0200 Subject: [PATCH 15/59] Bounce REMOTES model updates onto the UI thread refreshRemotes now loads the remotes on the worker and writes Model.Remotes, rebuilds the pull-requests map, and updates the selected remote's RemoteBranches inside an onUIThreadUnlessRepoChanged bounce. RemotesController.addAndCheckoutRemote read Model.Remotes right after its SYNC REMOTES refresh to select the newly-added remote; since that write now bounces, the selection (and the follow-up fetch) move into Then so they run against the post-refresh model rather than the stale one. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 34 +++++++++++-------- pkg/gui/controllers/remotes_controller.go | 32 +++++++++-------- 2 files changed, 37 insertions(+), 29 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 67b8e86f1..4811e026f 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -942,6 +942,7 @@ func (self *RefreshHelper) refreshReflogCommits() error { } func (self *RefreshHelper) refreshRemotes() error { + generation := self.c.State().GetRepoGeneration() prevSelectedRemote := self.c.Contexts().Remotes.GetSelected() remotes, err := self.c.Git().Loaders.RemoteLoader.GetRemotes() @@ -949,25 +950,28 @@ func (self *RefreshHelper) refreshRemotes() error { return err } - self.c.Model().Remotes = remotes + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.Model().Remotes = remotes - hadPrs := len(self.c.Model().PullRequestsMap) != 0 - self.rebuildPullRequestsMap() - if !hadPrs && len(self.c.Model().PullRequestsMap) != 0 { - // 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) - } + hadPrs := len(self.c.Model().PullRequestsMap) != 0 + self.rebuildPullRequestsMap() + if !hadPrs && len(self.c.Model().PullRequestsMap) != 0 { + // 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) + } - // 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 { - if remote.Name == prevSelectedRemote.Name { - self.c.Model().RemoteBranches = remote.Branches - break + // 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 { + if remote.Name == prevSelectedRemote.Name { + self.c.Model().RemoteBranches = remote.Branches + break + } } } - } + return nil + }) self.refreshView(self.c.Contexts().Remotes) self.refreshView(self.c.Contexts().RemoteBranches) diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index f7b16e228..e4f606ca3 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -156,24 +156,28 @@ func (self *RemotesController) addAndCheckoutRemote(remoteName string, remoteUrl 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. + // 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) + }, }) - - // 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). From bd47106d03e60f14c72fe001805a3882d921b601 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 15:17:43 +0200 Subject: [PATCH 16/59] Thread reflog commits explicitly into the branches load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BranchLoader.Load reads the reflog commits to sort branches by recency. Today it reads them straight from Model.ReflogCommits, which works because in the recency path the reflog refresh writes that field synchronously just before the branches refresh reads it (same goroutine, sequential). An upcoming commit bounces the reflog model write onto the UI thread, at which point Model.ReflogCommits wouldn't be updated yet when branches runs — branches would sort by the previous refresh's reflog. To decouple the branches load from *when* that write lands, pass the reflog commits to refreshBranches explicitly: refreshReflogCommits now returns the commits it loaded, and the recency path hands them straight to refreshBranches. The non-recency path (branches and reflog run concurrently, as before) keeps passing Model.ReflogCommits. Pure refactor: behavior is identical, since the value passed is exactly what Load read from the model before. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 45 +++++++++++++------ 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 4811e026f..7cd8b6a59 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -179,10 +179,13 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { } else { branchesAndRemotesWg.Add(1) refresh("branches", func() { - self.refreshBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex, true) + // Not a recency sort, so branches doesn't depend on the reflog + // being fresh; it runs concurrently with the reflog refresh + // below and reads whatever's in the model, as it always has. + self.refreshBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex, true, self.c.Model().ReflogCommits) branchesAndRemotesWg.Done() }) - refresh("reflog", func() { _ = self.refreshReflogCommits() }) + refresh("reflog", func() { _, _ = self.refreshReflogCommits() }) } } else if scopeSet.Includes(types.REBASE_COMMITS) { // the above block handles rebase commits so we only need to call this one @@ -378,27 +381,38 @@ func getModeName(mode types.RefreshMode) string { // on startup to sort the branches by recency. So we have two phases: INITIAL, and COMPLETE. // In the initial phase we don't get any reflog commits, but we asynchronously get them // and refresh the branches after that -func (self *RefreshHelper) refreshReflogCommitsConsideringStartup() { +// refreshReflogCommitsConsideringStartup returns the reflog commits that the +// caller should hand to refreshBranches for recency sorting. In the COMPLETE +// (normal) case that's the freshly-loaded reflog; in the INITIAL case the +// reflog is loaded asynchronously (and drives its own branches refresh once +// ready), so we return the current model value for the immediate, +// non-recency-sorted branches refresh the caller does in the meantime. +func (self *RefreshHelper) refreshReflogCommitsConsideringStartup() []*models.Commit { switch self.c.State().GetRepoState().GetStartupStage() { case types.INITIAL: self.c.OnWorker(func(_ gocui.Task) error { - _ = self.refreshReflogCommits() - self.refreshBranches(false, true, true) + reflogCommits, _ := self.refreshReflogCommits() + self.refreshBranches(false, true, true, reflogCommits) self.c.State().GetRepoState().SetStartupStage(types.COMPLETE) return nil }) + return self.c.Model().ReflogCommits + case types.COMPLETE: - _ = self.refreshReflogCommits() + reflogCommits, _ := self.refreshReflogCommits() + return reflogCommits } + + return self.c.Model().ReflogCommits } func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, keepBranchSelectionIndex bool) { loadBehindCounts := self.c.State().GetRepoState().GetStartupStage() == types.COMPLETE - self.refreshReflogCommitsConsideringStartup() + reflogCommits := self.refreshReflogCommitsConsideringStartup() - self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, loadBehindCounts) + self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, loadBehindCounts, reflogCommits) } func (self *RefreshHelper) refreshCommitsAndCommitFiles(commitSelection types.CommitSelectionBehavior) { @@ -700,12 +714,12 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs() ([]*models.SubmoduleCo // self.refreshStatus is called at the end of this because that's when we can // be sure there is a State.Model.Branches array to pick the current branch from -func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSelectionIndex bool, loadBehindCounts bool) { +func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSelectionIndex bool, loadBehindCounts bool, reflogCommits []*models.Commit) { self.c.Mutexes().RefreshingBranchesMutex.Lock() defer self.c.Mutexes().RefreshingBranchesMutex.Unlock() branches, err := self.c.Git().Loaders.BranchLoader.Load( - self.c.Model().ReflogCommits, + reflogCommits, self.c.Model().MainBranches, self.c.Model().Branches, loadBehindCounts, @@ -900,7 +914,10 @@ func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs [ // This method also manages two things: ReflogCommits and FilteredReflogCommits. // FilteredReflogCommits are rendered in the reflogs panel, and ReflogCommits // are used by the branches panel to obtain recency values for sorting. -func (self *RefreshHelper) refreshReflogCommits() error { +// refreshReflogCommits returns the (non-filtered) ReflogCommits it loaded, so +// that a subsequent branches refresh can use them for recency sorting without +// having to read them back out of the model. +func (self *RefreshHelper) refreshReflogCommits() ([]*models.Commit, error) { // pulling state into its own variable in case it gets swapped out for another state // and we get an out of bounds exception model := self.c.Model() @@ -926,19 +943,19 @@ func (self *RefreshHelper) refreshReflogCommits() error { } if err := refresh(&model.ReflogCommits, "", ""); err != nil { - return err + return nil, err } if self.c.Modes().Filtering.Active() { if err := refresh(&model.FilteredReflogCommits, self.c.Modes().Filtering.GetPath(), self.c.Modes().Filtering.GetAuthor()); err != nil { - return err + return nil, err } } else { model.FilteredReflogCommits = model.ReflogCommits } self.refreshView(self.c.Contexts().ReflogCommits) - return nil + return model.ReflogCommits, nil } func (self *RefreshHelper) refreshRemotes() error { From 063bba6b45ac0d8da36997f29d1c5599c0f6081b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 15:19:34 +0200 Subject: [PATCH 17/59] Bounce REFLOG model updates onto the UI thread refreshReflogCommits now does the git fetch on the worker and computes the new ReflogCommits / FilteredReflogCommits values (still reading the existing slices for the incremental prepend), then writes them in an onUIThreadUnlessRepoChanged bounce. The freshly-computed reflog is still returned for the branches load, so recency sorting is unaffected by the write now landing on the UI thread (see the previous commit). Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 7cd8b6a59..fc405c6bf 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -918,44 +918,53 @@ func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs [ // that a subsequent branches refresh can use them for recency sorting without // having to read them back out of the model. func (self *RefreshHelper) refreshReflogCommits() ([]*models.Commit, error) { + generation := self.c.State().GetRepoGeneration() // pulling state into its own variable in case it gets swapped out for another state // and we get an out of bounds exception model := self.c.Model() - refresh := func(stateCommits *[]*models.Commit, filterPath string, filterAuthor string) error { + // load does the git work on the worker and returns the new value for a + // reflog slice, reading the existing slice for the incremental fetch. The + // caller writes the result in the bounce. + load := func(existing []*models.Commit, filterPath string, filterAuthor string) ([]*models.Commit, error) { var lastReflogCommit *models.Commit - if filterPath == "" && filterAuthor == "" && len(*stateCommits) > 0 { - lastReflogCommit = (*stateCommits)[0] + if filterPath == "" && filterAuthor == "" && len(existing) > 0 { + lastReflogCommit = existing[0] } commits, onlyObtainedNewReflogCommits, err := self.c.Git().Loaders.ReflogCommitLoader. - GetReflogCommits(self.c.Model().HashPool, lastReflogCommit, filterPath, filterAuthor) + GetReflogCommits(model.HashPool, lastReflogCommit, filterPath, filterAuthor) if err != nil { - return err + return nil, err } if onlyObtainedNewReflogCommits { - *stateCommits = append(commits, *stateCommits...) - } else { - *stateCommits = commits + return append(commits, existing...), nil } - return nil + return commits, nil } - if err := refresh(&model.ReflogCommits, "", ""); err != nil { + reflogCommits, err := load(model.ReflogCommits, "", "") + if err != nil { return nil, err } + filteredReflogCommits := reflogCommits if self.c.Modes().Filtering.Active() { - if err := refresh(&model.FilteredReflogCommits, self.c.Modes().Filtering.GetPath(), self.c.Modes().Filtering.GetAuthor()); err != nil { + filteredReflogCommits, err = load(model.FilteredReflogCommits, self.c.Modes().Filtering.GetPath(), self.c.Modes().Filtering.GetAuthor()) + if err != nil { return nil, err } - } else { - model.FilteredReflogCommits = model.ReflogCommits } + self.onUIThreadUnlessRepoChanged(generation, func() error { + model.ReflogCommits = reflogCommits + model.FilteredReflogCommits = filteredReflogCommits + return nil + }) + self.refreshView(self.c.Contexts().ReflogCommits) - return model.ReflogCommits, nil + return reflogCommits, nil } func (self *RefreshHelper) refreshRemotes() error { From 4c9fdc42213eb4a7953c3e24c07140ec70c9e05f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 15:21:20 +0200 Subject: [PATCH 18/59] Bounce STATUS view update onto the UI thread refreshStatus computes the status line on the calling goroutine (as before) but now writes it to the status view in an onUIThreadUnlessRepoChanged bounce rather than calling SetViewContent directly from the worker. RefreshingStatusMutex is left in place for now; it only guards the compute phase between concurrent callers and comes out in the mutex cleanup. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index fc405c6bf..083907881 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1048,6 +1048,8 @@ func (self *RefreshHelper) refreshStatus() { self.c.Mutexes().RefreshingStatusMutex.Lock() defer self.c.Mutexes().RefreshingStatusMutex.Unlock() + generation := self.c.State().GetRepoGeneration() + currentBranch := self.refsHelper.GetCheckedOutRef() if currentBranch == nil { // need to wait for branches to refresh @@ -1061,7 +1063,10 @@ func (self *RefreshHelper) refreshStatus() { status := presentation.FormatStatus(repoName, currentBranch, types.ItemOperationNone, linkedWorktreeName, workingTreeState, self.c.Tr, self.c.UserConfig()) - self.c.SetViewContent(self.c.Views().Status, status) + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.SetViewContent(self.c.Views().Status, status) + return nil + }) } func (self *RefreshHelper) refForLog() string { From 4c3f8b51ea57787a407f104e110c35ee2093e5ce Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 15:25:04 +0200 Subject: [PATCH 19/59] Bounce PULL_REQUESTS model updates onto the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refreshGithubPullRequests and setGithubPullRequests now do their network work on the worker and write Model.PullRequests / PullRequestsMap in an onUIThreadUnlessRepoChanged bounce (the "no github remotes" and "no base remote" early-returns clear them the same way). rebuildPullRequestsMap moves into the bounce so the map is built from Model.Branches and Model.Remotes as they stand on the UI thread — after those scopes' refreshes have applied their own bounces — rather than from whatever the worker happened to see. The remaining worker-side reads of Model.Branches (to pick which upstream branches to query) are the same not-yet-addressed worker-read race that applies to the other bounced scopes. RefreshingPullRequestsMutex is left in place for the mutex cleanup. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 083907881..917a58ecf 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1115,17 +1115,25 @@ func (self *RefreshHelper) refreshGithubPullRequests() { self.c.Mutexes().RefreshingPullRequestsMutex.Lock() defer self.c.Mutexes().RefreshingPullRequestsMutex.Unlock() + generation := self.c.State().GetRepoGeneration() + + clearPullRequests := func() { + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.Model().PullRequests = nil + self.c.Model().PullRequestsMap = nil + return nil + }) + } + githubRemotes := getAuthenticatedGithubRemotes(self.getGithubRemotes(), self.c.Git().GitHub.GetAuthToken) if len(githubRemotes) == 0 { - self.c.Model().PullRequests = nil - self.c.Model().PullRequestsMap = nil + clearPullRequests() return } baseInfo := getGithubBaseRemote(githubRemotes, self.c.Git().GitHub.ConfiguredBaseRemoteName()) if baseInfo == nil { - self.c.Model().PullRequests = nil - self.c.Model().PullRequestsMap = nil + clearPullRequests() if !self.githubBaseRemotePromptDismissed[self.c.Git().RepoPaths.RepoPath()] { self.promptForBaseGithubRepo(githubRemotes) @@ -1243,6 +1251,8 @@ func (self *RefreshHelper) rebuildPullRequestsMap() { } func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo) { + generation := self.c.State().GetRepoGeneration() + if len(self.c.Model().Branches) == 0 { return } @@ -1260,11 +1270,14 @@ func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo) { return } - self.c.Model().PullRequests = prs self.savePullRequestsToCache(prs) - self.rebuildPullRequestsMap() - self.c.OnUIThread(func() error { + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.Model().PullRequests = prs + // Rebuilding here rather than on the worker means the map is built from + // the branches and remotes as they are on the UI thread, after their + // own refreshes' bounces have applied. + self.rebuildPullRequestsMap() self.c.PostRefreshUpdate(self.c.Contexts().Branches) return nil }) From 549df1727937e8ff706820a830d42904b9b469c0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 15:32:11 +0200 Subject: [PATCH 20/59] Bounce COMMITS model updates onto the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refreshCommitsWithLimit now loads the commits, working-tree state and bisect info on the worker and writes them all — Model.Commits, Model.BisectInfo, Model.WorkingTreeStateAtLastCommitRefresh, Model.CheckedOutBranch, the authors, and the restored commit selection — in a single onUIThreadUnlessRepoChanged bounce. The selection restore (SelectHeadCommit / KeepCommitSelectionByHash) has to run in the bounce because it reads the freshly-loaded commits; the FocusLine scroll is enqueued from within the bounce so it still runs after refreshView's re-render, as before. refForLog no longer writes Model.BisectInfo as a side effect; it returns the bisect info it read, and the bounce writes it, keeping that model write on the UI thread. No caller reads Model.BisectInfo synchronously after a refresh (the bisect controller reads Git().Bisect.GetInfo() directly), so this is safe. refreshCommitsAndCommitFiles's post-refresh re-init of the commit files context depends on that restored selection, so it reads the selection in a bounce and dispatches the commit-files git work back to a worker. LocalCommitsMutex / AuthorsMutex are left in place for the mutex cleanup. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 109 +++++++++++------- 1 file changed, 68 insertions(+), 41 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 917a58ecf..a86e91111 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -416,6 +416,7 @@ func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, keepB } func (self *RefreshHelper) refreshCommitsAndCommitFiles(commitSelection types.CommitSelectionBehavior) { + generation := self.c.State().GetRepoGeneration() _ = self.refreshCommitsWithLimit(commitSelection) ctx := self.c.Contexts().CommitFiles.GetParentContext() if ctx != nil && ctx.GetKey() == context.LOCAL_COMMITS_CONTEXT_KEY { @@ -425,12 +426,22 @@ func (self *RefreshHelper) refreshCommitsAndCommitFiles(commitSelection types.Co // Ideally we would know when to refresh the commit files context and when not to, // or perhaps we could just pop that context off the stack whenever cycling windows. // For now the awkwardness remains. - commit := self.c.Contexts().LocalCommits.GetSelected() - if commit != nil && commit.RefName() != "" { - refRange := self.c.Contexts().LocalCommits.GetSelectedRefRangeForDiffFiles() - self.c.Contexts().CommitFiles.ReInit(commit, refRange) - _ = self.refreshCommitFilesContext() - } + // + // The commit selection is restored in refreshCommitsWithLimit's bounce, + // so read it on the UI thread after that bounce; then load the commit + // files back on a worker (refreshCommitFilesContext does git work). + self.onUIThreadUnlessRepoChanged(generation, func() error { + commit := self.c.Contexts().LocalCommits.GetSelected() + if commit != nil && commit.RefName() != "" { + refRange := self.c.Contexts().LocalCommits.GetSelectedRefRangeForDiffFiles() + self.c.Contexts().CommitFiles.ReInit(commit, refRange) + self.c.OnWorker(func(gocui.Task) error { + _ = self.refreshCommitFilesContext() + return nil + }) + } + return nil + }) } } @@ -464,6 +475,8 @@ func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitS self.c.Mutexes().LocalCommitsMutex.Lock() defer self.c.Mutexes().LocalCommitsMutex.Unlock() + generation := self.c.State().GetRepoGeneration() + var selectionRange *localCommitSelectionRange if commitSelection == types.KeepCommitSelectionByHash { selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode() @@ -471,13 +484,14 @@ func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitS } checkedOutRef := self.determineCheckedOutRef() + refName, bisectInfo := self.refForLog() commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( git_commands.GetCommitsOptions{ Limit: self.c.Contexts().LocalCommits.GetLimitCommits(), FilterPath: self.c.Modes().Filtering.GetPath(), FilterAuthor: self.c.Modes().Filtering.GetAuthor(), IncludeRebaseCommits: true, - RefName: self.refForLog(), + RefName: refName, RefForPushedStatus: checkedOutRef, All: self.c.Contexts().LocalCommits.GetShowWholeGitGraph(), MainBranches: self.c.Model().MainBranches, @@ -487,41 +501,51 @@ func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitS if err != nil { return err } - self.c.Model().Commits = commits - self.RefreshAuthors(commits) - self.c.Model().WorkingTreeStateAtLastCommitRefresh = self.c.Git().Status.WorkingTreeState() - if checkedOutRef != nil { - self.c.Model().CheckedOutBranch = checkedOutRef.RefName() - } else { - self.c.Model().CheckedOutBranch = "" - } + workingTreeState := self.c.Git().Status.WorkingTreeState() - scrollSelectionIntoView := false - switch commitSelection { - case types.SelectHeadCommit: - if headCommitIdx := models.HeadCommitIdx(commits); headCommitIdx >= 0 { - self.c.Contexts().LocalCommits.SetSelection(headCommitIdx) - scrollSelectionIntoView = true + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.Model().BisectInfo = bisectInfo + self.c.Model().Commits = commits + self.RefreshAuthors(commits) + self.c.Model().WorkingTreeStateAtLastCommitRefresh = workingTreeState + if checkedOutRef != nil { + self.c.Model().CheckedOutBranch = checkedOutRef.RefName() + } else { + self.c.Model().CheckedOutBranch = "" } - case types.KeepCommitSelectionByHash: - if selectionRange != nil { - selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, selectionRange) - if found { - self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, selectionRange.mode) - scrollSelectionIntoView = didMove + + scrollSelectionIntoView := false + switch commitSelection { + case types.SelectHeadCommit: + if headCommitIdx := models.HeadCommitIdx(commits); headCommitIdx >= 0 { + self.c.Contexts().LocalCommits.SetSelection(headCommitIdx) + scrollSelectionIntoView = true } + case types.KeepCommitSelectionByHash: + if selectionRange != nil { + selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, selectionRange) + if found { + self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, selectionRange.mode) + scrollSelectionIntoView = didMove + } + } + case types.KeepCommitSelectionIndex: + // The caller set the selection index deliberately; leave it untouched. } - case types.KeepCommitSelectionIndex: - // The caller set the selection index deliberately; leave it untouched. - } + + if scrollSelectionIntoView { + // Enqueued from within this bounce so it runs after refreshView's + // render below (which was enqueued first), matching the previous + // ordering where FocusLine ran after the view was re-rendered. + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.Contexts().LocalCommits.FocusLine(true) + return nil + }) + } + return nil + }) self.refreshView(self.c.Contexts().LocalCommits) - if scrollSelectionIntoView { - self.c.OnUIThread(func() error { - self.c.Contexts().LocalCommits.FocusLine(true) - return nil - }) - } return nil } @@ -1069,20 +1093,23 @@ func (self *RefreshHelper) refreshStatus() { }) } -func (self *RefreshHelper) refForLog() string { +// refForLog returns the ref to log commits from, along with the bisect info it +// read to decide that. The caller writes the bisect info to the model (in its +// bounce) rather than refForLog doing it, so the model write stays on the UI +// thread. +func (self *RefreshHelper) refForLog() (string, *git_commands.BisectInfo) { bisectInfo := self.c.Git().Bisect.GetInfo() - self.c.Model().BisectInfo = bisectInfo if !bisectInfo.Started() { - return "HEAD" + return "HEAD", bisectInfo } // need to see if our bisect's current commit is reachable from our 'new' ref. if bisectInfo.Bisecting() && !self.c.Git().Bisect.ReachableFromStart(bisectInfo) { - return bisectInfo.GetNewHash() + return bisectInfo.GetNewHash(), bisectInfo } - return bisectInfo.GetStartHash() + return bisectInfo.GetStartHash(), bisectInfo } func (self *RefreshHelper) refreshView(context types.Context) { From f7a61443fa832d47bfec4bb9726a7cc52eb5f19f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 15:56:01 +0200 Subject: [PATCH 21/59] Bounce BRANCHES model updates onto the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refreshBranches now loads the branches (and worktrees) on the worker and writes Model.Branches, the pull-requests map, Model.Worktrees, and the restored branch selection in an onUIThreadUnlessRepoChanged bounce. The selection restore and rebuildPullRequestsMap run in the bounce so they see the branches we just wrote; the LocalCommits re-render (for branch head visualization) moves into the same bounce. refreshStatus is adjusted to read the checked-out branch and the linked worktree name inside its bounce rather than on the worker: both derive from models (Branches, Worktrees) that are now written via bounces, so reading them on the worker would format the status from stale values — which showed up as the status line dropping the "(worktree)" suffix right after entering a submodule or switching worktrees. The git work (WorkingTreeState) stays on the worker. Two callers that read the branches model right after a SYNC branches refresh move their reads into Then: - BranchesHelper.PostFetchRefresh: AutoForwardBranches reads Model.Branches, so it runs in Then (preserving that a fetch error is still returned to the caller and that background auto-forward errors aren't surfaced as a popup). - BranchesController rename: the re-select-by-name loop runs in Then. RefreshingBranchesMutex is left in place for the mutex cleanup. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/branches_controller.go | 23 ++++--- .../controllers/helpers/branches_helper.go | 27 ++++++-- pkg/gui/controllers/helpers/refresh_helper.go | 68 +++++++++++-------- 3 files changed, 74 insertions(+), 44 deletions(-) diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index 45f98e9c5..131d19439 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -783,20 +783,25 @@ func (self *BranchesController) rename(branch *models.Branch) error { return err } - // need to find where the branch is now so that we can re-select it. That means we need to refetch the branches synchronously and then find our branch + // need to find where the branch is now so that we can re-select it. That means we need to + // refetch the branches and then find our branch. The branches model update is bounced + // onto the UI thread, so the re-selection (which reads Model.Branches) has to run in + // Then; reading it inline here would see the previous model. self.c.Refresh(types.RefreshOptions{ Mode: types.SYNC, Scope: []types.RefreshableView{types.BRANCHES, types.WORKTREES}, + Then: func() error { + // now that we've got our stuff again we need to find that branch and reselect it. + for i, newBranch := range self.c.Model().Branches { + if newBranch.Name == newBranchName { + self.context().SetSelection(i) + self.context().HandleRender() + } + } + return nil + }, }) - // now that we've got our stuff again we need to find that branch and reselect it. - for i, newBranch := range self.c.Model().Branches { - if newBranch.Name == newBranchName { - self.context().SetSelection(i) - self.context().HandleRender() - } - } - return nil }, }) diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 4283bd29a..e5c1a07e4 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -387,11 +387,28 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool) er if self.c.UserConfig().Git.AutoForwardBranches != "none" { scope = append(scope, types.WORKTREES) } - self.c.Refresh(types.RefreshOptions{Scope: scope, Mode: types.SYNC, Background: background}) - if fetchErr != nil { - return fetchErr - } - return self.AutoForwardBranches() + // AutoForwardBranches reads Model.Branches, which the branches refresh writes + // via a bounce, so it has to run in Then rather than right after Refresh + // returns (where it would still see the previous branches). + self.c.Refresh(types.RefreshOptions{ + Scope: scope, + Mode: types.SYNC, + Background: background, + Then: func() error { + if fetchErr != nil { + return nil + } + err := self.AutoForwardBranches() + if background && err != nil { + // The background poller discards this return value, so surface + // the error in the log rather than as a popup for background work. + self.c.Log.Error(err) + return nil + } + return err + }, + }) + return fetchErr } func (self *BranchesHelper) AutoForwardBranches() error { diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index a86e91111..876ce0f30 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -742,6 +742,8 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele self.c.Mutexes().RefreshingBranchesMutex.Lock() defer self.c.Mutexes().RefreshingBranchesMutex.Unlock() + generation := self.c.State().GetRepoGeneration() + branches, err := self.c.Git().Loaders.BranchLoader.Load( reflogCommits, self.c.Model().MainBranches, @@ -753,7 +755,7 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele }) }, func() { - self.c.OnUIThread(func() error { + self.onUIThreadUnlessRepoChanged(generation, func() error { self.c.Contexts().Branches.HandleRender() self.refreshStatus() return nil @@ -765,38 +767,42 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele prevSelectedBranch := self.c.Contexts().Branches.GetSelected() - self.c.Model().Branches = branches - self.rebuildPullRequestsMap() - + var worktrees []*models.Worktree if refreshWorktrees { - // TODO: this synchronous worker write goes away when refreshBranches is - // itself migrated to bouncing; for now it matches the rest of this - // not-yet-bounced function. - self.c.Model().Worktrees = self.loadWorktrees() - self.refreshView(self.c.Contexts().Worktrees) + worktrees = self.loadWorktrees() } - if !keepBranchSelectionIndex && prevSelectedBranch != nil { - self.searchHelper.ReApplyFilter(self.c.Contexts().Branches) + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.Model().Branches = branches + // Rebuilding here (rather than on the worker) means the map is built from + // the branches we just wrote, on the UI thread. + self.rebuildPullRequestsMap() - _, idx, found := lo.FindIndexOf(self.c.Contexts().Branches.GetItems(), - func(b *models.Branch) bool { return b.Name == prevSelectedBranch.Name }) - if found { - self.c.Contexts().Branches.SetSelectedLineIdx(idx) + if refreshWorktrees { + self.c.Model().Worktrees = worktrees + self.refreshView(self.c.Contexts().Worktrees) } - } - self.refreshView(self.c.Contexts().Branches) + if !keepBranchSelectionIndex && prevSelectedBranch != nil { + self.searchHelper.ReApplyFilter(self.c.Contexts().Branches) - // Need to re-render the commits view because the visualization of local - // branch heads might have changed - self.c.OnUIThread(func() error { + _, idx, found := lo.FindIndexOf(self.c.Contexts().Branches.GetItems(), + func(b *models.Branch) bool { return b.Name == prevSelectedBranch.Name }) + if found { + self.c.Contexts().Branches.SetSelectedLineIdx(idx) + } + } + + // Need to re-render the commits view because the visualization of local + // branch heads might have changed self.c.Mutexes().LocalCommitsMutex.Lock() self.c.Contexts().LocalCommits.HandleRender() self.c.Mutexes().LocalCommitsMutex.Unlock() return nil }) + self.refreshView(self.c.Contexts().Branches) + self.refreshStatus() } @@ -1074,20 +1080,22 @@ func (self *RefreshHelper) refreshStatus() { generation := self.c.State().GetRepoGeneration() - currentBranch := self.refsHelper.GetCheckedOutRef() - if currentBranch == nil { - // need to wait for branches to refresh - return - } - workingTreeState := self.c.Git().Status.WorkingTreeState() - linkedWorktreeName := self.worktreeHelper.GetLinkedWorktreeName() - repoName := self.c.Git().RepoPaths.RepoName() - status := presentation.FormatStatus(repoName, currentBranch, types.ItemOperationNone, linkedWorktreeName, workingTreeState, self.c.Tr, self.c.UserConfig()) - self.onUIThreadUnlessRepoChanged(generation, func() error { + // Read the checked-out branch and the linked worktree name here on the UI + // thread: both derive from models (Branches, Worktrees) that their + // refreshes now write via bounces, so reading them on the worker would + // see stale values from before those bounces applied. + currentBranch := self.refsHelper.GetCheckedOutRef() + if currentBranch == nil { + // need to wait for branches to refresh + return nil + } + linkedWorktreeName := self.worktreeHelper.GetLinkedWorktreeName() + + status := presentation.FormatStatus(repoName, currentBranch, types.ItemOperationNone, linkedWorktreeName, workingTreeState, self.c.Tr, self.c.UserConfig()) self.c.SetViewContent(self.c.Views().Status, status) return nil }) From 805738034f727e246bebfc8f35e3adc721c6873a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 16:51:55 +0200 Subject: [PATCH 22/59] Remove refresh mutexes made redundant by bouncing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that every refresh scope writes its model updates on the UI thread via onUIThreadUnlessRepoChanged, the per-scope mutexes that used to serialize concurrent worker-goroutine access are redundant: Model().Commits, .SubCommits, .Authors, the status view content, and .PullRequests/.PullRequestsMap are all now written only on the UI thread, and their readers already ran there. setSubCommits only existed to take the lock, so it's inlined to match refreshSubCommitsWithLimit, which writes Model().SubCommits directly. The worker phases still *read* some of these fields (the commit selection range, MergeRebasingCommits), but those reads race a concurrent refresh's bounced write regardless of the mutex — the write happens in the bounce, outside the locked region — so the mutex never protected them. That residual read race belongs to the broader -race effort, not to these locks. RefreshingBranchesMutex is deliberately kept. It is load-bearing for a reason unrelated to data races: at the INITIAL startup stage two refreshBranches run concurrently — an immediate one with an empty reflog (non-recency order) and an async one with the freshly-loaded reflog (recency order). The mutex serializes them so the recency write's bounce is enqueued last and wins. Without it the stale non-recency write can land last, reordering the branches list (caught by the recency-sort e2e tests: cherry_pick/*, branch/rebase_*). Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 20 ------------------- .../controllers/helpers/sub_commits_helper.go | 9 +-------- pkg/gui/types/common.go | 13 ++++-------- 3 files changed, 5 insertions(+), 37 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 876ce0f30..75d5d2fd4 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -472,9 +472,6 @@ func (self *RefreshHelper) determineCheckedOutRef() models.Ref { } func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitSelectionBehavior) error { - self.c.Mutexes().LocalCommitsMutex.Lock() - defer self.c.Mutexes().LocalCommitsMutex.Unlock() - generation := self.c.State().GetRepoGeneration() var selectionRange *localCommitSelectionRange @@ -629,9 +626,6 @@ func (self *RefreshHelper) refreshSubCommitsWithLimit() error { return nil } - self.c.Mutexes().SubCommitsMutex.Lock() - defer self.c.Mutexes().SubCommitsMutex.Unlock() - generation := self.c.State().GetRepoGeneration() commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( @@ -661,9 +655,6 @@ func (self *RefreshHelper) refreshSubCommitsWithLimit() error { } func (self *RefreshHelper) RefreshAuthors(commits []*models.Commit) { - self.c.Mutexes().AuthorsMutex.Lock() - defer self.c.Mutexes().AuthorsMutex.Unlock() - authors := self.c.Model().Authors for _, commit := range commits { if _, ok := authors[commit.AuthorEmail]; !ok { @@ -694,9 +685,6 @@ func (self *RefreshHelper) refreshCommitFilesContext() error { } func (self *RefreshHelper) refreshRebaseCommits() error { - self.c.Mutexes().LocalCommitsMutex.Lock() - defer self.c.Mutexes().LocalCommitsMutex.Unlock() - generation := self.c.State().GetRepoGeneration() updatedCommits, err := self.c.Git().Loaders.CommitLoader.MergeRebasingCommits(self.c.Model().HashPool, self.c.Model().Commits) @@ -795,9 +783,7 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele // Need to re-render the commits view because the visualization of local // branch heads might have changed - self.c.Mutexes().LocalCommitsMutex.Lock() self.c.Contexts().LocalCommits.HandleRender() - self.c.Mutexes().LocalCommitsMutex.Unlock() return nil }) @@ -1075,9 +1061,6 @@ func (self *RefreshHelper) refreshStashEntries() { // never call this on its own, it should only be called from within refreshCommits() func (self *RefreshHelper) refreshStatus() { - self.c.Mutexes().RefreshingStatusMutex.Lock() - defer self.c.Mutexes().RefreshingStatusMutex.Unlock() - generation := self.c.State().GetRepoGeneration() workingTreeState := self.c.Git().Status.WorkingTreeState() @@ -1147,9 +1130,6 @@ func (self *RefreshHelper) refreshView(context types.Context) { } func (self *RefreshHelper) refreshGithubPullRequests() { - self.c.Mutexes().RefreshingPullRequestsMutex.Lock() - defer self.c.Mutexes().RefreshingPullRequestsMutex.Unlock() - generation := self.c.State().GetRepoGeneration() clearPullRequests := func() { diff --git a/pkg/gui/controllers/helpers/sub_commits_helper.go b/pkg/gui/controllers/helpers/sub_commits_helper.go index fbf100e16..7bd928826 100644 --- a/pkg/gui/controllers/helpers/sub_commits_helper.go +++ b/pkg/gui/controllers/helpers/sub_commits_helper.go @@ -49,7 +49,7 @@ func (self *SubCommitsHelper) ViewSubCommits(opts ViewSubCommitsOpts) error { return err } - self.setSubCommits(commits) + self.c.Model().SubCommits = commits self.refreshHelper.RefreshAuthors(commits) subCommitsContext := self.c.Contexts().SubCommits @@ -71,10 +71,3 @@ func (self *SubCommitsHelper) ViewSubCommits(opts ViewSubCommitsOpts) error { self.c.Context().Push(self.c.Contexts().SubCommits, types.OnFocusOpts{}) return nil } - -func (self *SubCommitsHelper) setSubCommits(commits []*models.Commit) { - self.c.Mutexes().SubCommitsMutex.Lock() - defer self.c.Mutexes().SubCommitsMutex.Unlock() - - self.c.Model().SubCommits = commits -} diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 2b7dc2312..35f38d21e 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -338,15 +338,10 @@ type Model struct { } type Mutexes struct { - RefreshingBranchesMutex deadlock.Mutex - RefreshingStatusMutex deadlock.Mutex - RefreshingPullRequestsMutex deadlock.Mutex - LocalCommitsMutex deadlock.Mutex - SubCommitsMutex deadlock.Mutex - AuthorsMutex deadlock.Mutex - SubprocessMutex deadlock.Mutex - PopupMutex deadlock.Mutex - PtyMutex deadlock.Mutex + RefreshingBranchesMutex deadlock.Mutex + SubprocessMutex deadlock.Mutex + PopupMutex deadlock.Mutex + PtyMutex deadlock.Mutex } // A long-running operation associated with an item. For example, we'll show From 6d8ab1d0639f19227786a67103eca9582e219dd2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 17:35:44 +0200 Subject: [PATCH 23/59] Run the immediate startup branch refresh before spawning the async one At the INITIAL startup stage two branch refreshes happen: an immediate one sorted by whatever reflog we have (empty, so not by recency), and an async one that loads the reflog first and re-sorts by recency. Until now the async one was spawned first and the immediate one ran afterwards; this inverts that so the immediate refresh runs before the async one is spawned. With RefreshingBranchesMutex still in place this is behavior-preserving (the mutex serializes the two either way). It's a preparatory step for replacing that mutex with a branch-load sequence guard: running the immediate refresh first establishes a happens-before relation between the two loads' sequence numbers, so the recency-sorted one is guaranteed the higher sequence. This also lets refreshReflogCommitsConsideringStartup fold into refreshReflogAndBranches, whose two-phase logic is now all in one place. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 35 ++++++------------- 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 75d5d2fd4..95c7fa4a7 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -377,19 +377,18 @@ func getModeName(mode types.RefreshMode) string { } } -// during startup, the bottleneck is fetching the reflog entries. We need these -// on startup to sort the branches by recency. So we have two phases: INITIAL, and COMPLETE. -// In the initial phase we don't get any reflog commits, but we asynchronously get them -// and refresh the branches after that -// refreshReflogCommitsConsideringStartup returns the reflog commits that the -// caller should hand to refreshBranches for recency sorting. In the COMPLETE -// (normal) case that's the freshly-loaded reflog; in the INITIAL case the -// reflog is loaded asynchronously (and drives its own branches refresh once -// ready), so we return the current model value for the immediate, -// non-recency-sorted branches refresh the caller does in the meantime. -func (self *RefreshHelper) refreshReflogCommitsConsideringStartup() []*models.Commit { +// During startup, the bottleneck is fetching the reflog entries, which we need +// in order to sort the branches by recency. So we have two phases: INITIAL and +// COMPLETE. In the INITIAL phase we don't have any reflog commits yet, so we +// show the branches right away sorted by whatever we have (typically nothing, +// i.e. not by recency), then load the reflog on a worker and refresh the +// branches again, this time recency-sorted. From then on we're in the COMPLETE +// phase and load the reflog synchronously before refreshing the branches. +func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, keepBranchSelectionIndex bool) { switch self.c.State().GetRepoState().GetStartupStage() { case types.INITIAL: + self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, false, self.c.Model().ReflogCommits) + self.c.OnWorker(func(_ gocui.Task) error { reflogCommits, _ := self.refreshReflogCommits() self.refreshBranches(false, true, true, reflogCommits) @@ -397,22 +396,10 @@ func (self *RefreshHelper) refreshReflogCommitsConsideringStartup() []*models.Co return nil }) - return self.c.Model().ReflogCommits - case types.COMPLETE: reflogCommits, _ := self.refreshReflogCommits() - return reflogCommits + self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, true, reflogCommits) } - - return self.c.Model().ReflogCommits -} - -func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, keepBranchSelectionIndex bool) { - loadBehindCounts := self.c.State().GetRepoState().GetStartupStage() == types.COMPLETE - - reflogCommits := self.refreshReflogCommitsConsideringStartup() - - self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, loadBehindCounts, reflogCommits) } func (self *RefreshHelper) refreshCommitsAndCommitFiles(commitSelection types.CommitSelectionBehavior) { From 3103fe97ea9bb2653c4890dcbe8a1d67d1016aac Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 18:45:55 +0200 Subject: [PATCH 24/59] Replace RefreshingBranchesMutex with a branch-load sequence guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This removes the last refresh mutex. RefreshingBranchesMutex wasn't guarding a data race (Branch.BehindBaseBranch is atomic, and every model write is now bounced onto the UI thread); it was serializing the two branch loads that race at the INITIAL startup stage — an immediate one sorted without the reflog, and an async one that loads the reflog and sorts by recency — so that the recency-sorted write landed last and won. That serialization was never a real guarantee, only "very likely": it relied on the immediate load acquiring the lock before the async load, which had to load the reflog first. Instead, each branch load takes a monotonically increasing sequence number, and its bounce drops the write if a later-started load has already applied. Combined with the preceding commit (immediate load runs before the async one is spawned), this is an actual guarantee: the immediate non-recency load always has a lower sequence than its recency async partner, so the highest sequence number is always held by a recency-sorted load, and highest-wins converges on recency ordering — even if more refreshes fire during the INITIAL window, since each refresh's async out-sequences its own immediate. The guard also subsumes what the mutex gave post-startup: a slow, stale refresh's bounce can no longer clobber a newer refresh's branches. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 28 +++++++++++++++++-- pkg/gui/types/common.go | 7 ++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 95c7fa4a7..fe89bbad6 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -3,6 +3,7 @@ package helpers import ( "strings" "sync" + "sync/atomic" "time" "github.com/jesseduffield/generics/set" @@ -44,6 +45,15 @@ type RefreshHelper struct { // refresh that re-read refs/commits, read by the poller. refsSnapshotMutex deadlock.Mutex refsSnapshot string + + // branchLoadSeq hands out a monotonically increasing sequence number to + // each branch load (via Add, on the worker); appliedBranchLoadSeq is the + // highest sequence whose result has been written to the model (touched only + // on the UI thread, inside the bounce). Together they let a branch load's + // bounce drop its write if a later-started load has already applied, so + // concurrent branch loads don't clobber each other out of order. + branchLoadSeq atomic.Int64 + appliedBranchLoadSeq int64 } func NewRefreshHelper( @@ -384,6 +394,11 @@ func getModeName(mode types.RefreshMode) string { // i.e. not by recency), then load the reflog on a worker and refresh the // branches again, this time recency-sorted. From then on we're in the COMPLETE // phase and load the reflog synchronously before refreshing the branches. +// +// The immediate refresh must run before we spawn the async one, not after: that +// order gives the immediate (non-recency) load a lower branch-load sequence +// than the async (recency) load, so the sequence guard in refreshBranches keeps +// the recency-sorted result even if the two loads' bounces land out of order. func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, keepBranchSelectionIndex bool) { switch self.c.State().GetRepoState().GetStartupStage() { case types.INITIAL: @@ -714,8 +729,7 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs() ([]*models.SubmoduleCo // self.refreshStatus is called at the end of this because that's when we can // be sure there is a State.Model.Branches array to pick the current branch from func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSelectionIndex bool, loadBehindCounts bool, reflogCommits []*models.Commit) { - self.c.Mutexes().RefreshingBranchesMutex.Lock() - defer self.c.Mutexes().RefreshingBranchesMutex.Unlock() + loadSeq := self.branchLoadSeq.Add(1) generation := self.c.State().GetRepoGeneration() @@ -748,6 +762,16 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele } self.onUIThreadUnlessRepoChanged(generation, func() error { + // Drop this write if a branch load that started later has already applied + // its result. At the INITIAL startup stage an immediate load (not + // recency-sorted) and an async recency-sorted load run concurrently; this + // makes the later-started (recency-sorted) one win regardless of which + // finishes first, so its result isn't clobbered by the stale immediate one. + if loadSeq < self.appliedBranchLoadSeq { + return nil + } + self.appliedBranchLoadSeq = loadSeq + self.c.Model().Branches = branches // Rebuilding here (rather than on the worker) means the map is built from // the branches we just wrote, on the UI thread. diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 35f38d21e..2ce07f9c7 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -338,10 +338,9 @@ type Model struct { } type Mutexes struct { - RefreshingBranchesMutex deadlock.Mutex - SubprocessMutex deadlock.Mutex - PopupMutex deadlock.Mutex - PtyMutex deadlock.Mutex + SubprocessMutex deadlock.Mutex + PopupMutex deadlock.Mutex + PtyMutex deadlock.Mutex } // A long-running operation associated with an item. For example, we'll show From cf7c3d82e6e962ce78494e57c77058796ea617f2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 20:46:51 +0200 Subject: [PATCH 25/59] 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) --- pkg/gui/controllers/helpers/repos_helper.go | 72 +++++++++---------- .../controllers/helpers/worktree_helper.go | 8 ++- 2 files changed, 43 insertions(+), 37 deletions(-) diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index 94c9e4368..6257327e9 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -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 diff --git a/pkg/gui/controllers/helpers/worktree_helper.go b/pkg/gui/controllers/helpers/worktree_helper.go index 7cec9f873..2a189a752 100644 --- a/pkg/gui/controllers/helpers/worktree_helper.go +++ b/pkg/gui/controllers/helpers/worktree_helper.go @@ -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 }) } From e352cafd43bea26fedefa6e0f6e45bcad8213d4f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 20:56:00 +0200 Subject: [PATCH 26/59] Add background tasks and a synchronous busy query to gocui Repo-switch safety needs to answer, synchronously on the UI thread, "is any foreground work in flight right now?" so it can refuse a switch that would run against a repo about to be swapped out. gocui already tracks a task per OnWorker/Update for the test idle-listener; extend that. Tasks gain a background flag: background tasks (the ongoing routines like auto-fetch, and the refreshes they trigger) don't count towards busy, because their model writes are already guarded against a concurrent switch by the repo generation. Add OnWorkerBackground, UpdateBackground and UpdateContentOnlyBackground (plus the gui-layer OnUIThreadBackground / OnUIThreadContentOnlyBackground / OnWorkerBackground on IGuiCommon) so the few background call sites can opt in without touching the hundreds of foreground callers. TaskManager.hasBusyForegroundTaskExcept answers the query; Gui.Busy() wraps it, excluding the event currently being processed (recorded as currentTask) so a handler asking the question doesn't count itself. Nothing gates on Busy() yet; this is the mechanism only. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/gui.go | 65 ++++++++++++++++++++++++++++++---- pkg/gocui/task.go | 18 +++++++++- pkg/gocui/task_manager.go | 26 ++++++++++++-- pkg/gocui/task_manager_test.go | 63 ++++++++++++++++++++++++++++++++ pkg/gui/gui.go | 16 +++++++++ pkg/gui/gui_common.go | 12 +++++++ pkg/gui/types/common.go | 9 +++++ 7 files changed, 200 insertions(+), 9 deletions(-) create mode 100644 pkg/gocui/task_manager_test.go diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index ee1995911..ff113a2dd 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -193,6 +193,12 @@ type Gui struct { taskManager *TaskManager + // The task of the event currently being processed on the main goroutine, if + // any. Only touched from the main goroutine (in processEvent). It's excluded + // from the Busy() check so that an event handler asking "is anything else + // busy?" doesn't count itself. + currentTask Task + lastHoverView *View } @@ -273,7 +279,15 @@ func NewGui(opts NewGuiOpts) (*Gui, error) { } func (g *Gui) NewTask() *TaskImpl { - return g.taskManager.NewTask() + return g.taskManager.NewTask(false) +} + +// Busy reports whether any foreground work is in flight, ignoring the event +// currently being processed on the main goroutine (see currentTask). Background +// routines (auto-fetch etc.) don't count. It's used to decide whether it's safe +// to switch repos. Must be called on the main goroutine. +func (g *Gui) Busy() bool { + return g.taskManager.hasBusyForegroundTaskExcept(g.currentTask) } // An idle listener listens for when the program is idle. This is useful for @@ -628,7 +642,18 @@ type userEvent struct { // never fire in practice; if it does, that's a signal to investigate, not // to grow the buffer reflexively. func (g *Gui) Update(f func(*Gui) error) { - task := g.NewTask() + g.update(f, false) +} + +// Like Update, but the enqueued work is a background routine (or triggered by +// one), so it doesn't count towards the program being busy for repo-switch +// safety. See TaskImpl.background. +func (g *Gui) UpdateBackground(f func(*Gui) error) { + g.update(f, true) +} + +func (g *Gui) update(f func(*Gui) error, background bool) { + task := g.taskManager.NewTask(background) select { case g.userEvents <- userEvent{f: f, task: task}: @@ -639,7 +664,16 @@ func (g *Gui) Update(f func(*Gui) error) { // Like Update, but signals that the callback only modifies content. func (g *Gui) UpdateContentOnly(f func(*Gui) error) { - task := g.NewTask() + g.updateContentOnly(f, false) +} + +// Like UpdateContentOnly, but for background work (see UpdateBackground). +func (g *Gui) UpdateContentOnlyBackground(f func(*Gui) error) { + g.updateContentOnly(f, true) +} + +func (g *Gui) updateContentOnly(f func(*Gui) error, background bool) { + task := g.taskManager.NewTask(background) g.userEvents <- userEvent{f: f, task: task, contentOnly: true} } @@ -650,7 +684,18 @@ func (g *Gui) UpdateContentOnly(f func(*Gui) error) { // background goroutines where you wouldn't want lazygit to be considered busy // (i.e. when you wouldn't want a loader to be shown to the user) func (g *Gui) OnWorker(f func(Task) error) { - task := g.NewTask() + g.onWorker(f, false) +} + +// Like OnWorker, but for a background routine (or work triggered by one), so it +// doesn't count towards the program being busy for repo-switch safety. See +// TaskImpl.background. +func (g *Gui) OnWorkerBackground(f func(Task) error) { + g.onWorker(f, true) +} + +func (g *Gui) onWorker(f func(Task) error, background bool) { + task := g.taskManager.NewTask(background) go func() { g.onWorkerAux(f, task) task.Done() @@ -758,17 +803,25 @@ func (g *Gui) handleError(err error) error { func (g *Gui) processEvent() error { contentOnly := false + // currentTask is the task of the event we're about to handle; recording it + // lets Busy() ignore it, so a handler asking "is anything else busy?" (the + // repo-switch guard does) doesn't count itself. Handlers of the remaining + // events drained below run with currentTask still set to this primary event; + // that's fine because the only Busy() callers are keybinding handlers, which + // are always the primary event here. select { case ev := <-g.gEvents: task := g.NewTask() - defer func() { task.Done() }() + g.currentTask = task + defer func() { g.currentTask = nil; task.Done() }() if err := g.handleError(g.handleEvent(&ev)); err != nil { return err } case ev := <-g.userEvents: contentOnly = ev.contentOnly - defer func() { ev.task.Done() }() + g.currentTask = ev.task + defer func() { g.currentTask = nil; ev.task.Done() }() if err := g.handleError(ev.f(g)); err != nil { return err diff --git a/pkg/gocui/task.go b/pkg/gocui/task.go index ace72f4a8..377781a4f 100644 --- a/pkg/gocui/task.go +++ b/pkg/gocui/task.go @@ -8,8 +8,9 @@ type Task interface { Done() Pause() Continue() - // not exporting because we don't need to + // not exporting these because we don't need to isBusy() bool + isBackground() bool } type TaskImpl struct { @@ -17,6 +18,13 @@ type TaskImpl struct { busy bool onDone func() withMutex func(func()) + // Background tasks don't count towards the program being "busy" for the + // purpose of deciding whether a repo switch is safe (see + // TaskManager.hasBusyForegroundTaskExcept). They're the ongoing background + // routines (auto-fetch, files refresh, external-change detection) and the + // refreshes they trigger, whose model writes are already guarded against a + // concurrent repo switch by the repo generation. + background bool } func (self *TaskImpl) Done() { @@ -39,6 +47,10 @@ func (self *TaskImpl) isBusy() bool { return self.busy } +func (self *TaskImpl) isBackground() bool { + return self.background +} + type TaskStatus int const ( @@ -73,6 +85,10 @@ func (self *FakeTask) isBusy() bool { return self.status == TaskStatusBusy } +func (self *FakeTask) isBackground() bool { + return false +} + func (self *FakeTask) Status() TaskStatus { return self.status } diff --git a/pkg/gocui/task_manager.go b/pkg/gocui/task_manager.go index e3c82b4d4..23ef0f77e 100644 --- a/pkg/gocui/task_manager.go +++ b/pkg/gocui/task_manager.go @@ -22,7 +22,7 @@ func newTaskManager() *TaskManager { } } -func (self *TaskManager) NewTask() *TaskImpl { +func (self *TaskManager) NewTask(background bool) *TaskImpl { self.mutex.Lock() defer self.mutex.Unlock() @@ -30,12 +30,34 @@ func (self *TaskManager) NewTask() *TaskImpl { taskId := self.nextId onDone := func() { self.delete(taskId) } - task := &TaskImpl{id: taskId, busy: true, onDone: onDone, withMutex: self.withMutex} + task := &TaskImpl{id: taskId, busy: true, background: background, onDone: onDone, withMutex: self.withMutex} self.tasks[taskId] = task return task } +// hasBusyForegroundTaskExcept reports whether any task other than `ignore` is +// currently busy and not a background task. It's used to decide whether a repo +// switch is safe: a foreground operation (or the refresh it triggers, or that +// refresh's follow-up callbacks) still in flight means the switch must wait, so +// it doesn't run against a repo that's about to be swapped out. +// +// `ignore` is the event currently being processed on the UI thread — the switch +// attempt itself — which is always busy and so must not count as a reason to +// refuse itself. +func (self *TaskManager) hasBusyForegroundTaskExcept(ignore Task) bool { + self.mutex.Lock() + defer self.mutex.Unlock() + + for _, task := range self.tasks { + if task != ignore && task.isBusy() && !task.isBackground() { + return true + } + } + + return false +} + func (self *TaskManager) addIdleListener(c chan struct{}) { self.idleListeners = append(self.idleListeners, c) } diff --git a/pkg/gocui/task_manager_test.go b/pkg/gocui/task_manager_test.go new file mode 100644 index 000000000..7fe706d7a --- /dev/null +++ b/pkg/gocui/task_manager_test.go @@ -0,0 +1,63 @@ +package gocui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTaskManagerHasBusyForegroundTaskExcept(t *testing.T) { + t.Run("no tasks", func(t *testing.T) { + tm := newTaskManager() + assert.False(t, tm.hasBusyForegroundTaskExcept(nil)) + }) + + t.Run("a busy foreground task counts", func(t *testing.T) { + tm := newTaskManager() + tm.NewTask(false) + assert.True(t, tm.hasBusyForegroundTaskExcept(nil)) + }) + + t.Run("a busy background task does not count", func(t *testing.T) { + tm := newTaskManager() + tm.NewTask(true) + assert.False(t, tm.hasBusyForegroundTaskExcept(nil)) + }) + + t.Run("a done foreground task does not count", func(t *testing.T) { + tm := newTaskManager() + task := tm.NewTask(false) + task.Done() + assert.False(t, tm.hasBusyForegroundTaskExcept(nil)) + }) + + t.Run("a paused foreground task does not count", func(t *testing.T) { + tm := newTaskManager() + task := tm.NewTask(false) + task.Pause() + assert.False(t, tm.hasBusyForegroundTaskExcept(nil)) + }) + + t.Run("the ignored task does not count", func(t *testing.T) { + tm := newTaskManager() + task := tm.NewTask(false) + assert.False(t, tm.hasBusyForegroundTaskExcept(task)) + }) + + t.Run("another foreground task counts even when one is ignored", func(t *testing.T) { + tm := newTaskManager() + ignored := tm.NewTask(false) + tm.NewTask(false) + assert.True(t, tm.hasBusyForegroundTaskExcept(ignored)) + }) + + t.Run("only a background task alongside the ignored current event", func(t *testing.T) { + // This is the repo-switch case: the switch is handled as the current + // event (ignored) while a background refresh is in flight; it must not + // be considered busy. + tm := newTaskManager() + current := tm.NewTask(false) + tm.NewTask(true) + assert.False(t, tm.hasBusyForegroundTaskExcept(current)) + }) +} diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 834cf1e2d..5aa8beaec 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -1194,16 +1194,32 @@ func (gui *Gui) onUIThread(f func() error) { }) } +func (gui *Gui) onUIThreadBackground(f func() error) { + gui.g.UpdateBackground(func(*gocui.Gui) error { + return f() + }) +} + func (gui *Gui) onUIThreadContentOnly(f func() error) { gui.g.UpdateContentOnly(func(*gocui.Gui) error { return f() }) } +func (gui *Gui) onUIThreadContentOnlyBackground(f func() error) { + gui.g.UpdateContentOnlyBackground(func(*gocui.Gui) error { + return f() + }) +} + func (gui *Gui) onWorker(f func(gocui.Task) error) { gui.g.OnWorker(f) } +func (gui *Gui) onWorkerBackground(f func(gocui.Task) error) { + gui.g.OnWorkerBackground(f) +} + func (gui *Gui) getWindowDimensions(informationStr string, appStatus string) map[string]boxlayout.Dimensions { return gui.helpers.WindowArrangement.GetWindowDimensions(informationStr, appStatus) } diff --git a/pkg/gui/gui_common.go b/pkg/gui/gui_common.go index c74a99a05..d13120508 100644 --- a/pkg/gui/gui_common.go +++ b/pkg/gui/gui_common.go @@ -124,14 +124,26 @@ func (self *guiCommon) OnUIThread(f func() error) { self.gui.onUIThread(f) } +func (self *guiCommon) OnUIThreadBackground(f func() error) { + self.gui.onUIThreadBackground(f) +} + func (self *guiCommon) OnUIThreadContentOnly(f func() error) { self.gui.onUIThreadContentOnly(f) } +func (self *guiCommon) OnUIThreadContentOnlyBackground(f func() error) { + self.gui.onUIThreadContentOnlyBackground(f) +} + func (self *guiCommon) OnWorker(f func(gocui.Task) error) { self.gui.onWorker(f) } +func (self *guiCommon) OnWorkerBackground(f func(gocui.Task) error) { + self.gui.onWorkerBackground(f) +} + func (self *guiCommon) RenderToMainViews(opts types.RefreshMainOpts) { self.gui.refreshMainViews(opts) } diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 2ce07f9c7..766a5a757 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -75,13 +75,22 @@ type IGuiCommon interface { // Only necessary to call if you're not already on the UI thread i.e. you're inside a goroutine. // All controller handlers are executed on the UI thread. OnUIThread(f func() error) + // Like OnUIThread, but for work triggered by a background routine, so it + // doesn't count towards lazygit being busy (see the *Background methods on + // gocui.Gui and repo-switch safety). + OnUIThreadBackground(f func() error) // Like OnUIThread, but signals that the callback only modifies view // content (e.g. spinner), allows the event loop to skip // the expensive layout recalculation when only content changed. OnUIThreadContentOnly(f func() error) + // Like OnUIThreadContentOnly, but for background work (see OnUIThreadBackground). + OnUIThreadContentOnlyBackground(f func() error) // Runs a function in a goroutine. Use this whenever you want to run a goroutine and keep track of the fact // that lazygit is still busy. See docs/dev/Busy.md OnWorker(f func(gocui.Task) error) + // Like OnWorker, but for a background routine (or work it triggers), so it + // doesn't count towards lazygit being busy (see OnUIThreadBackground). + OnWorkerBackground(f func(gocui.Task) error) // Function to call at the end of our 'layout' function which renders views // For example, you may want a view's line to be focused only after that view is // resized, if in accordion mode. From d95900ccd08e64dddcee2606f4787c3224749130 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 21:15:01 +0200 Subject: [PATCH 27/59] Tag background routines and their refreshes as background tasks For the busy query to be usable as a repo-switch guard it has to be false while the ongoing background routines run, or a switch would be refused every time a background fetch or files refresh happened to be in flight. Mark that work as background so it's excluded from the query. The background routine dispatch in goEvery becomes OnWorkerBackground, and the auto-fetch waiting status renders its spinner through the background variants. Within a refresh, the background flag (which Refresh already carries as options.Background, and which the files path already threaded) is now threaded through every place that enqueues a task: the async scope workers, the model-write bounces (onUIThreadUnlessRepoChanged), refreshView, the staging bounce, the Then dispatch, and the branch-loader's behind-count worker. Two single-caller chains reached by a background files refresh get the flag too: MergeConflictsHelper.EscapeMerge and BranchesHelper. AutoForwardBranches (whose follow-up refresh must stay background when triggered by the background fetch). Nothing gates on the busy query yet, so this is behavior-preserving; background tasks still count as busy for the test idle-listener, which looks at every task regardless of the background flag. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/background.go | 7 +- .../controllers/helpers/app_status_helper.go | 31 ++- .../controllers/helpers/branches_helper.go | 6 +- .../helpers/merge_conflicts_helper.go | 14 +- pkg/gui/controllers/helpers/refresh_helper.go | 189 ++++++++++-------- 5 files changed, 146 insertions(+), 101 deletions(-) diff --git a/pkg/gui/background.go b/pkg/gui/background.go index 94bf4f678..6215f0d45 100644 --- a/pkg/gui/background.go +++ b/pkg/gui/background.go @@ -114,7 +114,7 @@ func (self *BackgroundRoutineMgr) startBackgroundFetch() { if self.gui.UserConfig().Gui.ShowBottomLine || firstTimeOrRetriggered { return self.gui.helpers.AppStatus.WithWaitingStatusImpl(self.gui.Tr.FetchingStatus, func(gocui.Task) error { return self.backgroundFetch() - }, nil) + }, nil, true) } return self.backgroundFetch() @@ -198,7 +198,10 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan stru if self.backgroundRefreshesPaused() { return } - self.gui.c.OnWorker(func(gocui.Task) error { + // OnWorkerBackground, not OnWorker: these routines and the refreshes + // they trigger must not count towards lazygit being busy, or they'd + // spuriously block a repo switch every time one happens to be running. + self.gui.c.OnWorkerBackground(func(gocui.Task) error { _ = function(retriggered) done <- struct{}{} return nil diff --git a/pkg/gui/controllers/helpers/app_status_helper.go b/pkg/gui/controllers/helpers/app_status_helper.go index b691db4a4..bffd87866 100644 --- a/pkg/gui/controllers/helpers/app_status_helper.go +++ b/pkg/gui/controllers/helpers/app_status_helper.go @@ -34,7 +34,7 @@ func (self *AppStatusHelper) Toast(message string, kind types.ToastKind) { self.statusMgr().AddToastStatus(message, kind) - self.renderAppStatus() + self.renderAppStatus(false) } // A custom task for WithWaitingStatus calls; it wraps the original one and @@ -61,11 +61,14 @@ func (self appStatusHelperTask) Continue() { // WithWaitingStatus wraps a function and shows a waiting status while the function is still executing func (self *AppStatusHelper) WithWaitingStatus(message string, f func(gocui.Task) error) { self.c.OnWorker(func(task gocui.Task) error { - return self.WithWaitingStatusImpl(message, f, task) + return self.WithWaitingStatusImpl(message, f, task, false) }) } -func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui.Task) error, task gocui.Task) error { +// background reports whether this waiting status belongs to a background routine +// (the auto-fetch poller); when it does, the spinner it drives must not count +// towards lazygit being busy, or it'd block repo switches while a fetch runs. +func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui.Task) error, task gocui.Task, background bool) error { // A waiting status means lazygit is driving a git operation itself (often // one that internally runs a rebase and continues it). Pause the background // routines for its duration so they don't refresh from an intermediate @@ -73,7 +76,7 @@ func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui. self.c.PauseBackgroundRefreshes(true) defer self.c.PauseBackgroundRefreshes(false) - return self.statusMgr().WithWaitingStatus(message, self.renderAppStatus, func(waitingStatusHandle *status.WaitingStatusHandle) error { + return self.statusMgr().WithWaitingStatus(message, func() { self.renderAppStatus(background) }, func(waitingStatusHandle *status.WaitingStatusHandle) error { return f(appStatusHelperTask{task, waitingStatusHandle}) }) } @@ -100,21 +103,33 @@ func (self *AppStatusHelper) GetStatusString() string { return appStatus } -func (self *AppStatusHelper) renderAppStatus() { - self.c.OnWorker(func(_ gocui.Task) error { +func (self *AppStatusHelper) renderAppStatus(background bool) { + // A background waiting status (auto-fetch) must not count towards lazygit + // being busy, so its spinner worker and per-frame UI updates go through the + // background variants. + onWorker := self.c.OnWorker + onUIThread := self.c.OnUIThread + onUIThreadContentOnly := self.c.OnUIThreadContentOnly + if background { + onWorker = self.c.OnWorkerBackground + onUIThread = self.c.OnUIThreadBackground + onUIThreadContentOnly = self.c.OnUIThreadContentOnlyBackground + } + + onWorker(func(_ gocui.Task) error { ticker := time.NewTicker(time.Millisecond * time.Duration(self.c.UserConfig().Gui.Spinner.Rate)) defer ticker.Stop() prevAppStatus := "" for range ticker.C { appStatus, color := self.statusMgr().GetStatusString(self.c.UserConfig()) - update := self.c.OnUIThreadContentOnly + update := onUIThreadContentOnly if utils.StringWidth(appStatus) != utils.StringWidth(prevAppStatus) { // Need a full layout whenever the width of the status string changes. This can't // happen during normal spinning because we validate that all spinner frames have // the same width, so typically this will only be triggered at the beginning and end // of a status, or if the status string changes midway for some reason. - update = self.c.OnUIThread + update = onUIThread } update(func() error { self.c.Views().AppStatus.FgColor = color diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index e5c1a07e4..053804760 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -398,7 +398,7 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool) er if fetchErr != nil { return nil } - err := self.AutoForwardBranches() + err := self.AutoForwardBranches(background) if background && err != nil { // The background poller discards this return value, so surface // the error in the log rather than as a popup for background work. @@ -411,7 +411,7 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool) er return fetchErr } -func (self *BranchesHelper) AutoForwardBranches() error { +func (self *BranchesHelper) AutoForwardBranches(background bool) error { if self.c.UserConfig().Git.AutoForwardBranches == "none" { return nil } @@ -443,7 +443,7 @@ func (self *BranchesHelper) AutoForwardBranches() error { self.c.LogCommand(strings.TrimRight(updateCommands, "\n"), false) err := self.c.Git().Branch.UpdateBranchRefs(updateCommands) - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}, Mode: types.SYNC}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}, Mode: types.SYNC, Background: background}) return err } diff --git a/pkg/gui/controllers/helpers/merge_conflicts_helper.go b/pkg/gui/controllers/helpers/merge_conflicts_helper.go index 6e6a01531..175bc3cc0 100644 --- a/pkg/gui/controllers/helpers/merge_conflicts_helper.go +++ b/pkg/gui/controllers/helpers/merge_conflicts_helper.go @@ -51,11 +51,17 @@ func (self *MergeConflictsHelper) resetMergeState() { self.context().GetState().Reset() } -func (self *MergeConflictsHelper) EscapeMerge() error { +func (self *MergeConflictsHelper) EscapeMerge(background bool) error { self.resetMergeState() // doing this in separate UI thread so that we're not still holding the lock by the time refresh the file - self.c.OnUIThread(func() error { + onUIThread := self.c.OnUIThread + if background { + // Reached from a background files refresh; keep it off the busy count + // (see the *Background dispatch methods) so it doesn't block a repo switch. + onUIThread = self.c.OnUIThreadBackground + } + onUIThread(func() error { // There is a race condition here: refreshing the files scope can trigger the // confirmation context to be pushed if all conflicts are resolved (prompting // to continue the merge/rebase. In that case, we don't want to then push the @@ -120,7 +126,7 @@ func (self *MergeConflictsHelper) Render() { }) } -func (self *MergeConflictsHelper) RefreshMergeState() error { +func (self *MergeConflictsHelper) RefreshMergeState(background bool) error { self.c.Contexts().MergeConflicts.GetMutex().Lock() defer self.c.Contexts().MergeConflicts.GetMutex().Unlock() @@ -134,7 +140,7 @@ func (self *MergeConflictsHelper) RefreshMergeState() error { } if !hasConflicts { - return self.EscapeMerge() + return self.EscapeMerge(background) } return nil diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index fe89bbad6..c0543d095 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -154,7 +154,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { // everything happens fast and it's better to have everything update // in the one frame if !self.c.InDemo() && options.Mode == types.ASYNC { - self.c.OnWorker(func(t gocui.Task) error { + self.onWorker(options.Background, func(t gocui.Task) error { f() return nil }) @@ -176,14 +176,14 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { // counts can change. Whenever we change branches we should also change commits // e.g. in the case of switching branches. refresh("commits and commit files", func() { - self.refreshCommitsAndCommitFiles(options.CommitSelection) + self.refreshCommitsAndCommitFiles(options.CommitSelection, options.Background) }) includeWorktreesWithBranches = scopeSet.Includes(types.WORKTREES) if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" { branchesAndRemotesWg.Add(1) refresh("reflog and branches", func() { - self.refreshReflogAndBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex) + self.refreshReflogAndBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex, options.Background) branchesAndRemotesWg.Done() }) } else { @@ -192,24 +192,24 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { // Not a recency sort, so branches doesn't depend on the reflog // being fresh; it runs concurrently with the reflog refresh // below and reads whatever's in the model, as it always has. - self.refreshBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex, true, self.c.Model().ReflogCommits) + self.refreshBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex, true, self.c.Model().ReflogCommits, options.Background) branchesAndRemotesWg.Done() }) - refresh("reflog", func() { _, _ = self.refreshReflogCommits() }) + refresh("reflog", func() { _, _ = self.refreshReflogCommits(options.Background) }) } } else if scopeSet.Includes(types.REBASE_COMMITS) { // the above block handles rebase commits so we only need to call this one // if we've asked specifically for rebase commits and not those other things - refresh("rebase commits", func() { _ = self.refreshRebaseCommits() }) + refresh("rebase commits", func() { _ = self.refreshRebaseCommits(options.Background) }) } if scopeSet.Includes(types.SUB_COMMITS) { - refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit() }) + refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit(options.Background) }) } // reason we're not doing this if the COMMITS type is included is that if the COMMITS type _is_ included we will refresh the commit files context anyway if scopeSet.Includes(types.COMMIT_FILES) && !scopeSet.Includes(types.COMMITS) { - refresh("commit files", func() { _ = self.refreshCommitFilesContext() }) + refresh("commit files", func() { _ = self.refreshCommitFilesContext(options.Background) }) } fileWg := sync.WaitGroup{} @@ -222,17 +222,17 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { } if scopeSet.Includes(types.STASH) { - refresh("stash", func() { self.refreshStashEntries() }) + refresh("stash", func() { self.refreshStashEntries(options.Background) }) } if scopeSet.Includes(types.TAGS) { - refresh("tags", func() { _ = self.refreshTags() }) + refresh("tags", func() { _ = self.refreshTags(options.Background) }) } if scopeSet.Includes(types.REMOTES) { branchesAndRemotesWg.Add(1) refresh("remotes", func() { - _ = self.refreshRemotes() + _ = self.refreshRemotes(options.Background) branchesAndRemotesWg.Done() }) } @@ -240,12 +240,12 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { if scopeSet.Includes(types.PULL_REQUESTS) { refresh("pull requests", func() { branchesAndRemotesWg.Wait() - self.refreshGithubPullRequests() + self.refreshGithubPullRequests(options.Background) }) } if scopeSet.Includes(types.WORKTREES) && !includeWorktreesWithBranches { - refresh("worktrees", func() { self.refreshWorktrees() }) + refresh("worktrees", func() { self.refreshWorktrees(options.Background) }) } if scopeSet.Includes(types.STAGING) { @@ -255,7 +255,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { // scope's model-update bounce — RefreshStagingPanel reads // Model.Files (via Files.GetSelected) and would otherwise // see the pre-refresh model. - self.c.OnUIThread(func() error { + self.onUIThread(options.Background, func() error { self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{}) return nil }) @@ -267,10 +267,10 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { } if scopeSet.Includes(types.MERGE_CONFLICTS) { - refresh("merge conflicts", func() { _ = self.mergeConflictsHelper.RefreshMergeState() }) + refresh("merge conflicts", func() { _ = self.mergeConflictsHelper.RefreshMergeState(options.Background) }) } - self.refreshStatus() + self.refreshStatus(options.Background) wg.Wait() @@ -281,7 +281,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { // returned but their bounces haven't been processed yet, so // invoking Then synchronously would run it on a model that's // still pre-refresh. - self.c.OnUIThread(options.Then) + self.onUIThread(options.Background, options.Then) } } @@ -399,27 +399,27 @@ func getModeName(mode types.RefreshMode) string { // order gives the immediate (non-recency) load a lower branch-load sequence // than the async (recency) load, so the sequence guard in refreshBranches keeps // the recency-sorted result even if the two loads' bounces land out of order. -func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, keepBranchSelectionIndex bool) { +func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, keepBranchSelectionIndex bool, background bool) { switch self.c.State().GetRepoState().GetStartupStage() { case types.INITIAL: - self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, false, self.c.Model().ReflogCommits) + self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, false, self.c.Model().ReflogCommits, background) - self.c.OnWorker(func(_ gocui.Task) error { - reflogCommits, _ := self.refreshReflogCommits() - self.refreshBranches(false, true, true, reflogCommits) + self.onWorker(background, func(_ gocui.Task) error { + reflogCommits, _ := self.refreshReflogCommits(background) + self.refreshBranches(false, true, true, reflogCommits, background) self.c.State().GetRepoState().SetStartupStage(types.COMPLETE) return nil }) case types.COMPLETE: - reflogCommits, _ := self.refreshReflogCommits() - self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, true, reflogCommits) + reflogCommits, _ := self.refreshReflogCommits(background) + self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, true, reflogCommits, background) } } -func (self *RefreshHelper) refreshCommitsAndCommitFiles(commitSelection types.CommitSelectionBehavior) { +func (self *RefreshHelper) refreshCommitsAndCommitFiles(commitSelection types.CommitSelectionBehavior, background bool) { generation := self.c.State().GetRepoGeneration() - _ = self.refreshCommitsWithLimit(commitSelection) + _ = self.refreshCommitsWithLimit(commitSelection, background) ctx := self.c.Contexts().CommitFiles.GetParentContext() if ctx != nil && ctx.GetKey() == context.LOCAL_COMMITS_CONTEXT_KEY { // This makes sense when we've e.g. just amended a commit, meaning we get a new commit hash at the same position. @@ -432,13 +432,13 @@ func (self *RefreshHelper) refreshCommitsAndCommitFiles(commitSelection types.Co // The commit selection is restored in refreshCommitsWithLimit's bounce, // so read it on the UI thread after that bounce; then load the commit // files back on a worker (refreshCommitFilesContext does git work). - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { commit := self.c.Contexts().LocalCommits.GetSelected() if commit != nil && commit.RefName() != "" { refRange := self.c.Contexts().LocalCommits.GetSelectedRefRangeForDiffFiles() self.c.Contexts().CommitFiles.ReInit(commit, refRange) - self.c.OnWorker(func(gocui.Task) error { - _ = self.refreshCommitFilesContext() + self.onWorker(background, func(gocui.Task) error { + _ = self.refreshCommitFilesContext(background) return nil }) } @@ -473,7 +473,7 @@ func (self *RefreshHelper) determineCheckedOutRef() models.Ref { return nil } -func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitSelectionBehavior) error { +func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitSelectionBehavior, background bool) error { generation := self.c.State().GetRepoGeneration() var selectionRange *localCommitSelectionRange @@ -502,7 +502,7 @@ func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitS } workingTreeState := self.c.Git().Status.WorkingTreeState() - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Model().BisectInfo = bisectInfo self.c.Model().Commits = commits self.RefreshAuthors(commits) @@ -536,7 +536,7 @@ func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitS // Enqueued from within this bounce so it runs after refreshView's // render below (which was enqueued first), matching the previous // ordering where FocusLine ran after the view was re-rendered. - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Contexts().LocalCommits.FocusLine(true) return nil }) @@ -544,7 +544,7 @@ func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitS return nil }) - self.refreshView(self.c.Contexts().LocalCommits) + self.refreshView(self.c.Contexts().LocalCommits, background) return nil } @@ -623,7 +623,7 @@ func hasRestorableCommitHash(commits []*models.Commit, idx int) bool { return idx >= 0 && idx < len(commits) && commits[idx].Hash() != "" } -func (self *RefreshHelper) refreshSubCommitsWithLimit() error { +func (self *RefreshHelper) refreshSubCommitsWithLimit(background bool) error { if self.c.Contexts().SubCommits.GetRef() == nil { return nil } @@ -646,13 +646,13 @@ func (self *RefreshHelper) refreshSubCommitsWithLimit() error { if err != nil { return err } - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Model().SubCommits = commits self.RefreshAuthors(commits) return nil }) - self.refreshView(self.c.Contexts().SubCommits) + self.refreshView(self.c.Contexts().SubCommits, background) return nil } @@ -668,7 +668,7 @@ func (self *RefreshHelper) RefreshAuthors(commits []*models.Commit) { } } -func (self *RefreshHelper) refreshCommitFilesContext() error { +func (self *RefreshHelper) refreshCommitFilesContext(background bool) error { from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) generation := self.c.State().GetRepoGeneration() @@ -677,16 +677,16 @@ func (self *RefreshHelper) refreshCommitFilesContext() error { if err != nil { return err } - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Model().CommitFiles = files self.c.Contexts().CommitFiles.CommitFileTreeViewModel.SetTree() return nil }) - self.refreshView(self.c.Contexts().CommitFiles) + self.refreshView(self.c.Contexts().CommitFiles, background) return nil } -func (self *RefreshHelper) refreshRebaseCommits() error { +func (self *RefreshHelper) refreshRebaseCommits(background bool) error { generation := self.c.State().GetRepoGeneration() updatedCommits, err := self.c.Git().Loaders.CommitLoader.MergeRebasingCommits(self.c.Model().HashPool, self.c.Model().Commits) @@ -695,17 +695,17 @@ func (self *RefreshHelper) refreshRebaseCommits() error { } workingTreeState := self.c.Git().Status.WorkingTreeState() - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Model().Commits = updatedCommits self.c.Model().WorkingTreeStateAtLastCommitRefresh = workingTreeState return nil }) - self.refreshView(self.c.Contexts().LocalCommits) + self.refreshView(self.c.Contexts().LocalCommits, background) return nil } -func (self *RefreshHelper) refreshTags() error { +func (self *RefreshHelper) refreshTags(background bool) error { generation := self.c.State().GetRepoGeneration() tags, err := self.c.Git().Loaders.TagLoader.GetTags() @@ -713,12 +713,12 @@ func (self *RefreshHelper) refreshTags() error { return err } - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Model().Tags = tags return nil }) - self.refreshView(self.c.Contexts().Tags) + self.refreshView(self.c.Contexts().Tags, background) return nil } @@ -728,7 +728,7 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs() ([]*models.SubmoduleCo // self.refreshStatus is called at the end of this because that's when we can // be sure there is a State.Model.Branches array to pick the current branch from -func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSelectionIndex bool, loadBehindCounts bool, reflogCommits []*models.Commit) { +func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSelectionIndex bool, loadBehindCounts bool, reflogCommits []*models.Commit, background bool) { loadSeq := self.branchLoadSeq.Add(1) generation := self.c.State().GetRepoGeneration() @@ -739,14 +739,14 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele self.c.Model().Branches, loadBehindCounts, func(f func() error) { - self.c.OnWorker(func(_ gocui.Task) error { + self.onWorker(background, func(_ gocui.Task) error { return f() }) }, func() { - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Contexts().Branches.HandleRender() - self.refreshStatus() + self.refreshStatus(background) return nil }) }) @@ -761,7 +761,7 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele worktrees = self.loadWorktrees() } - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { // Drop this write if a branch load that started later has already applied // its result. At the INITIAL startup stage an immediate load (not // recency-sorted) and an async recency-sorted load run concurrently; this @@ -779,7 +779,7 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele if refreshWorktrees { self.c.Model().Worktrees = worktrees - self.refreshView(self.c.Contexts().Worktrees) + self.refreshView(self.c.Contexts().Worktrees, background) } if !keepBranchSelectionIndex && prevSelectedBranch != nil { @@ -798,9 +798,9 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele return nil }) - self.refreshView(self.c.Contexts().Branches) + self.refreshView(self.c.Contexts().Branches, background) - self.refreshStatus() + self.refreshStatus(background) } func (self *RefreshHelper) refreshFilesAndSubmodules(background bool) error { @@ -813,8 +813,8 @@ func (self *RefreshHelper) refreshFilesAndSubmodules(background bool) error { return err } - self.refreshView(self.c.Contexts().Submodules) - self.refreshView(self.c.Contexts().Files) + self.refreshView(self.c.Contexts().Submodules, background) + self.refreshView(self.c.Contexts().Files, background) return nil } @@ -826,8 +826,8 @@ func (self *RefreshHelper) refreshFilesAndSubmodules(background bool) error { // bumps the generation, so a write captured under the old generation must not // clobber the new repo's state. Callers capture the generation with // State().GetRepoGeneration() before doing their git work and pass it in. -func (self *RefreshHelper) onUIThreadUnlessRepoChanged(generation int, f func() error) { - self.c.OnUIThread(func() error { +func (self *RefreshHelper) onUIThreadUnlessRepoChanged(generation int, background bool, f func() error) { + self.onUIThread(background, func() error { if self.c.State().GetRepoGeneration() != generation { return nil } @@ -835,6 +835,27 @@ func (self *RefreshHelper) onUIThreadUnlessRepoChanged(generation int, f func() }) } +// onWorker and onUIThread pick the foreground or background variant of the +// corresponding dispatch method depending on whether we're servicing a +// background refresh. Background refreshes (auto-fetch and friends) must not +// count towards lazygit being busy, or they'd spuriously block a repo switch; +// see the *Background methods on gocui.Gui. +func (self *RefreshHelper) onWorker(background bool, f func(gocui.Task) error) { + if background { + self.c.OnWorkerBackground(f) + } else { + self.c.OnWorker(f) + } +} + +func (self *RefreshHelper) onUIThread(background bool, f func() error) { + if background { + self.c.OnUIThreadBackground(f) + } else { + self.c.OnUIThread(f) + } +} + func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs []*models.SubmoduleConfig) error { fileTreeViewModel := self.c.Contexts().Files.FileTreeViewModel generation := self.c.State().GetRepoGeneration() @@ -898,7 +919,7 @@ func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs [ // (e.g. in the user's editor). Offer to continue it. We only do this // for operations we started ourselves; prompting for one that was // started outside lazygit (e.g. by a coding agent) would be confusing. - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { return self.mergeAndRebaseHelper.PromptToContinueRebase() }) } @@ -913,7 +934,7 @@ func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs [ }) } - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { // only taking over the filter if it hasn't already been set by the user. if conflictFileCount > 0 && prevConflictFileCount == 0 { if fileTreeViewModel.GetStatusFilter() == filetree.DisplayAll { @@ -944,7 +965,7 @@ func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs [ // refreshReflogCommits returns the (non-filtered) ReflogCommits it loaded, so // that a subsequent branches refresh can use them for recency sorting without // having to read them back out of the model. -func (self *RefreshHelper) refreshReflogCommits() ([]*models.Commit, error) { +func (self *RefreshHelper) refreshReflogCommits(background bool) ([]*models.Commit, error) { generation := self.c.State().GetRepoGeneration() // pulling state into its own variable in case it gets swapped out for another state // and we get an out of bounds exception @@ -984,17 +1005,17 @@ func (self *RefreshHelper) refreshReflogCommits() ([]*models.Commit, error) { } } - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { model.ReflogCommits = reflogCommits model.FilteredReflogCommits = filteredReflogCommits return nil }) - self.refreshView(self.c.Contexts().ReflogCommits) + self.refreshView(self.c.Contexts().ReflogCommits, background) return reflogCommits, nil } -func (self *RefreshHelper) refreshRemotes() error { +func (self *RefreshHelper) refreshRemotes(background bool) error { generation := self.c.State().GetRepoGeneration() prevSelectedRemote := self.c.Contexts().Remotes.GetSelected() @@ -1003,14 +1024,14 @@ func (self *RefreshHelper) refreshRemotes() error { return err } - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Model().Remotes = remotes hadPrs := len(self.c.Model().PullRequestsMap) != 0 self.rebuildPullRequestsMap() if !hadPrs && len(self.c.Model().PullRequestsMap) != 0 { // 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) + self.refreshView(self.c.Contexts().Branches, background) } // we need to ensure our selected remote branches aren't now outdated @@ -1026,8 +1047,8 @@ func (self *RefreshHelper) refreshRemotes() error { return nil }) - self.refreshView(self.c.Contexts().Remotes) - self.refreshView(self.c.Contexts().RemoteBranches) + self.refreshView(self.c.Contexts().Remotes, background) + self.refreshView(self.c.Contexts().RemoteBranches, background) return nil } @@ -1040,44 +1061,44 @@ func (self *RefreshHelper) loadWorktrees() []*models.Worktree { return worktrees } -func (self *RefreshHelper) refreshWorktrees() { +func (self *RefreshHelper) refreshWorktrees(background bool) { generation := self.c.State().GetRepoGeneration() worktrees := self.loadWorktrees() - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Model().Worktrees = worktrees return nil }) // need to refresh branches because the branches view shows worktrees against // branches - self.refreshView(self.c.Contexts().Branches) - self.refreshView(self.c.Contexts().Worktrees) + self.refreshView(self.c.Contexts().Branches, background) + self.refreshView(self.c.Contexts().Worktrees, background) } -func (self *RefreshHelper) refreshStashEntries() { +func (self *RefreshHelper) refreshStashEntries(background bool) { generation := self.c.State().GetRepoGeneration() stashEntries := self.c.Git().Loaders.StashLoader. GetStashEntries(self.c.Modes().Filtering.GetPath()) - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Model().StashEntries = stashEntries return nil }) - self.refreshView(self.c.Contexts().Stash) + self.refreshView(self.c.Contexts().Stash, background) } // never call this on its own, it should only be called from within refreshCommits() -func (self *RefreshHelper) refreshStatus() { +func (self *RefreshHelper) refreshStatus(background bool) { generation := self.c.State().GetRepoGeneration() workingTreeState := self.c.Git().Status.WorkingTreeState() repoName := self.c.Git().RepoPaths.RepoName() - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { // Read the checked-out branch and the linked worktree name here on the UI // thread: both derive from models (Branches, Worktrees) that their // refreshes now write via bounces, so reading them on the worker would @@ -1114,10 +1135,10 @@ func (self *RefreshHelper) refForLog() (string, *git_commands.BisectInfo) { return bisectInfo.GetStartHash(), bisectInfo } -func (self *RefreshHelper) refreshView(context types.Context) { +func (self *RefreshHelper) refreshView(context types.Context, background bool) { // refreshView is called from the worker goroutine that drives async // refreshes, so bounce to the UI thread before mutating view content. - self.c.OnUIThread(func() error { + self.onUIThread(background, func() error { // Re-applying the filter must be done before re-rendering the view, so that // the filtered list model is up to date for rendering. self.searchHelper.ReApplyFilter(context) @@ -1140,11 +1161,11 @@ func (self *RefreshHelper) refreshView(context types.Context) { }) } -func (self *RefreshHelper) refreshGithubPullRequests() { +func (self *RefreshHelper) refreshGithubPullRequests(background bool) { generation := self.c.State().GetRepoGeneration() clearPullRequests := func() { - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Model().PullRequests = nil self.c.Model().PullRequestsMap = nil return nil @@ -1167,7 +1188,7 @@ func (self *RefreshHelper) refreshGithubPullRequests() { return } - self.setGithubPullRequests(baseInfo) + self.setGithubPullRequests(baseInfo, background) } type githubRemoteInfo struct { @@ -1248,7 +1269,7 @@ func (self *RefreshHelper) promptForBaseGithubRepo(githubRemotes []githubRemoteI self.c.Log.Error(err) } - self.setGithubPullRequests(&info) + self.setGithubPullRequests(&info, false) return nil }) }, @@ -1276,7 +1297,7 @@ func (self *RefreshHelper) rebuildPullRequestsMap() { ) } -func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo) { +func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, background bool) { generation := self.c.State().GetRepoGeneration() if len(self.c.Model().Branches) == 0 { @@ -1298,7 +1319,7 @@ func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo) { self.savePullRequestsToCache(prs) - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Model().PullRequests = prs // Rebuilding here rather than on the worker means the map is built from // the branches and remotes as they are on the UI thread, after their From 8655d3f5a59d35cfc4176d66defed263b62d64a5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 4 Jul 2026 07:49:04 +0200 Subject: [PATCH 28/59] Exclude view-buffer render tasks from the busy query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo-switch busy query must not count view-buffer content rendering: those tasks paint a view rather than drive a git operation, so leaving one running across a switch is harmless (the switch's own refresh re-renders). More importantly, they fire on nearly every focus/selection change — including the context activation that runs right before a menu/prompt confirmation handler (e.g. confirming worktree creation). A synchronous busy check in such a handler would otherwise see that render and make the very switch the handler is about to request refuse itself. Route ViewBufferManager's tasks through a new gocui NewBackgroundTask so they're tracked for idle detection but excluded from the busy query. The task "background" flag now covers two kinds of non-blocking work: the background routines (and their refreshes) tagged earlier, and view rendering. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/gui.go | 7 +++++++ pkg/gocui/task.go | 12 ++++++++---- pkg/gui/tasks_adapter.go | 9 ++++++++- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index ff113a2dd..a13744997 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -282,6 +282,13 @@ func (g *Gui) NewTask() *TaskImpl { return g.taskManager.NewTask(false) } +// NewBackgroundTask creates a task that is tracked for idle detection but does +// not count towards the program being busy for repo-switch safety. See +// TaskImpl.background. +func (g *Gui) NewBackgroundTask() *TaskImpl { + return g.taskManager.NewTask(true) +} + // Busy reports whether any foreground work is in flight, ignoring the event // currently being processed on the main goroutine (see currentTask). Background // routines (auto-fetch etc.) don't count. It's used to decide whether it's safe diff --git a/pkg/gocui/task.go b/pkg/gocui/task.go index 377781a4f..08a77463f 100644 --- a/pkg/gocui/task.go +++ b/pkg/gocui/task.go @@ -20,10 +20,14 @@ type TaskImpl struct { withMutex func(func()) // Background tasks don't count towards the program being "busy" for the // purpose of deciding whether a repo switch is safe (see - // TaskManager.hasBusyForegroundTaskExcept). They're the ongoing background - // routines (auto-fetch, files refresh, external-change detection) and the - // refreshes they trigger, whose model writes are already guarded against a - // concurrent repo switch by the repo generation. + // TaskManager.hasBusyForegroundTaskExcept). Two kinds of work are tagged + // this way: the ongoing background routines (auto-fetch, files refresh, + // external-change detection) and the refreshes they trigger, whose model + // writes are already guarded against a concurrent repo switch by the repo + // generation; and view-buffer content rendering, which only paints a view + // and so is harmless to leave running across a switch. What stays + // foreground is lazygit driving a git operation and applying its results + // to the model — exactly the work a repo switch must not run underneath. background bool } diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 09edd2d36..dd7999107 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -136,7 +136,14 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager { view.SetOrigin(0, 0) }, func() gocui.Task { - return gui.c.GocuiGui().NewTask() + // A background task: rendering content into a view is display + // work, not lazygit driving a git operation, so it must not + // count towards being busy and block a repo switch. These + // renders fire on nearly every focus/selection change, including + // the context activation that happens right before a menu/prompt + // handler runs (e.g. confirming worktree creation), which would + // otherwise make the switch that handler triggers refuse itself. + return gui.c.GocuiGui().NewBackgroundTask() }, ) gui.viewBufferManagerMap[view.Name()] = manager From 56932abe0659edfa0bac2704366d6d8a431ab709 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 4 Jul 2026 07:49:15 +0200 Subject: [PATCH 29/59] Refuse a repo switch while a foreground operation is in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching repos reassigns gui.git and the process cwd; doing it while a foreground git operation (rebase/commit/push/…) is mid-flight would run that operation's remaining commands against the wrong repo. The same applies while the refresh an operation triggers is still settling: its model writes are generation-guarded, but the client-side Then/OnUIThread callbacks that run after it aren't, and shouldn't run against a repo that changed underneath them. Refuse the switch (with a toast) whenever gocui reports a busy foreground task. DispatchSwitchTo carries the guard for the simple callers. The callers that do work before the switch check up front instead, so a refused switch doesn't leave that work half-done: worktree creation checks before creating (its own waiting-status spinner would otherwise make the query busy and refuse its own switch); submodule-enter and the recent-repos menu check before mutating the repo-path stack (pushing / clearing it); and escape-to-parent (SwitchToParentRepo) checks before popping it, so a refusal doesn't consume the entry and strand the user with nowhere to escape back to. All then call the unguarded switchTo, which is safe because their own operation is complete by then. --- pkg/gui/controllers/helpers/repos_helper.go | 65 ++++++++++++++++--- .../controllers/helpers/worktree_helper.go | 13 +++- pkg/gui/controllers/quit_actions.go | 6 +- pkg/i18n/english.go | 2 + 4 files changed, 70 insertions(+), 16 deletions(-) diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index 6257327e9..a61ad0013 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -43,13 +43,20 @@ func NewRecentReposHelper( } func (self *ReposHelper) EnterSubmodule(submodule *models.SubmoduleConfig) error { + // Check before pushing onto the repo-path stack, so a refused switch + // doesn't leave a stale entry there (which escape would later switch back + // to, needlessly reloading the current repo). + if self.switchRefusedBecauseBusy() { + return nil + } + wd, err := os.Getwd() if err != nil { return err } self.c.State().GetRepoPathStack().Push(wd) - return self.DispatchSwitchToRepo(submodule.FullPath(), context.NO_CONTEXT) + return self.switchTo(submodule.FullPath(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT) } func (self *ReposHelper) getCurrentBranch(path string) string { @@ -129,10 +136,16 @@ func (self *ReposHelper) CreateRecentReposMenu() error { style.FgMagenta.Sprint(path), }, OnPress: func() error { + // Check before clearing the stack, so a refused switch doesn't + // forget the submodule breadcrumb (which would leave escape + // unable to return to the parent repo). + if self.switchRefusedBecauseBusy() { + return nil + } // if we were in a submodule, we want to forget about that stack of repos // so that hitting escape in the new repo does nothing self.c.State().GetRepoPathStack().Clear() - return self.DispatchSwitchToRepo(path, context.NO_CONTEXT) + return self.switchTo(path, self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT) }, } }) @@ -140,17 +153,49 @@ func (self *ReposHelper) CreateRecentReposMenu() error { return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.RecentRepos, Items: menuItems}) } -func (self *ReposHelper) DispatchSwitchToRepo(path string, contextKey types.ContextKey) error { - return self.DispatchSwitchTo(path, self.c.Tr.ErrRepositoryMovedOrDeleted, contextKey) +// SwitchToParentRepo switches back to the repo the current submodule was +// entered from (the top of the repo-path stack). Like the other callers that do +// work before switching, it checks for an in-flight operation *before* popping +// the stack, so a refused switch leaves the stack intact — otherwise the entry +// would be consumed and escape would no longer return to the parent once the +// operation finished. The caller must only call this when the stack is +// non-empty. +func (self *ReposHelper) SwitchToParentRepo() error { + if self.switchRefusedBecauseBusy() { + return nil + } + return self.switchTo(self.c.State().GetRepoPathStack().Pop(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT) } -// 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 { + if self.switchRefusedBecauseBusy() { + return nil + } + return self.switchTo(path, errMsg, contextKey) +} + +// switchRefusedBecauseBusy reports (and shows a toast) whether a repo switch +// must be refused because a foreground git operation is in flight. Switching +// reassigns gui.git and the process cwd, so switching mid-operation would run +// the operation's remaining git commands against the wrong repo. Callers that +// do work before the switch (creating a worktree, recording the repo-path +// stack) check this up front, so they don't do that work only to have the +// switch refused; the switch itself (switchTo) is then unguarded. +func (self *ReposHelper) switchRefusedBecauseBusy() bool { + if self.c.GocuiGui().Busy() { + self.c.ErrorToast(self.c.Tr.CantSwitchWhileOperationInProgress) + return true + } + return false +} + +// switchTo 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) switchTo(path string, errMsg string, contextKey types.ContextKey) error { env.UnsetGitLocationEnvVars() originalPath, err := os.Getwd() if err != nil { diff --git a/pkg/gui/controllers/helpers/worktree_helper.go b/pkg/gui/controllers/helpers/worktree_helper.go index 2a189a752..abfd1c0f6 100644 --- a/pkg/gui/controllers/helpers/worktree_helper.go +++ b/pkg/gui/controllers/helpers/worktree_helper.go @@ -426,6 +426,13 @@ func (self *WorktreeHelper) promptForWorktreeLocation(dirName string, prompt str } func (self *WorktreeHelper) createWorktree(opts git_commands.NewWorktreeOpts, contextKey types.ContextKey) error { + // Check now, before we create the worktree, rather than when we come to + // switch to it afterwards: by then this operation's own waiting-status + // spinner would make Busy() true and refuse our own switch. + if self.reposHelper.switchRefusedBecauseBusy() { + return nil + } + return self.c.WithWaitingStatus(self.c.Tr.AddingWorktree, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.AddWorktree) if err := self.c.Git().Worktree.New(opts); err != nil { @@ -434,9 +441,11 @@ func (self *WorktreeHelper) createWorktree(opts git_commands.NewWorktreeOpts, co // 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. + // dispatch it. It's unguarded (switchTo, not DispatchSwitchTo) + // because we checked above and creating the worktree is now + // complete, so switching to it is safe. self.c.OnUIThread(func() error { - return self.reposHelper.DispatchSwitchTo(opts.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, contextKey) + return self.reposHelper.switchTo(opts.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, contextKey) }) return nil }) diff --git a/pkg/gui/controllers/quit_actions.go b/pkg/gui/controllers/quit_actions.go index 40ad6f7e3..9a7082542 100644 --- a/pkg/gui/controllers/quit_actions.go +++ b/pkg/gui/controllers/quit_actions.go @@ -2,7 +2,6 @@ package controllers import ( "github.com/jesseduffield/lazygit/pkg/gocui" - "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" ) @@ -81,9 +80,8 @@ func (self *QuitActions) Escape() error { } } - repoPathStack := self.c.State().GetRepoPathStack() - if !repoPathStack.IsEmpty() { - return self.c.Helpers().Repos.DispatchSwitchToRepo(repoPathStack.Pop(), context.NO_CONTEXT) + if !self.c.State().GetRepoPathStack().IsEmpty() { + return self.c.Helpers().Repos.SwitchToParentRepo() } if self.c.UserConfig().QuitOnTopLevelReturn { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 0ee0a9e86..69ea7012f 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -769,6 +769,7 @@ type TranslationSet struct { ErrStageDirWithInlineMergeConflicts string ErrRepositoryMovedOrDeleted string ErrWorktreeMovedOrRemoved string + CantSwitchWhileOperationInProgress string CommandLog string ToggleShowCommandLog string FocusCommandLog string @@ -1921,6 +1922,7 @@ func EnglishTranslationSet() *TranslationSet { ErrRepositoryMovedOrDeleted: "Cannot find repo. It might have been moved or deleted ¯\\_(ツ)_/¯", CommandLog: "Command log", ErrWorktreeMovedOrRemoved: "Cannot find worktree. It might have been moved or removed ¯\\_(ツ)_/¯", + CantSwitchWhileOperationInProgress: "Can't switch repositories while an operation is in progress", ToggleShowCommandLog: "Toggle show/hide command log", FocusCommandLog: "Focus command log", CommandLogHeader: "You can hide/focus this panel by pressing '%s'\n", From 5414daf492901464734e145430bd6325026f73c9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 4 Jul 2026 12:28:13 +0200 Subject: [PATCH 30/59] Exclude toast rendering from the busy query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A toast keeps a foreground spinner task alive for its whole lifetime (~2-4s): showing one calls renderAppStatus, whose OnWorker loop runs until the status string clears. With the repo-switch guard in place that made the guard's own "can't switch, operation in progress" toast keep Busy() true, so the next escape/switch was refused until the toast faded — you had to wait it out. Render toasts in the background, like view-buffer content: a toast is a transient notification, not lazygit driving an operation, so a switch during one is fine. A real operation that shows a toast still keeps its own foreground task busy independently. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/app_status_helper.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/helpers/app_status_helper.go b/pkg/gui/controllers/helpers/app_status_helper.go index bffd87866..90b87b3b8 100644 --- a/pkg/gui/controllers/helpers/app_status_helper.go +++ b/pkg/gui/controllers/helpers/app_status_helper.go @@ -34,7 +34,12 @@ func (self *AppStatusHelper) Toast(message string, kind types.ToastKind) { self.statusMgr().AddToastStatus(message, kind) - self.renderAppStatus(false) + // Render the toast in the background: it's a transient notification, not + // lazygit driving an operation, so it must not count towards being busy — + // otherwise a toast (e.g. the "can't switch, operation in progress" one) + // would itself block a repo switch until it faded. A real operation showing + // a toast still keeps its own foreground task busy independently. + self.renderAppStatus(true) } // A custom task for WithWaitingStatus calls; it wraps the original one and From bd6081d601389a4814b5c964b94a950b53b8b266 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 4 Jul 2026 21:18:38 +0200 Subject: [PATCH 31/59] Select the checked-out branch via a refresh intent, not off-thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- pkg/gui/controllers/branches_controller.go | 8 +-- pkg/gui/controllers/helpers/refresh_helper.go | 59 +++++++++++++------ pkg/gui/controllers/helpers/refs_helper.go | 47 ++++++--------- pkg/gui/controllers/remotes_controller.go | 17 ++++-- pkg/gui/types/refresh.go | 31 ++++++++-- 5 files changed, 99 insertions(+), 63 deletions(-) diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index 131d19439..a5c55884b 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -599,11 +599,11 @@ func (self *BranchesController) createNewBranchWithName(newBranchName string) er return err } - self.c.Helpers().Refs.SelectFirstBranchAndFirstCommit() self.c.Refresh(types.RefreshOptions{ - Mode: types.ASYNC, - KeepBranchSelectionIndex: true, - CommitSelection: types.KeepCommitSelectionIndex, + Mode: types.ASYNC, + BranchSelection: types.SelectCheckedOutBranch, + CommitSelection: types.SelectHeadCommit, + SelectTopReflogCommit: true, }) return nil } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index c0543d095..cb1856aa9 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -183,7 +183,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" { branchesAndRemotesWg.Add(1) refresh("reflog and branches", func() { - self.refreshReflogAndBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex, options.Background) + self.refreshReflogAndBranches(includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, options.Background) branchesAndRemotesWg.Done() }) } else { @@ -192,10 +192,10 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { // Not a recency sort, so branches doesn't depend on the reflog // being fresh; it runs concurrently with the reflog refresh // below and reads whatever's in the model, as it always has. - self.refreshBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex, true, self.c.Model().ReflogCommits, options.Background) + self.refreshBranches(includeWorktreesWithBranches, options.BranchSelection, true, self.c.Model().ReflogCommits, options.Background) branchesAndRemotesWg.Done() }) - refresh("reflog", func() { _, _ = self.refreshReflogCommits(options.Background) }) + refresh("reflog", func() { _, _ = self.refreshReflogCommits(options.Background, options.SelectTopReflogCommit) }) } } else if scopeSet.Includes(types.REBASE_COMMITS) { // the above block handles rebase commits so we only need to call this one @@ -399,21 +399,21 @@ func getModeName(mode types.RefreshMode) string { // order gives the immediate (non-recency) load a lower branch-load sequence // than the async (recency) load, so the sequence guard in refreshBranches keeps // the recency-sorted result even if the two loads' bounces land out of order. -func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, keepBranchSelectionIndex bool, background bool) { +func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, background bool) { switch self.c.State().GetRepoState().GetStartupStage() { case types.INITIAL: - self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, false, self.c.Model().ReflogCommits, background) + self.refreshBranches(refreshWorktrees, branchSelection, false, self.c.Model().ReflogCommits, background) self.onWorker(background, func(_ gocui.Task) error { - reflogCommits, _ := self.refreshReflogCommits(background) - self.refreshBranches(false, true, true, reflogCommits, background) + reflogCommits, _ := self.refreshReflogCommits(background, false) + self.refreshBranches(false, types.SelectCheckedOutBranch, true, reflogCommits, background) self.c.State().GetRepoState().SetStartupStage(types.COMPLETE) return nil }) case types.COMPLETE: - reflogCommits, _ := self.refreshReflogCommits(background) - self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, true, reflogCommits, background) + reflogCommits, _ := self.refreshReflogCommits(background, selectTopReflogCommit) + self.refreshBranches(refreshWorktrees, branchSelection, true, reflogCommits, background) } } @@ -728,7 +728,7 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs() ([]*models.SubmoduleCo // self.refreshStatus is called at the end of this because that's when we can // be sure there is a State.Model.Branches array to pick the current branch from -func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSelectionIndex bool, loadBehindCounts bool, reflogCommits []*models.Commit, background bool) { +func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, background bool) { loadSeq := self.branchLoadSeq.Add(1) generation := self.c.State().GetRepoGeneration() @@ -754,8 +754,6 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele self.c.Log.Error(err) } - prevSelectedBranch := self.c.Contexts().Branches.GetSelected() - var worktrees []*models.Worktree if refreshWorktrees { worktrees = self.loadWorktrees() @@ -772,6 +770,11 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele } self.appliedBranchLoadSeq = loadSeq + // Read the currently-selected branch before overwriting the list, so we + // can restore it by name below. Reading it here in the bounce keeps it on + // the UI thread. + prevSelectedBranch := self.c.Contexts().Branches.GetSelected() + self.c.Model().Branches = branches // Rebuilding here (rather than on the worker) means the map is built from // the branches we just wrote, on the UI thread. @@ -782,14 +785,25 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele self.refreshView(self.c.Contexts().Worktrees, background) } - if !keepBranchSelectionIndex && prevSelectedBranch != nil { - self.searchHelper.ReApplyFilter(self.c.Contexts().Branches) + // Setting the selection here, in the same bounce that writes the list, + // keeps it on the UI thread and keeps the list and selection updating in + // the same frame. + switch branchSelection { + case types.KeepBranchSelectionByName: + if prevSelectedBranch != nil { + self.searchHelper.ReApplyFilter(self.c.Contexts().Branches) - _, idx, found := lo.FindIndexOf(self.c.Contexts().Branches.GetItems(), - func(b *models.Branch) bool { return b.Name == prevSelectedBranch.Name }) - if found { - self.c.Contexts().Branches.SetSelectedLineIdx(idx) + _, idx, found := lo.FindIndexOf(self.c.Contexts().Branches.GetItems(), + func(b *models.Branch) bool { return b.Name == prevSelectedBranch.Name }) + if found { + self.c.Contexts().Branches.SetSelectedLineIdx(idx) + } } + case types.SelectCheckedOutBranch: + // The checked-out branch is always at the top of the list. Setting + // the selection doesn't scroll the view, so also reset the origin. + self.c.Contexts().Branches.SetSelectedLineIdx(0) + self.c.Contexts().Branches.GetView().SetOriginY(0) } // Need to re-render the commits view because the visualization of local @@ -965,7 +979,7 @@ func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs [ // refreshReflogCommits returns the (non-filtered) ReflogCommits it loaded, so // that a subsequent branches refresh can use them for recency sorting without // having to read them back out of the model. -func (self *RefreshHelper) refreshReflogCommits(background bool) ([]*models.Commit, error) { +func (self *RefreshHelper) refreshReflogCommits(background bool, selectTopEntry bool) ([]*models.Commit, error) { generation := self.c.State().GetRepoGeneration() // pulling state into its own variable in case it gets swapped out for another state // and we get an out of bounds exception @@ -1008,6 +1022,13 @@ func (self *RefreshHelper) refreshReflogCommits(background bool) ([]*models.Comm self.onUIThreadUnlessRepoChanged(generation, background, func() error { model.ReflogCommits = reflogCommits model.FilteredReflogCommits = filteredReflogCommits + // Setting the selection here, in the same bounce that writes the list, + // keeps it on the UI thread and atomic with the list update. Setting the + // selection doesn't scroll the view, so also reset the origin. + if selectTopEntry { + self.c.Contexts().ReflogCommits.SetSelectedLineIdx(0) + self.c.Contexts().ReflogCommits.GetView().SetOriginY(0) + } return nil }) diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index b90b9150b..8d5e8397c 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -31,15 +31,6 @@ func NewRefsHelper( } } -func (self *RefsHelper) SelectFirstBranchAndFirstCommit() { - self.c.Contexts().Branches.SetSelection(0) - self.c.Contexts().ReflogCommits.SetSelection(0) - self.c.Contexts().LocalCommits.SetSelection(0) - self.c.Contexts().Branches.GetView().SetOriginY(0) - self.c.Contexts().ReflogCommits.GetView().SetOriginY(0) - self.c.Contexts().LocalCommits.GetView().SetOriginY(0) -} - func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions) error { waitingStatus := options.WaitingStatus if waitingStatus == "" { @@ -49,8 +40,6 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions cmdOptions := git_commands.CheckoutOptions{Force: false, EnvVars: options.EnvVars} refresh := func() { - self.SelectFirstBranchAndFirstCommit() - // loading a heap of commits is slow so we limit them whenever doing a reset self.c.Contexts().LocalCommits.SetLimitCommits(true) @@ -67,10 +56,11 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions scope = append(scope, types.PULL_REQUESTS) } self.c.Refresh(types.RefreshOptions{ - Mode: types.BLOCK_UI, - Scope: scope, - KeepBranchSelectionIndex: true, - CommitSelection: types.KeepCommitSelectionIndex, + Mode: types.BLOCK_UI, + Scope: scope, + BranchSelection: types.SelectCheckedOutBranch, + CommitSelection: types.SelectHeadCommit, + SelectTopReflogCommit: true, }) } @@ -375,12 +365,11 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{}) } - self.SelectFirstBranchAndFirstCommit() - self.c.Refresh(types.RefreshOptions{ - Mode: types.BLOCK_UI, - KeepBranchSelectionIndex: true, - CommitSelection: types.KeepCommitSelectionIndex, + Mode: types.BLOCK_UI, + BranchSelection: types.SelectCheckedOutBranch, + CommitSelection: types.SelectHeadCommit, + SelectTopReflogCommit: true, }) } @@ -534,12 +523,11 @@ func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchNa } } - self.SelectFirstBranchAndFirstCommit() - self.c.Refresh(types.RefreshOptions{ - Mode: types.BLOCK_UI, - KeepBranchSelectionIndex: true, - CommitSelection: types.KeepCommitSelectionIndex, + Mode: types.BLOCK_UI, + BranchSelection: types.SelectCheckedOutBranch, + CommitSelection: types.SelectHeadCommit, + SelectTopReflogCommit: true, }) return nil } @@ -576,12 +564,11 @@ func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName stri } } - self.SelectFirstBranchAndFirstCommit() - self.c.Refresh(types.RefreshOptions{ - Mode: types.BLOCK_UI, - KeepBranchSelectionIndex: true, - CommitSelection: types.KeepCommitSelectionIndex, + Mode: types.BLOCK_UI, + BranchSelection: types.SelectCheckedOutBranch, + CommitSelection: types.SelectHeadCommit, + SelectTopReflogCommit: true, }) return nil } diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index e4f606ca3..8bd19ad81 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -376,10 +376,19 @@ func (self *RemotesController) fetchAndCheckout(remote *models.Remote, branchNam 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 + // 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) diff --git a/pkg/gui/types/refresh.go b/pkg/gui/types/refresh.go index 591aff5f3..f4041bb2e 100644 --- a/pkg/gui/types/refresh.go +++ b/pkg/gui/types/refresh.go @@ -55,22 +55,41 @@ const ( SelectHeadCommit ) +// BranchSelectionBehavior controls which local branch is selected after the +// branches list is reloaded by a refresh. +type BranchSelectionBehavior int + +const ( + // Keep the same branch selected by name, restoring it at its new position if + // the order changed. This is the right default whenever the list reloads + // underneath a selection the user hasn't deliberately changed. + KeepBranchSelectionByName BranchSelectionBehavior = iota + + // Select the checked-out branch (the one at the top of the list). Used after + // operations that check something out - checkout, creating a branch, moving + // commits to a new branch - so the newly checked-out ref ends up selected. + SelectCheckedOutBranch +) + type RefreshOptions struct { Then func() error Scope []RefreshableView // e.g. []RefreshableView{COMMITS, BRANCHES}. Leave empty to refresh everything Mode RefreshMode // one of SYNC (default), ASYNC, and BLOCK_UI - // Normally a refresh of the branches tries to keep the same branch selected - // (by name); this is usually important in case the order of branches - // changes. Passing true for KeepBranchSelectionIndex suppresses this and - // keeps the selection index the same. Useful after checking out a detached - // head, and selecting index 0. - KeepBranchSelectionIndex bool + // Controls which local branch is selected after the refresh. Defaults to + // KeepBranchSelectionByName. + BranchSelection BranchSelectionBehavior // Controls which local commit is selected after the refresh. Defaults to // KeepCommitSelectionByHash. CommitSelection CommitSelectionBehavior + // When true, select the top (most recent) reflog entry after the refresh. + // Used alongside SelectCheckedOutBranch by operations that check something + // out, since the checkout adds a new reflog entry at the top. Defaults to + // keeping the reflog selection where it is. + SelectTopReflogCommit bool + // When true, this refresh was initiated by a background routine rather than // by a user action. Every git command suppresses optional locks by default // so it can't contend for index.lock (see git_commands.OptionalLocksEnvVar); From 7c4d8045f9dbcab76c8f627d3c9735de56af37ed Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 5 Jul 2026 17:12:42 +0200 Subject: [PATCH 32/59] Clamp the commit-file tree selection when the tree is rebuilt CommitFileTreeViewModel embedded the low-level tree's SetTree, which rebuilds the node list without touching the cursor. So after a shrinking rebuild (e.g. moving a patch out into the index removes a file), the selection index could be left past the end of the tree. GetSelectedItems then indexes out of range and returns a nil node, which segfaults callers such as canEditFiles when the options map is rendered during layout. Override SetTree to ClampSelection after the rebuild. Unlike FileTreeViewModel we deliberately don't also re-find the selected node by path: that walk lands on the containing directory when a file is removed from a dir that then collapses, whereas keeping the clamped index lands on the sibling file (see discard_old_file_changes). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../filetree/commit_file_tree_view_model.go | 16 ++++++++ .../commit_file_tree_view_model_test.go | 40 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 pkg/gui/filetree/commit_file_tree_view_model_test.go diff --git a/pkg/gui/filetree/commit_file_tree_view_model.go b/pkg/gui/filetree/commit_file_tree_view_model.go index e33316788..a58f7d93e 100644 --- a/pkg/gui/filetree/commit_file_tree_view_model.go +++ b/pkg/gui/filetree/commit_file_tree_view_model.go @@ -142,6 +142,22 @@ func (self *CommitFileTreeViewModel) GetSelectedPath() string { return node.GetPath() } +// SetTree rebuilds the tree and clamps the selection so it stays in range. The +// embedded tree's SetTree only rebuilds the node list and doesn't touch the +// cursor, so after a shrinking rebuild (e.g. moving a patch out into the index) +// the selection index could be left past the end of the tree; GetSelectedItems +// would then return a nil node and crash callers such as canEditFiles when the +// options map is rendered during layout. +// +// Unlike FileTreeViewModel.SetTree we don't re-find the selected node by path +// afterwards: that walk lands on the containing directory when a file is removed +// from a dir that then collapses, whereas keeping the (clamped) index lands on +// the sibling file, which is what we want here. +func (self *CommitFileTreeViewModel) SetTree() { + self.ICommitFileTree.SetTree() + self.ClampSelection() +} + // duplicated from file_tree_view_model.go. Generics will help here func (self *CommitFileTreeViewModel) ToggleShowTree() { selectedNode := self.GetSelected() diff --git a/pkg/gui/filetree/commit_file_tree_view_model_test.go b/pkg/gui/filetree/commit_file_tree_view_model_test.go new file mode 100644 index 000000000..c8862f6f9 --- /dev/null +++ b/pkg/gui/filetree/commit_file_tree_view_model_test.go @@ -0,0 +1,40 @@ +package filetree + +import ( + "testing" + + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/common" + "github.com/stretchr/testify/assert" +) + +// When the tree shrinks under the selection - e.g. moving a patch out into the +// index removes a file - SetTree must keep the selection in range. Otherwise +// GetSelectedItems returns a nil node, which crashes callers such as +// canEditFiles when the options map is rendered during layout. +func TestCommitFileTreeViewModelSetTreeClampsSelectionOnShrink(t *testing.T) { + files := []*models.CommitFile{ + {Path: "file1"}, + {Path: "file2"}, + {Path: "file3"}, + } + viewModel := NewCommitFileTreeViewModel( + func() []*models.CommitFile { return files }, + common.NewDummyCommon(), + false, // flat list + ) + viewModel.SetTree() + viewModel.SetSelectedLineIdx(viewModel.Len() - 1) + + // The file under the cursor goes away and the tree shrinks. + files = []*models.CommitFile{{Path: "file1"}} + viewModel.SetTree() + + assert.Less(t, viewModel.GetSelectedLineIdx(), viewModel.Len()) + assert.NotNil(t, viewModel.GetSelected()) + items, _, _ := viewModel.GetSelectedItems() + assert.NotEmpty(t, items) + for _, item := range items { + assert.NotNil(t, item) + } +} From 23cfa9b070900dc0ca785a1e8f2254e6ce7ae602 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 5 Jul 2026 20:50:33 +0200 Subject: [PATCH 33/59] Also refresh branches and remotes when refreshing pull requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pull-request fetch queries GitHub for the tracking branches' upstreams against the configured remotes. It therefore depends on the branches and remotes being up to date; a refresh that asks for pull requests but not for those (e.g. checking out a branch) would fetch against a stale branch/remote list — for instance missing the PR of the branch just checked out. Expand the scope so pull requests always co-refresh branches and remotes. This also sets up the next commit to hand the freshly-loaded branches and remotes straight to the fetch, instead of reading them back from the model (which, now that those writes are bounced onto the UI thread, would be stale on the worker). Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index cb1856aa9..c23c01d80 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -131,6 +131,8 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { // can move HEAD), so refresh commits + branches alongside // - submodules are refreshed as part of the files refresh // - merge conflicts are part of what the files refresh produces + // - pull requests are fetched for the tracking branches against the + // remotes, so refresh both alongside to fetch against fresh data if scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) { scopeSet.Add(types.COMMITS, types.BRANCHES) } @@ -140,6 +142,9 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { if scopeSet.Includes(types.FILES) { scopeSet.Add(types.MERGE_CONFLICTS) } + if scopeSet.Includes(types.PULL_REQUESTS) { + scopeSet.Add(types.BRANCHES, types.REMOTES) + } // Capture the refs snapshot now, before we start reading git's state // below, rather than after. This is important to guard against the race From f0ea537956e938c633345c3a45139388daef46fa Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 5 Jul 2026 20:55:13 +0200 Subject: [PATCH 34/59] Fetch pull requests using the freshly-loaded branches and remotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR fetch needs the current branches (for their upstreams) and remotes to know what to query. It read them from Model().Branches / Model().Remotes on its own worker, after waiting on branchesAndRemotesWg for the branches and remotes refreshes to finish. That wait no longer guarantees fresh data: those refreshes now write the model in a bounce onto the UI thread, and Done() fires before the bounce has been processed. So the fetch read the pre-refresh lists — most visibly, checking out a branch that has a PR wouldn't show that PR until the next refresh, because the fetch queried the old branch set. Have refreshBranches / refreshReflogAndBranches / refreshRemotes return what they loaded, stash it in locals in Refresh, and hand it to the fetch. The wait on branchesAndRemotesWg orders the fetch after both loads have stored their slices, so it fetches against exactly the branches and remotes that were just loaded, with no model read on the worker. The previous commit guarantees both are always in scope when pull requests are, so no fallback is needed. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 69 ++++++++++++------- 1 file changed, 46 insertions(+), 23 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index c23c01d80..5e15e17c1 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -175,6 +175,14 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { } branchesAndRemotesWg := sync.WaitGroup{} + // The pull-request fetch (below) needs the just-loaded branches and + // remotes. Their model writes are bounced onto the UI thread, so the + // fetch worker can't read them back from the model without racing (and + // would see the pre-refresh values); instead the branches and remotes + // loads stash what they loaded here, and the wait on + // branchesAndRemotesWg gives the fetch the happens-before to read them. + var loadedBranches []*models.Branch + var loadedRemotes []*models.Remote includeWorktreesWithBranches := false if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) { // whenever we change commits, we should update branches because the upstream/downstream @@ -188,7 +196,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" { branchesAndRemotesWg.Add(1) refresh("reflog and branches", func() { - self.refreshReflogAndBranches(includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, options.Background) + loadedBranches = self.refreshReflogAndBranches(includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, options.Background) branchesAndRemotesWg.Done() }) } else { @@ -197,7 +205,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { // Not a recency sort, so branches doesn't depend on the reflog // being fresh; it runs concurrently with the reflog refresh // below and reads whatever's in the model, as it always has. - self.refreshBranches(includeWorktreesWithBranches, options.BranchSelection, true, self.c.Model().ReflogCommits, options.Background) + loadedBranches = self.refreshBranches(includeWorktreesWithBranches, options.BranchSelection, true, self.c.Model().ReflogCommits, options.Background) branchesAndRemotesWg.Done() }) refresh("reflog", func() { _, _ = self.refreshReflogCommits(options.Background, options.SelectTopReflogCommit) }) @@ -237,7 +245,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { if scopeSet.Includes(types.REMOTES) { branchesAndRemotesWg.Add(1) refresh("remotes", func() { - _ = self.refreshRemotes(options.Background) + loadedRemotes, _ = self.refreshRemotes(options.Background) branchesAndRemotesWg.Done() }) } @@ -245,7 +253,11 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { if scopeSet.Includes(types.PULL_REQUESTS) { refresh("pull requests", func() { branchesAndRemotesWg.Wait() - self.refreshGithubPullRequests(options.Background) + // Use the branches and remotes the loads above stashed, not + // Model().Branches/Remotes: those writes are bounced onto the + // UI thread and may not have landed on this worker yet. The + // wait above orders us after both loads have stashed theirs. + self.refreshGithubPullRequests(loadedBranches, loadedRemotes, options.Background) }) } @@ -404,10 +416,13 @@ func getModeName(mode types.RefreshMode) string { // order gives the immediate (non-recency) load a lower branch-load sequence // than the async (recency) load, so the sequence guard in refreshBranches keeps // the recency-sorted result even if the two loads' bounces land out of order. -func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, background bool) { +func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, background bool) []*models.Branch { switch self.c.State().GetRepoState().GetStartupStage() { case types.INITIAL: - self.refreshBranches(refreshWorktrees, branchSelection, false, self.c.Model().ReflogCommits, background) + // Return the immediate (non-recency) load's branches; the recency-sorted + // reload below runs on its own worker after we return. Both hold the same + // set of branches, which is all the caller (the PR fetch) needs. + branches := self.refreshBranches(refreshWorktrees, branchSelection, false, self.c.Model().ReflogCommits, background) self.onWorker(background, func(_ gocui.Task) error { reflogCommits, _ := self.refreshReflogCommits(background, false) @@ -416,10 +431,14 @@ func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, branc return nil }) + return branches + case types.COMPLETE: reflogCommits, _ := self.refreshReflogCommits(background, selectTopReflogCommit) - self.refreshBranches(refreshWorktrees, branchSelection, true, reflogCommits, background) + return self.refreshBranches(refreshWorktrees, branchSelection, true, reflogCommits, background) } + + return nil } func (self *RefreshHelper) refreshCommitsAndCommitFiles(commitSelection types.CommitSelectionBehavior, background bool) { @@ -733,7 +752,7 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs() ([]*models.SubmoduleCo // self.refreshStatus is called at the end of this because that's when we can // be sure there is a State.Model.Branches array to pick the current branch from -func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, background bool) { +func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, background bool) []*models.Branch { loadSeq := self.branchLoadSeq.Add(1) generation := self.c.State().GetRepoGeneration() @@ -820,6 +839,10 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, branchSelectio self.refreshView(self.c.Contexts().Branches, background) self.refreshStatus(background) + + // Return the freshly-loaded branches so the caller can hand them to the PR + // fetch without reading them back from the (bounce-written) model. + return branches } func (self *RefreshHelper) refreshFilesAndSubmodules(background bool) error { @@ -1041,13 +1064,13 @@ func (self *RefreshHelper) refreshReflogCommits(background bool, selectTopEntry return reflogCommits, nil } -func (self *RefreshHelper) refreshRemotes(background bool) error { +func (self *RefreshHelper) refreshRemotes(background bool) ([]*models.Remote, error) { generation := self.c.State().GetRepoGeneration() prevSelectedRemote := self.c.Contexts().Remotes.GetSelected() remotes, err := self.c.Git().Loaders.RemoteLoader.GetRemotes() if err != nil { - return err + return nil, err } self.onUIThreadUnlessRepoChanged(generation, background, func() error { @@ -1075,7 +1098,7 @@ func (self *RefreshHelper) refreshRemotes(background bool) error { self.refreshView(self.c.Contexts().Remotes, background) self.refreshView(self.c.Contexts().RemoteBranches, background) - return nil + return remotes, nil } func (self *RefreshHelper) loadWorktrees() []*models.Worktree { @@ -1187,7 +1210,7 @@ func (self *RefreshHelper) refreshView(context types.Context, background bool) { }) } -func (self *RefreshHelper) refreshGithubPullRequests(background bool) { +func (self *RefreshHelper) refreshGithubPullRequests(branches []*models.Branch, remotes []*models.Remote, background bool) { generation := self.c.State().GetRepoGeneration() clearPullRequests := func() { @@ -1198,7 +1221,7 @@ func (self *RefreshHelper) refreshGithubPullRequests(background bool) { }) } - githubRemotes := getAuthenticatedGithubRemotes(self.getGithubRemotes(), self.c.Git().GitHub.GetAuthToken) + githubRemotes := getAuthenticatedGithubRemotes(self.getGithubRemotes(remotes), self.c.Git().GitHub.GetAuthToken) if len(githubRemotes) == 0 { clearPullRequests() return @@ -1209,12 +1232,12 @@ func (self *RefreshHelper) refreshGithubPullRequests(background bool) { clearPullRequests() if !self.githubBaseRemotePromptDismissed[self.c.Git().RepoPaths.RepoPath()] { - self.promptForBaseGithubRepo(githubRemotes) + self.promptForBaseGithubRepo(githubRemotes, branches) } return } - self.setGithubPullRequests(baseInfo, background) + self.setGithubPullRequests(baseInfo, branches, background) } type githubRemoteInfo struct { @@ -1223,8 +1246,8 @@ type githubRemoteInfo struct { authToken string } -func (self *RefreshHelper) getGithubRemotes() []githubRemoteInfo { - return lo.FilterMap(self.c.Model().Remotes, func(remote *models.Remote, _ int) (githubRemoteInfo, bool) { +func (self *RefreshHelper) getGithubRemotes(remotes []*models.Remote) []githubRemoteInfo { + return lo.FilterMap(remotes, func(remote *models.Remote, _ int) (githubRemoteInfo, bool) { if len(remote.Urls) == 0 { return githubRemoteInfo{}, false } @@ -1285,7 +1308,7 @@ func getGithubBaseRemote(githubRemotes []githubRemoteInfo, configuredRemoteName return nil } -func (self *RefreshHelper) promptForBaseGithubRepo(githubRemotes []githubRemoteInfo) { +func (self *RefreshHelper) promptForBaseGithubRepo(githubRemotes []githubRemoteInfo, branches []*models.Branch) { menuItems := lo.Map(githubRemotes, func(info githubRemoteInfo, _ int) *types.MenuItem { return &types.MenuItem{ LabelColumns: []string{info.remote.Name, style.FgCyan.Sprint(info.serviceInfo.RepoName)}, @@ -1295,7 +1318,7 @@ func (self *RefreshHelper) promptForBaseGithubRepo(githubRemotes []githubRemoteI self.c.Log.Error(err) } - self.setGithubPullRequests(&info, false) + self.setGithubPullRequests(&info, branches, false) return nil }) }, @@ -1323,17 +1346,17 @@ func (self *RefreshHelper) rebuildPullRequestsMap() { ) } -func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, background bool) { +func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, branches []*models.Branch, background bool) { generation := self.c.State().GetRepoGeneration() - if len(self.c.Model().Branches) == 0 { + if len(branches) == 0 { return } - branches := lo.Filter(self.c.Model().Branches, func(branch *models.Branch, _ int) bool { + trackingBranches := lo.Filter(branches, func(branch *models.Branch, _ int) bool { return branch.IsTrackingRemote() }) - branchNames := lo.Map(branches, func(branch *models.Branch, _ int) string { + branchNames := lo.Map(trackingBranches, func(branch *models.Branch, _ int) string { return branch.UpstreamBranch }) From 1def541acb476be744f7a2a54e501300a9cf9493 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 14:44:23 +0200 Subject: [PATCH 35/59] Add IsUIThread and OnUIThreadAndWait to gocui The next commits move refresh workers to read UI-thread-owned state (the model, contexts, selection) on the UI thread rather than off it. Two primitives support that: - OnUIThreadAndWait runs a function on the main event loop and blocks the caller until it has run, so a worker can read that state without racing. OnUIThreadAndWaitBackground is the same for background routines, whose work must not count towards the program being busy. - IsUIThread reports whether the caller is on the main event loop, for a debug-only assertion that a refresh was issued from the thread it claims. It records the main loop's goroutine id in MainLoop and compares via goid, so it's promoted from an indirect to a direct dependency. goid is used only by that debug assertion, never to drive production control flow. Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 2 +- pkg/gocui/gui.go | 49 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 6820d941d..c10004176 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.4.0 github.com/mgutz/str v1.2.0 github.com/mitchellh/go-ps v1.0.0 + github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe github.com/rivo/uniseg v0.4.7 github.com/sahilm/fuzzy v0.1.3 github.com/samber/lo v1.53.0 @@ -62,7 +63,6 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/onsi/ginkgo v1.10.3 // indirect github.com/onsi/gomega v1.34.1 // indirect - github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect golang.org/x/mod v0.35.0 // indirect diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index a13744997..6002ebf9c 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -9,11 +9,13 @@ import ( "runtime" "strings" "sync" + "sync/atomic" "time" "github.com/gdamore/tcell/v3" "github.com/go-errors/errors" "github.com/jesseduffield/generics/set" + "github.com/petermattis/goid" "github.com/rivo/uniseg" "github.com/samber/lo" ) @@ -200,6 +202,11 @@ type Gui struct { currentTask Task lastHoverView *View + + // uiThreadID is the goroutine id of the main event loop, recorded when + // MainLoop starts. IsUIThread compares against it. Written once, read from + // worker goroutines, so it's atomic. + uiThreadID atomic.Int64 } type NewGuiOpts struct { @@ -684,6 +691,46 @@ func (g *Gui) updateContentOnly(f func(*Gui) error, background bool) { g.userEvents <- userEvent{f: f, task: task, contentOnly: true} } +// IsUIThread reports whether the caller is running on the main event-loop +// goroutine (the one running MainLoop). It calls goid.Get, so use it only for +// debug assertions, not to drive production control flow. +func (g *Gui) IsUIThread() bool { + return goid.Get() == g.uiThreadID.Load() +} + +// OnUIThreadAndWait runs f on the main event-loop goroutine and blocks the +// caller until f has run, returning f's error. Use it to read UI-thread-owned +// state (the model, contexts) from a worker without racing the UI thread. +// +// It must be called from a worker goroutine, never from the UI thread itself: +// the UI thread would block waiting for a callback only it can run, which +// deadlocks. Callers arrange this by construction (see the refresh helper's +// RefreshFromWorker); a debug-only assertion there guards against getting it +// wrong. +func (g *Gui) OnUIThreadAndWait(f func() error) error { + return g.onUIThreadAndWait(f, false) +} + +// Like OnUIThreadAndWait, but the enqueued work belongs to a background routine, +// so it doesn't count towards the program being busy (see UpdateBackground). +func (g *Gui) OnUIThreadAndWaitBackground(f func() error) error { + return g.onUIThreadAndWait(f, true) +} + +func (g *Gui) onUIThreadAndWait(f func() error, background bool) error { + enqueue := g.Update + if background { + enqueue = g.UpdateBackground + } + + result := make(chan error, 1) + enqueue(func(*Gui) error { + result <- f() + return nil + }) + return <-result +} + // Calls a function in a goroutine. Handles panics gracefully and tracks // number of background tasks. // Always use this when you want to spawn a goroutine and you want lazygit to @@ -766,6 +813,8 @@ func (g *Gui) SetManagerFunc(manager func(*Gui) error) { // MainLoop runs the main loop until an error is returned. A successful // finish should return ErrQuit. func (g *Gui) MainLoop() error { + g.uiThreadID.Store(goid.Get()) + go func() { for { select { From 080542c9fb1e3eebbbcc1ea3fd5d8e6f064527db Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 14:45:03 +0200 Subject: [PATCH 36/59] Capture the commits refresh's inputs on the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- pkg/gui/background.go | 2 +- pkg/gui/controllers/branches_controller.go | 4 +- .../controllers/helpers/branches_helper.go | 12 +- pkg/gui/controllers/helpers/gpg_helper.go | 4 +- pkg/gui/controllers/helpers/refresh_helper.go | 130 +++++++++++++++--- pkg/gui/controllers/helpers/refs_helper.go | 8 +- .../controllers/helpers/worktree_helper.go | 4 +- .../controllers/local_commits_controller.go | 8 +- pkg/gui/controllers/remotes_controller.go | 2 +- pkg/gui/controllers/sync_controller.go | 2 +- pkg/gui/controllers/tags_controller.go | 6 +- pkg/gui/controllers/undo_controller.go | 2 +- pkg/gui/gui_common.go | 4 + .../custom_commands/handler_creator.go | 2 +- pkg/gui/types/common.go | 6 + 15 files changed, 147 insertions(+), 49 deletions(-) diff --git a/pkg/gui/background.go b/pkg/gui/background.go index 6215f0d45..17f3677f6 100644 --- a/pkg/gui/background.go +++ b/pkg/gui/background.go @@ -184,7 +184,7 @@ func (self *BackgroundRoutineMgr) checkForExternalChanges() { // No need to update the stored snapshot here; Refresh does that. self.gui.c.Log.Info("External ref change detected — refreshing") - self.gui.c.Refresh(types.RefreshOptions{Background: true}) + self.gui.c.RefreshFromWorker(types.RefreshOptions{Background: true}) } // returns a channel that can be used to trigger the callback immediately diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index a5c55884b..9b9e9e546 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -734,7 +734,7 @@ func (self *BranchesController) fastForward(branch *models.Branch) error { WorktreePath: worktreePath, }, ) - self.c.Refresh(types.RefreshOptions{Mode: types.SYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC}) return err } @@ -743,7 +743,7 @@ func (self *BranchesController) fastForward(branch *models.Branch) error { err := self.c.Git().Sync.FastForward( task, branch.Name, branch.UpstreamRemote, branch.UpstreamBranch, ) - self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.BRANCHES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.BRANCHES}}) return err }) } diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 053804760..683d26db3 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -46,7 +46,7 @@ func (self *BranchesHelper) ConfirmLocalDelete(branches []*models.Branch) error } self.c.Contexts().Branches.CollapseRangeSelectionToTop() - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}}) return nil }) }) @@ -84,7 +84,7 @@ func (self *BranchesHelper) ConfirmDeleteRemote(remoteBranches []*models.RemoteB if err := self.deleteRemoteBranches(remoteBranches, task); err != nil { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) if resetRemoteBranchesSelection { self.c.Contexts().RemoteBranches.CollapseRangeSelectionToTop() } @@ -152,7 +152,7 @@ func (self *BranchesHelper) ConfirmLocalAndRemoteDelete(branches []*models.Branc } self.c.Contexts().Branches.CollapseRangeSelectionToTop() - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) return nil }) }, @@ -312,7 +312,7 @@ func (self *BranchesHelper) deleteLocalBranchesContinuation(branches []*models.B } self.c.Contexts().Branches.CollapseRangeSelectionToTop() - self.c.Refresh(types.RefreshOptions{ + self.c.RefreshFromWorker(types.RefreshOptions{ Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}, }) @@ -330,7 +330,7 @@ func (self *BranchesHelper) deleteLocalAndRemoteBranchesContinuation(branches [] } self.c.Contexts().Branches.CollapseRangeSelectionToTop() - self.c.Refresh(types.RefreshOptions{ + self.c.RefreshFromWorker(types.RefreshOptions{ Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.REMOTES, types.FILES}, }) @@ -390,7 +390,7 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool) er // AutoForwardBranches reads Model.Branches, which the branches refresh writes // via a bounce, so it has to run in Then rather than right after Refresh // returns (where it would still see the previous branches). - self.c.Refresh(types.RefreshOptions{ + self.c.RefreshFromWorker(types.RefreshOptions{ Scope: scope, Mode: types.SYNC, Background: background, diff --git a/pkg/gui/controllers/helpers/gpg_helper.go b/pkg/gui/controllers/helpers/gpg_helper.go index fb8fae628..fd74a400b 100644 --- a/pkg/gui/controllers/helpers/gpg_helper.go +++ b/pkg/gui/controllers/helpers/gpg_helper.go @@ -88,7 +88,7 @@ func (self *GpgHelper) runAndStream( ) error { return self.c.WithWaitingStatus(waitingStatus, func(gocui.Task) error { if err := cmdObj.StreamOutput().Run(); err != nil { - self.c.Refresh(failureRefreshOptions) + self.c.RefreshFromWorker(failureRefreshOptions) return fmt.Errorf( self.c.Tr.GitCommandFailed, self.c.UserConfig().Keybinding.Universal.ExtrasMenu, ) @@ -100,7 +100,7 @@ func (self *GpgHelper) runAndStream( } } - self.c.Refresh(successRefreshOptions) + self.c.RefreshFromWorker(successRefreshOptions) return nil }) } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 5e15e17c1..48a6b6db8 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -79,6 +79,17 @@ func NewRefreshHelper( } func (self *RefreshHelper) Refresh(options types.RefreshOptions) { + self.performRefresh(options, false) +} + +// RefreshFromWorker is Refresh for callers already running on a worker +// goroutine (e.g. inside a WithWaitingStatus handler) rather than the UI +// thread. See IGuiCommon.RefreshFromWorker. +func (self *RefreshHelper) RefreshFromWorker(options types.RefreshOptions) { + self.performRefresh(options, true) +} + +func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFromWorker bool) { if options.Mode == types.ASYNC && options.Then != nil { panic("RefreshOptions.Then doesn't work with mode ASYNC") } @@ -101,6 +112,13 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { ) } + // f runs on the UI thread when the refresh was initiated there, and also for + // BLOCK_UI, which dispatches f onto the UI thread regardless of the caller. + // Only a SYNC/ASYNC refresh initiated from a worker runs f on that worker. + // This, not calledFromWorker alone, is what decides whether a scope capture + // runs inline or has to hop (see captureOnUIThread). + fRunsOnUIThread := options.Mode == types.BLOCK_UI || !calledFromWorker + f := func() { var scopeSet *set.Set[types.RefreshableView] if len(options.Scope) == 0 { @@ -188,8 +206,16 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { // whenever we change commits, we should update branches because the upstream/downstream // counts can change. Whenever we change branches we should also change commits // e.g. in the case of switching branches. + // Capture the commits refresh's model/context/mode inputs on the UI + // thread, before the git work is dispatched to a worker, so the + // worker computes from an immutable snapshot instead of reading + // state the UI thread concurrently mutates. + var capturedCommits capturedCommitState + self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + capturedCommits = self.captureCommitsState(options.CommitSelection) + }) refresh("commits and commit files", func() { - self.refreshCommitsAndCommitFiles(options.CommitSelection, options.Background) + self.refreshCommitsAndCommitFiles(capturedCommits, options.CommitSelection, options.Background) }) includeWorktreesWithBranches = scopeSet.Includes(types.WORKTREES) @@ -441,11 +467,49 @@ func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, branc return nil } -func (self *RefreshHelper) refreshCommitsAndCommitFiles(commitSelection types.CommitSelectionBehavior, background bool) { +// capturedCommitState holds everything the commits refresh reads from the +// model, contexts, and modes. It is gathered on the UI thread (see +// captureCommitsState) before the git work is dispatched to a worker, so the +// worker computes from an immutable snapshot rather than reading state the UI +// thread concurrently mutates. +type capturedCommitState struct { + selectionRange *localCommitSelectionRange + limitCommits bool + showWholeGitGraph bool + filterPath string + filterAuthor string + mainBranches *git_commands.MainBranches + hashPool *utils.StringPool + parentIsLocalCommits bool +} + +// captureCommitsState reads the commits refresh's model/context/mode inputs +// into an immutable snapshot. It must run on the UI thread. +func (self *RefreshHelper) captureCommitsState(commitSelection types.CommitSelectionBehavior) capturedCommitState { + var selectionRange *localCommitSelectionRange + if commitSelection == types.KeepCommitSelectionByHash { + selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode() + selectionRange = captureLocalCommitSelectionRange(self.c.Model().Commits, selectedIdx, rangeStartIdx, rangeSelectMode) + } + + parentCtx := self.c.Contexts().CommitFiles.GetParentContext() + + return capturedCommitState{ + selectionRange: selectionRange, + limitCommits: self.c.Contexts().LocalCommits.GetLimitCommits(), + showWholeGitGraph: self.c.Contexts().LocalCommits.GetShowWholeGitGraph(), + filterPath: self.c.Modes().Filtering.GetPath(), + filterAuthor: self.c.Modes().Filtering.GetAuthor(), + mainBranches: self.c.Model().MainBranches, + hashPool: self.c.Model().HashPool, + parentIsLocalCommits: parentCtx != nil && parentCtx.GetKey() == context.LOCAL_COMMITS_CONTEXT_KEY, + } +} + +func (self *RefreshHelper) refreshCommitsAndCommitFiles(captured capturedCommitState, commitSelection types.CommitSelectionBehavior, background bool) { generation := self.c.State().GetRepoGeneration() - _ = self.refreshCommitsWithLimit(commitSelection, background) - ctx := self.c.Contexts().CommitFiles.GetParentContext() - if ctx != nil && ctx.GetKey() == context.LOCAL_COMMITS_CONTEXT_KEY { + _ = self.refreshCommitsWithLimit(captured, commitSelection, background) + if captured.parentIsLocalCommits { // This makes sense when we've e.g. just amended a commit, meaning we get a new commit hash at the same position. // However if we've just added a brand new commit, it pushes the list down by one and so we would end up // showing the contents of a different commit than the one we initially entered. @@ -497,28 +561,22 @@ func (self *RefreshHelper) determineCheckedOutRef() models.Ref { return nil } -func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitSelectionBehavior, background bool) error { +func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, commitSelection types.CommitSelectionBehavior, background bool) error { generation := self.c.State().GetRepoGeneration() - var selectionRange *localCommitSelectionRange - if commitSelection == types.KeepCommitSelectionByHash { - selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode() - selectionRange = captureLocalCommitSelectionRange(self.c.Model().Commits, selectedIdx, rangeStartIdx, rangeSelectMode) - } - checkedOutRef := self.determineCheckedOutRef() refName, bisectInfo := self.refForLog() commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( git_commands.GetCommitsOptions{ - Limit: self.c.Contexts().LocalCommits.GetLimitCommits(), - FilterPath: self.c.Modes().Filtering.GetPath(), - FilterAuthor: self.c.Modes().Filtering.GetAuthor(), + Limit: captured.limitCommits, + FilterPath: captured.filterPath, + FilterAuthor: captured.filterAuthor, IncludeRebaseCommits: true, RefName: refName, RefForPushedStatus: checkedOutRef, - All: self.c.Contexts().LocalCommits.GetShowWholeGitGraph(), - MainBranches: self.c.Model().MainBranches, - HashPool: self.c.Model().HashPool, + All: captured.showWholeGitGraph, + MainBranches: captured.mainBranches, + HashPool: captured.hashPool, }, ) if err != nil { @@ -545,10 +603,10 @@ func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitS scrollSelectionIntoView = true } case types.KeepCommitSelectionByHash: - if selectionRange != nil { - selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, selectionRange) + if captured.selectionRange != nil { + selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, captured.selectionRange) if found { - self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, selectionRange.mode) + self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, captured.selectionRange.mode) scrollSelectionIntoView = didMove } } @@ -898,6 +956,36 @@ func (self *RefreshHelper) onUIThread(background bool, f func() error) { } } +// captureOnUIThread runs fn on the UI thread and returns once it has run. fn +// reads the model/context/mode state a refresh scope needs into locals, so the +// worker that follows computes from an immutable snapshot instead of reading +// state the UI thread concurrently mutates. When the enclosing refresh function +// runs on the UI thread (fRunsOnUIThread is true) fn runs inline; when it runs +// on a worker, fn is dispatched to the UI thread and we block for it. +// +// The inline case matters for correctness as much as the hop: a SYNC or +// BLOCK_UI refresh parks the UI thread in a wg.Wait while its scope workers +// run, so a scope worker that tried to hop to the UI thread there would +// deadlock. Capturing before those workers are spawned — inline, on the UI +// thread — avoids that entirely. This is why BLOCK_UI (which always runs on the +// UI thread, even from a worker caller) captures inline rather than hopping. +func (self *RefreshHelper) captureOnUIThread(fRunsOnUIThread bool, background bool, fn func()) { + if fRunsOnUIThread { + fn() + return + } + + wrapped := func() error { + fn() + return nil + } + if background { + _ = self.c.GocuiGui().OnUIThreadAndWaitBackground(wrapped) + } else { + _ = self.c.GocuiGui().OnUIThreadAndWait(wrapped) + } +} + func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs []*models.SubmoduleConfig) error { fileTreeViewModel := self.c.Contexts().Files.FileTreeViewModel generation := self.c.State().GetRepoGeneration() diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index 8d5e8397c..70ff18593 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -55,7 +55,7 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions if options.RefreshPullRequests { scope = append(scope, types.PULL_REQUESTS) } - self.c.Refresh(types.RefreshOptions{ + self.c.RefreshFromWorker(types.RefreshOptions{ Mode: types.BLOCK_UI, Scope: scope, BranchSelection: types.SelectCheckedOutBranch, @@ -204,7 +204,7 @@ func (self *RefsHelper) ResetToRef(ref string, strength string, envVars []string // loading a heap of commits is slow so we limit them whenever doing a reset self.c.Contexts().LocalCommits.SetLimitCommits(true) - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.BRANCHES, types.REFLOG, types.COMMITS}, CommitSelection: types.KeepCommitSelectionIndex}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.BRANCHES, types.REFLOG, types.COMMITS}, CommitSelection: types.KeepCommitSelectionIndex}) return nil } @@ -523,7 +523,7 @@ func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchNa } } - self.c.Refresh(types.RefreshOptions{ + self.c.RefreshFromWorker(types.RefreshOptions{ Mode: types.BLOCK_UI, BranchSelection: types.SelectCheckedOutBranch, CommitSelection: types.SelectHeadCommit, @@ -564,7 +564,7 @@ func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName stri } } - self.c.Refresh(types.RefreshOptions{ + self.c.RefreshFromWorker(types.RefreshOptions{ Mode: types.BLOCK_UI, BranchSelection: types.SelectCheckedOutBranch, CommitSelection: types.SelectHeadCommit, diff --git a/pkg/gui/controllers/helpers/worktree_helper.go b/pkg/gui/controllers/helpers/worktree_helper.go index abfd1c0f6..980d810ae 100644 --- a/pkg/gui/controllers/helpers/worktree_helper.go +++ b/pkg/gui/controllers/helpers/worktree_helper.go @@ -163,7 +163,7 @@ func (self *WorktreeHelper) remove(worktree *models.Worktree, force bool, then f return then(task) } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}}) return nil }) } @@ -181,7 +181,7 @@ func (self *WorktreeHelper) Detach(worktree *models.Worktree, then func(gocui.Ta return then(task) } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}}) return nil }) } diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 5150e8fba..04c7fc290 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -487,7 +487,7 @@ func (self *LocalCommitsController) handleReword(summary string, description str if err != nil { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) return nil }) } @@ -854,7 +854,7 @@ func (self *LocalCommitsController) resetAuthor(start, end int) error { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) return nil }) } @@ -870,7 +870,7 @@ func (self *LocalCommitsController) setAuthor(start, end int) error { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) return nil }) }, @@ -889,7 +889,7 @@ func (self *LocalCommitsController) addCoAuthor(start, end int) error { if err := self.c.Git().Rebase.AddCommitCoAuthor(self.c.Model().Commits, start, end, value); err != nil { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) return nil }) }, diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index 8bd19ad81..d4c838f7c 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -391,7 +391,7 @@ func (self *RemotesController) fetchAndCheckout(remote *models.Remote, branchNam } } } - self.c.Refresh(refreshOptions) + self.c.RefreshFromWorker(refreshOptions) return err }) } diff --git a/pkg/gui/controllers/sync_controller.go b/pkg/gui/controllers/sync_controller.go index 0f754eb49..fafd4e7dd 100644 --- a/pkg/gui/controllers/sync_controller.go +++ b/pkg/gui/controllers/sync_controller.go @@ -229,7 +229,7 @@ func (self *SyncController) pushAux(currentBranch *models.Branch, opts pushOpts) } return err } - self.c.Refresh(types.RefreshOptions{Mode: types.SYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC}) return nil }) } diff --git a/pkg/gui/controllers/tags_controller.go b/pkg/gui/controllers/tags_controller.go index fe2c4e80f..2a59af5ec 100644 --- a/pkg/gui/controllers/tags_controller.go +++ b/pkg/gui/controllers/tags_controller.go @@ -168,7 +168,7 @@ func (self *TagsController) localDelete(tag *models.Tag) error { return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.DeleteLocalTag) err := self.c.Git().Tag.LocalDelete(tag.Name) - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) return err }) } @@ -210,7 +210,7 @@ func (self *TagsController) remoteDelete(tag *models.Tag) error { return err } self.c.Toast(self.c.Tr.RemoteTagDeletedMessage) - self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) return nil }) }, @@ -264,7 +264,7 @@ func (self *TagsController) localAndRemoteDelete(tag *models.Tag) error { if err := self.c.Git().Tag.LocalDelete(tag.Name); err != nil { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) return nil }) }, diff --git a/pkg/gui/controllers/undo_controller.go b/pkg/gui/controllers/undo_controller.go index 775e871a4..0954d66b0 100644 --- a/pkg/gui/controllers/undo_controller.go +++ b/pkg/gui/controllers/undo_controller.go @@ -271,7 +271,7 @@ func (self *UndoController) hardResetWithAutoStash(commitHash string, options ha if err != nil { return err } - self.c.Refresh(types.RefreshOptions{}) + self.c.RefreshFromWorker(types.RefreshOptions{}) return nil }) } diff --git a/pkg/gui/gui_common.go b/pkg/gui/gui_common.go index d13120508..c8de7545d 100644 --- a/pkg/gui/gui_common.go +++ b/pkg/gui/gui_common.go @@ -30,6 +30,10 @@ func (self *guiCommon) Refresh(opts types.RefreshOptions) { self.gui.helpers.Refresh.Refresh(opts) } +func (self *guiCommon) RefreshFromWorker(opts types.RefreshOptions) { + self.gui.helpers.Refresh.RefreshFromWorker(opts) +} + func (self *guiCommon) PostRefreshUpdate(context types.Context) { self.gui.postRefreshUpdate(context) } diff --git a/pkg/gui/services/custom_commands/handler_creator.go b/pkg/gui/services/custom_commands/handler_creator.go index 1e321c2de..4eb762019 100644 --- a/pkg/gui/services/custom_commands/handler_creator.go +++ b/pkg/gui/services/custom_commands/handler_creator.go @@ -314,7 +314,7 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses } output, err := cmdObj.RunWithOutput() - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) if err != nil { if customCommand.After != nil && customCommand.After.CheckForConflicts { diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 766a5a757..0f8e99ee8 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -30,6 +30,12 @@ type IGuiCommon interface { LogCommand(cmdStr string, isCommandLine bool) // we call this when we want to refetch some models and render the result. Internally calls PostRefreshUpdate Refresh(RefreshOptions) + // Like Refresh, but for callers running on a worker goroutine (e.g. inside + // a WithWaitingStatus handler) rather than the UI thread. The refresh + // captures the model/context state it needs on the UI thread before doing + // its git work; knowing which thread the caller is on lets it capture + // inline (UI thread) or hop across (worker) without racing or deadlocking. + RefreshFromWorker(RefreshOptions) // we call this when we've changed something in the view model but not the actual model, // e.g. expanding or collapsing a folder in a file view. Calling 'Refresh' in this // case would be overkill, although refresh will internally call 'PostRefreshUpdate' From 558fd2c9d349b597932483c3b81c66a3c1c21281 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 14:46:32 +0200 Subject: [PATCH 37/59] Route merge/rebase result handling to the right refresh entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CheckMergeOrRebaseWithRefreshOptions refreshes after a merge/rebase step, and until now always via the UI-thread Refresh. Most of its callers are on a worker (the WithWaitingStatus/WithInlineStatus merge, squash-merge, rebase, pull, amend, drop, and patch-move handlers), so that refresh reads the commits scope off the UI thread — the race the previous commit addresses for everything else. Split it: the default is for worker callers and refreshes via RefreshFromWorker; a new CheckMergeOrRebaseWithRefreshOptionsFromUIThread is for the handlers that run the step synchronously on the UI thread (WithWaitingStatusSync, kept sync so rapid key presses batch): move up/down, revert, squash-fixups, cherry-pick paste, and patch-discard. The two share a private impl carrying which thread the caller is on, and the auto-skip recursion (genericMergeCommandImpl for an empty commit) threads it through so the follow-up step refreshes on the same thread. The merge-and-commit refresh in SquashMergeCommitted, also on a worker, moves to RefreshFromWorker to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/helpers/cherry_pick_helper.go | 2 +- .../helpers/merge_and_rebase_helper.go | 66 ++++++++++++++----- .../controllers/local_commits_controller.go | 8 +-- .../controllers/patch_building_controller.go | 2 +- 4 files changed, 56 insertions(+), 22 deletions(-) diff --git a/pkg/gui/controllers/helpers/cherry_pick_helper.go b/pkg/gui/controllers/helpers/cherry_pick_helper.go index e2fe46545..673f657f5 100644 --- a/pkg/gui/controllers/helpers/cherry_pick_helper.go +++ b/pkg/gui/controllers/helpers/cherry_pick_helper.go @@ -95,7 +95,7 @@ func (self *CherryPickHelper) Paste() error { cherryPickedCommits := self.getData().CherryPickedCommits result := self.c.Git().Rebase.CherryPickCommits(cherryPickedCommits) - err := self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{Mode: types.SYNC}) + err := self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptionsFromUIThread(result, types.RefreshOptions{Mode: types.SYNC}) if err != nil { return result } diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 51488a922..6adf84712 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -79,7 +79,9 @@ func (self *MergeAndRebaseHelper) ContinueRebase() error { } func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error { - return self.genericMergeCommandImpl(command, true) + // The menu/prompt/confirm handlers that reach here run on the UI thread and + // spin up a worker (via the waiting status below) to do the actual work. + return self.genericMergeCommandImpl(command, true, false) } // genericMergeCommandImpl runs a merge/rebase continue/skip/abort and handles @@ -87,10 +89,12 @@ func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error { // non-subprocess path runs on a worker with a waiting status. // // showWaitingStatus is false only for the recursive auto-skip in -// CheckMergeOrRebaseWithRefreshOptions: that call already runs on the caller's -// thread (the worker of the enclosing waiting status, or the UI thread for the -// synchronous callers), so it must not spin up a second one. -func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWaitingStatus bool) error { +// checkMergeOrRebaseImpl: that call already runs on the caller's thread (the +// worker of the enclosing waiting status, or the UI thread for the synchronous +// callers), so it must not spin up a second one. calledFromWorker says which of +// those two the body runs on, so the post-action refresh picks Refresh vs +// RefreshFromWorker correctly. +func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWaitingStatus bool, calledFromWorker bool) error { status := self.c.Git().Status.WorkingTreeState() if status.None() { @@ -128,29 +132,30 @@ func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWa if needsSubprocess { // TODO: see if we should be calling more of the code from self.Git.Rebase.GenericMergeOrRebaseAction success, err := self.c.RunSubprocess(self.c.Git().Rebase.GenericMergeOrRebaseActionCmdObj(commandType, command)) - self.c.Refresh(types.RefreshOptions{ + self.refreshAfterMergeOrRebase(types.RefreshOptions{ Mode: types.ASYNC, CommitSelection: commitSelectionAfterMerge(success && selectHeadCommitOnSuccess), - }) + }, calledFromWorker) self.RecordWhetherMergeOrRebaseStartedInLazygit() return err } - runAction := func() error { + runAction := func(calledFromWorker bool) error { result := self.c.Git().Rebase.GenericMergeOrRebaseAction(commandType, command) - return self.CheckMergeOrRebaseWithRefreshOptions(result, + return self.checkMergeOrRebaseImpl(result, types.RefreshOptions{ Mode: types.ASYNC, CommitSelection: commitSelectionAfterMerge(result == nil && selectHeadCommitOnSuccess), - }) + }, calledFromWorker) } if showWaitingStatus { return self.c.WithWaitingStatus(status.Title(self.c.Tr), func(gocui.Task) error { - return runAction() + // The waiting status ran runAction on a worker. + return runAction(true) }) } - return runAction() + return runAction(calledFromWorker) } // commitSelectionAfterMerge maps whether a merge/rebase/pull created a new @@ -205,17 +210,34 @@ func (self *MergeAndRebaseHelper) RecordWhetherMergeOrRebaseStartedInLazygit() { self.c.Git().Status.WorkingTreeState().Any()) } +// CheckMergeOrRebaseWithRefreshOptions handles the result of a merge/rebase +// step and refreshes. It's for callers running on a worker (the +// WithWaitingStatus / WithInlineStatus handlers), which is the large majority; +// UI-thread callers use CheckMergeOrRebaseWithRefreshOptionsFromUIThread. func (self *MergeAndRebaseHelper) CheckMergeOrRebaseWithRefreshOptions(result error, refreshOptions types.RefreshOptions) error { - self.c.Refresh(refreshOptions) + return self.checkMergeOrRebaseImpl(result, refreshOptions, true) +} + +// CheckMergeOrRebaseWithRefreshOptionsFromUIThread is like +// CheckMergeOrRebaseWithRefreshOptions, but for the callers that run the +// merge/rebase synchronously on the UI thread (the WithWaitingStatusSync +// move/revert/squash-fixups/cherry-pick-paste/patch-discard handlers, kept sync +// so rapid key presses batch) rather than on a worker. +func (self *MergeAndRebaseHelper) CheckMergeOrRebaseWithRefreshOptionsFromUIThread(result error, refreshOptions types.RefreshOptions) error { + return self.checkMergeOrRebaseImpl(result, refreshOptions, false) +} + +func (self *MergeAndRebaseHelper) checkMergeOrRebaseImpl(result error, refreshOptions types.RefreshOptions, calledFromWorker bool) error { + self.refreshAfterMergeOrRebase(refreshOptions, calledFromWorker) self.RecordWhetherMergeOrRebaseStartedInLazygit() if result == nil { return nil } else if strings.Contains(result.Error(), "No changes - did you forget to use") { - return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false) + return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false, calledFromWorker) } else if strings.Contains(result.Error(), "The previous cherry-pick is now empty") { - return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false) + return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false, calledFromWorker) } else if strings.Contains(result.Error(), "No rebase in progress?") { // assume in this case that we're already done return nil @@ -223,6 +245,18 @@ func (self *MergeAndRebaseHelper) CheckMergeOrRebaseWithRefreshOptions(result er return self.CheckForConflicts(result) } +// refreshAfterMergeOrRebase issues the post-action refresh on the entry point +// that matches the thread the merge/rebase ran on: RefreshFromWorker for the +// worker callers, Refresh for the ones that stayed synchronously on the UI +// thread. +func (self *MergeAndRebaseHelper) refreshAfterMergeOrRebase(refreshOptions types.RefreshOptions, calledFromWorker bool) { + if calledFromWorker { + self.c.RefreshFromWorker(refreshOptions) + } else { + self.c.Refresh(refreshOptions) + } +} + func (self *MergeAndRebaseHelper) CheckMergeOrRebase(result error) error { return self.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{Mode: types.ASYNC}) } @@ -628,7 +662,7 @@ func (self *MergeAndRebaseHelper) SquashMergeCommitted(refName, checkedOutBranch if err != nil { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) return nil }) } diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 04c7fc290..12244d983 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -741,7 +741,7 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s self.context().MoveSelection(1) self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) } - return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( + return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread( err, types.RefreshOptions{Mode: types.SYNC, CommitSelection: types.KeepCommitSelectionIndex}) }) } @@ -769,7 +769,7 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta self.context().MoveSelection(-1) self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) } - return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( + return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread( err, types.RefreshOptions{Mode: types.SYNC, CommitSelection: types.KeepCommitSelectionIndex}) }) } @@ -927,7 +927,7 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end } result := self.c.Git().Commit.Revert(hashes, isMerge) - if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{Mode: types.SYNC}); err != nil { + if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread(result, types.RefreshOptions{Mode: types.SYNC}); err != nil { return err } @@ -1127,7 +1127,7 @@ func (self *LocalCommitsController) squashFixupsImpl(commit *models.Commit, reba self.c.LogAction(self.c.Tr.Actions.SquashAllAboveFixupCommits) err := self.c.Git().Rebase.SquashAllAboveFixupCommits(commit) self.context().MoveSelectedLine(-selectionOffset) - return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( + return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread( err, types.RefreshOptions{Mode: types.SYNC}) }) } diff --git a/pkg/gui/controllers/patch_building_controller.go b/pkg/gui/controllers/patch_building_controller.go index 5e4a17169..d596c2ead 100644 --- a/pkg/gui/controllers/patch_building_controller.go +++ b/pkg/gui/controllers/patch_building_controller.go @@ -228,7 +228,7 @@ func (self *PatchBuildingController) discardSelectionFromCommit() error { self.c.LogAction(self.c.Tr.Actions.RemovePatchFromCommit) err := self.c.Git().Patch.DeletePatchesFromCommit(self.c.Model().Commits, commitIndex) self.c.Helpers().PatchBuilding.Escape() - return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( + return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread( err, types.RefreshOptions{Mode: types.SYNC}) }) } From 988d04bda9983c6c8c6dfbb26e479315522e4e13 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 14:47:13 +0200 Subject: [PATCH 38/59] Assert a refresh uses the entry point matching its goroutine Now that every commits-reaching refresh issued from a worker goes through RefreshFromWorker, guard the choice: in debug builds, panic if a refresh was issued from the UI thread as RefreshFromWorker or from a worker as Refresh. The caller's own goroutine is recorded at the top of performRefresh, before a BLOCK_UI refresh dispatches onto the UI thread, so the check holds for every mode rather than being fooled by BLOCK_UI. It's scoped to the commits refresh for now, the only converted scope; once the rest are converted the guard can move up to cover every refresh unconditionally. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 48a6b6db8..1d8e17913 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -119,6 +119,15 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // runs inline or has to hop (see captureOnUIThread). fRunsOnUIThread := options.Mode == types.BLOCK_UI || !calledFromWorker + // Record the caller's own goroutine now, before a BLOCK_UI refresh dispatches + // f onto the UI thread, so the debug assertion below can verify the caller + // picked the entry point matching its thread regardless of the mode. Only + // read in debug (goid stays out of production control flow). + callerIsUIThread := false + if self.c.GetConfig().GetDebug() { + callerIsUIThread = self.c.GocuiGui().IsUIThread() + } + f := func() { var scopeSet *set.Set[types.RefreshableView] if len(options.Scope) == 0 { @@ -203,6 +212,18 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr var loadedRemotes []*models.Remote includeWorktreesWithBranches := false if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) { + // Debug-only guard: the caller must have picked the entry point that + // matches its goroutine — Refresh on the UI thread, RefreshFromWorker + // on a worker. We check the caller's own thread (captured above, + // before any BLOCK_UI dispatch), so it holds regardless of the mode. + // It's scoped to the commits refresh for now, the one scope whose + // worker reads have moved to the UI-thread capture below; once the + // other scopes are converted too it can move up to guard every + // refresh unconditionally. + if self.c.GetConfig().GetDebug() && callerIsUIThread == calledFromWorker { + panic("Refresh called from a worker, or RefreshFromWorker called from the UI thread") + } + // whenever we change commits, we should update branches because the upstream/downstream // counts can change. Whenever we change branches we should also change commits // e.g. in the case of switching branches. From 56989922e434f213638104c76877394dbd3b387e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 15:43:01 +0200 Subject: [PATCH 39/59] Capture the remotes/sub-commits/commit-files/rebase-commits inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These four refreshes each read model, context, and mode state directly on their worker — the same class of race the commits refresh had: - remotes reads the selected remote (Contexts().Remotes.GetSelected), needed to keep the remote-branches selection valid; - sub-commits reads the SubCommits ref/limit/divergence, the filtering path/author, and Model.MainBranches/HashPool; - commit-files reads the diff endpoints (CommitFiles from/to and the diffing args); - rebase-commits reads Model.HashPool/Commits. Give each the same treatment as commits: gather its inputs into an immutable snapshot on the UI thread (via captureOnUIThread, inline for a UI-thread refresh, hopped for a worker one) before dispatching the git work, and have the refresh compute from the snapshot. The commit-files re-init inside the commits refresh captures its endpoints in the bounce, right after ReInit sets them, before dispatching to the worker. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 111 ++++++++++++++---- 1 file changed, 90 insertions(+), 21 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 1d8e17913..66912b877 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -260,16 +260,29 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr } else if scopeSet.Includes(types.REBASE_COMMITS) { // the above block handles rebase commits so we only need to call this one // if we've asked specifically for rebase commits and not those other things - refresh("rebase commits", func() { _ = self.refreshRebaseCommits(options.Background) }) + var rebaseHashPool *utils.StringPool + var rebaseCommits []*models.Commit + self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + rebaseHashPool, rebaseCommits = self.captureRebaseCommitState() + }) + refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, options.Background) }) } if scopeSet.Includes(types.SUB_COMMITS) { - refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit(options.Background) }) + var capturedSubCommits capturedSubCommitState + self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + capturedSubCommits = self.captureSubCommitState() + }) + refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit(capturedSubCommits, options.Background) }) } // reason we're not doing this if the COMMITS type is included is that if the COMMITS type _is_ included we will refresh the commit files context anyway if scopeSet.Includes(types.COMMIT_FILES) && !scopeSet.Includes(types.COMMITS) { - refresh("commit files", func() { _ = self.refreshCommitFilesContext(options.Background) }) + var capturedCommitFiles capturedCommitFilesState + self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + capturedCommitFiles = self.captureCommitFilesState() + }) + refresh("commit files", func() { _ = self.refreshCommitFilesContext(capturedCommitFiles, options.Background) }) } fileWg := sync.WaitGroup{} @@ -290,9 +303,16 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr } if scopeSet.Includes(types.REMOTES) { + // Capture the previously-selected remote on the UI thread; the worker + // needs it to keep the remote-branches selection valid, and reading + // the Remotes context off the UI thread races its render. + var prevSelectedRemote *models.Remote + self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + prevSelectedRemote = self.c.Contexts().Remotes.GetSelected() + }) branchesAndRemotesWg.Add(1) refresh("remotes", func() { - loadedRemotes, _ = self.refreshRemotes(options.Background) + loadedRemotes, _ = self.refreshRemotes(prevSelectedRemote, options.Background) branchesAndRemotesWg.Done() }) } @@ -546,8 +566,11 @@ func (self *RefreshHelper) refreshCommitsAndCommitFiles(captured capturedCommitS if commit != nil && commit.RefName() != "" { refRange := self.c.Contexts().LocalCommits.GetSelectedRefRangeForDiffFiles() self.c.Contexts().CommitFiles.ReInit(commit, refRange) + // Capture the diff endpoints here, on the UI thread and after + // ReInit has set them, before dispatching the git work. + capturedCommitFiles := self.captureCommitFilesState() self.onWorker(background, func(gocui.Task) error { - _ = self.refreshCommitFilesContext(background) + _ = self.refreshCommitFilesContext(capturedCommitFiles, background) return nil }) } @@ -726,8 +749,35 @@ func hasRestorableCommitHash(commits []*models.Commit, idx int) bool { return idx >= 0 && idx < len(commits) && commits[idx].Hash() != "" } -func (self *RefreshHelper) refreshSubCommitsWithLimit(background bool) error { - if self.c.Contexts().SubCommits.GetRef() == nil { +// capturedSubCommitState holds the sub-commits refresh's model/context/mode +// inputs, gathered on the UI thread (see captureSubCommitState) before the git +// work is dispatched to a worker. +type capturedSubCommitState struct { + ref models.Ref + limitCommits bool + refToShowDivergenceFrom string + filterPath string + filterAuthor string + mainBranches *git_commands.MainBranches + hashPool *utils.StringPool +} + +// captureSubCommitState reads the sub-commits refresh's inputs into an immutable +// snapshot. It must run on the UI thread. +func (self *RefreshHelper) captureSubCommitState() capturedSubCommitState { + return capturedSubCommitState{ + ref: self.c.Contexts().SubCommits.GetRef(), + limitCommits: self.c.Contexts().SubCommits.GetLimitCommits(), + refToShowDivergenceFrom: self.c.Contexts().SubCommits.GetRefToShowDivergenceFrom(), + filterPath: self.c.Modes().Filtering.GetPath(), + filterAuthor: self.c.Modes().Filtering.GetAuthor(), + mainBranches: self.c.Model().MainBranches, + hashPool: self.c.Model().HashPool, + } +} + +func (self *RefreshHelper) refreshSubCommitsWithLimit(captured capturedSubCommitState, background bool) error { + if captured.ref == nil { return nil } @@ -735,15 +785,15 @@ func (self *RefreshHelper) refreshSubCommitsWithLimit(background bool) error { commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( git_commands.GetCommitsOptions{ - Limit: self.c.Contexts().SubCommits.GetLimitCommits(), - FilterPath: self.c.Modes().Filtering.GetPath(), - FilterAuthor: self.c.Modes().Filtering.GetAuthor(), + Limit: captured.limitCommits, + FilterPath: captured.filterPath, + FilterAuthor: captured.filterAuthor, IncludeRebaseCommits: false, - RefName: self.c.Contexts().SubCommits.GetRef().FullRefName(), - RefToShowDivergenceFrom: self.c.Contexts().SubCommits.GetRefToShowDivergenceFrom(), - RefForPushedStatus: self.c.Contexts().SubCommits.GetRef(), - MainBranches: self.c.Model().MainBranches, - HashPool: self.c.Model().HashPool, + RefName: captured.ref.FullRefName(), + RefToShowDivergenceFrom: captured.refToShowDivergenceFrom, + RefForPushedStatus: captured.ref, + MainBranches: captured.mainBranches, + HashPool: captured.hashPool, }, ) if err != nil { @@ -771,12 +821,26 @@ func (self *RefreshHelper) RefreshAuthors(commits []*models.Commit) { } } -func (self *RefreshHelper) refreshCommitFilesContext(background bool) error { +// capturedCommitFilesState holds the commit-files refresh's context/mode inputs +// (the diff endpoints), gathered on the UI thread before the git work runs. +type capturedCommitFilesState struct { + from string + to string + reverse bool +} + +// captureCommitFilesState reads the commit-files refresh's diff endpoints into +// an immutable snapshot. It must run on the UI thread. +func (self *RefreshHelper) captureCommitFilesState() capturedCommitFilesState { from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) + return capturedCommitFilesState{from: from, to: to, reverse: reverse} +} + +func (self *RefreshHelper) refreshCommitFilesContext(captured capturedCommitFilesState, background bool) error { generation := self.c.State().GetRepoGeneration() - files, err := self.c.Git().Loaders.CommitFileLoader.GetFilesInDiff(from, to, reverse) + files, err := self.c.Git().Loaders.CommitFileLoader.GetFilesInDiff(captured.from, captured.to, captured.reverse) if err != nil { return err } @@ -789,10 +853,16 @@ func (self *RefreshHelper) refreshCommitFilesContext(background bool) error { return nil } -func (self *RefreshHelper) refreshRebaseCommits(background bool) error { +// captureRebaseCommitState reads the rebase-commits refresh's model inputs into +// an immutable snapshot. It must run on the UI thread. +func (self *RefreshHelper) captureRebaseCommitState() (hashPool *utils.StringPool, commits []*models.Commit) { + return self.c.Model().HashPool, self.c.Model().Commits +} + +func (self *RefreshHelper) refreshRebaseCommits(hashPool *utils.StringPool, commits []*models.Commit, background bool) error { generation := self.c.State().GetRepoGeneration() - updatedCommits, err := self.c.Git().Loaders.CommitLoader.MergeRebasingCommits(self.c.Model().HashPool, self.c.Model().Commits) + updatedCommits, err := self.c.Git().Loaders.CommitLoader.MergeRebasingCommits(hashPool, commits) if err != nil { return err } @@ -1173,9 +1243,8 @@ func (self *RefreshHelper) refreshReflogCommits(background bool, selectTopEntry return reflogCommits, nil } -func (self *RefreshHelper) refreshRemotes(background bool) ([]*models.Remote, error) { +func (self *RefreshHelper) refreshRemotes(prevSelectedRemote *models.Remote, background bool) ([]*models.Remote, error) { generation := self.c.State().GetRepoGeneration() - prevSelectedRemote := self.c.Contexts().Remotes.GetSelected() remotes, err := self.c.Git().Loaders.RemoteLoader.GetRemotes() if err != nil { From 3e5c99e1e4580607292529c5314b12c716164176 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 16:19:30 +0200 Subject: [PATCH 40/59] Make the started-in-lazygit and startup-stage flags atomic GuiRepoState.mergeOrRebaseStartedInLazygit and StartupStage are plain fields, but they're written and read from worker goroutines: the former from both the files refresh and the merge/rebase result path (which runs on a worker for the async callers), the latter from the reflog/branches load as it transitions the startup stage. Those are data races. Make both atomic, like Branch.BehindBaseBranch. They're leaf flags, not mutexes guarding model or view state, so an atomic is the natural fit and keeps the merge/rebase result path out of this change. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/gui.go | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 5aa8beaec..d77673e9c 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -234,8 +234,11 @@ type GuiRepoState struct { SplitMainPanel bool - SearchState *types.SearchState - StartupStage types.StartupStage // Allows us to not load everything at once + SearchState *types.SearchState + // Lets us not load everything at once. Written and read from refresh + // workers (the reflog/branches load transitions it INITIAL->COMPLETE), so + // it's atomic. Holds a types.StartupStage. + startupStage atomic.Int32 ContextMgr *ContextMgr Contexts *context.ContextTree @@ -262,7 +265,11 @@ type GuiRepoState struct { // continue such an operation once its conflicts are resolved if we started // it ourselves; for an externally started one, popping up unbidden would be // confusing. Reset whenever we observe that no operation is in progress. - mergeOrRebaseStartedInLazygit bool + // + // Written from both the files refresh worker and the merge/rebase result + // path (which runs on a worker for the async callers), and read from the + // files refresh worker, so it's atomic. + mergeOrRebaseStartedInLazygit atomic.Bool } var _ types.IRepoStateAccessor = new(GuiRepoState) @@ -276,11 +283,11 @@ func (self *GuiRepoState) GetWindowViewNameMap() *utils.ThreadSafeMap[string, st } func (self *GuiRepoState) GetStartupStage() types.StartupStage { - return self.StartupStage + return types.StartupStage(self.startupStage.Load()) } func (self *GuiRepoState) SetStartupStage(value types.StartupStage) { - self.StartupStage = value + self.startupStage.Store(int32(value)) } func (self *GuiRepoState) GetCurrentPopupOpts() *types.CreatePopupPanelOpts { @@ -292,11 +299,11 @@ func (self *GuiRepoState) SetCurrentPopupOpts(value *types.CreatePopupPanelOpts) } func (self *GuiRepoState) GetMergeOrRebaseStartedInLazygit() bool { - return self.mergeOrRebaseStartedInLazygit + return self.mergeOrRebaseStartedInLazygit.Load() } func (self *GuiRepoState) SetMergeOrRebaseStartedInLazygit(value bool) { - self.mergeOrRebaseStartedInLazygit = value + self.mergeOrRebaseStartedInLazygit.Store(value) } func (self *GuiRepoState) GetScreenMode() types.ScreenMode { From fd6b20847acba3331d06a1a375ea38b57509f0e2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 16:19:42 +0200 Subject: [PATCH 41/59] Capture the files, reflog, branches and stash refresh inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining refresh scopes each still read model, context, and mode state directly on their worker, racing the UI thread — the same class of race the commits refresh had: - files reads Model.Files (to detect resolved conflicts and drive the auto-stage) and the Files context's ForceShowUntracked; - reflog reads the existing reflog slices (for the incremental fetch), Model.HashPool and the filtering path/author; - branches reads Model.MainBranches and the previous branches (for the BehindBaseBranch carry-over); - stash reads the filtering path. Gather each scope's inputs into an immutable snapshot on the UI thread (via captureOnUIThread) before dispatching the git work, and have the refresh compute from the snapshot — for branches, threaded through both the immediate and the recency-sorted startup loads, which share one snapshot (the BehindBaseBranch carry-over is identical either way). Status, tags and worktrees read nothing UI-owned, so they're left alone. For the snapshots to actually run on the UI thread, the worker callers that reach these scopes must announce themselves: convert the submodule operations, the submodule stash-and-reset, and the background files poller to RefreshFromWorker. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/background.go | 2 +- pkg/gui/controllers/files_controller.go | 2 +- pkg/gui/controllers/helpers/refresh_helper.go | 141 ++++++++++++++---- pkg/gui/controllers/submodules_controller.go | 16 +- 4 files changed, 118 insertions(+), 43 deletions(-) diff --git a/pkg/gui/background.go b/pkg/gui/background.go index 17f3677f6..8633f4624 100644 --- a/pkg/gui/background.go +++ b/pkg/gui/background.go @@ -133,7 +133,7 @@ func (self *BackgroundRoutineMgr) startBackgroundFilesRefresh() { userConfig := self.gui.UserConfig() self.goEvery(userConfig.Refresher.RefreshIntervalDuration(), self.gui.stopChan, func(_ bool) error { - self.gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Background: true}) + self.gui.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Background: true}) return nil }) } diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index d7720ee34..fc35518e7 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1808,7 +1808,7 @@ func (self *FilesController) ResetSubmodule(submodule *models.SubmoduleConfig) e return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.SUBMODULES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.SUBMODULES}}) return nil }) } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 66912b877..cbf388d0a 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -227,13 +227,17 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // whenever we change commits, we should update branches because the upstream/downstream // counts can change. Whenever we change branches we should also change commits // e.g. in the case of switching branches. - // Capture the commits refresh's model/context/mode inputs on the UI - // thread, before the git work is dispatched to a worker, so the - // worker computes from an immutable snapshot instead of reading - // state the UI thread concurrently mutates. + // Capture the commits, reflog and branches refresh inputs (model, + // contexts, modes) on the UI thread, before the git work is dispatched + // to a worker, so the workers compute from an immutable snapshot + // instead of reading state the UI thread concurrently mutates. var capturedCommits capturedCommitState + var capturedReflog capturedReflogState + var capturedBranches capturedBranchState self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { capturedCommits = self.captureCommitsState(options.CommitSelection) + capturedReflog = self.captureReflogState() + capturedBranches = self.captureBranchState() }) refresh("commits and commit files", func() { self.refreshCommitsAndCommitFiles(capturedCommits, options.CommitSelection, options.Background) @@ -243,7 +247,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" { branchesAndRemotesWg.Add(1) refresh("reflog and branches", func() { - loadedBranches = self.refreshReflogAndBranches(includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, options.Background) + loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, options.Background) branchesAndRemotesWg.Done() }) } else { @@ -251,11 +255,13 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr refresh("branches", func() { // Not a recency sort, so branches doesn't depend on the reflog // being fresh; it runs concurrently with the reflog refresh - // below and reads whatever's in the model, as it always has. - loadedBranches = self.refreshBranches(includeWorktreesWithBranches, options.BranchSelection, true, self.c.Model().ReflogCommits, options.Background) + // below and uses the reflog we captured up front, as it always has. + loadedBranches = self.refreshBranches(capturedBranches, includeWorktreesWithBranches, options.BranchSelection, true, capturedReflog.reflogCommits, options.Background) branchesAndRemotesWg.Done() }) - refresh("reflog", func() { _, _ = self.refreshReflogCommits(options.Background, options.SelectTopReflogCommit) }) + refresh("reflog", func() { + _, _ = self.refreshReflogCommits(capturedReflog, options.Background, options.SelectTopReflogCommit) + }) } } else if scopeSet.Includes(types.REBASE_COMMITS) { // the above block handles rebase commits so we only need to call this one @@ -287,15 +293,23 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr fileWg := sync.WaitGroup{} if scopeSet.Includes(types.FILES) { + var capturedFiles capturedFilesState + self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + capturedFiles = self.captureFilesState() + }) fileWg.Add(1) refresh("files", func() { - _ = self.refreshFilesAndSubmodules(options.Background) + _ = self.refreshFilesAndSubmodules(capturedFiles, options.Background) fileWg.Done() }) } if scopeSet.Includes(types.STASH) { - refresh("stash", func() { self.refreshStashEntries(options.Background) }) + var stashFilterPath string + self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + stashFilterPath = self.c.Modes().Filtering.GetPath() + }) + refresh("stash", func() { self.refreshStashEntries(stashFilterPath, options.Background) }) } if scopeSet.Includes(types.TAGS) { @@ -483,17 +497,60 @@ func getModeName(mode types.RefreshMode) string { // order gives the immediate (non-recency) load a lower branch-load sequence // than the async (recency) load, so the sequence guard in refreshBranches keeps // the recency-sorted result even if the two loads' bounces land out of order. -func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, background bool) []*models.Branch { +// capturedReflogState holds the reflog refresh's model/mode inputs, gathered on +// the UI thread before the git work runs. The existing reflog slices feed the +// incremental fetch (we only load entries newer than the ones we already have). +type capturedReflogState struct { + reflogCommits []*models.Commit + filteredReflogCommits []*models.Commit + hashPool *utils.StringPool + filteringActive bool + filterPath string + filterAuthor string +} + +// captureReflogState reads the reflog refresh's inputs into an immutable +// snapshot. It must run on the UI thread. +func (self *RefreshHelper) captureReflogState() capturedReflogState { + return capturedReflogState{ + reflogCommits: self.c.Model().ReflogCommits, + filteredReflogCommits: self.c.Model().FilteredReflogCommits, + hashPool: self.c.Model().HashPool, + filteringActive: self.c.Modes().Filtering.Active(), + filterPath: self.c.Modes().Filtering.GetPath(), + filterAuthor: self.c.Modes().Filtering.GetAuthor(), + } +} + +// capturedBranchState holds the branches refresh's model inputs, gathered on the +// UI thread before the git work runs. oldBranches is used only to carry over the +// previous BehindBaseBranch values (to reduce flicker) — an atomic each, so a +// pre-refresh snapshot serves both the immediate and recency loads identically. +type capturedBranchState struct { + mainBranches *git_commands.MainBranches + oldBranches []*models.Branch +} + +// captureBranchState reads the branches refresh's model inputs into an immutable +// snapshot. It must run on the UI thread. +func (self *RefreshHelper) captureBranchState() capturedBranchState { + return capturedBranchState{ + mainBranches: self.c.Model().MainBranches, + oldBranches: self.c.Model().Branches, + } +} + +func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflogState, capturedBranches capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, background bool) []*models.Branch { switch self.c.State().GetRepoState().GetStartupStage() { case types.INITIAL: // Return the immediate (non-recency) load's branches; the recency-sorted // reload below runs on its own worker after we return. Both hold the same // set of branches, which is all the caller (the PR fetch) needs. - branches := self.refreshBranches(refreshWorktrees, branchSelection, false, self.c.Model().ReflogCommits, background) + branches := self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, false, capturedReflog.reflogCommits, background) self.onWorker(background, func(_ gocui.Task) error { - reflogCommits, _ := self.refreshReflogCommits(background, false) - self.refreshBranches(false, types.SelectCheckedOutBranch, true, reflogCommits, background) + reflogCommits, _ := self.refreshReflogCommits(capturedReflog, background, false) + self.refreshBranches(capturedBranches, false, types.SelectCheckedOutBranch, true, reflogCommits, background) self.c.State().GetRepoState().SetStartupStage(types.COMPLETE) return nil }) @@ -501,8 +558,8 @@ func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, branc return branches case types.COMPLETE: - reflogCommits, _ := self.refreshReflogCommits(background, selectTopReflogCommit) - return self.refreshBranches(refreshWorktrees, branchSelection, true, reflogCommits, background) + reflogCommits, _ := self.refreshReflogCommits(capturedReflog, background, selectTopReflogCommit) + return self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, true, reflogCommits, background) } return nil @@ -901,15 +958,15 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs() ([]*models.SubmoduleCo // self.refreshStatus is called at the end of this because that's when we can // be sure there is a State.Model.Branches array to pick the current branch from -func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, background bool) []*models.Branch { +func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, background bool) []*models.Branch { loadSeq := self.branchLoadSeq.Add(1) generation := self.c.State().GetRepoGeneration() branches, err := self.c.Git().Loaders.BranchLoader.Load( reflogCommits, - self.c.Model().MainBranches, - self.c.Model().Branches, + captured.mainBranches, + captured.oldBranches, loadBehindCounts, func(f func() error) { self.onWorker(background, func(_ gocui.Task) error { @@ -994,13 +1051,13 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, branchSelectio return branches } -func (self *RefreshHelper) refreshFilesAndSubmodules(background bool) error { +func (self *RefreshHelper) refreshFilesAndSubmodules(captured capturedFilesState, background bool) error { configs, err := self.refreshStateSubmoduleConfigs() if err != nil { return err } - if err := self.refreshStateFiles(background, configs); err != nil { + if err := self.refreshStateFiles(captured, background, configs); err != nil { return err } @@ -1077,7 +1134,25 @@ func (self *RefreshHelper) captureOnUIThread(fRunsOnUIThread bool, background bo } } -func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs []*models.SubmoduleConfig) error { +// capturedFilesState holds the files refresh's context/model inputs, gathered +// on the UI thread before the git work runs: the previous files list (to detect +// resolved conflicts and drive the auto-stage), and whether untracked files are +// force-shown. +type capturedFilesState struct { + prevFiles []*models.File + forceShowUntracked bool +} + +// captureFilesState reads the files refresh's inputs into an immutable snapshot. +// It must run on the UI thread. +func (self *RefreshHelper) captureFilesState() capturedFilesState { + return capturedFilesState{ + prevFiles: self.c.Model().Files, + forceShowUntracked: self.c.Contexts().Files.ForceShowUntracked(), + } +} + +func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, background bool, submoduleConfigs []*models.SubmoduleConfig) error { fileTreeViewModel := self.c.Contexts().Files.FileTreeViewModel generation := self.c.State().GetRepoGeneration() @@ -1091,7 +1166,7 @@ func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs [ // Although this also means that at startup we won't be staging anything until // we call git status again. pathsToStage := []string{} - for _, file := range self.c.Model().Files { + for _, file := range captured.prevFiles { if file.HasMergeConflicts { prevConflictFileCount++ } @@ -1115,7 +1190,7 @@ func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs [ files := self.c.Git().Loaders.FileLoader. GetStatusFiles(git_commands.GetStatusFileOptions{ - ForceShowUntracked: self.c.Contexts().Files.ForceShowUntracked(), + ForceShowUntracked: captured.forceShowUntracked, Background: background, }) @@ -1186,15 +1261,15 @@ func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs [ // refreshReflogCommits returns the (non-filtered) ReflogCommits it loaded, so // that a subsequent branches refresh can use them for recency sorting without // having to read them back out of the model. -func (self *RefreshHelper) refreshReflogCommits(background bool, selectTopEntry bool) ([]*models.Commit, error) { +func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, background bool, selectTopEntry bool) ([]*models.Commit, error) { generation := self.c.State().GetRepoGeneration() // pulling state into its own variable in case it gets swapped out for another state // and we get an out of bounds exception model := self.c.Model() // load does the git work on the worker and returns the new value for a - // reflog slice, reading the existing slice for the incremental fetch. The - // caller writes the result in the bounce. + // reflog slice, reading the existing slice (captured on the UI thread) for + // the incremental fetch. The caller writes the result in the bounce. load := func(existing []*models.Commit, filterPath string, filterAuthor string) ([]*models.Commit, error) { var lastReflogCommit *models.Commit if filterPath == "" && filterAuthor == "" && len(existing) > 0 { @@ -1202,7 +1277,7 @@ func (self *RefreshHelper) refreshReflogCommits(background bool, selectTopEntry } commits, onlyObtainedNewReflogCommits, err := self.c.Git().Loaders.ReflogCommitLoader. - GetReflogCommits(model.HashPool, lastReflogCommit, filterPath, filterAuthor) + GetReflogCommits(captured.hashPool, lastReflogCommit, filterPath, filterAuthor) if err != nil { return nil, err } @@ -1213,14 +1288,14 @@ func (self *RefreshHelper) refreshReflogCommits(background bool, selectTopEntry return commits, nil } - reflogCommits, err := load(model.ReflogCommits, "", "") + reflogCommits, err := load(captured.reflogCommits, "", "") if err != nil { return nil, err } filteredReflogCommits := reflogCommits - if self.c.Modes().Filtering.Active() { - filteredReflogCommits, err = load(model.FilteredReflogCommits, self.c.Modes().Filtering.GetPath(), self.c.Modes().Filtering.GetAuthor()) + if captured.filteringActive { + filteredReflogCommits, err = load(captured.filteredReflogCommits, captured.filterPath, captured.filterAuthor) if err != nil { return nil, err } @@ -1304,11 +1379,11 @@ func (self *RefreshHelper) refreshWorktrees(background bool) { self.refreshView(self.c.Contexts().Worktrees, background) } -func (self *RefreshHelper) refreshStashEntries(background bool) { +func (self *RefreshHelper) refreshStashEntries(filterPath string, background bool) { generation := self.c.State().GetRepoGeneration() stashEntries := self.c.Git().Loaders.StashLoader. - GetStashEntries(self.c.Modes().Filtering.GetPath()) + GetStashEntries(filterPath) self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Model().StashEntries = stashEntries diff --git a/pkg/gui/controllers/submodules_controller.go b/pkg/gui/controllers/submodules_controller.go index 97b7ff3dd..a2dd22ed3 100644 --- a/pkg/gui/controllers/submodules_controller.go +++ b/pkg/gui/controllers/submodules_controller.go @@ -164,7 +164,7 @@ func (self *SubmodulesController) add() error { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) return nil }) }, @@ -193,7 +193,7 @@ func (self *SubmodulesController) editURL(submodule *models.SubmoduleConfig) err return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) return nil }) }, @@ -210,7 +210,7 @@ func (self *SubmodulesController) init(submodule *models.SubmoduleConfig) error return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) return nil }) } @@ -229,7 +229,7 @@ func (self *SubmodulesController) openBulkActionsMenu() error { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) return nil }) }, @@ -244,7 +244,7 @@ func (self *SubmodulesController) openBulkActionsMenu() error { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) return nil }) }, @@ -259,7 +259,7 @@ func (self *SubmodulesController) openBulkActionsMenu() error { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) return nil }) }, @@ -274,7 +274,7 @@ func (self *SubmodulesController) openBulkActionsMenu() error { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) return nil }) }, @@ -292,7 +292,7 @@ func (self *SubmodulesController) update(submodule *models.SubmoduleConfig) erro return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) return nil }) } From 5162a768eb4c23531acbdf89d0519f6918060d9d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 16:20:11 +0200 Subject: [PATCH 42/59] Guard every refresh's entry point, not just the commits scope With every scope's worker reads now captured on the UI thread and every worker caller on RefreshFromWorker, the debug entry-point assertion no longer needs to be scoped to the commits refresh. Move it to the top of performRefresh so it guards every refresh regardless of which scopes it touches, and drop the per-scope gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index cbf388d0a..65870ca20 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -119,13 +119,13 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // runs inline or has to hop (see captureOnUIThread). fRunsOnUIThread := options.Mode == types.BLOCK_UI || !calledFromWorker - // Record the caller's own goroutine now, before a BLOCK_UI refresh dispatches - // f onto the UI thread, so the debug assertion below can verify the caller - // picked the entry point matching its thread regardless of the mode. Only - // read in debug (goid stays out of production control flow). - callerIsUIThread := false - if self.c.GetConfig().GetDebug() { - callerIsUIThread = self.c.GocuiGui().IsUIThread() + // Debug-only guard: every refresh must be issued from the entry point that + // matches its goroutine — Refresh on the UI thread, RefreshFromWorker on a + // worker. We check the caller's own goroutine here, before a BLOCK_UI + // refresh dispatches f onto the UI thread, so it holds regardless of the + // mode. goid stays out of production control flow (debug only). + if self.c.GetConfig().GetDebug() && self.c.GocuiGui().IsUIThread() == calledFromWorker { + panic("Refresh called from a worker, or RefreshFromWorker called from the UI thread") } f := func() { @@ -212,18 +212,6 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr var loadedRemotes []*models.Remote includeWorktreesWithBranches := false if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) { - // Debug-only guard: the caller must have picked the entry point that - // matches its goroutine — Refresh on the UI thread, RefreshFromWorker - // on a worker. We check the caller's own thread (captured above, - // before any BLOCK_UI dispatch), so it holds regardless of the mode. - // It's scoped to the commits refresh for now, the one scope whose - // worker reads have moved to the UI-thread capture below; once the - // other scopes are converted too it can move up to guard every - // refresh unconditionally. - if self.c.GetConfig().GetDebug() && callerIsUIThread == calledFromWorker { - panic("Refresh called from a worker, or RefreshFromWorker called from the UI thread") - } - // whenever we change commits, we should update branches because the upstream/downstream // counts can change. Whenever we change branches we should also change commits // e.g. in the case of switching branches. From eb95ae15f39bcee6a57c36f876d689e355cbca73 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 17:22:38 +0200 Subject: [PATCH 43/59] Capture the custom-patch handlers' commit reads on the UI thread These handlers dispatch their rebase to a worker via WithWaitingStatus but read Model().Commits (and, for move-to-selected-commit, the selected line index) from inside that worker, racing the UI thread's model writes. Read them on the UI thread before dispatching and close over the results. getPatchCommitIndex stays as-is: moving its call out of the worker makes its own Model().Commits read UI-thread-bound too, so the identical copy in patch_building_controller.go needs no matching signature change. The two pull-patch-into-new-commit handlers still push a context and close the commit-message panel from the worker; those writes are a separate concern, left for a follow-up. --- .../custom_patch_options_menu_action.go | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/pkg/gui/controllers/custom_patch_options_menu_action.go b/pkg/gui/controllers/custom_patch_options_menu_action.go index cabba4739..20e36833e 100644 --- a/pkg/gui/controllers/custom_patch_options_menu_action.go +++ b/pkg/gui/controllers/custom_patch_options_menu_action.go @@ -132,10 +132,11 @@ func (self *CustomPatchOptionsMenuAction) returnFocusFromPatchExplorerIfNecessar func (self *CustomPatchOptionsMenuAction) handleDeletePatchFromCommit() error { self.returnFocusFromPatchExplorerIfNecessary() + commits := self.c.Model().Commits + commitIndex := self.getPatchCommitIndex() return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { - commitIndex := self.getPatchCommitIndex() self.c.LogAction(self.c.Tr.Actions.RemovePatchFromCommit) - err := self.c.Git().Patch.DeletePatchesFromCommit(self.c.Model().Commits, commitIndex) + err := self.c.Git().Patch.DeletePatchesFromCommit(commits, commitIndex) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) }) } @@ -143,10 +144,12 @@ func (self *CustomPatchOptionsMenuAction) handleDeletePatchFromCommit() error { func (self *CustomPatchOptionsMenuAction) handleMovePatchToSelectedCommit() error { self.returnFocusFromPatchExplorerIfNecessary() + commits := self.c.Model().Commits + commitIndex := self.getPatchCommitIndex() + toCommitIndex := self.c.Contexts().LocalCommits.GetSelectedLineIdx() return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { - commitIndex := self.getPatchCommitIndex() self.c.LogAction(self.c.Tr.Actions.MovePatchToSelectedCommit) - err := self.c.Git().Patch.MovePatchToSelectedCommit(self.c.Model().Commits, commitIndex, self.c.Contexts().LocalCommits.GetSelectedLineIdx()) + err := self.c.Git().Patch.MovePatchToSelectedCommit(commits, commitIndex, toCommitIndex) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) }) } @@ -159,10 +162,11 @@ func (self *CustomPatchOptionsMenuAction) handleMovePatchIntoWorkingTree() error Title: self.c.Tr.MustStashTitle, Prompt: self.c.Tr.MustStashWarning, HandleConfirm: func() error { + commits := self.c.Model().Commits + commitIndex := self.getPatchCommitIndex() return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { - commitIndex := self.getPatchCommitIndex() self.c.LogAction(self.c.Tr.Actions.MovePatchIntoIndex) - err := self.c.Git().Patch.MovePatchIntoIndex(self.c.Model().Commits, commitIndex, mustStash) + err := self.c.Git().Patch.MovePatchIntoIndex(commits, commitIndex, mustStash) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) }) }, @@ -183,10 +187,11 @@ func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommit() error { DescriptionTitle: self.c.Tr.CommitDescriptionTitle, PreserveMessage: false, OnConfirm: func(summary string, description string) error { + commits := self.c.Model().Commits return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { self.c.Helpers().Commits.CloseCommitMessagePanel() self.c.LogAction(self.c.Tr.Actions.MovePatchIntoNewCommit) - err := self.c.Git().Patch.PullPatchIntoNewCommit(self.c.Model().Commits, commitIndex, summary, description) + err := self.c.Git().Patch.PullPatchIntoNewCommit(commits, commitIndex, summary, description) if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err); err != nil { return err } @@ -214,10 +219,11 @@ func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommitBefore() e DescriptionTitle: self.c.Tr.CommitDescriptionTitle, PreserveMessage: false, OnConfirm: func(summary string, description string) error { + commits := self.c.Model().Commits return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { self.c.Helpers().Commits.CloseCommitMessagePanel() self.c.LogAction(self.c.Tr.Actions.MovePatchIntoNewCommit) - err := self.c.Git().Patch.PullPatchIntoNewCommitBefore(self.c.Model().Commits, commitIndex, summary, description) + err := self.c.Git().Patch.PullPatchIntoNewCommitBefore(commits, commitIndex, summary, description) if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err); err != nil { return err } From b4a976834f9672bdf2bd2da0a7496d8851846efa Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 17:24:32 +0200 Subject: [PATCH 44/59] Capture reword/amend/author commit reads on the UI thread handleReword, amendTo, and the reset/set/add-co-author handlers pass Model().Commits (and the selected line index) to a git rebase from inside the WithWaitingStatus worker, racing the UI thread's model writes. Read them on the UI thread before dispatching. The author handlers index the full commit list by absolute start/end, so the range sub-slice withItemsRange hands amendAttribute is not what they need; capture the full Model().Commits there and thread it through. --- .../controllers/local_commits_controller.go | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 12244d983..595918fb2 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -475,7 +475,9 @@ func (self *LocalCommitsController) switchFromCommitMessagePanelToEditor(filepat } func (self *LocalCommitsController) handleReword(summary string, description string) error { - if models.IsHeadCommit(self.c.Model().Commits, self.c.Contexts().LocalCommits.GetSelectedLineIdx()) { + commits := self.c.Model().Commits + selectedIdx := self.c.Contexts().LocalCommits.GetSelectedLineIdx() + if models.IsHeadCommit(commits, selectedIdx) { // we've selected the top commit so no rebase is required return self.c.Helpers().GPG.WithGpgHandling(self.c.Git().Commit.RewordLastCommit(summary, description), git_commands.CommitGpgSign, @@ -483,7 +485,7 @@ func (self *LocalCommitsController) handleReword(summary string, description str } return self.c.WithWaitingStatus(self.c.Tr.RewordingStatus, func(gocui.Task) error { - err := self.c.Git().Rebase.RewordCommit(self.c.Model().Commits, self.c.Contexts().LocalCommits.GetSelectedLineIdx(), summary, description) + err := self.c.Git().Rebase.RewordCommit(commits, selectedIdx, summary, description) if err != nil { return err } @@ -788,11 +790,13 @@ func (self *LocalCommitsController) amendTo(commit *models.Commit) error { }) } } else { + commits := self.c.Model().Commits + selectedIdx := self.context().GetView().SelectedLineIdx() handleCommit = func() error { return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error { return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.AmendCommit) - err := self.c.Git().Rebase.AmendTo(self.c.Model().Commits, self.context().GetView().SelectedLineIdx()) + err := self.c.Git().Rebase.AmendTo(commits, selectedIdx) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) }) }) @@ -820,26 +824,30 @@ func (self *LocalCommitsController) canAmend(_ *models.Commit) *types.DisabledRe return self.canAmendRange(self.c.Model().Commits, idx, idx) } -func (self *LocalCommitsController) amendAttribute(commits []*models.Commit, start, end int) error { +func (self *LocalCommitsController) amendAttribute(_ []*models.Commit, start, end int) error { + // The author operations index into the full commit list by absolute + // start/end, so capture that here on the UI thread rather than reading + // Model().Commits from the worker the menu items dispatch to. + commits := self.c.Model().Commits opts := self.c.KeybindingsOpts() return self.c.Menu(types.CreateMenuOptions{ Title: "Amend commit attribute", Items: []*types.MenuItem{ { Label: self.c.Tr.ResetAuthor, - OnPress: func() error { return self.resetAuthor(start, end) }, + OnPress: func() error { return self.resetAuthor(commits, start, end) }, Keys: opts.GetKeys(opts.Config.AmendAttribute.ResetAuthor), Tooltip: self.c.Tr.ResetAuthorTooltip, }, { Label: self.c.Tr.SetAuthor, - OnPress: func() error { return self.setAuthor(start, end) }, + OnPress: func() error { return self.setAuthor(commits, start, end) }, Keys: opts.GetKeys(opts.Config.AmendAttribute.SetAuthor), Tooltip: self.c.Tr.SetAuthorTooltip, }, { Label: self.c.Tr.AddCoAuthor, - OnPress: func() error { return self.addCoAuthor(start, end) }, + OnPress: func() error { return self.addCoAuthor(commits, start, end) }, Keys: opts.GetKeys(opts.Config.AmendAttribute.AddCoAuthor), Tooltip: self.c.Tr.AddCoAuthorTooltip, }, @@ -847,10 +855,10 @@ func (self *LocalCommitsController) amendAttribute(commits []*models.Commit, sta }) } -func (self *LocalCommitsController) resetAuthor(start, end int) error { +func (self *LocalCommitsController) resetAuthor(commits []*models.Commit, start, end int) error { return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.ResetCommitAuthor) - if err := self.c.Git().Rebase.ResetCommitAuthor(self.c.Model().Commits, start, end); err != nil { + if err := self.c.Git().Rebase.ResetCommitAuthor(commits, start, end); err != nil { return err } @@ -859,14 +867,14 @@ func (self *LocalCommitsController) resetAuthor(start, end int) error { }) } -func (self *LocalCommitsController) setAuthor(start, end int) error { +func (self *LocalCommitsController) setAuthor(commits []*models.Commit, start, end int) error { self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.SetAuthorPromptTitle, FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(), HandleConfirm: func(value string) error { return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.SetCommitAuthor) - if err := self.c.Git().Rebase.SetCommitAuthor(self.c.Model().Commits, start, end, value); err != nil { + if err := self.c.Git().Rebase.SetCommitAuthor(commits, start, end, value); err != nil { return err } @@ -879,14 +887,14 @@ func (self *LocalCommitsController) setAuthor(start, end int) error { return nil } -func (self *LocalCommitsController) addCoAuthor(start, end int) error { +func (self *LocalCommitsController) addCoAuthor(commits []*models.Commit, start, end int) error { self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.AddCoAuthorPromptTitle, FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(), HandleConfirm: func(value string) error { return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.AddCommitCoAuthor) - if err := self.c.Git().Rebase.AddCommitCoAuthor(self.c.Model().Commits, start, end, value); err != nil { + if err := self.c.Git().Rebase.AddCommitCoAuthor(commits, start, end, value); err != nil { return err } self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) From fceba3121209658009109b98b2907db66e586e26 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 17:26:30 +0200 Subject: [PATCH 45/59] Capture moveCommitsToNewBranch's model reads on the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two move helpers run inside the WithWaitingStatus worker that withNewBranchNamePrompt dispatches to, but read Model().Files/Submodules (to decide whether to auto-stash) and Model().Commits (the unpushed commits to cherry-pick off the base branch) from there, racing the UI thread's model writes. Compute mustStash — needed by both paths — at the top, and the unpushed commits in the off-of-main menu item, on the UI thread, and pass them into the helpers. --- pkg/gui/controllers/helpers/refs_helper.go | 25 ++++++++++++---------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index 70ff18593..61f3a232d 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -424,6 +424,8 @@ func (self *RefsHelper) MoveCommitsToNewBranch() error { return err } + mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) + withNewBranchNamePrompt := func(baseBranchName string, f func(string) error) error { prompt := utils.ResolvePlaceholderString( self.c.Tr.NewBranchNameBranchOff, @@ -462,7 +464,9 @@ func (self *RefsHelper) MoveCommitsToNewBranch() error { Title: self.c.Tr.MoveCommitsToNewBranch, Prompt: prompt, HandleConfirm: func() error { - return withNewBranchNamePrompt(currentBranch.Name, self.moveCommitsToNewBranchStackedOnCurrentBranch) + return withNewBranchNamePrompt(currentBranch.Name, func(newBranchName string) error { + return self.moveCommitsToNewBranchStackedOnCurrentBranch(newBranchName, mustStash) + }) }, }) return nil @@ -482,27 +486,31 @@ func (self *RefsHelper) MoveCommitsToNewBranch() error { { Label: fmt.Sprintf(self.c.Tr.MoveCommitsToNewBranchFromBaseItem, shortBaseBranchName), OnPress: func() error { + commitsToCherryPick := lo.Filter(self.c.Model().Commits, func(commit *models.Commit, _ int) bool { + return commit.Status == models.StatusUnpushed + }) return withNewBranchNamePrompt(shortBaseBranchName, func(newBranchName string) error { - return self.moveCommitsToNewBranchOffOfMainBranch(newBranchName, baseBranchRef) + return self.moveCommitsToNewBranchOffOfMainBranch(newBranchName, baseBranchRef, commitsToCherryPick, mustStash) }) }, }, { Label: fmt.Sprintf(self.c.Tr.MoveCommitsToNewBranchStackedItem, currentBranch.Name), OnPress: func() error { - return withNewBranchNamePrompt(currentBranch.Name, self.moveCommitsToNewBranchStackedOnCurrentBranch) + return withNewBranchNamePrompt(currentBranch.Name, func(newBranchName string) error { + return self.moveCommitsToNewBranchStackedOnCurrentBranch(newBranchName, mustStash) + }) }, }, }, }) } -func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchName string) error { +func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchName string, mustStash bool) error { if err := self.c.Git().Branch.NewWithoutCheckout(newBranchName, "HEAD"); err != nil { return err } - mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) if mustStash { if err := self.c.Git().Stash.Push(fmt.Sprintf(self.c.Tr.AutoStashForNewBranch, newBranchName)); err != nil { return err @@ -532,12 +540,7 @@ func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchNa return nil } -func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName string, baseBranchRef string) error { - commitsToCherryPick := lo.Filter(self.c.Model().Commits, func(commit *models.Commit, _ int) bool { - return commit.Status == models.StatusUnpushed - }) - - mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) +func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName string, baseBranchRef string, commitsToCherryPick []*models.Commit, mustStash bool) error { if mustStash { if err := self.c.Git().Stash.Push(fmt.Sprintf(self.c.Tr.AutoStashForNewBranch, newBranchName)); err != nil { return err From 462d75232bdd97d73981e0854ba04347e763f4b1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 17:27:07 +0200 Subject: [PATCH 46/59] Look up the submodule file and branch worktree on the UI thread ResetSubmodule and fastForward each call a helper that reads the model from inside their worker: FileForSubmodule reads Model().Files and worktreeForBranch reads Model().Worktrees, racing the UI thread's model writes. Hoist both lookups above the worker dispatch. --- pkg/gui/controllers/branches_controller.go | 2 +- pkg/gui/controllers/files_controller.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index 9b9e9e546..a886a410b 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -710,9 +710,9 @@ func (self *BranchesController) fastForward(branch *models.Branch) error { } action := self.c.Tr.Actions.FastForwardBranch + worktree, ok := self.worktreeForBranch(branch) return self.c.WithInlineStatus(branch, types.ItemOperationFastForwarding, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error { - worktree, ok := self.worktreeForBranch(branch) if ok { self.c.LogAction(action) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index fc35518e7..b70b67ab7 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1791,10 +1791,10 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error { } func (self *FilesController) ResetSubmodule(submodule *models.SubmoduleConfig) error { + file := self.c.Helpers().WorkingTree.FileForSubmodule(submodule) return self.c.WithWaitingStatus(self.c.Tr.ResettingSubmoduleStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.ResetSubmodule) - file := self.c.Helpers().WorkingTree.FileForSubmodule(submodule) if file != nil { if err := self.c.Git().WorkingTree.UnStageFile(file.Names(), file.Tracked); err != nil { return err From 2edfeac5382c2b9d4c107795d8e8cc7d0cccfbb0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 17:32:19 +0200 Subject: [PATCH 47/59] Capture the commit-file discard and patch-toggle reads on the UI thread discard reads Model().Commits and the selected commit index from its WithWaitingStatus worker; read them in HandleConfirm instead. toggleForPatch reads the commit-files ref name from the worker, and its startPatchBuilder call reads the context's canRebase and diff range from there too. Capture the ref name and run startPatchBuilder in HandleConfirm before dispatching; PatchBuilder.Start only assigns fields, so moving it off the worker changes no timing. discard still collapses the range selection from the worker; that write is a separate concern, left for a follow-up. --- .../controllers/commits_files_controller.go | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index f9fda0b93..07979de5f 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -337,6 +337,8 @@ func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileN Title: self.c.Tr.DiscardFileChangesTitle, Prompt: prompt, HandleConfirm: func() error { + commits := self.c.Model().Commits + selectedLineIdx := self.c.Contexts().LocalCommits.GetSelectedLineIdx() return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { var filePaths []string selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes) @@ -356,7 +358,7 @@ func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileN }) } - err := self.c.Git().Rebase.DiscardOldFileChanges(self.c.Model().Commits, self.c.Contexts().LocalCommits.GetSelectedLineIdx(), filePaths) + err := self.c.Git().Rebase.DiscardOldFileChanges(commits, selectedLineIdx, filePaths) if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err); err != nil { return err } @@ -442,20 +444,16 @@ func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.Comm self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView) } + refName := self.context().GetRef().RefName() + toggle := func() error { return self.c.WithWaitingStatus(self.c.Tr.UpdatingPatch, func(gocui.Task) error { - if !self.c.Git().Patch.PatchBuilder.Active() { - if err := self.startPatchBuilder(); err != nil { - return err - } - } - selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes) // Find if any file in the selection is unselected or partially added adding := lo.SomeBy(selectedNodes, func(node *filetree.CommitFileNode) bool { return node.SomeFile(func(file *models.CommitFile) bool { - fileStatus := self.c.Git().Patch.PatchBuilder.GetFileStatus(file.Path, self.context().GetRef().RefName()) + fileStatus := self.c.Git().Patch.PatchBuilder.GetFileStatus(file.Path, refName) return fileStatus == patch.PART || fileStatus == patch.UNSELECTED }) }) @@ -498,6 +496,12 @@ func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.Comm self.c.Git().Patch.PatchBuilder.Reset() } + if !self.c.Git().Patch.PatchBuilder.Active() { + if err := self.startPatchBuilder(); err != nil { + return err + } + } + return toggle() }, }) From 6d21efb515f9e5850d30821fee35c71fda560ca2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 09:57:49 +0200 Subject: [PATCH 48/59] Make the local-commits limit-commits flag atomic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CheckoutRef and ResetToRef set this flag from their worker goroutine (to load fewer commits for speed) while the commits refresh reads it on the UI thread in captureCommitsState to decide how many to load — a data race. Make it an atomic.Bool so those writes are safe where they are, rather than routing the flag through a refresh intent. Precedent: Branch.BehindBaseBranch. --- pkg/gui/context/local_commits_context.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index 056035cce..d929aca88 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "strings" + "sync/atomic" "time" "github.com/jesseduffield/lazygit/pkg/commands/models" @@ -142,7 +143,9 @@ type LocalCommitsViewModel struct { // If this is true we limit the amount of commits we load, for the sake of keeping things fast. // If the user attempts to scroll past the end of the list, we will load more commits. - limitCommits bool + // Atomic because a checkout or reset sets it from a worker goroutine while the + // commits refresh reads it on the UI thread to decide how many commits to load. + limitCommits atomic.Bool // If this is true we'll use git log --all when fetching the commits. showWholeGitGraph bool @@ -151,9 +154,9 @@ type LocalCommitsViewModel struct { func NewLocalCommitsViewModel(getModel func() []*models.Commit, c *ContextCommon) *LocalCommitsViewModel { self := &LocalCommitsViewModel{ ListViewModel: NewListViewModel(getModel), - limitCommits: true, showWholeGitGraph: c.UserConfig().Git.Log.ShowWholeGraph, } + self.limitCommits.Store(true) return self } @@ -225,11 +228,11 @@ func (self *LocalCommitsContext) ModelSearchResults(searchStr string, caseSensit } func (self *LocalCommitsViewModel) SetLimitCommits(value bool) { - self.limitCommits = value + self.limitCommits.Store(value) } func (self *LocalCommitsViewModel) GetLimitCommits() bool { - return self.limitCommits + return self.limitCommits.Load() } func (self *LocalCommitsViewModel) SetShowWholeGitGraph(value bool) { From 6c38ddc9a7e0fc5fbfb6a0c965534336e60c1aa1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 10:00:09 +0200 Subject: [PATCH 49/59] Set ResetToRef's post-reset selection via refresh intents ResetToRef ran on a worker and wrote the local-commits and reflog selection directly (SetSelection(0) on both) before its refresh, racing the UI thread. Fold those into the refresh's selection intents: SelectHeadCommit for the commits (after a reset HEAD is the top commit, and mid-interactive-rebase it correctly picks the real head over the first todo entry) and SelectTopReflogCommit for the reflog. The now-atomic SetLimitCommits stays where it is. --- pkg/gui/controllers/helpers/refs_helper.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index 61f3a232d..5f07b8ea6 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -199,12 +199,14 @@ func (self *RefsHelper) ResetToRef(ref string, strength string, envVars []string return err } - self.c.Contexts().LocalCommits.SetSelection(0) - self.c.Contexts().ReflogCommits.SetSelection(0) // loading a heap of commits is slow so we limit them whenever doing a reset self.c.Contexts().LocalCommits.SetLimitCommits(true) - self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.BRANCHES, types.REFLOG, types.COMMITS}, CommitSelection: types.KeepCommitSelectionIndex}) + self.c.RefreshFromWorker(types.RefreshOptions{ + Scope: []types.RefreshableView{types.FILES, types.BRANCHES, types.REFLOG, types.COMMITS}, + CommitSelection: types.SelectHeadCommit, + SelectTopReflogCommit: true, + }) return nil } From 5d8c89349779622903a06f2e06c10c31650eb2c7 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 10:03:16 +0200 Subject: [PATCH 50/59] Capture commits and set selection on the UI thread for squash/fixup/drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit interactiveRebaseWithFlag and dropMergeCommit ran inside the WithWaitingStatus worker but read Model().Commits and wrote the selection (SetSelection(startIdx)) there, racing the UI thread. Thread the commits slice in from each caller, and hoist the pre-rebase selection into a UI-thread helper (selectRebaseResultCommit) called before dispatching — squash/fixup unconditionally, drop only on the non-merge path, matching the previous action guard. --- .../controllers/local_commits_controller.go | 47 ++++++++++++------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 595918fb2..23e94adf7 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -340,9 +340,11 @@ func (self *LocalCommitsController) squashDown(selectedCommits []*models.Commit, Title: self.c.Tr.Squash, Prompt: self.c.Tr.SureSquashThisCommit, HandleConfirm: func() error { + commits := self.c.Model().Commits + self.selectRebaseResultCommit(startIdx) return self.c.WithWaitingStatus(self.c.Tr.SquashingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.SquashCommitDown) - return self.interactiveRebase(todo.Squash, startIdx, endIdx) + return self.interactiveRebase(commits, todo.Squash, startIdx, endIdx) }) }, }) @@ -362,9 +364,11 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star Label: self.c.Tr.Fixup, Keys: menuKey('f'), OnPress: func() error { + commits := self.c.Model().Commits + self.selectRebaseResultCommit(startIdx) return self.c.WithWaitingStatus(self.c.Tr.FixingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.FixupCommit) - return self.interactiveRebase(todo.Fixup, startIdx, endIdx) + return self.interactiveRebase(commits, todo.Fixup, startIdx, endIdx) }) }, Tooltip: self.c.Tr.FixupTooltip, @@ -373,9 +377,11 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star Label: self.c.Tr.FixupKeepMessage, Keys: menuKey('c'), OnPress: func() error { + commits := self.c.Model().Commits + self.selectRebaseResultCommit(startIdx) return self.c.WithWaitingStatus(self.c.Tr.FixingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.FixupCommitKeepMessage) - return self.interactiveRebaseWithFlag(todo.Fixup, startIdx, endIdx, "-C") + return self.interactiveRebaseWithFlag(commits, todo.Fixup, startIdx, endIdx, "-C") }) }, Tooltip: self.c.Tr.FixupKeepMessageTooltip, @@ -566,12 +572,16 @@ func (self *LocalCommitsController) drop(selectedCommits []*models.Commit, start Title: self.c.Tr.DropCommitTitle, Prompt: lo.Ternary(isMerge, self.c.Tr.DropMergeCommitPrompt, self.c.Tr.DropCommitPrompt), HandleConfirm: func() error { + commits := self.c.Model().Commits + if !isMerge { + self.selectRebaseResultCommit(startIdx) + } return self.c.WithWaitingStatus(self.c.Tr.DroppingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.DropCommit) if isMerge { - return self.dropMergeCommit(startIdx) + return self.dropMergeCommit(commits, startIdx) } - return self.interactiveRebase(todo.Drop, startIdx, endIdx) + return self.interactiveRebase(commits, todo.Drop, startIdx, endIdx) }) }, }) @@ -579,8 +589,8 @@ func (self *LocalCommitsController) drop(selectedCommits []*models.Commit, start return nil } -func (self *LocalCommitsController) dropMergeCommit(commitIdx int) error { - err := self.c.Git().Rebase.DropMergeCommit(self.c.Model().Commits, commitIdx) +func (self *LocalCommitsController) dropMergeCommit(commits []*models.Commit, commitIdx int) error { + err := self.c.Git().Rebase.DropMergeCommit(commits, commitIdx) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) } @@ -658,22 +668,25 @@ func (self *LocalCommitsController) pick(selectedCommits []*models.Commit) error panic("should be disabled when not rebasing") } -func (self *LocalCommitsController) interactiveRebase(action todo.TodoCommand, startIdx int, endIdx int) error { - return self.interactiveRebaseWithFlag(action, startIdx, endIdx, "") +func (self *LocalCommitsController) interactiveRebase(commits []*models.Commit, action todo.TodoCommand, startIdx int, endIdx int) error { + return self.interactiveRebaseWithFlag(commits, action, startIdx, endIdx, "") } -func (self *LocalCommitsController) interactiveRebaseWithFlag(action todo.TodoCommand, startIdx int, endIdx int, flag string) error { - // When performing an action that will remove the selected commits, we need to select the - // next commit down (which will end up at the start index after the action is performed) - if action == todo.Drop || action == todo.Fixup || action == todo.Squash { - self.context().SetSelection(startIdx) - } - - err := self.c.Git().Rebase.InteractiveRebase(self.c.Model().Commits, startIdx, endIdx, action, flag) +func (self *LocalCommitsController) interactiveRebaseWithFlag(commits []*models.Commit, action todo.TodoCommand, startIdx int, endIdx int, flag string) error { + err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, action, flag) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) } +// selectRebaseResultCommit selects the commit that a drop/fixup/squash starting +// at startIdx will leave there. It must run on the UI thread before the rebase: +// the commit currently at startIdx is removed, so the refresh's +// keep-selection-by-hash can't restore it and falls back to the index, which by +// then holds the commit that shifted up into its place. +func (self *LocalCommitsController) selectRebaseResultCommit(startIdx int) { + self.context().SetSelection(startIdx) +} + // updateTodos sees if the selected commit is in fact a rebasing // commit meaning you are trying to edit the todo file rather than actually // begin a rebase. It then updates the todo file with that action From f07e94afe0cf236f52d75eef3b8177e119c70b67 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 10:12:13 +0200 Subject: [PATCH 51/59] Keep RebaseOntoRef's marked-base access on the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three rebase-onto menu items read Modes().MarkedBaseCommit.GetHash() (a bare string field) and, on success, cleared it via ResetMarkedBaseCommit and pushed the commits context — all from the WithWaitingStatus worker, racing the UI thread. Read the marked base hash before dispatching, and bounce the post-rebase reset and context push through OnUIThread, still guarded by the success check so they don't run on the conflict path. --- .../helpers/merge_and_rebase_helper.go | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 6adf84712..b0c53b831 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -424,8 +424,8 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { DisabledReason: disabledReason, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.RebaseBranch) + baseCommit := self.c.Modes().MarkedBaseCommit.GetHash() return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(task gocui.Task) error { - baseCommit := self.c.Modes().MarkedBaseCommit.GetHash() var err error if baseCommit != "" { err = self.c.Git().Rebase.RebaseBranchFromBaseCommit(ref, baseCommit) @@ -434,7 +434,9 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { } err = self.CheckMergeOrRebase(err) if err == nil { - return self.ResetMarkedBaseCommit() + self.c.OnUIThread(func() error { + return self.ResetMarkedBaseCommit() + }) } return err }) @@ -449,8 +451,8 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { Tooltip: self.c.Tr.InteractiveRebaseTooltip, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.RebaseBranch) + baseCommit := self.c.Modes().MarkedBaseCommit.GetHash() return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(task gocui.Task) error { - baseCommit := self.c.Modes().MarkedBaseCommit.GetHash() var err error if baseCommit != "" { err = self.c.Git().Rebase.EditRebaseFromBaseCommit(ref, baseCommit) @@ -460,10 +462,13 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { if err = self.CheckMergeOrRebase(err); err != nil { return err } - if err = self.ResetMarkedBaseCommit(); err != nil { - return err - } - self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) + self.c.OnUIThread(func() error { + if err := self.ResetMarkedBaseCommit(); err != nil { + return err + } + self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) + return nil + }) return nil }) }, @@ -477,8 +482,8 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { Tooltip: self.c.Tr.RebaseOntoBaseBranchTooltip, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.RebaseBranch) + baseCommit := self.c.Modes().MarkedBaseCommit.GetHash() return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(task gocui.Task) error { - baseCommit := self.c.Modes().MarkedBaseCommit.GetHash() var err error if baseCommit != "" { err = self.c.Git().Rebase.RebaseBranchFromBaseCommit(baseBranch, baseCommit) @@ -487,7 +492,9 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { } err = self.CheckMergeOrRebase(err) if err == nil { - return self.ResetMarkedBaseCommit() + self.c.OnUIThread(func() error { + return self.ResetMarkedBaseCommit() + }) } return err }) From 67b0a6b1a46ef80399454f5954fd1f5c33ddacfa Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 10:12:19 +0200 Subject: [PATCH 52/59] Move the pull-patch panel close and focus off the worker The pull-patch-into-new-commit handlers closed the commit-message panel and, on success, pushed the local-commits context from inside the WithWaitingStatus worker. Close the panel in OnConfirm before dispatching (UI thread), and bounce the post-rebase context push through OnUIThread, keeping it on the success path. --- .../custom_patch_options_menu_action.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/pkg/gui/controllers/custom_patch_options_menu_action.go b/pkg/gui/controllers/custom_patch_options_menu_action.go index 20e36833e..3d15ce899 100644 --- a/pkg/gui/controllers/custom_patch_options_menu_action.go +++ b/pkg/gui/controllers/custom_patch_options_menu_action.go @@ -188,14 +188,17 @@ func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommit() error { PreserveMessage: false, OnConfirm: func(summary string, description string) error { commits := self.c.Model().Commits + self.c.Helpers().Commits.CloseCommitMessagePanel() return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { - self.c.Helpers().Commits.CloseCommitMessagePanel() self.c.LogAction(self.c.Tr.Actions.MovePatchIntoNewCommit) err := self.c.Git().Patch.PullPatchIntoNewCommit(commits, commitIndex, summary, description) if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err); err != nil { return err } - self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) + self.c.OnUIThread(func() error { + self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) + return nil + }) return nil }) }, @@ -220,14 +223,17 @@ func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommitBefore() e PreserveMessage: false, OnConfirm: func(summary string, description string) error { commits := self.c.Model().Commits + self.c.Helpers().Commits.CloseCommitMessagePanel() return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { - self.c.Helpers().Commits.CloseCommitMessagePanel() self.c.LogAction(self.c.Tr.Actions.MovePatchIntoNewCommit) err := self.c.Git().Patch.PullPatchIntoNewCommitBefore(commits, commitIndex, summary, description) if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err); err != nil { return err } - self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) + self.c.OnUIThread(func() error { + self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) + return nil + }) return nil }) }, From e7105a3138cebf767e15f6f5fd00d816f971609b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 10:17:33 +0200 Subject: [PATCH 53/59] Collapse the branch range selection on the UI thread after a delete The three branch-delete handlers and the two worktree-removal continuations collapsed the Branches/RemoteBranches range selection from their worker goroutine, racing the UI thread. Wrap each collapse in OnUIThread, keeping it in the same spot relative to the refresh (FIFO preserves the collapse-then-refresh order the name-restore depends on). --- .../controllers/helpers/branches_helper.go | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 683d26db3..5c72bacfd 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -45,7 +45,10 @@ func (self *BranchesHelper) ConfirmLocalDelete(branches []*models.Branch) error return err } - self.c.Contexts().Branches.CollapseRangeSelectionToTop() + self.c.OnUIThread(func() error { + self.c.Contexts().Branches.CollapseRangeSelectionToTop() + return nil + }) self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}}) return nil }) @@ -86,7 +89,10 @@ func (self *BranchesHelper) ConfirmDeleteRemote(remoteBranches []*models.RemoteB } self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) if resetRemoteBranchesSelection { - self.c.Contexts().RemoteBranches.CollapseRangeSelectionToTop() + self.c.OnUIThread(func() error { + self.c.Contexts().RemoteBranches.CollapseRangeSelectionToTop() + return nil + }) } return nil }) @@ -151,7 +157,10 @@ func (self *BranchesHelper) ConfirmLocalAndRemoteDelete(branches []*models.Branc return err } - self.c.Contexts().Branches.CollapseRangeSelectionToTop() + self.c.OnUIThread(func() error { + self.c.Contexts().Branches.CollapseRangeSelectionToTop() + return nil + }) self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) return nil }) @@ -311,7 +320,10 @@ func (self *BranchesHelper) deleteLocalBranchesContinuation(branches []*models.B return err } - self.c.Contexts().Branches.CollapseRangeSelectionToTop() + self.c.OnUIThread(func() error { + self.c.Contexts().Branches.CollapseRangeSelectionToTop() + return nil + }) self.c.RefreshFromWorker(types.RefreshOptions{ Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}, @@ -329,7 +341,10 @@ func (self *BranchesHelper) deleteLocalAndRemoteBranchesContinuation(branches [] return err } - self.c.Contexts().Branches.CollapseRangeSelectionToTop() + self.c.OnUIThread(func() error { + self.c.Contexts().Branches.CollapseRangeSelectionToTop() + return nil + }) self.c.RefreshFromWorker(types.RefreshOptions{ Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.REMOTES, types.FILES}, From 6cd93de5b9ba4938cbe648eeab034fc813eb1243 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 10:18:52 +0200 Subject: [PATCH 54/59] Cancel the commit-file range selection on the UI thread after discard The discard handler cancelled the commit-files range selection from its WithWaitingStatus worker. Bounce it through OnUIThread, keeping it after the successful CheckMergeOrRebase as before. --- pkg/gui/controllers/commits_files_controller.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index 07979de5f..b90e14b74 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -363,9 +363,12 @@ func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileN return err } - if self.context().RangeSelectEnabled() { - self.context().GetList().CancelRangeSelect() - } + self.c.OnUIThread(func() error { + if self.context().RangeSelectEnabled() { + self.context().GetList().CancelRangeSelect() + } + return nil + }) return nil }) From 12757e2723c830d6831913892bb2cc5cde785873 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 10:20:14 +0200 Subject: [PATCH 55/59] Swap the file-path suggestions trie on the UI thread GetFilePathSuggestionsFunc builds the trie on a worker (the slow AllRepoFiles walk) and then assigned Model().FilesTrie and refreshed the suggestions panel from there, racing the UI thread that reads the trie. Keep the build on the worker but bounce just the model assignment and the refresh through OnUIThread. --- pkg/gui/controllers/helpers/suggestions_helper.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/pkg/gui/controllers/helpers/suggestions_helper.go b/pkg/gui/controllers/helpers/suggestions_helper.go index d26f96f1c..8a5916816 100644 --- a/pkg/gui/controllers/helpers/suggestions_helper.go +++ b/pkg/gui/controllers/helpers/suggestions_helper.go @@ -137,10 +137,12 @@ func (self *SuggestionsHelper) GetFilePathSuggestionsFunc() func(string) []*type trie.Insert(patricia.Prefix(file), file) } - // cache the trie for future use - self.c.Model().FilesTrie = trie - - self.c.Contexts().Suggestions.RefreshSuggestions() + self.c.OnUIThread(func() error { + // cache the trie for future use + self.c.Model().FilesTrie = trie + self.c.Contexts().Suggestions.RefreshSuggestions() + return nil + }) return err }) From fefb3b632e79ea478d5883b5f5e25e64555c340d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 10:20:14 +0200 Subject: [PATCH 56/59] Clear the preserved commit message on the UI thread The commit's gpg onSuccess runs on a worker when the command output is streamed, so its ClearPreservedCommitMessage wrote commit-message context state off the UI thread. Bounce that write through OnUIThread. --- pkg/gui/controllers/helpers/working_tree_helper.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/helpers/working_tree_helper.go b/pkg/gui/controllers/helpers/working_tree_helper.go index 7e321854b..36dfd2032 100644 --- a/pkg/gui/controllers/helpers/working_tree_helper.go +++ b/pkg/gui/controllers/helpers/working_tree_helper.go @@ -149,7 +149,12 @@ func (self *WorkingTreeHelper) handleCommit(summary string, description string, self.c.LogAction(self.c.Tr.Actions.Commit) return self.gpgHelper.WithGpgHandlingAndSelectHeadCommit(cmdObj, git_commands.CommitGpgSign, self.c.Tr.CommittingStatus, func() error { - self.commitsHelper.ClearPreservedCommitMessage() + // This runs on a worker when the commit output is streamed, so + // bounce the preserved-message write to the UI thread. + self.c.OnUIThread(func() error { + self.commitsHelper.ClearPreservedCommitMessage() + return nil + }) return nil }) } From 2c3a6acafaf511696d4dfb8f5c46a63c2463a65d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 12:36:02 +0200 Subject: [PATCH 57/59] Thread a refreshEnv through the refresh scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every refresh scope needs two ambient values to bounce its model and view updates back to the UI thread safely: the background flag (which picks the dispatch variant that doesn't count towards lazygit being busy) and the repo generation that guards the bounce against a repo switch. These were threaded separately — background as a parameter on every refreshXxx function, generation re-read from the model inside each one. Bundle them into a single refreshEnv passed through instead, so the guard has a home to grow into (the next commit needs the generation in refreshView, which currently has no access to it). Capturing the generation once, at the start of the refresh, is also more correct than the previous per-function re-read. The baseline should reflect the repo whose inputs the refresh snapshotted (all captured up front on the UI thread), not whenever each scope's worker happens to wake. With the per-function read, a background refresh whose worker woke after a repo switch would read the new generation and let its bounce through, writing data computed from the old repo's inputs into the new repo; capturing up front makes that bounce drop instead. No behavior change for foreground refreshes, where the UI thread is held for the whole refresh and the generation can't move under it. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 245 +++++++++--------- pkg/gui/types/common.go | 8 +- 2 files changed, 123 insertions(+), 130 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 65870ca20..b1b3ef017 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -89,6 +89,15 @@ func (self *RefreshHelper) RefreshFromWorker(options types.RefreshOptions) { self.performRefresh(options, true) } +type refreshEnv struct { + // whether this is a background refresh (which selects the dispatch variant that + // doesn't count towards lazygit being busy) + background bool + + // the repo generation captured when the refresh started + generation int +} + func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFromWorker bool) { if options.Mode == types.ASYNC && options.Then != nil { panic("RefreshOptions.Then doesn't work with mode ASYNC") @@ -129,6 +138,13 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr } f := func() { + // Capture the repo generation once, here at the start, so every scope's + // bounce is guarded against the same baseline. + env := refreshEnv{ + background: options.Background, + generation: self.c.State().GetRepoGeneration(), + } + var scopeSet *set.Set[types.RefreshableView] if len(options.Scope) == 0 { // not refreshing staging/patch-building unless explicitly requested because we only need @@ -186,7 +202,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // everything happens fast and it's better to have everything update // in the one frame if !self.c.InDemo() && options.Mode == types.ASYNC { - self.onWorker(options.Background, func(t gocui.Task) error { + self.onWorker(env.background, func(t gocui.Task) error { f() return nil }) @@ -222,20 +238,20 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr var capturedCommits capturedCommitState var capturedReflog capturedReflogState var capturedBranches capturedBranchState - self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + self.captureOnUIThread(fRunsOnUIThread, env.background, func() { capturedCommits = self.captureCommitsState(options.CommitSelection) capturedReflog = self.captureReflogState() capturedBranches = self.captureBranchState() }) refresh("commits and commit files", func() { - self.refreshCommitsAndCommitFiles(capturedCommits, options.CommitSelection, options.Background) + self.refreshCommitsAndCommitFiles(capturedCommits, options.CommitSelection, env) }) includeWorktreesWithBranches = scopeSet.Includes(types.WORKTREES) if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" { branchesAndRemotesWg.Add(1) refresh("reflog and branches", func() { - loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, options.Background) + loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, env) branchesAndRemotesWg.Done() }) } else { @@ -244,11 +260,11 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // Not a recency sort, so branches doesn't depend on the reflog // being fresh; it runs concurrently with the reflog refresh // below and uses the reflog we captured up front, as it always has. - loadedBranches = self.refreshBranches(capturedBranches, includeWorktreesWithBranches, options.BranchSelection, true, capturedReflog.reflogCommits, options.Background) + loadedBranches = self.refreshBranches(capturedBranches, includeWorktreesWithBranches, options.BranchSelection, true, capturedReflog.reflogCommits, env) branchesAndRemotesWg.Done() }) refresh("reflog", func() { - _, _ = self.refreshReflogCommits(capturedReflog, options.Background, options.SelectTopReflogCommit) + _, _ = self.refreshReflogCommits(capturedReflog, env, options.SelectTopReflogCommit) }) } } else if scopeSet.Includes(types.REBASE_COMMITS) { @@ -256,52 +272,52 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // if we've asked specifically for rebase commits and not those other things var rebaseHashPool *utils.StringPool var rebaseCommits []*models.Commit - self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + self.captureOnUIThread(fRunsOnUIThread, env.background, func() { rebaseHashPool, rebaseCommits = self.captureRebaseCommitState() }) - refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, options.Background) }) + refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, env) }) } if scopeSet.Includes(types.SUB_COMMITS) { var capturedSubCommits capturedSubCommitState - self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + self.captureOnUIThread(fRunsOnUIThread, env.background, func() { capturedSubCommits = self.captureSubCommitState() }) - refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit(capturedSubCommits, options.Background) }) + refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit(capturedSubCommits, env) }) } // reason we're not doing this if the COMMITS type is included is that if the COMMITS type _is_ included we will refresh the commit files context anyway if scopeSet.Includes(types.COMMIT_FILES) && !scopeSet.Includes(types.COMMITS) { var capturedCommitFiles capturedCommitFilesState - self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + self.captureOnUIThread(fRunsOnUIThread, env.background, func() { capturedCommitFiles = self.captureCommitFilesState() }) - refresh("commit files", func() { _ = self.refreshCommitFilesContext(capturedCommitFiles, options.Background) }) + refresh("commit files", func() { _ = self.refreshCommitFilesContext(capturedCommitFiles, env) }) } fileWg := sync.WaitGroup{} if scopeSet.Includes(types.FILES) { var capturedFiles capturedFilesState - self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + self.captureOnUIThread(fRunsOnUIThread, env.background, func() { capturedFiles = self.captureFilesState() }) fileWg.Add(1) refresh("files", func() { - _ = self.refreshFilesAndSubmodules(capturedFiles, options.Background) + _ = self.refreshFilesAndSubmodules(capturedFiles, env) fileWg.Done() }) } if scopeSet.Includes(types.STASH) { var stashFilterPath string - self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + self.captureOnUIThread(fRunsOnUIThread, env.background, func() { stashFilterPath = self.c.Modes().Filtering.GetPath() }) - refresh("stash", func() { self.refreshStashEntries(stashFilterPath, options.Background) }) + refresh("stash", func() { self.refreshStashEntries(stashFilterPath, env) }) } if scopeSet.Includes(types.TAGS) { - refresh("tags", func() { _ = self.refreshTags(options.Background) }) + refresh("tags", func() { _ = self.refreshTags(env) }) } if scopeSet.Includes(types.REMOTES) { @@ -309,12 +325,12 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // needs it to keep the remote-branches selection valid, and reading // the Remotes context off the UI thread races its render. var prevSelectedRemote *models.Remote - self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + self.captureOnUIThread(fRunsOnUIThread, env.background, func() { prevSelectedRemote = self.c.Contexts().Remotes.GetSelected() }) branchesAndRemotesWg.Add(1) refresh("remotes", func() { - loadedRemotes, _ = self.refreshRemotes(prevSelectedRemote, options.Background) + loadedRemotes, _ = self.refreshRemotes(prevSelectedRemote, env) branchesAndRemotesWg.Done() }) } @@ -326,12 +342,12 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // Model().Branches/Remotes: those writes are bounced onto the // UI thread and may not have landed on this worker yet. The // wait above orders us after both loads have stashed theirs. - self.refreshGithubPullRequests(loadedBranches, loadedRemotes, options.Background) + self.refreshGithubPullRequests(loadedBranches, loadedRemotes, env) }) } if scopeSet.Includes(types.WORKTREES) && !includeWorktreesWithBranches { - refresh("worktrees", func() { self.refreshWorktrees(options.Background) }) + refresh("worktrees", func() { self.refreshWorktrees(env) }) } if scopeSet.Includes(types.STAGING) { @@ -341,7 +357,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // scope's model-update bounce — RefreshStagingPanel reads // Model.Files (via Files.GetSelected) and would otherwise // see the pre-refresh model. - self.onUIThread(options.Background, func() error { + self.onUIThread(env.background, func() error { self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{}) return nil }) @@ -353,10 +369,10 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr } if scopeSet.Includes(types.MERGE_CONFLICTS) { - refresh("merge conflicts", func() { _ = self.mergeConflictsHelper.RefreshMergeState(options.Background) }) + refresh("merge conflicts", func() { _ = self.mergeConflictsHelper.RefreshMergeState(env.background) }) } - self.refreshStatus(options.Background) + self.refreshStatus(env) wg.Wait() @@ -367,7 +383,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // returned but their bounces haven't been processed yet, so // invoking Then synchronously would run it on a model that's // still pre-refresh. - self.onUIThread(options.Background, options.Then) + self.onUIThread(env.background, options.Then) } } @@ -528,17 +544,17 @@ func (self *RefreshHelper) captureBranchState() capturedBranchState { } } -func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflogState, capturedBranches capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, background bool) []*models.Branch { +func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflogState, capturedBranches capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, env refreshEnv) []*models.Branch { switch self.c.State().GetRepoState().GetStartupStage() { case types.INITIAL: // Return the immediate (non-recency) load's branches; the recency-sorted // reload below runs on its own worker after we return. Both hold the same // set of branches, which is all the caller (the PR fetch) needs. - branches := self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, false, capturedReflog.reflogCommits, background) + branches := self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, false, capturedReflog.reflogCommits, env) - self.onWorker(background, func(_ gocui.Task) error { - reflogCommits, _ := self.refreshReflogCommits(capturedReflog, background, false) - self.refreshBranches(capturedBranches, false, types.SelectCheckedOutBranch, true, reflogCommits, background) + self.onWorker(env.background, func(_ gocui.Task) error { + reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, false) + self.refreshBranches(capturedBranches, false, types.SelectCheckedOutBranch, true, reflogCommits, env) self.c.State().GetRepoState().SetStartupStage(types.COMPLETE) return nil }) @@ -546,8 +562,8 @@ func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflo return branches case types.COMPLETE: - reflogCommits, _ := self.refreshReflogCommits(capturedReflog, background, selectTopReflogCommit) - return self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, true, reflogCommits, background) + reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, selectTopReflogCommit) + return self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, true, reflogCommits, env) } return nil @@ -592,9 +608,8 @@ func (self *RefreshHelper) captureCommitsState(commitSelection types.CommitSelec } } -func (self *RefreshHelper) refreshCommitsAndCommitFiles(captured capturedCommitState, commitSelection types.CommitSelectionBehavior, background bool) { - generation := self.c.State().GetRepoGeneration() - _ = self.refreshCommitsWithLimit(captured, commitSelection, background) +func (self *RefreshHelper) refreshCommitsAndCommitFiles(captured capturedCommitState, commitSelection types.CommitSelectionBehavior, env refreshEnv) { + _ = self.refreshCommitsWithLimit(captured, commitSelection, env) if captured.parentIsLocalCommits { // This makes sense when we've e.g. just amended a commit, meaning we get a new commit hash at the same position. // However if we've just added a brand new commit, it pushes the list down by one and so we would end up @@ -606,7 +621,7 @@ func (self *RefreshHelper) refreshCommitsAndCommitFiles(captured capturedCommitS // The commit selection is restored in refreshCommitsWithLimit's bounce, // so read it on the UI thread after that bounce; then load the commit // files back on a worker (refreshCommitFilesContext does git work). - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { commit := self.c.Contexts().LocalCommits.GetSelected() if commit != nil && commit.RefName() != "" { refRange := self.c.Contexts().LocalCommits.GetSelectedRefRangeForDiffFiles() @@ -614,8 +629,8 @@ func (self *RefreshHelper) refreshCommitsAndCommitFiles(captured capturedCommitS // Capture the diff endpoints here, on the UI thread and after // ReInit has set them, before dispatching the git work. capturedCommitFiles := self.captureCommitFilesState() - self.onWorker(background, func(gocui.Task) error { - _ = self.refreshCommitFilesContext(capturedCommitFiles, background) + self.onWorker(env.background, func(gocui.Task) error { + _ = self.refreshCommitFilesContext(capturedCommitFiles, env) return nil }) } @@ -650,9 +665,7 @@ func (self *RefreshHelper) determineCheckedOutRef() models.Ref { return nil } -func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, commitSelection types.CommitSelectionBehavior, background bool) error { - generation := self.c.State().GetRepoGeneration() - +func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, commitSelection types.CommitSelectionBehavior, env refreshEnv) error { checkedOutRef := self.determineCheckedOutRef() refName, bisectInfo := self.refForLog() commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( @@ -673,7 +686,7 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, } workingTreeState := self.c.Git().Status.WorkingTreeState() - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().BisectInfo = bisectInfo self.c.Model().Commits = commits self.RefreshAuthors(commits) @@ -707,7 +720,7 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, // Enqueued from within this bounce so it runs after refreshView's // render below (which was enqueued first), matching the previous // ordering where FocusLine ran after the view was re-rendered. - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Contexts().LocalCommits.FocusLine(true) return nil }) @@ -715,7 +728,7 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, return nil }) - self.refreshView(self.c.Contexts().LocalCommits, background) + self.refreshView(self.c.Contexts().LocalCommits, env) return nil } @@ -821,13 +834,11 @@ func (self *RefreshHelper) captureSubCommitState() capturedSubCommitState { } } -func (self *RefreshHelper) refreshSubCommitsWithLimit(captured capturedSubCommitState, background bool) error { +func (self *RefreshHelper) refreshSubCommitsWithLimit(captured capturedSubCommitState, env refreshEnv) error { if captured.ref == nil { return nil } - generation := self.c.State().GetRepoGeneration() - commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( git_commands.GetCommitsOptions{ Limit: captured.limitCommits, @@ -844,13 +855,13 @@ func (self *RefreshHelper) refreshSubCommitsWithLimit(captured capturedSubCommit if err != nil { return err } - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().SubCommits = commits self.RefreshAuthors(commits) return nil }) - self.refreshView(self.c.Contexts().SubCommits, background) + self.refreshView(self.c.Contexts().SubCommits, env) return nil } @@ -882,19 +893,17 @@ func (self *RefreshHelper) captureCommitFilesState() capturedCommitFilesState { return capturedCommitFilesState{from: from, to: to, reverse: reverse} } -func (self *RefreshHelper) refreshCommitFilesContext(captured capturedCommitFilesState, background bool) error { - generation := self.c.State().GetRepoGeneration() - +func (self *RefreshHelper) refreshCommitFilesContext(captured capturedCommitFilesState, env refreshEnv) error { files, err := self.c.Git().Loaders.CommitFileLoader.GetFilesInDiff(captured.from, captured.to, captured.reverse) if err != nil { return err } - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().CommitFiles = files self.c.Contexts().CommitFiles.CommitFileTreeViewModel.SetTree() return nil }) - self.refreshView(self.c.Contexts().CommitFiles, background) + self.refreshView(self.c.Contexts().CommitFiles, env) return nil } @@ -904,39 +913,35 @@ func (self *RefreshHelper) captureRebaseCommitState() (hashPool *utils.StringPoo return self.c.Model().HashPool, self.c.Model().Commits } -func (self *RefreshHelper) refreshRebaseCommits(hashPool *utils.StringPool, commits []*models.Commit, background bool) error { - generation := self.c.State().GetRepoGeneration() - +func (self *RefreshHelper) refreshRebaseCommits(hashPool *utils.StringPool, commits []*models.Commit, env refreshEnv) error { updatedCommits, err := self.c.Git().Loaders.CommitLoader.MergeRebasingCommits(hashPool, commits) if err != nil { return err } workingTreeState := self.c.Git().Status.WorkingTreeState() - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().Commits = updatedCommits self.c.Model().WorkingTreeStateAtLastCommitRefresh = workingTreeState return nil }) - self.refreshView(self.c.Contexts().LocalCommits, background) + self.refreshView(self.c.Contexts().LocalCommits, env) return nil } -func (self *RefreshHelper) refreshTags(background bool) error { - generation := self.c.State().GetRepoGeneration() - +func (self *RefreshHelper) refreshTags(env refreshEnv) error { tags, err := self.c.Git().Loaders.TagLoader.GetTags() if err != nil { return err } - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().Tags = tags return nil }) - self.refreshView(self.c.Contexts().Tags, background) + self.refreshView(self.c.Contexts().Tags, env) return nil } @@ -946,25 +951,23 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs() ([]*models.SubmoduleCo // self.refreshStatus is called at the end of this because that's when we can // be sure there is a State.Model.Branches array to pick the current branch from -func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, background bool) []*models.Branch { +func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch { loadSeq := self.branchLoadSeq.Add(1) - generation := self.c.State().GetRepoGeneration() - branches, err := self.c.Git().Loaders.BranchLoader.Load( reflogCommits, captured.mainBranches, captured.oldBranches, loadBehindCounts, func(f func() error) { - self.onWorker(background, func(_ gocui.Task) error { + self.onWorker(env.background, func(_ gocui.Task) error { return f() }) }, func() { - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Contexts().Branches.HandleRender() - self.refreshStatus(background) + self.refreshStatus(env) return nil }) }) @@ -977,7 +980,7 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh worktrees = self.loadWorktrees() } - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { // Drop this write if a branch load that started later has already applied // its result. At the INITIAL startup stage an immediate load (not // recency-sorted) and an async recency-sorted load run concurrently; this @@ -1000,7 +1003,7 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh if refreshWorktrees { self.c.Model().Worktrees = worktrees - self.refreshView(self.c.Contexts().Worktrees, background) + self.refreshView(self.c.Contexts().Worktrees, env) } // Setting the selection here, in the same bounce that writes the list, @@ -1030,27 +1033,27 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh return nil }) - self.refreshView(self.c.Contexts().Branches, background) + self.refreshView(self.c.Contexts().Branches, env) - self.refreshStatus(background) + self.refreshStatus(env) // Return the freshly-loaded branches so the caller can hand them to the PR // fetch without reading them back from the (bounce-written) model. return branches } -func (self *RefreshHelper) refreshFilesAndSubmodules(captured capturedFilesState, background bool) error { +func (self *RefreshHelper) refreshFilesAndSubmodules(captured capturedFilesState, env refreshEnv) error { configs, err := self.refreshStateSubmoduleConfigs() if err != nil { return err } - if err := self.refreshStateFiles(captured, background, configs); err != nil { + if err := self.refreshStateFiles(captured, env, configs); err != nil { return err } - self.refreshView(self.c.Contexts().Submodules, background) - self.refreshView(self.c.Contexts().Files, background) + self.refreshView(self.c.Contexts().Submodules, env) + self.refreshView(self.c.Contexts().Files, env) return nil } @@ -1060,11 +1063,11 @@ func (self *RefreshHelper) refreshFilesAndSubmodules(captured capturedFilesState // Refresh workers do their git work off the UI thread and enqueue their model // writes here; a repo switch (which replaces the whole model and context tree) // bumps the generation, so a write captured under the old generation must not -// clobber the new repo's state. Callers capture the generation with -// State().GetRepoGeneration() before doing their git work and pass it in. -func (self *RefreshHelper) onUIThreadUnlessRepoChanged(generation int, background bool, f func() error) { - self.onUIThread(background, func() error { - if self.c.State().GetRepoGeneration() != generation { +// clobber the new repo's state. The generation is captured once at the start of +// the refresh and carried in env (see refreshEnv). +func (self *RefreshHelper) onUIThreadUnlessRepoChanged(env refreshEnv, f func() error) { + self.onUIThread(env.background, func() error { + if self.c.State().GetRepoGeneration() != env.generation { return nil } return f() @@ -1140,9 +1143,8 @@ func (self *RefreshHelper) captureFilesState() capturedFilesState { } } -func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, background bool, submoduleConfigs []*models.SubmoduleConfig) error { +func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env refreshEnv, submoduleConfigs []*models.SubmoduleConfig) error { fileTreeViewModel := self.c.Contexts().Files.FileTreeViewModel - generation := self.c.State().GetRepoGeneration() prevConflictFileCount := 0 if self.c.UserConfig().Git.AutoStageResolvedConflicts { @@ -1179,7 +1181,7 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, backgr files := self.c.Git().Loaders.FileLoader. GetStatusFiles(git_commands.GetStatusFileOptions{ ForceShowUntracked: captured.forceShowUntracked, - Background: background, + Background: env.background, }) conflictFileCount := 0 @@ -1203,7 +1205,7 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, backgr // (e.g. in the user's editor). Offer to continue it. We only do this // for operations we started ourselves; prompting for one that was // started outside lazygit (e.g. by a coding agent) would be confusing. - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { return self.mergeAndRebaseHelper.PromptToContinueRebase() }) } @@ -1218,7 +1220,7 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, backgr }) } - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { // only taking over the filter if it hasn't already been set by the user. if conflictFileCount > 0 && prevConflictFileCount == 0 { if fileTreeViewModel.GetStatusFilter() == filetree.DisplayAll { @@ -1249,8 +1251,7 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, backgr // refreshReflogCommits returns the (non-filtered) ReflogCommits it loaded, so // that a subsequent branches refresh can use them for recency sorting without // having to read them back out of the model. -func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, background bool, selectTopEntry bool) ([]*models.Commit, error) { - generation := self.c.State().GetRepoGeneration() +func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, env refreshEnv, selectTopEntry bool) ([]*models.Commit, error) { // pulling state into its own variable in case it gets swapped out for another state // and we get an out of bounds exception model := self.c.Model() @@ -1289,7 +1290,7 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, ba } } - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { model.ReflogCommits = reflogCommits model.FilteredReflogCommits = filteredReflogCommits // Setting the selection here, in the same bounce that writes the list, @@ -1302,26 +1303,24 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, ba return nil }) - self.refreshView(self.c.Contexts().ReflogCommits, background) + self.refreshView(self.c.Contexts().ReflogCommits, env) return reflogCommits, nil } -func (self *RefreshHelper) refreshRemotes(prevSelectedRemote *models.Remote, background bool) ([]*models.Remote, error) { - generation := self.c.State().GetRepoGeneration() - +func (self *RefreshHelper) refreshRemotes(prevSelectedRemote *models.Remote, env refreshEnv) ([]*models.Remote, error) { remotes, err := self.c.Git().Loaders.RemoteLoader.GetRemotes() if err != nil { return nil, err } - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().Remotes = remotes hadPrs := len(self.c.Model().PullRequestsMap) != 0 self.rebuildPullRequestsMap() if !hadPrs && len(self.c.Model().PullRequestsMap) != 0 { // 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, background) + self.refreshView(self.c.Contexts().Branches, env) } // we need to ensure our selected remote branches aren't now outdated @@ -1337,8 +1336,8 @@ func (self *RefreshHelper) refreshRemotes(prevSelectedRemote *models.Remote, bac return nil }) - self.refreshView(self.c.Contexts().Remotes, background) - self.refreshView(self.c.Contexts().RemoteBranches, background) + self.refreshView(self.c.Contexts().Remotes, env) + self.refreshView(self.c.Contexts().RemoteBranches, env) return remotes, nil } @@ -1351,44 +1350,38 @@ func (self *RefreshHelper) loadWorktrees() []*models.Worktree { return worktrees } -func (self *RefreshHelper) refreshWorktrees(background bool) { - generation := self.c.State().GetRepoGeneration() - +func (self *RefreshHelper) refreshWorktrees(env refreshEnv) { worktrees := self.loadWorktrees() - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().Worktrees = worktrees return nil }) // need to refresh branches because the branches view shows worktrees against // branches - self.refreshView(self.c.Contexts().Branches, background) - self.refreshView(self.c.Contexts().Worktrees, background) + self.refreshView(self.c.Contexts().Branches, env) + self.refreshView(self.c.Contexts().Worktrees, env) } -func (self *RefreshHelper) refreshStashEntries(filterPath string, background bool) { - generation := self.c.State().GetRepoGeneration() - +func (self *RefreshHelper) refreshStashEntries(filterPath string, env refreshEnv) { stashEntries := self.c.Git().Loaders.StashLoader. GetStashEntries(filterPath) - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().StashEntries = stashEntries return nil }) - self.refreshView(self.c.Contexts().Stash, background) + self.refreshView(self.c.Contexts().Stash, env) } // never call this on its own, it should only be called from within refreshCommits() -func (self *RefreshHelper) refreshStatus(background bool) { - generation := self.c.State().GetRepoGeneration() - +func (self *RefreshHelper) refreshStatus(env refreshEnv) { workingTreeState := self.c.Git().Status.WorkingTreeState() repoName := self.c.Git().RepoPaths.RepoName() - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { // Read the checked-out branch and the linked worktree name here on the UI // thread: both derive from models (Branches, Worktrees) that their // refreshes now write via bounces, so reading them on the worker would @@ -1425,10 +1418,10 @@ func (self *RefreshHelper) refForLog() (string, *git_commands.BisectInfo) { return bisectInfo.GetStartHash(), bisectInfo } -func (self *RefreshHelper) refreshView(context types.Context, background bool) { +func (self *RefreshHelper) refreshView(context types.Context, env refreshEnv) { // refreshView is called from the worker goroutine that drives async // refreshes, so bounce to the UI thread before mutating view content. - self.onUIThread(background, func() error { + self.onUIThread(env.background, func() error { // Re-applying the filter must be done before re-rendering the view, so that // the filtered list model is up to date for rendering. self.searchHelper.ReApplyFilter(context) @@ -1451,11 +1444,9 @@ func (self *RefreshHelper) refreshView(context types.Context, background bool) { }) } -func (self *RefreshHelper) refreshGithubPullRequests(branches []*models.Branch, remotes []*models.Remote, background bool) { - generation := self.c.State().GetRepoGeneration() - +func (self *RefreshHelper) refreshGithubPullRequests(branches []*models.Branch, remotes []*models.Remote, env refreshEnv) { clearPullRequests := func() { - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().PullRequests = nil self.c.Model().PullRequestsMap = nil return nil @@ -1478,7 +1469,7 @@ func (self *RefreshHelper) refreshGithubPullRequests(branches []*models.Branch, return } - self.setGithubPullRequests(baseInfo, branches, background) + self.setGithubPullRequests(baseInfo, branches, env) } type githubRemoteInfo struct { @@ -1559,7 +1550,11 @@ func (self *RefreshHelper) promptForBaseGithubRepo(githubRemotes []githubRemoteI self.c.Log.Error(err) } - self.setGithubPullRequests(&info, branches, false) + // This fetch runs on its own worker after the user picked a + // base remote, so it's not part of a performRefresh and has no + // ambient env; build a foreground one now, capturing the + // current generation as the guard baseline. + self.setGithubPullRequests(&info, branches, refreshEnv{generation: self.c.State().GetRepoGeneration()}) return nil }) }, @@ -1587,9 +1582,7 @@ func (self *RefreshHelper) rebuildPullRequestsMap() { ) } -func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, branches []*models.Branch, background bool) { - generation := self.c.State().GetRepoGeneration() - +func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, branches []*models.Branch, env refreshEnv) { if len(branches) == 0 { return } @@ -1609,7 +1602,7 @@ func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, bra self.savePullRequestsToCache(prs) - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().PullRequests = prs // Rebuilding here rather than on the worker means the map is built from // the branches and remotes as they are on the UI thread, after their diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 0f8e99ee8..4ced8bd79 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -394,10 +394,10 @@ type IStateAccessor interface { ClearItemOperation(item HasUrn) // A counter that is bumped every time we switch to a different repository - // (see Gui.resetState). Refresh workers capture it before doing their git - // work and pass it to onUIThreadUnlessRepoChanged, so that a model update - // computed for one repo can be dropped rather than applied to another if the - // user switched repos while the refresh was in flight. + // (see Gui.resetState). A refresh captures it when it starts and carries it + // through to onUIThreadUnlessRepoChanged, so that a model update computed for + // one repo can be dropped rather than applied to another if the user switched + // repos while the refresh was in flight. GetRepoGeneration() int } From 19b34851ff5ffb31aef310686e74a4625404c2b3 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 12:39:28 +0200 Subject: [PATCH 58/59] Guard the view-render and prompt-dismiss bounces on the generation The model-update bounces already drop themselves when the repo is switched mid-refresh (onUIThreadUnlessRepoChanged), but three bounces that touch the UI without writing the model did not: refreshView's render, the staging-panel refresh, and the stale continue-rebase prompt dismissal. All three ran unconditionally on the UI thread, so a background refresh in flight across a repo switch could render the old repo's data (through a context object belonging to the now-replaced context tree), or pop the new repo's popup based on the old repo's prompt state. Route them through onUIThreadUnlessRepoChanged too, so they're dropped alongside the model writes they accompany. This also fixes the dismiss bounce using the raw foreground OnUIThread, which ignored the background flag every other bounce in a background refresh respects. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index b1b3ef017..3c8524ffe 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -356,8 +356,9 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // Bounce onto the UI thread so this runs after the files // scope's model-update bounce — RefreshStagingPanel reads // Model.Files (via Files.GetSelected) and would otherwise - // see the pre-refresh model. - self.onUIThread(env.background, func() error { + // see the pre-refresh model. Guard on the generation so a + // repo switch mid-refresh drops it, like the model bounces. + self.onUIThreadUnlessRepoChanged(env, func() error { self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{}) return nil }) @@ -1214,7 +1215,10 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re // appeared. Either way, a "continue?" prompt we're showing is now stale // (e.g. the operation was continued or aborted outside lazygit), so // dismiss it rather than leave the user with a prompt that would fail. - self.c.OnUIThread(func() error { + // Guard on the generation like the sibling PromptToContinueRebase + // bounce above: if the repo was switched while this refresh was in + // flight, a prompt showing now belongs to the new repo, so leave it be. + self.onUIThreadUnlessRepoChanged(env, func() error { self.mergeAndRebaseHelper.DismissContinueRebasePromptIfShowing() return nil }) @@ -1420,8 +1424,12 @@ func (self *RefreshHelper) refForLog() (string, *git_commands.BisectInfo) { func (self *RefreshHelper) refreshView(context types.Context, env refreshEnv) { // refreshView is called from the worker goroutine that drives async - // refreshes, so bounce to the UI thread before mutating view content. - self.onUIThread(env.background, func() error { + // refreshes, so bounce to the UI thread before mutating view content. Guard + // on the generation like the model-update bounces do: if the repo was + // switched while the refresh was in flight, its model write was already + // dropped, so there's nothing fresh to render — and the captured context + // belongs to the old repo's now-replaced context tree anyway. + self.onUIThreadUnlessRepoChanged(env, func() error { // Re-applying the filter must be done before re-rendering the view, so that // the filtered list model is up to date for rendering. self.searchHelper.ReApplyFilter(context) From 4d33d9df8be770c76df29bc29f3805985c47d130 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 15:37:25 +0200 Subject: [PATCH 59/59] Mention the `Then` rule in AGENTS.md --- AGENTS.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 6aa8f7017..947add510 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -256,6 +256,34 @@ Follow this even when the need for the refactor is only discovered in the middle of working on the branch; suggest to the user to rewrite the history to move the refactor to an earlier commit (but don't do it without asking first). +## Don't read model state right after a `Refresh` + +A `Refresh` (or `RefreshFromWorker`) does its git work on a worker and then +*enqueues* the model update onto the UI thread. So when `Refresh` returns, the +model is **not** updated yet — the write is still queued. Reading a field +synchronously right after refreshing its scope reads the stale, pre-refresh +value (and this is true even for SYNC refreshes): + +```go +self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) +files := self.c.Model().Files // BUG: still the pre-refresh value +``` + +Put the read in `RefreshOptions.Then` instead — it's queued after the scope's +model writes, so it sees the fresh value: + +```go +self.c.Refresh(types.RefreshOptions{ + Scope: []types.RefreshableView{types.FILES}, + Then: func() error { + files := self.c.Model().Files // fresh + return nil + }, +}) +``` + +`Then` is a `func() error` and works with any non-`ASYNC` mode. + ## Integration test conventions Don't bind views to local variables. Always chain method calls directly from