diff --git a/src/resources/extensions/gsd/repo-identity.ts b/src/resources/extensions/gsd/repo-identity.ts index 272da7de6..39204ab91 100644 --- a/src/resources/extensions/gsd/repo-identity.ts +++ b/src/resources/extensions/gsd/repo-identity.ts @@ -378,6 +378,34 @@ export function ensureGsdSymlink(projectPath: string): string { return localGsd; } + // Guard: If projectPath is a plain subdirectory (not a worktree) of a git + // repo that already has a .gsd at the git root, do not create a duplicate + // symlink in the subdirectory — that causes `.gsd 2` collision variants on + // macOS (#2380). Worktrees are excluded because they legitimately need their + // own .gsd symlink pointing at the shared external state dir. + if (!inWorktree) { + try { + const gitRoot = resolveGitRoot(projectPath); + const normalizedProject = canonicalizeExistingPath(projectPath); + const normalizedRoot = canonicalizeExistingPath(gitRoot); + if (normalizedProject !== normalizedRoot) { + const rootGsd = join(gitRoot, ".gsd"); + if (existsSync(rootGsd)) { + try { + const rootStat = lstatSync(rootGsd); + if (rootStat.isSymbolicLink() || rootStat.isDirectory()) { + return rootStat.isSymbolicLink() ? realpathSync(rootGsd) : rootGsd; + } + } catch { + // Fall through to normal logic if we can't stat root .gsd + } + } + } + } catch { + // If git root detection fails, fall through to normal logic + } + } + // Clean up macOS numbered collision variants (.gsd 2, .gsd 3, etc.) before // any existence checks — otherwise they accumulate and confuse state (#2205). cleanNumberedGsdVariants(projectPath); diff --git a/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts b/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts index b6e231cf5..e576188db 100644 --- a/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +++ b/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts @@ -184,6 +184,38 @@ test('subdirectory of parent repo gets unique identity after git init (#1639)', rmSync(parentRepo, { recursive: true, force: true }); }); +test('ensureGsdSymlink from subdirectory does not create .gsd in subdir when git-root .gsd exists (#2380)', () => { + const repo = realpathSync(mkdtempSync(join(tmpdir(), "gsd-subdir-symlink-"))); + run("git init -b main", repo); + run('git config user.name "Pi Test"', repo); + run('git config user.email "pi@example.com"', repo); + run('git remote add origin git@github.com:example/subdir-test.git', repo); + writeFileSync(join(repo, "README.md"), "# Subdir Test\n", "utf-8"); + run("git add README.md", repo); + run('git commit -m "init"', repo); + + // Set up .gsd symlink at the git root (normal project initialisation) + ensureGsdSymlink(repo); + assert.ok(existsSync(join(repo, ".gsd")), "root .gsd exists after ensureGsdSymlink"); + assert.ok(lstatSync(join(repo, ".gsd")).isSymbolicLink(), "root .gsd is a symlink"); + + // Create a subdirectory and call ensureGsdSymlink from there + const subdir = join(repo, "src", "lib"); + mkdirSync(subdir, { recursive: true }); + ensureGsdSymlink(subdir); + + // ensureGsdSymlink should NOT create a .gsd in the subdirectory + // because the git root already has a valid .gsd symlink. + assert.ok(!existsSync(join(subdir, ".gsd")), "no .gsd created in subdirectory when git-root .gsd exists (#2380)"); + assert.ok(!existsSync(join(repo, "src", ".gsd")), "no .gsd created in intermediate directory"); + + // The root .gsd should still be intact + assert.ok(existsSync(join(repo, ".gsd")), "root .gsd still exists"); + assert.ok(lstatSync(join(repo, ".gsd")).isSymbolicLink(), "root .gsd is still a symlink"); + + rmSync(repo, { recursive: true, force: true }); +}); + test('validateProjectId rejects invalid values', () => { for (const invalid of ["has spaces", "path/traversal", "dot..dot", "back\\slash"]) { assert.ok(!validateProjectId(invalid), `validateProjectId rejects invalid value: "${invalid}"`);