mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 06:28:59 +02:00
setup-claude-skill-assets and user-render-out-dir-install slice link_claude_skill_dirs out of setup without the helpers it now calls, so the extracted function died with "command not found" (or, inside an if, degraded into "foreign, skipped"). Both harnesses now carry the full helper set and the globals. The hidden-files census allows .gstack-owned, which the linker writes for directories it creates rather than copying from the skill source. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
287 lines
14 KiB
TypeScript
287 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 * 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 = spawnSync('bash', ['-c', script], { encoding: 'utf-8', 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 = spawnSync('bash', ['-c', script], { encoding: 'utf-8', 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 = 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', 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 });
|
|
}
|
|
});
|
|
});
|