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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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