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 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");
|
||||
|
||||
|
||||
@@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user