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>
This commit is contained in:
Garry Tan
2026-08-16 09:49:31 -07:00
co-authored by Claude Fable 5
parent 1dbed2c01f
commit 9af589bb73
8 changed files with 399 additions and 33 deletions
+20 -12
View File
@@ -440,27 +440,35 @@ case "${1:-}" in
# treatment as "ok", matching gstack-gbrain-detect --is-ok and
# gen-skill-docs.
echo "Detected gbrain v$VERSION (local-status: $STATUS)."
# Render brain-aware blocks INTO the global install so EVERY project's
# Claude sessions get them (other projects read SKILL.md + sections from
# ~/.claude/skills/gstack via absolute paths baked at gen time). Guards
# (never mutate an arbitrary directory): the target must exist, not be a
# symlink (a symlinked install points at a dev worktree — rendering there
# would dirty tracked source), and look like a real gstack clone.
# Render brain-aware blocks into an UNTRACKED out-dir (#2569) and
# repoint the installed skills at it — the old in-place render wrote
# into TRACKED files of the global install checkout, so the checkout
# stayed permanently dirty and every upgrade grew a redundant stash.
# Guards (never mutate an arbitrary directory): the install must
# exist, not be a symlink (a symlinked install points at a dev
# worktree — bin/dev-setup owns that flow), and look like a real
# gstack clone.
INSTALL_DIR="$HOME/.claude/skills/gstack"
RENDER_DIR="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}"
if [ ! -d "$INSTALL_DIR" ]; then
echo "No global install at $INSTALL_DIR — nothing to render. (Dev workspaces get blocks via bin/dev-setup.)"
elif [ -L "$INSTALL_DIR" ]; then
echo "Skip: $INSTALL_DIR is a symlink (likely a dev worktree). Rendering there would dirty tracked source — run bin/dev-setup in that worktree instead."
echo "Skip: $INSTALL_DIR is a symlink (likely a dev worktree). Run bin/dev-setup in that worktree instead."
elif [ ! -f "$INSTALL_DIR/VERSION" ] || [ ! -f "$INSTALL_DIR/package.json" ]; then
echo "Skip: $INSTALL_DIR doesn't look like a gstack clone (missing VERSION/package.json) — refusing to modify it."
elif ! command -v bun >/dev/null 2>&1; then
echo "Skip: bun not on PATH — can't render. Install bun, then re-run 'gstack-config gbrain-refresh'."
elif ( cd "$INSTALL_DIR" && bun run gen:skill-docs:user --host claude >/dev/null 2>&1 ); then
echo "Rendered brain-aware blocks into $INSTALL_DIR — now live across all your projects' Claude sessions."
echo "Note: this dirties the install's git tree (generated blocks differ from main, by design)."
echo " A 'git reset --hard origin/main' there reverts them; re-run 'gstack-config gbrain-refresh' to restore."
else
echo "Warning: render failed. Run 'cd $INSTALL_DIR && bun run gen:skill-docs:user --host claude' manually to see the error."
rm -rf "$RENDER_DIR"
if ( cd "$INSTALL_DIR" && bun run gen:skill-docs:user --host claude --out-dir "$RENDER_DIR" >/dev/null 2>&1 ); then
# Repoint installed skills at the render — gstack-relink prefers
# the render dir when present.
"$INSTALL_DIR/bin/gstack-relink" >/dev/null 2>&1 || true
echo "Rendered brain-aware blocks into $RENDER_DIR — now live across all your projects' Claude sessions."
echo "The install checkout stays clean: upgrades no longer stash generated render dirt (#2569)."
else
echo "Warning: render failed. Run 'cd $INSTALL_DIR && bun run gen:skill-docs:user --host claude --out-dir $RENDER_DIR' manually to see the error."
fi
fi
;;
*)
+9 -1
View File
@@ -36,6 +36,12 @@ SKILLS_DIR="${GSTACK_SKILLS_DIR:-$(dirname "$INSTALL_DIR")}"
# Read prefix setting
PREFIX=$("$GSTACK_CONFIG" get skill_prefix 2>/dev/null || echo "false")
# #2569: rendered :user variants (brain-aware blocks) live in an UNTRACKED
# out-dir instead of the tracked install checkout. When a render exists for a
# skill, relink serves it — otherwise a config change would silently flip
# every skill back to the canonical (blockless) source.
RENDER_DIR="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}"
# Helper: remove old skill entry (symlink or real directory with symlinked SKILL.md)
_cleanup_skill_entry() {
local entry="$1"
@@ -98,7 +104,9 @@ for skill_dir in "$INSTALL_DIR"/*/; do
[ -L "$target" ] && rm -f "$target"
# Create real directory with symlinked SKILL.md (absolute path)
mkdir -p "$target"
ln -snf "$INSTALL_DIR/$skill/SKILL.md" "$target/SKILL.md"
skill_md_src="$INSTALL_DIR/$skill/SKILL.md"
[ -f "$RENDER_DIR/$skill/SKILL.md" ] && skill_md_src="$RENDER_DIR/$skill/SKILL.md"
ln -snf "$skill_md_src" "$target/SKILL.md"
SKILL_COUNT=$((SKILL_COUNT + 1))
done
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Migration: v1.67.0.0 — restore tracked files dirtied by the legacy in-place
# gbrain render (#2569, F12).
#
# Why a migration: pre-v1.67, gbrain-enabled setups ran `gen:skill-docs:user
# --host claude` IN PLACE inside the global install checkout
# (~/.claude/skills/gstack), rewriting ~16 TRACKED SKILL.md files. The
# checkout stayed permanently dirty, and every /gstack-upgrade `git stash`
# saved a redundant snapshot of generated content — stashes that invite a
# dangerous `git stash pop` of stale instruction markdown over a newer
# version. v1.67 renders to an untracked out-dir (~/.gstack/render/claude)
# instead, so this migration does the ONE-TIME cleanup of the legacy dirt:
# `git checkout --` on the modified generated files.
#
# Restore scope is deliberately narrow: only UNSTAGED MODIFIED files whose
# path is a SKILL.md or lives under a sections/ directory — the exact
# footprint of the legacy render. Anything else a user changed by hand
# (untracked files, staged work, non-generated edits) is left alone and
# reported. setup's gbrain step re-renders the brain-aware variant into the
# out-dir immediately after migrations run, so no capability is lost.
#
# Affected: global-git installs that ever ran ./setup or `gstack-config
# gbrain-refresh` with gbrain detected, before v1.67.0.0.
#
# Idempotent: a clean checkout is a no-op. Non-fatal throughout.
set -u
INSTALL_DIR="${GSTACK_INSTALL_DIR:-$HOME/.claude/skills/gstack}"
# Only operate on a real (non-symlink) git checkout that looks like gstack.
[ -d "$INSTALL_DIR" ] || exit 0
[ -L "$INSTALL_DIR" ] && exit 0
[ -f "$INSTALL_DIR/VERSION" ] || exit 0
git -C "$INSTALL_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
_DIRTY="$(git -C "$INSTALL_DIR" status --porcelain 2>/dev/null || true)"
[ -n "$_DIRTY" ] || exit 0
_RESTORED=0
_LEFT=0
while IFS= read -r _LINE; do
[ -n "$_LINE" ] || continue
_CODE="${_LINE:0:2}"
_PATH="${_LINE:3}"
# Legacy render footprint: unstaged modifications to generated markdown.
case "$_CODE" in
" M")
case "$_PATH" in
SKILL.md|*/SKILL.md|*/sections/*.md)
if git -C "$INSTALL_DIR" checkout -- "$_PATH" 2>/dev/null; then
_RESTORED=$((_RESTORED + 1))
else
_LEFT=$((_LEFT + 1))
fi
;;
*) _LEFT=$((_LEFT + 1)) ;;
esac
;;
*) _LEFT=$((_LEFT + 1)) ;;
esac
done <<EOF_DIRTY
$_DIRTY
EOF_DIRTY
if [ "$_RESTORED" -gt 0 ]; then
echo " v1.67.0.0: restored $_RESTORED tracked file(s) dirtied by the legacy in-place gbrain render (#2569)."
echo " Brain-aware blocks now render to ~/.gstack/render/claude — the checkout stays clean from here on."
fi
if [ "$_LEFT" -gt 0 ]; then
echo " v1.67.0.0: left $_LEFT non-render change(s) in $INSTALL_DIR untouched (not the legacy render footprint)."
fi
exit 0
+50 -18
View File
@@ -785,7 +785,18 @@ link_claude_skill_dirs() {
mkdir -p "$target"
# Validate target isn't a symlink before creating the link
if [ -L "$target/SKILL.md" ]; then rm "$target/SKILL.md"; fi
_link_or_copy "$gstack_dir/$dir_name/SKILL.md" "$target/SKILL.md"
# #2569: prefer a rendered :user variant when present. gbrain installs
# render brain-aware SKILL.md into ${GSTACK_HOME}/render/claude via
# gen:skill-docs --out-dir instead of dirtying the tracked source
# checkout; when a render exists for this skill, serve it. The rendered
# file's section-base paths point into the render dir, so section reads
# resolve there too.
_skill_md_src="$gstack_dir/$dir_name/SKILL.md"
_render_dir="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}"
if [ -f "$_render_dir/$dir_name/SKILL.md" ]; then
_skill_md_src="$_render_dir/$dir_name/SKILL.md"
fi
_link_or_copy "$_skill_md_src" "$target/SKILL.md"
# Link every runtime asset the skill ships next to its SKILL.md (#2317,
# #2454): sections/ for carved skills, review's checklist.md +
# specialists/, qa's templates/ + references/, gstack-upgrade's
@@ -1411,6 +1422,7 @@ if [ "$INSTALL_CLAUDE" -eq 1 ]; then
"$SOURCE_GSTACK_DIR/bin/gstack-patch-names" "$SOURCE_GSTACK_DIR" "$SKILL_PREFIX"
link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
link_claude_root_skill_alias "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
_CLAUDE_SKILLS_LINKED=1
# Self-healing: re-run gstack-relink to ensure name: fields and directory
# names are consistent with the config. This catches cases where an interrupted
# setup, stale git state, or gen:skill-docs left name: fields out of sync.
@@ -1486,6 +1498,7 @@ if [ "$INSTALL_CLAUDE" -eq 1 ]; then
"$SOURCE_GSTACK_DIR/bin/gstack-patch-names" "$SOURCE_GSTACK_DIR" "$SKILL_PREFIX"
link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
link_claude_root_skill_alias "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
_CLAUDE_SKILLS_LINKED=1
GSTACK_RELINK="$SOURCE_GSTACK_DIR/bin/gstack-relink"
if [ -x "$GSTACK_RELINK" ]; then
GSTACK_SKILLS_DIR="$INSTALL_SKILLS_DIR" GSTACK_INSTALL_DIR="$SOURCE_GSTACK_DIR" "$GSTACK_RELINK" >/dev/null 2>&1 || true
@@ -1728,24 +1741,27 @@ if [ "$NO_TEAM_MODE" -eq 1 ]; then
log "Team mode disabled: auto-update hook removed."
fi
# ─── GBrain detection + conditional SKILL.md regen ──────────────────────
# ─── GBrain detection + conditional SKILL.md render ─────────────────────
#
# Detect whether gbrain is installed and persist the result to
# ~/.gstack/gbrain-detection.json so gen-skill-docs can decide whether to
# render GBRAIN_CONTEXT_LOAD and GBRAIN_SAVE_RESULTS blocks. If detected,
# regenerate the Claude-host SKILL.md files with the un-suppressed
# (compressed) brain-aware blocks via `bun run gen:skill-docs:user`.
# render the Claude-host :user variant (un-suppressed brain-aware blocks)
# into an UNTRACKED out-dir — ${GSTACK_HOME}/render/claude — and repoint the
# installed skills at it (#2569). The old in-place render wrote into TRACKED
# files of the install checkout, so a global-git install stayed permanently
# dirty and every upgrade stashed 16 files of generated dirt.
#
# If gbrain is not detected, the canonical no-gbrain SKILL.md files
# (which were just generated above by `gen:skill-docs --host claude` if
# applicable, or which are checked in) stay as-is. Zero token overhead
# for non-gbrain users.
# If gbrain is not detected, the canonical no-gbrain SKILL.md files stay
# as-is (zero token overhead) and any stale render dir is removed so it
# can't shadow canonical files on the next relink.
#
# Users who install gbrain after running ./setup should re-run setup OR
# call `gstack-config gbrain-refresh` + `bun run gen:skill-docs:user`.
# call `gstack-config gbrain-refresh`.
DETECT_BIN="$SOURCE_GSTACK_DIR/bin/gstack-gbrain-detect"
GBRAIN_STATE_DIR="${GSTACK_HOME:-$HOME/.gstack}"
DETECTION_FILE="$GBRAIN_STATE_DIR/gbrain-detection.json"
_GSTACK_RENDER_DIR="${GSTACK_USER_RENDER_DIR:-$GBRAIN_STATE_DIR/render/claude}"
# PID-unique tmp so concurrent setups (parallel Conductor workspaces) can't
# clobber each other's in-flight detection write.
DETECTION_TMP="$DETECTION_FILE.$$.tmp"
@@ -1758,28 +1774,44 @@ if [ -x "$DETECT_BIN" ]; then
# all gate on the same check instead of re-grepping the JSON.
if "$DETECT_BIN" --is-ok 2>/dev/null; then
if [ -n "${GSTACK_SKIP_GBRAIN_REGEN:-}" ]; then
# Dev/source tree (set by bin/dev-setup): never regenerate tracked
# SKILL.md in place — that dirties checked-in source. Detection is
# still persisted above; the dev workspace renders the :user variant
# into an untracked dir, and other projects get blocks via
# `gstack-config gbrain-refresh`.
# Dev/source tree (set by bin/dev-setup): detection is persisted
# above; the dev workspace renders the :user variant into its own
# untracked dir (.claude/gstack-rendered), and other projects get
# blocks via `gstack-config gbrain-refresh`.
log "gbrain detected — GSTACK_SKIP_GBRAIN_REGEN set: leaving tracked SKILL.md canonical (dev/source tree)."
else
log "gbrain detected — regenerating Claude SKILL.md with brain-aware blocks (~250 token overhead per planning skill)..."
(
log "gbrain detected — rendering brain-aware Claude SKILL.md into $_GSTACK_RENDER_DIR (~250 token overhead per planning skill; source checkout stays clean)..."
rm -rf "$_GSTACK_RENDER_DIR"
if (
cd "$SOURCE_GSTACK_DIR"
# No pipe before the || guard: `cmd | tail -3` reports TAIL's exit
# status, so a generator crash read as success (same masking the
# main gen:skill-docs site had). Capture, show the tail, propagate.
_GEN_USER_OUT=$(bun_cmd run gen:skill-docs:user --host claude 2>&1)
_GEN_USER_OUT=$(bun_cmd run gen:skill-docs:user --host claude --out-dir "$_GSTACK_RENDER_DIR" 2>&1)
_GEN_USER_RC=$?
printf '%s\n' "$_GEN_USER_OUT" | tail -3
exit "$_GEN_USER_RC"
) || log " warning: gen:skill-docs:user failed — run 'bun run gen:skill-docs:user' manually if you want brain-aware blocks"
); then
# Repoint the installed skills at the fresh render — the installer
# prefers rendered files when present (#2569).
if [ "${_CLAUDE_SKILLS_LINKED:-0}" -eq 1 ]; then
link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" >/dev/null
fi
else
log " warning: gen:skill-docs:user failed — run 'bun run gen:skill-docs:user --host claude --out-dir $_GSTACK_RENDER_DIR' manually if you want brain-aware blocks"
fi
fi
else
log "gbrain not detected — brain-aware blocks suppressed in planning-skill SKILL.md files (zero token overhead)."
log " To enable: install gbrain via /setup-gbrain, then re-run ./setup or 'gstack-config gbrain-refresh'."
# A render from a previous gbrain install would shadow canonical files
# on the next link/relink — drop it and restore canonical links.
if [ -d "$_GSTACK_RENDER_DIR" ] && [ -z "${GSTACK_SKIP_GBRAIN_REGEN:-}" ]; then
rm -rf "$_GSTACK_RENDER_DIR"
if [ "${_CLAUDE_SKILLS_LINKED:-0}" -eq 1 ]; then
link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" >/dev/null
fi
fi
fi
else
rm -f "$DETECTION_TMP"
+11
View File
@@ -20,11 +20,17 @@ describe('gen-skill-docs --out-dir (B2 render isolation)', () => {
return createHash('sha256').update(fs.readFileSync(p)).digest('hex');
}
function porcelain(): string {
const r = spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf-8' });
return r.status === 0 ? r.stdout : '';
}
test('renders :user to out-dir, rewrites section paths, leaves worktree canonical', () => {
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-home-'));
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-out-'));
const worktreeSkill = path.join(ROOT, 'ship', 'SKILL.md');
const beforeHash = hashFile(worktreeSkill);
const beforePorcelain = porcelain();
try {
// Force gbrain detection ON for --respect-detection.
fs.writeFileSync(
@@ -47,6 +53,11 @@ describe('gen-skill-docs --out-dir (B2 render isolation)', () => {
// (a) worktree byte-unchanged
expect(hashFile(worktreeSkill)).toBe(beforeHash);
// (a2, #2569) the render adds ZERO new dirt to the source checkout —
// compared before/after rather than asserting empty, so a dev's own
// unrelated dirty files can't false-fail the suite.
expect(porcelain()).toBe(beforePorcelain);
// (b) inline block present in the rendered SKILL.md
expect(skillContent).toContain('Brain Context Load');
+8 -2
View File
@@ -2350,10 +2350,13 @@ describe('setup script validation', () => {
test('Codex install uses link_codex_skill_dirs', () => {
// The Codex install section (section 5) should use the Codex function
// End marker: the next numbered section header (a marker that doesn't
// exist slices to EOF and the assertion reads unrelated sections).
const codexSection = setupContent.slice(
setupContent.indexOf('# 5. Install for Codex'),
setupContent.indexOf('# 6. Create')
setupContent.indexOf('# 6. Install for Kiro')
);
expect(setupContent.indexOf('# 6. Install for Kiro')).toBeGreaterThan(-1);
expect(codexSection).toContain('create_codex_runtime_root');
expect(codexSection).toContain('link_codex_skill_dirs');
expect(codexSection).not.toContain('link_claude_skill_dirs');
@@ -2398,7 +2401,10 @@ describe('setup script validation', () => {
const fnBody = setupContent.slice(fnStart, fnEnd);
expect(fnBody).toContain('mkdir -p "$target"');
// v1.36.0.0: routes through _link_or_copy helper for Windows fallback (cp on MSYS2/Git Bash).
expect(fnBody).toContain('_link_or_copy "$gstack_dir/$dir_name/SKILL.md" "$target/SKILL.md"');
// v1.67 (#2569): the source is render-aware — canonical SKILL.md, or the
// rendered :user variant from ${GSTACK_HOME}/render/claude when present.
expect(fnBody).toContain('_skill_md_src="$gstack_dir/$dir_name/SKILL.md"');
expect(fnBody).toContain('_link_or_copy "$_skill_md_src" "$target/SKILL.md"');
});
// REGRESSION: cleanup functions must handle both old symlinks AND new real-directory pattern
+32
View File
@@ -266,6 +266,38 @@ describe('gstack-relink (#578)', () => {
expect(new Set(names).size).toBe(names.length);
});
// #2569: rendered :user variants live in ${GSTACK_HOME}/render/claude.
// relink must serve the render when present — otherwise any config change
// silently flips every skill back to the canonical (blockless) source.
test('prefers a rendered SKILL.md from GSTACK_HOME/render/claude (#2569)', () => {
setupMockInstall(['qa', 'ship']);
const renderDir = path.join(tmpDir, 'render', 'claude', 'qa');
fs.mkdirSync(renderDir, { recursive: true });
fs.writeFileSync(
path.join(renderDir, 'SKILL.md'),
'---\nname: qa\ndescription: test\n---\nrendered brain-aware qa',
);
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, {
GSTACK_INSTALL_DIR: installDir,
GSTACK_SKILLS_DIR: skillsDir,
GSTACK_HOME: tmpDir,
});
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
GSTACK_INSTALL_DIR: installDir,
GSTACK_SKILLS_DIR: skillsDir,
GSTACK_HOME: tmpDir,
});
const qaLink = path.join(skillsDir, 'qa', 'SKILL.md');
expect(fs.readlinkSync(qaLink)).toBe(path.join(renderDir, 'SKILL.md'));
expect(fs.readFileSync(qaLink, 'utf-8')).toContain('rendered brain-aware qa');
// ship has no render — canonical source link.
expect(fs.readlinkSync(path.join(skillsDir, 'ship', 'SKILL.md'))).toBe(
path.join(installDir, 'ship', 'SKILL.md'),
);
});
// FIRST INSTALL: --no-prefix must create ONLY flat names, zero gstack-* pollution
test('first install --no-prefix: only flat names exist, zero gstack-* entries', () => {
setupMockInstall(['qa', 'ship', 'review', 'plan-ceo-review', 'gstack-upgrade']);
+196
View File
@@ -0,0 +1,196 @@
/**
* :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 });
}
});
});