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