Files
lazygit/pkg/gui/background.go
T
Stefan HallerandClaude Opus 4.8 d94f2f05ac Only pass --no-optional-locks for background status refreshes
We set GIT_OPTIONAL_LOCKS=0 for every git command we run. That env var
only affects `git status`: it tells git not to take the optional lock it
would otherwise use to write the index back after refreshing the cached
stat information. The intent was to avoid contending for index.lock with
git commands the user runs in a terminal.

The downside is that our `git status` never persists the refreshed
stat-cache. So whenever the working tree's cached stat info goes stale
(e.g. editing files and discarding the changes, or a checkout), every
subsequent status re-hashes the affected files to confirm they're clean,
and stays slow until something else writes the index (such as the user
running `git status` in a terminal).

Fix this by only suppressing optional locks for refreshes that run
unattended in the background; foreground refreshes triggered by a user
action now run a plain `git status` that writes the refreshed index back,
just like the command line does. Background refreshes keep passing
--no-optional-locks so they still can't cause lock contention.

RefreshOptions gains a Background flag that the background routines set,
threaded down to the status command.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:14:24 +02:00

237 lines
7.3 KiB
Go

package gui
import (
"fmt"
"runtime"
"sync/atomic"
"time"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
)
type BackgroundRoutineMgr struct {
gui *Gui
// When this is greater than zero, the background routines (e.g. file refresh)
// skip their work. We pause them while the gui is suspended (e.g. for a
// subprocess) and while lazygit is itself driving a git operation that would
// otherwise be caught mid-flight (see the waiting-status helpers). It's a
// count rather than a bool because these pause scopes can overlap.
pauseRefreshesCount atomic.Int32
// a channel to trigger an immediate background fetch; we use this when switching repos
triggerFetch chan struct{}
}
func (self *BackgroundRoutineMgr) PauseBackgroundRefreshes(pause bool) {
if pause {
self.pauseRefreshesCount.Add(1)
} else {
self.pauseRefreshesCount.Add(-1)
}
}
func (self *BackgroundRoutineMgr) backgroundRefreshesPaused() bool {
return self.pauseRefreshesCount.Load() > 0
}
func (self *BackgroundRoutineMgr) startBackgroundRoutines() {
userConfig := self.gui.UserConfig()
if userConfig.Git.AutoFetch {
fetchInterval := userConfig.Refresher.FetchInterval
if fetchInterval > 0 {
go utils.Safe(self.startBackgroundFetch)
} else {
self.gui.c.Log.Errorf(
"Value of config option 'refresher.fetchInterval' (%d) is invalid, disabling auto-fetch",
fetchInterval)
}
}
if userConfig.Git.AutoRefresh {
refreshInterval := userConfig.Refresher.RefreshInterval
if refreshInterval > 0 {
go utils.Safe(self.startBackgroundFilesRefresh)
} else {
self.gui.c.Log.Errorf(
"Value of config option 'refresher.refreshInterval' (%d) is invalid, disabling auto-refresh",
refreshInterval)
}
}
if userConfig.Git.AutoDetectExternalChanges {
interval := userConfig.Refresher.ExternalChangeCheckInterval
if interval > 0 {
go utils.Safe(self.startBackgroundExternalChangeDetection)
} else {
self.gui.c.Log.Errorf(
"Value of config option 'refresher.externalChangeCheckInterval' (%d) is invalid, disabling external change detection",
interval)
}
}
if self.gui.Config.GetDebug() {
self.goEvery(time.Second*time.Duration(10), self.gui.stopChan, func(_ bool) error {
formatBytes := func(b uint64) string {
const unit = 1000
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := uint64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB",
float64(b)/float64(div), "kMGTPE"[exp])
}
m := runtime.MemStats{}
runtime.ReadMemStats(&m)
self.gui.c.Log.Infof("Heap memory in use: %s", formatBytes(m.HeapAlloc))
return nil
})
}
}
func (self *BackgroundRoutineMgr) startBackgroundFetch() {
self.gui.waitForIntro.Wait()
fetch := func(firstTimeOrRetriggered bool) error {
// Do this on the UI thread so that we don't have to deal with synchronization around the
// access of the repo state.
self.gui.onUIThread(func() error {
// There's a race here, where we might be recording the time stamp for a different repo
// than where the fetch actually ran. It's not very likely though, and not harmful if it
// does happen; guarding against it would be more effort than it's worth.
self.gui.State.LastBackgroundFetchTime = time.Now()
return nil
})
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)
}
return self.backgroundFetch()
}
// We want an immediate fetch at startup, and since goEvery starts by
// waiting for the interval, we need to trigger one manually first
_ = fetch(true)
userConfig := self.gui.UserConfig()
self.triggerFetch = self.goEvery(userConfig.Refresher.FetchIntervalDuration(), self.gui.stopChan, fetch)
}
func (self *BackgroundRoutineMgr) startBackgroundFilesRefresh() {
self.gui.waitForIntro.Wait()
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})
return nil
})
}
func (self *BackgroundRoutineMgr) startBackgroundExternalChangeDetection() {
self.gui.waitForIntro.Wait()
// We don't seed the snapshot here. The startup refresh captures one on
// entry (like every refs-touching refresh), and until one has been
// captured RefsSnapshotChangedSince treats the empty baseline as
// "unchanged", so we never fire a spurious refresh before a baseline
// exists — no need to depend on the timing of that startup refresh.
userConfig := self.gui.UserConfig()
self.goEvery(
userConfig.Refresher.ExternalChangeCheckIntervalDuration(),
self.gui.stopChan,
func(_ bool) error {
self.checkForExternalChanges()
return nil
},
)
}
func (self *BackgroundRoutineMgr) checkForExternalChanges() {
current, err := self.gui.git.Status.RefsSnapshot()
if err != nil {
// Transient error (e.g. git process couldn't start). Don't update the
// stored snapshot; we'll retry next tick.
self.gui.c.Log.Warnf("RefsSnapshot failed: %v", err)
return
}
if !self.gui.helpers.Refresh.RefsSnapshotChangedSince(current) {
return
}
// goEvery checks the pause count before starting us, but a git operation
// may have begun (and paused refreshes) after that check, while we were
// reading the snapshot above. In that case the change we detected is the
// operation's own intermediate state, so back off: the operation will
// refresh and re-snapshot when it finishes, and if the change was really
// external we'll catch it on the next tick after the pause lifts. We don't
// update the stored snapshot, so nothing is swallowed.
if self.backgroundRefreshesPaused() {
return
}
// 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})
}
// returns a channel that can be used to trigger the callback immediately
func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan struct{}, function func(bool) error) chan struct{} {
done := make(chan struct{})
retrigger := make(chan struct{})
go utils.Safe(func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
doit := func(retriggered bool) {
if self.backgroundRefreshesPaused() {
return
}
self.gui.c.OnWorker(func(gocui.Task) error {
_ = function(retriggered)
done <- struct{}{}
return nil
})
// waiting so that we don't bunch up refreshes if the refresh takes longer than the
// interval, or if a retrigger comes in while we're still processing a timer-based one
// (or vice versa)
<-done
}
for {
select {
case <-ticker.C:
doit(false)
case <-retrigger:
ticker.Reset(interval)
doit(true)
case <-stop:
return
}
}
})
return retrigger
}
func (self *BackgroundRoutineMgr) backgroundFetch() (err error) {
err = self.gui.git.Sync.FetchBackground()
return self.gui.helpers.BranchesHelper.PostFetchRefresh(err, true)
}
func (self *BackgroundRoutineMgr) triggerImmediateFetch() {
if self.triggerFetch != nil {
self.triggerFetch <- struct{}{}
}
}