on main help sign in
fix(config): always resolve the data directory to an absolute path (#2883)

Co-authored-by: Charm Crush <crush@charm.land>

commitc853a939ef079d5e8b662df41f61fe7932d61628 authorChristian Rocha <christian@rocha.is> dateTue 12 May 2026, 10:37 (5 months ago) parentbf5dd26
5 files changed+43−26
internal/agent/prompt/prompt.go+2−4 internal/agent/tools/glob.go+2−4 internal/config/load.go+2 internal/config/load_test.go+35−14 internal/shell/dispatch.go+2−4
internal/agent/prompt/prompt.go+2−4
@@ -13,6 +13,7 @@ import (
1313 "time"
1414
1515 "github.com/charmbracelet/crush/internal/config"
16+ "github.com/charmbracelet/crush/internal/filepathext"
1617 "github.com/charmbracelet/crush/internal/home"
1718 "github.com/charmbracelet/crush/internal/shell"
1819 "github.com/charmbracelet/crush/internal/skills"
@@ -107,10 +108,7 @@ func processFile(filePath string) *ContextFile {
107108
108109func processContextPath(p string, store *config.ConfigStore) []ContextFile {
109110 var contexts []ContextFile
110− fullPath := p
111− if !filepath.IsAbs(p) {
112− fullPath = filepath.Join(store.WorkingDir(), p)
113− }
111+ fullPath := filepathext.SmartJoin(store.WorkingDir(), p)
114112 info, err := os.Stat(fullPath)
115113 if err != nil {
116114 return contexts
internal/agent/tools/glob.go+2−4
@@ -13,6 +13,7 @@ import (
1313 "strings"
1414
1515 "charm.land/fantasy"
16+ "github.com/charmbracelet/crush/internal/filepathext"
1617 "github.com/charmbracelet/crush/internal/fsext"
1718)
1819
@@ -96,10 +97,7 @@ func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {
9697 if len(p) == 0 {
9798 continue
9899 }
99− absPath := string(p)
100− if !filepath.IsAbs(absPath) {
101− absPath = filepath.Join(searchRoot, absPath)
102− }
100+ absPath := filepathext.SmartJoin(searchRoot, string(p))
103101 if fsext.SkipHidden(absPath) {
104102 continue
105103 }
internal/config/load.go+2
@@ -21,6 +21,7 @@ import (
2121 "github.com/charmbracelet/crush/internal/agent/hyper"
2222 "github.com/charmbracelet/crush/internal/csync"
2323 "github.com/charmbracelet/crush/internal/env"
24+ "github.com/charmbracelet/crush/internal/filepathext"
2425 "github.com/charmbracelet/crush/internal/fsext"
2526 "github.com/charmbracelet/crush/internal/home"
2627 powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
@@ -429,6 +430,7 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
429430 c.Options.DataDirectory = filepath.Join(workingDir, defaultDataDirectory)
430431 }
431432 }
433+ c.Options.DataDirectory = filepath.Clean(filepathext.SmartJoin(workingDir, c.Options.DataDirectory))
432434 if c.Providers == nil {
433435 c.Providers = csync.NewMap[string, ProviderConfig]()
434436 }
internal/config/load_test.go+35−14
@@ -74,22 +74,43 @@ func testStore(cfg *Config) *ConfigStore {
7474}
7575
7676func TestConfig_setDefaults(t *testing.T) {
77− cfg := &Config{}
77+ t.Run("sets default data directory", func(t *testing.T) {
78+ cfg := &Config{}
79+ workingDir := t.TempDir()
80+
81+ cfg.setDefaults(workingDir, "")
82+
83+ require.NotNil(t, cfg.Options)
84+ require.NotNil(t, cfg.Options.TUI)
85+ require.NotNil(t, cfg.Options.ContextPaths)
86+ require.NotNil(t, cfg.Providers)
87+ require.NotNil(t, cfg.Models)
88+ require.NotNil(t, cfg.LSP)
89+ require.NotNil(t, cfg.MCP)
90+ require.Equal(t, filepath.Join(workingDir, ".crush"), cfg.Options.DataDirectory)
91+ require.Equal(t, "AGENTS.md", cfg.Options.InitializeAs)
92+ for _, path := range defaultContextPaths {
93+ require.Contains(t, cfg.Options.ContextPaths, path)
94+ }
95+ })
7896
79− cfg.setDefaults("/tmp", "")
97+ t.Run("resolves relative configured data directory from working directory", func(t *testing.T) {
98+ cfg := &Config{Options: &Options{DataDirectory: "."}}
99+ workingDir := filepath.Join(t.TempDir(), "worktree")
80100
81− require.NotNil(t, cfg.Options)
82− require.NotNil(t, cfg.Options.TUI)
83− require.NotNil(t, cfg.Options.ContextPaths)
84− require.NotNil(t, cfg.Providers)
85− require.NotNil(t, cfg.Models)
86− require.NotNil(t, cfg.LSP)
87− require.NotNil(t, cfg.MCP)
88− require.Equal(t, filepath.Join("/tmp", ".crush"), cfg.Options.DataDirectory)
89− require.Equal(t, "AGENTS.md", cfg.Options.InitializeAs)
90− for _, path := range defaultContextPaths {
91− require.Contains(t, cfg.Options.ContextPaths, path)
92− }
101+ cfg.setDefaults(workingDir, "")
102+
103+ require.Equal(t, workingDir, cfg.Options.DataDirectory)
104+ })
105+
106+ t.Run("resolves relative flag data directory from working directory", func(t *testing.T) {
107+ cfg := &Config{}
108+ workingDir := filepath.Join(t.TempDir(), "worktree")
109+
110+ cfg.setDefaults(workingDir, "./state")
111+
112+ require.Equal(t, filepath.Join(workingDir, "state"), cfg.Options.DataDirectory)
113+ })
93114}
94115
95116func TestConfig_configureProviders(t *testing.T) {
internal/shell/dispatch.go+2−4
@@ -14,6 +14,7 @@ import (
1414 "runtime"
1515 "strings"
1616
17+ "github.com/charmbracelet/crush/internal/filepathext"
1718 "mvdan.cc/sh/v3/expand"
1819 "mvdan.cc/sh/v3/interp"
1920 "mvdan.cc/sh/v3/syntax"
@@ -51,13 +52,10 @@ func scriptDispatchHandler(blockFuncs []BlockFunc) func(next interp.ExecHandlerF
5152 return next(ctx, args)
5253 }
5354
54− scriptPath := args[0]
5555 // Resolve relative paths against the interpreter's cwd, not
5656 // the process cwd — hook commands are authored with the hook
5757 // Runner's cwd in mind and sub-shells can cd before an exec.
58− if !filepath.IsAbs(scriptPath) {
59− scriptPath = filepath.Join(interp.HandlerCtx(ctx).Dir, scriptPath)
60− }
58+ scriptPath := filepathext.SmartJoin(interp.HandlerCtx(ctx).Dir, args[0])
6159 probe, err := probeFile(scriptPath)
6260 if err != nil {
6361 return err