From 1d6b3dc4e181af6daa3d108a6ab30491be679420 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 30 May 2026 16:43:10 +0200 Subject: [PATCH 1/9] Fix schema minimum for refresh and fetch intervals The schema annotated refreshInterval and fetchInterval with minimum=0, but the background routines reject a value of 0 (they require interval > 0 and otherwise log it as invalid and disable the feature). So 0 is not actually a valid value; switch to exclusiveMinimum=0 so the schema matches what the code accepts. --- pkg/config/user_config.go | 4 ++-- schema-master/config.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index cac87ec9157..2bfd20c7609 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -44,10 +44,10 @@ type UserConfig struct { type RefresherConfig struct { // File/submodule refresh interval in seconds. // Auto-refresh can be disabled via option 'git.autoRefresh'. - RefreshInterval int `yaml:"refreshInterval" jsonschema:"minimum=0"` + RefreshInterval int `yaml:"refreshInterval" jsonschema:"exclusiveMinimum=0"` // Re-fetch interval in seconds. // Auto-fetch can be disabled via option 'git.autoFetch'. - FetchInterval int `yaml:"fetchInterval" jsonschema:"minimum=0"` + FetchInterval int `yaml:"fetchInterval" jsonschema:"exclusiveMinimum=0"` } func (c *RefresherConfig) RefreshIntervalDuration() time.Duration { diff --git a/schema-master/config.json b/schema-master/config.json index 2e968ba8f95..716fbef0fdd 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -3521,13 +3521,13 @@ "properties": { "refreshInterval": { "type": "integer", - "minimum": 0, + "exclusiveMinimum": 0, "description": "File/submodule refresh interval in seconds.\nAuto-refresh can be disabled via option 'git.autoRefresh'.", "default": 10 }, "fetchInterval": { "type": "integer", - "minimum": 0, + "exclusiveMinimum": 0, "description": "Re-fetch interval in seconds.\nAuto-fetch can be disabled via option 'git.autoFetch'.", "default": 60 } From af8f8d0becb26b01710899581e5e46abb2d21f0d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 30 May 2026 11:30:47 +0200 Subject: [PATCH 2/9] Log CPU time of external commands in addition to wall-clock time For certain kinds of performance investigations it is useful to see this, and doesn't terribly pollute the log, so just do this always. --- pkg/commands/oscommands/cmd_obj_runner.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pkg/commands/oscommands/cmd_obj_runner.go b/pkg/commands/oscommands/cmd_obj_runner.go index ae11298ae4d..9786826189d 100644 --- a/pkg/commands/oscommands/cmd_obj_runner.go +++ b/pkg/commands/oscommands/cmd_obj_runner.go @@ -105,12 +105,18 @@ func (self *cmdObjRunner) RunWithOutputAux(cmdObj *CmdObj) (string, error) { } t := time.Now() - output, err := sanitisedCommandOutput(cmdObj.GetCmd().CombinedOutput()) + cmd := cmdObj.GetCmd() + output, err := sanitisedCommandOutput(cmd.CombinedOutput()) if err != nil { self.log.WithField("command", cmdObj.ToString()).Error(output) } - self.log.Infof("%s (%s)", cmdObj.ToString(), time.Since(t)) + wall := time.Since(t) + if ps := cmd.ProcessState; ps != nil { + self.log.Infof("%s (wall %s, cpu %s)", cmdObj.ToString(), wall, ps.UserTime()+ps.SystemTime()) + } else { + self.log.Infof("%s (wall %s)", cmdObj.ToString(), wall) + } return output, err } From bca46e7de17d6c3720718607d5960a2fb6dfab2e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 29 May 2026 13:04:45 +0200 Subject: [PATCH 3/9] Centralize scope expansion in Refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several downstream conditions in Refresh() relied on multi-scope predicates to express "if X is in scope, Y also needs refreshing". This makes it hard to add new code that needs to ask "does this refresh re-read refs?", because the answer involves mirroring one of those predicates and keeping them in sync forever. Expand the co-refreshing relationships once, up front, right after the scope set is built. The downstream conditions then collapse to single-scope checks against the (now-expanded) set. Behavior is preserved. Two of the scattered multi-scope conditions are intentionally left as-is because they express subsumption rather than co-refresh (one branch already does the work of another internally — expanding would cause double-refresh), and one expresses mid-function coupling on a flag set inside the COMMITS/BRANCHES block. --- pkg/gui/controllers/helpers/refresh_helper.go | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index d27b38feb4f..f1277e00748 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -106,6 +106,23 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { scopeSet = set.NewFromSlice(options.Scope) } + // Expand co-refreshing scopes up front so downstream conditions can be + // simple single-scope checks. The relationships are: + // - whenever the reflog or bisect info changes, commits and branches + // can change too (e.g. switching branches updates the reflog and + // 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 + if scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) { + scopeSet.Add(types.COMMITS, types.BRANCHES) + } + if scopeSet.Includes(types.SUBMODULES) { + scopeSet.Add(types.FILES) + } + if scopeSet.Includes(types.FILES) { + scopeSet.Add(types.MERGE_CONFLICTS) + } + wg := sync.WaitGroup{} refresh := func(name string, f func()) { // if we're in a demo we don't want any async refreshes because @@ -129,7 +146,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { branchesAndRemotesWg := sync.WaitGroup{} includeWorktreesWithBranches := false - if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) || scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) { + if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) { // 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. @@ -166,7 +183,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { } fileWg := sync.WaitGroup{} - if scopeSet.Includes(types.FILES) || scopeSet.Includes(types.SUBMODULES) { + if scopeSet.Includes(types.FILES) { fileWg.Add(1) refresh("files", func() { _ = self.refreshFilesAndSubmodules() @@ -212,7 +229,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { refresh("patch building", func() { self.patchBuildingHelper.RefreshPatchBuildingPanel(types.OnFocusOpts{}) }) } - if scopeSet.Includes(types.MERGE_CONFLICTS) || scopeSet.Includes(types.FILES) { + if scopeSet.Includes(types.MERGE_CONFLICTS) { refresh("merge conflicts", func() { _ = self.mergeConflictsHelper.RefreshMergeState() }) } From 30e1eb27ba5466c348a0f98356942ce50daa41af Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 29 May 2026 13:05:37 +0200 Subject: [PATCH 4/9] Add Status.RefsSnapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cheap fingerprint of local branches and HEAD that future code can poll to detect when refs have moved externally. Branches come from a porcelain for-each-ref. HEAD is read directly from .git/HEAD: that avoids spawning a child process and captures the symref-or-hash distinction we need to tell "detached at X" apart from "on a branch pointing at X" — they share a commit hash, which is exactly the situation at the end of a rebase when HEAD reattaches to the branch. The reftable backend doesn't keep a real .git/HEAD (it writes a fixed stub), so when we see that stub or the file is unreadable we fall back to porcelain commands, which are backend-agnostic. Uses DontLog so a future polling caller won't spam the command log. Not yet wired up to any caller. --- pkg/commands/git_commands/deps_test.go | 6 ++ pkg/commands/git_commands/status.go | 62 ++++++++++++++++ pkg/commands/git_commands/status_test.go | 91 ++++++++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 pkg/commands/git_commands/status_test.go diff --git a/pkg/commands/git_commands/deps_test.go b/pkg/commands/git_commands/deps_test.go index 85de496dcee..235f2171699 100644 --- a/pkg/commands/git_commands/deps_test.go +++ b/pkg/commands/git_commands/deps_test.go @@ -168,6 +168,12 @@ func buildBranchCommands(deps commonDeps) *BranchCommands { return NewBranchCommands(gitCommon) } +func buildStatusCommands(deps commonDeps) *StatusCommands { + gitCommon := buildGitCommon(deps) + + return NewStatusCommands(gitCommon) +} + func buildFlowCommands(deps commonDeps) *FlowCommands { gitCommon := buildGitCommon(deps) diff --git a/pkg/commands/git_commands/status.go b/pkg/commands/git_commands/status.go index ff09e22bc31..d9120d87d33 100644 --- a/pkg/commands/git_commands/status.go +++ b/pkg/commands/git_commands/status.go @@ -4,8 +4,10 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/spf13/afero" ) type StatusCommands struct { @@ -82,6 +84,66 @@ func (self *StatusCommands) IsInRevert() (bool, error) { return self.os.FileExists(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "REVERT_HEAD")) } +// RefsSnapshot returns a string fingerprint of the current state of local +// branches and HEAD. Comparing two snapshots byte-for-byte tells us whether +// any local ref or HEAD has moved since the last snapshot. +func (self *StatusCommands) RefsSnapshot() (string, error) { + t := time.Now() + defer func() { self.Log.Infof("RefsSnapshot took %s", time.Since(t)) }() + + refsArgs := NewGitCmd("for-each-ref"). + Arg("--format=%(objectname) %(refname)"). + Arg("refs/heads"). + ToArgv() + refs, err := self.cmd.New(refsArgs).DontLog().RunWithOutput() + if err != nil { + return "", err + } + + head, err := self.headSnapshot() + if err != nil { + return "", err + } + + return refs + head, nil +} + +// headSnapshot returns a fingerprint of HEAD that distinguishes "detached at +// commit X" from "on a branch that points at X". The commit hash alone can't +// tell those apart, which matters at the end of a rebase: HEAD reattaches to +// the branch without the hash changing, and we'd otherwise miss that refresh. +// +// We read .git/HEAD directly rather than shelling out: it's faster (no child +// process) and its content is exactly the symref-or-hash distinction we want +// ("ref: refs/heads/foo" when attached, the raw hash when detached). The +// reftable backend, however, doesn't keep a real .git/HEAD — it writes a fixed +// stub ("ref: refs/heads/.invalid") that never reflects the actual HEAD. When +// we see that stub (or the file is missing/unreadable) we fall back to +// porcelain commands, which are backend-agnostic. +func (self *StatusCommands) headSnapshot() (string, error) { + headPath := filepath.Join(self.repoPaths.WorktreeGitDirPath(), "HEAD") + if content, err := afero.ReadFile(self.Fs, headPath); err == nil { + head := strings.TrimSpace(string(content)) + if head != "" && head != "ref: refs/heads/.invalid" { + return head, nil + } + } + + // symbolic-ref gives the branch when HEAD is attached and fails when it's + // detached, in which case rev-parse gives the commit HEAD points at. + symbolicRefArgs := NewGitCmd("symbolic-ref").Arg("HEAD").ToArgv() + if symref, err := self.cmd.New(symbolicRefArgs).DontLog().RunWithOutput(); err == nil { + return strings.TrimSpace(symref), nil + } + + revParseArgs := NewGitCmd("rev-parse").Arg("HEAD").ToArgv() + head, err := self.cmd.New(revParseArgs).DontLog().RunWithOutput() + if err != nil { + return "", err + } + return strings.TrimSpace(head), nil +} + // Full ref (e.g. "refs/heads/mybranch") of the branch that is currently // being rebased, or empty string when we're not in a rebase func (self *StatusCommands) BranchBeingRebased() string { diff --git a/pkg/commands/git_commands/status_test.go b/pkg/commands/git_commands/status_test.go new file mode 100644 index 00000000000..dc6b2d55836 --- /dev/null +++ b/pkg/commands/git_commands/status_test.go @@ -0,0 +1,91 @@ +package git_commands + +import ( + "testing" + + "github.com/go-errors/errors" + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/samber/lo" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" +) + +func TestStatusRefsSnapshot(t *testing.T) { + const forEachRefOutput = "aaaa refs/heads/main\nbbbb refs/heads/topic\n" + forEachRefArgs := []string{"for-each-ref", "--format=%(objectname) %(refname)", "refs/heads"} + + scenarios := []struct { + testName string + headFile *string // nil means: don't create a .git/HEAD file (simulates it being unreadable). + runner *oscommands.FakeCmdObjRunner + expectedHead string + }{ + { + // files backend, on a branch: read straight from .git/HEAD, no + // child process for HEAD. + testName: "attached, read from HEAD file", + headFile: lo.ToPtr("ref: refs/heads/main\n"), + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil), + expectedHead: "ref: refs/heads/main", + }, + { + // files backend, detached: .git/HEAD holds the raw hash. + testName: "detached, read from HEAD file", + headFile: lo.ToPtr("aaaa\n"), + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil), + expectedHead: "aaaa", + }, + { + // reftable backend (HEAD is a fixed stub), attached: fall back to + // symbolic-ref, which succeeds. + testName: "reftable stub, attached, fall back to symbolic-ref", + headFile: lo.ToPtr("ref: refs/heads/.invalid\n"), + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil). + ExpectGitArgs([]string{"symbolic-ref", "HEAD"}, "refs/heads/main\n", nil), + expectedHead: "refs/heads/main", + }, + { + // reftable backend, detached: symbolic-ref fails, fall back to + // rev-parse. + testName: "reftable stub, detached, fall back to rev-parse", + headFile: lo.ToPtr("ref: refs/heads/.invalid\n"), + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil). + ExpectGitArgs([]string{"symbolic-ref", "HEAD"}, "", errors.New("fatal: ref HEAD is not a symbolic ref")). + ExpectGitArgs([]string{"rev-parse", "HEAD"}, "aaaa\n", nil), + expectedHead: "aaaa", + }, + { + // HEAD file missing/unreadable: same fallback as reftable. + testName: "no HEAD file, fall back to symbolic-ref", + headFile: nil, + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil). + ExpectGitArgs([]string{"symbolic-ref", "HEAD"}, "refs/heads/main\n", nil), + expectedHead: "refs/heads/main", + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + fs := afero.NewMemMapFs() + if s.headFile != nil { + assert.NoError(t, afero.WriteFile(fs, "/repo/.git/HEAD", []byte(*s.headFile), 0o600)) + } + + instance := buildStatusCommands(commonDeps{ + runner: s.runner, + fs: fs, + repoPaths: MockRepoPaths("/repo"), + }) + + snapshot, err := instance.RefsSnapshot() + assert.NoError(t, err) + assert.Equal(t, forEachRefOutput+s.expectedHead, snapshot) + s.runner.CheckForMissingCalls() + }) + } +} From cb18c33ee880defa1d83b4e844e4c85e64275b20 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 29 May 2026 13:06:52 +0200 Subject: [PATCH 5/9] Add config options for external change detection Two settings to control the upcoming background polling mechanism: - git.autoDetectExternalChanges (default true) is the on/off switch, parallel to autoFetch/autoRefresh - refresher.externalChangeCheckInterval (default 2 seconds) is the poll cadence Disabling is the bool's job, not a magic 0 interval, matching the existing convention. Not yet referenced by any code. --- docs-master/Config.md | 10 ++++++++++ pkg/config/app_config_test.go | 11 +++++++++++ pkg/config/user_config.go | 15 +++++++++++++-- schema-master/config.json | 11 +++++++++++ 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/docs-master/Config.md b/docs-master/Config.md index 9f79218217f..35edc67ebbb 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -406,6 +406,11 @@ git: # If true, periodically refresh files and submodules autoRefresh: true + # If true, poll the repo periodically for external ref changes (commits, branch + # updates, checkouts made outside lazygit) and refresh when one is detected. + # Independent of autoRefresh, which only governs the files panel. + autoDetectExternalChanges: true + # If not "none", lazygit will automatically fast-forward local branches to match # their upstream after fetching. Applies to branches that are not the currently # checked out branch, and only to those that are strictly behind their upstream @@ -517,6 +522,11 @@ refresher: # Auto-fetch can be disabled via option 'git.autoFetch'. fetchInterval: 60 + # Interval in seconds at which lazygit polls for external ref changes (commits, + # branch updates, checkouts made outside lazygit). + # Detection can be disabled via option 'git.autoDetectExternalChanges'. + externalChangeCheckInterval: 2 + # If true, show a confirmation popup before quitting Lazygit confirmOnQuit: false diff --git a/pkg/config/app_config_test.go b/pkg/config/app_config_test.go index 1109256a9d3..8e6c85f32df 100644 --- a/pkg/config/app_config_test.go +++ b/pkg/config/app_config_test.go @@ -654,6 +654,12 @@ git: # If true, periodically refresh files and submodules autoRefresh: true + # If true, poll the repo periodically for external ref changes (commits, + # branch updates, checkouts made outside lazygit) and refresh when one + # is detected. Independent of autoRefresh, which only governs the files + # panel. + autoDetectExternalChanges: true + # If true, pass the --all arg to git fetch fetchAll: true @@ -723,6 +729,11 @@ refresher: # Auto-fetch can be disabled via option 'git.autoFetch'. fetchInterval: 60 + # Interval in seconds at which lazygit polls for external ref changes + # (commits, branch updates, checkouts made outside lazygit). + # Detection can be disabled via option 'git.autoDetectExternalChanges'. + externalChangeCheckInterval: 2 + # If true, show a confirmation popup before quitting Lazygit confirmOnQuit: false diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 2bfd20c7609..9cf10e950c8 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -48,6 +48,9 @@ type RefresherConfig struct { // Re-fetch interval in seconds. // Auto-fetch can be disabled via option 'git.autoFetch'. FetchInterval int `yaml:"fetchInterval" jsonschema:"exclusiveMinimum=0"` + // Interval in seconds at which lazygit polls for external ref changes (commits, branch updates, checkouts made outside lazygit). + // Detection can be disabled via option 'git.autoDetectExternalChanges'. + ExternalChangeCheckInterval int `yaml:"externalChangeCheckInterval" jsonschema:"exclusiveMinimum=0"` } func (c *RefresherConfig) RefreshIntervalDuration() time.Duration { @@ -58,6 +61,10 @@ func (c *RefresherConfig) FetchIntervalDuration() time.Duration { return time.Second * time.Duration(c.FetchInterval) } +func (c *RefresherConfig) ExternalChangeCheckIntervalDuration() time.Duration { + return time.Second * time.Duration(c.ExternalChangeCheckInterval) +} + type GuiConfig struct { // See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color AuthorColors map[string]string `yaml:"authorColors"` @@ -289,6 +296,8 @@ type GitConfig struct { AutoFetch bool `yaml:"autoFetch"` // If true, periodically refresh files and submodules AutoRefresh bool `yaml:"autoRefresh"` + // If true, poll the repo periodically for external ref changes (commits, branch updates, checkouts made outside lazygit) and refresh when one is detected. Independent of autoRefresh, which only governs the files panel. + AutoDetectExternalChanges bool `yaml:"autoDetectExternalChanges"` // If not "none", lazygit will automatically fast-forward local branches to match their upstream after fetching. Applies to branches that are not the currently checked out branch, and only to those that are strictly behind their upstream (as opposed to diverged). // Possible values: 'none' | 'onlyMainBranches' | 'allBranches' AutoForwardBranches string `yaml:"autoForwardBranches" jsonschema:"enum=none,enum=onlyMainBranches,enum=allBranches"` @@ -912,6 +921,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { MainBranches: []string{"master", "main"}, AutoFetch: true, AutoRefresh: true, + AutoDetectExternalChanges: true, AutoForwardBranches: "onlyMainBranches", FetchAll: true, AutoStageResolvedConflicts: true, @@ -927,8 +937,9 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { TruncateCopiedCommitHashesTo: 12, }, Refresher: RefresherConfig{ - RefreshInterval: 10, - FetchInterval: 60, + RefreshInterval: 10, + FetchInterval: 60, + ExternalChangeCheckInterval: 2, }, Update: UpdateConfig{ Method: "prompt", diff --git a/schema-master/config.json b/schema-master/config.json index 716fbef0fdd..5c4c53c4bb1 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -358,6 +358,11 @@ "description": "If true, periodically refresh files and submodules", "default": true }, + "autoDetectExternalChanges": { + "type": "boolean", + "description": "If true, poll the repo periodically for external ref changes (commits, branch updates, checkouts made outside lazygit) and refresh when one is detected. Independent of autoRefresh, which only governs the files panel.", + "default": true + }, "autoForwardBranches": { "type": "string", "enum": [ @@ -3530,6 +3535,12 @@ "exclusiveMinimum": 0, "description": "Re-fetch interval in seconds.\nAuto-fetch can be disabled via option 'git.autoFetch'.", "default": 60 + }, + "externalChangeCheckInterval": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Interval in seconds at which lazygit polls for external ref changes (commits, branch updates, checkouts made outside lazygit).\nDetection can be disabled via option 'git.autoDetectExternalChanges'.", + "default": 2 } }, "additionalProperties": false, From 0221fb830c45c74cc90807fb97f45bb34d5e6550 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 29 May 2026 13:08:09 +0200 Subject: [PATCH 6/9] Snapshot refs state before refs-touching refreshes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the storage and snapshot-update half of the external-change-detection mechanism. RefreshHelper now keeps a mutex-protected snapshot string and exposes accessors for it; Refresh captures a fresh snapshot at the start of any refresh whose scope set includes COMMITS or BRANCHES. We capture before reading the git state, not after. Capturing after would let an external change that lands between the git state read and the snapshot (say, the next step of a rebase running in another terminal) leave the stored snapshot newer than what we actually rendered; the poller would then see no difference and never refresh again, stranding the UI on the intermediate state. Capturing first keeps the snapshot from running ahead of the render, so if disk moves during the refresh the next poll catches it. No reader of the snapshot exists yet — the polling goroutine that consumes it comes in a later commit. Keeping the snapshot hook in its own commit isolates the invariant that the snapshot stays in sync with what the UI has observed, which is what makes the poller's change-detection predicate work across in-app commands and focus-in refreshes. --- pkg/gui/controllers/helpers/refresh_helper.go | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index f1277e00748..c78ce7e48bc 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -19,6 +19,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" + "github.com/sasha-s/go-deadlock" ) type RefreshHelper struct { @@ -36,6 +37,12 @@ type RefreshHelper struct { // Keyed by repo path so that switching to a different repo while lazygit is running // still triggers the prompt there. githubBaseRemotePromptDismissed map[string]bool + + // Last observed refs+HEAD fingerprint, used by the background poller to + // decide whether a real refresh is needed. Written at the end of every + // refresh that re-read refs/commits, read by the poller. + refsSnapshotMutex deadlock.Mutex + refsSnapshot string } func NewRefreshHelper( @@ -123,6 +130,13 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { scopeSet.Add(types.MERGE_CONFLICTS) } + // Capture the refs snapshot now, before we start reading git's state + // below, rather than after. This is important to guard against the race + // of git's state changing externally while (or right after) we are + // refreshing; the risk is one potential extra refresh, but capturing the + // snapshot at the end would risk missing one, which is worse. + self.updateRefsSnapshotIfRelevant(scopeSet) + wg := sync.WaitGroup{} refresh := func(name string, f func()) { // if we're in a demo we don't want any async refreshes because @@ -253,6 +267,46 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { f() } +// SetRefsSnapshot stores the given snapshot as the last observed refs state. +// Called externally by the background poller at startup to seed the snapshot, +// and internally by Refresh at the end of a refs-touching refresh. +func (self *RefreshHelper) SetRefsSnapshot(snapshot string) { + self.refsSnapshotMutex.Lock() + defer self.refsSnapshotMutex.Unlock() + self.refsSnapshot = snapshot +} + +// RefsSnapshotChangedSince reports whether the given snapshot differs from +// the last observed one. Pure read; does not update internal state. +func (self *RefreshHelper) RefsSnapshotChangedSince(snapshot string) bool { + self.refsSnapshotMutex.Lock() + defer self.refsSnapshotMutex.Unlock() + return snapshot != self.refsSnapshot +} + +// updateRefsSnapshotIfRelevant captures a fresh refs snapshot from disk at the +// start of a refresh that re-reads refs/commits (see the call site for why we +// capture before reading the model rather than after). This keeps the +// background poller's stored snapshot in sync with what's been observed by the +// UI, so in-app commands and focus-in refreshes don't cause the next poll to +// spuriously re-trigger. +// +// We check just COMMITS and BRANCHES because the scope-expansion step at the +// top of Refresh has already added these whenever REFLOG or BISECT_INFO are +// in scope, and whenever a nil scope was passed. +func (self *RefreshHelper) updateRefsSnapshotIfRelevant(scopeSet *set.Set[types.RefreshableView]) { + if !scopeSet.Includes(types.COMMITS) && !scopeSet.Includes(types.BRANCHES) { + return + } + + snapshot, err := self.c.Git().Status.RefsSnapshot() + if err != nil { + self.c.Log.Warnf("RefsSnapshot failed during refresh: %v", err) + return + } + self.SetRefsSnapshot(snapshot) +} + func getScopeNames(scopes []types.RefreshableView) []string { scopeNameMap := map[types.RefreshableView]string{ types.COMMITS: "commits", From 6a500bae29f3564b4fc999aa84cb6cc7c797895f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 29 May 2026 14:05:13 +0200 Subject: [PATCH 7/9] Detect external ref changes via background polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- pkg/gui/background.go | 63 +++++++++++++++++++++++++++++ pkg/gui/gui.go | 2 + test/default_test_config/config.yml | 1 + 3 files changed, 66 insertions(+) diff --git a/pkg/gui/background.go b/pkg/gui/background.go index 2575aedd123..66c3f9016a6 100644 --- a/pkg/gui/background.go +++ b/pkg/gui/background.go @@ -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() { self.goEvery(time.Second*time.Duration(10), self.gui.stopChan, func(_ bool) error { formatBytes := func(b uint64) string { @@ -127,6 +138,58 @@ func (self *BackgroundRoutineMgr) startBackgroundFilesRefresh() { }) } +func (self *BackgroundRoutineMgr) startBackgroundExternalChangeDetection() { + self.gui.waitForIntro.Wait() + + // Seed the snapshot so we don't trigger a refresh on the very first tick. + // The startup refresh path has already populated the in-memory model from + // disk, so anything we observe now is what's already on screen. + // On error: leave the snapshot empty so the first successful poll sees a + // diff and triggers a refresh, which is the safe behavior. + if snapshot, err := self.gui.git.Status.RefsSnapshot(); err == nil { + self.gui.helpers.Refresh.SetRefsSnapshot(snapshot) + } + + 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 func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan struct{}, function func(bool) error) chan struct{} { done := make(chan struct{}) diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index e2881cca148..ee58bbfb82f 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -515,8 +515,10 @@ func (gui *Gui) checkForChangedConfigsThatDontAutoReload(oldConfig *config.UserC configsThatDontAutoReload := []string{ "Git.AutoFetch", "Git.AutoRefresh", + "Git.AutoDetectExternalChanges", "Refresher.RefreshInterval", "Refresher.FetchInterval", + "Refresher.ExternalChangeCheckInterval", "Update.Method", "Update.Days", } diff --git a/test/default_test_config/config.yml b/test/default_test_config/config.yml index 5a822ae774a..198fcbdd1ce 100644 --- a/test/default_test_config/config.yml +++ b/test/default_test_config/config.yml @@ -20,3 +20,4 @@ git: # TODO: add tests which explicitly test auto-refresh functionality autoRefresh: false autoFetch: false + autoDetectExternalChanges: false From 734e8cbdb4168c637fd05f219bd0510e195e8410 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 30 May 2026 20:24:21 +0200 Subject: [PATCH 8/9] fixup! Snapshot refs state before refs-touching refreshes --- pkg/gui/controllers/helpers/refresh_helper.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index c78ce7e48bc..b51c528e2e1 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -281,6 +281,17 @@ func (self *RefreshHelper) SetRefsSnapshot(snapshot string) { func (self *RefreshHelper) RefsSnapshotChangedSince(snapshot string) bool { self.refsSnapshotMutex.Lock() defer self.refsSnapshotMutex.Unlock() + + // An empty stored snapshot means no refresh has captured one yet, so we + // have no baseline to compare against and report "unchanged" rather than + // firing a spurious refresh. This can only be the unset zero value: a + // snapshot we actually computed is never empty, because its HEAD component + // is always non-empty (a branch ref when attached, a hash when detached — + // even a repo with no commits yields "ref: refs/heads/main"). + if self.refsSnapshot == "" { + return false + } + return snapshot != self.refsSnapshot } From f2e3675a52795cd8c2ac7423b595e882f6fe10ec Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 30 May 2026 20:24:56 +0200 Subject: [PATCH 9/9] fixup! Detect external ref changes via background polling --- pkg/gui/background.go | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/pkg/gui/background.go b/pkg/gui/background.go index 66c3f9016a6..afd343df330 100644 --- a/pkg/gui/background.go +++ b/pkg/gui/background.go @@ -141,14 +141,11 @@ func (self *BackgroundRoutineMgr) startBackgroundFilesRefresh() { func (self *BackgroundRoutineMgr) startBackgroundExternalChangeDetection() { self.gui.waitForIntro.Wait() - // Seed the snapshot so we don't trigger a refresh on the very first tick. - // The startup refresh path has already populated the in-memory model from - // disk, so anything we observe now is what's already on screen. - // On error: leave the snapshot empty so the first successful poll sees a - // diff and triggers a refresh, which is the safe behavior. - if snapshot, err := self.gui.git.Status.RefsSnapshot(); err == nil { - self.gui.helpers.Refresh.SetRefsSnapshot(snapshot) - } + // 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(