From 80ad2db71ac3fb03721b0824168a1f6bdae779ae Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 15 Aug 2026 00:19:20 +0200 Subject: [PATCH] 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)