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:
parent
d83000d05d
commit
a3c7992a26
4 changed files with 232 additions and 6 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue