diff --git a/internal/devconfig/config.go b/internal/devconfig/config.go index c7e38d02965..b029fd51fbd 100644 --- a/internal/devconfig/config.go +++ b/internal/devconfig/config.go @@ -141,8 +141,17 @@ func Find(path string) (*Config, error) { // searchDir looks for a config file in dir. It does not search parent // directories. +// +// In addition to a top-level devbox.json, searchDir also looks for the config +// inside a .config subdirectory (.config/devbox.json). This lets users keep the +// project root tidy by moving devbox's files out of the way, while still being +// discoverable. A top-level devbox.json always takes precedence over one in +// .config. func searchDir(dir string) (*Config, error) { - try := []string{configfile.DefaultName} + try := []string{ + configfile.DefaultName, + filepath.Join(".config", configfile.DefaultName), + } for _, name := range try { path := filepath.Join(dir, name) slog.Debug("trying config file", "path", path) diff --git a/internal/devconfig/config_test.go b/internal/devconfig/config_test.go index 9f747796156..c4f29521969 100644 --- a/internal/devconfig/config_test.go +++ b/internal/devconfig/config_test.go @@ -49,6 +49,69 @@ func TestOpen(t *testing.T) { }) } +func TestOpenDotConfig(t *testing.T) { + t.Run("FindsConfigInDotConfigDir", func(t *testing.T) { + root, _, _ := mkNestedDirs(t) + dotConfig := filepath.Join(root, ".config") + if err := os.MkdirAll(dotConfig, 0o777); err != nil { + t.Fatalf("os.MkdirAll(%q) error: %v", dotConfig, err) + } + if _, err := Init(dotConfig); err != nil { + t.Fatalf("Init(%q) error: %v", dotConfig, err) + } + + cfg, err := Open(root) + if err != nil { + t.Fatalf("Open(%q) error: %v", root, err) + } + want := filepath.Join(dotConfig, configfile.DefaultName) + if cfg.Root.AbsRootPath != want { + t.Errorf("cfg.Root.AbsRootPath = %q, want %q", cfg.Root.AbsRootPath, want) + } + }) + t.Run("TopLevelConfigTakesPrecedence", func(t *testing.T) { + root, _, _ := mkNestedDirs(t) + dotConfig := filepath.Join(root, ".config") + if err := os.MkdirAll(dotConfig, 0o777); err != nil { + t.Fatalf("os.MkdirAll(%q) error: %v", dotConfig, err) + } + if _, err := Init(root); err != nil { + t.Fatalf("Init(%q) error: %v", root, err) + } + if _, err := Init(dotConfig); err != nil { + t.Fatalf("Init(%q) error: %v", dotConfig, err) + } + + cfg, err := Open(root) + if err != nil { + t.Fatalf("Open(%q) error: %v", root, err) + } + want := filepath.Join(root, configfile.DefaultName) + if cfg.Root.AbsRootPath != want { + t.Errorf("cfg.Root.AbsRootPath = %q, want %q", cfg.Root.AbsRootPath, want) + } + }) + t.Run("FindWalksUpToParentDotConfig", func(t *testing.T) { + root, child, _ := mkNestedDirs(t) + dotConfig := filepath.Join(root, ".config") + if err := os.MkdirAll(dotConfig, 0o777); err != nil { + t.Fatalf("os.MkdirAll(%q) error: %v", dotConfig, err) + } + if _, err := Init(dotConfig); err != nil { + t.Fatalf("Init(%q) error: %v", dotConfig, err) + } + + cfg, err := Find(child) + if err != nil { + t.Fatalf("Find(%q) error: %v", child, err) + } + want := filepath.Join(dotConfig, configfile.DefaultName) + if cfg.Root.AbsRootPath != want { + t.Errorf("cfg.Root.AbsRootPath = %q, want %q", cfg.Root.AbsRootPath, want) + } + }) +} + func TestOpenError(t *testing.T) { t.Run("NotExist", func(t *testing.T) { root, _, _ := mkNestedDirs(t)