mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-08-28 10:15:32 -05:00
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>
237 lines
7.3 KiB
Go
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{}{}
|
|
}
|
|
}
|