mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-11 23:49:01 +02:00
Third review cycle on the ownership model, every item reproduced against a fixture before the fix: - Runtime assets (sections/, templates/, checklist.md, ...) were refreshed with rm -rf regardless of who owned the directory, so an unclaimed or weakly-owned directory lost the user's same-named real files. Real assets are now replaced only in a directory gstack created or strongly owns (marker, or SKILL.md symlink into gstack), plus the legacy Windows real-copy shape; elsewhere they are kept and reported. Symlinks are never content and are always refreshed. - The prefix-flip cleanup deleted a customized banner-bearing SKILL.md that the link pass would have backed up. Both cleanups now compare the file against the source (raw, or with its name: line rewritten to the entry name, which is how alias and prefixed copies legitimately differ) and move a differing file to the backup root. - A failed backup (unwritable root) returned success and the caller linked over the file anyway. It now fails, and the entry is left untouched and reported. - A foreign DIRECTORY symlink whose target had no SKILL.md fell through to the "unclaimed directory" rule and was replaced by a real directory. A symlink that does not resolve into gstack is foreign, full stop. - The alias installers stamped .gstack-owned into pre-existing directories; they now follow the same created-or-already-marked rule. - A directory counts as "only links" only when every link resolves into gstack: a user's own symlink makes it mixed, so their link survives. - The gstack-tree heuristic requires bin/gstack-relink, not just a VERSION file, a setup script and a bin/ directory. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
/**
|
|
* cleanup_old_claude_symlinks destination scan (#2204).
|
|
*
|
|
* The helper used to iterate the payload skill dirs. When the payload is
|
|
* gone the glob matches nothing, so leftover flat skill dirs in $skills_dir
|
|
* stay forever. This suite extracts the REAL function from setup and drives
|
|
* it against a temp skills tree — payload-missing orphans must go, user
|
|
* skills must stay.
|
|
*/
|
|
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');
|
|
|
|
function extractFn(name: string): string {
|
|
const start = SETUP_SRC.indexOf(`${name}() {`);
|
|
const end = SETUP_SRC.indexOf('\n}\n', start);
|
|
if (start < 0 || end < 0) throw new Error(`Could not locate ${name}() in setup`);
|
|
return SETUP_SRC.slice(start, end + 2);
|
|
}
|
|
|
|
function cleanupBody(): string {
|
|
return [extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('_backup_skill_md'), '_BACKED_UP_SKILL_MDS=()', `_SKILL_BACKUP_ROOT="${os.tmpdir()}/cleanup-orphans-backups-${process.pid}"`, extractFn('cleanup_old_claude_symlinks')].join('\n');
|
|
}
|
|
|
|
describe('setup: cleanup_old_claude_symlinks — static (#2204)', () => {
|
|
test('scans the skills dir, not only the payload', () => {
|
|
const body = cleanupBody();
|
|
expect(body).toContain('for old_target in "$skills_dir"/*');
|
|
expect(body).toContain('[ "$skill_name" = "gstack" ] && continue');
|
|
expect(body).toContain('readlink');
|
|
expect(body).toContain('gstack/*');
|
|
expect(body).toContain('gstack-*) continue');
|
|
expect(body).toContain('-d "$old_target"');
|
|
expect(body).toContain('-L "$old_target/SKILL.md"');
|
|
expect(body).toContain('rm -rf "$old_target"');
|
|
// SKILL.md arm must use path-segment provenance, not a bare substring.
|
|
expect(body).toContain('gstack/*|*/gstack/*|*/.gstack/render/claude/*');
|
|
expect(body).not.toMatch(/\*gstack\*\)/);
|
|
});
|
|
|
|
test('Windows real-file reap still requires a live payload name list', () => {
|
|
const body = cleanupBody();
|
|
expect(body).toContain('for skill_dir in "$gstack_dir"/*/');
|
|
expect(body).toContain('[ "${IS_WINDOWS:-0}" -eq 1 ] && [ -d "$gstack_dir" ]');
|
|
});
|
|
});
|
|
|
|
describe.skipIf(process.platform === 'win32')('setup: cleanup_old_claude_symlinks — behavior (#2204)', () => {
|
|
function runCleanup(opts: {
|
|
isWindows?: '0' | '1';
|
|
payload?: boolean;
|
|
plant: (skills: string, payload: string) => void;
|
|
}): { status: number; stdout: string; stderr: string; names: string[]; tmp: string } {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cleanup-orphans-'));
|
|
const skills = path.join(tmp, 'skills');
|
|
const payload = path.join(skills, 'gstack');
|
|
fs.mkdirSync(skills, { recursive: true });
|
|
if (opts.payload) {
|
|
fs.mkdirSync(payload, { recursive: true });
|
|
}
|
|
opts.plant(skills, payload);
|
|
const gstackArg = opts.payload ? payload : path.join(skills, 'missing-payload');
|
|
const script = [
|
|
'set -e',
|
|
`IS_WINDOWS=${opts.isWindows ?? '0'}`,
|
|
extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('_backup_skill_md'), '_BACKED_UP_SKILL_MDS=()', `_SKILL_BACKUP_ROOT="${tmp}/backups"`,
|
|
extractFn('cleanup_old_claude_symlinks'),
|
|
`cleanup_old_claude_symlinks "${gstackArg}" "${skills}"`,
|
|
].join('\n');
|
|
const result = spawnSync('bash', ['-c', script], {
|
|
encoding: 'utf-8',
|
|
timeout: 5000,
|
|
});
|
|
const names = fs.existsSync(skills)
|
|
? fs.readdirSync(skills).sort()
|
|
: [];
|
|
return {
|
|
status: result.status ?? -1,
|
|
stdout: result.stdout ?? '',
|
|
stderr: result.stderr ?? '',
|
|
names,
|
|
tmp,
|
|
};
|
|
}
|
|
|
|
function plantDanglingSkillMd(skills: string, name: string) {
|
|
const dir = path.join(skills, name);
|
|
fs.mkdirSync(dir);
|
|
fs.symlinkSync(`gstack/${name}/SKILL.md`, path.join(dir, 'SKILL.md'));
|
|
}
|
|
|
|
function plantUserSkill(skills: string, name: string) {
|
|
const dir = path.join(skills, name);
|
|
fs.mkdirSync(dir);
|
|
fs.writeFileSync(path.join(dir, 'SKILL.md'), '---\nname: user-owned\n---\n');
|
|
}
|
|
|
|
test('payload gone: dangling SKILL.md orphan is removed, user skill stays', () => {
|
|
const r = runCleanup({
|
|
payload: false,
|
|
plant(skills) {
|
|
plantDanglingSkillMd(skills, 'qa');
|
|
plantUserSkill(skills, 'my-own');
|
|
},
|
|
});
|
|
try {
|
|
expect(r.status).toBe(0);
|
|
expect(r.stderr).toBe('');
|
|
expect(r.stdout).toContain('cleaned up old entries: qa');
|
|
expect(r.names).toEqual(['my-own']);
|
|
} finally {
|
|
fs.rmSync(r.tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('payload gone: whole-dir symlink into gstack/ is removed', () => {
|
|
const r = runCleanup({
|
|
payload: false,
|
|
plant(skills) {
|
|
fs.symlinkSync('gstack/qa', path.join(skills, 'qa'));
|
|
plantUserSkill(skills, 'my-own');
|
|
},
|
|
});
|
|
try {
|
|
expect(r.status).toBe(0);
|
|
expect(r.names).toEqual(['my-own']);
|
|
} finally {
|
|
fs.rmSync(r.tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('payload present: leftover flat name pointing at gstack is still removed', () => {
|
|
const r = runCleanup({
|
|
payload: true,
|
|
plant(skills, payload) {
|
|
const src = path.join(payload, 'qa');
|
|
fs.mkdirSync(src);
|
|
fs.writeFileSync(path.join(src, 'SKILL.md'), '---\nname: qa\n---\n');
|
|
plantDanglingSkillMd(skills, 'qa');
|
|
},
|
|
});
|
|
try {
|
|
expect(r.status).toBe(0);
|
|
expect(r.names).toEqual(['gstack']);
|
|
} finally {
|
|
fs.rmSync(r.tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('payload present: dangling name absent from the payload is still removed', () => {
|
|
// Unique dest-scan win: the old "$gstack_dir"/*/ loop only considered
|
|
// names that still exist in the payload. A retired leftover must go.
|
|
const r = runCleanup({
|
|
payload: true,
|
|
plant(skills, payload) {
|
|
const src = path.join(payload, 'ship');
|
|
fs.mkdirSync(src);
|
|
fs.writeFileSync(path.join(src, 'SKILL.md'), '---\nname: ship\n---\n');
|
|
plantDanglingSkillMd(skills, 'qa');
|
|
},
|
|
});
|
|
try {
|
|
expect(r.status).toBe(0);
|
|
expect(r.names).toEqual(['gstack']);
|
|
} finally {
|
|
fs.rmSync(r.tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('does not remove a SKILL.md symlink that does not point at gstack', () => {
|
|
const r = runCleanup({
|
|
payload: false,
|
|
plant(skills) {
|
|
const dir = path.join(skills, 'elsewhere');
|
|
fs.mkdirSync(dir);
|
|
fs.symlinkSync('other/SKILL.md', path.join(dir, 'SKILL.md'));
|
|
},
|
|
});
|
|
try {
|
|
expect(r.status).toBe(0);
|
|
expect(r.stdout).toBe('');
|
|
expect(r.names).toEqual(['elsewhere']);
|
|
} finally {
|
|
fs.rmSync(r.tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('does not remove a SKILL.md whose target merely contains the substring gstack', () => {
|
|
const r = runCleanup({
|
|
payload: false,
|
|
plant(skills) {
|
|
const dir = path.join(skills, 'notes');
|
|
fs.mkdirSync(dir);
|
|
fs.symlinkSync('../../archive/my-gstack-backup/SKILL.md', path.join(dir, 'SKILL.md'));
|
|
},
|
|
});
|
|
try {
|
|
expect(r.status).toBe(0);
|
|
expect(r.stdout).toBe('');
|
|
expect(r.names).toEqual(['notes']);
|
|
} finally {
|
|
fs.rmSync(r.tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('reaps a leftover whose SKILL.md points at the user render dir', () => {
|
|
const r = runCleanup({
|
|
payload: false,
|
|
plant(skills) {
|
|
const dir = path.join(skills, 'qa');
|
|
fs.mkdirSync(dir);
|
|
fs.symlinkSync('../../.gstack/render/claude/qa/SKILL.md', path.join(dir, 'SKILL.md'));
|
|
},
|
|
});
|
|
try {
|
|
expect(r.status).toBe(0);
|
|
expect(r.stdout).toContain('cleaned up old entries: qa');
|
|
expect(r.names).toEqual([]);
|
|
} finally {
|
|
fs.rmSync(r.tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('does not remove prefixed gstack-* names or the payload dir', () => {
|
|
const r = runCleanup({
|
|
payload: true,
|
|
plant(skills, payload) {
|
|
fs.writeFileSync(path.join(payload, 'SKILL.md'), '---\nname: gstack\n---\n');
|
|
const prefixed = path.join(skills, 'gstack-qa');
|
|
fs.mkdirSync(prefixed);
|
|
fs.symlinkSync('gstack/qa/SKILL.md', path.join(prefixed, 'SKILL.md'));
|
|
},
|
|
});
|
|
try {
|
|
expect(r.status).toBe(0);
|
|
expect(r.names).toEqual(['gstack', 'gstack-qa']);
|
|
} finally {
|
|
fs.rmSync(r.tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('Windows real-file orphan is left alone when the payload is gone', () => {
|
|
const r = runCleanup({
|
|
isWindows: '1',
|
|
payload: false,
|
|
plant(skills) {
|
|
plantUserSkill(skills, 'qa');
|
|
},
|
|
});
|
|
try {
|
|
expect(r.status).toBe(0);
|
|
expect(r.names).toEqual(['qa']);
|
|
} finally {
|
|
fs.rmSync(r.tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// #2119: a bare name match used to delete a USER's own skill that happened to
|
|
// share a gstack skill name. Provenance must be proven: the .gstack-owned
|
|
// marker, a byte-identical copy of the payload source, or gen-skill-docs'
|
|
// AUTO-GENERATED header (legacy copies made before the marker existed).
|
|
test('Windows real-file leftover is removed only when provably gstack-owned', () => {
|
|
const generated = '---\nname: ship\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n# ship\n';
|
|
const r = runCleanup({
|
|
isWindows: '1',
|
|
payload: true,
|
|
plant(skills, payload) {
|
|
for (const name of ['qa', 'ship', 'review', 'browse']) {
|
|
fs.mkdirSync(path.join(payload, name));
|
|
fs.writeFileSync(path.join(payload, name, 'SKILL.md'), name === 'ship' ? generated : `---\nname: ${name}\n---\n`);
|
|
}
|
|
// Byte-identical copy of the payload source → ours.
|
|
fs.mkdirSync(path.join(skills, 'qa'));
|
|
fs.copyFileSync(path.join(payload, 'qa', 'SKILL.md'), path.join(skills, 'qa', 'SKILL.md'));
|
|
// Legacy copy carrying the generated header but drifted from source → ours.
|
|
fs.mkdirSync(path.join(skills, 'ship'));
|
|
fs.writeFileSync(path.join(skills, 'ship', 'SKILL.md'), generated.replace('# ship', '# ship (older render)'));
|
|
// Marker-carrying copy with arbitrary content → ours.
|
|
fs.mkdirSync(path.join(skills, 'browse'));
|
|
fs.writeFileSync(path.join(skills, 'browse', 'SKILL.md'), '---\nname: browse\n---\n# stale copy\n');
|
|
fs.writeFileSync(path.join(skills, 'browse', '.gstack-owned'), '');
|
|
// The user's OWN skill that shares a gstack name → foreign, must survive.
|
|
plantUserSkill(skills, 'review');
|
|
plantUserSkill(skills, 'my-own');
|
|
},
|
|
});
|
|
try {
|
|
expect(r.status).toBe(0);
|
|
expect(r.names).toEqual(['gstack', 'my-own', 'review']);
|
|
expect(fs.readFileSync(path.join(r.tmp, 'skills', 'review', 'SKILL.md'), 'utf-8')).toContain('user-owned');
|
|
} finally {
|
|
fs.rmSync(r.tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// The Windows arm is the ONLY path that touches a real-file SKILL.md. On
|
|
// Unix a same-name real-file skill must survive even when the payload names
|
|
// it and even when its bytes are identical to the payload source.
|
|
test('Unix (IS_WINDOWS=0): a same-name real-file skill is never reaped, even if byte-identical to the payload', () => {
|
|
const r = runCleanup({
|
|
isWindows: '0',
|
|
payload: true,
|
|
plant(skills, payload) {
|
|
fs.mkdirSync(path.join(payload, 'qa'));
|
|
fs.writeFileSync(path.join(payload, 'qa', 'SKILL.md'), '---\nname: qa\n---\n');
|
|
fs.mkdirSync(path.join(skills, 'qa'));
|
|
fs.copyFileSync(path.join(payload, 'qa', 'SKILL.md'), path.join(skills, 'qa', 'SKILL.md'));
|
|
fs.mkdirSync(path.join(skills, 'ship'));
|
|
fs.writeFileSync(path.join(skills, 'ship', 'SKILL.md'), '---\nname: ship\n---\n');
|
|
fs.writeFileSync(path.join(skills, 'ship', '.gstack-owned'), '');
|
|
},
|
|
});
|
|
try {
|
|
expect(r.status).toBe(0);
|
|
expect(r.stdout).toBe('');
|
|
expect(r.names).toEqual(['gstack', 'qa', 'ship']);
|
|
} finally {
|
|
fs.rmSync(r.tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|