diff --git a/bin/gstack-config b/bin/gstack-config index 575f93523..70bcf27ce 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -440,27 +440,35 @@ case "${1:-}" in # treatment as "ok", matching gstack-gbrain-detect --is-ok and # gen-skill-docs. echo "Detected gbrain v$VERSION (local-status: $STATUS)." - # Render brain-aware blocks INTO the global install so EVERY project's - # Claude sessions get them (other projects read SKILL.md + sections from - # ~/.claude/skills/gstack via absolute paths baked at gen time). Guards - # (never mutate an arbitrary directory): the target must exist, not be a - # symlink (a symlinked install points at a dev worktree — rendering there - # would dirty tracked source), and look like a real gstack clone. + # Render brain-aware blocks into an UNTRACKED out-dir (#2569) and + # repoint the installed skills at it — the old in-place render wrote + # into TRACKED files of the global install checkout, so the checkout + # stayed permanently dirty and every upgrade grew a redundant stash. + # Guards (never mutate an arbitrary directory): the install must + # exist, not be a symlink (a symlinked install points at a dev + # worktree — bin/dev-setup owns that flow), and look like a real + # gstack clone. INSTALL_DIR="$HOME/.claude/skills/gstack" + RENDER_DIR="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}" if [ ! -d "$INSTALL_DIR" ]; then echo "No global install at $INSTALL_DIR — nothing to render. (Dev workspaces get blocks via bin/dev-setup.)" elif [ -L "$INSTALL_DIR" ]; then - echo "Skip: $INSTALL_DIR is a symlink (likely a dev worktree). Rendering there would dirty tracked source — run bin/dev-setup in that worktree instead." + echo "Skip: $INSTALL_DIR is a symlink (likely a dev worktree). Run bin/dev-setup in that worktree instead." elif [ ! -f "$INSTALL_DIR/VERSION" ] || [ ! -f "$INSTALL_DIR/package.json" ]; then echo "Skip: $INSTALL_DIR doesn't look like a gstack clone (missing VERSION/package.json) — refusing to modify it." elif ! command -v bun >/dev/null 2>&1; then echo "Skip: bun not on PATH — can't render. Install bun, then re-run 'gstack-config gbrain-refresh'." - elif ( cd "$INSTALL_DIR" && bun run gen:skill-docs:user --host claude >/dev/null 2>&1 ); then - echo "Rendered brain-aware blocks into $INSTALL_DIR — now live across all your projects' Claude sessions." - echo "Note: this dirties the install's git tree (generated blocks differ from main, by design)." - echo " A 'git reset --hard origin/main' there reverts them; re-run 'gstack-config gbrain-refresh' to restore." else - echo "Warning: render failed. Run 'cd $INSTALL_DIR && bun run gen:skill-docs:user --host claude' manually to see the error." + rm -rf "$RENDER_DIR" + if ( cd "$INSTALL_DIR" && bun run gen:skill-docs:user --host claude --out-dir "$RENDER_DIR" >/dev/null 2>&1 ); then + # Repoint installed skills at the render — gstack-relink prefers + # the render dir when present. + "$INSTALL_DIR/bin/gstack-relink" >/dev/null 2>&1 || true + echo "Rendered brain-aware blocks into $RENDER_DIR — now live across all your projects' Claude sessions." + echo "The install checkout stays clean: upgrades no longer stash generated render dirt (#2569)." + else + echo "Warning: render failed. Run 'cd $INSTALL_DIR && bun run gen:skill-docs:user --host claude --out-dir $RENDER_DIR' manually to see the error." + fi fi ;; *) diff --git a/bin/gstack-relink b/bin/gstack-relink index 75d3337e2..e87bc3fac 100755 --- a/bin/gstack-relink +++ b/bin/gstack-relink @@ -36,6 +36,12 @@ SKILLS_DIR="${GSTACK_SKILLS_DIR:-$(dirname "$INSTALL_DIR")}" # Read prefix setting PREFIX=$("$GSTACK_CONFIG" get skill_prefix 2>/dev/null || echo "false") +# #2569: rendered :user variants (brain-aware blocks) live in an UNTRACKED +# out-dir instead of the tracked install checkout. When a render exists for a +# skill, relink serves it — otherwise a config change would silently flip +# every skill back to the canonical (blockless) source. +RENDER_DIR="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}" + # Helper: remove old skill entry (symlink or real directory with symlinked SKILL.md) _cleanup_skill_entry() { local entry="$1" @@ -98,7 +104,9 @@ for skill_dir in "$INSTALL_DIR"/*/; do [ -L "$target" ] && rm -f "$target" # Create real directory with symlinked SKILL.md (absolute path) mkdir -p "$target" - ln -snf "$INSTALL_DIR/$skill/SKILL.md" "$target/SKILL.md" + skill_md_src="$INSTALL_DIR/$skill/SKILL.md" + [ -f "$RENDER_DIR/$skill/SKILL.md" ] && skill_md_src="$RENDER_DIR/$skill/SKILL.md" + ln -snf "$skill_md_src" "$target/SKILL.md" SKILL_COUNT=$((SKILL_COUNT + 1)) done diff --git a/gstack-upgrade/migrations/v1.67.0.0.sh b/gstack-upgrade/migrations/v1.67.0.0.sh new file mode 100755 index 000000000..b8209e539 --- /dev/null +++ b/gstack-upgrade/migrations/v1.67.0.0.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Migration: v1.67.0.0 — restore tracked files dirtied by the legacy in-place +# gbrain render (#2569, F12). +# +# Why a migration: pre-v1.67, gbrain-enabled setups ran `gen:skill-docs:user +# --host claude` IN PLACE inside the global install checkout +# (~/.claude/skills/gstack), rewriting ~16 TRACKED SKILL.md files. The +# checkout stayed permanently dirty, and every /gstack-upgrade `git stash` +# saved a redundant snapshot of generated content — stashes that invite a +# dangerous `git stash pop` of stale instruction markdown over a newer +# version. v1.67 renders to an untracked out-dir (~/.gstack/render/claude) +# instead, so this migration does the ONE-TIME cleanup of the legacy dirt: +# `git checkout --` on the modified generated files. +# +# Restore scope is deliberately narrow: only UNSTAGED MODIFIED files whose +# path is a SKILL.md or lives under a sections/ directory — the exact +# footprint of the legacy render. Anything else a user changed by hand +# (untracked files, staged work, non-generated edits) is left alone and +# reported. setup's gbrain step re-renders the brain-aware variant into the +# out-dir immediately after migrations run, so no capability is lost. +# +# Affected: global-git installs that ever ran ./setup or `gstack-config +# gbrain-refresh` with gbrain detected, before v1.67.0.0. +# +# Idempotent: a clean checkout is a no-op. Non-fatal throughout. +set -u + +INSTALL_DIR="${GSTACK_INSTALL_DIR:-$HOME/.claude/skills/gstack}" + +# Only operate on a real (non-symlink) git checkout that looks like gstack. +[ -d "$INSTALL_DIR" ] || exit 0 +[ -L "$INSTALL_DIR" ] && exit 0 +[ -f "$INSTALL_DIR/VERSION" ] || exit 0 +git -C "$INSTALL_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0 + +_DIRTY="$(git -C "$INSTALL_DIR" status --porcelain 2>/dev/null || true)" +[ -n "$_DIRTY" ] || exit 0 + +_RESTORED=0 +_LEFT=0 +while IFS= read -r _LINE; do + [ -n "$_LINE" ] || continue + _CODE="${_LINE:0:2}" + _PATH="${_LINE:3}" + # Legacy render footprint: unstaged modifications to generated markdown. + case "$_CODE" in + " M") + case "$_PATH" in + SKILL.md|*/SKILL.md|*/sections/*.md) + if git -C "$INSTALL_DIR" checkout -- "$_PATH" 2>/dev/null; then + _RESTORED=$((_RESTORED + 1)) + else + _LEFT=$((_LEFT + 1)) + fi + ;; + *) _LEFT=$((_LEFT + 1)) ;; + esac + ;; + *) _LEFT=$((_LEFT + 1)) ;; + esac +done </dev/null 2>&1 || true @@ -1728,24 +1741,27 @@ if [ "$NO_TEAM_MODE" -eq 1 ]; then log "Team mode disabled: auto-update hook removed." fi -# ─── GBrain detection + conditional SKILL.md regen ────────────────────── +# ─── GBrain detection + conditional SKILL.md render ───────────────────── # # Detect whether gbrain is installed and persist the result to # ~/.gstack/gbrain-detection.json so gen-skill-docs can decide whether to # render GBRAIN_CONTEXT_LOAD and GBRAIN_SAVE_RESULTS blocks. If detected, -# regenerate the Claude-host SKILL.md files with the un-suppressed -# (compressed) brain-aware blocks via `bun run gen:skill-docs:user`. +# render the Claude-host :user variant (un-suppressed brain-aware blocks) +# into an UNTRACKED out-dir — ${GSTACK_HOME}/render/claude — and repoint the +# installed skills at it (#2569). The old in-place render wrote into TRACKED +# files of the install checkout, so a global-git install stayed permanently +# dirty and every upgrade stashed 16 files of generated dirt. # -# If gbrain is not detected, the canonical no-gbrain SKILL.md files -# (which were just generated above by `gen:skill-docs --host claude` if -# applicable, or which are checked in) stay as-is. Zero token overhead -# for non-gbrain users. +# If gbrain is not detected, the canonical no-gbrain SKILL.md files stay +# as-is (zero token overhead) and any stale render dir is removed so it +# can't shadow canonical files on the next relink. # # Users who install gbrain after running ./setup should re-run setup OR -# call `gstack-config gbrain-refresh` + `bun run gen:skill-docs:user`. +# call `gstack-config gbrain-refresh`. DETECT_BIN="$SOURCE_GSTACK_DIR/bin/gstack-gbrain-detect" GBRAIN_STATE_DIR="${GSTACK_HOME:-$HOME/.gstack}" DETECTION_FILE="$GBRAIN_STATE_DIR/gbrain-detection.json" +_GSTACK_RENDER_DIR="${GSTACK_USER_RENDER_DIR:-$GBRAIN_STATE_DIR/render/claude}" # PID-unique tmp so concurrent setups (parallel Conductor workspaces) can't # clobber each other's in-flight detection write. DETECTION_TMP="$DETECTION_FILE.$$.tmp" @@ -1758,28 +1774,44 @@ if [ -x "$DETECT_BIN" ]; then # all gate on the same check instead of re-grepping the JSON. if "$DETECT_BIN" --is-ok 2>/dev/null; then if [ -n "${GSTACK_SKIP_GBRAIN_REGEN:-}" ]; then - # Dev/source tree (set by bin/dev-setup): never regenerate tracked - # SKILL.md in place — that dirties checked-in source. Detection is - # still persisted above; the dev workspace renders the :user variant - # into an untracked dir, and other projects get blocks via - # `gstack-config gbrain-refresh`. + # Dev/source tree (set by bin/dev-setup): detection is persisted + # above; the dev workspace renders the :user variant into its own + # untracked dir (.claude/gstack-rendered), and other projects get + # blocks via `gstack-config gbrain-refresh`. log "gbrain detected — GSTACK_SKIP_GBRAIN_REGEN set: leaving tracked SKILL.md canonical (dev/source tree)." else - log "gbrain detected — regenerating Claude SKILL.md with brain-aware blocks (~250 token overhead per planning skill)..." - ( + log "gbrain detected — rendering brain-aware Claude SKILL.md into $_GSTACK_RENDER_DIR (~250 token overhead per planning skill; source checkout stays clean)..." + rm -rf "$_GSTACK_RENDER_DIR" + if ( cd "$SOURCE_GSTACK_DIR" # No pipe before the || guard: `cmd | tail -3` reports TAIL's exit # status, so a generator crash read as success (same masking the # main gen:skill-docs site had). Capture, show the tail, propagate. - _GEN_USER_OUT=$(bun_cmd run gen:skill-docs:user --host claude 2>&1) + _GEN_USER_OUT=$(bun_cmd run gen:skill-docs:user --host claude --out-dir "$_GSTACK_RENDER_DIR" 2>&1) _GEN_USER_RC=$? printf '%s\n' "$_GEN_USER_OUT" | tail -3 exit "$_GEN_USER_RC" - ) || log " warning: gen:skill-docs:user failed — run 'bun run gen:skill-docs:user' manually if you want brain-aware blocks" + ); then + # Repoint the installed skills at the fresh render — the installer + # prefers rendered files when present (#2569). + if [ "${_CLAUDE_SKILLS_LINKED:-0}" -eq 1 ]; then + link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" >/dev/null + fi + else + log " warning: gen:skill-docs:user failed — run 'bun run gen:skill-docs:user --host claude --out-dir $_GSTACK_RENDER_DIR' manually if you want brain-aware blocks" + fi fi else log "gbrain not detected — brain-aware blocks suppressed in planning-skill SKILL.md files (zero token overhead)." log " To enable: install gbrain via /setup-gbrain, then re-run ./setup or 'gstack-config gbrain-refresh'." + # A render from a previous gbrain install would shadow canonical files + # on the next link/relink — drop it and restore canonical links. + if [ -d "$_GSTACK_RENDER_DIR" ] && [ -z "${GSTACK_SKIP_GBRAIN_REGEN:-}" ]; then + rm -rf "$_GSTACK_RENDER_DIR" + if [ "${_CLAUDE_SKILLS_LINKED:-0}" -eq 1 ]; then + link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" >/dev/null + fi + fi fi else rm -f "$DETECTION_TMP" diff --git a/test/gen-skill-docs-out-dir.test.ts b/test/gen-skill-docs-out-dir.test.ts index 7b187c56e..acf80075f 100644 --- a/test/gen-skill-docs-out-dir.test.ts +++ b/test/gen-skill-docs-out-dir.test.ts @@ -20,11 +20,17 @@ describe('gen-skill-docs --out-dir (B2 render isolation)', () => { return createHash('sha256').update(fs.readFileSync(p)).digest('hex'); } + function porcelain(): string { + const r = spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf-8' }); + return r.status === 0 ? r.stdout : ''; + } + test('renders :user to out-dir, rewrites section paths, leaves worktree canonical', () => { const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-home-')); const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-out-')); const worktreeSkill = path.join(ROOT, 'ship', 'SKILL.md'); const beforeHash = hashFile(worktreeSkill); + const beforePorcelain = porcelain(); try { // Force gbrain detection ON for --respect-detection. fs.writeFileSync( @@ -47,6 +53,11 @@ describe('gen-skill-docs --out-dir (B2 render isolation)', () => { // (a) worktree byte-unchanged expect(hashFile(worktreeSkill)).toBe(beforeHash); + // (a2, #2569) the render adds ZERO new dirt to the source checkout — + // compared before/after rather than asserting empty, so a dev's own + // unrelated dirty files can't false-fail the suite. + expect(porcelain()).toBe(beforePorcelain); + // (b) inline block present in the rendered SKILL.md expect(skillContent).toContain('Brain Context Load'); diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index ba94d3a69..b69b104e5 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -2350,10 +2350,13 @@ describe('setup script validation', () => { test('Codex install uses link_codex_skill_dirs', () => { // The Codex install section (section 5) should use the Codex function + // End marker: the next numbered section header (a marker that doesn't + // exist slices to EOF and the assertion reads unrelated sections). const codexSection = setupContent.slice( setupContent.indexOf('# 5. Install for Codex'), - setupContent.indexOf('# 6. Create') + setupContent.indexOf('# 6. Install for Kiro') ); + expect(setupContent.indexOf('# 6. Install for Kiro')).toBeGreaterThan(-1); expect(codexSection).toContain('create_codex_runtime_root'); expect(codexSection).toContain('link_codex_skill_dirs'); expect(codexSection).not.toContain('link_claude_skill_dirs'); @@ -2398,7 +2401,10 @@ describe('setup script validation', () => { const fnBody = setupContent.slice(fnStart, fnEnd); expect(fnBody).toContain('mkdir -p "$target"'); // v1.36.0.0: routes through _link_or_copy helper for Windows fallback (cp on MSYS2/Git Bash). - expect(fnBody).toContain('_link_or_copy "$gstack_dir/$dir_name/SKILL.md" "$target/SKILL.md"'); + // v1.67 (#2569): the source is render-aware — canonical SKILL.md, or the + // rendered :user variant from ${GSTACK_HOME}/render/claude when present. + expect(fnBody).toContain('_skill_md_src="$gstack_dir/$dir_name/SKILL.md"'); + expect(fnBody).toContain('_link_or_copy "$_skill_md_src" "$target/SKILL.md"'); }); // REGRESSION: cleanup functions must handle both old symlinks AND new real-directory pattern diff --git a/test/relink.test.ts b/test/relink.test.ts index 5af335c50..767db6006 100644 --- a/test/relink.test.ts +++ b/test/relink.test.ts @@ -266,6 +266,38 @@ describe('gstack-relink (#578)', () => { expect(new Set(names).size).toBe(names.length); }); + // #2569: rendered :user variants live in ${GSTACK_HOME}/render/claude. + // relink must serve the render when present — otherwise any config change + // silently flips every skill back to the canonical (blockless) source. + test('prefers a rendered SKILL.md from GSTACK_HOME/render/claude (#2569)', () => { + setupMockInstall(['qa', 'ship']); + const renderDir = path.join(tmpDir, 'render', 'claude', 'qa'); + fs.mkdirSync(renderDir, { recursive: true }); + fs.writeFileSync( + path.join(renderDir, 'SKILL.md'), + '---\nname: qa\ndescription: test\n---\nrendered brain-aware qa', + ); + + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + GSTACK_HOME: tmpDir, + }); + run(`${path.join(installDir, 'bin', 'gstack-relink')}`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + GSTACK_HOME: tmpDir, + }); + + const qaLink = path.join(skillsDir, 'qa', 'SKILL.md'); + expect(fs.readlinkSync(qaLink)).toBe(path.join(renderDir, 'SKILL.md')); + expect(fs.readFileSync(qaLink, 'utf-8')).toContain('rendered brain-aware qa'); + // ship has no render — canonical source link. + expect(fs.readlinkSync(path.join(skillsDir, 'ship', 'SKILL.md'))).toBe( + path.join(installDir, 'ship', 'SKILL.md'), + ); + }); + // FIRST INSTALL: --no-prefix must create ONLY flat names, zero gstack-* pollution test('first install --no-prefix: only flat names exist, zero gstack-* entries', () => { setupMockInstall(['qa', 'ship', 'review', 'plan-ceo-review', 'gstack-upgrade']); diff --git a/test/user-render-out-dir-install.test.ts b/test/user-render-out-dir-install.test.ts new file mode 100644 index 000000000..eb7946fe2 --- /dev/null +++ b/test/user-render-out-dir-install.test.ts @@ -0,0 +1,196 @@ +/** + * :user render never dirties the global-git install (#2569). + * + * Pre-v1.67, gbrain-enabled setups ran `gen:skill-docs:user --host claude` + * IN PLACE inside the install checkout, rewriting ~16 tracked SKILL.md files. + * The checkout stayed permanently dirty and every upgrade stashed a redundant + * snapshot of generated content. The fix renders to an untracked out-dir + * (~/.gstack/render/claude) and makes the Claude installers — setup's + * link_claude_skill_dirs AND bin/gstack-relink — prefer rendered files when + * present. A one-time migration (v1.67.0.0.sh) restores the legacy dirt. + * + * (The render mechanism itself — worktree byte-unchanged, section repointing — + * is pinned by test/gen-skill-docs-out-dir.test.ts.) + */ +import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8'); +const CONFIG_SRC = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-config'), 'utf-8'); +const RELINK_SRC = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-relink'), 'utf-8'); +const MIGRATION = path.join(ROOT, 'gstack-upgrade', 'migrations', 'v1.67.0.0.sh'); + +function extractFn(src: string, name: string): string { + const start = src.indexOf(`${name}() {`); + const end = src.indexOf('\n}\n', start); + if (start < 0 || end < 0) throw new Error(`Could not locate ${name}()`); + return src.slice(start, end + 2); +} + +describe(':user render targets the out-dir, never the checkout (#2569)', () => { + test('setup renders gen:skill-docs:user with --out-dir only', () => { + const sites = SETUP_SRC.split('gen:skill-docs:user').length - 1; + const outDirSites = SETUP_SRC.split('gen:skill-docs:user --host claude --out-dir').length - 1; + // Every executable :user invocation carries --out-dir. (Prose/log + // mentions don't pair with `bun_cmd run`.) + const executableSites = SETUP_SRC.split('run gen:skill-docs:user').length - 1; + expect(executableSites).toBeGreaterThan(0); + expect(outDirSites).toBe(executableSites); + expect(sites).toBeGreaterThanOrEqual(outDirSites); + }); + + test('setup wipes and repoints: rm -rf render dir + relink after a successful render', () => { + const block = SETUP_SRC.slice( + SETUP_SRC.indexOf('# ─── GBrain detection + conditional SKILL.md render'), + SETUP_SRC.indexOf('# 11. Plan-tune cathedral hook install'), + ); + expect(block).toContain('rm -rf "$_GSTACK_RENDER_DIR"'); + expect(block).toContain('link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"'); + // Stale-render cleanup on the gbrain-gone path. + expect(block).toContain('gbrain not detected'); + }); + + test('gstack-config gbrain-refresh renders to the out-dir and dropped the dirty-tree caveat', () => { + expect(CONFIG_SRC).toContain('gen:skill-docs:user --host claude --out-dir'); + expect(CONFIG_SRC).not.toContain("this dirties the install's git tree"); + expect(CONFIG_SRC).toContain('gstack-relink'); + }); + + test('gstack-relink prefers the render dir when a rendered SKILL.md exists', () => { + expect(RELINK_SRC).toContain('render/claude'); + expect(RELINK_SRC).toContain('[ -f "$RENDER_DIR/$skill/SKILL.md" ] && skill_md_src="$RENDER_DIR/$skill/SKILL.md"'); + }); +}); + +describe('link_claude_skill_dirs prefers rendered SKILL.md (behavior)', () => { + test('a rendered variant is served; skills without one fall back to source', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-render-pref-')); + try { + const src = path.join(tmp, 'src'); + const skills = path.join(tmp, 'skills'); + const home = path.join(tmp, 'gstack-home'); + // Source tree: two skills. + for (const s of ['alpha', 'beta']) { + fs.mkdirSync(path.join(src, s), { recursive: true }); + fs.writeFileSync( + path.join(src, s, 'SKILL.md'), + `---\nname: ${s}\ndescription: t\n---\ncanonical-${s}\n`, + ); + } + // Render exists for alpha only. + fs.mkdirSync(path.join(home, 'render', 'claude', 'alpha'), { recursive: true }); + fs.writeFileSync( + path.join(home, 'render', 'claude', 'alpha', 'SKILL.md'), + '---\nname: alpha\ndescription: t\n---\nrendered-alpha with Brain Context Load\n', + ); + fs.mkdirSync(skills, { recursive: true }); + + const script = [ + 'set -e', + 'IS_WINDOWS=0', + 'SKILL_PREFIX=0', + '_WINDOWS_COPY_NOTE_PRINTED=1', + `GSTACK_HOME="${home}"`, + extractFn(SETUP_SRC, '_link_or_copy'), + extractFn(SETUP_SRC, '_print_windows_copy_note_once'), + extractFn(SETUP_SRC, '_link_skill_runtime_assets'), + extractFn(SETUP_SRC, 'link_claude_skill_dirs'), + `link_claude_skill_dirs "${src}" "${skills}"`, + ].join('\n'); + const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 15_000 }); + expect(r.status).toBe(0); + + expect(fs.readFileSync(path.join(skills, 'alpha', 'SKILL.md'), 'utf-8')).toContain('rendered-alpha'); + expect(fs.readFileSync(path.join(skills, 'beta', 'SKILL.md'), 'utf-8')).toContain('canonical-beta'); + // The SOURCE stayed canonical — the render is served via the link only. + expect(fs.readFileSync(path.join(src, 'alpha', 'SKILL.md'), 'utf-8')).toContain('canonical-alpha'); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); + +describe('migration v1.67.0.0 — legacy in-place render cleanup (F12)', () => { + function git(cwd: string, ...args: string[]): void { + const r = spawnSync('git', args, { cwd, encoding: 'utf-8' }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`); + } + + function makeLegacyInstall(tmp: string): string { + const install = path.join(tmp, 'install'); + fs.mkdirSync(path.join(install, 'ship', 'sections'), { recursive: true }); + fs.writeFileSync(path.join(install, 'VERSION'), '1.66.0.0\n'); + fs.writeFileSync(path.join(install, 'ship', 'SKILL.md'), 'canonical ship\n'); + fs.writeFileSync(path.join(install, 'ship', 'sections', 'tests.md'), 'canonical section\n'); + fs.writeFileSync(path.join(install, 'README.md'), 'readme\n'); + git(install, 'init', '-b', 'main'); + git(install, 'config', 'user.email', 't@t.test'); + git(install, 'config', 'user.name', 't'); + git(install, 'add', '-A'); + git(install, 'commit', '-m', 'base', '-q'); + return install; + } + + function runMigration(install: string): { status: number | null; stdout: string } { + const r = spawnSync('bash', [MIGRATION], { + encoding: 'utf-8', + env: { ...process.env, GSTACK_INSTALL_DIR: install }, + timeout: 15_000, + }); + return { status: r.status, stdout: r.stdout }; + } + + test('restores render-class dirt, leaves user changes alone, idempotent', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-migration-')); + try { + const install = makeLegacyInstall(tmp); + // Legacy render dirt + a genuine user edit + an untracked file. + fs.writeFileSync(path.join(install, 'ship', 'SKILL.md'), 'brain-aware rendered ship\n'); + fs.writeFileSync(path.join(install, 'ship', 'sections', 'tests.md'), 'brain-aware section\n'); + fs.writeFileSync(path.join(install, 'README.md'), 'user edit\n'); + fs.writeFileSync(path.join(install, 'notes.txt'), 'untracked\n'); + + const r1 = runMigration(install); + expect(r1.status).toBe(0); + expect(r1.stdout).toContain('restored 2 tracked file(s)'); + expect(fs.readFileSync(path.join(install, 'ship', 'SKILL.md'), 'utf-8')).toBe('canonical ship\n'); + expect(fs.readFileSync(path.join(install, 'ship', 'sections', 'tests.md'), 'utf-8')).toBe('canonical section\n'); + // The user's own edits are NOT the render footprint — untouched, reported. + expect(fs.readFileSync(path.join(install, 'README.md'), 'utf-8')).toBe('user edit\n'); + expect(fs.existsSync(path.join(install, 'notes.txt'))).toBe(true); + expect(r1.stdout).toContain('left'); + + // Idempotent: nothing left in the footprint on the second run. + const r2 = runMigration(install); + expect(r2.status).toBe(0); + expect(r2.stdout).not.toContain('restored'); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('clean checkout, missing dir, and symlinked install are all silent no-ops', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-migration-noop-')); + try { + const install = makeLegacyInstall(tmp); + const clean = runMigration(install); + expect(clean.status).toBe(0); + expect(clean.stdout.trim()).toBe(''); + + const missing = runMigration(path.join(tmp, 'does-not-exist')); + expect(missing.status).toBe(0); + + const link = path.join(tmp, 'symlinked-install'); + fs.symlinkSync(install, link); + const sym = runMigration(link); + expect(sym.status).toBe(0); + expect(sym.stdout.trim()).toBe(''); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +});