Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 57 additions & 35 deletions src/always-on/workspace/SnapshotCopyProvider.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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, {
Expand All @@ -115,45 +117,65 @@ async function tryReflinkCopy(source: string, target: string): Promise<boolean>
}

function isIgnored(filePath: string, root: string, ignores: Set<string>): 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<string>): Promise<void> {
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<string>): Promise<boolean> {
try {
await pruneIgnored(target, ignores);
return true;
} catch {
await rm(target, { recursive: true, force: true });
return false;
}
}

async function estimateSize(root: string, ignores: Set<string>): Promise<number> {
// 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<string>): Promise<number> {
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<string>): Promise<number> {
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 };
Expand Down
47 changes: 47 additions & 0 deletions tests/always-on/workspace/SnapshotCopyProvider.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});