feat(FN-5188): complete Step 1 — linked worktree refusal

Fusion-Task-Id: FN-5188
Fusion-Task-Lineage: 6d238ff1-2d69-44e7-8694-ec19e412f8ef
This commit is contained in:
Fusion (runfusion.ai)
2026-05-19 12:21:21 -07:00
committed by gsxdsm
parent d475c355e5
commit 1565b6dc52
4 changed files with 177 additions and 1 deletions

View File

@@ -0,0 +1,109 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { execSync } from "node:child_process";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { LinkedWorktreeBootstrapRefusedError } from "../project-root-guard.js";
import { TaskStore } from "../store.js";
function git(command: string, cwd: string): string {
return execSync(command, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
}
describe("linked worktree bootstrap guard", () => {
const originalVitest = process.env.VITEST;
const originalOptIn = process.env.FUSION_TEST_LINKED_WORKTREE_GUARD;
const originalAllowNested = process.env.FUSION_ALLOW_NESTED_PROJECT;
let tempDir: string;
beforeEach(() => {
process.env.VITEST = "true";
process.env.FUSION_TEST_LINKED_WORKTREE_GUARD = "1";
delete process.env.FUSION_ALLOW_NESTED_PROJECT;
tempDir = mkdtempSync(join(tmpdir(), "fn-linked-worktree-guard-"));
});
afterEach(() => {
if (originalVitest === undefined) delete process.env.VITEST;
else process.env.VITEST = originalVitest;
if (originalOptIn === undefined) delete process.env.FUSION_TEST_LINKED_WORKTREE_GUARD;
else process.env.FUSION_TEST_LINKED_WORKTREE_GUARD = originalOptIn;
if (originalAllowNested === undefined) delete process.env.FUSION_ALLOW_NESTED_PROJECT;
else process.env.FUSION_ALLOW_NESTED_PROJECT = originalAllowNested;
rmSync(tempDir, { recursive: true, force: true });
});
function setupRepoWithLinkedWorktree(): { repoDir: string; worktreePath: string } {
const repoDir = join(tempDir, "repo");
mkdirSync(repoDir, { recursive: true });
git("git init --initial-branch=main", repoDir);
git('git config user.name "Fusion Test"', repoDir);
git('git config user.email "test@example.com"', repoDir);
writeFileSync(join(repoDir, "README.md"), "root\n");
git("git add README.md", repoDir);
git('git commit -m "init"', repoDir);
const worktreePath = join(tempDir, "repo-worktree");
git(`git worktree add -b feature/test ${worktreePath}`, repoDir);
return { repoDir, worktreePath };
}
it("refuses TaskStore bootstrap inside a linked worktree when the parent project already exists", () => {
const { repoDir, worktreePath } = setupRepoWithLinkedWorktree();
mkdirSync(join(repoDir, ".fusion"), { recursive: true });
writeFileSync(join(repoDir, ".fusion", "fusion.db"), "");
expect(() => new TaskStore(worktreePath)).toThrow(LinkedWorktreeBootstrapRefusedError);
expect(() => new TaskStore(worktreePath)).toThrow(
expect.objectContaining({
message: expect.stringContaining(worktreePath),
}),
);
try {
new TaskStore(worktreePath);
} catch (error) {
expect(error).toBeInstanceOf(LinkedWorktreeBootstrapRefusedError);
expect((error as Error).message).toContain(worktreePath);
expect((error as Error).message).toContain(repoDir);
expect((error as Error).message).toContain("FUSION_ALLOW_NESTED_PROJECT=1");
return;
}
throw new Error("Expected linked-worktree bootstrap refusal");
});
it("allows bootstrap when the nested-project escape hatch is set", () => {
const { repoDir, worktreePath } = setupRepoWithLinkedWorktree();
mkdirSync(join(repoDir, ".fusion"), { recursive: true });
writeFileSync(join(repoDir, ".fusion", "fusion.db"), "");
process.env.FUSION_ALLOW_NESTED_PROJECT = "1";
const store = new TaskStore(worktreePath, dirname(worktreePath), { inMemoryDb: true });
store.close();
});
it("allows bootstrap when the parent repo has no Fusion project", () => {
const { worktreePath } = setupRepoWithLinkedWorktree();
const store = new TaskStore(worktreePath, dirname(worktreePath), { inMemoryDb: true });
store.close();
});
it("allows bootstrap outside git repositories", () => {
const plainDir = join(tempDir, "plain");
mkdirSync(plainDir, { recursive: true });
const store = new TaskStore(plainDir, dirname(plainDir), { inMemoryDb: true });
store.close();
});
it("allows bootstrap from the main worktree even when it already has a Fusion project", () => {
const repoDir = join(tempDir, "repo-main");
mkdirSync(repoDir, { recursive: true });
git("git init --initial-branch=main", repoDir);
mkdirSync(join(repoDir, ".fusion"), { recursive: true });
writeFileSync(join(repoDir, ".fusion", "fusion.db"), "");
expect(existsSync(join(repoDir, ".git"))).toBe(true);
const store = new TaskStore(repoDir, dirname(repoDir), { inMemoryDb: true });
store.close();
});
});

View File

@@ -230,6 +230,11 @@ export {
MasterKeyPermissionError,
MasterKeyCorruptError,
} from "./master-key.js";
export {
assertNotLinkedWorktreeOfExistingProject,
assertProjectRootDir,
LinkedWorktreeBootstrapRefusedError,
} from "./project-root-guard.js";
export { discoverPiExtensions, formatPiExtensionSource, getEnabledPiExtensionPaths, getFusionAgentDir, getFusionAgentSettingsPath, getLegacyPiAgentDir, getPiExtensionDiscoveryDirs, getProjectRootFromWorktree, reconcileClaudeCliPaths, reconcileDroidCliPaths, resolvePiExtensionProjectRoot, updatePiExtensionDisabledIds } from "./pi-extensions.js";
export type { PiExtensionEntry, PiExtensionSettings, PiExtensionSource } from "./pi-extensions.js";
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";

View File

@@ -4,8 +4,21 @@
* nested `.fusion/.fusion` tree we want to fail loudly on.
*/
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
const FUSION_DIR_SUFFIX = /(?:^|[\\/])\.fusion(?:[\\/])?$/;
export class LinkedWorktreeBootstrapRefusedError extends Error {
constructor(cwd: string, parentRoot: string) {
super(
`Refusing to bootstrap a Fusion project at ${cwd}: this is a linked worktree of ${parentRoot}, which already has a Fusion project at ${parentRoot}/.fusion. Run from the parent, or set FUSION_ALLOW_NESTED_PROJECT=1 to override.`,
);
this.name = "LinkedWorktreeBootstrapRefusedError";
}
}
export function assertProjectRootDir(rootDir: string, caller: string): void {
if (FUSION_DIR_SUFFIX.test(rootDir)) {
throw new Error(
@@ -14,3 +27,48 @@ export function assertProjectRootDir(rootDir: string, caller: string): void {
);
}
}
export function assertNotLinkedWorktreeOfExistingProject(rootDir: string, _caller: string): void {
const resolvedRootDir = resolve(rootDir);
if (existsSync(join(resolvedRootDir, ".fusion", "fusion.db"))) {
return;
}
if (
process.env.VITEST === "true"
&& process.env.FUSION_TEST_LINKED_WORKTREE_GUARD !== "1"
) {
return;
}
if (process.env.FUSION_ALLOW_NESTED_PROJECT === "1") {
return;
}
const gitCommonDir = spawnSync("git", ["rev-parse", "--git-common-dir"], {
cwd: resolvedRootDir,
encoding: "utf8",
});
const gitDir = spawnSync("git", ["rev-parse", "--git-dir"], {
cwd: resolvedRootDir,
encoding: "utf8",
});
if (gitCommonDir.status !== 0 || gitDir.status !== 0) {
return;
}
const resolvedCommonDir = resolve(resolvedRootDir, gitCommonDir.stdout.trim());
const resolvedGitDir = resolve(resolvedRootDir, gitDir.stdout.trim());
if (resolvedCommonDir === resolvedGitDir) {
return;
}
const parentRoot = resolvedCommonDir.endsWith(`${join("", ".git")}`)
? dirname(resolvedCommonDir)
: resolvedCommonDir;
if (!existsSync(join(parentRoot, ".fusion", "fusion.db"))) {
return;
}
throw new LinkedWorktreeBootstrapRefusedError(resolvedRootDir, parentRoot);
}

View File

@@ -41,7 +41,10 @@ import { extractTaskIdTokens, normalizeTitleForTaskId } from "./task-title-id-dr
import { resolveTitleSummarizerSettingsModel } from "./model-resolution.js";
import { getErrorMessage } from "./error-message.js";
import { getTaskCreatedHook } from "./task-creation-hooks.js";
import { assertProjectRootDir } from "./project-root-guard.js";
import {
assertNotLinkedWorktreeOfExistingProject,
assertProjectRootDir,
} from "./project-root-guard.js";
import { generateTaskLineageId, normalizeTaskCommitAssociation } from "./task-lineage.js";
import { createDistributedTaskIdAllocator, reconcileTaskIdState, resolveLocalNodeId, type DistributedTaskIdAllocator } from "./distributed-task-id.js";
import { detectStalledReview } from "./stalled-review-detector.js";
@@ -955,6 +958,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
super();
this.setMaxListeners(100);
assertProjectRootDir(rootDir, "TaskStore");
assertNotLinkedWorktreeOfExistingProject(rootDir, "TaskStore");
this.fusionDir = join(rootDir, ".fusion");
this.tasksDir = join(this.fusionDir, "tasks");
this.configPath = join(this.fusionDir, "config.json");