store.go

  1package config
  2
  3import (
  4	"cmp"
  5	"context"
  6	"encoding/json"
  7	"fmt"
  8	"log/slog"
  9	"os"
 10	"path/filepath"
 11	"slices"
 12
 13	"charm.land/catwalk/pkg/catwalk"
 14	hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
 15	"github.com/charmbracelet/crush/internal/env"
 16	"github.com/charmbracelet/crush/internal/oauth"
 17	"github.com/charmbracelet/crush/internal/oauth/copilot"
 18	"github.com/charmbracelet/crush/internal/oauth/hyper"
 19	"github.com/tidwall/gjson"
 20	"github.com/tidwall/sjson"
 21)
 22
 23// fileSnapshot captures metadata about a config file at a point in time.
 24type fileSnapshot struct {
 25	Path    string
 26	Exists  bool
 27	Size    int64
 28	ModTime int64 // UnixNano
 29}
 30
 31// RuntimeOverrides holds per-session settings that are never persisted to
 32// disk. They are applied on top of the loaded Config and survive only for
 33// the lifetime of the process (or workspace).
 34type RuntimeOverrides struct {
 35	SkipPermissionRequests bool
 36}
 37
 38// ConfigStore is the single entry point for all config access. It owns the
 39// pure-data Config, runtime state (working directory, resolver, known
 40// providers), and persistence to both global and workspace config files.
 41type ConfigStore struct {
 42	config             *Config
 43	workingDir         string
 44	resolver           VariableResolver
 45	globalDataPath     string   // ~/.local/share/crush/crush.json
 46	workspacePath      string   // .crush/crush.json
 47	loadedPaths        []string // config files that were successfully loaded
 48	knownProviders     []catwalk.Provider
 49	overrides          RuntimeOverrides
 50	trackedConfigPaths []string                // unique, normalized config file paths
 51	snapshots          map[string]fileSnapshot // path -> snapshot at last capture
 52	autoReloadDisabled bool                    // set during load/reload to prevent re-entrancy
 53	reloadInProgress   bool                    // set during reload to avoid disk writes mid-reload
 54}
 55
 56// Config returns the pure-data config struct (read-only after load).
 57func (s *ConfigStore) Config() *Config {
 58	return s.config
 59}
 60
 61// WorkingDir returns the current working directory.
 62func (s *ConfigStore) WorkingDir() string {
 63	return s.workingDir
 64}
 65
 66// Resolver returns the variable resolver.
 67func (s *ConfigStore) Resolver() VariableResolver {
 68	return s.resolver
 69}
 70
 71// Resolve resolves a variable reference using the configured resolver.
 72func (s *ConfigStore) Resolve(key string) (string, error) {
 73	if s.resolver == nil {
 74		return "", fmt.Errorf("no variable resolver configured")
 75	}
 76	return s.resolver.ResolveValue(key)
 77}
 78
 79// KnownProviders returns the list of known providers.
 80func (s *ConfigStore) KnownProviders() []catwalk.Provider {
 81	return s.knownProviders
 82}
 83
 84// SetupAgents configures the coder and task agents on the config.
 85func (s *ConfigStore) SetupAgents() {
 86	s.config.SetupAgents()
 87}
 88
 89// Overrides returns the runtime overrides for this store.
 90func (s *ConfigStore) Overrides() *RuntimeOverrides {
 91	return &s.overrides
 92}
 93
 94// LoadedPaths returns the config file paths that were successfully loaded.
 95func (s *ConfigStore) LoadedPaths() []string {
 96	return slices.Clone(s.loadedPaths)
 97}
 98
 99// configPath returns the file path for the given scope.
100func (s *ConfigStore) configPath(scope Scope) (string, error) {
101	switch scope {
102	case ScopeWorkspace:
103		if s.workspacePath == "" {
104			return "", ErrNoWorkspaceConfig
105		}
106		return s.workspacePath, nil
107	default:
108		return s.globalDataPath, nil
109	}
110}
111
112// HasConfigField checks whether a key exists in the config file for the given
113// scope.
114func (s *ConfigStore) HasConfigField(scope Scope, key string) bool {
115	path, err := s.configPath(scope)
116	if err != nil {
117		return false
118	}
119	data, err := os.ReadFile(path)
120	if err != nil {
121		return false
122	}
123	return gjson.Get(string(data), key).Exists()
124}
125
126// SetConfigField sets a key/value pair in the config file for the given scope.
127// After a successful write, it automatically reloads config to keep in-memory
128// state fresh.
129func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
130	path, err := s.configPath(scope)
131	if err != nil {
132		return fmt.Errorf("%s: %w", key, err)
133	}
134	data, err := os.ReadFile(path)
135	if err != nil {
136		if os.IsNotExist(err) {
137			data = []byte("{}")
138		} else {
139			return fmt.Errorf("failed to read config file: %w", err)
140		}
141	}
142
143	newValue, err := sjson.Set(string(data), key, value)
144	if err != nil {
145		return fmt.Errorf("failed to set config field %s: %w", key, err)
146	}
147	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
148		return fmt.Errorf("failed to create config directory %q: %w", path, err)
149	}
150	if err := os.WriteFile(path, []byte(newValue), 0o600); err != nil {
151		return fmt.Errorf("failed to write config file: %w", err)
152	}
153
154	// Auto-reload to keep in-memory state fresh after config edits.
155	// We use context.Background() since this is an internal operation that
156	// shouldn't be cancelled by user context.
157	if err := s.autoReload(context.Background()); err != nil {
158		// Log warning but don't fail the write - disk is already updated.
159		slog.Warn("Config file updated but failed to reload in-memory state", "error", err)
160	}
161
162	return nil
163}
164
165// RemoveConfigField removes a key from the config file for the given scope.
166// After a successful write, it automatically reloads config to keep in-memory
167// state fresh.
168func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
169	path, err := s.configPath(scope)
170	if err != nil {
171		return fmt.Errorf("%s: %w", key, err)
172	}
173	data, err := os.ReadFile(path)
174	if err != nil {
175		return fmt.Errorf("failed to read config file: %w", err)
176	}
177
178	newValue, err := sjson.Delete(string(data), key)
179	if err != nil {
180		return fmt.Errorf("failed to delete config field %s: %w", key, err)
181	}
182	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
183		return fmt.Errorf("failed to create config directory %q: %w", path, err)
184	}
185	if err := os.WriteFile(path, []byte(newValue), 0o600); err != nil {
186		return fmt.Errorf("failed to write config file: %w", err)
187	}
188
189	// Auto-reload to keep in-memory state fresh after config edits.
190	if err := s.autoReload(context.Background()); err != nil {
191		slog.Warn("Config file updated but failed to reload in-memory state", "error", err)
192	}
193
194	return nil
195}
196
197// UpdatePreferredModel updates the preferred model for the given type and
198// persists it to the config file at the given scope.
199func (s *ConfigStore) UpdatePreferredModel(scope Scope, modelType SelectedModelType, model SelectedModel) error {
200	s.config.Models[modelType] = model
201	if err := s.SetConfigField(scope, fmt.Sprintf("models.%s", modelType), model); err != nil {
202		return fmt.Errorf("failed to update preferred model: %w", err)
203	}
204	if err := s.recordRecentModel(scope, modelType, model); err != nil {
205		return err
206	}
207	return nil
208}
209
210// SetCompactMode sets the compact mode setting and persists it.
211func (s *ConfigStore) SetCompactMode(scope Scope, enabled bool) error {
212	if s.config.Options == nil {
213		s.config.Options = &Options{}
214	}
215	s.config.Options.TUI.CompactMode = enabled
216	return s.SetConfigField(scope, "options.tui.compact_mode", enabled)
217}
218
219// SetTransparentBackground sets the transparent background setting and persists it.
220func (s *ConfigStore) SetTransparentBackground(scope Scope, enabled bool) error {
221	if s.config.Options == nil {
222		s.config.Options = &Options{}
223	}
224	s.config.Options.TUI.Transparent = &enabled
225	return s.SetConfigField(scope, "options.tui.transparent", enabled)
226}
227
228// SetProviderAPIKey sets the API key for a provider and persists it.
229func (s *ConfigStore) SetProviderAPIKey(scope Scope, providerID string, apiKey any) error {
230	var providerConfig ProviderConfig
231	var exists bool
232	var setKeyOrToken func()
233
234	switch v := apiKey.(type) {
235	case string:
236		if err := s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), v); err != nil {
237			return fmt.Errorf("failed to save api key to config file: %w", err)
238		}
239		setKeyOrToken = func() { providerConfig.APIKey = v }
240	case *oauth.Token:
241		if err := cmp.Or(
242			s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), v.AccessToken),
243			s.SetConfigField(scope, fmt.Sprintf("providers.%s.oauth", providerID), v),
244		); err != nil {
245			return err
246		}
247		setKeyOrToken = func() {
248			providerConfig.APIKey = v.AccessToken
249			providerConfig.OAuthToken = v
250			switch providerID {
251			case string(catwalk.InferenceProviderCopilot):
252				providerConfig.SetupGitHubCopilot()
253			}
254		}
255	}
256
257	providerConfig, exists = s.config.Providers.Get(providerID)
258	if exists {
259		setKeyOrToken()
260		s.config.Providers.Set(providerID, providerConfig)
261		return nil
262	}
263
264	var foundProvider *catwalk.Provider
265	for _, p := range s.knownProviders {
266		if string(p.ID) == providerID {
267			foundProvider = &p
268			break
269		}
270	}
271
272	if foundProvider != nil {
273		providerConfig = ProviderConfig{
274			ID:           providerID,
275			Name:         foundProvider.Name,
276			BaseURL:      foundProvider.APIEndpoint,
277			Type:         foundProvider.Type,
278			Disable:      false,
279			ExtraHeaders: make(map[string]string),
280			ExtraParams:  make(map[string]string),
281			Models:       foundProvider.Models,
282		}
283		setKeyOrToken()
284	} else {
285		return fmt.Errorf("provider with ID %s not found in known providers", providerID)
286	}
287	s.config.Providers.Set(providerID, providerConfig)
288	return nil
289}
290
291// RefreshOAuthToken refreshes the OAuth token for the given provider.
292// Before making an external refresh request, it checks the config file on
293// disk to see if another Crush session has already refreshed the token. If
294// a newer token is found, it is used instead of refreshing.
295func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, providerID string) error {
296	providerConfig, exists := s.config.Providers.Get(providerID)
297	if !exists {
298		return fmt.Errorf("provider %s not found", providerID)
299	}
300
301	if providerConfig.OAuthToken == nil {
302		return fmt.Errorf("provider %s does not have an OAuth token", providerID)
303	}
304
305	// Check if another session refreshed the token recently by reading
306	// the current token from the config file on disk.
307	newToken, err := s.loadTokenFromDisk(scope, providerID)
308	if err != nil {
309		slog.Warn("Failed to read token from config file, proceeding with refresh", "provider", providerID, "error", err)
310	} else if newToken != nil && newToken.AccessToken != providerConfig.OAuthToken.AccessToken {
311		slog.Info("Using token refreshed by another session", "provider", providerID)
312		providerConfig.OAuthToken = newToken
313		providerConfig.APIKey = newToken.AccessToken
314		if providerID == string(catwalk.InferenceProviderCopilot) {
315			providerConfig.SetupGitHubCopilot()
316		}
317		s.config.Providers.Set(providerID, providerConfig)
318		return nil
319	}
320
321	var refreshedToken *oauth.Token
322	var refreshErr error
323	switch providerID {
324	case string(catwalk.InferenceProviderCopilot):
325		refreshedToken, refreshErr = copilot.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
326	case hyperp.Name:
327		refreshedToken, refreshErr = hyper.ExchangeToken(ctx, providerConfig.OAuthToken.RefreshToken)
328	default:
329		return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
330	}
331	if refreshErr != nil {
332		return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)
333	}
334
335	slog.Info("Successfully refreshed OAuth token", "provider", providerID)
336	providerConfig.OAuthToken = refreshedToken
337	providerConfig.APIKey = refreshedToken.AccessToken
338
339	switch providerID {
340	case string(catwalk.InferenceProviderCopilot):
341		providerConfig.SetupGitHubCopilot()
342	}
343
344	s.config.Providers.Set(providerID, providerConfig)
345
346	if err := cmp.Or(
347		s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), refreshedToken.AccessToken),
348		s.SetConfigField(scope, fmt.Sprintf("providers.%s.oauth", providerID), refreshedToken),
349	); err != nil {
350		return fmt.Errorf("failed to persist refreshed token: %w", err)
351	}
352
353	return nil
354}
355
356// loadTokenFromDisk reads the OAuth token for the given provider from the
357// config file on disk. Returns nil if the token is not found or matches the
358// current in-memory token.
359func (s *ConfigStore) loadTokenFromDisk(scope Scope, providerID string) (*oauth.Token, error) {
360	path, err := s.configPath(scope)
361	if err != nil {
362		return nil, err
363	}
364
365	data, err := os.ReadFile(path)
366	if err != nil {
367		if os.IsNotExist(err) {
368			return nil, nil
369		}
370		return nil, err
371	}
372
373	oauthKey := fmt.Sprintf("providers.%s.oauth", providerID)
374	oauthResult := gjson.Get(string(data), oauthKey)
375	if !oauthResult.Exists() {
376		return nil, nil
377	}
378
379	var token oauth.Token
380	if err := json.Unmarshal([]byte(oauthResult.Raw), &token); err != nil {
381		return nil, err
382	}
383
384	if token.AccessToken == "" {
385		return nil, nil
386	}
387
388	return &token, nil
389}
390
391// recordRecentModel records a model in the recent models list.
392func (s *ConfigStore) recordRecentModel(scope Scope, modelType SelectedModelType, model SelectedModel) error {
393	if model.Provider == "" || model.Model == "" {
394		return nil
395	}
396
397	if s.config.RecentModels == nil {
398		s.config.RecentModels = make(map[SelectedModelType][]SelectedModel)
399	}
400
401	eq := func(a, b SelectedModel) bool {
402		return a.Provider == b.Provider && a.Model == b.Model
403	}
404
405	entry := SelectedModel{
406		Provider: model.Provider,
407		Model:    model.Model,
408	}
409
410	current := s.config.RecentModels[modelType]
411	withoutCurrent := slices.DeleteFunc(slices.Clone(current), func(existing SelectedModel) bool {
412		return eq(existing, entry)
413	})
414
415	updated := append([]SelectedModel{entry}, withoutCurrent...)
416	if len(updated) > maxRecentModelsPerType {
417		updated = updated[:maxRecentModelsPerType]
418	}
419
420	if slices.EqualFunc(current, updated, eq) {
421		return nil
422	}
423
424	s.config.RecentModels[modelType] = updated
425
426	if err := s.SetConfigField(scope, fmt.Sprintf("recent_models.%s", modelType), updated); err != nil {
427		return fmt.Errorf("failed to persist recent models: %w", err)
428	}
429
430	return nil
431}
432
433// NewTestStore creates a ConfigStore for testing purposes.
434func NewTestStore(cfg *Config, loadedPaths ...string) *ConfigStore {
435	return &ConfigStore{
436		config:      cfg,
437		loadedPaths: loadedPaths,
438	}
439}
440
441// ImportCopilot attempts to import a GitHub Copilot token from disk.
442func (s *ConfigStore) ImportCopilot() (*oauth.Token, bool) {
443	if s.HasConfigField(ScopeGlobal, "providers.copilot.api_key") || s.HasConfigField(ScopeGlobal, "providers.copilot.oauth") {
444		return nil, false
445	}
446
447	diskToken, hasDiskToken := copilot.RefreshTokenFromDisk()
448	if !hasDiskToken {
449		return nil, false
450	}
451
452	slog.Info("Found existing GitHub Copilot token on disk. Authenticating...")
453	token, err := copilot.RefreshToken(context.TODO(), diskToken)
454	if err != nil {
455		slog.Error("Unable to import GitHub Copilot token", "error", err)
456		return nil, false
457	}
458
459	if err := s.SetProviderAPIKey(ScopeGlobal, string(catwalk.InferenceProviderCopilot), token); err != nil {
460		return token, false
461	}
462
463	if err := cmp.Or(
464		s.SetConfigField(ScopeGlobal, "providers.copilot.api_key", token.AccessToken),
465		s.SetConfigField(ScopeGlobal, "providers.copilot.oauth", token),
466	); err != nil {
467		slog.Error("Unable to save GitHub Copilot token to disk", "error", err)
468	}
469
470	slog.Info("GitHub Copilot successfully imported")
471	return token, true
472}
473
474// StalenessResult contains the result of a staleness check.
475type StalenessResult struct {
476	Dirty   bool
477	Changed []string
478	Missing []string
479	Errors  map[string]error // stat errors by path
480}
481
482// ConfigStaleness checks whether any tracked config files have changed on disk
483// since the last snapshot. Returns dirty=true if any files changed or went
484// missing, along with sorted lists of affected paths. Stat errors are
485// captured in Errors map but still treated as non-existence for dirty detection.
486func (s *ConfigStore) ConfigStaleness() StalenessResult {
487	var result StalenessResult
488	result.Errors = make(map[string]error)
489
490	for _, path := range s.trackedConfigPaths {
491		snapshot, hadSnapshot := s.snapshots[path]
492
493		info, err := os.Stat(path)
494		exists := err == nil && !info.IsDir()
495
496		if err != nil && !os.IsNotExist(err) {
497			// Capture permission/IO errors separately from non-existence
498			result.Errors[path] = err
499			result.Dirty = true
500		}
501
502		if !exists {
503			if hadSnapshot && snapshot.Exists {
504				// File existed before but now missing
505				result.Missing = append(result.Missing, path)
506				result.Dirty = true
507			}
508			continue
509		}
510
511		// File exists now
512		if !hadSnapshot || !snapshot.Exists {
513			// File didn't exist before but does now
514			result.Changed = append(result.Changed, path)
515			result.Dirty = true
516			continue
517		}
518
519		// Check for content or metadata changes
520		if snapshot.Size != info.Size() || snapshot.ModTime != info.ModTime().UnixNano() {
521			result.Changed = append(result.Changed, path)
522			result.Dirty = true
523		}
524	}
525
526	// Sort for deterministic output
527	slices.Sort(result.Changed)
528	slices.Sort(result.Missing)
529
530	return result
531}
532
533// RefreshStalenessSnapshot captures fresh snapshots of all tracked config files.
534// Call this after reloading config to clear dirty state.
535func (s *ConfigStore) RefreshStalenessSnapshot() error {
536	if s.snapshots == nil {
537		s.snapshots = make(map[string]fileSnapshot)
538	}
539
540	for _, path := range s.trackedConfigPaths {
541		info, err := os.Stat(path)
542		exists := err == nil && !info.IsDir()
543
544		snapshot := fileSnapshot{
545			Path:   path,
546			Exists: exists,
547		}
548
549		if exists {
550			snapshot.Size = info.Size()
551			snapshot.ModTime = info.ModTime().UnixNano()
552		}
553
554		s.snapshots[path] = snapshot
555	}
556
557	return nil
558}
559
560// CaptureStalenessSnapshot captures snapshots for the given paths, building the
561// tracked config paths list. Paths are deduplicated and normalized.
562func (s *ConfigStore) CaptureStalenessSnapshot(paths []string) {
563	// Build unique set of normalized paths
564	seen := make(map[string]struct{})
565	for _, p := range paths {
566		if p == "" {
567			continue
568		}
569		// Normalize path
570		abs, err := filepath.Abs(p)
571		if err != nil {
572			abs = p
573		}
574		seen[abs] = struct{}{}
575	}
576
577	// Also track workspace and global config paths if set
578	if s.workspacePath != "" {
579		abs, err := filepath.Abs(s.workspacePath)
580		if err == nil {
581			seen[abs] = struct{}{}
582		}
583	}
584	if s.globalDataPath != "" {
585		abs, err := filepath.Abs(s.globalDataPath)
586		if err == nil {
587			seen[abs] = struct{}{}
588		}
589	}
590
591	// Build sorted list for deterministic ordering
592	s.trackedConfigPaths = make([]string, 0, len(seen))
593	for p := range seen {
594		s.trackedConfigPaths = append(s.trackedConfigPaths, p)
595	}
596	slices.Sort(s.trackedConfigPaths)
597
598	// Capture initial snapshots
599	s.RefreshStalenessSnapshot()
600}
601
602// captureStalenessSnapshot is an alias for CaptureStalenessSnapshot for internal use.
603func (s *ConfigStore) captureStalenessSnapshot(paths []string) {
604	s.CaptureStalenessSnapshot(paths)
605}
606
607// ReloadFromDisk re-runs the config load/merge flow and updates the in-memory
608// config atomically. It rebuilds the staleness snapshot after successful reload.
609// On failure, the store state is rolled back to its previous state.
610func (s *ConfigStore) ReloadFromDisk(ctx context.Context) error {
611	if s.workingDir == "" {
612		return fmt.Errorf("cannot reload: working directory not set")
613	}
614
615	// Disable auto-reload during reload to prevent nested/re-entrant calls.
616	s.autoReloadDisabled = true
617	s.reloadInProgress = true
618	defer func() {
619		s.autoReloadDisabled = false
620		s.reloadInProgress = false
621	}()
622
623	configPaths := lookupConfigs(s.workingDir)
624	cfg, loadedPaths, err := loadFromConfigPaths(configPaths)
625	if err != nil {
626		return fmt.Errorf("failed to reload config: %w", err)
627	}
628
629	// Apply defaults (using existing data directory if set)
630	var dataDir string
631	if s.config != nil && s.config.Options != nil {
632		dataDir = s.config.Options.DataDirectory
633	}
634	cfg.setDefaults(s.workingDir, dataDir)
635
636	// Merge workspace config if present
637	workspacePath := filepath.Join(cfg.Options.DataDirectory, fmt.Sprintf("%s.json", appName))
638	if wsData, err := os.ReadFile(workspacePath); err == nil && len(wsData) > 0 {
639		merged, mergeErr := loadFromBytes(append([][]byte{mustMarshalConfig(cfg)}, wsData))
640		if mergeErr == nil {
641			dataDir := cfg.Options.DataDirectory
642			*cfg = *merged
643			cfg.setDefaults(s.workingDir, dataDir)
644			loadedPaths = append(loadedPaths, workspacePath)
645		}
646	}
647
648	// Validate hooks after all config merging is complete so matcher
649	// regexes are recompiled on the reloaded config (mirrors Load).
650	if err := cfg.ValidateHooks(); err != nil {
651		return fmt.Errorf("invalid hook configuration on reload: %w", err)
652	}
653
654	// Preserve runtime overrides
655	overrides := s.overrides
656
657	// Reconfigure providers
658	env := env.New()
659	resolver := NewShellVariableResolver(env)
660	providers, err := Providers(cfg)
661	if err != nil {
662		return fmt.Errorf("failed to load providers during reload: %w", err)
663	}
664
665	if err := cfg.configureProviders(s, env, resolver, providers); err != nil {
666		return fmt.Errorf("failed to configure providers during reload: %w", err)
667	}
668
669	// Save current state for potential rollback
670	oldConfig := s.config
671	oldLoadedPaths := s.loadedPaths
672	oldResolver := s.resolver
673	oldKnownProviders := s.knownProviders
674	oldOverrides := s.overrides
675	oldWorkspacePath := s.workspacePath
676
677	// Update store state BEFORE running model/agent setup (so they see new config)
678	s.config = cfg
679	s.loadedPaths = loadedPaths
680	s.resolver = resolver
681	s.knownProviders = providers
682	s.overrides = overrides
683	s.workspacePath = workspacePath
684
685	// Mirror startup flow: setup models and agents against NEW config
686	var setupErr error
687	if !cfg.IsConfigured() {
688		slog.Warn("No providers configured after reload")
689	} else {
690		if err := configureSelectedModels(s, providers, false); err != nil {
691			setupErr = fmt.Errorf("failed to configure selected models during reload: %w", err)
692		} else {
693			s.SetupAgents()
694		}
695	}
696
697	// Rollback on setup failure
698	if setupErr != nil {
699		s.config = oldConfig
700		s.loadedPaths = oldLoadedPaths
701		s.resolver = oldResolver
702		s.knownProviders = oldKnownProviders
703		s.overrides = oldOverrides
704		s.workspacePath = oldWorkspacePath
705		return setupErr
706	}
707
708	// Rebuild staleness tracking
709	s.captureStalenessSnapshot(loadedPaths)
710
711	return nil
712}
713
714// autoReload conditionally reloads config from disk after writes.
715// It returns nil (no error) for expected skip cases: when auto-reload is
716// disabled during load/reload flows, or when working directory is not set
717// (e.g., during testing). Only actual reload failures return an error.
718func (s *ConfigStore) autoReload(ctx context.Context) error {
719	if s.autoReloadDisabled {
720		return nil // Expected skip: already in load/reload flow
721	}
722	if s.workingDir == "" {
723		return nil // Expected skip: working directory not set
724	}
725	return s.ReloadFromDisk(ctx)
726}