From a2cf915f8d166b3f170e721fefb5a6de2ea51624 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 12 Oct 2026 18:38:28 +0200 Subject: [PATCH] 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,