Skip to content

Commit 95522d2

Browse files
Merge pull request #1714 from entireio/fix/1140-enable-state
fix(setup): enable writes the enabled flag to the resolved settings scope
2 parents aa03f22 + b11b53b commit 95522d2

5 files changed

Lines changed: 1016 additions & 81 deletions

File tree

cmd/entire/cli/integration_test/setup_cmd_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,3 +267,93 @@ func TestHooksRunAfterLocalOnlyEnable(t *testing.T) {
267267
t.Fatal("commit has no Entire-Checkpoint trailer — hooks silently no-op'd with only settings.local.json")
268268
}
269269
}
270+
271+
// TestEnableReenablesProjectScopeAfterProjectDisable is a full-flow
272+
// reproduction of a re-enable regression: after `entire disable --project`,
273+
// running `entire enable --checkpoint-remote ...` with no --project/--local
274+
// reported success but wrote the enabled flag to .entire/settings.local.json,
275+
// leaving the project .entire/settings.json the user disabled still
276+
// enabled=false.
277+
//
278+
// Because settings.local.json (enabled:true) overrides settings.json in the
279+
// merged view that both `entire status` and IsEnabled read, status actually
280+
// reported ENABLED — the effective state was correct and only the committed
281+
// file was stale. That still bites anyone without the local file (a fresh
282+
// clone, a teammate) and leaves the committed source of truth wrong.
283+
//
284+
// This drives the real entire binary end-to-end — enable, disable --project,
285+
// then a setup-flag re-enable — and asserts the PROJECT settings.json (the file
286+
// the user actually disabled) is enabled again.
287+
func TestEnableReenablesProjectScopeAfterProjectDisable(t *testing.T) {
288+
t.Parallel()
289+
env := NewTestEnv(t)
290+
defer env.Cleanup()
291+
292+
env.InitRepo()
293+
294+
// First-time setup via the real binary; a plain enable writes the project
295+
// .entire/settings.json.
296+
env.RunCLI("enable", "--agent", "claude-code", "--telemetry=false")
297+
assertProjectSettingsEnabled(t, env, true)
298+
299+
// Disable at the project scope → settings.json enabled=false.
300+
env.RunCLI("disable", "--project")
301+
assertProjectSettingsEnabled(t, env, false)
302+
303+
// Re-enable with a setup flag but WITHOUT --project/--local. Pre-fix the
304+
// enabled flag landed in settings.local.json, so the project file the user
305+
// disabled stayed enabled=false.
306+
env.RunCLI("enable", "--checkpoint-remote", "github:org/repo", "--skip-push-sessions", "--telemetry=false")
307+
308+
assertProjectSettingsEnabled(t, env, true)
309+
310+
// And no local override may contradict it: settings.local.json must be
311+
// absent or itself enabled:true, so the merged view can't silently flip
312+
// back to disabled by accident of the flow.
313+
assertLocalSettingsAbsentOrEnabled(t, env)
314+
}
315+
316+
// assertLocalSettingsAbsentOrEnabled asserts that .entire/settings.local.json,
317+
// if present, does not carry an enabled:false override that would mask the
318+
// committed project scope.
319+
func assertLocalSettingsAbsentOrEnabled(t *testing.T, env *TestEnv) {
320+
t.Helper()
321+
localPath := filepath.Join(env.RepoDir, ".entire", "settings.local.json")
322+
data, err := os.ReadFile(localPath)
323+
if os.IsNotExist(err) {
324+
return
325+
}
326+
if err != nil {
327+
t.Fatalf("read .entire/settings.local.json: %v", err)
328+
}
329+
var s struct {
330+
Enabled *bool `json:"enabled"`
331+
}
332+
if err := json.Unmarshal(data, &s); err != nil {
333+
t.Fatalf("parse .entire/settings.local.json: %v\ncontent: %s", err, data)
334+
}
335+
if s.Enabled != nil && !*s.Enabled {
336+
t.Fatalf("settings.local.json carries enabled:false, which would mask the re-enabled project scope\ncontent: %s", data)
337+
}
338+
}
339+
340+
// assertProjectSettingsEnabled reads .entire/settings.json (the project scope,
341+
// never settings.local.json) and asserts its enabled flag matches want.
342+
func assertProjectSettingsEnabled(t *testing.T, env *TestEnv, want bool) {
343+
t.Helper()
344+
settingsPath := filepath.Join(env.RepoDir, ".entire", "settings.json")
345+
data, err := os.ReadFile(settingsPath)
346+
if err != nil {
347+
t.Fatalf("read .entire/settings.json: %v", err)
348+
}
349+
var s struct {
350+
Enabled bool `json:"enabled"`
351+
}
352+
if err := json.Unmarshal(data, &s); err != nil {
353+
t.Fatalf("parse .entire/settings.json: %v\ncontent: %s", err, data)
354+
}
355+
if s.Enabled != want {
356+
t.Fatalf("project settings.json enabled=%v, want %v — enabled flag written to the wrong scope\ncontent: %s",
357+
s.Enabled, want, data)
358+
}
359+
}

cmd/entire/cli/settings/settings.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,13 @@ func saveRaw(path, label string, raw map[string]json.RawMessage) error {
630630
if err != nil {
631631
return fmt.Errorf("marshal %s settings: %w", label, err)
632632
}
633+
// Ensure the parent directory exists, mirroring the struct save path
634+
// (saveToFile). Without this, the raw save path fails in a repo that has
635+
// never created .entire/ — e.g. a bare `entire disable` in a fresh repo,
636+
// which resolves to a raw flip before any directory is created.
637+
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
638+
return fmt.Errorf("creating %s settings directory: %w", label, err)
639+
}
633640
if err := jsonutil.WriteFileAtomic(path, data, 0o644); err != nil {
634641
return fmt.Errorf("writing %s settings: %w", label, err)
635642
}

cmd/entire/cli/settings/settings_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1332,6 +1332,45 @@ func TestMergeReviewProfiles_PureAndPrecedence(t *testing.T) {
13321332
}
13331333
}
13341334

1335+
// TestSaveProjectRaw_CreatesMissingParentDir verifies the raw save path creates
1336+
// its parent directory, mirroring the struct save path (saveToFile). Without
1337+
// this, a raw enabled-flag flip in a repo that has never created .entire/
1338+
// (e.g. a bare `entire disable` in a fresh repo) hard-fails with "no such file
1339+
// or directory". Regression test for the saveRaw MkdirAll fix.
1340+
func TestSaveProjectRaw_CreatesMissingParentDir(t *testing.T) {
1341+
tmpDir := t.TempDir()
1342+
path := filepath.Join(tmpDir, ".entire", "settings.json")
1343+
1344+
raw := map[string]json.RawMessage{"enabled": json.RawMessage("false")}
1345+
if err := SaveProjectRaw(path, raw); err != nil {
1346+
t.Fatalf("SaveProjectRaw() into a missing .entire dir should succeed, got: %v", err)
1347+
}
1348+
1349+
data, err := os.ReadFile(path)
1350+
if err != nil {
1351+
t.Fatalf("settings file should have been created: %v", err)
1352+
}
1353+
if !strings.Contains(string(data), `"enabled": false`) {
1354+
t.Errorf("expected enabled:false, got: %s", data)
1355+
}
1356+
}
1357+
1358+
// TestSaveLocalRaw_CreatesMissingParentDir is the local-scope mirror of
1359+
// TestSaveProjectRaw_CreatesMissingParentDir.
1360+
func TestSaveLocalRaw_CreatesMissingParentDir(t *testing.T) {
1361+
tmpDir := t.TempDir()
1362+
path := filepath.Join(tmpDir, ".entire", "settings.local.json")
1363+
1364+
raw := map[string]json.RawMessage{"enabled": json.RawMessage("false")}
1365+
if err := SaveLocalRaw(path, raw); err != nil {
1366+
t.Fatalf("SaveLocalRaw() into a missing .entire dir should succeed, got: %v", err)
1367+
}
1368+
1369+
if _, err := os.ReadFile(path); err != nil {
1370+
t.Fatalf("local settings file should have been created: %v", err)
1371+
}
1372+
}
1373+
13351374
// Regression: `entire enable --local` writes only .entire/settings.local.json,
13361375
// but the hook activation check (IsSetUpAndEnabled) only looked for
13371376
// .entire/settings.json, so hooks silently no-op'd. It must recognize a

0 commit comments

Comments
 (0)