From 94b7cf01bd03d0a46e2ea4b07949cf048ae02c75 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 9 Jun 2026 11:37:57 -0700 Subject: [PATCH] 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 --- .../core/src/__tests__/pi-extensions.test.ts | 22 ++++++++++++++++++- packages/core/src/pi-extensions.ts | 18 ++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/core/src/__tests__/pi-extensions.test.ts b/packages/core/src/__tests__/pi-extensions.test.ts index f85fda17fe..142adb8298 100644 --- a/packages/core/src/__tests__/pi-extensions.test.ts +++ b/packages/core/src/__tests__/pi-extensions.test.ts @@ -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-")); diff --git a/packages/core/src/pi-extensions.ts b/packages/core/src/pi-extensions.ts index 73e527c06b..4cc6bfd8ce 100644 --- a/packages/core/src/pi-extensions.ts +++ b/packages/core/src/pi-extensions.ts @@ -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,