fix(FN-6085): accept canonical merge worktree paths
This commit is contained in:
3
.changeset/merge-worktree-canonical-path.md
Normal file
3
.changeset/merge-worktree-canonical-path.md
Normal file
@@ -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.
|
||||||
@@ -26,8 +26,10 @@ const settingsManagerInMemoryMock = vi.fn(() => ({ kind: "settings-manager" }));
|
|||||||
const setFallbackResolverMock = vi.fn();
|
const setFallbackResolverMock = vi.fn();
|
||||||
const reloadMock = vi.fn(async () => {});
|
const reloadMock = vi.fn(async () => {});
|
||||||
const execSyncMock = vi.fn((_cmd?: any, _opts?: any) => "");
|
const execSyncMock = vi.fn((_cmd?: any, _opts?: any) => "");
|
||||||
|
const spawnSyncMock = vi.fn(() => ({ status: 1, stdout: "" }));
|
||||||
const existsSyncMock = vi.fn((_path: PathLike) => false);
|
const existsSyncMock = vi.fn((_path: PathLike) => false);
|
||||||
const readFileSyncMock = vi.fn((_path?: any) => "{}");
|
const readFileSyncMock = vi.fn((_path?: any) => "{}");
|
||||||
|
const realpathSyncNativeMock = vi.fn((path: PathLike) => String(path));
|
||||||
const readCustomProvidersMock = vi.fn(() => []);
|
const readCustomProvidersMock = vi.fn(() => []);
|
||||||
|
|
||||||
// Capture DefaultResourceLoader constructor args
|
// 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 () => {
|
vi.mock("node:fs", async () => {
|
||||||
@@ -73,6 +75,9 @@ vi.mock("node:fs", async () => {
|
|||||||
...actual,
|
...actual,
|
||||||
existsSync: existsSyncMock,
|
existsSync: existsSyncMock,
|
||||||
readFileSync: readFileSyncMock,
|
readFileSync: readFileSyncMock,
|
||||||
|
realpathSync: Object.assign(vi.fn((path: PathLike) => String(path)), {
|
||||||
|
native: realpathSyncNativeMock,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,8 +148,10 @@ describe("createFnAgent prompt layer wiring", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
capturedResourceLoaderOptions = null;
|
capturedResourceLoaderOptions = null;
|
||||||
execSyncMock.mockReturnValue("");
|
execSyncMock.mockReturnValue("");
|
||||||
|
spawnSyncMock.mockReturnValue({ status: 1, stdout: "" });
|
||||||
existsSyncMock.mockReturnValue(false);
|
existsSyncMock.mockReturnValue(false);
|
||||||
readFileSyncMock.mockReturnValue("{}");
|
readFileSyncMock.mockReturnValue("{}");
|
||||||
|
realpathSyncNativeMock.mockImplementation((path: PathLike) => String(path));
|
||||||
readCustomProvidersMock.mockReturnValue([]);
|
readCustomProvidersMock.mockReturnValue([]);
|
||||||
findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId }));
|
findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId }));
|
||||||
createAgentSessionMock.mockResolvedValue({
|
createAgentSessionMock.mockResolvedValue({
|
||||||
@@ -174,6 +181,40 @@ describe("createFnAgent prompt layer wiring", () => {
|
|||||||
expect(override).toBe("Stable prefix.");
|
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 () => {
|
it("passes dynamic layer via appendSystemPromptOverride when layers provided", async () => {
|
||||||
const { createFnAgent } = await import("../pi.js");
|
const { createFnAgent } = await import("../pi.js");
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* 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 { exec, execFile } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
@@ -1400,22 +1400,30 @@ async function isRegisteredGitWorktree(projectRoot: string, worktreePath: string
|
|||||||
cwd: projectRoot,
|
cwd: projectRoot,
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
});
|
});
|
||||||
const resolvedWorktree = resolve(worktreePath);
|
const resolvedWorktree = normalizeExistingPathForGitComparison(worktreePath);
|
||||||
return stdout.split("\n").some((line) =>
|
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 {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeExistingPathForGitComparison(path: string): string {
|
||||||
|
try {
|
||||||
|
return realpathSync.native(path);
|
||||||
|
} catch {
|
||||||
|
return resolve(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function isCompleteGitWorktree(worktreePath: string): Promise<boolean> {
|
async function isCompleteGitWorktree(worktreePath: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync("git rev-parse --show-toplevel", {
|
const { stdout } = await execAsync("git rev-parse --show-toplevel", {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
});
|
});
|
||||||
return resolve(stdout.trim()) === resolve(worktreePath);
|
return normalizeExistingPathForGitComparison(stdout.trim()) === normalizeExistingPathForGitComparison(worktreePath);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user