From 5a03b4804366b7810f952ac811317fe49edf7cae Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 15:38:40 +0000 Subject: [PATCH] feat(config): discover devbox.json in a .config subdirectory Look for the project config in `.config/devbox.json` in addition to a top-level `devbox.json`, so users can keep the project root tidy by moving devbox's files into `.config`. A top-level `devbox.json` always takes precedence when both exist. This complements DEVBOX_CONFIG (already supported) and addresses the auto-discovery half of #2792. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01L4HytKeYVy9zYHadK7TfWN --- internal/devconfig/config.go | 11 +++++- internal/devconfig/config_test.go | 63 +++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) 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)