diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index 950693ed4a..c2953e2e07 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" iofs "io/fs" + "log" "os" "path" "path/filepath" @@ -315,7 +316,24 @@ func desiredClaudeSettings(fs fsys.FS, cityDir string) ([]byte, claudeSettingsSo return base, claudeSettingsSourceNone, nil } - merged, err := overlay.MergeSettingsJSON(base, overrideData) + // Apply targeted in-place upgrades to legacy forms of managed gascity + // hook commands and matchers in the user's override before merging with + // the embedded base. Custom hook events and custom commands are + // preserved verbatim. The previous "use base instead" path discarded + // user customizations along with stale managed-hook bytes; this path + // patches the managed bytes while keeping customizations intact. + upgradedOverride, _, upgradeErr := upgradeClaudeFile(overrideData) + if upgradeErr != nil { + // Upgrade failure (e.g., malformed JSON) — fall back to original + // override; better to keep the user file as-is than fail install. + // Log so unexpected upgrade failures are discoverable rather than + // silent: a malformed user file is benign here, but a + // MarshalCanonicalJSON failure would indicate a gascity bug. + log.Printf("hooks: claude settings upgrade failed, using original override: %v", upgradeErr) + upgradedOverride = overrideData + } + + merged, err := overlay.MergeSettingsJSON(base, upgradedOverride) if err != nil { return nil, claudeSettingsSourceNone, fmt.Errorf("merging Claude settings from %s: %w", overridePath, err) } @@ -343,14 +361,16 @@ func readClaudeSettingsOverride(fs fsys.FS, cityDir string, base []byte) (string hookExists := hookState == candidateFound runtimeExists := runtimeState == candidateFound - if hookExists && - (!runtimeExists || !bytes.Equal(hookData, runtimeData)) && - !claudeFileNeedsUpgrade(hookData) { + // The previous !claudeFileNeedsUpgrade gates here forced cities whose + // settings.json had stale managed-hook commands AND user customizations + // to fall through to the "use base" branch, silently discarding their + // customizations. desiredClaudeSettings now patches stale managed + // commands in-place via upgradeClaudeFile before merging with base, so + // customizations survive while managed commands get upgraded. + if hookExists && (!runtimeExists || !bytes.Equal(hookData, runtimeData)) { return hookPath, hookData, claudeSettingsSourceLegacyHook, nil } - if runtimeExists && - !bytes.Equal(runtimeData, base) && - !claudeFileNeedsUpgrade(runtimeData) { + if runtimeExists && !bytes.Equal(runtimeData, base) { return runtimePath, runtimeData, claudeSettingsSourceLegacyRuntime, nil } return "", nil, claudeSettingsSourceNone, nil @@ -635,41 +655,223 @@ func writeManagedFile(fs fsys.FS, dst string, data []byte, policy writeManagedFi return nil } +// claudeFileNeedsUpgrade reports whether the existing settings.json contains +// known legacy forms of managed gascity hook commands or matchers that would +// be patched by upgradeClaudeFile. Used by isStaleHookFile to decide whether +// to overwrite the legacy hook-file path; readClaudeSettingsOverride no +// longer gates on this since desiredClaudeSettings applies the upgrade +// in-place before merge. +// +// The previous implementation enumerated 16 byte-exact transforms of the +// embedded template and matched the user's bytes against that set. Any +// custom addition (e.g. an extra Stop hook entry) defeated every variant +// match, so cities with customizations never received upstream fixes — +// most notably the PreCompact `--auto` patch from commit 7b3b913a, which +// landed weeks before this rewrite but never propagated to cities like +// pipex-city that had drifted from the canonical embedded shape. func claudeFileNeedsUpgrade(existing []byte) bool { - current, err := readEmbedded("config/claude.json") + _, changed, err := upgradeClaudeFile(existing) if err != nil { return false } - transforms := []func(string) string{ - func(s string) string { - return strings.Replace(s, `gc handoff --auto \"context cycle\"`, `gc handoff \"context cycle\"`, 1) - }, - func(s string) string { - return strings.Replace(s, `gc handoff --auto \"context cycle\"`, `gc prime --hook`, 1) - }, - func(s string) string { - return strings.Replace(s, `GC_MANAGED_SESSION_HOOK=1 GC_HOOK_EVENT_NAME=SessionStart gc prime --hook`, `gc prime --hook`, 1) - }, - func(s string) string { - return strings.Replace(s, `"matcher": "startup"`, `"matcher": ""`, 1) - }, - } - - known := make(map[string]struct{}) - var enumerate func(int, string, bool) - enumerate = func(idx int, candidate string, changed bool) { - if idx == len(transforms) { - if changed { - known[candidate] = struct{}{} + return changed +} + +// upgradeClaudeFile parses the existing Claude settings.json and patches +// known legacy forms of managed gascity hook commands and matchers to their +// current shape. Walks the hook events so upgrades can be event-aware +// (e.g. SessionStart matcher upgrade, PreCompact command upgrade); custom +// hook events and custom commands are preserved verbatim. +// +// Returns the (possibly re-marshaled) JSON bytes and whether any patch +// was applied. +func upgradeClaudeFile(existing []byte) ([]byte, bool, error) { + var root any + if err := json.Unmarshal(existing, &root); err != nil { + return nil, false, err + } + rootMap, ok := root.(map[string]any) + if !ok { + return existing, false, nil + } + hooks, ok := rootMap["hooks"].(map[string]any) + if !ok { + return existing, false, nil + } + changed := false + for event, entries := range hooks { + entriesArr, ok := entries.([]any) + if !ok { + continue + } + for _, entry := range entriesArr { + entryMap, ok := entry.(map[string]any) + if !ok { + continue + } + if upgradeClaudeHookEntry(event, entryMap) { + changed = true } - return } - enumerate(idx+1, candidate, changed) - next := transforms[idx](candidate) - enumerate(idx+1, next, changed || next != candidate) } - enumerate(0, string(current), false) + if !changed { + return existing, false, nil + } + data, err := overlay.MarshalCanonicalJSON(root) + if err != nil { + return nil, false, err + } + return data, true, nil +} - _, ok := known[string(existing)] - return ok +// upgradeClaudeHookEntry applies event-aware upgrades to a single +// {matcher, hooks: [...]} entry under one of the hook event arrays. +// +// Upgrade applies only when the entry is identifiable as a GC-managed +// legacy entry — at least one hook command must match a known legacy +// form via isLegacyGCManagedCommand. User-authored entries that happen +// to share an empty matcher or a wrapper that prefixes "gc prime --hook" +// are left untouched. +func upgradeClaudeHookEntry(event string, entry map[string]any) bool { + hookCmds, ok := entry["hooks"].([]any) + if !ok { + return false + } + + // First pass: identify whether this entry has the GC-managed legacy + // shape (via at least one recognizable legacy command body), and + // upgrade any commands that match a known legacy form. + changed := false + hasManagedCommand := false + for _, h := range hookCmds { + hMap, ok := h.(map[string]any) + if !ok { + continue + } + cmd, ok := hMap["command"].(string) + if !ok { + continue + } + if isLegacyGCManagedCommand(event, cmd) { + hasManagedCommand = true + } + if upgraded, didUpgrade := upgradeClaudeHookCommand(event, cmd); didUpgrade { + hMap["command"] = upgraded + changed = true + } + } + + // Second pass: normalize matcher only when the entry is identifiably + // GC-managed. Blocks user-authored SessionStart entries with + // matcher:"" from being silently rewritten to "startup". + if event == "SessionStart" && hasManagedCommand { + if matcher, ok := entry["matcher"].(string); ok && matcher == "" { + entry["matcher"] = "startup" + changed = true + } + } + return changed +} + +// canonicalGCPathPrefix is the env-setup prefix gc prepends to every +// managed hook command. Legacy command bodies always appear either bare +// or with this prefix; user-wrapped variants never have this exact prefix. +const canonicalGCPathPrefix = `export PATH="$HOME/go/bin:$HOME/.local/bin:$PATH" && ` + +// commandBodyAfterCanonicalPrefix returns the portion of command following +// the canonical gc PATH-export prefix if present, else returns command +// unchanged. Used to anchor legacy-form matching against the post-prefix +// body without matching arbitrary user-authored prefixes. +func commandBodyAfterCanonicalPrefix(command string) string { + return strings.TrimPrefix(command, canonicalGCPathPrefix) +} + +// isLegacyGCManagedCommand reports whether a hook command body matches a +// known legacy form (or the already-upgraded current SessionStart form) +// that gc previously generated. Used to gate matcher normalization in +// upgradeClaudeHookEntry — user-authored commands that wrap, +// suffix-append, or otherwise extend the legacy form (e.g. +// "my-wrapper gc prime --hook --foo", "gc prime --hook --foo", +// `gc prime --hook && my-extra-step`, or the current-form preamble +// with extra trailing args appended) return false and are left alone. +// All recognition paths require exact-body match — gc has only ever +// emitted these tokens as the complete command body, never with +// trailing args. +func isLegacyGCManagedCommand(event, command string) bool { + body := commandBodyAfterCanonicalPrefix(command) + switch event { + case "PreCompact": + return equalsLegacyCommandBody(body, "gc prime --hook") || + equalsLegacyCommandBody(body, `gc handoff "context cycle"`) || + equalsLegacyCommandBody(body, `gc handoff --auto "context cycle"`) + case "SessionStart": + return equalsLegacyCommandBody(body, "gc prime --hook") || + equalsLegacyCommandBody(body, sessionStartCurrentFormBody) + } + return false +} + +// sessionStartCurrentFormBody is the canonical current-form managed +// SessionStart command body (post-canonical-PATH-prefix). Recognized +// via exact-body match in isLegacyGCManagedCommand so an already-upgraded +// entry still gates matcher normalization, without matching user +// commands that prefix-collide with the GC_MANAGED_SESSION_HOOK= or +// full env-var preamble. If gc ever extends the current-form command +// with additional arguments, update this constant alongside the +// emission site so legacy detection remains tight. +const sessionStartCurrentFormBody = `GC_MANAGED_SESSION_HOOK=1 GC_HOOK_EVENT_NAME=SessionStart gc prime --hook` + +// equalsLegacyCommandBody reports whether the command body is exactly the +// legacy token. gc historically emitted these tokens as the complete +// command body (possibly with the canonical PATH-export prefix), never +// with trailing arguments or chained commands. Treating any deviation — +// wrappers, suffix-appended flags, "&&" chains, suffix-token collisions +// like "gc prime --hookable" — as user authorship and leaving the +// command alone is the only safe classification for an upgrade pass that +// silently rewrites managed entries. +func equalsLegacyCommandBody(command, token string) bool { + return command == token +} + +// upgradeClaudeHookCommand returns the upgraded form of an event-scoped +// command if it matches a known legacy shape via exact-body match. +// Returns ("", false) when no upgrade applies. +// +// The match anchors against the command body following the canonical +// gc PATH-export prefix (or against the bare body if there is no +// prefix), and requires that body to equal a known legacy form +// verbatim. This permits gc's own legacy commands (which always carry +// the canonical PATH prefix and have no trailing args) to upgrade, +// while blocking wrapped variants ("my-wrapper gc prime --hook --foo") +// and suffix-appended variants ("gc prime --hook --foo", +// `gc prime --hook && my-step`) from matching and being silently +// rewritten. +func upgradeClaudeHookCommand(event, command string) (string, bool) { + body := commandBodyAfterCanonicalPrefix(command) + switch event { + case "PreCompact": + // Older legacy: PreCompact used `gc prime --hook` before + // `gc handoff` was introduced. Upgrade to the current + // `gc handoff --auto "context cycle"` form. Tested first + // because it changes the same trailing token the bare-handoff + // form would otherwise patch. + if equalsLegacyCommandBody(body, `gc prime --hook`) { + return strings.Replace(command, `gc prime --hook`, `gc handoff --auto "context cycle"`, 1), true + } + // Legacy: bare `gc handoff "context cycle"` (no --auto) + // requests a controller restart on every Claude Code + // compaction event, killing the session (gc-flp1). Upstream + // fix landed in commit 7b3b913a; this patches existing cities. + if equalsLegacyCommandBody(body, `gc handoff "context cycle"`) { + return strings.Replace(command, `gc handoff "context cycle"`, `gc handoff --auto "context cycle"`, 1), true + } + case "SessionStart": + // Legacy: bare `gc prime --hook` without the + // GC_MANAGED_SESSION_HOOK / GC_HOOK_EVENT_NAME env vars the + // current managed form expects. + if equalsLegacyCommandBody(body, `gc prime --hook`) { + return strings.Replace(command, `gc prime --hook`, `GC_MANAGED_SESSION_HOOK=1 GC_HOOK_EVENT_NAME=SessionStart gc prime --hook`, 1), true + } + } + return "", false } diff --git a/internal/hooks/hooks_test.go b/internal/hooks/hooks_test.go index fabb6e2a27..6d27508575 100644 --- a/internal/hooks/hooks_test.go +++ b/internal/hooks/hooks_test.go @@ -612,6 +612,329 @@ func TestInstallClaudeUpgradesGeneratedFileWithAllKnownDrift(t *testing.T) { } } +// TestInstallClaudeUpgradesPreCompactPreservingCustomHookEvent verifies that +// a settings.json containing a stale managed PreCompact command (no --auto) +// AND a custom user-added hook event (e.g. Stop) gets the managed command +// upgraded while the custom hook event is preserved verbatim. +// +// Regression for the byte-enumerated claudeFileNeedsUpgrade brittleness +// observed in pipex-city: the prior implementation matched files byte-exact +// against 16 transforms of the embedded template; any custom addition +// defeated every variant match, so the file fell through to "user override" +// and never received upstream fixes (notably commit 7b3b913a's --auto patch). +// The JSON-aware upgradeClaudeFile rewrite handles this case correctly. +func TestInstallClaudeUpgradesPreCompactPreservingCustomHookEvent(t *testing.T) { + fs := fsys.NewFake() + current, err := readEmbedded("config/claude.json") + if err != nil { + t.Fatalf("readEmbedded: %v", err) + } + // Start from the canonical embedded shape, downgrade PreCompact to the + // bare-handoff legacy form, and inject a custom Stop hook event that + // is not part of the managed set. + stale := strings.Replace(string(current), `gc handoff --auto \"context cycle\"`, `gc handoff \"context cycle\"`, 1) + if stale == string(current) { + t.Fatal("PreCompact downgrade did not modify the fixture — check the legacy form pattern") + } + var doc map[string]any + if err := json.Unmarshal([]byte(stale), &doc); err != nil { + t.Fatalf("parsing stale fixture: %v", err) + } + hooks, ok := doc["hooks"].(map[string]any) + if !ok { + t.Fatalf("stale fixture has no hooks map") + } + hooks["Stop"] = []any{ + map[string]any{ + "matcher": "", + "hooks": []any{ + map[string]any{ + "type": "command", + "command": `export PATH="$HOME/go/bin:$HOME/.local/bin:$PATH" && gc hook --inject`, + }, + }, + }, + } + staleWithCustom, err := json.MarshalIndent(doc, "", " ") + if err != nil { + t.Fatalf("re-marshaling stale fixture: %v", err) + } + fs.Files["/city/.gc/settings.json"] = staleWithCustom + + if err := Install(fs, "/city", "/work", []string{"claude"}); err != nil { + t.Fatalf("Install: %v", err) + } + + runtime := fs.Files["/city/.gc/settings.json"] + + // The managed PreCompact command must be upgraded to include --auto. + preCompactCmd := claudeHookCommand(t, runtime, "PreCompact") + if !strings.Contains(preCompactCmd, `gc handoff --auto "context cycle"`) { + t.Fatalf("PreCompact command not upgraded to include --auto:\n%s", preCompactCmd) + } + + // The custom Stop hook must survive the upgrade verbatim. + stopCmd := claudeHookCommand(t, runtime, "Stop") + if !strings.Contains(stopCmd, `gc hook --inject`) { + t.Fatalf("custom Stop hook lost during upgrade — expected gc hook --inject in:\n%s", string(runtime)) + } + + // Sanity: the canonical SessionStart and UserPromptSubmit managed hooks + // must still be present (merged from base). + if !strings.Contains(string(runtime), "SessionStart") { + t.Fatalf("runtime lost SessionStart after upgrade:\n%s", string(runtime)) + } + if !strings.Contains(string(runtime), "UserPromptSubmit") { + t.Fatalf("runtime lost UserPromptSubmit after upgrade:\n%s", string(runtime)) + } +} + +// TestInstallClaudeDoesNotClobberUserWrappedCommand is the regression test +// for the heuristic-tightening fixup applied after PR #2072's adversarial +// review surfaced two majors via Codex. The pre-fixup upgrade used bare +// strings.Contains on "gc prime --hook", which would rewrite user-authored +// wrapper variants like "my-wrapper gc prime --hook --foo" on every gc run. +// The token-anchored fixup blocks this. +func TestInstallClaudeDoesNotClobberUserWrappedCommand(t *testing.T) { + fs := fsys.NewFake() + userOwned := `{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": "my-wrapper gc prime --hook --foo" + } + ] + } + ] + } +}` + fs.Files["/city/hooks/claude.json"] = []byte(userOwned) + fs.Files["/city/.gc/settings.json"] = []byte(userOwned) + + if err := Install(fs, "/city", "/work", []string{"claude"}); err != nil { + t.Fatalf("Install: %v", err) + } + + hookData := fs.Files["/city/hooks/claude.json"] + if !strings.Contains(string(hookData), `my-wrapper gc prime --hook --foo`) { + t.Fatalf("user-wrapped SessionStart command was rewritten — gc must not touch wrapped variants:\n%s", string(hookData)) + } +} + +// TestInstallClaudeDoesNotNormalizeUserAuthoredEmptyMatcher is the second +// regression for the heuristic-tightening fixup. Codex's major finding #2 +// flagged that upgradeClaudeHookEntry would rewrite ANY SessionStart entry +// with matcher:"" to matcher:"startup", regardless of whether the entry's +// commands were GC-managed. A user-authored entry with matcher:"" and a +// non-managed command must survive untouched. +func TestInstallClaudeDoesNotNormalizeUserAuthoredEmptyMatcher(t *testing.T) { + fs := fsys.NewFake() + userOwned := `{ + "hooks": { + "SessionStart": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "echo user-wrote-this" + } + ] + } + ] + } +}` + fs.Files["/city/.gc/settings.json"] = []byte(userOwned) + + if err := Install(fs, "/city", "/work", []string{"claude"}); err != nil { + t.Fatalf("Install: %v", err) + } + + runtime := fs.Files["/city/.gc/settings.json"] + entries := claudeHookEntries(t, runtime, "SessionStart") + // The user-authored SessionStart entry should survive with matcher + // unchanged; merge may add the managed entry separately but the + // user-authored matcher:"" must not be normalized away. + foundUserOwned := false + for _, e := range entries { + if e.Matcher == "" { + foundUserOwned = true + break + } + } + if !foundUserOwned { + t.Fatalf("user-authored SessionStart entry with matcher:\"\" was rewritten — gc must not normalize matcher unless entry is identifiably GC-managed:\n%s", string(runtime)) + } +} + +// TestInstallClaudeDoesNotClobberUserSuffixAppendedCommand is the regression +// test for the suffix-append class of silent rewrites surfaced by Codex's +// pass-2 review of PR #2072. The pass-1 fixup blocked wrapper prefixes +// via token-anchored prefix matching, but accepted any whitespace-bounded +// suffix after the legacy token — so user-authored commands like +// "gc prime --hook --my-flag" still matched as managed and were rewritten +// to "GC_MANAGED_SESSION_HOOK=1 ... gc prime --hook --my-flag" plus an +// unconditional matcher:"" → "startup" normalization. The exact-body +// match fixup blocks the suffix-append class entirely. +func TestInstallClaudeDoesNotClobberUserSuffixAppendedCommand(t *testing.T) { + fs := fsys.NewFake() + userOwned := `{ + "hooks": { + "SessionStart": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "gc prime --hook --my-flag" + } + ] + } + ] + } +}` + fs.Files["/city/.gc/settings.json"] = []byte(userOwned) + + if err := Install(fs, "/city", "/work", []string{"claude"}); err != nil { + t.Fatalf("Install: %v", err) + } + + runtime := fs.Files["/city/.gc/settings.json"] + entries := claudeHookEntries(t, runtime, "SessionStart") + foundUserOwned := false + for _, e := range entries { + if e.Matcher != "" { + continue + } + for _, h := range e.Hooks { + if h.Command == "gc prime --hook --my-flag" { + foundUserOwned = true + } + } + } + if !foundUserOwned { + t.Fatalf("user-authored SessionStart command 'gc prime --hook --my-flag' was rewritten — gc must not mutate suffix-appended commands:\n%s", string(runtime)) + } +} + +// TestInstallClaudeDoesNotClobberUserChainedCommand is the second regression +// for the suffix-append class. A user who chained their own step after the +// legacy command body via "&&" must survive the upgrade verbatim. The +// pass-1 token-anchored prefix accepted whitespace as a token boundary +// and would have rewritten this; the exact-body match blocks it. +func TestInstallClaudeDoesNotClobberUserChainedCommand(t *testing.T) { + fs := fsys.NewFake() + userOwned := `{ + "hooks": { + "PreCompact": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "gc prime --hook && echo user-chained-step" + } + ] + } + ] + } +}` + fs.Files["/city/.gc/settings.json"] = []byte(userOwned) + + if err := Install(fs, "/city", "/work", []string{"claude"}); err != nil { + t.Fatalf("Install: %v", err) + } + + runtime := fs.Files["/city/.gc/settings.json"] + entries := claudeHookEntries(t, runtime, "PreCompact") + foundUserOwned := false + for _, e := range entries { + for _, h := range e.Hooks { + if h.Command == "gc prime --hook && echo user-chained-step" { + foundUserOwned = true + } + } + } + if !foundUserOwned { + t.Fatalf("user-authored PreCompact chained command was rewritten — gc must not mutate &&-chained commands:\n%s", string(runtime)) + } +} + +// TestInstallClaudeDoesNotClobberUserSuffixAppendedCurrentForm covers the +// current-form variant of the suffix-append class. A user-authored command +// that begins with the canonical current-form env-var preamble but appends +// extra arguments (e.g. a custom flag the user added on top of the +// managed body) must not be classified as managed by isLegacyGCManagedCommand, +// which would otherwise drive matcher normalization on the user-authored +// entry. The fix tightens the current-form recognition path to exact-body +// match. +func TestInstallClaudeDoesNotClobberUserSuffixAppendedCurrentForm(t *testing.T) { + fs := fsys.NewFake() + userOwned := `{ + "hooks": { + "SessionStart": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "GC_MANAGED_SESSION_HOOK=1 GC_HOOK_EVENT_NAME=SessionStart gc prime --hook --my-flag" + } + ] + } + ] + } +}` + fs.Files["/city/.gc/settings.json"] = []byte(userOwned) + + if err := Install(fs, "/city", "/work", []string{"claude"}); err != nil { + t.Fatalf("Install: %v", err) + } + + runtime := fs.Files["/city/.gc/settings.json"] + entries := claudeHookEntries(t, runtime, "SessionStart") + foundUserOwned := false + for _, e := range entries { + if e.Matcher != "" { + continue + } + for _, h := range e.Hooks { + if h.Command == "GC_MANAGED_SESSION_HOOK=1 GC_HOOK_EVENT_NAME=SessionStart gc prime --hook --my-flag" { + foundUserOwned = true + } + } + } + if !foundUserOwned { + t.Fatalf("user-authored current-form SessionStart command with trailing arg was rewritten or had its matcher normalized — gc must require exact-body match for current-form recognition:\n%s", string(runtime)) + } +} + +// TestInstallClaudeIdempotent verifies that a second Install call on an +// already-upgraded file is byte-stable. Matches the +// TestInstallCodexIsByteStableAcrossRepeatedInstalls pattern in the Codex +// path; was missing for the Claude path and surfaced by code-reviewer in +// the #2072 adversarial review. +func TestInstallClaudeIdempotent(t *testing.T) { + fs := fsys.NewFake() + if err := Install(fs, "/city", "/work", []string{"claude"}); err != nil { + t.Fatalf("first Install: %v", err) + } + first := append([]byte(nil), fs.Files["/city/.gc/settings.json"]...) + + if err := Install(fs, "/city", "/work", []string{"claude"}); err != nil { + t.Fatalf("second Install: %v", err) + } + second := fs.Files["/city/.gc/settings.json"] + + if string(first) != string(second) { + t.Fatalf("second Install produced different bytes — upgrade is not idempotent:\nfirst:\n%s\n\nsecond:\n%s", string(first), string(second)) + } +} + func TestInstallClaudeMergesCityDotClaudeSettings(t *testing.T) { fs := fsys.NewFake() fs.Files["/city/.claude/settings.json"] = []byte(`{