From 2ddeebca72040e25334427b629563c8811456fa6 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 12 Oct 2026 18:14:13 +0200 Subject: [PATCH] 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) --- pkg/commands/git_commands/file_loader.go | 10 +++++++++- pkg/commands/git_commands/file_loader_test.go | 20 +++++++++++++++++++ pkg/gui/controllers/helpers/refresh_helper.go | 10 +--------- .../helpers/refresh_helper_test.go | 12 +++++------ .../submodule/discard_removed_submodule.go | 12 ----------- 5 files changed, 36 insertions(+), 28 deletions(-) diff --git a/pkg/commands/git_commands/file_loader.go b/pkg/commands/git_commands/file_loader.go index 747572b38..7ec13836f 100644 --- a/pkg/commands/git_commands/file_loader.go +++ b/pkg/commands/git_commands/file_loader.go @@ -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, } diff --git a/pkg/commands/git_commands/file_loader_test.go b/pkg/commands/git_commands/file_loader_test.go index 23602ff9e..ad8882715 100644 --- a/pkg/commands/git_commands/file_loader_test.go +++ b/pkg/commands/git_commands/file_loader_test.go @@ -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, diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 826d12400..0cbde1f70 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -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 diff --git a/pkg/gui/controllers/helpers/refresh_helper_test.go b/pkg/gui/controllers/helpers/refresh_helper_test.go index 5e58c56a3..1ef4b581a 100644 --- a/pkg/gui/controllers/helpers/refresh_helper_test.go +++ b/pkg/gui/controllers/helpers/refresh_helper_test.go @@ -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)) }) diff --git a/pkg/integration/tests/submodule/discard_removed_submodule.go b/pkg/integration/tests/submodule/discard_removed_submodule.go index 514f01900..b417a9953 100644 --- a/pkg/integration/tests/submodule/discard_removed_submodule.go +++ b/pkg/integration/tests/submodule/discard_removed_submodule.go @@ -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")