fix(setup): stop `enable --agent` from leaking local overrides into project settings · Entire

fix(setup): stop enable --agent from leaking local overrides into project settings

e1f7e8b→main·

suhaanthayyil·6d ago·2 files·+108 added/-8 removed

setupAgentHooksNonInteractive (the --agent entry point to entire enable) loaded the merged settings view and wrote it back wholesale to the target file, so local-only overrides (log_level, local_dev, ...) in settings.local.json still flattened into the shared settings.json on repos with a project settings file — the #1140 leak the previous fix closed for bare enable/disable, but not for this entry point.

Load settings scoped to the target file only (matching the pattern already used by updateStrategyOptions/updateGlobalSettings), and scope saveEnabledState's cross-file sync to just the "enabled" key instead of writing the full struct into the other scope's file.

Changes

2

1372 unmodified lines

1373
1374
1375
1376
1377
1378
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1384
1391
1392
1386
1393
1394
1395
1396
334 unmodified lines

1731
1732
1733
1727
1728
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
25 unmodified lines

1776
1777
1778
1760
1779
1780
1781

1372 unmodified lines

return save(path, raw)

// saveEnabledState writes settings to the target file and also updates the
// other settings file if it exists, preventing local/project from getting
// out of sync on the enabled field.
// saveEnabledState writes settings to the target file, and — when writing
// project settings — also syncs just the "enabled" key into the local
// settings file if it exists, so a local override can't leave the repo
// looking disabled after project settings turn it on. It intentionally does
// NOT write the full struct s into the other file: s is scoped to the target
// file's own content (see setupAgentHooksNonInteractive), and writing it
// wholesale into the other scope would overwrite that file's own fields
// (local_dev, log_level, personal strategy_options, ...) with the target
// scope's values — the same #1140 leak setEnabledRaw exists to avoid, just
// in the other direction.
func saveEnabledState(ctx context.Context, s *EntireSettings, useProjectSettings bool) error {
    if useProjectSettings {
        if err := SaveEntireSettings(ctx, s); err != nil {
            return fmt.Errorf("failed to save settings: %w", err)
        }
        // Also update local if it exists, so it doesn't override
        // Also sync just the enabled key to local if it exists, so it doesn't override.
        if localExists(ctx) {
            if err := SaveEntireSettingsLocal(ctx, s); err != nil {
                if err := setEnabledRaw(ctx, settings.LoadLocalRaw, settings.SaveLocalRaw, s.Enabled); err != nil {
                    return fmt.Errorf("failed to save local settings: %w", err)
                }
            }
        }

return fmt.Errorf("failed to setup .entire directory: %w", err)
}

// Load existing settings to preserve other options (like strategy_options.push)
settings, err := LoadEntireSettings(ctx)
// Resolve the target file up front so the load below is scoped to that
// file's own content rather than the merged view (see the comment above
// saveEnabledState for why: writing the merged struct back into a single
// scope leaks the other scope's fields into it, e.g. #1140).
targetFile, configDisplay := settingsTargetFile(ctx, opts.UseLocalSettings, opts.UseProjectSettings)
targetFileAbs, err := paths.AbsPath(ctx, targetFile)
if err != nil {
    targetFileAbs = targetFile
}

// Load existing settings from the target file only, to preserve other
// options already set there (like strategy_options.push) without pulling
// in the other scope's overrides.
settings, err := settings.LoadFromFile(targetFileAbs)
if err != nil {
    // If we can't load, start with defaults
    settings = &EntireSettings{}
}

// Testing functions:

// TestSetupAgentHooksNonInteractive_DoesNotLeakLocalOverridesIntoProject
func TestSetupAgentHooksNonInteractive_DoesNotLeakLocalOverridesIntoProject(t *testing.T) {
    setupTestRepo(t)
    writeSettings(t, testSettingsEnabled)
    writeLocalSettings(t, `{\"log_level\": \"debug\"}`)
    writeClaudeHooksFixture(t)

ag, err := agent.Get(types.AgentName("claude-code"))
    if err != nil {
        t.Fatalf("agent.Get(claude-code) error = %v", err)
    }

var buf bytes.Buffer
    if err := setupAgentHooksNonInteractive(context.Background(), &buf, ag, EnableOptions{}); err != nil {
        t.Fatalf("setupAgentHooksNonInteractive() error = %v", err)
    }

projectS, err := settings.LoadFromFile(EntireSettingsFile)
    if err != nil {
        t.Fatalf("failed to load project settings: %v", err)
    }
    if projectS.LogLevel != "" {
        t.Errorf("local-only log_level leaked into project settings: %q", projectS.LogLevel)
    }
    if !projectS.Enabled {
        t.Error("expected project settings to remain enabled")
    }

localS, err := settings.LoadFromFile(EntireSettingsLocalFile)
    if err != nil {
        t.Fatalf("failed to load local settings: %v", err)
    }
    if localS.LogLevel != "debug" {
        t.Errorf("expected local log_level to be preserved, got %q", localS.LogLevel)
    }
}

// TestSetupAgentHooksNonInteractive_LocalTarget_DoesNotLeakProjectFieldsIntoLocal
func TestSetupAgentHooksNonInteractive_LocalTarget_DoesNotLeakProjectFieldsIntoLocal(t *testing.T) {
    setupTestRepo(t)
    writeSettings(t, `{\"enabled\": true, \"log_level\": \"warn\"}`)
    writeClaudeHooksFixture(t)

ag, err := agent.Get(types.AgentName("claude-code"))
    if err != nil {
        t.Fatalf("agent.Get(claude-code) error = %v", err)
    }

var buf bytes.Buffer
    opts := EnableOptions{UseLocalSettings: true}
    if err := setupAgentHooksNonInteractive(context.Background(), &buf, ag, opts); err != nil {
        t.Fatalf("setupAgentHooksNonInteractive() error = %v", err)
    }

localS, err := settings.LoadFromFile(EntireSettingsLocalFile)
    if err != nil {
        t.Fatalf("failed to load local settings: %v", err)
    }
    if localS.LogLevel != "" {
        t.Errorf("project-only log_level leaked into local settings: %q", localS.LogLevel)
    }
    if !localS.Enabled {
        t.Error("expected local settings to be enabled")
    }

projectS, err := settings.LoadFromFile(EntireSettingsFile)
    if err != nil {
        t.Fatalf("failed to load project settings: %v", err)
    }
    if projectS.LogLevel != "warn" {
        t.Errorf("expected project log_level to be preserved, got %q", projectS.LogLevel)
    }
}

func TestRunDisable(t *testing.T) {
    setupTestDir(t)
    writeSettings(t, testSettingsEnabled)