diff --git a/src/always-on/workspace/SnapshotCopyProvider.ts b/src/always-on/workspace/SnapshotCopyProvider.ts index 9910447e..62aee884 100644 --- a/src/always-on/workspace/SnapshotCopyProvider.ts +++ b/src/always-on/workspace/SnapshotCopyProvider.ts @@ -1,7 +1,7 @@ import { existsSync } from "node:fs"; -import { cp, mkdir, rm, stat } from "node:fs/promises"; +import { cp, lstat, mkdir, opendir, rm, stat } from "node:fs/promises"; import { platform } from "node:os"; -import { resolve } from "node:path"; +import { isAbsolute, relative, resolve } from "node:path"; import { spawn } from "node:child_process"; import { AlwaysOnError } from "../protocol/errors.js"; import type { WorkspaceHandle } from "../protocol/types.js"; @@ -116,44 +116,56 @@ async function tryReflinkCopy(source: string, target: string): Promise function isIgnored(filePath: string, root: string, ignores: Set): boolean { if (filePath === root) return false; - const rel = filePath.startsWith(root) ? filePath.slice(root.length).replace(/^[/\\]+/, "") : filePath; + const rel = relative(root, filePath); + if (!rel || rel === "." || rel.startsWith("..") || isAbsolute(rel)) return false; if (rel.length === 0) return false; - const head = rel.split(/[/\\]/)[0]; - if (ignores.has(head)) return true; - return false; + return rel.split(/[/\\]/).some((segment) => ignores.has(segment)); } async function pruneIgnored(target: string, ignores: Set): Promise { - for (const entry of ignores) { - await rm(resolve(target, entry), { recursive: true, force: true }).catch(() => undefined); + let dir; + try { + dir = await opendir(target); + } catch { + return; + } + + for await (const entry of dir) { + const entryPath = resolve(target, entry.name); + if (entry.isDirectory() && ignores.has(entry.name)) { + await rm(entryPath, { recursive: true, force: true }).catch(() => undefined); + continue; + } + if (entry.isDirectory()) { + await pruneIgnored(entryPath, ignores); + } } } async function estimateSize(root: string, ignores: Set): Promise { - // Quick best-effort estimate; if the OS command fails fall back to 0 - // (caller still copies but skips the cap). - if (platform() === "win32") { - return estimateSizeWindows(root, ignores); + try { + return await estimateSizeWalk(root, root, ignores); + } catch { + return 0; } - return runCommand("du", ["-sk", root]) - .then((result) => { - if (result.exitCode !== 0) return 0; - const tokens = result.stdout.trim().split(/\s+/); - const kb = Number.parseInt(tokens[0], 10); - return Number.isFinite(kb) ? kb * 1024 : 0; - }) - .catch(() => 0); } -async function estimateSizeWindows(root: string, _ignores: Set): Promise { - const script = `(Get-ChildItem -Path '${root.replace(/'/g, "''")}' -Recurse -File -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum`; - return runCommand("powershell", ["-NoProfile", "-Command", script]) - .then((result) => { - if (result.exitCode !== 0) return 0; - const bytes = Number.parseInt(result.stdout.trim(), 10); - return Number.isFinite(bytes) ? bytes : 0; - }) - .catch(() => 0); +async function estimateSizeWalk(root: string, filePath: string, ignores: Set): Promise { + if (isIgnored(filePath, root, ignores)) { + return 0; + } + + const info = await lstat(filePath); + if (!info.isDirectory()) { + return info.size; + } + + let total = 0; + const dir = await opendir(filePath); + for await (const entry of dir) { + total += await estimateSizeWalk(root, resolve(filePath, entry.name), ignores); + } + return total; } type CommandResult = { exitCode: number; stdout: string; stderr: string }; diff --git a/tests/always-on/workspace/SnapshotCopyProvider.test.ts b/tests/always-on/workspace/SnapshotCopyProvider.test.ts new file mode 100644 index 00000000..24f7a32c --- /dev/null +++ b/tests/always-on/workspace/SnapshotCopyProvider.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { SnapshotCopyProvider } from "../../../src/always-on/workspace/SnapshotCopyProvider.js"; + +test("snapshot-copy size cap and pruning ignore nested ignored directories", async () => { + const root = await mkdtemp(join(tmpdir(), "pilotdeck-snapshot-copy-")); + const project = join(root, "project"); + const snapshots = join(root, "snapshots"); + await mkdir(join(project, "packages", "app", "src"), { recursive: true }); + await mkdir(join(project, "packages", "app", "node_modules", "large-lib"), { recursive: true }); + await mkdir(join(project, "packages", "app", "dist"), { recursive: true }); + await writeFile(join(project, "packages", "app", "src", "index.ts"), "export {};\n"); + await writeFile(join(project, "packages", "app", "node_modules", "large-lib", "bundle.js"), Buffer.alloc(4096)); + await writeFile(join(project, "packages", "app", "dist", "bundle.js"), Buffer.alloc(4096)); + + try { + const provider = new SnapshotCopyProvider({ + baseDir: snapshots, + maxBytes: 1024, + }); + + const handle = await provider.prepare({ + projectRoot: project, + runId: "run-1", + }); + + assert.equal(handle.cwd, join(snapshots, "run-1")); + assert.equal(Number(handle.metadata.baseSize) < 1024, true); + await access(join(handle.cwd, "packages", "app", "src", "index.ts")); + await assert.rejects(access(join(handle.cwd, "packages", "app", "node_modules"))); + await assert.rejects(access(join(handle.cwd, "packages", "app", "dist"))); + } finally { + await rm(root, { recursive: true, force: true }); + } +});