Add direnv support (#5660)

Supports loading direnv's environment files (`.envrc`) when switching
repos or worktrees, or when entering or exiting submodules.

There's no configuration for this; the functionality is automatically
enabled when direnv is installed.

Closes #3653.
This commit is contained in:
Stefan Haller 2026-06-04 09:10:50 +02:00 committed by GitHub
commit 38526c9ec4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 539 additions and 10 deletions

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,17 @@ func openRecentRepo(app *App) bool {
for _, repoDir := range app.Config.GetAppState().RecentRepos {
if isRepo, _ := isDirectoryAGitRepository(repoDir); isRepo {
if err := os.Chdir(repoDir); err == nil {
// We're still in setup, before the gui exists, so we can't show the approval popup
// that DispatchSwitchTo offers for blocked .envrc files; just log and move on.
// Also, the logs only go to the debug log, not the Command Log, because that's not
// available yet, either.
result := direnv.Load(app.OSCommand.Cmd)
if result.Message != "" {
app.Log.WithField("message", result.Message).Info("direnv")
}
if result.Err != nil {
app.Log.WithError(result.Err).Warn("direnv load failed")
}
return true
}
}
@ -239,12 +251,8 @@ func (app *App) setupRepo(
}
// check if we have a recent repo we can open
for _, repoDir := range app.Config.GetAppState().RecentRepos {
if isRepo, _ := isDirectoryAGitRepository(repoDir); isRepo {
if err := os.Chdir(repoDir); err == nil {
return true, nil
}
}
if openRecentRepo(app) {
return true, nil
}
fmt.Fprintln(os.Stderr, app.Tr.NoRecentRepositories)
@ -262,7 +270,7 @@ func (app *App) setupRepo(
os.Exit(0)
}
if didOpenRepo := openRecentRepo(app); didOpenRepo {
if openRecentRepo(app) {
return true, nil
}

View file

@ -0,0 +1,130 @@
package direnv
import (
"bytes"
"encoding/json"
"os"
"os/exec"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
)
// LoadResult bundles everything callers might want to know about a direnv
// invocation. The env-var delta has already been applied to the process by
// the time Load returns.
type LoadResult struct {
// Message is whatever direnv printed to stderr — useful to log
// (success: "direnv: loading .envrc"; error: the error text).
Message string
// Err is non-nil when direnv exited non-zero or its stdout could
// not be parsed.
Err error
// Blocked is true when the target .envrc exists but hasn't been
// approved with `direnv allow` yet. EnvrcPath then holds the path
// direnv said was blocked, suitable for passing to Allow.
Blocked bool
EnvrcPath string
}
// 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.
func Load(cmd oscommands.ICmdObjBuilder) LoadResult {
if _, lookupErr := exec.LookPath("direnv"); lookupErr != nil {
return LoadResult{}
}
stdout, stderr, runErr := cmd.New([]string{
"direnv", "export", "json",
}).DontLog().RunWithOutputs()
result := LoadResult{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 {
result.Err = runErr
if envrcPath := queryBlockedEnvrc(cmd); envrcPath != "" {
result.Blocked = true
result.EnvrcPath = envrcPath
}
} else {
result.Err = parseErr
}
return result
}
// Allow runs `direnv allow <envrcPath>` to approve a .envrc file so the next
// Load can read it.
func Allow(cmd oscommands.ICmdObjBuilder, envrcPath string) error {
return cmd.New([]string{"direnv", "allow", envrcPath}).DontLog().Run()
}
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
}
// queryBlockedEnvrc asks direnv (via `status --json`) whether the current
// directory has a found-but-not-yet-allowed .envrc, and returns its path
// if so. We use direnv's structured output rather than parsing the
// human-readable "is blocked" line because the status output is more
// stable across versions and locales.
func queryBlockedEnvrc(cmd oscommands.ICmdObjBuilder) string {
stdout, _, err := cmd.New([]string{
"direnv", "status", "--json",
}).DontLog().RunWithOutputs()
if err != nil {
return ""
}
return parseDirenvStatus([]byte(stdout))
}
func parseDirenvStatus(stdout []byte) string {
var status struct {
State struct {
FoundRC *struct {
Allowed int `json:"allowed"`
Path string `json:"path"`
} `json:"foundRC"`
} `json:"state"`
}
if err := json.Unmarshal(stdout, &status); err != nil {
return ""
}
if status.State.FoundRC == nil {
return ""
}
// direnv's AllowStatus enum (`internal/cmd/rc.go`): 0=Allowed,
// 1=NotAllowed, 2=Denied. Only NotAllowed is something the user
// can approve; Denied means they already said no.
const notAllowed = 1
if status.State.FoundRC.Allowed != notAllowed {
return ""
}
return status.State.FoundRC.Path
}

View file

@ -0,0 +1,88 @@
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)
}
})
}
}
func TestParseDirenvStatus(t *testing.T) {
scenarios := []struct {
name string
input string
want string
}{
{
name: "no .envrc found",
input: `{"state":{"foundRC":null}}`,
want: "",
},
{
name: "found and allowed (0)",
input: `{"state":{"foundRC":{"allowed":0,"path":"/repo/.envrc"}}}`,
want: "",
},
{
name: "found but not allowed (1) — eligible for approval",
input: `{"state":{"foundRC":{"allowed":1,"path":"/repo/.envrc"}}}`,
want: "/repo/.envrc",
},
{
name: "found but denied (2) — user already said no",
input: `{"state":{"foundRC":{"allowed":2,"path":"/repo/.envrc"}}}`,
want: "",
},
{
name: "malformed JSON",
input: `{not json`,
want: "",
},
{
name: "empty input",
input: "",
want: "",
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.want, parseDirenvStatus([]byte(s.input)))
})
}
}

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,13 +171,70 @@ func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey
return err
}
direnvResult := self.logDirenvResult(direnv.Load(self.c.OS().Cmd))
if err := self.recordDirectoryHelper.RecordCurrentDirectory(); err != nil {
return err
self.c.Log.Errorf("error recording current directory: %v", err)
}
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
}
if direnvResult.Blocked {
self.c.OnUIThread(func() error {
self.promptDirenvApproval(direnvResult.EnvrcPath)
return nil
})
return nil
}
return direnvResult.Err
})
}
// logDirenvResult writes whatever direnv emitted to the command log and the
// debug log; both happen for every load attempt regardless of outcome.
func (self *ReposHelper) logDirenvResult(result direnv.LoadResult) direnv.LoadResult {
if result.Message != "" {
self.c.LogCommand(result.Message, false)
}
if result.Err != nil {
self.c.Log.WithError(result.Err).Warn("direnv load failed")
}
return result
}
// promptDirenvApproval shows the user the contents of an unapproved .envrc
// and offers to run `direnv allow` for them. On confirm, we approve the
// file and re-run Load so the new env reaches subprocesses; on cancel we
// leave the env as-is (the previous repo's vars are already unloaded by
// the initial Load call, which is the correct state).
func (self *ReposHelper) promptDirenvApproval(envrcPath string) {
content, err := os.ReadFile(envrcPath)
if err != nil {
self.c.Log.WithError(err).Warn("could not read .envrc for approval prompt")
return
}
indented := " " + strings.ReplaceAll(strings.TrimRight(string(content), "\n"), "\n", "\n ")
prompt := utils.ResolvePlaceholderString(self.c.Tr.DirenvApprovalPrompt, map[string]string{
"confirmKey": self.c.UserConfig().Keybinding.Universal.Confirm.String(),
"cancelKey": self.c.UserConfig().Keybinding.Universal.Return.String(),
"content": indented,
})
self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.DirenvApprovalTitle,
Prompt: prompt,
HandleConfirm: func() error {
if err := direnv.Allow(self.c.OS().Cmd, envrcPath); err != nil {
return err
}
return self.logDirenvResult(direnv.Load(self.c.OS().Cmd)).Err
},
})
}

View file

@ -891,6 +891,8 @@ type TranslationSet struct {
CreateWorktreeFromDetached string
LcWorktree string
ChangingDirectoryTo string
DirenvApprovalTitle string
DirenvApprovalPrompt string
Name string
Branch string
Path string
@ -2012,6 +2014,8 @@ func EnglishTranslationSet() *TranslationSet {
CreateWorktreeFromDetached: "Create worktree from {{.ref}} (detached)",
LcWorktree: "worktree",
ChangingDirectoryTo: "Changing directory to {{.path}}",
DirenvApprovalTitle: "Approve .envrc?",
DirenvApprovalPrompt: "Press {{.confirmKey}} to run 'direnv allow' and load the environment.\nPress {{.cancelKey}} to skip.\n\n{{.content}}",
Name: "Name",
Branch: "Branch",
Path: "Path",

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))
}
}

View file

@ -0,0 +1,90 @@
package misc
import (
"os"
"path/filepath"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
// When the new repo's .envrc is blocked, lazygit offers the user a popup to
// approve it without leaving the app. Confirming runs `direnv allow` and
// re-runs the load so the env reaches subprocesses immediately.
var DirenvApprovesEnvrc = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Approving a blocked .envrc from the in-app popup loads its env",
ExtraCmdArgs: []string{},
ExtraEnvVars: map[string]string{
"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")
shell.CreateFile("../other/.envrc", "export LG_DIRENV_TEST=approved_value\n")
// Fake direnv that flips behavior once `direnv allow` runs.
// Before allow: export errors with the "blocked" signal,
// status reports allowed=1 (NotAllowed).
// On allow: create a sentinel and exit 0.
// After allow: export emits the loaded delta normally.
shell.CreateFile("../bin/direnv", `#!/bin/sh
SENTINEL="$(dirname "$0")/.approved"
case "$1 $2" in
"allow "*)
touch "$SENTINEL"
exit 0
;;
"export json")
if [ -f "$SENTINEL" ]; then
echo '{"LG_DIRENV_TEST":"approved_value"}'
echo "direnv: loading $PWD/.envrc" >&2
else
echo '{"LG_DIRENV_TEST":null}'
echo "direnv: error $PWD/.envrc is blocked" >&2
exit 1
fi
;;
"status --json")
if [ -f "$SENTINEL" ]; then
printf '{"state":{"foundRC":{"allowed":0,"path":"%s/.envrc"}}}\n' "$PWD"
else
printf '{"state":{"foundRC":{"allowed":1,"path":"%s/.envrc"}}}\n' "$PWD"
fi
;;
esac
`)
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().Confirmation().
Title(Equals("Approve .envrc?")).
Content(Contains("export LG_DIRENV_TEST=approved_value")).
Confirm()
t.Views().Files().
Focus().
Press(config.Keybinding{"X"}).
NavigateToLine(Contains("output.txt"))
t.Views().Main().Content(Contains("VAR=approved_value"))
},
})

View file

@ -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"))
},
})

View file

@ -0,0 +1,76 @@
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. This test exercises the
// "skip approval" branch: the approval popup appears, the user cancels,
// and the previous repo's env is still gone.
var DirenvUnloadsOnBlockedEnvrc = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Blocked .envrc unloads the previous repo's env even if the user skips approval",
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("../other/.envrc", "export LG_DIRENV_TEST=from_envrc\n")
shell.CreateFile("../bin/direnv", `#!/bin/sh
case "$1 $2" in
"export json")
echo '{"LG_DIRENV_TEST":null}'
echo "direnv: error $PWD/.envrc is blocked" >&2
exit 1
;;
"status --json")
printf '{"state":{"foundRC":{"allowed":1,"path":"%s/.envrc"}}}\n' "$PWD"
;;
esac
`)
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().Confirmation().
Title(Equals("Approve .envrc?")).
Content(Contains("export LG_DIRENV_TEST=from_envrc")).
Cancel()
t.Views().Files().
Focus().
Press(config.Keybinding{"X"}).
NavigateToLine(Contains("output.txt"))
t.Views().Main().Content(Contains("VAR=[]"))
},
})

View file

@ -334,6 +334,9 @@ var tests = []*components.IntegrationTest{
misc.ConfirmOnQuit,
misc.CopyConfirmationMessageToClipboard,
misc.CopyToClipboard,
misc.DirenvApprovesEnvrc,
misc.DirenvLoadedOnRepoSwitch,
misc.DirenvUnloadsOnBlockedEnvrc,
misc.InitialOpen,
misc.RecentReposOnLaunch,
patch_building.Apply,