FN-9162: Group workspace worktrees under configurable roots
Configure collision-safe workspace and repository subfolders beneath custom worktree roots. - add shared workspace layout helpers and export them through runtime and gate barrels - route acquisition, pinning, cleanup, archive, and extension discovery through grouped paths - allow absolute worktree roots in settings and document the workspace layout - protect shared-root containers from destructive sweeps and cover multi-repository behavior Files changed: .changeset/fn-9162-workspace-worktree-root.md | 7 ++ docs/settings-reference.md | 2 +- docs/workspaces.md | 6 ++ .../extension-workspace-worktree-root.test.ts | 81 +++++++++++++++++ packages/cli/src/extension.ts | 53 ++++++++++- packages/core/src/__tests__/pi-extensions.test.ts | 46 +++++++++- .../core/src/__tests__/worktree-layout.test.ts | 49 ++++++++++ packages/core/src/index.gate.ts | 13 +++ packages/core/src/index.ts | 9 ++ packages/core/src/plugins/pi-extensions.ts | 12 ++- packages/core/src/task-store/archive-lifecycle.ts | 11 +-- packages/core/src/tasks/worktree-layout.ts | 77 ++++++++++++++++ .../dashboard/app/components/SettingsModal.tsx | 4 +- .../settings/sections/WorktreesSection.tsx | 2 +- .../worktree-acquisition-workspace.test.ts | 55 ++++++++++- .../engine/src/__tests__/worktree-paths.test.ts | 19 +++- .../engine/src/__tests__/worktree-pinning.test.ts | 17 ++++ .../engine/src/__tests__/worktree-pool.test.ts | 19 +++- .../src/executor/workspace-main-checkout-guard.ts | 2 +- packages/engine/src/self-healing.ts | 20 +++- .../engine/src/worktree/worktree-acquisition.ts | 101 +++++++++++++++------ packages/engine/src/worktree/worktree-names.ts | 14 +-- packages/engine/src/worktree/worktree-paths.ts | 75 ++++++++++++--- packages/engine/src/worktree/worktree-pinning.ts | 10 +- packages/engine/src/worktree/worktree-pool.ts | 44 ++++++--- 25 files changed, 656 insertions(+), 92 deletions(-) Fusion-Task-Id: FN-9162 Fusion-Task-Lineage: b5f5e247-577c-4357-9ffe-97e2447b4fd4 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-9162-workspace-worktree-root.md
Normal file
7
.changeset/fn-9162-workspace-worktree-root.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Group workspace worktrees beneath configurable workspace roots.
|
||||
category: feature
|
||||
dev: Native workspace worktrees use deterministic workspace and repository path segments.
|
||||
@@ -613,7 +613,7 @@ When `pushAfterMerge` is enabled, Fusion first tries a working-tree-independent
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `worktreesDir` | `string` | `undefined` | Optional container directory for task worktrees. Supports absolute paths, project-relative paths, `~` expansion, and `{repo}` token substitution (project root basename). Defaults to `<projectRoot>/.worktrees` when unset and applies to newly-created worktrees/pool scans. When `worktrunk.enabled` is `true`, worktrunk-managed layout takes precedence and this directory is ignored until worktrunk is disabled. |
|
||||
| `worktreesDir` | `string` | `undefined` | Optional container directory for task worktrees. Supports absolute paths, project-relative paths, `~` expansion, and `{repo}` token substitution. For workspace projects, a configured root groups native member checkouts as `<root>/<workspace>/<repo>/<name>`; safe workspace names are preserved and unsafe/nested segments gain deterministic hash suffixes. The default unset workspace layout is unchanged. Equal workspace basenames sharing one external root fail closed rather than collide. When `worktrunk.enabled` is `true`, worktrunk-managed layout takes precedence. |
|
||||
| `worktrunk.enabled` | `boolean` | `false` | Enables the worktrunk backend (`WorktreeBackend`) for worktree operations. When enabled, worktrunk layout supersedes Fusion’s `.worktrees/<task-id>` and `worktreesDir` behavior. This key exists in global and project settings; project values override global values for matching fields. Setting this to `true` is rejected by the settings API and CLI until the pinned `wt` binary resolves and probe-verifies. Install first via Settings → Worktrunk integration (or `GET /api/worktrunk/status` + `POST /api/worktrunk/install-request`). Auto-install remains fail-closed until the upstream manifest is human-verified, so the default placeholder manifest will not fabricate a binary. See [Architecture: WorktreeBackend abstraction](./architecture.md#worktreebackend-abstraction). |
|
||||
| `worktrunk.binaryPath` | `string \| undefined` | `undefined` | Optional absolute override for the `wt` binary. When unset, Fusion probes `wt` on `$PATH`, then checks the cached install path, and only then considers the gated auto-install flow. Auto-install is currently fail-closed until the upstream manifest is human-verified, so operators who enable `worktrunk.enabled` should set `worktrunk.binaryPath` or install `wt` themselves. When enabling `worktrunk.enabled`, this resolved/overridden path is still probe-verified before the setting is accepted. |
|
||||
| `worktrunk.onFailure` | `"fail" \| "fallback-native"` | `"fail"` | Failure behavior for delegated worktrunk operations. `"fail"` (default) pauses the task with `pausedReason: "worktrunk_operation_failed"` and surfaces worktrunk stderr via `task.worktrunkFailure`. `"fallback-native"` switches to the native backend and emits a one-shot dashboard fallback alert per task (`task.worktrunkFallbackAlertedAt`). |
|
||||
|
||||
@@ -133,3 +133,9 @@ A branch-gone member without landing proof requires manual intervention. Inspect
|
||||
### Workspace mode appears to re-enable after being toggled off
|
||||
|
||||
Check `.fusion/config.json`: explicit `workspaceMode: false` is the guard that suppresses automatic detection. Also inspect `.fusion/workspace.json`; the setting and member configuration are separate artifacts. Re-register or update the configuration deliberately if the project was previously detected as a workspace.
|
||||
|
||||
## Worktree layout
|
||||
|
||||
When `worktreesDir` is unset, workspace members keep the existing `<member>/.worktrees/<name>` layout. When it is configured, Fusion resolves the configured root once from the workspace root and creates each native member checkout at `<configured-root>/<workspace>/<repo>/<name>`. A safe workspace directory basename is preserved verbatim; unsafe names use a sanitized segment plus a deterministic eight-character hash. Nested or unsafe member paths use the same sanitized-and-hashed rule, preventing flattened-name collisions.
|
||||
|
||||
Fusion writes `.fusion-workspace-root` only while acquiring an external shared root. It rejects a second, different workspace root with the same safe basename rather than sharing the group; configure another root or rename one workspace. The marker never resolves paths and is only a deletion veto. Recorded worktree paths remain authoritative, so existing checkouts are not migrated. Grouped paths are forward-derived and never converted back to a project root by parent trimming. `.ai-merge` remains at the ungrouped configured root. Workspace directory sweeps do not reclaim by walking groups; archive and workspace recovery reclaim recorded member paths addressably.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import {
|
||||
WORKSPACE_GROUP_MARKER_FILENAME,
|
||||
workspaceRepoSegment,
|
||||
workspaceWorktreeGroupSegment,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
__resolveProjectRootForTesting,
|
||||
clearHostTaskStores,
|
||||
closeCachedStores,
|
||||
setHostTaskStore,
|
||||
} from "../extension.js";
|
||||
|
||||
function git(cwd: string, args: string[]): string {
|
||||
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await closeCachedStores();
|
||||
clearHostTaskStores();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("grouped workspace extension root resolution", () => {
|
||||
/*
|
||||
FNXC:WorkspaceWorktree 2026-08-20-02:23:
|
||||
Workspace members are independent Git repositories, so their linked checkout
|
||||
cannot discover the host through a local `.fusion` walk. The separately loaded
|
||||
Pi module must consume the host registry's forward-derived candidate and return
|
||||
the workspace root, never the member, grouped container, or checkout.
|
||||
*/
|
||||
it("resolves a member checkout through the host registry in a separately evaluated extension module", async () => {
|
||||
const workspaceRoot = mkdtempSync(join(tmpdir(), "PRD-1234-my-slug-"));
|
||||
const sharedRoot = join(dirname(workspaceRoot), "fn-9162-shared-worktrees");
|
||||
const apiRoot = join(workspaceRoot, "api");
|
||||
const worktreeDir = join(
|
||||
sharedRoot,
|
||||
workspaceWorktreeGroupSegment(workspaceRoot),
|
||||
workspaceRepoSegment("api"),
|
||||
"fn-9162",
|
||||
);
|
||||
|
||||
try {
|
||||
mkdirSync(join(workspaceRoot, ".fusion"), { recursive: true });
|
||||
writeFileSync(join(workspaceRoot, ".fusion", "config.json"), JSON.stringify({
|
||||
settings: { workspaceMode: true, worktreesDir: sharedRoot },
|
||||
}));
|
||||
writeFileSync(join(workspaceRoot, ".fusion", "workspace.json"), JSON.stringify({ repos: ["api"] }));
|
||||
mkdirSync(apiRoot, { recursive: true });
|
||||
git(apiRoot, ["init", "-q", "-b", "main"]);
|
||||
git(apiRoot, ["config", "user.email", "test@example.com"]);
|
||||
git(apiRoot, ["config", "user.name", "Test"]);
|
||||
writeFileSync(join(apiRoot, "base.txt"), "base\n");
|
||||
git(apiRoot, ["add", "base.txt"]);
|
||||
git(apiRoot, ["commit", "-q", "-m", "base"]);
|
||||
mkdirSync(dirname(worktreeDir), { recursive: true });
|
||||
git(apiRoot, ["worktree", "add", "--detach", worktreeDir, "HEAD"]);
|
||||
writeFileSync(join(sharedRoot, workspaceWorktreeGroupSegment(workspaceRoot), WORKSPACE_GROUP_MARKER_FILENAME), resolve(workspaceRoot));
|
||||
|
||||
setHostTaskStore(workspaceRoot, { id: "workspace-host" } as never);
|
||||
vi.resetModules();
|
||||
const isolated = await import("../extension.js");
|
||||
|
||||
expect(isolated.__resolveProjectRootForTesting(join(worktreeDir, "src"))).toBe(resolve(workspaceRoot));
|
||||
expect(isolated.__resolveProjectRootForTesting(worktreeDir)).not.toBe(resolve(apiRoot));
|
||||
expect(isolated.__resolveProjectRootForTesting(worktreeDir)).not.toBe(dirname(dirname(worktreeDir)));
|
||||
} finally {
|
||||
try {
|
||||
git(apiRoot, ["worktree", "remove", "--force", worktreeDir]);
|
||||
} catch {
|
||||
// The fixture may fail before Git records its linked checkout.
|
||||
}
|
||||
rmSync(sharedRoot, { recursive: true, force: true });
|
||||
rmSync(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
MAX_TASK_LIST_TEXT_CHARS,
|
||||
resolveSecretAccessPolicy,
|
||||
getProjectRootFromWorktree,
|
||||
resolveWorktreesDirLayout,
|
||||
resolveTaskGithubTracking,
|
||||
formatCurrentTaskLine,
|
||||
resolveFusionSessionPrincipal,
|
||||
@@ -92,7 +93,7 @@ import {
|
||||
import * as dashboard from "@fusion/dashboard";
|
||||
import { resolve, relative, isAbsolute, sep, basename, extname, join } from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
@@ -209,9 +210,42 @@ const MIME_TYPES: Record<string, string> = {
|
||||
|
||||
let warnedMissingProjectRootResolver = false;
|
||||
|
||||
type WorkspaceRootConfig = { settings?: { worktreesDir?: string; workspaceMode?: boolean } };
|
||||
type WorkspaceReposConfig = { repos?: unknown };
|
||||
|
||||
/*
|
||||
FNXC:WorkspaceWorktree 2026-08-20-01:46:
|
||||
Extension sessions launched from grouped member checkouts must use a project root
|
||||
provided by a forward-derived layout candidate. The hash-bearing group segment is
|
||||
one-way, so parent trimming can select a non-project directory and is forbidden.
|
||||
*/
|
||||
function getKnownWorktreeCandidates(): Array<{ dir: string; projectRoot: string }> {
|
||||
const candidates: Array<{ dir: string; projectRoot: string }> = [];
|
||||
for (const projectRoot of knownProjectRoots) {
|
||||
try {
|
||||
const config = JSON.parse(readFileSync(join(projectRoot, ".fusion", "config.json"), "utf8")) as WorkspaceRootConfig;
|
||||
const settings = config.settings;
|
||||
candidates.push({ dir: resolveWorktreesDirLayout(projectRoot, settings), projectRoot });
|
||||
if (settings?.workspaceMode !== true) continue;
|
||||
const workspace = JSON.parse(readFileSync(join(projectRoot, ".fusion", "workspace.json"), "utf8")) as WorkspaceReposConfig;
|
||||
if (!Array.isArray(workspace.repos)) continue;
|
||||
for (const repoRelPath of workspace.repos) {
|
||||
if (typeof repoRelPath !== "string") continue;
|
||||
candidates.push({
|
||||
dir: resolveWorktreesDirLayout(projectRoot, settings, { workspaceRootDir: projectRoot, repoRelPath }),
|
||||
projectRoot,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// A missing or malformed local config cannot prove a workspace root.
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function resolveProjectRoot(cwd: string): string {
|
||||
const worktreeProjectRoot = typeof getProjectRootFromWorktree === "function"
|
||||
? getProjectRootFromWorktree(cwd)
|
||||
? getProjectRootFromWorktree(cwd, { worktreesDirCandidates: getKnownWorktreeCandidates() })
|
||||
: null;
|
||||
if (typeof getProjectRootFromWorktree !== "function" && !warnedMissingProjectRootResolver) {
|
||||
warnedMissingProjectRootResolver = true;
|
||||
@@ -235,6 +269,16 @@ function resolveProjectRoot(cwd: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkspaceWorktree 2026-08-20-02:23:
|
||||
Keep the production root-resolution route directly testable so a separately
|
||||
loaded Pi extension proves it reads the host's shared known-project registry
|
||||
for grouped workspace member checkouts instead of stopping at the member repo.
|
||||
*/
|
||||
export function __resolveProjectRootForTesting(cwd: string): string {
|
||||
return resolveProjectRoot(cwd);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PostgresCutover 2026-07-04-00:00:
|
||||
The agent-tool store path must boot the PostgreSQL backend (embedded by default, or external via DATABASE_URL) instead of constructing a legacy SQLite TaskStore, whose runtime was removed under VAL-REMOVAL-005. Mirrors the fn serve boot path, which already routes through createTaskStoreForBackend. The boot result is cached per project root so the connection pool (and any embedded PostgreSQL process) is released deterministically in closeCachedStores.
|
||||
@@ -259,6 +303,7 @@ interface ExtensionStoreState {
|
||||
readonly cache: Map<string, CachedStoreEntry>;
|
||||
readonly bootInflight: Map<string, Promise<TaskStore>>;
|
||||
readonly bootFailureCooldown: Map<string, { untilMs: number; error: string }>;
|
||||
readonly knownProjectRoots: Set<string>;
|
||||
}
|
||||
|
||||
const extensionStoreStateKey = Symbol.for("@runfusion/fusion/extension-store-state");
|
||||
@@ -267,11 +312,14 @@ const extensionStoreState = extensionStoreGlobal[extensionStoreStateKey] ?? {
|
||||
cache: new Map<string, CachedStoreEntry>(),
|
||||
bootInflight: new Map<string, Promise<TaskStore>>(),
|
||||
bootFailureCooldown: new Map<string, { untilMs: number; error: string }>(),
|
||||
knownProjectRoots: new Set<string>(),
|
||||
};
|
||||
extensionStoreGlobal[extensionStoreStateKey] = extensionStoreState;
|
||||
|
||||
/** Cache stores per project root to avoid re-booting the backend on every tool call. */
|
||||
const storeCache = extensionStoreState.cache;
|
||||
/* FNXC:WorkspaceWorktree 2026-08-20-01:46: Keep grouped-layout candidates visible to Pi's separately evaluated extension module. */
|
||||
const knownProjectRoots = extensionStoreState.knownProjectRoots;
|
||||
/*
|
||||
FNXC:MergeQueue 2026-07-15-11:08:
|
||||
Concurrent first-call fn_* tools must share one boot promise. Without this, two parallel cache misses each call createTaskStoreForBackend and contend on fusion:schema-applier advisory locks / pool setup — the pattern behind wedged fn_task_show during AI merge.
|
||||
@@ -573,6 +621,7 @@ async function getStore(
|
||||
* The entry is external: closeCachedStores / clearHostTaskStores will not shut it down — the host owns lifecycle.
|
||||
*/
|
||||
export function setHostTaskStore(projectRoot: string, store: TaskStore): void {
|
||||
knownProjectRoots.add(resolve(projectRoot));
|
||||
// FNXC:WorkflowLifecycle 2026-07-16-10:00: Install before caching an injected host store because getStore returns cached stores without a construction pass; this preserves executor-less archive cleanup during host startup.
|
||||
installBaselineArchiveWorktreeDisposer(store, {rootDir: projectRoot, getSettings: () => store.getSettings()});
|
||||
const canonical = resolveProjectRoot(projectRoot);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
import {
|
||||
workspaceRepoSegment,
|
||||
workspaceWorktreeGroupSegment,
|
||||
} from "../tasks/worktree-layout.js";
|
||||
import { getProjectRootFromWorktree, resolvePiExtensionProjectRoot } from "../plugins/pi-extensions.js";
|
||||
|
||||
function git(cwd: string, args: string): string {
|
||||
@@ -38,6 +42,46 @@ describe("getProjectRootFromWorktree", () => {
|
||||
).toBe("/tmp");
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:WorkspaceWorktree 2026-08-20-02:04:
|
||||
A grouped checkout has two one-way segments between its configured root and
|
||||
worktree. Pi must receive the known workspace root as a forward-derived
|
||||
candidate, never infer it by trimming those path components.
|
||||
*/
|
||||
it("resolves a real grouped workspace checkout to the supplied workspace root", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "PRD-9162 unsafe root "));
|
||||
const sharedRoot = join(dirname(root), "fn-9162-shared-worktrees");
|
||||
const candidateDir = join(
|
||||
sharedRoot,
|
||||
workspaceWorktreeGroupSegment(root),
|
||||
workspaceRepoSegment("group/api"),
|
||||
);
|
||||
const worktreeDir = join(candidateDir, "fn-9162");
|
||||
try {
|
||||
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");
|
||||
mkdirSync(candidateDir, { recursive: true });
|
||||
git(root, `worktree add --detach ${JSON.stringify(worktreeDir)} HEAD`);
|
||||
|
||||
expect(getProjectRootFromWorktree(join(worktreeDir, "src"), {
|
||||
worktreesDirCandidates: [{ dir: candidateDir, projectRoot: root }],
|
||||
})).toBe(resolve(root));
|
||||
} finally {
|
||||
try {
|
||||
git(root, `worktree remove --force ${JSON.stringify(worktreeDir)}`);
|
||||
} catch {
|
||||
// Best-effort cleanup after an incomplete real-git fixture.
|
||||
}
|
||||
rmSync(sharedRoot, { recursive: true, force: true });
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null without throwing when child_process partial mocks omit spawnSync", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("node:child_process", () => ({
|
||||
|
||||
49
packages/core/src/__tests__/worktree-layout.test.ts
Normal file
49
packages/core/src/__tests__/worktree-layout.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertWorkspaceRepoRelPath,
|
||||
resolveWorktreesDirLayout,
|
||||
sanitizePathSegment,
|
||||
workspaceRepoSegment,
|
||||
workspaceWorktreeGroupSegment,
|
||||
} from "../tasks/worktree-layout.js";
|
||||
|
||||
describe("workspace worktree layout", () => {
|
||||
const workspace = "/tmp/PRD-1234-my-slug";
|
||||
const context = { workspaceRootDir: workspace, repoRelPath: "api" };
|
||||
|
||||
it("keeps the unset layout byte-identical", () => {
|
||||
expect(resolveWorktreesDirLayout("/tmp/repo", undefined)).toBe("/tmp/repo/.worktrees");
|
||||
expect(resolveWorktreesDirLayout(join(workspace, "api"), undefined, context)).toBe(join(workspace, "api", ".worktrees"));
|
||||
});
|
||||
|
||||
it("resolves configured roots once at the workspace and groups repositories", () => {
|
||||
expect(resolveWorktreesDirLayout(join(workspace, "api"), { worktreesDir: "../trees/{repo}" } as any, context))
|
||||
.toBe(resolve(workspace, "../trees/PRD-1234-my-slug/PRD-1234-my-slug/api"));
|
||||
expect(resolveWorktreesDirLayout(join(workspace, "api"), { worktreesDir: "/var/tmp/trees" } as any, context))
|
||||
.toBe("/var/tmp/trees/PRD-1234-my-slug/api");
|
||||
expect(resolveWorktreesDirLayout(join(workspace, "api"), { worktreesDir: "~/.trees" } as any, context))
|
||||
.toBe(join(homedir(), ".trees/PRD-1234-my-slug/api"));
|
||||
});
|
||||
|
||||
it("preserves safe workspace names and hashes unsafe names deterministically", () => {
|
||||
expect(workspaceWorktreeGroupSegment(workspace)).toBe("PRD-1234-my-slug");
|
||||
const unsafeRoot = "/tmp/PRD-1234 My Slug";
|
||||
expect(workspaceWorktreeGroupSegment(unsafeRoot)).toBe(`PRD-1234-My-Slug-${createHash("sha256").update(resolve(unsafeRoot)).digest("hex").slice(0, 8)}`);
|
||||
expect(workspaceWorktreeGroupSegment("/tmp/🧪")).toMatch(/^workspace-[a-f0-9]{8}$/);
|
||||
expect(workspaceWorktreeGroupSegment("/a/PRD-1234-my-slug")).toBe(workspaceWorktreeGroupSegment("/b/PRD-1234-my-slug"));
|
||||
});
|
||||
|
||||
it("separates nested repository paths from lossy flattened names", () => {
|
||||
expect(workspaceRepoSegment("group/api")).toMatch(/^group-api-[a-f0-9]{8}$/);
|
||||
expect(workspaceRepoSegment("group/api")).not.toBe(workspaceRepoSegment("group-api"));
|
||||
expect(workspaceRepoSegment("group\\api")).toBe(workspaceRepoSegment("group/api"));
|
||||
});
|
||||
|
||||
it("sanitizes and rejects escaping paths", () => {
|
||||
expect(sanitizePathSegment(".. A/ß ..")).toBe("A");
|
||||
for (const path of ["../api", "/api", "..", ""]) expect(() => assertWorkspaceRepoRelPath(path)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -2327,6 +2327,19 @@ export {
|
||||
} from "./secrets/secrets-sync-passphrase.js";
|
||||
export { suggestTaskPrefix } from "./tasks/task-prefix.js";
|
||||
/*
|
||||
FNXC:WorkspaceWorktree 2026-08-20-02:45:
|
||||
The engine-core gate barrel must expose the shared workspace worktree layout helpers because engine path resolution imports them at runtime.
|
||||
*/
|
||||
export {
|
||||
WORKSPACE_GROUP_MARKER_FILENAME,
|
||||
sanitizePathSegment,
|
||||
assertWorkspaceRepoRelPath,
|
||||
workspaceWorktreeGroupSegment,
|
||||
workspaceRepoSegment,
|
||||
resolveWorktreesDirLayout,
|
||||
} from "./tasks/worktree-layout.js";
|
||||
export type { WorkspaceWorktreeContext } from "./tasks/worktree-layout.js";
|
||||
/*
|
||||
FNXC:WorkflowStepResults 2026-07-19-01:00:
|
||||
Keep this gate-safe barrel's workflow-step-results re-exports in SYNC with the main barrel (index.ts). The `engine-core` vitest project builds its @fusion/core from THIS file (scripts/build-engine-core-gate-bundle.mjs), so any lease/step-result export present in index.ts but missing here resolves to `undefined` ONLY under engine-core — which is exactly how U3's `classifyReviewLease` went missing and threw "classifyReviewLease is not a function" on every defaultOn Plan Review run in that project (caught by task-pipeline-smoke). When adding an export to the index.ts workflow-step-results block, add it here too.
|
||||
*/
|
||||
|
||||
@@ -2866,6 +2866,15 @@ export {
|
||||
export { pruneTaskLifecycleEvents } from "./task-store/task-lifecycle-event-retention.js";
|
||||
|
||||
export { buildConsumerId } from "./task-store/task-lifecycle-consumer-identity.js";
|
||||
export {
|
||||
WORKSPACE_GROUP_MARKER_FILENAME,
|
||||
sanitizePathSegment,
|
||||
assertWorkspaceRepoRelPath,
|
||||
workspaceWorktreeGroupSegment,
|
||||
workspaceRepoSegment,
|
||||
resolveWorktreesDirLayout,
|
||||
} from "./tasks/worktree-layout.js";
|
||||
export type { WorkspaceWorktreeContext } from "./tasks/worktree-layout.js";
|
||||
export type { AgentActivityEventType, AgentActivityAttribution, AgentActivityIdProvenance, AgentActivityIdCandidate, AgentActivityAttributionClaim, AgentActivityMetadataValueSpec, AgentActivityEvent, AgentActivityEventInput, AgentActivityQuery } from "./types/agents/agents.js";
|
||||
export { AGENT_ACTIVITY_EVENT_TYPES, AGENT_ACTIVITY_ATTRIBUTIONS, AGENT_ACTIVITY_LANE_SENTINELS, AGENT_ACTIVITY_GENERATED_ID_PATTERNS, AGENT_ACTIVITY_HANDOFF_REASONS, AGENT_ACTIVITY_TOOL_NAMES, AGENT_ACTIVITY_WORKFLOW_STEP_IDS, AGENT_ACTIVITY_METADATA_SCHEMA, AGENT_ACTIVITY_METADATA_KEYS, isAgentActivityEventType } from "./types/agents/agents.js";
|
||||
export { appendAgentActivityEvent, queryAgentActivityEvents, getMaxAgentActivitySeq, pruneAgentActivityEvents } from "./task-store/async/async-agent-activity.js";
|
||||
|
||||
@@ -50,7 +50,7 @@ export function getFusionAgentSettingsPath(home?: string): string {
|
||||
|
||||
export function getProjectRootFromWorktree(
|
||||
cwd: string,
|
||||
opts?: { worktreesDirCandidates?: string[] },
|
||||
opts?: { worktreesDirCandidates?: Array<string | { dir: string; projectRoot: string }> },
|
||||
): string | null {
|
||||
const knownWorktreePatterns = [
|
||||
/^(.+?)[\\/]\.worktrees[\\/][^\\/]+(?:[\\/]|$)/,
|
||||
@@ -64,16 +64,18 @@ export function getProjectRootFromWorktree(
|
||||
}
|
||||
|
||||
for (const candidate of opts?.worktreesDirCandidates ?? []) {
|
||||
const normalizedCandidate = resolve(candidate);
|
||||
const candidateDir = typeof candidate === "string" ? candidate : candidate.dir;
|
||||
const objectCandidate = typeof candidate === "string" ? undefined : candidate;
|
||||
const normalizedCandidate = resolve(candidateDir);
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const rel = relative(normalizedCandidate, normalizedCwd);
|
||||
if (rel !== "" && !rel.startsWith("..") && !isAbsolute(rel)) {
|
||||
const firstSegment = rel.split(/[\\/]/).filter(Boolean)[0];
|
||||
if (firstSegment) {
|
||||
// Grouped workspace paths are one-way; callers that know the project root provide it explicitly.
|
||||
if (objectCandidate) return objectCandidate.projectRoot;
|
||||
const parent = normalizedCandidate.split(/[\\/]/).slice(0, -1).join("/");
|
||||
if (parent) {
|
||||
return parent;
|
||||
}
|
||||
if (parent && parent !== "." && parent !== normalizedCandidate) return parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,11 @@ import {getErrorMessage} from "../process/error-message.js";
|
||||
import {ArchiveWorkspaceDisposalError, ArchiveWorkspaceDisposalIncompleteError, ArchiveWorkspaceWorktreeDisposerMissingError, getArchiveWorkspaceWorktreeDisposer, getArchiveWorktreeDisposer, type ArchiveWorkspaceDisposalResult, type WorkspaceDisposalPlanEntry} from "../db/archive-worktree-disposer.js";
|
||||
import {acquireWorktreePathReservation, canonicalizeWorktreePath} from "../tasks/worktree-path-reservation.js";
|
||||
import {LiveTaskWorktreeRemovalRefusedError} from "../tasks/task-archive-liveness.js";
|
||||
import {basename, join, resolve} from "node:path";
|
||||
import {homedir} from "node:os";
|
||||
import {join} from "node:path";
|
||||
import {resolveWorktreesDirLayout, type WorkspaceWorktreeContext} from "../tasks/worktree-layout.js";
|
||||
|
||||
function resolveArchiveWorktreesDir(store: TaskStore, configured?: string): string {
|
||||
const value = configured?.replace(/^~(?=$|[\\/])/, homedir()).replaceAll("{repo}", basename(store.rootDir));
|
||||
return value ? resolve(store.rootDir, value) : join(store.rootDir, ".worktrees");
|
||||
function resolveArchiveWorktreesDir(store: TaskStore, configured?: string, workspaceContext?: WorkspaceWorktreeContext): string {
|
||||
return resolveWorktreesDirLayout(store.rootDir, {worktreesDir: configured}, workspaceContext);
|
||||
}
|
||||
|
||||
export async function buildWorkspaceDisposalPlan(store: TaskStore, task: Task): Promise<{plan: WorkspaceDisposalPlanEntry[]; singularDeduplicated: boolean}> {
|
||||
@@ -89,7 +88,7 @@ export async function prepareArchivedWorkspaceWorktrees(store: TaskStore, task:
|
||||
reservations[entry.repoRel] = await acquireWorktreePathReservation({
|
||||
canonicalPath: canonical,
|
||||
rootDir: entry.repoRootDir,
|
||||
worktreesDir: resolveArchiveWorktreesDir({rootDir: entry.repoRootDir} as TaskStore, settings.worktreesDir),
|
||||
worktreesDir: resolveArchiveWorktreesDir(store, settings.worktreesDir, {workspaceRootDir: store.rootDir, repoRelPath: entry.repoRel}),
|
||||
});
|
||||
}
|
||||
return {plan, reservations, singularDeduplicated};
|
||||
|
||||
77
packages/core/src/tasks/worktree-layout.ts
Normal file
77
packages/core/src/tasks/worktree-layout.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, isAbsolute, join, normalize, resolve, sep } from "node:path";
|
||||
import type { Settings } from "../types/settings/settings-scope.js";
|
||||
|
||||
export interface WorkspaceWorktreeContext {
|
||||
workspaceRootDir: string;
|
||||
repoRelPath: string;
|
||||
}
|
||||
|
||||
export const WORKSPACE_GROUP_MARKER_FILENAME = ".fusion-workspace-root";
|
||||
|
||||
/**
|
||||
* FNXC:WorkspaceWorktree 2026-08-20-01:20:
|
||||
* Workspace checkout grouping uses a pure, single-valued segment so acquisition,
|
||||
* containment, reservations, and cleanup derive the same directory. Candidate
|
||||
* directories make ownership undecidable; marker files only reject conflicting
|
||||
* acquisition and are never an input to path resolution.
|
||||
*/
|
||||
export function sanitizePathSegment(raw: string): string {
|
||||
return raw.replace(/[^A-Za-z0-9._-]/g, "-").replace(/-+/g, "-").replace(/^[-.]+|[-.]+$/g, "");
|
||||
}
|
||||
|
||||
function hash8(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex").slice(0, 8);
|
||||
}
|
||||
|
||||
export function workspaceWorktreeGroupSegment(workspaceRootDir: string): string {
|
||||
const resolvedRoot = resolve(workspaceRootDir);
|
||||
const base = basename(resolvedRoot);
|
||||
if (/^[A-Za-z0-9._][A-Za-z0-9._-]*$/.test(base) && base !== "." && base !== "..") return base;
|
||||
const hash = hash8(resolvedRoot);
|
||||
return `${sanitizePathSegment(base) || "workspace"}-${hash}`;
|
||||
}
|
||||
|
||||
/** Reject a user-controlled repo path before it can escape a workspace root. */
|
||||
export function assertWorkspaceRepoRelPath(repoRelPath: string): void {
|
||||
if (typeof repoRelPath !== "string" || repoRelPath.length === 0 || isAbsolute(repoRelPath)) {
|
||||
throw new Error(`Invalid workspace repo path (must be relative and in-root): ${String(repoRelPath)}`);
|
||||
}
|
||||
const normalized = normalize(repoRelPath.replaceAll("\\", "/"));
|
||||
if (normalized === ".." || normalized.startsWith(`..${sep}`) || normalized.startsWith("../")) {
|
||||
throw new Error(`Invalid workspace repo path (escapes workspace root): ${repoRelPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function workspaceRepoSegment(repoRelPath: string): string {
|
||||
assertWorkspaceRepoRelPath(repoRelPath);
|
||||
const normalized = normalize(repoRelPath.replaceAll("\\", "/")).replaceAll("\\", "/");
|
||||
if (/^[A-Za-z0-9._][A-Za-z0-9._-]*$/.test(normalized) && !normalized.includes("/") && normalized !== "." && normalized !== "..") {
|
||||
return normalized;
|
||||
}
|
||||
const flattened = sanitizePathSegment(normalized.split("/").join("-")) || "repo";
|
||||
return `${flattened}-${hash8(normalized)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:WorkspaceWorktree 2026-08-20-01:20:
|
||||
* The unset setting intentionally retains the historic per-repository `.worktrees`
|
||||
* layout. Grouping applies only to an explicitly configured root, while `.ai-merge`
|
||||
* remains resolved separately from the ungrouped root.
|
||||
*/
|
||||
export function resolveWorktreesDirLayout(
|
||||
rootDir: string,
|
||||
settings: Pick<Settings, "worktreesDir"> | undefined,
|
||||
workspaceContext?: WorkspaceWorktreeContext,
|
||||
): string {
|
||||
const configured = settings?.worktreesDir;
|
||||
if (!configured) return join(rootDir, ".worktrees");
|
||||
|
||||
const resolutionRoot = workspaceContext?.workspaceRootDir ?? rootDir;
|
||||
const expandedHome = configured.replace(/^~(?=$|[\\/])/, homedir());
|
||||
const expandedRepo = expandedHome.replaceAll("{repo}", basename(resolutionRoot));
|
||||
const configuredRoot = resolve(resolutionRoot, expandedRepo);
|
||||
if (!workspaceContext) return configuredRoot;
|
||||
return join(configuredRoot, workspaceWorktreeGroupSegment(workspaceContext.workspaceRootDir), workspaceRepoSegment(workspaceContext.repoRelPath));
|
||||
}
|
||||
@@ -1259,7 +1259,7 @@ export function SettingsModal({
|
||||
loading: worktreesDirPickerLoading,
|
||||
error: worktreesDirPickerError,
|
||||
refresh: refreshWorktreesDirPicker,
|
||||
} = useWorkspaceFileBrowser("project", worktreesDirPickerOpen, projectId, { allowAbsolutePaths: false });
|
||||
} = useWorkspaceFileBrowser("project", worktreesDirPickerOpen, projectId, { allowAbsolutePaths: true });
|
||||
|
||||
const {
|
||||
entries: worktreeCopyFilePickerEntries,
|
||||
@@ -3277,8 +3277,6 @@ export function SettingsModal({
|
||||
}, [closeWorktreesDirPicker]);
|
||||
|
||||
const selectCurrentWorktreesDir = useCallback(() => {
|
||||
if (isSlashPrefixedAbsolutePath(worktreesDirPickerCurrentPath)) return;
|
||||
|
||||
const normalizedPath = worktreesDirPickerCurrentPath === "."
|
||||
? "./"
|
||||
: (worktreesDirPickerCurrentPath.endsWith("/") ? worktreesDirPickerCurrentPath : `${worktreesDirPickerCurrentPath}/`);
|
||||
|
||||
@@ -219,7 +219,7 @@ export function WorktreesSection({ form, setForm, gitRemotes, worktrunkInstall,
|
||||
<SettingsHelpTip settingKey="worktreesDir">
|
||||
{form.worktrunk?.enabled === true
|
||||
? "Disabled because Worktrunk integration is enabled — worktrunk manages the worktree directory layout. Disable worktrunk integration to use a custom directory."
|
||||
: <>{t("settings.worktrees.optionalSupports", " Optional. Supports ")}<code>~</code>{t("settings.worktrees.and", " and ")}<code>{"{repo}"}</code>{t("settings.worktrees.defaultsTo", ". Defaults to ")}<code><projectRoot>/.worktrees</code>{t("settings.worktrees.whenUnsetOnlyAffectsNewlyCreatedWorktrees", " when unset. Only affects newly-created worktrees. ")}</>}
|
||||
: <>{t("settings.worktrees.optionalSupports", " Optional. Supports ")}<code>~</code>{t("settings.worktrees.and", " and ")}<code>{"{repo}"}</code>{t("settings.worktrees.defaultsTo", ". Absolute paths are allowed. Workspace projects group configured roots by workspace and repository. Defaults to ")}<code><projectRoot>/.worktrees</code>{t("settings.worktrees.whenUnsetOnlyAffectsNewlyCreatedWorktrees", " when unset. Only affects newly-created worktrees. ")}</>}
|
||||
</SettingsHelpTip>
|
||||
</div>
|
||||
<div className="settings-overlap-ignore-path-controls">
|
||||
|
||||
@@ -9,16 +9,24 @@ root. The TaskStore is an in-memory fake (no DB / no network) per FN-5048 — re
|
||||
git only where the invariant needs it; everything else is a narrow seam.
|
||||
*/
|
||||
import { execSync, spawnSync } from "node:child_process";
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { Settings, Task, TaskStore } from "@fusion/core";
|
||||
import {
|
||||
WORKSPACE_GROUP_MARKER_FILENAME,
|
||||
workspaceRepoSegment,
|
||||
workspaceWorktreeGroupSegment,
|
||||
type Settings,
|
||||
type Task,
|
||||
type TaskStore,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
acquireTaskWorktree,
|
||||
acquireWorkspaceRepoWorktree,
|
||||
WorkspaceRepoAcquireBusyError,
|
||||
} from "../worktree/worktree-acquisition.js";
|
||||
import { ActiveSessionRegistry } from "../agents/active-session-registry.js";
|
||||
import { cleanupOrphanedWorktrees } from "../worktree/worktree-pool.js";
|
||||
import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js";
|
||||
|
||||
const describeIfGit = hasGit ? describe : describe.skip;
|
||||
@@ -546,6 +554,47 @@ describeIfGit("acquireWorkspaceRepoWorktree (U2 per-repo hardening)", { timeout:
|
||||
expect(current().workspaceWorktrees).toBeUndefined();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:WorkspaceWorktree 2026-08-20-02:04:
|
||||
Grouped workspace roots must be proven through real `git worktree add` calls.
|
||||
A path-helper fixture cannot detect the task-id collision that occurs when two
|
||||
member repositories share a configured root.
|
||||
*/
|
||||
it("groups real multi-repo acquisitions and protects their shared-root container from a foreign project sweep", async () => {
|
||||
fixture = await createWorkspaceFixture(["api", "web", "foreign"]);
|
||||
const sharedRoot = join(dirname(fixture.rootDir), "shared-worktrees");
|
||||
const settings = { ...SETTINGS, worktreesDir: sharedRoot };
|
||||
const { store, current } = makeFakeStore(makeTask("FN-9162"));
|
||||
const registry = new ActiveSessionRegistry();
|
||||
|
||||
const api = await acquireWorkspaceRepoWorktree({
|
||||
repoRelPath: "api", workspaceRootDir: fixture.rootDir, task: current(), store, settings, registry,
|
||||
});
|
||||
const web = await acquireWorkspaceRepoWorktree({
|
||||
repoRelPath: "web", workspaceRootDir: fixture.rootDir, task: current(), store, settings, registry,
|
||||
});
|
||||
|
||||
const group = workspaceWorktreeGroupSegment(fixture.rootDir);
|
||||
expect(api.worktreePath).toBe(join(sharedRoot, group, workspaceRepoSegment("api"), "fn-9162"));
|
||||
expect(web.worktreePath).toBe(join(sharedRoot, group, workspaceRepoSegment("web"), "fn-9162"));
|
||||
expect(api.worktreePath).not.toBe(web.worktreePath);
|
||||
expect(existsSync(join(api.worktreePath, ".git"))).toBe(true);
|
||||
expect(existsSync(join(web.worktreePath, ".git"))).toBe(true);
|
||||
expect(readFileSync(join(sharedRoot, group, WORKSPACE_GROUP_MARKER_FILENAME), "utf8").trim()).toBe(fixture.rootDir);
|
||||
|
||||
// A non-workspace project can share the configured root. Its real cleanup
|
||||
// path must not interpret this workspace's group container as an orphan.
|
||||
const cleaned = await cleanupOrphanedWorktrees(
|
||||
fixture.repoPath("foreign"),
|
||||
{ listTasks: async () => [] } as unknown as TaskStore,
|
||||
{ worktreesDir: sharedRoot, workspaceMode: false },
|
||||
);
|
||||
expect(cleaned).toBe(0);
|
||||
expect(existsSync(api.worktreePath)).toBe(true);
|
||||
expect(existsSync(web.worktreePath)).toBe(true);
|
||||
expect(existsSync(join(sharedRoot, group, WORKSPACE_GROUP_MARKER_FILENAME))).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the single-repo acquisition persistence contract when suppression is absent", async () => {
|
||||
fixture = await createWorkspaceFixture(["repo-a"]);
|
||||
const initial = makeTask("FN-10");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { homedir } from "node:os";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import {
|
||||
AI_MERGE_DIRNAME,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
isAiMergeContainerDir,
|
||||
isWorktreeContainerDir,
|
||||
isInsideConfiguredWorktreesDir,
|
||||
isReclaimableWorktreeCandidate,
|
||||
resolveAiMergeRootPath,
|
||||
resolveTaskWorktreePath,
|
||||
resolveTaskWorktreePathForBackend,
|
||||
@@ -74,6 +76,21 @@ describe("worktree-paths", () => {
|
||||
expect(isWorktreeContainerDir(".fusion-recovery-child")).toBe(false);
|
||||
});
|
||||
|
||||
it("vetoes workspace containers and plain directories before a destructive sweep", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "fusion-worktree-paths-"));
|
||||
try {
|
||||
const plain = join(root, "plain");
|
||||
const group = join(root, "workspace-group");
|
||||
await mkdir(plain);
|
||||
await mkdir(group);
|
||||
await writeFile(join(group, ".fusion-workspace-root"), "/workspace");
|
||||
expect(await isReclaimableWorktreeCandidate(plain, { rootDir: root })).toBe(false);
|
||||
expect(await isReclaimableWorktreeCandidate(group, { rootDir: root })).toBe(false);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("detects paths inside and outside configured dir", () => {
|
||||
const dir = resolveWorktreesDir(rootDir, { worktreesDir: "../{repo}.worktrees" } as any);
|
||||
expect(isInsideConfiguredWorktreesDir(rootDir, { worktreesDir: "../{repo}.worktrees" } as any, join(dir, "fn-1"))).toBe(true);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { workspaceRepoSegment, workspaceWorktreeGroupSegment } from "@fusion/core";
|
||||
import {
|
||||
isTaskPinnedWorktreeNaming,
|
||||
pinnedWorktreeSlug,
|
||||
@@ -50,6 +51,22 @@ describe("worktree-pinning", () => {
|
||||
const b = pinnedWorktreePathForTask("FN-9", {}, "/repo");
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("groups task-id paths by workspace and repository under a configured root", () => {
|
||||
const workspaceRoot = "/projects/PRD-1234-my-slug";
|
||||
const settings = { worktreesDir: "/shared/worktrees" };
|
||||
const api = pinnedWorktreePathForTask("FN-9162", settings, join(workspaceRoot, "api"), {
|
||||
workspaceRootDir: workspaceRoot,
|
||||
repoRelPath: "api",
|
||||
});
|
||||
const web = pinnedWorktreePathForTask("FN-9162", settings, join(workspaceRoot, "web"), {
|
||||
workspaceRootDir: workspaceRoot,
|
||||
repoRelPath: "web",
|
||||
});
|
||||
expect(api).toBe(join("/shared/worktrees", workspaceWorktreeGroupSegment(workspaceRoot), workspaceRepoSegment("api"), "fn-9162"));
|
||||
expect(web).toBe(join("/shared/worktrees", workspaceWorktreeGroupSegment(workspaceRoot), workspaceRepoSegment("web"), "fn-9162"));
|
||||
expect(api).not.toBe(web);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preservedWorktreeTargetPathForTask", () => {
|
||||
|
||||
@@ -48,12 +48,20 @@ vi.mock("../worktree/worktree-desktop-artifacts.js", () => ({
|
||||
removeDesktopBuildArtifacts: vi.fn().mockResolvedValue({ removed: [], skipped: [], failures: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../worktree/worktree-paths.js", () => ({
|
||||
isInsideConfiguredWorktreesDir: vi.fn(() => true),
|
||||
isReclaimableWorktreeCandidate: vi.fn().mockResolvedValue(true),
|
||||
isWorktreeContainerDir: vi.fn((name: string) => name === ".ai-merge" || name === ".fusion-recovery"),
|
||||
resolveWorktreesDir: vi.fn((rootDir: string) => `${rootDir}/.worktrees`),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
lstatSync: vi.fn().mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => false }),
|
||||
readdirSync: vi.fn().mockReturnValue([]),
|
||||
readFileSync: vi.fn().mockReturnValue(""),
|
||||
rmSync: vi.fn(),
|
||||
realpathSync: vi.fn((path: string) => path),
|
||||
}));
|
||||
|
||||
vi.mock("../worktree/worktree-prune.js", () => ({
|
||||
@@ -120,6 +128,10 @@ describe("WorktreePool", () => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(desktopArtifacts.removeDesktopBuildArtifacts).mockResolvedValue({ removed: [], skipped: [], failures: [] });
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
// Shared-root reaping must prove both paths use this repository's common gitdir.
|
||||
mockedExecSync.mockImplementation((command: unknown) =>
|
||||
String(command).includes("rev-parse --git-common-dir") ? Buffer.from("/root/.git\n") : Buffer.from(""),
|
||||
);
|
||||
pool = new WorktreePool();
|
||||
});
|
||||
|
||||
@@ -835,6 +847,7 @@ function makeDirEntry(name: string) {
|
||||
|
||||
function mockRegisteredWorktrees(rootDir: string, names: string[]) {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
if (String(cmd).includes("rev-parse --git-common-dir")) return Buffer.from(`${rootDir}/.git\n`);
|
||||
if (String(cmd) === "git worktree list --porcelain") {
|
||||
return [
|
||||
`worktree ${rootDir}`,
|
||||
@@ -1171,7 +1184,7 @@ describe("reapOrphanWorktrees", () => {
|
||||
mockedLstatSync.mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => false } as any);
|
||||
});
|
||||
|
||||
it("excludes internal containers while removing half-initialized task worktrees", async () => {
|
||||
it("excludes containers and unproven half-initialized directories", async () => {
|
||||
mockedReaddirSync.mockReturnValue([
|
||||
makeDirEntry(".ai-merge"),
|
||||
makeDirEntry(".fusion-recovery"),
|
||||
@@ -1180,8 +1193,8 @@ describe("reapOrphanWorktrees", () => {
|
||||
|
||||
const removed = await reapOrphanWorktrees("/root");
|
||||
|
||||
expect(removed).toBe(1);
|
||||
expect(mockedRmSync).toHaveBeenCalledWith("/root/.worktrees/half-built", { recursive: true, force: true });
|
||||
expect(removed).toBe(0);
|
||||
expect(mockedRmSync).not.toHaveBeenCalledWith("/root/.worktrees/half-built", expect.anything());
|
||||
expect(mockedRmSync).not.toHaveBeenCalledWith("/root/.worktrees/.ai-merge", expect.anything());
|
||||
expect(mockedRmSync).not.toHaveBeenCalledWith("/root/.worktrees/.fusion-recovery", expect.anything());
|
||||
});
|
||||
|
||||
@@ -93,7 +93,7 @@ export async function detectWorkspaceMainCheckoutWork(
|
||||
}
|
||||
} catch { skipped.push(repo); continue; }
|
||||
const repoScope = deriveRepoScopeSubset(declaredScope, repo);
|
||||
const worktreesDir = path.resolve(resolveWorktreesDir(checkout, deps.settings));
|
||||
const worktreesDir = path.resolve(resolveWorktreesDir(checkout, deps.settings, { workspaceRootDir: deps.rootDir, repoRelPath: repo }));
|
||||
const excluded = (file: string) => {
|
||||
const absolute = path.resolve(checkout, file);
|
||||
return file === ".fusion" || file.startsWith(".fusion/") || isWithin(absolute, worktreesDir) || recordedPaths.some((candidate) => isWithin(absolute, candidate));
|
||||
|
||||
@@ -103,7 +103,7 @@ import { getTaskCompletionBlockerForStore } from "./execution/task-completion.js
|
||||
import { shouldReclaimWedgedMerge } from "./merge/merge-reclaim-policy.js";
|
||||
|
||||
import { advanceIntegrationBranchRef } from "./merge/merger-ref-update-advance.js";
|
||||
import { isWorktreeContainerDir, resolveAiMergeRootPath, resolveLegacyAiMergeRootPath, resolveWorktreesDir } from "./worktree/worktree-paths.js";
|
||||
import { isReclaimableWorktreeCandidate, isWorktreeContainerDir, resolveAiMergeRootPath, resolveLegacyAiMergeRootPath, resolveWorktreesDir } from "./worktree/worktree-paths.js";
|
||||
import { canonicalFusionBranchName, resolveTaskWorkingBranch } from "./worktree/worktree-names.js";
|
||||
import { preservedWorktreeTargetPathForTask } from "./worktree/worktree-pinning.js";
|
||||
import { resolveIntegrationBranch } from "./merge/integration-branch.js";
|
||||
@@ -16193,6 +16193,10 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
|
||||
*/
|
||||
private async reapUnregisteredOrphans(): Promise<number> {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.workspaceMode === true) {
|
||||
log.debug("[self-healing] skipped workspace unregistered-orphan reap — recorded member paths are reclaimed addressably");
|
||||
return 0;
|
||||
}
|
||||
if (settings.worktrunk?.enabled === true) {
|
||||
log.debug("[self-healing] skipped native unregistered-orphan reap — worktrunk backend owns layout");
|
||||
const backend = resolveWorktreeBackend(settings, { logger: log });
|
||||
@@ -16216,7 +16220,10 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
|
||||
if (dirs.length === 0) return 0;
|
||||
|
||||
const registered = await getRegisteredWorktreePaths(this.options.rootDir);
|
||||
const unregistered = dirs.filter((d) => !registered.has(resolve(d)));
|
||||
const ownedDirs = (await Promise.all(dirs.map(async (dir) =>
|
||||
(await isReclaimableWorktreeCandidate(dir, { rootDir: this.options.rootDir })) ? dir : null,
|
||||
))).filter((dir): dir is string => dir !== null);
|
||||
const unregistered = ownedDirs.filter((dir) => !registered.has(resolve(dir)));
|
||||
|
||||
let cleaned = 0;
|
||||
for (const path of unregistered) {
|
||||
@@ -16513,6 +16520,10 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
|
||||
private async enforceWorktreeCap(): Promise<void> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.workspaceMode === true) {
|
||||
log.debug("[self-healing] skipped workspace worktree cap enforcement — recorded member paths are reclaimed addressably");
|
||||
return;
|
||||
}
|
||||
if (settings.worktrunk?.enabled === true) {
|
||||
log.debug("[self-healing] skipped native worktree cap enforcement — worktrunk backend owns layout");
|
||||
const backend = resolveWorktreeBackend(settings, { logger: log });
|
||||
@@ -16526,7 +16537,10 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
|
||||
const cap = (settings.maxWorktrees ?? 4) * 2;
|
||||
|
||||
const entries = readdirSync(worktreesDir, { withFileTypes: true });
|
||||
const dirs = entries.filter((e) => e.isDirectory() && !isWorktreeContainerDir(e.name));
|
||||
const dirs = (await Promise.all(entries
|
||||
.filter((entry) => entry.isDirectory() && !isWorktreeContainerDir(entry.name))
|
||||
.map(async (entry) => (await isReclaimableWorktreeCandidate(join(worktreesDir, entry.name), { rootDir: this.options.rootDir })) ? entry : null),
|
||||
)).filter((entry): entry is typeof entries[number] => entry !== null);
|
||||
|
||||
if (dirs.length <= cap) return;
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import { existsSync } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { exec } from "node:child_process";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { acquireWorktreePathReservation, canonicalizeWorktreePath, resolveEngineIncarnationId, resolveEngineNodeId, type RunMutationContext, type Settings, type Task, type TaskStore, type SecretsStore, type WorkspaceLeaseHandle } from "@fusion/core";
|
||||
import { acquireWorktreePathReservation, assertWorkspaceRepoRelPath, canonicalizeWorktreePath, resolveEngineIncarnationId, resolveEngineNodeId, workspaceWorktreeGroupSegment, WORKSPACE_GROUP_MARKER_FILENAME, type RunMutationContext, type Settings, type Task, type TaskStore, type SecretsStore, type WorkspaceLeaseHandle, type WorkspaceWorktreeContext } from "@fusion/core";
|
||||
import { generateWorktreeName, resolveTaskWorkingBranch, slugify } from "./worktree-names.js";
|
||||
import { resolveTaskWorktreePathForBackend, resolveWorktreesDir, WORKTREE_RECOVERY_DIRNAME } from "./worktree-paths.js";
|
||||
import { hydrateWorktreeDb } from "./worktree-db-hydrate.js";
|
||||
@@ -125,6 +125,8 @@ export interface AcquireTaskWorktreeOptions {
|
||||
*/
|
||||
/** Suppress singular `worktree` and `branch` persistence for workspace sub-repo acquisition. */
|
||||
suppressSingularWorktreePersist?: boolean;
|
||||
/** Workspace-only layout context; native git operations still use rootDir (the sub-repository). */
|
||||
workspaceContext?: WorkspaceWorktreeContext;
|
||||
}
|
||||
|
||||
export interface AcquireTaskWorktreeResult {
|
||||
@@ -141,6 +143,17 @@ export interface AcquireTaskWorktreeResult {
|
||||
}
|
||||
|
||||
/** A typed refresh refusal: callers must park before creating a coding session. */
|
||||
export class WorkspaceWorktreeGroupConflictError extends Error {
|
||||
constructor(
|
||||
public readonly workspaceRootDir: string,
|
||||
public readonly existingWorkspaceRootDir: string,
|
||||
public readonly groupDir: string,
|
||||
) {
|
||||
super(`Workspace worktree group conflict: ${workspaceRootDir} and ${existingWorkspaceRootDir} resolve to ${groupDir}. Configure a distinct worktreesDir for one of these projects, or rename one workspace directory.`);
|
||||
this.name = "WorkspaceWorktreeGroupConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
export class WorktreeBaseRefreshError extends Error {
|
||||
constructor(public readonly refresh: WorktreeBaseRefreshResult) {
|
||||
super(`Worktree base refresh blocked execution: ${refresh.kind}`);
|
||||
@@ -326,8 +339,44 @@ async function pinnedWorktreeBranchMatches(rootDir: string, worktreePath: string
|
||||
return match?.branch === expectedBranch;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkspaceWorktree 2026-08-20-01:20:
|
||||
A shared configured root needs an acquisition-time owner marker so equal workspace
|
||||
basenames never share a derivable group. The marker rejects conflicts only; all path
|
||||
resolution remains the pure core layout function and sweeps use marker presence only
|
||||
as a deletion veto.
|
||||
*/
|
||||
async function ensureWorkspaceGroupOwnership(
|
||||
workspaceContext: WorkspaceWorktreeContext | undefined,
|
||||
settings: Partial<Settings>,
|
||||
): Promise<void> {
|
||||
if (!workspaceContext || !settings.worktreesDir || settings.worktrunk?.enabled) return;
|
||||
const workspaceRootDir = resolve(workspaceContext.workspaceRootDir);
|
||||
const configuredRoot = resolveWorktreesDir(workspaceRootDir, settings);
|
||||
const rel = relative(workspaceRootDir, configuredRoot);
|
||||
if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) return;
|
||||
const groupDir = join(configuredRoot, workspaceWorktreeGroupSegment(workspaceRootDir));
|
||||
await mkdir(groupDir, { recursive: true });
|
||||
const marker = join(groupDir, WORKSPACE_GROUP_MARKER_FILENAME);
|
||||
try {
|
||||
await writeFile(marker, workspaceRootDir, { flag: "wx" });
|
||||
} catch (error: unknown) {
|
||||
const errorCode = error && typeof error === "object" && "code" in error ? error.code : undefined;
|
||||
if (errorCode !== "EEXIST") throw error;
|
||||
try {
|
||||
const existingRoot = resolve((await readFile(marker, "utf8")).trim());
|
||||
if (existingRoot !== workspaceRootDir) throw new WorkspaceWorktreeGroupConflictError(workspaceRootDir, existingRoot, groupDir);
|
||||
} catch (readError: unknown) {
|
||||
if (readError instanceof WorkspaceWorktreeGroupConflictError) throw readError;
|
||||
const readErrorCode = readError && typeof readError === "object" && "code" in readError ? readError.code : undefined;
|
||||
if (readErrorCode !== "ENOENT") throw readError;
|
||||
await writeFile(marker, workspaceRootDir, { flag: "wx" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Promise<AcquireTaskWorktreeResult> {
|
||||
const { task, rootDir, store, settings, pool, logger, audit, runContext, createWorktree, runConfiguredCommand, runInitCommand, taskEnv, secretsStore } = opts;
|
||||
const { task, rootDir, store, settings, pool, logger, audit, runContext, createWorktree, runConfiguredCommand, runInitCommand, taskEnv, secretsStore, workspaceContext } = opts;
|
||||
const persistWorktreeAssignment = async (patch: Parameters<TaskStore["updateTask"]>[1]): Promise<void> => {
|
||||
if (!opts.suppressSingularWorktreePersist) {
|
||||
await store.updateTask(task.id, patch);
|
||||
@@ -434,6 +483,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await ensureWorkspaceGroupOwnership(workspaceContext, settings);
|
||||
const branchName = resolveTaskWorkingBranch(task);
|
||||
const resolveExistingWorktreeBackendKind = async (path: string): Promise<WorktreeBackend["kind"]> =>
|
||||
(await readPersistedWorktreeBackendKind(path)) ?? opts.createWorktreeBackendKind ?? backend.kind;
|
||||
@@ -465,10 +515,12 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
? task.id.toLowerCase()
|
||||
: naming === "task-title"
|
||||
? slugify(task.title || task.description.slice(0, 60))
|
||||
: generateWorktreeName(rootDir, settings);
|
||||
worktreePath = await resolveTaskWorktreePathForBackend(rootDir, worktreeName, settings, backend, branchName);
|
||||
: generateWorktreeName(rootDir, settings, workspaceContext);
|
||||
worktreePath = await resolveTaskWorktreePathForBackend(rootDir, worktreeName, settings, backend, branchName, workspaceContext);
|
||||
}
|
||||
|
||||
// Grouped workspace paths have two container levels; native git requires the immediate parent to exist.
|
||||
if (workspaceContext && backend.kind !== "worktrunk") await mkdir(dirname(worktreePath), { recursive: true });
|
||||
let isResume = Boolean(task.worktree && existsSync(worktreePath));
|
||||
// FNXC:TaskPinnedWorktrees 2026-07-16-00:00: the non-pinned resume-classification self-heal is skipped in
|
||||
// pinned mode; acquirePinnedWorktree runs its own derive→validate→reuse-or-recreate decision below.
|
||||
@@ -487,8 +539,8 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
logger?.log(`${task.id}: assigned worktree is not usable; creating a fresh worktree instead: ${worktreePath}`);
|
||||
await store.logEntry(task.id, "Assigned worktree is not a registered, usable git worktree; creating a fresh worktree instead", worktreePath, runContext);
|
||||
await persistWorktreeAssignment({ worktree: null, branch: null, sessionFile: null });
|
||||
const fallbackName = generateWorktreeName(rootDir, settings);
|
||||
worktreePath = await resolveTaskWorktreePathForBackend(rootDir, fallbackName, settings, backend, branchName);
|
||||
const fallbackName = generateWorktreeName(rootDir, settings, workspaceContext);
|
||||
worktreePath = await resolveTaskWorktreePathForBackend(rootDir, fallbackName, settings, backend, branchName, workspaceContext);
|
||||
isResume = false;
|
||||
}
|
||||
}
|
||||
@@ -579,7 +631,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
if (reservationHeld) return createWorktreeWithoutReservation(createBranch, createPath, createTaskId, startPoint, allowRename);
|
||||
const reservation = await acquireWorktreePathReservation({
|
||||
canonicalPath: await canonicalizeWorktreePath(createPath),
|
||||
worktreesDir: resolveWorktreesDir(rootDir, settings),
|
||||
worktreesDir: resolveWorktreesDir(rootDir, settings, workspaceContext),
|
||||
rootDir,
|
||||
/*
|
||||
FNXC:WorkflowLifecycle 2026-07-16-10:00:
|
||||
@@ -739,8 +791,8 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
logger?.warn(`${task.id}: acquisition ${source} returned repo root; clearing assignment and creating a fresh worktree`);
|
||||
await store.logEntry(task.id, "Acquisition attempted to return the project root as a task worktree; creating a fresh worktree instead", guardedPath, runContext);
|
||||
await persistWorktreeAssignment({ worktree: null, branch: null, sessionFile: null });
|
||||
const fallbackName = generateWorktreeName(rootDir, settings);
|
||||
const fallbackPath = await resolveTaskWorktreePathForBackend(rootDir, fallbackName, settings, backend, branchName);
|
||||
const fallbackName = generateWorktreeName(rootDir, settings, workspaceContext);
|
||||
const fallbackPath = await resolveTaskWorktreePathForBackend(rootDir, fallbackName, settings, backend, branchName, workspaceContext);
|
||||
const created = await createWorktreeImpl(branchName, fallbackPath, task.id, freshStartPoint, allowSiblingBranchRename);
|
||||
return finalizeCreatedWorktree(created, "fresh", "return-guard");
|
||||
};
|
||||
@@ -783,7 +835,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
* consume any worktree-session retry budget (acquisition returns a valid fresh worktree directly).
|
||||
*/
|
||||
const acquirePinnedWorktree = async (): Promise<AcquireTaskWorktreeResult> => {
|
||||
const pinnedPath = pinnedWorktreePathForTask(task.id, settings, rootDir);
|
||||
const pinnedPath = pinnedWorktreePathForTask(task.id, settings, rootDir, workspaceContext);
|
||||
const resumedBranch = task.branch ?? branchName;
|
||||
|
||||
if (task.worktree && canonicalizePath(task.worktree) !== canonicalizePath(pinnedPath)) {
|
||||
@@ -798,7 +850,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
|
||||
const reservation = await acquireWorktreePathReservation({
|
||||
canonicalPath: await canonicalizeWorktreePath(pinnedPath),
|
||||
worktreesDir: resolveWorktreesDir(rootDir, settings),
|
||||
worktreesDir: resolveWorktreesDir(rootDir, settings, workspaceContext),
|
||||
rootDir,
|
||||
isLiveWorktree: async () => {
|
||||
if (activeSessionRegistry.isPathActive(pinnedPath)) return true;
|
||||
@@ -810,7 +862,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
*/
|
||||
reconcileQuarantined: async () => {
|
||||
if (existsSync(pinnedPath)) return;
|
||||
if (!isInsideWorktreesDir(rootDir, pinnedPath, settings)) {
|
||||
if (!isInsideWorktreesDir(rootDir, pinnedPath, settings, workspaceContext)) {
|
||||
throw new Error(`Refusing to reconcile quarantined task-pinned worktree outside configured worktrees directory: ${pinnedPath}`);
|
||||
}
|
||||
if (activeSessionRegistry.isPathActive(pinnedPath)) {
|
||||
@@ -869,7 +921,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
undefined,
|
||||
runContext,
|
||||
);
|
||||
if (isInsideWorktreesDir(rootDir, pinnedPath, settings)) {
|
||||
if (isInsideWorktreesDir(rootDir, pinnedPath, settings, workspaceContext)) {
|
||||
try {
|
||||
const preserveAsOrphanDirectory = !classification.ok
|
||||
&& (classification.classification === "incomplete" || classification.classification === "unregistered")
|
||||
@@ -897,7 +949,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
* Configured worktrees may live on another filesystem. Preserve atomically beside the
|
||||
* configured worktree root instead of weakening recovery to recursive copy-and-delete.
|
||||
*/
|
||||
const canonicalWorktreesRoot = await realpath(resolveWorktreesDir(rootDir, settings));
|
||||
const canonicalWorktreesRoot = await realpath(resolveWorktreesDir(rootDir, settings, workspaceContext));
|
||||
const localRecoveryRoot = await ensureContainedDirectory(canonicalWorktreesRoot, WORKTREE_RECOVERY_DIRNAME);
|
||||
const localRecoveryWorktrees = await ensureContainedDirectory(localRecoveryRoot, "worktrees");
|
||||
actualRecoveryRoot = localRecoveryWorktrees;
|
||||
@@ -1052,8 +1104,8 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
logger?.warn(`${task.id}: failed to remove unusable pooled worktree ${worktreePath}: ${formatError(removeErr)}`);
|
||||
}
|
||||
}
|
||||
const fallbackName = generateWorktreeName(rootDir, settings);
|
||||
worktreePath = await resolveTaskWorktreePathForBackend(rootDir, fallbackName, settings, backend, branchName);
|
||||
const fallbackName = generateWorktreeName(rootDir, settings, workspaceContext);
|
||||
worktreePath = await resolveTaskWorktreePathForBackend(rootDir, fallbackName, settings, backend, branchName, workspaceContext);
|
||||
branch = branchName;
|
||||
} else {
|
||||
/*
|
||||
@@ -1264,14 +1316,8 @@ FNXC:WorkspaceWorktree 2026-06-22-00:00:
|
||||
An absolute path or a `..` escape (`../outside`) would resolve a worktree outside the workspace
|
||||
root. Validate it is a normalized, relative, in-root path before resolving the absolute path.
|
||||
*/
|
||||
function assertInRootRepoRelPath(repoRelPath: string, sep: string, isAbsolute: (p: string) => boolean, normalize: (p: string) => string): void {
|
||||
if (typeof repoRelPath !== "string" || repoRelPath.length === 0 || isAbsolute(repoRelPath)) {
|
||||
throw new Error(`Invalid workspace repo path (must be relative and in-root): ${String(repoRelPath)}`);
|
||||
}
|
||||
const normalized = normalize(repoRelPath);
|
||||
if (normalized === ".." || normalized.startsWith(`..${sep}`) || normalized.startsWith("../")) {
|
||||
throw new Error(`Invalid workspace repo path (escapes workspace root): ${repoRelPath}`);
|
||||
}
|
||||
function assertInRootRepoRelPath(repoRelPath: string): void {
|
||||
assertWorkspaceRepoRelPath(repoRelPath);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -1288,10 +1334,10 @@ export async function acquireWorkspaceRepoWorktree(
|
||||
): Promise<{ worktreePath: string; branch: string; baseCommitSha?: string; alreadyAcquired: boolean }> {
|
||||
const { repoRelPath, workspaceRootDir, task, store, settings, logger, secretsStore, audit, runContext, runConfiguredCommand, taskEnv } = opts;
|
||||
const registry = opts.registry ?? activeSessionRegistry;
|
||||
const { join, isAbsolute, normalize, sep } = await import("node:path");
|
||||
const { join } = await import("node:path");
|
||||
|
||||
// FNXC:WorkspaceWorktree 2026-06-22-00:00: reject absolute / `..`-escaping repo paths before resolving.
|
||||
assertInRootRepoRelPath(repoRelPath, sep, isAbsolute, normalize);
|
||||
assertInRootRepoRelPath(repoRelPath);
|
||||
const repoAbsPath = join(workspaceRootDir, repoRelPath);
|
||||
|
||||
let durableAcquireLease: WorkspaceLeaseHandle | undefined;
|
||||
@@ -1459,6 +1505,7 @@ export async function acquireWorkspaceRepoWorktree(
|
||||
const result = await acquireTaskWorktree({
|
||||
task: { ...task, worktree: undefined, branch: undefined, executionStartBranch: baseResolution.branch },
|
||||
suppressSingularWorktreePersist: true,
|
||||
workspaceContext: { workspaceRootDir, repoRelPath },
|
||||
rootDir: repoAbsPath,
|
||||
store,
|
||||
// FNXC:Workspace 2026-07-07-08:40 (FN-7360 regression — strip shared branch overrides for per-repo start-point):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readdirSync } from "node:fs";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { Settings, Task } from "@fusion/core";
|
||||
import type { Settings, Task, WorkspaceWorktreeContext } from "@fusion/core";
|
||||
import { resolveTaskWorktreePath, resolveWorktreesDir } from "./worktree-paths.js";
|
||||
|
||||
export const ADJECTIVES = [
|
||||
@@ -83,8 +83,8 @@ export function slugify(str: string): string {
|
||||
* @param rootDir - The project root directory (parent of `.worktrees/`)
|
||||
* @returns A unique worktree directory name (not a full path)
|
||||
*/
|
||||
export function generateWorktreeName(rootDir: string, settings?: Pick<Settings, "worktreesDir">): string {
|
||||
return generateReservedWorktreeName(rootDir, new Set(), settings);
|
||||
export function generateWorktreeName(rootDir: string, settings?: Pick<Settings, "worktreesDir">, workspaceContext?: WorkspaceWorktreeContext): string {
|
||||
return generateReservedWorktreeName(rootDir, new Set(), settings, workspaceContext);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,12 +95,13 @@ export function generateReservedWorktreeName(
|
||||
rootDir: string,
|
||||
reservedNames: Set<string> = new Set(),
|
||||
settings?: Pick<Settings, "worktreesDir">,
|
||||
workspaceContext?: WorkspaceWorktreeContext,
|
||||
): string {
|
||||
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
|
||||
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
|
||||
const baseName = `${adjective}-${noun}`;
|
||||
|
||||
const worktreesDir = resolveWorktreesDir(rootDir, settings);
|
||||
const worktreesDir = resolveWorktreesDir(rootDir, settings, workspaceContext);
|
||||
const existing = getExistingWorktreeNames(worktreesDir);
|
||||
for (const reserved of reservedNames) {
|
||||
existing.add(reserved);
|
||||
@@ -136,6 +137,7 @@ export function planTaskWorktreePath(
|
||||
naming: string | undefined,
|
||||
reservedNames: Set<string>,
|
||||
settings?: Pick<Settings, "worktreesDir">,
|
||||
workspaceContext?: WorkspaceWorktreeContext,
|
||||
): string {
|
||||
if (task.worktree) {
|
||||
const existingName = task.worktree.split("/").filter(Boolean).pop();
|
||||
@@ -153,12 +155,12 @@ export function planTaskWorktreePath(
|
||||
break;
|
||||
case "random":
|
||||
default:
|
||||
worktreeName = generateReservedWorktreeName(rootDir, reservedNames, settings);
|
||||
worktreeName = generateReservedWorktreeName(rootDir, reservedNames, settings, workspaceContext);
|
||||
break;
|
||||
}
|
||||
|
||||
reservedNames.add(worktreeName);
|
||||
return resolveTaskWorktreePath(rootDir, settings, worktreeName);
|
||||
return resolveTaskWorktreePath(rootDir, settings, worktreeName, workspaceContext);
|
||||
}
|
||||
|
||||
function getExistingWorktreeNames(worktreesDir: string): Set<string> {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { homedir } from "node:os";
|
||||
import { basename, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import type { Settings } from "@fusion/core";
|
||||
import { execFile } from "node:child_process";
|
||||
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
||||
import { promisify } from "node:util";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { resolveWorktreesDirLayout, WORKSPACE_GROUP_MARKER_FILENAME, type Settings, type WorkspaceWorktreeContext } from "@fusion/core";
|
||||
import type { WorktreeBackendKind } from "./worktree-backend.js";
|
||||
import { canonicalizePath } from "./worktree-pool.js";
|
||||
|
||||
export const AI_MERGE_DIRNAME = ".ai-merge";
|
||||
export const WORKTREE_RECOVERY_DIRNAME = ".fusion-recovery";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export function isAiMergeContainerDir(name: string): boolean {
|
||||
return name === AI_MERGE_DIRNAME;
|
||||
}
|
||||
@@ -19,6 +23,52 @@ export function isWorktreeContainerDir(name: string): boolean {
|
||||
return isAiMergeContainerDir(name) || name === WORKTREE_RECOVERY_DIRNAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:WorkspaceWorktree 2026-08-20-01:46:
|
||||
* Shared configured roots may contain other projects' worktrees and workspace group
|
||||
* containers. Reaping is permitted only after Git proves the candidate shares this
|
||||
* project's common directory; a workspace marker is solely an additional delete veto.
|
||||
*/
|
||||
export async function isReclaimableWorktreeCandidate(
|
||||
entryAbsPath: string,
|
||||
options: { rootDir: string },
|
||||
): Promise<boolean> {
|
||||
if (isWorktreeContainerDir(entryAbsPath.split(/[\\/]/).pop() ?? "")) return false;
|
||||
if (existsSync(join(entryAbsPath, WORKSPACE_GROUP_MARKER_FILENAME))) return false;
|
||||
const dotGit = join(entryAbsPath, ".git");
|
||||
if (!existsSync(dotGit)) return false;
|
||||
|
||||
// The normal linked-worktree form is a gitdir file below the main checkout's
|
||||
// admin directory. Prove that relationship without trusting a directory name.
|
||||
try {
|
||||
const match = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, "utf8"));
|
||||
if (match) {
|
||||
const gitdir = resolve(entryAbsPath, match[1]!.trim());
|
||||
const rootGitDir = resolve(options.rootDir, ".git");
|
||||
const rel = relative(rootGitDir, gitdir);
|
||||
if (rel !== "" && !rel.startsWith("..") && !isAbsolute(rel)) return true;
|
||||
// FNXC:WorkspaceWorktree 2026-08-20-01:46: A linked project root has a `.git` file, so Git must prove its external common directory.
|
||||
}
|
||||
} catch {
|
||||
// Fall through to Git's common-dir probe for uncommon worktree layouts.
|
||||
}
|
||||
|
||||
try {
|
||||
const [candidate, root] = await Promise.all([
|
||||
execFileAsync("git", ["-C", entryAbsPath, "rev-parse", "--git-common-dir"], { encoding: "utf8", timeout: 10_000 }),
|
||||
execFileAsync("git", ["-C", options.rootDir, "rev-parse", "--git-common-dir"], { encoding: "utf8", timeout: 10_000 }),
|
||||
]);
|
||||
const canonical = (cwd: string, value: string) => {
|
||||
const path = resolve(cwd, value.trim());
|
||||
try { return realpathSync(path); } catch { return path; }
|
||||
};
|
||||
return canonical(entryAbsPath, candidate.stdout) === canonical(options.rootDir, root.stdout);
|
||||
} catch {
|
||||
// Destructive sweeps fail closed when Git metadata cannot prove ownership.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAiMergeRootPath(
|
||||
rootDir: string,
|
||||
settings: Pick<Settings, "worktreesDir"> | undefined,
|
||||
@@ -33,23 +83,18 @@ export function resolveLegacyAiMergeRootPath(rootDir: string): string {
|
||||
export function resolveWorktreesDir(
|
||||
rootDir: string,
|
||||
settings: Pick<Settings, "worktreesDir"> | undefined,
|
||||
workspaceContext?: WorkspaceWorktreeContext,
|
||||
): string {
|
||||
const configured = settings?.worktreesDir;
|
||||
if (!configured) {
|
||||
return join(rootDir, ".worktrees");
|
||||
}
|
||||
|
||||
const expandedHome = configured.replace(/^~(?=$|[\\/])/, homedir());
|
||||
const expandedRepo = expandedHome.replaceAll("{repo}", basename(rootDir));
|
||||
return resolve(rootDir, expandedRepo);
|
||||
return resolveWorktreesDirLayout(rootDir, settings, workspaceContext);
|
||||
}
|
||||
|
||||
export function resolveTaskWorktreePath(
|
||||
rootDir: string,
|
||||
settings: Pick<Settings, "worktreesDir"> | undefined,
|
||||
worktreeName: string,
|
||||
workspaceContext?: WorkspaceWorktreeContext,
|
||||
): string {
|
||||
return join(resolveWorktreesDir(rootDir, settings), worktreeName);
|
||||
return join(resolveWorktreesDir(rootDir, settings, workspaceContext), worktreeName);
|
||||
}
|
||||
|
||||
// Structural backend input avoids importing the full WorktreeBackend interface here.
|
||||
@@ -62,19 +107,21 @@ export async function resolveTaskWorktreePathForBackend(
|
||||
resolveWorktreePath?: (input: { rootDir: string; worktreeName: string; branch: string }) => Promise<string>;
|
||||
},
|
||||
branch: string,
|
||||
workspaceContext?: WorkspaceWorktreeContext,
|
||||
): Promise<string> {
|
||||
if (backend.kind === "worktrunk" && backend.resolveWorktreePath) {
|
||||
return backend.resolveWorktreePath({ rootDir, worktreeName, branch });
|
||||
}
|
||||
return resolveTaskWorktreePath(rootDir, settings, worktreeName);
|
||||
return resolveTaskWorktreePath(rootDir, settings, worktreeName, workspaceContext);
|
||||
}
|
||||
|
||||
export function isInsideConfiguredWorktreesDir(
|
||||
rootDir: string,
|
||||
settings: Pick<Settings, "worktreesDir"> | undefined,
|
||||
candidate: string,
|
||||
workspaceContext?: WorkspaceWorktreeContext,
|
||||
): boolean {
|
||||
const worktreesDir = canonicalizePath(resolveWorktreesDir(rootDir, settings));
|
||||
const worktreesDir = canonicalizePath(resolveWorktreesDir(rootDir, settings, workspaceContext));
|
||||
const target = canonicalizePath(candidate);
|
||||
const rel = relative(worktreesDir, target);
|
||||
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Settings } from "@fusion/core";
|
||||
import type { Settings, WorkspaceWorktreeContext } from "@fusion/core";
|
||||
import { basename } from "node:path";
|
||||
import { resolveTaskWorktreePath } from "./worktree-paths.js";
|
||||
|
||||
@@ -35,8 +35,9 @@ export function pinnedWorktreePathForTask(
|
||||
taskId: string,
|
||||
settings: Pick<Settings, "worktreesDir"> | undefined,
|
||||
rootDir: string,
|
||||
workspaceContext?: WorkspaceWorktreeContext,
|
||||
): string {
|
||||
return resolveTaskWorktreePath(rootDir, settings, pinnedWorktreeSlug(taskId));
|
||||
return resolveTaskWorktreePath(rootDir, settings, pinnedWorktreeSlug(taskId), workspaceContext);
|
||||
}
|
||||
|
||||
/** Preserve the task-pinned naming invariant while normalizing legacy paths. */
|
||||
@@ -45,8 +46,9 @@ export function preservedWorktreeTargetPathForTask(
|
||||
sourcePath: string,
|
||||
settings: Pick<Settings, "worktreeNaming" | "worktreesDir"> | undefined,
|
||||
rootDir: string,
|
||||
workspaceContext?: WorkspaceWorktreeContext,
|
||||
): string {
|
||||
return isTaskPinnedWorktreeNaming(settings)
|
||||
? pinnedWorktreePathForTask(taskId, settings, rootDir)
|
||||
: resolveTaskWorktreePath(rootDir, settings, basename(sourcePath));
|
||||
? pinnedWorktreePathForTask(taskId, settings, rootDir, workspaceContext)
|
||||
: resolveTaskWorktreePath(rootDir, settings, basename(sourcePath), workspaceContext);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import { promisify } from "node:util";
|
||||
import { existsSync, lstatSync, readdirSync, readFileSync, rmSync, realpathSync } from "node:fs";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { basename, dirname, join, relative, resolve, isAbsolute } from "node:path";
|
||||
import type { SecretsStore, Settings, TaskStore, WorktrunkSettings } from "@fusion/core";
|
||||
import type { SecretsStore, Settings, TaskStore, WorktrunkSettings, WorkspaceWorktreeContext } from "@fusion/core";
|
||||
import { assertCleanBranchAtBase, inspectBranchConflict } from "../execution/branch-conflicts.js";
|
||||
import { worktreePoolLog } from "../logger.js";
|
||||
/*
|
||||
*/
|
||||
import { isInsideConfiguredWorktreesDir, isWorktreeContainerDir, resolveWorktreesDir } from "./worktree-paths.js";
|
||||
import { isInsideConfiguredWorktreesDir, isReclaimableWorktreeCandidate, isWorktreeContainerDir, resolveWorktreesDir } from "./worktree-paths.js";
|
||||
import { canonicalFusionBranchName } from "./worktree-names.js";
|
||||
import {
|
||||
resolveWorktrunkBinary,
|
||||
@@ -418,8 +418,9 @@ export function isInsideWorktreesDir(
|
||||
rootDir: string,
|
||||
worktreePath: string,
|
||||
settings?: Pick<Settings, "worktreesDir">,
|
||||
workspaceContext?: WorkspaceWorktreeContext,
|
||||
): boolean {
|
||||
return isInsideConfiguredWorktreesDir(rootDir, settings, worktreePath);
|
||||
return isInsideConfiguredWorktreesDir(rootDir, settings, worktreePath, workspaceContext);
|
||||
}
|
||||
|
||||
export type ReclaimableWorktreePlacement =
|
||||
@@ -880,8 +881,13 @@ export class WorktreePool {
|
||||
export async function scanIdleWorktrees(
|
||||
rootDir: string,
|
||||
store: TaskStore,
|
||||
settings?: Pick<Settings, "worktreesDir">,
|
||||
settings?: Pick<Settings, "worktreesDir" | "workspaceMode">,
|
||||
): Promise<string[]> {
|
||||
/* FNXC:WorkspaceWorktree 2026-08-20-01:20: Group containers are not task worktrees; workspace cleanup uses recorded member paths rather than directory walking. */
|
||||
if (settings?.workspaceMode) {
|
||||
worktreePoolLog.debug?.("Skipping directory walk for workspace worktrees; recorded paths are reclaimed addressably.");
|
||||
return [];
|
||||
}
|
||||
const worktreesDir = resolveWorktreesDir(rootDir, settings);
|
||||
|
||||
if (!existsSync(worktreesDir)) {
|
||||
@@ -901,9 +907,10 @@ export async function scanIdleWorktrees(
|
||||
return [];
|
||||
}
|
||||
|
||||
if (dirs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
dirs = (await Promise.all(dirs.map(async (dir) =>
|
||||
(await isReclaimableWorktreeCandidate(dir, { rootDir })) ? dir : null,
|
||||
))).filter((dir): dir is string => dir !== null);
|
||||
if (dirs.length === 0) return [];
|
||||
|
||||
const registeredWorktrees = await getRegisteredWorktreePaths(rootDir);
|
||||
const registeredDirs = dirs.filter((dir) => registeredWorktrees.has(resolve(dir)));
|
||||
@@ -967,8 +974,12 @@ export async function scanIdleWorktrees(
|
||||
export async function cleanupOrphanedWorktrees(
|
||||
rootDir: string,
|
||||
store: TaskStore,
|
||||
settings?: Pick<Settings, "worktreesDir">,
|
||||
settings?: Pick<Settings, "worktreesDir" | "workspaceMode">,
|
||||
): Promise<number> {
|
||||
if (settings?.workspaceMode) {
|
||||
worktreePoolLog.debug?.("Skipping workspace orphan sweep; recorded paths are reclaimed addressably.");
|
||||
return 0;
|
||||
}
|
||||
const worktreesDir = resolveWorktreesDir(rootDir, settings);
|
||||
if (!existsSync(worktreesDir)) {
|
||||
return 0;
|
||||
@@ -990,7 +1001,10 @@ export async function cleanupOrphanedWorktrees(
|
||||
}
|
||||
}
|
||||
|
||||
const unregistered = dirs.filter((dir) => !registeredWorktrees.has(resolve(dir)));
|
||||
const ownedDirs = (await Promise.all(dirs.map(async (dir) =>
|
||||
(await isReclaimableWorktreeCandidate(dir, { rootDir })) ? dir : null,
|
||||
))).filter((dir): dir is string => dir !== null);
|
||||
const unregistered = ownedDirs.filter((dir) => !registeredWorktrees.has(resolve(dir)));
|
||||
const candidates = [...orphaned, ...unregistered];
|
||||
let cleaned = 0;
|
||||
|
||||
@@ -1093,8 +1107,12 @@ function dotGitPointerIsDangling(dotGitPath: string): boolean {
|
||||
|
||||
export async function reapOrphanWorktrees(
|
||||
projectRoot: string,
|
||||
settings?: Pick<Settings, "worktreesDir">,
|
||||
settings?: Pick<Settings, "worktreesDir" | "workspaceMode">,
|
||||
): Promise<number> {
|
||||
if (settings?.workspaceMode) {
|
||||
worktreePoolLog.debug?.("Skipping workspace orphan reaping; recorded paths are reclaimed addressably.");
|
||||
return 0;
|
||||
}
|
||||
const worktreesDir = resolveWorktreesDir(projectRoot, settings);
|
||||
|
||||
if (!existsSync(worktreesDir)) {
|
||||
@@ -1107,7 +1125,7 @@ export async function reapOrphanWorktrees(
|
||||
entries = readdirSync(worktreesDir, { withFileTypes: true })
|
||||
.filter((e) => {
|
||||
// Only real directories — never symlinks or internal worktree containers.
|
||||
if (!e.isDirectory() || isWorktreeContainerDir(e.name)) return false;
|
||||
if (!e.isDirectory() || isWorktreeContainerDir(e.name) || !existsSync(join(worktreesDir, e.name, ".git"))) return false;
|
||||
try {
|
||||
return lstatSync(join(worktreesDir, e.name)).isDirectory() && !lstatSync(join(worktreesDir, e.name)).isSymbolicLink();
|
||||
} catch {
|
||||
@@ -1121,6 +1139,10 @@ export async function reapOrphanWorktrees(
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (entries.length === 0) return 0;
|
||||
entries = (await Promise.all(entries.map(async (entry) =>
|
||||
(await isReclaimableWorktreeCandidate(entry.fullPath, { rootDir: projectRoot })) ? entry : null,
|
||||
))).filter((entry): entry is { name: string; fullPath: string } => entry !== null);
|
||||
if (entries.length === 0) return 0;
|
||||
|
||||
// Get the set of paths registered with git
|
||||
|
||||
Reference in New Issue
Block a user