Merge pull request #1714 from entireio/fix/1140-enable-state · Entire
Log in
Merge pull request #1714 from entireio/fix/1140-enable-state
95522d2→main·
suhaanthayyil·2d ago·5 files·+1,018 added/-83 removed
fix(setup): enable writes the enabled flag to the resolved settings scope
Changes
5
cmd/entire/cli
integration_test
Msetup_cmd_test.go+90
settings
Msettings.go+7
Msettings_test.go+39
Msetup.go+199/-42
Msetup_test.go+683/-41
266 unmodified lines
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
266 unmodified lines
t.Fatal("commit has no Entire-Checkpoint trailer — hooks silently no-op'd with only settings.local.json")
}
}
// TestEnableReenablesProjectScopeAfterProjectDisable is a full-flow
// reproduction of a re-enable regression: after `entire disable --project`,
// running `entire enable --checkpoint-remote ...` with no --project/--local
// reported success but wrote the enabled flag to .entire/settings.local.json,
// leaving the project .entire/settings.json the user disabled still
// enabled=false.
//
// Because settings.local.json (enabled:true) overrides settings.json in the
// merged view that both `entire status` and IsEnabled read, status actually
// reported ENABLED — the effective state was correct and only the committed
// file was stale. That still bites anyone without the local file (a fresh
// clone, a teammate) and leaves the committed source of truth wrong.
//
// This drives the real entire binary end-to-end — enable, disable --project,
// then a setup-flag re-enable — and asserts the PROJECT settings.json (the file
// the user actually disabled) is enabled again.
func TestEnableReenablesProjectScopeAfterProjectDisable(t *testing.T) {
t.Parallel()
env := NewTestEnv(t)
defer env.Cleanup()
env.InitRepo()
// First-time setup via the real binary; a plain enable writes the project
// .entire/settings.json.
env.RunCLI("enable", "--agent", "claude-code", "--telemetry=false")
assertProjectSettingsEnabled(t, env, true)
// Disable at the project scope → settings.json enabled=false.
env.RunCLI("disable", "--project")
assertProjectSettingsEnabled(t, env, false)
// Re-enable with a setup flag but WITHOUT --project/--local. Pre-fix the
// enabled flag landed in settings.local.json, so the project file the user
// disabled stayed enabled=false.
env.RunCLI("enable", "--checkpoint-remote", "github:org/repo", "--skip-push-sessions", "--telemetry=false")
assertProjectSettingsEnabled(t, env, true)
// And no local override may contradict it: settings.local.json must be
// absent or itself enabled:true, so the merged view can't silently flip
// back to disabled by accident of the flow.
assertLocalSettingsAbsentOrEnabled(t, env)
}
// assertLocalSettingsAbsentOrEnabled asserts that .entire/settings.local.json,
// if present, does not carry an enabled:false override that would mask the
// committed project scope.
func assertLocalSettingsAbsentOrEnabled(t *testing.T, env *TestEnv) {
t.Helper()
localPath := filepath.Join(env.RepoDir, ".entire", "settings.local.json")
data, err := os.ReadFile(localPath)
if os.IsNotExist(err) {
return
}
if err != nil {
t.Fatalf("read .entire/settings.local.json: %v", err)
}
var s struct {
Enabled *bool `json:"enabled"`
}
if err := json.Unmarshal(data, &s); err != nil {
t.Fatalf("parse .entire/settings.local.json: %v\ncontent: %s", err, data)
}
if s.Enabled != nil && !*s.Enabled {
t.Fatalf("settings.local.json carries enabled:false, which would mask the re-enabled project scope\ncontent: %s", data)
}
}
// assertProjectSettingsEnabled reads .entire/settings.json (the project scope,
// never settings.local.json) and asserts its enabled flag matches want.
func assertProjectSettingsEnabled(t *testing.T, env *TestEnv, want bool) {
t.Helper()
settingsPath := filepath.Join(env.RepoDir, ".entire", "settings.json")
data, err := os.ReadFile(settingsPath)
if err != nil {
t.Fatalf("read .entire/settings.json: %v", err)
}
var s struct {
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(data, &s); err != nil {
t.Fatalf("parse .entire/settings.json: %v\ncontent: %s", err, data)
}
if s.Enabled != want {
t.Fatalf("project settings.json enabled=%v, want %v — enabled flag written to the wrong scope\ncontent: %s",
s.Enabled, want, data)
}
}
Mcmd/entire/cli/integration_test/setup_cmd_test.go+90
629 unmodified lines
630
631
632
633
634
635
636
637
638
639
640
641
642
629 unmodified lines
if err != nil {
return fmt.Errorf("marshal %s settings: %w", label, err)
}
// Ensure the parent directory exists, mirroring the struct save path
// (saveToFile). Without this, the raw save path fails in a repo that has
// never created .entire/ — e.g. a bare `entire disable` in a fresh repo,
// which resolves to a raw flip before any directory is created.
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return fmt.Errorf("creating %s settings directory: %w", label, err)
}
if err := jsonutil.WriteFileAtomic(path, data, 0o644); err != nil {
return fmt.Errorf("writing %s settings: %w", label, err)
}
Mcmd/entire/cli/settings/settings.go+7
1331 unmodified lines
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1331 unmodified lines
}
}
// TestSaveProjectRaw_CreatesMissingParentDir verifies the raw save path creates
// its parent directory, mirroring the struct save path (saveToFile). Without
// this, a raw enabled-flag flip in a repo that has never created .entire/
// (e.g. a bare `entire disable` in a fresh repo) hard-fails with "no such file
// or directory". Regression test for the saveRaw MkdirAll fix.
func TestSaveProjectRaw_CreatesMissingParentDir(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, ".entire", "settings.json")
raw := map[string]json.RawMessage{"enabled": json.RawMessage("false")}
if err := SaveProjectRaw(path, raw); err != nil {
t.Fatalf("SaveProjectRaw() into a missing .entire dir should succeed, got: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("settings file should have been created: %v", err)
}
if !strings.Contains(string(data), `"enabled": false`) {
t.Errorf("expected enabled:false, got: %s", data)
}
}
// TestSaveLocalRaw_CreatesMissingParentDir is the local-scope mirror of
// TestSaveProjectRaw_CreatesMissingParentDir.
func TestSaveLocalRaw_CreatesMissingParentDir(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, ".entire", "settings.local.json")
raw := map[string]json.RawMessage{"enabled": json.RawMessage("false")}
if err := SaveLocalRaw(path, raw); err != nil {
t.Fatalf("SaveLocalRaw() into a missing .entire dir should succeed, got: %v", err)
}
if _, err := os.ReadFile(path); err != nil {
t.Fatalf("local settings file should have been created: %v", err)
}
}
// Regression: `entire enable --local` writes only .entire/settings.local.json,
// but the hook activation check (IsSetUpAndEnabled) only looked for
// .entire/settings.json, so hooks silently no-op'd. It must recognize a
Mcmd/entire/cli/settings/settings_test.go+39
1 unmodified line
2
3
4
5
6
7
8
1040 unmodified lines
1049
1050
1051
1052
1053
1054
1055
17 unmodified lines
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
32 unmodified lines
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1117
1141
1142
1143
1144
1145
1146
1147
1124
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
168 unmodified lines
1345
1346
1347
1299
1300
1301
1348
1349
1350
1351
1352
1303
1304
1305
1306
1307
1308
1309
1310
1353
1354
1355
1356
2 unmodified lines
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1320
1321
1322
1379
1380
1381
1382
1383
1384
1385
1325
1326
1327
1386
1387
1388
1389
1331
1390
1391
1392
1393
1335
1336
1337
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1343
1466
1467
1345
1468
1469
1470
1471
334 unmodified lines
1806
1807
1808
1686
1687
1809
1810
1811
1812
1813
1814
1815
1689
1690
1816
1817
1692
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1694
1837
1838
1839
1697
1840
1841
1842
1843
1844
1702
1845
1846
1847
1705
1848
1849
1850
1708
1851
1852
1853
1854
1 unmodified line
1856
1857
1858
1716
1859
1860
1861
1719
1720
1862
1863
1864
1865
1724
1725
1726
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1729
1886
1887
1888
1889
1 unmodified line
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
1040 unmodified lines
}
func newDisableCmd() *cobra.Command {
var useLocalSettings bool
var useProjectSettings bool
var uninstall bool
var force bool
17 unmodified lines
if uninstall {
return runUninstall(ctx, cmd.OutOrStdout(), cmd.ErrOrStderr(), force)
}
if err := validateSetupFlags(useLocalSettings, useProjectSettings); err != nil {
return err
}
return runDisable(ctx, cmd.OutOrStdout(), useProjectSettings)
},
}
cmd.Flags().BoolVar(&useLocalSettings, "local", false, "Update .entire/settings.local.json (the default) instead of .entire/settings.json")
cmd.Flags().BoolVar(&useProjectSettings, "project", false, "Update .entire/settings.json instead of .entire/settings.local.json")
cmd.Flags().BoolVar(&uninstall, "uninstall", false, "Completely remove Entire from this repository")
cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt (use with --uninstall)")
32 unmodified lines
}
}
// Resolve the target scope first, then decide whether there is anything to
// do. Enable writes to the scope resolved by settingsTargetFile, which is
// also what strategy/checkpoint-backend updates above use. Without this, a
// plain `entire enable` (no --project/--local) resolved the strategy write
// to the existing project settings.json but wrote the enabled flag to
// settings.local.json, leaving the project file the user disabled still
// enabled=false.
targetFile, _ := settingsTargetFile(ctx, opts.UseLocalSettings, opts.UseProjectSettings)
useProject := targetFile == settings.EntireSettingsFile
// The merged view can report enabled while the resolved target file is
// itself still disabled — exactly the legacy split state a pre-fix binary
// left on disk (committed settings.json enabled:false masked by
// settings.local.json enabled:true, which wins in the merge). In that case
// the early "already enabled" return would never flip the target file, even
// with an explicit --project, so `enable` could not recover that split
// state. Only short-circuit when the merged view is enabled AND the target
// file is not itself explicitly disabled.
enabled, err := IsEnabled(ctx)
if err == nil && enabled {
if err == nil && enabled && !scopeExplicitlyDisabled(ctx, useProject) {
if !usedSetupFlow {
fmt.Fprintln(w, "Entire is already enabled.")
}
printEnabledStatus(ctx, w)
return nil
}
return runEnable(ctx, w, opts.UseProjectSettings)
return runEnable(ctx, w, useProject)
}
// scopeExplicitlyDisabled reports whether the settings file for the given scope
// exists and carries an explicit "enabled": false. A missing file or a missing
// "enabled" key returns false: those default to enabled, so there is nothing to
// recover. Used to detect the legacy split state where the merged view is
// enabled but the target file the user cares about is still disabled.
func scopeExplicitlyDisabled(ctx context.Context, useProject bool) bool {
load := settings.LoadLocalRaw
if useProject {
load = settings.LoadProjectRaw
}
_, raw, _, err := load(ctx)
if err != nil {
return false
}
value, ok := raw["enabled"]
if !ok {
return false
}
var enabled bool
if err := json.Unmarshal(value, &enabled); err != nil {
return false
}
return !enabled
}
func runEnableInteractive(ctx context.Context, w io.Writer, agents []agent.Agent, opts EnableOptions) error {
168 unmodified lines
fmt.Fprintln(w, "\nTo add more agents, run `entire agent add <name>`.")
}
// runEnable sets the enabled flag in settings.
// Writes to the target file (local by default, project with --project),
// and also updates the other file if it exists, so they can't get out of sync.
// runEnable flips the enabled flag to true in the scope chosen by the caller
// (see setEnabledFlag). Callers resolve the scope: runEnableOnConfiguredRepo
// uses settingsTargetFile so a bare `entire enable` targets the committed
// settings.json when present and can recover a repo disabled there.
func runEnable(ctx context.Context, w io.Writer, useProjectSettings bool) error {
s, err := LoadEntireSettings(ctx)
if err != nil {
return fmt.Errorf("failed to load settings: %w", err)
}
s.Enabled = true
if err := saveEnabledState(ctx, s, useProjectSettings); err != nil {
if err := setEnabledFlag(ctx, true, useProjectSettings); err != nil {
return err
}
2 unmodified lines
return nil
}
// runDisable flips the enabled flag to false in the resolved settings scope.
//
// Scope resolution is deliberately asymmetric with enable because
// settings.local.json overrides settings.json in the merged view:
// - bare `entire disable` (and --local) writes settings.local.json — the
// minimal, always-effective way to silence Entire on one machine without
// editing committed team config;
// - --project writes the committed settings.json (and setEnabledFlag also
// syncs the local file if present, so a stale local override can't leave
// the repo enabled).
//
// This restores origin/main's default (bare disable -> local) and matches the
// --project flag's help text. Enable, by contrast, must reach the committed
// file to recover a project the user disabled there, so it resolves via
// settingsTargetFile (see runEnableOnConfiguredRepo). --local is accepted for
// symmetry with enable; for disable it is the same as the bare default.
func runDisable(ctx context.Context, w io.Writer, useProjectSettings bool) error {
s, err := LoadEntireSettings(ctx)
if err != nil {
return fmt.Errorf("failed to load settings: %w", err)
targetFile := settings.EntireSettingsLocalFile
configDisplay := configDisplayLocal
if useProjectSettings {
targetFile = settings.EntireSettingsFile
configDisplay = configDisplayProject
}
s.Enabled = false
if err := saveEnabledState(ctx, s, useProjectSettings); err != nil {
if err := setEnabledFlag(ctx, false, targetFile == settings.EntireSettingsFile); err != nil {
return err
}
fmt.Fprintln(w, "Entire is now disabled.")
fmt.Fprintf(w, "Entire is now disabled (%s).\n", configDisplay)
return nil
}
// 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.
// setEnabledFlag flips only the "enabled" key in the target scope's settings
// file, and — when writing the project scope — also syncs that one key into
// settings.local.json if it exists. The sync is one-directional (project ->
// local) because settings.local.json overrides settings.json in the merged
// view, so a stale local "enabled": false would otherwise keep the repo
// disabled after a project-scope re-enable.
//
// This is the canonical explanation of the merged-vs-scoped write rule that the
// whole enable/disable surface follows; other sites point here.
//
// The write path stays scoped to a single file's own raw JSON on purpose.
// Enable/disable *read* current state through the LoadEntireSettings merged
// view (e.g. IsEnabled), which flattens settings.local.json overrides
// (local_dev, log_level, personal strategy_options/checkpoint_remote, ...) on
// top of settings.json. Writing that merged struct back into one file would
// leak a developer's local-only overrides into the shared, committed project
// file whenever a write resolves to settings.json. setEnabledRaw
// therefore edits only the "enabled" key in each file's own content; its
// sibling saveEnabledState applies the same rule to a caller-provided,
// already-target-scoped struct.
func setEnabledFlag(ctx context.Context, enabled, useProjectSettings bool) error {
if useProjectSettings {
if err := setEnabledRaw(ctx, settings.LoadProjectRaw, settings.SaveProjectRaw, enabled); err != nil {
return fmt.Errorf("failed to save settings: %w", err)
}
// Also update local if it exists, so it doesn't override.
if localExists(ctx) {
if err := setEnabledRaw(ctx, settings.LoadLocalRaw, settings.SaveLocalRaw, enabled); err != nil {
return fmt.Errorf("failed to save local settings: %w", err)
}
}
} else {
if err := setEnabledRaw(ctx, settings.LoadLocalRaw, settings.SaveLocalRaw, enabled); err != nil {
return fmt.Errorf("failed to save local settings: %w", err)
}
}
return nil
}
// setEnabledRaw loads a settings file via load, sets its "enabled" key, and
// writes it back via save, preserving every other key already in that file.
func setEnabledRaw(
ctx context.Context,
load func(context.Context) (path string, raw map[string]json.RawMessage, exists bool, err error),
save func(path string, raw map[string]json.RawMessage) error,
enabled bool,
) error {
path, raw, _, err := load(ctx)
if err != nil {
return err
}
value, err := json.Marshal(enabled)
if err != nil {
return fmt.Errorf("marshal enabled flag: %w", err)
}
raw["enabled"] = value
return save(path, raw)
}
// saveEnabledState writes the caller-provided, already-target-scoped struct s
// to the target file, then applies the same one-directional project -> local
// sync of the "enabled" key as setEnabledFlag (see that function for the full
// merged-vs-scoped rationale). s must already be scoped to the target file's
// own content: it is intentionally NOT written into the other scope, which
// would overwrite that file's own fields (local_dev, log_level, personal
// strategy_options, ...) — the same leak this rule prevents, 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)
}
}
334 unmodified lines
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 setEnabledFlag for
// why: writing the merged struct back into a single scope leaks the other
// scope's fields into it).
targetFile, configDisplay := settingsTargetFile(ctx, opts.UseLocalSettings, opts.UseProjectSettings)
targetFileAbs, err := paths.AbsPath(ctx, targetFile)
if err != nil {
// If we can't load, start with defaults
settings = &EntireSettings{}
targetFileAbs = targetFile
}
settings.Enabled = true
// 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. The local var is named targetSettings so
// it does not shadow the settings package for the rest of the function.
//
// On a parse/validation failure we refuse rather than start from defaults:
// the previous behavior silently replaced a settings.json holding real
// content (strategy_options, log_level, and — under DisallowUnknownFields —
// any key written by a newer CLI) with a bare {"enabled": true}, destroying
// the user's config. A missing file is NOT an error here (LoadFromFile
// returns defaults for it), so first-time enable still works. This mirrors
// updateStrategyOptions, which already refuses on an unparseable target file.
targetSettings, err := settings.LoadFromFile(targetFileAbs)
if err != nil {
return fmt.Errorf("refusing to enable: %s could not be parsed (invalid JSON, or written by a newer entire version); fix or remove it, or upgrade the CLI, then retry: %w", configDisplay, err)
}
targetSettings.Enabled = true
if opts.LocalDev {
settings.LocalDev = true
targetSettings.LocalDev = true
}
if opts.AbsoluteGitHookPath {
settings.AbsoluteGitHookPath = true
targetSettings.AbsoluteGitHookPath = true
}
// Auto-enable external_agents setting if the agent is external.
if external.IsExternal(ag) {
settings.ExternalAgents = true
targetSettings.ExternalAgents = true
}
opts.applyStrategyOptions(settings)
opts.applyStrategyOptions(targetSettings)
// Apply an explicit --checkpoint-backend (no prompt on this non-interactive path).
if err := applyCheckpointBackendFlag(settings, opts.CheckpointBackend); err != nil {
if err := applyCheckpointBackendFlag(targetSettings, opts.CheckpointBackend); err != nil {
return err
}
1 unmodified line
// Note: if telemetry is nil (not configured), it defaults to disabled
if !opts.Telemetry || os.Getenv("ENTIRE_TELEMETRY_OPTOUT") != "" {
f := false
settings.Telemetry = &f
targetSettings.Telemetry = &f
}
targetFile, configDisplay := settingsTargetFile(ctx, opts.UseLocalSettings, opts.UseProjectSettings)
if err := saveEnabledState(ctx, settings, targetFile == EntireSettingsFile); err != nil {
if err := saveEnabledState(ctx, targetSettings, targetFile == EntireSettingsFile); err != nil {
return fmt.Errorf("failed to save settings: %w", err)
}
// Use settings values (merged from existing config + flags) for hook installation
// This ensures re-running `entire enable --agent X` without flags preserves existing settings
if _, err := strategy.InstallGitHook(ctx, true, settings.LocalDev, settings.AbsoluteGitHookPath); err != nil {
// Hook installation decisions need the merged view across both settings
// files, not just the single scope we wrote to above: local_dev and
// absolute_git_hook_path may be set only in settings.local.json while
// this enable resolves to settings.json (or vice versa). Using the
// target-scoped struct here would silently drop that override when
// regenerating the git hook script. This mirrors runEnableInteractive,
// which uses the merged view for the same two fields; only the *write*
// path (saveEnabledState above) stays scoped to the target file (see
// setEnabledFlag for why).
mergedSettings, err := LoadEntireSettings(ctx)
if err != nil {
logging.Warn(ctx, "could not load merged settings for hook installation; proceeding with target-scoped settings only, so local overrides (e.g. local_dev, absolute_git_hook_path) may not be applied to the generated git hook", "error", err)
mergedSettings = targetSettings
}
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)
}
strategy.CheckAndWarnHookManagers(ctx, w, settings.LocalDev, settings.AbsoluteGitHookPath)
strategy.CheckAndWarnHookManagers(ctx, w, hookLocalDev, hookAbsoluteGitHookPath)
if installedHooks == 0 {
msg := fmt.Sprintf("Hooks for %s already installed", ag.Description())
Mcmd/entire/cli/setup.go+199/-42
224 unmodified lines
225
226
227
228
229
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
234
235
236
237
238
239
240
323
324
325
326
327
328
246
329
330
331
249
250
332
333
334
335
336
255
337
338
339
340
1 unmodified line
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
266
405
406
407
408
409
410
271
272
273
274
411
412
413
414
415
416
417
418
419
277
278
420
421
422
423
424
283
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
298
299
468
469
470
471
472
473
302
474
475
476
477
306
307
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
315
512
513
514
515
516
320
517
518
322
519
520
324
325
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
140 unmodified lines
871
872
873
472
473
474
475
874
875
876
877
878
879
880
881
882
883
884
885
3 unmodified lines
889
890
891
485
892
893
894
895
2 unmodified lines
898
899
900
494
901
902
903
497
904
905
906
500
907
908
909
503
910
911
912
913
914
915
509
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
652 unmodified lines
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
224 unmodified lines
}
}
// TestRunEnable_ProjectFlag_ClearsLocalDisable verifies that `entire enable --project`
// after `entire disable` (which writes to local) actually re-enables by updating both files.
// TestRunEnableOnConfiguredRepo_RecoversLegacySplitState covers recovering
// the split state a pre-fix binary left on disk — committed
// settings.json enabled:false, settings.local.json enabled:true. The local
// override wins in the merged view, so IsEnabled reports true; a bare early
// return on the merged view would leave the committed project file disabled
// forever, even with an explicit --project. runEnableOnConfiguredRepo must
// detect that the target scope is itself disabled and flip it.
func TestRunEnableOnConfiguredRepo_RecoversLegacySplitState(t *testing.T) {
setupTestRepo(t)
// Legacy split state.
writeSettings(t, testSettingsDisabled)
writeLocalSettings(t, `{"enabled": true}`)
// Sanity: the merged view already reports enabled (local override wins).
enabled, err := IsEnabled(context.Background())
if err != nil {
t.Fatalf("IsEnabled() error = %v", err)
}
if !enabled {
t.Fatal("precondition: merged view should report enabled (local override wins)")
}
cmd := newEnableCmd()
var buf bytes.Buffer
cmd.SetOut(&buf)
if err := runEnableOnConfiguredRepo(context.Background(), cmd, EnableOptions{UseProjectSettings: true}); err != nil {
t.Fatalf("runEnableOnConfiguredRepo(--project) error = %v", err)
}
// The committed project file must now be enabled — this split state could
// not recover before this fix.
projectS, err := settings.LoadFromFile(EntireSettingsFile)
if err != nil {
t.Fatalf("failed to load project settings: %v", err)
}
if !projectS.Enabled {
t.Error("committed settings.json should be enabled:true after enable --project recovered the split state")
}
}
// TestRunEnableOnConfiguredRepo_BareEnable_RecoversLegacySplitState verifies the
// same recovery happens for a bare `entire enable` (no --project), which
// resolves to the committed settings.json via settingsTargetFile.
func TestRunEnableOnConfiguredRepo_BareEnable_RecoversLegacySplitState(t *testing.T) {
setupTestRepo(t)
writeSettings(t, testSettingsDisabled)
writeLocalSettings(t, `{"enabled": true}`)
cmd := newEnableCmd()
var buf bytes.Buffer
cmd.SetOut(&buf)
if err := runEnableOnConfiguredRepo(context.Background(), cmd, EnableOptions{}); err != nil {
t.Fatalf("runEnableOnConfiguredRepo() error = %v", err)
}
projectS, err := settings.LoadFromFile(EntireSettingsFile)
if err != nil {
t.Fatalf("failed to load project settings: %v", err)
}
if !projectS.Enabled {
t.Error("committed settings.json should be enabled:true after a bare enable recovered the split state")
}
}
// TestRunEnableOnConfiguredRepo_AlreadyEnabled_NoSplit verifies the early
// return still fires (nothing to flip, "already enabled") when the merged view
// AND the resolved target scope agree that Entire is enabled.
func TestRunEnableOnConfiguredRepo_AlreadyEnabled_NoSplit(t *testing.T) {
setupTestRepo(t)
writeSettings(t, testSettingsEnabled)
cmd := newEnableCmd()
var buf bytes.Buffer
cmd.SetOut(&buf)
if err := runEnableOnConfiguredRepo(context.Background(), cmd, EnableOptions{}); err != nil {
t.Fatalf("runEnableOnConfiguredRepo() error = %v", err)
}
if !strings.Contains(buf.String(), "already enabled") {
t.Errorf("expected 'already enabled' output when nothing to recover, got: %s", buf.String())
}
}
// TestRunEnable_ProjectFlag_ClearsLocalDisable verifies that `entire enable
// --project` clears a real local disable override. The precondition is seeded
// directly (settings.local.json enabled:false with a local-only field) rather
// than through runDisable, so the "local override wins and must be cleared"
// scenario is genuinely exercised — the local-sync in setEnabledFlag's project
// branch is what makes the re-enable stick.
func TestRunEnable_ProjectFlag_ClearsLocalDisable(t *testing.T) {
setupTestDir(t)
writeSettings(t, testSettingsEnabled)
// A real local disable override with a local-only field to prove the sync
// touches only the enabled key.
writeLocalSettings(t, `{"enabled": false, "local_dev": true}`)
// Simulate `entire disable` (writes enabled:false to local)
var buf bytes.Buffer
if err := runDisable(context.Background(), &buf, false); err != nil {
t.Fatalf("runDisable() error = %v", err)
}
// Verify it's disabled
// Precondition: the local override wins, so the merged view is disabled.
enabled, err := IsEnabled(context.Background())
if err != nil {
t.Fatalf("IsEnabled() error = %v", err)
}
if enabled {
t.Fatal("Expected disabled after runDisable")
t.Fatal("precondition: local override should make the merged view disabled")
}
// Now re-enable with --project flag
buf.Reset()
var buf bytes.Buffer
if err := runEnable(context.Background(), &buf, true); err != nil {
t.Fatalf("runEnable(project=true) error = %v", err)
}
// Must actually be enabled — local override must not win
// Must actually be enabled — the local override must have been cleared.
enabled, err = IsEnabled(context.Background())
if err != nil {
t.Fatalf("IsEnabled() error = %v", err)
1 unmodified line
if !enabled {
t.Error("Expected enabled after runEnable --project, but IsEnabled() returned false (local override not cleared)")
}
// The local file's enabled key was synced to true, and its local-only
// field survived.
localContent, err := os.ReadFile(EntireSettingsLocalFile)
if err != nil {
t.Fatalf("failed to read local settings: %v", err)
}
if !strings.Contains(string(localContent), `"enabled":true`) && !strings.Contains(string(localContent), `"enabled": true`) {
t.Errorf("local override should be synced to enabled:true, got: %s", localContent)
}
if !strings.Contains(string(localContent), "local_dev") {
t.Errorf("local-only field local_dev should be retained, got: %s", localContent)
}
}
// TestRunEnable_ProjectScope_ClearsExplicitLocalDisable seeds both files
// disabled (committed settings.json enabled:false AND settings.local.json
// enabled:false with local_dev) and asserts that a project-scope enable flips
// both and retains the local-only field. This is the mutation-sensitive test
// for setEnabledFlag's project-branch local sync: skipping the sync leaves the
// local override at enabled:false, which would win and keep IsEnabled false.
func TestRunEnable_ProjectScope_ClearsExplicitLocalDisable(t *testing.T) {
setupTestDir(t)
writeSettings(t, testSettingsDisabled)
writeLocalSettings(t, `{"enabled": false, "local_dev": true}`)
var buf bytes.Buffer
if err := runEnable(context.Background(), &buf, true); err != nil {
t.Fatalf("runEnable(project=true) error = %v", err)
}
enabled, err := IsEnabled(context.Background())
if err != nil {
t.Fatalf("IsEnabled() error = %v", err)
}
if !enabled {
t.Error("Expected enabled after runEnable --project (local override must be synced to enabled:true)")
}
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("committed project settings should be enabled:true, got: %s", projectContent)
}
localContent, err := os.ReadFile(EntireSettingsLocalFile)
if err != nil {
t.Fatalf("failed to read local settings: %v", err)
}
if !strings.Contains(string(localContent), `"enabled":true`) && !strings.Contains(string(localContent), `"enabled": true`) {
t.Errorf("local override should be synced to enabled:true, got: %s", localContent)
}
if !strings.Contains(string(localContent), "local_dev") {
t.Errorf("local-only field local_dev should be retained, got: %s", localContent)
}
}
// TestRunEnable_DefaultFlag_ClearsLocalDisable verifies that `entire enable`
// (default, no --project) after `entire disable` actually re-enables.
// (default/local scope) clears an explicitly-seeded local disable override.
func TestRunEnable_DefaultFlag_ClearsLocalDisable(t *testing.T) {
setupTestDir(t)
writeSettings(t, testSettingsEnabled)
writeLocalSettings(t, `{"enabled": false, "local_dev": true}`)
// Simulate `entire disable` (writes enabled:false to local)
var buf bytes.Buffer
if err := runDisable(context.Background(), &buf, false); err != nil {
t.Fatalf("runDisable() error = %v", err)
// Precondition: local override wins → disabled.
enabled, err := IsEnabled(context.Background())
if err != nil {
t.Fatalf("IsEnabled() error = %v", err)
}
if enabled {
t.Fatal("precondition: local override should make the merged view disabled")
}
// Now re-enable with default (no --project)
buf.Reset()
var buf bytes.Buffer
if err := runEnable(context.Background(), &buf, false); err != nil {
t.Fatalf("runEnable(project=false) error = %v", err)
}
enabled, err := IsEnabled(context.Background())
enabled, err = IsEnabled(context.Background())
if err != nil {
t.Fatalf("IsEnabled() error = %v", err)
}
if !enabled {
t.Error("Expected enabled after runEnable, but IsEnabled() returned false")
}
localContent, err := os.ReadFile(EntireSettingsLocalFile)
if err != nil {
t.Fatalf("failed to read local settings: %v", err)
}
if !strings.Contains(string(localContent), "local_dev") {
t.Errorf("local-only field local_dev should be retained, got: %s", localContent)
}
}
// TestSetupAgentHooksNonInteractive_ClearsLocalDisable verifies that a
// project-scope `enable --agent` clears a real local disable override. The
// precondition is seeded directly (settings.local.json enabled:false) rather
// than via runDisable, and the assertion checks the local override was actually
// synced — otherwise "ClearsLocalDisable" would assert nothing.
func TestSetupAgentHooksNonInteractive_ClearsLocalDisable(t *testing.T) {
setupTestRepo(t)
writeSettings(t, testSettingsEnabled)
writeLocalSettings(t, `{"enabled": false, "local_dev": true}`)
writeClaudeHooksFixture(t)
// Precondition: local override wins → disabled.
enabled, err := IsEnabled(context.Background())
if err != nil {
t.Fatalf("IsEnabled() error = %v", err)
}
if enabled {
t.Fatal("precondition: local override should make the merged view disabled")
}
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 := runDisable(context.Background(), &buf, false); err != nil {
t.Fatalf("runDisable() error = %v", err)
// UseProjectSettings so the enable resolves to the committed file and its
// project branch syncs the local override.
if err := setupAgentHooksNonInteractive(context.Background(), &buf, ag, EnableOptions{UseProjectSettings: true}); err != nil {
t.Fatalf("setupAgentHooksNonInteractive() error = %v", err)
}
enabled, err := IsEnabled(context.Background())
enabled, err = IsEnabled(context.Background())
if err != nil {
t.Fatalf("IsEnabled() error = %v", err)
}
if enabled {
t.Fatal("expected disabled after runDisable")
if !enabled {
t.Fatal("expected enabled after setupAgentHooksNonInteractive (local override must be cleared)")
}
// TestSetupAgentHooksNonInteractive_DoesNotLeakLocalOverridesIntoProject:
// `entire enable --agent <name>` on an already-configured repo used to load the
// merged settings view (LoadEntireSettings) and write it back wholesale to the
// project file via saveEnabledState, flattening settings.local.json-only
// overrides (e.g. log_level) into the shared, committed settings.json — the
// same leak fixed for the bare enable/disable path, just via a different
// entry point (setupAgentHooksNonInteractive).
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)
}
buf.Reset()
var buf bytes.Buffer
if err := setupAgentHooksNonInteractive(context.Background(), &buf, ag, EnableOptions{}); err != nil {
t.Fatalf("setupAgentHooksNonInteractive() error = %v", err)
}
enabled, err = IsEnabled(context.Background())
projectS, err := settings.LoadFromFile(EntireSettingsFile)
if err != nil {
t.Fatalf("IsEnabled() error = %v", err)
t.Fatalf("failed to load project settings: %v", err)
}
if !enabled {
t.Fatal("expected enabled after setupAgentHooksNonInteractive")
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_UsesMergedViewForHookInstall:
// setupAgentHooksNonInteractive loads settings.LoadFromFile 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. Write scoping (no leaking
// local_dev into the committed project file) must still hold.
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")
}
}
// TestSetupAgentHooksNonInteractive_UsesMergedAbsoluteHookPathForHookInstall is
// the absolute_git_hook_path counterpart of the local_dev merged-view test:
// with absolute_git_hook_path set only in settings.local.json while the enable
// resolves (via --project) to settings.json, the generated hook must embed the
// absolute binary path from the merged view — not fall back to the bare
// "entire" prefix the target-scoped struct alone would yield. Guards against a
// mutation reverting hookAbsoluteGitHookPath to the scoped struct.
func TestSetupAgentHooksNonInteractive_UsesMergedAbsoluteHookPathForHookInstall(t *testing.T) {
setupTestRepo(t)
writeSettings(t, testSettingsEnabled)
// absolute_git_hook_path only in the local override; no local_dev, which
// would otherwise take precedence in hookCmdPrefix.
writeLocalSettings(t, `{"enabled": true, "absolute_git_hook_path": true}`)
writeClaudeHooksFixture(t)
ag, err := agent.Get(types.AgentName("claude-code"))
if err != nil {
t.Fatalf("agent.Get(claude-code) error = %v", err)
}
// The hook must embed the resolved absolute executable path (what
// absolute_git_hook_path produces), proving the merged override was honored.
exe, err := os.Executable()
if err != nil {
t.Fatalf("os.Executable() error = %v", err)
}
resolved, err := filepath.EvalSymlinks(exe)
if err != nil {
t.Fatalf("EvalSymlinks() error = %v", err)
}
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), resolved) {
t.Errorf("expected hook to embed absolute binary path %q from the merged view, got: %s", resolved, hookContent)
}
// The write path must still stay scoped: absolute_git_hook_path 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.AbsoluteGitHookPath {
t.Error("local-only absolute_git_hook_path override leaked into project settings")
}
}
// TestSetupAgentHooksNonInteractive_LocalTarget_DoesNotLeakProjectFieldsIntoLocal
// covers the mirror-image direction: writing to settings.local.json (--local)
// must not flatten project-only fields into the local file either.
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)
}
}
// TestSetupAgentHooksNonInteractive_RefusesToClobberUnparseableSettings covers
// the finding that `entire enable --agent` silently wiped a corrupt or
// newer-versioned target settings file to defaults. settings.LoadFromFile
// errors on invalid JSON AND on any unknown key (DisallowUnknownFields); the
// old catch replaced the struct with defaults and wrote it back, so a
// settings.json with strategy_options/log_level/one-unknown-key became exactly
// {"enabled": true}. Now it refuses and leaves the file untouched.
func TestSetupAgentHooksNonInteractive_RefusesToClobberUnparseableSettings(t *testing.T) {
setupTestRepo(t)
// A settings.json a newer CLI could write: valid JSON, real content, plus a
// key this build doesn't recognize (rejected by DisallowUnknownFields).
original := `{"enabled": false, "log_level": "debug", "totally_unknown_future_key": 42}`
writeSettings(t, original)
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.Fatal("expected setupAgentHooksNonInteractive to refuse on an unparseable settings file, got nil error")
}
// The file must be left as-is, not wiped to {"enabled": true}.
got, err := os.ReadFile(EntireSettingsFile)
if err != nil {
t.Fatalf("failed to read project settings: %v", err)
}
if !strings.Contains(string(got), "totally_unknown_future_key") {
t.Errorf("unknown key must survive (file must not be clobbered), got: %s", got)
}
if !strings.Contains(string(got), "log_level") {
t.Errorf("log_level must survive (file must not be clobbered), got: %s", got)
}
if strings.Contains(string(got), `"enabled": true`) || strings.Contains(string(got), `"enabled":true`) {
t.Errorf("enabled must not have been flipped/rewritten, got: %s", got)
}
}
140 unmodified lines
}
}
// 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) {
// TestRunDisable_BareCommand_WritesLocalOverrideWhenProjectOnly verifies that a
// bare `entire disable`, on a repo that only has a committed settings.json (no
// settings.local.json yet), writes the enabled:false override into
// settings.local.json and leaves the committed settings.json untouched. Bare
// disable is a personal, non-destructive silence: because local overrides
// project in the merged view, it makes IsEnabled false without editing shared
// team config. Restores origin/main behavior; regression test for the bare
// disable scope-resolution finding.
func TestRunDisable_BareCommand_WritesLocalOverrideWhenProjectOnly(t *testing.T) {
setupTestDir(t)
// Only create project settings (no local settings)
writeSettings(t, testSettingsEnabled)
3 unmodified lines
t.Fatalf("runDisable() error = %v", err)
}
// Should be disabled
// Should be disabled (local override wins in the merged view).
enabled, err := IsEnabled(context.Background())
if err != nil {
t.Fatalf("IsEnabled(context.Background()) error = %v", err)
2 unmodified lines
t.Error("Entire should be disabled after running disable command")
}
// Local settings file should be created with enabled:false
// The local override should be created with enabled:false.
localContent, err := os.ReadFile(EntireSettingsLocalFile)
if err != nil {
t.Fatalf("Local settings file should have been created: %v", err)
t.Fatalf("settings.local.json should have been created: %v", err)
}
if !strings.Contains(string(localContent), `"enabled":false`) && !strings.Contains(string(localContent), `"enabled": false`) {
t.Errorf("Local settings should have enabled:false, got: %s", localContent)
t.Errorf("local settings should have enabled:false, got: %s", localContent)
}
// Project settings should remain unchanged (still enabled)
// The committed project file must be left untouched (still enabled).
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 still have enabled:true, got: %s", projectContent)
t.Errorf("committed project settings should stay enabled:true after a bare disable, got: %s", projectContent)
}
}
// TestRunDisable_CreatesSettingsDirWhenMissing verifies that a bare `entire
// disable` succeeds in a repo that has never created a .entire/ directory,
// creating settings.local.json (with its parent dir) rather than hard-failing.
// End-to-end regression test for the saveRaw MkdirAll fix.
func TestRunDisable_CreatesSettingsDirWhenMissing(t *testing.T) {
setupTestDir(t)
// No .entire/ directory or settings files at all.
var stdout bytes.Buffer
if err := runDisable(context.Background(), &stdout, false); err != nil {
t.Fatalf("runDisable() in a repo with no .entire/ dir should succeed, got: %v", err)
}
enabled, err := IsEnabled(context.Background())
if err != nil {
t.Fatalf("IsEnabled(context.Background()) error = %v", err)
}
if enabled {
t.Error("Entire should be disabled after running disable command")
}
localContent, err := os.ReadFile(EntireSettingsLocalFile)
if err != nil {
t.Fatalf("settings.local.json should have been created: %v", err)
}
if !strings.Contains(string(localContent), `"enabled":false`) && !strings.Contains(string(localContent), `"enabled": false`) {
t.Errorf("local settings should have enabled:false, got: %s", localContent)
}
}
// TestRunDisable_BareCommand_WritesLocalWhenBothExist verifies that a bare
// `entire disable`, when both settings.json and settings.local.json exist,
// writes enabled:false into the local override only and leaves the committed
// settings.json untouched (no field leakage between scopes). Regression test
// for the bare disable scope-resolution finding.
func TestRunDisable_BareCommand_WritesLocalWhenBothExist(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")
}
// The committed project file must be untouched: still enabled, keeps its
// own fields, and never gains the local-only override.
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("committed project settings should stay enabled:true after a bare disable, got: %s", projectContent)
}
if !strings.Contains(string(projectContent), "log_level") {
t.Errorf("project settings should retain its own log_level field, got: %s", projectContent)
}
if strings.Contains(string(projectContent), "local_dev") {
t.Errorf("project settings must not gain local-only override local_dev, got: %s", projectContent)
}
// The local override carries the disable and keeps its own fields.
localContent, err := os.ReadFile(EntireSettingsLocalFile)
if err != nil {
t.Fatalf("failed to read local settings: %v", err)
}
if !strings.Contains(string(localContent), `"enabled":false`) && !strings.Contains(string(localContent), `"enabled": false`) {
t.Errorf("local settings should have enabled:false, got: %s", localContent)
}
if !strings.Contains(string(localContent), "local_dev") {
t.Errorf("local settings should retain its own local_dev field, got: %s", localContent)
}
}
// TestRunDisable_ProjectFlag_WritesCommittedFile verifies that `entire disable
// --project` flips the committed settings.json and syncs the local override so
// a stale local file can't leave the repo enabled.
func TestRunDisable_ProjectFlag_WritesCommittedFile(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, true); err != nil {
t.Fatalf("runDisable(project=true) error = %v", err)
}
projectContent, err := os.ReadFile(EntireSettingsFile)
if err != nil {
t.Fatalf("failed to read project settings: %v", err)
}
if !strings.Contains(string(projectContent), `"enabled":false`) && !strings.Contains(string(projectContent), `"enabled": false`) {
t.Errorf("project settings should have enabled:false, got: %s", projectContent)
}
if strings.Contains(string(projectContent), "local_dev") {
t.Errorf("project settings must not leak local-only override local_dev, got: %s", projectContent)
}
localContent, err := os.ReadFile(EntireSettingsLocalFile)
if err != nil {
t.Fatalf("failed to read local settings: %v", err)
}
if !strings.Contains(string(localContent), `"enabled":false`) && !strings.Contains(string(localContent), `"enabled": false`) {
t.Errorf("local settings should be synced to enabled:false, got: %s", localContent)
}
}
// TestRunEnable_ProjectFlag_DoesNotLeakLocalOverrides verifies that
// `entire enable --project` with a local-only override present (e.g.
// local_dev, set via settings.local.json) does not write that override into
// the shared, committed project settings.json — only the enabled flag should
// change there (runEnable must not round-trip the merged settings view
// through the project file).
func TestRunEnable_ProjectFlag_DoesNotLeakLocalOverrides(t *testing.T) {
setupTestDir(t)
writeSettings(t, testSettingsDisabled)
writeLocalSettings(t, `{"enabled": true, "local_dev": true}`)
var buf bytes.Buffer
if err := runEnable(context.Background(), &buf, true); err != nil {
t.Fatalf("runEnable(project=true) error = %v", err)
}
// The merged view is correctly enabled.
enabled, err := IsEnabled(context.Background())
if err != nil {
t.Fatalf("IsEnabled() error = %v", err)
}
if !enabled {
t.Error("expected enabled after runEnable --project")
}
// The project file must be flipped to enabled, and must NOT gain the
// local-only override.
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:true, got: %s", projectContent)
}
if strings.Contains(string(projectContent), "local_dev") {
t.Errorf("project settings must not leak local-only override local_dev, got: %s", projectContent)
}
// The local file's own override must be preserved untouched.
localContent, err := os.ReadFile(EntireSettingsLocalFile)
if err != nil {
t.Fatalf("failed to read local settings: %v", err)
}
if !strings.Contains(string(localContent), "local_dev") {
t.Errorf("local settings should still contain local_dev override, got: %s", localContent)
}
}
// TestRunEnable_LocalScope_PreservesLocalOnlyFields verifies that `entire
// enable` (default, no --project) with an existing local-only override only
// flips the enabled flag in settings.local.json and leaves the rest of that
// file's own content (like local_dev) intact.
func TestRunEnable_LocalScope_PreservesLocalOnlyFields(t *testing.T) {
setupTestDir(t)
writeSettings(t, testSettingsEnabled)
writeLocalSettings(t, `{"enabled": false, "local_dev": true}`)
var buf bytes.Buffer
if err := runEnable(context.Background(), &buf, false); err != nil {
t.Fatalf("runEnable(project=false) error = %v", err)
}
enabled, err := IsEnabled(context.Background())
if err != nil {
t.Fatalf("IsEnabled() error = %v", err)
}
if !enabled {
t.Error("expected enabled after runEnable")
}
localContent, err := os.ReadFile(EntireSettingsLocalFile)
if err != nil {
t.Fatalf("failed to read local settings: %v", err)
}
if !strings.Contains(string(localContent), `"enabled":true`) && !strings.Contains(string(localContent), `"enabled": true`) {
t.Errorf("local settings should have enabled:true, got: %s", localContent)
}
if !strings.Contains(string(localContent), "local_dev") {
t.Errorf("local settings should still contain local_dev override, got: %s", localContent)
}
// Project settings must be untouched by the local-scope write.
projectContent, err := os.ReadFile(EntireSettingsFile)
if err != nil {
t.Fatalf("failed to read project settings: %v", err)
}
if strings.Contains(string(projectContent), "local_dev") {
t.Errorf("project settings must not gain local-only override local_dev, got: %s", projectContent)
}
}
652 unmodified lines
}
}
// Regression: `entire enable --checkpoint-remote ...` (no --project)
// on a repo disabled at the project level must re-enable the project
// settings.json, not write the enabled flag to a shadow settings.local.json —
// which left the file the user disabled still enabled=false.
func TestEnableCmd_StrategyFlagsOnDisabledProjectRepo_EnablesProjectFile(t *testing.T) {
setupTestRepo(t)
writeSettings(t, testSettingsDisabled) // settings.json: {"enabled": false}
writeClaudeHooksFixture(t)
cmd := newEnableCmd()
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"--checkpoint-remote", "github:org/repo", "--skip-push-sessions"})
if err := cmd.Execute(); err != nil {
t.Fatalf("enable error = %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String())
}
// The project file the user disabled must be enabled again.
projectS, err := settings.LoadFromFile(EntireSettingsFile)
if err != nil {
t.Fatalf("load project settings: %v", err)
}
if !projectS.Enabled {
t.Errorf("settings.json still enabled=false after enable; the enabled flag went to the wrong file")
}
}
// Tests for detectOrSelectAgent
func TestDetectOrSelectAgent_AgentDetected(t *testing.T) {
Mcmd/entire/cli/setup_test.go+683/-41