Don't refuse a repo switch during a pure refresh

The refreshes on focus-in, right after a repo switch, and after
returning from a subprocess are full foreground refreshes, so their
tasks kept Busy() true for as long as the slowest scope took — and any
switch attempt in that window was refused with the "can't switch"
toast. The focus-in one is particularly annoying: focusing lazygit is
often precisely what the user does in order to switch repos, and right
after regaining focus is when a refresh takes longest.

Blocking the switch bought nothing there. The refusal exists for user
operations, whose follow-up work (e.g. a Then callback reading the
model) isn't covered by the switch-safety guards; but these refreshes
merely reload state, and a refresh by itself is now switch-safe: its
git commands run against the repo it was started for, and the
generation guard drops its updates when the repo changed.

We can't just mark them Background, because that flag also decides
whether the files refresh lets git take optional locks to persist its
refreshed stat cache — worth doing for an attended refresh, and the
focus-in refresh (typically running right after external changes) is
the case that profits most. So split the two meanings: a new
DontBlockRepoSwitch option dispatches the refresh's tasks as background
tasks (excluded from Busy()) while keeping the attended optional-locks
behavior. Combining it with Then panics, since Then is not
generation-guarded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-07-19 22:43:34 +02:00
parent 8e045653be
commit ae095f276b
3 changed files with 40 additions and 8 deletions

View file

@ -91,10 +91,19 @@ func (self *RefreshHelper) RefreshFromWorker(options types.RefreshOptions) {
}
type refreshEnv struct {
// whether this is a background refresh (which selects the dispatch variant that
// doesn't count towards lazygit being busy)
// Whether everything this refresh dispatches uses the background task
// variants, which don't count towards lazygit being busy — so the refresh
// doesn't block switching repos. Set for refreshes initiated by a
// background routine, and for foreground ones that opted in via
// RefreshOptions.DontBlockRepoSwitch.
background bool
// Whether the refresh was initiated by an unattended background routine
// (RefreshOptions.Background) rather than by user activity. The files
// refresh uses this to decide whether git may take optional locks and
// persist its refreshed stat cache.
backgroundRoutine bool
// the repo generation captured when the refresh started
generation int
@ -175,6 +184,13 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
panic("Refresh called from a worker, or RefreshFromWorker called from the UI thread")
}
if options.Then != nil && options.DontBlockRepoSwitch {
// Then is not generation-guarded, so if a switch crossed the refresh it
// would run against the newly switched-to repo. A refresh carrying a
// Then must keep blocking switches.
panic("a refresh with a Then callback must not set DontBlockRepoSwitch")
}
// Capture the refresh's baseline once, here at the start: the repo
// generation that every scope's bounce is guarded against, and the git
// command instance the scopes run their commands through. The two are
@ -186,9 +202,10 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
// against the repo it started in, and the generation guard drops its
// writes.
env := refreshEnv{
background: options.Background,
background: options.Background || options.DontBlockRepoSwitch,
backgroundRoutine: options.Background,
}
self.captureOnUIThread(calledFromWorker, options.Background, func() {
self.captureOnUIThread(calledFromWorker, env.background, func() {
env.generation = self.c.State().GetRepoGeneration()
env.git = self.c.Git()
})
@ -1276,7 +1293,7 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
files := env.git.Loaders.FileLoader.
GetStatusFiles(git_commands.GetStatusFileOptions{
ForceShowUntracked: captured.forceShowUntracked,
Background: env.background,
Background: env.backgroundRoutine,
})
conflictFileCount := 0

View file

@ -390,7 +390,7 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context
}
gui.c.Log.Info("Receiving focus - refreshing")
gui.helpers.Refresh.Refresh(types.RefreshOptions{})
gui.helpers.Refresh.Refresh(types.RefreshOptions{DontBlockRepoSwitch: true})
return reloadErr
}
@ -1031,7 +1031,7 @@ func (gui *Gui) runSubprocessWithSuspenseAndRefresh(subprocess *oscommands.CmdOb
return err
}
gui.c.Refresh(types.RefreshOptions{})
gui.c.Refresh(types.RefreshOptions{DontBlockRepoSwitch: true})
return nil
}
@ -1108,7 +1108,7 @@ func (gui *Gui) loadNewRepo() error {
return err
}
gui.c.Refresh(types.RefreshOptions{})
gui.c.Refresh(types.RefreshOptions{DontBlockRepoSwitch: true})
if err := gui.os.UpdateWindowTitle(); err != nil {
return err

View file

@ -94,4 +94,19 @@ type RefreshOptions struct {
// fast. Background refreshes leave the suppression in place: not persisting
// the stat-cache is the right trade-off for unattended work.
Background bool
// When true, this foreground refresh does not block switching repos while
// it is in flight. A refresh is switch-safe by construction — its git
// commands run against the repo it was started for, and the generation
// guard drops its model/view updates if the repo changed — but a refresh
// triggered by a user operation still blocks switching (its tasks count
// towards Busy()), because the operation's follow-up work isn't covered
// by those guards. A refresh that merely reloads state (on focus, after a
// repo switch, after returning from a subprocess) has no such follow-up,
// so it opts in here and a repo switch during it is allowed rather than
// refused with a toast.
//
// Must not be combined with Then: Then is not generation-guarded, so it
// would run against the newly switched-to repo.
DontBlockRepoSwitch bool
}