From 165b4bb499223b4f812cc8f5ffd52f5c7beb9054 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 12 Oct 2026 18:12:13 +0200 Subject: [PATCH 01/14] Add a test for discarding the directory of a removed submodule If a commit that you check out or pull removes a submodule, git leaves the submodule's directory behind, and git status reports it as an untracked directory. The files panel shows that directory with a file below it that has no name. Discarding the directory shows an error, even though the directory is gone afterwards. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../submodule/discard_removed_submodule.go | 56 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 2 files changed, 57 insertions(+) create mode 100644 pkg/integration/tests/submodule/discard_removed_submodule.go diff --git a/pkg/integration/tests/submodule/discard_removed_submodule.go b/pkg/integration/tests/submodule/discard_removed_submodule.go new file mode 100644 index 000000000..514f01900 --- /dev/null +++ b/pkg/integration/tests/submodule/discard_removed_submodule.go @@ -0,0 +1,56 @@ +package submodule + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DiscardRemovedSubmodule = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Discard the directory that a removed submodule leaves behind", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("first commit") + shell.CloneIntoSubmodule("sub", "modules/sub") + shell.Commit("add submodule") + + // This is what checking out a commit that removes the submodule does: + // the submodule is gone from the index and from .gitmodules, but git + // leaves its directory behind. + shell.RunCommand([]string{"git", "rm", "--cached", "modules/sub"}) + shell.RunCommand([]string{"git", "rm", ".gitmodules"}) + shell.Commit("remove submodule") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + /* EXPECTED: + Equals("▼ modules").IsSelected(), + Equals(" ?? sub"), + ACTUAL: */ + Equals("▼ modules/sub").IsSelected(), + Equals(" ?? "), + ). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Discard changes")). + 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") + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 76b8b3357..9461fc2d3 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -569,6 +569,7 @@ var tests = []*components.IntegrationTest{ status.LogCmd, status.LogCmdStatusPanelAllBranchesLog, submodule.Add, + submodule.DiscardRemovedSubmodule, submodule.Enter, submodule.EnterFromDotfileBareRepo, submodule.EnterNested, From 2ddeebca72040e25334427b629563c8811456fa6 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 12 Oct 2026 18:14:13 +0200 Subject: [PATCH 02/14] 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") From 8b79e1dc1ff6f8b1a39ca023afb69d6d2c0454af Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 12 Oct 2026 18:20:30 +0200 Subject: [PATCH 03/14] Translate the "submodule" label in the files panel Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/context/working_tree_context.go | 2 +- pkg/gui/presentation/files.go | 7 +++++-- pkg/gui/presentation/files_test.go | 2 +- pkg/i18n/english.go | 2 ++ 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/pkg/gui/context/working_tree_context.go b/pkg/gui/context/working_tree_context.go index 706bed3b6..8b428e15b 100644 --- a/pkg/gui/context/working_tree_context.go +++ b/pkg/gui/context/working_tree_context.go @@ -34,7 +34,7 @@ func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext { getDisplayStrings := func(_ int, _ int) [][]string { showFileIcons := icons.IsIconEnabled() && c.UserConfig().Gui.ShowFileIcons showNumstat := c.UserConfig().Gui.ShowNumstatInFilesView - lines := presentation.RenderFileTree(viewModel, c.Model().Submodules, showFileIcons, showNumstat, &c.UserConfig().Gui.CustomIcons, c.UserConfig().Gui.ShowRootItemInFileTree) + lines := presentation.RenderFileTree(viewModel, c.Model().Submodules, showFileIcons, showNumstat, &c.UserConfig().Gui.CustomIcons, c.UserConfig().Gui.ShowRootItemInFileTree, c.Tr) return lo.Map(lines, func(line string, _ int) []string { return []string{line} }) diff --git a/pkg/gui/presentation/files.go b/pkg/gui/presentation/files.go index 534229905..ae92890bf 100644 --- a/pkg/gui/presentation/files.go +++ b/pkg/gui/presentation/files.go @@ -10,6 +10,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/filetree" "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" "github.com/jesseduffield/lazygit/pkg/gui/style" + "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/jesseduffield/lazygit/pkg/utils" ) @@ -26,12 +27,13 @@ func RenderFileTree( showNumstat bool, customIconsConfig *config.CustomIconsConfig, showRootItem bool, + tr *i18n.TranslationSet, ) []string { collapsedPaths := tree.CollapsedPaths() return renderAux(tree.GetRoot().Raw(), collapsedPaths, -1, -1, func(node *filetree.Node[models.File], treeDepth int, visualDepth int, isCollapsed bool) string { fileNode := filetree.NewFileNode(node) - return getFileLine(isCollapsed, fileNode.GetHasUnstagedChanges(), fileNode.GetHasStagedChanges(), treeDepth, visualDepth, showNumstat, showFileIcons, submoduleConfigs, node, customIconsConfig, showRootItem) + return getFileLine(isCollapsed, fileNode.GetHasUnstagedChanges(), fileNode.GetHasStagedChanges(), treeDepth, visualDepth, showNumstat, showFileIcons, submoduleConfigs, node, customIconsConfig, showRootItem, tr) }) } @@ -121,6 +123,7 @@ func getFileLine( node *filetree.Node[models.File], customIconsConfig *config.CustomIconsConfig, showRootItem bool, + tr *i18n.TranslationSet, ) string { name := fileNameAtDepth(node, treeDepth, showRootItem) output := "" @@ -169,7 +172,7 @@ func getFileLine( output += nameColor.Sprint(utils.EscapeSpecialChars(name)) if isSubmodule { - output += theme.DefaultTextColor.Sprint(" (submodule)") + output += theme.DefaultTextColor.Sprint(" (" + tr.SubmoduleLabel + ")") } if file != nil && showNumstat { diff --git a/pkg/gui/presentation/files_test.go b/pkg/gui/presentation/files_test.go index 9a92731a0..da0483541 100644 --- a/pkg/gui/presentation/files_test.go +++ b/pkg/gui/presentation/files_test.go @@ -123,7 +123,7 @@ M file1 for _, path := range s.collapsedPaths { viewModel.ToggleCollapsed(path) } - result := RenderFileTree(viewModel, nil, false, s.showLineChanges, &config.CustomIconsConfig{}, s.showRootItem) + result := RenderFileTree(viewModel, nil, false, s.showLineChanges, &config.CustomIconsConfig{}, s.showRootItem, common.Tr) assert.EqualValues(t, s.expected, result) }) } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 0fb388216..1eb463cc1 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -103,6 +103,7 @@ type TranslationSet struct { MergeConflictTakeCurrentCommit string MergeConflictTakeIncomingCommit string SubmoduleMergeConflictDescription string + SubmoduleLabel string StageConflictsRangeDisabled string Checkout string CheckoutTooltip string @@ -1268,6 +1269,7 @@ func EnglishTranslationSet() *TranslationSet { MergeConflictTakeCurrentCommit: "Take current commit", 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", StageConflictsRangeDisabled: "Cannot stage a selection that includes files with merge conflicts; resolve them individually with {{.goIntoKey}} first.", Checkout: "Checkout", CheckoutTooltip: "Checkout selected item.", From 8a2d9758e0ca075d3b626672f9f5fc56a7b19476 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 12 Oct 2026 18:21:54 +0200 Subject: [PATCH 04/14] Move gitDirOfRepo to git_commands The file loader is going to need it too. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/commands/git_commands/repo_paths.go | 31 +++++++++++++++++++ pkg/gui/controllers/helpers/repos_helper.go | 33 +-------------------- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/pkg/commands/git_commands/repo_paths.go b/pkg/commands/git_commands/repo_paths.go index 9850b5a4f..967268f88 100644 --- a/pkg/commands/git_commands/repo_paths.go +++ b/pkg/commands/git_commands/repo_paths.go @@ -313,3 +313,34 @@ func runGitRevParse(gitCmd *oscommands.CmdObj) (string, error) { } return strings.TrimSpace(res), nil } + +// GitDirOfRepo returns the directory that holds the git data of the repo at +// repoPath, and whether it could be found. An ordinary repo keeps that data in +// a .git directory; a worktree and a submodule have a .git file naming the +// directory instead. +func GitDirOfRepo(repoPath string) (string, bool) { + gitDirPath := filepath.Join(repoPath, ".git") + + stat, err := os.Stat(gitDirPath) + if err != nil { + return "", false + } + if stat.IsDir() { + return gitDirPath, true + } + + content, err := os.ReadFile(gitDirPath) + if err != nil { + return "", false + } + gitDir, ok := strings.CutPrefix(strings.TrimSpace(string(content)), "gitdir: ") + if !ok { + return "", false + } + // A relative name is relative to the repo. Git writes one for a submodule, + // and for a worktree created with --relative-paths. + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(repoPath, gitDir) + } + return gitDir, true +} diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index 00cfbbd0a..b31185659 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -70,37 +70,6 @@ type headInfo struct { hash string } -// gitDirOfRepo returns the directory that holds the git data of the repo at -// repoPath, and whether it could be found. An ordinary repo keeps that data in -// a .git directory; a worktree and a submodule have a .git file naming the -// directory instead. -func gitDirOfRepo(repoPath string) (string, bool) { - gitDirPath := filepath.Join(repoPath, ".git") - - stat, err := os.Stat(gitDirPath) - if err != nil { - return "", false - } - if stat.IsDir() { - return gitDirPath, true - } - - content, err := os.ReadFile(gitDirPath) - if err != nil { - return "", false - } - gitDir, ok := strings.CutPrefix(strings.TrimSpace(string(content)), "gitdir: ") - if !ok { - return "", false - } - // A relative name is relative to the repo. Git writes one for a submodule, - // and for a worktree created with --relative-paths. - if !filepath.IsAbs(gitDir) { - gitDir = filepath.Join(repoPath, gitDir) - } - return gitDir, true -} - // The branch git names in the HEAD file of a repo that keeps its refs in a // reftable. The refs live in a binary table there, and the name in HEAD // resolves nowhere, so that a reader of the file gets an error instead of a @@ -110,7 +79,7 @@ const reftablePlaceholderBranch = ".invalid" // readHeadInfo reads the HEAD file of the repo at repoPath to find out what it // has checked out, and reports whether that worked. func readHeadInfo(repoPath string) (headInfo, bool) { - gitDir, ok := gitDirOfRepo(repoPath) + gitDir, ok := git_commands.GitDirOfRepo(repoPath) if !ok { return headInfo{}, false } From 0380dd48512a49b7bd0ec436c163e445f1aac38e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 12 Oct 2026 18:24:59 +0200 Subject: [PATCH 05/14] 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(), ) }, }) From 875641557b3593ade7035b3fadd6f655c6b7700d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 12 Oct 2026 18:27:16 +0200 Subject: [PATCH 06/14] Move the menu for removing a worktree to BranchesHelper The files panel is going to show it too. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../controllers/helpers/branches_helper.go | 60 +++++++++++++++++++ pkg/gui/controllers/worktrees_controller.go | 58 +----------------- 2 files changed, 61 insertions(+), 57 deletions(-) diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 5f26c8099..849226e03 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -231,6 +231,66 @@ func (self *BranchesHelper) promptWorktreeBranchDelete( }) } +// RemoveWorktreeMenu shows the menu for removing a worktree, optionally along +// with the branch that it has checked out. +func (self *BranchesHelper) RemoveWorktreeMenu(worktree *models.Worktree) error { + if worktree.IsMain { + return errors.New(self.c.Tr.CantDeleteMainWorktree) + } + + if worktree.IsCurrent { + return errors.New(self.c.Tr.CantDeleteCurrentWorktree) + } + + removeWorktreeItem := &types.MenuItem{ + Label: self.c.Tr.RemoveWorktree, + Keys: menuKey('w'), + OnPress: func() error { + return self.worktreeHelper.Remove(worktree, nil) + }, + } + + branch, branchFound := lo.Find(self.c.Model().Branches, func(branch *models.Branch) bool { + return branch.Name == worktree.Branch + }) + // A worktree with a detached HEAD has no branch to delete + detachedReason := &types.DisabledReason{Text: self.c.Tr.WorktreeNotCheckedOutOnBranch} + + removeWorktreeAndBranchItem := &types.MenuItem{ + Label: self.c.Tr.RemoveWorktreeAndDeleteBranch, + Keys: menuKey('b'), + OnPress: func() error { + return self.RemoveWorktreeAndDeleteBranch(worktree, branch) + }, + } + if !branchFound { + removeWorktreeAndBranchItem.DisabledReason = detachedReason + } + + removeWorktreeAndBothBranchesItem := &types.MenuItem{ + Label: self.c.Tr.RemoveWorktreeAndDeleteBothBranches, + Keys: menuKey('r'), + OnPress: func() error { + return self.RemoveWorktreeAndDeleteBothBranches(worktree, branch) + }, + } + if !branchFound { + removeWorktreeAndBothBranchesItem.DisabledReason = detachedReason + } else if !branch.IsTrackingRemote() || branch.UpstreamGone { + removeWorktreeAndBothBranchesItem.DisabledReason = &types.DisabledReason{ + Text: self.c.Tr.UpstreamNotSetError, + } + } + + return self.c.Menu(types.CreateMenuOptions{ + Title: utils.ResolvePlaceholderString( + self.c.Tr.RemoveWorktreeMenuTitle, + map[string]string{"worktreeName": worktree.Name}, + ), + Items: []*types.MenuItem{removeWorktreeItem, removeWorktreeAndBranchItem, removeWorktreeAndBothBranchesItem}, + }) +} + // RemoveWorktreeAndDeleteBranch removes the worktree and deletes the local branch // it has checked out, force-warning first if the branch isn't fully merged. It's // the worktrees-panel counterpart to deleting a worktree-checked-out branch from diff --git a/pkg/gui/controllers/worktrees_controller.go b/pkg/gui/controllers/worktrees_controller.go index 02d20b3f2..886cf9efd 100644 --- a/pkg/gui/controllers/worktrees_controller.go +++ b/pkg/gui/controllers/worktrees_controller.go @@ -1,7 +1,6 @@ package controllers import ( - "errors" "fmt" "strings" "text/tabwriter" @@ -11,7 +10,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" - "github.com/samber/lo" ) type WorktreesController struct { @@ -123,61 +121,7 @@ func (self *WorktreesController) add() error { } func (self *WorktreesController) remove(worktree *models.Worktree) error { - if worktree.IsMain { - return errors.New(self.c.Tr.CantDeleteMainWorktree) - } - - if worktree.IsCurrent { - return errors.New(self.c.Tr.CantDeleteCurrentWorktree) - } - - removeWorktreeItem := &types.MenuItem{ - Label: self.c.Tr.RemoveWorktree, - Keys: menuKey('w'), - OnPress: func() error { - return self.c.Helpers().Worktree.Remove(worktree, nil) - }, - } - - branch, branchFound := lo.Find(self.c.Model().Branches, func(branch *models.Branch) bool { - return branch.Name == worktree.Branch - }) - // A worktree with a detached HEAD has no branch to delete - detachedReason := &types.DisabledReason{Text: self.c.Tr.WorktreeNotCheckedOutOnBranch} - - removeWorktreeAndBranchItem := &types.MenuItem{ - Label: self.c.Tr.RemoveWorktreeAndDeleteBranch, - Keys: menuKey('b'), - OnPress: func() error { - return self.c.Helpers().BranchesHelper.RemoveWorktreeAndDeleteBranch(worktree, branch) - }, - } - if !branchFound { - removeWorktreeAndBranchItem.DisabledReason = detachedReason - } - - removeWorktreeAndBothBranchesItem := &types.MenuItem{ - Label: self.c.Tr.RemoveWorktreeAndDeleteBothBranches, - Keys: menuKey('r'), - OnPress: func() error { - return self.c.Helpers().BranchesHelper.RemoveWorktreeAndDeleteBothBranches(worktree, branch) - }, - } - if !branchFound { - removeWorktreeAndBothBranchesItem.DisabledReason = detachedReason - } else if !branch.IsTrackingRemote() || branch.UpstreamGone { - removeWorktreeAndBothBranchesItem.DisabledReason = &types.DisabledReason{ - Text: self.c.Tr.UpstreamNotSetError, - } - } - - return self.c.Menu(types.CreateMenuOptions{ - Title: utils.ResolvePlaceholderString( - self.c.Tr.RemoveWorktreeMenuTitle, - map[string]string{"worktreeName": worktree.Name}, - ), - Items: []*types.MenuItem{removeWorktreeItem, removeWorktreeAndBranchItem, removeWorktreeAndBothBranchesItem}, - }) + return self.c.Helpers().BranchesHelper.RemoveWorktreeMenu(worktree) } func (self *WorktreesController) GetOnDoubleClick() func() error { From 3ffcc0c7b06ee3ce34ee4171bf2a2893b815ffa1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 12 Oct 2026 18:27:41 +0200 Subject: [PATCH 07/14] Extract worktreeOfFile from markWorktreeFiles The files panel is going to need to find the worktree of an entry too. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 0cbde1f70..4b3d1c429 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1476,10 +1476,7 @@ func markWorktreeFiles(files []*models.File, worktrees []*models.Worktree, workt changed := false for _, file := range files { - absPath := filepath.Join(worktreePath, file.Path) - isWorktree := lo.SomeBy(worktrees, func(worktree *models.Worktree) bool { - return worktree.Path == absPath - }) + _, isWorktree := worktreeOfFile(file, worktrees, worktreePath) if isWorktree != file.IsWorktree { file.IsWorktree = isWorktree @@ -1490,6 +1487,15 @@ func markWorktreeFiles(files []*models.File, worktrees []*models.Worktree, workt return changed } +// worktreeOfFile returns the worktree that an entry of the files panel is, if +// it is one. worktreePath is the path of the worktree that we're in. +func worktreeOfFile(file *models.File, worktrees []*models.Worktree, worktreePath string) (*models.Worktree, bool) { + absPath := filepath.Join(worktreePath, file.Path) + return lo.Find(worktrees, func(worktree *models.Worktree) bool { + return worktree.Path == absPath + }) +} + // the reflogs panel is the only panel where we cache data, in that we only // load entries that have been created since we last ran the call. This means // we need to be more careful with how we use this, and to ensure we're emptying From e2a29a6d77ae047742410b8aa11e03622c052911 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 12 Oct 2026 18:30:53 +0200 Subject: [PATCH 08/14] Remove a worktree inside the repo properly when discarding it If a linked worktree lives inside the repo's working tree, it shows up in the files panel. Discarding it there deleted its directory like that of any untracked file. git still had the worktree on record, so the worktrees panel listed it as missing, and its branch couldn't be checked out anywhere else until the worktree was pruned. Show the same menu for removing the worktree as the worktrees panel does. Like a submodule, a worktree can't be discarded together with other entries, because it has a menu of its own. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/controllers/files_controller.go | 28 +++++++++++++-- .../controllers/helpers/branches_helper.go | 4 +-- .../helpers/working_tree_helper.go | 6 ++++ pkg/i18n/english.go | 2 ++ pkg/integration/tests/test_list.go | 1 + .../worktree/discard_range_with_worktree.go | 35 +++++++++++++++++++ .../tests/worktree/worktree_in_repo.go | 8 ++--- 7 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 pkg/integration/tests/worktree/discard_range_with_worktree.go diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index ce53ef65b..64ebb8d15 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1691,24 +1691,41 @@ func findSubmoduleNode(nodes []*filetree.FileNode, submodules []*models.Submodul return nil } +func findWorktreeNode(nodes []*filetree.FileNode) *models.File { + for _, node := range nodes { + if worktreeFile := node.FindFirstFileBy(func(f *models.File) bool { return f.IsWorktree }); worktreeFile != nil { + return worktreeFile + } + } + return nil +} + func (self *FilesController) canRemove(selectedNodes []*filetree.FileNode) *types.DisabledReason { - // Return disabled if the selection contains multiple changed items and includes a submodule change. + // Return disabled if the selection contains multiple changed items and + // includes a submodule change or a worktree. These have menus of their own. submodules := self.c.Model().Submodules hasFiles := false uniqueSelectedSubmodules := set.New[*models.SubmoduleConfig]() + uniqueSelectedWorktreePaths := set.New[string]() for _, node := range selectedNodes { _ = node.ForEachFile(func(f *models.File) error { if submodule := f.SubmoduleConfig(submodules); submodule != nil { uniqueSelectedSubmodules.Add(submodule) + } else if f.IsWorktree { + uniqueSelectedWorktreePaths.Add(f.Path) } else { hasFiles = true } return nil }) - if uniqueSelectedSubmodules.Len() > 0 && (hasFiles || uniqueSelectedSubmodules.Len() > 1) { + if uniqueSelectedSubmodules.Len() > 0 && + (hasFiles || uniqueSelectedWorktreePaths.Len() > 0 || uniqueSelectedSubmodules.Len() > 1) { return &types.DisabledReason{Text: self.c.Tr.MultiSelectNotSupportedForSubmodules} } + if uniqueSelectedWorktreePaths.Len() > 0 && (hasFiles || uniqueSelectedWorktreePaths.Len() > 1) { + return &types.DisabledReason{Text: self.c.Tr.MultiSelectNotSupportedForWorktrees} + } } return nil @@ -1737,6 +1754,13 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error { return self.c.Menu(types.CreateMenuOptions{Title: submoduleNode.GetPath(), Items: menuItems}) } + // Likewise, if we have a worktree, it is the only thing selected + if worktreeFile := findWorktreeNode(selectedNodes); worktreeFile != nil { + if worktree, ok := self.c.Helpers().WorkingTree.WorktreeForFile(worktreeFile); ok { + return self.c.Helpers().BranchesHelper.RemoveWorktreeMenu(worktree) + } + } + discardAllChangesItem := types.MenuItem{ Label: self.c.Tr.DiscardAllChanges, OnPress: func() error { diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 849226e03..8117f4bae 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -293,8 +293,8 @@ func (self *BranchesHelper) RemoveWorktreeMenu(worktree *models.Worktree) error // RemoveWorktreeAndDeleteBranch removes the worktree and deletes the local branch // it has checked out, force-warning first if the branch isn't fully merged. It's -// the worktrees-panel counterpart to deleting a worktree-checked-out branch from -// the branches panel. +// the remove-worktree menu's counterpart to deleting a worktree-checked-out +// branch from the branches panel. func (self *BranchesHelper) RemoveWorktreeAndDeleteBranch( worktree *models.Worktree, branch *models.Branch, ) error { diff --git a/pkg/gui/controllers/helpers/working_tree_helper.go b/pkg/gui/controllers/helpers/working_tree_helper.go index 5f68cb822..1cdc99606 100644 --- a/pkg/gui/controllers/helpers/working_tree_helper.go +++ b/pkg/gui/controllers/helpers/working_tree_helper.go @@ -113,6 +113,12 @@ func (self *WorkingTreeHelper) FileForSubmodule(submodule *models.SubmoduleConfi return nil } +// WorktreeForFile returns the worktree that an entry of the files panel is, if +// it is one +func (self *WorkingTreeHelper) WorktreeForFile(file *models.File) (*models.Worktree, bool) { + return worktreeOfFile(file, self.c.Model().Worktrees, self.c.Git().RepoPaths.WorktreePath()) +} + func (self *WorkingTreeHelper) OpenMergeTool() error { self.c.LogAction(self.c.Tr.Actions.OpenMergeTool) return self.c.RunSubprocessAndRefresh( diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index e961638d3..81dd2c335 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -1003,6 +1003,7 @@ type TranslationSet struct { SelectedItemIsNotABranch string SelectedItemDoesNotHaveFiles string MultiSelectNotSupportedForSubmodules string + MultiSelectNotSupportedForWorktrees string NothingToStageForSubmodule string CommandDoesNotSupportOpeningInEditor string CustomCommands string @@ -2199,6 +2200,7 @@ func EnglishTranslationSet() *TranslationSet { SelectedItemIsNotABranch: "Selected item is not a branch", SelectedItemDoesNotHaveFiles: "Selected item does not have files to view", MultiSelectNotSupportedForSubmodules: "Multiselection not supported for submodules", + MultiSelectNotSupportedForWorktrees: "Multiselection not supported for worktrees", NothingToStageForSubmodule: "Nothing to stage: the parent repo can only stage a new submodule commit, not the uncommitted changes inside a submodule. Commit inside the submodule first.", CommandDoesNotSupportOpeningInEditor: "This command doesn't support switching to the editor", CustomCommands: "Custom commands", diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 9461fc2d3..8e8e27aec 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -696,6 +696,7 @@ var tests = []*components.IntegrationTest{ worktree.CustomCommand, worktree.DefaultPathTilde, worktree.DetachWorktreeFromBranch, + worktree.DiscardRangeWithWorktree, worktree.DotfileBareRepo, worktree.DoubleNestedLinkedSubmodule, worktree.EditFileOfBranchInOtherWorktree, diff --git a/pkg/integration/tests/worktree/discard_range_with_worktree.go b/pkg/integration/tests/worktree/discard_range_with_worktree.go new file mode 100644 index 000000000..064918479 --- /dev/null +++ b/pkg/integration/tests/worktree/discard_range_with_worktree.go @@ -0,0 +1,35 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DiscardRangeWithWorktree = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Discarding a range of files that includes a worktree inside the repo is not supported, because a worktree has its own menu for removing it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.ShowFileTree = false + }, + SetupRepo: func(shell *Shell) { + shell.NewBranch("mybranch") + shell.CreateFileAndAdd("README.md", "hello world") + shell.Commit("initial commit") + shell.AddWorktree("mybranch", "nested-worktree", "newbranch") + shell.CreateFile("untracked-file", "content") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("?? nested-worktree (worktree)").IsSelected(), + Equals("?? untracked-file"), + ). + Press(keys.Universal.RangeSelectDown). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectToast(Equals("Disabled: Multiselection not supported for worktrees")) + }) + }, +}) diff --git a/pkg/integration/tests/worktree/worktree_in_repo.go b/pkg/integration/tests/worktree/worktree_in_repo.go index f6b5b44a3..d05fc9136 100644 --- a/pkg/integration/tests/worktree/worktree_in_repo.go +++ b/pkg/integration/tests/worktree/worktree_in_repo.go @@ -64,18 +64,18 @@ var WorktreeInRepo = NewIntegrationTest(NewIntegrationTestArgs{ Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Menu(). - Title(Equals("Discard changes")). - Select(Contains("Discard all changes")). + Title(Equals("Remove worktree 'linked-worktree'?")). + Select(MatchesRegexp("Remove worktree$")). Confirm() }). IsEmpty() - // confirm worktree appears as missing + t.FileSystem().PathNotPresent("linked-worktree") + t.Views().Worktrees(). Focus(). Lines( Contains("(main worktree)").IsSelected(), - Contains("linked-worktree (missing)"), ) }, }) From d8d11434a4ff19520d872d5acb399ae46ce79677 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 12 Oct 2026 18:31:11 +0200 Subject: [PATCH 09/14] Extract wrapForReadability from conflictResolutionHint More messages for the main view are going to need it. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/controllers/files_controller.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 64ebb8d15..9b2b91f59 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -749,11 +749,18 @@ func (self *FilesController) enterFile(clickedViewLineIdx int) error { func (self *FilesController) conflictResolutionHint(description string) string { message := description + "\n\n" + fmt.Sprintf(self.c.Tr.MergeConflictPressEnterToResolve, self.c.UserConfig().Keybinding.Universal.GoInto) - if self.c.Views().Main.InnerWidth() > 70 { - lines, _, _ := utils.WrapViewLinesToWidth(true, false, message, 70, 4) - message = strings.Join(lines, "\n") + return wrapForReadability(message, self.c.Views().Main.InnerWidth()) +} + +// wrapForReadability wraps a message for the main view when the view is wide +// enough that long lines would otherwise hurt readability. +func wrapForReadability(message string, viewWidth int) string { + const readableWidth = 70 + if viewWidth <= readableWidth { + return message } - return message + lines, _, _ := utils.WrapViewLinesToWidth(true, false, message, readableWidth, 4) + return strings.Join(lines, "\n") } // conflictNeedsResolutionDialog reports whether a file's merge conflict can only From 35909f65d83bbea5551e95c475ebd82ce1587af6 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 12 Oct 2026 18:34:33 +0200 Subject: [PATCH 10/14] Explain the directories that git status reports as a whole Selecting one of these in the files panel showed the error "Could not access 'modules/sub/null'". The main view showed the entry's diff against /dev/null with --no-index, as for an untracked file. When git compares a file with a directory that way, it looks for a file of the same name inside the directory. Show what the directory is and what to do about it instead. Below that, show the status of the repository that it is, or the files in it if it is a plain directory. The status tells you whether discarding the directory would lose anything. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/commands/git_commands/working_tree.go | 31 +++++++++ pkg/gui/controllers/files_controller.go | 58 ++++++++++++++++ .../helpers/working_tree_helper.go | 12 ++++ pkg/i18n/english.go | 16 +++++ .../tests/file/explain_nested_repositories.go | 69 +++++++++++++++++++ .../tests/file/explain_untracked_directory.go | 34 +++++++++ pkg/integration/tests/test_list.go | 2 + 7 files changed, 222 insertions(+) create mode 100644 pkg/integration/tests/file/explain_nested_repositories.go create mode 100644 pkg/integration/tests/file/explain_untracked_directory.go diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index 8ebff5910..09c86a882 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -412,6 +412,37 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, mode return self.cmd.New(cmdArgs).DontLog() } +// NestedRepositoryStatusCmdObj returns the command that shows the status of a +// git repository inside the working tree, for the main view +func (self *WorkingTreeCommands) NestedRepositoryStatusCmdObj(path string) *oscommands.CmdObj { + cmdArgs := NewGitCmd("status"). + Config("color.status=always"). + Config("advice.statusHints=false"). + Dir(path). + ToArgv() + + return ForOtherRepo(self.cmd.New(cmdArgs)).DontLog() +} + +// NestedRepositoryHasOwnGitDir reports whether the git repository at the given +// path of the working tree keeps its git dir inside its directory, so that +// discarding the directory discards the repository's history too. A submodule +// or a worktree keeps it elsewhere. +func (self *WorkingTreeCommands) NestedRepositoryHasOwnGitDir(path string) bool { + info, err := os.Stat(filepath.Join(self.repoPaths.worktreePath, path, ".git")) + return err == nil && info.IsDir() +} + +// UntrackedFilesCmdObj returns the command that lists the untracked files in +// the given directory, for the main view +func (self *WorkingTreeCommands) UntrackedFilesCmdObj(dirPath string) *oscommands.CmdObj { + cmdArgs := NewGitCmd("ls-files"). + Arg("--others", "--exclude-standard", "--", dirPath). + ToArgv() + + return self.cmd.New(cmdArgs).DontLog() +} + // ShowFileDiff get the diff of specified from and to. Typically this will be used for a single commit so it'll be 123abc^..123abc // but when we're in diff mode it could be any 'from' to any 'to'. The reverse flag is also here thanks to diff mode. // For a renamed file, previousPath is the path it was renamed from (empty otherwise); diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 9b2b91f59..e4c42279b 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -9,6 +9,7 @@ import ( "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/filetree" @@ -275,11 +276,68 @@ func (self *FilesController) GetOnRenderToMain() func() { return } + if node.File != nil && node.File.DirectoryKind != models.NotADirectory { + self.renderDirectoryEntry(node.File) + return + } + self.renderWorkingTreeDiff(node) }) } } +// renderDirectoryEntry renders an entry that git status reports as a whole +// directory. It explains what the directory is and what to do about it, and +// below a divider shows the status of the repository that it is, or the files +// in it if it isn't one. +func (self *FilesController) renderDirectoryEntry(file *models.File) { + self.c.Helpers().MergeConflicts.ResetMergeState() + + placeholders := map[string]string{"path": file.Path} + var description string + var heading string + var cmdObj *oscommands.CmdObj + if file.DirectoryKind == models.PlainDirectory { + description = utils.ResolvePlaceholderString(self.c.Tr.PlainDirectoryDescription, placeholders) + heading = utils.ResolvePlaceholderString(self.c.Tr.PlainDirectoryFiles, placeholders) + cmdObj = self.c.Git().WorkingTree.UntrackedFilesCmdObj(file.Path) + } else { + description = self.nestedRepositoryDescription(file) + heading = utils.ResolvePlaceholderString(self.c.Tr.NestedRepositoryStatus, placeholders) + cmdObj = self.c.Git().WorkingTree.NestedRepositoryStatusCmdObj(file.Path) + } + + prefix := types.PrefixForWidth(func(width int) string { + return wrapForReadability(description, width) + "\n\n" + titledRule(heading, width) + "\n\n" + }) + self.renderToMainWithTask(types.NewRunCommandTaskWithPrefix(cmdObj.GetCmd(), prefix)) +} + +// titledRule returns a horizontal line across the given width with a title in +// it, for dividing a view's content into sections +func titledRule(title string, width int) string { + start := "── " + title + " " + return start + strings.Repeat("─", max(width-utils.StringWidth(start), 0)) +} + +func (self *FilesController) nestedRepositoryDescription(file *models.File) string { + placeholders := self.c.Helpers().WorkingTree.NestedRepositoryPlaceholders(file) + + switch { + case file.IsWorktree: + return utils.ResolvePlaceholderString(self.c.Tr.WorktreeInsideRepoDescription, placeholders) + case file.DirectoryKind == models.RemovedSubmodule: + return utils.ResolvePlaceholderString(self.c.Tr.RemovedSubmoduleDescription, placeholders) + default: + discardHint := self.c.Tr.NestedRepositoryDiscardHint + if self.c.Git().WorkingTree.NestedRepositoryHasOwnGitDir(file.Path) { + discardHint = self.c.Tr.NestedRepositoryDiscardWarning + } + return utils.ResolvePlaceholderString( + self.c.Tr.NestedRepositoryDescription+"\n\n"+discardHint, placeholders) + } +} + // renderToMainWithTask renders the given task to the main view with the standard // diff title and subtitle. func (self *FilesController) renderToMainWithTask(task types.UpdateTask) { diff --git a/pkg/gui/controllers/helpers/working_tree_helper.go b/pkg/gui/controllers/helpers/working_tree_helper.go index 1cdc99606..32582d675 100644 --- a/pkg/gui/controllers/helpers/working_tree_helper.go +++ b/pkg/gui/controllers/helpers/working_tree_helper.go @@ -119,6 +119,18 @@ func (self *WorkingTreeHelper) WorktreeForFile(file *models.File) (*models.Workt return worktreeOfFile(file, self.c.Model().Worktrees, self.c.Git().RepoPaths.WorktreePath()) } +// NestedRepositoryPlaceholders returns the placeholders for the texts that +// tell you what to do about a nested repository +func (self *WorkingTreeHelper) NestedRepositoryPlaceholders(file *models.File) map[string]string { + keybindings := self.c.UserConfig().Keybinding + return map[string]string{ + "path": file.Path, + "discardKey": keybindings.Universal.Remove.String(), + "removeKey": keybindings.Universal.Remove.String(), + "ignoreKey": keybindings.Files.IgnoreFile.String(), + } +} + func (self *WorkingTreeHelper) OpenMergeTool() error { self.c.LogAction(self.c.Tr.Actions.OpenMergeTool) return self.c.RunSubprocessAndRefresh( diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 81dd2c335..da7774b80 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -107,6 +107,14 @@ type TranslationSet struct { RemovedSubmoduleLabel string NestedRepositoryLabel string WorktreeLabel string + RemovedSubmoduleDescription string + NestedRepositoryDescription string + NestedRepositoryDiscardHint string + NestedRepositoryDiscardWarning string + WorktreeInsideRepoDescription string + NestedRepositoryStatus string + PlainDirectoryDescription string + PlainDirectoryFiles string StageConflictsRangeDisabled string Checkout string CheckoutTooltip string @@ -1277,6 +1285,14 @@ func EnglishTranslationSet() *TranslationSet { RemovedSubmoduleLabel: "removed submodule", NestedRepositoryLabel: "nested repository", WorktreeLabel: "worktree", + RemovedSubmoduleDescription: "'{{.path}}' is left over from a submodule that this repository no longer has. A commit that you pulled or checked out probably removed it. Git leaves the files of a removed submodule behind, so they show up as an untracked directory.\n\nIf you don't need them any more, press {{.discardKey}} to discard the directory. This also discards any uncommitted changes in it; see the status below. The submodule's commits stay in its git directory, so checking out an older commit that still has the submodule keeps working.", + NestedRepositoryDescription: "'{{.path}}' is a git repository of its own inside this one. Git doesn't look into it, so it shows up as a single untracked entry.\n\nIf this repository is meant to track it, add it as a submodule. Otherwise, you can ignore it with {{.ignoreKey}} so that it no longer shows up here.", + NestedRepositoryDiscardHint: "Pressing {{.discardKey}} discards the directory, including any uncommitted changes in it; see the status below.", + NestedRepositoryDiscardWarning: "Pressing {{.discardKey}} discards the directory, including the repository with all of its history.", + WorktreeInsideRepoDescription: "'{{.path}}' is a linked worktree of this repository. Git doesn't look into it, so it shows up as a single untracked entry.\n\nTo remove the worktree, press {{.removeKey}}. If you keep worktrees inside the repository, add their directory to .gitignore so that they don't show up here.", + NestedRepositoryStatus: "Status of '{{.path}}'", + PlainDirectoryDescription: "'{{.path}}' is an untracked directory. Git shows it as a single entry because status.showUntrackedFiles is set to \"normal\".", + PlainDirectoryFiles: "Files in '{{.path}}'", 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/file/explain_nested_repositories.go b/pkg/integration/tests/file/explain_nested_repositories.go new file mode 100644 index 000000000..b837401e9 --- /dev/null +++ b/pkg/integration/tests/file/explain_nested_repositories.go @@ -0,0 +1,69 @@ +package file + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ExplainNestedRepositories = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "The main view explains what a removed submodule, a nested repository and a worktree inside the repo are, and shows their status", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.ShowFileTree = false + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("first commit") + shell.CloneIntoSubmodule("sub", "modules/sub") + shell.Commit("add submodule") + shell.RunCommand([]string{"git", "rm", "--cached", "modules/sub"}) + shell.RunCommand([]string{"git", "rm", ".gitmodules"}) + shell.Commit("remove submodule") + shell.CreateFile("modules/sub/new-file", "content") + + shell.RunCommand([]string{"git", "init", "nested"}) + + shell.AddWorktree("master", "worktree", "newbranch") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("?? modules/sub (removed submodule)").IsSelected(), + Equals("?? nested (nested repository)"), + Equals("?? worktree (worktree)"), + ) + + t.Views().Main(). + Content( + Contains("'modules/sub' is left over from a submodule"). + Contains("press d to discard the directory"). + Contains("── Status of 'modules/sub' ─"). + Contains("On branch master"). + Contains("new-file"), + ) + + t.Views().Files(). + NavigateToLine(Contains("nested")) + + t.Views().Main(). + Content( + Contains("'nested' is a git repository of its own"). + Contains("ignore it with i"). + Contains("of its history."). + Contains("── Status of 'nested' ─"). + Contains("No commits yet"), + ) + + t.Views().Files(). + NavigateToLine(Contains("worktree")) + + t.Views().Main(). + Content( + Contains("'worktree' is a linked worktree"). + Contains("press d."). + Contains("── Status of 'worktree' ─"). + Contains("On branch newbranch"), + ) + }, +}) diff --git a/pkg/integration/tests/file/explain_untracked_directory.go b/pkg/integration/tests/file/explain_untracked_directory.go new file mode 100644 index 000000000..f9d1f5acf --- /dev/null +++ b/pkg/integration/tests/file/explain_untracked_directory.go @@ -0,0 +1,34 @@ +package file + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ExplainUntrackedDirectory = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "With status.showUntrackedFiles set to normal, the main view explains why an untracked directory is a single entry, and lists the files in it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("first commit") + shell.SetConfig("status.showUntrackedFiles", "normal") + shell.CreateFile("dir/file1", "content") + shell.CreateFile("dir/subdir/file2", "content") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("?? dir/").IsSelected(), + ) + + t.Views().Main(). + Content( + Contains("'dir' is an untracked directory"). + Contains("── Files in 'dir' ─"). + Contains("dir/file1"). + Contains("dir/subdir/file2"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 8e8e27aec..003ec0f03 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -253,6 +253,8 @@ var tests = []*components.IntegrationTest{ file.DiscardVariousChanges, file.DiscardVariousChangesRangeSelect, file.ExcludeWithoutInfoDir, + file.ExplainNestedRepositories, + file.ExplainUntrackedDirectory, file.Gitignore, file.GitignoreSpecialCharacters, file.PaneShownAgainStartsAtTheTop, From d2675b7deaf568814a2e832cbd5fe509db92162b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 12 Oct 2026 18:36:03 +0200 Subject: [PATCH 11/14] Extract stageAllScope from toggleStagedAllWithLock Whether stage-all may be used is going to depend on what it stages. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/controllers/files_controller.go | 32 +++++++++++++++++++++---- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index e4c42279b..89e2f1166 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -993,22 +993,44 @@ func (self *FilesController) toggleStagedAll() error { return nil } +// What staging all files stages +type stageAllScope int + +const ( + // Only the files that are visible, because a text filter is active + stageAllVisibleFiles stageAllScope = iota + // Only tracked files, because only those are shown + stageAllTrackedFiles + stageAllFiles +) + +func (self *FilesController) stageAllScope() stageAllScope { + if self.context().IsFiltering() { + return stageAllVisibleFiles + } + if self.context().GetStatusFilter() == filetree.DisplayTracked { + return stageAllTrackedFiles + } + return stageAllFiles +} + func (self *FilesController) toggleStagedAllWithLock() error { root := self.context().FileTreeViewModel.GetRoot() stage := func(unstagedNodes []*filetree.FileNode) error { - if self.context().IsFiltering() { - // When filtering, only stage visible files + switch self.stageAllScope() { + case stageAllVisibleFiles: var paths []string _ = root.ForEachFile(func(file *models.File) error { paths = append(paths, file.Path) return nil }) return self.c.Git().WorkingTree.StageFiles(paths, nil) + case stageAllTrackedFiles: + return self.c.Git().WorkingTree.StageAll(true) + default: + return self.c.Git().WorkingTree.StageAll(false) } - - onlyTrackedFiles := self.context().GetStatusFilter() == filetree.DisplayTracked - return self.c.Git().WorkingTree.StageAll(onlyTrackedFiles) } unstage := func(nodes []*filetree.FileNode) error { From a2cf915f8d166b3f170e721fefb5a6de2ea51624 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 12 Oct 2026 18:38:28 +0200 Subject: [PATCH 12/14] Don't stage nested repositories If you staged the directory of a removed submodule, a nested repository or a worktree inside the repo, git added it as an embedded repository: a gitlink without an entry in .gitmodules. For a removed submodule, this brings it back in a broken state. git warns about it, but lazygit didn't show the warning. Staging all files did the same to every such directory. Disable staging a selection that contains one of these, and disable staging all files if that would stage one. The disabled reason tells you how to get rid of it. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/commands/models/file.go | 7 +++ pkg/gui/controllers/files_controller.go | 32 +++++++++++- .../helpers/working_tree_helper.go | 16 ++++++ pkg/i18n/english.go | 4 ++ .../tests/file/stage_nested_repository.go | 51 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 6 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 pkg/integration/tests/file/stage_nested_repository.go diff --git a/pkg/commands/models/file.go b/pkg/commands/models/file.go index 5c7b79cea..1fe75f395 100644 --- a/pkg/commands/models/file.go +++ b/pkg/commands/models/file.go @@ -53,6 +53,13 @@ const ( RemovedSubmodule ) +// IsNestedRepository reports whether the file is a git repository of its own +// inside the working tree, such as a linked worktree or the directory of a +// removed submodule +func (f *File) IsNestedRepository() bool { + return f.DirectoryKind == NestedRepository || f.DirectoryKind == 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/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 89e2f1166..3d2bd686c 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -137,7 +137,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types { Keys: opts.GetKeys(opts.Config.Files.ToggleStagedAll), Handler: self.toggleStagedAll, - GetDisabledReason: self.require(self.anyFilesDisplayed), + GetDisabledReason: self.require(self.anyFilesDisplayed, self.canStageAll), Description: self.c.Tr.ToggleStagedAll, Tooltip: self.c.Tr.ToggleStagedAllTooltip, }, @@ -842,7 +842,8 @@ func (self *FilesController) conflictNeedsResolutionDialog(file *models.File) bo // canStageSelection disables staging when a multiple selection includes a file // with a conflict that must be resolved through a dialog; those have to be -// resolved one at a time. +// resolved one at a time. It also disables it when the selection includes a +// nested repository. func (self *FilesController) canStageSelection(nodes []*filetree.FileNode) *types.DisabledReason { if len(nodes) > 1 { for _, node := range nodes { @@ -858,6 +859,33 @@ func (self *FilesController) canStageSelection(nodes []*filetree.FileNode) *type } } + for _, node := range nodes { + if nestedRepository := node.FindFirstFileBy((*models.File).IsNestedRepository); nestedRepository != nil { + return self.c.Helpers().WorkingTree.CantStageNestedRepository(nestedRepository) + } + } + + return nil +} + +// canStageAll disables staging all files when that would stage a nested +// repository. +func (self *FilesController) canStageAll() *types.DisabledReason { + var nestedRepository *models.File + switch self.stageAllScope() { + case stageAllVisibleFiles: + nestedRepository = self.context().FileTreeViewModel.GetRoot().FindFirstFileBy((*models.File).IsNestedRepository) + case stageAllTrackedFiles: + // A nested repository is never tracked + return nil + default: + nestedRepository, _ = lo.Find(self.c.Model().Files, (*models.File).IsNestedRepository) + } + + if nestedRepository != nil { + return self.c.Helpers().WorkingTree.CantStageNestedRepository(nestedRepository) + } + return nil } diff --git a/pkg/gui/controllers/helpers/working_tree_helper.go b/pkg/gui/controllers/helpers/working_tree_helper.go index 32582d675..a4b8c6cae 100644 --- a/pkg/gui/controllers/helpers/working_tree_helper.go +++ b/pkg/gui/controllers/helpers/working_tree_helper.go @@ -13,6 +13,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) @@ -131,6 +132,21 @@ func (self *WorkingTreeHelper) NestedRepositoryPlaceholders(file *models.File) m } } +// CantStageNestedRepository returns why the given nested repository can't be +// staged. git add records a nested repository as an embedded repository: a +// gitlink without an entry in .gitmodules. Nobody wants that, so we don't let +// you stage one, and say how to get rid of it instead. +func (self *WorkingTreeHelper) CantStageNestedRepository(file *models.File) *types.DisabledReason { + text := self.c.Tr.StageNestedRepositoryDisabled + if file.IsWorktree { + text = self.c.Tr.StageWorktreeDisabled + } + + return &types.DisabledReason{ + Text: utils.ResolvePlaceholderString(text, self.NestedRepositoryPlaceholders(file)), + } +} + func (self *WorkingTreeHelper) OpenMergeTool() error { self.c.LogAction(self.c.Tr.Actions.OpenMergeTool) return self.c.RunSubprocessAndRefresh( diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index da7774b80..4f3110624 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -115,6 +115,8 @@ type TranslationSet struct { NestedRepositoryStatus string PlainDirectoryDescription string PlainDirectoryFiles string + StageNestedRepositoryDisabled string + StageWorktreeDisabled string StageConflictsRangeDisabled string Checkout string CheckoutTooltip string @@ -1291,6 +1293,8 @@ func EnglishTranslationSet() *TranslationSet { NestedRepositoryDiscardWarning: "Pressing {{.discardKey}} discards the directory, including the repository with all of its history.", WorktreeInsideRepoDescription: "'{{.path}}' is a linked worktree of this repository. Git doesn't look into it, so it shows up as a single untracked entry.\n\nTo remove the worktree, press {{.removeKey}}. If you keep worktrees inside the repository, add their directory to .gitignore so that they don't show up here.", NestedRepositoryStatus: "Status of '{{.path}}'", + StageNestedRepositoryDisabled: "Can't stage '{{.path}}' because it is a git repository of its own. Discard it with {{.discardKey}}, or ignore it with {{.ignoreKey}}.", + StageWorktreeDisabled: "Can't stage '{{.path}}' because it is a worktree. Remove it with {{.removeKey}}, or ignore it with {{.ignoreKey}}.", PlainDirectoryDescription: "'{{.path}}' is an untracked directory. Git shows it as a single entry because status.showUntrackedFiles is set to \"normal\".", PlainDirectoryFiles: "Files in '{{.path}}'", StageConflictsRangeDisabled: "Cannot stage a selection that includes files with merge conflicts; resolve them individually with {{.goIntoKey}} first.", diff --git a/pkg/integration/tests/file/stage_nested_repository.go b/pkg/integration/tests/file/stage_nested_repository.go new file mode 100644 index 000000000..7776af656 --- /dev/null +++ b/pkg/integration/tests/file/stage_nested_repository.go @@ -0,0 +1,51 @@ +package file + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var StageNestedRepository = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Staging a nested repository, or all files while there is one, is not supported, because git would add it as an embedded repository", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.ShowFileTree = false + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("first commit") + shell.CloneIntoSubmodule("sub", "modules/sub") + shell.Commit("add submodule") + shell.RunCommand([]string{"git", "rm", "--cached", "modules/sub"}) + shell.RunCommand([]string{"git", "rm", ".gitmodules"}) + shell.Commit("remove submodule") + + shell.CreateFile("file", "content") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("?? file").IsSelected(), + Equals("?? modules/sub (removed submodule)"), + ). + Press(keys.Files.ToggleStagedAll). + Tap(func() { + t.ExpectToast(Equals("Disabled: Can't stage 'modules/sub' because it is a git repository of its own. Discard it with d, or ignore it with i.")) + }). + NavigateToLine(Contains("modules/sub")). + PressPrimaryAction(). + Tap(func() { + t.ExpectToast(Equals("Disabled: Can't stage 'modules/sub' because it is a git repository of its own. Discard it with d, or ignore it with i.")) + }). + // Staging all files works if the nested repository is filtered out + FilterOrSearch("file"). + Lines( + Equals("?? file").IsSelected(), + ). + Press(keys.Files.ToggleStagedAll). + Lines( + Equals("A file").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 003ec0f03..1975e07dc 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -266,6 +266,7 @@ var tests = []*components.IntegrationTest{ file.StageAllWithoutChangedFiles, file.StageChildrenRangeSelect, file.StageDeletedRangeSelect, + file.StageNestedRepository, file.StageRangeSelect, file.StagedChangesInLowerPane, filter_and_search.FilterByFileStatus, From 9009083f55ffe1c380d79aa9c61de9089e1bed66 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 12 Oct 2026 18:45:43 +0200 Subject: [PATCH 13/14] Don't stage nested repositories when committing with nothing staged If nothing is staged, committing offers to stage all files first, or stages them without asking if gui.skipNoStagedFilesWarning is set. Like staging all files in the files panel, this adds a nested repository as an embedded repository. Show why the nested repository can't be staged instead, as staging all files in the files panel does. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../helpers/working_tree_helper.go | 4 ++ .../commit_all_with_nested_repository.go | 52 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 3 files changed, 57 insertions(+) create mode 100644 pkg/integration/tests/commit/commit_all_with_nested_repository.go diff --git a/pkg/gui/controllers/helpers/working_tree_helper.go b/pkg/gui/controllers/helpers/working_tree_helper.go index a4b8c6cae..a11830649 100644 --- a/pkg/gui/controllers/helpers/working_tree_helper.go +++ b/pkg/gui/controllers/helpers/working_tree_helper.go @@ -266,6 +266,10 @@ func (self *WorkingTreeHelper) WithEnsureCommittableFiles(handler func() error) } if !self.AnyStagedFiles() { + if nestedRepository, ok := lo.Find(self.c.Model().Files, (*models.File).IsNestedRepository); ok { + return errors.New(self.CantStageNestedRepository(nestedRepository).Text) + } + if self.c.UserConfig().Gui.SkipNoStagedFilesWarning { self.c.LogAction(self.c.Tr.Actions.StageAllFiles) if err := self.c.Git().WorkingTree.StageAll(false); err != nil { diff --git a/pkg/integration/tests/commit/commit_all_with_nested_repository.go b/pkg/integration/tests/commit/commit_all_with_nested_repository.go new file mode 100644 index 000000000..497498d68 --- /dev/null +++ b/pkg/integration/tests/commit/commit_all_with_nested_repository.go @@ -0,0 +1,52 @@ +package commit + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var CommitAllWithNestedRepository = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Committing with nothing staged doesn't offer to stage all files if that would stage a nested repository", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.ShowFileTree = false + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("first commit") + shell.CreateFile("file", "content") + shell.RunCommand([]string{"git", "init", "nested"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("?? file").IsSelected(), + Equals("?? nested (nested repository)"), + ). + Press(keys.Files.CommitChanges). + Tap(func() { + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Equals("Can't stage 'nested' because it is a git repository of its own. Discard it with d, or ignore it with i.")). + Confirm() + }). + // Once something is staged, committing works as usual + PressPrimaryAction(). + Press(keys.Files.CommitChanges). + Tap(func() { + t.ExpectPopup().CommitMessagePanel(). + Type("add file"). + Confirm() + }). + Lines( + Equals("?? nested (nested repository)").IsSelected(), + ) + + t.Views().Commits(). + Lines( + Contains("add file"), + Contains("first commit"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 1975e07dc..0736fecf6 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -114,6 +114,7 @@ var tests = []*components.IntegrationTest{ commit.CheckoutFileFromRangeSelectionOfCommits, commit.CheckoutFileWithLocalModifications, commit.Commit, + commit.CommitAllWithNestedRepository, commit.CommitMultiline, commit.CommitSkipHooks, commit.CommitSwitchToEditor, From a75b0457c18fae987e320d389d8e4f17466185e8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 12 Oct 2026 18:48:13 +0200 Subject: [PATCH 14/14] Ask before discarding a nested repository if that loses anything Discarding a nested repository deletes its directory like that of any untracked file. If the repository keeps its git dir in its directory, this deletes all of its history, and nothing said so. The directory of a removed submodule keeps its git dir elsewhere, but it may have changes that aren't committed. Check for both before discarding. If anything would be lost, list it and ask for confirmation. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/commands/git_commands/working_tree.go | 13 +++ pkg/gui/controllers/files_controller.go | 98 +++++++++++++++---- pkg/i18n/english.go | 8 ++ .../tests/file/discard_nested_repositories.go | 69 +++++++++++++ pkg/integration/tests/test_list.go | 1 + 5 files changed, 169 insertions(+), 20 deletions(-) create mode 100644 pkg/integration/tests/file/discard_nested_repositories.go diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index 09c86a882..db68d694f 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -433,6 +433,19 @@ func (self *WorkingTreeCommands) NestedRepositoryHasOwnGitDir(path string) bool return err == nil && info.IsDir() } +// NestedRepositoryHasUncommittedChanges reports whether the git repository at +// the given path of the working tree has changes that aren't committed, +// including untracked files +func (self *WorkingTreeCommands) NestedRepositoryHasUncommittedChanges(path string) (bool, error) { + cmdArgs := NewGitCmd("status"). + Dir(path). + Arg("--porcelain", "--untracked-files=normal"). + ToArgv() + + output, err := ForOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput() + return strings.TrimSpace(output) != "", err +} + // UntrackedFilesCmdObj returns the command that lists the untracked files in // the given directory, for the main view func (self *WorkingTreeCommands) UntrackedFilesCmdObj(dirPath string) *oscommands.CmdObj { diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 3d2bd686c..c696f0406 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1879,19 +1879,21 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error { discardAllChangesItem := types.MenuItem{ Label: self.c.Tr.DiscardAllChanges, OnPress: func() error { - self.c.LogAction(self.c.Tr.Actions.DiscardAllChangesInFile) + return self.confirmDiscardingNestedRepositories(selectedNodes, func() error { + self.c.LogAction(self.c.Tr.Actions.DiscardAllChangesInFile) - if self.context().IsSelectingRange() { - defer self.context().CancelRangeSelect() - } + if self.context().IsSelectingRange() { + defer self.context().CancelRangeSelect() + } - nodes := lo.Map(selectedNodes, func(n *filetree.FileNode, _ int) git_commands.IFileNode { return n }) - if err := self.c.Git().WorkingTree.DiscardAllDirChanges(nodes); err != nil { - return err - } + nodes := lo.Map(selectedNodes, func(n *filetree.FileNode, _ int) git_commands.IFileNode { return n }) + if err := self.c.Git().WorkingTree.DiscardAllDirChanges(nodes); err != nil { + return err + } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) - return nil + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) + return nil + }) }, Keys: self.c.KeybindingsOpts().GetKeys(self.c.UserConfig().Keybinding.Files.ConfirmDiscard), Tooltip: utils.ResolvePlaceholderString( @@ -1905,19 +1907,21 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error { discardUnstagedChangesItem := types.MenuItem{ Label: self.c.Tr.DiscardUnstagedChanges, OnPress: func() error { - self.c.LogAction(self.c.Tr.Actions.DiscardAllUnstagedChangesInFile) + return self.confirmDiscardingNestedRepositories(selectedNodes, func() error { + self.c.LogAction(self.c.Tr.Actions.DiscardAllUnstagedChangesInFile) - if self.context().IsSelectingRange() { - defer self.context().CancelRangeSelect() - } + if self.context().IsSelectingRange() { + defer self.context().CancelRangeSelect() + } - nodes := lo.Map(selectedNodes, func(n *filetree.FileNode, _ int) git_commands.IFileNode { return n }) - if err := self.c.Git().WorkingTree.DiscardUnstagedDirChanges(nodes); err != nil { - return err - } + nodes := lo.Map(selectedNodes, func(n *filetree.FileNode, _ int) git_commands.IFileNode { return n }) + if err := self.c.Git().WorkingTree.DiscardUnstagedDirChanges(nodes); err != nil { + return err + } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) - return nil + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) + return nil + }) }, Keys: menuKey('u'), Tooltip: utils.ResolvePlaceholderString( @@ -1940,6 +1944,60 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error { return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.DiscardChangesTitle, Items: menuItems}) } +// confirmDiscardingNestedRepositories calls discard, but asks first if +// discarding the given nodes would discard something of a nested repository in +// them that can't be restored: the repository itself, if it keeps its git dir +// inside its directory, or changes that it has that aren't committed. +func (self *FilesController) confirmDiscardingNestedRepositories(nodes []*filetree.FileNode, discard func() error) error { + var nestedRepositories []*models.File + for _, node := range nodes { + _ = node.ForEachFile(func(file *models.File) error { + if file.IsNestedRepository() { + nestedRepositories = append(nestedRepositories, file) + } + return nil + }) + } + + if len(nestedRepositories) == 0 { + return discard() + } + + return self.c.WithWaitingStatus(self.c.Tr.CheckingNestedRepositories, func(gocui.Task) error { + var losses []string + for _, file := range nestedRepositories { + placeholders := map[string]string{"path": file.Path} + if self.c.Git().WorkingTree.NestedRepositoryHasOwnGitDir(file.Path) { + losses = append(losses, utils.ResolvePlaceholderString( + self.c.Tr.NestedRepositoryHistoryWouldBeLost, placeholders)) + continue + } + + // If we can't tell, assume that there are changes, so that we ask + hasChanges, err := self.c.Git().WorkingTree.NestedRepositoryHasUncommittedChanges(file.Path) + if hasChanges || err != nil { + losses = append(losses, utils.ResolvePlaceholderString( + self.c.Tr.NestedRepositoryChangesWouldBeLost, placeholders)) + } + } + + self.c.OnUIThread(func() error { + if len(losses) == 0 { + return discard() + } + + self.c.Confirm(types.ConfirmOpts{ + Title: self.c.Tr.DiscardChangesTitle, + Prompt: self.c.Tr.DiscardNestedRepositoriesPrompt + "\n\n- " + strings.Join(losses, "\n- "), + HandleConfirm: discard, + }) + return nil + }) + + return nil + }) +} + func (self *FilesController) ResetSubmodule(submodule *models.SubmoduleConfig) error { file := self.c.Helpers().WorkingTree.FileForSubmodule(submodule) return self.c.WithWaitingStatus(self.c.Tr.ResettingSubmoduleStatus, func(gocui.Task) error { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 4f3110624..b71d084a6 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -117,6 +117,10 @@ type TranslationSet struct { PlainDirectoryFiles string StageNestedRepositoryDisabled string StageWorktreeDisabled string + CheckingNestedRepositories string + DiscardNestedRepositoriesPrompt string + NestedRepositoryHistoryWouldBeLost string + NestedRepositoryChangesWouldBeLost string StageConflictsRangeDisabled string Checkout string CheckoutTooltip string @@ -1295,6 +1299,10 @@ func EnglishTranslationSet() *TranslationSet { NestedRepositoryStatus: "Status of '{{.path}}'", StageNestedRepositoryDisabled: "Can't stage '{{.path}}' because it is a git repository of its own. Discard it with {{.discardKey}}, or ignore it with {{.ignoreKey}}.", StageWorktreeDisabled: "Can't stage '{{.path}}' because it is a worktree. Remove it with {{.removeKey}}, or ignore it with {{.ignoreKey}}.", + CheckingNestedRepositories: "Checking for uncommitted changes", + DiscardNestedRepositoriesPrompt: "Discarding this also discards the following. Are you sure?", + NestedRepositoryHistoryWouldBeLost: "'{{.path}}': the repository with all of its history", + NestedRepositoryChangesWouldBeLost: "'{{.path}}': uncommitted changes", PlainDirectoryDescription: "'{{.path}}' is an untracked directory. Git shows it as a single entry because status.showUntrackedFiles is set to \"normal\".", PlainDirectoryFiles: "Files in '{{.path}}'", StageConflictsRangeDisabled: "Cannot stage a selection that includes files with merge conflicts; resolve them individually with {{.goIntoKey}} first.", diff --git a/pkg/integration/tests/file/discard_nested_repositories.go b/pkg/integration/tests/file/discard_nested_repositories.go new file mode 100644 index 000000000..b286a263f --- /dev/null +++ b/pkg/integration/tests/file/discard_nested_repositories.go @@ -0,0 +1,69 @@ +package file + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DiscardNestedRepositories = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Discarding a nested repository asks first if that would discard its history or changes that it has that aren't committed", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.ShowFileTree = false + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("first commit") + shell.CloneIntoSubmodule("sub", "modules/sub") + shell.Commit("add submodule") + shell.RunCommand([]string{"git", "rm", "--cached", "modules/sub"}) + shell.RunCommand([]string{"git", "rm", ".gitmodules"}) + shell.Commit("remove submodule") + shell.CreateFile("modules/sub/new-file", "content") + + shell.RunCommand([]string{"git", "init", "nested"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("?? modules/sub (removed submodule)").IsSelected(), + Equals("?? nested (nested repository)"), + ). + Press(keys.Universal.RangeSelectDown). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Discard changes")). + Select(Contains("Discard all changes")). + Confirm() + + t.ExpectPopup().Confirmation(). + Title(Equals("Discard changes")). + Content(Equals("Discarding this also discards the following. Are you sure?\n\n" + + "- 'modules/sub': uncommitted changes\n" + + "- 'nested': the repository with all of its history")). + Cancel() + }). + Lines( + Equals("?? modules/sub (removed submodule)").IsSelected(), + Equals("?? nested (nested repository)").IsSelected(), + ). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Discard changes")). + Select(Contains("Discard all changes")). + Confirm() + + t.ExpectPopup().Confirmation(). + Title(Equals("Discard changes")). + Content(Contains("Are you sure?")). + Confirm() + }). + IsEmpty() + + t.FileSystem().PathNotPresent("modules/sub") + t.FileSystem().PathNotPresent("nested") + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 0736fecf6..36b71ea00 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -245,6 +245,7 @@ var tests = []*components.IntegrationTest{ file.DirectoryDiffWithRenamedFiles, file.DiscardAllDirChanges, file.DiscardAllDirChangesWhenFiltering, + file.DiscardNestedRepositories, file.DiscardRangeSelect, file.DiscardStagedChanges, file.DiscardUnstagedDirChanges,