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)