Repository: jesseduffield/lazygit (language: go)

Issue:
Global submodules view
**Describe the solution you'd like**
It would be great if `submodules` tab could show all submodules of the current project recursively. Not only submodules of the current context, but also submodules of submodules, etc. As one list



Discussion / hints:
I agree that this could be useful, but it's a bit tricky to do. Here's [a hacky commit](https://github.com/jesseduffield/lazygit/commit/6456eb19aed6b2e46886c45c867b718d7ca8cc68) that provides the basics if you want to try it; no PR yet, as this is not really good enough yet.

Problems:
- showing the nested submodules only works once the submodules are checked out. The reason is that lazygit parses the `.gitmodules` file to determine which submodules exist. That's probably an acceptable limitation in practice.
- entering a submodule works, and that's probably the functionality that you're expecting most (I guess). However, there are other things that you can do to a submodule (remove, update, etc), and those commands can't be done in the top-level repo, so right now they fail with an error message. It would probably be possible to make them work by cd'ing into the directory of the direct parent repo, but I haven't done that yet.
- submodules have a name and a path; constructing the nested path is straightforward, but the name needs to be synthesized somehow. In the commit referenced above I chose to do that in the same way as the path, by joining the parent name and the nested name with a `/`. I'm not sure that's good though, as it blurs the difference between name and path even more (many people have trouble understanding the difference, it seems).

@vikulikov Let me know if this is going into a direction that you find useful.
I think yes, it is. There is also a `git` command that lets you run arbitrary commands on every submodule in the project recursively (`git submodule foreach --recursive '<your command>'`). Can it be useful for this feature? Maybe it can let you rid of the file parsing
I know, but it's too slow for our purpose, because it spawns a shell process for every submodule; especially on Windows, where forking subprocesses is expensive.

Fix patch:
diff --git a/pkg/commands/git_commands/git_command_builder.go b/pkg/commands/git_commands/git_command_builder.go
index 4aa35be5f79..b6fe573641c 100644
--- a/pkg/commands/git_commands/git_command_builder.go
+++ b/pkg/commands/git_commands/git_command_builder.go
@@ -60,6 +60,14 @@ func (self *GitCommandBuilder) Dir(path string) *GitCommandBuilder {
 	return self
 }
 
+func (self *GitCommandBuilder) DirIf(condition bool, path string) *GitCommandBuilder {
+	if condition {
+		return self.Dir(path)
+	}
+
+	return self
+}
+
 // Note, you may prefer to use the Dir method instead of this one
 func (self *GitCommandBuilder) Worktree(path string) *GitCommandBuilder {
 	// worktree arg comes before the command
diff --git a/pkg/commands/git_commands/submodule.go b/pkg/commands/git_commands/submodule.go
index d9d1ccd20b6..40a0d35098f 100644
--- a/pkg/commands/git_commands/submodule.go
+++ b/pkg/commands/git_commands/submodule.go
@@ -26,8 +26,12 @@ func NewSubmoduleCommands(gitCommon *GitCommon) *SubmoduleCommands {
 	}
 }
 
-func (self *SubmoduleCommands) GetConfigs() ([]*models.SubmoduleConfig, error) {
-	file, err := os.Open(".gitmodules")
+func (self *SubmoduleCommands) GetConfigs(parentModule *models.SubmoduleConfig) ([]*models.SubmoduleConfig, error) {
+	gitModulesPath := ".gitmodules"
+	if parentModule != nil {
+		gitModulesPath = filepath.Join(parentModule.FullPath(), gitModulesPath)
+	}
+	file, err := os.Open(gitModulesPath)
 	if err != nil {
 		if os.IsNotExist(err) {
 			return nil, nil
@@ -51,21 +55,27 @@ func (self *SubmoduleCommands) GetConfigs() ([]*models.SubmoduleConfig, error) {
 	}
 
 	configs := []*models.SubmoduleConfig{}
+	lastConfigIdx := -1
 	for scanner.Scan() {
 		line := scanner.Text()
 
 		if name, ok := firstMatch(line, `\[submodule "(.*)"\]`); ok {
-			configs = append(configs, &models.SubmoduleConfig{Name: name})
+			configs = append(configs, &models.SubmoduleConfig{
+				Name: name, ParentModule: parentModule,
+			})
+			lastConfigIdx = len(configs) - 1
 			continue
 		}
 
-		if len(configs) > 0 {
-			lastConfig := configs[len(configs)-1]
-
+		if lastConfigIdx != -1 {
 			if path, ok := firstMatch(line, `\s*path\s*=\s*(.*)\s*`); ok {
-				lastConfig.Path = path
+				configs[lastConfigIdx].Path = path
+				nestedConfigs, err := self.GetConfigs(configs[lastConfigIdx])
+				if err == nil {
+					configs = append(configs, nestedConfigs...)
+				}
 			} else if url, ok := firstMatch(line, `\s*url\s*=\s*(.*)\s*`); ok {
-				lastConfig.Url = url
+				configs[lastConfigIdx].Url = url
 			}
 		}
 	}
@@ -77,12 +87,12 @@ func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error {
 	// if the path does not exist then it hasn't yet been initialized so we'll swallow the error
 	// because the intention here is to have no dirty worktree state
 	if _, err := os.Stat(submodule.Path); os.IsNotExist(err) {
-		self.Log.Infof("submodule path %s does not exist, returning", submodule.Path)
+		self.Log.Infof("submodule path %s does not exist, returning", submodule.FullPath())
 		return nil
 	}
 
 	cmdArgs := NewGitCmd("stash").
-		Dir(submodule.Path).
+		Dir(submodule.FullPath()).
 		Arg("--include-untracked").
 		ToArgv()
 
@@ -90,8 +100,13 @@ func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error {
 }
 
 func (self *SubmoduleCommands) Reset(submodule *models.SubmoduleConfig) error {
+	parentDir := ""
+	if submodule.ParentModule != nil {
+		parentDir = submodule.ParentModule.FullPath()
+	}
 	cmdArgs := NewGitCmd("submodule").
 		Arg("update", "--init", "--force", "--", submodule.Path).
+		DirIf(parentDir != "", parentDir).
 		ToArgv()
 
 	return self.cmd.New(cmdArgs).Run()
@@ -107,6 +122,20 @@ func (self *SubmoduleCommands) UpdateAll() error {
 func (self *SubmoduleCommands) Delete(submodule *models.SubmoduleConfig) error {
 	// based on https://gist.github.com/myusuf3/7f645819ded92bda6677
 
+	if submodule.ParentModule != nil {
+		wd, err := os.Getwd()
+		if err != nil {
+			return err
+		}
+
+		err = os.Chdir(submodule.ParentModule.FullPath())
+		if err != nil {
+			return err
+		}
+
+		defer func() { _ = os.Chdir(wd) }()
+	}
+
 	if err := self.cmd.New(
 		NewGitCmd("submodule").
 			Arg("deinit", "--force", "--", submodule.Path).ToArgv(),
@@ -141,7 +170,7 @@ func (self *SubmoduleCommands) Delete(submodule *models.SubmoduleConfig) error {
 
 	// We may in fact want to use the repo's git dir path but git docs say not to
 	// mix submodules and worktrees anyway.
-	return os.RemoveAll(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "modules", submodule.Path))
+	return os.RemoveAll(submodule.GitDirPath(self.repoPaths.repoGitDirPath))
 }
 
 func (self *SubmoduleCommands) Add(name string, path string, url string) error {
@@ -158,10 +187,24 @@ func (self *SubmoduleCommands) Add(name string, path string, url string) error {
 	return self.cmd.New(cmdArgs).Run()
 }
 
-func (self *SubmoduleCommands) UpdateUrl(name string, path string, newUrl string) error {
+func (self *SubmoduleCommands) UpdateUrl(submodule *models.SubmoduleConfig, newUrl string) error {
+	if submodule.ParentModule != nil {
+		wd, err := os.Getwd()
+		if err != nil {
+			return err
+		}
+
+		err = os.Chdir(submodule.ParentModule.FullPath())
+		if err != nil {
+			return err
+		}
+
+		defer func() { _ = os.Chdir(wd) }()
+	}
+
 	setUrlCmdStr := NewGitCmd("config").
 		Arg(
-			"--file", ".gitmodules", "submodule."+name+".url", newUrl,
+			"--file", ".gitmodules", "submodule."+submodule.Name+".url", newUrl,
 		).
 		ToArgv()
 
@@ -170,7 +213,7 @@ func (self *SubmoduleCommands) UpdateUrl(name string, path string, newUrl string
 		return err
 	}
 
-	syncCmdStr := NewGitCmd("submodule").Arg("sync", "--", path).
+	syncCmdStr := NewGitCmd("submodule").Arg("sync", "--", submodule.Path).
 		ToArgv()
 
 	if err := self.cmd.New(syncCmdStr).Run(); err != nil {
diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go
index 2bb82578d52..99665d7cca2 100644
--- a/pkg/commands/git_commands/working_tree.go
+++ b/pkg/commands/git_commands/working_tree.go
@@ -343,7 +343,7 @@ func (self *WorkingTreeCommands) RemoveUntrackedFiles() error {
 
 // ResetAndClean removes all unstaged changes and removes all untracked files
 func (self *WorkingTreeCommands) ResetAndClean() error {
-	submoduleConfigs, err := self.submodule.GetConfigs()
+	submoduleConfigs, err := self.submodule.GetConfigs(nil)
 	if err != nil {
 		return err
 	}
diff --git a/pkg/commands/models/submodule_config.go b/pkg/commands/models/submodule_config.go
index f525769217d..7df0d131abf 100644
--- a/pkg/commands/models/submodule_config.go
+++ b/pkg/commands/models/submodule_config.go
@@ -1,15 +1,35 @@
 package models
 
+import "path/filepath"
+
 type SubmoduleConfig struct {
 	Name string
 	Path string
 	Url  string
+
+	ParentModule *SubmoduleConfig // nil if top-level
 }
 
-func (r *SubmoduleConfig) RefName() string {
+func (r *SubmoduleConfig) FullName() string {
+	if r.ParentModule != nil {
+		return r.ParentModule.FullName() + "/" + r.Name
+	}
+
 	return r.Name
 }
 
+func (r *SubmoduleConfig) FullPath() string {
+	if r.ParentModule != nil {
+		return r.ParentModule.FullPath() + "/" + r.Path
+	}
+
+	return r.Path
+}
+
+func (r *SubmoduleConfig) RefName() string {
+	return r.FullName()
+}
+
 func (r *SubmoduleConfig) ID() string {
 	return r.RefName()
 }
@@ -17,3 +37,12 @@ func (r *SubmoduleConfig) ID() string {
 func (r *SubmoduleConfig) Description() string {
 	return r.RefName()
 }
+
+func (r *SubmoduleConfig) GitDirPath(repoGitDirPath string) string {
+	parentPath := repoGitDirPath
+	if r.ParentModule != nil {
+		parentPath = r.ParentModule.GitDirPath(repoGitDirPath)
+	}
+
+	return filepath.Join(parentPath, "modules", r.Name)
+}
diff --git a/pkg/gui/context/submodules_context.go b/pkg/gui/context/submodules_context.go
index aff8f64ab19..dbd12077a27 100644
--- a/pkg/gui/context/submodules_context.go
+++ b/pkg/gui/context/submodules_context.go
@@ -17,7 +17,7 @@ func NewSubmodulesContext(c *ContextCommon) *SubmodulesContext {
 	viewModel := NewFilteredListViewModel(
 		func() []*models.SubmoduleConfig { return c.Model().Submodules },
 		func(submodule *models.SubmoduleConfig) []string {
-			return []string{submodule.Name}
+			return []string{submodule.FullName()}
 		},
 		nil,
 	)
diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go
index da43c47bb68..fd0d11881d6 100644
--- a/pkg/gui/controllers/helpers/refresh_helper.go
+++ b/pkg/gui/controllers/helpers/refresh_helper.go
@@ -415,7 +415,7 @@ func (self *RefreshHelper) refreshTags() error {
 }
 
 func (self *RefreshHelper) refreshStateSubmoduleConfigs() error {
-	configs, err := self.c.Git().Submodule.GetConfigs()
+	configs, err := self.c.Git().Submodule.GetConfigs(nil)
 	if err != nil {
 		return err
 	}
diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go
index 59d45e0c195..c4a00cb7334 100644
--- a/pkg/gui/controllers/helpers/repos_helper.go
+++ b/pkg/gui/controllers/helpers/repos_helper.go
@@ -48,7 +48,7 @@ func (self *ReposHelper) EnterSubmodule(submodule *models.SubmoduleConfig) error
 	}
 	self.c.State().GetRepoPathStack().Push(wd)
 
-	return self.DispatchSwitchToRepo(submodule.Path, context.NO_CONTEXT)
+	return self.DispatchSwitchToRepo(submodule.FullPath(), context.NO_CONTEXT)
 }
 
 func (self *ReposHelper) getCurrentBranch(path string) string {
diff --git a/pkg/gui/controllers/submodules_controller.go b/pkg/gui/controllers/submodules_controller.go
index 3cf3b5bf537..dde1a1f46d2 100644
--- a/pkg/gui/controllers/submodules_controller.go
+++ b/pkg/gui/controllers/submodules_controller.go
@@ -116,8 +116,8 @@ func (self *SubmodulesController) GetOnRenderToMain() func() error {
 			} else {
 				prefix := fmt.Sprintf(
 					"Name: %s\nPath: %s\nUrl:  %s\n\n",
-					style.FgGreen.Sprint(submodule.Name),
-					style.FgYellow.Sprint(submodule.Path),
+					style.FgGreen.Sprint(submodule.FullName()),
+					style.FgYellow.Sprint(submodule.FullPath()),
 					style.FgCyan.Sprint(submodule.Url),
 				)
 
@@ -178,12 +178,12 @@ func (self *SubmodulesController) add() error {
 
 func (self *SubmodulesController) editURL(submodule *models.SubmoduleConfig) error {
 	return self.c.Prompt(types.PromptOpts{
-		Title:          fmt.Sprintf(self.c.Tr.UpdateSubmoduleUrl, submodule.Name),
+		Title:          fmt.Sprintf(self.c.Tr.UpdateSubmoduleUrl, submodule.FullName()),
 		InitialContent: submodule.Url,
 		HandleConfirm: func(newUrl string) error {
 			return self.c.WithWaitingStatus(self.c.Tr.UpdatingSubmoduleUrlStatus, func(gocui.Task) error {
 				self.c.LogAction(self.c.Tr.Actions.UpdateSubmoduleUrl)
-				err := self.c.Git().Submodule.UpdateUrl(submodule.Name, submodule.Path, newUrl)
+				err := self.c.Git().Submodule.UpdateUrl(submodule, newUrl)
 				if err != nil {
 					_ = self.c.Error(err)
 				}
@@ -272,7 +272,7 @@ func (self *SubmodulesController) update(submodule *models.SubmoduleConfig) erro
 func (self *SubmodulesController) remove(submodule *models.SubmoduleConfig) error {
 	return self.c.Confirm(types.ConfirmOpts{
 		Title:  self.c.Tr.RemoveSubmodule,
-		Prompt: fmt.Sprintf(self.c.Tr.RemoveSubmodulePrompt, submodule.Name),
+		Prompt: fmt.Sprintf(self.c.Tr.RemoveSubmodulePrompt, submodule.FullName()),
 		HandleConfirm: func() error {
 			self.c.LogAction(self.c.Tr.Actions.RemoveSubmodule)
 			if err := self.c.Git().Submodule.Delete(submodule); err != nil {
diff --git a/pkg/gui/presentation/submodules.go b/pkg/gui/presentation/submodules.go
index e580ee1f6db..72c6bfc081a 100644
--- a/pkg/gui/presentation/submodules.go
+++ b/pkg/gui/presentation/submodules.go
@@ -13,5 +13,15 @@ func GetSubmoduleListDisplayStrings(submodules []*models.SubmoduleConfig) [][]st
 }
 
 func getSubmoduleDisplayStrings(s *models.SubmoduleConfig) []string {
-	return []string{theme.DefaultTextColor.Sprint(s.Name)}
+	name := s.Name
+	if s.ParentModule != nil {
+		indentation := ""
+		for p := s.ParentModule; p != nil; p = p.ParentModule {
+			indentation += "  "
+		}
+
+		name = indentation + "- " + s.Name
+	}
+
+	return []string{theme.DefaultTextColor.Sprint(name)}
 }
diff --git a/pkg/integration/components/git.go b/pkg/integration/components/git.go
index ed327b3ede2..1e2975be25a 100644
--- a/pkg/integration/components/git.go
+++ b/pkg/integration/components/git.go
@@ -2,7 +2,10 @@ package components
 
 import (
 	"fmt"
+	"log"
 	"strings"
+
+	"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
 )
 
 type Git struct {
@@ -44,3 +47,11 @@ func (self *Git) expect(cmdArgs []string, condition func(string) (bool, string))
 
 	return self
 }
+
+func (self *Git) Version() *git_commands.GitVersion {
+	version, err := getGitVersion()
+	if err != nil {
+		log.Fatalf("Could not get git version: %v", err)
+	}
+	return version
+}
diff --git a/pkg/integration/components/shell.go b/pkg/integration/components/shell.go
index 48ff3fdf734..60c62791826 100644
--- a/pkg/integration/components/shell.go
+++ b/pkg/integration/components/shell.go
@@ -345,9 +345,9 @@ func (self *Shell) CloneIntoRemote(name string) *Shell {
 	return self
 }
 
-func (self *Shell) CloneIntoSubmodule(submoduleName string) *Shell {
+func (self *Shell) CloneIntoSubmodule(submoduleName string, submodulePath string) *Shell {
 	self.Clone("other_repo")
-	self.RunCommand([]string{"git", "submodule", "add", "../other_repo", submoduleName})
+	self.RunCommand([]string{"git", "submodule", "add", "--name", submoduleName, "../other_repo", submodulePath})
 
 	return self
 }


Test patch:
diff --git a/pkg/integration/tests/submodule/enter.go b/pkg/integration/tests/submodule/enter.go
index 7b055c2b66e..29e983b7f38 100644
--- a/pkg/integration/tests/submodule/enter.go
+++ b/pkg/integration/tests/submodule/enter.go
@@ -20,7 +20,7 @@ var Enter = NewIntegrationTest(NewIntegrationTestArgs{
 	},
 	SetupRepo: func(shell *Shell) {
 		shell.EmptyCommit("first commit")
-		shell.CloneIntoSubmodule("my_submodule")
+		shell.CloneIntoSubmodule("my_submodule_name", "my_submodule_path")
 		shell.GitAddAll()
 		shell.Commit("add submodule")
 	},
@@ -29,14 +29,18 @@ var Enter = NewIntegrationTest(NewIntegrationTestArgs{
 			t.Views().Status().Content(Contains("repo"))
 		}
 		assertInSubmodule := func() {
-			t.Views().Status().Content(Contains("my_submodule"))
+			if t.Git().Version().IsAtLeast(2, 22, 0) {
+				t.Views().Status().Content(Contains("my_submodule_path(my_submodule_name)"))
+			} else {
+				t.Views().Status().Content(Contains("my_submodule_path"))
+			}
 		}
 
 		assertInParentRepo()
 
 		t.Views().Submodules().Focus().
 			Lines(
-				Contains("my_submodule").IsSelected(),
+				Contains("my_submodule_name").IsSelected(),
 			).
 			// enter the submodule
 			PressEnter()
@@ -60,7 +64,7 @@ var Enter = NewIntegrationTest(NewIntegrationTestArgs{
 
 		t.Views().Files().Focus().
 			Lines(
-				MatchesRegexp(` M.*my_submodule \(submodule\)`).IsSelected(),
+				MatchesRegexp(` M.*my_submodule_path \(submodule\)`).IsSelected(),
 			).
 			Tap(func() {
 				// main view also shows the new commit when we're looking at the submodule within the files view
diff --git a/pkg/integration/tests/submodule/enter_nested.go b/pkg/integration/tests/submodule/enter_nested.go
new file mode 100644
index 00000000000..172dfbfaeca
--- /dev/null
+++ b/pkg/integration/tests/submodule/enter_nested.go
@@ -0,0 +1,52 @@
+package submodule
+
+import (
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var EnterNested = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "Enter a nested submodule",
+	ExtraCmdArgs: []string{},
+	Skip:         false,
+	SetupConfig:  func(cfg *config.AppConfig) {},
+	SetupRepo: func(shell *Shell) {
+		setupNestedSubmodules(shell)
+	},
+	Run: func(t *TestDriver, keys config.KeybindingConfig) {
+		t.Views().Submodules().Focus().
+			Lines(
+				Equals("outerSubName").IsSelected(),
+				Equals("  - innerSubName"),
+			).
+			Tap(func() {
+				t.Views().Main().ContainsLines(
+					Contains("Name: outerSubName"),
+					Contains("Path: modules/outerSubPath"),
+					Contains("Url:  ../outerSubmodule"),
+				)
+			}).
+			SelectNextItem().
+			Tap(func() {
+				t.Views().Main().ContainsLines(
+					Contains("Name: outerSubName/innerSubName"),
+					Contains("Path: modules/outerSubPath/modules/innerSubPath"),
+					Contains("Url:  ../innerSubmodule"),
+				)
+			}).
+			// enter the nested submodule
+			PressEnter()
+
+		if t.Git().Version().IsAtLeast(2, 22, 0) {
+			t.Views().Status().Content(Contains("innerSubPath(innerSubName)"))
+		} else {
+			t.Views().Status().Content(Contains("innerSubPath"))
+		}
+		t.Views().Commits().ContainsLines(
+			Contains("initial inner commit"),
+		)
+
+		t.Views().Files().PressEscape()
+		t.Views().Status().Content(Contains("repo"))
+	},
+})
diff --git a/pkg/integration/tests/submodule/remove.go b/pkg/integration/tests/submodule/remove.go
index 3d85d4d5c32..22fb83f3006 100644
--- a/pkg/integration/tests/submodule/remove.go
+++ b/pkg/integration/tests/submodule/remove.go
@@ -12,20 +12,23 @@ var Remove = NewIntegrationTest(NewIntegrationTestArgs{
 	SetupConfig:  func(config *config.AppConfig) {},
 	SetupRepo: func(shell *Shell) {
 		shell.EmptyCommit("first commit")
-		shell.CloneIntoSubmodule("my_submodule")
+		shell.CloneIntoSubmodule("my_submodule_name", "my_submodule_path")
 		shell.GitAddAll()
 		shell.Commit("add submodule")
 	},
 	Run: func(t *TestDriver, keys config.KeybindingConfig) {
+		gitDirSubmodulePath := ".git/modules/my_submodule_name"
+		t.FileSystem().PathPresent(gitDirSubmodulePath)
+
 		t.Views().Submodules().Focus().
 			Lines(
-				Contains("my_submodule").IsSelected(),
+				Contains("my_submodule_name").IsSelected(),
 			).
 			Press(keys.Universal.Remove).
 			Tap(func() {
 				t.ExpectPopup().Confirmation().
 					Title(Equals("Remove submodule")).
-					Content(Equals("Are you sure you want to remove submodule 'my_submodule' and its corresponding directory? This is irreversible.")).
+					Content(Equals("Are you sure you want to remove submodule 'my_submodule_name' and its corresponding directory? This is irreversible.")).
 					Confirm()
 			}).
 			IsEmpty()
@@ -33,13 +36,15 @@ var Remove = NewIntegrationTest(NewIntegrationTestArgs{
 		t.Views().Files().Focus().
 			Lines(
 				MatchesRegexp(`M.*\.gitmodules`).IsSelected(),
-				MatchesRegexp(`D.*my_submodule`),
+				MatchesRegexp(`D.*my_submodule_path`),
 			)
 
 		t.Views().Main().Content(
-			Contains("-[submodule \"my_submodule\"]").
-				Contains("-   path = my_submodule").
+			Contains("-[submodule \"my_submodule_name\"]").
+				Contains("-   path = my_submodule_path").
 				Contains("-   url = ../other_repo"),
 		)
+
+		t.FileSystem().PathNotPresent(gitDirSubmodulePath)
 	},
 })
diff --git a/pkg/integration/tests/submodule/remove_nested.go b/pkg/integration/tests/submodule/remove_nested.go
new file mode 100644
index 00000000000..ae32c0907a1
--- /dev/null
+++ b/pkg/integration/tests/submodule/remove_nested.go
@@ -0,0 +1,56 @@
+package submodule
+
+import (
+	"path/filepath"
+
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var RemoveNested = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "Remove a nested submodule",
+	ExtraCmdArgs: []string{},
+	Skip:         false,
+	SetupConfig:  func(config *config.AppConfig) {},
+	SetupRepo: func(shell *Shell) {
+		setupNestedSubmodules(shell)
+	},
+	Run: func(t *TestDriver, keys config.KeybindingConfig) {
+		gitDirSubmodulePath, _ := filepath.Abs(".git/modules/outerSubName/modules/innerSubName")
+		t.FileSystem().PathPresent(gitDirSubmodulePath)
+
+		t.Views().Submodules().Focus().
+			Lines(
+				Equals("outerSubName").IsSelected(),
+				Equals("  - innerSubName"),
+			).
+			SelectNextItem().
+			Press(keys.Universal.Remove).
+			Tap(func() {
+				t.ExpectPopup().Confirmation().
+					Title(Equals("Remove submodule")).
+					Content(Equals("Are you sure you want to remove submodule 'outerSubName/innerSubName' and its corresponding directory? This is irreversible.")).
+					Confirm()
+			}).
+			Lines(
+				Equals("outerSubName").IsSelected(),
+			).
+			Press(keys.Universal.GoInto)
+
+		t.Views().Files().IsFocused().
+			Lines(
+				Contains("modules").IsSelected(),
+				MatchesRegexp(`D.*innerSubPath`),
+				MatchesRegexp(`M.*\.gitmodules`),
+			).
+			NavigateToLine(Contains(".gitmodules"))
+
+		t.Views().Main().Content(
+			Contains("-[submodule \"innerSubName\"]").
+				Contains("-   path = modules/innerSubPath").
+				Contains("-   url = ../innerSubmodule"),
+		)
+
+		t.FileSystem().PathNotPresent(gitDirSubmodulePath)
+	},
+})
diff --git a/pkg/integration/tests/submodule/reset.go b/pkg/integration/tests/submodule/reset.go
index fba91bee84e..a723561fc9f 100644
--- a/pkg/integration/tests/submodule/reset.go
+++ b/pkg/integration/tests/submodule/reset.go
@@ -20,7 +20,7 @@ var Reset = NewIntegrationTest(NewIntegrationTestArgs{
 	},
 	SetupRepo: func(shell *Shell) {
 		shell.EmptyCommit("first commit")
-		shell.CloneIntoSubmodule("my_submodule")
+		shell.CloneIntoSubmodule("my_submodule_name", "my_submodule_path")
 		shell.GitAddAll()
 		shell.Commit("add submodule")
 
@@ -31,22 +31,24 @@ var Reset = NewIntegrationTest(NewIntegrationTestArgs{
 			t.Views().Status().Content(Contains("repo"))
 		}
 		assertInSubmodule := func() {
-			t.Views().Status().Content(Contains("my_submodule"))
+			if t.Git().Version().IsAtLeast(2, 22, 0) {
+				t.Views().Status().Content(Contains("my_submodule_path(my_submodule_name)"))
+			} else {
+				t.Views().Status().Content(Contains("my_submodule_path"))
+			}
 		}
 
 		assertInParentRepo()
 
 		t.Views().Submodules().Focus().
 			Lines(
-				Contains("my_submodule").IsSelected(),
+				Contains("my_submodule_name").IsSelected(),
 			).
 			// enter the submodule
 			PressEnter()
 
 		assertInSubmodule()
 
-		t.Views().Status().Content(Contains("my_submodule"))
-
 		t.Views().Files().IsFocused().
 			Press("e").
 			Tap(func() {
@@ -65,18 +67,18 @@ var Reset = NewIntegrationTest(NewIntegrationTestArgs{
 
 		t.Views().Submodules().IsFocused()
 
-		t.Views().Main().Content(Contains("Submodule my_submodule contains modified content"))
+		t.Views().Main().Content(Contains("Submodule my_submodule_path contains modified content"))
 
 		t.Views().Files().Focus().
 			Lines(
-				MatchesRegexp(` M.*my_submodule \(submodule\)`),
+				MatchesRegexp(` M.*my_submodule_path \(submodule\)`),
 				Contains("other_file").IsSelected(),
 			).
 			// Verify we can't use range select on submodules
 			Press(keys.Universal.ToggleRangeSelect).
 			SelectPreviousItem().
 			Lines(
-				MatchesRegexp(` M.*my_submodule \(submodule\)`).IsSelected(),
+				MatchesRegexp(` M.*my_submodule_path \(submodule\)`).IsSelected(),
 				Contains("other_file").IsSelected(),
 			).
 			Press(keys.Universal.Remove).
@@ -85,13 +87,13 @@ var Reset = NewIntegrationTest(NewIntegrationTestArgs{
 			}).
 			Press(keys.Universal.ToggleRangeSelect).
 			Lines(
-				MatchesRegexp(` M.*my_submodule \(submodule\)`).IsSelected(),
+				MatchesRegexp(` M.*my_submodule_path \(submodule\)`).IsSelected(),
 				Contains("other_file"),
 			).
 			Press(keys.Universal.Remove).
 			Tap(func() {
 				t.ExpectPopup().Menu().
-					Title(Equals("my_submodule")).
+					Title(Equals("my_submodule_path")).
 					Select(Contains("Stash uncommitted submodule changes and update")).
 					Confirm()
 			}).
diff --git a/pkg/integration/tests/submodule/shared.go b/pkg/integration/tests/submodule/shared.go
new file mode 100644
index 00000000000..43e0144abbc
--- /dev/null
+++ b/pkg/integration/tests/submodule/shared.go
@@ -0,0 +1,39 @@
+package submodule
+
+import (
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+func setupNestedSubmodules(shell *Shell) {
+	// we're going to have a directory structure like this:
+	// project
+	//  - repo/modules/outerSubName/modules/innerSubName/
+	//
+	shell.CreateFileAndAdd("rootFile", "rootStuff")
+	shell.Commit("initial repo commit")
+
+	shell.Chdir("..")
+	shell.CreateDir("innerSubmodule")
+	shell.Chdir("innerSubmodule")
+	shell.Init()
+	shell.CreateFileAndAdd("inner", "inner")
+	shell.Commit("initial inner commit")
+
+	shell.Chdir("..")
+	shell.CreateDir("outerSubmodule")
+	shell.Chdir("outerSubmodule")
+	shell.Init()
+	shell.CreateFileAndAdd("outer", "outer")
+	shell.Commit("initial outer commit")
+	shell.CreateDir("modules")
+	// the git config (-c) parameter below is required
+	// to let git create a file-protocol/path submodule
+	shell.RunCommand([]string{"git", "-c", "protocol.file.allow=always", "submodule", "add", "--name", "innerSubName", "../innerSubmodule", "modules/innerSubPath"})
+	shell.Commit("add dependency as innerSubmodule")
+
+	shell.Chdir("../repo")
+	shell.CreateDir("modules")
+	shell.RunCommand([]string{"git", "-c", "protocol.file.allow=always", "submodule", "add", "--name", "outerSubName", "../outerSubmodule", "modules/outerSubPath"})
+	shell.Commit("add dependency as outerSubmodule")
+	shell.RunCommand([]string{"git", "-c", "protocol.file.allow=always", "submodule", "update", "--init", "--recursive"})
+}
diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go
index e26a0731f2b..531fce5d98d 100644
--- a/pkg/integration/tests/test_list.go
+++ b/pkg/integration/tests/test_list.go
@@ -245,7 +245,9 @@ var tests = []*components.IntegrationTest{
 	stash.StashUnstaged,
 	submodule.Add,
 	submodule.Enter,
+	submodule.EnterNested,
 	submodule.Remove,
+	submodule.RemoveNested,
 	submodule.Reset,
 	sync.FetchPrune,
 	sync.FetchWhenSortedByDate,