Files
gstack/test/relink.test.ts
Garry Tan 7ea6ead9fa fix: ship idempotency + skill prefix name patching (v0.14.3.0) (#693)
* fix: add idempotency guards to /ship Steps 4, 7, 8 (#649)

If git push succeeds but gh pr create fails, re-running /ship would
double-bump VERSION and duplicate CHANGELOG entries. Now:
- Step 4: check if VERSION already differs from base branch
- Step 7: fetch only the specific branch, skip push if already up to date
- Step 8: if PR exists, update body via gh pr edit instead of creating duplicate

No CHANGELOG guard needed — Step 5 is already idempotent by design
("replace existing entries with one unified entry").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: patch name: in SKILL.md frontmatter for prefix mode (#620, #578)

./setup --prefix creates gstack-* symlinks but SKILL.md still says
name: qa, so Claude Code ignores the prefix. Now:
- New bin/gstack-patch-names shared helper patches name: field via sed
- setup calls it after link_claude_skill_dirs
- gstack-relink calls it after symlink loop
- gen-skill-docs.ts prints warning when skill_prefix is true

Edge cases: gstack-upgrade not double-prefixed, root gstack skill
never prefixed, prefix removal restores original names, SKILL.md
without frontmatter is a safe no-op.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add name patching + ship idempotency tests (#620, #649)

- 4 unit tests for name: patching in relink.test.ts (prefix on/off,
  gstack-upgrade not double-prefixed, no-frontmatter no-op)
- 2 tests for gen-skill-docs prefix warning
- 1 E2E test for ship idempotency (periodic tier)
- Updated setupMockInstall to write SKILL.md with proper frontmatter
- Added ship-idempotency touchfiles + tier classification

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.14.3.0)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: PR idempotency checks open state, dedupe touchfiles, sync package.json

- Step 8 PR guard now checks state==OPEN so closed PRs don't prevent
  new PR creation (adversarial review finding)
- Remove duplicate ship-idempotency entry in E2E_TOUCHFILES
- Sync package.json version to 0.14.3.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: patch name: before creating symlinks to fix --no-prefix ordering bug

gstack-patch-names must run BEFORE link_claude_skill_dirs so symlink
names reflect the correct (patched) name: values. Previously, switching
from --prefix to --no-prefix would read stale gstack-* names from
SKILL.md and create wrong symlinks. (Codex adversarial finding)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:25:46 -06:00

230 lines
9.4 KiB
TypeScript

import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin');
let tmpDir: string;
let skillsDir: string;
let installDir: string;
function run(cmd: string, env: Record<string, string> = {}, expectFail = false): string {
try {
return execSync(cmd, {
cwd: ROOT,
env: { ...process.env, GSTACK_STATE_DIR: tmpDir, ...env },
encoding: 'utf-8',
timeout: 10000,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
} catch (e: any) {
if (expectFail) return (e.stderr || e.stdout || '').toString().trim();
throw e;
}
}
// Create a mock gstack install directory with skill subdirs
function setupMockInstall(skills: string[]): void {
installDir = path.join(tmpDir, 'gstack-install');
skillsDir = path.join(tmpDir, 'skills');
fs.mkdirSync(installDir, { recursive: true });
fs.mkdirSync(skillsDir, { recursive: true });
// Copy the real gstack-config and gstack-relink to the mock install
const mockBin = path.join(installDir, 'bin');
fs.mkdirSync(mockBin, { recursive: true });
fs.copyFileSync(path.join(BIN, 'gstack-config'), path.join(mockBin, 'gstack-config'));
fs.chmodSync(path.join(mockBin, 'gstack-config'), 0o755);
if (fs.existsSync(path.join(BIN, 'gstack-relink'))) {
fs.copyFileSync(path.join(BIN, 'gstack-relink'), path.join(mockBin, 'gstack-relink'));
fs.chmodSync(path.join(mockBin, 'gstack-relink'), 0o755);
}
if (fs.existsSync(path.join(BIN, 'gstack-patch-names'))) {
fs.copyFileSync(path.join(BIN, 'gstack-patch-names'), path.join(mockBin, 'gstack-patch-names'));
fs.chmodSync(path.join(mockBin, 'gstack-patch-names'), 0o755);
}
// Create mock skill directories with proper frontmatter
for (const skill of skills) {
fs.mkdirSync(path.join(installDir, skill), { recursive: true });
fs.writeFileSync(
path.join(installDir, skill, 'SKILL.md'),
`---\nname: ${skill}\ndescription: test\n---\n# ${skill}`
);
}
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-relink-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('gstack-relink (#578)', () => {
// Test 11: prefixed symlinks when skill_prefix=true
test('creates gstack-* symlinks when skill_prefix=true', () => {
setupMockInstall(['qa', 'ship', 'review']);
// Set config to prefix mode
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`);
// Run relink with env pointing to the mock install
const output = run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
GSTACK_INSTALL_DIR: installDir,
GSTACK_SKILLS_DIR: skillsDir,
});
// Verify gstack-* symlinks exist
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(true);
expect(fs.existsSync(path.join(skillsDir, 'gstack-ship'))).toBe(true);
expect(fs.existsSync(path.join(skillsDir, 'gstack-review'))).toBe(true);
expect(output).toContain('gstack-');
});
// Test 12: flat symlinks when skill_prefix=false
test('creates flat symlinks when skill_prefix=false', () => {
setupMockInstall(['qa', 'ship', 'review']);
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`);
const output = run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
GSTACK_INSTALL_DIR: installDir,
GSTACK_SKILLS_DIR: skillsDir,
});
expect(fs.existsSync(path.join(skillsDir, 'qa'))).toBe(true);
expect(fs.existsSync(path.join(skillsDir, 'ship'))).toBe(true);
expect(fs.existsSync(path.join(skillsDir, 'review'))).toBe(true);
expect(output).toContain('flat');
});
// Test 13: cleans stale symlinks from opposite mode
test('cleans up stale symlinks from opposite mode', () => {
setupMockInstall(['qa', 'ship']);
// Create prefixed symlinks first
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`);
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
GSTACK_INSTALL_DIR: installDir,
GSTACK_SKILLS_DIR: skillsDir,
});
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(true);
// Switch to flat mode
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`);
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
GSTACK_INSTALL_DIR: installDir,
GSTACK_SKILLS_DIR: skillsDir,
});
// Flat symlinks should exist, prefixed should be gone
expect(fs.existsSync(path.join(skillsDir, 'qa'))).toBe(true);
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
});
// Test 14: error when install dir missing
test('prints error when install dir missing', () => {
const output = run(`${BIN}/gstack-relink`, {
GSTACK_INSTALL_DIR: '/nonexistent/path/gstack',
GSTACK_SKILLS_DIR: '/nonexistent/path/skills',
}, true);
expect(output).toContain('setup');
});
// Test: gstack-upgrade does NOT get double-prefixed
test('does not double-prefix gstack-upgrade directory', () => {
setupMockInstall(['qa', 'ship', 'gstack-upgrade']);
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`);
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
GSTACK_INSTALL_DIR: installDir,
GSTACK_SKILLS_DIR: skillsDir,
});
// gstack-upgrade should keep its name, NOT become gstack-gstack-upgrade
expect(fs.existsSync(path.join(skillsDir, 'gstack-upgrade'))).toBe(true);
expect(fs.existsSync(path.join(skillsDir, 'gstack-gstack-upgrade'))).toBe(false);
// Regular skills still get prefixed
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(true);
});
// Test 15: gstack-config set skill_prefix triggers relink
test('gstack-config set skill_prefix triggers relink', () => {
setupMockInstall(['qa', 'ship']);
// Run gstack-config set which should auto-trigger relink
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, {
GSTACK_INSTALL_DIR: installDir,
GSTACK_SKILLS_DIR: skillsDir,
});
// If relink was triggered, symlinks should exist
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(true);
expect(fs.existsSync(path.join(skillsDir, 'gstack-ship'))).toBe(true);
});
});
describe('gstack-patch-names (#620/#578)', () => {
// Helper to read name: from SKILL.md frontmatter
function readSkillName(skillDir: string): string | null {
const content = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf-8');
const match = content.match(/^name:\s*(.+)$/m);
return match ? match[1].trim() : null;
}
test('prefix=true patches name: field in SKILL.md', () => {
setupMockInstall(['qa', 'ship', 'review']);
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`);
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
GSTACK_INSTALL_DIR: installDir,
GSTACK_SKILLS_DIR: skillsDir,
});
// Verify name: field is patched with gstack- prefix
expect(readSkillName(path.join(installDir, 'qa'))).toBe('gstack-qa');
expect(readSkillName(path.join(installDir, 'ship'))).toBe('gstack-ship');
expect(readSkillName(path.join(installDir, 'review'))).toBe('gstack-review');
});
test('prefix=false restores name: field in SKILL.md', () => {
setupMockInstall(['qa', 'ship']);
// First, prefix them
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`);
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
GSTACK_INSTALL_DIR: installDir,
GSTACK_SKILLS_DIR: skillsDir,
});
expect(readSkillName(path.join(installDir, 'qa'))).toBe('gstack-qa');
// Now switch to flat mode
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`);
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
GSTACK_INSTALL_DIR: installDir,
GSTACK_SKILLS_DIR: skillsDir,
});
// Verify name: field is restored to unprefixed
expect(readSkillName(path.join(installDir, 'qa'))).toBe('qa');
expect(readSkillName(path.join(installDir, 'ship'))).toBe('ship');
});
test('gstack-upgrade name: not double-prefixed', () => {
setupMockInstall(['qa', 'gstack-upgrade']);
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`);
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
GSTACK_INSTALL_DIR: installDir,
GSTACK_SKILLS_DIR: skillsDir,
});
// gstack-upgrade should keep its name, NOT become gstack-gstack-upgrade
expect(readSkillName(path.join(installDir, 'gstack-upgrade'))).toBe('gstack-upgrade');
// Regular skill should be prefixed
expect(readSkillName(path.join(installDir, 'qa'))).toBe('gstack-qa');
});
test('SKILL.md without frontmatter is a no-op', () => {
setupMockInstall(['qa']);
// Overwrite qa SKILL.md with no frontmatter
fs.writeFileSync(path.join(installDir, 'qa', 'SKILL.md'), '# qa\nSome content.');
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`);
// Should not crash
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
GSTACK_INSTALL_DIR: installDir,
GSTACK_SKILLS_DIR: skillsDir,
});
// Content should be unchanged (no name: to patch)
const content = fs.readFileSync(path.join(installDir, 'qa', 'SKILL.md'), 'utf-8');
expect(content).toBe('# qa\nSome content.');
});
});