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) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-10-15 20:51:34 +02:00
co-authored by Claude Opus 5.5
parent 8a2d9758e0
commit 0380dd4851
8 changed files with 165 additions and 14 deletions
+39 -8
View File
@@ -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).
@@ -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
}
+22
View File
@@ -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
+29 -4
View File
@@ -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
+18
View File
@@ -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{
+6
View File
@@ -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.",
@@ -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() {
@@ -22,7 +22,7 @@ var WorktreeInsideRepo = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Files().
IsFocused().
Lines(
Equals("?? 󰌹 nested-worktree").IsSelected(),
Equals("?? 󰌹 nested-worktree (worktree)").IsSelected(),
)
},
})