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 <N>` 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 <digits>` pattern, not `.gsd-backup`)

Fixes #2205

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-23 11:53:51 -04:00 committed by GitHub
parent d83000d05d
commit a3c7992a26
4 changed files with 232 additions and 6 deletions

View file

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

View file

@ -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"

View file

@ -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 <digits>` 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 `<project>/.gsd` symlink points to the external state directory.
*
* 1. mkdir -p the external dir
* 2. If `<project>/.gsd` doesn't exist create symlink
* 3. If `<project>/.gsd` is already the correct symlink no-op
* 4. If `<project>/.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 `<project>/.gsd` doesn't exist create symlink
* 4. If `<project>/.gsd` is already the correct symlink no-op
* 5. If `<project>/.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 });

View file

@ -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<void> {
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);
});