fix(FN-1455): initialize git during project registration
Fusion-Task-Id: FN-1455
This commit is contained in:
@@ -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