fix(setup): resolve settings scope for bare disable and merged hook-install reads · Entire

fix(setup): resolve settings scope for bare disable and merged hook-install reads

b9a5e46→main·

Two follow-on gaps from the #1140 enable/disable scope-resolution fixes:

- runDisable passed the raw, un-resolved useProjectSettings flag straight into setEnabledFlag, so a bare entire disable always wrote to settings.local.json (creating it if missing) instead of flipping whichever file already held the state. On a repo with only a committed settings.json, this silently diverged the checked-in file (still enabled:true) from the merged view. Now routes through the same settingsTargetFile resolution runEnableOnConfiguredRepo already uses.

- setupAgentHooksNonInteractive builds its settings struct via settings.LoadFromFile(targetFileAbs), scoped to a single file, which is correct for the write path (avoids leaking the other scope's fields, per #1140) but was also used for local_dev/absolute_git_hook_path when installing/regenerating the git hook script. An override set only in the other scope's file was silently dropped from the hook's cmd prefix. Hook-install decisions now read the merged view (LoadEntireSettings), matching runEnableInteractive; the write path stays scoped.

Changes

2

1313 unmodified lines

}

func runDisable(ctx context.Context, w io.Writer, useProjectSettings bool) error {
    if err := setEnabledFlag(ctx, false, useProjectSettings); err != nil {
        targetFile, _ := settingsTargetFile(ctx, false, useProjectSettings)
        if err := setEnabledFlag(ctx, false, targetFile == settings.EntireSettingsFile); err != nil {
            return err
        }
    }

if _, err := strategy.InstallGitHook(ctx, true, settings.LocalDev, settings.AbsoluteGitHookPath); err != nil {
        mergedSettings, err := LoadEntireSettings(ctx)
        if err != nil {
            mergedSettings = settings
        }
        hookLocalDev := mergedSettings.LocalDev || opts.LocalDev
        hookAbsoluteGitHookPath := mergedSettings.AbsoluteGitHookPath || opts.AbsoluteGitHookPath

if _, err := strategy.InstallGitHook(ctx, true, hookLocalDev, hookAbsoluteGitHookPath); err != nil {
            return fmt.Errorf("failed to install git hooks: %w", err)
        }
    }
}

// TestSetupAgentHooksNonInteractive_UsesMergedViewForHookInstall covers the // finding at cmd/entire/cli/setup.go:1747: setupAgentHooksNonInteractive // loads settings.LoadFromFile(targetFileAbs), scoped to a single file, for // building the settings struct it writes. If local_dev is set only in // settings.local.json while this enable resolves (via --project) to // settings.json, the local_dev override must still be honored when // installing/regenerating the git hook script — otherwise it's silently dropped and the hook reverts to the plain "entire" cmd prefix instead of the local-dev "./scripts/entire-dev" one. func TestSetupAgentHooksNonInteractive_UsesMergedViewForHookInstall(t *testing.T) { setupTestRepo(t) writeSettings(t, testSettingsEnabled) writeLocalSettings(t, {"enabled": true, "local_dev": true}) 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{UseProjectSettings: true} if err := setupAgentHooksNonInteractive(context.Background(), &buf, ag, opts); err != nil { t.Fatalf("setupAgentHooksNonInteractive() error = %v", err) }

// The git hook script must reflect the merged local_dev override, even // though the write resolved to the project file. hooksDir, err := strategy.GetHooksDir(context.Background()) if err != nil { t.Fatalf("GetHooksDir() error = %v", err) } hookContent, err := os.ReadFile(filepath.Join(hooksDir, "post-commit")) if err != nil { t.Fatalf("failed to read post-commit hook: %v", err) } if !strings.Contains(string(hookContent), "./scripts/entire-dev") { t.Errorf("expected hook to use local-dev cmd prefix from the merged view, got: %s", hookContent) }

// The write path must still stay scoped: local_dev must not leak into // the committed project settings.json. projectS, err := settings.LoadFromFile(EntireSettingsFile) if err != nil { t.Fatalf("failed to load project settings: %v", err) } if projectS.LocalDev { t.Error("local-only local_dev override leaked into project settings") } if !projectS.Enabled { t.Error("expected project settings to remain enabled") } }

// TestRunDisable_CreatesLocalSettingsWhenMissing verifies that running // entire disable without --project creates settings.local.json when it // doesn't exist, rather than writing to settings.json. func TestRunDisable_CreatesLocalSettingsWhenMissing(t *testing.T) { }

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

// TestRunDisable_BareCommand_FlipsCorrectFileWhenBothExist verifies that a // bare entire disable (no --project/--local), when both settings.json and // settings.local.json exist, flips the same file settingsTargetFile would // pick (project, since it's checked first) and does not leak fields between // scopes. Regression test for the finding at cmd/entire/cli/setup.go:1317. func TestRunDisable_BareCommand_FlipsCorrectFileWhenBothExist(t *testing.T) { setupTestDir(t) writeSettings(t, {"enabled": true, "log_level": "warn"}) writeLocalSettings(t, {"enabled": true, "local_dev": true})

var stdout bytes.Buffer if err := runDisable(context.Background(), &stdout, false); err != nil { t.Fatalf("runDisable() error = %v", err) }

enabled, err := IsEnabled(context.Background()) if err != nil { t.Fatalf("IsEnabled() error = %v", err) } if enabled { t.Error("Entire should be disabled after running disable command") }

// Project settings should be flipped and keep its own fields. projectContent, err := os.ReadFile(EntireSettingsFile) if err != nil { t.Fatalf("Failed to read project settings: %v", err) } if !strings.Contains(string(projectContent), "enabled":true) && !strings.Contains(string(projectContent), "enabled": true) { t.Errorf("project settings should have enabled:false, got: %s", projectContent) } }