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>
This commit is contained in:
Fusion Agent
2026-08-19 13:31:41 +00:00
parent aad4b73a47
commit 29f4de39c4
14 changed files with 643 additions and 281 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Make project onboarding produce task-ready Git repositories or fail closed.
category: fix
dev: Shared registration now creates a baseline HEAD, reconciles managed Fusion ignore rules, preserves existing repositories, and prepares workspace members before activation.

View File

@@ -149,10 +149,9 @@ fn init
fn init --name my-project --path /absolute/path/to/project
```
When the target directory is not already a Git repository, Fusion initializes
minimal Git metadata during registration so task worktrees can be created. Use
`fn init --git` only when you also want Fusion to create the explicit starter
commit used by that flag.
Project initialization is always execution-ready. When the target directory is not already a Git repository, Fusion runs `git init`, creates a verifiable baseline `HEAD` containing only the managed `.gitignore`, and verifies that task worktrees can be created. An existing committed repository keeps its history, branch, remotes, configuration, index, and user changes; missing managed ignore rules are left visible as user changes rather than committed automatically.
The managed ignore entries are `.fusion/`, `.pi/`, `.worktrees/`, `fusion.db`, `fusion.db-wal`, and `fusion.db-shm`. Git readiness is required before registration or activation, so Git and filesystem failures fail the command instead of reporting a usable local project. `fn init --git` remains accepted for script compatibility and has the same behavior as the default; it is no longer a separate Git initialization path.
During fresh initialization, Fusion also installs the bundled `fusion` skill into supported local agent homes when the target skill does not already exist:
@@ -885,9 +884,7 @@ Subcommands: `list|ls`, `add`, `remove|rm`, `show`, `info`, `set-default|default
`fn project list` and `fn project show/info` report `In-Flight Agents` from live task state: in-progress executors plus triage planners whose task is in `triage` with `status === "planning"` and is not paused. The readout intentionally ignores stale persisted `projectHealth.inFlightAgentCount` bookkeeping.
`fn project add` registers an existing directory with Fusion. If the directory
does not contain a Git repository yet, Fusion runs a minimal `git init` during
registration and fails the registration if Git is unavailable.
`fn project add` registers an existing directory with Fusion. Registration first establishes the shared Git-readiness contract: non-Git and unborn repositories receive a baseline `HEAD`, managed Fusion-local paths are ignored, and committed repositories are preserved. If Git, ignore reconciliation, or baseline creation fails, the project is not registered or activated.
---

View File

@@ -86,7 +86,16 @@ In each repository you want Fusion to manage, run:
fn init
```
On fresh init, Fusion also installs its bundled `fusion` skill into supported agent homes (`~/.claude/skills/fusion`, `~/.codex/skills/fusion`, `~/.gemini/skills/fusion`) when those targets are missing. Existing installs are left untouched.
Registration is fail-closed on Git readiness. For a new or unborn repository, Fusion creates a real baseline `HEAD` containing only its managed `.gitignore`; an existing committed repository keeps its history, branch, remotes, configuration, index, and user changes. Fusion adds missing managed ignore rules without committing an existing repository's changes:
- `.fusion/`
- `.pi/`
- `.worktrees/`
- `fusion.db`
- `fusion.db-wal`
- `fusion.db-shm`
If Git, `.gitignore` reconciliation, or baseline creation fails, the project is not registered or activated. Install Git on the Fusion host and retry; Fusion never offers a create-anyway bypass. On fresh init, Fusion also installs its bundled `fusion` skill into supported agent homes (`~/.claude/skills/fusion`, `~/.codex/skills/fusion`, `~/.gemini/skills/fusion`) when those targets are missing. Existing installs are left untouched.
## First Run and Onboarding
@@ -101,9 +110,9 @@ On first launch, Fusion opens an onboarding wizard with guided setup steps:
1. **AI Setup** — choose a provider and authenticate (you only need one to start). Anthropic/Claude and OpenAI Codex use a pasted authorization-code OAuth flow in onboarding and Settings (sign in, then paste the final redirect URL or code back into Fusion), and Fusion warns before login so you remember to copy the browser address bar URL before the redirect tab appears to fail. After the initial Claude OAuth login, Fusion normally refreshes the OAuth credential automatically with the stored refresh token when the access token expires, so repeated manual re-login is not usually required. **Anthropic — via Claude CLI** remains available as a separate optional path. Deprecated Google Gemini CLI / Antigravity entries are hidden; Google/Gemini API key, Google Generative AI, Vertex, and Cloud Code options remain available.
2. **GitHub (Optional)** — connect GitHub for issue import and PR workflows. When dashboard OAuth is configured, this step includes an in-flow **Connect GitHub OAuth** action. It also shows whether the Fusion host has GitHub CLI (`gh`) available: run `gh auth login` on the host when `gh` is installed but unauthenticated, or use the GitHub CLI releases/install guidance when `gh` is missing. The step also checks whether the Fusion host can run `git`; if Git is missing, onboarding shows platform install guidance before you reach clone, init, or repository registration flows. Install Git and GitHub CLI on the machine or service container running Fusion, not just on the browser/client device. See [Git downloads](https://git-scm.com/downloads) and [GitHub CLI releases](https://github.com/cli/cli/releases/latest) for macOS, Windows, and Linux options.
3. **Project Setup** — choose how Fusion should prepare a repository:
- **Use Existing Directory** registers a folder that is already a git repository or a workspace root with detected sub-repositories. See [Workspaces](./workspaces.md) for multi-repository setup and operation.
- **Initialize New Repository** registers an existing local folder and lets the server run `git init` during registration when the folder is not already a git repository.
- **Clone Git Repository** runs `git clone` from a remote URL into an empty or absent destination directory, then registers the cloned folder. Fusion rejects blank clone URLs and populated destinations.
- **Use Existing Directory** registers a folder that is already a git repository or a workspace root with detected sub-repositories, then verifies Git readiness before activation. See [Workspaces](./workspaces.md) for multi-repository setup and operation.
- **Initialize New Repository** registers an existing local folder and lets the server run `git init`, create a baseline `HEAD`, and reconcile managed ignores before activation.
- **Clone Git Repository** runs `git clone` from a remote URL into an empty or absent destination directory, then verifies the cloned checkout before activation. Fusion rejects blank clone URLs, populated destinations, and any readiness failure; a cloned checkout remains available for retry.
- Creating a folder from the project directory picker automatically selects that new folder for registration.
4. **First Task** — create your first task or import one from GitHub.

View File

@@ -19,7 +19,7 @@ You can register a workspace from three surfaces:
- The project registration API accepts `workspaceMode`. An explicit `true` requests detection; an omitted value also permits automatic detection. The detection endpoint returns `{ repos, isWorkspace }`.
- The interactive CLI project resolver detects candidates, asks you to confirm workspace mode, initializes the store, then writes the workspace configuration.
`detectWorkspaceRepos` scans only direct children of the selected root. It excludes `node_modules`, `.fusion`, `.git`, and `.pi`; a child must have a `.git` marker and pass a real Git work-tree probe. Nested repositories are not discovered. When workspace configuration is present, `ensureGitRepositoryForProjectPath` intentionally skips `git init` at the root: do not create a root repository just to make workspace mode work.
`detectWorkspaceRepos` scans only direct children of the selected root. It excludes `node_modules`, `.fusion`, `.git`, and `.pi`; a child must have a `.git` marker and pass a real Git work-tree probe. Nested repositories are not discovered. Registration then prepares every configured or detected member: each member must have a verifiable `HEAD` and the managed `.gitignore` rules (`.fusion/`, `.pi/`, `.worktrees/`, `fusion.db`, `fusion.db-wal`, and `fusion.db-shm`). If a member is non-Git or unborn, Fusion initializes it and creates a baseline; if preparation fails, no workspace project row is registered or activated. When workspace configuration is present, `ensureGitRepositoryForProjectPath` intentionally skips `git init` at the root: do not create a root repository just to make workspace mode work.
## The workspace config file
@@ -39,11 +39,11 @@ For example:
Each `repos` entry is relative to the workspace root and must stay inside it. Absolute paths, `..` escapes, empty values, and non-string values are rejected or filtered when `loadWorkspaceConfig` reads the file. Keep member repositories as direct children so they remain discoverable and easy to operate.
The configuration file is written by registration, repository initialization, the interactive CLI resolver, and `addWorkspaceRepo`. The configuration file makes the root a workspace at repository-initialization time. The automatic path writes the `workspaceMode` setting before `workspace.json`, preventing a partially written configuration from making the next registration incorrectly treat the root as a workspace.
The configuration file is written by registration, repository initialization, the interactive CLI resolver, and `addWorkspaceRepo`. The configuration file makes the root a workspace at repository-initialization time. The automatic path prepares members before publishing the workspace decision, then writes the `workspaceMode` setting before `workspace.json`, preventing a partially written configuration from making the next registration incorrectly treat the root as a workspace. The root remains browse-only and non-Git throughout.
## Adding a repository to an existing workspace
In **Settings → General → Workspace repositories**, choose a detected candidate or enter a direct-child directory and select **Add**. The same operation is available to integrations as `POST /api/git/workspace-repos` with `{ "repo": "api" }`. Adds are idempotent. Fusion requires an in-root direct child that is a real Git work tree and rejects excluded names (`node_modules`, `.fusion`, `.git`, `.pi`), absolute paths, and escapes.
In **Settings → General → Workspace repositories**, choose a detected candidate or enter a direct-child directory and select **Add**. The same operation is available to integrations as `POST /api/git/workspace-repos` with `{ "repo": "api" }`. Adds are idempotent. Fusion requires an in-root direct child that is a real Git work tree and rejects excluded names (`node_modules`, `.fusion`, `.git`, `.pi`, `.worktrees`), absolute paths, and escapes.
A running task picks up a newly added member on its next `fn_acquire_repo_worktree` call without restarting the engine. Membership only grows during a live run: a failed or empty refresh preserves the last known-good members; removals require an engine restart. Tasks already in review or merging refuse late acquisition to avoid bypassing review; create a follow-up task instead.

View File

@@ -10,7 +10,7 @@ import { runInit } from "../init.js";
import { installShippedSkillsIntoProject, SHIPPED_SKILL_NAMES, type ShippedSkillName } from "../claude-skills.js";
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { GitRepositoryInitializationError } from "@fusion/core";
import { ensureGitRepositoryForProjectPath, GitRepositoryInitializationError } from "@fusion/core";
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
const mock = vi.fn(function () {});
@@ -75,8 +75,9 @@ async function git(command: string, cwd: string): Promise<string> {
}
const localStorageGitignoreEntries = [
".fusion",
".pi",
".fusion/",
".pi/",
".worktrees/",
"fusion.db",
"fusion.db-wal",
"fusion.db-shm",
@@ -110,18 +111,19 @@ describe("init command", () => {
createdAt: "2026-07-14T00:00:00.000Z",
updatedAt: "",
});
mockEnsureProjectForPath.mockResolvedValue({
outcome: "registered",
mockEnsureProjectForPath.mockImplementation(async ({ path }: { path: string }) => ({
outcome: "registered" as const,
gitRepository: await ensureGitRepositoryForProjectPath(path),
project: {
id: "proj_1234567890abcdef",
name: "test-project",
path: tempProjectDir,
isolationMode: "in-process",
status: "initializing",
path,
isolationMode: "in-process" as const,
status: "initializing" as const,
createdAt: "2026-07-14T00:00:00.000Z",
updatedAt: "",
},
});
}));
mockIsValidSqliteDatabaseFile.mockImplementation((dbPath: string) => {
if (!existsSync(dbPath)) {
return false;
@@ -193,7 +195,7 @@ describe("init command", () => {
id: "proj_1234567890abcdef",
createdAt: "2026-07-14T00:00:00.000Z",
});
expect(mockEnsureProjectForPath).not.toHaveBeenCalled();
expect(mockEnsureProjectForPath).toHaveBeenCalledWith(expect.objectContaining({ path: tempProjectDir }));
});
it("should reject existing invalid fusion.db files", async () => {
@@ -389,7 +391,7 @@ describe("init command", () => {
const lines = toLines(readFileSync(gitignorePath, "utf-8"));
expect(lines.filter((line) => line === ".fusion")).toHaveLength(1);
for (const entry of [".pi", "fusion.db", "fusion.db-wal", "fusion.db-shm"]) {
for (const entry of [".pi/", ".worktrees/", "fusion.db", "fusion.db-wal", "fusion.db-shm"]) {
expect(lines.filter((line) => line === entry)).toHaveLength(1);
}
});
@@ -405,12 +407,21 @@ describe("init command", () => {
expect(contentAfterInit).toBe(existingContent);
});
it("initializes git when --git is enabled in a non-git directory", async () => {
it.each([false, true])("creates execution-ready Git state by default and with --git=%s", async (compatibilityFlag) => {
expect(existsSync(join(tempProjectDir, ".git"))).toBe(false);
await runInit({ path: tempProjectDir, git: true });
await runInit({ path: tempProjectDir, ...(compatibilityFlag ? { git: true } : {}) });
expect(existsSync(join(tempProjectDir, ".git"))).toBe(true);
await expect(git("git rev-parse --verify HEAD^{commit}", tempProjectDir)).resolves.toMatch(/^[0-9a-f]+$/);
const tree = await git("git ls-tree -r --name-only HEAD", tempProjectDir);
expect(tree).toBe(".gitignore");
const worktree = join(tempProjectDir, "task-worktree");
await git(`git worktree add -b fusion/init-${compatibilityFlag ? "git" : "default"} ${worktree} HEAD`, tempProjectDir);
await git(`git worktree remove --force ${worktree}`, tempProjectDir);
for (const entry of localStorageGitignoreEntries) {
expect(toLines(readFileSync(join(tempProjectDir, ".gitignore"), "utf8"))).toContain(entry);
}
});
it("creates an initial commit when --git initializes a repository", async () => {
@@ -448,7 +459,7 @@ describe("init command", () => {
console.log = originalLog;
}
expect(existsSync(join(tempProjectDir, ".git"))).toBe(false);
expect(existsSync(join(tempProjectDir, ".git"))).toBe(true);
expect(mockEnsureProjectForPath).toHaveBeenCalledWith(
expect.objectContaining({
path: tempProjectDir,

View File

@@ -8,7 +8,7 @@
* Idempotent: if already initialized, reports success without recreating.
*/
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
import { existsSync, mkdirSync } from "node:fs";
import { join, resolve, basename } from "node:path";
import { exec } from "node:child_process";
import { promisify } from "node:util";
@@ -24,7 +24,6 @@ import {
writeProjectIdentity,
} from "@fusion/core";
import { maybeInstallClaudeSkillForNewProject } from "./claude-skills-runner.js";
import { isGitRepo } from "./git.js";
import {
installBundledShippedSkills,
type SkillInstallResult,
@@ -36,7 +35,7 @@ export interface InitOptions {
name?: string;
/** Path to initialize (defaults to cwd) */
path?: string;
/** Initialize a git repository if one does not exist */
/** Compatibility flag; project readiness is now always established. */
git?: boolean;
}
@@ -66,6 +65,12 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
const existing = await central.getProjectByPath(cwd);
if (existing) {
// Repair Git readiness even when the project was already registered.
await central.ensureProjectForPath({
path: cwd,
identity: readProjectIdentity(fusionDir) ?? undefined,
name: existing.name,
});
try {
writeProjectIdentity(join(cwd, ".fusion"), {
id: existing.id,
@@ -112,13 +117,12 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
console.log(` ✓ Created .fusion/ directory`);
}
if (options.git && !(await isGitRepo(cwd))) {
await initializeGitRepo(cwd);
console.log(` ✓ Initialized git repository`);
}
// Add local Fusion/Pi storage directories to .gitignore
await addLocalStorageToGitignore(cwd);
/*
FNXC:ProjectSetup 2026-08-19-12:44:
`--git` remains accepted for scripts that already pass it, but the shared CentralCore
readiness seam now always creates the baseline and managed ignore rules. Keeping one
path prevents default onboarding and explicit `--git` from producing different task state.
*/
await warnIfQmdMissing();
const bundledSkillInstall = installBundledShippedSkills();
@@ -132,6 +136,12 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
// Check if already registered
const existing = await central.getProjectByPath(cwd);
if (existing) {
// Repair Git readiness before reporting an already-registered project as ready.
await central.ensureProjectForPath({
path: cwd,
identity: readProjectIdentity(fusionDir) ?? undefined,
name: existing.name,
});
/*
FNXC:ProjectIdentityMarker 2026-07-14-22:25:
A project already registered in PostgreSQL can still reach this branch when its local `.fusion/project.json` marker is missing. Repair the marker before returning so subsequent startup and init checks use the same durable identity as a newly registered project.
@@ -237,91 +247,6 @@ async function detectProjectName(dir: string): Promise<string> {
return basename(dir) || "my-project";
}
/**
* Add local Fusion/Pi storage directories to .gitignore if not already present.
* Idempotent: only adds missing entries.
*/
async function addLocalStorageToGitignore(cwd: string): Promise<void> {
const gitignorePath = join(cwd, ".gitignore");
let content = "";
if (existsSync(gitignorePath)) {
try {
content = readFileSync(gitignorePath, "utf-8");
} catch {
// Best-effort: if we can't read, treat as empty
}
}
const lines = content.split(/\r?\n/);
const existingEntries = new Set(lines.map((line) => line.trim()));
const missingEntries = [".fusion", ".pi", "fusion.db", "fusion.db-wal", "fusion.db-shm"]
.filter((entry) => !existingEntries.has(entry));
if (missingEntries.length === 0) {
return;
}
const prefix = content.length === 0 || content.endsWith("\n") ? "" : "\n";
const newContent = `${content}${prefix}${missingEntries.join("\n")}\n`;
try {
writeFileSync(gitignorePath, newContent);
console.log(` ✓ Updated .gitignore (added: ${missingEntries.join(", ")})`);
} catch {
// Best-effort: don't fail init if we can't write to .gitignore
console.log(` ⚠ Could not update .gitignore (best-effort)`);
}
}
async function initializeGitRepo(cwd: string): Promise<void> {
await execAsync("git init", { cwd, timeout: 10_000 });
try {
const { stdout } = await execAsync("git symbolic-ref --quiet --short HEAD", {
cwd,
timeout: 10_000,
});
if (stdout.trim() !== "main") {
await execAsync("git checkout -b main", { cwd, timeout: 10_000 });
}
} catch {
// Older git versions or detached/unborn states may fail symbolic-ref.
// Best-effort: create/switch to main.
try {
await execAsync("git checkout -b main", { cwd, timeout: 10_000 });
} catch {
await execAsync("git checkout main", { cwd, timeout: 10_000 });
}
}
await ensureGitConfig(cwd, "user.name", "Fusion");
await ensureGitConfig(cwd, "user.email", "noreply@runfusion.ai");
const gitkeepPath = join(cwd, ".gitkeep");
if (!existsSync(gitkeepPath)) {
writeFileSync(gitkeepPath, "\n");
}
await execAsync("git add .gitkeep", { cwd, timeout: 10_000 });
await execAsync('git commit --allow-empty -m "chore: initial commit"', {
cwd,
timeout: 10_000,
});
}
async function ensureGitConfig(cwd: string, key: string, value: string): Promise<void> {
try {
const { stdout } = await execAsync(`git config --get ${key}`, { cwd, timeout: 10_000 });
if (stdout.trim().length > 0) {
return;
}
} catch {
// Missing config; set a local default.
}
await execAsync(`git config ${key} "${value}"`, { cwd, timeout: 10_000 });
}
async function warnIfQmdMissing(): Promise<void> {
if (await isQmdAvailable()) {
console.log(` ✓ qmd available for memory search`);

View File

@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it } from "vitest";
import { execFile } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
@@ -35,16 +35,65 @@ describe("ensureGitRepositoryForProjectPath", () => {
return path;
}
it("initializes an empty directory without creating commits or files", async () => {
it("initializes an empty directory with a real baseline and task worktree support", async () => {
const projectPath = tempDir("fusion-git-init-");
const worktreePath = join(tempDir("fusion-git-worktree-") , "task");
const outcome = await ensureGitRepositoryForProjectPath(projectPath);
expect(outcome).toBe("initialized");
expect(existsSync(join(projectPath, ".git"))).toBe(true);
await expect(git(projectPath, ["rev-parse", "--is-inside-work-tree"])).resolves.toBe("true");
await expect(git(projectPath, ["rev-parse", "--verify", "HEAD"])).rejects.toThrow();
expect(existsSync(join(projectPath, ".gitkeep"))).toBe(false);
await expect(git(projectPath, ["rev-parse", "--verify", "HEAD^{commit}"])).resolves.toMatch(/^[0-9a-f]+$/);
expect(await git(projectPath, ["ls-tree", "-r", "--name-only", "HEAD"])).toBe(".gitignore");
expect(readFileSync(join(projectPath, ".gitignore"), "utf8")).toBe(
".fusion/\n.pi/\n.worktrees/\nfusion.db\nfusion.db-wal\nfusion.db-shm\n",
);
await git(projectPath, ["worktree", "add", "-b", "fusion/fn-038", worktreePath, "HEAD"]);
expect(existsSync(join(worktreePath, ".gitignore"))).toBe(true);
await git(projectPath, ["worktree", "remove", "--force", worktreePath]);
});
it("does not commit application files when preparing a populated non-Git directory", async () => {
const projectPath = tempDir("fusion-git-populated-");
writeFileSync(join(projectPath, "README.md"), "user content\n");
writeFileSync(join(projectPath, ".gitignore"), "dist/\r\n\r\n");
await ensureGitRepositoryForProjectPath(projectPath);
expect(await git(projectPath, ["ls-tree", "-r", "--name-only", "HEAD"])).toBe(".gitignore");
expect(readFileSync(join(projectPath, ".gitignore"), "utf8")).toBe(
"dist/\r\n\r\n.fusion/\r\n.pi/\r\n.worktrees/\r\nfusion.db\r\nfusion.db-wal\r\nfusion.db-shm\r\n",
);
expect(existsSync(join(projectPath, "README.md"))).toBe(true);
const status = await git(projectPath, ["status", "--porcelain"]);
expect(status).toContain("README.md");
/*
FNXC:ProjectSetup 2026-08-19-13:25:
The baseline contains only managed rules; the user's pre-existing ignore rule must
remain an unstaged, operator-visible edit instead of being silently staged by setup.
*/
await expect(git(projectPath, ["diff", "--", ".gitignore"])).resolves.toContain("dist/");
await expect(git(projectPath, ["diff", "--cached", "--", ".gitignore"])).resolves.toBe("");
for (const artifact of [".fusion", ".pi", ".worktrees", "fusion.db", "fusion.db-wal", "fusion.db-shm"]) {
if (artifact.startsWith(".")) mkdirSync(join(projectPath, artifact), { recursive: true });
else writeFileSync(join(projectPath, artifact), "state\n");
await expect(git(projectPath, ["check-ignore", "-q", artifact])).resolves.toBe("");
}
});
it("prepares an unborn repository without renaming its branch or committing staged user files", async () => {
const projectPath = tempDir("fusion-git-unborn-");
await git(projectPath, ["init"]);
await git(projectPath, ["symbolic-ref", "HEAD", "refs/heads/operator-branch"]);
writeFileSync(join(projectPath, "app.txt"), "staged by user\n");
await git(projectPath, ["add", "app.txt"]);
await ensureGitRepositoryForProjectPath(projectPath);
await expect(git(projectPath, ["symbolic-ref", "--short", "HEAD"])).resolves.toBe("operator-branch");
expect(await git(projectPath, ["ls-tree", "-r", "--name-only", "HEAD"])).toBe(".gitignore");
expect(await git(projectPath, ["status", "--porcelain"])).toContain("A app.txt");
});
it("leaves an existing repository commits, config, and remotes unchanged", async () => {
@@ -57,16 +106,61 @@ describe("ensureGitRepositoryForProjectPath", () => {
await git(projectPath, ["commit", "-m", "existing commit"]);
await git(projectPath, ["remote", "add", "origin", "https://github.com/example/repo.git"]);
writeFileSync(join(projectPath, ".gitignore"), "dist/\n");
const beforeCommit = await git(projectPath, ["rev-parse", "HEAD"]);
const beforeCommitCount = await git(projectPath, ["rev-list", "--count", "HEAD"]);
const beforeUserName = await git(projectPath, ["config", "user.name"]);
const beforeRemote = await git(projectPath, ["remote", "get-url", "origin"]);
writeFileSync(join(projectPath, "staged.txt"), "staged\n");
await git(projectPath, ["add", "staged.txt"]);
const outcome = await ensureGitRepositoryForProjectPath(projectPath);
const firstIgnore = readFileSync(join(projectPath, ".gitignore"), "utf8");
const firstCommit = await git(projectPath, ["rev-parse", "HEAD"]);
await ensureGitRepositoryForProjectPath(projectPath);
expect(outcome).toBe("existing");
expect(firstCommit).toBe(beforeCommit);
await expect(git(projectPath, ["rev-parse", "HEAD"])).resolves.toBe(beforeCommit);
await expect(git(projectPath, ["rev-list", "--count", "HEAD"])).resolves.toBe(beforeCommitCount);
await expect(git(projectPath, ["config", "user.name"])).resolves.toBe(beforeUserName);
await expect(git(projectPath, ["remote", "get-url", "origin"])).resolves.toBe(beforeRemote);
expect(readFileSync(join(projectPath, ".gitignore"), "utf8")).toBe(firstIgnore);
expect(await git(projectPath, ["status", "--porcelain"])).toContain("A staged.txt");
for (const rule of [".fusion/", ".pi/", ".worktrees/", "fusion.db", "fusion.db-wal", "fusion.db-shm"]) {
expect(firstIgnore).toContain(rule);
}
});
it("merges equivalent managed rules without duplicates and converges concurrent calls", async () => {
const projectPath = tempDir("fusion-git-equivalent-rules-");
writeFileSync(join(projectPath, ".gitignore"), "/.fusion/\n.pi\n/worktrees/\nfusion.db\n");
const outcomes = await Promise.all([
ensureGitRepositoryForProjectPath(projectPath),
ensureGitRepositoryForProjectPath(projectPath),
]);
expect(outcomes.sort()).toEqual(["existing", "initialized"]);
const content = readFileSync(join(projectPath, ".gitignore"), "utf8");
expect(content.match(/fusion\.db(?:\r?\n|$)/g)).toHaveLength(1);
expect(content.match(/\.fusion\/?(?:\r?\n|$)/g)).toHaveLength(1);
expect(content).toContain(".worktrees/");
await expect(git(projectPath, ["rev-list", "--count", "HEAD"])).resolves.toBe("1");
});
it.skipIf(process.platform === "win32")("supports special-character paths and refuses an unsafe .gitignore symlink", async () => {
const projectPath = tempDir("fusion git [special]-");
await ensureGitRepositoryForProjectPath(projectPath);
await expect(git(projectPath, ["rev-parse", "--verify", "HEAD^{commit}"])).resolves.toMatch(/^[0-9a-f]+$/);
const symlinkPath = tempDir("fusion-git-symlink-target-");
const symlinkProject = tempDir("fusion-git-symlink-");
symlinkSync(join(symlinkPath, "outside-ignore"), join(symlinkProject, ".gitignore"));
await expect(ensureGitRepositoryForProjectPath(symlinkProject)).rejects.toMatchObject({
name: "GitRepositoryInitializationError",
causeMessage: expect.stringMatching(/symbolic link/i),
});
});
it("treats a linked worktree with .git as a file as an existing repository", async () => {
@@ -109,17 +203,30 @@ describe("ensureGitRepositoryForProjectPath", () => {
).rejects.toBeInstanceOf(GitRepositoryInitializationError);
});
it("skips git init for a workspace-mode project root (.fusion/workspace.json present)", async () => {
it("keeps a configured workspace root non-Git while preparing every member", async () => {
const projectPath = tempDir("fusion-git-workspace-");
// Simulate workspace init: .fusion/workspace.json exists, root is non-git
const firstRepo = join(projectPath, "repo-a");
const secondRepo = join(projectPath, "repo-b");
mkdirSync(firstRepo, { recursive: true });
mkdirSync(secondRepo, { recursive: true });
writeFileSync(join(firstRepo, "README.md"), "repo a\n");
await git(secondRepo, ["init"]);
await git(secondRepo, ["config", "user.name", "Repo User"]);
await git(secondRepo, ["config", "user.email", "repo@example.com"]);
writeFileSync(join(secondRepo, "README.md"), "repo b\n");
await git(secondRepo, ["add", "README.md"]);
await git(secondRepo, ["commit", "-m", "repo b"]);
mkdirSync(join(projectPath, ".fusion"), { recursive: true });
writeFileSync(join(projectPath, ".fusion", "workspace.json"), JSON.stringify({ repos: ["repo-a"] }));
writeFileSync(join(projectPath, ".fusion", "workspace.json"), JSON.stringify({ repos: ["repo-a", "repo-b"] }));
const outcome = await ensureGitRepositoryForProjectPath(projectPath);
expect(outcome).toBe("existing");
// No .git should be created at the workspace root
expect(outcome).toBe("initialized");
expect(existsSync(join(projectPath, ".git"))).toBe(false);
await expect(git(firstRepo, ["rev-parse", "HEAD^{commit}"])).resolves.toMatch(/^[0-9a-f]+$/);
await expect(git(secondRepo, ["rev-list", "--count", "HEAD"])).resolves.toBe("1");
expect(readFileSync(join(firstRepo, ".gitignore"), "utf8")).toContain(".worktrees/");
expect(readFileSync(join(secondRepo, ".gitignore"), "utf8")).toContain(".worktrees/");
});
it("detects workspace sub-repos and skips git init when workspace.json is missing", async () => {

View File

@@ -176,14 +176,6 @@ export interface EnsureProjectForPathInput {
isolationMode?: IsolationMode;
nodeId?: string;
settings?: ProjectSettings;
/*
FNXC:ProjectSetup 2026-07-18-04:30:
Operator-confirmed "create anyway without a git repo" when git is not
installed on the host. Skips ensureGitRepositoryForProjectPath entirely so
registration succeeds on a plain directory; the repo can be initialized
later once git exists.
*/
skipGitInit?: boolean;
}
export interface EnsureProjectForPathResult {
@@ -583,37 +575,44 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
async ensureProjectForPath(input: EnsureProjectForPathInput): Promise<EnsureProjectForPathResult> {
this.ensureInitialized();
/*
FNXC:ProjectSetup 2026-08-19-12:44:
Git readiness is deliberately awaited before both new registration and existing-row
repair. A failed init, ignore reconciliation, member preparation, or baseline commit
therefore cannot be followed by a PostgreSQL insert or activation, while a retry remains
safe because the filesystem seam is idempotent. Validate an orphan identity conflict
first so a bad marker never mutates an unrelated incoming directory.
*/
const existing = await this.getProjectByPath(input.path);
if (existing) {
return { project: existing, reattached: false, outcome: "existing" };
const gitRepository = await this.ensureGitRepositoryForProjectPath(input.path);
return { project: existing, reattached: false, outcome: "existing", gitRepository };
}
if (input.identity?.id) {
const byId = await this.getProject(input.identity.id);
if (!byId) {
const gitRepository = input.skipGitInit
? undefined
: await this.ensureGitRepositoryForProjectPath(input.path);
const reattached = await this.registerProject({
id: input.identity.id,
name: input.name ?? basename(input.path),
path: input.path,
isolationMode: input.isolationMode,
nodeId: input.nodeId,
settings: input.settings,
});
this.emit("project:reattached", reattached, "identity-recovered");
return { project: reattached, reattached: true, outcome: "reattached", gitRepository };
}
if (byId.path !== input.path) {
if (byId && byId.path !== input.path) {
throw new ProjectIdentityConflictError(input.identity.id, byId.path, input.path);
}
return { project: byId, reattached: false, outcome: "existing" };
if (byId) {
const gitRepository = await this.ensureGitRepositoryForProjectPath(input.path);
return { project: byId, reattached: false, outcome: "existing", gitRepository };
}
const gitRepository = await this.ensureGitRepositoryForProjectPath(input.path);
const reattached = await this.registerProject({
id: input.identity.id,
name: input.name ?? basename(input.path),
path: input.path,
isolationMode: input.isolationMode,
nodeId: input.nodeId,
settings: input.settings,
});
this.emit("project:reattached", reattached, "identity-recovered");
return { project: reattached, reattached: true, outcome: "reattached", gitRepository };
}
const gitRepository = input.skipGitInit
? undefined
: await this.ensureGitRepositoryForProjectPath(input.path);
const gitRepository = await this.ensureGitRepositoryForProjectPath(input.path);
const registered = await this.registerProject({
name: input.name ?? basename(input.path),
path: input.path,

View File

@@ -1,10 +1,27 @@
import { execFile } from "node:child_process";
import { resolve } from "node:path";
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";
@@ -16,7 +33,7 @@ export interface GitRepositoryCommandResult {
export type GitRepositoryCommandRunner = (
command: string,
args: string[],
options: { cwd?: string; timeout: number },
options: { cwd?: string; timeout: number; env?: NodeJS.ProcessEnv },
) => Promise<GitRepositoryCommandResult>;
export interface EnsureGitRepositoryOptions {
@@ -29,13 +46,23 @@ export class GitRepositoryInitializationError extends Error {
readonly causeMessage: string;
constructor(path: string, causeMessage: string) {
super(`Could not initialize Git repository at ${path}: ${causeMessage}`);
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 = {},
@@ -43,57 +70,199 @@ export async function ensureGitRepositoryForProjectPath(
const runner = options.runner ?? runGitCommand;
const timeout = options.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS;
/*
FNXC:Workspace 2026-06-24-10:00:
A workspace-mode project root is intentionally NOT a git repository — it is a parent
directory containing multiple git sub-repos (detected at init time and recorded in
.fusion/workspace.json). Running `git init` here would create a stray empty repo at the
workspace root, poisoning every downstream git command: the executor sets the session cwd
to this root (browse-only), and `git rev-parse --abbrev-ref HEAD` fails on the unborn HEAD
with "ambiguous argument 'HEAD'". Detect workspace mode via the config file and skip the
git-init so the root stays non-git, matching the workspace execution contract (KTD1).
*/
if (await loadWorkspaceConfig(projectPath)) {
return "existing";
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 "existing";
return prepareSingleRepository(projectPath, runner, timeout, false);
}
/*
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.
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) {
// 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.
const outcome = await prepareWorkspaceRepositories(projectPath, detectedRepos, runner, timeout);
await setWorkspaceModeInConfig(projectPath, true);
await saveWorkspaceConfig(projectPath, { repos: detectedRepos });
return "existing";
return outcome;
}
}
try {
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 });
return "initialized";
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) {
throw new GitRepositoryInitializationError(projectPath, extractCommandErrorMessage(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 });
}
}
@@ -113,7 +282,7 @@ async function isInsideGitWorkTree(
async function runGitCommand(
command: string,
args: string[],
options: { cwd?: string; timeout: number },
options: { cwd?: string; timeout: number; env?: NodeJS.ProcessEnv },
): Promise<GitRepositoryCommandResult> {
/*
FNXC:Onboarding 2026-07-18-03:20:
@@ -127,6 +296,7 @@ async function runGitCommand(
const result = await execFileAsync(binary, args, {
cwd: options.cwd,
timeout: options.timeout,
env: options.env,
encoding: "utf-8",
});
return {
@@ -141,6 +311,7 @@ async function runGitCommand(
const result = await execFileAsync(retryBinary, args, {
cwd: options.cwd,
timeout: options.timeout,
env: options.env,
encoding: "utf-8",
});
return {
@@ -240,7 +411,7 @@ export class WorkspaceRepoValidationError extends Error {
}
const WORKSPACE_CONFIG_FILENAME = "workspace.json";
const EXCLUDED_WORKSPACE_ENTRIES = new Set(["node_modules", ".fusion", ".git", ".pi"]);
const EXCLUDED_WORKSPACE_ENTRIES = new Set(["node_modules", ".fusion", ".git", ".pi", ".worktrees"]);
/**
* Reads .fusion/config.json and returns true when `workspaceMode` is explicitly

View File

@@ -137,8 +137,6 @@ export interface ProjectCreateInput {
cloneUrl?: string;
workspaceMode?: boolean;
taskPrefix?: string;
/** Confirmed "create anyway without a git repo" when git is missing on the host (never valid for clone mode). */
skipGitInit?: boolean;
}
export type DockerNodeConfigInfo = DockerNodeConfig;

View File

@@ -232,56 +232,33 @@ export function SetupWizardModal({
};
/*
FNXC:ProjectSetup 2026-07-18-04:30:
Registering a project on a host without git previously failed AFTER submission with a raw
spawn error. Probe git up front and warn with an explicit choice: open the git downloads,
or create the project anyway without a git repo (skipGitInit). Clone mode cannot proceed
without git, so its dialog only offers the install link. The probe is best-effort — if the
status call itself fails, registration proceeds and server-side errors still surface.
FNXC:ProjectSetup 2026-08-19-12:44:
Registration is fail-closed when Git is unavailable. Every setup mode uses the same
install/download plus cancel/retry contract; no bypass can register a project that
cannot create task worktrees. The server remains authoritative when the probe itself
is unavailable, so a transient status endpoint failure does not create a second policy.
*/
let skipGitInit = false;
try {
const { gitCli } = await fetchAuthStatus();
if (gitCli && !gitCli.available) {
if (state.manualMode === "clone") {
const choice = await confirmWithChoice({
title: t("setup.gitMissingTitle", "Git is not installed"),
message: t(
"setup.gitMissingCloneMessage",
"Cloning a repository requires Git on the Fusion host, and Git was not found. Install Git, then try again — Fusion picks it up without a restart.",
),
confirmLabel: t("setup.gitMissingOpenDownloads", "Open Git downloads"),
cancelLabel: t("setup.cancel", "Cancel"),
alwaysAsk: true,
});
if (choice === "primary") openExternalUrl(gitCli.installUrl ?? "https://git-scm.com/downloads");
abortRegistration();
return;
}
const choice = await confirmWithChoice({
title: t("setup.gitMissingTitle", "Git is not installed"),
message: t(
"setup.gitMissingMessage",
"Git was not found on the Fusion host. Fusion projects normally live in a git repository so agents can branch, commit, and merge work. You can install Git first (Fusion picks it up without a restart), or create the project anyway without a git repository.",
"Git was not found on the Fusion host. Install Git, then try again — Fusion projects need Git so agents can create task worktrees.",
),
confirmLabel: t("setup.gitMissingCreateAnyway", "Create anyway without Git"),
tertiaryLabel: t("setup.gitMissingOpenDownloads", "Open Git downloads"),
confirmLabel: t("setup.gitMissingOpenDownloads", "Open Git downloads"),
cancelLabel: t("setup.cancel", "Cancel"),
alwaysAsk: true,
});
if (choice === "tertiary") {
if (choice === "primary") {
openExternalUrl(gitCli.installUrl ?? "https://git-scm.com/downloads");
abortRegistration();
return;
}
if (choice !== "primary") {
abortRegistration();
return;
}
skipGitInit = true;
abortRegistration();
return;
}
} catch {
// Probe failure must not block registration.
// The registration route still performs the authoritative readiness check.
}
try {
@@ -294,7 +271,6 @@ export function SetupWizardModal({
cloneUrl: state.manualMode === "clone" ? trimmedCloneUrl : undefined,
workspaceMode: state.manualMode === "existing" ? state.workspaceMode : false,
taskPrefix: state.manualTaskPrefix.trim() || undefined,
skipGitInit: skipGitInit || undefined,
};
const result = await registerProject(input);

View File

@@ -3,10 +3,10 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { SetupWizardModal } from "../SetupWizardModal";
/*
FNXC:ProjectSetup 2026-07-18-04:30:
Registering a project on a host without git used to fail AFTER submission with
a raw spawn error. The wizard must warn up front with an explicit choice:
open the git downloads, or create anyway without a git repo (skipGitInit).
FNXC:ProjectSetup 2026-08-19-12:44:
Git-unavailable setup is fail-closed across every repository mode and viewport: the only
available actions are install/download or cancel/retry, and registration never receives a
bypass field for a project that cannot create task worktrees.
*/
const mockRegisterProject = vi.fn();
@@ -32,61 +32,69 @@ vi.mock("../../hooks/useNodes", () => ({
useNodes: () => ({ nodes: [], loading: false }),
}));
async function fillAndSubmit(): Promise<void> {
fireEvent.change(screen.getByPlaceholderText("/path/to/your/project"), {
function chooseMode(mode: "existing" | "init" | "clone"): void {
const labels = {
existing: /use existing directory/i,
init: /initialize new repository/i,
clone: /clone git repository/i,
};
fireEvent.click(screen.getByRole("radio", { name: labels[mode] }));
}
async function fillAndSubmit(mode: "existing" | "init" | "clone"): Promise<void> {
chooseMode(mode);
const pathPlaceholder = mode === "clone"
? "/path/for/new-clone"
: mode === "init" ? "/path/to/new-project" : "/path/to/your/project";
fireEvent.change(screen.getByPlaceholderText(pathPlaceholder), {
target: { value: "/tmp/demo-project" },
});
const nameInput = document.getElementById("project-name") as HTMLInputElement;
fireEvent.change(nameInput, { target: { value: "Demo" } });
fireEvent.click(screen.getByRole("button", { name: /register project/i }));
if (mode === "clone") {
fireEvent.change(screen.getByPlaceholderText("https://github.com/owner/repo.git"), {
target: { value: "https://github.com/example/demo.git" },
});
}
fireEvent.click(screen.getByRole("button", { name: /register project|initialize and register|clone and register/i }));
}
describe("SetupWizardModal git-missing warning", () => {
describe.each(["desktop", "mobile"] as const)("SetupWizardModal git-missing warning (%s)", (viewport) => {
beforeEach(() => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: viewport === "mobile" ? 390 : 1280 });
mockRegisterProject.mockReset().mockResolvedValue({ id: "proj_1", name: "Demo", path: "/tmp/demo-project" });
mockFetchAuthStatus.mockReset();
mockConfirmWithChoice.mockReset();
});
it("creates anyway with skipGitInit when the operator confirms", async () => {
it.each(["existing", "init", "clone"] as const)("blocks %s registration when Git is unavailable", async (mode) => {
mockFetchAuthStatus.mockResolvedValue({ providers: [], gitCli: { available: false, installUrl: "https://git-scm.com/downloads" } });
mockConfirmWithChoice.mockResolvedValue("primary");
render(<SetupWizardModal onProjectRegistered={vi.fn()} includeAgentStep={false} />);
await fillAndSubmit();
await waitFor(() => {
expect(mockRegisterProject).toHaveBeenCalledWith(expect.objectContaining({ skipGitInit: true }));
});
expect(mockConfirmWithChoice).toHaveBeenCalledWith(
expect.objectContaining({ title: expect.stringMatching(/git is not installed/i) }),
);
});
it("opens the git downloads and aborts on the tertiary choice", async () => {
mockFetchAuthStatus.mockResolvedValue({ providers: [], gitCli: { available: false, installUrl: "https://git-scm.com/downloads" } });
mockConfirmWithChoice.mockResolvedValue("tertiary");
const windowOpen = vi.spyOn(window, "open").mockReturnValue(null);
render(<SetupWizardModal onProjectRegistered={vi.fn()} includeAgentStep={false} />);
await fillAndSubmit();
await fillAndSubmit(mode);
await waitFor(() => {
expect(windowOpen).toHaveBeenCalledWith("https://git-scm.com/downloads", "_blank");
});
await waitFor(() => expect(mockConfirmWithChoice).toHaveBeenCalledTimes(1));
expect(mockRegisterProject).not.toHaveBeenCalled();
expect(windowOpen).toHaveBeenCalledWith("https://git-scm.com/downloads", "_blank");
expect(mockConfirmWithChoice).toHaveBeenCalledWith(expect.objectContaining({
title: expect.stringMatching(/git is not installed/i),
confirmLabel: expect.stringMatching(/open git downloads/i),
cancelLabel: expect.stringMatching(/cancel/i),
}));
expect(mockConfirmWithChoice.mock.calls[0]?.[0]).not.toHaveProperty("tertiaryLabel");
windowOpen.mockRestore();
});
it("registers without skipGitInit and without a dialog when git is available", async () => {
it("registers with Git available without the obsolete bypass payload", async () => {
mockFetchAuthStatus.mockResolvedValue({ providers: [], gitCli: { available: true, version: "2.50.0", installUrl: "https://git-scm.com/downloads" } });
render(<SetupWizardModal onProjectRegistered={vi.fn()} includeAgentStep={false} />);
await fillAndSubmit();
await fillAndSubmit("existing");
await waitFor(() => {
expect(mockRegisterProject).toHaveBeenCalledWith(expect.objectContaining({ skipGitInit: undefined }));
});
await waitFor(() => expect(mockRegisterProject).toHaveBeenCalledTimes(1));
expect(mockRegisterProject.mock.calls[0]?.[0]).not.toHaveProperty("skipGitInit");
expect(mockConfirmWithChoice).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,165 @@
// @vitest-environment node
import express from "express";
import { afterEach, describe, expect, it, vi } from "vitest";
import { execFile } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { ensureGitRepositoryForProjectPath } from "@fusion/core";
import { registerProjectRoutes } from "../register-project-routes.js";
import { request } from "../../test-request.js";
const execFileAsync = promisify(execFile);
const getOrCreateProjectStore = vi.fn();
const updateSettings = vi.fn();
vi.mock("../../project-store-resolver.js", () => ({
getOrCreateProjectStore: (...args: unknown[]) => getOrCreateProjectStore(...args),
evictProjectStore: vi.fn(),
}));
async function git(cwd: string, args: string[]): Promise<string> {
const result = await execFileAsync("git", args, { cwd, encoding: "utf8" });
return result.stdout.trim();
}
function appFor(central: Record<string, unknown>) {
const router = express.Router();
registerProjectRoutes({
router,
options: { centralCore: central },
runtimeLogger: { child: () => ({ warn: vi.fn() }), warn: vi.fn() },
prioritizeProjectsForCurrentDirectory: vi.fn((projects) => projects),
rethrowAsApiError: (error: unknown) => { throw error; },
} as never);
const app = express();
app.use(express.json());
app.use("/api", router);
app.use((error: { statusCode?: number; message?: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(error.statusCode ?? 500).json({ error: error.message });
});
return app;
}
describe("POST /api/projects Git readiness", () => {
const cleanup: string[] = [];
afterEach(() => {
cleanup.splice(0).forEach((path) => rmSync(path, { recursive: true, force: true }));
getOrCreateProjectStore.mockReset();
updateSettings.mockReset();
});
function tempDir(prefix: string): string {
const path = mkdtempSync(join(tmpdir(), prefix));
cleanup.push(path);
return path;
}
function centralFor() {
const updateProject = vi.fn(async (id: string, patch: Record<string, unknown>) => ({
id,
name: "Registration test",
path: currentPath,
status: patch.status,
createdAt: new Date(0).toISOString(),
}));
const ensureProjectForPath = vi.fn(async (input: { path: string }) => {
const gitRepository = await ensureGitRepositoryForProjectPath(input.path);
return {
project: {
id: "project-1",
name: "Registration test",
path: input.path,
status: "initializing",
createdAt: new Date(0).toISOString(),
},
reattached: false,
outcome: "registered" as const,
gitRepository,
};
});
const central = {
isInitialized: () => true,
ensureProjectForPath,
updateProject,
};
return { central, ensureProjectForPath, updateProject };
}
let currentPath = "";
it.each([
["existing", "empty non-Git directory"],
["init", "unborn Git directory"],
] as const)("does not return success for %s until %s is ready", async (mode, label) => {
currentPath = tempDir(`fusion-dashboard-${mode}-`);
if (mode === "init") await git(currentPath, ["init"]);
const { central, ensureProjectForPath, updateProject } = centralFor();
getOrCreateProjectStore.mockResolvedValue({ updateSettings });
const response = await request(
appFor(central),
"POST",
"/api/projects",
JSON.stringify({ name: "Registration test", path: currentPath, gitSetupMode: mode }),
{ "content-type": "application/json" },
);
expect(response.status, label).toBe(201);
expect(ensureProjectForPath).toHaveBeenCalledTimes(1);
expect(updateProject).toHaveBeenCalledWith("project-1", { status: "active" });
await expect(git(currentPath, ["rev-parse", "--verify", "HEAD^{commit}"])).resolves.toMatch(/^[0-9a-f]+$/);
await git(currentPath, ["worktree", "add", "-b", `fusion/${mode}`, join(currentPath, "task-worktree"), "HEAD"]);
await git(currentPath, ["worktree", "remove", "--force", join(currentPath, "task-worktree")]);
mkdirSync(join(currentPath, ".worktrees"), { recursive: true });
expect(await git(currentPath, ["check-ignore", "-q", ".worktrees/task"])).toBe("");
});
it("registers a cloned checkout only after the shared readiness seam", async () => {
const source = tempDir("fusion-dashboard-clone-source-");
await git(source, ["init"]);
await git(source, ["config", "user.name", "Clone User"]);
await git(source, ["config", "user.email", "clone@example.com"]);
writeFileSync(join(source, "README.md"), "clone\n");
await git(source, ["add", "README.md"]);
await git(source, ["commit", "-m", "source"]);
currentPath = join(tempDir("fusion-dashboard-clone-parent-"), "checkout");
const { central, ensureProjectForPath } = centralFor();
getOrCreateProjectStore.mockResolvedValue({ updateSettings });
const response = await request(
appFor(central),
"POST",
"/api/projects",
JSON.stringify({ name: "Clone test", path: currentPath, gitSetupMode: "clone", cloneUrl: source }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(201);
expect(ensureProjectForPath).toHaveBeenCalledTimes(1);
await expect(git(currentPath, ["rev-parse", "--verify", "HEAD^{commit}"])).resolves.toMatch(/^[0-9a-f]+$/);
mkdirSync(join(currentPath, ".fusion"), { recursive: true });
expect(await git(currentPath, ["check-ignore", "-q", ".fusion/project.json"])).toBe("");
});
it("does not activate or register when the readiness seam fails", async () => {
currentPath = tempDir("fusion-dashboard-failure-");
const ensureProjectForPath = vi.fn(async () => { throw new Error("Git baseline failed"); });
const updateProject = vi.fn();
const response = await request(
appFor({ isInitialized: () => true, ensureProjectForPath, updateProject }),
"POST",
"/api/projects",
JSON.stringify({ name: "Failure", path: currentPath, gitSetupMode: "existing" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(500);
expect(response.body.error).toContain("Git baseline failed");
expect(ensureProjectForPath).toHaveBeenCalledTimes(1);
expect(updateProject).not.toHaveBeenCalled();
});
});

View File

@@ -320,16 +320,6 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
throw badRequest("cloneUrl can only be provided when gitSetupMode is 'clone'");
}
/*
FNXC:ProjectSetup 2026-07-18-04:30:
skipGitInit is the dashboard's confirmed "create anyway without a git
repo" choice when git is missing on the host. Never valid for clone mode
(cloning requires git by definition).
*/
const skipGitInit = req.body?.skipGitInit === true;
if (skipGitInit && normalizedGitSetupMode === "clone") {
throw badRequest("skipGitInit cannot be combined with clone mode");
}
if (normalizedGitSetupMode === "clone" && normalizedCloneUrl === undefined) {
throw badRequest("cloneUrl must be a non-empty string when gitSetupMode is 'clone'");
}
@@ -445,7 +435,6 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
name: normalizedName,
isolationMode,
nodeId,
skipGitInit,
});
const project = ensured.project;