diff --git a/src/resources/extensions/gsd/auto-worktree-sync.ts b/src/resources/extensions/gsd/auto-worktree-sync.ts index 0f4dd6158..643576098 100644 --- a/src/resources/extensions/gsd/auto-worktree-sync.ts +++ b/src/resources/extensions/gsd/auto-worktree-sync.ts @@ -170,6 +170,20 @@ export function escapeStaleWorktree(base: string): string { // base is inside .gsd/worktrees/ — extract the project root const projectRoot = base.slice(0, idx); + + // Guard: If the candidate project root's .gsd IS the user-level ~/.gsd, + // the string-slice heuristic matched the wrong /.gsd/ boundary. This happens + // when .gsd is a symlink into ~/.gsd/projects/ and process.cwd() + // resolved through the symlink. Returning ~ would be catastrophic (#1676). + const candidateGsd = join(projectRoot, ".gsd").replaceAll("\\", "/"); + const gsdHomePath = gsdHome.replaceAll("\\", "/"); + if (candidateGsd === gsdHomePath || candidateGsd.startsWith(gsdHomePath + "/")) { + // Don't chdir to home — return base unchanged. + // resolveProjectRoot() in worktree.ts has the full git-file-based recovery + // and will be called by the caller (startAuto → projectRoot()). + return base; + } + try { process.chdir(projectRoot); } catch { diff --git a/src/resources/extensions/gsd/repo-identity.ts b/src/resources/extensions/gsd/repo-identity.ts index ccfd4f3fb..3a5416198 100644 --- a/src/resources/extensions/gsd/repo-identity.ts +++ b/src/resources/extensions/gsd/repo-identity.ts @@ -243,6 +243,15 @@ export function ensureGsdSymlink(projectPath: string): string { const localGsd = join(projectPath, ".gsd"); const inWorktree = isInsideWorktree(projectPath); + // Guard: Never create a symlink at ~/.gsd — that's the user-level GSD home, + // not a project .gsd. This can happen if resolveProjectRoot() or + // escapeStaleWorktree() returned ~ as the project root (#1676). + const localGsdNormalized = localGsd.replaceAll("\\", "/"); + const gsdHomePath = gsdHome.replaceAll("\\", "/"); + if (localGsdNormalized === gsdHomePath) { + return localGsd; + } + // Ensure external directory exists mkdirSync(externalPath, { recursive: true }); diff --git a/src/resources/extensions/gsd/tests/worktree.test.ts b/src/resources/extensions/gsd/tests/worktree.test.ts index 40842f8a3..d95a00c94 100644 --- a/src/resources/extensions/gsd/tests/worktree.test.ts +++ b/src/resources/extensions/gsd/tests/worktree.test.ts @@ -204,6 +204,9 @@ async function main(): Promise { "/real/project", "uses GSD_PROJECT_ROOT when set", ); + delete process.env.GSD_PROJECT_ROOT; + + // Without GSD_PROJECT_ROOT, direct layout still works (no ~/.gsd collision) assertEq( resolveProjectRoot("/some/repo"), "/some/repo", diff --git a/src/resources/extensions/gsd/worktree.ts b/src/resources/extensions/gsd/worktree.ts index 573b865bf..b38cabacd 100644 --- a/src/resources/extensions/gsd/worktree.ts +++ b/src/resources/extensions/gsd/worktree.ts @@ -123,16 +123,15 @@ export function detectWorktreeName(basePath: string): string | null { * operate against the real project root, not a worktree subdirectory. */ export function resolveProjectRoot(basePath: string): string { - const normalizedPath = basePath.replaceAll("\\", "/"); - const seg = findWorktreeSegment(normalizedPath); - if (!seg) return basePath; - // Layer 1: If the coordinator passed the real project root, use it. - // Only apply this override when basePath actually looks like a worktree path. if (process.env.GSD_PROJECT_ROOT) { return process.env.GSD_PROJECT_ROOT; } + const normalizedPath = basePath.replaceAll("\\", "/"); + const seg = findWorktreeSegment(normalizedPath); + if (!seg) return basePath; + // Candidate root via the string-slice heuristic const sepChar = basePath.includes("\\") ? "\\" : "/"; const gsdMarker = `${sepChar}.gsd${sepChar}`; @@ -173,7 +172,7 @@ function resolveProjectRootFromGitFile(worktreePath: string): string | null { try { // Walk up from the worktree path to find the .git file let dir = worktreePath; - while (true) { + for (let i = 0; i < 10; i++) { const gitPath = join(dir, ".git"); if (existsSync(gitPath)) { const content = readFileSync(gitPath, "utf8").trim(); diff --git a/tests/repro-worktree-bug/verify-fix.mjs b/tests/repro-worktree-bug/verify-fix.mjs index e40e3d4db..b437dc9a9 100644 --- a/tests/repro-worktree-bug/verify-fix.mjs +++ b/tests/repro-worktree-bug/verify-fix.mjs @@ -31,7 +31,7 @@ function findWorktreeSegment(normalizedPath) { function resolveProjectRootFromGitFile(worktreePath) { try { let dir = worktreePath; - while (true) { + for (let i = 0; i < 10; i++) { const gitPath = join(dir, ".git"); if (existsSync(gitPath)) { const content = readFileSync(gitPath, "utf8").trim(); @@ -71,15 +71,15 @@ function normalizePathForCompare(path) { } function resolveProjectRoot(basePath) { - const normalizedPath = basePath.replaceAll("\\", "/"); - const seg = findWorktreeSegment(normalizedPath); - if (!seg) return basePath; - // Layer 1: If the coordinator passed the real project root, use it. if (process.env.GSD_PROJECT_ROOT) { return process.env.GSD_PROJECT_ROOT; } + const normalizedPath = basePath.replaceAll("\\", "/"); + const seg = findWorktreeSegment(normalizedPath); + if (!seg) return basePath; + const sepChar = basePath.includes("\\") ? "\\" : "/"; const gsdMarker = `${sepChar}.gsd${sepChar}`; const gsdIdx = basePath.indexOf(gsdMarker); diff --git a/tests/repro-worktree-bug/verify-integration.mjs b/tests/repro-worktree-bug/verify-integration.mjs index 12c3c6f84..adbfc7ce9 100644 --- a/tests/repro-worktree-bug/verify-integration.mjs +++ b/tests/repro-worktree-bug/verify-integration.mjs @@ -41,7 +41,7 @@ function findWorktreeSegment(normalizedPath) { function resolveProjectRootFromGitFile(worktreePath) { try { let dir = worktreePath; - while (true) { + for (let i = 0; i < 10; i++) { const gitPath = join(dir, ".git"); if (existsSync(gitPath)) { const content = readFileSync(gitPath, "utf8").trim(); @@ -81,14 +81,15 @@ function normalizePathForCompare(path) { } function resolveProjectRoot(basePath) { - const normalizedPath = basePath.replaceAll("\\", "/"); - const seg = findWorktreeSegment(normalizedPath); - if (!seg) return basePath; - + // Layer 1: If the coordinator passed the real project root, use it. if (process.env.GSD_PROJECT_ROOT) { return process.env.GSD_PROJECT_ROOT; } + const normalizedPath = basePath.replaceAll("\\", "/"); + const seg = findWorktreeSegment(normalizedPath); + if (!seg) return basePath; + const sepChar = basePath.includes("\\") ? "\\" : "/"; const gsdMarker = `${sepChar}.gsd${sepChar}`; const gsdIdx = basePath.indexOf(gsdMarker);