diff --git a/.changeset/merge-worktree-task-tools.md b/.changeset/merge-worktree-task-tools.md new file mode 100644 index 0000000000..63e1c64f41 --- /dev/null +++ b/.changeset/merge-worktree-task-tools.md @@ -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. diff --git a/packages/cli/src/__tests__/extension-task-tools.test.ts b/packages/cli/src/__tests__/extension-task-tools.test.ts index abcfc92788..7debb9de66 100644 --- a/packages/cli/src/__tests__/extension-task-tools.test.ts +++ b/packages/cli/src/__tests__/extension-task-tools.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; 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 { join } from "node:path"; +import { execSync } from "node:child_process"; import { TaskStore, getProjectRootFromWorktree } from "@fusion/core"; function makeCtx(cwd: string) { @@ -15,6 +16,10 @@ async function loadExtension() { 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", () => { beforeEach(() => { 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(); + 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 () => { const repoRoot = await mkdtemp(join(tmpdir(), "fn-4927-cli-")); const worktreeRoot = join(repoRoot, ".worktrees", "ambient"); diff --git a/packages/core/src/__tests__/pi-extensions.test.ts b/packages/core/src/__tests__/pi-extensions.test.ts index 1931ffec9d..f85fda17fe 100644 --- a/packages/core/src/__tests__/pi-extensions.test.ts +++ b/packages/core/src/__tests__/pi-extensions.test.ts @@ -1,9 +1,14 @@ 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 { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; 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", () => { it("detects POSIX worktree paths", () => { expect(getProjectRootFromWorktree("/repo/.worktrees/fn-001")).toBe("/repo"); @@ -32,6 +37,35 @@ describe("getProjectRootFromWorktree", () => { }), ).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", () => { diff --git a/packages/core/src/pi-extensions.ts b/packages/core/src/pi-extensions.ts index 6118bf64f5..73e527c06b 100644 --- a/packages/core/src/pi-extensions.ts +++ b/packages/core/src/pi-extensions.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; 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"; @@ -66,9 +67,42 @@ export function getProjectRootFromWorktree( } } + const gitLinkedWorktreeRoot = getProjectRootFromGitLinkedWorktree(cwd); + if (gitLinkedWorktreeRoot) { + return gitLinkedWorktreeRoot; + } + 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 { const worktreeProjectRoot = getProjectRootFromWorktree(cwd); if (worktreeProjectRoot && existsSync(join(worktreeProjectRoot, ".fusion"))) {