diff --git a/packages/cli/src/project-resolver.ts b/packages/cli/src/project-resolver.ts index 68ac2f71d1..9e19789add 100644 --- a/packages/cli/src/project-resolver.ts +++ b/packages/cli/src/project-resolver.ts @@ -645,8 +645,27 @@ export async function registerProjectInteractive( if (subRepos.length > 0) { console.log(`\n Found ${subRepos.length} git repositories in ${absPath}:`); subRepos.forEach((r: string) => console.log(` • ${r}`)); - console.log(`\n Initializing as a Fusion workspace...\n`); - detectedSubRepos = subRepos; + + if (interactive) { + /* + FNXC:Workspace 2026-06-24-16:00: + Ask the user to confirm workspace mode instead of auto-applying it. A directory with + nested git repos might be a monorepo with submodules or an existing project that + happens to have git-tracked dependencies — the user must explicitly opt in. + */ + const useWorkspace = await promptConfirm( + `\n Use workspace mode? (tasks run per sub-repo, no git at the root)`, + true, + ); + if (useWorkspace) { + detectedSubRepos = subRepos; + } else { + console.log(` ⚠ Skipping workspace mode. A git repo will be initialized at the root.`); + } + } else { + // Non-interactive: auto-apply (dashboard registration or scripted flow) + detectedSubRepos = subRepos; + } // workspace.json is written below, only after a confirmed store.init() succeeds. } // else: fall through to existing error path @@ -663,6 +682,8 @@ export async function registerProjectInteractive( await store.init(); if (detectedSubRepos) { await saveWorkspaceConfig(absPath, { repos: detectedSubRepos }); + // Persist workspaceMode in config.json so it's visible/toggleable in the dashboard + await store.updateSettings({ workspaceMode: true }); } console.log(` ✓ Initialized fn at ${absPath}`); } else { diff --git a/packages/core/src/__tests__/git-repository.test.ts b/packages/core/src/__tests__/git-repository.test.ts index 973109b6b4..e2fd763cef 100644 --- a/packages/core/src/__tests__/git-repository.test.ts +++ b/packages/core/src/__tests__/git-repository.test.ts @@ -1,12 +1,13 @@ import { afterEach, describe, expect, it } from "vitest"; import { execFile } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { ensureGitRepositoryForProjectPath, GitRepositoryInitializationError, + detectWorkspaceRepos, type GitRepositoryCommandRunner, } from "../git-repository.js"; @@ -120,4 +121,83 @@ describe("ensureGitRepositoryForProjectPath", () => { // No .git should be created at the workspace root expect(existsSync(join(projectPath, ".git"))).toBe(false); }); + + it("detects workspace sub-repos and skips git init when workspace.json is missing", async () => { + const projectPath = tempDir("fusion-git-workspace-detect-"); + // Create a real git sub-repo inside the project root (but no workspace.json) + const subRepo = join(projectPath, "repo-a"); + mkdirSync(subRepo, { recursive: true }); + await git(subRepo, ["init", "-b", "main"]); + await git(subRepo, ["config", "user.email", "test@test.com"]); + await git(subRepo, ["config", "user.name", "Test"]); + writeFileSync(join(subRepo, "README.md"), "# repo-a\n"); + await git(subRepo, ["add", "README.md"]); + await git(subRepo, ["commit", "-m", "init"]); + + const outcome = await ensureGitRepositoryForProjectPath(projectPath); + + expect(outcome).toBe("existing"); + expect(existsSync(join(projectPath, ".git"))).toBe(false); + // workspace.json should be auto-persisted so future calls hit the fast path + expect(existsSync(join(projectPath, ".fusion", "workspace.json"))).toBe(true); + // config.json should reflect workspaceMode: true so the dashboard toggle is correct + const configPath = join(projectPath, ".fusion", "config.json"); + expect(existsSync(configPath)).toBe(true); + const config = JSON.parse(readFileSync(configPath, "utf-8")); + expect(config.settings?.workspaceMode).toBe(true); + }); + + it("does not misclassify node_modules git dirs as workspace sub-repos", async () => { + const projectPath = tempDir("fusion-git-workspace-nodemodules-"); + // Create a node_modules sub-dir with a real .git (simulates a package installed from git) + const fakePkg = join(projectPath, "node_modules", "some-package"); + mkdirSync(fakePkg, { recursive: true }); + await git(fakePkg, ["init", "-b", "main"]); + await git(fakePkg, ["config", "user.email", "test@test.com"]); + await git(fakePkg, ["config", "user.name", "Test"]); + writeFileSync(join(fakePkg, "index.js"), "module.exports = {};\n"); + await git(fakePkg, ["add", "index.js"]); + await git(fakePkg, ["commit", "-m", "init"]); + + // Also create a real sibling sub-repo to prove it IS detected while node_modules is excluded + const realRepo = join(projectPath, "my-app"); + mkdirSync(realRepo, { recursive: true }); + await git(realRepo, ["init", "-b", "main"]); + await git(realRepo, ["config", "user.email", "test@test.com"]); + await git(realRepo, ["config", "user.name", "Test"]); + writeFileSync(join(realRepo, "README.md"), "# my-app\n"); + await git(realRepo, ["add", "README.md"]); + await git(realRepo, ["commit", "-m", "init"]); + + const detected = await detectWorkspaceRepos(projectPath); + + // node_modules is excluded; my-app is detected + expect(detected).toEqual(["my-app"]); + }); + + it("skips auto-detection when workspaceMode is explicitly false in config.json", async () => { + const projectPath = tempDir("fusion-git-workspace-disabled-"); + // Create a real git sub-repo so detectWorkspaceRepos would find it + const subRepo = join(projectPath, "repo-a"); + mkdirSync(subRepo, { recursive: true }); + await git(subRepo, ["init", "-b", "main"]); + await git(subRepo, ["config", "user.email", "test@test.com"]); + await git(subRepo, ["config", "user.name", "Test"]); + writeFileSync(join(subRepo, "README.md"), "# repo-a\n"); + await git(subRepo, ["add", "README.md"]); + await git(subRepo, ["commit", "-m", "init"]); + + // Write config.json with workspaceMode: false (user disabled it via dashboard) + mkdirSync(join(projectPath, ".fusion"), { recursive: true }); + writeFileSync( + join(projectPath, ".fusion", "config.json"), + JSON.stringify({ settings: { workspaceMode: false } }), + ); + + const outcome = await ensureGitRepositoryForProjectPath(projectPath); + + // Should proceed to git init, not workspace detection + expect(outcome).toBe("initialized"); + expect(existsSync(join(projectPath, ".git"))).toBe(true); + }); }); diff --git a/packages/core/src/git-repository.ts b/packages/core/src/git-repository.ts index 3bef28e70a..063190b307 100644 --- a/packages/core/src/git-repository.ts +++ b/packages/core/src/git-repository.ts @@ -59,6 +59,34 @@ export async function ensureGitRepositoryForProjectPath( return "existing"; } + /* + FNXC:Workspace 2026-06-24-14:30: + Fallback workspace detection: when workspace.json is missing (e.g. project added via + dashboard or `fn project add`, which don't run the interactive workspace detection flow), + probe for git sub-repos. If found, persist workspace.json AND set workspaceMode: true in + config.json so the dashboard toggle reflects the actual state. This covers all registration + surfaces: the CLI interactive setup writes workspace.json explicitly, but dashboard POST + /api/projects and `fn project add` do not — without this fallback they would create a stray + .git at the workspace root because loadWorkspaceConfig returned null. + + FNXC:Workspace 2026-06-24-17:00: + If the user has explicitly disabled workspace mode (workspaceMode: false in config.json), + skip auto-detection and proceed to git init. Without this guard, toggling workspace mode off + via the dashboard would have no lasting effect — the fallback would re-detect sub-repos and + re-create workspace.json on the next registration call. + */ + if (!(await isWorkspaceModeExplicitlyDisabled(projectPath))) { + const detectedRepos = await detectWorkspaceRepos(projectPath, runner, timeout); + if (detectedRepos.length > 0) { + // Write config.json first so a failure here doesn't leave a stale workspace.json + // that would short-circuit loadWorkspaceConfig on the next call without the + // workspaceMode setting being persisted. + await setWorkspaceModeInConfig(projectPath, true); + await saveWorkspaceConfig(projectPath, { repos: detectedRepos }); + return "existing"; + } + } + try { await runner("git", ["-C", projectPath, "init"], { timeout }); return "initialized"; @@ -116,8 +144,16 @@ function extractCommandErrorMessage(error: unknown): string { /** * 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): Promise { +export async function detectWorkspaceRepos( + dir: string, + runner: GitRepositoryCommandRunner = runGitCommand, + timeout: number = DEFAULT_GIT_TIMEOUT_MS, +): Promise { let entries: string[]; try { const { readdir } = await import("node:fs/promises"); @@ -134,7 +170,17 @@ export async function detectWorkspaceRepos(dir: string): Promise { 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/) 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. + */ + const EXCLUDED_ENTRIES = new Set(["node_modules", ".fusion", ".git", ".pi"]); for (const entry of entries) { + if (EXCLUDED_ENTRIES.has(entry)) continue; + const childDir = join(dir, entry); // Cheap pre-filter: skip children with no `.git` marker at all before spawning git. try { @@ -143,7 +189,7 @@ export async function detectWorkspaceRepos(dir: string): Promise { } catch { continue; } - if (await isInsideGitWorkTree(childDir, runGitCommand, DEFAULT_GIT_TIMEOUT_MS)) { + if (await isInsideGitWorkTree(childDir, runner, timeout)) { found.push(entry); } } @@ -156,6 +202,51 @@ export interface WorkspaceConfig { const WORKSPACE_CONFIG_FILENAME = "workspace.json"; +/** + * 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 { + 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. + */ +async function setWorkspaceModeInConfig(projectPath: string, value: boolean): Promise { + 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 = {}; + try { + config = JSON.parse(await readFile(configPath, "utf-8")) as Record; + } 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; + 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 diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 8933340707..77eb6e7176 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -538,6 +538,7 @@ export const DEFAULT_PROJECT_SETTINGS = { researchDefaultTimeout: 300000, researchMaxSourcesPerRun: 20, researchMaxSynthesisRounds: 2, + workspaceMode: false, } satisfies CompleteSettings; /** diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 350bd90b63..cc921f093a 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -1,8 +1,9 @@ import { EventEmitter } from "node:events"; import { randomUUID } from "node:crypto"; -import { mkdir, readdir, readFile, stat, writeFile, rename, unlink } from "node:fs/promises"; +import { mkdir, readdir, readFile, stat, writeFile, rename, unlink, rm } from "node:fs/promises"; import { join } from "node:path"; import { existsSync, watch, type Dirent, type FSWatcher } from "node:fs"; +import { detectWorkspaceRepos, saveWorkspaceConfig, loadWorkspaceConfig } from "./git-repository.js"; import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, Artifact, ArtifactCreateInput, ArtifactType, ArtifactWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, CommitAssociationDiffBackfillReport, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrEntityState, PrThreadState, PrThreadOutcome, PrConflictState, PrChecksRollup, PrReviewDecision, PluginActivation, PluginActivationInput } from "./types.js"; import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js"; import { VALID_TRANSITIONS, COLUMNS, DEFAULT_SETTINGS, isColumn, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey, assertNotWorkspaceTaskMerge } from "./types.js"; @@ -3879,6 +3880,40 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} } } + /* + FNXC:Workspace 2026-06-24-16:00: + When workspaceMode is toggled on, detect sub-repos and persist workspace.json so the + executor and ensureGitRepositoryForProjectPath treat the root as workspace-mode. When + toggled off, remove workspace.json so the root falls back to single-repo behavior. + */ + if (updatedMerged.workspaceMode === true && previousMerged.workspaceMode !== true) { + try { + const existing = await loadWorkspaceConfig(this.rootDir); + if (!existing) { + const repos = await detectWorkspaceRepos(this.rootDir); + if (repos.length > 0) { + await saveWorkspaceConfig(this.rootDir, { repos }); + } + } + } catch (err) { + storeLog.warn("workspace.json sync failed after workspaceMode toggle-on", { + phase: "updateSettings:workspace-toggle-on", + rootDir: this.rootDir, + error: err instanceof Error ? err.message : String(err), + }); + } + } else if (updatedMerged.workspaceMode === false && previousMerged.workspaceMode === true) { + try { + await rm(join(this.rootDir, ".fusion", "workspace.json"), { force: true }); + } catch (err) { + storeLog.warn("workspace.json removal failed after workspaceMode toggle-off", { + phase: "updateSettings:workspace-toggle-off", + rootDir: this.rootDir, + error: err instanceof Error ? err.message : String(err), + }); + } + } + return updatedMerged; }); } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index bcdeda2481..c90f437082 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -4466,6 +4466,16 @@ export interface ProjectSettings { /** Hard cap on the synthesized "Earlier room context" summary block. * Default: 1500. */ chatRoomSummaryMaxChars?: number; + /** + * FNXC:Workspace 2026-06-24-16:00: + * When true, the project root is treated as a workspace-mode parent directory containing + * multiple git sub-repos (recorded in .fusion/workspace.json), not a single git repo. + * ensureGitRepositoryForProjectPath skips `git init` for workspace roots, and the executor + * runs tasks per-sub-repo instead of at the root. Auto-detected at registration time when + * sub-repos are found, with an interactive confirmation prompt. Can be toggled per-project + * via the dashboard Settings modal or PUT /settings. + */ + workspaceMode?: boolean; } /** diff --git a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx index 51cfdca7d0..fdf64991bc 100644 --- a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx @@ -94,6 +94,18 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast setForm((f) => ({ ...f, ephemeralAgentsEnabled: e.target.checked }))}/>{t("settings.general.useEphemeralTaskWorkerAgents", " Use ephemeral task-worker agents ")} {t("settings.general.whenEnabledDefaultFusionSpawnsShortLived", " When enabled (default), Fusion spawns short-lived ")}executor-FN-XXXX{t("settings.general.agentsToRunEachTaskWhenDisabledOnly", " agents to run each task. When disabled, only permanent agents execute tasks and the scheduler auto-assigns work using the agent reporting chain. Tasks with no eligible permanent agent stay queued. ")} + {/* + FNXC:Workspace 2026-06-24-16:00: + Workspace mode toggle: when enabled, the project root is treated as a workspace parent + containing multiple git sub-repos instead of a single git repo. The executor runs tasks + per-sub-repo, and git init is skipped at the root. Toggling on triggers detectWorkspaceRepos + and persists .fusion/workspace.json; toggling off removes it. + */} +
+ + {t("settings.general.workspaceModeHint", "When enabled, the project root is treated as a workspace containing multiple git sub-repos. Tasks run per-sub-repo and no git repo is created at the root. Disable for single-repo projects.")} +