277 lines
8.8 KiB
JavaScript
277 lines
8.8 KiB
JavaScript
/**
|
|
* 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 { execSync } from "node:child_process";
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
realpathSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { homedir, tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
|
|
// ── Fixed functions (copied from worktree.ts after fix) ─────────────────
|
|
|
|
function findWorktreeSegment(normalizedPath) {
|
|
const directMarker = "/.sf/worktrees/";
|
|
const idx = normalizedPath.indexOf(directMarker);
|
|
if (idx !== -1) {
|
|
return { sfIdx: idx, afterWorktrees: idx + directMarker.length };
|
|
}
|
|
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 resolveProjectRootFromGitFile(worktreePath) {
|
|
try {
|
|
let dir = worktreePath;
|
|
for (let i = 0; i < 10; i++) {
|
|
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) {
|
|
// Layer 1: If the coordinator passed the real project root, use it.
|
|
if (process.env.SF_PROJECT_ROOT) {
|
|
return process.env.SF_PROJECT_ROOT;
|
|
}
|
|
|
|
const normalizedPath = basePath.replaceAll("\\", "/");
|
|
const seg = findWorktreeSegment(normalizedPath);
|
|
if (!seg) return basePath;
|
|
|
|
const sepChar = basePath.includes("\\") ? "\\" : "/";
|
|
const sfMarker = `${sepChar}.sf${sepChar}`;
|
|
const sfIdx = basePath.indexOf(sfMarker);
|
|
const candidate =
|
|
sfIdx !== -1 ? basePath.slice(0, sfIdx) : basePath.slice(0, seg.sfIdx);
|
|
|
|
// Layer 2: Guard against resolving to the user's home directory.
|
|
const sfHome = normalizePathForCompare(
|
|
process.env.SF_HOME || join(homedir(), ".sf"),
|
|
);
|
|
const candidateSfPath = normalizePathForCompare(join(candidate, ".sf"));
|
|
|
|
if (candidateSfPath === sfHome || candidateSfPath.startsWith(sfHome + "/")) {
|
|
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(), "sf-verify-fix-"));
|
|
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`;
|
|
const PROJECT_REAL = normalizePathForCompare(PROJECT_DIR);
|
|
const EXPECTED_BUGGY_ROOT = normalizePathForCompare(resolve(USER_SF, ".."));
|
|
|
|
process.env.SF_HOME = USER_SF;
|
|
|
|
console.log("=== Setting up filesystem layout ===\n");
|
|
|
|
mkdirSync(`${PROJECT_SF_STORAGE}/worktrees`, { recursive: true });
|
|
mkdirSync(`${PROJECT_SF_STORAGE}/milestones`, { recursive: true });
|
|
mkdirSync(PROJECT_DIR, { recursive: true });
|
|
symlinkSync(PROJECT_SF_STORAGE, PROJECT_SF_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 .sf/worktrees/M001 -b worktree/M001", {
|
|
cwd: PROJECT_DIR,
|
|
stdio: "pipe",
|
|
});
|
|
console.log("Created real git worktree at .sf/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: SF_PROJECT_ROOT env var (Layer 1) ──────────────────────────
|
|
|
|
console.log("=== Layer 1: SF_PROJECT_ROOT env var ===\n");
|
|
|
|
process.env.SF_PROJECT_ROOT = PROJECT_DIR;
|
|
const resolvedPath = realpathSync(`${PROJECT_DIR}/.sf/worktrees/M001`);
|
|
test(
|
|
"SF_PROJECT_ROOT overrides path resolution",
|
|
resolveProjectRoot(resolvedPath),
|
|
PROJECT_DIR,
|
|
);
|
|
delete process.env.SF_PROJECT_ROOT;
|
|
|
|
// ── Test 2: Direct layout still works ────────────────────────────────────
|
|
|
|
console.log("\n=== Direct layout (no symlink collision) ===\n");
|
|
|
|
test(
|
|
"Direct layout resolves correctly",
|
|
resolveProjectRoot("/foo/.sf/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}/.sf/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 sfMarker = `${sepChar}.sf${sepChar}`;
|
|
const sfIdx = basePath.indexOf(sfMarker);
|
|
if (sfIdx !== -1) return basePath.slice(0, sfIdx);
|
|
return basePath.slice(0, seg.sfIdx);
|
|
}
|
|
|
|
const oldResult = oldResolveProjectRoot(workerCwd);
|
|
console.log(` Old (buggy) code returns: ${oldResult}`);
|
|
test(
|
|
"Old code returns parent of SF 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);
|
|
}
|