fix(api): turn repositories found by the onboarding scan into real projects (#116)
The scan's project phase called prisma.space with fields the Space model does not have (title, path). Both calls threw, a per-repository try/catch swallowed the errors, and no project was ever created. With the field names fixed, the space would still have had no organization or membership, so nobody could have seen it. The phase now goes through createSpaceForMember and wireProjectWorkspace, the helpers behind spaces.create: each repository becomes a project of the deployment owner in their current organization, linked to its folder with its git remote, branch and head. It still runs only in the owner's scan with ONBOARDING_SCAN_HOME set. A repository whose real folder one of the owner's projects already links is skipped, so scanning again creates nothing; the phase stops at the per-member space limit; and a second start while a scan runs (a StrictMode remount, a retry) joins it instead of linking everything twice. Discovery no longer descends into dot folders, where shell and editor plugin checkouts would otherwise become projects. The Prisma client is typed, so a field the schema lacks is a compile error again instead of a swallowed one. readGitCheckout loaded child_process with require(), which does not exist in the ES module runtime the API runs in, so linking any folder with a .git directory threw "require is not defined", in the scan and in the New project dialog alike. It now imports spawn statically; the module is only reachable through its server-only subpath, so browser bundles still never load it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ebcq9seT9eCoHtqxuWEUzg
This commit is contained in:
parent
f8eabee997
commit
2b967df569
@ -1,11 +1,42 @@
|
|||||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, realpath, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import type * as db from "@rakazo/db";
|
||||||
|
import { SpaceLimitError } from "@rakazo/db";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
|
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
|
||||||
|
|
||||||
const owner = { userId: "user-owner", isDeploymentOwner: true };
|
const createSpaceForMember = vi.hoisted(() => vi.fn());
|
||||||
const member = { userId: "user-member", isDeploymentOwner: false };
|
vi.mock("@rakazo/db", async (original) => ({
|
||||||
|
...(await original<typeof db>()),
|
||||||
|
createSpaceForMember,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true };
|
||||||
|
const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false };
|
||||||
|
|
||||||
|
type SpaceUpdate = { where: { id: string }; data: { workspacePath: string } };
|
||||||
|
|
||||||
|
/** The spaces the scan reads and links; new ones come from `createSpaceForMember`. */
|
||||||
|
function fakeDatabase({ spaceLimit = Number.POSITIVE_INFINITY } = {}) {
|
||||||
|
const spaces: Array<{ id: string; name: string; workspacePath: string | null }> = [];
|
||||||
|
createSpaceForMember.mockImplementation(async (_prisma: unknown, input: { name: string }) => {
|
||||||
|
if (spaces.length >= spaceLimit) throw new SpaceLimitError();
|
||||||
|
const space = { id: `space-${spaces.length + 1}`, name: input.name, workspacePath: null };
|
||||||
|
spaces.push(space);
|
||||||
|
return { id: space.id, name: space.name };
|
||||||
|
});
|
||||||
|
const prisma = {
|
||||||
|
space: {
|
||||||
|
findMany: async () => spaces.filter((space) => space.workspacePath),
|
||||||
|
update: async ({ where, data }: SpaceUpdate) => {
|
||||||
|
const space = spaces.find((candidate) => candidate.id === where.id);
|
||||||
|
if (space) space.workspacePath = data.workspacePath;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as db.PrismaClient;
|
||||||
|
return { prisma, spaces };
|
||||||
|
}
|
||||||
|
|
||||||
async function scanHomeWithOneFile(): Promise<string> {
|
async function scanHomeWithOneFile(): Promise<string> {
|
||||||
const home = await mkdtemp(join(tmpdir(), "evoo-scan-"));
|
const home = await mkdtemp(join(tmpdir(), "evoo-scan-"));
|
||||||
@ -14,6 +45,12 @@ async function scanHomeWithOneFile(): Promise<string> {
|
|||||||
return home;
|
return home;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function scanHomeWithRepos(...repos: string[]): Promise<string> {
|
||||||
|
const home = await scanHomeWithOneFile();
|
||||||
|
for (const repo of repos) await mkdir(join(home, repo, ".git"), { recursive: true });
|
||||||
|
return home;
|
||||||
|
}
|
||||||
|
|
||||||
async function completed(scanId: string, userId: string) {
|
async function completed(scanId: string, userId: string) {
|
||||||
return await vi.waitFor(
|
return await vi.waitFor(
|
||||||
async () => {
|
async () => {
|
||||||
@ -25,13 +62,21 @@ async function completed(scanId: string, userId: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The emulated runtime that the shared test setup pins keeps the scan simulated. */
|
||||||
|
function allowHostScan() {
|
||||||
|
vi.stubEnv("AGENT_RUNTIME", "");
|
||||||
|
vi.stubEnv("SANDBOX_PROVIDER", "");
|
||||||
|
}
|
||||||
|
|
||||||
describe("onboarding scan", () => {
|
describe("onboarding scan", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
|
createSpaceForMember.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides a scan from every account except the one that started it", async () => {
|
it("hides a scan from every account except the one that started it", async () => {
|
||||||
const { scanId } = await startOnboardingScan({ actor: member }, {});
|
const { prisma } = fakeDatabase();
|
||||||
|
const { scanId } = await startOnboardingScan({ actor: member }, prisma);
|
||||||
await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/);
|
await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/);
|
||||||
await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({
|
await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({
|
||||||
status: expect.any(String),
|
status: expect.any(String),
|
||||||
@ -40,25 +85,77 @@ describe("onboarding scan", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("never reads the host without a configured scan home", async () => {
|
it("never reads the host without a configured scan home", async () => {
|
||||||
const { scanId } = await startOnboardingScan({ actor: owner }, {});
|
const { prisma } = fakeDatabase();
|
||||||
|
const { scanId } = await startOnboardingScan({ actor: owner }, prisma);
|
||||||
const status = await completed(scanId, owner.userId);
|
const status = await completed(scanId, owner.userId);
|
||||||
expect(status.status).toBe("complete");
|
expect(status.status).toBe("complete");
|
||||||
expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } });
|
expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lets only the deployment owner discover the configured scan home", async () => {
|
it("lets only the deployment owner discover the configured scan home", async () => {
|
||||||
// The shared test setup pins the emulated runtime, which also keeps the scan simulated.
|
allowHostScan();
|
||||||
vi.stubEnv("AGENT_RUNTIME", "");
|
const scanHome = await scanHomeWithRepos("Projects/app");
|
||||||
vi.stubEnv("SANDBOX_PROVIDER", "");
|
const { prisma } = fakeDatabase();
|
||||||
const scanHome = await scanHomeWithOneFile();
|
|
||||||
|
|
||||||
const asMember = await startOnboardingScan({ actor: member, scanHome }, {});
|
const asMember = await startOnboardingScan({ actor: member, scanHome }, prisma);
|
||||||
const memberStatus = await completed(asMember.scanId, member.userId);
|
const memberStatus = await completed(asMember.scanId, member.userId);
|
||||||
expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } });
|
expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } });
|
||||||
|
expect(createSpaceForMember).not.toHaveBeenCalled();
|
||||||
|
|
||||||
const asOwner = await startOnboardingScan({ actor: owner, scanHome }, {});
|
const asOwner = await startOnboardingScan({ actor: owner, scanHome }, prisma);
|
||||||
const ownerStatus = await completed(asOwner.scanId, owner.userId);
|
const ownerStatus = await completed(asOwner.scanId, owner.userId);
|
||||||
expect(ownerStatus.status).toBe("complete");
|
expect(ownerStatus.status).toBe("complete");
|
||||||
expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } });
|
expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("links each repository of the deployment owner to one project of its own", async () => {
|
||||||
|
allowHostScan();
|
||||||
|
const scanHome = await scanHomeWithRepos("Projects/app", ".config/nvim");
|
||||||
|
const app = await realpath(join(scanHome, "Projects", "app"));
|
||||||
|
const { prisma, spaces } = fakeDatabase();
|
||||||
|
|
||||||
|
const first = await startOnboardingScan({ actor: owner, scanHome }, prisma);
|
||||||
|
const firstStatus = await completed(first.scanId, owner.userId);
|
||||||
|
expect(firstStatus.status).toBe("complete");
|
||||||
|
expect(firstStatus.result).toMatchObject({
|
||||||
|
localRepos: [{ name: "app" }],
|
||||||
|
createdProjects: [{ spaceId: "space-1", name: "app", path: app }],
|
||||||
|
});
|
||||||
|
expect(createSpaceForMember).toHaveBeenCalledWith(prisma, {
|
||||||
|
currentSpaceId: owner.spaceId,
|
||||||
|
userId: owner.userId,
|
||||||
|
name: "app",
|
||||||
|
});
|
||||||
|
expect(spaces).toEqual([{ id: "space-1", name: "app", workspacePath: app }]);
|
||||||
|
|
||||||
|
// Scanning again finds the linked project instead of creating a second one.
|
||||||
|
const again = await startOnboardingScan({ actor: owner, scanHome }, prisma);
|
||||||
|
const againStatus = await completed(again.scanId, owner.userId);
|
||||||
|
expect(againStatus.result).toMatchObject({ createdProjects: [] });
|
||||||
|
expect(createSpaceForMember).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins a running scan instead of linking its repositories twice", async () => {
|
||||||
|
allowHostScan();
|
||||||
|
const scanHome = await scanHomeWithRepos("Projects/app");
|
||||||
|
const { prisma, spaces } = fakeDatabase();
|
||||||
|
|
||||||
|
const first = await startOnboardingScan({ actor: owner, scanHome }, prisma);
|
||||||
|
const second = await startOnboardingScan({ actor: owner, scanHome }, prisma);
|
||||||
|
expect(second.scanId).toBe(first.scanId);
|
||||||
|
await completed(first.scanId, owner.userId);
|
||||||
|
expect(spaces).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops creating projects at the space limit and still completes", async () => {
|
||||||
|
allowHostScan();
|
||||||
|
const scanHome = await scanHomeWithRepos("Projects/a", "Projects/b", "Projects/c");
|
||||||
|
const { prisma } = fakeDatabase({ spaceLimit: 1 });
|
||||||
|
|
||||||
|
const { scanId } = await startOnboardingScan({ actor: owner, scanHome }, prisma);
|
||||||
|
const status = await completed(scanId, owner.userId);
|
||||||
|
expect(status.status).toBe("complete");
|
||||||
|
expect(status.result).toMatchObject({ createdProjects: [{ spaceId: "space-1" }] });
|
||||||
|
expect(createSpaceForMember).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,9 +1,16 @@
|
|||||||
// Onboarding scan — real filesystem scan, Git analysis, and profile compilation.
|
// Onboarding scan — real filesystem scan, Git analysis, and profile compilation.
|
||||||
// Runs synchronously on the host machine (no sandbox needed).
|
// Runs synchronously on the host machine (no sandbox needed).
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { access, readdir, stat } from "node:fs/promises";
|
import { access, readdir, realpath, stat } from "node:fs/promises";
|
||||||
import { dirname, join } from "node:path";
|
import { basename, dirname, join } from "node:path";
|
||||||
import type { Actor } from "@rakazo/contracts";
|
import type { Actor } from "@rakazo/contracts";
|
||||||
|
import type { PrismaClient } from "@rakazo/db";
|
||||||
|
import {
|
||||||
|
createSpaceForMember,
|
||||||
|
InvalidSpaceNameError,
|
||||||
|
SpaceLimitError,
|
||||||
|
wireProjectWorkspace,
|
||||||
|
} from "@rakazo/db";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@ -54,10 +61,9 @@ interface ProfileSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CreatedProject {
|
interface CreatedProject {
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
description: string;
|
name: string;
|
||||||
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScanRecord {
|
interface ScanRecord {
|
||||||
@ -115,8 +121,10 @@ async function runSimulatedScan(record: ScanRecord) {
|
|||||||
record.completedAt = new Date();
|
record.completedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ScanActor = Pick<Actor, "userId" | "spaceId" | "isDeploymentOwner">;
|
||||||
|
|
||||||
export interface OnboardingScanRequest {
|
export interface OnboardingScanRequest {
|
||||||
actor: Pick<Actor, "userId" | "isDeploymentOwner">;
|
actor: ScanActor;
|
||||||
botId?: string;
|
botId?: string;
|
||||||
/** Home-style directory to discover. Unset keeps the scan off the host filesystem. */
|
/** Home-style directory to discover. Unset keeps the scan off the host filesystem. */
|
||||||
scanHome?: string;
|
scanHome?: string;
|
||||||
@ -124,8 +132,12 @@ export interface OnboardingScanRequest {
|
|||||||
|
|
||||||
export async function startOnboardingScan(
|
export async function startOnboardingScan(
|
||||||
{ actor, botId, scanHome }: OnboardingScanRequest,
|
{ actor, botId, scanHome }: OnboardingScanRequest,
|
||||||
prisma: any,
|
prisma: PrismaClient,
|
||||||
): Promise<{ scanId: string }> {
|
): Promise<{ scanId: string }> {
|
||||||
|
// A repeated start (a remount, a retry) joins the running scan, so no repository is linked twice.
|
||||||
|
for (const running of scanRegistry.values()) {
|
||||||
|
if (running.ownerUserId === actor.userId && !running.completedAt) return { scanId: running.id };
|
||||||
|
}
|
||||||
const scanId = `scan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
const scanId = `scan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
const record: ScanRecord = {
|
const record: ScanRecord = {
|
||||||
id: scanId,
|
id: scanId,
|
||||||
@ -154,7 +166,7 @@ export async function startOnboardingScan(
|
|||||||
await runSimulatedScan(record);
|
await runSimulatedScan(record);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await executeFullScan(scanHome, botId ?? "", prisma);
|
const result = await executeFullScan(scanHome, actor, prisma);
|
||||||
record.status = "complete";
|
record.status = "complete";
|
||||||
record.progress = 100;
|
record.progress = 100;
|
||||||
record.message = "Scan complete — profile ready";
|
record.message = "Scan complete — profile ready";
|
||||||
@ -189,7 +201,11 @@ export async function getScanStatus(
|
|||||||
// Full scan pipeline
|
// Full scan pipeline
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function executeFullScan(homeDir: string, _botId: string, prisma: any): Promise<ScanResult> {
|
async function executeFullScan(
|
||||||
|
homeDir: string,
|
||||||
|
actor: ScanActor,
|
||||||
|
prisma: PrismaClient,
|
||||||
|
): Promise<ScanResult> {
|
||||||
// Phase 1: Local file scan
|
// Phase 1: Local file scan
|
||||||
const [fileSummary, projectFolders] = await scanFiles(homeDir);
|
const [fileSummary, projectFolders] = await scanFiles(homeDir);
|
||||||
|
|
||||||
@ -200,8 +216,8 @@ async function executeFullScan(homeDir: string, _botId: string, prisma: any): Pr
|
|||||||
// Phase 4: Profile compilation from findings
|
// Phase 4: Profile compilation from findings
|
||||||
const profileSummary = compileProfile(localRepos, fileSummary, projectFolders);
|
const profileSummary = compileProfile(localRepos, fileSummary, projectFolders);
|
||||||
|
|
||||||
// Phase 6: Project folder creation
|
// Phase 6: Projects for discovered repositories
|
||||||
const createdProjects = await createProjectFolders(localRepos, prisma);
|
const createdProjects = await createProjects(localRepos, actor, prisma);
|
||||||
|
|
||||||
// Phase 7: Sub-agent role creation
|
// Phase 7: Sub-agent role creation
|
||||||
const subAgentRoles = generateSubAgentRoles(profileSummary, localRepos);
|
const subAgentRoles = generateSubAgentRoles(profileSummary, localRepos);
|
||||||
@ -413,9 +429,9 @@ async function findGitDirectories(dir: string, depth: number = 0): Promise<strin
|
|||||||
if (entry.name === ".git") {
|
if (entry.name === ".git") {
|
||||||
results.push(dirname(fullPath));
|
results.push(dirname(fullPath));
|
||||||
} else if (
|
} else if (
|
||||||
|
// Dot folders hold tools and caches (shell and editor plugins), not the person's projects.
|
||||||
|
!entry.name.startsWith(".") &&
|
||||||
entry.name !== "node_modules" &&
|
entry.name !== "node_modules" &&
|
||||||
entry.name !== ".git" &&
|
|
||||||
entry.name !== ".venv" &&
|
|
||||||
entry.name !== "dist"
|
entry.name !== "dist"
|
||||||
) {
|
) {
|
||||||
results.push(...(await findGitDirectories(fullPath, depth + 1)));
|
results.push(...(await findGitDirectories(fullPath, depth + 1)));
|
||||||
@ -622,38 +638,43 @@ function generateBriefing(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Phase 5: Project folder creation
|
// Phase 5: Projects for discovered repositories
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function createProjectFolders(repos: RepoInfo[], prisma: any): Promise<CreatedProject[]> {
|
/** Give each discovered repository a project linked to its folder, unless one already links it. */
|
||||||
|
async function createProjects(
|
||||||
|
repos: RepoInfo[],
|
||||||
|
actor: ScanActor,
|
||||||
|
prisma: PrismaClient,
|
||||||
|
): Promise<CreatedProject[]> {
|
||||||
|
const linked = await prisma.space.findMany({
|
||||||
|
where: { workspacePath: { not: null }, memberships: { some: { userId: actor.userId } } },
|
||||||
|
select: { workspacePath: true },
|
||||||
|
});
|
||||||
|
const linkedFolders = new Set(linked.map((space) => space.workspacePath));
|
||||||
const created: CreatedProject[] = [];
|
const created: CreatedProject[] = [];
|
||||||
|
|
||||||
for (const repo of repos) {
|
for (const repo of repos) {
|
||||||
|
// Compare and link the real folder, so the next scan recognizes the projects of this one.
|
||||||
|
const folder = await realpath(repo.path).catch(() => null);
|
||||||
|
if (!folder || linkedFolders.has(folder)) continue;
|
||||||
|
let space: { id: string; name: string };
|
||||||
try {
|
try {
|
||||||
// Check if space already exists
|
space = await createSpaceForMember(prisma, {
|
||||||
const existing = await prisma.space.findFirst({ where: { title: repo.name } });
|
currentSpaceId: actor.spaceId,
|
||||||
if (existing) {
|
userId: actor.userId,
|
||||||
created.push({
|
name: basename(folder),
|
||||||
id: existing.id,
|
|
||||||
name: repo.name,
|
|
||||||
spaceId: existing.id,
|
|
||||||
description: repo.remote || repo.path,
|
|
||||||
});
|
});
|
||||||
continue;
|
} catch (error) {
|
||||||
|
if (error instanceof SpaceLimitError) break;
|
||||||
|
if (error instanceof InvalidSpaceNameError) continue;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
// Create new space
|
const workspace = await wireProjectWorkspace(prisma, {
|
||||||
const space = await prisma.space.create({
|
|
||||||
data: { title: repo.name, path: repo.path },
|
|
||||||
});
|
|
||||||
created.push({
|
|
||||||
id: space.id,
|
|
||||||
name: repo.name,
|
|
||||||
spaceId: space.id,
|
spaceId: space.id,
|
||||||
description: repo.remote || repo.path,
|
workspacePath: folder,
|
||||||
});
|
});
|
||||||
} catch {
|
created.push({ spaceId: space.id, name: space.name, path: workspace.path });
|
||||||
// Space might already exist or DB unavailable — skip
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return created;
|
return created;
|
||||||
|
|||||||
@ -1,19 +1,10 @@
|
|||||||
|
// Server-only. Kept out of the package index so browser bundles never load `node:child_process`.
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
import { access } from "node:fs/promises";
|
import { access } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { gitStatusArgv, gitUntrackedFilesArgv, parseGitNameOnly } from "./compose-update.js";
|
import { gitStatusArgv, gitUntrackedFilesArgv, parseGitNameOnly } from "./compose-update.js";
|
||||||
import { DEFAULT_UPDATE_REMOTE, parseGitStatusPorcelain } from "./self-update.js";
|
import { DEFAULT_UPDATE_REMOTE, parseGitStatusPorcelain } from "./self-update.js";
|
||||||
|
|
||||||
// Lazy import to avoid bundling `node:child_process` in browser bundles.
|
|
||||||
// `defaultGitRunner` is server-only; lazy-loading ensures tree-shaking works.
|
|
||||||
type SpawnFn = typeof import("node:child_process").spawn;
|
|
||||||
let _spawn: SpawnFn | undefined;
|
|
||||||
const getSpawn = (): SpawnFn => {
|
|
||||||
if (_spawn === undefined) {
|
|
||||||
_spawn = require("node:child_process").spawn as SpawnFn;
|
|
||||||
}
|
|
||||||
return _spawn;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface GitCheckoutStatus {
|
export interface GitCheckoutStatus {
|
||||||
present: boolean;
|
present: boolean;
|
||||||
remoteUrl: string | null;
|
remoteUrl: string | null;
|
||||||
@ -32,7 +23,6 @@ export type GitRunner = (args: string[], cwd: string) => Promise<GitRunnerResult
|
|||||||
|
|
||||||
export async function defaultGitRunner(args: string[], cwd: string): Promise<GitRunnerResult> {
|
export async function defaultGitRunner(args: string[], cwd: string): Promise<GitRunnerResult> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const spawn = getSpawn();
|
|
||||||
const child = spawn("git", args, { cwd, env: process.env });
|
const child = spawn("git", args, { cwd, env: process.env });
|
||||||
let output = "";
|
let output = "";
|
||||||
child.stdout.on("data", (chunk: Buffer | string) => {
|
child.stdout.on("data", (chunk: Buffer | string) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user