From a3c7992a26cfc365b739d94543df545a36f9817d Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 23 Mar 2026 11:53:51 -0400 Subject: [PATCH] fix: clean up macOS numbered .gsd collision variants (#2205) (#2210) macOS APFS silently renames `.gsd` to `.gsd 2`, `.gsd 3`, etc. when a directory already exists at the symlink target path. This causes GSD to lose its state directory, making tracked planning files appear deleted. - Add `cleanNumberedGsdVariants()` to detect and remove `.gsd ` entries - Call it early in `ensureGsdSymlink()` before any existence checks - Add `numbered_gsd_variant` doctor check that detects and auto-fixes them - Add 19-assertion test covering directories, symlinks, mixed scenarios, and selective removal (only `.gsd ` pattern, not `.gsd-backup`) Fixes #2205 Co-authored-by: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/doctor-checks.ts | 33 +++- src/resources/extensions/gsd/doctor-types.ts | 1 + src/resources/extensions/gsd/repo-identity.ts | 53 +++++- .../tests/symlink-numbered-variants.test.ts | 151 ++++++++++++++++++ 4 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/symlink-numbered-variants.test.ts diff --git a/src/resources/extensions/gsd/doctor-checks.ts b/src/resources/extensions/gsd/doctor-checks.ts index 64eb0a921..1b208c4a8 100644 --- a/src/resources/extensions/gsd/doctor-checks.ts +++ b/src/resources/extensions/gsd/doctor-checks.ts @@ -2,7 +2,7 @@ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, rmSync, import { basename, dirname, join, sep } from "node:path"; import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js"; -import { readRepoMeta, externalProjectsRoot } from "./repo-identity.js"; +import { readRepoMeta, externalProjectsRoot, cleanNumberedGsdVariants } from "./repo-identity.js"; import { loadFile, parseRoadmap } from "./files.js"; import { resolveMilestoneFile, milestonesDir, gsdRoot, resolveGsdRootFile, relGsdRootFile } from "./paths.js"; import { deriveState, isMilestoneComplete } from "./state.js"; @@ -776,6 +776,37 @@ export async function checkRuntimeHealth( // Non-fatal — external state check failed } + // ── Numbered .gsd collision variants (#2205) ─────────────────────────── + // macOS APFS can create ".gsd 2", ".gsd 3" etc. when a directory blocks + // symlink creation. These must be removed so the canonical .gsd is used. + try { + const variantPattern = /^\.gsd \d+$/; + const entries = readdirSync(basePath); + const variants = entries.filter(e => variantPattern.test(e)); + if (variants.length > 0) { + for (const v of variants) { + issues.push({ + severity: "warning", + code: "numbered_gsd_variant", + scope: "project", + unitId: "project", + message: `Found macOS collision variant "${v}" — this can cause GSD state to appear deleted.`, + file: v, + fixable: true, + }); + } + + if (shouldFix("numbered_gsd_variant")) { + const removed = cleanNumberedGsdVariants(basePath); + for (const name of removed) { + fixesApplied.push(`removed numbered .gsd variant: ${name}`); + } + } + } + } catch { + // Non-fatal — variant check failed + } + // ── Metrics ledger integrity ─────────────────────────────────────────── try { const metricsPath = join(root, "metrics.json"); diff --git a/src/resources/extensions/gsd/doctor-types.ts b/src/resources/extensions/gsd/doctor-types.ts index 29bce4f7b..96cab2ff1 100644 --- a/src/resources/extensions/gsd/doctor-types.ts +++ b/src/resources/extensions/gsd/doctor-types.ts @@ -33,6 +33,7 @@ export type DoctorIssueCode = | "unresolvable_dependency" | "failed_migration" | "broken_symlink" + | "numbered_gsd_variant" // Environment health checks (#1221) | "env_node_version" | "env_dependencies" diff --git a/src/resources/extensions/gsd/repo-identity.ts b/src/resources/extensions/gsd/repo-identity.ts index d3133c3d6..f3e350801 100644 --- a/src/resources/extensions/gsd/repo-identity.ts +++ b/src/resources/extensions/gsd/repo-identity.ts @@ -8,7 +8,7 @@ import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; -import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; @@ -271,15 +271,54 @@ export function externalProjectsRoot(): string { return join(base, "projects"); } +// ─── Numbered Variant Cleanup ──────────────────────────────────────────────── + +/** + * macOS collision pattern: `.gsd 2`, `.gsd 3`, `.gsd 4`, etc. + * + * When `symlinkSync` (or Finder) tries to create `.gsd` but a real directory + * already exists at that path, macOS APFS silently renames the new entry to + * `.gsd 2`, then `.gsd 3`, and so on. These numbered variants confuse GSD + * because the canonical `.gsd` path no longer resolves to the external state + * directory, making tracked planning files appear deleted. + * + * This helper scans the project root for entries matching `.gsd ` and + * removes them. It is called early in `ensureGsdSymlink()` so that the + * canonical `.gsd` path is always the one in use. + */ +const GSD_NUMBERED_VARIANT_RE = /^\.gsd \d+$/; + +export function cleanNumberedGsdVariants(projectPath: string): string[] { + const removed: string[] = []; + try { + const entries = readdirSync(projectPath); + for (const entry of entries) { + if (GSD_NUMBERED_VARIANT_RE.test(entry)) { + const fullPath = join(projectPath, entry); + try { + rmSync(fullPath, { recursive: true, force: true }); + removed.push(entry); + } catch { + // Best-effort: if removal fails (e.g. permissions), continue with next + } + } + } + } catch { + // Non-fatal: readdir failure should not block symlink creation + } + return removed; +} + // ─── Symlink Management ───────────────────────────────────────────────────── /** * Ensure the `/.gsd` symlink points to the external state directory. * - * 1. mkdir -p the external dir - * 2. If `/.gsd` doesn't exist → create symlink - * 3. If `/.gsd` is already the correct symlink → no-op - * 4. If `/.gsd` is a real directory → return as-is (migration handles later) + * 1. Clean up any macOS numbered collision variants (`.gsd 2`, `.gsd 3`, etc.) + * 2. mkdir -p the external dir + * 3. If `/.gsd` doesn't exist → create symlink + * 4. If `/.gsd` is already the correct symlink → no-op + * 5. If `/.gsd` is a real directory → return as-is (migration handles later) * * Returns the resolved external path. */ @@ -297,6 +336,10 @@ export function ensureGsdSymlink(projectPath: string): string { return localGsd; } + // Clean up macOS numbered collision variants (.gsd 2, .gsd 3, etc.) before + // any existence checks — otherwise they accumulate and confuse state (#2205). + cleanNumberedGsdVariants(projectPath); + // Ensure external directory exists mkdirSync(externalPath, { recursive: true }); diff --git a/src/resources/extensions/gsd/tests/symlink-numbered-variants.test.ts b/src/resources/extensions/gsd/tests/symlink-numbered-variants.test.ts new file mode 100644 index 000000000..ed14dfb47 --- /dev/null +++ b/src/resources/extensions/gsd/tests/symlink-numbered-variants.test.ts @@ -0,0 +1,151 @@ +/** + * Tests for macOS numbered symlink variant cleanup (#2205). + * + * macOS can rename `.gsd` to `.gsd 2`, `.gsd 3`, etc. when a directory + * already exists at the target path. ensureGsdSymlink() must detect and + * remove these numbered variants so the real `.gsd` symlink is always + * the one in use. + */ + +import { + mkdtempSync, + rmSync, + writeFileSync, + existsSync, + lstatSync, + realpathSync, + mkdirSync, + symlinkSync, + readlinkSync, +} from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; + +import { ensureGsdSymlink, externalGsdRoot } from "../repo-identity.ts"; +import { createTestContext } from "./test-helpers.ts"; + +const { assertEq, assertTrue, report } = createTestContext(); + +function run(command: string, cwd: string): string { + return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim(); +} + +async function main(): Promise { + const base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-symlink-variants-"))); + const stateDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-state-variants-"))); + + try { + process.env.GSD_STATE_DIR = stateDir; + + // Set up a minimal git repo + run("git init -b main", base); + run('git config user.name "Pi Test"', base); + run('git config user.email "pi@example.com"', base); + run('git remote add origin git@github.com:example/repo.git', base); + writeFileSync(join(base, "README.md"), "# Test Repo\n", "utf-8"); + run("git add README.md", base); + run('git commit -m "chore: init"', base); + + const externalPath = externalGsdRoot(base); + + // ── Test: numbered variant directories are cleaned up ────────────── + console.log("\n=== ensureGsdSymlink removes numbered .gsd variants (#2205) ==="); + { + // Simulate macOS creating numbered variants: ".gsd 2", ".gsd 3" + mkdirSync(join(base, ".gsd 2"), { recursive: true }); + mkdirSync(join(base, ".gsd 3"), { recursive: true }); + mkdirSync(join(base, ".gsd 4"), { recursive: true }); + + const result = ensureGsdSymlink(base); + assertEq(result, externalPath, "ensureGsdSymlink returns external path"); + assertTrue(existsSync(join(base, ".gsd")), ".gsd exists after ensureGsdSymlink"); + assertTrue(lstatSync(join(base, ".gsd")).isSymbolicLink(), ".gsd is a symlink"); + + // The numbered variants must have been removed + assertTrue(!existsSync(join(base, ".gsd 2")), '".gsd 2" directory was cleaned up'); + assertTrue(!existsSync(join(base, ".gsd 3")), '".gsd 3" directory was cleaned up'); + assertTrue(!existsSync(join(base, ".gsd 4")), '".gsd 4" directory was cleaned up'); + } + + // ── Test: numbered variant symlinks are cleaned up ───────────────── + console.log("\n=== ensureGsdSymlink removes numbered symlink variants ==="); + { + // Clean slate + rmSync(join(base, ".gsd"), { recursive: true, force: true }); + + // Simulate: ".gsd 2" is a symlink to the correct target (the real .gsd) + // and ".gsd" doesn't exist — this is the actual macOS scenario + const staleTarget = join(stateDir, "projects", "stale-target"); + mkdirSync(staleTarget, { recursive: true }); + symlinkSync(externalPath, join(base, ".gsd 2"), "junction"); + symlinkSync(staleTarget, join(base, ".gsd 3"), "junction"); + + const result = ensureGsdSymlink(base); + assertEq(result, externalPath, "ensureGsdSymlink returns external path when variants exist"); + assertTrue(existsSync(join(base, ".gsd")), ".gsd exists"); + assertTrue(lstatSync(join(base, ".gsd")).isSymbolicLink(), ".gsd is a symlink"); + + assertTrue(!existsSync(join(base, ".gsd 2")), '".gsd 2" symlink variant was cleaned up'); + assertTrue(!existsSync(join(base, ".gsd 3")), '".gsd 3" symlink variant was cleaned up'); + } + + // ── Test: real .gsd directory blocks symlink, but variants still cleaned ── + console.log("\n=== ensureGsdSymlink cleans variants even when .gsd is a real directory ==="); + { + // Clean slate + rmSync(join(base, ".gsd"), { recursive: true, force: true }); + + // .gsd is a real directory (git-tracked) and numbered variants exist + mkdirSync(join(base, ".gsd", "milestones"), { recursive: true }); + writeFileSync(join(base, ".gsd", "milestones", "M001.md"), "# M001\n", "utf-8"); + mkdirSync(join(base, ".gsd 2"), { recursive: true }); + mkdirSync(join(base, ".gsd 3"), { recursive: true }); + + const result = ensureGsdSymlink(base); + // When .gsd is a real directory, ensureGsdSymlink preserves it + assertEq(result, join(base, ".gsd"), "real .gsd directory preserved"); + assertTrue(lstatSync(join(base, ".gsd")).isDirectory(), ".gsd remains a directory"); + + // But the numbered variants should still be cleaned up + assertTrue(!existsSync(join(base, ".gsd 2")), '".gsd 2" cleaned even when .gsd is a directory'); + assertTrue(!existsSync(join(base, ".gsd 3")), '".gsd 3" cleaned even when .gsd is a directory'); + } + + // ── Test: only numeric-suffixed variants are removed ─────────────── + console.log("\n=== ensureGsdSymlink only removes .gsd + space + digit variants ==="); + { + rmSync(join(base, ".gsd"), { recursive: true, force: true }); + + // These should NOT be touched + mkdirSync(join(base, ".gsd-backup"), { recursive: true }); + mkdirSync(join(base, ".gsd_old"), { recursive: true }); + + // These SHOULD be removed (macOS collision pattern) + mkdirSync(join(base, ".gsd 2"), { recursive: true }); + mkdirSync(join(base, ".gsd 10"), { recursive: true }); + + ensureGsdSymlink(base); + + assertTrue(existsSync(join(base, ".gsd-backup")), ".gsd-backup is NOT removed"); + assertTrue(existsSync(join(base, ".gsd_old")), ".gsd_old is NOT removed"); + assertTrue(!existsSync(join(base, ".gsd 2")), '".gsd 2" removed'); + assertTrue(!existsSync(join(base, ".gsd 10")), '".gsd 10" removed'); + + // Cleanup non-variant dirs + rmSync(join(base, ".gsd-backup"), { recursive: true, force: true }); + rmSync(join(base, ".gsd_old"), { recursive: true, force: true }); + } + + } finally { + delete process.env.GSD_STATE_DIR; + try { rmSync(base, { recursive: true, force: true }); } catch { /* ignore */ } + try { rmSync(stateDir, { recursive: true, force: true }); } catch { /* ignore */ } + report(); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +});