From 685e1a9a9a5fcda7beaca50c337155639a6f769f Mon Sep 17 00:00:00 2001 From: Haoqian Li Date: Sat, 20 Jun 2026 18:21:11 +0800 Subject: [PATCH] fix snapshot copy nested ignores --- .../workspace/SnapshotCopyProvider.ts | 92 ++++++++++++------- .../workspace/SnapshotCopyProvider.test.ts | 47 ++++++++++ 2 files changed, 104 insertions(+), 35 deletions(-) create mode 100644 tests/always-on/workspace/SnapshotCopyProvider.test.ts diff --git a/src/always-on/workspace/SnapshotCopyProvider.ts b/src/always-on/workspace/SnapshotCopyProvider.ts index 9910447e..61cdf732 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, readdir, 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"; @@ -82,14 +82,16 @@ export class SnapshotCopyProvider implements WorkspaceProvider { if (platform() === "darwin") { const ok = await tryClonefile(source, target); if (ok) { - await pruneIgnored(target, ignores).catch(() => undefined); - return "clonefile"; + if (await tryPruneSnapshot(target, ignores)) { + return "clonefile"; + } } } else if (platform() === "linux") { const ok = await tryReflinkCopy(source, target); if (ok) { - await pruneIgnored(target, ignores).catch(() => undefined); - return "reflink"; + if (await tryPruneSnapshot(target, ignores)) { + return "reflink"; + } } } await cp(source, target, { @@ -115,45 +117,65 @@ 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; - if (rel.length === 0) return false; - const head = rel.split(/[/\\]/)[0]; - if (ignores.has(head)) return true; - return false; + return relativePathSegments(filePath, root).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); + const entries = await readdir(target, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = resolve(target, entry.name); + if (ignores.has(entry.name)) { + await rm(entryPath, { recursive: true, force: true }); + continue; + } + if (entry.isDirectory()) { + await pruneIgnored(entryPath, ignores); + } + } +} + +async function tryPruneSnapshot(target: string, ignores: Set): Promise { + try { + await pruneIgnored(target, ignores); + return true; + } catch { + await rm(target, { recursive: true, force: true }); + return false; } } 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); + return estimateSizeWalk(root, root, ignores).catch(() => 0); +} + +async function estimateSizeWalk(filePath: string, root: string, ignores: Set): Promise { + if (isIgnored(filePath, root, ignores)) { + 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); + + const info = await lstat(filePath).catch(() => undefined); + if (!info) { + return 0; + } + if (!info.isDirectory()) { + return info.size; + } + + const entries = await readdir(filePath, { withFileTypes: true }).catch(() => []); + let total = info.size; + for (const entry of entries) { + total += await estimateSizeWalk(resolve(filePath, entry.name), root, ignores); + } + return total; } -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); +function relativePathSegments(filePath: string, root: string): string[] { + const rel = relative(root, filePath); + if (!rel || isAbsolute(rel)) { + return []; + } + const segments = rel.split(/[/\\]/).filter(Boolean); + return segments[0] === ".." ? [] : segments; } 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..2535c324 --- /dev/null +++ b/tests/always-on/workspace/SnapshotCopyProvider.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { SnapshotCopyProvider } from "../../../src/always-on/workspace/SnapshotCopyProvider.js"; + +test("snapshot copy ignores nested dependency directories before size checks and after copy", async (t) => { + const fixtureRoot = await mkdtemp(join(tmpdir(), "pilotdeck-snapshot-copy-")); + t.after(() => rm(fixtureRoot, { recursive: true, force: true })); + + const projectRoot = join(fixtureRoot, "project"); + const baseDir = join(fixtureRoot, "snapshots"); + const sourceDir = join(projectRoot, "packages", "app", "src"); + const nestedNodeModules = join(projectRoot, "packages", "app", "node_modules"); + const nestedDist = join(projectRoot, "packages", "app", "dist"); + const nestedGitFile = join(projectRoot, "packages", "lib", ".git"); + + await mkdir(sourceDir, { recursive: true }); + await mkdir(nestedNodeModules, { recursive: true }); + await mkdir(nestedDist, { recursive: true }); + await mkdir(join(projectRoot, "packages", "lib"), { recursive: true }); + await writeFile(join(projectRoot, "README.md"), "small source file\n"); + await writeFile(join(sourceDir, "index.ts"), "export const ok = true;\n"); + await writeFile(join(nestedNodeModules, "large.bin"), Buffer.alloc(2 * 1024 * 1024)); + await writeFile(join(nestedDist, "bundle.js"), Buffer.alloc(512 * 1024)); + await writeFile(nestedGitFile, "gitdir: ../../.git/modules/lib\n"); + + const provider = new SnapshotCopyProvider({ + baseDir, + maxBytes: 64 * 1024, + }); + + const handle = await provider.prepare({ + projectRoot, + runId: "run-1", + }); + + assert.ok(existsSync(join(handle.cwd, "README.md"))); + assert.ok(existsSync(join(handle.cwd, "packages", "app", "src", "index.ts"))); + assert.equal(existsSync(join(handle.cwd, "packages", "app", "node_modules")), false); + assert.equal(existsSync(join(handle.cwd, "packages", "app", "dist")), false); + assert.equal(existsSync(join(handle.cwd, "packages", "lib", ".git")), false); + assert.ok(Number(handle.metadata.baseSize) < 64 * 1024); +});