mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-08-28 10:15:32 -05:00
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) <noreply@anthropic.com>
305 lines
7.9 KiB
Go
305 lines
7.9 KiB
Go
package components
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
|
|
lazycoreUtils "github.com/jesseduffield/lazycore/pkg/utils"
|
|
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
|
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
|
"github.com/jesseduffield/lazygit/pkg/utils"
|
|
"github.com/samber/lo"
|
|
)
|
|
|
|
type RunTestArgs struct {
|
|
Tests []*IntegrationTest
|
|
Logf func(format string, formatArgs ...any)
|
|
RunCmd func(cmd *exec.Cmd) (int, error)
|
|
TestWrapper func(test *IntegrationTest, f func() error)
|
|
Sandbox bool
|
|
WaitForDebugger bool
|
|
RaceDetector bool
|
|
CodeCoverageDir string
|
|
InputDelay int
|
|
MaxAttempts int
|
|
}
|
|
|
|
// This function lets you run tests either from within `go test` or from a regular binary.
|
|
// The reason for having two separate ways of testing is that `go test` isn't great at
|
|
// showing what's actually happening during the test, but it's still good at running
|
|
// tests in telling you about their results.
|
|
func RunTests(args RunTestArgs) error {
|
|
projectRootDir := lazycoreUtils.GetLazyRootDirectory()
|
|
err := os.Chdir(projectRootDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
testDir := filepath.Join(projectRootDir, "test", "_results")
|
|
if err := buildLazygit(args); err != nil {
|
|
return err
|
|
}
|
|
|
|
gitVersion, err := getGitVersion()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, test := range args.Tests {
|
|
args.TestWrapper(test, func() error {
|
|
paths := NewPaths(
|
|
filepath.Join(testDir, test.Name()),
|
|
)
|
|
|
|
for i := range args.MaxAttempts {
|
|
err := runTest(test, args, paths, projectRootDir, gitVersion)
|
|
if err != nil {
|
|
if i == args.MaxAttempts-1 {
|
|
return err
|
|
}
|
|
args.Logf("retrying test %s", test.Name())
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func runTest(
|
|
test *IntegrationTest,
|
|
args RunTestArgs,
|
|
paths Paths,
|
|
projectRootDir string,
|
|
gitVersion *git_commands.GitVersion,
|
|
) error {
|
|
if test.Skip() {
|
|
args.Logf("Skipping test %s", test.Name())
|
|
return nil
|
|
}
|
|
|
|
if !test.ShouldRunForGitVersion(gitVersion) {
|
|
args.Logf("Skipping test %s for git version %d.%d.%d", test.Name(), gitVersion.Major, gitVersion.Minor, gitVersion.Patch)
|
|
return nil
|
|
}
|
|
|
|
workingDir, err := prepareTestDir(test, paths, projectRootDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cmd, err := getLazygitCommand(test, args, paths, projectRootDir, workingDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pid, err := args.RunCmd(cmd)
|
|
|
|
// Print race detector log regardless of the command's exit status
|
|
if args.RaceDetector {
|
|
logPath := fmt.Sprintf("%s.%d", raceDetectorLogsPath(), pid)
|
|
if bytes, err := os.ReadFile(logPath); err == nil {
|
|
args.Logf("Race detector log:\n" + string(bytes))
|
|
}
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func prepareTestDir(
|
|
test *IntegrationTest,
|
|
paths Paths,
|
|
rootDir string,
|
|
) (string, error) {
|
|
findOrCreateDir(paths.Root())
|
|
deleteAndRecreateEmptyDir(paths.Actual())
|
|
|
|
err := os.Mkdir(paths.ActualRepo(), 0o777)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
workingDir := createFixture(test, paths, rootDir)
|
|
|
|
return workingDir, nil
|
|
}
|
|
|
|
func buildLazygit(testArgs RunTestArgs) error {
|
|
args := []string{"go", "build"}
|
|
if testArgs.WaitForDebugger {
|
|
// Disable compiler optimizations (-N) and inlining (-l) because this
|
|
// makes debugging work better
|
|
args = append(args, "-gcflags=all=-N -l")
|
|
}
|
|
if testArgs.RaceDetector {
|
|
args = append(args, "-race")
|
|
}
|
|
if testArgs.CodeCoverageDir != "" {
|
|
args = append(args, "-cover")
|
|
}
|
|
args = append(args, "-o", tempLazygitPath(), filepath.FromSlash("pkg/integration/clients/injector/main.go"))
|
|
osCommand := oscommands.NewDummyOSCommand()
|
|
return osCommand.Cmd.New(args).Run()
|
|
}
|
|
|
|
// Sets up the fixture for test and returns the working directory to invoke
|
|
// lazygit in.
|
|
func createFixture(test *IntegrationTest, paths Paths, rootDir string) string {
|
|
env := NewTestEnvironment(rootDir)
|
|
|
|
env = append(env, fmt.Sprintf("%s=%s", PWD, paths.ActualRepo()))
|
|
shell := NewShell(
|
|
paths.ActualRepo(),
|
|
env,
|
|
func(errorMsg string) { panic(errorMsg) },
|
|
)
|
|
shell.Init()
|
|
|
|
test.SetupRepo(shell)
|
|
|
|
return shell.dir
|
|
}
|
|
|
|
func testPath(rootdir string) string {
|
|
return filepath.Join(rootdir, "test")
|
|
}
|
|
|
|
func globalGitConfigPath(rootDir string) string {
|
|
return filepath.Join(testPath(rootDir), "global_git_config")
|
|
}
|
|
|
|
func getGitVersion() (*git_commands.GitVersion, error) {
|
|
osCommand := oscommands.NewDummyOSCommand()
|
|
cmdObj := osCommand.Cmd.New([]string{"git", "--version"})
|
|
versionStr, err := cmdObj.RunWithOutput()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return git_commands.ParseGitVersion(versionStr)
|
|
}
|
|
|
|
func getLazygitCommand(
|
|
test *IntegrationTest,
|
|
args RunTestArgs,
|
|
paths Paths,
|
|
rootDir string,
|
|
workingDir string,
|
|
) (*exec.Cmd, error) {
|
|
osCommand := oscommands.NewDummyOSCommand()
|
|
|
|
err := os.RemoveAll(paths.Config())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
templateConfigDir := filepath.Join(rootDir, "test", "default_test_config")
|
|
err = oscommands.CopyDir(templateConfigDir, paths.Config())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cmdArgs := []string{tempLazygitPath(), "-debug", "--use-config-dir=" + paths.Config()}
|
|
|
|
resolvedExtraArgs := lo.Map(test.ExtraCmdArgs(), func(arg string, _ int) string {
|
|
return utils.ResolvePlaceholderString(arg, map[string]string{
|
|
"actualPath": paths.Actual(),
|
|
"actualRepoPath": paths.ActualRepo(),
|
|
})
|
|
})
|
|
cmdArgs = append(cmdArgs, resolvedExtraArgs...)
|
|
|
|
// Use a limited environment for test isolation, including pass through
|
|
// of just allowed host environment variables
|
|
cmdObj := osCommand.Cmd.NewWithEnviron(cmdArgs, NewTestEnvironment(rootDir))
|
|
|
|
// Integration tests related to symlink behavior need a PWD that
|
|
// preserves symlinks. By default, SetWd will set a symlink-resolved
|
|
// value for PWD. Here, we override that with the path (that may)
|
|
// contain a symlink to simulate behavior in a user's shell correctly.
|
|
cmdObj.SetWd(workingDir)
|
|
cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", PWD, workingDir))
|
|
|
|
cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", LAZYGIT_ROOT_DIR, rootDir))
|
|
|
|
if args.CodeCoverageDir != "" {
|
|
// We set this explicitly here rather than inherit it from the test runner's
|
|
// environment because the test runner has its own coverage directory that
|
|
// it writes to and so if we pass GOCOVERDIR to that, it will be overwritten.
|
|
cmdObj.AddEnvVars("GOCOVERDIR=" + args.CodeCoverageDir)
|
|
}
|
|
|
|
cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", TEST_NAME_ENV_VAR, test.Name()))
|
|
if args.Sandbox {
|
|
cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", SANDBOX_ENV_VAR, "true"))
|
|
}
|
|
if args.WaitForDebugger {
|
|
cmdObj.AddEnvVars(fmt.Sprintf("%s=true", WAIT_FOR_DEBUGGER_ENV_VAR))
|
|
}
|
|
// Set a race detector log path only to avoid spamming the terminal with the
|
|
// logs. We are not showing this anywhere yet.
|
|
cmdObj.AddEnvVars(fmt.Sprintf("GORACE=log_path=%s", raceDetectorLogsPath()))
|
|
if test.ExtraEnvVars() != nil {
|
|
for key, value := range test.ExtraEnvVars() {
|
|
resolvedValue := utils.ResolvePlaceholderString(value, map[string]string{
|
|
"actualPath": paths.Actual(),
|
|
"actualRepoPath": paths.ActualRepo(),
|
|
})
|
|
cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", key, resolvedValue))
|
|
}
|
|
}
|
|
|
|
if args.InputDelay > 0 {
|
|
cmdObj.AddEnvVars(fmt.Sprintf("INPUT_DELAY=%d", args.InputDelay))
|
|
}
|
|
|
|
cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", GIT_CONFIG_GLOBAL_ENV_VAR, globalGitConfigPath(rootDir)))
|
|
|
|
return cmdObj.GetCmd(), nil
|
|
}
|
|
|
|
func tempLazygitPath() string {
|
|
return filepath.Join("/tmp", "lazygit", "test_lazygit")
|
|
}
|
|
|
|
func raceDetectorLogsPath() string {
|
|
return filepath.Join("/tmp", "lazygit", "race_log")
|
|
}
|
|
|
|
func findOrCreateDir(path string) {
|
|
_, err := os.Stat(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
err = os.MkdirAll(path, 0o777)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
} else {
|
|
panic(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func deleteAndRecreateEmptyDir(path string) {
|
|
// remove contents of integration test directory
|
|
dir, err := os.ReadDir(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
err = os.Mkdir(path, 0o777)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
} else {
|
|
panic(err)
|
|
}
|
|
}
|
|
for _, d := range dir {
|
|
os.RemoveAll(filepath.Join(path, d.Name()))
|
|
}
|
|
}
|