From b2143a57afb014eaf67497eeb1f3bb8742fcf6a5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 28 May 2026 20:07:51 +0200 Subject: [PATCH 1/3] Cleanup: drop unneeded variable --- pkg/app/app.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/app/app.go b/pkg/app/app.go index 09b2236db4a..96dff31f62a 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -262,7 +262,7 @@ func (app *App) setupRepo( os.Exit(0) } - if didOpenRepo := openRecentRepo(app); didOpenRepo { + if openRecentRepo(app) { return true, nil } From 44a722ccd5bab1c1250362b02b54a84a5766cbe9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 28 May 2026 09:53:30 +0200 Subject: [PATCH 2/3] Dedupe the recent-repos fallback in setupRepo The for-loop here was a verbatim copy of openRecentRepo, so call that instead. --- pkg/app/app.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pkg/app/app.go b/pkg/app/app.go index 96dff31f62a..9af0c46d75a 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -239,12 +239,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) From d5df46809f19cb02b763efbac3c6c0ff59389785 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 28 May 2026 10:07:00 +0200 Subject: [PATCH 3/3] Load direnv environment when switching repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user opens a repo from the recent-repos menu or jumps between worktrees inside lazygit, only the env vars present at process startup reach subprocesses. That breaks pre-commit hooks and other tools whose dependencies are pulled in by a per-repo .envrc — users were left with read-only operations because the env their shell would normally load via direnv never made it into lazygit's git invocations. Shell out to `direnv export json` after each chdir and apply the JSON delta via os.Setenv/Unsetenv. direnv tracks the previous load in its own DIRENV_DIFF env var, so the delta also unloads vars from the old repo when entering one without a matching .envrc. If direnv isn't on PATH the call is a no-op, so users who don't use direnv pay nothing and users who do need no config to opt in. Any stderr direnv emits (loading messages, "blocked .envrc" errors, etc.) goes to the command log. The integration test puts a fake direnv on PATH and asserts that a value it exports reaches a custom command after switching repos. Wiring this up needed runner.go to support `{{actualPath}}` placeholders in ExtraEnvVars, mirroring the existing support for ExtraCmdArgs, so the test can prepend a fixture-relative directory to PATH. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/app/app.go | 10 +++ pkg/commands/direnv/direnv.go | 58 ++++++++++++++++ pkg/commands/direnv/direnv_test.go | 43 ++++++++++++ pkg/gui/controllers/helpers/repos_helper.go | 9 +++ pkg/integration/components/runner.go | 6 +- .../misc/direnv_loaded_on_repo_switch.go | 68 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 7 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 pkg/commands/direnv/direnv.go create mode 100644 pkg/commands/direnv/direnv_test.go create mode 100644 pkg/integration/tests/misc/direnv_loaded_on_repo_switch.go diff --git a/pkg/app/app.go b/pkg/app/app.go index 9af0c46d75a..1f49a6a2354 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -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" @@ -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 } } diff --git a/pkg/commands/direnv/direnv.go b/pkg/commands/direnv/direnv.go new file mode 100644 index 00000000000..9a600ef79eb --- /dev/null +++ b/pkg/commands/direnv/direnv.go @@ -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 +} diff --git a/pkg/commands/direnv/direnv_test.go b/pkg/commands/direnv/direnv_test.go new file mode 100644 index 00000000000..69b102d4d2e --- /dev/null +++ b/pkg/commands/direnv/direnv_test.go @@ -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) + } + }) + } +} diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index 156c0ab309b..ade6b65fb32 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -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" @@ -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 } diff --git a/pkg/integration/components/runner.go b/pkg/integration/components/runner.go index 83ddfe66d5c..5640c3e7044 100644 --- a/pkg/integration/components/runner.go +++ b/pkg/integration/components/runner.go @@ -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)) } } diff --git a/pkg/integration/tests/misc/direnv_loaded_on_repo_switch.go b/pkg/integration/tests/misc/direnv_loaded_on_repo_switch.go new file mode 100644 index 00000000000..14470da88f8 --- /dev/null +++ b/pkg/integration/tests/misc/direnv_loaded_on_repo_switch.go @@ -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")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 2bf2837cdf1..68c800bc97d 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.DirenvLoadedOnRepoSwitch, misc.InitialOpen, misc.RecentReposOnLaunch, patch_building.Apply,