Files
gstack/test/user-render-out-dir-install.test.ts
T
Garry TanandClaude Fable 5.1 1204828699 test: run assembled setup harness scripts from a temp file, not bash -c argv (Windows MSYS2 8 KB truncation)
windows-free-tests (run 33907177851) failed in
test/setup-alias-name-uniqueness.test.ts with
  bash: -c: line 178: unexpected EOF while looking for matching `'
The harness slices functions out of `setup` and passed the joined script as
one `bash -c` argv element. The ownership gate grew that script from 6.7 KB
to 15.7 KB, and on Windows bash is an MSYS2 program: when its parent is a
non-MSYS process (bun), msys-2.0.dll's build_argv() runs any argument
containing `?*["'(){}` through globify()/glob(), which copies the pattern
into a fixed `Char patbuf[8192]` and silently stops after 8192 - MB_CUR_MAX
(8186 chars under C.UTF-8); GLOB_NOCHECK then returns the truncated text as
the argument. Character 8186 lands inside the single-quoted sed token on
line 178. Rebuilding the exact script with CI path shapes and cutting it at
8186-8190 characters reproduces the identical message locally; cmd.exe's
8191-UTF-16 cap and CreateProcess's 32767 do not fit the evidence.

Fix: test/helpers/bash-script.ts writes the script to a temp file and runs
`bash <path>` — a short glob-free argument that never enters globify. Every
setup harness that assembled a script for `bash -c` (11 files, 22 sites)
uses it; timeouts and env are preserved verbatim, spawn/timeout errors are
appended to stderr, temp cleanup is best-effort. `spawnSync('bash',
[<Windows absolute path>])` already passes on windows-latest in setup-help,
uninstall-windows-copies and the migration tests. The Windows-curated list
is byte-identical before and after.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 23:05:59 +00:00

288 lines
14 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 { runBashScript } from './helpers/bash-script';
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 renders into a TMP dir, swaps on success, repoints after (never wipes first)', () => {
const block = SETUP_SRC.slice(
SETUP_SRC.indexOf('# ─── GBrain detection + conditional SKILL.md render'),
SETUP_SRC.indexOf('# 11. Plan-tune cathedral hook install'),
);
// The live render dir is symlinked into by installed skills — it may only
// be replaced AFTER a successful render (a pre-render wipe left every
// brain-aware SKILL.md link dangling on a transient render failure).
expect(block).toContain('--out-dir "$_GSTACK_RENDER_TMP"');
expect(block).not.toContain('--out-dir "$_GSTACK_RENDER_DIR"');
expect(block).toContain('_swap_in_render "$_GSTACK_RENDER_DIR" "$_GSTACK_RENDER_TMP"');
expect(block).toContain('link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"');
// Stale-render cleanup on the gbrain-gone path (a deliberate wipe).
expect(block).toContain('gbrain not detected');
expect(block).toContain('rm -rf "$_GSTACK_RENDER_DIR"');
});
test('gstack-config gbrain-refresh renders to a TMP out-dir, swaps on success', () => {
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');
expect(CONFIG_SRC).toContain('--out-dir "$RENDER_TMP"');
expect(CONFIG_SRC).not.toContain('--out-dir "$RENDER_DIR"');
expect(CONFIG_SRC).toContain('_swap_in_render "$RENDER_DIR" "$RENDER_TMP"');
});
test('_swap_in_render behavior: success replaces, and the shape means failure never touches the live dir', () => {
// Both files carry the same-contract helper — drive each for real.
for (const src of [SETUP_SRC, CONFIG_SRC]) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-render-swap-'));
try {
const live = path.join(tmp, 'claude');
const fresh = path.join(tmp, 'claude.tmp.123');
fs.mkdirSync(path.join(live, 'ship'), { recursive: true });
fs.writeFileSync(path.join(live, 'ship', 'SKILL.md'), 'old-render\n');
fs.mkdirSync(path.join(fresh, 'ship'), { recursive: true });
fs.writeFileSync(path.join(fresh, 'ship', 'SKILL.md'), 'new-render\n');
const script = [
'set -e',
extractFn(src, '_swap_in_render'),
`_swap_in_render "${live}" "${fresh}"`,
].join('\n');
const r = runBashScript(script, { timeout: 15_000 });
expect(r.status).toBe(0);
// Live dir now serves the fresh render at the SAME path (links into
// it stay valid), tmp and .old are gone.
expect(fs.readFileSync(path.join(live, 'ship', 'SKILL.md'), 'utf-8')).toBe('new-render\n');
expect(fs.existsSync(fresh)).toBe(false);
expect(fs.readdirSync(tmp)).toEqual(['claude']);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
}
});
test('a FAILED render leaves the previous render dir fully intact (gstack-config path, end-to-end shape)', () => {
// Reconstruct the exact failure branch: render into tmp fails → tmp is
// removed, the live dir (and the symlinks into it) are untouched, and
// _swap_in_render is never called.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-render-fail-'));
try {
const live = path.join(tmp, 'claude');
fs.mkdirSync(path.join(live, 'ship'), { recursive: true });
fs.writeFileSync(path.join(live, 'ship', 'SKILL.md'), 'previous-render\n');
// An installed skill symlinks into the live render dir.
const installed = path.join(tmp, 'installed-ship-SKILL.md');
fs.symlinkSync(path.join(live, 'ship', 'SKILL.md'), installed);
const script = [
'set -u',
extractFn(CONFIG_SRC, '_swap_in_render'),
`RENDER_DIR="${live}"`,
'RENDER_TMP="$RENDER_DIR.tmp.$$"',
'rm -rf "$RENDER_TMP"',
// The render fails (broken template, bun error, disk full).
'if ( mkdir -p "$RENDER_TMP" && false ); then',
' _swap_in_render "$RENDER_DIR" "$RENDER_TMP"',
'else',
' rm -rf "$RENDER_TMP"',
' echo "render failed — previous render left in place" >&2',
'fi',
].join('\n');
const r = runBashScript(script, { timeout: 15_000 });
expect(r.status).toBe(0);
expect(fs.readFileSync(path.join(live, 'ship', 'SKILL.md'), 'utf-8')).toBe('previous-render\n');
// The installed symlink still resolves — the skill set did not vanish.
expect(fs.readFileSync(installed, 'utf-8')).toBe('previous-render\n');
expect(fs.readdirSync(tmp).sort()).toEqual(['claude', 'installed-ship-SKILL.md']);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
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, '_gstack_link_target_abs'),
extractFn(SETUP_SRC, '_gstack_target_is_ours'),
extractFn(SETUP_SRC, '_gstack_generated_header'),
extractFn(SETUP_SRC, '_claude_entry_owned_strongly'),
extractFn(SETUP_SRC, '_claude_entry_is_ours'),
extractFn(SETUP_SRC, '_write_owned_marker'),
extractFn(SETUP_SRC, '_backup_skill_md'),
extractFn(SETUP_SRC, '_cleanup_weak_dir'),
extractFn(SETUP_SRC, '_gstack_dir_only_links'),
extractFn(SETUP_SRC, '_cleanup_linked_dir'),
'_FOREIGN_SKIPPED_ENTRIES=()',
'_BACKED_UP_SKILL_MDS=()',
`_SKILL_BACKUP_ROOT="${os.tmpdir()}/gstack-harness-backups-${process.pid}"`,
extractFn(SETUP_SRC, 'link_claude_skill_dirs'),
`link_claude_skill_dirs "${src}" "${skills}"`,
].join('\n');
const r = runBashScript(script, { 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', timeout: 30_000 });
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 });
}
});
});