fix(FN-6079): resolve task tools from merge worktrees
This commit is contained in:
3
.changeset/merge-worktree-task-tools.md
Normal file
3
.changeset/merge-worktree-task-tools.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"@runfusion/fusion": patch
|
||||||
|
|
||||||
|
Fix Fusion task tools from AI merge temp worktrees so merger agents can fetch task details without trying to bootstrap a nested project.
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
vi.setConfig({ testTimeout: 20000, hookTimeout: 20000 });
|
vi.setConfig({ testTimeout: 20000, hookTimeout: 20000 });
|
||||||
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
import { TaskStore, getProjectRootFromWorktree } from "@fusion/core";
|
import { TaskStore, getProjectRootFromWorktree } from "@fusion/core";
|
||||||
|
|
||||||
function makeCtx(cwd: string) {
|
function makeCtx(cwd: string) {
|
||||||
@@ -15,6 +16,10 @@ async function loadExtension() {
|
|||||||
return mod.default;
|
return mod.default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function git(cwd: string, args: string): string {
|
||||||
|
return execSync(`git ${args}`, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
||||||
|
}
|
||||||
|
|
||||||
describe("extension task tools resolve repo root from worktrees", () => {
|
describe("extension task tools resolve repo root from worktrees", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
@@ -70,6 +75,54 @@ describe("extension task tools resolve repo root from worktrees", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses canonical project root for task tools from AI merge temp linked worktrees", async () => {
|
||||||
|
const repoRoot = await mkdtemp(join(tmpdir(), "fn-6079-cli-"));
|
||||||
|
const mergeRoot = await mkdtemp(join(tmpdir(), "fusion-ai-merge-fn-6079-"));
|
||||||
|
try {
|
||||||
|
git(repoRoot, "init -q -b main");
|
||||||
|
git(repoRoot, "config user.email test@example.com");
|
||||||
|
git(repoRoot, "config user.name Test");
|
||||||
|
await writeFile(join(repoRoot, "base.txt"), "base\n");
|
||||||
|
git(repoRoot, "add -A");
|
||||||
|
git(repoRoot, "commit -q -m base");
|
||||||
|
|
||||||
|
const store = new TaskStore(repoRoot);
|
||||||
|
await store.init();
|
||||||
|
const created = await store.createTask({ description: "Task visible from merge worktree" });
|
||||||
|
git(repoRoot, `worktree add --detach ${JSON.stringify(mergeRoot)} HEAD`);
|
||||||
|
await mkdir(join(mergeRoot, "packages"), { recursive: true });
|
||||||
|
|
||||||
|
const extension = await loadExtension();
|
||||||
|
const tools = new Map<string, any>();
|
||||||
|
extension({
|
||||||
|
registerTool(def: any) {
|
||||||
|
tools.set(def.name, def);
|
||||||
|
},
|
||||||
|
registerCommand: vi.fn(),
|
||||||
|
registerShortcut: vi.fn(),
|
||||||
|
registerFlag: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const showTool = tools.get("fn_task_show");
|
||||||
|
const listTool = tools.get("fn_task_list");
|
||||||
|
|
||||||
|
const show = await showTool.execute("show", { id: created.id }, undefined, undefined, makeCtx(mergeRoot));
|
||||||
|
const list = await listTool.execute("list", {}, undefined, undefined, makeCtx(join(mergeRoot, "packages")));
|
||||||
|
|
||||||
|
expect(show.content[0].text).toContain("Task visible from merge worktree");
|
||||||
|
expect(list.content[0].text).toContain(created.id);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
git(repoRoot, `worktree remove --force ${JSON.stringify(mergeRoot)}`);
|
||||||
|
} catch {
|
||||||
|
// best effort cleanup
|
||||||
|
}
|
||||||
|
await rm(mergeRoot, { recursive: true, force: true });
|
||||||
|
await rm(repoRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back when getProjectRootFromWorktree is unavailable in no-task context", async () => {
|
it("falls back when getProjectRootFromWorktree is unavailable in no-task context", async () => {
|
||||||
const repoRoot = await mkdtemp(join(tmpdir(), "fn-4927-cli-"));
|
const repoRoot = await mkdtemp(join(tmpdir(), "fn-4927-cli-"));
|
||||||
const worktreeRoot = join(repoRoot, ".worktrees", "ambient");
|
const worktreeRoot = join(repoRoot, ".worktrees", "ambient");
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
import { getProjectRootFromWorktree, resolvePiExtensionProjectRoot } from "../pi-extensions.js";
|
import { getProjectRootFromWorktree, resolvePiExtensionProjectRoot } from "../pi-extensions.js";
|
||||||
|
|
||||||
|
function git(cwd: string, args: string): string {
|
||||||
|
return execSync(`git ${args}`, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
||||||
|
}
|
||||||
|
|
||||||
describe("getProjectRootFromWorktree", () => {
|
describe("getProjectRootFromWorktree", () => {
|
||||||
it("detects POSIX worktree paths", () => {
|
it("detects POSIX worktree paths", () => {
|
||||||
expect(getProjectRootFromWorktree("/repo/.worktrees/fn-001")).toBe("/repo");
|
expect(getProjectRootFromWorktree("/repo/.worktrees/fn-001")).toBe("/repo");
|
||||||
@@ -32,6 +37,35 @@ describe("getProjectRootFromWorktree", () => {
|
|||||||
}),
|
}),
|
||||||
).toBe("/tmp");
|
).toBe("/tmp");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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-"));
|
||||||
|
try {
|
||||||
|
const expectedRoot = realpathSync(root);
|
||||||
|
git(root, "init -q -b main");
|
||||||
|
git(root, "config user.email test@example.com");
|
||||||
|
git(root, "config user.name Test");
|
||||||
|
mkdirSync(join(root, ".fusion"), { recursive: true });
|
||||||
|
writeFileSync(join(root, "base.txt"), "base\n");
|
||||||
|
git(root, "add -A");
|
||||||
|
git(root, "commit -q -m base");
|
||||||
|
git(root, `worktree add --detach ${JSON.stringify(worktreeRoot)} HEAD`);
|
||||||
|
mkdirSync(join(worktreeRoot, "subdir"), { recursive: true });
|
||||||
|
|
||||||
|
expect(getProjectRootFromWorktree(worktreeRoot)).toBe(expectedRoot);
|
||||||
|
expect(getProjectRootFromWorktree(join(worktreeRoot, "subdir"))).toBe(expectedRoot);
|
||||||
|
expect(resolvePiExtensionProjectRoot(worktreeRoot)).toBe(expectedRoot);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
git(root, `worktree remove --force ${JSON.stringify(worktreeRoot)}`);
|
||||||
|
} catch {
|
||||||
|
// best effort cleanup
|
||||||
|
}
|
||||||
|
rmSync(worktreeRoot, { recursive: true, force: true });
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("@fusion/core export surface", () => {
|
describe("@fusion/core export surface", () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { basename, isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
|
||||||
const FUSION_DISABLED_EXTENSIONS_KEY = "fusionDisabledExtensions";
|
const FUSION_DISABLED_EXTENSIONS_KEY = "fusionDisabledExtensions";
|
||||||
|
|
||||||
@@ -66,9 +67,42 @@ export function getProjectRootFromWorktree(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gitLinkedWorktreeRoot = getProjectRootFromGitLinkedWorktree(cwd);
|
||||||
|
if (gitLinkedWorktreeRoot) {
|
||||||
|
return gitLinkedWorktreeRoot;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getProjectRootFromGitLinkedWorktree(cwd: string): string | null {
|
||||||
|
const resolvedCwd = resolve(cwd);
|
||||||
|
const commonDir = spawnSync("git", ["rev-parse", "--git-common-dir"], {
|
||||||
|
cwd: resolvedCwd,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
});
|
||||||
|
const gitDir = spawnSync("git", ["rev-parse", "--git-dir"], {
|
||||||
|
cwd: resolvedCwd,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
});
|
||||||
|
if (commonDir.status !== 0 || gitDir.status !== 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedCommonDir = resolve(resolvedCwd, commonDir.stdout.trim());
|
||||||
|
const resolvedGitDir = resolve(resolvedCwd, gitDir.stdout.trim());
|
||||||
|
if (!resolvedCommonDir || !resolvedGitDir || resolvedCommonDir === resolvedGitDir) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentRoot = resolvedCommonDir.endsWith(`${sep}.git`)
|
||||||
|
? dirname(resolvedCommonDir)
|
||||||
|
: resolvedCommonDir;
|
||||||
|
return existsSync(join(parentRoot, ".fusion")) ? parentRoot : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolvePiExtensionProjectRoot(cwd: string): string {
|
export function resolvePiExtensionProjectRoot(cwd: string): string {
|
||||||
const worktreeProjectRoot = getProjectRootFromWorktree(cwd);
|
const worktreeProjectRoot = getProjectRootFromWorktree(cwd);
|
||||||
if (worktreeProjectRoot && existsSync(join(worktreeProjectRoot, ".fusion"))) {
|
if (worktreeProjectRoot && existsSync(join(worktreeProjectRoot, ".fusion"))) {
|
||||||
|
|||||||
Reference in New Issue
Block a user