Detect external ref changes via background polling

Add a 2-second background poll that calls Status.RefsSnapshot and
compares against the snapshot stored at the end of the last refs-
touching refresh. On a diff, trigger a full refresh — same scope as the
focus-in handler, because once we know something changed externally
we can't be sure what (an agent might have created a worktree or
stashed something alongside the commit we detected).

Refresh runs in SYNC mode because goEvery already serializes iterations
via <-done: a slow refresh delays the next tick naturally instead of
letting work stack. The post-refresh hook from the previous commit
updates the snapshot, so in-app commands don't cause the next poll to
spuriously re-fire.

Disabled in the integration test config, like autoRefresh and autoFetch,
because demo replays make repo changes throughout the run; at 2-second
cadence the resulting full refreshes compete with the demo's own
choreography and push some demos past their 40-second timeout.

Also list the two new config keys in checkForChangedConfigsThatDontAutoReload
so a config edit warns the user that lazygit needs a restart.
This commit is contained in:
Stefan Haller
2026-06-19 18:07:48 +02:00
parent c1eeacdfe8
commit 3050303ed2
3 changed files with 63 additions and 0 deletions
+60
View File
@@ -62,6 +62,17 @@ func (self *BackgroundRoutineMgr) startBackgroundRoutines() {
} }
} }
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() { if self.gui.Config.GetDebug() {
self.goEvery(time.Second*time.Duration(10), self.gui.stopChan, func(_ bool) error { self.goEvery(time.Second*time.Duration(10), self.gui.stopChan, func(_ bool) error {
formatBytes := func(b uint64) string { formatBytes := func(b uint64) string {
@@ -127,6 +138,55 @@ func (self *BackgroundRoutineMgr) startBackgroundFilesRefresh() {
}) })
} }
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{})
}
// returns a channel that can be used to trigger the callback immediately // 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{} { func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan struct{}, function func(bool) error) chan struct{} {
done := make(chan struct{}) done := make(chan struct{})
+2
View File
@@ -515,8 +515,10 @@ func (gui *Gui) checkForChangedConfigsThatDontAutoReload(oldConfig *config.UserC
configsThatDontAutoReload := []string{ configsThatDontAutoReload := []string{
"Git.AutoFetch", "Git.AutoFetch",
"Git.AutoRefresh", "Git.AutoRefresh",
"Git.AutoDetectExternalChanges",
"Refresher.RefreshInterval", "Refresher.RefreshInterval",
"Refresher.FetchInterval", "Refresher.FetchInterval",
"Refresher.ExternalChangeCheckInterval",
"Update.Method", "Update.Method",
"Update.Days", "Update.Days",
} }
+1
View File
@@ -20,3 +20,4 @@ git:
# TODO: add tests which explicitly test auto-refresh functionality # TODO: add tests which explicitly test auto-refresh functionality
autoRefresh: false autoRefresh: false
autoFetch: false autoFetch: false
autoDetectExternalChanges: false