mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 22:48:57 +02:00
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>
197 lines
9.0 KiB
TypeScript
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 });
|
|
}
|
|
});
|
|
});
|