fix(FN-1455): initialize git during project registration
Fusion-Task-Id: FN-1455
This commit is contained in:
3
.changeset/auto-git-init-project-registration.md
Normal file
3
.changeset/auto-git-init-project-registration.md
Normal file
@@ -0,0 +1,3 @@
|
||||
"@runfusion/fusion": patch
|
||||
|
||||
Initialize missing Git repositories automatically when registering Fusion projects.
|
||||
@@ -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`
|
||||
|
||||
@@ -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 <path>/package.json` line in the builder stage.
|
||||
|
||||
@@ -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`.
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
@@ -178,6 +176,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)`);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
110
packages/core/src/__tests__/git-repository.test.ts
Normal file
110
packages/core/src/__tests__/git-repository.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
100
packages/core/src/git-repository.ts
Normal file
100
packages/core/src/git-repository.ts
Normal 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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user