Skip to content
Open
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
20 changes: 13 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,15 @@ 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")
}
if derr != nil {
app.Log.WithError(derr).Warn("direnv load failed")
}
return true
}
}
Expand Down Expand Up @@ -239,12 +249,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 +268,7 @@ func (app *App) setupRepo(
os.Exit(0)
}

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

Expand Down
58 changes: 58 additions & 0 deletions pkg/commands/direnv/direnv.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package direnv

import (
"bytes"
"encoding/json"
"os"
"os/exec"
"strings"

"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
)

// 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) {
if _, lookupErr := exec.LookPath("direnv"); lookupErr != nil {
return "", nil
}

stdout, stderr, runErr := cmd.New([]string{
"direnv", "export", "json",
}).DontLog().RunWithOutputs()
message = strings.TrimRight(stderr, "\n")
if runErr != nil {
return message, runErr
}

delta, parseErr := parseDirenvExport([]byte(stdout))
if parseErr != nil {
return message, parseErr
}
for k, v := range delta {
if v == nil {
_ = os.Unsetenv(k)
} else {
_ = os.Setenv(k, *v)
}
}
return message, nil
}

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
}
43 changes: 43 additions & 0 deletions pkg/commands/direnv/direnv_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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)
}
})
}
}
9 changes: 9 additions & 0 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,6 +171,14 @@ func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey
return err
}

msg, derr := direnv.Load(self.c.OS().Cmd)
if msg != "" {
self.c.LogCommand(msg, false)
}
if derr != nil {
self.c.Log.WithError(derr).Warn("direnv load failed")
}

if err := self.recordDirectoryHelper.RecordCurrentDirectory(); err != nil {
return err
}
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
68 changes: 68 additions & 0 deletions pkg/integration/tests/misc/direnv_loaded_on_repo_switch.go
Original file line number Diff line number Diff line change
@@ -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"))
},
})
1 change: 1 addition & 0 deletions pkg/integration/tests/test_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ var tests = []*components.IntegrationTest{
misc.ConfirmOnQuit,
misc.CopyConfirmationMessageToClipboard,
misc.CopyToClipboard,
misc.DirenvLoadedOnRepoSwitch,
misc.InitialOpen,
misc.RecentReposOnLaunch,
patch_building.Apply,
Expand Down
Loading