Show a directory that git status reports as a whole as a single entry

git status doesn't descend into a nested repository, such as the
directory that a removed submodule leaves behind. It reports the
directory as a whole, with a trailing slash. The file tree splits paths
at slashes, so it showed a directory with a file without a name in it.
Discarding the directory removed it, but then the cleanup of emptied
directories took the parent of "modules/sub/" to be "modules/sub"
itself, and failed to read it.

Take the slash off when loading the files. This also covers any
untracked directory if status.showUntrackedFiles is "normal", and linked
worktrees inside the repo. Their slash was taken off when they were
marked as worktrees; this is no longer needed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-10-15 09:13:12 +02:00
co-authored by Claude Opus 5.5
parent 165b4bb499
commit 2ddeebca72
5 changed files with 36 additions and 28 deletions
+9 -1
View File
@@ -73,8 +73,16 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File
continue
}
// git status reports a directory that it doesn't descend into with a
// trailing slash. This is the case for a nested repository, and for
// any untracked directory if status.showUntrackedFiles is "normal". We
// 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, "/")
file := &models.File{
Path: status.Path,
Path: path,
PreviousPath: status.PreviousPath,
DisplayString: status.StatusString,
}
@@ -188,6 +188,26 @@ func TestFileGetStatusFiles(t *testing.T) {
},
},
},
{
testName: "Directory that git doesn't descend into",
similarityThreshold: 50,
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, "?? dir/nested/", nil),
expectedFiles: []*models.File{
{
Path: "dir/nested",
HasStagedChanges: false,
HasUnstagedChanges: true,
Tracked: false,
Added: true,
Deleted: false,
HasMergeConflicts: false,
HasInlineMergeConflicts: false,
DisplayString: "?? dir/nested/",
ShortStatus: "??",
},
},
},
{
testName: "Renamed files",
similarityThreshold: 50,
@@ -1467,9 +1467,7 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
}
// 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.
// that the files view can render them as such.
//
// 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
@@ -1487,12 +1485,6 @@ func markWorktreeFiles(files []*models.File, worktrees []*models.Worktree, workt
file.IsWorktree = isWorktree
changed = true
}
if isWorktree {
if trimmed := strings.TrimSuffix(file.Path, "/"); trimmed != file.Path {
file.Path = trimmed
changed = true
}
}
}
return changed
@@ -309,12 +309,12 @@ func TestMarkWorktreeFiles(t *testing.T) {
{Path: filepath.Join("/", "path", "to", "worktree3")},
}
t.Run("marks the files that are worktrees, and takes their slash off", func(t *testing.T) {
t.Run("marks the files that are worktrees", func(t *testing.T) {
files := []*models.File{
{Path: "file"},
{Path: "worktree1/"},
{Path: "dir/worktree2/"},
{Path: "dir/"},
{Path: "worktree1"},
{Path: "dir/worktree2"},
{Path: "dir"},
}
assert.True(t, markWorktreeFiles(files, worktrees, worktreePath))
@@ -322,12 +322,12 @@ func TestMarkWorktreeFiles(t *testing.T) {
{Path: "file"},
{Path: "worktree1", IsWorktree: true},
{Path: "dir/worktree2", IsWorktree: true},
{Path: "dir/"},
{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/"}}
files := []*models.File{{Path: "file"}, {Path: "dir"}}
assert.False(t, markWorktreeFiles(files, worktrees, worktreePath))
})
@@ -26,12 +26,8 @@ var DiscardRemovedSubmodule = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Files().
IsFocused().
Lines(
/* EXPECTED:
Equals("▼ modules").IsSelected(),
Equals(" ?? sub"),
ACTUAL: */
Equals("▼ modules/sub").IsSelected(),
Equals(" ?? "),
).
Press(keys.Universal.Remove).
Tap(func() {
@@ -40,15 +36,7 @@ var DiscardRemovedSubmodule = NewIntegrationTest(NewIntegrationTestArgs{
Select(Contains("Discard all changes")).
Confirm()
}).
/* EXPECTED:
IsFocused().
ACTUAL: */
Tap(func() {
t.ExpectPopup().Alert().
Title(Equals("Error")).
Content(Equals("open modules/sub: no such file or directory")).
Confirm()
}).
IsEmpty()
t.FileSystem().PathNotPresent("modules/sub")