/** * :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 }); } }); });