Remember how to get back to a repo we entered a submodule from

Entering a submodule clears GIT_DIR and GIT_WORK_TREE, as it must: they
say where the superproject is. But the stack we push the superproject
onto so that escape brings us back only held its path, and for a repo
opened with --git-dir/--work-tree the path leads nowhere — git can't
find a repo there. Escaping out of a submodule of a dotfile repo failed
with "not a git repository", or, if some unrelated repo happened to lie
above the work tree, quietly switched to that one instead.

Push the environment onto the stack along with the path, taken from the
repo paths rather than from the process env, so that it also covers a
repo we worked the location out for ourselves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-08-08 11:15:01 +02:00
co-authored by Claude Opus 5
parent 9b1078a2ca
commit 06b421ad0c
6 changed files with 133 additions and 17 deletions
+12
View File
@@ -35,6 +35,18 @@ func UnsetGitLocationEnvVars() {
_ = os.Unsetenv(GitWorkTreeEnvVar)
}
// GetGitLocationEnvVars returns the location variables that are set, as
// "NAME=value" entries.
func GetGitLocationEnvVars() []string {
envVars := []string{}
for _, name := range []string{GitDirEnvVar, GitWorkTreeEnvVar} {
if value := os.Getenv(name); value != "" {
envVars = append(envVars, name+"="+value)
}
}
return envVars
}
// SetGitLocationEnvVars sets the location variables from "NAME=value" entries,
// clearing both first so that only what is given remains. Passing nothing is
// how you say the repo is to be found from the working directory.
+33 -11
View File
@@ -54,7 +54,10 @@ func (self *ReposHelper) EnterSubmodule(submodule *models.SubmoduleConfig) error
if err != nil {
return err
}
self.c.State().GetRepoPathStack().Push(wd)
self.c.State().GetRepoPathStack().Push(types.RepoLocation{
Path: wd,
GitLocationEnvVars: self.c.Git().RepoPaths.GitLocationEnvVars(),
})
return self.switchTo(submodule.FullPath(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
}
@@ -164,7 +167,7 @@ func (self *ReposHelper) SwitchToParentRepo() error {
if self.switchRefusedBecauseBusy() {
return nil
}
return self.switchTo(self.c.State().GetRepoPathStack().Pop(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
return self.switchToLocation(self.c.State().GetRepoPathStack().Pop(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
}
func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey types.ContextKey) error {
@@ -189,23 +192,41 @@ func (self *ReposHelper) switchRefusedBecauseBusy() bool {
return false
}
// switchTo switches lazygit to the repository (or worktree) at the given path.
// It runs synchronously on the UI thread: the switch swaps gui.State (in
// resetState) and reassigns gui.git and the process cwd, all of which the UI
// thread also reads, so doing it here rather than on a worker avoids racing
// those reads. The heavy data loading is still dispatched asynchronously by the
// refresh that onNewRepo kicks off.
// switchTo switches lazygit to the repository (or worktree) at the given path,
// which git is expected to find from that path alone. That's true of every repo
// we switch to without having been there before.
func (self *ReposHelper) switchTo(path string, errMsg string, contextKey types.ContextKey) error {
env.UnsetGitLocationEnvVars()
return self.switchToLocation(types.RepoLocation{Path: path}, errMsg, contextKey)
}
// switchToLocation switches lazygit to the repository (or worktree) at the
// given location. It runs synchronously on the UI thread: the switch swaps
// gui.State (in resetState) and reassigns gui.git and the process cwd, all of
// which the UI thread also reads, so doing it here rather than on a worker
// avoids racing those reads. The heavy data loading is still dispatched
// asynchronously by the refresh that onNewRepo kicks off.
//
// Everything from here on has to find the repo the way git does, from the
// directory we're about to change to, so the location's environment goes into
// the process env before we do. Usually that just clears whatever the repo
// we're leaving needed, but going back to a repo whose git dir isn't in its
// work tree (a dotfile repo opened with --git-dir/--work-tree, say) is the
// reason we remember the environment at all: nothing in the path leads to its
// git dir. On failure we put back what the repo we're staying in needs.
func (self *ReposHelper) switchToLocation(location types.RepoLocation, errMsg string, contextKey types.ContextKey) error {
originalPath, err := os.Getwd()
if err != nil {
return nil
}
originalGitLocationEnvVars := env.GetGitLocationEnvVars()
msg := utils.ResolvePlaceholderString(self.c.Tr.ChangingDirectoryTo, map[string]string{"path": path})
env.SetGitLocationEnvVars(location.GitLocationEnvVars)
msg := utils.ResolvePlaceholderString(self.c.Tr.ChangingDirectoryTo, map[string]string{"path": location.Path})
self.c.LogCommand(msg, false)
if err := os.Chdir(path); err != nil {
if err := os.Chdir(location.Path); err != nil {
env.SetGitLocationEnvVars(originalGitLocationEnvVars)
if os.IsNotExist(err) {
return errors.New(errMsg)
}
@@ -213,6 +234,7 @@ func (self *ReposHelper) switchTo(path string, errMsg string, contextKey types.C
}
if err := commands.VerifyInGitRepo(self.c.OS()); err != nil {
env.SetGitLocationEnvVars(originalGitLocationEnvVars)
if err := os.Chdir(originalPath); err != nil {
return err
}
+5 -5
View File
@@ -94,9 +94,9 @@ type Gui struct {
Mutexes types.Mutexes
// when you enter into a submodule we'll append the superproject's path to this array
// so that you can return to the superproject
RepoPathStack *utils.Stack[string]
// when you enter into a submodule we'll append the superproject's location to
// this array so that you can return to the superproject
RepoPathStack *utils.Stack[types.RepoLocation]
// this tells us whether our views have been initially set up
ViewsSetup bool
@@ -158,7 +158,7 @@ type StateAccessor struct {
var _ types.IStateAccessor = new(StateAccessor)
func (self *StateAccessor) GetRepoPathStack() *utils.Stack[string] {
func (self *StateAccessor) GetRepoPathStack() *utils.Stack[types.RepoLocation] {
return self.gui.RepoPathStack
}
@@ -799,7 +799,7 @@ func NewGui(
viewBufferManagerMap: map[string]*tasks.ViewBufferManager{},
viewPtmxMap: map[string]oscommands.Pty{},
showRecentRepos: showRecentRepos,
RepoPathStack: &utils.Stack[string]{},
RepoPathStack: &utils.Stack[types.RepoLocation]{},
RepoStateMap: map[Repo]*GuiRepoState{},
GuiLog: []string{},
+10 -1
View File
@@ -403,8 +403,17 @@ type HasUrn interface {
URN() string
}
// RepoLocation is everything it takes to open a repo again: the directory to
// change to, plus the environment telling git where the repo is for the repos
// git can't find from that directory (see RepoPaths.GitLocationEnvVars), which
// is empty for all the others.
type RepoLocation struct {
Path string
GitLocationEnvVars []string
}
type IStateAccessor interface {
GetRepoPathStack() *utils.Stack[string]
GetRepoPathStack() *utils.Stack[RepoLocation]
GetRepoState() IRepoStateAccessor
GetDiffRendererConfigManager() *config.DiffRendererConfigManager
// tells us whether we're currently updating lazygit
@@ -0,0 +1,72 @@
package submodule
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
// Entering a submodule and escaping back out again, in a repo that git can only
// find because we were told where it is (--git-dir/--work-tree). Entering the
// submodule has to leave that behind, since it says where the superproject is,
// so coming back out has to bring it along again.
var EnterFromDotfileBareRepo = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Enter a submodule of a dotfile bare repo and escape back out again",
ExtraCmdArgs: []string{"--git-dir={{.actualPath}}/.bare", "--work-tree={{.actualPath}}/repo"},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
// we're going to have a directory structure like this:
// project
// - .bare (the git dir)
// - repo (the work tree, with no .git of its own)
// - my_submodule_name (the submodule's remote)
//
// The work tree is called 'repo' because that's the directory that all
// lazygit tests start in
// make a repo for the submodule to be cloned from, using the .git dir
// that every test starts with
shell.EmptyCommit("initial submodule commit")
shell.Clone("my_submodule_name")
// now turn the test repo into a dotfile-style bare repo
shell.DeleteFile(".git")
shell.RunCommand([]string{"git", "init", "--bare", "../.bare"})
gitInBareRepo := []string{"git", "--git-dir=../.bare", "--work-tree=."}
shell.RunCommand(append(gitInBareRepo, "checkout", "-b", "mybranch"))
shell.CreateFile("blah", "blah\n")
shell.RunCommand(append(gitInBareRepo, "add", "blah"))
shell.RunCommand(append(gitInBareRepo, "commit", "-m", "initial commit"))
shell.RunCommand(append(gitInBareRepo, "-c", "protocol.file.allow=always", "submodule",
"add", "--name", "my_submodule_name", "../my_submodule_name", "my_submodule_path"))
shell.RunCommand(append(gitInBareRepo, "commit", "-m", "add submodule"))
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
assertInParentRepo := func() {
t.Views().Status().Content(Contains("repo"))
t.Views().Commits().Lines(
Contains("add submodule"),
Contains("initial commit"),
)
}
assertInParentRepo()
t.Views().Submodules().Focus().
Lines(
Contains("my_submodule_name").IsSelected(),
).
PressEnter()
t.Views().Status().Content(Contains("my_submodule_path"))
t.Views().Commits().Lines(
Contains("initial submodule commit"),
)
t.Views().Files().IsFocused().PressEscape()
assertInParentRepo()
t.Views().Submodules().IsFocused()
},
})
+1
View File
@@ -441,6 +441,7 @@ var tests = []*components.IntegrationTest{
status.LogCmdStatusPanelAllBranchesLog,
submodule.Add,
submodule.Enter,
submodule.EnterFromDotfileBareRepo,
submodule.EnterNested,
submodule.Remove,
submodule.RemoveNested,