Skip to content

Commit c51923d

Browse files
steveyeggeclaude
andcommitted
fix: merge user-level config under project config instead of ignoring it (GH#2375)
config.Initialize() used a configFileSet gate that picked ONE config file and skipped all lower-priority tiers. This meant the idle-monitor daemon (which sets BEADS_DIR) never loaded ~/.config/bd/config.yaml, silently ignoring user-level settings like dolt.auto-commit. Now all config tiers are collected and merged with proper precedence: lowest priority loaded first, then each higher tier merged on top via Viper MergeInConfig. This preserves the documented layering: BEADS_DIR > project .beads/ > ~/.config/bd/ > ~/.beads/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 86861b6 commit c51923d

File tree

1 file changed

+60
-43
lines changed

1 file changed

+60
-43
lines changed

internal/config/config.go

Lines changed: 60 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -27,24 +27,38 @@ func Initialize() error {
2727
// Set config type to yaml (we only load config.yaml, not config.json)
2828
v.SetConfigType("yaml")
2929

30-
// Explicitly locate config.yaml and use SetConfigFile to avoid picking up config.json
31-
// Precedence: BEADS_DIR > project .beads/config.yaml > ~/.config/bd/config.yaml > ~/.beads/config.yaml
32-
configFileSet := false
33-
34-
// 0. Check BEADS_DIR first (highest priority)
35-
// This ensures bd commands with BEADS_DIR set find the correct config
36-
if beadsDir := os.Getenv("BEADS_DIR"); beadsDir != "" && !configFileSet {
37-
configPath := filepath.Join(beadsDir, "config.yaml")
38-
if _, err := os.Stat(configPath); err == nil {
39-
v.SetConfigFile(configPath)
40-
configFileSet = true
30+
// Collect config files from lowest to highest priority.
31+
// We load the lowest first with ReadInConfig, then MergeInConfig each
32+
// subsequent file so higher-priority values overwrite lower-priority ones.
33+
//
34+
// Precedence (highest to lowest):
35+
// BEADS_DIR/config.yaml > project .beads/config.yaml > ~/.config/bd/config.yaml > ~/.beads/config.yaml
36+
//
37+
// Previously, only ONE config file was loaded (the highest-priority match),
38+
// which meant user-level config was silently ignored when project-level
39+
// config existed — e.g., the idle-monitor daemon with BEADS_DIR set (GH#2375).
40+
var configPaths []string // ordered lowest priority first
41+
var primaryConfigPath string // project-level config (for config.local.yaml and SaveConfigValue)
42+
43+
// 3. Legacy: ~/.beads/config.yaml (lowest priority)
44+
if homeDir, err := os.UserHomeDir(); err == nil {
45+
p := filepath.Join(homeDir, ".beads", "config.yaml")
46+
if _, err := os.Stat(p); err == nil {
47+
configPaths = append(configPaths, p)
4148
}
4249
}
4350

44-
// 1. Walk up from CWD to find project .beads/config.yaml
45-
// This allows commands to work from subdirectories
51+
// 2. User: ~/.config/bd/config.yaml
52+
if configDir, err := os.UserConfigDir(); err == nil {
53+
p := filepath.Join(configDir, "bd", "config.yaml")
54+
if _, err := os.Stat(p); err == nil {
55+
configPaths = append(configPaths, p)
56+
}
57+
}
58+
59+
// 1. Project: walk up from CWD to find .beads/config.yaml
4660
cwd, err := os.Getwd()
47-
if err == nil && !configFileSet {
61+
if err == nil {
4862
// In the beads repo, `.beads/config.yaml` is tracked and may set sync.mode=dolt-native.
4963
// In `go test` (especially for `cmd/bd`), we want to avoid unintentionally picking up
5064
// the repo-local config, while still allowing tests to load config.yaml from temp repos.
@@ -66,42 +80,31 @@ func Initialize() error {
6680
// Walk up parent directories to find .beads/config.yaml
6781
for dir := cwd; dir != filepath.Dir(dir); dir = filepath.Dir(dir) {
6882
beadsDir := filepath.Join(dir, ".beads")
69-
configPath := filepath.Join(beadsDir, "config.yaml")
70-
if _, err := os.Stat(configPath); err == nil {
83+
p := filepath.Join(beadsDir, "config.yaml")
84+
if _, err := os.Stat(p); err == nil {
7185
if ignoreRepoConfig && moduleRoot != "" {
7286
// Only ignore the repo-local config (moduleRoot/.beads/config.yaml).
73-
wantIgnore := filepath.Clean(configPath) == filepath.Clean(filepath.Join(moduleRoot, ".beads", "config.yaml"))
87+
wantIgnore := filepath.Clean(p) == filepath.Clean(filepath.Join(moduleRoot, ".beads", "config.yaml"))
7488
if wantIgnore {
7589
continue
7690
}
7791
}
78-
// Found .beads/config.yaml - set it explicitly
79-
v.SetConfigFile(configPath)
80-
configFileSet = true
92+
configPaths = append(configPaths, p)
93+
primaryConfigPath = p
8194
break
8295
}
8396
}
8497
}
8598

86-
// 2. User config directory (~/.config/bd/config.yaml)
87-
if !configFileSet {
88-
if configDir, err := os.UserConfigDir(); err == nil {
89-
configPath := filepath.Join(configDir, "bd", "config.yaml")
90-
if _, err := os.Stat(configPath); err == nil {
91-
v.SetConfigFile(configPath)
92-
configFileSet = true
93-
}
94-
}
95-
}
96-
97-
// 3. Home directory (~/.beads/config.yaml)
98-
if !configFileSet {
99-
if homeDir, err := os.UserHomeDir(); err == nil {
100-
configPath := filepath.Join(homeDir, ".beads", "config.yaml")
101-
if _, err := os.Stat(configPath); err == nil {
102-
v.SetConfigFile(configPath)
103-
configFileSet = true
99+
// 0. BEADS_DIR: highest priority
100+
if beadsDir := os.Getenv("BEADS_DIR"); beadsDir != "" {
101+
p := filepath.Join(beadsDir, "config.yaml")
102+
if _, err := os.Stat(p); err == nil {
103+
// Avoid duplicate if BEADS_DIR points to same config as CWD walk
104+
if primaryConfigPath == "" || filepath.Clean(p) != filepath.Clean(primaryConfigPath) {
105+
configPaths = append(configPaths, p)
104106
}
107+
primaryConfigPath = p
105108
}
106109
}
107110

@@ -194,23 +197,37 @@ func Initialize() error {
194197
// Maps project names to paths for resolving external: blocked_by references
195198
v.SetDefault("external_projects", map[string]string{})
196199

197-
// Read config file if it was found
198-
if configFileSet {
200+
// Load config files: lowest priority first, each MergeInConfig overwrites
201+
if len(configPaths) > 0 {
202+
v.SetConfigFile(configPaths[0])
199203
if err := v.ReadInConfig(); err != nil {
200204
return fmt.Errorf("error reading config file: %w", err)
201205
}
202-
debug.Logf("Debug: loaded config from %s\n", v.ConfigFileUsed())
206+
debug.Logf("Debug: loaded config from %s\n", configPaths[0])
207+
208+
for _, p := range configPaths[1:] {
209+
v.SetConfigFile(p)
210+
if err := v.MergeInConfig(); err != nil {
211+
return fmt.Errorf("error merging config file %s: %w", p, err)
212+
}
213+
debug.Logf("Debug: merged config from %s\n", p)
214+
}
215+
216+
// Restore primary config path as ConfigFileUsed (used by SaveConfigValue,
217+
// ResolveExternalProjectPath, etc.)
218+
v.SetConfigFile(primaryConfigPath)
203219

204220
// Merge local config overrides if present (config.local.yaml)
205221
// This allows machine-specific settings without polluting tracked config
206-
configDir := filepath.Dir(v.ConfigFileUsed())
207-
localConfigPath := filepath.Join(configDir, "config.local.yaml")
222+
localConfigPath := filepath.Join(filepath.Dir(primaryConfigPath), "config.local.yaml")
208223
if _, err := os.Stat(localConfigPath); err == nil {
209224
v.SetConfigFile(localConfigPath)
210225
if err := v.MergeInConfig(); err != nil {
211226
return fmt.Errorf("error merging local config file: %w", err)
212227
}
213228
debug.Logf("Debug: merged local config from %s\n", localConfigPath)
229+
// Restore primary as ConfigFileUsed
230+
v.SetConfigFile(primaryConfigPath)
214231
}
215232
} else {
216233
// No config.yaml found - use defaults and environment variables

0 commit comments

Comments
 (0)