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/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() + }) + } +} 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 } 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 cac87ec9157..9cf10e950c8 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -44,10 +44,13 @@ 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"` + // 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/pkg/gui/background.go b/pkg/gui/background.go index 2575aedd123..afd343df330 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,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 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/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index d27b38feb4f..b51c528e2e1 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( @@ -106,6 +113,30 @@ 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) + } + + // 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 @@ -129,7 +160,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 +197,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 +243,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() }) } @@ -236,6 +267,57 @@ 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() + + // 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 +} + +// 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", 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/schema-master/config.json b/schema-master/config.json index 2e968ba8f95..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": [ @@ -3521,15 +3526,21 @@ "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 + }, + "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, 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