mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
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:
parent
bb8955f2de
commit
b76c1072ff
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
90
pkg/integration/tests/misc/direnv_approves_envrc.go
Normal file
90
pkg/integration/tests/misc/direnv_approves_envrc.go
Normal 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"))
|
||||
},
|
||||
})
|
||||
|
|
@ -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=[]"))
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -334,6 +334,7 @@ var tests = []*components.IntegrationTest{
|
|||
misc.ConfirmOnQuit,
|
||||
misc.CopyConfirmationMessageToClipboard,
|
||||
misc.CopyToClipboard,
|
||||
misc.DirenvApprovesEnvrc,
|
||||
misc.DirenvLoadedOnRepoSwitch,
|
||||
misc.DirenvUnloadsOnBlockedEnvrc,
|
||||
misc.InitialOpen,
|
||||
|
|
|
|||
Loading…
Reference in a new issue