fix(workspace): detect sub-repos when workspace.json is missing (#1739)

## Problem

The previous fix (#1738) only checked `loadWorkspaceConfig` to skip `git
init` on workspace-mode roots. However, the dashboard `POST
/api/projects` and `fn project add` routes never create `workspace.json`
(only the CLI interactive setup in `registerProjectInteractive` does).
So re-adding a workspace project through the dashboard still triggered
`git init` because the guard saw no `workspace.json`, creating a stray
`.git` at the workspace root.

## Fix

Add `detectWorkspaceRepos` as a fallback in
`ensureGitRepositoryForProjectPath`: 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.

## Files Changed

- `packages/core/src/git-repository.ts` — `detectWorkspaceRepos`
fallback + `saveWorkspaceConfig` + FNXC comment
- `packages/core/src/__tests__/git-repository.test.ts` — regression test
verifying sub-repo detection skips `git init` and persists
`workspace.json`

## Testing

- `pnpm typecheck` (`@fusion/core`) — pass
- `pnpm lint` — pass
- `vitest run git-repository.test.ts` — 6/6 pass (5 existing + 1 new)

<!-- stage-review-badge-begin -->

---

<a href="https://stagereview.app/Runfusion/Fusion/pull/1739">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://stagereview.app/assets/gh-open-in-stage-dark.svg">
<img src="https://stagereview.app/assets/gh-open-in-stage-light.svg"
alt="Open in Stage">
  </picture>
</a>

<!-- stage-review-badge-end -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a “Workspace mode (multi-repo)” checkbox in the dashboard to
enable/disable workspace behavior.
* Updated CLI interactive mode to prompt for using workspace mode;
non-interactive mode continues to auto-detect sub-repositories.
* **Bug Fixes**
* Improved workspace auto-detection when workspace config is missing,
including persisting detected repositories.
* Workspace mode now skips creating a root `.git` when sub-repositories
are detected.
* When workspace mode is explicitly disabled, auto-detection is skipped;
disabling also removes the workspace config.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-06-24 10:10:12 -07:00
committed by GitHub
7 changed files with 256 additions and 6 deletions

View File

@@ -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 {

View File

@@ -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);
});
});

View File

@@ -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<string[]> {
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");
@@ -134,7 +170,17 @@ export async function detectWorkspaceRepos(dir: string): Promise<string[]> {
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.
*/
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<string[]> {
} 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<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.
*/
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

View File

@@ -538,6 +538,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
researchDefaultTimeout: 300000,
researchMaxSourcesPerRun: 20,
researchMaxSynthesisRounds: 2,
workspaceMode: false,
} satisfies CompleteSettings<ProjectSettingsSchema>;
/**

View File

@@ -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;
});
}

View File

@@ -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;
}
/**

View File

@@ -94,6 +94,18 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast
<input id="ephemeralAgentsEnabled" type="checkbox" checked={form.ephemeralAgentsEnabled !== false} onChange={(e) => setForm((f) => ({ ...f, ephemeralAgentsEnabled: e.target.checked }))}/>{t("settings.general.useEphemeralTaskWorkerAgents", " Use ephemeral task-worker agents ")}</label>
<small>{t("settings.general.whenEnabledDefaultFusionSpawnsShortLived", " When enabled (default), Fusion spawns short-lived ")}<code>executor-FN-XXXX</code>{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. ")}</small>
</div>
{/*
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.
*/}
<div className="form-group">
<label htmlFor="workspaceMode" className="checkbox-label">
<input id="workspaceMode" type="checkbox" checked={form.workspaceMode === true} onChange={(e) => setForm((f) => ({ ...f, workspaceMode: e.target.checked }))}/>{t("settings.general.workspaceMode", " Workspace mode (multi-repo) ")}</label>
<small>{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.")}</small>
</div>
<div className="form-group">
<label htmlFor="completionDocumentationMode">{t("settings.general.completionDocumentationAutomation", "Completion Documentation Automation")}</label>
<select id="completionDocumentationMode" value={form.completionDocumentationMode || "off"} onChange={(e) => setForm((f) => ({