fix(gsd): prevent ensureGsdSymlink from creating subdirectory .gsd when git-root .gsd exists

When running GSD from a subdirectory (e.g. `cd src/ && gsd`),
ensureGsdSymlink would create a new `.gsd` symlink in the subdirectory
even though a valid `.gsd` already exists at the git root. On macOS
APFS this triggers the `.gsd 2` collision variant problem from #2205.

Add an early guard that detects when projectPath is a plain subdirectory
(not a worktree) of a git repo that already has `.gsd` at its root, and
returns the existing root .gsd target instead of creating a duplicate.

Fixes #2380

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-24 13:46:31 -04:00 committed by Lex Christopherson
parent a53d021864
commit a3250c4103
2 changed files with 60 additions and 0 deletions

View file

@ -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);

View file

@ -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}"`);