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) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-10-15 20:51:34 +02:00
co-authored by Claude Opus 5.5
parent d2675b7dea
commit a2cf915f8d
6 changed files with 109 additions and 2 deletions
+7
View File
@@ -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
+30 -2
View File
@@ -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
}
@@ -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(
+4
View File
@@ -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.",
@@ -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(),
)
},
})
+1
View File
@@ -266,6 +266,7 @@ var tests = []*components.IntegrationTest{
file.StageAllWithoutChangedFiles,
file.StageChildrenRangeSelect,
file.StageDeletedRangeSelect,
file.StageNestedRepository,
file.StageRangeSelect,
file.StagedChangesInLowerPane,
filter_and_search.FilterByFileStatus,