Offer direnv .envrc approval from inside lazygit

When a user switches into a repo whose .envrc hasn't been approved with
`direnv allow`, the previous behavior was to drop a "blocked" error
popup and leave the user to fix it externally. That meant opening a
terminal, running `direnv allow`, and then either restarting lazygit or
switching repos and back to refresh the env — easy to get wrong, easy
to forget.

When `direnv export json` exits non-zero, follow up with `direnv status
--json` to ask direnv whether the current directory has a not-yet-
allowed .envrc, and if so, get its path. Then show a confirmation popup
with the .envrc contents inline so the user can read what they're
approving. Confirming runs `direnv allow <path>` and re-runs the load
so the new env reaches subprocesses immediately; cancelling leaves the
env unloaded (the same state as before this commit when direnv refused
to load the .envrc).

Using `direnv status --json` instead of parsing the "is blocked"
stderr line means we rely on direnv's structured output rather than
its human-readable error format, which is more stable across versions
and avoids assumptions about output formatting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-06-01 11:07:16 +02:00
parent bb8955f2de
commit b76c1072ff
8 changed files with 300 additions and 39 deletions

View file

@ -172,14 +172,16 @@ 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")
// 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 derr != nil {
app.Log.WithError(derr).Warn("direnv load failed")
if result.Err != nil {
app.Log.WithError(result.Err).Warn("direnv load failed")
}
return true
}

View file

@ -10,23 +10,39 @@ import (
"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.
//
// 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) {
func Load(cmd oscommands.ICmdObjBuilder) LoadResult {
if _, lookupErr := exec.LookPath("direnv"); lookupErr != nil {
return "", nil
return LoadResult{}
}
stdout, stderr, runErr := cmd.New([]string{
"direnv", "export", "json",
}).DontLog().RunWithOutputs()
message = strings.TrimRight(stderr, "\n")
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
@ -44,9 +60,21 @@ func Load(cmd oscommands.ICmdObjBuilder) (message string, err error) {
// 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
result.Err = runErr
if envrcPath := queryBlockedEnvrc(cmd); envrcPath != "" {
result.Blocked = true
result.EnvrcPath = envrcPath
}
} else {
result.Err = parseErr
}
return message, 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) {
@ -60,3 +88,43 @@ func parseDirenvExport(stdout []byte) (map[string]*string, error) {
}
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

@ -41,3 +41,48 @@ func TestParseDirenvExport(t *testing.T) {
})
}
}
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

@ -171,13 +171,7 @@ 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")
}
direnvResult := self.logDirenvResult(direnv.Load(self.c.OS().Cmd))
if err := self.recordDirectoryHelper.RecordCurrentDirectory(); err != nil {
self.c.Log.Errorf("error recording current directory: %v", err)
@ -190,6 +184,57 @@ func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey
return err
}
return direnvErr
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

@ -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

@ -11,11 +11,11 @@ import (
// 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).
// 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 and shows an error popup",
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"),
@ -37,10 +37,19 @@ var DirenvUnloadsOnBlockedEnvrc = NewIntegrationTest(NewIntegrationTestArgs{
shell.EmptyCommit("initial")
shell.CloneNonBare("other")
shell.CreateFile("../other/.envrc", "export LG_DIRENV_TEST=from_envrc\n")
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
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")
},
@ -53,18 +62,15 @@ exit 1
).
Confirm()
t.ExpectPopup().Alert().
Title(Equals("Error")).
Content(Contains("is blocked")).
Confirm()
t.ExpectPopup().Confirmation().
Title(Equals("Approve .envrc?")).
Content(Contains("export LG_DIRENV_TEST=from_envrc")).
Cancel()
// 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(),
)
NavigateToLine(Contains("output.txt"))
t.Views().Main().Content(Contains("VAR=[]"))
},
})

View file

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