@relayfile/local-mount 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,12 +12,12 @@ npm install @relayfile/local-mount
12
12
 
13
13
  ## What it exports
14
14
 
15
- ### `createSymlinkMount(projectDir, mountDir, options)`
15
+ ### `createMount(projectDir, mountDir, options)`
16
16
 
17
17
  Builds a mounted copy of `projectDir` at `mountDir` and returns a handle:
18
18
 
19
19
  ```ts
20
- interface SymlinkMountHandle {
20
+ interface MountHandle {
21
21
  mountDir: string;
22
22
  syncBack(opts?: { signal?: AbortSignal }): Promise<number>;
23
23
  startAutoSync(opts?: AutoSyncOptions): AutoSyncHandle;
@@ -29,7 +29,7 @@ Behavior:
29
29
  - Copies regular files into the mount
30
30
  - Applies ignore rules from `ignoredPatterns`
31
31
  - Marks read-only matches as mode `0o444`
32
- - Excludes `.git` and `node_modules` by default
32
+ - Excludes `.git` and `node_modules` by default. Pass `includeGit: true` to opt the project's `.git` directory back in (see [Including `.git`](#including-git))
33
33
  - Writes `_MOUNT_README.md` and `.relayfile-local-mount` into the mount
34
34
  - Skips syncing `_MOUNT_README.md`, `.relayfile-local-mount`, ignored files, read-only files, and symlinks back to the source project
35
35
 
@@ -110,6 +110,28 @@ Conflict and delete rules:
110
110
  - readonly paths never flow mount→project; project-side edits still flow into the mount (the mount copy is re-chmodded `0o444`)
111
111
  - `_MOUNT_README.md`, `.relayfile-local-mount`, ignored paths, and excluded directories never cross
112
112
 
113
+ ## Including `.git`
114
+
115
+ By default, the project's `.git` directory is excluded from the mount, which means git commands inside the mount fail with `fatal: not a git repository`. Pass `includeGit: true` (on `createMount` or `launchOnMount`) to copy `.git` into the mount with **one-way project→mount sync**:
116
+
117
+ - `.git` is copied on mount creation, so `git status`, `git log`, `git diff`, `git commit`, etc. all work inside the mount.
118
+ - Project-side changes under `.git/**` flow into the mount (e.g. if a teammate's tooling moves `HEAD` while the agent is running).
119
+ - Mount-side changes under `.git/**` are **not** synced back to the project. Branches, commits, or refs the agent creates in the mount stay sandboxed and are discarded on cleanup.
120
+
121
+ If the agent needs its commits to survive, push to a remote from inside the mount. Source files outside `.git` continue to follow the normal bidirectional sync rules.
122
+
123
+ ```ts
124
+ launchOnMount({
125
+ cli: 'claude',
126
+ args: ['--print', 'Inspect the diff and propose a fix.'],
127
+ projectDir,
128
+ mountDir,
129
+ includeGit: true,
130
+ });
131
+ ```
132
+
133
+ Note that `.git` can be sizable (hundreds of MB on long-lived repos); the initial mount creation copies the whole tree.
134
+
113
135
  ## Dotfile semantics
114
136
 
115
137
  `@relayfile/local-mount` uses glob-style patterns, powered by [`ignore`](https://www.npmjs.com/package/ignore).
@@ -213,6 +235,18 @@ The implementation is intentionally conservative about `mountDir`:
213
235
 
214
236
  These checks help prevent accidental deletion of unrelated directories during mount recreation and cleanup.
215
237
 
238
+ ## Why copy instead of symlink?
239
+
240
+ The mount is built by copying files rather than symlinking them. Symlinks would break several of the package's guarantees:
241
+
242
+ 1. **`.agentreadonly` can't be enforced.** Read-only is implemented by `chmod 0o444` on the mount copy. `chmod` follows symlinks, so applying it to a symlink would mark the *source* file read-only, flipping the project's permissions instead of restricting the agent's view.
243
+ 2. **The auto-sync conflict model assumes two distinct files.** Rules like "both sides changed → mount wins", "one side deleted → propagate", and "readonly paths never flow mount→project but project-side edits still flow into the mount" only make sense if mount and source are separate bytes. Through a symlink they're the same inode — there's no mount-side copy to re-chmod `0o444` after a project-side edit.
244
+ 3. **Editor save-via-rename breaks symlinks anyway.** Most editors save by writing a temp file and renaming it over the target, which replaces the symlink with a regular file and severs the link. A "live view" via symlinks isn't reliable in practice.
245
+ 4. **Containment.** A copy gives you a checkpoint: if the agent destroys files or writes garbage, the source is untouched until `syncBack()` filters and copies back. With symlinks, every keystroke is live on the project.
246
+ 5. **`.agentignore` hiding works fine with symlinks** (just don't link), but the readonly and conflict semantics still need copies — so a hybrid would be more complex than just copying everything.
247
+
248
+ Source-side symlinks that resolve to regular files inside the project *are* followed when building the mount; the resolved bytes are copied. Symlinks the agent creates inside the mount are skipped on sync-back.
249
+
216
250
  ## Notes
217
251
 
218
252
  - Requires Node.js 18+
@@ -10,6 +10,13 @@ export interface AutoSyncContext {
10
10
  */
11
11
  isIgnored: (relPosix: string, isDirectory?: boolean) => boolean;
12
12
  isReadonly: (relPosix: string) => boolean;
13
+ /**
14
+ * One-way project→mount paths. Project-side changes flow into the mount,
15
+ * but mount-side changes never flow back. Unlike readonly, the mount copy
16
+ * is left writable so tools (e.g. git) can mutate it locally; those
17
+ * mutations are simply discarded on cleanup.
18
+ */
19
+ isNoSyncBack: (relPosix: string) => boolean;
13
20
  isReservedFile: (relPosix: string) => boolean;
14
21
  }
15
22
  export interface AutoSyncOptions {
package/dist/auto-sync.js CHANGED
@@ -262,11 +262,16 @@ function reconcile(state, ctx, onError, signal) {
262
262
  * Resolution rules ("mount wins"):
263
263
  * - If both sides changed since last sync → mount→project.
264
264
  * - Only mount changed → mount→project (unless mount-side change is disallowed
265
- * for readonly files; then drop the mount change).
265
+ * for readonly / noSyncBack files; then drop the mount change).
266
266
  * - Only project changed → project→mount.
267
267
  * - One side missing:
268
268
  * • Other side changed since last sync → recreate the missing side.
269
269
  * • Otherwise → propagate the delete.
270
+ *
271
+ * `readonly` and `noSyncBack` both forbid mount→project. The split exists so
272
+ * the chmod 0o444 only fires for true readonly entries (e.g. `.agentreadonly`
273
+ * matches), while noSyncBack entries (e.g. `.git/**` when `includeGit: true`)
274
+ * stay writable in the mount so tools can mutate them locally.
270
275
  */
271
276
  function syncOneFile(relPosix, state, ctx) {
272
277
  const mountAbs = path.join(ctx.realMountDir, relPosix);
@@ -275,6 +280,7 @@ function syncOneFile(relPosix, state, ctx) {
275
280
  const projectStat = safeFileStat(projectAbs);
276
281
  const prev = state.get(relPosix);
277
282
  const readonly = ctx.isReadonly(relPosix);
283
+ const noSyncBack = readonly || ctx.isNoSyncBack(relPosix);
278
284
  if (!mountStat && !projectStat) {
279
285
  state.delete(relPosix);
280
286
  return false;
@@ -290,15 +296,15 @@ function syncOneFile(relPosix, state, ctx) {
290
296
  return false;
291
297
  }
292
298
  // Differ with no history: arbitrary tiebreak → mount wins.
293
- if (readonly) {
294
- // Readonly can't accept mount-side writes; fall back to project→mount.
299
+ if (noSyncBack) {
300
+ // Mount-side writes never flow back; fall back to project→mount.
295
301
  return doProjectToMount(relPosix, state, ctx, projectAbs, mountAbs, readonly);
296
302
  }
297
303
  return doMountToProject(relPosix, state, ctx, mountAbs, projectAbs);
298
304
  }
299
305
  if (mountStat && !projectStat) {
300
- if (readonly) {
301
- // New file in mount with a readonly pattern → cannot sync back.
306
+ if (noSyncBack) {
307
+ // New file in mount with a no-sync-back pattern → cannot sync back.
302
308
  return false;
303
309
  }
304
310
  return doMountToProject(relPosix, state, ctx, mountAbs, projectAbs);
@@ -319,7 +325,7 @@ function syncOneFile(relPosix, state, ctx) {
319
325
  if (mountStat && projectStat) {
320
326
  if (!mountChanged && !projectChanged)
321
327
  return false;
322
- if (mountChanged && !readonly) {
328
+ if (mountChanged && !noSyncBack) {
323
329
  return doMountToProject(relPosix, state, ctx, mountAbs, projectAbs);
324
330
  }
325
331
  if (projectChanged) {
@@ -328,7 +334,7 @@ function syncOneFile(relPosix, state, ctx) {
328
334
  return false;
329
335
  }
330
336
  if (mountStat && !projectStat) {
331
- if (mountChanged && !readonly) {
337
+ if (mountChanged && !noSyncBack) {
332
338
  return doMountToProject(relPosix, state, ctx, mountAbs, projectAbs);
333
339
  }
334
340
  // Project deleted externally and mount hasn't been touched since → mirror.
@@ -339,8 +345,8 @@ function syncOneFile(relPosix, state, ctx) {
339
345
  return doProjectToMount(relPosix, state, ctx, projectAbs, mountAbs, readonly);
340
346
  }
341
347
  // Mount deleted and project hasn't been touched since → mirror to project.
342
- if (readonly) {
343
- // Readonly deletes in mount don't sync back; recreate mount from project.
348
+ if (noSyncBack) {
349
+ // No-sync-back deletes in mount don't propagate; recreate from project.
344
350
  return doProjectToMount(relPosix, state, ctx, projectAbs, mountAbs, readonly);
345
351
  }
346
352
  return doDeleteProject(relPosix, state, projectAbs);
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { createSymlinkMount, type SymlinkMountOptions, type SymlinkMountHandle, } from './symlink-mount.js';
1
+ export { createMount, type MountOptions, type MountHandle, } from './mount.js';
2
2
  export { type AutoSyncOptions, type AutoSyncHandle, } from './auto-sync.js';
3
3
  export { readAgentDotfiles, type ReadAgentDotfilesOptions, type AgentDotfilePatterns, } from './dotfiles.js';
4
4
  export { launchOnMount, type LaunchOnMountOptions, type LaunchOnMountResult, } from './launch.js';
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- export { createSymlinkMount, } from './symlink-mount.js';
1
+ export { createMount, } from './mount.js';
2
2
  export { readAgentDotfiles, } from './dotfiles.js';
3
3
  export { launchOnMount, } from './launch.js';
package/dist/launch.d.ts CHANGED
@@ -14,6 +14,12 @@ export interface LaunchOnMountOptions {
14
14
  readonlyPatterns?: string[];
15
15
  /** Extra directory names to exclude from the mount on top of defaults. */
16
16
  excludeDirs?: string[];
17
+ /**
18
+ * Include the project's `.git` directory inside the mount with one-way
19
+ * project→mount sync. Defaults to false. See {@link MountOptions.includeGit}
20
+ * for details.
21
+ */
22
+ includeGit?: boolean;
17
23
  /** Extra env vars merged on top of `process.env`. */
18
24
  env?: NodeJS.ProcessEnv;
19
25
  /** Optional agent name, used in the _MOUNT_README.md "Agent:" line. */
package/dist/launch.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { createSymlinkMount } from './symlink-mount.js';
2
+ import { createMount } from './mount.js';
3
3
  /**
4
4
  * Create a mount of `projectDir` at `mountDir`, spawn `cli` with `args` using
5
5
  * the mount as its cwd, forward SIGINT/SIGTERM to the child, sync writable
@@ -7,11 +7,12 @@ import { createSymlinkMount } from './symlink-mount.js';
7
7
  * exit code.
8
8
  */
9
9
  export async function launchOnMount(opts) {
10
- const handle = createSymlinkMount(opts.projectDir, opts.mountDir, {
10
+ const handle = createMount(opts.projectDir, opts.mountDir, {
11
11
  ignoredPatterns: opts.ignoredPatterns ?? [],
12
12
  readonlyPatterns: opts.readonlyPatterns ?? [],
13
13
  excludeDirs: opts.excludeDirs ?? [],
14
14
  agentName: opts.agentName,
15
+ includeGit: opts.includeGit,
15
16
  });
16
17
  let syncedCount = 0;
17
18
  let finalized = false;
@@ -0,0 +1,38 @@
1
+ import { type AutoSyncHandle, type AutoSyncOptions } from './auto-sync.js';
2
+ export interface MountOptions {
3
+ ignoredPatterns: string[];
4
+ readonlyPatterns: string[];
5
+ excludeDirs: string[];
6
+ /**
7
+ * Optional agent name used in the _MOUNT_README.md "Agent:" line.
8
+ * If omitted, the doc uses a generic "agent" value.
9
+ */
10
+ agentName?: string;
11
+ /**
12
+ * Include the project's `.git` directory inside the mount with one-way
13
+ * project→mount sync. Default: false (`.git` is excluded entirely, matching
14
+ * historical behavior).
15
+ *
16
+ * When true:
17
+ * - `.git` is copied into the mount on creation, so git commands work inside.
18
+ * - Project-side changes under `.git/**` flow into the mount.
19
+ * - Mount-side changes under `.git/**` do NOT flow back to the project, so
20
+ * commits/branches the agent creates inside the mount stay sandboxed and
21
+ * are discarded with the mount on cleanup. Push to a remote to keep them.
22
+ */
23
+ includeGit?: boolean;
24
+ }
25
+ export interface MountHandle {
26
+ mountDir: string;
27
+ syncBack(opts?: {
28
+ signal?: AbortSignal;
29
+ }): Promise<number>;
30
+ /**
31
+ * Start bidirectional auto-sync: watches both the mount and project trees
32
+ * via @parcel/watcher and runs a full reconcile every `scanIntervalMs`
33
+ * as a safety net. Returns a handle you must `stop()` before teardown.
34
+ */
35
+ startAutoSync(opts?: AutoSyncOptions): AutoSyncHandle;
36
+ cleanup(): void;
37
+ }
38
+ export declare function createMount(projectDir: string, mountDir: string, options: MountOptions): MountHandle;
@@ -6,16 +6,26 @@ const DEFAULT_EXCLUDED_DIRS = ['.git', 'node_modules'];
6
6
  const MOUNT_README_FILENAME = '_MOUNT_README.md';
7
7
  const MOUNT_MARKER_FILENAME = '.relayfile-local-mount';
8
8
  const MOUNT_MARKER_CONTENT = 'This directory is managed by @relayfile/local-mount. Do not place unrelated files here; the directory will be deleted when the mount is torn down.\n';
9
- export function createSymlinkMount(projectDir, mountDir, options) {
9
+ export function createMount(projectDir, mountDir, options) {
10
10
  const resolvedProjectDir = realpathSync(projectDir);
11
11
  const resolvedMountDir = path.resolve(mountDir);
12
12
  const readonlyPatterns = [...options.readonlyPatterns];
13
13
  const ignoredPatterns = [...options.ignoredPatterns];
14
+ const includeGit = options.includeGit === true;
14
15
  const readonlyMatcher = createPathMatcher(readonlyPatterns);
15
16
  const ignoredMatcher = createPathMatcher(ignoredPatterns);
16
- const excludeSet = new Set([...DEFAULT_EXCLUDED_DIRS, ...options.excludeDirs]
17
+ // `.git` is in DEFAULT_EXCLUDED_DIRS so the mount stays small and git
18
+ // operations don't accidentally cross-mutate the host repo. When the caller
19
+ // opts in via `includeGit`, drop it from the defaults and instead route it
20
+ // through the noSyncBack matcher below so it stays one-way.
21
+ const defaultExcludes = includeGit
22
+ ? DEFAULT_EXCLUDED_DIRS.filter((d) => d !== '.git')
23
+ : DEFAULT_EXCLUDED_DIRS;
24
+ const excludeSet = new Set([...defaultExcludes, ...options.excludeDirs]
17
25
  .map((entry) => normalizeRelativePosix(entry).replace(/^\/+|\/+$/g, ''))
18
26
  .filter(Boolean));
27
+ const noSyncBackPatterns = includeGit ? ['.git', '.git/**'] : [];
28
+ const noSyncBackMatcher = createPathMatcher(noSyncBackPatterns);
19
29
  // Guard against mountDir === projectDir. We compare both the realpath'd
20
30
  // project dir and the plain resolved project dir so callers that pass the
21
31
  // same argument for both are caught even when the path is a symlink (e.g.
@@ -42,6 +52,7 @@ export function createSymlinkMount(projectDir, mountDir, options) {
42
52
  isExcluded: (relPosix) => isExcludedPath(relPosix, excludeSet),
43
53
  isIgnored: (relPosix, isDir) => isPathMatched(relPosix, ignoredMatcher, isDir),
44
54
  isReadonly: (relPosix) => isPathMatched(relPosix, readonlyMatcher),
55
+ isNoSyncBack: (relPosix) => isPathMatched(relPosix, noSyncBackMatcher),
45
56
  isReservedFile: (relPosix) => relPosix === MOUNT_README_FILENAME || relPosix === MOUNT_MARKER_FILENAME,
46
57
  };
47
58
  return {
@@ -56,7 +67,7 @@ export function createSymlinkMount(projectDir, mountDir, options) {
56
67
  if (signal?.aborted) {
57
68
  break;
58
69
  }
59
- const syncedForFile = syncMountedFileBack(sourceFile, realMountDir, realProjectDir, readonlyMatcher, ignoredMatcher);
70
+ const syncedForFile = syncMountedFileBack(sourceFile, realMountDir, realProjectDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher);
60
71
  synced += syncedForFile;
61
72
  if (signal && syncedForFile > 0 && !signal.aborted) {
62
73
  await new Promise((resolve) => setImmediate(resolve));
@@ -107,7 +118,7 @@ function assertMountDirSafeToRemove(mountDir, projectDir) {
107
118
  const markerPath = path.join(resolved, MOUNT_MARKER_FILENAME);
108
119
  if (!existsSync(markerPath)) {
109
120
  throw new Error(`Refusing to remove ${resolved}: missing ${MOUNT_MARKER_FILENAME} marker. ` +
110
- `Only directories previously created by createSymlinkMount can be reused as mountDir.`);
121
+ `Only directories previously created by createMount can be reused as mountDir.`);
111
122
  }
112
123
  }
113
124
  function walkProjectTree(projectDir, currentDir, mountDir, excludeSet, readonlyMatcher, ignoredMatcher) {
@@ -242,8 +253,8 @@ function hasSameContent(left, right) {
242
253
  return false;
243
254
  }
244
255
  }
245
- function syncMountedFileBack(sourceFile, mountDir, projectDir, readonlyMatcher, ignoredMatcher) {
246
- const relative = resolveSyncRelativePath(sourceFile, mountDir, readonlyMatcher, ignoredMatcher);
256
+ function syncMountedFileBack(sourceFile, mountDir, projectDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher) {
257
+ const relative = resolveSyncRelativePath(sourceFile, mountDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher);
247
258
  if (!relative)
248
259
  return 0;
249
260
  const safeTargetPath = resolveVerifiedSyncTarget(projectDir, relative);
@@ -255,7 +266,7 @@ function syncMountedFileBack(sourceFile, mountDir, projectDir, readonlyMatcher,
255
266
  copyFileSync(sourceFile, safeTargetPath);
256
267
  return 1;
257
268
  }
258
- function resolveSyncRelativePath(sourceFile, mountDir, readonlyMatcher, ignoredMatcher) {
269
+ function resolveSyncRelativePath(sourceFile, mountDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher) {
259
270
  const relative = path.relative(mountDir, sourceFile);
260
271
  if (relative === '' || relative.startsWith('..'))
261
272
  return null;
@@ -264,7 +275,9 @@ function resolveSyncRelativePath(sourceFile, mountDir, readonlyMatcher, ignoredM
264
275
  return null;
265
276
  if (relativePosix === MOUNT_MARKER_FILENAME)
266
277
  return null;
267
- if (isPathMatched(relative, readonlyMatcher) || isPathMatched(relative, ignoredMatcher))
278
+ if (isPathMatched(relative, readonlyMatcher) ||
279
+ isPathMatched(relative, ignoredMatcher) ||
280
+ isPathMatched(relative, noSyncBackMatcher))
268
281
  return null;
269
282
  try {
270
283
  if (lstatSync(sourceFile).isSymbolicLink())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relayfile/local-mount",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "description": "Create a symlink/copy mount of a project directory with .agentignore/.agentreadonly semantics, then launch a CLI inside it and sync writable changes back on exit",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1,25 +0,0 @@
1
- import { type AutoSyncHandle, type AutoSyncOptions } from './auto-sync.js';
2
- export interface SymlinkMountOptions {
3
- ignoredPatterns: string[];
4
- readonlyPatterns: string[];
5
- excludeDirs: string[];
6
- /**
7
- * Optional agent name used in the _MOUNT_README.md "Agent:" line.
8
- * If omitted, the doc uses a generic "agent" value.
9
- */
10
- agentName?: string;
11
- }
12
- export interface SymlinkMountHandle {
13
- mountDir: string;
14
- syncBack(opts?: {
15
- signal?: AbortSignal;
16
- }): Promise<number>;
17
- /**
18
- * Start bidirectional auto-sync: watches both the mount and project trees
19
- * via @parcel/watcher and runs a full reconcile every `scanIntervalMs`
20
- * as a safety net. Returns a handle you must `stop()` before teardown.
21
- */
22
- startAutoSync(opts?: AutoSyncOptions): AutoSyncHandle;
23
- cleanup(): void;
24
- }
25
- export declare function createSymlinkMount(projectDir: string, mountDir: string, options: SymlinkMountOptions): SymlinkMountHandle;