From b720e7e15c0b677223d7818389ef9a45fb301328 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 19 Mar 2026 18:54:11 -0400 Subject: [PATCH] fix: verify symlink after migration + fix test failures (#1377) (#1404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit migrateToExternalState() moved .gsd/ to ~/.gsd/projects// 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 --- src/resource-loader.ts | 3 +++ .../extensions/gsd/migrate-external.ts | 26 +++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/resource-loader.ts b/src/resource-loader.ts index 66f4ad617..d4c0158a9 100644 --- a/src/resource-loader.ts +++ b/src/resource-loader.ts @@ -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 diff --git a/src/resources/extensions/gsd/migrate-external.ts b/src/resources/extensions/gsd/migrate-external.ts index 31172e9ff..dd8ab252f 100644 --- a/src/resources/extensions/gsd/migrate-external.ts +++ b/src/resources/extensions/gsd/migrate-external.ts @@ -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 };