mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
Tell git where the repo is when it can't find it itself
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
(527124d0e0), 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) <noreply@anthropic.com>
This commit is contained in:
parent
34d41b5d51
commit
d19af37ee7
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
13
pkg/env/env.go
vendored
13
pkg/env/env.go
vendored
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -541,6 +541,7 @@ var tests = []*components.IntegrationTest{
|
|||
worktree.RemoveWorktreeAndDeleteLocalAndRemoteBranch,
|
||||
worktree.RemoveWorktreeFromBranch,
|
||||
worktree.ResetWindowTabs,
|
||||
worktree.SeparateWorkTreeConfig,
|
||||
worktree.SymlinkIntoRepoSubdir,
|
||||
worktree.WorktreeInRepo,
|
||||
}
|
||||
|
|
|
|||
70
pkg/integration/tests/worktree/separate_work_tree_config.go
Normal file
70
pkg/integration/tests/worktree/separate_work_tree_config.go
Normal file
|
|
@ -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"),
|
||||
)
|
||||
},
|
||||
})
|
||||
Loading…
Reference in a new issue