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:
committed by
gsxdsm
parent
d475c355e5
commit
1565b6dc52
109
packages/core/src/__tests__/project-root.linked-worktree.test.ts
Normal file
109
packages/core/src/__tests__/project-root.linked-worktree.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user