diff --git a/pkg/env/env.go b/pkg/env/env.go index 5203577ae..391fa6ac6 100644 --- a/pkg/env/env.go +++ b/pkg/env/env.go @@ -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. diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index a61ad0013..d3b6bfe29 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -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 } diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index a463a435b..bde383caf 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -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{}, diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 169b5278e..75cb53018 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -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 diff --git a/pkg/integration/tests/submodule/enter_from_dotfile_bare_repo.go b/pkg/integration/tests/submodule/enter_from_dotfile_bare_repo.go new file mode 100644 index 000000000..e5537c5f9 --- /dev/null +++ b/pkg/integration/tests/submodule/enter_from_dotfile_bare_repo.go @@ -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() + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 9fbc7ef28..9598982d3 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -441,6 +441,7 @@ var tests = []*components.IntegrationTest{ status.LogCmdStatusPanelAllBranchesLog, submodule.Add, submodule.Enter, + submodule.EnterFromDotfileBareRepo, submodule.EnterNested, submodule.Remove, submodule.RemoveNested,