Load direnv environment when switching repos

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) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-06-04 09:05:01 +02:00
co-authored by Claude Opus 4.7
parent 2601556189
commit bb8955f2de
8 changed files with 274 additions and 2 deletions
+10
View File
@@ -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
}
}
+62
View File
@@ -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
}
+43
View File
@@ -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)
}
})
}
}
+14 -1
View File
@@ -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
})
}
+5 -1
View File
@@ -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))
}
}
@@ -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"))
},
})
@@ -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=[]"))
},
})
+2
View File
@@ -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,