Files
gstack/test/user-render-out-dir-install.test.ts
T
Garry TanandClaude Fable 5 9af589bb73 fix(setup): render the gbrain :user variant to an out-dir — global installs stay git-clean
On a global-git install with gbrain, ./setup and 'gstack-config
gbrain-refresh' ran gen:skill-docs:user IN PLACE inside the install
checkout, rewriting ~16 TRACKED SKILL.md files. The checkout stayed
permanently dirty, every /gstack-upgrade 'git stash' saved a redundant
snapshot of generated content, and the growing stash list invited a 'git
stash pop' that would lay stale instruction markdown from an older gstack
over the current version — a quiet wrong-rules failure mode.

Fix, wired through machinery that already existed (gen-skill-docs
--out-dir + the symlink install layer): brain-aware SKILL.md now renders
into the untracked ~/.gstack/render/claude, and both Claude installers
serve the render when present — setup's link_claude_skill_dirs prefers
$GSTACK_HOME/render/claude/<skill>/SKILL.md, and bin/gstack-relink does
the same so a later config change can't silently flip skills back to the
blockless canonical source. setup wipes and rebuilds the render each run,
repoints installed skills after a successful render, and removes a stale
render (re-linking canonical) when gbrain is gone. gbrain-refresh renders
to the out-dir and repoints via relink; its 'this dirties the install's
git tree' caveat is retired because it no longer does.

A one-time upgrade migration (gstack-upgrade/migrations/v1.67.0.0.sh, F12)
restores the legacy dirt: unstaged modifications to SKILL.md / sections/
*.md files in the install checkout are git-checkout'd back to canonical;
anything outside that footprint (user edits, untracked files, staged work)
is left alone and reported. Idempotent, non-fatal, symlinked installs
skipped.

Tests: render-preference behavior for both installers, static pins that
every executable :user invocation carries --out-dir and the caveat text is
gone, migration fixture (restore/leave/idempotent/no-op matrix), and the
existing out-dir render test now asserts 'git status --porcelain' gains
zero new entries across a full :user render.

Fixes #2569

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 09:49:31 -07:00

197 lines
9.0 KiB
TypeScript

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