@relayfile/local-mount 0.10.21 → 0.10.23

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.
@@ -30,6 +30,25 @@ export interface AutoSyncContext {
30
30
  */
31
31
  isNoSyncBack: (relPosix: string) => boolean;
32
32
  isReservedFile: (relPosix: string) => boolean;
33
+ /**
34
+ * True while the mount root still looks like a live mount (its marker
35
+ * file exists). Checked before any deletion is mirrored across trees: a
36
+ * mount directory that was torn down externally (crash cleanup, manual
37
+ * rm) must read as "the mount is gone", never as "the agent deleted
38
+ * every file" — without this, autosync would faithfully propagate the
39
+ * teardown as a mass delete of the user's project.
40
+ */
41
+ mountRootIntact: () => boolean;
42
+ /** Same guard for the project side: its disappearance must not empty the mount. */
43
+ projectRootIntact: () => boolean;
44
+ /**
45
+ * Sync state seeded by the mount population loop: one entry per copied
46
+ * file with both sides' mtimes recorded at copy time. When present,
47
+ * `startAutoSync` clones it instead of running the full-tree
48
+ * content-comparison priming pass — the copy already proved both sides
49
+ * identical, so re-reading every file pair only rediscovers that.
50
+ */
51
+ initialState?: ReadonlyMap<string, FileState>;
33
52
  }
34
53
  export interface AutoSyncOptions {
35
54
  /**
@@ -71,5 +90,18 @@ export interface AutoSyncHandle {
71
90
  totalChanges(): number;
72
91
  /** Resolves once both watchers have completed their initial scan. */
73
92
  ready(): Promise<void>;
93
+ /**
94
+ * Snapshot of the per-file sync state (both sides' last-synced mtimes),
95
+ * keyed by posix-relative path. Persist it alongside a kept mount and feed
96
+ * it to `attachMount` so the next session's first reconcile can
97
+ * distinguish deletions from creations. Run a full `reconcile()` first if
98
+ * the snapshot must cover paths only reconciles visit (e.g. `.git/**`
99
+ * under `includeGit`).
100
+ */
101
+ exportState(): Record<string, FileState>;
102
+ }
103
+ export interface FileState {
104
+ mountMtimeMs?: number;
105
+ projectMtimeMs?: number;
74
106
  }
75
107
  export declare function startAutoSync(ctx: AutoSyncContext, opts?: AutoSyncOptions): AutoSyncHandle;
package/dist/auto-sync.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { chmodSync, constants as fsConstants, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import watcher from '@parcel/watcher';
4
+ import { preserveMtime, statsImplySameContent } from './stat-compare.js';
4
5
  const STOP_EVENT_SETTLE_MS = 250;
5
6
  const DEFAULT_SCAN_INTERVAL_MS = 10_000;
6
7
  const DEFAULT_HEALTHY_SCAN_INTERVAL_MS = 60_000;
@@ -19,8 +20,13 @@ export function startAutoSync(ctx, opts = {}) {
19
20
  const healthyScanIntervalMs = normalizeScanInterval('healthyScanIntervalMs', opts.healthyScanIntervalMs, opts.scanIntervalMs === undefined ? DEFAULT_HEALTHY_SCAN_INTERVAL_MS : opts.scanIntervalMs);
20
21
  const debounceMs = opts.debounceMs ?? 50;
21
22
  const onError = opts.onError ?? (() => { });
22
- const state = new Map();
23
- primeState(state, ctx);
23
+ // Population-seeded state skips the priming walk entirely. Entries are
24
+ // cloned, not shared: the internal map mutates on every sync, and callers
25
+ // hold (or persist) their snapshot under a readonly contract.
26
+ const state = new Map(Array.from(ctx.initialState ?? [], ([rel, fileState]) => [rel, { ...fileState }]));
27
+ if (!ctx.initialState) {
28
+ primeState(state, ctx);
29
+ }
24
30
  let syncing = false;
25
31
  let pending = false;
26
32
  let stopping = false;
@@ -293,6 +299,7 @@ export function startAutoSync(ctx, opts = {}) {
293
299
  ready: async () => {
294
300
  await watchersReady;
295
301
  },
302
+ exportState: () => Object.fromEntries(Array.from(state, ([rel, fileState]) => [rel, { ...fileState }])),
296
303
  };
297
304
  }
298
305
  function buildIgnoreGlobs(ctx, watchRoot) {
@@ -478,7 +485,11 @@ function syncOneFile(relPosix, state, ctx) {
478
485
  if (mountChanged && !noSyncBack) {
479
486
  return doMountToProject(relPosix, state, ctx, mountAbs, projectAbs);
480
487
  }
481
- // Project deleted externally and mount hasn't been touched since → mirror.
488
+ // Project deleted externally and mount hasn't been touched since →
489
+ // mirror — but only while the project root itself is still there. A
490
+ // vanished project tree is a teardown, not a per-file delete.
491
+ if (!ctx.projectRootIntact())
492
+ return false;
482
493
  return doDeleteMount(relPosix, state, mountAbs);
483
494
  }
484
495
  if (!mountStat && projectStat) {
@@ -490,6 +501,11 @@ function syncOneFile(relPosix, state, ctx) {
490
501
  // No-sync-back deletes in mount don't propagate; recreate from project.
491
502
  return doProjectToMount(relPosix, state, ctx, projectAbs, mountAbs, readonly);
492
503
  }
504
+ // Guard the catastrophic case: if the mount root itself is gone (torn
505
+ // down externally while this autosync was still alive), every mount
506
+ // file reads as "deleted" — propagating that would erase the project.
507
+ if (!ctx.mountRootIntact())
508
+ return false;
493
509
  return doDeleteProject(relPosix, state, projectAbs);
494
510
  }
495
511
  return false;
@@ -505,6 +521,9 @@ function doMountToProject(relPosix, state, ctx, mountAbs, projectAbs) {
505
521
  return false;
506
522
  }
507
523
  copyFileSync(mountAbs, target, fsConstants.COPYFILE_FICLONE);
524
+ const mountStat = safeFileStat(mountAbs);
525
+ if (mountStat)
526
+ preserveMtime(target, mountStat);
508
527
  updateState(state, relPosix, mountAbs, target);
509
528
  return true;
510
529
  }
@@ -527,6 +546,9 @@ function doProjectToMount(relPosix, state, ctx, projectAbs, mountAbs, readonly)
527
546
  catch { /* best effort */ }
528
547
  }
529
548
  copyFileSync(projectAbs, target, fsConstants.COPYFILE_FICLONE);
549
+ const sourceStat = safeFileStat(projectAbs);
550
+ if (sourceStat)
551
+ preserveMtime(target, sourceStat);
530
552
  if (readonly) {
531
553
  try {
532
554
  chmodSync(target, 0o444);
@@ -620,6 +642,22 @@ function isSymlinkTarget(target) {
620
642
  }
621
643
  }
622
644
  function sameContent(left, right) {
645
+ try {
646
+ const leftStat = statSync(left);
647
+ const rightStat = statSync(right);
648
+ if (leftStat.size !== rightStat.size)
649
+ return false;
650
+ // Copies preserve source mtimes, so equal size plus (near-)equal mtime
651
+ // means "same write" — skip re-reading both files.
652
+ if (statsImplySameContent(leftStat, rightStat))
653
+ return true;
654
+ }
655
+ catch {
656
+ return false;
657
+ }
658
+ return sameContentBytes(left, right);
659
+ }
660
+ function sameContentBytes(left, right) {
623
661
  try {
624
662
  const a = statSync(left);
625
663
  const b = statSync(right);
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { createMount, type MountOptions, type MountHandle, } from './mount.js';
2
- export { type AutoSyncOptions, type AutoSyncHandle, } from './auto-sync.js';
1
+ export { createMount, attachMount, type MountOptions, type MountHandle, } from './mount.js';
2
+ export { type AutoSyncOptions, type AutoSyncHandle, type FileState, } 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 { createMount, } from './mount.js';
1
+ export { createMount, attachMount, } from './mount.js';
2
2
  export { readAgentDotfiles, } from './dotfiles.js';
3
3
  export { launchOnMount, } from './launch.js';
package/dist/mount.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type AutoSyncHandle, type AutoSyncOptions } from './auto-sync.js';
1
+ import { type AutoSyncHandle, type AutoSyncOptions, type FileState } from './auto-sync.js';
2
2
  export interface MountOptions {
3
3
  ignoredPatterns: string[];
4
4
  readonlyPatterns: string[];
@@ -27,11 +27,37 @@ export interface MountOptions {
27
27
  * excluded unless `includeGit` is true, even when this is false.
28
28
  */
29
29
  includeDefaultExcludeDirs?: boolean;
30
+ /**
31
+ * How the initial mount population enumerates project files.
32
+ *
33
+ * - `'walk'` (default): recursive directory walk honoring the exclude and
34
+ * ignore rules. Copies every non-excluded file it encounters, including
35
+ * gitignored build outputs and caches the default excludes don't cover.
36
+ * - `'git'`: enumerate via `git ls-files --cached --others
37
+ * --exclude-standard` — exactly the tracked plus untracked-unignored
38
+ * set, so gitignored trees (nested caches, worktrees, build outputs at
39
+ * any depth) never enter the mount. Exclude and ignore rules still apply
40
+ * on top. Throws if the project is not a usable git checkout.
41
+ * - `'auto'`: `'git'` when the project has a `.git`, no `.gitmodules`, and
42
+ * `git ls-files` succeeds; silently falls back to `'walk'` otherwise.
43
+ *
44
+ * With `includeGit: true`, git-list population copies `.git` as one
45
+ * timestamp-preserving bulk clone (copy-on-write where the filesystem
46
+ * supports it) instead of walking it file-by-file. When any ignored or
47
+ * readonly pattern targets `.git` itself, population falls back to
48
+ * `'walk'` so those patterns keep applying inside `.git`.
49
+ */
50
+ population?: 'walk' | 'git' | 'auto';
30
51
  }
31
52
  export interface MountHandle {
32
53
  mountDir: string;
33
54
  initialFileCount?: number;
34
55
  initialMountDurationMs?: number;
56
+ /**
57
+ * Which population strategy actually ran (after `'auto'` resolution), or
58
+ * `'reattach'` for handles from {@link attachMount}.
59
+ */
60
+ population: 'git' | 'walk' | 'reattach';
35
61
  syncBack(opts?: {
36
62
  signal?: AbortSignal;
37
63
  paths?: Iterable<string>;
@@ -46,3 +72,18 @@ export interface MountHandle {
46
72
  cleanup(): void;
47
73
  }
48
74
  export declare function createMount(projectDir: string, mountDir: string, options: MountOptions): Promise<MountHandle>;
75
+ /**
76
+ * Reattach to a mount directory a previous `createMount` populated (and a
77
+ * previous session left behind) without wiping or re-copying anything.
78
+ *
79
+ * The caller owns correctness of the reuse: pass the same patterns the mount
80
+ * was created with, and pass `initialState` from a prior
81
+ * `AutoSyncHandle.exportState()` so the first reconcile can tell "unchanged
82
+ * since last session" from "changed on one side" — without it, files deleted
83
+ * from the project while the mount sat idle would be treated as new
84
+ * mount-side creations and resurrected. Refuses directories that don't carry
85
+ * the mount marker.
86
+ */
87
+ export declare function attachMount(projectDir: string, mountDir: string, options: MountOptions & {
88
+ initialState?: Record<string, FileState>;
89
+ }): Promise<MountHandle>;
package/dist/mount.js CHANGED
@@ -1,7 +1,10 @@
1
- import { chmodSync, constants as fsConstants, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, } from 'node:fs';
1
+ import { chmodSync, constants as fsConstants, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
2
3
  import ignore from 'ignore';
4
+ import os from 'node:os';
3
5
  import path from 'node:path';
4
6
  import { startAutoSync, } from './auto-sync.js';
7
+ import { preserveMtime, statsImplySameContent } from './stat-compare.js';
5
8
  const DEFAULT_ANY_DEPTH_EXCLUDES = [
6
9
  '.git',
7
10
  'node_modules',
@@ -15,6 +18,13 @@ const DEFAULT_ANY_DEPTH_EXCLUDES = [
15
18
  '.turbo',
16
19
  '.cache',
17
20
  '.DS_Store',
21
+ // Virtualenvs are never mount material and routinely nest below the root
22
+ // (e.g. packages/*/py/.venv). They also self-ignore via an internal
23
+ // `.gitignore` that root-level rules never see, so without an any-depth
24
+ // exclude the sync layers would happily mirror thousands of interpreter
25
+ // files.
26
+ '.venv',
27
+ 'venv',
18
28
  ];
19
29
  const DEFAULT_ROOT_EXCLUDES = [
20
30
  'target',
@@ -22,8 +32,6 @@ const DEFAULT_ROOT_EXCLUDES = [
22
32
  'dist',
23
33
  'build',
24
34
  'out',
25
- '.venv',
26
- 'venv',
27
35
  'env',
28
36
  'coverage',
29
37
  ];
@@ -37,7 +45,6 @@ export async function createMount(projectDir, mountDir, options) {
37
45
  const ignoredPatterns = [...options.ignoredPatterns];
38
46
  const includeGit = options.includeGit === true;
39
47
  const readonlyMatcher = createPathMatcher(readonlyPatterns);
40
- const ignoredMatcher = createPathMatcher(ignoredPatterns);
41
48
  const includeDefaultExcludeDirs = options.includeDefaultExcludeDirs !== false;
42
49
  // `.git` is in the default any-depth excludes so the mount stays small and git
43
50
  // operations don't accidentally cross-mutate the host repo. When the caller
@@ -46,6 +53,20 @@ export async function createMount(projectDir, mountDir, options) {
46
53
  const excludeRules = createExcludeRules(options.excludeDirs, includeGit, includeDefaultExcludeDirs);
47
54
  const noSyncBackPatterns = includeGit ? ['.git', '.git/**'] : [];
48
55
  const noSyncBackMatcher = createPathMatcher(noSyncBackPatterns);
56
+ const requestedPopulation = options.population ?? 'walk';
57
+ const gitPopulation = requestedPopulation !== 'walk'
58
+ ? prepareGitPopulation(resolvedProjectDir, ignoredPatterns, readonlyPatterns, includeGit)
59
+ : null;
60
+ if (requestedPopulation === 'git' && gitPopulation === null) {
61
+ throw new Error(`population: 'git' requires a plain git checkout at ${resolvedProjectDir} ` +
62
+ "(no submodules, no pattern negations, no ignored/readonly patterns matching '.git')");
63
+ }
64
+ const population = gitPopulation === null ? 'walk' : 'git';
65
+ // Git-list population must keep the *sync* layers in agreement with what it
66
+ // mounted: gitignored files never enter the mount, so reconcile/syncBack
67
+ // must treat them as ignored too or the first full reconcile would copy
68
+ // every gitignored tree into the mount after all.
69
+ const isIgnored = buildIgnoredPredicate(createPathMatcher(ignoredPatterns), gitPopulation);
49
70
  // Guard against mountDir === projectDir. We compare both the realpath'd
50
71
  // project dir and the plain resolved project dir so callers that pass the
51
72
  // same argument for both are caught even when the path is a symlink (e.g.
@@ -61,41 +82,147 @@ export async function createMount(projectDir, mountDir, options) {
61
82
  const realMountDir = realpathSync(resolvedMountDir);
62
83
  writeFileSync(path.join(realMountDir, MOUNT_MARKER_FILENAME), MOUNT_MARKER_CONTENT, 'utf8');
63
84
  const initialMountStartedAt = Date.now();
64
- const initialFileCount = await walkProjectTree(resolvedProjectDir, resolvedProjectDir, realMountDir, realMountDir, excludeRules, readonlyMatcher, ignoredMatcher);
85
+ // Sync state seeded during population: every copy records both sides'
86
+ // mtimes, so autosync can skip its full-tree content-comparison priming
87
+ // pass — the copy loop already proved the two sides identical.
88
+ const initialState = new Map();
89
+ let initialFileCount;
90
+ if (gitPopulation !== null) {
91
+ initialFileCount = await populateFromGitFileList(resolvedProjectDir, realMountDir, gitPopulation.files, excludeRules, readonlyMatcher, isIgnored, initialState);
92
+ if (includeGit) {
93
+ initialFileCount += cloneGitInto(resolvedProjectDir, realMountDir);
94
+ // The clone bypasses the per-file copy loop, so seed its state
95
+ // explicitly: without entries, project-side .git deletions (pack-refs,
96
+ // gc) would never propagate, and pre-autosync mount-side .git setup
97
+ // writes would be clobbered by the first reconcile's no-history path.
98
+ seedClonedGitState(resolvedProjectDir, realMountDir, initialState);
99
+ }
100
+ }
101
+ else {
102
+ initialFileCount = await walkProjectTree(resolvedProjectDir, resolvedProjectDir, realMountDir, realMountDir, excludeRules, readonlyMatcher, isIgnored, initialState);
103
+ }
65
104
  const initialMountDurationMs = Date.now() - initialMountStartedAt;
66
105
  const readmePath = resolveSafeCopyTarget(realMountDir, path.join(realMountDir, MOUNT_README_FILENAME));
67
106
  if (!readmePath) {
68
107
  throw new Error('Failed to create mount readme inside mountDir');
69
108
  }
70
109
  writeFileSync(readmePath, buildMountReadme(options.agentName, readonlyPatterns, ignoredPatterns), 'utf8');
110
+ return buildMountHandle({
111
+ resolvedProjectDir,
112
+ resolvedMountDir,
113
+ realMountDir,
114
+ excludeRules,
115
+ readonlyMatcher,
116
+ isIgnored,
117
+ noSyncBackMatcher,
118
+ initialState,
119
+ initialFileCount,
120
+ initialMountDurationMs,
121
+ population,
122
+ });
123
+ }
124
+ /**
125
+ * Reattach to a mount directory a previous `createMount` populated (and a
126
+ * previous session left behind) without wiping or re-copying anything.
127
+ *
128
+ * The caller owns correctness of the reuse: pass the same patterns the mount
129
+ * was created with, and pass `initialState` from a prior
130
+ * `AutoSyncHandle.exportState()` so the first reconcile can tell "unchanged
131
+ * since last session" from "changed on one side" — without it, files deleted
132
+ * from the project while the mount sat idle would be treated as new
133
+ * mount-side creations and resurrected. Refuses directories that don't carry
134
+ * the mount marker.
135
+ */
136
+ export async function attachMount(projectDir, mountDir, options) {
137
+ const resolvedProjectDir = realpathSync(projectDir);
138
+ const resolvedMountDir = path.resolve(mountDir);
139
+ const readonlyPatterns = [...options.readonlyPatterns];
140
+ const ignoredPatterns = [...options.ignoredPatterns];
141
+ const includeGit = options.includeGit === true;
142
+ const readonlyMatcher = createPathMatcher(readonlyPatterns);
143
+ // A reattached mount must ignore exactly what its population did: when the
144
+ // caller requests git-mode semantics, re-derive the same gitignore-aware
145
+ // predicate (the guards re-run too, so a repo that has since grown
146
+ // submodules degrades to plain caller patterns — matching what a fresh
147
+ // createMount would do).
148
+ const gitPopulation = (options.population ?? 'walk') !== 'walk'
149
+ ? prepareGitPopulation(resolvedProjectDir, ignoredPatterns, readonlyPatterns, includeGit)
150
+ : null;
151
+ const isIgnored = buildIgnoredPredicate(createPathMatcher(ignoredPatterns), gitPopulation);
152
+ const includeDefaultExcludeDirs = options.includeDefaultExcludeDirs !== false;
153
+ const excludeRules = createExcludeRules(options.excludeDirs, includeGit, includeDefaultExcludeDirs);
154
+ const noSyncBackPatterns = includeGit ? ['.git', '.git/**'] : [];
155
+ const noSyncBackMatcher = createPathMatcher(noSyncBackPatterns);
156
+ if (resolvedMountDir === resolvedProjectDir ||
157
+ resolvedMountDir === path.resolve(projectDir)) {
158
+ throw new Error('mountDir must be different from projectDir');
159
+ }
160
+ if ((options.population ?? 'walk') === 'git' && gitPopulation === null) {
161
+ throw new Error(`population: 'git' requires a plain git checkout at ${resolvedProjectDir} ` +
162
+ "(no submodules, no pattern negations, no ignored/readonly patterns matching '.git')");
163
+ }
164
+ const markerPath = path.join(resolvedMountDir, MOUNT_MARKER_FILENAME);
165
+ if (!existsSync(markerPath)) {
166
+ throw new Error(`attachMount: ${resolvedMountDir} is missing the ${MOUNT_MARKER_FILENAME} marker; ` +
167
+ 'only directories previously populated by createMount can be reattached.');
168
+ }
169
+ const realMountDir = realpathSync(resolvedMountDir);
170
+ // Same root/overlap validation as createMount: cleanup() recursively
171
+ // removes the mount dir, so a marked directory that overlaps the project
172
+ // must never be attachable. Unlike createMount, the mount dir exists here
173
+ // (marker just verified), so both sides compare as realpaths — otherwise
174
+ // symlinked temp roots (macOS /var → /private/var) would defeat the check.
175
+ assertMountDirSafeToRemove(realMountDir, resolvedProjectDir);
176
+ const initialState = new Map(Object.entries(options.initialState ?? {}));
177
+ return buildMountHandle({
178
+ resolvedProjectDir,
179
+ resolvedMountDir,
180
+ realMountDir,
181
+ excludeRules,
182
+ readonlyMatcher,
183
+ isIgnored,
184
+ noSyncBackMatcher,
185
+ initialState,
186
+ population: 'reattach',
187
+ });
188
+ }
189
+ function buildMountHandle(input) {
190
+ const { resolvedProjectDir, resolvedMountDir, realMountDir, excludeRules, readonlyMatcher, isIgnored, noSyncBackMatcher, initialState, } = input;
71
191
  const autoSyncContext = {
72
192
  realMountDir,
73
193
  realProjectDir: resolvedProjectDir,
74
194
  isExcluded: (relPosix) => isExcludedPath(relPosix, excludeRules),
75
195
  excludedAnyDepthNames: [...excludeRules.anyDepthNames],
76
196
  excludedRootPrefixes: [...excludeRules.rootPrefixes],
77
- isIgnored: (relPosix, isDir) => isPathMatched(relPosix, ignoredMatcher, isDir),
197
+ isIgnored,
78
198
  isReadonly: (relPosix) => isPathMatched(relPosix, readonlyMatcher),
79
199
  isNoSyncBack: (relPosix) => isPathMatched(relPosix, noSyncBackMatcher),
80
200
  isReservedFile: (relPosix) => relPosix === MOUNT_README_FILENAME || relPosix === MOUNT_MARKER_FILENAME,
201
+ mountRootIntact: () => existsSync(path.join(realMountDir, MOUNT_MARKER_FILENAME)),
202
+ projectRootIntact: () => existsSync(resolvedProjectDir),
203
+ initialState,
81
204
  };
82
205
  return {
83
206
  mountDir: resolvedMountDir,
84
- initialFileCount,
85
- initialMountDurationMs,
207
+ initialFileCount: input.initialFileCount,
208
+ initialMountDurationMs: input.initialMountDurationMs,
209
+ population: input.population,
86
210
  async syncBack(opts) {
87
211
  let synced = 0;
88
212
  const realProjectDir = realpathSync(resolvedProjectDir);
89
213
  const realMountDir = realpathSync(resolvedMountDir);
214
+ // No-sync-back subtrees (`.git/**` under includeGit) can never produce
215
+ // a sync, so don't descend into them at all — `.git` alone is often
216
+ // thousands of entries.
90
217
  const files = opts?.paths
91
218
  ? syncBackPathsToFiles(realMountDir, opts.paths)
92
- : listFiles(realMountDir);
219
+ : listFiles(realMountDir, (relPosix) => isPathMatched(relPosix, noSyncBackMatcher, true));
93
220
  const signal = opts?.signal;
94
221
  for (const sourceFile of files) {
95
222
  if (signal?.aborted) {
96
223
  break;
97
224
  }
98
- const syncedForFile = syncMountedFileBack(sourceFile, realMountDir, realProjectDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher, (relPosix) => isExcludedPath(relPosix, excludeRules));
225
+ const syncedForFile = syncMountedFileBack(sourceFile, realMountDir, realProjectDir, readonlyMatcher, isIgnored, noSyncBackMatcher, (relPosix) => isExcludedPath(relPosix, excludeRules));
99
226
  synced += syncedForFile;
100
227
  if (signal && syncedForFile > 0 && !signal.aborted) {
101
228
  // Intentionally uses setTimeout(resolve, 0) rather than the
@@ -160,7 +287,7 @@ function assertMountDirSafeToRemove(mountDir, projectDir) {
160
287
  // tick during init even on flat directories with thousands of entries. The
161
288
  // goal is not throughput; it is keeping the parent event loop unblocked.
162
289
  const WALK_YIELD_EVERY = 64;
163
- async function walkProjectTree(projectDir, currentDir, mountDir, currentMountDir, excludeRules, readonlyMatcher, ignoredMatcher) {
290
+ async function walkProjectTree(projectDir, currentDir, mountDir, currentMountDir, excludeRules, readonlyMatcher, isIgnored, state) {
164
291
  await yieldToEventLoop();
165
292
  const entries = readdirSync(currentDir, { withFileTypes: true });
166
293
  let processed = 0;
@@ -181,7 +308,7 @@ async function walkProjectTree(projectDir, currentDir, mountDir, currentMountDir
181
308
  if (isExcludedPath(relativePath, excludeRules)) {
182
309
  continue;
183
310
  }
184
- if (isPathMatched(relativePath, ignoredMatcher, entry.isDirectory())) {
311
+ if (isIgnored(relativePath, entry.isDirectory())) {
185
312
  continue;
186
313
  }
187
314
  const mountPath = path.join(currentMountDir, entry.name);
@@ -190,11 +317,11 @@ async function walkProjectTree(projectDir, currentDir, mountDir, currentMountDir
190
317
  if (!safeMountDir) {
191
318
  continue;
192
319
  }
193
- copiedFiles += await walkProjectTree(projectDir, absolutePath, mountDir, safeMountDir, excludeRules, readonlyMatcher, ignoredMatcher);
320
+ copiedFiles += await walkProjectTree(projectDir, absolutePath, mountDir, safeMountDir, excludeRules, readonlyMatcher, isIgnored, state);
194
321
  continue;
195
322
  }
196
323
  if (entry.isSymbolicLink()) {
197
- if (copySymlinkedFile(projectDir, mountDir, absolutePath, mountPath, relativePath, readonlyMatcher)) {
324
+ if (copySymlinkedFile(projectDir, mountDir, absolutePath, mountPath, relativePath, readonlyMatcher, state)) {
198
325
  copiedFiles += 1;
199
326
  }
200
327
  continue;
@@ -202,16 +329,319 @@ async function walkProjectTree(projectDir, currentDir, mountDir, currentMountDir
202
329
  if (!entry.isFile()) {
203
330
  continue;
204
331
  }
205
- if (copyMountedFile(projectDir, mountDir, absolutePath, mountPath, relativePath, readonlyMatcher)) {
332
+ if (copyMountedFile(projectDir, mountDir, absolutePath, mountPath, relativePath, readonlyMatcher, state)) {
333
+ copiedFiles += 1;
334
+ }
335
+ }
336
+ return copiedFiles;
337
+ }
338
+ /**
339
+ * Populate the mount from a git-provided file list instead of a full tree
340
+ * walk. The list is exactly `git ls-files --cached --others
341
+ * --exclude-standard` output, so gitignored trees never even get visited;
342
+ * exclude and ignore rules are still applied per path so callers' patterns
343
+ * behave identically to walk mode.
344
+ */
345
+ async function populateFromGitFileList(projectDir, mountDir, gitFiles, excludeRules, readonlyMatcher, isIgnored, state) {
346
+ // Sorted order keeps sibling files adjacent so the ensure-directory cache
347
+ // inside resolveSafeCopyTarget hits its realpath cache line after line.
348
+ const sorted = [...gitFiles].sort();
349
+ let processed = 0;
350
+ let copiedFiles = 0;
351
+ for (const raw of sorted) {
352
+ if (processed > 0 && processed % WALK_YIELD_EVERY === 0) {
353
+ await yieldToEventLoop();
354
+ }
355
+ processed += 1;
356
+ const relativePath = normalizeRelativePosix(raw);
357
+ if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(raw)) {
358
+ continue;
359
+ }
360
+ if (isExcludedPath(relativePath, excludeRules)) {
361
+ continue;
362
+ }
363
+ if (isIgnored(relativePath, false)) {
364
+ continue;
365
+ }
366
+ const absolutePath = path.join(projectDir, ...relativePath.split('/'));
367
+ if (isPathWithinRoot(absolutePath, mountDir)) {
368
+ continue;
369
+ }
370
+ let entryStat;
371
+ try {
372
+ entryStat = lstatSync(absolutePath);
373
+ }
374
+ catch {
375
+ // Listed but deleted from the working tree (staged deletes) — skip.
376
+ continue;
377
+ }
378
+ const mountPath = path.join(mountDir, ...relativePath.split('/'));
379
+ if (entryStat.isSymbolicLink()) {
380
+ if (copySymlinkedFile(projectDir, mountDir, absolutePath, mountPath, relativePath, readonlyMatcher, state)) {
381
+ copiedFiles += 1;
382
+ }
383
+ continue;
384
+ }
385
+ // Non-files (submodule gitlinks appear as directories, sockets, fifos)
386
+ // are never mount candidates.
387
+ if (!entryStat.isFile()) {
388
+ continue;
389
+ }
390
+ if (copyMountedFile(projectDir, mountDir, absolutePath, mountPath, relativePath, readonlyMatcher, state)) {
206
391
  copiedFiles += 1;
207
392
  }
208
393
  }
209
394
  return copiedFiles;
210
395
  }
396
+ /**
397
+ * The "is this path hidden from the mount and its sync?" decision.
398
+ *
399
+ * Walk mode: caller patterns only (historical behavior). Git mode: caller
400
+ * patterns first (they always win), then the repo's gitignore rules — except
401
+ * for tracked-but-gitignored files (and their ancestor directories, so walk
402
+ * pruning can't hide them), which git itself treats as ordinary content.
403
+ */
404
+ function buildIgnoredPredicate(callerMatcher, gitPopulation) {
405
+ if (gitPopulation === null) {
406
+ return (relPosix, isDirectory) => isPathMatched(relPosix, callerMatcher, isDirectory);
407
+ }
408
+ const gitignoreMatcher = createPathMatcher(gitPopulation.gitignoreLines);
409
+ // Nested .gitignore files scope to their directory: rules match paths
410
+ // relative to the rule file's location, exactly as git applies them.
411
+ const nested = gitPopulation.nestedIgnores.map(({ prefix, lines }) => ({
412
+ prefix: prefix === '' ? '' : `${prefix}/`,
413
+ matcher: createPathMatcher(lines),
414
+ }));
415
+ const trackedIgnored = new Set(gitPopulation.trackedIgnoredFiles);
416
+ const trackedIgnoredDirs = new Set();
417
+ for (const file of gitPopulation.trackedIgnoredFiles) {
418
+ let dir = file;
419
+ while (dir.includes('/')) {
420
+ dir = dir.slice(0, dir.lastIndexOf('/'));
421
+ trackedIgnoredDirs.add(dir);
422
+ }
423
+ }
424
+ return (relPosix, isDirectory) => {
425
+ if (isPathMatched(relPosix, callerMatcher, isDirectory))
426
+ return true;
427
+ if (isDirectory ? trackedIgnoredDirs.has(relPosix) : trackedIgnored.has(relPosix)) {
428
+ return false;
429
+ }
430
+ if (isPathMatched(relPosix, gitignoreMatcher, isDirectory))
431
+ return true;
432
+ for (const { prefix, matcher } of nested) {
433
+ if (prefix !== '' && !relPosix.startsWith(prefix))
434
+ continue;
435
+ const scoped = prefix === '' ? relPosix : relPosix.slice(prefix.length);
436
+ if (scoped && isPathMatched(scoped, matcher, isDirectory))
437
+ return true;
438
+ }
439
+ return false;
440
+ };
441
+ }
442
+ function runGitLsFiles(projectDir, args) {
443
+ let result;
444
+ try {
445
+ result = spawnSync('git', ['-C', projectDir, 'ls-files', '-z', ...args], {
446
+ maxBuffer: 1024 * 1024 * 1024,
447
+ });
448
+ }
449
+ catch {
450
+ return null;
451
+ }
452
+ if (result.error || result.status !== 0)
453
+ return null;
454
+ return result.stdout.toString('utf8').split('\0').filter(Boolean);
455
+ }
456
+ function readIgnoreRuleLines(filePath) {
457
+ try {
458
+ return readFileSync(filePath, 'utf8').split('\n');
459
+ }
460
+ catch {
461
+ return [];
462
+ }
463
+ }
464
+ /**
465
+ * Decide whether git-list population applies and gather its inputs.
466
+ * Returns null (→ walk fallback) when any precondition fails:
467
+ *
468
+ * - not a git checkout, or git itself fails;
469
+ * - `.gitmodules` present — submodule working trees are populated by the
470
+ * walk but invisible to a plain `ls-files` call;
471
+ * - caller patterns contain negations (`!keep`) — those can re-include
472
+ * gitignored paths, which a git-derived file list can never surface;
473
+ * - with `includeGit`, patterns that match the root `.git` tree — the bulk
474
+ * `.git` clone doesn't consult matchers, only the walk does.
475
+ */
476
+ function prepareGitPopulation(projectDir, ignoredPatterns, readonlyPatterns, includeGit) {
477
+ if (!existsSync(path.join(projectDir, '.git')))
478
+ return null;
479
+ if (existsSync(path.join(projectDir, '.gitmodules')))
480
+ return null;
481
+ const allPatterns = [...ignoredPatterns, ...readonlyPatterns];
482
+ if (allPatterns.some((p) => p.trim().startsWith('!')))
483
+ return null;
484
+ // Probe the actual matchers rather than inspecting pattern strings: this
485
+ // catches every syntax that can reach inside `.git` (globs included)
486
+ // without false-positiving on `.github` / `.gitignore`-style names.
487
+ if (includeGit && patternsMatchGitDir(allPatterns))
488
+ return null;
489
+ const trackedIgnored = runGitLsFiles(projectDir, ['--cached', '-i', '--exclude-standard']);
490
+ if (trackedIgnored === null)
491
+ return null;
492
+ const listed = runGitLsFiles(projectDir, ['--cached', '--others', '--exclude-standard']);
493
+ if (listed === null)
494
+ return null;
495
+ // Belt and braces: ls-files never emits `.git` paths, but the mount must
496
+ // not trust a spawned tool's output for that invariant.
497
+ const files = listed.filter((p) => p !== '.git' && !p.startsWith('.git/'));
498
+ // Nested .gitignore files scope their rules to their own directory; the
499
+ // listing honors them already, the sync predicate needs them explicitly.
500
+ const nestedIgnores = [];
501
+ for (const file of files) {
502
+ if (!file.endsWith('/.gitignore'))
503
+ continue;
504
+ const prefix = normalizeRelativePosix(file.slice(0, -'/.gitignore'.length));
505
+ const lines = readIgnoreRuleLines(path.join(projectDir, ...file.split('/')));
506
+ const hasRules = lines.some((l) => {
507
+ const t = l.trim();
508
+ return t !== '' && !t.startsWith('#');
509
+ });
510
+ if (hasRules && prefix !== '')
511
+ nestedIgnores.push({ prefix, lines });
512
+ }
513
+ return {
514
+ files,
515
+ gitignoreLines: [
516
+ ...readIgnoreRuleLines(path.join(projectDir, '.gitignore')),
517
+ ...readIgnoreRuleLines(path.join(projectDir, '.git', 'info', 'exclude')),
518
+ // The user's global excludes also shaped the listing; without them the
519
+ // sync layers would re-import globally-ignored files (.DS_Store etc.).
520
+ ...readGlobalGitExcludeLines(projectDir),
521
+ ],
522
+ nestedIgnores,
523
+ trackedIgnoredFiles: trackedIgnored.map((p) => normalizeRelativePosix(p)),
524
+ };
525
+ }
526
+ /**
527
+ * Would any caller pattern hide or freeze something under the root `.git`?
528
+ * Tested against representative `.git` paths through the real matcher so
529
+ * glob spellings are covered; `.github`-style names don't match.
530
+ */
531
+ function patternsMatchGitDir(patterns) {
532
+ const matcher = createPathMatcher([...patterns]);
533
+ return (isPathMatched('.git', matcher, true) ||
534
+ matcher.ignores('.git/config') ||
535
+ matcher.ignores('.git/hooks/pre-commit'));
536
+ }
537
+ /**
538
+ * Record autosync state for every file the `.git` bulk clone produced. The
539
+ * clone preserved timestamps, so this is a stat-only sweep of both sides —
540
+ * no content reads. Files whose project counterpart vanished between clone
541
+ * and sweep are simply skipped and take the first-sight path later.
542
+ */
543
+ function seedClonedGitState(projectDir, mountDir, state) {
544
+ const mountGit = path.join(mountDir, '.git');
545
+ if (!existsSync(mountGit))
546
+ return;
547
+ for (const mountAbs of listFiles(mountGit)) {
548
+ const rel = normalizeRelativePosix(path.relative(mountDir, mountAbs));
549
+ if (!rel || rel.startsWith('..'))
550
+ continue;
551
+ try {
552
+ const mountStat = lstatSync(mountAbs);
553
+ if (!mountStat.isFile())
554
+ continue;
555
+ const projectStat = lstatSync(path.join(projectDir, ...rel.split('/')));
556
+ if (!projectStat.isFile())
557
+ continue;
558
+ state.set(rel, {
559
+ mountMtimeMs: mountStat.mtimeMs,
560
+ projectMtimeMs: projectStat.mtimeMs,
561
+ });
562
+ }
563
+ catch {
564
+ /* skipped entries take autosync's first-sight path */
565
+ }
566
+ }
567
+ }
568
+ /** Lines from the user's global git excludes file (core.excludesFile or the XDG default). */
569
+ function readGlobalGitExcludeLines(projectDir) {
570
+ let configured = null;
571
+ try {
572
+ const result = spawnSync('git', ['-C', projectDir, 'config', '--path', '--get', 'core.excludesfile'], { maxBuffer: 1024 * 1024 });
573
+ if (!result.error && result.status === 0) {
574
+ configured = result.stdout.toString('utf8').trim() || null;
575
+ }
576
+ }
577
+ catch {
578
+ /* fall through to the XDG default */
579
+ }
580
+ const candidate = configured !== null
581
+ ? // `--path` expands `~`; a bare relative value (a misconfiguration git
582
+ // itself resolves against its process cwd) resolves against the
583
+ // project dir here, matching the `git -C projectDir` listing calls.
584
+ path.resolve(projectDir, configured)
585
+ : path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'git', 'ignore');
586
+ return readIgnoreRuleLines(candidate);
587
+ }
588
+ /**
589
+ * Copy the project's `.git` into the mount as a single timestamp-preserving
590
+ * bulk clone (copy-on-write via FICLONE where the filesystem supports it).
591
+ * Returns the entry count contribution for `initialFileCount` (0 or 1 — the
592
+ * per-file count of the walk path isn't worth a second traversal here).
593
+ *
594
+ * A worktree-style `.git` *file* (gitdir pointer) is copied as-is, matching
595
+ * the walk path's behavior.
596
+ */
597
+ function cloneGitInto(projectDir, mountDir) {
598
+ const source = path.join(projectDir, '.git');
599
+ let sourceStat;
600
+ try {
601
+ sourceStat = lstatSync(source);
602
+ }
603
+ catch {
604
+ return 0;
605
+ }
606
+ const target = path.join(mountDir, '.git');
607
+ try {
608
+ if (!sourceStat.isDirectory()) {
609
+ // A worktree-style `.git` *file* is a gitdir pointer into the host
610
+ // checkout's private worktree metadata. Copying it verbatim would let
611
+ // git commands inside the mount mutate the host's index/HEAD, so
612
+ // linked worktrees get no sandboxed `.git` at all under git
613
+ // population. (The legacy walk still copies the pointer; fixing that
614
+ // pre-existing behavior is out of scope here.)
615
+ return 0;
616
+ }
617
+ cpSync(source, target, {
618
+ recursive: true,
619
+ force: true,
620
+ preserveTimestamps: true,
621
+ mode: fsConstants.COPYFILE_FICLONE,
622
+ // Sockets and fifos (e.g. fsmonitor--daemon.ipc) can't be copied.
623
+ filter: (src) => {
624
+ try {
625
+ const st = lstatSync(src);
626
+ return st.isDirectory() || st.isFile() || st.isSymbolicLink();
627
+ }
628
+ catch {
629
+ return false;
630
+ }
631
+ },
632
+ });
633
+ return 1;
634
+ }
635
+ catch {
636
+ // Best-effort: a partially cloned .git is still more useful than a
637
+ // failed mount. Git commands inside the mount surface any gaps.
638
+ return 0;
639
+ }
640
+ }
211
641
  function yieldToEventLoop() {
212
642
  return new Promise((resolve) => setImmediate(resolve));
213
643
  }
214
- function copySymlinkedFile(projectDir, mountDir, sourcePath, mountPath, relativePath, readonlyMatcher) {
644
+ function copySymlinkedFile(projectDir, mountDir, sourcePath, mountPath, relativePath, readonlyMatcher, state) {
215
645
  let realSource;
216
646
  let resolvedStat;
217
647
  try {
@@ -224,9 +654,15 @@ function copySymlinkedFile(projectDir, mountDir, sourcePath, mountPath, relative
224
654
  if (!isPathWithinRoot(realSource, projectDir) || !resolvedStat.isFile()) {
225
655
  return false;
226
656
  }
227
- return copyMountedFile(projectDir, mountDir, realSource, mountPath, relativePath, readonlyMatcher, resolvedStat.mode);
657
+ return copyMountedFile(projectDir, mountDir, realSource, mountPath, relativePath, readonlyMatcher,
658
+ // Auto-sync stats the project side with a symlink-rejecting lstat, so a
659
+ // seeded entry for a symlink's path would read as "project side gone,
660
+ // mount unchanged" on the first reconcile and delete the mount copy.
661
+ // Leave symlink copies unseeded — they take the historical
662
+ // first-sight path, whose symlink-target write guard keeps them alive.
663
+ null, resolvedStat.mode);
228
664
  }
229
- function copyMountedFile(sourceRoot, mountDir, sourcePath, mountPath, relativePath, readonlyMatcher, sourceMode) {
665
+ function copyMountedFile(sourceRoot, mountDir, sourcePath, mountPath, relativePath, readonlyMatcher, state, sourceMode) {
230
666
  const safeMountPath = resolveSafeCopyTarget(mountDir, mountPath);
231
667
  if (!safeMountPath) {
232
668
  return false;
@@ -235,15 +671,38 @@ function copyMountedFile(sourceRoot, mountDir, sourcePath, mountPath, relativePa
235
671
  if (!safeSourcePath) {
236
672
  return false;
237
673
  }
674
+ const sourceStat = statSync(safeSourcePath);
238
675
  copyFileSync(safeSourcePath, safeMountPath, fsConstants.COPYFILE_FICLONE);
676
+ // Carry the source mtime onto the copy so both trees stat as "the same
677
+ // write" — reconcile and syncBack can then trust the stat quick check
678
+ // instead of re-reading file contents.
679
+ preserveMtime(safeMountPath, sourceStat);
680
+ if (state)
681
+ recordCopiedState(state, relativePath, safeMountPath, sourceStat);
239
682
  if (isPathMatched(relativePath, readonlyMatcher)) {
240
683
  chmodSync(safeMountPath, 0o444);
241
684
  return true;
242
685
  }
243
- const mode = sourceMode ?? statSync(safeSourcePath).mode;
686
+ const mode = sourceMode ?? sourceStat.mode;
244
687
  chmodSync(safeMountPath, mode & 0o777);
245
688
  return true;
246
689
  }
690
+ /**
691
+ * Seed the autosync state for a file the population loop just copied. The
692
+ * mount side is stat'd after the mtime carry-over so the recorded value is
693
+ * exactly what a later stat will report.
694
+ */
695
+ function recordCopiedState(state, relativePath, mountPath, sourceStat) {
696
+ try {
697
+ state.set(normalizeRelativePosix(relativePath), {
698
+ mountMtimeMs: statSync(mountPath).mtimeMs,
699
+ projectMtimeMs: sourceStat.mtimeMs,
700
+ });
701
+ }
702
+ catch {
703
+ /* unseeded entries just take autosync's first-sight path */
704
+ }
705
+ }
247
706
  function ensureDirectory(pathValue) {
248
707
  mkdirSync(pathValue, { recursive: true });
249
708
  }
@@ -260,7 +719,7 @@ function ensureDirectoryWithinRoot(rootPath, dirPath) {
260
719
  return null;
261
720
  }
262
721
  }
263
- function listFiles(baseDir) {
722
+ function listFiles(baseDir, skipDir) {
264
723
  const files = [];
265
724
  const stack = [baseDir];
266
725
  while (stack.length > 0) {
@@ -271,6 +730,11 @@ function listFiles(baseDir) {
271
730
  for (const entry of entries) {
272
731
  const entryPath = path.join(current, entry.name);
273
732
  if (entry.isDirectory()) {
733
+ if (skipDir) {
734
+ const relPosix = normalizeRelativePosix(path.relative(baseDir, entryPath));
735
+ if (relPosix && skipDir(relPosix))
736
+ continue;
737
+ }
274
738
  stack.push(entryPath);
275
739
  }
276
740
  else if (entry.isFile() || entry.isSymbolicLink()) {
@@ -361,10 +825,12 @@ function hasSameContent(left, right) {
361
825
  if (leftStat.size !== rightStat.size) {
362
826
  return false;
363
827
  }
364
- // Same size: fall back to a full byte comparison. Buffer.equals short-
365
- // circuits internally but we still read both files; for very large files
366
- // callers may want a streaming approach, though in practice mounts are
367
- // dominated by source code where this is cheap.
828
+ // Population preserves source mtimes onto copies, so equal size plus
829
+ // (near-)equal mtime means "same write" without re-reading either side.
830
+ if (statsImplySameContent(leftStat, rightStat)) {
831
+ return true;
832
+ }
833
+ // Same size but diverged mtimes: fall back to a full byte comparison.
368
834
  const leftContent = readFileSync(left);
369
835
  const rightContent = readFileSync(right);
370
836
  return leftContent.equals(rightContent);
@@ -373,8 +839,8 @@ function hasSameContent(left, right) {
373
839
  return false;
374
840
  }
375
841
  }
376
- function syncMountedFileBack(sourceFile, mountDir, projectDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher, isExcluded) {
377
- const relative = resolveSyncRelativePath(sourceFile, mountDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher, isExcluded);
842
+ function syncMountedFileBack(sourceFile, mountDir, projectDir, readonlyMatcher, isIgnored, noSyncBackMatcher, isExcluded) {
843
+ const relative = resolveSyncRelativePath(sourceFile, mountDir, readonlyMatcher, isIgnored, noSyncBackMatcher, isExcluded);
378
844
  if (!relative)
379
845
  return 0;
380
846
  const safeTargetPath = resolveVerifiedSyncTarget(projectDir, relative);
@@ -384,9 +850,17 @@ function syncMountedFileBack(sourceFile, mountDir, projectDir, readonlyMatcher,
384
850
  return 0;
385
851
  }
386
852
  copyFileSync(sourceFile, safeTargetPath);
853
+ // Keep both sides stat-identical so a later pass (or a reused mount) can
854
+ // take the quick check instead of re-reading content.
855
+ try {
856
+ preserveMtime(safeTargetPath, statSync(sourceFile));
857
+ }
858
+ catch {
859
+ /* best effort */
860
+ }
387
861
  return 1;
388
862
  }
389
- function resolveSyncRelativePath(sourceFile, mountDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher, isExcluded) {
863
+ function resolveSyncRelativePath(sourceFile, mountDir, readonlyMatcher, isIgnored, noSyncBackMatcher, isExcluded) {
390
864
  const relative = path.relative(mountDir, sourceFile);
391
865
  if (relative === '' || relative.startsWith('..'))
392
866
  return null;
@@ -397,7 +871,7 @@ function resolveSyncRelativePath(sourceFile, mountDir, readonlyMatcher, ignoredM
397
871
  return null;
398
872
  if (isExcluded(relativePosix) ||
399
873
  isPathMatched(relative, readonlyMatcher) ||
400
- isPathMatched(relative, ignoredMatcher) ||
874
+ isIgnored(relativePosix) ||
401
875
  isPathMatched(relative, noSyncBackMatcher))
402
876
  return null;
403
877
  try {
@@ -0,0 +1,37 @@
1
+ import type { Stats } from 'node:fs';
2
+ /**
3
+ * Window inside which two mtimes are considered "the same write".
4
+ *
5
+ * Mount population preserves source mtimes onto copies via utimes, which
6
+ * round-trips through fractional-second floats with sub-microsecond error —
7
+ * and Node's own `cpSync({ preserveTimestamps: true })` truncates to whole
8
+ * milliseconds. 2ms absorbs both while staying far below the pace at which
9
+ * real writes to the same path land.
10
+ */
11
+ export declare const MTIME_QUICK_EQUAL_TOLERANCE_MS = 2;
12
+ /**
13
+ * Files written within this window are never quick-checked: a just-written
14
+ * file is exactly the case where a same-size rewrite could land inside the
15
+ * mtime tolerance and masquerade as unchanged. Stale mtimes (anything older
16
+ * than a few seconds) can only be within tolerance of each other because one
17
+ * side is a preserved-mtime copy of the other.
18
+ */
19
+ export declare const RECENT_WRITE_GUARD_MS = 5000;
20
+ /**
21
+ * rsync-style quick check: equal sizes plus (near-)equal mtimes imply equal
22
+ * content, letting sync paths skip a full byte comparison. Only meaningful
23
+ * for regular files whose copies were made with mtime preservation; callers
24
+ * fall back to byte comparison when this returns false.
25
+ *
26
+ * Guarded two ways: mtimes must agree within the tolerance (absorbing utimes
27
+ * float round-trips and `cpSync({ preserveTimestamps })` millisecond
28
+ * truncation), and neither side may have been written inside the recency
29
+ * window — fresh writes always take the byte-comparison path.
30
+ */
31
+ export declare function statsImplySameContent(a: Stats, b: Stats): boolean;
32
+ /**
33
+ * Carry a source file's mtime onto its copy so the two sides stat as "the
34
+ * same write" and the quick check applies. Best-effort: when it fails, the
35
+ * quick check simply degrades to a byte comparison.
36
+ */
37
+ export declare function preserveMtime(targetPath: string, sourceStat: Stats): void;
@@ -0,0 +1,51 @@
1
+ import { utimesSync } from 'node:fs';
2
+ /**
3
+ * Window inside which two mtimes are considered "the same write".
4
+ *
5
+ * Mount population preserves source mtimes onto copies via utimes, which
6
+ * round-trips through fractional-second floats with sub-microsecond error —
7
+ * and Node's own `cpSync({ preserveTimestamps: true })` truncates to whole
8
+ * milliseconds. 2ms absorbs both while staying far below the pace at which
9
+ * real writes to the same path land.
10
+ */
11
+ export const MTIME_QUICK_EQUAL_TOLERANCE_MS = 2;
12
+ /**
13
+ * Files written within this window are never quick-checked: a just-written
14
+ * file is exactly the case where a same-size rewrite could land inside the
15
+ * mtime tolerance and masquerade as unchanged. Stale mtimes (anything older
16
+ * than a few seconds) can only be within tolerance of each other because one
17
+ * side is a preserved-mtime copy of the other.
18
+ */
19
+ export const RECENT_WRITE_GUARD_MS = 5_000;
20
+ /**
21
+ * rsync-style quick check: equal sizes plus (near-)equal mtimes imply equal
22
+ * content, letting sync paths skip a full byte comparison. Only meaningful
23
+ * for regular files whose copies were made with mtime preservation; callers
24
+ * fall back to byte comparison when this returns false.
25
+ *
26
+ * Guarded two ways: mtimes must agree within the tolerance (absorbing utimes
27
+ * float round-trips and `cpSync({ preserveTimestamps })` millisecond
28
+ * truncation), and neither side may have been written inside the recency
29
+ * window — fresh writes always take the byte-comparison path.
30
+ */
31
+ export function statsImplySameContent(a, b) {
32
+ if (a.size !== b.size)
33
+ return false;
34
+ if (Math.abs(a.mtimeMs - b.mtimeMs) > MTIME_QUICK_EQUAL_TOLERANCE_MS)
35
+ return false;
36
+ const newest = Math.max(a.mtimeMs, b.mtimeMs);
37
+ return Date.now() - newest > RECENT_WRITE_GUARD_MS;
38
+ }
39
+ /**
40
+ * Carry a source file's mtime onto its copy so the two sides stat as "the
41
+ * same write" and the quick check applies. Best-effort: when it fails, the
42
+ * quick check simply degrades to a byte comparison.
43
+ */
44
+ export function preserveMtime(targetPath, sourceStat) {
45
+ try {
46
+ utimesSync(targetPath, sourceStat.atimeMs / 1000, sourceStat.mtimeMs / 1000);
47
+ }
48
+ catch {
49
+ /* quick check falls back to content comparison */
50
+ }
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relayfile/local-mount",
3
- "version": "0.10.21",
3
+ "version": "0.10.23",
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",