From bb8955f2de2db03abb964b2feeb1278590a6d02b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 28 May 2026 10:07:00 +0200 Subject: [PATCH] Load direnv environment when switching repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user opens a repo from the recent-repos menu or jumps between worktrees inside lazygit, only the env vars present at process startup reach subprocesses. That breaks pre-commit hooks and other tools whose dependencies are pulled in by a per-repo .envrc — users were left with read-only operations because the env their shell would normally load via direnv never made it into lazygit's git invocations. Shell out to `direnv export json` after each chdir and apply the JSON delta via os.Setenv/Unsetenv. direnv tracks the previous load in its own DIRENV_DIFF env var, so the delta also unloads vars from the old repo when entering one without a matching .envrc. If direnv isn't on PATH the call is a no-op, so users who don't use direnv pay nothing and users who do need no config to opt in. Any stderr direnv emits (loading messages, "blocked .envrc" errors, etc.) goes to the command log. The integration test puts a fake direnv on PATH and asserts that a value it exports reaches a custom command after switching repos. Wiring this up needed runner.go to support `{{actualPath}}` placeholders in ExtraEnvVars, mirroring the existing support for ExtraCmdArgs, so the test can prepend a fixture-relative directory to PATH. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/app/app.go | 10 +++ pkg/commands/direnv/direnv.go | 62 ++++++++++++++++ pkg/commands/direnv/direnv_test.go | 43 ++++++++++++ pkg/gui/controllers/helpers/repos_helper.go | 15 +++- pkg/integration/components/runner.go | 6 +- .../misc/direnv_loaded_on_repo_switch.go | 68 ++++++++++++++++++ .../misc/direnv_unloads_on_blocked_envrc.go | 70 +++++++++++++++++++ pkg/integration/tests/test_list.go | 2 + 8 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 pkg/commands/direnv/direnv.go create mode 100644 pkg/commands/direnv/direnv_test.go create mode 100644 pkg/integration/tests/misc/direnv_loaded_on_repo_switch.go create mode 100644 pkg/integration/tests/misc/direnv_unloads_on_blocked_envrc.go diff --git a/pkg/app/app.go b/pkg/app/app.go index 9af0c46d7..1f49a6a23 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/afero" appTypes "github.com/jesseduffield/lazygit/pkg/app/types" + "github.com/jesseduffield/lazygit/pkg/commands/direnv" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/common" @@ -171,6 +172,15 @@ func openRecentRepo(app *App) bool { for _, repoDir := range app.Config.GetAppState().RecentRepos { if isRepo, _ := isDirectoryAGitRepository(repoDir); isRepo { if err := os.Chdir(repoDir); err == nil { + // The command log isn't up yet, so any direnv diagnostics + // only make it to the debug log here. + msg, derr := direnv.Load(app.OSCommand.Cmd) + if msg != "" { + app.Log.WithField("message", msg).Info("direnv") + } + if derr != nil { + app.Log.WithError(derr).Warn("direnv load failed") + } return true } } diff --git a/pkg/commands/direnv/direnv.go b/pkg/commands/direnv/direnv.go new file mode 100644 index 000000000..9d5d2d1e1 --- /dev/null +++ b/pkg/commands/direnv/direnv.go @@ -0,0 +1,62 @@ +package direnv + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "strings" + + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" +) + +// Load runs `direnv export json` for the current working directory and applies +// the resulting env-var delta to the current process. If direnv isn't on PATH, +// it's a no-op — users who don't use direnv pay nothing, and users who do need +// no config to opt in. +// +// direnv prints diagnostics to stderr ("direnv: loading .envrc", "direnv: +// error /path/.envrc is blocked", etc.); whatever it printed is returned in +// message so callers can surface it in their command log. +func Load(cmd oscommands.ICmdObjBuilder) (message string, err error) { + if _, lookupErr := exec.LookPath("direnv"); lookupErr != nil { + return "", nil + } + + stdout, stderr, runErr := cmd.New([]string{ + "direnv", "export", "json", + }).DontLog().RunWithOutputs() + message = strings.TrimRight(stderr, "\n") + + // Apply whatever delta direnv produced even if it exited non-zero. + // When the new dir's .envrc is blocked, direnv still emits a valid + // JSON delta on stdout that unloads vars from the previous dir; + // without applying it the old env would leak into the new repo. + delta, parseErr := parseDirenvExport([]byte(stdout)) + for k, v := range delta { + if v == nil { + _ = os.Unsetenv(k) + } else { + _ = os.Setenv(k, *v) + } + } + + // Prefer the runtime error (whose Error() text is direnv's stderr) + // over a parse error, since it's the more actionable signal. + if runErr != nil { + return message, runErr + } + return message, parseErr +} + +func parseDirenvExport(stdout []byte) (map[string]*string, error) { + trimmed := bytes.TrimSpace(stdout) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return nil, nil + } + var delta map[string]*string + if err := json.Unmarshal(trimmed, &delta); err != nil { + return nil, err + } + return delta, nil +} diff --git a/pkg/commands/direnv/direnv_test.go b/pkg/commands/direnv/direnv_test.go new file mode 100644 index 000000000..69b102d4d --- /dev/null +++ b/pkg/commands/direnv/direnv_test.go @@ -0,0 +1,43 @@ +package direnv + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseDirenvExport(t *testing.T) { + hello := "hello" + empty := "" + + scenarios := []struct { + name string + input string + want map[string]*string + wantErr bool + }{ + {name: "empty stdout means no .envrc was loaded", input: "", want: nil}, + {name: "literal null from direnv means no delta", input: "null", want: nil}, + {name: "empty object means no delta", input: "{}", want: map[string]*string{}}, + {name: "string value is a set", input: `{"FOO":"hello"}`, want: map[string]*string{"FOO": &hello}}, + {name: "null value is an unset", input: `{"FOO":null}`, want: map[string]*string{"FOO": nil}}, + { + name: "set and unset can coexist", + input: `{"FOO":"hello","BAR":null,"BAZ":""}`, + want: map[string]*string{"FOO": &hello, "BAR": nil, "BAZ": &empty}, + }, + {name: "malformed JSON is an error", input: `{not json`, wantErr: true}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + got, err := parseDirenvExport([]byte(s.input)) + if s.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, s.want, got) + } + }) + } +} diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index 4da15fa13..f8972b837 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -10,6 +10,7 @@ import ( appTypes "github.com/jesseduffield/lazygit/pkg/app/types" "github.com/jesseduffield/lazygit/pkg/commands" + "github.com/jesseduffield/lazygit/pkg/commands/direnv" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/env" "github.com/jesseduffield/lazygit/pkg/gocui" @@ -170,6 +171,14 @@ func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey return err } + direnvMsg, direnvErr := direnv.Load(self.c.OS().Cmd) + if direnvMsg != "" { + self.c.LogCommand(direnvMsg, false) + } + if direnvErr != nil { + self.c.Log.WithError(direnvErr).Warn("direnv load failed") + } + if err := self.recordDirectoryHelper.RecordCurrentDirectory(); err != nil { self.c.Log.Errorf("error recording current directory: %v", err) } @@ -177,6 +186,10 @@ func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey self.c.Mutexes().RefreshingFilesMutex.Lock() defer self.c.Mutexes().RefreshingFilesMutex.Unlock() - return self.onNewRepo(appTypes.StartArgs{}, contextKey) + if err := self.onNewRepo(appTypes.StartArgs{}, contextKey); err != nil { + return err + } + + return direnvErr }) } diff --git a/pkg/integration/components/runner.go b/pkg/integration/components/runner.go index 83ddfe66d..5640c3e70 100644 --- a/pkg/integration/components/runner.go +++ b/pkg/integration/components/runner.go @@ -246,7 +246,11 @@ func getLazygitCommand( cmdObj.AddEnvVars(fmt.Sprintf("GORACE=log_path=%s", raceDetectorLogsPath())) if test.ExtraEnvVars() != nil { for key, value := range test.ExtraEnvVars() { - cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", key, value)) + resolvedValue := utils.ResolvePlaceholderString(value, map[string]string{ + "actualPath": paths.Actual(), + "actualRepoPath": paths.ActualRepo(), + }) + cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", key, resolvedValue)) } } diff --git a/pkg/integration/tests/misc/direnv_loaded_on_repo_switch.go b/pkg/integration/tests/misc/direnv_loaded_on_repo_switch.go new file mode 100644 index 000000000..14470da88 --- /dev/null +++ b/pkg/integration/tests/misc/direnv_loaded_on_repo_switch.go @@ -0,0 +1,68 @@ +package misc + +import ( + "os" + "path/filepath" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +// Verifies that when the user switches repos from inside lazygit, env vars +// that direnv would load for the target repo are applied to subprocesses +// (custom commands, git hooks, etc.). The test puts a fake `direnv` binary +// on PATH so it works regardless of whether the host has real direnv +// installed. +var DirenvLoadedOnRepoSwitch = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Switching repos applies direnv-loaded env vars to subprocesses", + ExtraCmdArgs: []string{}, + ExtraEnvVars: map[string]string{ + // Prepend a dir under the test fixture to PATH so our fake direnv + // wins lookup. The placeholder is resolved at run time. + "PATH": "{{actualPath}}/bin:" + os.Getenv("PATH"), + }, + SetupConfig: func(cfg *config.AppConfig) { + otherRepo, _ := filepath.Abs("../other") + cfg.GetAppState().RecentRepos = []string{otherRepo} + cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ + { + Key: config.Keybinding{"X"}, + Context: "files", + Command: `echo "VAR=$LG_DIRENV_TEST" > output.txt`, + }, + } + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("initial") + shell.CloneNonBare("other") + + // Fake direnv: echoes a fixed JSON delta on stdout (set + // LG_DIRENV_TEST) and a "loading" line on stderr, exactly as + // real direnv would after authorizing an .envrc. + shell.CreateFile("../bin/direnv", `#!/bin/sh +echo '{"LG_DIRENV_TEST":"from_direnv"}' +echo "direnv: loading .envrc" >&2 +`) + shell.MakeExecutable("../bin/direnv") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // Switch to the "other" repo via the recent-repos menu. + t.GlobalPress(keys.Universal.OpenRecentRepos) + t.ExpectPopup().Menu().Title(Equals("Recent repositories")). + Lines( + Contains("other").IsSelected(), + Contains("Cancel"), + ). + Confirm() + + // Run the custom command; if direnv loading worked, $LG_DIRENV_TEST + // reaches the subprocess and ends up in output.txt. + t.Views().Files(). + Focus(). + Press(config.Keybinding{"X"}). + Lines( + Contains("output.txt").IsSelected(), + ) + t.Views().Main().Content(Contains("VAR=from_direnv")) + }, +}) diff --git a/pkg/integration/tests/misc/direnv_unloads_on_blocked_envrc.go b/pkg/integration/tests/misc/direnv_unloads_on_blocked_envrc.go new file mode 100644 index 000000000..ca7afe104 --- /dev/null +++ b/pkg/integration/tests/misc/direnv_unloads_on_blocked_envrc.go @@ -0,0 +1,70 @@ +package misc + +import ( + "os" + "path/filepath" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +// Real direnv exits non-zero when the destination .envrc isn't authorized, +// but it still emits a valid JSON delta on stdout that unloads vars from +// the previously-active .envrc. We have to apply that delta anyway, or the +// previous repo's env leaks into the new one. The fake direnv here mimics +// that behavior; the test also asserts that the user gets an error popup +// (the command log alone is easy to miss). +var DirenvUnloadsOnBlockedEnvrc = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Blocked .envrc unloads the previous repo's env and shows an error popup", + ExtraCmdArgs: []string{}, + ExtraEnvVars: map[string]string{ + "PATH": "{{actualPath}}/bin:" + os.Getenv("PATH"), + // Simulates a var that the previous repo's .envrc would have set. + "LG_DIRENV_TEST": "from_previous_repo", + }, + SetupConfig: func(cfg *config.AppConfig) { + otherRepo, _ := filepath.Abs("../other") + cfg.GetAppState().RecentRepos = []string{otherRepo} + cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ + { + Key: config.Keybinding{"X"}, + Context: "files", + Command: `echo "VAR=[$LG_DIRENV_TEST]" > output.txt`, + }, + } + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("initial") + shell.CloneNonBare("other") + + shell.CreateFile("../bin/direnv", `#!/bin/sh +echo '{"LG_DIRENV_TEST":null}' +echo "direnv: error /repo/.envrc is blocked. Run 'direnv allow' to approve its content" >&2 +exit 1 +`) + shell.MakeExecutable("../bin/direnv") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.GlobalPress(keys.Universal.OpenRecentRepos) + t.ExpectPopup().Menu().Title(Equals("Recent repositories")). + Lines( + Contains("other").IsSelected(), + Contains("Cancel"), + ). + Confirm() + + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Contains("is blocked")). + Confirm() + + // If unload worked, $LG_DIRENV_TEST is empty in the custom command. + t.Views().Files(). + Focus(). + Press(config.Keybinding{"X"}). + Lines( + Contains("output.txt").IsSelected(), + ) + t.Views().Main().Content(Contains("VAR=[]")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 2bf2837cd..3679099e0 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -334,6 +334,8 @@ var tests = []*components.IntegrationTest{ misc.ConfirmOnQuit, misc.CopyConfirmationMessageToClipboard, misc.CopyToClipboard, + misc.DirenvLoadedOnRepoSwitch, + misc.DirenvUnloadsOnBlockedEnvrc, misc.InitialOpen, misc.RecentReposOnLaunch, patch_building.Apply,