FN-6102: guard spawnSync lookup in pi extensions

Avoid crashing when partial child_process mocks omit spawnSync.

- lazily load spawnSync from node:child_process via createRequire
- return null from Git linked worktree detection when spawnSync is unavailable
- add a regression test covering partial child_process mocks that only stub execSync

Files changed:
 packages/core/src/__tests__/pi-extensions.test.ts | 22 +++++++++++++++++++++-
 packages/core/src/pi-extensions.ts                | 18 +++++++++++++++++-
 2 files changed, 38 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-6102

Fusion-Task-Lineage: 22f98a1c-d296-4aa4-ac3d-7beead127dac
This commit is contained in:
gsxdsm
2026-06-09 11:37:57 -07:00
parent 83e0603504
commit 94b7cf01bd
2 changed files with 38 additions and 2 deletions

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
@@ -38,6 +38,26 @@ describe("getProjectRootFromWorktree", () => {
).toBe("/tmp");
});
it("returns null without throwing when child_process partial mocks omit spawnSync", async () => {
vi.resetModules();
vi.doMock("node:child_process", () => ({
execSync: vi.fn(),
}));
try {
const { getProjectRootFromWorktree: getProjectRootFromWorktreeWithPartialMock } = await import(
"../pi-extensions.js"
);
const unmatchedPath = join(tmpdir(), "fn-6102-not-a-worktree");
expect(() => getProjectRootFromWorktreeWithPartialMock(unmatchedPath)).not.toThrow();
expect(getProjectRootFromWorktreeWithPartialMock(unmatchedPath)).toBe(null);
} finally {
vi.doUnmock("node:child_process");
vi.resetModules();
}
});
it("detects arbitrary Git linked worktree paths when the parent has Fusion metadata", () => {
const root = mkdtempSync(join(tmpdir(), "fn-6079-root-"));
const worktreeRoot = mkdtempSync(join(tmpdir(), "fusion-ai-merge-fn-6079-"));

View File

@@ -1,9 +1,20 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
const FUSION_DISABLED_EXTENSIONS_KEY = "fusionDisabledExtensions";
const require = createRequire(import.meta.url);
let cachedSpawnSync: typeof import("node:child_process")["spawnSync"] | undefined;
let didLoadSpawnSync = false;
function getSpawnSync(): typeof import("node:child_process")["spawnSync"] | undefined {
if (!didLoadSpawnSync) {
didLoadSpawnSync = true;
cachedSpawnSync = require("node:child_process").spawnSync;
}
return cachedSpawnSync;
}
export type PiExtensionSource = "fusion-global" | "pi-global" | "fusion-project" | "pi-project" | "package";
@@ -76,6 +87,11 @@ export function getProjectRootFromWorktree(
}
function getProjectRootFromGitLinkedWorktree(cwd: string): string | null {
const spawnSync = getSpawnSync();
if (!spawnSync) {
return null;
}
const resolvedCwd = resolve(cwd);
const commonDir = spawnSync("git", ["rev-parse", "--git-common-dir"], {
cwd: resolvedCwd,