Fix worktree root resolution in deep symlink paths (#1680)
* fix: prevent parallel worktree path resolution from escaping to home directory When .gsd is a symlink into ~/.gsd/projects/<hash> (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 <splichal@gmail.com>
This commit is contained in:
parent
c4286f4c57
commit
98530fad11
7 changed files with 875 additions and 11 deletions
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
|
||||
// ── 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<void> {
|
|||
"/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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/<hash>`), 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/<name>`,
|
||||
* 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/<hash>, 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/<name>
|
||||
*
|
||||
* 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: <real-project>/.git/worktrees/<name>
|
||||
const gitDir = resolve(dir, content.slice(8));
|
||||
// Walk up: .git/worktrees/<name> → .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 || "/";
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
9
tests/repro-worktree-bug/Dockerfile
Normal file
9
tests/repro-worktree-bug/Dockerfile
Normal file
|
|
@ -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"]
|
||||
177
tests/repro-worktree-bug/repro.mjs
Normal file
177
tests/repro-worktree-bug/repro.mjs
Normal file
|
|
@ -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/<hash> and worktree isolation is enabled.
|
||||
*
|
||||
* Layout mimics pi's default:
|
||||
* /root/.gsd/projects/<hash>/ ← 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/<hash>/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/<name>
|
||||
const directMarker = "/.gsd/worktrees/";
|
||||
const idx = normalizedPath.indexOf(directMarker);
|
||||
if (idx !== -1) {
|
||||
return { gsdIdx: idx, afterWorktrees: idx + directMarker.length };
|
||||
}
|
||||
// Symlink-resolved layout: /.gsd/projects/<hash>/worktrees/<name>
|
||||
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);
|
||||
}
|
||||
265
tests/repro-worktree-bug/verify-fix.mjs
Normal file
265
tests/repro-worktree-bug/verify-fix.mjs
Normal file
|
|
@ -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);
|
||||
}
|
||||
264
tests/repro-worktree-bug/verify-integration.mjs
Normal file
264
tests/repro-worktree-bug/verify-integration.mjs
Normal file
|
|
@ -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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue