diff --git a/pkg/app/app.go b/pkg/app/app.go index 1f49a6a23..15a3f327a 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -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 } diff --git a/pkg/commands/direnv/direnv.go b/pkg/commands/direnv/direnv.go index 9d5d2d1e1..8e98785c2 100644 --- a/pkg/commands/direnv/direnv.go +++ b/pkg/commands/direnv/direnv.go @@ -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 ` 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 +} diff --git a/pkg/commands/direnv/direnv_test.go b/pkg/commands/direnv/direnv_test.go index 69b102d4d..43fdbce88 100644 --- a/pkg/commands/direnv/direnv_test.go +++ b/pkg/commands/direnv/direnv_test.go @@ -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))) + }) + } +} diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index f8972b837..bde1c47c6 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -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 + }, }) } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index b52c3f20e..5dcc80806 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -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", diff --git a/pkg/integration/tests/misc/direnv_approves_envrc.go b/pkg/integration/tests/misc/direnv_approves_envrc.go new file mode 100644 index 000000000..60780ef19 --- /dev/null +++ b/pkg/integration/tests/misc/direnv_approves_envrc.go @@ -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")) + }, +}) diff --git a/pkg/integration/tests/misc/direnv_unloads_on_blocked_envrc.go b/pkg/integration/tests/misc/direnv_unloads_on_blocked_envrc.go index ca7afe104..541bc446a 100644 --- a/pkg/integration/tests/misc/direnv_unloads_on_blocked_envrc.go +++ b/pkg/integration/tests/misc/direnv_unloads_on_blocked_envrc.go @@ -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=[]")) }, }) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 3679099e0..6f0032391 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -334,6 +334,7 @@ var tests = []*components.IntegrationTest{ misc.ConfirmOnQuit, misc.CopyConfirmationMessageToClipboard, misc.CopyToClipboard, + misc.DirenvApprovesEnvrc, misc.DirenvLoadedOnRepoSwitch, misc.DirenvUnloadsOnBlockedEnvrc, misc.InitialOpen,