fix(FN-6079): resolve task tools from merge worktrees

This commit is contained in:
gsxdsm
2026-06-09 08:19:41 -07:00
parent a2f4bb14c0
commit 85c3420f93
4 changed files with 127 additions and 3 deletions

View File

@@ -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<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 () => {
const repoRoot = await mkdtemp(join(tmpdir(), "fn-4927-cli-"));
const worktreeRoot = join(repoRoot, ".worktrees", "ambient");

View File

@@ -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", () => {

View File

@@ -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"))) {