From 7cbd93f945558522694438138b48dd8625023533 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 09:17:45 +0200 Subject: [PATCH 01/15] Give the worktree loader tests their repos' git dirs The scenarios describe their repo by its paths but leave the git dirs empty, which no repo has. Unused for now; the loader is about to want them. Co-Authored-By: Claude Opus 5 (1M context) --- .../git_commands/worktree_loader_test.go | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/pkg/commands/git_commands/worktree_loader_test.go b/pkg/commands/git_commands/worktree_loader_test.go index 1127540ca..151f20e8c 100644 --- a/pkg/commands/git_commands/worktree_loader_test.go +++ b/pkg/commands/git_commands/worktree_loader_test.go @@ -23,8 +23,10 @@ func TestGetWorktrees(t *testing.T) { { testName: "Single worktree (main)", repoPaths: &RepoPaths{ - repoPath: "/path/to/repo", - worktreePath: "/path/to/repo", + repoPath: "/path/to/repo", + worktreePath: "/path/to/repo", + repoGitDirPath: "/path/to/repo/.git", + worktreeGitDirPath: "/path/to/repo/.git", }, before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) { runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"}, @@ -55,8 +57,10 @@ branch refs/heads/mybranch { testName: "Multiple worktrees (main + linked)", repoPaths: &RepoPaths{ - repoPath: "/path/to/repo", - worktreePath: "/path/to/repo", + repoPath: "/path/to/repo", + worktreePath: "/path/to/repo", + repoGitDirPath: "/path/to/repo/.git", + worktreeGitDirPath: "/path/to/repo/.git", }, before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) { runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"}, @@ -106,8 +110,10 @@ branch refs/heads/mybranch-worktree { testName: "Worktree missing path", repoPaths: &RepoPaths{ - repoPath: "/path/to/repo", - worktreePath: "/path/to/repo", + repoPath: "/path/to/repo", + worktreePath: "/path/to/repo", + repoGitDirPath: "/path/to/repo/.git", + worktreeGitDirPath: "/path/to/repo/.git", }, before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) { runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"}, @@ -136,8 +142,10 @@ branch refs/heads/missingbranch { testName: "In linked worktree", repoPaths: &RepoPaths{ - repoPath: "/path/to/repo", - worktreePath: "/path/to/repo-worktree", + repoPath: "/path/to/repo", + worktreePath: "/path/to/repo-worktree", + repoGitDirPath: "/path/to/repo/.git", + worktreeGitDirPath: "/path/to/repo/.git/worktrees/repo-worktree", }, before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) { runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"}, @@ -187,8 +195,10 @@ branch refs/heads/mybranch-worktree { testName: "Detached HEAD worktree", repoPaths: &RepoPaths{ - repoPath: "/path/to/repo", - worktreePath: "/path/to/repo", + repoPath: "/path/to/repo", + worktreePath: "/path/to/repo", + repoGitDirPath: "/path/to/repo/.git", + worktreeGitDirPath: "/path/to/repo/.git", }, before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) { runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"}, From e1b8ef048aeb615250d7547fc828b48920ca73e8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 09:26:14 +0200 Subject: [PATCH 02/15] Add a worktree loader test for being in a submodule A submodule's git dir doesn't live inside its working tree, and `git worktree list` reports it by its git dir. Lazygit compares that against the working tree path, so it recognizes neither the current nor the main worktree, and the UI ends up claiming we're in a linked worktree. Co-Authored-By: Claude Opus 5 (1M context) --- .../git_commands/worktree_loader_test.go | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/pkg/commands/git_commands/worktree_loader_test.go b/pkg/commands/git_commands/worktree_loader_test.go index 151f20e8c..6f612bbd9 100644 --- a/pkg/commands/git_commands/worktree_loader_test.go +++ b/pkg/commands/git_commands/worktree_loader_test.go @@ -192,6 +192,48 @@ branch refs/heads/mybranch-worktree }, expectedErr: "", }, + { + testName: "In a submodule", + repoPaths: &RepoPaths{ + repoPath: "/path/to/repo/mysubmodule", + worktreePath: "/path/to/repo/mysubmodule", + repoGitDirPath: "/path/to/repo/.git/modules/mysubmodule", + worktreeGitDirPath: "/path/to/repo/.git/modules/mysubmodule", + }, + before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) { + // A submodule's git dir doesn't live inside its working tree, and + // `git worktree list` reports the git dir rather than the working + // tree it belongs to. + runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"}, + `worktree /path/to/repo/.git/modules/mysubmodule +HEAD d85cc9d281fa6ae1665c68365fc70e75e82a042d +branch refs/heads/mybranch +`, + nil) + + gitArgs := append(append([]string{"-C", "/path/to/repo/.git/modules/mysubmodule"}, getRevParseArgs()...), "--absolute-git-dir") + runner.ExpectGitArgs(gitArgs, "/path/to/repo/.git/modules/mysubmodule", nil) + + _ = fs.MkdirAll("/path/to/repo/.git/modules/mysubmodule", 0o755) + }, + expectedWorktrees: []*models.Worktree{ + { + /* EXPECTED: + IsMain: true, + IsCurrent: true, + ACTUAL: */ + IsMain: false, + IsCurrent: false, + Path: "/path/to/repo/.git/modules/mysubmodule", + IsPathMissing: false, + GitDir: "/path/to/repo/.git/modules/mysubmodule", + Branch: "mybranch", + Head: "d85cc9d281fa6ae1665c68365fc70e75e82a042d", + Name: "mysubmodule", + }, + }, + expectedErr: "", + }, { testName: "Detached HEAD worktree", repoPaths: &RepoPaths{ From d2d5bdc2bcd66d6eba2db9922442d0cf996e0d41 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 7 Aug 2026 22:36:53 +0200 Subject: [PATCH 03/15] Identify the current and main worktree by git dir, not by path `git worktree list` reports the main worktree as the common git dir with a trailing "/.git" removed, which equals the working tree only when the git dir sits inside it. In a submodule, a bare repo, or a repo using core.worktree it doesn't, so comparing the reported path against the working tree path matches nothing: no worktree is recognized as current or as main. Most visibly, inside a submodule lazygit claimed we were in a linked worktree named after the submodule, and offered to remove that "worktree". Comparing git dirs identifies a worktree unambiguously, so use that. A worktree whose directory is gone has no git dir to compare, and there we still have nothing better than its path. The submodule tests were asserting the linked-worktree suffix in the status view; it is gone now, and the repo name still says which submodule we're in. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/worktree_loader.go | 30 ++++++++++++------- .../git_commands/worktree_loader_test.go | 4 --- pkg/integration/tests/submodule/enter.go | 2 +- .../tests/submodule/enter_nested.go | 2 +- pkg/integration/tests/submodule/reset.go | 2 +- 5 files changed, 23 insertions(+), 17 deletions(-) diff --git a/pkg/commands/git_commands/worktree_loader.go b/pkg/commands/git_commands/worktree_loader.go index 0e3615f13..adf1e48dc 100644 --- a/pkg/commands/git_commands/worktree_loader.go +++ b/pkg/commands/git_commands/worktree_loader.go @@ -22,9 +22,6 @@ func NewWorktreeLoader(gitCommon *GitCommon) *WorktreeLoader { } func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) { - currentRepoPath := self.repoPaths.RepoPath() - worktreePath := self.repoPaths.WorktreePath() - cmdArgs := NewGitCmd("worktree").Arg("list", "--porcelain").ToArgv() worktreesOutput, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput() if err != nil { @@ -54,17 +51,13 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) { if strings.HasPrefix(splitLine, "worktree ") { path := strings.SplitN(splitLine, " ", 2)[1] - isMain := path == currentRepoPath - isCurrent := path == worktreePath - isPathMissing := self.pathExists(path) current = &models.Worktree{ - IsMain: isMain, - IsCurrent: isCurrent, - IsPathMissing: isPathMissing, + IsPathMissing: self.pathExists(path), Path: path, // we defer populating GitDir until a loop below so that - // we can parallelize the calls to git rev-parse + // we can parallelize the calls to git rev-parse, and + // IsMain/IsCurrent because they are derived from GitDir GitDir: "", } } else if strings.HasPrefix(splitLine, "HEAD ") { @@ -95,6 +88,23 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) { } wg.Wait() + // Identify the current and the main worktree by their git dir rather than by + // their path: `git worktree list` reports the main worktree as the common + // git dir with a trailing "/.git" removed, which is the working tree only + // when the git dir sits inside it. In a submodule, a bare repo or a repo + // using core.worktree it doesn't, and comparing paths then matches nothing. + // A worktree whose directory is gone has no git dir to compare, so there we + // have nothing better than its path. + for _, worktree := range worktrees { + if worktree.GitDir != "" { + worktree.IsCurrent = worktree.GitDir == self.repoPaths.WorktreeGitDirPath() + worktree.IsMain = worktree.GitDir == self.repoPaths.RepoGitDirPath() + } else { + worktree.IsCurrent = worktree.Path == self.repoPaths.WorktreePath() + worktree.IsMain = worktree.Path == self.repoPaths.RepoPath() + } + } + names := getUniqueNamesFromPaths(lo.Map(worktrees, func(worktree *models.Worktree, _ int) string { return worktree.Path })) diff --git a/pkg/commands/git_commands/worktree_loader_test.go b/pkg/commands/git_commands/worktree_loader_test.go index 6f612bbd9..c537c6be4 100644 --- a/pkg/commands/git_commands/worktree_loader_test.go +++ b/pkg/commands/git_commands/worktree_loader_test.go @@ -218,12 +218,8 @@ branch refs/heads/mybranch }, expectedWorktrees: []*models.Worktree{ { - /* EXPECTED: IsMain: true, IsCurrent: true, - ACTUAL: */ - IsMain: false, - IsCurrent: false, Path: "/path/to/repo/.git/modules/mysubmodule", IsPathMissing: false, GitDir: "/path/to/repo/.git/modules/mysubmodule", diff --git a/pkg/integration/tests/submodule/enter.go b/pkg/integration/tests/submodule/enter.go index b768ed40e..67df35276 100644 --- a/pkg/integration/tests/submodule/enter.go +++ b/pkg/integration/tests/submodule/enter.go @@ -29,7 +29,7 @@ var Enter = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Status().Content(Contains("repo")) } assertInSubmodule := func() { - t.Views().Status().Content(Contains("my_submodule_path(my_submodule_name)")) + t.Views().Status().Content(Contains("my_submodule_path")) } assertInParentRepo() diff --git a/pkg/integration/tests/submodule/enter_nested.go b/pkg/integration/tests/submodule/enter_nested.go index 24cdf5261..1fc96425c 100644 --- a/pkg/integration/tests/submodule/enter_nested.go +++ b/pkg/integration/tests/submodule/enter_nested.go @@ -37,7 +37,7 @@ var EnterNested = NewIntegrationTest(NewIntegrationTestArgs{ // enter the nested submodule PressEnter() - t.Views().Status().Content(Contains("innerSubPath(innerSubName)")) + t.Views().Status().Content(Contains("innerSubPath")) t.Views().Commits().ContainsLines( Contains("initial inner commit"), ) diff --git a/pkg/integration/tests/submodule/reset.go b/pkg/integration/tests/submodule/reset.go index d671066a1..6c23bbd85 100644 --- a/pkg/integration/tests/submodule/reset.go +++ b/pkg/integration/tests/submodule/reset.go @@ -31,7 +31,7 @@ var Reset = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Status().Content(Contains("repo")) } assertInSubmodule := func() { - t.Views().Status().Content(Contains("my_submodule_path(my_submodule_name)")) + t.Views().Status().Content(Contains("my_submodule_path")) } assertInParentRepo() From 180039e78c7eda0b7f0698a970a4380d5a51710f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 09:26:58 +0200 Subject: [PATCH 04/15] Add a repo paths test for a repo with a separate work tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the work tree lives somewhere else entirely — set up with core.worktree or --work-tree — we're still in the main worktree, so RepoPath() should be the work tree, as its own doc comment says. Instead we derive it from the git dir's location, which lands somewhere that isn't a worktree at all, and the repo name follows it. The ACTUAL lines are indented as they will be once the EXPECTED ones replace them, rather than as gofumpt wants them while the comment markers are still splitting the struct's alignment. That leaves this one file not gofumpt-clean until the next commit, in exchange for a diff there that shows only the lines that actually change. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/repo_paths_test.go | 62 ++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/pkg/commands/git_commands/repo_paths_test.go b/pkg/commands/git_commands/repo_paths_test.go index 29c40acee..54da6b978 100644 --- a/pkg/commands/git_commands/repo_paths_test.go +++ b/pkg/commands/git_commands/repo_paths_test.go @@ -123,6 +123,68 @@ func TestGetRepoPaths(t *testing.T) { }), Err: nil, }, + { + // A repo whose work tree lives somewhere else entirely, as set up by + // core.worktree or by --work-tree. We're in the main worktree, but the + // git dir is not inside it. + Name: "repo with a separate work tree", + BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) { + mockOutput := lo.Ternary(runtime.GOOS == "windows", []string{ + // --show-toplevel + `C:\path\to\worktree`, + // --git-dir + `C:\path\to\repo\.git`, + // --git-common-dir + `C:\path\to\repo\.git`, + // --is-bare-repository + "false", + // --show-superproject-working-tree + }, []string{ + // --show-toplevel + "/path/to/worktree", + // --git-dir + "/path/to/repo/.git", + // --git-common-dir + "/path/to/repo/.git", + // --is-bare-repository + "false", + // --show-superproject-working-tree + }) + runner.ExpectGitArgs( + append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"), + strings.Join(mockOutput, "\n"), + nil) + }, + Path: "/path/to/repo", + Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{ + worktreePath: `C:\path\to\worktree`, + worktreeGitDirPath: `C:\path\to\repo\.git`, + /* EXPECTED: + repoPath: `C:\path\to\worktree`, + ACTUAL: */ + repoPath: `C:\path\to\repo`, + repoGitDirPath: `C:\path\to\repo\.git`, + /* EXPECTED: + repoName: `worktree`, + ACTUAL: */ + repoName: `repo`, + isBareRepo: false, + }, &RepoPaths{ + worktreePath: "/path/to/worktree", + worktreeGitDirPath: "/path/to/repo/.git", + /* EXPECTED: + repoPath: "/path/to/worktree", + ACTUAL: */ + repoPath: "/path/to/repo", + repoGitDirPath: "/path/to/repo/.git", + /* EXPECTED: + repoName: "worktree", + ACTUAL: */ + repoName: "repo", + isBareRepo: false, + }), + Err: nil, + }, { Name: "submodule", BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) { From e17ed2484ce8dfaebc0c7c3ad9d9d2d4a0b7d2c3 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 09:29:20 +0200 Subject: [PATCH 05/15] Use the work tree as the repo path when it is the main worktree RepoPath() is meant to be the same as WorktreePath() when we're in the main worktree, but we derived it from the git dir's location instead. That is only the same thing when the git dir lives inside the work tree. With core.worktree, --work-tree, or a .git file pointing at a repo dir that isn't called .git, it lands on a directory that isn't a worktree at all, and the repo name we show follows it there. A worktree that has the repo's common git dir to itself is the main worktree, so use its path. That subsumes the submodule case, whose git dir lives under the superproject's .git/modules but is still the submodule's own common dir; --show-superproject-working-tree is now only needed for a linked worktree of a submodule. The existing bare repo test asserted a git output that can't occur (a work tree and --is-bare-repository=true at once), but the rest of it is the shape of a repo opened with --git-dir/--work-tree, where the new repo path is the correct one. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/repo_paths.go | 27 +++++++++++++++----- pkg/commands/git_commands/repo_paths_test.go | 20 +++------------ 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/pkg/commands/git_commands/repo_paths.go b/pkg/commands/git_commands/repo_paths.go index c64debfc5..6783c0971 100644 --- a/pkg/commands/git_commands/repo_paths.go +++ b/pkg/commands/git_commands/repo_paths.go @@ -95,15 +95,30 @@ func GetRepoPathsForDir( repoGitDirPath := gitDirResults[2] isBareRepo := gitDirResults[3] == "true" - // If we're in a submodule, --show-superproject-working-tree will return - // a value, meaning gitDirResults will be length 5. In that case - // return the worktree path as the repoPath. Otherwise we're in a - // normal repo or a worktree so return the parent of the git common - // dir (repoGitDirPath) + // A worktree that has the repo's common git dir to itself is the repo's main + // worktree, so it is the repoPath. That holds for a submodule as well: its + // git dir lives under the superproject's .git/modules, but it is still the + // submodule's own common dir. + isMainWorktree := worktreeGitDirPath == repoGitDirPath + + // If we're in a submodule, --show-superproject-working-tree will return a + // value, meaning gitDirResults will be length 5. That only tells us anything + // new for a linked worktree of a submodule, which isMainWorktree misses. isSubmodule := len(gitDirResults) == 5 + // Otherwise we're in a linked worktree, and the repoPath is the repo's main + // worktree. git won't tell us where that is: `git worktree list` reports it + // as the common git dir with a trailing "/.git" removed, which is this same + // derivation. So take the directory holding the common git dir. That is the + // main worktree of an ordinary repo, and of a bare one it is the directory + // its worktrees live in. It is not the main worktree of a repo that moved + // that elsewhere with core.worktree; there we end up naming the git dir's + // directory, which means that the repo name we display in the status panel + // isn't correct, and we start looking for .lazygit.yml in the wrong place. + // Both of those are not severe enough to justify the extra git call to get + // the real main worktree, so we accept this for this rather niche use case. var repoPath string - if isSubmodule { + if isMainWorktree || isSubmodule { repoPath = worktreePath } else { repoPath = filepath.Dir(repoGitDirPath) diff --git a/pkg/commands/git_commands/repo_paths_test.go b/pkg/commands/git_commands/repo_paths_test.go index 54da6b978..25fd1b2e0 100644 --- a/pkg/commands/git_commands/repo_paths_test.go +++ b/pkg/commands/git_commands/repo_paths_test.go @@ -109,16 +109,16 @@ func TestGetRepoPaths(t *testing.T) { Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{ worktreePath: `C:\path\to\repo`, worktreeGitDirPath: `C:\path\to\bare_repo\bare.git`, - repoPath: `C:\path\to\bare_repo`, + repoPath: `C:\path\to\repo`, repoGitDirPath: `C:\path\to\bare_repo\bare.git`, - repoName: `bare_repo`, + repoName: `repo`, isBareRepo: true, }, &RepoPaths{ worktreePath: "/path/to/repo", worktreeGitDirPath: "/path/to/bare_repo/bare.git", - repoPath: "/path/to/bare_repo", + repoPath: "/path/to/repo", repoGitDirPath: "/path/to/bare_repo/bare.git", - repoName: "bare_repo", + repoName: "repo", isBareRepo: true, }), Err: nil, @@ -159,28 +159,16 @@ func TestGetRepoPaths(t *testing.T) { Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{ worktreePath: `C:\path\to\worktree`, worktreeGitDirPath: `C:\path\to\repo\.git`, - /* EXPECTED: repoPath: `C:\path\to\worktree`, - ACTUAL: */ - repoPath: `C:\path\to\repo`, repoGitDirPath: `C:\path\to\repo\.git`, - /* EXPECTED: repoName: `worktree`, - ACTUAL: */ - repoName: `repo`, isBareRepo: false, }, &RepoPaths{ worktreePath: "/path/to/worktree", worktreeGitDirPath: "/path/to/repo/.git", - /* EXPECTED: repoPath: "/path/to/worktree", - ACTUAL: */ - repoPath: "/path/to/repo", repoGitDirPath: "/path/to/repo/.git", - /* EXPECTED: repoName: "worktree", - ACTUAL: */ - repoName: "repo", isBareRepo: false, }), Err: nil, From ca6c0500e61dce27f0bbd45c3bb876d0e37068a0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 7 Aug 2026 22:45:10 +0200 Subject: [PATCH 06/15] Don't clear gui.git when we fail to open a repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onNewRepo also runs when switching repos, and a failure there leaves us in the repo we came from — with a nil GitCommand, which nothing else is prepared for. Only assign once we have one. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/gui.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index a7466d584..b92c1733e 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -340,8 +340,10 @@ func (gui *Gui) onSwitchToNewRepo(startArgs appTypes.StartArgs, contextKey types } func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.ContextKey) error { - var err error - gui.git, err = commands.NewGitCommand( + // Don't assign to gui.git until we know we have one: this also runs when + // switching repos, and leaving the field nil would take down the repo we + // were in before, which is where the error puts us back. + git, err := commands.NewGitCommand( gui.Common, gui.gitVersion, gui.os, @@ -351,6 +353,7 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context if err != nil { return err } + gui.git = git err = gui.Config.ReloadUserConfigForRepo(gui.getPerRepoConfigFiles()) if err != nil { From e10a2f6a271e953b16b46af5725c5124e38724f4 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 09:29:41 +0200 Subject: [PATCH 07/15] Stop promising bare repo support "does not yet support" reads as a promise that it will, but it's quite likely that it never will. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/i18n/english.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 845155aed..da157ff78 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -1619,7 +1619,7 @@ func EnglishTranslationSet() *TranslationSet { DiscardFileChangesPromptResetPatch: "Are you sure you want to discard changes to the selected file(s) from this commit?\n\nThis action will start a rebase, reverting these file changes. Be aware that if subsequent commits depend on these changes, you may need to resolve conflicts.\n\nNote: This will reset the active custom patch!", DisabledForGPG: "Feature not available for users using GPG.\n\nIf you are using a passphrase agent (e.g. gpg-agent) so that you don't have to type your passphrase when signing, you can enable this feature by adding\n\ngit:\n overrideGpg: true\n\nto your lazygit config file.", CreateRepo: "Not in a git repository. Create a new git repository? (y/N): ", - BareRepo: "You've attempted to open Lazygit in a bare repo but Lazygit does not yet support bare repos. Open most recent repo? (y/n) ", + BareRepo: "You've attempted to open Lazygit in a bare repo but Lazygit does not support bare repos. Open most recent repo? (y/n) ", InitialBranch: "Branch name? (leave empty for git's default): ", NoRecentRepositories: "Must open lazygit in a git repository. No valid recent repositories. Exiting.", IncorrectNotARepository: "The value of 'notARepository' is incorrect. It should be one of 'prompt', 'create', 'skip', or 'quit'.", From 0ce248d1bf25ef7d422fe51441b7cf63643cdea9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 7 Aug 2026 22:49:32 +0200 Subject: [PATCH 08/15] Recognize a repo that has no work tree instead of bailing out git makes `rev-parse --show-toplevel` fatal when there's no work tree, so asking for it together with everything else meant we never got an answer at all for a bare repo: GetRepoPaths returned an error, nobody ever saw IsBareRepo() == true, and lazygit either died with a stack trace or decided we weren't in a repository. That's what you got for opening it in a directory holding a bare repo and a .git file pointing at it, which is a normal way to keep a repo and its worktrees together. Ask again without --show-toplevel when the first query fails: the other queries work fine without a work tree, so if they now succeed we know we're in a bare repo, and the existing prompt offering to open a recent repo does its job. If they fail too we're not in a repo at all, and the first error already says so. --is-bare-repository is gone from the query: a work tree implies core.bare is false, so it could only ever come back false there, and what matters to us is whether there is a work tree to show, which is what we now go by. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/app/errors.go | 2 +- pkg/commands/git.go | 7 ++ pkg/commands/git_commands/repo_paths.go | 55 ++++++++++++-- pkg/commands/git_commands/repo_paths_test.go | 77 +++++++++----------- pkg/i18n/english.go | 2 + 5 files changed, 91 insertions(+), 52 deletions(-) diff --git a/pkg/app/errors.go b/pkg/app/errors.go index 506fec276..ee24cff49 100644 --- a/pkg/app/errors.go +++ b/pkg/app/errors.go @@ -16,7 +16,7 @@ type errorMapping struct { func knownError(tr *i18n.TranslationSet, err error) (string, bool) { errorMessage := err.Error() - knownErrorMessages := []string{minGitVersionErrorMessage(tr)} + knownErrorMessages := []string{minGitVersionErrorMessage(tr), tr.BareRepoNotSupported} if lo.Contains(knownErrorMessages, errorMessage) { return errorMessage, true diff --git a/pkg/commands/git.go b/pkg/commands/git.go index 6a08c1d2b..a5fc0e608 100644 --- a/pkg/commands/git.go +++ b/pkg/commands/git.go @@ -67,6 +67,13 @@ func NewGitCommand( return nil, errors.Errorf("Error getting repo paths: %v", err) } + // A bare repo has no worktree for us to work in. Callers that can offer the + // user something better (app.setupRepo) check for this first; getting here + // means nobody could, e.g. because --git-dir was pointed at a bare repo. + if repoPaths.IsBareRepo() { + return nil, errors.New(cmn.Tr.BareRepoNotSupported) + } + err = os.Chdir(repoPaths.WorktreePath()) if err != nil { return nil, utils.WrapError(err) diff --git a/pkg/commands/git_commands/repo_paths.go b/pkg/commands/git_commands/repo_paths.go index 6783c0971..89a86640b 100644 --- a/pkg/commands/git_commands/repo_paths.go +++ b/pkg/commands/git_commands/repo_paths.go @@ -22,7 +22,8 @@ type RepoPaths struct { } // Path to the current worktree. If we're in the main worktree, this will -// be the same as RepoPath() +// be the same as RepoPath(). It is empty for a bare repo, which has no +// worktree at all. func (self *RepoPaths) WorktreePath() string { return self.worktreePath } @@ -53,6 +54,9 @@ func (self *RepoPaths) RepoName() string { return self.repoName } +// Whether the repo has no worktree, so that there is nothing for lazygit to +// show. Note that this isn't quite git's core.bare: a repo that calls itself +// non-bare but doesn't have a worktree either counts as bare for us. func (self *RepoPaths) IsBareRepo() bool { return self.isBareRepo } @@ -84,16 +88,18 @@ func GetRepoPathsForDir( dir string, cmd oscommands.ICmdObjBuilder, ) (*RepoPaths, error) { - gitDirOutput, err := callGitRevParseWithDir(cmd, dir, "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree") + gitDirOutput, err := callGitRevParseWithDir(cmd, dir, "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree") if err != nil { - return nil, err + // --show-toplevel is the only one of these that needs a work tree, and + // git makes it fatal when there isn't one. So this may just mean we're in + // a repo that has no work tree. + return getBareRepoPathsForDir(dir, cmd, err) } gitDirResults := strings.Split(utils.NormalizeLinefeeds(gitDirOutput), "\n") worktreePath := gitDirResults[0] worktreeGitDirPath := gitDirResults[1] repoGitDirPath := gitDirResults[2] - isBareRepo := gitDirResults[3] == "true" // A worktree that has the repo's common git dir to itself is the repo's main // worktree, so it is the repoPath. That holds for a submodule as well: its @@ -102,9 +108,9 @@ func GetRepoPathsForDir( isMainWorktree := worktreeGitDirPath == repoGitDirPath // If we're in a submodule, --show-superproject-working-tree will return a - // value, meaning gitDirResults will be length 5. That only tells us anything + // value, meaning gitDirResults will be length 4. That only tells us anything // new for a linked worktree of a submodule, which isMainWorktree misses. - isSubmodule := len(gitDirResults) == 5 + isSubmodule := len(gitDirResults) == 4 // Otherwise we're in a linked worktree, and the repoPath is the repo's main // worktree. git won't tell us where that is: `git worktree list` reports it @@ -131,7 +137,42 @@ func GetRepoPathsForDir( repoPath: repoPath, repoGitDirPath: repoGitDirPath, repoName: repoName, - isBareRepo: isBareRepo, + isBareRepo: false, + }, nil +} + +// getBareRepoPathsForDir is the fallback for when we couldn't ask git for the +// work tree. Everything but --show-toplevel works fine without one, so if the +// remaining queries succeed we are in a bare repo, and we return what we know +// about it with an empty worktreePath. If they fail too we simply aren't in a +// repo, and the caller's original error says so better than ours would. +func getBareRepoPathsForDir( + dir string, + cmd oscommands.ICmdObjBuilder, + errWithWorktree error, +) (*RepoPaths, error) { + output, err := callGitRevParseWithDir(cmd, dir, "--absolute-git-dir", "--git-common-dir") + if err != nil { + return nil, errWithWorktree + } + + results := strings.Split(utils.NormalizeLinefeeds(output), "\n") + repoGitDirPath := results[1] + // A bare repo has no worktree, and so no repo path in the sense the caller + // with a worktree means. It doesn't matter much what we say here, because + // nobody reads it: whoever is handed a bare repo either offers to open a + // recent one instead (app.setupRepo) or is turned away by NewGitCommand. The + // directory holding the git dir is the nearest thing there is to a repo + // path. + repoPath := filepath.Dir(repoGitDirPath) + + return &RepoPaths{ + worktreePath: "", + worktreeGitDirPath: results[0], + repoPath: repoPath, + repoGitDirPath: repoGitDirPath, + repoName: filepath.Base(repoPath), + isBareRepo: true, }, nil } diff --git a/pkg/commands/git_commands/repo_paths_test.go b/pkg/commands/git_commands/repo_paths_test.go index 25fd1b2e0..fed4905a8 100644 --- a/pkg/commands/git_commands/repo_paths_test.go +++ b/pkg/commands/git_commands/repo_paths_test.go @@ -38,8 +38,6 @@ func TestGetRepoPaths(t *testing.T) { `C:\path\to\repo\.git`, // --git-common-dir `C:\path\to\repo\.git`, - // --is-bare-repository - "false", // --show-superproject-working-tree }, []string{ // --show-toplevel @@ -48,12 +46,10 @@ func TestGetRepoPaths(t *testing.T) { "/path/to/repo/.git", // --git-common-dir "/path/to/repo/.git", - // --is-bare-repository - "false", // --show-superproject-working-tree }) runner.ExpectGitArgs( - append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"), + append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), strings.Join(mockOutput, "\n"), nil) }, @@ -76,49 +72,45 @@ func TestGetRepoPaths(t *testing.T) { Err: nil, }, { + // git refuses to answer --show-toplevel when there's no work tree, so + // we have to ask a second time without it. Name: "bare repo", BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) { - // setup for main worktree + runner.ExpectGitArgs( + append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), + "", + errors.New("fatal: this operation must be run in a work tree")) + mockOutput := lo.Ternary(runtime.GOOS == "windows", []string{ - // --show-toplevel - `C:\path\to\repo`, // --git-dir - `C:\path\to\bare_repo\bare.git`, + `C:\path\to\project\bare.git`, // --git-common-dir - `C:\path\to\bare_repo\bare.git`, - // --is-bare-repository - `true`, - // --show-superproject-working-tree + `C:\path\to\project\bare.git`, }, []string{ - // --show-toplevel - "/path/to/repo", // --git-dir - "/path/to/bare_repo/bare.git", + "/path/to/project/bare.git", // --git-common-dir - "/path/to/bare_repo/bare.git", - // --is-bare-repository - "true", - // --show-superproject-working-tree + "/path/to/project/bare.git", }) runner.ExpectGitArgs( - append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"), + append(getRevParseArgs(), "--absolute-git-dir", "--git-common-dir"), strings.Join(mockOutput, "\n"), nil) }, - Path: "/path/to/repo", + Path: "/path/to/project", Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{ - worktreePath: `C:\path\to\repo`, - worktreeGitDirPath: `C:\path\to\bare_repo\bare.git`, - repoPath: `C:\path\to\repo`, - repoGitDirPath: `C:\path\to\bare_repo\bare.git`, - repoName: `repo`, + worktreePath: "", + worktreeGitDirPath: `C:\path\to\project\bare.git`, + repoPath: `C:\path\to\project`, + repoGitDirPath: `C:\path\to\project\bare.git`, + repoName: `project`, isBareRepo: true, }, &RepoPaths{ - worktreePath: "/path/to/repo", - worktreeGitDirPath: "/path/to/bare_repo/bare.git", - repoPath: "/path/to/repo", - repoGitDirPath: "/path/to/bare_repo/bare.git", - repoName: "repo", + worktreePath: "", + worktreeGitDirPath: "/path/to/project/bare.git", + repoPath: "/path/to/project", + repoGitDirPath: "/path/to/project/bare.git", + repoName: "project", isBareRepo: true, }), Err: nil, @@ -136,8 +128,6 @@ func TestGetRepoPaths(t *testing.T) { `C:\path\to\repo\.git`, // --git-common-dir `C:\path\to\repo\.git`, - // --is-bare-repository - "false", // --show-superproject-working-tree }, []string{ // --show-toplevel @@ -146,12 +136,10 @@ func TestGetRepoPaths(t *testing.T) { "/path/to/repo/.git", // --git-common-dir "/path/to/repo/.git", - // --is-bare-repository - "false", // --show-superproject-working-tree }) runner.ExpectGitArgs( - append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"), + append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), strings.Join(mockOutput, "\n"), nil) }, @@ -183,8 +171,6 @@ func TestGetRepoPaths(t *testing.T) { `C:\path\to\repo\.git\modules\submodule1`, // --git-common-dir `C:\path\to\repo\.git\modules\submodule1`, - // --is-bare-repository - `false`, // --show-superproject-working-tree `C:\path\to\repo`, }, []string{ @@ -194,13 +180,11 @@ func TestGetRepoPaths(t *testing.T) { "/path/to/repo/.git/modules/submodule1", // --git-common-dir "/path/to/repo/.git/modules/submodule1", - // --is-bare-repository - "false", // --show-superproject-working-tree "/path/to/repo", }) runner.ExpectGitArgs( - append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"), + append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), strings.Join(mockOutput, "\n"), nil) }, @@ -226,7 +210,12 @@ func TestGetRepoPaths(t *testing.T) { Name: "git rev-parse returns an error", BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) { runner.ExpectGitArgs( - append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"), + append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), + "", + errors.New("fatal: invalid gitfile format: /path/to/repo/worktree2/.git")) + // we're not in a repo at all, so asking about a bare one fails too + runner.ExpectGitArgs( + append(getRevParseArgs(), "--absolute-git-dir", "--git-common-dir"), "", errors.New("fatal: invalid gitfile format: /path/to/repo/worktree2/.git")) }, @@ -234,7 +223,7 @@ func TestGetRepoPaths(t *testing.T) { Expected: nil, Err: func(getRevParseArgs argFn) error { args := strings.Join(getRevParseArgs(), " ") - return fmt.Errorf("'git %v --show-toplevel --absolute-git-dir --git-common-dir --is-bare-repository --show-superproject-working-tree' failed: fatal: invalid gitfile format: /path/to/repo/worktree2/.git", args) + return fmt.Errorf("'git %v --show-toplevel --absolute-git-dir --git-common-dir --show-superproject-working-tree' failed: fatal: invalid gitfile format: /path/to/repo/worktree2/.git", args) }, }, } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index da157ff78..f71e61adb 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -461,6 +461,7 @@ type TranslationSet struct { DisabledForGPG string CreateRepo string BareRepo string + BareRepoNotSupported string InitialBranch string NoRecentRepositories string IncorrectNotARepository string @@ -1620,6 +1621,7 @@ func EnglishTranslationSet() *TranslationSet { DisabledForGPG: "Feature not available for users using GPG.\n\nIf you are using a passphrase agent (e.g. gpg-agent) so that you don't have to type your passphrase when signing, you can enable this feature by adding\n\ngit:\n overrideGpg: true\n\nto your lazygit config file.", CreateRepo: "Not in a git repository. Create a new git repository? (y/N): ", BareRepo: "You've attempted to open Lazygit in a bare repo but Lazygit does not support bare repos. Open most recent repo? (y/n) ", + BareRepoNotSupported: "Lazygit does not support bare repos.", InitialBranch: "Branch name? (leave empty for git's default): ", NoRecentRepositories: "Must open lazygit in a git repository. No valid recent repositories. Exiting.", IncorrectNotARepository: "The value of 'notARepository' is incorrect. It should be one of 'prompt', 'create', 'skip', or 'quit'.", From 3d80e466ceffb5639fc45b48b91c100233890c7b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 09:34:46 +0200 Subject: [PATCH 09/15] Say why runInParentModule can name a relative directory Its working directory resolves against the process rather than against the repo the command builder pins commands to, which is only safe because nothing but foreground commands come through here. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/submodule.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/commands/git_commands/submodule.go b/pkg/commands/git_commands/submodule.go index d8c1208bc..e2012942f 100644 --- a/pkg/commands/git_commands/submodule.go +++ b/pkg/commands/git_commands/submodule.go @@ -225,6 +225,13 @@ func (self *SubmoduleCommands) UpdateAll() error { // temporarily chdir-ing the process there, which would leak the parent // module's directory into whatever other commands run concurrently (e.g. a // background refresh's). +// +// That directory is relative, so it resolves against the process working +// directory rather than against the repo directory the command builder +// otherwise pins commands to. Only foreground commands the user issued end up +// here, and lazygit won't switch repos while one of those is in flight, so the +// two are the same directory; don't call this from background work, where they +// need not be. func (self *SubmoduleCommands) runInParentModule(submodule *models.SubmoduleConfig, cmdObj *oscommands.CmdObj) error { if submodule.ParentModule != nil { cmdObj.SetWd(submodule.ParentModule.FullPath()) From 616d75a1fa0d9b3995d3dd7b3ab9ca19560d7780 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 09:35:42 +0200 Subject: [PATCH 10/15] Run Reset in the parent module the way the other commands do Reset told git to change directory with -C while runInParentModule does it by setting the command's working directory, but they were computing the same directory for the same reason. Use the helper, so that there is one place that knows what running in a nested submodule's parent means. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/submodule.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/pkg/commands/git_commands/submodule.go b/pkg/commands/git_commands/submodule.go index e2012942f..f31bd6196 100644 --- a/pkg/commands/git_commands/submodule.go +++ b/pkg/commands/git_commands/submodule.go @@ -199,16 +199,11 @@ 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() + return self.runInParentModule(submodule, self.cmd.New(cmdArgs)) } func (self *SubmoduleCommands) UpdateAll() error { From 34d41b5d51b22843f053f8164212a95ee055dd31 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 7 Aug 2026 22:56:15 +0200 Subject: [PATCH 11/15] Don't let our repo answer for a different one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GIT_DIR and GIT_WORK_TREE tell git where our repo is, and every command we run inherits them — including the ones we point at a submodule or another worktree. git resolves those against our repo instead, and says nothing about it: with GIT_DIR set, `git -C mysub log -1` reports the superproject's commit. So opening lazygit with --git-dir/--work-tree quietly broke resolving submodule conflicts, stashing and resetting a submodule, and detaching another worktree; the worktree list came back claiming every worktree shared our git dir. Drop the two variables from the commands that address another repo. Co-Authored-By: Claude Opus 5 (1M context) --- .../git_commands/git_command_builder.go | 10 ++++++++ pkg/commands/git_commands/repo_paths.go | 25 ++++++++++++++++++- pkg/commands/git_commands/submodule.go | 10 ++++---- pkg/commands/git_commands/submodule_test.go | 24 ++++++++++++++++++ pkg/commands/git_commands/worktree.go | 2 +- pkg/commands/git_commands/worktree_loader.go | 2 +- pkg/env/env.go | 19 +++++++++----- 7 files changed, 78 insertions(+), 14 deletions(-) diff --git a/pkg/commands/git_commands/git_command_builder.go b/pkg/commands/git_commands/git_command_builder.go index 49e27ec1f..f1a7c87b4 100644 --- a/pkg/commands/git_commands/git_command_builder.go +++ b/pkg/commands/git_commands/git_command_builder.go @@ -6,6 +6,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/config" + "github.com/jesseduffield/lazygit/pkg/env" ) // OptionalLocksEnvVar is the name of the environment variable that tells git @@ -18,6 +19,15 @@ import ( // that opts back in is the foreground files refresh; see FileLoader.gitStatus. const OptionalLocksEnvVar = "GIT_OPTIONAL_LOCKS" +// forOtherRepo prepares a command that operates on a repo other than the one +// we have open — a submodule, or another worktree. GIT_DIR and GIT_WORK_TREE +// say where our repo is, and every command we run inherits them, so a command +// pointed at a different repo would be resolved against ours instead: `git -C +// log` would silently log the superproject's commits. +func forOtherRepo(cmdObj *oscommands.CmdObj) *oscommands.CmdObj { + return cmdObj.RemoveEnvVar(env.GitDirEnvVar).RemoveEnvVar(env.GitWorkTreeEnvVar) +} + // convenience struct for building git commands. Especially useful when // including conditional args type GitCommandBuilder struct { diff --git a/pkg/commands/git_commands/repo_paths.go b/pkg/commands/git_commands/repo_paths.go index 89a86640b..9dbb853ef 100644 --- a/pkg/commands/git_commands/repo_paths.go +++ b/pkg/commands/git_commands/repo_paths.go @@ -176,17 +176,40 @@ func getBareRepoPathsForDir( }, nil } +// Asks git about the repo at dir. This is how we find our own repo, so it has +// to be answered the way git itself would answer it there, GIT_DIR and +// GIT_WORK_TREE included. func callGitRevParseWithDir( cmd oscommands.ICmdObjBuilder, dir string, gitRevArgs ...string, ) (string, error) { + return runGitRevParse(newGitRevParseCmd(cmd, dir, gitRevArgs...)) +} + +// Asks git about a repo that isn't the one we have open; see forOtherRepo. +func callGitRevParseInOtherRepo( + cmd oscommands.ICmdObjBuilder, + dir string, + gitRevArgs ...string, +) (string, error) { + return runGitRevParse(forOtherRepo(newGitRevParseCmd(cmd, dir, gitRevArgs...))) +} + +func newGitRevParseCmd( + cmd oscommands.ICmdObjBuilder, + dir string, + gitRevArgs ...string, +) *oscommands.CmdObj { gitRevParse := NewGitCmd("rev-parse").Arg("--path-format=absolute").Arg(gitRevArgs...) if dir != "" { gitRevParse.Dir(dir) } - gitCmd := cmd.New(gitRevParse.ToArgv()).DontLog() + return cmd.New(gitRevParse.ToArgv()).DontLog() +} + +func runGitRevParse(gitCmd *oscommands.CmdObj) (string, error) { res, err := gitCmd.RunWithOutput() if err != nil { return "", errors.Errorf("'%s' failed: %v", gitCmd.ToString(), err) diff --git a/pkg/commands/git_commands/submodule.go b/pkg/commands/git_commands/submodule.go index f31bd6196..7400f0514 100644 --- a/pkg/commands/git_commands/submodule.go +++ b/pkg/commands/git_commands/submodule.go @@ -157,7 +157,7 @@ func (self *SubmoduleCommands) GetCommitSummary(path string, sha string) (string Config("log.showsignature=false"). ToArgv() - summary, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput() + summary, err := forOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput() return strings.TrimSpace(summary), err } @@ -167,7 +167,7 @@ func (self *SubmoduleCommands) GetCommitSummary(path string, sha string) (string // caller then stages the submodule to record the resolution. func (self *SubmoduleCommands) CheckoutConflictCommit(path string, sha string) error { cmdArgs := NewGitCmd("checkout").Dir(path).Arg(sha).ToArgv() - return self.cmd.New(cmdArgs).Run() + return forOtherRepo(self.cmd.New(cmdArgs)).Run() } // ConflictSideLog returns a oneline log, run inside the submodule, of the commits @@ -179,7 +179,7 @@ func (self *SubmoduleCommands) ConflictSideLog(path string, side string, otherSi Arg("--oneline", "--color=always", otherSide+".."+side). ToArgv() - return self.cmd.New(cmdArgs).DontLog().RunWithOutput() + return forOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput() } func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error { @@ -195,7 +195,7 @@ func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error { Arg("--include-untracked"). ToArgv() - return self.cmd.New(cmdArgs).Run() + return forOtherRepo(self.cmd.New(cmdArgs)).Run() } func (self *SubmoduleCommands) Reset(submodule *models.SubmoduleConfig) error { @@ -229,7 +229,7 @@ func (self *SubmoduleCommands) UpdateAll() error { // need not be. func (self *SubmoduleCommands) runInParentModule(submodule *models.SubmoduleConfig, cmdObj *oscommands.CmdObj) error { if submodule.ParentModule != nil { - cmdObj.SetWd(submodule.ParentModule.FullPath()) + forOtherRepo(cmdObj.SetWd(submodule.ParentModule.FullPath())) } return cmdObj.Run() } diff --git a/pkg/commands/git_commands/submodule_test.go b/pkg/commands/git_commands/submodule_test.go index 279c963df..d449d81de 100644 --- a/pkg/commands/git_commands/submodule_test.go +++ b/pkg/commands/git_commands/submodule_test.go @@ -1,10 +1,13 @@ package git_commands import ( + "strings" "testing" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/jesseduffield/lazygit/pkg/env" + "github.com/samber/lo" "github.com/stretchr/testify/assert" ) @@ -80,6 +83,27 @@ func TestSubmoduleCheckoutConflictCommit(t *testing.T) { runner.CheckForMissingCalls() } +// A command that runs inside a submodule mustn't inherit the GIT_DIR and +// GIT_WORK_TREE that say where the superproject is; git would answer it from +// there instead, and the answer would look perfectly plausible. +func TestSubmoduleCommandDoesntUseOurGitLocation(t *testing.T) { + t.Setenv(env.GitDirEnvVar, "/path/to/repo/.git") + t.Setenv(env.GitWorkTreeEnvVar, "/path/to/repo") + + runner := oscommands.NewFakeRunner(t). + ExpectFunc("has neither GIT_DIR nor GIT_WORK_TREE", func(cmdObj *oscommands.CmdObj) bool { + return lo.NoneBy(cmdObj.GetEnvVars(), func(envVar string) bool { + return strings.HasPrefix(envVar, env.GitDirEnvVar+"=") || + strings.HasPrefix(envVar, env.GitWorkTreeEnvVar+"=") + }) + }, "bbbbbbb the subject\n", nil) + instance := buildSubmoduleCommands(commonDeps{runner: runner}) + + _, err := instance.GetCommitSummary("mysub", "bbbbbbb") + assert.NoError(t, err) + runner.CheckForMissingCalls() +} + func TestSubmoduleConflictSideLog(t *testing.T) { runner := oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-C", "mysub", "log", "--oneline", "--color=always", "ccccccc..bbbbbbb"}, "bbbbbbb left\n", nil) diff --git a/pkg/commands/git_commands/worktree.go b/pkg/commands/git_commands/worktree.go index 986bb6d42..64748b878 100644 --- a/pkg/commands/git_commands/worktree.go +++ b/pkg/commands/git_commands/worktree.go @@ -51,7 +51,7 @@ func (self *WorktreeCommands) Delete(worktreePath string, force bool) error { func (self *WorktreeCommands) Detach(worktreePath string) error { cmdArgs := NewGitCmd("checkout").Arg("--detach").GitDir(filepath.Join(worktreePath, ".git")).ToArgv() - return self.cmd.New(cmdArgs).Run() + return forOtherRepo(self.cmd.New(cmdArgs)).Run() } func WorktreeForBranch(branch *models.Branch, worktrees []*models.Worktree) (*models.Worktree, bool) { diff --git a/pkg/commands/git_commands/worktree_loader.go b/pkg/commands/git_commands/worktree_loader.go index adf1e48dc..f7577c870 100644 --- a/pkg/commands/git_commands/worktree_loader.go +++ b/pkg/commands/git_commands/worktree_loader.go @@ -77,7 +77,7 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) { if worktree.IsPathMissing { return } - gitDir, err := callGitRevParseWithDir(self.cmd, worktree.Path, "--absolute-git-dir") + gitDir, err := callGitRevParseInOtherRepo(self.cmd, worktree.Path, "--absolute-git-dir") if err != nil { self.Log.Warnf("Could not find git dir for worktree %s: %v", worktree.Path, err) return diff --git a/pkg/env/env.go b/pkg/env/env.go index 1ade5b8c6..74d3f7072 100644 --- a/pkg/env/env.go +++ b/pkg/env/env.go @@ -6,23 +6,30 @@ import ( // This package encapsulates accessing/mutating the ENV of the program. +// The variables with which git can be told where a repo is, rather than having +// it find out from the working directory. +const ( + GitDirEnvVar = "GIT_DIR" + GitWorkTreeEnvVar = "GIT_WORK_TREE" +) + func GetGitDirEnv() string { - return os.Getenv("GIT_DIR") + return os.Getenv(GitDirEnvVar) } func SetGitDirEnv(value string) { - os.Setenv("GIT_DIR", value) + os.Setenv(GitDirEnvVar, value) } func GetWorkTreeEnv() string { - return os.Getenv("GIT_WORK_TREE") + return os.Getenv(GitWorkTreeEnvVar) } func SetWorkTreeEnv(value string) { - os.Setenv("GIT_WORK_TREE", value) + os.Setenv(GitWorkTreeEnvVar, value) } func UnsetGitLocationEnvVars() { - _ = os.Unsetenv("GIT_DIR") - _ = os.Unsetenv("GIT_WORK_TREE") + _ = os.Unsetenv(GitDirEnvVar) + _ = os.Unsetenv(GitWorkTreeEnvVar) } From d19af37ee761b5f57d120c6879b76ebe8a6bbf8d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 7 Aug 2026 23:08:40 +0200 Subject: [PATCH 12/15] Tell git where the repo is when it can't find it itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git finds a repo by looking for a .git in the directory a command runs in. Lazygit runs its commands in the work tree, so that normally works — but not when the git dir lives somewhere else entirely, which is what core.worktree and --work-tree are for. Lazygit chdir'd into such a work tree and then ran commands that couldn't see any repo from there, so opening a repo with core.worktree set panicked on startup. It only worked with --git-dir because that leaves GIT_DIR in the environment for every command to inherit. Work out at startup whether git can find the repo from its work tree, and when it can't, put GIT_DIR and GIT_WORK_TREE on every command the repo's builder produces. As with the working directory the builder pins (527124d0e0e4), these also go into the process env — subprocesses don't come through the builder — but the commands don't read them from there, because the process env belongs to whichever repo we have switched to since. Working out whether git can find the repo means asking git, rather than reading the .git file, whose contents can spell the same directory differently than git does. The extra query is skipped for a repo whose git dir is simply its .git directory, which is nearly all of them. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git.go | 9 ++- pkg/commands/git_cmd_obj_builder.go | 14 +++- pkg/commands/git_cmd_obj_builder_test.go | 20 ++++++ pkg/commands/git_commands/repo_paths.go | 43 ++++++++++++ pkg/commands/git_commands/repo_paths_test.go | 19 +++++ pkg/env/env.go | 13 ++++ pkg/integration/tests/test_list.go | 1 + .../worktree/separate_work_tree_config.go | 70 +++++++++++++++++++ 8 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 pkg/integration/tests/worktree/separate_work_tree_config.go diff --git a/pkg/commands/git.go b/pkg/commands/git.go index a5fc0e608..69cddfa48 100644 --- a/pkg/commands/git.go +++ b/pkg/commands/git.go @@ -11,6 +11,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/patch" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/config" + "github.com/jesseduffield/lazygit/pkg/env" "github.com/jesseduffield/lazygit/pkg/utils" ) @@ -79,6 +80,12 @@ func NewGitCommand( return nil, utils.WrapError(err) } + // Everything we run through the command builder gets told where the repo is + // by the builder itself, but subprocesses don't go through it: user-defined + // custom commands, an editor, and the lazygit we re-enter as git's sequence + // editor during a rebase. Put it in the process env for those. + env.SetGitLocationEnvVars(repoPaths.GitLocationEnvVars()) + // Pin the config reads to the repo directory like all other git commands // (see NewGitCmdObjBuilder); the config commands run outside that builder. gitConfig.SetDir(repoPaths.WorktreePath()) @@ -101,7 +108,7 @@ func NewGitCommandAux( repoPaths *git_commands.RepoPaths, diffRendererConfigManager *config.DiffRendererConfigManager, ) *GitCommand { - cmd := NewGitCmdObjBuilder(cmn.Log, osCommand.Cmd, repoPaths.WorktreePath()) + cmd := NewGitCmdObjBuilder(cmn.Log, osCommand.Cmd, repoPaths.WorktreePath(), repoPaths.GitLocationEnvVars()) // here we're doing a bunch of dependency injection for each of our commands structs. // This is admittedly messy, but allows us to test each command struct in isolation, diff --git a/pkg/commands/git_cmd_obj_builder.go b/pkg/commands/git_cmd_obj_builder.go index 9c3cd50d4..d879019eb 100644 --- a/pkg/commands/git_cmd_obj_builder.go +++ b/pkg/commands/git_cmd_obj_builder.go @@ -20,6 +20,13 @@ type gitCmdObjBuilder struct { // the old builder) must keep running its commands against the repo it // started in, not whichever one the process has since moved to. repoDir string + + // The env vars every command we produce gets: the optional-locks one below, + // plus the repo's git location if it has one (see + // RepoPaths.GitLocationEnvVars). Those are in the process env too, but for + // the same reason as repoDir we don't rely on that: the process env belongs + // to whichever repo lazygit has since switched to. + envVars []string } var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{} @@ -30,7 +37,7 @@ var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{} // only the foreground files refresh) opt back in via CmdObj.RemoveEnvVar. var defaultEnvVar = git_commands.OptionalLocksEnvVar + "=0" -func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder, repoDir string) *gitCmdObjBuilder { +func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder, repoDir string, gitLocationEnvVars []string) *gitCmdObjBuilder { // the price of having a convenient interface where we can say .New(...).Run() is that our builder now depends on our runner, so when we want to wrap the default builder/runner in new functionality we need to jump through some hoops. We could avoid the use of a decorator function here by just exporting the runner field on the default builder but that would be misleading because we don't want anybody using that to run commands (i.e. we want there to be a single API used across the codebase) updatedBuilder := innerBuilder.CloneWithNewRunner(func(runner oscommands.ICmdObjRunner) oscommands.ICmdObjRunner { return &gitCmdObjRunner{ @@ -43,15 +50,16 @@ func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuild return &gitCmdObjBuilder{ innerBuilder: updatedBuilder, repoDir: repoDir, + envVars: append([]string{defaultEnvVar}, gitLocationEnvVars...), } } func (self *gitCmdObjBuilder) New(args []string) *oscommands.CmdObj { - return self.innerBuilder.New(args).AddEnvVars(defaultEnvVar).SetWd(self.repoDir) + return self.innerBuilder.New(args).AddEnvVars(self.envVars...).SetWd(self.repoDir) } func (self *gitCmdObjBuilder) NewShell(cmdStr string, shellFunctionsFile string) *oscommands.CmdObj { - return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(defaultEnvVar).SetWd(self.repoDir) + return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(self.envVars...).SetWd(self.repoDir) } func (self *gitCmdObjBuilder) Quote(str string) string { diff --git a/pkg/commands/git_cmd_obj_builder_test.go b/pkg/commands/git_cmd_obj_builder_test.go index 28e21501c..e969baa00 100644 --- a/pkg/commands/git_cmd_obj_builder_test.go +++ b/pkg/commands/git_cmd_obj_builder_test.go @@ -18,6 +18,7 @@ func TestGitCmdObjBuilderDisablesOptionalLocksByDefault(t *testing.T) { utils.NewDummyLog(), oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)), "/path/to/repo", + nil, ) assert.Contains(t, builder.New([]string{"git", "status"}).GetEnvVars(), git_commands.OptionalLocksEnvVar+"=0") @@ -34,8 +35,27 @@ func TestGitCmdObjBuilderPinsCommandsToRepoDir(t *testing.T) { utils.NewDummyLog(), oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)), "/path/to/repo", + nil, ) assert.Equal(t, "/path/to/repo", builder.New([]string{"git", "status"}).GetCmd().Dir) assert.Equal(t, "/path/to/repo", builder.NewShell("git status", "").GetCmd().Dir) } + +// A repo whose git dir isn't in its worktree can't be found by running a +// command there, so the builder has to tell every command where it is; see +// RepoPaths.GitLocationEnvVars. The process env says the same thing, but only +// for the repo lazygit is in right now, which isn't necessarily this one. +func TestGitCmdObjBuilderPinsCommandsToGitLocation(t *testing.T) { + builder := NewGitCmdObjBuilder( + utils.NewDummyLog(), + oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)), + "/path/to/worktree", + []string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"}, + ) + + assert.Subset(t, builder.New([]string{"git", "status"}).GetEnvVars(), + []string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"}) + assert.Subset(t, builder.NewShell("git status", "").GetEnvVars(), + []string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"}) +} diff --git a/pkg/commands/git_commands/repo_paths.go b/pkg/commands/git_commands/repo_paths.go index 9dbb853ef..4e2f8ab1a 100644 --- a/pkg/commands/git_commands/repo_paths.go +++ b/pkg/commands/git_commands/repo_paths.go @@ -8,6 +8,7 @@ import ( "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/jesseduffield/lazygit/pkg/env" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/spf13/afero" ) @@ -19,6 +20,7 @@ type RepoPaths struct { repoGitDirPath string repoName string isBareRepo bool + gitLocationEnvVars []string } // Path to the current worktree. If we're in the main worktree, this will @@ -61,6 +63,16 @@ func (self *RepoPaths) IsBareRepo() bool { return self.isBareRepo } +// The environment that tells git where this repo is, as "NAME=value" entries. +// It is empty for the vast majority of repos, which git finds for itself by +// looking for a .git in the directory a command runs in. It is only non-empty +// when that doesn't work — when the git dir lives somewhere else entirely, +// because of core.worktree or --work-tree — and then every command addressing +// the repo has to carry it. +func (self *RepoPaths) GitLocationEnvVars() []string { + return self.gitLocationEnvVars +} + // Returns the repo paths for a typical repo func MockRepoPaths(currentPath string) *RepoPaths { return &RepoPaths{ @@ -138,9 +150,40 @@ func GetRepoPathsForDir( repoGitDirPath: repoGitDirPath, repoName: repoName, isBareRepo: false, + gitLocationEnvVars: gitLocationEnvVars(cmd, worktreePath, worktreeGitDirPath), }, nil } +// gitLocationEnvVars works out whether git can find the repo by itself when a +// command runs in its worktree, and if it can't, returns the environment that +// tells git where it is. See RepoPaths.GitLocationEnvVars. +func gitLocationEnvVars( + cmd oscommands.ICmdObjBuilder, + worktreePath string, + worktreeGitDirPath string, +) []string { + // The ordinary repo, where the git dir sits in the worktree. Both paths are + // git's own answers from the same invocation, so they are spelled alike and + // comparing them is safe. + if worktreeGitDirPath == filepath.Join(worktreePath, ".git") { + return nil + } + + // A linked worktree or a submodule instead has a .git file naming its git + // dir, and git follows that just as happily. We could read the file, but the + // path in it may well name the same directory differently than git did + // above, so ask git to resolve it — from the worktree and nothing else. + discoveredGitDirPath, err := callGitRevParseInOtherRepo(cmd, worktreePath, "--absolute-git-dir") + if err == nil && discoveredGitDirPath == worktreeGitDirPath { + return nil + } + + return []string{ + env.GitDirEnvVar + "=" + worktreeGitDirPath, + env.GitWorkTreeEnvVar + "=" + worktreePath, + } +} + // getBareRepoPathsForDir is the fallback for when we couldn't ask git for the // work tree. Everything but --show-toplevel works fine without one, so if the // remaining queries succeed we are in a bare repo, and we return what we know diff --git a/pkg/commands/git_commands/repo_paths_test.go b/pkg/commands/git_commands/repo_paths_test.go index fed4905a8..e2827f22b 100644 --- a/pkg/commands/git_commands/repo_paths_test.go +++ b/pkg/commands/git_commands/repo_paths_test.go @@ -142,6 +142,14 @@ func TestGetRepoPaths(t *testing.T) { append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), strings.Join(mockOutput, "\n"), nil) + + // asking git to find the repo from the work tree gets us nowhere, + // because there is no .git there + worktree := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\worktree`, "/path/to/worktree") + runner.ExpectGitArgs( + append([]string{"-C", worktree}, append(getRevParseArgs(), "--absolute-git-dir")...), + "", + errors.New("fatal: not a git repository (or any of the parent directories): .git")) }, Path: "/path/to/repo", Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{ @@ -151,6 +159,7 @@ func TestGetRepoPaths(t *testing.T) { repoGitDirPath: `C:\path\to\repo\.git`, repoName: `worktree`, isBareRepo: false, + gitLocationEnvVars: []string{`GIT_DIR=C:\path\to\repo\.git`, `GIT_WORK_TREE=C:\path\to\worktree`}, }, &RepoPaths{ worktreePath: "/path/to/worktree", worktreeGitDirPath: "/path/to/repo/.git", @@ -158,6 +167,7 @@ func TestGetRepoPaths(t *testing.T) { repoGitDirPath: "/path/to/repo/.git", repoName: "worktree", isBareRepo: false, + gitLocationEnvVars: []string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"}, }), Err: nil, }, @@ -187,6 +197,15 @@ func TestGetRepoPaths(t *testing.T) { append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), strings.Join(mockOutput, "\n"), nil) + + // git finds the submodule's git dir from its work tree, via the + // .git file there + worktree := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\repo\submodule1`, "/path/to/repo/submodule1") + gitDir := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\repo\.git\modules\submodule1`, "/path/to/repo/.git/modules/submodule1") + runner.ExpectGitArgs( + append([]string{"-C", worktree}, append(getRevParseArgs(), "--absolute-git-dir")...), + gitDir, + nil) }, Path: "/path/to/repo/submodule1", Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{ diff --git a/pkg/env/env.go b/pkg/env/env.go index 74d3f7072..5203577ae 100644 --- a/pkg/env/env.go +++ b/pkg/env/env.go @@ -2,6 +2,7 @@ package env import ( "os" + "strings" ) // This package encapsulates accessing/mutating the ENV of the program. @@ -33,3 +34,15 @@ func UnsetGitLocationEnvVars() { _ = os.Unsetenv(GitDirEnvVar) _ = os.Unsetenv(GitWorkTreeEnvVar) } + +// 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. +func SetGitLocationEnvVars(envVars []string) { + UnsetGitLocationEnvVars() + for _, envVar := range envVars { + if name, value, ok := strings.Cut(envVar, "="); ok { + os.Setenv(name, value) + } + } +} diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index a6b5aeafd..9fbc7ef28 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -541,6 +541,7 @@ var tests = []*components.IntegrationTest{ worktree.RemoveWorktreeAndDeleteLocalAndRemoteBranch, worktree.RemoveWorktreeFromBranch, worktree.ResetWindowTabs, + worktree.SeparateWorkTreeConfig, worktree.SymlinkIntoRepoSubdir, worktree.WorktreeInRepo, } diff --git a/pkg/integration/tests/worktree/separate_work_tree_config.go b/pkg/integration/tests/worktree/separate_work_tree_config.go new file mode 100644 index 000000000..e6c036b20 --- /dev/null +++ b/pkg/integration/tests/worktree/separate_work_tree_config.go @@ -0,0 +1,70 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +// This case is like bare_repo_worktree_config.go, except that lazygit isn't +// told where the git dir is: it is started in the directory containing it, and +// finds it the way git does. The work tree is somewhere else entirely, so git +// can't find its way back from there, and every command we run has to be told +// where the repo is. + +var SeparateWorkTreeConfig = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Open lazygit in the git dir of a repo whose work tree is elsewhere, and add a file and commit", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + // we're going to have a directory structure like this: + // project + // - repo (holds the .git dir, and nothing else; lazygit starts here) + // - worktree (holds the files) + // + // 'repo' is the repository/directory that all lazygit tests start in + + shell.CreateFileAndAdd("blah", "original content\n") + shell.Commit("initial commit") + + // point the repo at a work tree outside of it (core.worktree is + // relative to the .git dir), and fill that work tree from HEAD + shell.CreateDir("../worktree") + shell.SetConfig("core.worktree", "../../worktree") + shell.RunCommand([]string{"git", "reset", "--hard"}) + + // the copy of the file we committed from is not in the work tree, so + // git no longer knows anything about it + shell.DeleteFile("blah") + + shell.UpdateFile("../worktree/blah", "updated content\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Lines( + Contains("initial commit"), + ) + + t.Views().Files(). + IsFocused(). + Lines( + Contains(" M blah"), // shows as modified + ). + PressPrimaryAction(). + Press(keys.Files.CommitChanges) + + t.ExpectPopup().CommitMessagePanel(). + Title(Equals("Commit summary")). + Type("Add blah"). + Confirm() + + t.Views().Files(). + IsEmpty() + + t.Views().Commits(). + Lines( + Contains("Add blah"), + Contains("initial commit"), + ) + }, +}) From 9b1078a2ca827ab415318d2a395a50b273e08fc1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 7 Aug 2026 23:11:26 +0200 Subject: [PATCH 13/15] Make StringStack generic Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/gui.go | 6 +++--- pkg/gui/types/common.go | 2 +- pkg/utils/stack.go | 28 ++++++++++++++++++++++++++++ pkg/utils/string_stack.go | 27 --------------------------- 4 files changed, 32 insertions(+), 31 deletions(-) create mode 100644 pkg/utils/stack.go delete mode 100644 pkg/utils/string_stack.go diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index b92c1733e..a463a435b 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -96,7 +96,7 @@ type Gui struct { // 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.StringStack + RepoPathStack *utils.Stack[string] // 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.StringStack { +func (self *StateAccessor) GetRepoPathStack() *utils.Stack[string] { return self.gui.RepoPathStack } @@ -799,7 +799,7 @@ func NewGui( viewBufferManagerMap: map[string]*tasks.ViewBufferManager{}, viewPtmxMap: map[string]oscommands.Pty{}, showRecentRepos: showRecentRepos, - RepoPathStack: &utils.StringStack{}, + RepoPathStack: &utils.Stack[string]{}, RepoStateMap: map[Repo]*GuiRepoState{}, GuiLog: []string{}, diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index b2b924bc0..169b5278e 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -404,7 +404,7 @@ type HasUrn interface { } type IStateAccessor interface { - GetRepoPathStack() *utils.StringStack + GetRepoPathStack() *utils.Stack[string] GetRepoState() IRepoStateAccessor GetDiffRendererConfigManager() *config.DiffRendererConfigManager // tells us whether we're currently updating lazygit diff --git a/pkg/utils/stack.go b/pkg/utils/stack.go new file mode 100644 index 000000000..9cac1f563 --- /dev/null +++ b/pkg/utils/stack.go @@ -0,0 +1,28 @@ +package utils + +type Stack[T any] struct { + stack []T +} + +func (self *Stack[T]) Push(item T) { + self.stack = append(self.stack, item) +} + +func (self *Stack[T]) Pop() T { + if len(self.stack) == 0 { + var zero T + return zero + } + n := len(self.stack) - 1 + last := self.stack[n] + self.stack = self.stack[:n] + return last +} + +func (self *Stack[T]) IsEmpty() bool { + return len(self.stack) == 0 +} + +func (self *Stack[T]) Clear() { + self.stack = nil +} diff --git a/pkg/utils/string_stack.go b/pkg/utils/string_stack.go deleted file mode 100644 index c2d18c70c..000000000 --- a/pkg/utils/string_stack.go +++ /dev/null @@ -1,27 +0,0 @@ -package utils - -type StringStack struct { - stack []string -} - -func (self *StringStack) Push(s string) { - self.stack = append(self.stack, s) -} - -func (self *StringStack) Pop() string { - if len(self.stack) == 0 { - return "" - } - n := len(self.stack) - 1 - last := self.stack[n] - self.stack = self.stack[:n] - return last -} - -func (self *StringStack) IsEmpty() bool { - return len(self.stack) == 0 -} - -func (self *StringStack) Clear() { - self.stack = []string{} -} From 06b421ad0c10c49a2dd4beaced382b67984b75c9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 7 Aug 2026 23:17:21 +0200 Subject: [PATCH 14/15] Remember how to get back to a repo we entered a submodule from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- pkg/env/env.go | 12 ++++ pkg/gui/controllers/helpers/repos_helper.go | 44 +++++++++--- pkg/gui/gui.go | 10 +-- pkg/gui/types/common.go | 11 ++- .../submodule/enter_from_dotfile_bare_repo.go | 72 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 6 files changed, 133 insertions(+), 17 deletions(-) create mode 100644 pkg/integration/tests/submodule/enter_from_dotfile_bare_repo.go 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, From f141fcc5703d851bf93a4caa58d1f1c94370f959 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 11:06:18 +0200 Subject: [PATCH 15/15] Open the repo when lazygit is started in its .git dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running lazygit in a .git dir got you told you were in a bare repo, which you weren't: the worktree was sitting right there, one directory up. git's own convention is that a git dir called .git belongs to the directory holding it — that's how `git worktree list` names the main worktree — so ask that directory, and if it is a worktree, open the repo we were really being asked about. The git dirs that aren't called .git keep the answer they had. A linked worktree's and a submodule's do have a worktree, but nothing we look at says where, so we would be guessing; a bare repo's has none to find. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/repo_paths.go | 49 +++++++++++++++++-- pkg/commands/git_commands/repo_paths_test.go | 42 ++++++++++++++++ .../tests/misc/start_in_git_dir.go | 34 +++++++++++++ pkg/integration/tests/test_list.go | 1 + 4 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 pkg/integration/tests/misc/start_in_git_dir.go diff --git a/pkg/commands/git_commands/repo_paths.go b/pkg/commands/git_commands/repo_paths.go index 4e2f8ab1a..61afc943f 100644 --- a/pkg/commands/git_commands/repo_paths.go +++ b/pkg/commands/git_commands/repo_paths.go @@ -56,9 +56,19 @@ func (self *RepoPaths) RepoName() string { return self.repoName } -// Whether the repo has no worktree, so that there is nothing for lazygit to -// show. Note that this isn't quite git's core.bare: a repo that calls itself -// non-bare but doesn't have a worktree either counts as bare for us. +// Whether we found no worktree, so that there is nothing for lazygit to show. +// Note that this isn't quite git's core.bare: a repo that calls itself non-bare +// but whose worktree we couldn't find counts as bare for us too. Concretely, +// this is true when we're in +// +// - a genuinely bare repo; +// - the git dir of a linked worktree (.git/worktrees/x), whose worktree is +// recorded but not somewhere we look; +// - a repo that keeps its worktree somewhere only GIT_WORK_TREE knows, such +// as a vcsh-style dotfiles repo that hasn't been given core.worktree. +// +// The .git dir of an ordinary repo is not one of them: GetRepoPathsForDir +// notices the worktree holding it and hands back that repo instead. func (self *RepoPaths) IsBareRepo() bool { return self.isBareRepo } @@ -99,6 +109,39 @@ func GetRepoPaths( func GetRepoPathsForDir( dir string, cmd oscommands.ICmdObjBuilder, +) (*RepoPaths, error) { + repoPaths, err := repoPathsForDir(dir, cmd) + if err != nil || !repoPaths.IsBareRepo() { + return repoPaths, err + } + + // We're in a git dir rather than in a working tree, which usually just means + // somebody ran lazygit in the .git of an ordinary repo. git's convention is + // that a git dir called .git belongs to the directory holding it, so look + // there: if that is a working tree, it is the repo we were asked about, and + // there's no reason to make the user go up a directory and try again. + // + // The git dirs that aren't called .git keep the paths we have. A linked + // worktree's (.git/worktrees/x) and a submodule's (.git/modules/x) do have a + // working tree, but only the directory holding a .git tells us where, so we + // would be guessing. A bare repo's has none to find. + if filepath.Base(repoPaths.WorktreeGitDirPath()) != ".git" { + return repoPaths, nil + } + + pathsFromWorkTree, err := repoPathsForDir(filepath.Dir(repoPaths.WorktreeGitDirPath()), cmd) + if err != nil || pathsFromWorkTree.IsBareRepo() { + return repoPaths, nil + } + return pathsFromWorkTree, nil +} + +// repoPathsForDir asks git about the repo at dir, and reports a bare repo when +// there is no working tree there. Unlike GetRepoPathsForDir it never looks +// anywhere but dir, which is what keeps that one from going round in circles. +func repoPathsForDir( + dir string, + cmd oscommands.ICmdObjBuilder, ) (*RepoPaths, error) { gitDirOutput, err := callGitRevParseWithDir(cmd, dir, "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree") if err != nil { diff --git a/pkg/commands/git_commands/repo_paths_test.go b/pkg/commands/git_commands/repo_paths_test.go index e2827f22b..c7b7705b4 100644 --- a/pkg/commands/git_commands/repo_paths_test.go +++ b/pkg/commands/git_commands/repo_paths_test.go @@ -115,6 +115,48 @@ func TestGetRepoPaths(t *testing.T) { }), Err: nil, }, + { + // Standing in the .git dir of an ordinary repo: git refuses to name a + // work tree, but the directory holding the .git is one, so we open the + // repo from there. + Name: "in a repo's .git dir", + BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) { + gitDir := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\repo\.git`, "/path/to/repo/.git") + worktree := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\repo`, "/path/to/repo") + + runner.ExpectGitArgs( + append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), + "", + errors.New("fatal: this operation must be run in a work tree")) + runner.ExpectGitArgs( + append(getRevParseArgs(), "--absolute-git-dir", "--git-common-dir"), + strings.Join([]string{gitDir, gitDir}, "\n"), + nil) + + // asking again from the directory holding the .git + runner.ExpectGitArgs( + append(append([]string{"-C", worktree}, getRevParseArgs()...), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), + strings.Join([]string{worktree, gitDir, gitDir}, "\n"), + nil) + }, + Path: "/path/to/repo/.git", + Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{ + worktreePath: `C:\path\to\repo`, + worktreeGitDirPath: `C:\path\to\repo\.git`, + repoPath: `C:\path\to\repo`, + repoGitDirPath: `C:\path\to\repo\.git`, + repoName: `repo`, + isBareRepo: false, + }, &RepoPaths{ + worktreePath: "/path/to/repo", + worktreeGitDirPath: "/path/to/repo/.git", + repoPath: "/path/to/repo", + repoGitDirPath: "/path/to/repo/.git", + repoName: "repo", + isBareRepo: false, + }), + Err: nil, + }, { // A repo whose work tree lives somewhere else entirely, as set up by // core.worktree or by --work-tree. We're in the main worktree, but the diff --git a/pkg/integration/tests/misc/start_in_git_dir.go b/pkg/integration/tests/misc/start_in_git_dir.go new file mode 100644 index 000000000..5fffcc2a9 --- /dev/null +++ b/pkg/integration/tests/misc/start_in_git_dir.go @@ -0,0 +1,34 @@ +package misc + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var StartInGitDir = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Start lazygit in a repo's .git dir, and have it open the repo", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("blah", "original content\n") + shell.Commit("initial commit") + shell.UpdateFile("blah", "updated content\n") + + // this is where lazygit will start + shell.Chdir(".git") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Lines( + Contains("initial commit"), + ) + + // we're in the work tree the .git belongs to, not in the .git itself + t.Views().Files(). + IsFocused(). + Lines( + Contains(" M blah"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 9598982d3..bc2c25e09 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -353,6 +353,7 @@ var tests = []*components.IntegrationTest{ misc.DirenvUnloadsOnBlockedEnvrc, misc.InitialOpen, misc.RecentReposOnLaunch, + misc.StartInGitDir, patch_building.Apply, patch_building.ApplyInReverse, patch_building.ApplyInReverseWithConflict,