diff --git a/pkg/commands/git_commands/file_loader.go b/pkg/commands/git_commands/file_loader.go index 7ec13836f..8e067ac02 100644 --- a/pkg/commands/git_commands/file_loader.go +++ b/pkg/commands/git_commands/file_loader.go @@ -2,6 +2,7 @@ package git_commands import ( "fmt" + "path/filepath" "strconv" "strings" @@ -16,17 +17,19 @@ type FileLoaderConfig interface { type FileLoader struct { *GitCommon - cmd oscommands.ICmdObjBuilder - config FileLoaderConfig - getFileType func(string) string + cmd oscommands.ICmdObjBuilder + config FileLoaderConfig + getFileType func(string) string + getDirectoryKind func(dirPath string, worktreeGitDirPath string) models.DirectoryKind } func NewFileLoader(gitCommon *GitCommon, cmd oscommands.ICmdObjBuilder, config FileLoaderConfig) *FileLoader { return &FileLoader{ - GitCommon: gitCommon, - cmd: cmd, - getFileType: oscommands.FileType, - config: config, + GitCommon: gitCommon, + cmd: cmd, + getFileType: oscommands.FileType, + getDirectoryKind: directoryKind, + config: config, } } @@ -79,7 +82,7 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File // take the slash off so that the file tree shows the directory as a // single entry; it would otherwise split the path into a directory and // a file without a name. - path := strings.TrimSuffix(status.Path, "/") + path, isDirectory := strings.CutSuffix(status.Path, "/") file := &models.File{ Path: path, @@ -87,6 +90,13 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File DisplayString: status.StatusString, } + if isDirectory { + file.DirectoryKind = self.getDirectoryKind( + filepath.Join(self.repoPaths.WorktreePath(), path), + self.repoPaths.WorktreeGitDirPath(), + ) + } + if diff, ok := fileDiffs[status.Path]; ok { file.LinesAdded = diff.LinesAdded file.LinesDeleted = diff.LinesDeleted @@ -101,6 +111,27 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File return files } +// directoryKind tells what kind of directory git status reported as a whole. +// dirPath is the directory's absolute path, and worktreeGitDirPath is the git +// dir of the worktree that we're in. +func directoryKind(dirPath string, worktreeGitDirPath string) models.DirectoryKind { + gitDir, ok := GitDirOfRepo(dirPath) + if !ok { + return models.PlainDirectory + } + + // git keeps the git dir of a submodule in the "modules" directory of the + // git dir of the worktree that it is checked out in, and puts a .git file + // into the submodule's directory that points there. That .git file stays + // when a commit removes the submodule. + relPath, err := filepath.Rel(filepath.Join(worktreeGitDirPath, "modules"), gitDir) + if err == nil && relPath != "." && !strings.HasPrefix(relPath, "..") { + return models.RemovedSubmodule + } + + return models.NestedRepository +} + // Looks up how long the conflict markers in the conflicted files are. We ask // git for all of them at once, because spawning a process per file would be // painfully slow when hundreds of files are conflicted (especially on Windows). diff --git a/pkg/commands/git_commands/file_loader_test.go b/pkg/commands/git_commands/file_loader_test.go index ad8882715..755923da7 100644 --- a/pkg/commands/git_commands/file_loader_test.go +++ b/pkg/commands/git_commands/file_loader_test.go @@ -1,6 +1,8 @@ package git_commands import ( + "os" + "path/filepath" "testing" "github.com/jesseduffield/lazygit/pkg/commands/models" @@ -205,6 +207,7 @@ func TestFileGetStatusFiles(t *testing.T) { HasInlineMergeConflicts: false, DisplayString: "?? dir/nested/", ShortStatus: "??", + DirectoryKind: models.NestedRepository, }, }, }, @@ -320,6 +323,9 @@ func TestFileGetStatusFiles(t *testing.T) { cmd: cmd, config: &FakeFileLoaderConfig{showUntrackedFiles: "yes"}, getFileType: func(string) string { return "file" }, + getDirectoryKind: func(string, string) models.DirectoryKind { + return models.NestedRepository + }, } assert.EqualValues(t, s.expectedFiles, loader.GetStatusFiles(GetStatusFileOptions{})) @@ -327,6 +333,49 @@ func TestFileGetStatusFiles(t *testing.T) { } } +func TestDirectoryKind(t *testing.T) { + repoDir := t.TempDir() + worktreeGitDir := filepath.Join(repoDir, ".git") + + writeGitFile := func(dir string, gitDir string) { + assert.NoError(t, os.MkdirAll(dir, 0o755)) + assert.NoError(t, os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: "+gitDir+"\n"), 0o644)) + } + + plainDir := filepath.Join(repoDir, "plain") + assert.NoError(t, os.MkdirAll(plainDir, 0o755)) + + nestedRepoDir := filepath.Join(repoDir, "nested") + assert.NoError(t, os.MkdirAll(filepath.Join(nestedRepoDir, ".git"), 0o755)) + + removedSubmoduleDir := filepath.Join(repoDir, "modules", "sub") + writeGitFile(removedSubmoduleDir, "../../.git/modules/sub") + + otherReposSubmoduleDir := filepath.Join(repoDir, "other-sub") + writeGitFile(otherReposSubmoduleDir, filepath.Join(t.TempDir(), ".git", "modules", "sub")) + + worktreeDir := filepath.Join(repoDir, "worktree") + writeGitFile(worktreeDir, filepath.Join(worktreeGitDir, "worktrees", "worktree")) + + scenarios := []struct { + name string + dirPath string + expected models.DirectoryKind + }{ + {"directory without a .git", plainDir, models.PlainDirectory}, + {"repository with a .git directory", nestedRepoDir, models.NestedRepository}, + {"submodule of this repo", removedSubmoduleDir, models.RemovedSubmodule}, + {"submodule of another repo", otherReposSubmoduleDir, models.NestedRepository}, + {"worktree of this repo", worktreeDir, models.NestedRepository}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + assert.Equal(t, s.expected, directoryKind(s.dirPath, worktreeGitDir)) + }) + } +} + type FakeFileLoaderConfig struct { showUntrackedFiles string } diff --git a/pkg/commands/models/file.go b/pkg/commands/models/file.go index 9eedfb1fc..5c7b79cea 100644 --- a/pkg/commands/models/file.go +++ b/pkg/commands/models/file.go @@ -29,8 +29,30 @@ type File struct { // If true, this must be a worktree folder IsWorktree bool + + // Set if git status reports this entry as a whole directory instead of + // listing the files in it + DirectoryKind DirectoryKind } +type DirectoryKind int + +const ( + NotADirectory DirectoryKind = iota + // An untracked directory without a repository of its own. git status + // only reports these as a whole if status.showUntrackedFiles is "normal". + PlainDirectory + // A git repository of its own inside the working tree, which git status + // doesn't descend into. A linked worktree of this repo inside the working + // tree is one of these too; IsWorktree tells it apart. + NestedRepository + // A nested repository whose git dir is among the git dirs that git keeps + // for the submodules of this repo. It is what is left of a submodule after + // checking out a commit that removed it: git takes the submodule out of + // the index, but leaves its directory behind. + RemovedSubmodule +) + // sometimes we need to deal with either a node (which contains a file) or an actual file type IFile interface { GetHasUnstagedChanges() bool diff --git a/pkg/gui/presentation/files.go b/pkg/gui/presentation/files.go index ae92890bf..813714f2c 100644 --- a/pkg/gui/presentation/files.go +++ b/pkg/gui/presentation/files.go @@ -161,18 +161,23 @@ func getFileLine( isSubmodule := file != nil && file.IsSubmodule(submoduleConfigs) isLinkedWorktree := file != nil && file.IsWorktree - isDirectory := file == nil + isDirectory := file == nil || file.DirectoryKind != models.NotADirectory if showFileIcons { - icon := icons.IconForFile(name, isSubmodule, isLinkedWorktree, isDirectory, customIconsConfig) + isRemovedSubmodule := file != nil && file.DirectoryKind == models.RemovedSubmodule + icon := icons.IconForFile(name, isSubmodule || isRemovedSubmodule, isLinkedWorktree, isDirectory, customIconsConfig) paint := color.HEX(icon.Color, false) output += paint.Sprint(icon.Icon) + nameColor.Sprint(" ") } output += nameColor.Sprint(utils.EscapeSpecialChars(name)) - if isSubmodule { - output += theme.DefaultTextColor.Sprint(" (" + tr.SubmoduleLabel + ")") + if file != nil && file.DirectoryKind == models.PlainDirectory { + output += nameColor.Sprint("/") + } + + if label := fileLabel(file, isSubmodule, tr); label != "" { + output += theme.DefaultTextColor.Sprint(" (" + label + ")") } if file != nil && showNumstat { @@ -184,6 +189,26 @@ func getFileLine( return output } +// fileLabel returns what to show in parentheses after the name of an entry of +// the files panel that is something other than a file or directory of this +// repo, or "" if it isn't. +func fileLabel(file *models.File, isSubmodule bool, tr *i18n.TranslationSet) string { + switch { + case file == nil: + return "" + case isSubmodule: + return tr.SubmoduleLabel + case file.IsWorktree: + return tr.WorktreeLabel + case file.DirectoryKind == models.RemovedSubmodule: + return tr.RemovedSubmoduleLabel + case file.DirectoryKind == models.NestedRepository: + return tr.NestedRepositoryLabel + default: + return "" + } +} + func formatFileStatus(file *models.File, restColor style.TextStyle) string { firstChar := file.ShortStatus[0:1] firstCharCl := style.FgGreen diff --git a/pkg/gui/presentation/files_test.go b/pkg/gui/presentation/files_test.go index da0483541..2189eed11 100644 --- a/pkg/gui/presentation/files_test.go +++ b/pkg/gui/presentation/files_test.go @@ -60,6 +60,24 @@ func TestRenderFileTree(t *testing.T) { " M test4", }, }, + { + name: "directories that git status reports as a whole", + files: []*models.File{ + {Path: "modules/sub", ShortStatus: "??", DirectoryKind: models.RemovedSubmodule}, + {Path: "nested", ShortStatus: "??", DirectoryKind: models.NestedRepository}, + {Path: "plain", ShortStatus: "??", DirectoryKind: models.PlainDirectory}, + {Path: "worktree", ShortStatus: "??", DirectoryKind: models.NestedRepository, IsWorktree: true}, + }, + expected: toStringSlice( + ` +▼ modules + ?? sub (removed submodule) +?? nested (nested repository) +?? plain/ +?? worktree (worktree) +`, + ), + }, { name: "big example", files: []*models.File{ diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 1eb463cc1..e961638d3 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -104,6 +104,9 @@ type TranslationSet struct { MergeConflictTakeIncomingCommit string SubmoduleMergeConflictDescription string SubmoduleLabel string + RemovedSubmoduleLabel string + NestedRepositoryLabel string + WorktreeLabel string StageConflictsRangeDisabled string Checkout string CheckoutTooltip string @@ -1270,6 +1273,9 @@ func EnglishTranslationSet() *TranslationSet { MergeConflictTakeIncomingCommit: "Take incoming commit", SubmoduleMergeConflictDescription: "Conflict: the submodule '{{.path}}' was set to a different commit in the current and the incoming changes. Pick which commit to keep.", SubmoduleLabel: "submodule", + RemovedSubmoduleLabel: "removed submodule", + NestedRepositoryLabel: "nested repository", + WorktreeLabel: "worktree", StageConflictsRangeDisabled: "Cannot stage a selection that includes files with merge conflicts; resolve them individually with {{.goIntoKey}} first.", Checkout: "Checkout", CheckoutTooltip: "Checkout selected item.", diff --git a/pkg/integration/tests/submodule/discard_removed_submodule.go b/pkg/integration/tests/submodule/discard_removed_submodule.go index b417a9953..62bb969a6 100644 --- a/pkg/integration/tests/submodule/discard_removed_submodule.go +++ b/pkg/integration/tests/submodule/discard_removed_submodule.go @@ -27,7 +27,7 @@ var DiscardRemovedSubmodule = NewIntegrationTest(NewIntegrationTestArgs{ IsFocused(). Lines( Equals("▼ modules").IsSelected(), - Equals(" ?? sub"), + Equals(" ?? sub (removed submodule)"), ). Press(keys.Universal.Remove). Tap(func() { diff --git a/pkg/integration/tests/worktree/worktree_inside_repo.go b/pkg/integration/tests/worktree/worktree_inside_repo.go index bae2ff8f1..6e4b09854 100644 --- a/pkg/integration/tests/worktree/worktree_inside_repo.go +++ b/pkg/integration/tests/worktree/worktree_inside_repo.go @@ -22,7 +22,7 @@ var WorktreeInsideRepo = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Files(). IsFocused(). Lines( - Equals("?? 󰌹 nested-worktree").IsSelected(), + Equals("?? 󰌹 nested-worktree (worktree)").IsSelected(), ) }, })