diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 554a3daa..3aeea8b4 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -7,7 +7,7 @@ import assert from "node:assert/strict"; import { loadConfig } from "./config.js"; import { GitWorktreeError } from "./git-worktrees.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; -import { WorkspaceRegistry } from "./workspaces.js"; +import { ensureCheckoutWorkspaceRoot, WorkspaceRegistry } from "./workspaces.js"; const execFileAsync = promisify(execFile); const root = await mkdtemp(join(tmpdir(), "devspace-workspace-test-")); @@ -47,6 +47,21 @@ try { assert.equal(missingWorkspace.workspace.mode, "checkout"); assert.equal((await stat(missingWorkspaceRoot)).isDirectory(), true); + { + let mkdirCalls = 0; + const existingStats = await ensureCheckoutWorkspaceRoot(root, { + stat: async (path) => { + assert.equal(path, root); + return await stat(path); + }, + mkdir: async () => { + mkdirCalls += 1; + }, + }); + assert.equal(existingStats.isDirectory(), true); + assert.equal(mkdirCalls, 0); + } + await assert.rejects( () => registry.openWorkspace({ path: root, mode: "worktree" }), (error: unknown) => diff --git a/src/workspaces.ts b/src/workspaces.ts index 3b7b51e9..c7d3d5e9 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import type { Stats } from "node:fs"; import type { WorkspaceMode, WorkspaceStore } from "./workspace-store.js"; import { mkdir, opendir, stat } from "node:fs/promises"; import { dirname, join, relative, resolve, sep } from "node:path"; @@ -61,6 +62,12 @@ export interface OpenWorkspaceInput { baseRef?: string; } +type PathStats = Stats; +type DirectoryOps = { + stat: (path: string) => Promise; + mkdir: (path: string, options: { recursive: true }) => Promise; +}; + export class WorkspaceRegistry { private readonly workspaces = new Map(); @@ -162,9 +169,7 @@ export class WorkspaceRegistry { private async openCheckoutWorkspace(path: string): Promise { const root = assertAllowedPath(path, this.config.allowedRoots); - await mkdir(root, { recursive: true }); - - const rootStats = await stat(root); + const rootStats = await ensureCheckoutWorkspaceRoot(root); if (!rootStats.isDirectory()) { throw new Error(`Workspace root must be a directory: ${path}`); } @@ -273,6 +278,22 @@ export class WorkspaceRegistry { } } +export async function ensureCheckoutWorkspaceRoot( + path: string, + ops: DirectoryOps = { stat, mkdir }, +): Promise { + try { + return await ops.stat(path); + } catch (error) { + if (!isErrnoException(error) || error.code !== "ENOENT") { + throw error; + } + } + + await ops.mkdir(path, { recursive: true }); + return await ops.stat(path); +} + const CONTEXT_FILE_NAMES = new Set(["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]); const SKIPPED_CONTEXT_DIRS = new Set([ ".git", @@ -326,3 +347,7 @@ async function walkWorkspace( await visit(path, entry); } } + +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +}