fix: verify symlink after migration + fix test failures (#1377) (#1404)

migrateToExternalState() moved .gsd/ to ~/.gsd/projects/<hash>/ and
created a symlink, but never verified the symlink resolved correctly.
On Windows, junction creation can silently fail or resolve to the wrong
target. If the symlink was broken, the backup (.gsd.migrating) was
deleted anyway, losing all project state.

Changes:
- migrate-external.ts: After creating symlink, verify it resolves to the
  expected path and is readable. If verification fails, restore from backup.
- repo-identity-worktree.test.ts: Canonicalize temp dirs with realpathSync
  to fix macOS /var → /private/var mismatch in path assertions.
- resource-loader.ts: Check for agents/ subdir before using dist/resources
  as source — partial builds (tsc without copy-resources) create an
  incomplete dist/resources that's missing agents/ and skills/.

Fixes #1377
This commit is contained in:
Tom Boucher 2026-03-19 18:54:11 -04:00 committed by GitHub
parent bdeec039c0
commit b720e7e15c
2 changed files with 27 additions and 2 deletions

View file

@ -19,6 +19,9 @@ import { loadRegistry, readManifestFromEntryPath, isExtensionEnabled, ensureRegi
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const distResources = join(packageRoot, 'dist', 'resources')
const srcResources = join(packageRoot, 'src', 'resources')
// Use dist/resources only if it has the full expected structure.
// A partial build (tsc without copy-resources) creates dist/resources/extensions/
// but not agents/ or skills/, causing initResources to sync from an incomplete source.
const resourcesDir = (existsSync(distResources) && existsSync(join(distResources, 'agents')))
? distResources
: srcResources

View file

@ -6,7 +6,7 @@
* symlink replaces the original directory so all paths remain valid.
*/
import { existsSync, lstatSync, mkdirSync, readdirSync, renameSync, cpSync, rmSync, symlinkSync } from "node:fs";
import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, renameSync, cpSync, rmSync, symlinkSync } from "node:fs";
import { join } from "node:path";
import { externalGsdRoot } from "./repo-identity.js";
import { getErrorMessage } from "./error-utils.js";
@ -99,7 +99,29 @@ export function migrateToExternalState(basePath: string): MigrationResult {
// Create symlink .gsd -> external path
symlinkSync(externalPath, localGsd, "junction");
// Remove .gsd.migrating
// Verify the symlink resolves correctly before removing the backup (#1377).
// On Windows, junction creation can silently succeed but resolve to the wrong
// target, or the external dir may not be accessible. If verification fails,
// restore from the backup.
try {
const resolved = realpathSync(localGsd);
const resolvedExternal = realpathSync(externalPath);
if (resolved !== resolvedExternal) {
// Symlink points to wrong target — restore backup
try { rmSync(localGsd, { force: true }); } catch { /* may not exist */ }
renameSync(migratingPath, localGsd);
return { migrated: false, error: `Migration verification failed: symlink resolves to ${resolved}, expected ${resolvedExternal}` };
}
// Verify we can read through the symlink
readdirSync(localGsd);
} catch (verifyErr) {
// Symlink broken or unreadable — restore backup
try { rmSync(localGsd, { force: true }); } catch { /* may not exist */ }
try { renameSync(migratingPath, localGsd); } catch { /* best-effort restore */ }
return { migrated: false, error: `Migration verification failed: ${getErrorMessage(verifyErr)}` };
}
// Remove .gsd.migrating only after symlink is verified
rmSync(migratingPath, { recursive: true, force: true });
return { migrated: true };