From e1123687f01a02482d1d2e5e4c9b98ee5eab2074 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 12 May 2026 09:24:47 -0400 Subject: [PATCH] fix(config): scope crush.json discovery to the current repo The upward search for crush.json had no boundary and would walk all the way to the filesystem root, which meant a stray crush.json placed high in the tree could be picked up by every project beneath it. This applies the same project boundary used for .crush discovery. Co-Authored-By: Charm Crush --- internal/config/load.go | 9 +++- internal/config/load_test.go | 92 ++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/internal/config/load.go b/internal/config/load.go index 011a09f9ee90885776cd6d198fd6082cf2cbb2a4..b705c0f4060ca0b17975949ecd3a8709bc0d9cc0 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -702,7 +702,12 @@ func configureSelectedModels(store *ConfigStore, knownProviders []catwalk.Provid return nil } -// lookupConfigs searches config files recursively from CWD up to FS root +// lookupConfigs searches config files starting at cwd and walking up +// through the current project. The upward walk stops at the git +// working tree root when one can be detected, otherwise at cwd itself, +// so an unrelated crush.json placed above the project is never picked +// up. Global user-level config locations are always included +// regardless of the boundary. func lookupConfigs(cwd string) []string { // prepend default config paths configPaths := []string{ @@ -712,7 +717,7 @@ func lookupConfigs(cwd string) []string { configNames := []string{appName + ".json", "." + appName + ".json"} - foundConfigs, err := fsext.Lookup(cwd, configNames...) + foundConfigs, err := fsext.LookupBounded(cwd, projectBoundary(cwd), configNames...) if err != nil { // returns at least default configs return configPaths diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 50a532c0ce8a9a330f82b9b7fb54c2fb3dc8aa22..f65273fc14fc5cb000f85a6c2e4f1a22a7fd1d01 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -37,6 +37,98 @@ func TestConfig_LoadFromBytes(t *testing.T) { require.Equal(t, "https://api.openai.com/v2", pc.BaseURL) } +func TestLookupConfigs_BoundedByProject(t *testing.T) { + // Force GlobalConfig and GlobalConfigData to point at locations we + // control so they can be present in the result without polluting + // the developer's real config. + globalDir := t.TempDir() + t.Setenv("CRUSH_GLOBAL_CONFIG", globalDir) + t.Setenv("CRUSH_GLOBAL_DATA", globalDir) + + t.Run("does not pick up crush.json above non-git project", func(t *testing.T) { + parent := t.TempDir() + + // crush.json above the project must not be adopted. + require.NoError(t, os.WriteFile( + filepath.Join(parent, "crush.json"), + []byte(`{}`), + 0o644, + )) + + project := filepath.Join(parent, "project") + require.NoError(t, os.Mkdir(project, 0o755)) + + got := lookupConfigs(project) + for _, p := range got { + require.NotEqual(t, filepath.Join(parent, "crush.json"), p) + } + }) + + t.Run("does not climb out of git worktree to find crush.json", func(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + + parent := t.TempDir() + + require.NoError(t, os.WriteFile( + filepath.Join(parent, "crush.json"), + []byte(`{}`), + 0o644, + )) + + worktree := filepath.Join(parent, "worktree") + require.NoError(t, os.Mkdir(worktree, 0o755)) + gitInit := exec.CommandContext(t.Context(), "git", "init", "-q") + gitInit.Dir = worktree + require.NoError(t, gitInit.Run()) + + got := lookupConfigs(worktree) + strayEval, err := filepath.EvalSymlinks(filepath.Join(parent, "crush.json")) + require.NoError(t, err) + for _, p := range got { + pEval, err := filepath.EvalSymlinks(p) + if err != nil { + continue + } + require.NotEqual(t, strayEval, pEval, "must not adopt parent crush.json") + } + }) + + t.Run("picks up crush.json inside the project", func(t *testing.T) { + project := t.TempDir() + local := filepath.Join(project, "crush.json") + require.NoError(t, os.WriteFile(local, []byte(`{}`), 0o644)) + + got := lookupConfigs(project) + + localEval, err := filepath.EvalSymlinks(local) + require.NoError(t, err) + var foundLocal bool + for _, p := range got { + pEval, err := filepath.EvalSymlinks(p) + if err != nil { + continue + } + if pEval == localEval { + foundLocal = true + break + } + } + require.True(t, foundLocal, "expected project crush.json to be in lookup result: %v", got) + }) + + t.Run("global config is always included regardless of boundary", func(t *testing.T) { + project := t.TempDir() + + got := lookupConfigs(project) + // Global config and global data path are always prepended, + // even when no project file exists. + require.Contains(t, got, GlobalConfig()) + require.Contains(t, got, GlobalConfigData()) + }) +} + func TestLoadFromConfigPaths_InvalidJSON(t *testing.T) { t.Parallel()