From 98530fad114e2fa167fd727abd78b5dc4a5daab9 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Sat, 21 Mar 2026 09:32:38 -0500 Subject: [PATCH] Fix worktree root resolution in deep symlink paths (#1680) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: prevent parallel worktree path resolution from escaping to home directory When .gsd is a symlink into ~/.gsd/projects/ (the default layout), parallel workers resolve their cwd through the symlink. findWorktreeSegment() then matches /.gsd/ at the user-level ~/.gsd boundary instead of the project .gsd, causing resolveProjectRoot() to return ~ as the project root. This corrupts ~/.gsd, creates ~/.git, and crashes pi. Fix (3 layers): 1. Pass GSD_PROJECT_ROOT env var from coordinator to workers — the coordinator already knows the real basePath unambiguously. 2. In resolveProjectRoot(), detect when the candidate root's .gsd matches the user-level ~/.gsd and fall back to reading the worktree's .git file (gitdir: pointer) to recover the real project root. 3. Existing validateDirectory() already blocks ~ — but the bug bypassed it because the worktree path itself was 'safe'. Also fixes the existing test that asserted the buggy behavior as correct. Closes gsd-build/gsd-2#1676 * fix worktree root resolution for deep symlink paths --------- Co-authored-by: Vojtěch Šplíchal --- .../extensions/gsd/parallel-orchestrator.ts | 5 + .../extensions/gsd/tests/worktree.test.ts | 58 +++- src/resources/extensions/gsd/worktree.ts | 108 ++++++- tests/repro-worktree-bug/Dockerfile | 9 + tests/repro-worktree-bug/repro.mjs | 177 ++++++++++++ tests/repro-worktree-bug/verify-fix.mjs | 265 ++++++++++++++++++ .../repro-worktree-bug/verify-integration.mjs | 264 +++++++++++++++++ 7 files changed, 875 insertions(+), 11 deletions(-) create mode 100644 tests/repro-worktree-bug/Dockerfile create mode 100644 tests/repro-worktree-bug/repro.mjs create mode 100644 tests/repro-worktree-bug/verify-fix.mjs create mode 100644 tests/repro-worktree-bug/verify-integration.mjs diff --git a/src/resources/extensions/gsd/parallel-orchestrator.ts b/src/resources/extensions/gsd/parallel-orchestrator.ts index 66adbdf88..33309eab8 100644 --- a/src/resources/extensions/gsd/parallel-orchestrator.ts +++ b/src/resources/extensions/gsd/parallel-orchestrator.ts @@ -431,6 +431,11 @@ export function spawnWorker( env: { ...process.env, GSD_MILESTONE_LOCK: milestoneId, + // Pass the real project root so workers don't need to re-derive it. + // Without this, process.cwd() resolves symlinks and the worktree + // path heuristic can match the user-level ~/.gsd instead of the + // project .gsd, causing writes to ~ and corrupting user config. + GSD_PROJECT_ROOT: basePath, // Prevent workers from spawning their own parallel sessions GSD_PARALLEL_WORKER: "1", }, diff --git a/src/resources/extensions/gsd/tests/worktree.test.ts b/src/resources/extensions/gsd/tests/worktree.test.ts index cf3dae359..40842f8a3 100644 --- a/src/resources/extensions/gsd/tests/worktree.test.ts +++ b/src/resources/extensions/gsd/tests/worktree.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, symlinkSync, realpathSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { execSync } from "node:child_process"; @@ -191,11 +191,27 @@ async function main(): Promise { // ── resolveProjectRoot: symlink-resolved paths ────────────────────────── console.log("\n=== resolveProjectRoot (symlink-resolved paths) ==="); + + // BUG FIX: symlink-resolved paths that land inside ~/.gsd should NOT + // resolve to the home directory. When the .git file fallback can't find + // the real project root (no git worktree metadata in these synthetic paths), + // resolveProjectRoot returns the input unchanged rather than returning ~. + + // With GSD_PROJECT_ROOT env var set (layer 1 — coordinator passes it) + process.env.GSD_PROJECT_ROOT = "/real/project"; assertEq( resolveProjectRoot("/Users/fran/.gsd/projects/89e1c9ad49bf/worktrees/M001"), - "/Users/fran", - "resolves to user home for symlink-resolved path", + "/real/project", + "uses GSD_PROJECT_ROOT when set", ); + assertEq( + resolveProjectRoot("/some/repo"), + "/some/repo", + "ignores GSD_PROJECT_ROOT override for non-worktree paths", + ); + delete process.env.GSD_PROJECT_ROOT; + + // Without GSD_PROJECT_ROOT, direct layout still works (no ~/.gsd collision) assertEq( resolveProjectRoot("/foo/.gsd/worktrees/M001"), "/foo", @@ -206,12 +222,44 @@ async function main(): Promise { "/some/repo", "returns unchanged for non-worktree path", ); + + // Without GSD_PROJECT_ROOT, direct layout with nested subdirs assertEq( - resolveProjectRoot("/data/.gsd/projects/deadbeef/worktrees/M003/nested"), + resolveProjectRoot("/data/.gsd/worktrees/M003/nested"), "/data", - "resolves correctly with nested subdirs after worktree name", + "resolves correctly with nested subdirs after worktree name (direct layout)", ); + // Real symlink + git worktree scenario, with deep nested path from cwd + { + const fakeHome = mkdtempSync(join(tmpdir(), "gsd-home-")); + const project = mkdtempSync(join(tmpdir(), "gsd-proj-")); + const storage = join(fakeHome, ".gsd", "projects", "abc123def456"); + mkdirSync(storage, { recursive: true }); + symlinkSync(storage, join(project, ".gsd")); + + run("git init -b main", project); + run("git config user.name 'Pi Test'", project); + run("git config user.email 'pi@example.com'", project); + writeFileSync(join(project, "README.md"), "init\n"); + run("git add -A && git commit -m init", project); + run("git worktree add .gsd/worktrees/M001 -b worktree/M001", project); + + const deep = join(project, ".gsd", "worktrees", "M001", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"); + mkdirSync(deep, { recursive: true }); + + process.env.GSD_HOME = join(fakeHome, ".gsd"); + assertEq( + resolveProjectRoot(realpathSync(deep)), + realpathSync(project), + "resolves to real project root from deep symlink-resolved worktree path", + ); + delete process.env.GSD_HOME; + + rmSync(project, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + } + rmSync(base, { recursive: true, force: true }); report(); } diff --git a/src/resources/extensions/gsd/worktree.ts b/src/resources/extensions/gsd/worktree.ts index 0027c5ca4..573b865bf 100644 --- a/src/resources/extensions/gsd/worktree.ts +++ b/src/resources/extensions/gsd/worktree.ts @@ -12,8 +12,9 @@ * SLICE_BRANCH_RE) remain for backwards compatibility with legacy branches. */ -import { existsSync, readFileSync, utimesSync } from "node:fs"; +import { existsSync, readFileSync, realpathSync, utimesSync } from "node:fs"; import { join, resolve, sep } from "node:path"; +import { homedir } from "node:os"; import { GitServiceImpl, writeIntegrationBranch, type TaskCommitContext } from "./git-service.js"; import { loadEffectiveGSDPreferences } from "./preferences.js"; @@ -108,6 +109,16 @@ export function detectWorktreeName(basePath: string): string | null { * If the path contains a worktrees segment, returns the portion before * `/.gsd/`. Otherwise returns the input unchanged. * + * When the worker was spawned with GSD_PROJECT_ROOT set, use that directly — + * the coordinator already knows the real project root unambiguously. + * + * When `/.gsd/` in the resolved path is actually the user-level `~/.gsd/` + * (common when `.gsd` is a symlink into `~/.gsd/projects/`), the + * string-slice heuristic would return `~` — which is catastrophically wrong. + * In that case, fall back to reading the worktree's `.git` file, which + * contains a `gitdir:` pointer to the real project's `.git/worktrees/`, + * giving the real project root unambiguously. + * * Use this in commands that call `process.cwd()` to ensure they always * operate against the real project root, not a worktree subdirectory. */ @@ -115,12 +126,97 @@ export function resolveProjectRoot(basePath: string): string { const normalizedPath = basePath.replaceAll("\\", "/"); const seg = findWorktreeSegment(normalizedPath); if (!seg) return basePath; - // Return the original path up to the /.gsd/ boundary - const sep = basePath.includes("\\") ? "\\" : "/"; - const gsdMarker = `${sep}.gsd${sep}`; + + // Layer 1: If the coordinator passed the real project root, use it. + // Only apply this override when basePath actually looks like a worktree path. + if (process.env.GSD_PROJECT_ROOT) { + return process.env.GSD_PROJECT_ROOT; + } + + // Candidate root via the string-slice heuristic + const sepChar = basePath.includes("\\") ? "\\" : "/"; + const gsdMarker = `${sepChar}.gsd${sepChar}`; const gsdIdx = basePath.indexOf(gsdMarker); - if (gsdIdx !== -1) return basePath.slice(0, gsdIdx); - return basePath.slice(0, seg.gsdIdx); + const candidate = gsdIdx !== -1 + ? basePath.slice(0, gsdIdx) + : basePath.slice(0, seg.gsdIdx); + + // Layer 2: Guard against resolving to the user's home directory. + // When .gsd is a symlink into ~/.gsd/projects/, the resolved path + // contains /.gsd/ at the user-level boundary. Slicing there yields ~ — wrong. + const gsdHome = normalizePathForCompare(process.env.GSD_HOME || join(homedir(), ".gsd")); + const candidateGsdPath = normalizePathForCompare(join(candidate, ".gsd")); + + if (candidateGsdPath === gsdHome || candidateGsdPath.startsWith(gsdHome + "/")) { + // The candidate is the home directory (or within it in a way that .gsd + // maps to the user-level GSD dir). Try to recover the real project root + // from the worktree's .git file. + const realRoot = resolveProjectRootFromGitFile(basePath); + if (realRoot) return realRoot; + // If git file resolution failed, return basePath unchanged rather than ~ + return basePath; + } + + return candidate; +} + +/** + * Recover the real project root from a worktree's .git file. + * + * Each git worktree has a `.git` file (not directory) containing: + * gitdir: /real/project/.git/worktrees/ + * + * Walking up from that gitdir gives us `/real/project/.git`, and its + * parent is the real project root. + */ +function resolveProjectRootFromGitFile(worktreePath: string): string | null { + try { + // Walk up from the worktree path to find the .git file + let dir = worktreePath; + while (true) { + const gitPath = join(dir, ".git"); + if (existsSync(gitPath)) { + const content = readFileSync(gitPath, "utf8").trim(); + if (content.startsWith("gitdir: ")) { + // gitdir points to: /.git/worktrees/ + const gitDir = resolve(dir, content.slice(8)); + // Walk up: .git/worktrees/ → .git/worktrees → .git → project root + const dotGitDir = resolve(gitDir, "..", ".."); + // Verify this looks like a .git directory + if (dotGitDir.endsWith(".git") || dotGitDir.endsWith(".git/") || dotGitDir.endsWith(".git\\")) { + return resolve(dotGitDir, ".."); + } + // Alternative: the commondir file inside the worktree gitdir + // points to the main .git directory + const commonDirPath = join(gitDir, "commondir"); + if (existsSync(commonDirPath)) { + const commonDir = readFileSync(commonDirPath, "utf8").trim(); + const resolvedCommonDir = resolve(gitDir, commonDir); + return resolve(resolvedCommonDir, ".."); + } + } + break; + } + const parent = resolve(dir, ".."); + if (parent === dir) break; + dir = parent; + } + } catch { + // Non-fatal — caller will use fallback + } + return null; +} + +function normalizePathForCompare(path: string): string { + let normalized: string; + try { + normalized = realpathSync(path); + } catch { + normalized = resolve(path); + } + const slashed = normalized.replaceAll("\\", "/"); + const trimmed = slashed.replace(/\/+$/, ""); + return trimmed || "/"; } /** diff --git a/tests/repro-worktree-bug/Dockerfile b/tests/repro-worktree-bug/Dockerfile new file mode 100644 index 000000000..cd839f98f --- /dev/null +++ b/tests/repro-worktree-bug/Dockerfile @@ -0,0 +1,9 @@ +FROM node:24-bookworm + +WORKDIR /test + +COPY repro.mjs /test/repro.mjs +COPY verify-fix.mjs /test/verify-fix.mjs +COPY verify-integration.mjs /test/verify-integration.mjs + +CMD ["node", "/test/verify-integration.mjs"] diff --git a/tests/repro-worktree-bug/repro.mjs b/tests/repro-worktree-bug/repro.mjs new file mode 100644 index 000000000..e8266d518 --- /dev/null +++ b/tests/repro-worktree-bug/repro.mjs @@ -0,0 +1,177 @@ +/** + * Reproduction: Parallel Worktree Path Resolution Escapes to Home Directory + * + * This script reproduces the bug where resolveProjectRoot() returns the + * user's home directory (~) when the project .gsd is a symlink into + * ~/.gsd/projects/ and worktree isolation is enabled. + * + * Layout mimics pi's default: + * /root/.gsd/projects// ← user-level GSD storage + * /tmp/myproject/.gsd → symlink to ↑ ← project's .gsd + * /tmp/myproject/.gsd/worktrees/M001/ ← worktree (logical path through symlink) + * + * When a worker spawns with cwd = /tmp/myproject/.gsd/worktrees/M001, + * process.cwd() resolves symlinks → /root/.gsd/projects//worktrees/M001. + * findWorktreeSegment() then matches /.gsd/ at the WRONG boundary (the + * user-level ~/.gsd), causing resolveProjectRoot() to return /root (home dir). + */ + +import { mkdirSync, symlinkSync, existsSync, realpathSync, mkdtempSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { join } from "node:path"; +import { homedir, tmpdir } from "node:os"; + +// ── Reproduce the exact functions from worktree.ts ────────────────────── + +function findWorktreeSegment(normalizedPath) { + // Direct layout: /.gsd/worktrees/ + const directMarker = "/.gsd/worktrees/"; + const idx = normalizedPath.indexOf(directMarker); + if (idx !== -1) { + return { gsdIdx: idx, afterWorktrees: idx + directMarker.length }; + } + // Symlink-resolved layout: /.gsd/projects//worktrees/ + const symlinkRe = /\/\.gsd\/projects\/[a-f0-9]+\/worktrees\//; + const match = normalizedPath.match(symlinkRe); + if (match && match.index !== undefined) { + return { gsdIdx: match.index, afterWorktrees: match.index + match[0].length }; + } + return null; +} + +function resolveProjectRoot(basePath) { + const normalizedPath = basePath.replaceAll("\\", "/"); + const seg = findWorktreeSegment(normalizedPath); + if (!seg) return basePath; + // Return the original path up to the /.gsd/ boundary + const sep = basePath.includes("\\") ? "\\" : "/"; + const gsdMarker = `${sep}.gsd${sep}`; + const gsdIdx = basePath.indexOf(gsdMarker); + if (gsdIdx !== -1) return basePath.slice(0, gsdIdx); + return basePath.slice(0, seg.gsdIdx); +} + +// ── Set up the filesystem layout ──────────────────────────────────────── + +const HASH = "abc123def456"; +const TEST_ROOT = mkdtempSync(join(tmpdir(), "gsd-repro-")); +const USER_GSD = process.env.GSD_HOME || join(TEST_ROOT, ".gsd"); +const USER_HOME = homedir(); +const PROJECT_GSD_STORAGE = `${USER_GSD}/projects/${HASH}`; +const PROJECT_DIR = mkdtempSync(join(tmpdir(), "myproject-")); +const PROJECT_GSD_LINK = `${PROJECT_DIR}/.gsd`; + +console.log("=== Setting up filesystem layout ===\n"); + +// 1. Create user-level GSD structure +mkdirSync(`${PROJECT_GSD_STORAGE}/worktrees/M001`, { recursive: true }); +mkdirSync(`${PROJECT_GSD_STORAGE}/milestones`, { recursive: true }); +console.log(`Created: ${PROJECT_GSD_STORAGE}/worktrees/M001`); + +// 2. Create project directory +mkdirSync(PROJECT_DIR, { recursive: true }); +console.log(`Created: ${PROJECT_DIR}`); + +// 3. Create symlink: project/.gsd → user-level storage +symlinkSync(PROJECT_GSD_STORAGE, PROJECT_GSD_LINK); +console.log(`Symlink: ${PROJECT_GSD_LINK} → ${PROJECT_GSD_STORAGE}`); + +// 4. Init git in project dir +execSync("git init -b main", { cwd: PROJECT_DIR, stdio: "pipe" }); +execSync('git config user.name "Test"', { cwd: PROJECT_DIR, stdio: "pipe" }); +execSync('git config user.email "test@test.com"', { cwd: PROJECT_DIR, stdio: "pipe" }); +execSync("git commit --allow-empty -m init", { cwd: PROJECT_DIR, stdio: "pipe" }); +console.log(`Git init: ${PROJECT_DIR}`); + +console.log("\n=== Path Resolution Tests ===\n"); + +// ── Test 1: Logical path (through symlink) ────────────────────────────── + +const logicalPath = `${PROJECT_DIR}/.gsd/worktrees/M001`; +console.log(`Test 1: Logical path (through symlink)`); +console.log(` Input: ${logicalPath}`); +console.log(` Expected: ${PROJECT_DIR}`); +const result1 = resolveProjectRoot(logicalPath); +console.log(` Got: ${result1}`); +console.log(` Status: ${result1 === PROJECT_DIR ? "✅ PASS" : "❌ FAIL — BUG NOT TRIGGERED (logical path)"}`); + +// ── Test 2: Resolved path (what process.cwd() returns) ────────────────── + +const resolvedPath = realpathSync(logicalPath); +console.log(`\nTest 2: Resolved path (what process.cwd() returns after chdir to worktree)`); +console.log(` Input: ${resolvedPath}`); +console.log(` Expected: ${PROJECT_DIR}`); +const result2 = resolveProjectRoot(resolvedPath); +console.log(` Got: ${result2}`); +const isBuggy = result2 !== PROJECT_DIR; +console.log(` Status: ${isBuggy ? "🐛 BUG REPRODUCED — resolves to wrong directory!" : "✅ PASS"}`); + +// ── Test 3: Simulate what actually happens in a worker ────────────────── + +console.log(`\nTest 3: Simulating worker process.cwd() resolution`); +process.chdir(logicalPath); +const workerCwd = process.cwd(); // This resolves symlinks! +console.log(` chdir to: ${logicalPath}`); +console.log(` cwd(): ${workerCwd}`); +console.log(` Expected project root: ${PROJECT_DIR}`); +const result3 = resolveProjectRoot(workerCwd); +console.log(` resolveProjectRoot(): ${result3}`); +const workerBuggy = result3 !== PROJECT_DIR; +console.log(` Status: ${workerBuggy ? "🐛 BUG REPRODUCED — worker would use wrong project root!" : "✅ PASS"}`); + +// ── Test 4: Show the cascade ──────────────────────────────────────────── + +if (workerBuggy) { + console.log(`\n=== Cascade Analysis ===\n`); + console.log(`The worker thinks project root is: ${result3}`); + console.log(`It would look for .gsd at: ${result3}/.gsd`); + console.log(`That path exists: ${existsSync(join(result3, ".gsd"))}`); + + if (existsSync(join(result3, ".gsd"))) { + const resolvedGsd = realpathSync(join(result3, ".gsd")); + console.log(`It resolves to: ${resolvedGsd}`); + console.log(`\nThis is the USER-LEVEL .gsd directory!`); + console.log(`The worker would:`); + console.log(` 1. Write session status to ~/.gsd/parallel/`); + console.log(` 2. Write orchestrator.json to ~/.gsd/`); + console.log(` 3. Potentially git init in ${result3} (the home directory)`); + console.log(` 4. Corrupt the user-level GSD configuration`); + } +} + +// ── Test 5: Verify findWorktreeSegment matches at the wrong /.gsd/ ────── + +console.log(`\n=== Root Cause Detail ===\n`); +const seg = findWorktreeSegment(resolvedPath); +if (seg) { + console.log(`findWorktreeSegment() matched:`); + console.log(` gsdIdx: ${seg.gsdIdx}`); + console.log(` afterWorktrees: ${seg.afterWorktrees}`); + console.log(` Path before /.gsd/: "${resolvedPath.slice(0, seg.gsdIdx)}"`); + console.log(` This is: ${resolvedPath.slice(0, seg.gsdIdx) === USER_HOME ? "THE HOME DIRECTORY (bug!)" : "some other directory"}`); + + // Show which regex matched + const directMarker = "/.gsd/worktrees/"; + const directIdx = resolvedPath.indexOf(directMarker); + if (directIdx !== -1) { + console.log(`\n Matched by: direct marker "/.gsd/worktrees/" at index ${directIdx}`); + console.log(` The /.gsd/ it found is at: "${resolvedPath.slice(0, directIdx + 5)}"`); + console.log(` This /.gsd/ is the USER-LEVEL ~/.gsd, not the project .gsd!`); + } else { + console.log(`\n Matched by: symlink regex`); + } +} + +// ── Summary ───────────────────────────────────────────────────────────── + +console.log(`\n${"=".repeat(60)}`); +if (workerBuggy) { + console.log(`\n🐛 BUG CONFIRMED: resolveProjectRoot() returns "${result3}"`); + console.log(` when it should return "${PROJECT_DIR}"`); + console.log(` because findWorktreeSegment() matches the /.gsd/ in the`); + console.log(` user-level ~/.gsd path, not the project-level .gsd symlink.`); + process.exit(1); +} else { + console.log(`\n✅ Bug not reproduced — may be fixed.`); + process.exit(0); +} diff --git a/tests/repro-worktree-bug/verify-fix.mjs b/tests/repro-worktree-bug/verify-fix.mjs new file mode 100644 index 000000000..e40e3d4db --- /dev/null +++ b/tests/repro-worktree-bug/verify-fix.mjs @@ -0,0 +1,265 @@ +/** + * Verification: Fix for worktree path resolution escaping to home directory + * + * Tests the FIXED resolveProjectRoot() against the same scenarios that + * reproduced the bug. Copies the fixed function logic from worktree.ts. + */ + +import { + mkdirSync, symlinkSync, existsSync, readFileSync, realpathSync, writeFileSync, mkdtempSync, +} from "node:fs"; +import { execSync } from "node:child_process"; +import { join, resolve } from "node:path"; +import { homedir, tmpdir } from "node:os"; + +// ── Fixed functions (copied from worktree.ts after fix) ───────────────── + +function findWorktreeSegment(normalizedPath) { + const directMarker = "/.gsd/worktrees/"; + const idx = normalizedPath.indexOf(directMarker); + if (idx !== -1) { + return { gsdIdx: idx, afterWorktrees: idx + directMarker.length }; + } + const symlinkRe = /\/\.gsd\/projects\/[a-f0-9]+\/worktrees\//; + const match = normalizedPath.match(symlinkRe); + if (match && match.index !== undefined) { + return { gsdIdx: match.index, afterWorktrees: match.index + match[0].length }; + } + return null; +} + +function resolveProjectRootFromGitFile(worktreePath) { + try { + let dir = worktreePath; + while (true) { + const gitPath = join(dir, ".git"); + if (existsSync(gitPath)) { + const content = readFileSync(gitPath, "utf8").trim(); + if (content.startsWith("gitdir: ")) { + const gitDir = resolve(dir, content.slice(8)); + const dotGitDir = resolve(gitDir, "..", ".."); + if (dotGitDir.endsWith(".git") || dotGitDir.endsWith(".git/") || dotGitDir.endsWith(".git\\")) { + return resolve(dotGitDir, ".."); + } + const commonDirPath = join(gitDir, "commondir"); + if (existsSync(commonDirPath)) { + const commonDir = readFileSync(commonDirPath, "utf8").trim(); + const resolvedCommonDir = resolve(gitDir, commonDir); + return resolve(resolvedCommonDir, ".."); + } + } + break; + } + const parent = resolve(dir, ".."); + if (parent === dir) break; + dir = parent; + } + } catch { } + return null; +} + +function normalizePathForCompare(path) { + let normalized; + try { + normalized = realpathSync(path); + } catch { + normalized = resolve(path); + } + const slashed = normalized.replaceAll("\\", "/"); + const trimmed = slashed.replace(/\/+$/, ""); + return trimmed || "/"; +} + +function resolveProjectRoot(basePath) { + const normalizedPath = basePath.replaceAll("\\", "/"); + const seg = findWorktreeSegment(normalizedPath); + if (!seg) return basePath; + + // Layer 1: If the coordinator passed the real project root, use it. + if (process.env.GSD_PROJECT_ROOT) { + return process.env.GSD_PROJECT_ROOT; + } + + const sepChar = basePath.includes("\\") ? "\\" : "/"; + const gsdMarker = `${sepChar}.gsd${sepChar}`; + const gsdIdx = basePath.indexOf(gsdMarker); + const candidate = gsdIdx !== -1 + ? basePath.slice(0, gsdIdx) + : basePath.slice(0, seg.gsdIdx); + + // Layer 2: Guard against resolving to the user's home directory. + const gsdHome = normalizePathForCompare(process.env.GSD_HOME || join(homedir(), ".gsd")); + const candidateGsdPath = normalizePathForCompare(join(candidate, ".gsd")); + + if (candidateGsdPath === gsdHome || candidateGsdPath.startsWith(gsdHome + "/")) { + const realRoot = resolveProjectRootFromGitFile(basePath); + if (realRoot) return realRoot; + return basePath; + } + + return candidate; +} + +// ── Set up filesystem layout ──────────────────────────────────────────── + +const HASH = "abc123def456"; +const TEST_ROOT = mkdtempSync(join(tmpdir(), "gsd-verify-fix-")); +const USER_GSD = process.env.GSD_HOME || join(TEST_ROOT, ".gsd"); +const USER_HOME = homedir(); +const PROJECT_GSD_STORAGE = `${USER_GSD}/projects/${HASH}`; +const PROJECT_DIR = mkdtempSync(join(tmpdir(), "myproject-")); +const PROJECT_GSD_LINK = `${PROJECT_DIR}/.gsd`; +const PROJECT_REAL = normalizePathForCompare(PROJECT_DIR); +const EXPECTED_BUGGY_ROOT = normalizePathForCompare(resolve(USER_GSD, "..")); + +process.env.GSD_HOME = USER_GSD; + +console.log("=== Setting up filesystem layout ===\n"); + +mkdirSync(`${PROJECT_GSD_STORAGE}/worktrees`, { recursive: true }); +mkdirSync(`${PROJECT_GSD_STORAGE}/milestones`, { recursive: true }); +mkdirSync(PROJECT_DIR, { recursive: true }); +symlinkSync(PROJECT_GSD_STORAGE, PROJECT_GSD_LINK); + +// Init git in project dir +execSync("git init -b main", { cwd: PROJECT_DIR, stdio: "pipe" }); +execSync('git config user.name "Test"', { cwd: PROJECT_DIR, stdio: "pipe" }); +execSync('git config user.email "test@test.com"', { cwd: PROJECT_DIR, stdio: "pipe" }); +writeFileSync(join(PROJECT_DIR, "README.md"), "hello\n"); +execSync("git add -A && git commit -m init", { cwd: PROJECT_DIR, stdio: "pipe" }); + +// Create a REAL git worktree (so .git file exists with gitdir pointer) +execSync("git worktree add .gsd/worktrees/M001 -b worktree/M001", { + cwd: PROJECT_DIR, + stdio: "pipe", +}); +console.log("Created real git worktree at .gsd/worktrees/M001\n"); + +let passed = 0; +let failed = 0; + +function test(name, actual, expected) { + if (actual === expected) { + console.log(` ✅ ${name}`); + passed++; + } else { + console.log(` ❌ ${name}`); + console.log(` Expected: ${expected}`); + console.log(` Got: ${actual}`); + failed++; + } +} + +// ── Test 1: GSD_PROJECT_ROOT env var (Layer 1) ────────────────────────── + +console.log("=== Layer 1: GSD_PROJECT_ROOT env var ===\n"); + +process.env.GSD_PROJECT_ROOT = PROJECT_DIR; +const resolvedPath = realpathSync(`${PROJECT_DIR}/.gsd/worktrees/M001`); +test( + "GSD_PROJECT_ROOT overrides path resolution", + resolveProjectRoot(resolvedPath), + PROJECT_DIR, +); +delete process.env.GSD_PROJECT_ROOT; + +// ── Test 2: Direct layout still works ──────────────────────────────────── + +console.log("\n=== Direct layout (no symlink collision) ===\n"); + +test( + "Direct layout resolves correctly", + resolveProjectRoot("/foo/.gsd/worktrees/M001"), + "/foo", +); + +test( + "Non-worktree path unchanged", + resolveProjectRoot("/some/repo"), + "/some/repo", +); + +// ── Test 3: Symlink-resolved path with git fallback (Layer 2) ──────────── + +console.log("\n=== Layer 2: Symlink-resolved path with git fallback ===\n"); + +// chdir into worktree via symlink — process.cwd() resolves symlinks +process.chdir(`${PROJECT_DIR}/.gsd/worktrees/M001`); +const workerCwd = process.cwd(); +console.log(` Worker cwd (resolved): ${workerCwd}`); +console.log(` Expected project root: ${PROJECT_DIR}`); + +const result = resolveProjectRoot(workerCwd); +console.log(` resolveProjectRoot(): ${result}`); +test( + "Symlink-resolved worktree path resolves to REAL project (not ~)", + result, + PROJECT_REAL, +); + +// Verify it's NOT the home directory +test( + "Result is not the home directory", + result !== USER_HOME, + true, +); + +// ── Test 4: Verify the git file fallback works ────────────────────────── + +console.log("\n=== Git file fallback detail ===\n"); + +const gitFileContent = readFileSync(join(workerCwd, ".git"), "utf8").trim(); +console.log(` .git file content: ${gitFileContent}`); +const gitDirResolved = resolve(workerCwd, gitFileContent.slice(8)); +console.log(` Resolved gitdir: ${gitDirResolved}`); +const projectFromGit = resolve(gitDirResolved, "..", ".."); +console.log(` Project from git: ${resolve(projectFromGit, "..")}`); + +const gitFallback = resolveProjectRootFromGitFile(workerCwd); +test( + "resolveProjectRootFromGitFile returns real project", + gitFallback, + PROJECT_REAL, +); + +// ── Test 5: Old buggy path would have returned ~ ──────────────────────── + +console.log("\n=== Regression guard ===\n"); + +// Simulate what the OLD code did: +function oldResolveProjectRoot(basePath) { + const normalizedPath = basePath.replaceAll("\\", "/"); + const seg = findWorktreeSegment(normalizedPath); + if (!seg) return basePath; + const sepChar = basePath.includes("\\") ? "\\" : "/"; + const gsdMarker = `${sepChar}.gsd${sepChar}`; + const gsdIdx = basePath.indexOf(gsdMarker); + if (gsdIdx !== -1) return basePath.slice(0, gsdIdx); + return basePath.slice(0, seg.gsdIdx); +} + +const oldResult = oldResolveProjectRoot(workerCwd); +console.log(` Old (buggy) code returns: ${oldResult}`); +test( + "Old code returns parent of GSD home (confirming bug existed)", + oldResult, + EXPECTED_BUGGY_ROOT, +); + +test( + "New code does NOT return home directory", + result !== USER_HOME, + true, +); + +// ── Summary ────────────────────────────────────────────────────────────── + +console.log(`\n${"=".repeat(60)}`); +console.log(`\nResults: ${passed} passed, ${failed} failed`); +if (failed > 0) { + console.log("\n🔴 FIX VERIFICATION FAILED"); + process.exit(1); +} else { + console.log("\n✅ ALL TESTS PASSED — Fix verified!"); + process.exit(0); +} diff --git a/tests/repro-worktree-bug/verify-integration.mjs b/tests/repro-worktree-bug/verify-integration.mjs new file mode 100644 index 000000000..12c3c6f84 --- /dev/null +++ b/tests/repro-worktree-bug/verify-integration.mjs @@ -0,0 +1,264 @@ +/** + * Integration verification: parallel directory writes go to the correct .gsd + * + * This verifies that after the fix, when code resolves paths inside a worktree + * with symlinked .gsd, writes target the project-level .gsd (through symlink) + * rather than the user-level ~/.gsd. + * + * Covers: + * 1. resolveProjectRoot() returns the real project, not ~ + * 2. gsdRoot() from the resolved project root finds project .gsd, not ~/.gsd + * 3. The parallel/ directory would be created under project .gsd + * 4. session-status writes target the correct location + * 5. orchestrator.json would be written to project .gsd + * 6. assertSafeDirectory blocks ~ as a project root + */ + +import { + mkdirSync, symlinkSync, existsSync, readFileSync, realpathSync, + writeFileSync, mkdtempSync, +} from "node:fs"; +import { execSync } from "node:child_process"; +import { join, resolve } from "node:path"; +import { homedir, tmpdir } from "node:os"; + +// ── Fixed functions (from worktree.ts after fix) ───────────────────────── + +function findWorktreeSegment(normalizedPath) { + const directMarker = "/.gsd/worktrees/"; + const idx = normalizedPath.indexOf(directMarker); + if (idx !== -1) { + return { gsdIdx: idx, afterWorktrees: idx + directMarker.length }; + } + const symlinkRe = /\/\.gsd\/projects\/[a-f0-9]+\/worktrees\//; + const match = normalizedPath.match(symlinkRe); + if (match && match.index !== undefined) { + return { gsdIdx: match.index, afterWorktrees: match.index + match[0].length }; + } + return null; +} + +function resolveProjectRootFromGitFile(worktreePath) { + try { + let dir = worktreePath; + while (true) { + const gitPath = join(dir, ".git"); + if (existsSync(gitPath)) { + const content = readFileSync(gitPath, "utf8").trim(); + if (content.startsWith("gitdir: ")) { + const gitDir = resolve(dir, content.slice(8)); + const dotGitDir = resolve(gitDir, "..", ".."); + if (dotGitDir.endsWith(".git") || dotGitDir.endsWith(".git/") || dotGitDir.endsWith(".git\\")) { + return resolve(dotGitDir, ".."); + } + const commonDirPath = join(gitDir, "commondir"); + if (existsSync(commonDirPath)) { + const commonDir = readFileSync(commonDirPath, "utf8").trim(); + const resolvedCommonDir = resolve(gitDir, commonDir); + return resolve(resolvedCommonDir, ".."); + } + } + break; + } + const parent = resolve(dir, ".."); + if (parent === dir) break; + dir = parent; + } + } catch { } + return null; +} + +function normalizePathForCompare(path) { + let normalized; + try { + normalized = realpathSync(path); + } catch { + normalized = resolve(path); + } + const slashed = normalized.replaceAll("\\", "/"); + const trimmed = slashed.replace(/\/+$/, ""); + return trimmed || "/"; +} + +function resolveProjectRoot(basePath) { + const normalizedPath = basePath.replaceAll("\\", "/"); + const seg = findWorktreeSegment(normalizedPath); + if (!seg) return basePath; + + if (process.env.GSD_PROJECT_ROOT) { + return process.env.GSD_PROJECT_ROOT; + } + + const sepChar = basePath.includes("\\") ? "\\" : "/"; + const gsdMarker = `${sepChar}.gsd${sepChar}`; + const gsdIdx = basePath.indexOf(gsdMarker); + const candidate = gsdIdx !== -1 + ? basePath.slice(0, gsdIdx) + : basePath.slice(0, seg.gsdIdx); + const gsdHome = normalizePathForCompare(process.env.GSD_HOME || join(homedir(), ".gsd")); + const candidateGsdPath = normalizePathForCompare(join(candidate, ".gsd")); + if (candidateGsdPath === gsdHome || candidateGsdPath.startsWith(gsdHome + "/")) { + const realRoot = resolveProjectRootFromGitFile(basePath); + if (realRoot) return realRoot; + return basePath; + } + return candidate; +} + +// Simplified gsdRoot — matches paths.ts probeGsdRoot logic +function gsdRoot(basePath) { + const local = join(basePath, ".gsd"); + if (existsSync(local)) return local; + return local; // fallback +} + +// Simplified validateDirectory — matches validate-directory.ts +function validateDirectory(dirPath) { + let resolved; + try { resolved = realpathSync(resolve(dirPath)); } catch { resolved = resolve(dirPath); } + let normalized = resolved.replace(/[/\\]+$/, ""); + if (normalized === "") normalized = "/"; + + let resolvedHome; + try { resolvedHome = realpathSync(resolve(homedir())).replace(/[/\\]+$/, ""); } catch { resolvedHome = resolve(homedir()).replace(/[/\\]+$/, ""); } + + if (normalized === resolvedHome) { + return { safe: false, severity: "blocked", reason: `Refusing to run in home directory: ${normalized}` }; + } + return { safe: true, severity: "ok" }; +} + +// ── Setup ──────────────────────────────────────────────────────────────── + +const HASH = "abc123def456"; +const TEST_ROOT = mkdtempSync(join(tmpdir(), "gsd-verify-integration-")); +const USER_GSD = process.env.GSD_HOME || join(TEST_ROOT, ".gsd"); +const USER_HOME = homedir(); +const PROJECT_GSD_STORAGE = `${USER_GSD}/projects/${HASH}`; +const PROJECT_DIR = mkdtempSync(join(tmpdir(), "myproject-")); +const PROJECT_GSD_LINK = `${PROJECT_DIR}/.gsd`; +const PROJECT_REAL = normalizePathForCompare(PROJECT_DIR); +let PROJECT_STORAGE_REAL = ""; + +process.env.GSD_HOME = USER_GSD; + +console.log("=== Setup ===\n"); + +mkdirSync(`${PROJECT_GSD_STORAGE}/worktrees`, { recursive: true }); +mkdirSync(`${PROJECT_GSD_STORAGE}/milestones`, { recursive: true }); +mkdirSync(PROJECT_DIR, { recursive: true }); +symlinkSync(PROJECT_GSD_STORAGE, PROJECT_GSD_LINK); +PROJECT_STORAGE_REAL = normalizePathForCompare(PROJECT_GSD_STORAGE); + +execSync("git init -b main", { cwd: PROJECT_DIR, stdio: "pipe" }); +execSync('git config user.name "Test"', { cwd: PROJECT_DIR, stdio: "pipe" }); +execSync('git config user.email "test@test.com"', { cwd: PROJECT_DIR, stdio: "pipe" }); +writeFileSync(join(PROJECT_DIR, "README.md"), "hello\n"); +execSync("git add -A && git commit -m init", { cwd: PROJECT_DIR, stdio: "pipe" }); +execSync("git worktree add .gsd/worktrees/M001 -b worktree/M001", { cwd: PROJECT_DIR, stdio: "pipe" }); +console.log("Created project with symlinked .gsd and real git worktree\n"); + +let passed = 0; +let failed = 0; +function test(name, actual, expected) { + if (actual === expected) { console.log(` ✅ ${name}`); passed++; } + else { console.log(` ❌ ${name}\n Expected: ${expected}\n Got: ${actual}`); failed++; } +} + +// ── Simulate worker environment ────────────────────────────────────────── + +process.chdir(`${PROJECT_DIR}/.gsd/worktrees/M001`); +const workerCwd = process.cwd(); // Resolves symlinks → /root/.gsd/projects/.../worktrees/M001 + +console.log("=== Test 1: resolveProjectRoot returns real project ===\n"); +console.log(` Worker cwd (resolved): ${workerCwd}`); + +const projectRoot = resolveProjectRoot(workerCwd); +console.log(` Resolved project root: ${projectRoot}`); +test("resolveProjectRoot returns real project root", projectRoot, PROJECT_REAL); +test("resolveProjectRoot does NOT return home dir", projectRoot !== USER_HOME, true); + +console.log("\n=== Test 2: gsdRoot finds project .gsd ===\n"); + +const gsd = gsdRoot(projectRoot); +console.log(` gsdRoot result: ${gsd}`); +test("gsdRoot points to project .gsd", gsd, `${PROJECT_REAL}/.gsd`); + +// Verify it's a symlink to the right place +const gsdReal = realpathSync(gsd); +console.log(` gsdRoot resolves to: ${gsdReal}`); +test("gsdRoot resolves to project storage", gsdReal, PROJECT_STORAGE_REAL); +test("gsdRoot does NOT resolve to user-level ~/.gsd", gsdReal !== USER_GSD, true); + +console.log("\n=== Test 3: parallel/ directory targets project .gsd ===\n"); + +const parallelDir = join(gsd, "parallel"); +console.log(` Parallel dir would be: ${parallelDir}`); +const parallelReal = join(gsdReal, "parallel"); +console.log(` Resolves physically to: ${parallelReal}`); +test("parallel dir is under project .gsd", parallelDir.startsWith(PROJECT_REAL), true); +test("parallel dir is NOT under ~/.gsd root", !parallelDir.startsWith(USER_GSD) || parallelDir.startsWith(`${USER_GSD}/projects/`), true); + +// Actually create it and verify +mkdirSync(parallelDir, { recursive: true }); +test("parallel dir was created", existsSync(parallelDir), true); +test("parallel dir physically exists in project storage", existsSync(parallelReal), true); + +// Write a session status file +const statusFile = join(parallelDir, "M001.status.json"); +writeFileSync(statusFile, JSON.stringify({ milestoneId: "M001", pid: 12345, state: "running" })); +test("session status file written to project parallel/", existsSync(statusFile), true); + +console.log("\n=== Test 4: orchestrator.json targets project .gsd ===\n"); + +const orchestratorPath = join(gsd, "orchestrator.json"); +console.log(` orchestrator.json would be at: ${orchestratorPath}`); +writeFileSync(orchestratorPath, JSON.stringify({ active: true })); +test("orchestrator.json written to project .gsd", existsSync(orchestratorPath), true); + +// Verify nothing leaked to user-level ~/.gsd root +const userParallelDir = join(USER_GSD, "parallel"); +const userOrchestratorPath = join(USER_GSD, "orchestrator.json"); +test("NO parallel/ dir at user-level ~/.gsd root", !existsSync(userParallelDir), true); +test("NO orchestrator.json at user-level ~/.gsd root", !existsSync(userOrchestratorPath), true); + +console.log("\n=== Test 5: validateDirectory blocks ~ as project root ===\n"); + +const homeValidation = validateDirectory(USER_HOME); +test("validateDirectory blocks home dir", homeValidation.safe, false); +test("validateDirectory blocks with 'blocked' severity", homeValidation.severity, "blocked"); + +const projectValidation = validateDirectory(PROJECT_DIR); +test("validateDirectory allows project dir", projectValidation.safe, true); + +console.log("\n=== Test 6: GSD_PROJECT_ROOT env var path ===\n"); + +process.env.GSD_PROJECT_ROOT = PROJECT_DIR; +const envResult = resolveProjectRoot(workerCwd); +test("GSD_PROJECT_ROOT short-circuits resolution", envResult, PROJECT_DIR); +delete process.env.GSD_PROJECT_ROOT; + +console.log("\n=== Test 7: Non-worktree paths unaffected ===\n"); + +test("Regular project path unchanged", resolveProjectRoot("/some/project"), "/some/project"); +test("Direct worktree layout still works", resolveProjectRoot("/foo/.gsd/worktrees/M001"), "/foo"); + +// ── Summary ────────────────────────────────────────────────────────────── + +console.log(`\n${"=".repeat(60)}`); +console.log(`\nResults: ${passed} passed, ${failed} failed`); +if (failed > 0) { + console.log("\n🔴 INTEGRATION VERIFICATION FAILED"); + process.exit(1); +} else { + console.log("\n✅ ALL INTEGRATION TESTS PASSED"); + console.log(" - resolveProjectRoot returns real project, not ~"); + console.log(" - gsdRoot finds project .gsd through symlink"); + console.log(" - parallel/ dir created in project .gsd, not ~/.gsd"); + console.log(" - session status writes land in correct location"); + console.log(" - orchestrator.json lands in correct location"); + console.log(" - validateDirectory blocks ~ as fallback safety net"); + console.log(" - GSD_PROJECT_ROOT env var works as primary layer"); + console.log(" - Non-worktree paths are unaffected by the fix"); + process.exit(0); +}