From 0380dd48512a49b7bd0ec436c163e445f1aac38e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 12 Oct 2026 18:24:59 +0200 Subject: [PATCH] Label the directories that git status reports as a whole The files panel shows such a directory like an untracked file. Nothing tells you that it is a directory, or that a submodule left it behind when a commit that you checked out removed the submodule. This is the case for the directory of a removed submodule, a nested repository, a linked worktree inside the repo, and any untracked directory if status.showUntrackedFiles is "normal". Tell these apart when loading the files, by looking at the directory's .git. Label removed submodules, nested repositories and worktrees, as we already do for submodules, and put a slash after the name of a plain directory, as git status does. With file icons on, a removed submodule gets the submodule icon, and a nested repository or a plain directory gets the directory icon. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/commands/git_commands/file_loader.go | 47 +++++++++++++++--- pkg/commands/git_commands/file_loader_test.go | 49 +++++++++++++++++++ pkg/commands/models/file.go | 22 +++++++++ pkg/gui/presentation/files.go | 33 +++++++++++-- pkg/gui/presentation/files_test.go | 18 +++++++ pkg/i18n/english.go | 6 +++ .../submodule/discard_removed_submodule.go | 2 +- .../tests/worktree/worktree_inside_repo.go | 2 +- 8 files changed, 165 insertions(+), 14 deletions(-) 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(), ) }, })