From cfe7961d54e6286c7065a88230402fad281fb923 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 15 Aug 2026 06:52:06 +0200 Subject: [PATCH 1/5] Give commits and branches their own scope checks Everything in performRefresh is meant to read as "if this scope was asked for, refresh it", with the scopes that always change together expanded into each other up front so that each check can name a single one. The commits and the branches were the exception: one condition asking for either of them refreshed both, so what that block does only followed from reading it together with the expansion at the top of the function. The rebase commits hung off the same condition as an else, even though it is the commits refresh they are an alternative to. Expand those two into each other like the other pairs, and give each of them a check of its own. They now capture their inputs separately, which is what every other scope has always done. The reflog stays with the branches rather than getting a check of its own, because sorting the branches by recency needs it loaded first. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 59 +++++++++++-------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index d076ced0a..59d6bb195 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -273,6 +273,10 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // - merge conflicts are part of what the files refresh produces // - pull requests are fetched for the tracking branches against the // remotes, so refresh both alongside to fetch against fresh data + // - commits and branches always go together: changing commits changes + // the branches' upstream/downstream counts, and changing branches + // (e.g. checking one out) changes the commits we show. This one comes + // last, so that it also covers the branches the rules above add. if scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) { scopeSet.Add(types.COMMITS, types.BRANCHES) } @@ -285,6 +289,9 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr if scopeSet.Includes(types.PULL_REQUESTS) { scopeSet.Add(types.BRANCHES, types.REMOTES) } + if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) { + scopeSet.Add(types.COMMITS, types.BRANCHES) + } // Capture the refs snapshot now, before we start reading git's state // below, rather than after. This is important to guard against the race @@ -321,27 +328,44 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr var loadedBranches []*models.Branch var loadedRemotes []*models.Remote includeWorktreesWithBranches := false - 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. - // Capture the commits, reflog and branches refresh inputs (model, - // contexts, modes) on the UI thread, before the git work is dispatched - // to a worker, so the workers compute from an immutable snapshot - // instead of reading state the UI thread concurrently mutates. + if scopeSet.Includes(types.COMMITS) { + // Capture the refresh's inputs (model, contexts, modes) on the UI + // thread, before the git work is dispatched to a worker, so the worker + // computes from an immutable snapshot instead of reading state the UI + // thread concurrently mutates. Every scope below does the same. var capturedCommits capturedCommitState - var capturedReflog capturedReflogState - var capturedBranches capturedBranchState if !self.captureOnUIThread(calledFromWorker, env.background, func() { capturedCommits = self.captureCommitsState() - capturedReflog = self.captureReflogState() - capturedBranches = self.captureBranchState() }) { return } refresh("commits and commit files", func() { self.refreshCommitsAndCommitFiles(capturedCommits, options.CommitSelection, env) }) + } else if scopeSet.Includes(types.REBASE_COMMITS) { + // the commits refresh above loads the rebase commits as well, so we only + // need this one when the rebase commits are all that was asked for + var rebaseHashPool *utils.StringPool + var rebaseCommits []*models.Commit + if !self.captureOnUIThread(calledFromWorker, env.background, func() { + rebaseHashPool, rebaseCommits = self.captureRebaseCommitState() + }) { + return + } + refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, env) }) + } + + if scopeSet.Includes(types.BRANCHES) { + // The reflog is refreshed here rather than in a scope of its own, + // because sorting the branches by recency needs it to be loaded first. + var capturedReflog capturedReflogState + var capturedBranches capturedBranchState + if !self.captureOnUIThread(calledFromWorker, env.background, func() { + capturedReflog = self.captureReflogState() + capturedBranches = self.captureBranchState() + }) { + return + } includeWorktreesWithBranches = scopeSet.Includes(types.WORKTREES) if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" { @@ -363,17 +387,6 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr _, _ = self.refreshReflogCommits(capturedReflog, env, options.SelectTopReflogCommit) }) } - } else if scopeSet.Includes(types.REBASE_COMMITS) { - // the above block handles rebase commits so we only need to call this one - // if we've asked specifically for rebase commits and not those other things - var rebaseHashPool *utils.StringPool - var rebaseCommits []*models.Commit - if !self.captureOnUIThread(calledFromWorker, env.background, func() { - rebaseHashPool, rebaseCommits = self.captureRebaseCommitState() - }) { - return - } - refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, env) }) } if scopeSet.Includes(types.SUB_COMMITS) { From 62caf427acfa1207ef563c986ea950c57820cc7c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 15 Aug 2026 00:09:22 +0200 Subject: [PATCH 2/5] Refresh the worktrees in their own scope again The worktrees were loaded and written by the branches refresh whenever both were in scope, because the branches view shows worktrees against branches: refreshing them separately rendered that view twice, once with worktrees that were still stale. Ordering the two is enough for that, and it leaves each scope owning its own model again. The worktrees refresh now runs first and queues its model write before it reports being done, so a branches refresh that waits for it queues its own write behind that one, and renders once with both. The worktrees scope only renders the branches view itself when nobody else is going to. As a side effect the two loads now run concurrently, where the branches refresh used to load the worktrees after its own branches. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 62 +++++++++++-------- 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 59d6bb195..44a14a538 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -318,6 +318,23 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr }) } + // The branches view shows worktrees against branches, so a branches render + // that happens before the refreshed worktrees have landed in the model shows + // stale ones, and rendering again once they land makes the view flicker. + // Refresh the worktrees first, then, and let the branches refresh wait for + // them: waitForWorktrees returns once the worktrees model write is queued, + // so the branches write that follows is queued behind it and the view + // renders once, with both. + worktreesWg := sync.WaitGroup{} + waitForWorktrees := func() { worktreesWg.Wait() } + if scopeSet.Includes(types.WORKTREES) { + worktreesWg.Add(1) + refresh("worktrees", func() { + defer worktreesWg.Done() + self.refreshWorktrees(env, scopeSet.Includes(types.BRANCHES)) + }) + } + branchesAndRemotesWg := sync.WaitGroup{} // The pull-request fetch (below) needs the just-loaded branches and // remotes. Their model writes are bounced onto the UI thread, so the @@ -327,7 +344,6 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // branchesAndRemotesWg gives the fetch the happens-before to read them. var loadedBranches []*models.Branch var loadedRemotes []*models.Remote - includeWorktreesWithBranches := false if scopeSet.Includes(types.COMMITS) { // Capture the refresh's inputs (model, contexts, modes) on the UI // thread, before the git work is dispatched to a worker, so the worker @@ -367,11 +383,10 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr return } - includeWorktreesWithBranches = scopeSet.Includes(types.WORKTREES) if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" { branchesAndRemotesWg.Add(1) refresh("reflog and branches", func() { - loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, env) + loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, waitForWorktrees, options.BranchSelection, options.SelectTopReflogCommit, env) branchesAndRemotesWg.Done() }) } else { @@ -380,7 +395,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // Not a recency sort, so branches doesn't depend on the reflog // being fresh; it runs concurrently with the reflog refresh // below and uses the reflog we captured up front, as it always has. - loadedBranches = self.refreshBranches(capturedBranches, includeWorktreesWithBranches, options.BranchSelection, true, capturedReflog.reflogCommits, env) + loadedBranches = self.refreshBranches(capturedBranches, waitForWorktrees, options.BranchSelection, true, capturedReflog.reflogCommits, env) branchesAndRemotesWg.Done() }) refresh("reflog", func() { @@ -481,10 +496,6 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr }) } - if scopeSet.Includes(types.WORKTREES) && !includeWorktreesWithBranches { - refresh("worktrees", func() { self.refreshWorktrees(env) }) - } - if scopeSet.Includes(types.STAGING) { refresh("staging", func() { fileWg.Wait() @@ -711,17 +722,19 @@ func (self *RefreshHelper) captureBranchState() capturedBranchState { } } -func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflogState, capturedBranches capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, env refreshEnv) []*models.Branch { +func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflogState, capturedBranches capturedBranchState, waitForWorktrees func(), branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, env refreshEnv) []*models.Branch { switch self.c.State().GetRepoState().GetStartupStage() { case types.INITIAL: // Return the immediate (non-recency) load's branches; the recency-sorted // reload below runs on its own worker after we return. Both hold the same // set of branches, which is all the caller (the PR fetch) needs. - branches := self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, false, capturedReflog.reflogCommits, env) + branches := self.refreshBranches(capturedBranches, waitForWorktrees, branchSelection, false, capturedReflog.reflogCommits, env) self.onWorker(env.background, func(_ gocui.Task) error { reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, false) - self.refreshBranches(capturedBranches, false, types.SelectCheckedOutBranch, true, reflogCommits, env) + // The load above already waited for the worktrees, so this one has + // nothing left to wait for. + self.refreshBranches(capturedBranches, func() {}, types.SelectCheckedOutBranch, true, reflogCommits, env) self.c.State().GetRepoState().SetStartupStage(types.COMPLETE) return nil }) @@ -730,7 +743,7 @@ func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflo case types.COMPLETE: reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, selectTopReflogCommit) - return self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, true, reflogCommits, env) + return self.refreshBranches(capturedBranches, waitForWorktrees, branchSelection, true, reflogCommits, env) } return nil @@ -1095,7 +1108,7 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs(env refreshEnv) ([]*mode // self.refreshStatus is called at the end of this because that's when we can // be sure there is a State.Model.Branches array to pick the current branch from -func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch { +func (self *RefreshHelper) refreshBranches(captured capturedBranchState, waitForWorktrees func(), branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch { loadSeq := self.branchLoadSeq.Add(1) branches, err := env.git.Loaders.BranchLoader.Load( @@ -1129,10 +1142,9 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh self.c.Log.Error(err) } - var worktrees []*models.Worktree - if refreshWorktrees { - worktrees = self.loadWorktrees(env) - } + // Render only once the refreshed worktrees are in the model; the branches + // view shows them against the branches (see performRefresh). + waitForWorktrees() self.onUIThreadUnlessRepoChanged(env, func() { // Drop this write if a branch load that started later has already applied @@ -1155,11 +1167,6 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh // the branches we just wrote, on the UI thread. self.rebuildPullRequestsMap() - if refreshWorktrees { - self.c.Model().Worktrees = worktrees - self.refreshView(self.c.Contexts().Worktrees, env) - } - // Setting the selection here, in the same bounce that writes the list, // keeps it on the UI thread and keeps the list and selection updating in // the same frame. @@ -1519,16 +1526,19 @@ func (self *RefreshHelper) loadWorktrees(env refreshEnv) []*models.Worktree { return worktrees } -func (self *RefreshHelper) refreshWorktrees(env refreshEnv) { +func (self *RefreshHelper) refreshWorktrees(env refreshEnv, branchesAreRefreshing bool) { worktrees := self.loadWorktrees(env) self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().Worktrees = worktrees }) - // need to refresh branches because the branches view shows worktrees against - // branches - self.refreshView(self.c.Contexts().Branches, env) + // The branches view shows worktrees against branches, so it needs to be + // rendered again as well. When the branches are being refreshed too, they + // render after waiting for the write above, so leave it to them. + if !branchesAreRefreshing { + self.refreshView(self.c.Contexts().Branches, env) + } self.refreshView(self.c.Contexts().Worktrees, env) } From a1f5db6bce94b3c87bd521da4641e560d51fb4d2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 15 Aug 2026 11:17:44 +0200 Subject: [PATCH 3/5] Add test for how we show a worktree that is inside the main work tree We show this with a worktree icon (which is only shown when nerd fonts are used, so turn these on), but also we strip the trailing `/` that "git status" reports, so that it shows as a file rather than a directory with a bogus file in it. The reason for adding the test is that we are going to touch the logic that determines whether an item in the Files panel is a linked worktree, and this guards against regressing. --- pkg/integration/tests/test_list.go | 1 + .../tests/worktree/worktree_inside_repo.go | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 pkg/integration/tests/worktree/worktree_inside_repo.go diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 16de7805a..fac10efb4 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -557,4 +557,5 @@ var tests = []*components.IntegrationTest{ worktree.SeparateWorkTreeConfig, worktree.SymlinkIntoRepoSubdir, worktree.WorktreeInRepo, + worktree.WorktreeInsideRepo, } diff --git a/pkg/integration/tests/worktree/worktree_inside_repo.go b/pkg/integration/tests/worktree/worktree_inside_repo.go new file mode 100644 index 000000000..bae2ff8f1 --- /dev/null +++ b/pkg/integration/tests/worktree/worktree_inside_repo.go @@ -0,0 +1,28 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var WorktreeInsideRepo = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A worktree that lives inside the repo's working tree is shown as a single item in the files panel", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.NerdFontsVersion = "3" + }, + SetupRepo: func(shell *Shell) { + shell.NewBranch("mybranch") + shell.CreateFileAndAdd("README.md", "hello world") + shell.Commit("initial commit") + shell.AddWorktree("mybranch", "nested-worktree", "newbranch") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("?? 󰌹 nested-worktree").IsSelected(), + ) + }, +}) From 80ad2db71ac3fb03721b0824168a1f6bdae779ae Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 15 Aug 2026 00:19:20 +0200 Subject: [PATCH 4/5] Recognize worktrees among the files from the worktrees model Finding out which of the files are worktrees of ours had its own answer to where this repo's worktrees are, walking the directory that git keeps them in. The worktrees panel asks git itself, and that is the better answer: it is the one git gives for the same question elsewhere in the app, and it doesn't need to know where git records what. The model that panel fills is all the files need, so mark them from it. That takes the work out of the file loader, whose other two callers were paying for it without wanting it, and it costs no git call at all: both models are written on the UI thread, so whichever of the two refreshes lands second marks the files against the other's fresh data. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/file_loader.go | 21 ---------- pkg/commands/git_commands/repo_paths.go | 40 ------------------ pkg/gui/controllers/helpers/refresh_helper.go | 41 +++++++++++++++++++ .../helpers/refresh_helper_test.go | 41 +++++++++++++++++++ 4 files changed, 82 insertions(+), 61 deletions(-) diff --git a/pkg/commands/git_commands/file_loader.go b/pkg/commands/git_commands/file_loader.go index 3f960cb30..747572b38 100644 --- a/pkg/commands/git_commands/file_loader.go +++ b/pkg/commands/git_commands/file_loader.go @@ -2,7 +2,6 @@ package git_commands import ( "fmt" - "path/filepath" "strconv" "strings" @@ -91,26 +90,6 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File self.setConflictMarkerSizes(files) - // Go through the files to see if any of these files are actually worktrees - // so that we can render them correctly - worktreePaths := linkedWortkreePaths(self.Fs, self.repoPaths.RepoGitDirPath()) - for _, file := range files { - for _, worktreePath := range worktreePaths { - absFilePath, err := filepath.Abs(file.Path) - if err != nil { - self.Log.Error(err) - continue - } - if absFilePath == worktreePath { - file.IsWorktree = true - // `git status` renders this worktree as a folder with a trailing slash but we'll represent it as a singular worktree - // If we include the slash, it will be rendered as a folder with a null file inside. - file.Path = strings.TrimSuffix(file.Path, "/") - break - } - } - } - return files } diff --git a/pkg/commands/git_commands/repo_paths.go b/pkg/commands/git_commands/repo_paths.go index 61afc943f..0473f8f8e 100644 --- a/pkg/commands/git_commands/repo_paths.go +++ b/pkg/commands/git_commands/repo_paths.go @@ -1,7 +1,6 @@ package git_commands import ( - ioFs "io/fs" "os" "path/filepath" "strings" @@ -10,7 +9,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/env" "github.com/jesseduffield/lazygit/pkg/utils" - "github.com/spf13/afero" ) type RepoPaths struct { @@ -302,41 +300,3 @@ func runGitRevParse(gitCmd *oscommands.CmdObj) (string, error) { } return strings.TrimSpace(res), nil } - -// Returns the paths of linked worktrees -func linkedWortkreePaths(fs afero.Fs, repoGitDirPath string) []string { - result := []string{} - // For each directory in this path we're going to cat the `gitdir` file and append its contents to our result - // That file points us to the `.git` file in the worktree. - worktreeGitDirsPath := filepath.Join(repoGitDirPath, "worktrees") - - // ensure the directory exists - _, err := fs.Stat(worktreeGitDirsPath) - if err != nil { - return result - } - - _ = afero.Walk(fs, worktreeGitDirsPath, func(currPath string, info ioFs.FileInfo, err error) error { - if err != nil { - return err - } - - if !info.IsDir() { - return nil - } - - gitDirPath := filepath.Join(currPath, "gitdir") - gitDirBytes, err := afero.ReadFile(fs, gitDirPath) - if err != nil { - // ignoring error - return nil - } - trimmedGitDir := strings.TrimSpace(string(gitDirBytes)) - // removing the .git part - worktreeDir := filepath.Dir(trimmedGitDir) - result = append(result, worktreeDir) - return nil - }) - - return result -} diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 44a14a538..40ce86eae 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1419,12 +1419,45 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re self.c.Model().Submodules = submoduleConfigs self.c.Model().Files = files + markWorktreeFiles(files, self.c.Model().Worktrees, env.git.RepoPaths.WorktreePath()) fileTreeViewModel.SetTree() }) return nil } +// markWorktreeFiles marks the files that are linked worktrees of this repo, so +// that the files view can render them as such. `git status` reports a worktree +// as an untracked directory, i.e. with a trailing slash, which we take off: +// keeping it would build a directory node with a nameless file inside it. +// +// It must run on the UI thread, as it works on the model. Both models it needs +// are written by refreshes of their own, so it is called after either of them +// lands; it reports whether it changed anything. +func markWorktreeFiles(files []*models.File, worktrees []*models.Worktree, worktreePath string) bool { + changed := false + + for _, file := range files { + absPath := filepath.Join(worktreePath, file.Path) + isWorktree := lo.SomeBy(worktrees, func(worktree *models.Worktree) bool { + return worktree.Path == absPath + }) + + if isWorktree != file.IsWorktree { + file.IsWorktree = isWorktree + changed = true + } + if isWorktree { + if trimmed := strings.TrimSuffix(file.Path, "/"); trimmed != file.Path { + file.Path = trimmed + changed = true + } + } + } + + return changed +} + // the reflogs panel is the only panel where we cache data, in that we only // load entries that have been created since we last ran the call. This means // we need to be more careful with how we use this, and to ensure we're emptying @@ -1531,6 +1564,14 @@ func (self *RefreshHelper) refreshWorktrees(env refreshEnv, branchesAreRefreshin self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().Worktrees = worktrees + + // A worktree inside our working tree is one of the files, so the files + // view has to be told about the ones we just loaded (see + // markWorktreeFiles). Rebuild the tree because a file's path can change. + if markWorktreeFiles(self.c.Model().Files, worktrees, env.git.RepoPaths.WorktreePath()) { + self.c.Contexts().Files.FileTreeViewModel.SetTree() + self.refreshView(self.c.Contexts().Files, env) + } }) // The branches view shows worktrees against branches, so it needs to be diff --git a/pkg/gui/controllers/helpers/refresh_helper_test.go b/pkg/gui/controllers/helpers/refresh_helper_test.go index d3829127a..b92a64285 100644 --- a/pkg/gui/controllers/helpers/refresh_helper_test.go +++ b/pkg/gui/controllers/helpers/refresh_helper_test.go @@ -1,6 +1,7 @@ package helpers import ( + "path/filepath" "testing" "github.com/jesseduffield/lazygit/pkg/commands/hosting_service" @@ -243,6 +244,46 @@ func TestGetAuthenticatedGithubRemotes(t *testing.T) { }, callsByHost) } +func TestMarkWorktreeFiles(t *testing.T) { + worktreePath := filepath.Join("/", "path", "to", "repo") + worktrees := []*models.Worktree{ + {Path: worktreePath}, + {Path: filepath.Join(worktreePath, "worktree1")}, + {Path: filepath.Join(worktreePath, "dir", "worktree2")}, + {Path: filepath.Join("/", "path", "to", "worktree3")}, + } + + t.Run("marks the files that are worktrees, and takes their slash off", func(t *testing.T) { + files := []*models.File{ + {Path: "file"}, + {Path: "worktree1/"}, + {Path: "dir/worktree2/"}, + {Path: "dir/"}, + } + + assert.True(t, markWorktreeFiles(files, worktrees, worktreePath)) + assert.Equal(t, []*models.File{ + {Path: "file"}, + {Path: "worktree1", IsWorktree: true}, + {Path: "dir/worktree2", IsWorktree: true}, + {Path: "dir/"}, + }, files) + }) + + t.Run("reports no change when there is nothing to mark", func(t *testing.T) { + files := []*models.File{{Path: "file"}, {Path: "dir/"}} + + assert.False(t, markWorktreeFiles(files, worktrees, worktreePath)) + }) + + t.Run("unmarks a file whose worktree is gone", func(t *testing.T) { + files := []*models.File{{Path: "worktree1", IsWorktree: true}} + + assert.True(t, markWorktreeFiles(files, nil, worktreePath)) + assert.Equal(t, []*models.File{{Path: "worktree1"}}, files) + }) +} + func makeGithubRemoteInfoList(names ...string) []githubRemoteInfo { return lo.Map(names, func(name string, _ int) githubRemoteInfo { return makeGithubRemoteInfo(name, name) From 312a5f2cc1fb942d38b99f6669f06e8677509df8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 15 Aug 2026 08:27:44 +0200 Subject: [PATCH 5/5] Only react to focus reports that change whether we're focused A terminal that supports focus reporting answers with the state it is already in when we turn reporting on, so at startup we were told that we had gained focus that we never lost, and refreshed everything a second time on top of the refresh that loading the repo had just started. The two ran at once, each with its own `git status`, which made both of them slower than the one refresh needed to be. Keep track of what the reports say, then, and pass on only the ones that change it. Assuming that we start out focused costs us nothing when we don't: that same first report says so, so a lazygit started in a window that isn't in front knows it from the start. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gocui/gui.go | 25 +++++++++++++++++++++++++ pkg/gui/gui_driver.go | 24 ++++++++++++++++-------- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index a9b68dc5c..833a0af81 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -222,6 +222,11 @@ type Gui struct { // worker goroutines, so it's atomic. uiThreadID atomic.Int64 + // focused says whether the terminal we're running in has focus, as far as + // its focus reports tell us (see IsFocused). Written by the event loop, + // readable from anywhere, so it's atomic. + focused atomic.Bool + // blockInputCount, when greater than zero, withholds keyboard input from // the handlers: key events are buffered into bufferedKeyEvents and replayed // once the count drops back to zero, while mouse clicks and hover are @@ -306,6 +311,12 @@ func NewGui(opts NewGuiOpts) (*Gui, error) { // runs during startup, before we reach MainLoop. g.uiThreadID.Store(goid.Get()) + // Assume we start out focused: a terminal that supports focus reports sends + // one for the state it is already in when we turn reporting on in MainLoop, + // and passing that on as a change would have the app react to a change that + // never happened. + g.focused.Store(true) + return g, nil } @@ -2009,7 +2020,21 @@ func (g *Gui) execKeybinding(v *View, kb *keybinding) error { return nil } +// IsFocused reports whether the terminal we're running in has focus. Terminals +// that don't report focus at all leave this true for good. +func (g *Gui) IsFocused() bool { + return g.focused.Load() +} + func (g *Gui) onFocus(ev *GocuiEvent) error { + // Terminals report their focus state when we turn focus reporting on, and + // some report it again when their window is activated, so only pass on the + // reports that actually change it. + if ev.Focused == g.focused.Load() { + return nil + } + g.focused.Store(ev.Focused) + if g.focusHandler != nil { return g.focusHandler(ev.Focused) } diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 096534e96..b922f0705 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -101,14 +101,25 @@ func (self *GuiDriver) replayMouseEventWithoutWaiting(x, y int, buttons tcell.Bu )) } -// FocusIn simulates the terminal window regaining focus, which is how lazygit -// learns to reload changed config files. Tests use it to exercise the live -// config-reload path. -func (self *GuiDriver) FocusIn() { +// replayFocusIn takes the focus away before handing it back, because that's the +// only way a terminal can report regaining it, and lazygit only reacts to focus +// reports that change the focus (see gocui.Gui.IsFocused). +func (self *GuiDriver) replayFocusIn() { + self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper( + tcell.NewEventFocus(false), + 0, + )) self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper( tcell.NewEventFocus(true), 0, )) +} + +// FocusIn simulates the terminal window regaining focus, which is how lazygit +// learns to reload changed config files. Tests use it to exercise the live +// config-reload path. +func (self *GuiDriver) FocusIn() { + self.replayFocusIn() self.waitTillIdle() } @@ -116,10 +127,7 @@ func (self *GuiDriver) FocusIn() { func (self *GuiDriver) FocusInAndClick(x, y int) { self.CheckAllToastsAcknowledged() - self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper( - tcell.NewEventFocus(true), - 0, - )) + self.replayFocusIn() self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( tcell.NewEventMouse(x, y, tcell.ButtonPrimary, 0), 0,