diff --git a/.changeset/merge-worktree-canonical-path.md b/.changeset/merge-worktree-canonical-path.md new file mode 100644 index 0000000000..e4e8f28678 --- /dev/null +++ b/.changeset/merge-worktree-canonical-path.md @@ -0,0 +1,3 @@ +"@runfusion/fusion": patch + +Fix coding-agent startup from AI merge temp worktrees on macOS by comparing Git worktree paths with filesystem-canonical paths. diff --git a/packages/engine/src/__tests__/pi-layers-wiring.test.ts b/packages/engine/src/__tests__/pi-layers-wiring.test.ts index 8c242edbab..c23f354963 100644 --- a/packages/engine/src/__tests__/pi-layers-wiring.test.ts +++ b/packages/engine/src/__tests__/pi-layers-wiring.test.ts @@ -26,8 +26,10 @@ const settingsManagerInMemoryMock = vi.fn(() => ({ kind: "settings-manager" })); const setFallbackResolverMock = vi.fn(); const reloadMock = vi.fn(async () => {}); const execSyncMock = vi.fn((_cmd?: any, _opts?: any) => ""); +const spawnSyncMock = vi.fn(() => ({ status: 1, stdout: "" })); const existsSyncMock = vi.fn((_path: PathLike) => false); const readFileSyncMock = vi.fn((_path?: any) => "{}"); +const realpathSyncNativeMock = vi.fn((path: PathLike) => String(path)); const readCustomProvidersMock = vi.fn(() => []); // Capture DefaultResourceLoader constructor args @@ -64,7 +66,7 @@ vi.mock("node:child_process", () => { } }); }); - return { execSync: execSyncFn, exec: execFn, execFile: vi.fn() }; + return { execSync: execSyncFn, exec: execFn, execFile: vi.fn(), spawnSync: spawnSyncMock }; }); vi.mock("node:fs", async () => { @@ -73,6 +75,9 @@ vi.mock("node:fs", async () => { ...actual, existsSync: existsSyncMock, readFileSync: readFileSyncMock, + realpathSync: Object.assign(vi.fn((path: PathLike) => String(path)), { + native: realpathSyncNativeMock, + }), }; }); @@ -143,8 +148,10 @@ describe("createFnAgent prompt layer wiring", () => { vi.clearAllMocks(); capturedResourceLoaderOptions = null; execSyncMock.mockReturnValue(""); + spawnSyncMock.mockReturnValue({ status: 1, stdout: "" }); existsSyncMock.mockReturnValue(false); readFileSyncMock.mockReturnValue("{}"); + realpathSyncNativeMock.mockImplementation((path: PathLike) => String(path)); readCustomProvidersMock.mockReturnValue([]); findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId })); createAgentSessionMock.mockResolvedValue({ @@ -174,6 +181,40 @@ describe("createFnAgent prompt layer wiring", () => { expect(override).toBe("Stable prefix."); }); + it("accepts macOS-canonicalized Git linked worktrees during session validation", async () => { + const cwd = "/var/folders/zp/fjh8794n7bl61c_pn1gmdt200000gn/T/project/.worktrees/fn-6085"; + const projectRoot = "/var/folders/zp/fjh8794n7bl61c_pn1gmdt200000gn/T/project"; + const canonicalCwd = "/private/var/folders/zp/fjh8794n7bl61c_pn1gmdt200000gn/T/project/.worktrees/fn-6085"; + existsSyncMock.mockImplementation((path: PathLike) => { + const text = String(path); + return text === cwd || text === `${cwd}/.git` || text === `${projectRoot}/.fusion`; + }); + realpathSyncNativeMock.mockImplementation((path: PathLike) => { + const text = String(path); + return text.startsWith("/var/folders/") ? `/private${text}` : text; + }); + execSyncMock.mockImplementation((cmd: string) => { + if (cmd.includes("git rev-parse --show-toplevel")) { + return `${canonicalCwd}\n`; + } + if (cmd.includes("git worktree list --porcelain")) { + return `worktree ${canonicalCwd}\nHEAD abc123\n`; + } + return ""; + }); + + const { createFnAgent } = await import("../pi.js"); + + await expect(createFnAgent({ + cwd, + systemPrompt: "system", + defaultProvider: "mock", + defaultModelId: "scripted", + })).resolves.toBeDefined(); + + expect(createAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({ cwd })); + }); + it("passes dynamic layer via appendSystemPromptOverride when layers provided", async () => { const { createFnAgent } = await import("../pi.js"); diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index 227972480a..3a70ed7e21 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -6,7 +6,7 @@ */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; import { exec, execFile } from "node:child_process"; import { promisify } from "node:util"; import { createRequire } from "node:module"; @@ -1400,22 +1400,30 @@ async function isRegisteredGitWorktree(projectRoot: string, worktreePath: string cwd: projectRoot, encoding: "utf-8", }); - const resolvedWorktree = resolve(worktreePath); + const resolvedWorktree = normalizeExistingPathForGitComparison(worktreePath); return stdout.split("\n").some((line) => - line.startsWith("worktree ") && resolve(line.slice("worktree ".length)) === resolvedWorktree + line.startsWith("worktree ") && normalizeExistingPathForGitComparison(line.slice("worktree ".length)) === resolvedWorktree ); } catch { return false; } } +function normalizeExistingPathForGitComparison(path: string): string { + try { + return realpathSync.native(path); + } catch { + return resolve(path); + } +} + async function isCompleteGitWorktree(worktreePath: string): Promise { try { const { stdout } = await execAsync("git rev-parse --show-toplevel", { cwd: worktreePath, encoding: "utf-8", }); - return resolve(stdout.trim()) === resolve(worktreePath); + return normalizeExistingPathForGitComparison(stdout.trim()) === normalizeExistingPathForGitComparison(worktreePath); } catch { return false; }