Merge pull request #1463 from Runfusion/gsxdsm/issue-1455-initialize-git-repository

fix(FN-1455): initialize git during project registration
This commit is contained in:
gsxdsm
2026-06-06 01:08:31 -07:00
committed by GitHub
19 changed files with 787 additions and 14 deletions

View File

@@ -0,0 +1,3 @@
"@runfusion/fusion": patch
Initialize missing Git repositories automatically when registering Fusion projects.

View File

@@ -49,6 +49,17 @@ The smallest unit of mission work: a single deliverable evaluated against its Co
### Fix Feature
A Feature auto-generated from a failed Validator Run to carry the remediation work for the assertions that failed, linked back to the Feature it descends from.
## Projects
### Project
A registered workspace that Fusion can operate on: it has a canonical local path, project-scoped settings and data, and must be backed by a usable Git work tree before task execution can create worktrees from it.
### Project Identity
The durable identity a registered Project carries locally so it can be reattached to the central registry after central state is lost or rebuilt, preserving rows keyed by the same project id instead of minting a replacement.
### Project Registration
The process that creates or reattaches a Project in Fusion's central registry for a local path. Registration is a readiness boundary: it should complete only when the path satisfies the invariants later execution depends on, such as being a Git work tree.
## Mission execution
### Autopilot

View File

@@ -36,6 +36,11 @@ 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.
During fresh initialization, Fusion also installs the bundled `fusion` skill into supported local agent homes when the target skill does not already exist:
- `~/.claude/skills/fusion`
@@ -618,6 +623,10 @@ fn project remove my-app --force
Subcommands: `list|ls`, `add`, `remove|rm`, `show`, `info`, `set-default|default`, `detect`.
`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 node`

View File

@@ -90,5 +90,5 @@ docker run --rm \
## Notes
- The container runs as the non-root `node` user.
- `git` must be available in the project volume for worktree operations (`.git` metadata and repository history are required).
- `git` must be available in the container runtime. The mounted project volume must preserve `.git` metadata and repository history for worktree operations; Fusion initializes missing repositories during project registration.
- The root `Dockerfile` installs with `pnpm install --frozen-lockfile` before copying full source, so every workspace package/plugin manifest in `pnpm-workspace.yaml` must have a corresponding `COPY <path>/package.json` line in the builder stage.

View File

@@ -0,0 +1,184 @@
---
title: "fix: Initialize Git repositories during project registration"
type: fix
status: completed
date: 2026-06-06
source_issue: https://github.com/Runfusion/Fusion/issues/1455
---
# fix: Initialize Git repositories during project registration
## Summary
Initialize Git metadata automatically when Fusion registers a project path that is not already a Git repository. The fix should live at the shared registration boundary so dashboard, setup, CLI, and auto-registration paths all avoid the first-task failure where the executor reports that the project is not a Git repository.
---
## Problem Frame
Issue #1455 reports that adding a new project without `.git` succeeds, but first task execution fails because Fusion requires a Git repository for worktree creation. The current failure appears later in `packages/engine/src/executor.ts`, while project creation paths converge earlier through `CentralCore.ensureProjectForPath(...)` in `packages/core/src/central-core.ts`. A dashboard-only fix would leave CLI and first-run/setup registration paths exposed.
---
## Requirements
- R1. Registering a new project path with no existing Git repository initializes Git before the project can become usable for task execution.
- R2. Registering an existing Git repository is idempotent and does not alter commits, remotes, branches, Git config, or working tree content.
- R3. Reattaching an existing Fusion project identity at a path with no Git repository also initializes Git before activation.
- R4. Registration fails with an actionable error if `git init` fails; it must not persist a project that will immediately fail on first task creation.
- R5. Dashboard project add, setup wizard completion, CLI `fn project add`, `fn init`, and cwd auto-registration all receive the shared behavior without each carrying separate Git initialization logic.
- R6. The implementation does not create an initial commit, `.gitkeep`, remotes, or local Git author config as part of automatic project registration.
- R7. The published CLI package records a patch changeset because the behavior affects `@runfusion/fusion`.
---
## Key Technical Decisions
- **Initialize at `CentralCore.ensureProjectForPath(...)`, not in dashboard UI code.** The shared method is already used by route registration, CLI project registration, CLI cwd auto-registration, and first-run/migration setup paths, so it is the smallest boundary that covers the issue consistently.
- **Use a minimal Git initializer for registration.** Existing `fn init --git` logic creates a branch, configures author identity, writes `.gitkeep`, and creates an initial commit. That is appropriate for an explicit CLI flag but too invasive for automatic project registration; the shared helper should only run `git init` when the target is not already a repository.
- **Block registration on initialization failure.** Registering a central project row after `git init` fails only moves the failure from registration to first task execution. The registration caller should surface the failure immediately and leave central state unchanged.
- **Detect repositories with Git, not only `.git` directory existence.** `.git` can be a file in worktrees, and repository detection should match the executor's real worktree needs. A helper based on `git rev-parse --is-inside-work-tree` with a fallback for absent Git is safer than a raw `.git` existence check.
- **Keep clone-mode behavior naturally idempotent.** Dashboard clone mode already runs `git clone`; the shared initializer should detect the cloned repository and no-op rather than adding clone-specific branches.
---
## High-Level Technical Design
Registration flow after this change:
```mermaid
flowchart TB
A[Caller requests project registration] --> B[CentralCore.ensureProjectForPath]
B --> C{Central project exists for path?}
C -->|yes| X[Return existing project]
C -->|no| D{Stored identity points to this path?}
D -->|conflict| E[Throw identity conflict]
D -->|reattach or fresh| F[ensureGitRepositoryForProjectPath]
F --> G{Already a Git work tree?}
G -->|yes| H[No-op]
G -->|no| I[Run git init]
I -->|failure| J[Throw actionable registration error]
I -->|success| K[registerProject]
H --> K
K --> L[Caller activates project and stamps identity]
```
The ordering is intentional: the Git side effect happens before central row insertion for fresh and reattach outcomes, while the existing-project outcome remains a no-op to avoid surprising already-registered projects.
---
## Implementation Units
### U1. Shared minimal Git initialization helper
- **Goal:** Provide a core helper that detects whether a path is already a Git work tree and runs only `git init` when needed.
- **Requirements:** R1, R2, R4, R6.
- **Dependencies:** none.
- **Files:** `packages/core/src/git-repository.ts` (new), `packages/core/src/__tests__/git-repository.test.ts` (new), `packages/core/src/index.ts`.
- **Approach:** Add a small async helper such as `ensureGitRepositoryForProjectPath(path)` in core. Use `execFile`/promisified `execFile` with argument arrays and timeouts for `git -C <path> rev-parse --is-inside-work-tree` and `git -C <path> init`. Return a structured outcome (`existing` or `initialized`) or throw a typed/actionable error that includes the path and the underlying Git stderr/message. Do not set branch names, Git config, remotes, commits, or files.
- **Patterns to follow:** `packages/core/src/gh-cli.ts` for `execFile`-style Git command wrapping; `packages/cli/src/commands/git.ts` for Git repository detection intent; `packages/cli/src/commands/init.ts` as a contrast for the richer explicit `--git` path that this helper must not duplicate.
- **Test scenarios:**
- Happy path: empty temp directory with no Git repository -> helper returns `initialized` and `.git` metadata exists.
- Happy path: existing repository with a commit, configured user, and remote -> helper returns `existing` and leaves commit count, config, and remotes unchanged.
- Edge case: Git worktree where `.git` is a file -> helper treats it as existing.
- Error path: missing `git` binary or failing `git init` -> helper throws an actionable error and does not mask the failure.
- **Verification:** Core helper tests prove minimal side effects and failure behavior without relying on dashboard or CLI mocks.
### U2. Integrate Git initialization into central project registration
- **Goal:** Ensure fresh and reattached central project registrations initialize Git before any project row is persisted.
- **Requirements:** R1, R2, R3, R4, R5.
- **Dependencies:** U1.
- **Files:** `packages/core/src/central-core.ts`, `packages/core/src/__tests__/central-core-ensure-project.test.ts`, `packages/core/src/__tests__/central-core.test.ts`.
- **Approach:** Call the shared helper inside `ensureProjectForPath(...)` only on paths that will produce `registered` or `reattached` outcomes. Keep the `existing` outcome untouched. For identity reattach, initialize before `registerProject({ id: ... })`; for fresh registration, initialize before the final `registerProject(...)`. Let helper errors propagate so callers receive a failed registration rather than a partially usable central row.
- **Patterns to follow:** Existing `ensureProjectForPath(...)` outcome structure in `packages/core/src/central-core.ts`; existing coverage in `packages/core/src/__tests__/central-core-ensure-project.test.ts` and the broader central-core project registration tests.
- **Test scenarios:**
- Fresh ensure: directory without Git -> returns `registered`, central row exists, and the path is a Git repository.
- Existing ensure: already-registered path without Git from old state -> returns `existing` and does not mutate Git metadata in this compatibility path.
- Reattach ensure: identity recovered at a directory without Git -> returns `reattached` and initializes Git before the row is reinserted.
- Error path: helper failure before fresh registration -> promise rejects and `getProjectByPath(path)` remains undefined.
- Error path: helper failure before reattach -> promise rejects and `getProject(identity.id)` remains undefined.
- **Verification:** Central-core tests prove the shared invariant at the registry boundary rather than only through one caller.
### U3. Preserve and simplify CLI registration behavior
- **Goal:** Ensure CLI registration paths inherit the shared initialization behavior without keeping duplicate automatic Git logic.
- **Requirements:** R1, R2, R5, R6.
- **Dependencies:** U2.
- **Files:** `packages/cli/src/commands/init.ts`, `packages/cli/src/commands/ensure-project-registered.ts`, `packages/cli/src/commands/project.ts`, `packages/cli/src/commands/__tests__/init.test.ts`, `packages/cli/src/commands/__tests__/ensure-project-registered.test.ts`, `packages/cli/src/commands/__tests__/project.test.ts`.
- **Approach:** Keep explicit `fn init --git` semantics intact for users who ask for an initial commit. Remove or update messaging that says a non-Git project is acceptable unless `--git` is passed, because shared registration will now initialize a minimal repository. In mocked CLI command tests, assert that `runInit(...)`, `runProjectAdd(...)`, and `ensureCwdProjectRegistered(...)` delegate through `ensureProjectForPath(...)` and handle propagated errors. Prove the Git side effect in real core integration tests from U2/U4 rather than duplicating it in mock-only CLI tests.
- **Patterns to follow:** Existing mocked `CentralCore.ensureProjectForPath` assertions in CLI command tests; existing `fn init --git` tests that should remain specific to initial-commit behavior.
- **Test scenarios:**
- `runInit({ path })` without `--git` delegates to `ensureProjectForPath(...)` and no longer logs a warning that Git must be initialized manually.
- `runInit({ path, git: true })` still creates the explicit initial commit path.
- `runProjectAdd(..., { force: true })` still delegates registration to `ensureProjectForPath(...)`; it does not run a second Git initializer.
- `ensureCwdProjectRegistered(...)` propagates a failed shared registration as its existing auto-registration failure path and does not stamp identity on failure.
- **Verification:** CLI tests distinguish automatic minimal registration from the explicit `--git` initial-commit command.
### U4. Lock dashboard and setup surfaces to the shared behavior
- **Goal:** Prove dashboard project add, clone add, setup wizard, and migration setup all rely on the shared central invariant.
- **Requirements:** R1, R2, R4, R5.
- **Dependencies:** U2.
- **Files:** `packages/dashboard/src/routes/register-project-routes.ts`, `packages/dashboard/src/__tests__/project-routes.test.ts`, `packages/core/src/migration.ts`, `packages/core/src/first-run.ts`, `packages/core/src/__tests__/migration.test.ts`, `packages/core/src/__tests__/first-run.test.ts`.
- **Approach:** Route code likely needs little or no production change because it already calls `ensureProjectForPath(...)`. Update route tests to reflect that missing-Git existing-directory mode succeeds through the central call, while a central helper failure returns an API error and does not activate the project. For setup/migration tests that use real `CentralCore`, add assertions that registered project paths are Git repositories afterward.
- **Patterns to follow:** `packages/dashboard/src/__tests__/project-routes.test.ts` route-handler tests for `POST /api/projects`; `MigrationCoordinator.completeSetup(...)` and `FirstRunExperience.completeSetup(...)` tests for multi-project setup flows.
- **Test scenarios:**
- Dashboard existing-directory add with no `.git` -> HTTP 201 and central ensure receives the normalized path.
- Dashboard clone add -> `git clone` creates the repository and shared initializer no-ops.
- Dashboard registration when central ensure throws Git initialization failure -> non-2xx API response with actionable message and no `updateProject(...)` activation.
- `MigrationCoordinator.completeSetup(...)` registers multiple Fusion projects without Git and each becomes a Git repository.
- `FirstRunExperience.completeSetup(...)` registers a directory without Git and returns an active project backed by a Git repository.
- **Verification:** Surface tests cover the invariant through both mocked route calls and real core setup paths.
### U5. Documentation and release marker
- **Goal:** Document the changed registration expectation and add the required patch changeset.
- **Requirements:** R7.
- **Dependencies:** U1-U4.
- **Files:** `docs/cli-reference.md`, `docs/docker.md`, `.changeset/<descriptive-name>.md`.
- **Approach:** Update CLI docs where project initialization and project add are described so they no longer imply users must manually run `git init` before first task execution. Keep Docker guidance that Git must be available in project volumes, but clarify that Fusion initializes missing repositories during registration and still requires the `git` binary. Add a patch changeset for `@runfusion/fusion` because this alters published CLI/dashboard behavior.
- **Patterns to follow:** Existing changeset files under `.changeset/`; existing CLI docs sections for `fn init` and `fn project add`.
- **Test scenarios:** Test expectation: none -- documentation and changeset only.
- **Verification:** Docs match the final behavior and the changeset is present with a patch bump for `@runfusion/fusion`.
---
## Scope Boundaries
- Automatic registration does not create commits, branches, remotes, `.gitkeep`, or Git author config. Those remain exclusive to explicit `fn init --git`.
- Existing central project rows are not retroactively repaired when merely listed or selected. This plan fixes creation/reattach registration; broader repair of old registrations can be a follow-up if needed.
- This plan does not change executor worktree requirements. The executor should continue to reject non-Git paths if a legacy or manually corrupted project reaches it.
- This plan does not add UI prompts before Git initialization. The issue requested automatic behavior, and the chosen scope confirmed shared automatic registration.
### Deferred to Follow-Up Work
- A startup or health-check repair path for already-registered legacy projects that lack Git metadata.
- A dashboard-visible "Git initialized automatically" toast or activity event. The functional fix does not require a new user-facing notification.
---
## System-Wide Impact
The change moves a filesystem side effect into central project registration. That is intentional but important: every caller that creates or reattaches a project may now run `git init` before a central row is inserted. Multi-node path mappings are unaffected because the initializer runs only against the local path being registered on the handling node.
---
## Risks & Dependencies
- **Missing `git` binary:** registration will fail earlier and more clearly. This is preferable to creating an unusable project, but Docker and setup docs should make the requirement explicit.
- **Partial filesystem side effect:** `git init` can create `.git` and a later central insert can still fail. That leaves Git metadata without a Fusion project row, which is acceptable and recoverable by retrying registration.
- **Existing compatibility path:** already-registered projects that lack Git remain possible until a separate repair path exists. The executor's current failure remains the backstop for those legacy states.
- **Test cost:** real `git init` tests are integration tests. Keep them narrow and temp-dir based; do not add polling loops, network remotes, or slow worktree scenarios.
---
## Sources & Research
- GitHub issue: `https://github.com/Runfusion/Fusion/issues/1455`.
- Shared registration boundary: `packages/core/src/central-core.ts`.
- First-task Git failure backstop: `packages/engine/src/executor.ts`.
- Dashboard registration route: `packages/dashboard/src/routes/register-project-routes.ts`.
- CLI registration callers: `packages/cli/src/commands/init.ts`, `packages/cli/src/commands/project.ts`, `packages/cli/src/commands/ensure-project-registered.ts`.
- Setup registration callers: `packages/core/src/migration.ts`, `packages/core/src/first-run.ts`.
- Testing policy and gate commands: `AGENTS.md`, `docs/testing.md`.

View File

@@ -0,0 +1,107 @@
---
title: Project registration must initialize Git before persisting the project
date: 2026-06-06
category: integration-issues
module: project-registration
problem_type: integration_issue
component: tooling
symptoms:
- Adding a project from a plain directory succeeds even though the directory is not a Git repository
- The first task later fails when the executor tries to create a worktree from a non-Git project path
- CLI and dashboard registration paths can drift if Git setup is handled only at one surface
root_cause: incomplete_setup
resolution_type: code_fix
severity: high
tags: [project-registration, git-init, central-core, worktrees, cli, dashboard]
---
# Project registration must initialize Git before persisting the project
## Problem
Fusion let users register a new project directory that was not a Git repository. The project appeared usable, but the first task failed later because task execution depends on Git worktree creation.
The failure was reported in issue #1455: project creation succeeded, but execution eventually hit the executor's "not a Git repository" backstop. That made the real setup failure show up after the user had already completed project registration.
## Symptoms
- `fn project add`, dashboard project add, setup flows, or cwd auto-registration could persist a project for a directory with no `.git` metadata.
- The first task for that project failed at execution time because the executor could not create a worktree from the registered path.
- Fixing only `fn init` or only the dashboard route would leave other registration surfaces exposed.
## What Didn't Work
- Treating this as a dashboard-only issue would not cover CLI project add, `fn init`, setup/migration registration, or cwd auto-registration.
- Reusing the explicit `fn init --git` path would have been too invasive for automatic registration: that path can configure Git identity, create `.gitkeep`, create a branch, and make an initial commit.
- Checking only for a `.git` directory would miss valid linked worktrees, where `.git` is a file that points to the real metadata.
- Letting central registration continue after `git init` failed would persist a project that was still guaranteed to fail on its first task.
## Solution
Move the minimal Git readiness check to the shared project registration boundary.
`CentralCore.ensureProjectForPath(...)` now calls a core helper before inserting or reattaching a central project row. The helper asks Git whether the path is already inside a work tree; if not, it runs a minimal `git init`.
```ts
export async function ensureGitRepositoryForProjectPath(
projectPath: string,
options: EnsureGitRepositoryOptions = {},
): Promise<GitRepositoryEnsureOutcome> {
const insideWorkTree = await runGit(["-C", projectPath, "rev-parse", "--is-inside-work-tree"]);
if (insideWorkTree.ok && insideWorkTree.stdout.trim() === "true") {
return "existing";
}
const init = await runGit(["-C", projectPath, "init"]);
if (!init.ok) {
throw new GitRepositoryInitializationError(projectPath, init.stderr || init.error?.message);
}
return "initialized";
}
```
The registration layer applies that helper only when a project row is being created or an existing local project identity is being reattached:
```ts
const gitRepository = await this.ensureGitRepositoryForProjectPath(canonicalPath);
return this.registerProject({
id: identity?.projectId,
name,
path: canonicalPath,
setupMode,
// ...
});
```
The automatic path deliberately does not create a commit, branch, remote, Git author config, or `.gitkeep`. Those remain part of the explicit `fn init --git` workflow.
CLI `fn init` also treats `GitRepositoryInitializationError` as a blocking central-registration failure instead of falling back to "project initialized locally" messaging. That keeps the failure actionable and prevents a partially registered central row.
## Why This Works
The executor's worktree requirement is a project invariant, not a property of one UI surface. Enforcing it at central registration means every caller that creates or reattaches a project receives the same behavior:
- dashboard project add
- setup wizard and first-run registration
- migration registration
- `fn project add`
- `fn init`
- cwd auto-registration
Using `git rev-parse --is-inside-work-tree` also matches Git's own repository model, including linked worktrees, instead of relying on filesystem shape.
Blocking registration on initialization failure moves the error to the earliest recoverable point. The user sees that Git could not initialize while registering the project, rather than discovering the problem only after task execution starts.
## Prevention
- Put project-readiness invariants at `CentralCore.ensureProjectForPath(...)` when every project creation surface must share them.
- Keep automatic project registration side effects minimal. If a richer setup path creates commits, branches, or config, leave that behavior behind an explicit flag.
- Regression tests should cover all known registration surfaces, not just the reported reproduction: CLI init, CLI project add, cwd auto-registration, dashboard route registration, setup/first-run paths, fresh registration, existing Git repos, linked worktrees, and reattach from local project identity.
- Test Git initialization failure as a persistence invariant: a failed `git init` must not insert or update the central project row.
## Related Issues
- Issue #1455 - project registration succeeded for non-Git directories, then first task execution failed.
- PR #1463 - shared project-registration Git initialization fix.

View File

@@ -70,6 +70,7 @@ describe("ensureCwdProjectRegistered", () => {
});
expect(result).not.toBeNull();
expect(existsSync(join(cwd, ".git"))).toBe(true);
expect(existsSync(join(cwd, ".fusion"))).toBe(true);
expect(existsSync(join(cwd, ".fusion", "fusion.db"))).toBe(true);
expect(ensureSpy).toHaveBeenCalledWith(
@@ -156,6 +157,7 @@ describe("ensureCwdProjectRegistered", () => {
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("[serve] Failed to auto-register current project: boom"),
);
expect(readProjectIdentity(cwd)).toBeNull();
await central.close();
});

View File

@@ -9,6 +9,7 @@ import { join } from "node:path";
import { runInit } from "../init.js";
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { GitRepositoryInitializationError } from "@fusion/core";
const execAsync = promisify(exec);
@@ -158,6 +159,14 @@ describe("init command", () => {
);
});
it("propagates Git initialization failures instead of reporting local init success", async () => {
const error = new GitRepositoryInitializationError(tempProjectDir, "git is not installed");
mockEnsureProjectForPath.mockRejectedValueOnce(error);
await expect(runInit({ path: tempProjectDir })).rejects.toBe(error);
expect(mockCentralClose).toHaveBeenCalled();
});
it("should be idempotent - report already initialized", async () => {
// First init
await runInit({ path: tempProjectDir });
@@ -358,7 +367,7 @@ describe("init command", () => {
expect(Number(commitCount)).toBe(1);
});
it("does not create git repository without --git and logs a hint", async () => {
it("delegates registration without --git and does not log a manual git hint", async () => {
const originalLog = console.log;
const logs: string[] = [];
console.log = (...args: unknown[]) => {
@@ -372,6 +381,41 @@ describe("init command", () => {
}
expect(existsSync(join(tempProjectDir, ".git"))).toBe(false);
expect(logs.join("\n")).toContain("Not a git repository. Run 'fn init --git' to auto-initialize one.");
expect(mockEnsureProjectForPath).toHaveBeenCalledWith(
expect.objectContaining({
path: tempProjectDir,
}),
);
expect(logs.join("\n")).not.toContain("Not a git repository");
});
it("logs when shared registration initializes git without --git", async () => {
mockEnsureProjectForPath.mockResolvedValueOnce({
outcome: "registered",
gitRepository: "initialized",
project: {
id: "proj_test",
name: "test-project",
path: tempProjectDir,
isolationMode: "in-process",
status: "initializing",
createdAt: "",
updatedAt: "",
},
});
const originalLog = console.log;
const logs: string[] = [];
console.log = (...args: unknown[]) => {
logs.push(args.join(" "));
};
try {
await runInit({ path: tempProjectDir });
} finally {
console.log = originalLog;
}
expect(logs.join("\n")).toContain("Initialized git repository");
});
});

View File

@@ -164,7 +164,12 @@ describe("project commands", () => {
const { runProjectAdd } = await import("../project.js");
await runProjectAdd("demo", ".", { force: true });
expect(mockRegisterProject).toHaveBeenCalled();
expect(mockEnsureProjectForPath).toHaveBeenCalledWith(
expect.objectContaining({
name: "demo",
path: process.cwd(),
}),
);
const lines = consoleSpy.mock.calls.map((call) => String(call[0]));
expect(lines.some((line) => line.includes("Registered project 'demo'"))).toBe(true);
expect(lines.some((line) => line.includes("Location:"))).toBe(true);
@@ -339,9 +344,9 @@ describe("project commands", () => {
expect(output).toContain("Completed: 10");
});
it("validation exits on missing required args for runProjectAdd", async () => {
it("validation exits on invalid project name for runProjectAdd", async () => {
const { runProjectAdd } = await import("../project.js");
await expect(runProjectAdd("", "/tmp")).rejects.toThrow("process.exit:1");
await expect(runProjectAdd("bad name", "/tmp")).rejects.toThrow("process.exit:1");
});
it("validation exits on missing required args for runProjectRemove", async () => {
@@ -398,6 +403,32 @@ describe("project commands", () => {
expect(output).toContain("Memory: initialized");
});
it("shows git initialized message when shared registration creates a git repository", async () => {
mockListProjects.mockResolvedValue([]);
mockRegisterProject.mockResolvedValue({
id: "proj-1",
name: "demo",
path: "/fake/demo",
isolationMode: "in-process",
});
mockEnsureProjectForPath.mockResolvedValueOnce({
outcome: "registered",
gitRepository: "initialized",
project: {
id: "proj-1",
name: "demo",
path: "/fake/demo",
isolationMode: "in-process",
},
});
const { runProjectAdd } = await import("../project.js");
await runProjectAdd("demo", testPath, { force: true });
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("Git: initialized");
});
it("does not show memory message when memory files already exist", async () => {
mockListProjects.mockResolvedValue([]);
mockRegisterProject.mockResolvedValue({

View File

@@ -15,6 +15,7 @@ import { promisify } from "node:util";
const execAsync = promisify(exec);
import {
CentralCore,
GitRepositoryInitializationError,
QMD_INSTALL_COMMAND,
isQmdAvailable,
isValidSqliteDatabaseFile,
@@ -105,12 +106,9 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
console.log(` ✓ Created .fusion/ directory`);
}
const hasGitRepo = await isGitRepo(cwd);
if (!hasGitRepo && options.git) {
if (options.git && !(await isGitRepo(cwd))) {
await initializeGitRepo(cwd);
console.log(` ✓ Initialized git repository`);
} else if (!hasGitRepo) {
console.log(` ⚠ Not a git repository. Run 'fn init --git' to auto-initialize one.`);
}
// Add local Fusion/Pi storage directories to .gitignore
@@ -169,6 +167,9 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
maybeInstallClaudeSkillForNewProject(cwd);
if (ensured.gitRepository === "initialized") {
console.log(` ✓ Initialized git repository`);
}
console.log(` ✓ Registered in central database`);
console.log(`\n✓ Project "${project.name}" initialized successfully!`);
console.log(`\n Next steps:`);
@@ -178,6 +179,10 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
await central.close();
} catch (err) {
if (err instanceof GitRepositoryInitializationError) {
await central.close();
throw err;
}
// If central DB registration fails, still report success since local files are created
console.log(` ⚠ Could not register in central database: ${(err as Error).message}`);
console.log(`\n✓ Project initialized locally (central registration can be done later)`);

View File

@@ -394,6 +394,9 @@ export async function runProjectAdd(
console.log(` Location: ${formatDisplayPath(project.path)}`);
console.log(` ID: ${project.id}`);
console.log(` Isolation: ${project.isolationMode}`);
if (ensured.gitRepository === "initialized") {
console.log(` Git: initialized`);
}
if (memoryInitialized) {
console.log(` Memory: initialized`);
}

View File

@@ -1,10 +1,26 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
import { execFile } from "node:child_process";
import { existsSync, mkdtempSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { promisify } from "node:util";
import { CentralCore } from "../central-core.js";
import { ProjectIdentityConflictError } from "../project-identity.js";
const execFileAsync = promisify(execFile);
async function isGitRepository(path: string): Promise<boolean> {
try {
const { stdout } = await execFileAsync("git", ["-C", path, "rev-parse", "--is-inside-work-tree"], {
encoding: "utf-8",
timeout: 10_000,
});
return stdout.trim() === "true";
} catch {
return false;
}
}
describe("CentralCore.ensureProjectForPath", () => {
const cleanup: string[] = [];
afterEach(() => cleanup.splice(0).forEach((p) => rmSync(p, { recursive: true, force: true })));
@@ -22,9 +38,12 @@ describe("CentralCore.ensureProjectForPath", () => {
const first = await central.ensureProjectForPath({ path: p1, name: "A" });
expect(first.reattached).toBe(false);
expect(first.gitRepository).toBe("initialized");
await expect(isGitRepository(p1)).resolves.toBe(true);
const existing = await central.ensureProjectForPath({ path: p1, name: "A" });
expect(existing.outcome).toBe("existing");
expect(existing.gitRepository).toBeUndefined();
await central.unregisterProject(first.project.id);
const events: Array<[string, string]> = [];
@@ -35,6 +54,7 @@ describe("CentralCore.ensureProjectForPath", () => {
identity: { id: first.project.id, createdAt: first.project.createdAt },
});
expect(reattached.reattached).toBe(true);
expect(reattached.gitRepository).toBe("existing");
expect(events).toEqual([[first.project.id, "identity-recovered"]]);
await expect(
@@ -47,4 +67,69 @@ describe("CentralCore.ensureProjectForPath", () => {
await central.close();
});
it("leaves already-registered legacy paths untouched", async () => {
const globalDir = mkdtempSync(join(tmpdir(), "central-"));
const projectPath = mkdtempSync(join(tmpdir(), "proj-legacy-"));
cleanup.push(globalDir, projectPath);
const central = new CentralCore(globalDir);
await central.init();
const registered = await central.registerProject({ path: projectPath, name: "Legacy" });
expect(existsSync(join(projectPath, ".git"))).toBe(false);
const ensured = await central.ensureProjectForPath({ path: projectPath, name: "Legacy" });
expect(ensured.outcome).toBe("existing");
expect(ensured.project.id).toBe(registered.id);
expect(ensured.gitRepository).toBeUndefined();
expect(existsSync(join(projectPath, ".git"))).toBe(false);
await central.close();
});
it("does not persist fresh registrations when git initialization fails", async () => {
const globalDir = mkdtempSync(join(tmpdir(), "central-"));
const projectPath = mkdtempSync(join(tmpdir(), "proj-fail-"));
cleanup.push(globalDir, projectPath);
const central = new CentralCore(globalDir, {
ensureGitRepositoryForProjectPath: async () => {
throw new Error("Could not initialize Git repository at project: git is not installed");
},
});
await central.init();
await expect(central.ensureProjectForPath({ path: projectPath, name: "Fail" })).rejects.toThrow(
"Could not initialize Git repository",
);
await expect(central.getProjectByPath(projectPath)).resolves.toBeUndefined();
await central.close();
});
it("does not persist reattachments when git initialization fails", async () => {
const globalDir = mkdtempSync(join(tmpdir(), "central-"));
const projectPath = mkdtempSync(join(tmpdir(), "proj-reattach-fail-"));
cleanup.push(globalDir, projectPath);
const central = new CentralCore(globalDir, {
ensureGitRepositoryForProjectPath: async () => {
throw new Error("Could not initialize Git repository at project: permission denied");
},
});
await central.init();
await expect(
central.ensureProjectForPath({
path: projectPath,
name: "Fail",
identity: { id: "proj_abcdef1234567890", createdAt: "2026-06-06T00:00:00.000Z" },
}),
).rejects.toThrow("Could not initialize Git repository");
await expect(central.getProject("proj_abcdef1234567890")).resolves.toBeUndefined();
await central.close();
});
});

View File

@@ -1,18 +1,34 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { execFile } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { realpath } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { tempWorkspace } from "@fusion/test-utils";
import { FirstRunExperience, createFirstRunExperience } from "../first-run.js";
import { CentralCore } from "../central-core.js";
const execFileAsync = promisify(execFile);
// Helper to create a fake kb project structure
function createFakeKbProject(dir: string): void {
mkdirSync(join(dir, ".fusion"), { recursive: true });
writeFileSync(join(dir, ".fusion", "fusion.db"), "");
}
async function isGitRepository(path: string): Promise<boolean> {
try {
const { stdout } = await execFileAsync("git", ["-C", path, "rev-parse", "--is-inside-work-tree"], {
encoding: "utf-8",
timeout: 10_000,
});
return stdout.trim() === "true";
} catch {
return false;
}
}
const TEST_FILE_DIR = dirname(fileURLToPath(import.meta.url));
function getSafeCwd(): string {
@@ -187,6 +203,7 @@ describe("FirstRunExperience", () => {
expect(result.success).toBe(true);
expect(result.projects).toHaveLength(1);
expect(result.projects[0].name).toBe("new-project");
await expect(isGitRepository(projectDir)).resolves.toBe(true);
expect(result.nextSteps.length).toBeGreaterThan(0);
});

View File

@@ -0,0 +1,110 @@
import { afterEach, describe, expect, it } from "vitest";
import { execFile } from "node:child_process";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import {
ensureGitRepositoryForProjectPath,
GitRepositoryInitializationError,
type GitRepositoryCommandRunner,
} from "../git-repository.js";
const execFileAsync = promisify(execFile);
async function git(cwd: string, args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", args, {
cwd,
timeout: 10_000,
encoding: "utf-8",
});
return stdout.trim();
}
describe("ensureGitRepositoryForProjectPath", () => {
const cleanup: string[] = [];
afterEach(() => {
cleanup.splice(0).forEach((path) => rmSync(path, { recursive: true, force: true }));
});
function tempDir(prefix: string): string {
const path = mkdtempSync(join(tmpdir(), prefix));
cleanup.push(path);
return path;
}
it("initializes an empty directory without creating commits or files", async () => {
const projectPath = tempDir("fusion-git-init-");
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);
});
it("leaves an existing repository commits, config, and remotes unchanged", async () => {
const projectPath = tempDir("fusion-git-existing-");
await git(projectPath, ["init"]);
await git(projectPath, ["config", "user.name", "Existing User"]);
await git(projectPath, ["config", "user.email", "existing@example.com"]);
writeFileSync(join(projectPath, "README.md"), "# Existing\n");
await git(projectPath, ["add", "README.md"]);
await git(projectPath, ["commit", "-m", "existing commit"]);
await git(projectPath, ["remote", "add", "origin", "https://github.com/example/repo.git"]);
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"]);
const outcome = await ensureGitRepositoryForProjectPath(projectPath);
expect(outcome).toBe("existing");
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);
});
it("treats a linked worktree with .git as a file as an existing repository", async () => {
const repoPath = tempDir("fusion-git-worktree-repo-");
const worktreeParent = tempDir("fusion-git-worktree-parent-");
const worktreePath = join(worktreeParent, "linked");
await git(repoPath, ["init"]);
await git(repoPath, ["config", "user.name", "Existing User"]);
await git(repoPath, ["config", "user.email", "existing@example.com"]);
writeFileSync(join(repoPath, "README.md"), "# Existing\n");
await git(repoPath, ["add", "README.md"]);
await git(repoPath, ["commit", "-m", "existing commit"]);
await git(repoPath, ["worktree", "add", worktreePath]);
const outcome = await ensureGitRepositoryForProjectPath(worktreePath);
expect(outcome).toBe("existing");
expect(existsSync(join(worktreePath, ".git"))).toBe(true);
await expect(git(worktreePath, ["rev-parse", "--is-inside-work-tree"])).resolves.toBe("true");
});
it("throws an actionable error when git init fails", async () => {
const projectPath = tempDir("fusion-git-fail-");
const runner: GitRepositoryCommandRunner = async (_command, args) => {
if (args.includes("rev-parse")) {
throw new Error("not a repository");
}
throw Object.assign(new Error("spawn git ENOENT"), { stderr: "git is not installed" });
};
await expect(
ensureGitRepositoryForProjectPath(projectPath, { runner }),
).rejects.toMatchObject({
name: "GitRepositoryInitializationError",
path: projectPath,
causeMessage: "git is not installed",
});
await expect(
ensureGitRepositoryForProjectPath(projectPath, { runner }),
).rejects.toBeInstanceOf(GitRepositoryInitializationError);
});
});

View File

@@ -3,9 +3,11 @@
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { execFile } from "node:child_process";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { tempWorkspace, useIsolatedCwd } from "@fusion/test-utils";
import {
FirstRunDetector,
@@ -16,6 +18,8 @@ import {
} from "../migration.js";
import { CentralCore } from "../central-core.js";
const execFileAsync = promisify(execFile);
// Helper to create a fake kb project
function createFakeKbProject(dir: string): void {
const kbDir = join(dir, ".fusion");
@@ -24,6 +28,18 @@ function createFakeKbProject(dir: string): void {
writeFileSync(join(kbDir, "fusion.db"), "");
}
async function isGitRepository(path: string): Promise<boolean> {
try {
const { stdout } = await execFileAsync("git", ["-C", path, "rev-parse", "--is-inside-work-tree"], {
encoding: "utf-8",
timeout: 10_000,
});
return stdout.trim() === "true";
} catch {
return false;
}
}
function createInvalidKbProject(dir: string): void {
const kbDir = join(dir, ".fusion");
mkdirSync(kbDir, { recursive: true });
@@ -469,6 +485,8 @@ describe("MigrationCoordinator", () => {
expect(result.success).toBe(true);
expect(result.projectsRegistered).toHaveLength(2);
expect(result.errors).toHaveLength(0);
await expect(isGitRepository(tempProjectDir1)).resolves.toBe(true);
await expect(isGitRepository(tempProjectDir2)).resolves.toBe(true);
});
it("should skip already registered projects", async () => {

View File

@@ -93,6 +93,10 @@ import {
ProjectIdentityConflictError,
type ProjectIdentity,
} from "./project-identity.js";
import {
ensureGitRepositoryForProjectPath,
type GitRepositoryEnsureOutcome,
} from "./git-repository.js";
// ── Event Types ───────────────────────────────────────────────────────────
export interface CentralCoreEvents {
@@ -161,6 +165,11 @@ export interface EnsureProjectForPathResult {
project: RegisteredProject;
reattached: boolean;
outcome: "existing" | "reattached" | "registered";
gitRepository?: GitRepositoryEnsureOutcome;
}
export interface CentralCoreOptions {
ensureGitRepositoryForProjectPath?: typeof ensureGitRepositoryForProjectPath;
}
export class CentralCore extends EventEmitter<CentralCoreEvents> {
@@ -170,6 +179,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
private nodeDiscovery: NodeDiscovery | null = null;
private discoveryConfig: DiscoveryConfig | null = null;
private readonly discoveredNodes = new Map<string, DiscoveredNode>();
private readonly ensureGitRepositoryForProjectPath: typeof ensureGitRepositoryForProjectPath;
private readonly onDiscoveryNodeDiscovered = (node: DiscoveredNode): void => {
void this.handleDiscoveryNodeDiscovered(node).catch((error) => {
@@ -194,10 +204,12 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
* @param globalDir — Directory for central database. Defaults to `~/.fusion/`.
* Accepts a custom path for testing.
*/
constructor(globalDir?: string) {
constructor(globalDir?: string, options: CentralCoreOptions = {}) {
super();
this.setMaxListeners(100);
this.globalDir = resolveGlobalDir(globalDir);
this.ensureGitRepositoryForProjectPath =
options.ensureGitRepositoryForProjectPath ?? ensureGitRepositoryForProjectPath;
}
/**
@@ -424,6 +436,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
if (input.identity?.id) {
const byId = await this.getProject(input.identity.id);
if (!byId) {
const gitRepository = await this.ensureGitRepositoryForProjectPath(input.path);
const reattached = await this.registerProject({
id: input.identity.id,
name: input.name ?? basename(input.path),
@@ -433,7 +446,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
settings: input.settings,
});
this.emit("project:reattached", reattached, "identity-recovered");
return { project: reattached, reattached: true, outcome: "reattached" };
return { project: reattached, reattached: true, outcome: "reattached", gitRepository };
}
if (byId.path !== input.path) {
throw new ProjectIdentityConflictError(input.identity.id, byId.path, input.path);
@@ -441,6 +454,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
return { project: byId, reattached: false, outcome: "existing" };
}
const gitRepository = await this.ensureGitRepositoryForProjectPath(input.path);
const registered = await this.registerProject({
name: input.name ?? basename(input.path),
path: input.path,
@@ -448,7 +462,7 @@ export class CentralCore extends EventEmitter<CentralCoreEvents> {
nodeId: input.nodeId,
settings: input.settings,
});
return { project: registered, reattached: false, outcome: "registered" };
return { project: registered, reattached: false, outcome: "registered", gitRepository };
}
/**

View File

@@ -0,0 +1,100 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const DEFAULT_GIT_TIMEOUT_MS = 10_000;
export type GitRepositoryEnsureOutcome = "existing" | "initialized";
export interface GitRepositoryCommandResult {
stdout: string;
stderr: string;
}
export type GitRepositoryCommandRunner = (
command: string,
args: string[],
options: { cwd?: string; timeout: number },
) => Promise<GitRepositoryCommandResult>;
export interface EnsureGitRepositoryOptions {
runner?: GitRepositoryCommandRunner;
timeoutMs?: number;
}
export class GitRepositoryInitializationError extends Error {
readonly path: string;
readonly causeMessage: string;
constructor(path: string, causeMessage: string) {
super(`Could not initialize Git repository at ${path}: ${causeMessage}`);
this.name = "GitRepositoryInitializationError";
this.path = path;
this.causeMessage = causeMessage;
}
}
export async function ensureGitRepositoryForProjectPath(
projectPath: string,
options: EnsureGitRepositoryOptions = {},
): Promise<GitRepositoryEnsureOutcome> {
const runner = options.runner ?? runGitCommand;
const timeout = options.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS;
if (await isInsideGitWorkTree(projectPath, runner, timeout)) {
return "existing";
}
try {
await runner("git", ["-C", projectPath, "init"], { timeout });
return "initialized";
} catch (error) {
throw new GitRepositoryInitializationError(projectPath, extractCommandErrorMessage(error));
}
}
async function isInsideGitWorkTree(
projectPath: string,
runner: GitRepositoryCommandRunner,
timeout: number,
): Promise<boolean> {
try {
const result = await runner("git", ["-C", projectPath, "rev-parse", "--is-inside-work-tree"], { timeout });
return result.stdout.trim() === "true";
} catch {
return false;
}
}
async function runGitCommand(
command: string,
args: string[],
options: { cwd?: string; timeout: number },
): Promise<GitRepositoryCommandResult> {
const result = await execFileAsync(command, args, {
cwd: options.cwd,
timeout: options.timeout,
encoding: "utf-8",
});
return {
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
};
}
function extractCommandErrorMessage(error: unknown): string {
if (error && typeof error === "object") {
const maybe = error as { stderr?: unknown; stdout?: unknown; message?: unknown; code?: unknown };
for (const value of [maybe.stderr, maybe.stdout, maybe.message]) {
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
if (maybe.code !== undefined) {
return `git exited with code ${String(maybe.code)}`;
}
}
return String(error);
}

View File

@@ -112,6 +112,16 @@ export {
stripMovedSettingsKeys,
patchContainsMovedKey,
} from "./moved-settings.js";
export {
ensureGitRepositoryForProjectPath,
GitRepositoryInitializationError,
} from "./git-repository.js";
export type {
GitRepositoryCommandResult,
GitRepositoryCommandRunner,
GitRepositoryEnsureOutcome,
EnsureGitRepositoryOptions,
} from "./git-repository.js";
// ── Trait model (U2) ─────────────────────────────────────────────────
export type {

View File

@@ -830,6 +830,26 @@ describe("POST /api/projects route handler", () => {
expect((res.body as any).error).toContain("bad identity json");
});
it("does not activate the project when shared registration fails", async () => {
const store = new MockStoreForRoutes();
const app = await createApp(store);
mockEnsureProjectForPath.mockRejectedValueOnce(
new Error("Could not initialize Git repository at /tmp: git is not installed"),
);
const res = await request(
app,
"POST",
"/api/projects",
JSON.stringify({ name: "Test Project", path: "/tmp" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(500);
expect((res.body as any).error).toContain("Could not initialize Git repository");
expect(mockUpdateProject).not.toHaveBeenCalled();
});
it("calls ensureMemoryFileWithBackend after project activation", async () => {
const store = new MockStoreForRoutes();
const app = await createApp(store);