singularity-forge/tests/repro-worktree-bug/repro.mjs
2026-05-05 14:46:18 +02:00

208 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 { execSync } from "node:child_process";
import {
existsSync,
mkdirSync,
mkdtempSync,
realpathSync,
symlinkSync,
} from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
// ── 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);
}