Files
fusion/packages/core/src/git/git-repository.ts
Fusion Agent 29f4de39c4 FN-038: prepare projects for Git during onboarding
Make project initialization and registration produce Git-ready projects consistently.\n\n- Centralize repository readiness and initialization behavior.\n- Update CLI onboarding and dashboard registration flows to use the Git-ready setup.\n- Add regression coverage and document the new project preparation behavior.\n\nFiles changed:\n .changeset/fn-038-git-ready-projects.md            |   7 +\n docs/cli-reference.md                              |  11 +-\n docs/getting-started.md                            |  17 +-\n docs/workspaces.md                                 |   4 +-\n packages/cli/src/commands/__tests__/init.test.ts   |  39 ++--\n packages/cli/src/commands/init.ts                  | 115 ++--------\n packages/core/src/__tests__/git-repository.test.ts | 125 +++++++++-\n packages/core/src/central/central-core.ts          |  57 +++--\n packages/core/src/git/git-repository.ts             | 254 +++++++++++++++++----\n packages/dashboard/app/api/projects/projects.ts    |   2 -\n packages/dashboard/app/components/SetupWizardModal.tsx |  46 +---\n packages/dashboard/app/components/__tests__/SetupWizardModal.git-missing.test.tsx |  74 +++---\n packages/dashboard/src/routes/__tests__/register-project-git-readiness.test.ts | 165 +++++++++++++\n packages/dashboard/src/routes/register-project-routes.ts |  11 -\n 14 files changed, 647 insertions(+), 280 deletions(-)

Fusion-Task-Id: FN-038

Fusion-Task-Lineage: 83634184-569b-4190-a88f-4a09eb832e9d

Co-authored-by: Fusion <noreply@runfusion.ai>
2026-08-20 03:57:23 +00:00

710 lines
27 KiB
TypeScript

import { execFile } from "node:child_process";
import { constants as fsConstants } from "node:fs";
import { mkdtemp, open, readFile, rm, writeFile, lstat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import { invalidateGitBinaryCache, isSpawnGitEnoent, resolveGitBinary } from "../cli/git-binary.js";
const execFileAsync = promisify(execFile);
const DEFAULT_GIT_TIMEOUT_MS = 10_000;
const FUSION_GIT_IDENTITY = {
GIT_AUTHOR_NAME: "Fusion",
GIT_AUTHOR_EMAIL: "noreply@runfusion.ai",
GIT_COMMITTER_NAME: "Fusion",
GIT_COMMITTER_EMAIL: "noreply@runfusion.ai",
} as const;
const MANAGED_GITIGNORE_ENTRIES = [
".fusion/",
".pi/",
".worktrees/",
"fusion.db",
"fusion.db-wal",
"fusion.db-shm",
] as const;
export type GitRepositoryEnsureOutcome = "existing" | "initialized";
export interface GitRepositoryCommandResult {
stdout: string;
stderr: string;
}
export type GitRepositoryCommandRunner = (
command: string,
args: string[],
options: { cwd?: string; timeout: number; env?: NodeJS.ProcessEnv },
) => Promise<GitRepositoryCommandResult>;
export interface EnsureGitRepositoryOptions {
runner?: GitRepositoryCommandRunner;
timeoutMs?: number;
}
export class GitRepositoryInitializationError extends Error {
readonly path: string;
readonly causeMessage: string;
constructor(path: string, causeMessage: string) {
super(`Could not prepare Git repository at ${path}: ${causeMessage}`);
this.name = "GitRepositoryInitializationError";
this.path = path;
this.causeMessage = causeMessage;
}
}
/**
* Ensures that a project has a usable Git baseline before any registry row is written.
*
* FNXC:ProjectSetup 2026-08-19-12:44:
* Registration must be fail-closed: non-Git directories and unborn repositories receive
* a real baseline containing only Fusion's managed `.gitignore` file, while committed
* repositories keep their history, branch, remotes, config, index, and user changes.
* Workspace roots remain browse-only; their members are prepared inside one canonical-root
* lock so dashboard, CLI, reattachment, and workspace registration cannot drift.
*/
export async function ensureGitRepositoryForProjectPath(
projectPath: string,
options: EnsureGitRepositoryOptions = {},
): Promise<GitRepositoryEnsureOutcome> {
const runner = options.runner ?? runGitCommand;
const timeout = options.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS;
return withWorkspaceModeLock(projectPath, async () => {
try {
return await ensureGitRepositoryForProjectPathLocked(projectPath, runner, timeout);
} catch (error) {
if (error instanceof GitRepositoryInitializationError) throw error;
throw new GitRepositoryInitializationError(projectPath, extractCommandErrorMessage(error));
}
});
}
async function ensureGitRepositoryForProjectPathLocked(
projectPath: string,
runner: GitRepositoryCommandRunner,
timeout: number,
): Promise<GitRepositoryEnsureOutcome> {
const workspace = await loadWorkspaceConfig(projectPath);
if (workspace) {
return prepareWorkspaceRepositories(projectPath, workspace.repos, runner, timeout);
}
if (await isInsideGitWorkTree(projectPath, runner, timeout)) {
return prepareSingleRepository(projectPath, runner, timeout, false);
}
/*
FNXC:Workspace 2026-08-19-12:44:
Dashboard and `fn project add` can discover workspace members without an existing
workspace.json. Decide workspace mode before touching the root, prepare every member,
then persist the decision. No root `.git` is ever created for this path.
*/
if (!(await isWorkspaceModeExplicitlyDisabled(projectPath))) {
const detectedRepos = await detectWorkspaceRepos(projectPath, runner, timeout);
if (detectedRepos.length > 0) {
const outcome = await prepareWorkspaceRepositories(projectPath, detectedRepos, runner, timeout);
await setWorkspaceModeInConfig(projectPath, true);
await saveWorkspaceConfig(projectPath, { repos: detectedRepos });
return outcome;
}
}
return prepareSingleRepository(projectPath, runner, timeout, true);
}
async function prepareWorkspaceRepositories(
rootDir: string,
repos: string[],
runner: GitRepositoryCommandRunner,
timeout: number,
): Promise<GitRepositoryEnsureOutcome> {
let initialized = false;
for (const relativeRepo of repos) {
const repoPath = join(rootDir, relativeRepo);
const outcome = await prepareSingleRepository(repoPath, runner, timeout, true);
initialized ||= outcome === "initialized";
}
return initialized ? "initialized" : "existing";
}
async function prepareSingleRepository(
projectPath: string,
runner: GitRepositoryCommandRunner,
timeout: number,
initializeIfMissing: boolean,
): Promise<GitRepositoryEnsureOutcome> {
let repositoryExists = await isInsideGitWorkTree(projectPath, runner, timeout);
if (!repositoryExists && !initializeIfMissing) {
throw new Error("workspace member is not a usable Git repository");
}
let initialized = false;
let indexWasEmpty = false;
if (!repositoryExists) {
await runner("git", ["-C", projectPath, "init"], { timeout });
repositoryExists = true;
initialized = true;
indexWasEmpty = (await runner("git", ["-C", projectPath, "ls-files", "--stage"], { timeout })).stdout.trim() === "";
} else {
indexWasEmpty = false;
}
const gitignore = await reconcileManagedGitignore(projectPath);
const hasHead = await hasVerifiableHead(projectPath, runner, timeout);
if (!hasHead) {
await createBaselineCommit(projectPath, gitignore.newline, runner, timeout);
/*
FNXC:ProjectSetup 2026-08-19-13:25:
The plumbing commit intentionally leaves Git's live index untouched. For a repository
Fusion just initialized, populate its previously empty index from the baseline so Git
does not report a staged deletion. Do not `git add .gitignore`: a pre-existing custom
ignore file must remain an unstaged operator-visible edit, not become Fusion-staged.
*/
if (initialized && indexWasEmpty) {
await runner("git", ["-C", projectPath, "read-tree", "HEAD"], { timeout });
}
}
return initialized || !hasHead ? "initialized" : "existing";
}
async function hasVerifiableHead(
projectPath: string,
runner: GitRepositoryCommandRunner,
timeout: number,
): Promise<boolean> {
try {
await runner("git", ["-C", projectPath, "rev-parse", "--verify", "HEAD^{commit}"], { timeout });
return true;
} catch {
return false;
}
}
function managedIgnoreKey(line: string): string | null {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("!")) return null;
return trimmed.replaceAll("\\", "/").replace(/^\/+/, "").replace(/\/+$/, "");
}
function managedGitignoreContent(newline: string): string {
return `${MANAGED_GITIGNORE_ENTRIES.join(newline)}${newline}`;
}
async function reconcileManagedGitignore(projectPath: string): Promise<{ newline: string }> {
const gitignorePath = join(projectPath, ".gitignore");
let content = "";
let fileExists = false;
try {
const stats = await lstat(gitignorePath);
fileExists = true;
if (stats.isSymbolicLink()) {
throw new Error(".gitignore is a symbolic link; refusing to follow an unsafe target");
}
if (!stats.isFile()) throw new Error(".gitignore is not a regular file");
content = await readFile(gitignorePath, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error;
}
const newline = content.includes("\r\n") ? "\r\n" : "\n";
const existing = new Set(content.split(/\r?\n/).map(managedIgnoreKey).filter((entry): entry is string => entry !== null));
const missing = MANAGED_GITIGNORE_ENTRIES.filter((entry) => {
const key = managedIgnoreKey(entry);
return key !== null && !existing.has(key);
});
if (missing.length === 0) return { newline };
const prefix = content.length === 0 || content.endsWith("\n") ? "" : newline;
const updated = `${content}${prefix}${missing.join(newline)}${newline}`;
await writeGitignoreSafely(gitignorePath, updated, fileExists);
return { newline };
}
async function writeGitignoreSafely(path: string, content: string, existing: boolean): Promise<void> {
const noFollow = (fsConstants as typeof fsConstants & { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0;
const flags = existing
? fsConstants.O_RDWR | noFollow
: fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow;
const handle = await open(path, flags, 0o644);
try {
await handle.truncate(0);
await handle.writeFile(content, "utf8");
} finally {
await handle.close();
}
}
async function createBaselineCommit(
projectPath: string,
newline: string,
runner: GitRepositoryCommandRunner,
timeout: number,
): Promise<void> {
const branch = (await runner("git", ["-C", projectPath, "symbolic-ref", "HEAD"], { timeout })).stdout.trim();
if (!branch.startsWith("refs/heads/") || branch.length <= "refs/heads/".length) {
throw new Error("cannot create a baseline commit without an operator-selected branch");
}
const tempDir = await mkdtemp(join(tmpdir(), "fusion-git-baseline-"));
const tempIndex = join(tempDir, "index");
const tempGitignore = join(tempDir, "gitignore");
const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
const identityEnv = { ...env, ...FUSION_GIT_IDENTITY };
try {
await writeFile(tempGitignore, managedGitignoreContent(newline), "utf8");
const blob = (await runner("git", ["-C", projectPath, "hash-object", "-w", "--", tempGitignore], { timeout })).stdout.trim();
await runner("git", ["-C", projectPath, "read-tree", "--empty"], { timeout, env });
await runner("git", ["-C", projectPath, "update-index", "--add", `--cacheinfo`, `100644,${blob},.gitignore`], { timeout, env });
const tree = (await runner("git", ["-C", projectPath, "write-tree"], { timeout, env })).stdout.trim();
const commit = (await runner("git", ["-C", projectPath, "commit-tree", tree, "-m", "chore: initialize Fusion project"], { timeout, env: identityEnv })).stdout.trim();
if (!commit) throw new Error("Git returned an empty baseline commit");
await runner("git", ["-C", projectPath, "update-ref", branch, commit], { timeout });
} finally {
await rm(tempDir, { recursive: true, force: true });
}
}
async function isInsideGitWorkTree(
projectPath: string,
runner: GitRepositoryCommandRunner,
timeout: number,
): Promise<boolean> {
try {
const result = await runner("git", ["-C", projectPath, "rev-parse", "--is-inside-work-tree"], { timeout });
return result.stdout.trim() === "true";
} catch {
return false;
}
}
async function runGitCommand(
command: string,
args: string[],
options: { cwd?: string; timeout: number; env?: NodeJS.ProcessEnv },
): Promise<GitRepositoryCommandResult> {
/*
FNXC:Onboarding 2026-07-18-03:20:
Route "git" through resolveGitBinary so a git installed AFTER the server
started (stale PATH snapshot — spawn git ENOENT during first-run project
setup) is found at its well-known install location; on ENOENT re-resolve
once so a mid-session install is picked up without restarting Fusion.
*/
const binary = command === "git" ? await resolveGitBinary() : command;
try {
const result = await execFileAsync(binary, args, {
cwd: options.cwd,
timeout: options.timeout,
env: options.env,
encoding: "utf-8",
});
return {
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
};
} catch (error) {
if (command !== "git" || !isSpawnGitEnoent(error)) throw error;
invalidateGitBinaryCache();
const retryBinary = await resolveGitBinary();
if (retryBinary === binary) throw error;
const result = await execFileAsync(retryBinary, args, {
cwd: options.cwd,
timeout: options.timeout,
env: options.env,
encoding: "utf-8",
});
return {
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
};
}
}
function extractCommandErrorMessage(error: unknown): string {
if (error && typeof error === "object") {
const maybe = error as { stderr?: unknown; stdout?: unknown; message?: unknown; code?: unknown };
for (const value of [maybe.stderr, maybe.stdout, maybe.message]) {
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
if (maybe.code !== undefined) {
return `git exited with code ${String(maybe.code)}`;
}
}
return String(error);
}
/**
* Scans `dir` one level deep for sub-directories that are git repositories.
* Returns relative paths of found repos, sorted alphabetically.
*
* Excludes `node_modules`, `.fusion`, and other known non-workspace directories so that
* packages installed from git sources (which leave real `.git` dirs) do not produce
* false-positive workspace members.
*/
export async function detectWorkspaceRepos(
dir: string,
runner: GitRepositoryCommandRunner = runGitCommand,
timeout: number = DEFAULT_GIT_TIMEOUT_MS,
): Promise<string[]> {
let entries: string[];
try {
const { readdir } = await import("node:fs/promises");
entries = await readdir(dir);
} catch {
return [];
}
const { stat } = await import("node:fs/promises");
const { join } = await import("node:path");
const found: string[] = [];
/*
FNXC:Workspace 2026-06-22-00:00:
A bare `.git` marker (e.g. a stray file copied in, or an unrelated tool's artifact) is not
proof of a git repository. Each candidate child is validated with a real `git rev-parse`
work-tree probe before it counts, so stray `.git` entries do not yield false-positive repos.
*/
/*
FNXC:Workspace 2026-06-24-15:00:
Exclude node_modules and .fusion so that npm packages installed from git sources (which
leave real .git directories inside node_modules/<package>) and Fusion's own state directory
do not produce false-positive workspace members. A workspace root is a plain directory whose
immediate children are the intended sub-repos, not transitive dependency artifacts.
*/
for (const entry of entries) {
if (EXCLUDED_WORKSPACE_ENTRIES.has(entry)) continue;
const childDir = join(dir, entry);
// Cheap pre-filter: skip children with no `.git` marker at all before spawning git.
try {
const s = await stat(join(childDir, ".git"));
if (!s.isDirectory() && !s.isFile()) continue;
} catch {
continue;
}
if (await isInsideGitWorkTree(childDir, runner, timeout)) {
found.push(entry);
}
}
return found.sort();
}
export interface WorkspaceConfig {
repos: string[];
}
export type WorkspaceRepoValidationReason =
| "not-a-workspace"
| "invalid-path"
| "not-direct-child"
| "excluded-name"
| "missing"
| "not-a-git-work-tree";
export class WorkspaceRepoValidationError extends Error {
constructor(readonly reason: WorkspaceRepoValidationReason) {
super(`Workspace repository validation failed: ${reason}`);
this.name = "WorkspaceRepoValidationError";
}
}
const WORKSPACE_CONFIG_FILENAME = "workspace.json";
const EXCLUDED_WORKSPACE_ENTRIES = new Set(["node_modules", ".fusion", ".git", ".pi", ".worktrees"]);
/**
* Reads .fusion/config.json and returns true when `workspaceMode` is explicitly
* set to `false`. This guards the auto-detection fallback so a user who has
* intentionally disabled workspace mode doesn't get it silently re-enabled.
*/
async function isWorkspaceModeExplicitlyDisabled(projectPath: string): Promise<boolean> {
try {
const { readFile } = await import("node:fs/promises");
const { join } = await import("node:path");
const raw = await readFile(join(projectPath, ".fusion", "config.json"), "utf-8");
const config = JSON.parse(raw) as { settings?: { workspaceMode?: boolean } };
return config.settings?.workspaceMode === false;
} catch {
return false;
}
}
/**
* FNXC:Workspace 2026-06-24-17:15:
* Writes `workspaceMode: true` into .fusion/config.json so the dashboard toggle
* reflects that workspace mode is active after auto-detection. Reads-merges-writes
* to avoid clobbering existing config settings.
*/
export async function setWorkspaceModeInConfig(projectPath: string, value: boolean): Promise<void> {
const { readFile, writeFile, mkdir } = await import("node:fs/promises");
const { join } = await import("node:path");
const configPath = join(projectPath, ".fusion", "config.json");
let config: Record<string, unknown> = {};
try {
config = JSON.parse(await readFile(configPath, "utf-8")) as Record<string, unknown>;
} catch (err) {
// Only treat "file not found" as empty config; re-throw parse/permission errors
// so a corrupted config.json doesn't get silently clobbered with a fresh object.
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") throw err;
}
// Validate settings is a plain object before merging
if (typeof config.settings !== "object" || config.settings === null || Array.isArray(config.settings)) {
config.settings = {};
}
const settings = config.settings as Record<string, unknown>;
settings.workspaceMode = value;
await mkdir(join(projectPath, ".fusion"), { recursive: true });
await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
}
/*
FNXC:Workspace 2026-06-22-00:00:
Workspace repo entries are later joined onto the workspace root to resolve worktrees, so an
attacker-controlled or corrupted workspace.json with an absolute path or a `..` escape
(`../outside-repo`) would resolve outside the workspace root. Each entry must be a normalized,
relative, in-root path; absolute paths, `..` escapes, and non-string entries are rejected.
*/
function isInRootRelativePath(entry: unknown, pathMod: typeof import("node:path")): entry is string {
if (typeof entry !== "string" || entry.length === 0) return false;
if (pathMod.isAbsolute(entry)) return false;
const normalized = pathMod.normalize(entry);
if (normalized === ".." || normalized.startsWith(`..${pathMod.sep}`) || normalized.startsWith("../")) {
return false;
}
return true;
}
export async function loadWorkspaceConfig(rootDir: string): Promise<WorkspaceConfig | null> {
const { readFile } = await import("node:fs/promises");
const pathMod = await import("node:path");
const { join } = pathMod;
const configPath = join(rootDir, ".fusion", WORKSPACE_CONFIG_FILENAME);
try {
const raw = await readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as unknown;
// FNXC:Workspace 2026-06-22-09:30 (Phase C review nit): validate that `repos` is an array
// OF STRINGS, not merely an array. A malformed config (`{ repos: [123, null] }`) would
// otherwise pass and feed non-string values into path joins downstream.
if (
parsed !== null &&
typeof parsed === "object" &&
"repos" in parsed &&
Array.isArray((parsed as { repos: unknown }).repos) &&
(parsed as { repos: unknown[] }).repos.every((r) => typeof r === "string")
) {
const rawRepos = (parsed as { repos: unknown[] }).repos;
const repos = rawRepos.filter((entry): entry is string => isInRootRelativePath(entry, pathMod));
return { ...(parsed as object), repos };
}
return null;
} catch {
return null;
}
}
/*
FNXC:Workspace 2026-08-20-02:03:
Issue 3480 item 6 requires workspace membership to be editable after registration. New members are
in-root direct-child Git work trees, additions are idempotent, and the write shares the workspace-mode
lock so a concurrent mode toggle cannot lose a member.
*/
export async function addWorkspaceRepo(
rootDir: string,
repoRelPath: string,
options: { runner?: GitRepositoryCommandRunner; timeout?: number } = {},
): Promise<{ outcome: "added" | "already-member"; repos: string[] }> {
const pathMod = await import("node:path");
const { stat } = await import("node:fs/promises");
const repo = typeof repoRelPath === "string" ? repoRelPath.trim() : "";
if (!repo || !isInRootRelativePath(repo, pathMod)) {
throw new WorkspaceRepoValidationError("invalid-path");
}
const normalized = pathMod.normalize(repo);
// A workspace member must name a child, not the workspace root (including child/..).
if (normalized === ".") {
throw new WorkspaceRepoValidationError("not-direct-child");
}
if (normalized.includes(pathMod.sep) || normalized.includes("/")) {
throw new WorkspaceRepoValidationError("not-direct-child");
}
if (EXCLUDED_WORKSPACE_ENTRIES.has(normalized)) {
throw new WorkspaceRepoValidationError("excluded-name");
}
const childPath = pathMod.join(rootDir, normalized);
try {
if (!(await stat(childPath)).isDirectory()) throw new WorkspaceRepoValidationError("missing");
} catch (error) {
if (error instanceof WorkspaceRepoValidationError) throw error;
throw new WorkspaceRepoValidationError("missing");
}
const runner = options.runner ?? runGitCommand;
if (!(await isInsideGitWorkTree(childPath, runner, options.timeout ?? DEFAULT_GIT_TIMEOUT_MS))) {
throw new WorkspaceRepoValidationError("not-a-git-work-tree");
}
return withWorkspaceModeLock(rootDir, async () => {
const config = await loadWorkspaceConfig(rootDir);
if (!config) throw new WorkspaceRepoValidationError("not-a-workspace");
if (config.repos.includes(normalized)) return { outcome: "already-member", repos: config.repos };
const repos = [...config.repos, normalized].sort();
await saveWorkspaceConfig(rootDir, { ...config, repos });
return { outcome: "added", repos };
});
}
export async function saveWorkspaceConfig(rootDir: string, config: WorkspaceConfig): Promise<void> {
const { mkdir, writeFile } = await import("node:fs/promises");
const { join } = await import("node:path");
const fusionDir = join(rootDir, ".fusion");
await mkdir(fusionDir, { recursive: true });
await writeFile(
join(fusionDir, WORKSPACE_CONFIG_FILENAME),
JSON.stringify(config, null, 2),
"utf-8",
);
}
/** Removes the workspace authority file and reports whether it existed. */
export async function removeWorkspaceConfig(rootDir: string): Promise<boolean> {
const { unlink } = await import("node:fs/promises");
const { join } = await import("node:path");
try {
await unlink(join(rootDir, ".fusion", WORKSPACE_CONFIG_FILENAME));
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return false;
throw error;
}
}
/*
FNXC:Workspace 2026-08-15-05:28:
The PostgreSQL setting alone cannot control workspace execution: the executor reads workspace.json,
while registration suppression reads config.json. Toggle both in the documented ordering and reconcile
from workspace.json, the runtime authority. Existing non-empty workspace configs are preserved because
registration and CLI flows may have saved a curated repo subset before publishing their settings update.
The helper receives filesystem operations because sibling calls in this module cannot be intercepted by
module mocks. Its per-root in-process lock keeps the multi-step disk mutation and its observed result from
braiding with another toggle; it does not fence another OS process, whose residual divergence is settled by
the next publish's re-read and reconciliation.
*/
export interface WorkspaceModeToggleOps {
loadWorkspaceConfig: typeof loadWorkspaceConfig;
saveWorkspaceConfig: typeof saveWorkspaceConfig;
setWorkspaceModeInConfig: (rootDir: string, enabled: boolean) => Promise<void>;
detectWorkspaceRepos: typeof detectWorkspaceRepos;
removeWorkspaceConfig: (rootDir: string) => Promise<boolean>;
}
export type WorkspaceModeToggleResult = {
enabled: boolean | undefined;
repos: string[];
workspaceConfigWritten: boolean;
workspaceConfigRemoved: boolean;
failureReason?: string;
mirrorDivergent?: boolean;
};
const workspaceModeLockTails = new Map<string, Promise<void>>();
/** Serializes one complete workspace-mode critical section for a project root. */
export async function withWorkspaceModeLock<T>(rootDir: string, fn: () => Promise<T>): Promise<T> {
const key = resolve(rootDir);
const previous = workspaceModeLockTails.get(key) ?? Promise.resolve();
let release!: () => void;
const tail = new Promise<void>((done) => { release = done; });
const chained = previous.then(() => tail);
workspaceModeLockTails.set(key, chained);
await previous;
try {
return await fn();
} finally {
release();
if (workspaceModeLockTails.get(key) === chained) workspaceModeLockTails.delete(key);
}
}
function workspaceToggleFailure(prefix: string, error: unknown): string {
const code = error && typeof error === "object" && "code" in error
? String((error as { code?: unknown }).code)
: error instanceof Error ? error.name : "unknown";
return `${prefix}: ${code}`;
}
export async function applyWorkspaceModeToggle(
rootDir: string,
enabled: boolean,
options: { ops?: Partial<WorkspaceModeToggleOps>; lockHeld?: boolean } = {},
): Promise<WorkspaceModeToggleResult> {
const ops: WorkspaceModeToggleOps = {
loadWorkspaceConfig,
saveWorkspaceConfig,
setWorkspaceModeInConfig,
detectWorkspaceRepos,
removeWorkspaceConfig,
...options.ops,
};
const run = async (): Promise<WorkspaceModeToggleResult> => {
let failureReason: string | undefined;
let mirrorValue: boolean | undefined;
let workspaceConfigWritten = false;
let workspaceConfigRemoved = false;
let repos: string[] = [];
try {
if (enabled) {
const existing = await ops.loadWorkspaceConfig(rootDir);
if (existing?.repos.length) {
repos = existing.repos;
await ops.setWorkspaceModeInConfig(rootDir, true);
mirrorValue = true;
} else {
repos = await ops.detectWorkspaceRepos(rootDir);
if (repos.length === 0) {
failureReason = "no-sub-repositories";
} else {
// Mirror first: a failed mirror must not leave workspace.json enabling execution.
await ops.setWorkspaceModeInConfig(rootDir, true);
mirrorValue = true;
await ops.saveWorkspaceConfig(rootDir, { repos });
workspaceConfigWritten = true;
}
}
} else {
// Disable mirror first so a partial delete cannot be auto-detected back to enabled.
await ops.setWorkspaceModeInConfig(rootDir, false);
mirrorValue = false;
workspaceConfigRemoved = await ops.removeWorkspaceConfig(rootDir);
}
} catch (error) {
failureReason ??= workspaceToggleFailure(enabled ? "workspace-config-write-failed" : "workspace-config-remove-failed", error);
}
let achieved: boolean | undefined;
try {
const observed = await ops.loadWorkspaceConfig(rootDir);
repos = observed?.repos ?? [];
achieved = repos.length > 0;
} catch (_error) {
return { enabled: undefined, repos, workspaceConfigWritten, workspaceConfigRemoved, failureReason: "achieved-state-unknown" };
}
let mirrorDivergent = false;
if (mirrorValue !== undefined && mirrorValue !== achieved) {
try {
await ops.setWorkspaceModeInConfig(rootDir, achieved);
} catch (error) {
mirrorDivergent = true;
failureReason ??= workspaceToggleFailure("workspace-config-mirror-compensation-failed", error);
}
}
return { enabled: achieved, repos, workspaceConfigWritten, workspaceConfigRemoved, failureReason, mirrorDivergent: mirrorDivergent || undefined };
};
try {
return options.lockHeld ? await run() : await withWorkspaceModeLock(rootDir, run);
} catch (error) {
return { enabled: undefined, repos: [], workspaceConfigWritten: false, workspaceConfigRemoved: false, failureReason: workspaceToggleFailure("workspace-toggle-failed", error) };
}
}