@relayfile/local-mount 0.6.13 → 0.6.15

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
@@ -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. Pass `includeGit: true` to opt the project's `.git` directory back in (see [Including `.git`](#including-git))
32
+ - Excludes `.git`, `node_modules`, `.npm-cache`, and common build/cache output directories 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,47 @@ 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
+ ## Default Excludes
114
+
115
+ By default, mounts skip directories and files that are usually large generated output
116
+ or local caches. These names match at any path depth:
117
+
118
+ ```txt
119
+ .git
120
+ node_modules
121
+ .npm-cache
122
+ __pycache__
123
+ .pytest_cache
124
+ .mypy_cache
125
+ .ruff_cache
126
+ .gradle
127
+ .nyc_output
128
+ .turbo
129
+ .cache
130
+ .DS_Store
131
+ ```
132
+
133
+ These more generic names match only at the project root so source paths such as
134
+ `src/build/` or `packages/env/` are still mounted:
135
+
136
+ ```txt
137
+ target
138
+ .next
139
+ dist
140
+ build
141
+ out
142
+ .venv
143
+ venv
144
+ env
145
+ coverage
146
+ ```
147
+
148
+ Pass `includeDefaultExcludeDirs: false` to opt out of the broad build/cache list.
149
+ For safety, `.git` stays excluded unless you also pass `includeGit: true`.
150
+ `excludeDirs` still appends to whichever default set is active; bare caller entries
151
+ retain the historical any-depth behavior, while path-style entries are root-relative
152
+ prefixes.
153
+
113
154
  ## Including `.git`
114
155
 
115
156
  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**:
@@ -192,7 +233,7 @@ const result = await launchOnMount({
192
233
  mountDir,
193
234
  ignoredPatterns,
194
235
  readonlyPatterns,
195
- excludeDirs: ['dist'],
236
+ excludeDirs: ['vendor-cache'],
196
237
  agentName: 'reviewer',
197
238
  onBeforeLaunch: async (dir) => {
198
239
  // Add extra instructions or scratch files inside the mount if needed.
@@ -2,6 +2,18 @@ export interface AutoSyncContext {
2
2
  realMountDir: string;
3
3
  realProjectDir: string;
4
4
  isExcluded: (relPosix: string) => boolean;
5
+ /**
6
+ * Normalized directory names that drive any-depth `isExcluded` matches.
7
+ * Used purely to hint `@parcel/watcher` which subtrees to skip subscribing
8
+ * to. The in-handler `isSyncCandidate` filter remains authoritative.
9
+ */
10
+ excludedAnyDepthNames: readonly string[];
11
+ /**
12
+ * Root-anchored excluded names/prefixes such as `build` or `packages/cache`.
13
+ * These are matched only from the watch root to avoid hiding legitimate
14
+ * nested source directories like `src/build`.
15
+ */
16
+ excludedRootPrefixes: readonly string[];
5
17
  /**
6
18
  * Directory-only ignore patterns (ending in `/`) must only match when the
7
19
  * path is a directory. Callers that know the path's type pass `isDirectory`;
package/dist/auto-sync.js CHANGED
@@ -80,7 +80,6 @@ export function startAutoSync(ctx, opts = {}) {
80
80
  }, debounceMs);
81
81
  pendingDebounces.set(absPath, t);
82
82
  };
83
- const ignoreGlobs = buildIgnoreGlobs(ctx);
84
83
  const subscribeTo = (root) => watcher.subscribe(root, (err, events) => {
85
84
  if (err) {
86
85
  onError(err);
@@ -89,7 +88,7 @@ export function startAutoSync(ctx, opts = {}) {
89
88
  for (const ev of events) {
90
89
  schedulePathSync(root, ev.path);
91
90
  }
92
- }, { ignore: ignoreGlobs });
91
+ }, { ignore: buildIgnoreGlobs(ctx, root) });
93
92
  let mountSub;
94
93
  let projectSub;
95
94
  // Subscribe in parallel but track each outcome independently. With
@@ -163,20 +162,29 @@ export function startAutoSync(ctx, opts = {}) {
163
162
  },
164
163
  };
165
164
  }
166
- function buildIgnoreGlobs(ctx) {
167
- // @parcel/watcher matches globs against absolute paths via globset. For each
168
- // excluded directory name, ignore both the directory itself and everything
169
- // beneath it, anywhere under the watched root. The `isExcluded` predicate is
170
- // driven by a Set of directory names, so we probe a small set of common
171
- // exclusions rather than introspecting it. The in-handler `isSyncCandidate`
172
- // filter is authoritative this is just a perf hint so the watcher doesn't
173
- // recurse into heavy trees like node_modules or .git.
165
+ function buildIgnoreGlobs(ctx, watchRoot) {
166
+ // @parcel/watcher's wrapper splits each ignore entry by is-glob: globs are
167
+ // compiled by picomatch and matched as regexes against absolute event paths;
168
+ // non-globs are resolved as literal absolute paths. For each excluded entry
169
+ // (library defaults + user-supplied excludeDirs) we emit shapes that mirror
170
+ // `isExcludedPath`'s semantics, so a watcher-suppressed event never differs
171
+ // from what the in-handler filter would have rejected.
172
+ //
173
+ // - Any-depth names (e.g. `node_modules`) emit `**/<name>` plus
174
+ // `**/<name>/**`. picomatch turns both into depth-agnostic regexes
175
+ // that catch the dir and its descendants.
176
+ // - Root prefixes (e.g. `build` or `build/cache`) are root-anchored
177
+ // in `isExcludedPath` — they only match `<root>/build/cache`, NOT
178
+ // `<root>/src/build/cache`. Emit absolute patterns rooted at the
179
+ // watch dir so the watcher hides the same set: a literal absolute
180
+ // path (which the wrapper routes to ignorePaths) plus an anchored
181
+ // descendant glob.
174
182
  const globs = [];
175
- const candidates = ['.git', 'node_modules', 'dist', 'build', '.next', '.cache'];
176
- for (const name of candidates) {
177
- if (ctx.isExcluded(name)) {
178
- globs.push(`**/${name}`, `**/${name}/**`);
179
- }
183
+ for (const name of ctx.excludedAnyDepthNames) {
184
+ globs.push(`**/${name}`, `**/${name}/**`);
185
+ }
186
+ for (const prefix of ctx.excludedRootPrefixes) {
187
+ globs.push(`${watchRoot}/${prefix}`, `${watchRoot}/${prefix}/**`);
180
188
  }
181
189
  return globs;
182
190
  }
package/dist/launch.d.ts CHANGED
@@ -14,6 +14,11 @@ 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 built-in cache/build exclusion list. Defaults to true. `.git`
19
+ * remains excluded unless `includeGit` is true.
20
+ */
21
+ includeDefaultExcludeDirs?: boolean;
17
22
  /**
18
23
  * Include the project's `.git` directory inside the mount with one-way
19
24
  * project→mount sync. Defaults to false. See {@link MountOptions.includeGit}
package/dist/launch.js CHANGED
@@ -13,6 +13,7 @@ export async function launchOnMount(opts) {
13
13
  excludeDirs: opts.excludeDirs ?? [],
14
14
  agentName: opts.agentName,
15
15
  includeGit: opts.includeGit,
16
+ includeDefaultExcludeDirs: opts.includeDefaultExcludeDirs,
16
17
  });
17
18
  let syncedCount = 0;
18
19
  let finalized = false;
package/dist/mount.d.ts CHANGED
@@ -21,6 +21,12 @@ export interface MountOptions {
21
21
  * are discarded with the mount on cleanup. Push to a remote to keep them.
22
22
  */
23
23
  includeGit?: boolean;
24
+ /**
25
+ * Include the built-in list of large cache/build output directories in
26
+ * the mount exclusion set. Default: true. The `.git` directory remains
27
+ * excluded unless `includeGit` is true, even when this is false.
28
+ */
29
+ includeDefaultExcludeDirs?: boolean;
24
30
  }
25
31
  export interface MountHandle {
26
32
  mountDir: string;
package/dist/mount.js CHANGED
@@ -2,7 +2,31 @@ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync,
2
2
  import ignore from 'ignore';
3
3
  import path from 'node:path';
4
4
  import { startAutoSync, } from './auto-sync.js';
5
- const DEFAULT_EXCLUDED_DIRS = ['.git', 'node_modules'];
5
+ const DEFAULT_ANY_DEPTH_EXCLUDES = [
6
+ '.git',
7
+ 'node_modules',
8
+ '.npm-cache',
9
+ '__pycache__',
10
+ '.pytest_cache',
11
+ '.mypy_cache',
12
+ '.ruff_cache',
13
+ '.gradle',
14
+ '.nyc_output',
15
+ '.turbo',
16
+ '.cache',
17
+ '.DS_Store',
18
+ ];
19
+ const DEFAULT_ROOT_EXCLUDES = [
20
+ 'target',
21
+ '.next',
22
+ 'dist',
23
+ 'build',
24
+ 'out',
25
+ '.venv',
26
+ 'venv',
27
+ 'env',
28
+ 'coverage',
29
+ ];
6
30
  const MOUNT_README_FILENAME = '_MOUNT_README.md';
7
31
  const MOUNT_MARKER_FILENAME = '.relayfile-local-mount';
8
32
  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';
@@ -14,16 +38,12 @@ export function createMount(projectDir, mountDir, options) {
14
38
  const includeGit = options.includeGit === true;
15
39
  const readonlyMatcher = createPathMatcher(readonlyPatterns);
16
40
  const ignoredMatcher = createPathMatcher(ignoredPatterns);
17
- // `.git` is in DEFAULT_EXCLUDED_DIRS so the mount stays small and git
41
+ const includeDefaultExcludeDirs = options.includeDefaultExcludeDirs !== false;
42
+ // `.git` is in the default any-depth excludes so the mount stays small and git
18
43
  // operations don't accidentally cross-mutate the host repo. When the caller
19
44
  // opts in via `includeGit`, drop it from the defaults and instead route it
20
45
  // 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]
25
- .map((entry) => normalizeRelativePosix(entry).replace(/^\/+|\/+$/g, ''))
26
- .filter(Boolean));
46
+ const excludeRules = createExcludeRules(options.excludeDirs, includeGit, includeDefaultExcludeDirs);
27
47
  const noSyncBackPatterns = includeGit ? ['.git', '.git/**'] : [];
28
48
  const noSyncBackMatcher = createPathMatcher(noSyncBackPatterns);
29
49
  // Guard against mountDir === projectDir. We compare both the realpath'd
@@ -40,7 +60,7 @@ export function createMount(projectDir, mountDir, options) {
40
60
  mkdirSync(resolvedMountDir, { recursive: true });
41
61
  const realMountDir = realpathSync(resolvedMountDir);
42
62
  writeFileSync(path.join(realMountDir, MOUNT_MARKER_FILENAME), MOUNT_MARKER_CONTENT, 'utf8');
43
- walkProjectTree(resolvedProjectDir, resolvedProjectDir, realMountDir, excludeSet, readonlyMatcher, ignoredMatcher);
63
+ walkProjectTree(resolvedProjectDir, resolvedProjectDir, realMountDir, realMountDir, excludeRules, readonlyMatcher, ignoredMatcher);
44
64
  const readmePath = resolveSafeCopyTarget(realMountDir, path.join(realMountDir, MOUNT_README_FILENAME));
45
65
  if (!readmePath) {
46
66
  throw new Error('Failed to create mount readme inside mountDir');
@@ -49,7 +69,9 @@ export function createMount(projectDir, mountDir, options) {
49
69
  const autoSyncContext = {
50
70
  realMountDir,
51
71
  realProjectDir: resolvedProjectDir,
52
- isExcluded: (relPosix) => isExcludedPath(relPosix, excludeSet),
72
+ isExcluded: (relPosix) => isExcludedPath(relPosix, excludeRules),
73
+ excludedAnyDepthNames: [...excludeRules.anyDepthNames],
74
+ excludedRootPrefixes: [...excludeRules.rootPrefixes],
53
75
  isIgnored: (relPosix, isDir) => isPathMatched(relPosix, ignoredMatcher, isDir),
54
76
  isReadonly: (relPosix) => isPathMatched(relPosix, readonlyMatcher),
55
77
  isNoSyncBack: (relPosix) => isPathMatched(relPosix, noSyncBackMatcher),
@@ -67,7 +89,7 @@ export function createMount(projectDir, mountDir, options) {
67
89
  if (signal?.aborted) {
68
90
  break;
69
91
  }
70
- const syncedForFile = syncMountedFileBack(sourceFile, realMountDir, realProjectDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher);
92
+ const syncedForFile = syncMountedFileBack(sourceFile, realMountDir, realProjectDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher, (relPosix) => isExcludedPath(relPosix, excludeRules));
71
93
  synced += syncedForFile;
72
94
  if (signal && syncedForFile > 0 && !signal.aborted) {
73
95
  await new Promise((resolve) => setImmediate(resolve));
@@ -121,7 +143,7 @@ function assertMountDirSafeToRemove(mountDir, projectDir) {
121
143
  `Only directories previously created by createMount can be reused as mountDir.`);
122
144
  }
123
145
  }
124
- function walkProjectTree(projectDir, currentDir, mountDir, excludeSet, readonlyMatcher, ignoredMatcher) {
146
+ function walkProjectTree(projectDir, currentDir, mountDir, currentMountDir, excludeRules, readonlyMatcher, ignoredMatcher) {
125
147
  const entries = readdirSync(currentDir, { withFileTypes: true });
126
148
  for (const entry of entries) {
127
149
  const absolutePath = path.join(currentDir, entry.name);
@@ -132,18 +154,19 @@ function walkProjectTree(projectDir, currentDir, mountDir, excludeSet, readonlyM
132
154
  if (isPathWithinRoot(absolutePath, mountDir)) {
133
155
  continue;
134
156
  }
135
- if (isExcludedPath(relativePath, excludeSet)) {
157
+ if (isExcludedPath(relativePath, excludeRules)) {
136
158
  continue;
137
159
  }
138
160
  if (isPathMatched(relativePath, ignoredMatcher, entry.isDirectory())) {
139
161
  continue;
140
162
  }
141
- const mountPath = path.join(mountDir, relativePath);
163
+ const mountPath = path.join(currentMountDir, entry.name);
142
164
  if (entry.isDirectory()) {
143
- if (!ensureDirectoryWithinRoot(mountDir, mountPath)) {
165
+ const safeMountDir = ensureDirectoryWithinRoot(mountDir, mountPath);
166
+ if (!safeMountDir) {
144
167
  continue;
145
168
  }
146
- walkProjectTree(projectDir, absolutePath, mountDir, excludeSet, readonlyMatcher, ignoredMatcher);
169
+ walkProjectTree(projectDir, absolutePath, mountDir, safeMountDir, excludeRules, readonlyMatcher, ignoredMatcher);
147
170
  continue;
148
171
  }
149
172
  if (entry.isSymbolicLink()) {
@@ -193,15 +216,15 @@ function ensureDirectory(pathValue) {
193
216
  }
194
217
  function ensureDirectoryWithinRoot(rootPath, dirPath) {
195
218
  if (!isPathWithinRoot(dirPath, rootPath)) {
196
- return false;
219
+ return null;
197
220
  }
198
221
  try {
199
222
  ensureDirectory(dirPath);
200
223
  const realDir = realpathSync(dirPath);
201
- return isPathWithinRoot(realDir, rootPath);
224
+ return isPathWithinRoot(realDir, rootPath) ? realDir : null;
202
225
  }
203
226
  catch {
204
- return false;
227
+ return null;
205
228
  }
206
229
  }
207
230
  function listFiles(baseDir) {
@@ -230,6 +253,38 @@ function normalizeRelativePosix(filePath) {
230
253
  function createPathMatcher(patterns) {
231
254
  return ignore().add(patterns.map((pattern) => pattern.trim()).filter((pattern) => pattern !== '' && !pattern.startsWith('#')));
232
255
  }
256
+ function createExcludeRules(excludeDirs, includeGit, includeDefaultExcludeDirs) {
257
+ const anyDepthNames = new Set();
258
+ const rootPrefixes = new Set();
259
+ if (includeDefaultExcludeDirs) {
260
+ addExcludeEntries(anyDepthNames, rootPrefixes, DEFAULT_ANY_DEPTH_EXCLUDES, 'any-depth');
261
+ addExcludeEntries(anyDepthNames, rootPrefixes, DEFAULT_ROOT_EXCLUDES, 'root-prefix');
262
+ }
263
+ else if (!includeGit) {
264
+ addExcludeEntries(anyDepthNames, rootPrefixes, ['.git'], 'any-depth');
265
+ }
266
+ if (includeGit) {
267
+ anyDepthNames.delete('.git');
268
+ }
269
+ // Preserve caller-supplied excludeDirs semantics: bare names match at any
270
+ // depth, while path-style entries are root-anchored prefixes.
271
+ addExcludeEntries(anyDepthNames, rootPrefixes, excludeDirs, 'legacy');
272
+ return { anyDepthNames, rootPrefixes };
273
+ }
274
+ function addExcludeEntries(anyDepthNames, rootPrefixes, entries, mode) {
275
+ for (const entry of entries) {
276
+ const normalized = normalizeRelativePosix(entry).replace(/^\/+|\/+$/g, '');
277
+ if (!normalized) {
278
+ continue;
279
+ }
280
+ if (mode === 'root-prefix' || (mode === 'legacy' && normalized.includes('/'))) {
281
+ rootPrefixes.add(normalized);
282
+ }
283
+ else {
284
+ anyDepthNames.add(normalized);
285
+ }
286
+ }
287
+ }
233
288
  function isPathMatched(relPath, matcher, isDirectory = false) {
234
289
  const normalized = normalizeRelativePosix(relPath);
235
290
  return matcher.ignores(normalized) || (isDirectory && matcher.ignores(`${normalized}/`));
@@ -253,8 +308,8 @@ function hasSameContent(left, right) {
253
308
  return false;
254
309
  }
255
310
  }
256
- function syncMountedFileBack(sourceFile, mountDir, projectDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher) {
257
- const relative = resolveSyncRelativePath(sourceFile, mountDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher);
311
+ function syncMountedFileBack(sourceFile, mountDir, projectDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher, isExcluded) {
312
+ const relative = resolveSyncRelativePath(sourceFile, mountDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher, isExcluded);
258
313
  if (!relative)
259
314
  return 0;
260
315
  const safeTargetPath = resolveVerifiedSyncTarget(projectDir, relative);
@@ -266,7 +321,7 @@ function syncMountedFileBack(sourceFile, mountDir, projectDir, readonlyMatcher,
266
321
  copyFileSync(sourceFile, safeTargetPath);
267
322
  return 1;
268
323
  }
269
- function resolveSyncRelativePath(sourceFile, mountDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher) {
324
+ function resolveSyncRelativePath(sourceFile, mountDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher, isExcluded) {
270
325
  const relative = path.relative(mountDir, sourceFile);
271
326
  if (relative === '' || relative.startsWith('..'))
272
327
  return null;
@@ -275,7 +330,8 @@ function resolveSyncRelativePath(sourceFile, mountDir, readonlyMatcher, ignoredM
275
330
  return null;
276
331
  if (relativePosix === MOUNT_MARKER_FILENAME)
277
332
  return null;
278
- if (isPathMatched(relative, readonlyMatcher) ||
333
+ if (isExcluded(relativePosix) ||
334
+ isPathMatched(relative, readonlyMatcher) ||
279
335
  isPathMatched(relative, ignoredMatcher) ||
280
336
  isPathMatched(relative, noSyncBackMatcher))
281
337
  return null;
@@ -310,14 +366,14 @@ function resolveVerifiedSyncTarget(projectDir, relativePath) {
310
366
  return null;
311
367
  }
312
368
  }
313
- function isExcludedPath(relativePath, excludeSet) {
369
+ function isExcludedPath(relativePath, excludeRules) {
314
370
  const normalized = normalizeRelativePosix(relativePath).replace(/^\/+|\/+$/g, '');
315
371
  if (!normalized)
316
372
  return false;
317
373
  const segments = normalized.split('/');
318
374
  return segments.some((segment, index) => {
319
375
  const prefix = segments.slice(0, index + 1).join('/');
320
- return excludeSet.has(segment) || excludeSet.has(prefix);
376
+ return excludeRules.anyDepthNames.has(segment) || excludeRules.rootPrefixes.has(prefix);
321
377
  });
322
378
  }
323
379
  function isPathWithinRoot(candidatePath, rootPath) {
@@ -330,19 +386,11 @@ function resolveSafeCopyTarget(rootPath, candidatePath) {
330
386
  return null;
331
387
  }
332
388
  const parentPath = path.dirname(candidatePath);
333
- if (!ensureDirectoryWithinRoot(rootPath, parentPath)) {
334
- return null;
335
- }
336
- try {
337
- const realParent = realpathSync(parentPath);
338
- if (!isPathWithinRoot(realParent, rootPath)) {
339
- return null;
340
- }
341
- return path.join(realParent, path.basename(candidatePath));
342
- }
343
- catch {
389
+ const realParent = ensureDirectoryWithinRoot(rootPath, parentPath);
390
+ if (!realParent) {
344
391
  return null;
345
392
  }
393
+ return path.join(realParent, path.basename(candidatePath));
346
394
  }
347
395
  function resolveVerifiedFilePath(rootPath, candidatePath) {
348
396
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relayfile/local-mount",
3
- "version": "0.6.13",
3
+ "version": "0.6.15",
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",