From 79b2d6197e51d4c02de41e8d0e6f1caf589d0974 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 12 May 2026 09:19:13 -0400 Subject: [PATCH 1/4] feat(fsext): stop upward lookup at a boundary directory Add variants of the upward filesystem search that stop at a caller-supplied boundary directory instead of walking all the way up to the filesystem root or to $HOME. Callers that want to avoid adopting matches from outside their project can now express that boundary explicitly. Existing callers and behavior are unchanged. Co-Authored-By: Charm Crush --- internal/fsext/lookup.go | 124 +++++++++++++++++++++++++++++++++ internal/fsext/lookup_test.go | 125 ++++++++++++++++++++++++++++++++++ 2 files changed, 249 insertions(+) diff --git a/internal/fsext/lookup.go b/internal/fsext/lookup.go index 098426571c69521a5978a2c2e0a4178b51b0aae6..e9d43f791bb5259dc437f6749310e741d56271cc 100644 --- a/internal/fsext/lookup.go +++ b/internal/fsext/lookup.go @@ -82,6 +82,81 @@ func LookupClosest(dir, target string) (string, bool) { return found, err == nil && found != "" } +// LookupClosestBounded behaves like LookupClosest but constrains the +// upward search to stopDir. The walk inspects dir, then each ancestor up +// to and including stopDir, then terminates regardless of whether the +// target was found. Use this when the caller wants to avoid adopting +// matches from outside a project boundary (for example a sibling +// worktree or a parent project). +// +// If stopDir is empty, only dir itself is searched. If stopDir is not an +// ancestor of dir, the walk still terminates at the filesystem root. +// The $HOME and ownership safeguards from LookupClosest are preserved +// as outer bounds. +func LookupClosestBounded(dir, stopDir, target string) (string, bool) { + var found string + + err := traverseUpBounded(dir, stopDir, func(cwd string, owner int) error { + fpath := filepath.Join(cwd, target) + + err := probeEnt(fpath, owner) + if errors.Is(err, os.ErrNotExist) { + return nil + } + + if err != nil { + return fmt.Errorf("error probing file %s: %w", fpath, err) + } + + if cwd == home.Dir() { + return filepath.SkipAll + } + + found = fpath + return filepath.SkipAll + }) + + return found, err == nil && found != "" +} + +// LookupBounded behaves like Lookup but constrains the upward search to +// stopDir. The walk inspects dir, then each ancestor up to and including +// stopDir, then terminates. If stopDir is empty, only dir itself is +// searched. +func LookupBounded(dir, stopDir string, targets ...string) ([]string, error) { + if len(targets) == 0 { + return nil, nil + } + + var found []string + + err := traverseUpBounded(dir, stopDir, func(cwd string, owner int) error { + for _, target := range targets { + fpath := filepath.Join(cwd, target) + err := probeEnt(fpath, owner) + + // skip to the next file on permission denied + if errors.Is(err, os.ErrNotExist) || + errors.Is(err, os.ErrPermission) { + continue + } + + if err != nil { + return fmt.Errorf("error probing file %s: %w", fpath, err) + } + + found = append(found, fpath) + } + + return nil + }) + if err != nil { + return nil, err + } + + return found, nil +} + // traverseUp walks up from given directory up until filesystem root reached. // It passes absolute path of current directory and staring directory owner ID // to callback function. It is up to user to check ownership. @@ -116,6 +191,55 @@ func traverseUp(dir string, walkFn func(dir string, owner int) error) error { } } +// traverseUpBounded walks up from dir, visiting each ancestor up to and +// including stopDir, then terminates. If stopDir is empty, only dir +// itself is visited; callers that want an unbounded walk should use +// traverseUp instead. If stopDir is set but is not an ancestor of dir +// the walk still stops at the filesystem root, so callers cannot +// accidentally produce an infinite walk by passing a sibling path. +func traverseUpBounded(dir, stopDir string, walkFn func(dir string, owner int) error) error { + cwd, err := filepath.Abs(dir) + if err != nil { + return fmt.Errorf("cannot convert CWD to absolute path: %w", err) + } + + stop := cwd + if stopDir != "" { + stop, err = filepath.Abs(stopDir) + if err != nil { + return fmt.Errorf("cannot convert stop dir to absolute path: %w", err) + } + } + + owner, err := Owner(dir) + if err != nil { + return fmt.Errorf("cannot get ownership: %w", err) + } + + for { + err := walkFn(cwd, owner) + if err == nil || errors.Is(err, filepath.SkipDir) { + if cwd == stop { + return nil + } + + parent := filepath.Dir(cwd) + if parent == cwd { + return nil + } + + cwd = parent + continue + } + + if errors.Is(err, filepath.SkipAll) { + return nil + } + + return err + } +} + // probeEnt checks if entity at given path exists and belongs to given owner func probeEnt(fspath string, owner int) error { _, err := os.Stat(fspath) diff --git a/internal/fsext/lookup_test.go b/internal/fsext/lookup_test.go index 97c167f37d8ebcf4d19124367955874e7f816b67..f4e445c179459dc0deed85708014a6690d4a008d 100644 --- a/internal/fsext/lookup_test.go +++ b/internal/fsext/lookup_test.go @@ -353,6 +353,131 @@ func TestLookup(t *testing.T) { }) } +func TestLookupClosestBounded(t *testing.T) { + t.Run("found in starting directory", func(t *testing.T) { + testDir := t.TempDir() + + targetFile := filepath.Join(testDir, "target.txt") + require.NoError(t, os.WriteFile(targetFile, []byte("test"), 0o644)) + + foundPath, found := LookupClosestBounded(testDir, testDir, "target.txt") + require.True(t, found) + require.Equal(t, targetFile, foundPath) + }) + + t.Run("found at boundary directory", func(t *testing.T) { + boundary := t.TempDir() + + subDir := filepath.Join(boundary, "subdir") + require.NoError(t, os.Mkdir(subDir, 0o755)) + + targetFile := filepath.Join(boundary, "target.txt") + require.NoError(t, os.WriteFile(targetFile, []byte("test"), 0o644)) + + foundPath, found := LookupClosestBounded(subDir, boundary, "target.txt") + require.True(t, found) + require.Equal(t, targetFile, foundPath) + }) + + t.Run("does not climb past boundary", func(t *testing.T) { + parent := t.TempDir() + + // Target lives above the boundary. + require.NoError(t, os.WriteFile(filepath.Join(parent, "target.txt"), []byte("test"), 0o644)) + + boundary := filepath.Join(parent, "project") + require.NoError(t, os.Mkdir(boundary, 0o755)) + + subDir := filepath.Join(boundary, "subdir") + require.NoError(t, os.Mkdir(subDir, 0o755)) + + foundPath, found := LookupClosestBounded(subDir, boundary, "target.txt") + require.False(t, found) + require.Empty(t, foundPath) + }) + + t.Run("empty boundary searches only starting directory", func(t *testing.T) { + parent := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(parent, "target.txt"), []byte("test"), 0o644)) + + subDir := filepath.Join(parent, "subdir") + require.NoError(t, os.Mkdir(subDir, 0o755)) + + foundPath, found := LookupClosestBounded(subDir, "", "target.txt") + require.False(t, found) + require.Empty(t, foundPath) + }) + + t.Run("empty boundary still finds in starting directory", func(t *testing.T) { + testDir := t.TempDir() + + targetFile := filepath.Join(testDir, "target.txt") + require.NoError(t, os.WriteFile(targetFile, []byte("test"), 0o644)) + + foundPath, found := LookupClosestBounded(testDir, "", "target.txt") + require.True(t, found) + require.Equal(t, targetFile, foundPath) + }) +} + +func TestLookupBounded(t *testing.T) { + t.Run("returns matches at and below boundary", func(t *testing.T) { + boundary := t.TempDir() + + subDir := filepath.Join(boundary, "subdir") + require.NoError(t, os.Mkdir(subDir, 0o755)) + + atBoundary := filepath.Join(boundary, "target.txt") + atSub := filepath.Join(subDir, "target.txt") + require.NoError(t, os.WriteFile(atBoundary, []byte("a"), 0o644)) + require.NoError(t, os.WriteFile(atSub, []byte("b"), 0o644)) + + found, err := LookupBounded(subDir, boundary, "target.txt") + require.NoError(t, err) + require.Len(t, found, 2) + require.Contains(t, found, atBoundary) + require.Contains(t, found, atSub) + }) + + t.Run("ignores matches above boundary", func(t *testing.T) { + parent := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(parent, "target.txt"), []byte("nope"), 0o644)) + + boundary := filepath.Join(parent, "project") + require.NoError(t, os.Mkdir(boundary, 0o755)) + + subDir := filepath.Join(boundary, "subdir") + require.NoError(t, os.Mkdir(subDir, 0o755)) + + // Target lives only above the boundary. + found, err := LookupBounded(subDir, boundary, "target.txt") + require.NoError(t, err) + require.Empty(t, found) + }) + + t.Run("empty boundary searches only starting directory", func(t *testing.T) { + parent := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(parent, "target.txt"), []byte("nope"), 0o644)) + + subDir := filepath.Join(parent, "subdir") + require.NoError(t, os.Mkdir(subDir, 0o755)) + + found, err := LookupBounded(subDir, "", "target.txt") + require.NoError(t, err) + require.Empty(t, found) + }) + + t.Run("no targets returns nil", func(t *testing.T) { + dir := t.TempDir() + found, err := LookupBounded(dir, dir) + require.NoError(t, err) + require.Empty(t, found) + }) +} + func TestProbeEnt(t *testing.T) { t.Run("existing file with correct owner", func(t *testing.T) { tempDir := t.TempDir() From e2e0bc093145650a45a1e663bc9674ea3542bea1 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 12 May 2026 09:22:57 -0400 Subject: [PATCH 2/4] fix(config): scope .crush discovery to the current repo When Crush starts up it looks for an existing .crush directory by walking from the working directory toward the filesystem root. In layouts with several worktrees of the same project under a common parent, that walk could end up putting crush.db files in the project root in worktrees. This stops the walk at the git working tree root, when one can be detected, and otherwise at the working directory itself. Each project should now get its own .crush as expected. Co-Authored-By: Charm Crush --- internal/config/load.go | 44 ++++++++++++++++++++++++- internal/config/load_test.go | 63 ++++++++++++++++++++++++++++++++++++ internal/fsext/lookup.go | 18 ++++++++++- 3 files changed, 123 insertions(+), 2 deletions(-) diff --git a/internal/config/load.go b/internal/config/load.go index bc75422a082d0a20d081a700ae3c126bd727b132..011a09f9ee90885776cd6d198fd6082cf2cbb2a4 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -424,7 +424,7 @@ func (c *Config) setDefaults(workingDir, dataDir string) { if dataDir != "" { c.Options.DataDirectory = dataDir } else if c.Options.DataDirectory == "" { - if path, ok := fsext.LookupClosest(workingDir, defaultDataDirectory); ok { + if path, ok := fsext.LookupClosestBounded(workingDir, projectBoundary(workingDir), defaultDataDirectory); ok { c.Options.DataDirectory = path } else { c.Options.DataDirectory = filepath.Join(workingDir, defaultDataDirectory) @@ -873,6 +873,48 @@ func isInsideWorktree() bool { return err == nil && strings.TrimSpace(string(bts)) == "true" } +// worktreeRoot returns the absolute path of the git working tree root for +// dir, or the empty string if dir is not inside a working tree (bare +// repositories, missing git binary, plain directories, or any other +// failure mode). Linked worktrees and submodules each report their own +// top-level, which is what callers want when bounding lookups. +func worktreeRoot(dir string) string { + cmd := exec.CommandContext( + context.Background(), + "git", "rev-parse", "--show-toplevel", + ) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "" + } + root := strings.TrimSpace(string(out)) + if root == "" { + return "" + } + abs, err := filepath.Abs(root) + if err != nil { + return "" + } + return abs +} + +// projectBoundary returns the directory at which an upward configuration +// search rooted at dir should stop. It is the git working tree root when +// one can be detected, otherwise dir itself. Returning dir as a +// fallback keeps Crush from silently adopting state files placed above +// the current project. +func projectBoundary(dir string) string { + if root := worktreeRoot(dir); root != "" { + return root + } + abs, err := filepath.Abs(dir) + if err != nil { + return dir + } + return abs +} + // GlobalSkillsDirs returns the default directories for Agent Skills. // Skills in these directories are auto-discovered and their files can be read // without permission prompts. diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 0a50cce5ea38c8f73bfa60111d410dc15a60fd4e..50a532c0ce8a9a330f82b9b7fb54c2fb3dc8aa22 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -4,6 +4,7 @@ import ( "io" "log/slog" "os" + "os/exec" "path/filepath" "testing" @@ -111,6 +112,68 @@ func TestConfig_setDefaults(t *testing.T) { require.Equal(t, filepath.Join(workingDir, "state"), cfg.Options.DataDirectory) }) + + t.Run("does not adopt .crush from a parent project", func(t *testing.T) { + parent := t.TempDir() + + // .crush in the parent: it should not be reused by the child + // because there is no git context joining them. + require.NoError(t, os.Mkdir(filepath.Join(parent, defaultDataDirectory), 0o755)) + + child := filepath.Join(parent, "child") + require.NoError(t, os.Mkdir(child, 0o755)) + + cfg := &Config{} + cfg.setDefaults(child, "") + + require.Equal(t, + filepath.Clean(filepath.Join(child, defaultDataDirectory)), + filepath.Clean(cfg.Options.DataDirectory), + ) + }) + + t.Run("does not climb out of git worktree to find .crush", func(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + + parent := t.TempDir() + + // Stray .crush above the worktree root. + require.NoError(t, os.Mkdir(filepath.Join(parent, defaultDataDirectory), 0o755)) + + worktree := filepath.Join(parent, "worktree") + require.NoError(t, os.Mkdir(worktree, 0o755)) + + sub := filepath.Join(worktree, "pkg") + require.NoError(t, os.Mkdir(sub, 0o755)) + + // Make worktree a real git repo so the boundary detection + // resolves to it, mirroring what happens with linked worktrees + // in real usage. + gitInit := exec.CommandContext(t.Context(), "git", "init", "-q") + gitInit.Dir = worktree + require.NoError(t, gitInit.Run()) + + cfg := &Config{} + cfg.setDefaults(sub, "") + + // Resolve symlinks because TempDir on macOS sits under /var + // which is a symlink to /private/var. The data directory has + // not been created yet, so resolve its parent and join. + gotDir, gotName := filepath.Split(cfg.Options.DataDirectory) + gotEvalDir, err := filepath.EvalSymlinks(filepath.Clean(gotDir)) + require.NoError(t, err) + gotEval := filepath.Join(gotEvalDir, gotName) + + strayEval, err := filepath.EvalSymlinks(filepath.Join(parent, defaultDataDirectory)) + require.NoError(t, err) + require.NotEqual(t, strayEval, gotEval, "must not adopt parent .crush") + + subEval, err := filepath.EvalSymlinks(sub) + require.NoError(t, err) + require.Equal(t, filepath.Join(subEval, defaultDataDirectory), gotEval) + }) } func TestConfig_configureProviders(t *testing.T) { diff --git a/internal/fsext/lookup.go b/internal/fsext/lookup.go index e9d43f791bb5259dc437f6749310e741d56271cc..696219ac89209b718ed0e106a4bc6a879105961c 100644 --- a/internal/fsext/lookup.go +++ b/internal/fsext/lookup.go @@ -197,6 +197,11 @@ func traverseUp(dir string, walkFn func(dir string, owner int) error) error { // traverseUp instead. If stopDir is set but is not an ancestor of dir // the walk still stops at the filesystem root, so callers cannot // accidentally produce an infinite walk by passing a sibling path. +// +// Boundary comparison is performed against symlink-resolved paths so +// that callers passing logically equivalent paths (a symlinked /var vs +// the underlying /private/var, for example) still terminate at the +// expected directory. func traverseUpBounded(dir, stopDir string, walkFn func(dir string, owner int) error) error { cwd, err := filepath.Abs(dir) if err != nil { @@ -210,6 +215,7 @@ func traverseUpBounded(dir, stopDir string, walkFn func(dir string, owner int) e return fmt.Errorf("cannot convert stop dir to absolute path: %w", err) } } + canonStop := canonicalize(stop) owner, err := Owner(dir) if err != nil { @@ -219,7 +225,7 @@ func traverseUpBounded(dir, stopDir string, walkFn func(dir string, owner int) e for { err := walkFn(cwd, owner) if err == nil || errors.Is(err, filepath.SkipDir) { - if cwd == stop { + if canonicalize(cwd) == canonStop { return nil } @@ -240,6 +246,16 @@ func traverseUpBounded(dir, stopDir string, walkFn func(dir string, owner int) e } } +// canonicalize resolves any symbolic links in path. If resolution fails +// (typically because path does not exist yet) the original path is +// returned cleaned, so callers can still perform stable equality checks. +func canonicalize(path string) string { + if resolved, err := filepath.EvalSymlinks(path); err == nil { + return resolved + } + return filepath.Clean(path) +} + // probeEnt checks if entity at given path exists and belongs to given owner func probeEnt(fspath string, owner int) error { _, err := os.Stat(fspath) From e1123687f01a02482d1d2e5e4c9b98ee5eab2074 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 12 May 2026 09:24:47 -0400 Subject: [PATCH 3/4] 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() From a7932c5669e1e1299ff731c0f4e64a6b4c275fa4 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 12 May 2026 09:29:45 -0400 Subject: [PATCH 4/4] test(config): tests for the data directory paths Co-Authored-By: Charm Crush --- internal/config/config.go | 18 +++++++++++------- internal/config/load_test.go | 30 ++++++++++++++++++++++++++++++ internal/swagger/docs.go | 2 +- internal/swagger/swagger.json | 2 +- internal/swagger/swagger.yaml | 6 +++++- schema.json | 2 +- 6 files changed, 49 insertions(+), 11 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 6b179abebbffe425ab624217ce40cb55ccbf7877..db6125017282f6e401c76002894c191e23e14d7c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -259,13 +259,17 @@ func (Attribution) JSONSchemaExtend(schema *jsonschema.Schema) { } type Options struct { - ContextPaths []string `json:"context_paths,omitempty" jsonschema:"description=Paths to files containing context information for the AI,example=.cursorrules,example=CRUSH.md"` - SkillsPaths []string `json:"skills_paths,omitempty" jsonschema:"description=Paths to directories containing Agent Skills (folders with SKILL.md files),example=~/.config/crush/skills,example=./skills"` - TUI *TUIOptions `json:"tui,omitempty" jsonschema:"description=Terminal user interface options"` - Debug bool `json:"debug,omitempty" jsonschema:"description=Enable debug logging,default=false"` - DebugLSP bool `json:"debug_lsp,omitempty" jsonschema:"description=Enable debug logging for LSP servers,default=false"` - DisableAutoSummarize bool `json:"disable_auto_summarize,omitempty" jsonschema:"description=Disable automatic conversation summarization,default=false"` - DataDirectory string `json:"data_directory,omitempty" jsonschema:"description=Directory for storing application data (relative to working directory),default=.crush,example=.crush"` // Relative to the cwd + ContextPaths []string `json:"context_paths,omitempty" jsonschema:"description=Paths to files containing context information for the AI,example=.cursorrules,example=CRUSH.md"` + SkillsPaths []string `json:"skills_paths,omitempty" jsonschema:"description=Paths to directories containing Agent Skills (folders with SKILL.md files),example=~/.config/crush/skills,example=./skills"` + TUI *TUIOptions `json:"tui,omitempty" jsonschema:"description=Terminal user interface options"` + Debug bool `json:"debug,omitempty" jsonschema:"description=Enable debug logging,default=false"` + DebugLSP bool `json:"debug_lsp,omitempty" jsonschema:"description=Enable debug logging for LSP servers,default=false"` + DisableAutoSummarize bool `json:"disable_auto_summarize,omitempty" jsonschema:"description=Disable automatic conversation summarization,default=false"` + // DataDirectory is where Crush keeps per-project state such as + // the SQLite database and workspace overrides. Relative paths are + // resolved against the working directory; absolute paths are used + // verbatim. After defaulting the stored value is always absolute. + DataDirectory string `json:"data_directory,omitempty" jsonschema:"description=Directory for storing application data. Relative paths are resolved against the working directory; absolute paths are used as-is.,default=.crush,example=.crush"` DisabledTools []string `json:"disabled_tools,omitempty" jsonschema:"description=List of built-in tools to disable and hide from the agent,example=bash,example=sourcegraph"` DisableProviderAutoUpdate bool `json:"disable_provider_auto_update,omitempty" jsonschema:"description=Disable providers auto-update,default=false"` DisableDefaultProviders bool `json:"disable_default_providers,omitempty" jsonschema:"description=Ignore all default/embedded providers. When enabled\\, providers must be fully specified in the config file with base_url\\, models\\, and api_key - no merging with defaults occurs,default=false"` diff --git a/internal/config/load_test.go b/internal/config/load_test.go index f65273fc14fc5cb000f85a6c2e4f1a22a7fd1d01..cb69976f7f8130e6023fde36629790a86d5138f1 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -205,6 +205,36 @@ func TestConfig_setDefaults(t *testing.T) { require.Equal(t, filepath.Join(workingDir, "state"), cfg.Options.DataDirectory) }) + t.Run("preserves absolute configured data directory", func(t *testing.T) { + // Use a platform-appropriate absolute path so the test runs + // the same way on POSIX and Windows. + absDir := filepath.Join(t.TempDir(), "data") + cfg := &Config{Options: &Options{DataDirectory: absDir}} + + cfg.setDefaults(filepath.Join(t.TempDir(), "worktree"), "") + + require.Equal(t, absDir, cfg.Options.DataDirectory) + }) + + t.Run("workspace merge re-entry keeps an absolute data directory", func(t *testing.T) { + // Simulate the load and reload paths: defaults are applied + // twice with the data directory potentially carried through + // from an earlier merge as a relative string. + workingDir := filepath.Join(t.TempDir(), "worktree") + cfg := &Config{} + cfg.setDefaults(workingDir, "") + + // Workspace JSON sets data_directory to a relative value; the + // merge replaces the struct, then setDefaults runs again. + cfg.Options.DataDirectory = "./state" + cfg.setDefaults(workingDir, "") + + require.True(t, filepath.IsAbs(cfg.Options.DataDirectory), + "data directory must remain absolute after re-merge, got %q", + cfg.Options.DataDirectory) + require.Equal(t, filepath.Join(workingDir, "state"), cfg.Options.DataDirectory) + }) + t.Run("does not adopt .crush from a parent project", func(t *testing.T) { parent := t.TempDir() diff --git a/internal/swagger/docs.go b/internal/swagger/docs.go index e36cae3f015d0cfc474bffd9f697e6170cc5a73a..ec106954f54e055d1ebe9ff64db9c63454a644e2 100644 --- a/internal/swagger/docs.go +++ b/internal/swagger/docs.go @@ -2939,7 +2939,7 @@ const docTemplate = `{ } }, "data_directory": { - "description": "Relative to the cwd", + "description": "DataDirectory is where Crush keeps per-project state such as the SQLite database and workspace overrides. Relative paths are resolved against the working directory; absolute paths are used verbatim. After defaulting the stored value is always absolute.", "type": "string" }, "debug": { diff --git a/internal/swagger/swagger.json b/internal/swagger/swagger.json index 3785d226e8d08af1be38786f83cea1f400c3eb68..b3ccbe22b783b78f508fa1a6b05f38d2f45f8612 100644 --- a/internal/swagger/swagger.json +++ b/internal/swagger/swagger.json @@ -2932,7 +2932,7 @@ } }, "data_directory": { - "description": "Relative to the cwd", + "description": "DataDirectory is where Crush keeps per-project state such as the SQLite database and workspace overrides. Relative paths are resolved against the working directory; absolute paths are used verbatim. After defaulting the stored value is always absolute.", "type": "string" }, "debug": { diff --git a/internal/swagger/swagger.yaml b/internal/swagger/swagger.yaml index f0de0979b842425fa5b5287377aa1d1f18b9a4e5..d1265256c785236bac581401c7c6e44838ff5dae 100644 --- a/internal/swagger/swagger.yaml +++ b/internal/swagger/swagger.yaml @@ -287,7 +287,11 @@ definitions: type: string type: array data_directory: - description: Relative to the cwd + description: |- + DataDirectory is where Crush keeps per-project state such as the SQLite + database and workspace overrides. Relative paths are resolved against + the working directory; absolute paths are used verbatim. After + defaulting the stored value is always absolute. type: string debug: type: boolean diff --git a/schema.json b/schema.json index 8b98269414175422347ae208f34997081cdc6249..751a5f529f8cb2773286d1dbd98b99b548c6503b 100644 --- a/schema.json +++ b/schema.json @@ -423,7 +423,7 @@ }, "data_directory": { "type": "string", - "description": "Directory for storing application data (relative to working directory)", + "description": "Directory for storing application data. Relative paths are resolved against the working directory; absolute paths are used as-is.", "default": ".crush", "examples": [ ".crush"