Skip to content

Bidirectional autosync for local-mount#49

Merged
willwashburn merged 5 commits intomainfrom
auto-sync-mount
Apr 19, 2026
Merged

Bidirectional autosync for local-mount#49
willwashburn merged 5 commits intomainfrom
auto-sync-mount

Conversation

@willwashburn
Copy link
Copy Markdown
Member

@willwashburn willwashburn commented Apr 19, 2026

Summary

  • Adds @relayfile/local-mount auto-sync: chokidar watchers on both the mount and project trees plus a periodic full-scan safety net, so edits propagate continuously during a CLI run instead of only at exit.
  • New startAutoSync() on the mount handle and an autoSync option on launchOnMount (enabled by default; pass false to opt out or an options object to tune scanIntervalMs / writeFinishMs).
  • Conflict rule is "mount wins": both sides changed since last sync → mount→project; one side deleted with the other unchanged → propagate the delete; readonly paths only flow project→mount.

Test plan

  • npx tsc --noEmit in packages/local-mount
  • npx vitest run in packages/local-mount (21/21 pass, including 10 new autosync tests)
  • Smoke-test: run an agent CLI under launchOnMount and verify edits from the agent show up in the project dir while the CLI is still running
  • Smoke-test: edit a file in the project dir during a run and verify the change appears in the mount

🤖 Generated with Claude Code


Open in Devin Review

Introduce a new autosync subsystem to keep mount↔project files in sync. Adds packages/local-mount/src/auto-sync.ts implementing a chokidar-based watcher + periodic reconcile with mount-wins resolution, readonly/ignored/excluded handling, safe write targets, and lifecycle APIs (start/stop/reconcile/ready/totalChanges). Exposes AutoSync types from the package index and wires a startAutoSync() method into the symlink mount handle. Integrates auto-sync into the launch flow via an autoSync option that runs during the spawned CLI and aggregates auto-sync changes into final sync counts. Adds comprehensive tests (auto-sync.test.ts) and records the chokidar dependency in package.json.
Update packages/local-mount/README.md to document the new auto-sync functionality and API surface. Adds startAutoSync to SymlinkMountHandle and describes AutoSyncOptions and AutoSyncHandle. Updates launchOnMount behavior to start/stop bidirectional auto-sync (or disable it), documents onAfterSync semantics, outlines how auto-sync works (chokidar + periodic reconcile, mtime tracking), lists conflict/delete rules and readonly/ignored path behavior, and clarifies that syncBack is a one-shot mount-only pass that writes files (never deletes).
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds bidirectional, in-process autosync to @relayfile/local-mount so changes propagate continuously during a CLI run rather than only syncing back on exit.

Changes:

  • Introduces a new startAutoSync() API on SymlinkMountHandle implemented via chokidar watchers plus a periodic full reconcile.
  • Extends launchOnMount with an autoSync option (default enabled) to run autosync for the lifetime of the spawned CLI.
  • Adds Vitest coverage for autosync behavior and adds chokidar as a dependency.

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
packages/local-mount/src/symlink-mount.ts Plumbs autosync context + exposes startAutoSync() on the mount handle.
packages/local-mount/src/launch.ts Starts/stops autosync around the spawned CLI; aggregates sync counts.
packages/local-mount/src/index.ts Re-exports autosync public types.
packages/local-mount/src/auto-sync.ts New autosync implementation (watchers + reconcile + conflict rules).
packages/local-mount/src/auto-sync.test.ts New tests for bidirectional syncing, readonly/ignored behavior, and reserved files.
packages/local-mount/package.json Adds chokidar dependency.
package-lock.json Locks chokidar and readdirp versions.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +68 to +75
let autoSyncChanges = 0;
if (autoSync) {
await autoSync.stop();
autoSyncChanges = autoSync.totalChanges();
autoSync = undefined;
}
const finalSynced = await handle.syncBack();
syncedCount = autoSyncChanges + finalSynced;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the doc comment in 96e690c to reflect that syncedFileCount is the total across both directions (autosync activity + final syncBack) rather than a strict mount→project count. The broader meaning is more useful as an "anything changed?" signal for callers — kept the aggregation but realigned the contract.

Comment on lines +154 to +170
function primeState(state: Map<string, FileState>, ctx: AutoSyncContext): void {
// Record current mtimes for every file that exists in both trees. Files that
// differ, or exist on only one side, are left out so the first reconcile
// treats them as changed and syncs them.
walk(ctx.realMountDir, ctx, (abs) => {
const rel = toRelPosix(abs, ctx);
if (rel === null) return;
if (!isSyncCandidate(rel, ctx)) return;
const mountStat = safeFileStat(abs);
if (!mountStat) return;
const projectAbs = path.join(ctx.realProjectDir, rel);
const projectStat = safeFileStat(projectAbs);
if (!projectStat) return;
state.set(rel, {
mountMtimeMs: mountStat.mtimeMs,
projectMtimeMs: projectStat.mtimeMs,
});
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 96e690c by adding a sameContent check before seeding state. Files that differ at startup are now left out, so the first reconcile sees no prev entry and goes through the content-based resolution path.

Comment thread packages/local-mount/src/auto-sync.ts Outdated
Comment on lines +182 to +183
const changed = syncOneFile(relPosix, state, ctx);
if (changed) count += 1;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 96e690c. visit() now wraps syncOneFile in try/catch and forwards errors to onError, so one bad path no longer aborts the whole reconcile pass. reconcile() takes onError as a parameter.

Comment on lines +319 to +326
const target = resolveSafeWriteTarget(ctx.realProjectDir, projectAbs);
if (!target) return false;
if (existsSync(target) && sameContent(mountAbs, target)) {
updateState(state, relPosix, mountAbs, target);
return false;
}
copyFileSync(mountAbs, target);
updateState(state, relPosix, mountAbs, target);
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 96e690c. Added an isSymlinkTarget check in both doMountToProject and doProjectToMount — if the target already exists as a symlink, we refuse to write through it. Matches syncBacks behavior and blocks writes from escaping the root.

Comment thread packages/local-mount/src/auto-sync.ts Outdated
Comment on lines +272 to +275
? prev?.mountMtimeMs === undefined || mountStat.mtimeMs > prev.mountMtimeMs
: false;
const projectChanged = projectStat
? prev?.projectMtimeMs === undefined || projectStat.mtimeMs > prev.projectMtimeMs
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 96e690c — switched both mountChanged and projectChanged to !==. Coarse-resolution or backdated writes no longer get silently skipped during periodic reconciles.

Copy link
Copy Markdown

@noodleonthecape noodleonthecape left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice change. I pulled this locally, ran the @relayfile/local-mount typecheck and test suite, and spot-checked the auto-sync logic plus coverage. The mount/project conflict handling, readonly behavior, reserved-file exclusions, and launch lifecycle all look solid to me.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

- primeState now skips files whose content differs at startup, so the
  first reconcile actually resolves initial drift (previously seeded
  matching mtimes and then suppressed the sync)
- reconcile() isolates per-path errors so one bad path no longer aborts
  the whole pass; errors are surfaced via onError
- doMountToProject / doProjectToMount refuse to write through a target
  that already exists as a symlink, matching syncBack's behavior and
  preventing writes from escaping the root
- mtime change detection uses !== instead of > so coarse-resolution or
  backdated writes aren't silently missed during periodic reconciles
- Clarified onAfterSync doc: the count is now total changes in both
  directions (autosync + final syncBack), not a strict mount→project tally

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

AutoSyncContext.isIgnored's lambda in symlink-mount always OR'd the
file-form and directory-form match, so a regular file whose path
matched a directory-only pattern (e.g. `cache/`) was incorrectly
ignored by autosync. The initial mount walk already distinguished
the two via `entry.isDirectory()`, so the bug only affected the
autosync path.

- AutoSyncContext.isIgnored now accepts an optional `isDirectory`
  flag; symlink-mount constructs it as a thin pass-through to
  `isPathMatched`
- walk() in auto-sync.ts passes `entry.isDirectory()` to isIgnored
- isSyncCandidate() keeps the default (file), matching its callers
- shouldChokidarIgnore() uses chokidar's optional `stats` second arg
  when available so dir-form matches are only applied to actual
  directories. Without stats (pre-stat prune call) we fall back to
  file-form to avoid false-positive pruning; chokidar calls the
  filter a second time with stats so pruning still happens at the
  directory level
- Regression test covers `cache/` pattern with a file at `docs/cache`

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@willwashburn willwashburn merged commit 5389315 into main Apr 19, 2026
6 checks passed
@willwashburn willwashburn deleted the auto-sync-mount branch April 19, 2026 01:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants