feat(#607): rebuild get-shit-done-cc → gsd-core migration (per-package cache + installer auto-cleanup + --dry-run) (#611)

* feat(#607): rebuild get-shit-done-cc → gsd-core migration

Leftover get-shit-done-cc installs poisoned the shared update cache,
causing a permanent false "update available". Rebuild the migration so a
stale old install is both harmless and actively removed.

- Per-package update cache filename (gsd-update-check-<slug>.json) in the
  shared ~/.cache/gsd dir, single-sourced via package-identity; writers
  stamp package_name and readers reject foreign/absent lineage. Multi-
  runtime visibility preserved (same shared dir + filename across runtimes).
- New get-shit-done/bin/lib/legacy-cleanup.cjs seam: detects code-file
  references to the old package + the legacy fixed-name cache across home
  runtime dirs; installer auto-cleans on every install; --dry-run previews
  and mutates nothing. User hooks and dev-preferences are never touched.
- update.md cache-clear globs gsd-update-check*.json across ALL supported
  runtimes (adds cursor/windsurf/augment/trae/qwen/hermes/codebuddy/cline).
- Fix worker MODULE_NOT_FOUND post-install (ship managed-hooks-registry.cjs
  + degrade gracefully) so the per-package cache is always written.
- Diataxis how-to: docs/cleanup-get-shit-done-cc.md.

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

* chore(#607): add changeset for PR #611

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

* fix(#607): set USERPROFILE alongside HOME in dry-run install test for Windows

os.homedir() reads USERPROFILE on win32, so HOME-only isolation let the
spawned installer scan the real runner home on windows-latest. Set both.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-02 12:19:57 -04:00
committed by GitHub
parent df04aae5e4
commit 91f5bd7cb1
26 changed files with 1546 additions and 75 deletions

View File

@@ -71,12 +71,12 @@ describe('bug-2784: update.md cache-clear covers shared cache path', () => {
}
const sharedCacheClearCmds = bashLines.filter(
(line) => /^rm\b/.test(line) && line.includes('.cache/gsd/gsd-update-check.json')
(line) => /^rm\b/.test(line) && line.includes('.cache/gsd/gsd-update-check') && line.includes('*.json')
);
assert.ok(
sharedCacheClearCmds.length > 0,
[
'run_update step bash blocks must include an `rm` command targeting .cache/gsd/gsd-update-check.json.',
'run_update step bash blocks must include an `rm` command targeting .cache/gsd/gsd-update-check*.json (glob form clearing legacy + per-package variants).',
`Bash lines found: ${JSON.stringify(bashLines)}`,
].join('\n')
);

View File

@@ -17,8 +17,8 @@ const {
renderGithubReleaseNotes,
} = require(path.join(ROOT, 'scripts', 'changeset', 'github-release-notes.cjs'));
function run(command, args, cwd) {
const result = cp.spawnSync(command, args, { cwd, encoding: 'utf8' });
function run(command, args, cwd, env) {
const result = cp.spawnSync(command, args, { cwd, encoding: 'utf8', env: env || process.env });
assert.equal(result.status, 0, `${command} ${args.join(' ')}\nstdout=${result.stdout}\nstderr=${result.stderr}`);
return result.stdout;
}
@@ -31,19 +31,34 @@ function writeFragment(repo, name, type, pr, body) {
function createTaggedRepo() {
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-release-notes-'));
run('git', ['init', '-q'], repo);
run('git', ['config', 'user.email', 'test@example.com'], repo);
run('git', ['config', 'user.name', 'Test User'], repo);
// Isolate from the developer's global/system git config (e.g. gpgSign settings)
// by pointing GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM at an empty file inside
// the temp dir. This prevents tag.gpgSign / commit.gpgSign / tag.forceSignAnnotated
// from leaking in and breaking lightweight tags or unsigned commits.
const emptyGitConfig = path.join(repo, '.git-config-empty');
fs.writeFileSync(emptyGitConfig, '');
const gitEnv = {
...process.env,
GIT_CONFIG_GLOBAL: emptyGitConfig,
GIT_CONFIG_SYSTEM: emptyGitConfig,
};
run('git', ['init', '-q'], repo, gitEnv);
// Belt-and-suspenders: also set local repo config to disable signing
run('git', ['config', 'user.email', 'test@example.com'], repo, gitEnv);
run('git', ['config', 'user.name', 'Test User'], repo, gitEnv);
run('git', ['config', 'commit.gpgSign', 'false'], repo, gitEnv);
run('git', ['config', 'tag.gpgSign', 'false'], repo, gitEnv);
run('git', ['config', 'tag.forceSignAnnotated', 'false'], repo, gitEnv);
fs.writeFileSync(path.join(repo, 'README.md'), 'fixture\n');
run('git', ['add', 'README.md'], repo);
run('git', ['commit', '-q', '-m', 'initial'], repo);
run('git', ['tag', 'v1.0.0'], repo);
run('git', ['add', 'README.md'], repo, gitEnv);
run('git', ['commit', '-q', '-m', 'initial'], repo, gitEnv);
run('git', ['tag', 'v1.0.0'], repo, gitEnv);
writeFragment(repo, 'fix-install-sdk', 'Fixed', 101, '**`gsd-sdk` now installs reliably** — persistent PATH is checked.');
writeFragment(repo, 'remove-intel-noise', 'Removed', 102, '**`gsd-intel-updater` no longer emits layout detection noise** — ordinary projects stay quiet.');
run('git', ['add', '.changeset'], repo);
run('git', ['commit', '-q', '-m', 'add changesets'], repo);
run('git', ['tag', 'v1.0.1'], repo);
run('git', ['add', '.changeset'], repo, gitEnv);
run('git', ['commit', '-q', '-m', 'add changesets'], repo, gitEnv);
run('git', ['tag', 'v1.0.1'], repo, gitEnv);
return repo;
}

View File

@@ -1265,26 +1265,62 @@ describe('shared cache directory (#1421)', () => {
);
});
test('gsd-statusline.js checks shared cache first, falls back to legacy (#1421)', () => {
const content = fs.readFileSync(
test('gsd-statusline.js reads the per-package shared cache and rejects foreign lineage (#1421/#607)', () => {
const { evaluateUpdateCache } = require('../hooks/gsd-statusline.js');
const { updateCacheFileName, PACKAGE_NAME } = require('../get-shit-done/bin/lib/package-identity.cjs');
// Per-package filename embeds the package identity — no generic fallback
assert.strictEqual(
updateCacheFileName,
'gsd-update-check-opengsd-gsd-core.json',
'updateCacheFileName must be the per-package filename'
);
// The statusline must NOT reference a legacyCacheFile — the legacy fallback was removed
// allow-test-rule: architectural-invariant
const statuslineSrc = fs.readFileSync(
path.join(__dirname, '..', 'hooks', 'gsd-statusline.js'), 'utf-8'
);
// Statusline must check the shared cache path first
assert.ok(
content.includes("path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json')"),
'statusline must check shared cache at ~/.cache/gsd/gsd-update-check.json'
!statuslineSrc.includes('legacyCacheFile'),
'gsd-statusline.js must not reference legacyCacheFile — legacy fallback was removed in #607'
);
// Must fall back to legacy runtime-specific cache for backward compat
assert.ok(
content.includes("path.join(claudeDir, 'cache', 'gsd-update-check.json')"),
'statusline must fall back to legacy cache at claudeDir/cache/gsd-update-check.json'
statuslineSrc.includes(updateCacheFileName) || statuslineSrc.includes('updateCacheFileName'),
'gsd-statusline.js must reference the per-package updateCacheFileName'
);
// Shared cache must be checked before legacy (existsSync order matters)
const sharedIdx = content.indexOf('sharedCacheFile');
const legacyIdx = content.indexOf('legacyCacheFile');
assert.ok(
sharedIdx < legacyIdx,
'shared cache must be defined and checked before legacy cache'
// evaluateUpdateCache: foreign package_name → no update shown
assert.deepStrictEqual(
evaluateUpdateCache({ package_name: 'other-package', update_available: true }),
{ showUpdate: false, staleWarning: 'none' },
'foreign package_name must be rejected (lineage guard)'
);
// evaluateUpdateCache: absent package_name → no update shown
assert.deepStrictEqual(
evaluateUpdateCache({ update_available: true }),
{ showUpdate: false, staleWarning: 'none' },
'absent package_name must be rejected (lineage guard)'
);
// evaluateUpdateCache: null cache → no update shown
assert.deepStrictEqual(
evaluateUpdateCache(null),
{ showUpdate: false, staleWarning: 'none' },
'null cache must return no-update'
);
// evaluateUpdateCache: matching package_name + update_available:true → show update
const result = evaluateUpdateCache({ package_name: PACKAGE_NAME, update_available: true });
assert.strictEqual(result.showUpdate, true,
'matching package_name with update_available:true must set showUpdate=true'
);
// evaluateUpdateCache: matching package_name + update_available:false → no update
const noUpdate = evaluateUpdateCache({ package_name: PACKAGE_NAME, update_available: false });
assert.strictEqual(noUpdate.showUpdate, false,
'matching package_name with update_available:false must not show update'
);
});
});

View File

@@ -27,6 +27,7 @@ const {
shouldSuppressFailureWarning,
RATE_LIMIT_SECONDS,
} = require('../hooks/gsd-update-banner.js');
const { updateCacheFileName } = require('../get-shit-done/bin/lib/package-identity.cjs');
// ─── Pure function: buildBannerOutput ───────────────────────────────────────
@@ -51,7 +52,7 @@ describe('buildBannerOutput', () => {
test('returns banner envelope when update_available is true', () => {
const out = buildBannerOutput({
cache: { update_available: true, installed: '1.39.0', latest: '1.40.0' },
cache: { update_available: true, installed: '1.39.0', latest: '1.40.0', package_name: '@opengsd/gsd-core' },
parseError: false,
suppressFailureWarning: false,
});
@@ -96,7 +97,7 @@ describe('buildBannerOutput', () => {
test('falls back to "unknown" when installed/latest missing', () => {
const out = buildBannerOutput({
cache: { update_available: true },
cache: { update_available: true, package_name: '@opengsd/gsd-core' },
parseError: false,
suppressFailureWarning: false,
});
@@ -183,7 +184,7 @@ describe('gsd-update-banner.js end-to-end', () => {
function writeCache(home, contents) {
fs.writeFileSync(
path.join(home, '.cache', 'gsd', 'gsd-update-check.json'),
path.join(home, '.cache', 'gsd', updateCacheFileName),
typeof contents === 'string' ? contents : JSON.stringify(contents)
);
}
@@ -206,6 +207,7 @@ describe('gsd-update-banner.js end-to-end', () => {
update_available: true,
installed: '1.39.0',
latest: '1.40.0',
package_name: '@opengsd/gsd-core',
});
const r = runHook(home);
assert.equal(r.status, 0);

View File

@@ -1,6 +1,7 @@
// Tests for gsd-tools effort sync command (#488)
// Verifies that effort frontmatter in installed agent files can be re-synced
// when effort config changes after initial install.
// allow-test-rule: structural-regression-guard — readFileSync asserts on installed agent .md files (the product under mutation) to verify dry-run safety and apply correctness; stderr.includes guards the CLI argument-rejection contract.
'use strict';

View File

@@ -4,6 +4,7 @@
* Covers: query, status, diff, validate, snapshot, patch-meta,
* extract-exports, enabled/disabled gating, and CLI routing via gsd-tools.
*/
// allow-test-rule: source-text-is-the-product — readFileSync assertions target API-SURFACE.md, which is the generated product of intelApiSurface; asserting on its text content is the only way to verify correct generation.
'use strict';

View File

@@ -15,7 +15,7 @@ const path = require('node:path');
const fs = require('node:fs');
const ROOT = path.join(__dirname, '..');
const { deriveIdentity, formatManualInstall, render } = require(
const { deriveIdentity, formatManualInstall, render, slugifyPackageName } = require(
path.join(ROOT, 'scripts', 'generate-package-identity.cjs'),
);
const GENERATED = path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'package-identity.cjs');
@@ -57,6 +57,28 @@ describe('Issue #498: deriveIdentity (pure, package.json -> coordinates)', () =>
assert.equal(id.binName, 'gsd-core');
assert.equal(id.repoSlug, 'open-gsd/gsd-core');
});
test('deriveIdentity returns cacheSlug for @opengsd/gsd-core', () => {
const real = require(path.join(ROOT, 'package.json'));
const id = deriveIdentity(real);
assert.equal(id.cacheSlug, 'opengsd-gsd-core');
});
test('deriveIdentity returns updateCacheFileName for @opengsd/gsd-core', () => {
const real = require(path.join(ROOT, 'package.json'));
const id = deriveIdentity(real);
assert.equal(id.updateCacheFileName, 'gsd-update-check-opengsd-gsd-core.json');
});
});
describe('Issue #498: slugifyPackageName (pure helper for cache filename)', () => {
test('slugifyPackageName strips leading @, replaces / with -, for @opengsd/gsd-core', () => {
assert.equal(slugifyPackageName('@opengsd/gsd-core'), 'opengsd-gsd-core');
});
test('slugifyPackageName returns empty string for empty input', () => {
assert.equal(slugifyPackageName(''), '');
});
});
describe('Issue #498: formatManualInstall (the npx fallback command)', () => {
@@ -91,6 +113,7 @@ describe('Issue #498: generated runtime module (baked, drift-checked)', () => {
// bug-3707). The sync check is about content, not the checkout's eol.
const norm = (s) => s.replace(/\r\n/g, '\n');
const expected = render(deriveIdentity(require(path.join(ROOT, 'package.json'))));
// allow-test-rule: architectural-invariant
const actual = fs.readFileSync(GENERATED, 'utf8');
assert.equal(norm(actual), norm(expected),
'package-identity.cjs is stale — run `node scripts/generate-package-identity.cjs`');
@@ -103,6 +126,16 @@ describe('Issue #498: generated runtime module (baked, drift-checked)', () => {
assert.equal(id.repoSlug, 'open-gsd/gsd-core');
});
test('generated module exports cacheSlug matching @opengsd/gsd-core', () => {
const id = require(GENERATED);
assert.equal(id.cacheSlug, 'opengsd-gsd-core');
});
test('generated module exports updateCacheFileName matching @opengsd/gsd-core', () => {
const id = require(GENERATED);
assert.equal(id.updateCacheFileName, 'gsd-update-check-opengsd-gsd-core.json');
});
test('generated manualInstallCommand closes over the baked coordinates', () => {
const id = require(GENERATED);
assert.equal(

View File

@@ -0,0 +1,177 @@
/**
* Tests for cache lineage validation (issue #607).
*
* Verifies that per-package cache filenames and package_name lineage guards
* are correctly enforced across gsd-update-banner.js, gsd-statusline.js,
* and the worker result shape.
*/
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const { PACKAGE_NAME, updateCacheFileName } = require('../get-shit-done/bin/lib/package-identity.cjs');
const { buildBannerOutput } = require('../hooks/gsd-update-banner.js');
const { evaluateUpdateCache } = require('../hooks/gsd-statusline.js');
// ─── Package identity constants ──────────────────────────────────────────────
describe('package-identity exports', () => {
test('PACKAGE_NAME is @opengsd/gsd-core', () => {
assert.equal(PACKAGE_NAME, '@opengsd/gsd-core');
});
test('updateCacheFileName is per-package filename', () => {
assert.equal(updateCacheFileName, 'gsd-update-check-opengsd-gsd-core.json');
});
});
// ─── Worker result shape: package_name field ─────────────────────────────────
// The worker writes { ..., package_name: PACKAGE_NAME } to the cache.
// We assert the documented contract by confirming PACKAGE_NAME is correct
// and that it equals the value that the worker will embed.
describe('worker result shape contract', () => {
test('PACKAGE_NAME value matches the expected installed package', () => {
// The worker adds package_name: PACKAGE_NAME to its result object.
// This test asserts the value that will appear in the cache.
assert.equal(PACKAGE_NAME, '@opengsd/gsd-core');
});
});
// ─── buildBannerOutput: lineage guard ────────────────────────────────────────
describe('buildBannerOutput lineage guard', () => {
test('returns null when package_name is present but foreign', () => {
const out = buildBannerOutput({
cache: {
update_available: true,
installed: '1.2.0',
latest: '1.42.3',
package_name: 'get-shit-done-cc',
},
parseError: false,
suppressFailureWarning: false,
});
assert.equal(out, null, 'foreign lineage must be rejected');
});
test('returns banner when package_name matches PACKAGE_NAME', () => {
const out = buildBannerOutput({
cache: {
update_available: true,
installed: '1.2.0',
latest: '1.3.0',
package_name: '@opengsd/gsd-core',
},
parseError: false,
suppressFailureWarning: false,
});
assert.ok(out, 'expected banner envelope for matching lineage');
assert.equal(typeof out.systemMessage, 'string');
assert.ok(out.systemMessage.includes('1.2.0'));
assert.ok(out.systemMessage.includes('1.3.0'));
assert.ok(out.systemMessage.includes('/gsd:update'));
});
test('returns null when package_name is absent (untrusted cache)', () => {
const out = buildBannerOutput({
cache: {
update_available: true,
installed: '1.2.0',
latest: '1.3.0',
// no package_name field
},
parseError: false,
suppressFailureWarning: false,
});
assert.equal(out, null, 'absent package_name must be treated as untrusted → null');
});
});
// ─── evaluateUpdateCache: lineage guard in statusline ────────────────────────
describe('evaluateUpdateCache lineage guard', () => {
test('returns showUpdate=false when cache is null', () => {
const r = evaluateUpdateCache(null);
assert.equal(r.showUpdate, false);
assert.equal(r.staleWarning, 'none');
});
test('returns showUpdate=false when package_name is absent (untrusted)', () => {
const r = evaluateUpdateCache({
update_available: true,
installed: '1.2.0',
latest: '1.3.0',
});
assert.equal(r.showUpdate, false);
assert.equal(r.staleWarning, 'none');
});
test('returns showUpdate=false when package_name is foreign', () => {
const r = evaluateUpdateCache({
update_available: true,
installed: '1.2.0',
latest: '1.3.0',
package_name: 'some-other-package',
});
assert.equal(r.showUpdate, false);
assert.equal(r.staleWarning, 'none');
});
test('returns showUpdate=true when update_available and package_name matches', () => {
const r = evaluateUpdateCache({
update_available: true,
installed: '1.2.0',
latest: '1.3.0',
package_name: '@opengsd/gsd-core',
});
assert.equal(r.showUpdate, true);
assert.equal(r.staleWarning, 'none');
});
test('returns showUpdate=false when update_available=false', () => {
const r = evaluateUpdateCache({
update_available: false,
installed: '1.3.0',
latest: '1.3.0',
package_name: '@opengsd/gsd-core',
});
assert.equal(r.showUpdate, false);
assert.equal(r.staleWarning, 'none');
});
test('returns staleWarning=stale when stale_hooks present and matching package_name', () => {
const r = evaluateUpdateCache({
update_available: false,
installed: '1.3.0',
latest: '1.3.0',
package_name: '@opengsd/gsd-core',
stale_hooks: [{ file: 'gsd-statusline.js', hookVersion: '1.2.0', installedVersion: '1.3.0' }],
});
assert.equal(r.staleWarning, 'stale');
});
test('returns staleWarning=dev when installed > latest (dev install) and matching package_name', () => {
const r = evaluateUpdateCache({
update_available: false,
installed: '2.0.0',
latest: '1.3.0',
package_name: '@opengsd/gsd-core',
stale_hooks: [{ file: 'gsd-statusline.js', hookVersion: '1.2.0', installedVersion: '2.0.0' }],
});
assert.equal(r.staleWarning, 'dev');
});
test('returns staleWarning=none when stale_hooks present but package_name is foreign', () => {
const r = evaluateUpdateCache({
update_available: false,
installed: '1.3.0',
latest: '1.2.0',
package_name: 'foreign-pkg',
stale_hooks: [{ file: 'gsd-statusline.js', hookVersion: '1.2.0', installedVersion: '1.3.0' }],
});
assert.equal(r.staleWarning, 'none');
});
});

View File

@@ -0,0 +1,302 @@
// allow-test-rule: integration-test-input
// Test-created temp dirs are the only filesystem reads here — not repo source files.
// This is an integration test that seeds fixture files in OS temp dirs and
// asserts that the installer correctly handles --dry-run and the
// cleanupLegacyGsdCc exported helper.
/**
* #607 — --dry-run flag and cleanupLegacyGsdCc wiring.
*
* Covers:
* 1. Spawning `node bin/install.js --claude --global --dry-run` with an
* isolated HOME that contains a seeded legacy artifact. Asserts exit 0,
* stdout names the artifact and contains "dry" (case-insensitive), and
* no files are mutated (artifact still present; no .claude install).
* Also asserts the per-package cache path appears AT MOST ONCE (no
* double-print regression).
* 2. Spawning `node bin/install.js --claude --dry-run --uninstall` asserts
* the "does not preview --uninstall" warning prints and exits 0 without
* uninstalling anything.
* 3. Direct unit call to the exported cleanupLegacyGsdCc helper:
* - dryRun:true → plan lists the artifact, removes nothing.
* - dryRun:false → seeded leftover removed, dev-preferences.md preserved.
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { spawnSync } = require('node:child_process');
const REPO_ROOT = path.resolve(__dirname, '..');
const INSTALL_BIN = path.join(REPO_ROOT, 'bin', 'install.js');
const { cleanup } = require('./helpers.cjs');
// ─── helpers ─────────────────────────────────────────────────────────────────
function mkTmp(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function writeFile(filePath, content) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, 'utf8');
}
// The assembled signal string used as file content to trigger
// content-references-old-package detection.
const LEGACY_PKG_SIGNAL = 'get-shit-done' + '-cc';
// ─── Suite 1: spawn --dry-run, assert no mutations ───────────────────────────
describe('#607 --dry-run flag: spawned installer exits 0 and mutates nothing', () => {
let tmpHome;
beforeEach(() => {
tmpHome = mkTmp('gsd-607-dryhome-');
});
afterEach(() => {
cleanup(tmpHome);
});
test('exits 0; stdout names artifact and contains "dry"; no install; artifact preserved; no double-print', () => {
// Seed a legacy artifact: a .cjs hook file under HOME/.gemini/hooks/ whose
// content contains the old package name (content-signal, not orphan-by-name).
// This exercises the content-references-old-package reason exclusively.
const legacyHook = path.join(tmpHome, '.gemini', 'hooks', 'gsd-old-update-worker.cjs');
writeFile(legacyHook, `// installed via ${LEGACY_PKG_SIGNAL}\nconsole.log("old worker");`);
// Seed the legacy shared cache file
const legacyCache = path.join(tmpHome, '.cache', 'gsd', 'gsd-update-check.json');
writeFile(legacyCache, JSON.stringify({ legacy: true }));
// Spawn the installer with --dry-run
const result = spawnSync(
process.execPath,
[INSTALL_BIN, '--claude', '--global', '--dry-run'],
{
env: {
...process.env,
HOME: tmpHome,
USERPROFILE: tmpHome,
// Redirect Claude config dir into isolated tmp home
CLAUDE_CONFIG_DIR: path.join(tmpHome, '.claude'),
// Suppress slow stale-SDK npm check
GSD_SKIP_STALE_SDK_CHECK: '1',
// Do NOT set GSD_TEST_MODE — we want the main() block to run
GSD_TEST_MODE: undefined,
},
cwd: REPO_ROOT,
encoding: 'utf8',
timeout: 30_000,
}
);
// Exit code must be 0
assert.equal(
result.status,
0,
`Expected exit 0 but got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`
);
const stdout = result.stdout + result.stderr;
// stdout must contain the word "dry" (case-insensitive)
assert.match(
stdout,
/dry/i,
`Expected stdout to contain "dry". Got:\n${stdout}`
);
// stdout must mention the seeded legacy artifact path
assert.ok(
stdout.includes(legacyHook),
`Expected stdout to mention ${legacyHook}.\nGot:\n${stdout}`
);
// The seeded artifact must STILL EXIST (no mutations)
assert.ok(
fs.existsSync(legacyHook),
`Legacy hook must still exist after --dry-run: ${legacyHook}`
);
// The legacy cache must STILL EXIST
assert.ok(
fs.existsSync(legacyCache),
`Legacy cache must still exist after --dry-run: ${legacyCache}`
);
// No actual install happened — .claude/get-shit-done must not exist
const installDir = path.join(tmpHome, '.claude', 'get-shit-done');
assert.equal(
fs.existsSync(installDir),
false,
`No install should happen during --dry-run; found: ${installDir}`
);
// Regression: the per-package cache path must appear AT MOST ONCE
// (guard against the duplicate-print bug where it was printed both inside
// cleanupLegacyGsdCc and again in the outer --dry-run block).
const updateCacheFileName = require(
path.join(REPO_ROOT, 'get-shit-done', 'bin', 'lib', 'package-identity.cjs')
).updateCacheFileName;
const perPkgCacheFile = path.join(tmpHome, '.cache', 'gsd', updateCacheFileName);
const occurrences = stdout.split(perPkgCacheFile).length - 1;
assert.ok(
occurrences <= 1,
`Per-package cache path must appear at most once in stdout; found ${occurrences} times.\nstdout:\n${stdout}`
);
});
test('--uninstall --dry-run prints "does not preview --uninstall" warning and exits 0', () => {
const result = spawnSync(
process.execPath,
[INSTALL_BIN, '--claude', '--uninstall', '--dry-run'],
{
env: {
...process.env,
HOME: tmpHome,
USERPROFILE: tmpHome,
CLAUDE_CONFIG_DIR: path.join(tmpHome, '.claude'),
GSD_SKIP_STALE_SDK_CHECK: '1',
GSD_TEST_MODE: undefined,
},
cwd: REPO_ROOT,
encoding: 'utf8',
timeout: 30_000,
}
);
assert.equal(
result.status,
0,
`Expected exit 0 but got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`
);
const stdout = result.stdout + result.stderr;
// Must print the warning about --uninstall not being previewed
assert.ok(
stdout.includes('does not preview --uninstall'),
`Expected "does not preview --uninstall" warning.\nGot:\n${stdout}`
);
// No uninstall occurred — .claude/get-shit-done must not have been removed
// (it never existed, but we confirm the installer didn't blow up)
assert.equal(
result.status,
0,
'Process must exit 0'
);
});
});
// ─── Suite 2: direct helper unit tests ───────────────────────────────────────
describe('#607 cleanupLegacyGsdCc: exported helper unit tests', () => {
// GSD_TEST_MODE is already set at the top so requiring install.js is safe.
const { cleanupLegacyGsdCc } = require(INSTALL_BIN);
let tmpRoot;
let homeDir;
beforeEach(() => {
tmpRoot = mkTmp('gsd-607-unit-');
homeDir = path.join(tmpRoot, 'home');
fs.mkdirSync(homeDir, { recursive: true });
});
afterEach(() => {
cleanup(tmpRoot);
});
test('dryRun:true — plan lists seeded artifact; nothing removed', () => {
// Seed a content-signal code file under homeDir/.gemini/hooks/
const legacyHook = path.join(homeDir, '.gemini', 'hooks', 'gsd-old-update-worker.cjs');
writeFile(legacyHook, `// installed via ${LEGACY_PKG_SIGNAL}\nconsole.log("old worker");`);
const logMessages = [];
const mockLogger = { log: (msg) => logMessages.push(msg) };
const { plan, result } = cleanupLegacyGsdCc({
homeDir,
dryRun: true,
logger: mockLogger,
});
// Plan must include the seeded artifact
const planEntry = plan.find((p) => p.path === legacyHook);
assert.ok(planEntry, `Plan must list seeded artifact: ${legacyHook}\nActual plan: ${JSON.stringify(plan)}`);
// dryRun result must flag it as skipped, not removed
assert.equal(result.dryRun, true);
assert.equal(result.removed.length, 0, 'dryRun must remove nothing');
// The artifact must still exist
assert.ok(
fs.existsSync(legacyHook),
`Artifact must survive dry-run: ${legacyHook}`
);
// Logger should have been called at least once
assert.ok(logMessages.length > 0, 'Logger should have been called');
});
test('dryRun:false — seeded leftover removed; dev-preferences.md preserved', () => {
// Seed a content-signal code file
const legacyHook = path.join(homeDir, '.gemini', 'hooks', 'gsd-old-update-worker.cjs');
writeFile(legacyHook, `// installed via ${LEGACY_PKG_SIGNAL}\nconsole.log("old worker");`);
// Seed a dev-preferences.md that must NOT be removed
const devPrefs = path.join(homeDir, '.gemini', 'get-shit-done', 'dev-preferences.md');
writeFile(devPrefs, '# My prefs\n\nSome user content — must not be touched.');
const { plan, result } = cleanupLegacyGsdCc({
homeDir,
dryRun: false,
});
// The legacy hook must be in the plan
const planEntry = plan.find((p) => p.path === legacyHook);
assert.ok(planEntry, `Legacy hook must appear in plan: ${legacyHook}\nActual plan: ${JSON.stringify(plan)}`);
// The legacy hook must have been removed
assert.equal(
fs.existsSync(legacyHook),
false,
`Legacy hook must be removed: ${legacyHook}`
);
// The removed list must include the legacy hook
assert.ok(
result.removed.includes(legacyHook),
`removed[] must include legacy hook\nActual removed: ${JSON.stringify(result.removed)}`
);
// dev-preferences.md must NOT be in the plan and must still exist
const devPrefsInPlan = plan.find((p) => p.path === devPrefs);
assert.equal(devPrefsInPlan, undefined, 'dev-preferences.md must never appear in plan');
assert.ok(
fs.existsSync(devPrefs),
`dev-preferences.md must be preserved: ${devPrefs}`
);
});
test('dryRun:true — returns plan and result without error (no files present)', () => {
// homeDir exists but no legacy artifacts seeded
const { plan, result } = cleanupLegacyGsdCc({
homeDir,
dryRun: true,
});
assert.ok(Array.isArray(plan), 'plan must be an array');
assert.equal(result.dryRun, true);
assert.equal(result.removed.length, 0, 'nothing to remove');
});
});

View File

@@ -0,0 +1,327 @@
/**
* Tests for legacy artifact cleanup seam (issue #607).
*
* Covers planLegacyCleanup and applyLegacyCleanup from
* get-shit-done/bin/lib/legacy-cleanup.cjs using real temp dirs so the
* filesystem logic is exercised end-to-end without touching live config dirs.
*
* These tests read files they create themselves in OS temp directories —
* not repo source files. The fs reads are test-input reads, not source-grep.
* // allow-test-rule: integration-test-input
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { planLegacyCleanup, applyLegacyCleanup } = require(
path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'legacy-cleanup.cjs')
);
const { MANAGED_HOOKS } = require(
path.join(__dirname, '..', 'hooks', 'managed-hooks-registry.cjs')
);
const { cleanup } = require('./helpers.cjs');
// Assembled the same way the implementation does so this file also avoids the
// bare literal (correctness: the test content strings below DO contain it,
// which is fine — tests may reference the signal string directly).
const OLD_PACKAGE_SIGNAL = 'get-shit-done' + '-cc';
// ─── Helpers ─────────────────────────────────────────────────────────────────
function mkTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-607-'));
}
function writeFile(filePath, content) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, 'utf8');
}
// ─── Suite ───────────────────────────────────────────────────────────────────
describe('issue-607 legacy-cleanup: planLegacyCleanup', () => {
let tmpRoot;
let configDir;
let homeDir;
beforeEach(() => {
tmpRoot = mkTmpDir();
configDir = path.join(tmpRoot, 'config');
homeDir = path.join(tmpRoot, 'home');
fs.mkdirSync(configDir, { recursive: true });
fs.mkdirSync(homeDir, { recursive: true });
});
afterEach(() => {
cleanup(tmpRoot);
});
// ── content-references-old-package ─────────────────────────────────────────
test('flags a hook file in hooks/ whose content contains the old package signal', () => {
const hookFile = path.join(configDir, 'hooks', 'gsd-check-update-worker.js');
writeFile(hookFile, '// installed via ' + OLD_PACKAGE_SIGNAL + '\nconsole.log("hello");');
const plan = planLegacyCleanup([configDir], { homeDir });
const entry = plan.find((p) => p.path === hookFile);
assert.ok(entry, 'expected hookFile to appear in plan');
assert.equal(entry.reason, 'content-references-old-package');
});
test('does NOT flag a file whose content does not reference the old package', () => {
const hookFile = path.join(configDir, 'hooks', 'gsd-check-update-worker.js');
writeFile(hookFile, '// installed via @opengsd/gsd-core\nconsole.log("ok");');
const plan = planLegacyCleanup([configDir], { homeDir });
const entry = plan.find((p) => p.path === hookFile);
assert.equal(entry, undefined, 'clean hook must not appear in plan');
});
// ── data-loss regression: user custom hooks must be preserved ──────────────
test('regression #607 (data-loss): user custom gsd-*.js with NO old-package content must NOT appear in plan', () => {
// Previously the orphaned-hook-by-name rule would flag any gsd-*.js not in
// MANAGED_HOOKS — deleting user-authored hooks. This is the key regression test.
const userHook = path.join(configDir, 'hooks', 'gsd-my-custom.js');
writeFile(userHook, '// my custom hook — does not reference the old package');
const plan = planLegacyCleanup([configDir], { homeDir });
const entry = plan.find((p) => p.path === userHook);
assert.equal(entry, undefined, 'user custom gsd-*.js with no old-package content must NOT be in plan');
});
test('regression #607 (data-loss): user custom gsd-*.sh with NO old-package content must NOT appear in plan', () => {
const userHook = path.join(configDir, 'hooks', 'gsd-my-custom.sh');
writeFile(userHook, '#!/bin/sh\n# my custom shell hook, clean');
const plan = planLegacyCleanup([configDir], { homeDir });
const entry = plan.find((p) => p.path === userHook);
assert.equal(entry, undefined, 'user custom gsd-*.sh with no old-package content must NOT be in plan');
});
// ── self-deletion regression: get-shit-done/ subtree must NOT be scanned ──
test('regression #607 (self-deletion): a code file under get-shit-done/bin/lib/ containing old-package signal must NOT be flagged', () => {
// The subtree 'get-shit-done' is no longer scanned — the current package's
// own infra lives there and would falsely match if scanned.
const libFile = path.join(configDir, 'get-shit-done', 'bin', 'lib', 'legacy-cleanup.cjs');
writeFile(libFile, "'use strict';\nconst SIG = 'get-shit-done' + '-cc';\nmodule.exports = {};");
const plan = planLegacyCleanup([configDir], { homeDir });
const entry = plan.find((p) => p.path === libFile);
assert.equal(entry, undefined, 'code file in get-shit-done/ subtree must NOT appear in plan (subtree not scanned)');
});
// ── issue-607 regression: markdown files must never be flagged ─────────────
test('regression #607: CHANGELOG.md containing old-package signal must NOT be flagged', () => {
// Markdown docs legitimately cite the old package name in historical context.
const changelogFile = path.join(configDir, 'get-shit-done', 'CHANGELOG.md');
writeFile(changelogFile, '# Changelog\n\nMigrated from ' + OLD_PACKAGE_SIGNAL + ' to @opengsd/gsd-core.');
const plan = planLegacyCleanup([configDir], { homeDir });
const entry = plan.find((p) => p.path === changelogFile);
assert.equal(entry, undefined, 'CHANGELOG.md must never appear in plan even if it cites the old package name');
});
test('regression #607: a workflow .md file containing old-package signal must NOT be flagged', () => {
const workflowMd = path.join(configDir, 'get-shit-done', 'workflows', 'update.md');
writeFile(workflowMd, '# Update workflow\n\nPreviously required ' + OLD_PACKAGE_SIGNAL + ' to be installed.');
const plan = planLegacyCleanup([configDir], { homeDir });
const entry = plan.find((p) => p.path === workflowMd);
assert.equal(entry, undefined, 'workflow .md must never appear in plan even if it cites the old package name');
});
// ── dev-preferences exclusion ──────────────────────────────────────────────
test('NEVER flags dev-preferences.md even if its content contains old-package signal', () => {
// Place dev-preferences.md inside a GSD-managed subtree
const devPrefs = path.join(configDir, 'hooks', 'dev-preferences.md');
writeFile(devPrefs, '# My prefs\n\nI used to use ' + OLD_PACKAGE_SIGNAL);
const plan = planLegacyCleanup([configDir], { homeDir });
const entry = plan.find((p) => p.path === devPrefs);
assert.equal(entry, undefined, 'dev-preferences.md must never appear in plan');
});
test('NEVER flags a file under a dev-preferences/ directory', () => {
const dpFile = path.join(configDir, 'hooks', 'dev-preferences', 'notes.md');
writeFile(dpFile, 'old notes referencing ' + OLD_PACKAGE_SIGNAL);
const plan = planLegacyCleanup([configDir], { homeDir });
const entry = plan.find((p) => p.path === dpFile);
assert.equal(entry, undefined, 'file under dev-preferences/ dir must never appear in plan');
});
// ── legacy-shared-cache ────────────────────────────────────────────────────
test('flags the legacy shared cache when it exists with reason legacy-shared-cache', () => {
const cachePath = path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json');
writeFile(cachePath, JSON.stringify({ update_available: false }));
const plan = planLegacyCleanup([], { homeDir });
const entry = plan.find((p) => p.path === cachePath);
assert.ok(entry, 'expected legacy cache to appear in plan');
assert.equal(entry.reason, 'legacy-shared-cache');
});
test('does NOT flag the legacy shared cache when it is absent', () => {
// homeDir exists but cache file was never written
const plan = planLegacyCleanup([], { homeDir });
const cachePath = path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json');
const entry = plan.find((p) => p.path === cachePath);
assert.equal(entry, undefined, 'absent cache must not appear in plan');
});
// ── deduplication and sort ─────────────────────────────────────────────────
test('de-duplicates candidates when two configDirs share same absolute path (same dir listed twice)', () => {
const signalFile = path.join(configDir, 'hooks', 'gsd-old-feature.sh');
writeFile(signalFile, '# ' + OLD_PACKAGE_SIGNAL);
const plan = planLegacyCleanup([configDir, configDir], { homeDir });
const entries = plan.filter((p) => p.path === signalFile);
assert.equal(entries.length, 1, 'same path must appear only once');
});
test('plan entries are sorted by path', () => {
writeFile(path.join(configDir, 'hooks', 'gsd-zzz-last.js'), '// ' + OLD_PACKAGE_SIGNAL);
writeFile(path.join(configDir, 'hooks', 'gsd-aaa-first.js'), '// ' + OLD_PACKAGE_SIGNAL);
const plan = planLegacyCleanup([configDir], { homeDir });
const paths = plan.map((p) => p.path);
const sorted = [...paths].sort();
assert.deepEqual(paths, sorted, 'plan must be sorted by path');
});
// ── only content-references-old-package and legacy-shared-cache reasons ────
test('plan entries only ever have reason content-references-old-package or legacy-shared-cache', () => {
writeFile(path.join(configDir, 'hooks', 'gsd-worker.js'), '// ' + OLD_PACKAGE_SIGNAL);
const cachePath = path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json');
writeFile(cachePath, '{}');
// User custom hook — should NOT appear
writeFile(path.join(configDir, 'hooks', 'gsd-my-custom.js'), '// user hook, clean');
const plan = planLegacyCleanup([configDir], { homeDir });
const validReasons = new Set(['content-references-old-package', 'legacy-shared-cache']);
for (const entry of plan) {
assert.ok(validReasons.has(entry.reason), `unexpected reason: ${entry.reason}`);
}
});
});
describe('issue-607 legacy-cleanup: applyLegacyCleanup', () => {
let tmpRoot;
let configDir;
let homeDir;
beforeEach(() => {
tmpRoot = mkTmpDir();
configDir = path.join(tmpRoot, 'config');
homeDir = path.join(tmpRoot, 'home');
fs.mkdirSync(configDir, { recursive: true });
fs.mkdirSync(homeDir, { recursive: true });
});
afterEach(() => {
cleanup(tmpRoot);
});
// ── dry-run ────────────────────────────────────────────────────────────────
test('dryRun:true removes nothing and returns dryRun:true + all paths in skipped', () => {
// Use a content-signal hook (only way to get a plan entry now)
const signalHook = path.join(configDir, 'hooks', 'gsd-check-update-worker.js');
writeFile(signalHook, '// ' + OLD_PACKAGE_SIGNAL);
const cacheFile = path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json');
writeFile(cacheFile, '{}');
const plan = planLegacyCleanup([configDir], { homeDir });
assert.ok(plan.length > 0, 'precondition: plan must be non-empty');
const logMessages = [];
const mockLogger = { log: (msg) => logMessages.push(msg) };
const result = applyLegacyCleanup(plan, { dryRun: true, logger: mockLogger });
assert.equal(result.dryRun, true);
assert.equal(result.removed.length, 0, 'dryRun must remove nothing');
assert.equal(result.skipped.length, plan.length, 'all plan entries must be in skipped');
assert.deepEqual(result.skipped.sort(), plan.map((p) => p.path).sort());
// All flagged files must still exist
for (const item of plan) {
assert.ok(fs.existsSync(item.path), `${item.path} must still exist after dry-run`);
}
// Logger must have been called for each item
assert.equal(logMessages.length, plan.length, 'logger must be called once per plan item');
for (const msg of logMessages) {
assert.ok(msg.startsWith('[dry-run] would remove:'), `log message format unexpected: ${msg}`);
}
});
// ── real apply ────────────────────────────────────────────────────────────
test('apply removes flagged files and returns them in removed[]', () => {
// Content-signal hook — flagged and must be removed
const signalHook = path.join(configDir, 'hooks', 'gsd-check-update-worker.js');
writeFile(signalHook, '// ' + OLD_PACKAGE_SIGNAL);
const cacheFile = path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json');
writeFile(cacheFile, '{}');
// User custom hook — clean content, must NOT be in plan and must survive
const userHook = path.join(configDir, 'hooks', 'gsd-my-custom.js');
writeFile(userHook, '// user hook, no old package ref');
// Managed hook with clean content — must NOT be in plan and must survive
const managedHook = path.join(configDir, 'hooks', MANAGED_HOOKS[0]);
writeFile(managedHook, '// @opengsd/gsd-core only');
const plan = planLegacyCleanup([configDir], { homeDir });
assert.ok(plan.length > 0, 'precondition: plan must be non-empty');
// Verify clean files not in plan
assert.equal(plan.find((p) => p.path === userHook), undefined, 'user hook must not be in plan');
assert.equal(plan.find((p) => p.path === managedHook), undefined, 'managed hook must not be in plan');
const result = applyLegacyCleanup(plan);
assert.equal(result.dryRun, false);
assert.deepEqual(result.removed.sort(), plan.map((p) => p.path).sort());
assert.equal(result.errors.length, 0, 'no errors expected');
// Flagged files must be gone
for (const item of plan) {
assert.equal(fs.existsSync(item.path), false, `${item.path} must have been removed`);
}
// Clean (non-flagged) files must still exist
assert.ok(fs.existsSync(userHook), 'user custom hook must be preserved');
assert.ok(fs.existsSync(managedHook), 'managed hook must be preserved');
});
test('apply does not touch dev-preferences.md even if it somehow entered the plan (invariant)', () => {
// planLegacyCleanup never adds dev-prefs; this test confirms that invariant.
const devPrefs = path.join(configDir, 'hooks', 'dev-preferences.md');
writeFile(devPrefs, '# prefs\n' + OLD_PACKAGE_SIGNAL + ' was used here');
const plan = planLegacyCleanup([configDir], { homeDir });
const inPlan = plan.find((p) => p.path === devPrefs);
assert.equal(inPlan, undefined, 'planLegacyCleanup must never include dev-preferences.md');
// Since dev-prefs is not in the plan, applying the plan cannot remove it.
applyLegacyCleanup(plan);
assert.ok(fs.existsSync(devPrefs), 'dev-preferences.md must survive apply');
});
});

View File

@@ -79,6 +79,24 @@ describe('orphaned hooks stale detection (#1750)', () => {
}
});
test('MANAGED_HOOKS is a superset of all gsd-* hooks in HOOKS_TO_COPY (all extensions)', () => {
// Every hook-named file in HOOKS_TO_COPY (matching the gsd-* naming pattern
// that the registry governs, regardless of extension) must appear in MANAGED_HOOKS.
// Non-hook support files like managed-hooks-registry.cjs are intentionally
// excluded from this check because they are not themselves hooks.
// This catches missing .sh entries as well as .js entries.
assert.ok(Array.isArray(HOOKS_TO_COPY), 'HOOKS_TO_COPY must be an array');
const gsdHooks = HOOKS_TO_COPY.filter(h => h.startsWith('gsd-'));
assert.ok(gsdHooks.length >= 5, `expected at least 5 gsd-* hooks in HOOKS_TO_COPY, got ${gsdHooks.length}`);
for (const hook of gsdHooks) {
assert.ok(
MANAGED_HOOKS.includes(hook),
`MANAGED_HOOKS should include '${hook}' (from HOOKS_TO_COPY) — add it to hooks/managed-hooks-registry.cjs`
);
}
});
test('orphaned hook filenames are NOT in MANAGED_HOOKS', () => {
const orphanedHooks = [
'gsd-intel-index.js',