* fix(#934): reapply verifier handles missing pristine baseline post-rename Gap 1 (verify-reapply-patches.cjs): when backup-meta.json records a pristine_hash for a file but gsd-pristine/ has no corresponding snapshot on disk, the verifier fell to over-broad mode and produced false FAIL_USER_LINES_MISSING. Fix: return advisory OK_NO_BASELINE (non-blocking, exit 0) so the verifier does not block on files it cannot reason about. Gap 2 (new migration 004): migration 003 removed legacy get-shit-done/ runtime files but left gsd-pristine/get-shit-done/ orphan snapshots in place. Those stale snapshots referenced get-shit-done/... key paths that no longer match the active gsd-core/... layout. Fix: add migration 004-prune-stale-pristine-get-shit-done (NOT editing 003, preserving its checksum — ref #670 guard) to remove all files under gsd-pristine/get-shit-done/ as GSD-managed pristine snapshots. Includes tests: bug-934 OK_NO_BASELINE assertions in the verifier test, new installer-migration-prune-stale-pristine.test.cjs, updated installer-migrations baseline-lock checksum for 004. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#934): rename migration to satisfy legacy-name guard + mark intentional path refs Rename src/installer-migrations/004-prune-stale-pristine-get-shit-done.cts → 004-prune-stale-pristine-snapshots.cts so the filename no longer contains the forbidden token. Update .gitignore and eslint.config.mjs to track the new built path. Add gsd-allow-legacy-name markers to the remaining intentional uses of the legacy path string in the migration body (lines 3 and 100) and in tests (installer-migration-prune-stale-pristine.test.cjs lines 202 and 226; and the baseline-lock key in installer-migrations.test.cjs:1469). Update the baseline checksum for migration 2026-06-09-prune-stale-pristine-get-shit-done to reflect the two new marker comments added to its body. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -88,6 +88,7 @@ describe('Bug #2969: deterministic Step 5 verification gate', () => {
|
||||
// Locks the public diagnostic surface — adding a code requires updating
|
||||
// this assertion, removing one breaks consumers that switch on the enum.
|
||||
// Bug #3657 added OK_PRISTINE_DRIFT_DETECTED.
|
||||
// Bug #934 added OK_NO_BASELINE.
|
||||
assert.deepEqual(
|
||||
Object.keys(REASON).sort(),
|
||||
[
|
||||
@@ -95,6 +96,7 @@ describe('Bug #2969: deterministic Step 5 verification gate', () => {
|
||||
'FAIL_INSTALLED_NOT_REGULAR_FILE',
|
||||
'FAIL_READ_ERROR',
|
||||
'FAIL_USER_LINES_MISSING',
|
||||
'OK_NO_BASELINE',
|
||||
'OK_NO_SIGNIFICANT_BACKUP_LINES',
|
||||
'OK_NO_USER_LINES_VS_PRISTINE',
|
||||
'OK_PRISTINE_DRIFT_DETECTED',
|
||||
@@ -178,7 +180,8 @@ describe('Bug #2969: deterministic Step 5 verification gate', () => {
|
||||
assert.equal(status, 1);
|
||||
// Bug #3657 (Finding 1): drifted + drifted_files are additive fields added to surface
|
||||
// pristine-drift skips distinctly from failures. Shape-lock updated to include them.
|
||||
assert.deepEqual(Object.keys(report).sort(), ['checked', 'drifted', 'drifted_files', 'failures', 'results']);
|
||||
// Bug #934: no_baseline + no_baseline_files are additive fields for missing-pristine advisory.
|
||||
assert.deepEqual(Object.keys(report).sort(), ['checked', 'drifted', 'drifted_files', 'failures', 'no_baseline', 'no_baseline_files', 'results']);
|
||||
const r0 = report.results[0];
|
||||
assert.deepEqual(Object.keys(r0).sort(), ['file', 'missing', 'reason', 'status']);
|
||||
assert.equal(typeof r0.file, 'string');
|
||||
|
||||
@@ -332,6 +332,7 @@ describe('Bug #3657: pristine-drift does not produce false FAIL_USER_LINES_MISSI
|
||||
'FAIL_INSTALLED_NOT_REGULAR_FILE',
|
||||
'FAIL_READ_ERROR',
|
||||
'FAIL_USER_LINES_MISSING',
|
||||
'OK_NO_BASELINE',
|
||||
'OK_NO_SIGNIFICANT_BACKUP_LINES',
|
||||
'OK_NO_USER_LINES_VS_PRISTINE',
|
||||
'OK_PRISTINE_DRIFT_DETECTED',
|
||||
@@ -525,3 +526,156 @@ describe('Bug #3657: pristine-drift does not produce false FAIL_USER_LINES_MISSI
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bug #934: OK_NO_BASELINE — pristine dir provided, hash recorded, but file absent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Bug #934: OK_NO_BASELINE when recordedHash present but pristine file absent', () => {
|
||||
|
||||
/**
|
||||
* Core regression: backup-meta.json has a pristine_hash for the file but
|
||||
* the gsd-pristine/ snapshot is absent from disk (the installer's
|
||||
* saveLocalPatches discarded the only candidate because its hash did not
|
||||
* match the old-release hash — the file changed upstream between releases).
|
||||
* Without the fix the verifier falls to over-broad mode and treats every
|
||||
* upstream-removed line as a "user-added line that must survive", producing
|
||||
* FAIL_USER_LINES_MISSING false positives.
|
||||
* With the fix the verifier returns OK_NO_BASELINE (non-blocking, advisory).
|
||||
*/
|
||||
test('exits 0 with reason=OK_NO_BASELINE when recordedHash present but pristine absent', () => {
|
||||
resetFixture();
|
||||
|
||||
const FILE = 'gsd-core/workflows/execute-phase.md';
|
||||
|
||||
// The backup contains both the old upstream content and the user's line.
|
||||
const backupContent =
|
||||
'upstream line that was present in 1.4.0 but removed in 1.4.2 release\n' +
|
||||
'another upstream line removed upstream between gsd-core releases here\n' +
|
||||
'model: sonnet in frontmatter — this is the real user customisation line\n';
|
||||
|
||||
// The installed file has the new upstream content + the user's real line.
|
||||
const installedContent =
|
||||
'brand-new upstream line that replaced the old content in gsd-core 1.4.2\n' +
|
||||
'model: sonnet in frontmatter — this is the real user customisation line\n';
|
||||
|
||||
// backup-meta.json records a hash (modern installer) but gsd-pristine/ is absent.
|
||||
writeBackupMeta({ pristine_hashes: { [FILE]: 'sha256:deadbeef00000000000000000000000000000000000000000000000000000001' } });
|
||||
writeFile(path.join(patchesDir, FILE), backupContent);
|
||||
writeFile(path.join(configDir, FILE), installedContent);
|
||||
// Deliberately do NOT write a pristine file — this is the gap-1 scenario.
|
||||
|
||||
const { status, report } = runVerifier();
|
||||
|
||||
// Must exit 0: cannot reason without baseline → non-blocking advisory.
|
||||
assert.equal(status, 0, `expected exit 0; got ${status}; report=${JSON.stringify(report)}`);
|
||||
assert.equal(report.failures, 0, `expected 0 failures; got ${report.failures}`);
|
||||
const r0 = report.results[0];
|
||||
assert.equal(r0.status, 'ok', `expected status ok; got ${r0.status}`);
|
||||
assert.equal(r0.reason, REASON.OK_NO_BASELINE,
|
||||
`expected OK_NO_BASELINE; got ${r0.reason}`);
|
||||
assert.deepEqual(r0.missing, []);
|
||||
});
|
||||
|
||||
/**
|
||||
* Counter-test: when pristine is absent but NO recordedHash is present
|
||||
* (pre-fix installer that never wrote backup-meta.json), the verifier must
|
||||
* still fall to over-broad mode — the old behaviour for untracked backups.
|
||||
* OK_NO_BASELINE must NOT fire in this case.
|
||||
*/
|
||||
test('falls through to over-broad mode when pristine absent AND no recordedHash', () => {
|
||||
resetFixture();
|
||||
|
||||
const FILE = 'gsd-core/workflows/plan-phase.md';
|
||||
const droppedLine = 'user-added instruction that was dropped from the install output';
|
||||
const backupContent =
|
||||
'stock upstream line long enough to be significant in the file\n' +
|
||||
droppedLine + '\n';
|
||||
const installedContent = 'stock upstream line long enough to be significant in the file\n';
|
||||
|
||||
// No backup-meta.json — simulates pre-fix installer with no hash records.
|
||||
writeFile(path.join(patchesDir, FILE), backupContent);
|
||||
writeFile(path.join(configDir, FILE), installedContent);
|
||||
// No pristine file.
|
||||
|
||||
const { status, report } = runVerifier();
|
||||
|
||||
// Over-broad mode catches the genuinely dropped user line.
|
||||
assert.equal(status, 1, 'over-broad mode should catch the dropped user line');
|
||||
assert.equal(report.failures, 1);
|
||||
const r0 = report.results[0];
|
||||
assert.equal(r0.status, 'fail');
|
||||
assert.equal(r0.reason, REASON.FAIL_USER_LINES_MISSING);
|
||||
assert.ok(r0.missing.includes(droppedLine),
|
||||
`dropped line must appear in .missing[]; got ${JSON.stringify(r0.missing)}`);
|
||||
// Must NOT be OK_NO_BASELINE — that only fires when a hash WAS recorded.
|
||||
assert.notEqual(r0.reason, REASON.OK_NO_BASELINE);
|
||||
});
|
||||
|
||||
/**
|
||||
* Presence check: when pristine IS present AND hash matches, the normal
|
||||
* flow must proceed (not short-circuit to OK_NO_BASELINE).
|
||||
* A real dropped user line must still be caught.
|
||||
*/
|
||||
test('does not short-circuit to OK_NO_BASELINE when pristine exists and hash matches', () => {
|
||||
resetFixture();
|
||||
|
||||
const FILE = 'gsd-core/workflows/plan-phase.md';
|
||||
const pristineContent = 'stock upstream line long enough to be significant content\n';
|
||||
const droppedLine = 'user customisation that was genuinely dropped from the merged output';
|
||||
const backupContent = pristineContent + droppedLine + '\n';
|
||||
const installedContent = pristineContent; // user line dropped — real failure
|
||||
|
||||
writeBackupMeta({ pristine_hashes: { [FILE]: sha256(pristineContent) } });
|
||||
writeFile(path.join(patchesDir, FILE), backupContent);
|
||||
writeFile(path.join(configDir, FILE), installedContent);
|
||||
writeFile(path.join(pristineDir, FILE), pristineContent);
|
||||
|
||||
const { status, report } = runVerifier();
|
||||
|
||||
assert.equal(status, 1, 'real dropped user line must be caught');
|
||||
assert.equal(report.failures, 1);
|
||||
const r0 = report.results[0];
|
||||
assert.equal(r0.status, 'fail');
|
||||
assert.equal(r0.reason, REASON.FAIL_USER_LINES_MISSING);
|
||||
assert.notEqual(r0.reason, REASON.OK_NO_BASELINE);
|
||||
assert.ok(r0.missing.includes(droppedLine));
|
||||
});
|
||||
|
||||
/**
|
||||
* When --pristine-dir is NOT provided at all (old CLI invocation without the
|
||||
* flag), the OK_NO_BASELINE path must never fire — there is no pristine dir
|
||||
* context to consult and the old over-broad behaviour must be preserved.
|
||||
*/
|
||||
test('does not return OK_NO_BASELINE when --pristine-dir is not provided', () => {
|
||||
resetFixture();
|
||||
|
||||
const FILE = 'gsd-core/workflows/execute-phase.md';
|
||||
const backupContent =
|
||||
'upstream line removed in newer version but present in backup\n' +
|
||||
'model: sonnet — user customisation line in the backup file\n';
|
||||
const installedContent =
|
||||
'replacement upstream line in the newer release version\n' +
|
||||
'model: sonnet — user customisation line in the backup file\n';
|
||||
|
||||
// Record a hash — but no pristine dir will be passed to the verifier.
|
||||
writeBackupMeta({ pristine_hashes: { [FILE]: 'sha256:deadbeef00000000000000000000000000000000000000000000000000000001' } });
|
||||
writeFile(path.join(patchesDir, FILE), backupContent);
|
||||
writeFile(path.join(configDir, FILE), installedContent);
|
||||
|
||||
// Run without --pristine-dir flag.
|
||||
const { status, report } = runVerifier({ pristine: false });
|
||||
|
||||
// Over-broad mode: every significant backup line is required.
|
||||
// "upstream line removed in newer version but present in backup" is NOT in
|
||||
// the installed content → over-broad mode FAILS this file (exit 1).
|
||||
// OK_NO_BASELINE must NOT fire — there was no pristine dir to consult.
|
||||
assert.equal(status, 1, `over-broad mode should fail (upstream-removed line absent); got ${status}`);
|
||||
const r0 = report.results[0];
|
||||
assert.equal(r0.status, 'fail', `expected fail status; got ${r0.status}`);
|
||||
assert.equal(r0.reason, REASON.FAIL_USER_LINES_MISSING,
|
||||
`expected FAIL_USER_LINES_MISSING from over-broad mode; got ${r0.reason}`);
|
||||
assert.notEqual(r0.reason, REASON.OK_NO_BASELINE,
|
||||
`OK_NO_BASELINE must not fire when --pristine-dir is not provided`);
|
||||
});
|
||||
});
|
||||
|
||||
280
tests/installer-migration-prune-stale-pristine.test.cjs
Normal file
280
tests/installer-migration-prune-stale-pristine.test.cjs
Normal file
@@ -0,0 +1,280 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* TDD tests for installer migration 004:
|
||||
* 2026-06-09-prune-stale-pristine-get-shit-done // gsd-allow-legacy-name
|
||||
*
|
||||
* Verifies plan() logic for:
|
||||
* 1. Stale pristine subdir absent -> empty plan (idempotency)
|
||||
* 2. Stale pristine subdir present -> remove-managed actions emitted for each file
|
||||
* 3. Stale pristine root is a symlink -> empty plan (symlink safety)
|
||||
* 4. Symlinked entry inside stale pristine dir is NOT emitted
|
||||
* 5. Mixed files: all get remove-managed (no user-file classification needed)
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
// Load compiled module (build:lib compiles src/*.cts -> gsd-core/bin/lib/*.cjs)
|
||||
const migration = require('../gsd-core/bin/lib/installer-migrations/004-prune-stale-pristine-snapshots.cjs');
|
||||
|
||||
function createTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-migration-004-test-'));
|
||||
}
|
||||
|
||||
function cleanup(dir) {
|
||||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup in migration test; no helpers import available
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeFile(root, relPath, content) {
|
||||
const fullPath = path.join(root, relPath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content, 'utf8');
|
||||
}
|
||||
|
||||
function writeManifest(root, files) {
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'gsd-file-manifest.json'),
|
||||
JSON.stringify({
|
||||
version: '1.3.0',
|
||||
timestamp: '2026-06-01T00:00:00.000Z',
|
||||
mode: 'full',
|
||||
files,
|
||||
}, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
// Build a plan context using the real installer-migrations classifyArtifact.
|
||||
const {
|
||||
classifyArtifact: realClassifyArtifact,
|
||||
readInstallManifest,
|
||||
} = require('../gsd-core/bin/lib/installer-migrations.cjs');
|
||||
|
||||
function makePlanCtx(configDir) {
|
||||
const manifest = readInstallManifest(configDir);
|
||||
return {
|
||||
configDir,
|
||||
classifyArtifact: (relPath) => realClassifyArtifact(configDir, relPath, manifest),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('migration 004 metadata', () => {
|
||||
test('exports a single migration object with required fields', () => {
|
||||
assert.equal(typeof migration, 'object');
|
||||
assert.equal(typeof migration.id, 'string');
|
||||
assert.ok(migration.id.length > 0, 'id must be non-empty');
|
||||
assert.equal(typeof migration.title, 'string');
|
||||
assert.equal(typeof migration.description, 'string');
|
||||
assert.equal(typeof migration.introducedIn, 'string');
|
||||
assert.ok(Array.isArray(migration.scopes), 'scopes must be an array');
|
||||
assert.ok(migration.scopes.includes('global'), 'scopes must include global');
|
||||
assert.ok(migration.scopes.includes('local'), 'scopes must include local');
|
||||
assert.strictEqual(migration.destructive, true);
|
||||
assert.equal(typeof migration.plan, 'function');
|
||||
});
|
||||
|
||||
test('id contains expected date prefix', () => {
|
||||
assert.ok(migration.id.startsWith('2026-06-09-'), `id should start with date prefix, got: ${migration.id}`);
|
||||
});
|
||||
|
||||
test('id references prune-stale-pristine', () => {
|
||||
assert.ok(
|
||||
migration.id.includes('prune-stale-pristine') || migration.id.includes('pristine'),
|
||||
`id should reference pristine pruning, got: ${migration.id}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 1: stale pristine subdir absent -> empty plan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('plan() — stale pristine subdir absent', () => {
|
||||
test('returns empty array when gsd-pristine/get-shit-done/ does not exist', () => { // gsd-allow-legacy-name
|
||||
const configDir = createTempDir();
|
||||
try {
|
||||
// Only gsd-pristine/gsd-core/ exists — no legacy subdir.
|
||||
writeFile(configDir, 'gsd-pristine/gsd-core/workflows/plan.md', 'pristine snapshot\n');
|
||||
writeManifest(configDir, {});
|
||||
|
||||
const actions = migration.plan(makePlanCtx(configDir));
|
||||
assert.deepEqual(actions, []);
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('returns empty array when gsd-pristine/ does not exist at all', () => {
|
||||
const configDir = createTempDir();
|
||||
try {
|
||||
writeManifest(configDir, {});
|
||||
const actions = migration.plan(makePlanCtx(configDir));
|
||||
assert.deepEqual(actions, []);
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 2: stale pristine subdir present -> remove-managed for each file
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('plan() — stale pristine files present', () => {
|
||||
test('emits remove-managed for each file under gsd-pristine/get-shit-done/', () => { // gsd-allow-legacy-name
|
||||
const configDir = createTempDir();
|
||||
try {
|
||||
writeFile(configDir, 'gsd-pristine/get-shit-done/workflows/plan.md', 'old pristine\n'); // gsd-allow-legacy-name
|
||||
writeFile(configDir, 'gsd-pristine/get-shit-done/skills/gsd-foo/SKILL.md', 'old skill\n'); // gsd-allow-legacy-name
|
||||
writeManifest(configDir, {});
|
||||
|
||||
const actions = migration.plan(makePlanCtx(configDir));
|
||||
assert.equal(actions.length, 2, `expected 2 actions, got ${actions.length}`);
|
||||
for (const action of actions) {
|
||||
assert.equal(action.type, 'remove-managed', `expected remove-managed, got ${action.type}`);
|
||||
assert.ok(
|
||||
action.relPath.replace(/\\/g, '/').startsWith('gsd-pristine/get-shit-done/'), // gsd-allow-legacy-name
|
||||
`relPath should start with gsd-pristine/get-shit-done/, got: ${action.relPath}`, // gsd-allow-legacy-name
|
||||
);
|
||||
assert.equal(typeof action.reason, 'string');
|
||||
assert.ok(action.reason.length > 0, 'reason must not be empty');
|
||||
assert.equal(typeof action.ownershipEvidence, 'string');
|
||||
assert.ok(action.ownershipEvidence.length > 0, 'ownershipEvidence must not be empty');
|
||||
}
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('emits exactly one remove-managed per file (correct relPaths)', () => {
|
||||
const configDir = createTempDir();
|
||||
try {
|
||||
writeFile(configDir, 'gsd-pristine/get-shit-done/workflows/execute-phase.md', 'pristine\n'); // gsd-allow-legacy-name
|
||||
writeManifest(configDir, {});
|
||||
|
||||
const actions = migration.plan(makePlanCtx(configDir));
|
||||
assert.equal(actions.length, 1);
|
||||
const relPathNorm = actions[0].relPath.replace(/\\/g, '/');
|
||||
assert.equal(relPathNorm, 'gsd-pristine/get-shit-done/workflows/execute-phase.md'); // gsd-allow-legacy-name
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('actions include classification override to managed-pristine', () => {
|
||||
const configDir = createTempDir();
|
||||
try {
|
||||
writeFile(configDir, 'gsd-pristine/get-shit-done/workflows/plan.md', 'pristine snapshot\n'); // gsd-allow-legacy-name
|
||||
writeManifest(configDir, {});
|
||||
|
||||
const actions = migration.plan(makePlanCtx(configDir));
|
||||
assert.equal(actions.length, 1);
|
||||
// The action must carry classification:'managed-pristine' so the framework
|
||||
// does not downgrade remove-managed to preserve-user (the file is not in
|
||||
// the manifest so classify() would return 'unknown').
|
||||
assert.equal(actions[0].classification, 'managed-pristine',
|
||||
'action must carry classification:managed-pristine override');
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 3: stale pristine root is a symlink -> plan returns [] (symlink safety)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('plan() — stale pristine root is a symlink', () => {
|
||||
test('returns empty array when gsd-pristine/get-shit-done/ is a symlink', () => { // gsd-allow-legacy-name
|
||||
const configDir = createTempDir();
|
||||
const externalDir = createTempDir();
|
||||
try {
|
||||
writeFile(externalDir, 'workflows/plan.md', 'pristine content\n');
|
||||
// Create gsd-pristine/ as a real dir but make get-shit-done/ a symlink. // gsd-allow-legacy-name
|
||||
fs.mkdirSync(path.join(configDir, 'gsd-pristine'), { recursive: true });
|
||||
const legacyLink = path.join(configDir, 'gsd-pristine', 'get-shit-done'); // gsd-allow-legacy-name
|
||||
fs.symlinkSync(externalDir, legacyLink);
|
||||
writeManifest(configDir, {});
|
||||
|
||||
const actions = migration.plan(makePlanCtx(configDir));
|
||||
assert.deepEqual(actions, [], 'plan() must return [] when stale pristine root is a symlink');
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
cleanup(externalDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 4: symlinked entry inside stale pristine dir is skipped
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('plan() — symlinked entry inside stale pristine dir is skipped', () => {
|
||||
test('symlinked file inside stale pristine dir is not included in plan actions', () => {
|
||||
const configDir = createTempDir();
|
||||
const externalTarget = createTempDir();
|
||||
try {
|
||||
// A real file inside gsd-pristine/get-shit-done/ // gsd-allow-legacy-name
|
||||
writeFile(configDir, 'gsd-pristine/get-shit-done/workflows/plan.md', 'real pristine\n'); // gsd-allow-legacy-name
|
||||
|
||||
// A symlink inside the same dir pointing to external target.
|
||||
const externalFile = path.join(externalTarget, 'external.md');
|
||||
fs.writeFileSync(externalFile, 'external content\n', 'utf8');
|
||||
const symlinkPath = path.join(configDir, 'gsd-pristine', 'get-shit-done', 'workflows', 'symlinked.md'); // gsd-allow-legacy-name
|
||||
fs.symlinkSync(externalFile, symlinkPath);
|
||||
|
||||
writeManifest(configDir, {});
|
||||
|
||||
const actions = migration.plan(makePlanCtx(configDir));
|
||||
|
||||
// Only the real file should appear; the symlinked entry must be skipped.
|
||||
assert.equal(actions.length, 1, `expected 1 action (real file only), got ${actions.length}`);
|
||||
const hasSymlinked = actions.some((a) => a.relPath.includes('symlinked'));
|
||||
assert.equal(hasSymlinked, false, 'symlinked entry must not appear in plan actions');
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
cleanup(externalTarget);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration: plan goes through planInstallerMigrations + applyInstallerMigrationPlan
|
||||
// Files are actually removed from disk.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('plan() — integration: stale pristine files are removed', () => {
|
||||
test('stale gsd-pristine/get-shit-done/ file is removed after apply', () => { // gsd-allow-legacy-name
|
||||
const configDir = createTempDir();
|
||||
try {
|
||||
const fileContent = 'old pristine snapshot content written by gsd installer\n';
|
||||
writeFile(configDir, 'gsd-pristine/get-shit-done/workflows/plan.md', fileContent); // gsd-allow-legacy-name
|
||||
writeManifest(configDir, {});
|
||||
|
||||
const { planInstallerMigrations, applyInstallerMigrationPlan } = require('../gsd-core/bin/lib/installer-migrations.cjs');
|
||||
const plan = planInstallerMigrations({
|
||||
configDir,
|
||||
migrations: [migration],
|
||||
scope: 'global',
|
||||
});
|
||||
|
||||
assert.equal(plan.blocked.length, 0, `expected no blocked actions; got ${JSON.stringify(plan.blocked)}`);
|
||||
applyInstallerMigrationPlan({ configDir, plan });
|
||||
|
||||
// File must be gone after apply.
|
||||
const stillThere = fs.existsSync(path.join(configDir, 'gsd-pristine', 'get-shit-done', 'workflows', 'plan.md')); // gsd-allow-legacy-name
|
||||
assert.equal(stillThere, false, 'stale pristine file must be removed after apply');
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1465,6 +1465,9 @@ test('shipped installer-migration checksums are locked to a committed baseline (
|
||||
'sha256:5ce55294aa02f25758f604a569c899a6d2d060299189f5f447f68d8033157058',
|
||||
'2026-06-02-rename-get-shit-done-to-gsd-core':
|
||||
'sha256:3a9f1d97f64097fb313203d19c6d93a187a38df61dd299afa5eef73e16124e95',
|
||||
// Migration 004: prune stale gsd-pristine/get-shit-done/ snapshots (#934) // gsd-allow-legacy-name
|
||||
'2026-06-09-prune-stale-pristine-get-shit-done': // gsd-allow-legacy-name
|
||||
'sha256:6555dd044659276fbc204e81793cd92c5315d54e7316bcdd82d2c98d15a7e9e8',
|
||||
};
|
||||
|
||||
const { DEFAULT_MIGRATIONS_DIR, migrationChecksum: computeChecksum } = require('../gsd-core/bin/lib/installer-migrations.cjs');
|
||||
|
||||
Reference in New Issue
Block a user