fix(FN-6079): resolve task tools from merge worktrees
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"))) {
|
||||
|
||||
Reference in New Issue
Block a user