From db971a91ea6d5b48b9246eccd8fdfa6878489645 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 6 Jun 2026 00:38:28 -0700 Subject: [PATCH] fix(FN-1455): initialize git during project registration Fusion-Task-Id: FN-1455 --- .../auto-git-init-project-registration.md | 3 + docs/cli-reference.md | 9 + docs/docker.md | 2 +- ...auto-git-init-project-registration-plan.md | 184 ++++++++++++++++++ .../ensure-project-registered.test.ts | 2 + .../cli/src/commands/__tests__/init.test.ts | 18 +- .../src/commands/__tests__/project.test.ts | 11 +- packages/cli/src/commands/init.ts | 10 +- .../central-core-ensure-project.test.ts | 87 ++++++++- packages/core/src/__tests__/first-run.test.ts | 17 ++ .../core/src/__tests__/git-repository.test.ts | 110 +++++++++++ packages/core/src/__tests__/migration.test.ts | 18 ++ packages/core/src/central-core.ts | 20 +- packages/core/src/git-repository.ts | 100 ++++++++++ packages/core/src/index.ts | 10 + .../src/__tests__/project-routes.test.ts | 20 ++ 16 files changed, 607 insertions(+), 14 deletions(-) create mode 100644 .changeset/auto-git-init-project-registration.md create mode 100644 docs/plans/2026-06-06-001-fix-auto-git-init-project-registration-plan.md create mode 100644 packages/core/src/__tests__/git-repository.test.ts create mode 100644 packages/core/src/git-repository.ts diff --git a/.changeset/auto-git-init-project-registration.md b/.changeset/auto-git-init-project-registration.md new file mode 100644 index 0000000000..6d83df8eed --- /dev/null +++ b/.changeset/auto-git-init-project-registration.md @@ -0,0 +1,3 @@ +"@runfusion/fusion": patch + +Initialize missing Git repositories automatically when registering Fusion projects. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index e74a444019..80da35e9c7 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -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` diff --git a/docs/docker.md b/docs/docker.md index 14bfc2c7d2..570ffb1631 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -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 and project volume for worktree operations. Fusion initializes missing repositories during project registration, but existing `.git` metadata and repository history are still required once tasks begin. - 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 /package.json` line in the builder stage. diff --git a/docs/plans/2026-06-06-001-fix-auto-git-init-project-registration-plan.md b/docs/plans/2026-06-06-001-fix-auto-git-init-project-registration-plan.md new file mode 100644 index 0000000000..5da0029e7b --- /dev/null +++ b/docs/plans/2026-06-06-001-fix-auto-git-init-project-registration-plan.md @@ -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 rev-parse --is-inside-work-tree` and `git -C 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/.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`. diff --git a/packages/cli/src/commands/__tests__/ensure-project-registered.test.ts b/packages/cli/src/commands/__tests__/ensure-project-registered.test.ts index dcb9ff663d..a0be6b1b57 100644 --- a/packages/cli/src/commands/__tests__/ensure-project-registered.test.ts +++ b/packages/cli/src/commands/__tests__/ensure-project-registered.test.ts @@ -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(); }); diff --git a/packages/cli/src/commands/__tests__/init.test.ts b/packages/cli/src/commands/__tests__/init.test.ts index 3d01c38ae6..5c7779f0e8 100644 --- a/packages/cli/src/commands/__tests__/init.test.ts +++ b/packages/cli/src/commands/__tests__/init.test.ts @@ -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,11 @@ 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"); }); }); diff --git a/packages/cli/src/commands/__tests__/project.test.ts b/packages/cli/src/commands/__tests__/project.test.ts index 26f251db9b..db187b6360 100644 --- a/packages/cli/src/commands/__tests__/project.test.ts +++ b/packages/cli/src/commands/__tests__/project.test.ts @@ -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 () => { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index ca1b53d82e..e087ece823 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -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 { 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 @@ -178,6 +176,10 @@ export async function runInit(options: InitOptions = {}): Promise { 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)`); diff --git a/packages/core/src/__tests__/central-core-ensure-project.test.ts b/packages/core/src/__tests__/central-core-ensure-project.test.ts index 3abdd829ec..a19b5b1e49 100644 --- a/packages/core/src/__tests__/central-core-ensure-project.test.ts +++ b/packages/core/src/__tests__/central-core-ensure-project.test.ts @@ -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 { + 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(); + }); }); diff --git a/packages/core/src/__tests__/first-run.test.ts b/packages/core/src/__tests__/first-run.test.ts index 8bddaf87e2..06dc8e1ba7 100644 --- a/packages/core/src/__tests__/first-run.test.ts +++ b/packages/core/src/__tests__/first-run.test.ts @@ -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 { + 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); }); diff --git a/packages/core/src/__tests__/git-repository.test.ts b/packages/core/src/__tests__/git-repository.test.ts new file mode 100644 index 0000000000..bb0cff60ca --- /dev/null +++ b/packages/core/src/__tests__/git-repository.test.ts @@ -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 { + 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); + }); +}); diff --git a/packages/core/src/__tests__/migration.test.ts b/packages/core/src/__tests__/migration.test.ts index 5fc2be3ebd..9d36a96cdf 100644 --- a/packages/core/src/__tests__/migration.test.ts +++ b/packages/core/src/__tests__/migration.test.ts @@ -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 { + 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 () => { diff --git a/packages/core/src/central-core.ts b/packages/core/src/central-core.ts index d6370b09be..3a2264494c 100644 --- a/packages/core/src/central-core.ts +++ b/packages/core/src/central-core.ts @@ -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 { @@ -170,6 +179,7 @@ export class CentralCore extends EventEmitter { private nodeDiscovery: NodeDiscovery | null = null; private discoveryConfig: DiscoveryConfig | null = null; private readonly discoveredNodes = new Map(); + 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 { * @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 { 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 { 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 { 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 { nodeId: input.nodeId, settings: input.settings, }); - return { project: registered, reattached: false, outcome: "registered" }; + return { project: registered, reattached: false, outcome: "registered", gitRepository }; } /** diff --git a/packages/core/src/git-repository.ts b/packages/core/src/git-repository.ts new file mode 100644 index 0000000000..e95a9740a7 --- /dev/null +++ b/packages/core/src/git-repository.ts @@ -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; + +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 { + 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 { + 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 { + 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); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 08c6712031..69d8a38ace 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -106,6 +106,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 { diff --git a/packages/dashboard/src/__tests__/project-routes.test.ts b/packages/dashboard/src/__tests__/project-routes.test.ts index f24c0aaa08..7b959e17f6 100644 --- a/packages/dashboard/src/__tests__/project-routes.test.ts +++ b/packages/dashboard/src/__tests__/project-routes.test.ts @@ -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);