- All gsdDir/gsdRoot/gsdHome → sfDir/sfRootDir/sfHome - GSDWorkspace* → SFWorkspace* interfaces - bootstrapGsdProject → bootstrapProject - runGSDDoctor → runSFDoctor - GsdClient → SfClient, gsd-client.ts → sf-client.ts - .gsd/ → .sf/ in all tests, docs, docker, native, vscode - Auto-migration: headless detects .gsd/ → renames to .sf/ - Deleted gsd-phase-state.ts backward-compat re-export - Renamed bin/gsd-from-source → bin/sf-from-source - Updated mintlify docs, github workflows, docker configs
177 lines
8.1 KiB
JavaScript
177 lines
8.1 KiB
JavaScript
/**
|
|
* 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 .sf is a symlink into
|
|
* ~/.sf/projects/<hash> and worktree isolation is enabled.
|
|
*
|
|
* Layout mimics pi's default:
|
|
* /root/.sf/projects/<hash>/ ← user-level SF storage
|
|
* /tmp/myproject/.sf → symlink to ↑ ← project's .sf
|
|
* /tmp/myproject/.sf/worktrees/M001/ ← worktree (logical path through symlink)
|
|
*
|
|
* When a worker spawns with cwd = /tmp/myproject/.sf/worktrees/M001,
|
|
* process.cwd() resolves symlinks → /root/.sf/projects/<hash>/worktrees/M001.
|
|
* findWorktreeSegment() then matches /.sf/ at the WRONG boundary (the
|
|
* user-level ~/.sf), 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: /.sf/worktrees/<name>
|
|
const directMarker = "/.sf/worktrees/";
|
|
const idx = normalizedPath.indexOf(directMarker);
|
|
if (idx !== -1) {
|
|
return { sfIdx: idx, afterWorktrees: idx + directMarker.length };
|
|
}
|
|
// Symlink-resolved layout: /.sf/projects/<hash>/worktrees/<name>
|
|
const symlinkRe = /\/\.sf\/projects\/[a-f0-9]+\/worktrees\//;
|
|
const match = normalizedPath.match(symlinkRe);
|
|
if (match && match.index !== undefined) {
|
|
return { sfIdx: 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 /.sf/ boundary
|
|
const sep = basePath.includes("\\") ? "\\" : "/";
|
|
const sfMarker = `${sep}.sf${sep}`;
|
|
const sfIdx = basePath.indexOf(sfMarker);
|
|
if (sfIdx !== -1) return basePath.slice(0, sfIdx);
|
|
return basePath.slice(0, seg.sfIdx);
|
|
}
|
|
|
|
// ── Set up the filesystem layout ────────────────────────────────────────
|
|
|
|
const HASH = "abc123def456";
|
|
const TEST_ROOT = mkdtempSync(join(tmpdir(), "sf-repro-"));
|
|
const USER_SF = process.env.SF_HOME || join(TEST_ROOT, ".sf");
|
|
const USER_HOME = homedir();
|
|
const PROJECT_SF_STORAGE = `${USER_SF}/projects/${HASH}`;
|
|
const PROJECT_DIR = mkdtempSync(join(tmpdir(), "myproject-"));
|
|
const PROJECT_SF_LINK = `${PROJECT_DIR}/.sf`;
|
|
|
|
console.log("=== Setting up filesystem layout ===\n");
|
|
|
|
// 1. Create user-level SF structure
|
|
mkdirSync(`${PROJECT_SF_STORAGE}/worktrees/M001`, { recursive: true });
|
|
mkdirSync(`${PROJECT_SF_STORAGE}/milestones`, { recursive: true });
|
|
console.log(`Created: ${PROJECT_SF_STORAGE}/worktrees/M001`);
|
|
|
|
// 2. Create project directory
|
|
mkdirSync(PROJECT_DIR, { recursive: true });
|
|
console.log(`Created: ${PROJECT_DIR}`);
|
|
|
|
// 3. Create symlink: project/.sf → user-level storage
|
|
symlinkSync(PROJECT_SF_STORAGE, PROJECT_SF_LINK);
|
|
console.log(`Symlink: ${PROJECT_SF_LINK} → ${PROJECT_SF_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}/.sf/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 .sf at: ${result3}/.sf`);
|
|
console.log(`That path exists: ${existsSync(join(result3, ".sf"))}`);
|
|
|
|
if (existsSync(join(result3, ".sf"))) {
|
|
const resolvedSf = realpathSync(join(result3, ".sf"));
|
|
console.log(`It resolves to: ${resolvedSf}`);
|
|
console.log(`\nThis is the USER-LEVEL .sf directory!`);
|
|
console.log(`The worker would:`);
|
|
console.log(` 1. Write session status to ~/.sf/parallel/`);
|
|
console.log(` 2. Write orchestrator.json to ~/.sf/`);
|
|
console.log(` 3. Potentially git init in ${result3} (the home directory)`);
|
|
console.log(` 4. Corrupt the user-level SF configuration`);
|
|
}
|
|
}
|
|
|
|
// ── Test 5: Verify findWorktreeSegment matches at the wrong /.sf/ ──────
|
|
|
|
console.log(`\n=== Root Cause Detail ===\n`);
|
|
const seg = findWorktreeSegment(resolvedPath);
|
|
if (seg) {
|
|
console.log(`findWorktreeSegment() matched:`);
|
|
console.log(` sfIdx: ${seg.sfIdx}`);
|
|
console.log(` afterWorktrees: ${seg.afterWorktrees}`);
|
|
console.log(` Path before /.sf/: "${resolvedPath.slice(0, seg.sfIdx)}"`);
|
|
console.log(` This is: ${resolvedPath.slice(0, seg.sfIdx) === USER_HOME ? "THE HOME DIRECTORY (bug!)" : "some other directory"}`);
|
|
|
|
// Show which regex matched
|
|
const directMarker = "/.sf/worktrees/";
|
|
const directIdx = resolvedPath.indexOf(directMarker);
|
|
if (directIdx !== -1) {
|
|
console.log(`\n Matched by: direct marker "/.sf/worktrees/" at index ${directIdx}`);
|
|
console.log(` The /.sf/ it found is at: "${resolvedPath.slice(0, directIdx + 5)}"`);
|
|
console.log(` This /.sf/ is the USER-LEVEL ~/.sf, not the project .sf!`);
|
|
} 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 /.sf/ in the`);
|
|
console.log(` user-level ~/.sf path, not the project-level .sf symlink.`);
|
|
process.exit(1);
|
|
} else {
|
|
console.log(`\n✅ Bug not reproduced — may be fixed.`);
|
|
process.exit(0);
|
|
}
|