mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 06:28:59 +02:00
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>
288 lines
14 KiB
TypeScript
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 });
|
|
}
|
|
});
|
|
});
|