Skip to content
Merged
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
4 changes: 3 additions & 1 deletion packages/local-mount/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

_No unreleased changes._
### Changed
- Initial mount and auto-sync file copies now request filesystem reflinks when available, while preserving byte-copy fallback behavior on filesystems without copy-on-write support.
- `createMount` now reports `initialFileCount` and `initialMountDurationMs` on the returned handle for caller-side mount setup telemetry.

## [0.7.23] - 2026-05-19

Expand Down
3 changes: 3 additions & 0 deletions packages/local-mount/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,16 @@ const handle = await createMount(projectDir, mountDir, options);

interface MountHandle {
mountDir: string;
initialFileCount?: number;
initialMountDurationMs?: number;
syncBack(opts?: { signal?: AbortSignal; paths?: Iterable<string> }): Promise<number>;
startAutoSync(opts?: AutoSyncOptions): AutoSyncHandle;
cleanup(): void;
}
```

`createMount` returns `Promise<MountHandle>`. The walker yields the event loop between directory entries so consumer-side timers (e.g. an `ora` spinner driven by `setInterval`) keep firing while the mount is being built.
The returned handle includes initial mount timing and copied-file count metadata so callers can report setup performance.

Behavior:
- Copies regular files into the mount, requesting a filesystem reflink clone when the source and mount are on a compatible same-volume filesystem and falling back to a byte copy otherwise
Expand Down
5 changes: 3 additions & 2 deletions packages/local-mount/src/auto-sync.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
chmodSync,
constants as fsConstants,
copyFileSync,
existsSync,
lstatSync,
Expand Down Expand Up @@ -641,7 +642,7 @@ function doMountToProject(
updateState(state, relPosix, mountAbs, target);
return false;
}
copyFileSync(mountAbs, target);
copyFileSync(mountAbs, target, fsConstants.COPYFILE_FICLONE);
updateState(state, relPosix, mountAbs, target);
return true;
}
Expand All @@ -666,7 +667,7 @@ function doProjectToMount(
if (existsSync(target)) {
try { chmodSync(target, 0o644); } catch { /* best effort */ }
}
copyFileSync(projectAbs, target);
copyFileSync(projectAbs, target, fsConstants.COPYFILE_FICLONE);
if (readonly) {
try { chmodSync(target, 0o444); } catch { /* best effort */ }
} else {
Expand Down
48 changes: 48 additions & 0 deletions packages/local-mount/src/mount-reflink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ function write(file: string, body: string): void {
writeFileSync(file, body, 'utf8');
}

async function waitFor(check: () => boolean, timeoutMs = 5000): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (check()) return;
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error(`waitFor timed out after ${timeoutMs}ms`);
}

describe('createMount reflink copies', () => {
let projectDir: string;
let mountDir: string;
Expand Down Expand Up @@ -64,10 +73,49 @@ describe('createMount reflink copies', () => {
fsConstants.COPYFILE_FICLONE
);
expect(existsSync(path.join(handle.mountDir, 'src/code.ts'))).toBe(true);
expect(handle.initialFileCount).toBe(1);
expect(handle.initialMountDurationMs).toBeGreaterThanOrEqual(0);

writeFileSync(path.join(handle.mountDir, 'src/code.ts'), 'mount-only edit', 'utf8');
expect(readFileSync(path.join(projectDir, 'src/code.ts'), 'utf8')).toBe('original');

handle.cleanup();
});

it('requests non-forcing filesystem reflinks for auto-sync copies', async () => {
write(path.join(projectDir, 'file.txt'), 'original');

const handle = await createMount(projectDir, mountDir, {
ignoredPatterns: [],
readonlyPatterns: [],
excludeDirs: [],
});
const auto = handle.startAutoSync({ debounceMs: 50, scanIntervalMs: 10_000 });
await auto.ready();
copyFileSyncMock.mockClear();

try {
writeFileSync(path.join(handle.mountDir, 'file.txt'), 'edited-in-mount', 'utf8');
await waitFor(() => readFileSync(path.join(projectDir, 'file.txt'), 'utf8') === 'edited-in-mount');
expect(copyFileSyncMock).toHaveBeenCalledWith(
expect.stringMatching(/file\.txt$/),
expect.stringMatching(/file\.txt$/),
fsConstants.COPYFILE_FICLONE
);

copyFileSyncMock.mockClear();
writeFileSync(path.join(projectDir, 'file.txt'), 'edited-in-project', 'utf8');
await waitFor(() =>
readFileSync(path.join(handle.mountDir, 'file.txt'), 'utf8') === 'edited-in-project'
);
expect(copyFileSyncMock).toHaveBeenCalledWith(
expect.stringMatching(/file\.txt$/),
expect.stringMatching(/file\.txt$/),
fsConstants.COPYFILE_FICLONE
);
} finally {
await auto.stop();
handle.cleanup();
}
});
});
44 changes: 29 additions & 15 deletions packages/local-mount/src/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export interface MountOptions {

export interface MountHandle {
mountDir: string;
initialFileCount?: number;
initialMountDurationMs?: number;
syncBack(opts?: { signal?: AbortSignal; paths?: Iterable<string> }): Promise<number>;
/**
* Start bidirectional auto-sync: watches both the mount and project trees
Expand Down Expand Up @@ -140,7 +142,8 @@ export async function createMount(
const realMountDir = realpathSync(resolvedMountDir);
writeFileSync(path.join(realMountDir, MOUNT_MARKER_FILENAME), MOUNT_MARKER_CONTENT, 'utf8');

await walkProjectTree(
const initialMountStartedAt = Date.now();
const initialFileCount = await walkProjectTree(
resolvedProjectDir,
resolvedProjectDir,
realMountDir,
Expand All @@ -149,6 +152,7 @@ export async function createMount(
readonlyMatcher,
ignoredMatcher
);
const initialMountDurationMs = Date.now() - initialMountStartedAt;

const readmePath = resolveSafeCopyTarget(realMountDir, path.join(realMountDir, MOUNT_README_FILENAME));
if (!readmePath) {
Expand Down Expand Up @@ -176,6 +180,8 @@ export async function createMount(

return {
mountDir: resolvedMountDir,
initialFileCount,
initialMountDurationMs,
async syncBack(opts?: { signal?: AbortSignal; paths?: Iterable<string> }): Promise<number> {
let synced = 0;
const realProjectDir = realpathSync(resolvedProjectDir);
Expand Down Expand Up @@ -279,11 +285,12 @@ async function walkProjectTree(
excludeRules: ExcludeRules,
readonlyMatcher: Ignore,
ignoredMatcher: Ignore
): Promise<void> {
): Promise<number> {
await yieldToEventLoop();
const entries = readdirSync(currentDir, { withFileTypes: true });

let processed = 0;
let copiedFiles = 0;
for (const entry of entries) {
if (processed > 0 && processed % WALK_YIELD_EVERY === 0) {
await yieldToEventLoop();
Expand Down Expand Up @@ -316,7 +323,7 @@ async function walkProjectTree(
if (!safeMountDir) {
continue;
}
await walkProjectTree(
copiedFiles += await walkProjectTree(
projectDir,
absolutePath,
mountDir,
Expand All @@ -329,30 +336,36 @@ async function walkProjectTree(
}

if (entry.isSymbolicLink()) {
copySymlinkedFile(
if (copySymlinkedFile(
projectDir,
mountDir,
absolutePath,
mountPath,
relativePath,
readonlyMatcher
);
)) {
copiedFiles += 1;
}
continue;
}

if (!entry.isFile()) {
continue;
}

copyMountedFile(
if (copyMountedFile(
projectDir,
mountDir,
absolutePath,
mountPath,
relativePath,
readonlyMatcher
);
)) {
copiedFiles += 1;
}
}

return copiedFiles;
}

function yieldToEventLoop(): Promise<void> {
Expand All @@ -366,21 +379,21 @@ function copySymlinkedFile(
mountPath: string,
relativePath: string,
readonlyMatcher: Ignore
): void {
): boolean {
let realSource: string;
let resolvedStat: Stats;
try {
realSource = realpathSync(sourcePath);
resolvedStat = statSync(sourcePath);
} catch {
return;
return false;
}

if (!isPathWithinRoot(realSource, projectDir) || !resolvedStat.isFile()) {
return;
return false;
}

copyMountedFile(
return copyMountedFile(
projectDir,
mountDir,
realSource,
Expand All @@ -399,26 +412,27 @@ function copyMountedFile(
relativePath: string,
readonlyMatcher: Ignore,
sourceMode?: number
): void {
): boolean {
const safeMountPath = resolveSafeCopyTarget(mountDir, mountPath);
if (!safeMountPath) {
return;
return false;
}

const safeSourcePath = resolveVerifiedFilePath(sourceRoot, sourcePath);
if (!safeSourcePath) {
return;
return false;
}

copyFileSync(safeSourcePath, safeMountPath, fsConstants.COPYFILE_FICLONE);

if (isPathMatched(relativePath, readonlyMatcher)) {
chmodSync(safeMountPath, 0o444);
return;
return true;
}

const mode = sourceMode ?? statSync(safeSourcePath).mode;
chmodSync(safeMountPath, mode & 0o777);
return true;
}

function ensureDirectory(pathValue: string): void {
Expand Down
Loading