@relayfile/local-mount 0.10.22 → 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.
- package/dist/auto-sync.d.ts +32 -0
- package/dist/auto-sync.js +41 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/mount.d.ts +42 -1
- package/dist/mount.js +502 -28
- package/dist/stat-compare.d.ts +37 -0
- package/dist/stat-compare.js +51 -0
- package/package.json +1 -1
package/dist/auto-sync.d.ts
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
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 →
|
|
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
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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 (
|
|
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,
|
|
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,
|
|
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 ??
|
|
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
|
-
//
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
|
|
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,
|
|
377
|
-
const relative = resolveSyncRelativePath(sourceFile, mountDir, readonlyMatcher,
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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",
|