Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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)
Expand All @@ -262,7 +270,7 @@ func (app *App) setupRepo(
os.Exit(0)
}

if didOpenRepo := openRecentRepo(app); didOpenRepo {
if openRecentRepo(app) {
return true, nil
}

Expand Down
130 changes: 130 additions & 0 deletions pkg/commands/direnv/direnv.go
Original file line number Diff line number Diff line change
@@ -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
}
88 changes: 88 additions & 0 deletions pkg/commands/direnv/direnv_test.go
Original file line number Diff line number Diff line change
@@ -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)))
})
}
}
62 changes: 60 additions & 2 deletions pkg/gui/controllers/helpers/repos_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
},
})
}
4 changes: 4 additions & 0 deletions pkg/i18n/english.go
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,8 @@ type TranslationSet struct {
CreateWorktreeFromDetached string
LcWorktree string
ChangingDirectoryTo string
DirenvApprovalTitle string
DirenvApprovalPrompt string
Name string
Branch string
Path string
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion pkg/integration/components/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}

Expand Down
Loading
Loading