From cab375a6f8a5edd87f6cb4ec9095c3521d733f52 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 24 Jun 2026 00:03:14 -0700 Subject: [PATCH 1/6] fix(workspace): detect sub-repos when workspace.json is missing The initial fix only checked loadWorkspaceConfig, but the dashboard POST /api/projects and `fn project add` routes never create workspace.json (only registerProjectInteractive does). So re-adding a workspace project through the dashboard still triggered git init because the guard saw no workspace.json. Add detectWorkspaceRepos as a fallback: after loadWorkspaceConfig and isInsideGitWorkTree both miss, probe for git sub-repos. If found, persist workspace.json and return 'existing' without running git init. This covers all registration surfaces. --- .../core/src/__tests__/git-repository.test.ts | 20 +++++++++++++++++++ packages/core/src/git-repository.ts | 16 +++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/packages/core/src/__tests__/git-repository.test.ts b/packages/core/src/__tests__/git-repository.test.ts index 973109b6b4..e61f97f724 100644 --- a/packages/core/src/__tests__/git-repository.test.ts +++ b/packages/core/src/__tests__/git-repository.test.ts @@ -120,4 +120,24 @@ 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); + }); }); diff --git a/packages/core/src/git-repository.ts b/packages/core/src/git-repository.ts index 3bef28e70a..beffccd194 100644 --- a/packages/core/src/git-repository.ts +++ b/packages/core/src/git-repository.ts @@ -59,6 +59,22 @@ 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 so future calls hit the fast + loadWorkspaceConfig path, and skip git init. 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. + */ + const detectedRepos = await detectWorkspaceRepos(projectPath); + if (detectedRepos.length > 0) { + await saveWorkspaceConfig(projectPath, { repos: detectedRepos }); + return "existing"; + } + try { await runner("git", ["-C", projectPath, "init"], { timeout }); return "initialized"; From ff155b9df721c9406a7d5cb2908185e607d14278 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 24 Jun 2026 00:26:31 -0700 Subject: [PATCH 2/6] fix(workspace): exclude node_modules from detection, best-effort save Address PR #1739 review feedback: - P1: Exclude node_modules, .fusion, .pi from detectWorkspaceRepos so packages installed from git sources don't produce false-positive workspace members. - P2: Wrap saveWorkspaceConfig in try/catch so a write failure (permissions, disk full) doesn't fail the current registration. - Nitpick: Thread runner/timeout through detectWorkspaceRepos so custom-runner callers are consistent across all code paths. --- .../core/src/__tests__/git-repository.test.ts | 18 +++++++++++ packages/core/src/git-repository.ts | 31 ++++++++++++++++--- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/core/src/__tests__/git-repository.test.ts b/packages/core/src/__tests__/git-repository.test.ts index e61f97f724..d26f0fa052 100644 --- a/packages/core/src/__tests__/git-repository.test.ts +++ b/packages/core/src/__tests__/git-repository.test.ts @@ -7,6 +7,7 @@ import { promisify } from "node:util"; import { ensureGitRepositoryForProjectPath, GitRepositoryInitializationError, + detectWorkspaceRepos, type GitRepositoryCommandRunner, } from "../git-repository.js"; @@ -140,4 +141,21 @@ describe("ensureGitRepositoryForProjectPath", () => { // workspace.json should be auto-persisted so future calls hit the fast path expect(existsSync(join(projectPath, ".fusion", "workspace.json"))).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"]); + + const detected = await detectWorkspaceRepos(projectPath); + + expect(detected).toEqual([]); + }); }); diff --git a/packages/core/src/git-repository.ts b/packages/core/src/git-repository.ts index beffccd194..a835db73d0 100644 --- a/packages/core/src/git-repository.ts +++ b/packages/core/src/git-repository.ts @@ -69,9 +69,14 @@ export async function ensureGitRepositoryForProjectPath( and `fn project add` do not — without this fallback they would create a stray .git at the workspace root because loadWorkspaceConfig returned null. */ - const detectedRepos = await detectWorkspaceRepos(projectPath); + const detectedRepos = await detectWorkspaceRepos(projectPath, runner, timeout); if (detectedRepos.length > 0) { - await saveWorkspaceConfig(projectPath, { repos: detectedRepos }); + try { + await saveWorkspaceConfig(projectPath, { repos: detectedRepos }); + } catch { + // Best-effort: persist for the fast path on future calls, but don't fail + // the current registration if the write fails (permissions, disk full, etc.). + } return "existing"; } @@ -132,8 +137,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"); @@ -150,7 +163,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 { @@ -159,7 +182,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); } } From 9aaf9117352b2364964a6dd864749d846e105d20 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 24 Jun 2026 00:45:24 -0700 Subject: [PATCH 3/6] feat(workspace): add per-project workspaceMode setting with interactive confirmation Add workspaceMode as a first-class ProjectSettings boolean that controls whether the project root is treated as a workspace parent (multi-repo) or a single git repo. - ProjectSettings type + DEFAULT_PROJECT_SETTINGS: workspaceMode?: boolean - CLI registerProjectInteractive: when sub-repos are detected, ask the user to confirm workspace mode instead of auto-applying - TaskStore.updateSettings: when workspaceMode is toggled on, detect sub-repos and persist workspace.json; when toggled off, remove it - Dashboard SettingsModal GeneralSection: workspace mode toggle checkbox This lets users change workspace mode per-project at any time via the dashboard Settings or PUT /settings API. --- packages/cli/src/project-resolver.ts | 25 ++++++++++++- packages/core/src/settings-schema.ts | 1 + packages/core/src/store.ts | 37 ++++++++++++++++++- packages/core/src/types.ts | 10 +++++ .../settings/sections/GeneralSection.tsx | 12 ++++++ 5 files changed, 82 insertions(+), 3 deletions(-) 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/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.")} +