From 8b049be31dd46fc1ae21f6a0c57cb61bf35cac54 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 7 Sep 2026 09:13:26 +0200 Subject: [PATCH] Find the lazygit root directory by go.mod instead of .git Running the tests in an exported source tarball fails with "must run in lazy project folder or child folder". GetLazyRootDirectory searches the working directory and its parents for a .git directory, and a tarball doesn't have one. This has always affected the integration tests; since 34da956f5d a unit test calls the function too, so now even `go test ./... -short` fails. Search for the go.mod file that declares lazygit's module instead. It ships in tarballs, and there is exactly one of it per source tree. Put the function in our own pkg/utils rather than change lazycore's; the criterion is specific to lazygit, and I don't feel like making a change to lazycore. Return an error rather than call log.Fatal, and report it from the two callers that run under `go test`. In a test binary, log.Fatal exits without attributing the failure to any test. That is the failure mode 34da956f5d set out to remove. The remaining callers are development tools that have nothing useful to do without the root directory; they keep exiting, now through MustFindLazygitRootDirectory. Also stop the search at the root of the file system rather than at "/". On Windows the old loop walks up to "C:\" and then spins there forever. --- pkg/cheatsheet/generate.go | 4 +- pkg/integration/clients/cli.go | 4 +- pkg/integration/clients/go_test.go | 11 +++- pkg/integration/clients/tui.go | 9 +-- pkg/integration/components/runner.go | 8 ++- pkg/integration/components/test_test.go | 9 ++- pkg/jsonschema/generate.go | 4 +- pkg/jsonschema/generate_config_docs.go | 4 +- pkg/utils/project_root.go | 69 ++++++++++++++++++++ pkg/utils/project_root_test.go | 86 +++++++++++++++++++++++++ 10 files changed, 188 insertions(+), 20 deletions(-) create mode 100644 pkg/utils/project_root.go create mode 100644 pkg/utils/project_root_test.go diff --git a/pkg/cheatsheet/generate.go b/pkg/cheatsheet/generate.go index 5c5a94530..ad62f9e54 100644 --- a/pkg/cheatsheet/generate.go +++ b/pkg/cheatsheet/generate.go @@ -19,12 +19,12 @@ import ( "strings" "github.com/jesseduffield/generics/maps" - "github.com/jesseduffield/lazycore/pkg/utils" "github.com/jesseduffield/lazygit/pkg/app" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/i18n" + "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) @@ -49,7 +49,7 @@ func CommandToRun() string { } func GetKeybindingsDir() string { - return utils.GetLazyRootDirectory() + "/docs-master/keybindings" + return utils.MustFindLazygitRootDirectory() + "/docs-master/keybindings" } func generateAtDir(cheatsheetDir string) { diff --git a/pkg/integration/clients/cli.go b/pkg/integration/clients/cli.go index 34a5f85bd..b8f4bc5cc 100644 --- a/pkg/integration/clients/cli.go +++ b/pkg/integration/clients/cli.go @@ -8,9 +8,9 @@ import ( "strconv" "strings" - "github.com/jesseduffield/lazycore/pkg/utils" "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests" + "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) @@ -54,7 +54,7 @@ func runAndPrintFatalError(test *components.IntegrationTest, f func() error) { } func getTestsToRun(testNames []string) []*components.IntegrationTest { - allIntegrationTests := tests.GetTests(utils.GetLazyRootDirectory()) + allIntegrationTests := tests.GetTests(utils.MustFindLazygitRootDirectory()) var testsToRun []*components.IntegrationTest if len(testNames) == 0 { diff --git a/pkg/integration/clients/go_test.go b/pkg/integration/clients/go_test.go index 4c1faa557..30e08bcd8 100644 --- a/pkg/integration/clients/go_test.go +++ b/pkg/integration/clients/go_test.go @@ -16,9 +16,9 @@ import ( "time" "github.com/creack/pty" - "github.com/jesseduffield/lazycore/pkg/utils" "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests" + "github.com/jesseduffield/lazygit/pkg/utils" "github.com/stretchr/testify/assert" ) @@ -37,8 +37,13 @@ func TestIntegration(t *testing.T) { codeCoverageDir := os.Getenv("LAZYGIT_GOCOVERDIR") testNumber := 0 - err := components.RunTests(components.RunTestArgs{ - Tests: tests.GetTests(utils.GetLazyRootDirectory()), + rootDir, err := utils.FindLazygitRootDirectory() + if err != nil { + t.Fatal(err) + } + + err = components.RunTests(components.RunTestArgs{ + Tests: tests.GetTests(rootDir), Logf: t.Logf, RunCmd: runCmdHeadless, TestWrapper: func(test *components.IntegrationTest, f func() error) { diff --git a/pkg/integration/clients/tui.go b/pkg/integration/clients/tui.go index 0f07b5b19..aa344cb27 100644 --- a/pkg/integration/clients/tui.go +++ b/pkg/integration/clients/tui.go @@ -9,12 +9,13 @@ import ( "path/filepath" "strings" - "github.com/jesseduffield/lazycore/pkg/utils" + lazycoreUtils "github.com/jesseduffield/lazycore/pkg/utils" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/tests" + "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) @@ -23,7 +24,7 @@ import ( var SLOW_INPUT_DELAY = 600 func RunTUI(raceDetector bool) { - rootDir := utils.GetLazyRootDirectory() + rootDir := utils.MustFindLazygitRootDirectory() testDir := filepath.Join(rootDir, "test", "integration") app := newApp(testDir) @@ -206,7 +207,7 @@ type app struct { } func newApp(testDir string) *app { - return &app{testDir: testDir, allTests: tests.GetTests(utils.GetLazyRootDirectory())} + return &app{testDir: testDir, allTests: tests.GetTests(utils.MustFindLazygitRootDirectory())} } func (self *app) getCurrentTest() *components.IntegrationTest { @@ -224,7 +225,7 @@ func (self *app) loadTests() { } func (self *app) adjustCursor() { - self.itemIdx = utils.Clamp(self.itemIdx, 0, len(self.filteredTests)-1) + self.itemIdx = lazycoreUtils.Clamp(self.itemIdx, 0, len(self.filteredTests)-1) } func (self *app) filterWithString(needle string) { diff --git a/pkg/integration/components/runner.go b/pkg/integration/components/runner.go index 098f3f2e9..6e686b17b 100644 --- a/pkg/integration/components/runner.go +++ b/pkg/integration/components/runner.go @@ -9,7 +9,6 @@ import ( "sync" "time" - 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" @@ -40,12 +39,15 @@ type RunTestArgs struct { // 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) + projectRootDir, err := utils.FindLazygitRootDirectory() if err != nil { return err } + if err := os.Chdir(projectRootDir); err != nil { + return err + } + testDir := filepath.Join(projectRootDir, "test", "_results") if err := buildLazygit(args); err != nil { return err diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go index cce96105a..3b608e698 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -5,13 +5,13 @@ import ( "path/filepath" "testing" - lazycoreUtils "github.com/jesseduffield/lazycore/pkg/utils" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types" + "github.com/jesseduffield/lazygit/pkg/utils" "github.com/stretchr/testify/assert" ) @@ -227,7 +227,12 @@ func TestFailingFixture(t *testing.T) { paths := NewPaths(t.TempDir()) assert.NoError(t, os.MkdirAll(paths.ActualRepo(), 0o777)) - workingDir, err := createFixture(test, paths, lazycoreUtils.GetLazyRootDirectory()) + rootDir, err := utils.FindLazygitRootDirectory() + if err != nil { + t.Fatal(err) + } + + workingDir, err := createFixture(test, paths, rootDir) assert.ErrorContains(t, err, "git checkout no-such-branch") assert.Empty(t, workingDir) diff --git a/pkg/jsonschema/generate.go b/pkg/jsonschema/generate.go index cf7596761..4f22b04bd 100644 --- a/pkg/jsonschema/generate.go +++ b/pkg/jsonschema/generate.go @@ -9,14 +9,14 @@ import ( "reflect" "strings" - "github.com/jesseduffield/lazycore/pkg/utils" "github.com/jesseduffield/lazygit/pkg/config" + "github.com/jesseduffield/lazygit/pkg/utils" "github.com/karimkhaleel/jsonschema" "github.com/samber/lo" ) func GetSchemaDir() string { - return utils.GetLazyRootDirectory() + "/schema-master" + return utils.MustFindLazygitRootDirectory() + "/schema-master" } func GenerateSchema() *jsonschema.Schema { diff --git a/pkg/jsonschema/generate_config_docs.go b/pkg/jsonschema/generate_config_docs.go index ba245e99a..0caa13db9 100644 --- a/pkg/jsonschema/generate_config_docs.go +++ b/pkg/jsonschema/generate_config_docs.go @@ -7,7 +7,7 @@ import ( "os" "strings" - "github.com/jesseduffield/lazycore/pkg/utils" + "github.com/jesseduffield/lazygit/pkg/utils" "github.com/karimkhaleel/jsonschema" "github.com/samber/lo" @@ -163,7 +163,7 @@ func (n *Node) MarshalYAML() (any, error) { } func writeToConfigDocs(config []byte) error { - configPath := utils.GetLazyRootDirectory() + "/docs-master/Config.md" + configPath := utils.MustFindLazygitRootDirectory() + "/docs-master/Config.md" markdown, err := os.ReadFile(configPath) if err != nil { return fmt.Errorf("Error reading Config.md file %w", err) diff --git a/pkg/utils/project_root.go b/pkg/utils/project_root.go new file mode 100644 index 000000000..54dd9f616 --- /dev/null +++ b/pkg/utils/project_root.go @@ -0,0 +1,69 @@ +package utils + +import ( + "fmt" + "log" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/samber/lo" +) + +// FindLazygitRootDirectory returns the root directory of the lazygit source +// tree, by searching the working directory and its parents for the go.mod file +// that declares lazygit's module. Only development tools use it: the +// integration test runner, the cheatsheet generator, and the JSON schema +// generator. Not to be confused with finding the root directory of the +// repository that lazygit is being run in. +// +// We search upwards rather than expect to be called from the root directory, +// because `go test` runs each test binary in the source directory of its +// package, not in the directory that `go test` was invoked from. +func FindLazygitRootDirectory() (string, error) { + startDir, err := os.Getwd() + if err != nil { + return "", err + } + + dir := startDir + for { + if declaresLazygitModule(filepath.Join(dir, "go.mod")) { + return dir, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf( + "failed to find the lazygit root directory: there is no go.mod for a lazygit module in %s or any of its parent directories", + startDir) + } + dir = parent + } +} + +// MustFindLazygitRootDirectory is FindLazygitRootDirectory for tools that can't +// do anything useful if the directory isn't found. +func MustFindLazygitRootDirectory() string { + dir, err := FindLazygitRootDirectory() + if err != nil { + log.Fatal(err) + } + return dir +} + +// A fork is free to rename the module, so we accept any module path with a +// "lazygit" element in it, e.g. github.com/jesseduffield/lazygit. +func declaresLazygitModule(goModPath string) bool { + contents, err := os.ReadFile(goModPath) + if err != nil { + return false + } + + return lo.SomeBy(strings.Split(string(contents), "\n"), func(line string) bool { + fields := strings.Fields(line) + return len(fields) >= 2 && fields[0] == "module" && + slices.Contains(strings.Split(fields[1], "/"), "lazygit") + }) +} diff --git a/pkg/utils/project_root_test.go b/pkg/utils/project_root_test.go new file mode 100644 index 000000000..c5b1c8d28 --- /dev/null +++ b/pkg/utils/project_root_test.go @@ -0,0 +1,86 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFindLazygitRootDirectory(t *testing.T) { + // This test runs in the pkg/utils directory, so we expect the function to + // search two levels up for the project root. + expectedRootDir, err := filepath.Abs(filepath.Join("..", "..")) + assert.NoError(t, err) + + rootDir, err := FindLazygitRootDirectory() + + assert.NoError(t, err) + assert.Equal(t, expectedRootDir, rootDir) +} + +func TestFindLazygitRootDirectoryOutsideProject(t *testing.T) { + t.Chdir(t.TempDir()) + + _, err := FindLazygitRootDirectory() + + assert.ErrorContains(t, err, "there is no go.mod for a lazygit module") +} + +func TestDeclaresLazygitModule(t *testing.T) { + scenarios := []struct { + testName string + contents string + expected bool + }{ + { + testName: "lazygit's go.mod", + contents: "module github.com/jesseduffield/lazygit\n\ngo 1.25.0\n", + expected: true, + }, + { + testName: "a fork's go.mod", + contents: "module gitlab.com/somebody-else/lazygit\n\ngo 1.25.0\n", + expected: true, + }, + { + testName: "a fork's go.mod with a major version suffix", + contents: "module github.com/somebody-else/lazygit/v2\n\ngo 1.25.0\n", + expected: true, + }, + { + testName: "module declaration preceded by a comment", + contents: "// a comment\n\nmodule github.com/jesseduffield/lazygit\n", + expected: true, + }, + { + testName: "module declaration followed by a comment", + contents: "module github.com/jesseduffield/lazygit // comment\n\ngo 1.25.0\n", + expected: true, + }, + { + testName: "another project's go.mod", + contents: "module github.com/jesseduffield/lazydocker\n\ngo 1.25.0\n", + expected: false, + }, + { + testName: "no module declaration", + contents: "go 1.25.0\n", + expected: false, + }, + } + + for _, scenario := range scenarios { + t.Run(scenario.testName, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "go.mod") + assert.NoError(t, os.WriteFile(path, []byte(scenario.contents), 0o644)) + + assert.Equal(t, scenario.expected, declaresLazygitModule(path)) + }) + } +} + +func TestDeclaresLazygitModuleWithoutAGoModFile(t *testing.T) { + assert.False(t, declaresLazygitModule(filepath.Join(t.TempDir(), "go.mod"))) +}