@mhalle/vost 0.8.1

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.
Files changed (51) hide show
  1. package/LICENSE +191 -0
  2. package/README.md +24 -0
  3. package/dist/batch.d.ts +82 -0
  4. package/dist/batch.js +152 -0
  5. package/dist/batch.js.map +1 -0
  6. package/dist/copy.d.ts +242 -0
  7. package/dist/copy.js +1229 -0
  8. package/dist/copy.js.map +1 -0
  9. package/dist/exclude.d.ts +68 -0
  10. package/dist/exclude.js +157 -0
  11. package/dist/exclude.js.map +1 -0
  12. package/dist/fileobj.d.ts +82 -0
  13. package/dist/fileobj.js +127 -0
  14. package/dist/fileobj.js.map +1 -0
  15. package/dist/fs.d.ts +581 -0
  16. package/dist/fs.js +1318 -0
  17. package/dist/fs.js.map +1 -0
  18. package/dist/gitstore.d.ts +74 -0
  19. package/dist/gitstore.js +131 -0
  20. package/dist/gitstore.js.map +1 -0
  21. package/dist/glob.d.ts +14 -0
  22. package/dist/glob.js +68 -0
  23. package/dist/glob.js.map +1 -0
  24. package/dist/index.d.ts +37 -0
  25. package/dist/index.js +51 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/lock.d.ts +15 -0
  28. package/dist/lock.js +71 -0
  29. package/dist/lock.js.map +1 -0
  30. package/dist/mirror.d.ts +53 -0
  31. package/dist/mirror.js +270 -0
  32. package/dist/mirror.js.map +1 -0
  33. package/dist/notes.d.ts +148 -0
  34. package/dist/notes.js +508 -0
  35. package/dist/notes.js.map +1 -0
  36. package/dist/paths.d.ts +16 -0
  37. package/dist/paths.js +44 -0
  38. package/dist/paths.js.map +1 -0
  39. package/dist/refdict.d.ts +117 -0
  40. package/dist/refdict.js +267 -0
  41. package/dist/refdict.js.map +1 -0
  42. package/dist/reflog.d.ts +33 -0
  43. package/dist/reflog.js +83 -0
  44. package/dist/reflog.js.map +1 -0
  45. package/dist/tree.d.ts +79 -0
  46. package/dist/tree.js +283 -0
  47. package/dist/tree.js.map +1 -0
  48. package/dist/types.d.ts +354 -0
  49. package/dist/types.js +302 -0
  50. package/dist/types.js.map +1 -0
  51. package/package.json +58 -0
package/dist/copy.js ADDED
@@ -0,0 +1,1229 @@
1
+ /**
2
+ * Copy/sync/remove/move operations between local disk and git repo.
3
+ *
4
+ * Ports the Python vost copy/ subpackage into a single module.
5
+ */
6
+ import { createHash } from 'node:crypto';
7
+ import { join, relative, basename, dirname } from 'node:path';
8
+ import git from 'isomorphic-git';
9
+ import { MODE_BLOB_EXEC, MODE_LINK, FileNotFoundError, IsADirectoryError, NotADirectoryError, fileEntryFromMode, emptyChangeReport, finalizeChanges, FileType, } from './types.js';
10
+ import { normalizePath } from './paths.js';
11
+ import { entryAtPath, modeFromDisk } from './tree.js';
12
+ import { globMatch } from './glob.js';
13
+ // ---------------------------------------------------------------------------
14
+ // Hashing
15
+ // ---------------------------------------------------------------------------
16
+ const HASH_CHUNK_SIZE = 65536;
17
+ function blobHasher(size) {
18
+ const h = createHash('sha1');
19
+ h.update(`blob ${size}\0`);
20
+ return h;
21
+ }
22
+ async function localFileOid(fsModule, fullPath, followSymlinks = false) {
23
+ const stat = await fsModule.promises.lstat(fullPath);
24
+ if (!followSymlinks && stat.isSymbolicLink()) {
25
+ const target = await fsModule.promises.readlink(fullPath);
26
+ const data = Buffer.from(target, 'utf8');
27
+ const h = blobHasher(data.length);
28
+ h.update(data);
29
+ return h.digest('hex');
30
+ }
31
+ const data = (await fsModule.promises.readFile(fullPath));
32
+ const h = blobHasher(data.length);
33
+ h.update(data);
34
+ return h.digest('hex');
35
+ }
36
+ // ---------------------------------------------------------------------------
37
+ // Directory walking
38
+ // ---------------------------------------------------------------------------
39
+ async function walkLocalPaths(fsModule, localPath, followSymlinks = false, exclude) {
40
+ const result = new Set();
41
+ async function recurse(dir, relDir) {
42
+ let entries;
43
+ try {
44
+ entries = (await fsModule.promises.readdir(dir));
45
+ }
46
+ catch {
47
+ return;
48
+ }
49
+ for (const name of entries) {
50
+ const full = join(dir, name);
51
+ const rel = relDir ? `${relDir}/${name}` : name;
52
+ let stat;
53
+ try {
54
+ stat = await fsModule.promises.lstat(full);
55
+ }
56
+ catch {
57
+ continue;
58
+ }
59
+ if (stat.isDirectory()) {
60
+ if (exclude && exclude.active && exclude.isExcluded(rel, true))
61
+ continue;
62
+ if (!followSymlinks && stat.isSymbolicLink()) {
63
+ result.add(rel);
64
+ }
65
+ else {
66
+ await recurse(full, rel);
67
+ }
68
+ }
69
+ else {
70
+ if (exclude && exclude.active && exclude.isExcluded(rel, false))
71
+ continue;
72
+ result.add(rel);
73
+ }
74
+ }
75
+ }
76
+ await recurse(localPath, '');
77
+ return result;
78
+ }
79
+ /**
80
+ * Walk a git tree and return file entries as a map.
81
+ *
82
+ * Builds a `{relativePath: {oid, mode}}` map for all files under
83
+ * `repoPath`. OID values are hex strings suitable for comparison
84
+ * against local file hashes. Returns an empty map when `repoPath`
85
+ * does not exist or is not a directory.
86
+ *
87
+ * @param fs - Filesystem snapshot to walk.
88
+ * @param repoPath - Root path in the repo tree (empty string for root).
89
+ * @returns Map of relative paths to `{oid, mode}` objects.
90
+ */
91
+ export async function walkRepo(fs, repoPath) {
92
+ const result = new Map();
93
+ if (repoPath) {
94
+ if (!(await fs.exists(repoPath)))
95
+ return result;
96
+ if (!(await fs.isDir(repoPath)))
97
+ return result;
98
+ }
99
+ const walkPath = repoPath || null;
100
+ for await (const [dirpath, , files] of fs.walk(walkPath)) {
101
+ for (const fe of files) {
102
+ const storePath = dirpath ? `${dirpath}/${fe.name}` : fe.name;
103
+ let rel;
104
+ if (repoPath && storePath.startsWith(repoPath + '/')) {
105
+ rel = storePath.slice(repoPath.length + 1);
106
+ }
107
+ else {
108
+ rel = storePath;
109
+ }
110
+ result.set(rel, { oid: fe.oid, mode: fe.mode });
111
+ }
112
+ }
113
+ return result;
114
+ }
115
+ // ---------------------------------------------------------------------------
116
+ // Disk-side glob
117
+ // ---------------------------------------------------------------------------
118
+ /**
119
+ * Expand a glob pattern against the local filesystem.
120
+ *
121
+ * Uses the same dotfile-aware rules as the repo-side `fs.glob()`:
122
+ * `*` and `?` do not match a leading `.` unless the pattern segment
123
+ * itself starts with `.`.
124
+ *
125
+ * @param fsModule - Node.js-compatible filesystem module.
126
+ * @param pattern - Glob pattern (e.g. `"src/**\/*.ts"`). Supports `*`, `?`, and `**`.
127
+ * @returns Sorted list of matching paths.
128
+ */
129
+ export async function diskGlob(fsModule, pattern) {
130
+ pattern = pattern.replace(/\/+$/, '');
131
+ if (!pattern)
132
+ return [];
133
+ pattern = pattern.replace(/\\/g, '/');
134
+ const isAbs = pattern.startsWith('/');
135
+ if (isAbs) {
136
+ const rest = pattern.slice(1);
137
+ const segments = rest ? rest.split('/') : [];
138
+ const results = await diskGlobWalk(fsModule, segments, '/');
139
+ return results.sort();
140
+ }
141
+ const segments = pattern.split('/');
142
+ const results = await diskGlobWalk(fsModule, segments, '');
143
+ return results.sort();
144
+ }
145
+ async function diskGlobWalk(fsModule, segments, prefix) {
146
+ const seg = segments[0];
147
+ const rest = segments.slice(1);
148
+ const scanDir = prefix || '.';
149
+ if (seg === '**') {
150
+ let entries;
151
+ try {
152
+ entries = (await fsModule.promises.readdir(scanDir));
153
+ }
154
+ catch {
155
+ return [];
156
+ }
157
+ const results = [];
158
+ if (rest.length > 0) {
159
+ results.push(...(await diskGlobWalk(fsModule, rest, prefix)));
160
+ }
161
+ else {
162
+ for (const name of entries) {
163
+ if (name.startsWith('.'))
164
+ continue;
165
+ results.push(prefix ? join(prefix, name) : name);
166
+ }
167
+ }
168
+ for (const name of entries) {
169
+ if (name.startsWith('.'))
170
+ continue;
171
+ const full = prefix ? join(prefix, name) : name;
172
+ try {
173
+ const stat = await fsModule.promises.stat(full);
174
+ if (stat.isDirectory()) {
175
+ results.push(...(await diskGlobWalk(fsModule, segments, full)));
176
+ }
177
+ }
178
+ catch { /* skip */ }
179
+ }
180
+ return results;
181
+ }
182
+ const hasWild = seg.includes('*') || seg.includes('?');
183
+ if (hasWild) {
184
+ let entries;
185
+ try {
186
+ entries = (await fsModule.promises.readdir(scanDir));
187
+ }
188
+ catch {
189
+ return [];
190
+ }
191
+ const results = [];
192
+ for (const name of entries) {
193
+ if (!globMatch(seg, name))
194
+ continue;
195
+ const full = prefix ? join(prefix, name) : name;
196
+ if (rest.length > 0) {
197
+ results.push(...(await diskGlobWalk(fsModule, rest, full)));
198
+ }
199
+ else {
200
+ results.push(full);
201
+ }
202
+ }
203
+ return results;
204
+ }
205
+ const full = prefix ? join(prefix, seg) : seg;
206
+ if (rest.length > 0) {
207
+ return diskGlobWalk(fsModule, rest, full);
208
+ }
209
+ try {
210
+ await fsModule.promises.access(full);
211
+ return [full];
212
+ }
213
+ catch {
214
+ return [];
215
+ }
216
+ }
217
+ async function resolveDiskSources(fsModule, sources) {
218
+ const resolved = [];
219
+ for (const src of sources) {
220
+ const contentsMode = src.endsWith('/');
221
+ if (contentsMode) {
222
+ const path = src.replace(/\/+$/, '');
223
+ let stat;
224
+ try {
225
+ stat = await fsModule.promises.stat(path);
226
+ }
227
+ catch {
228
+ throw new NotADirectoryError(path);
229
+ }
230
+ if (!stat.isDirectory())
231
+ throw new NotADirectoryError(path);
232
+ resolved.push({ localPath: path, mode: 'contents', prefix: '' });
233
+ }
234
+ else {
235
+ let stat;
236
+ try {
237
+ stat = await fsModule.promises.stat(src);
238
+ }
239
+ catch {
240
+ throw new FileNotFoundError(src);
241
+ }
242
+ if (stat.isDirectory()) {
243
+ resolved.push({ localPath: src, mode: 'dir', prefix: '' });
244
+ }
245
+ else {
246
+ resolved.push({ localPath: src, mode: 'file', prefix: '' });
247
+ }
248
+ }
249
+ }
250
+ return resolved;
251
+ }
252
+ /**
253
+ * Resolve repo source paths into their copy mode (file/dir/contents).
254
+ *
255
+ * Trailing `/` means contents mode; bare directory names mean directory mode;
256
+ * files mean file mode. Empty string or `/` means root contents.
257
+ *
258
+ * @param fs - The FS snapshot to resolve paths against.
259
+ * @param sources - Array of repo paths to resolve.
260
+ * @returns Array of resolved sources with mode and prefix.
261
+ * @throws {FileNotFoundError} If a source path does not exist.
262
+ * @throws {NotADirectoryError} If a trailing-`/` source is not a directory.
263
+ */
264
+ export async function resolveRepoSources(fs, sources) {
265
+ const resolved = [];
266
+ for (const src of sources) {
267
+ const contentsMode = src.endsWith('/');
268
+ if (contentsMode) {
269
+ const path = src.replace(/\/+$/, '');
270
+ const normalized = path ? normalizePath(path) : '';
271
+ if (normalized && !(await fs.isDir(normalized))) {
272
+ throw new NotADirectoryError(normalized);
273
+ }
274
+ resolved.push({ repoPath: normalized, mode: 'contents', prefix: '' });
275
+ }
276
+ else {
277
+ const normalized = src ? normalizePath(src) : '';
278
+ if (!normalized) {
279
+ resolved.push({ repoPath: '', mode: 'contents', prefix: '' });
280
+ }
281
+ else if (!(await fs.exists(normalized))) {
282
+ throw new FileNotFoundError(normalized);
283
+ }
284
+ else if (await fs.isDir(normalized)) {
285
+ resolved.push({ repoPath: normalized, mode: 'dir', prefix: '' });
286
+ }
287
+ else {
288
+ resolved.push({ repoPath: normalized, mode: 'file', prefix: '' });
289
+ }
290
+ }
291
+ }
292
+ return resolved;
293
+ }
294
+ // ---------------------------------------------------------------------------
295
+ // File enumeration
296
+ // ---------------------------------------------------------------------------
297
+ async function enumDiskToRepo(fsModule, resolved, dest, followSymlinks = false, exclude) {
298
+ const pairs = [];
299
+ for (const { localPath, mode, prefix } of resolved) {
300
+ const _dest = [dest, prefix].filter(Boolean).join('/');
301
+ if (mode === 'file') {
302
+ const name = basename(localPath);
303
+ if (exclude && exclude.active && exclude.isExcluded(name, false))
304
+ continue;
305
+ const repoFile = _dest ? `${_dest}/${name}` : name;
306
+ pairs.push([localPath, normalizePath(repoFile)]);
307
+ }
308
+ else if (mode === 'dir') {
309
+ const dirName = basename(localPath);
310
+ const target = _dest ? `${_dest}/${dirName}` : dirName;
311
+ const rels = await walkLocalPaths(fsModule, localPath, followSymlinks, exclude);
312
+ for (const rel of [...rels].sort()) {
313
+ pairs.push([join(localPath, rel), normalizePath(`${target}/${rel}`)]);
314
+ }
315
+ }
316
+ else {
317
+ // contents
318
+ const rels = await walkLocalPaths(fsModule, localPath, followSymlinks, exclude);
319
+ for (const rel of [...rels].sort()) {
320
+ const repoFile = _dest ? `${_dest}/${rel}` : rel;
321
+ pairs.push([join(localPath, rel), normalizePath(repoFile)]);
322
+ }
323
+ }
324
+ }
325
+ return pairs;
326
+ }
327
+ async function enumRepoToDisk(fs, resolved, dest) {
328
+ const pairs = [];
329
+ for (const { repoPath, mode, prefix } of resolved) {
330
+ const _dest = prefix ? join(dest, prefix) : dest;
331
+ if (mode === 'file') {
332
+ const name = repoPath.includes('/') ? repoPath.split('/').pop() : repoPath;
333
+ pairs.push([repoPath, join(_dest, name)]);
334
+ }
335
+ else if (mode === 'dir') {
336
+ const dirName = repoPath.includes('/') ? repoPath.split('/').pop() : repoPath;
337
+ const target = join(_dest, dirName);
338
+ for await (const [dirpath, , files] of fs.walk(repoPath)) {
339
+ for (const fe of files) {
340
+ const storePath = dirpath ? `${dirpath}/${fe.name}` : fe.name;
341
+ const rel = repoPath && storePath.startsWith(repoPath + '/')
342
+ ? storePath.slice(repoPath.length + 1)
343
+ : storePath;
344
+ pairs.push([storePath, join(target, rel)]);
345
+ }
346
+ }
347
+ }
348
+ else {
349
+ // contents
350
+ const walkPath = repoPath || null;
351
+ for await (const [dirpath, , files] of fs.walk(walkPath)) {
352
+ for (const fe of files) {
353
+ const storePath = dirpath ? `${dirpath}/${fe.name}` : fe.name;
354
+ const rel = repoPath && storePath.startsWith(repoPath + '/')
355
+ ? storePath.slice(repoPath.length + 1)
356
+ : storePath;
357
+ pairs.push([storePath, join(_dest, rel)]);
358
+ }
359
+ }
360
+ }
361
+ }
362
+ return pairs;
363
+ }
364
+ async function enumRepoToRepo(fs, resolved, dest) {
365
+ const pairs = [];
366
+ for (const { repoPath, mode, prefix } of resolved) {
367
+ const _dest = [dest, prefix].filter(Boolean).join('/');
368
+ if (mode === 'file') {
369
+ const name = repoPath.includes('/') ? repoPath.split('/').pop() : repoPath;
370
+ const destFile = _dest ? `${_dest}/${name}` : name;
371
+ pairs.push([repoPath, normalizePath(destFile)]);
372
+ }
373
+ else if (mode === 'dir') {
374
+ const dirName = repoPath.includes('/') ? repoPath.split('/').pop() : repoPath;
375
+ const target = _dest ? `${_dest}/${dirName}` : dirName;
376
+ for await (const [dirpath, , files] of fs.walk(repoPath)) {
377
+ for (const fe of files) {
378
+ const storePath = dirpath ? `${dirpath}/${fe.name}` : fe.name;
379
+ const rel = repoPath && storePath.startsWith(repoPath + '/')
380
+ ? storePath.slice(repoPath.length + 1)
381
+ : storePath;
382
+ pairs.push([storePath, normalizePath(`${target}/${rel}`)]);
383
+ }
384
+ }
385
+ }
386
+ else {
387
+ const walkPath = repoPath || null;
388
+ for await (const [dirpath, , files] of fs.walk(walkPath)) {
389
+ for (const fe of files) {
390
+ const storePath = dirpath ? `${dirpath}/${fe.name}` : fe.name;
391
+ const rel = repoPath && storePath.startsWith(repoPath + '/')
392
+ ? storePath.slice(repoPath.length + 1)
393
+ : storePath;
394
+ const destFile = _dest ? `${_dest}/${rel}` : rel;
395
+ pairs.push([storePath, normalizePath(destFile)]);
396
+ }
397
+ }
398
+ }
399
+ }
400
+ return pairs;
401
+ }
402
+ // ---------------------------------------------------------------------------
403
+ // File writing helpers
404
+ // ---------------------------------------------------------------------------
405
+ async function writeFilesToRepo(batch, fsModule, pairs, opts) {
406
+ for (const [localPath, repoPath] of pairs) {
407
+ try {
408
+ const stat = await fsModule.promises.lstat(localPath);
409
+ if (!opts.followSymlinks && stat.isSymbolicLink()) {
410
+ const target = await fsModule.promises.readlink(localPath);
411
+ await batch.writeSymlink(repoPath, target);
412
+ }
413
+ else {
414
+ await batch.writeFromFile(repoPath, localPath, { mode: opts.mode });
415
+ }
416
+ }
417
+ catch (err) {
418
+ if (!opts.ignoreErrors)
419
+ throw err;
420
+ opts.errors?.push({ path: localPath, error: String(err) });
421
+ }
422
+ }
423
+ }
424
+ async function writeFilesToDisk(fs, pairs, opts) {
425
+ const fsModule = fs._store._fsModule;
426
+ for (const [repoPath, localPath] of pairs) {
427
+ try {
428
+ // Clear blocking parent paths: if a parent is a file, remove it
429
+ const parentDir = dirname(localPath);
430
+ const parts = parentDir.split('/');
431
+ for (let i = 1; i <= parts.length; i++) {
432
+ const p = parts.slice(0, i).join('/');
433
+ if (!p)
434
+ continue;
435
+ try {
436
+ const st = await fsModule.promises.lstat(p);
437
+ if (!st.isDirectory()) {
438
+ await fsModule.promises.unlink(p);
439
+ break;
440
+ }
441
+ }
442
+ catch { /* doesn't exist yet */
443
+ break;
444
+ }
445
+ }
446
+ await fsModule.promises.mkdir(parentDir, { recursive: true });
447
+ // If dest is a directory but we need a file, remove the dir tree
448
+ try {
449
+ const st = await fsModule.promises.lstat(localPath);
450
+ if (st.isDirectory() && !st.isSymbolicLink()) {
451
+ await fsModule.promises.rm(localPath, { recursive: true, force: true });
452
+ }
453
+ else {
454
+ await fsModule.promises.unlink(localPath);
455
+ }
456
+ }
457
+ catch { /* doesn't exist */ }
458
+ const entry = await entryAtPath(fsModule, fs._store._gitdir, fs._treeOid, repoPath);
459
+ if (entry && entry.mode === MODE_LINK) {
460
+ const target = await fs.readlink(repoPath);
461
+ await fsModule.promises.symlink(target, localPath);
462
+ }
463
+ else {
464
+ const data = await fs.read(repoPath);
465
+ await fsModule.promises.writeFile(localPath, data);
466
+ if (entry && entry.mode === MODE_BLOB_EXEC) {
467
+ await fsModule.promises.chmod(localPath, 0o755);
468
+ }
469
+ }
470
+ }
471
+ catch (err) {
472
+ if (!opts.ignoreErrors)
473
+ throw err;
474
+ opts.errors?.push({ path: localPath, error: String(err) });
475
+ }
476
+ }
477
+ }
478
+ function filterTreeConflicts(writePaths, deletes) {
479
+ return deletes.filter((d) => {
480
+ for (const w of writePaths) {
481
+ if (d.startsWith(w + '/') || w.startsWith(d + '/'))
482
+ return false;
483
+ }
484
+ return true;
485
+ });
486
+ }
487
+ async function pruneEmptyDirs(fsModule, basePath) {
488
+ // Simple bottom-up directory pruning
489
+ async function recurse(dir) {
490
+ let entries;
491
+ try {
492
+ entries = (await fsModule.promises.readdir(dir));
493
+ }
494
+ catch {
495
+ return false;
496
+ }
497
+ let empty = true;
498
+ for (const name of entries) {
499
+ const full = join(dir, name);
500
+ try {
501
+ const stat = await fsModule.promises.stat(full);
502
+ if (stat.isDirectory()) {
503
+ const childEmpty = await recurse(full);
504
+ if (!childEmpty)
505
+ empty = false;
506
+ }
507
+ else {
508
+ empty = false;
509
+ }
510
+ }
511
+ catch {
512
+ empty = false;
513
+ }
514
+ }
515
+ if (empty && dir !== basePath) {
516
+ try {
517
+ await fsModule.promises.rmdir(dir);
518
+ }
519
+ catch { /* ignore */ }
520
+ }
521
+ return empty;
522
+ }
523
+ await recurse(basePath);
524
+ }
525
+ // ---------------------------------------------------------------------------
526
+ // Entry helpers
527
+ // ---------------------------------------------------------------------------
528
+ function makeEntriesFromDisk(fsModule, rels, relToAbs) {
529
+ return rels.map((rel) => {
530
+ const fullPath = relToAbs.get(rel) ?? rel;
531
+ return { path: rel, type: FileType.BLOB, src: fullPath };
532
+ });
533
+ }
534
+ async function makeEntriesFromRepo(fs, rels, basePath) {
535
+ const entries = [];
536
+ for (const rel of rels) {
537
+ const fullPath = basePath ? `${basePath}/${rel}` : rel;
538
+ const entry = await entryAtPath(fs._store._fsModule, fs._store._gitdir, fs._treeOid, fullPath);
539
+ if (entry) {
540
+ entries.push(fileEntryFromMode(rel, entry.mode, fullPath));
541
+ }
542
+ else {
543
+ entries.push({ path: rel, type: FileType.BLOB, src: fullPath });
544
+ }
545
+ }
546
+ return entries;
547
+ }
548
+ async function makeEntriesFromRepoDict(fs, rels, relToRepo) {
549
+ const entries = [];
550
+ for (const rel of rels) {
551
+ const repoPath = relToRepo.get(rel) ?? rel;
552
+ const entry = await entryAtPath(fs._store._fsModule, fs._store._gitdir, fs._treeOid, repoPath);
553
+ if (entry) {
554
+ entries.push(fileEntryFromMode(rel, entry.mode, repoPath));
555
+ }
556
+ else {
557
+ entries.push({ path: rel, type: FileType.BLOB, src: repoPath });
558
+ }
559
+ }
560
+ return entries;
561
+ }
562
+ // ---------------------------------------------------------------------------
563
+ // Copy: disk → repo
564
+ // ---------------------------------------------------------------------------
565
+ /**
566
+ * Copy local files, directories, or globs into the repo.
567
+ *
568
+ * Sources may use a trailing `/` for "contents" mode (pour directory
569
+ * contents into `dest` without creating a subdirectory).
570
+ *
571
+ * With `dryRun: true`, no changes are written; the input FS is
572
+ * returned with `.changes` populated.
573
+ *
574
+ * With `delete: true`, files under `dest` that are not covered by
575
+ * `sources` are removed (rsync `--delete` semantics).
576
+ *
577
+ * @param fs - Filesystem snapshot (must be writable, i.e. a branch).
578
+ * @param sources - One or more local paths to copy. A trailing `/` means "contents of directory".
579
+ * @param dest - Destination path in the repo tree.
580
+ * @param opts - Copy options.
581
+ * @param opts.dryRun - Preview changes without writing. Default `false`.
582
+ * @param opts.followSymlinks - Dereference symlinks instead of storing them. Default `false`.
583
+ * @param opts.message - Custom commit message.
584
+ * @param opts.mode - Override file mode for all written files.
585
+ * @param opts.ignoreExisting - Skip files that already exist at the destination. Default `false`.
586
+ * @param opts.delete - Remove destination files not present in sources. Default `false`.
587
+ * @param opts.ignoreErrors - Continue on per-file errors, collecting them in `changes.errors`. Default `false`.
588
+ * @param opts.checksum - Use content hashing to detect changes. When `false`, uses mtime comparison. Default `true`.
589
+ * @param opts.operation - Operation name for the commit message. Default `"cp"`.
590
+ * @returns FS snapshot after the copy, with `.changes` set.
591
+ * @throws {FileNotFoundError} If a source path does not exist (unless `ignoreErrors` is set).
592
+ * @throws {NotADirectoryError} If a trailing-`/` source is not a directory.
593
+ */
594
+ export async function copyIn(fs, sources, dest, opts = {}) {
595
+ const srcList = typeof sources === 'string' ? [sources] : sources;
596
+ const fsModule = fs._store._fsModule;
597
+ const changes = emptyChangeReport();
598
+ let resolved;
599
+ if (opts.ignoreErrors) {
600
+ resolved = [];
601
+ for (const src of srcList) {
602
+ try {
603
+ resolved.push(...await resolveDiskSources(fsModule, [src]));
604
+ }
605
+ catch (err) {
606
+ changes.errors.push({ path: src, error: String(err.message ?? err) });
607
+ }
608
+ }
609
+ if (resolved.length === 0) {
610
+ if (changes.errors.length > 0) {
611
+ throw new Error(`All files failed to copy: ${changes.errors.map((e) => e.error).join(', ')}`);
612
+ }
613
+ fs._changes = finalizeChanges(changes);
614
+ return fs;
615
+ }
616
+ }
617
+ else {
618
+ resolved = await resolveDiskSources(fsModule, srcList);
619
+ }
620
+ let pairs = await enumDiskToRepo(fsModule, resolved, dest, opts.followSymlinks, opts.exclude);
621
+ if (opts.delete) {
622
+ // Build {repo_rel: local_abs} map
623
+ const pairMap = new Map();
624
+ for (const [localPath, repoPath] of pairs) {
625
+ const rel = dest && repoPath.startsWith(dest + '/')
626
+ ? repoPath.slice(dest.length + 1)
627
+ : repoPath;
628
+ if (!pairMap.has(rel))
629
+ pairMap.set(rel, localPath);
630
+ }
631
+ const repoFiles = await walkRepo(fs, dest);
632
+ const localRels = new Set(pairMap.keys());
633
+ const repoRels = new Set(repoFiles.keys());
634
+ const addRels = [...localRels].filter((r) => !repoRels.has(r)).sort();
635
+ const deleteRels = [...repoRels].filter((r) => !localRels.has(r)).sort();
636
+ const both = [...localRels].filter((r) => repoRels.has(r)).sort();
637
+ const commitTs = opts.checksum === false ? await fs._getCommitTime() : 0;
638
+ const updateRels = [];
639
+ for (const rel of both) {
640
+ try {
641
+ const repoInfo = repoFiles.get(rel);
642
+ const localPath = pairMap.get(rel);
643
+ // mtime fast path: if file is older than commit, assume unchanged
644
+ if (opts.checksum === false) {
645
+ try {
646
+ const st = await fsModule.promises.stat(localPath);
647
+ if (Math.floor(st.mtimeMs / 1000) <= commitTs)
648
+ continue;
649
+ }
650
+ catch { /* fall through to hash */ }
651
+ }
652
+ const localOid = await localFileOid(fsModule, localPath, opts.followSymlinks);
653
+ if (localOid !== repoInfo.oid) {
654
+ updateRels.push(rel);
655
+ }
656
+ else if (repoInfo.mode !== MODE_LINK) {
657
+ const diskMode = await modeFromDisk(fsModule, localPath);
658
+ if (diskMode !== repoInfo.mode)
659
+ updateRels.push(rel);
660
+ }
661
+ }
662
+ catch {
663
+ updateRels.push(rel);
664
+ }
665
+ }
666
+ if (opts.dryRun) {
667
+ changes.add = makeEntriesFromDisk(fsModule, addRels, pairMap);
668
+ changes.update = makeEntriesFromDisk(fsModule, opts.ignoreExisting ? [] : updateRels, pairMap);
669
+ changes.delete = await makeEntriesFromRepo(fs, deleteRels, dest);
670
+ fs._changes = finalizeChanges(changes);
671
+ return fs;
672
+ }
673
+ const writeRels = [...addRels, ...(opts.ignoreExisting ? [] : updateRels)];
674
+ const writePairs = writeRels.map((rel) => [
675
+ pairMap.get(rel),
676
+ dest ? `${dest}/${rel}` : rel,
677
+ ]);
678
+ const safeDeletes = filterTreeConflicts(new Set(writeRels), deleteRels);
679
+ const deleteFull = safeDeletes.map((rel) => (dest ? `${dest}/${rel}` : rel));
680
+ changes.add = makeEntriesFromDisk(fsModule, addRels, pairMap);
681
+ changes.update = makeEntriesFromDisk(fsModule, opts.ignoreExisting ? [] : updateRels, pairMap);
682
+ changes.delete = await makeEntriesFromRepo(fs, safeDeletes, dest);
683
+ if (writePairs.length === 0 && deleteFull.length === 0) {
684
+ fs._changes = finalizeChanges(changes);
685
+ return fs;
686
+ }
687
+ const batch = fs.batch({ message: opts.message, operation: opts.operation ?? 'cp' });
688
+ await writeFilesToRepo(batch, fsModule, writePairs, {
689
+ followSymlinks: opts.followSymlinks,
690
+ mode: opts.mode,
691
+ ignoreErrors: opts.ignoreErrors,
692
+ errors: changes.errors,
693
+ });
694
+ for (const path of deleteFull) {
695
+ try {
696
+ await batch.remove(path);
697
+ }
698
+ catch { /* ignore missing */ }
699
+ }
700
+ const result = await batch.commit();
701
+ result._changes = finalizeChanges(changes);
702
+ return result;
703
+ }
704
+ // Non-delete mode
705
+ if (opts.ignoreExisting) {
706
+ const filtered = [];
707
+ for (const [l, r] of pairs) {
708
+ if (!(await fs.exists(r)))
709
+ filtered.push([l, r]);
710
+ }
711
+ pairs = filtered;
712
+ }
713
+ if (pairs.length === 0) {
714
+ fs._changes = finalizeChanges(changes);
715
+ return fs;
716
+ }
717
+ // Classify as add vs update
718
+ const addRels = [];
719
+ const updateRels = [];
720
+ const pairMap = new Map();
721
+ for (const [localPath, repoPath] of pairs) {
722
+ const rel = dest && repoPath.startsWith(dest + '/')
723
+ ? repoPath.slice(dest.length + 1)
724
+ : repoPath;
725
+ pairMap.set(rel, localPath);
726
+ if (await fs.exists(repoPath)) {
727
+ updateRels.push(rel);
728
+ }
729
+ else {
730
+ addRels.push(rel);
731
+ }
732
+ }
733
+ if (opts.dryRun) {
734
+ changes.add = makeEntriesFromDisk(fsModule, addRels, pairMap);
735
+ changes.update = makeEntriesFromDisk(fsModule, updateRels, pairMap);
736
+ fs._changes = finalizeChanges(changes);
737
+ return fs;
738
+ }
739
+ changes.add = makeEntriesFromDisk(fsModule, addRels, pairMap);
740
+ changes.update = makeEntriesFromDisk(fsModule, updateRels, pairMap);
741
+ const batch = fs.batch({ message: opts.message, operation: opts.operation ?? 'cp' });
742
+ await writeFilesToRepo(batch, fsModule, pairs, {
743
+ followSymlinks: opts.followSymlinks,
744
+ mode: opts.mode,
745
+ ignoreErrors: opts.ignoreErrors,
746
+ errors: changes.errors,
747
+ });
748
+ const result = await batch.commit();
749
+ result._changes = finalizeChanges(changes);
750
+ return result;
751
+ }
752
+ // ---------------------------------------------------------------------------
753
+ // Copy: repo → disk
754
+ // ---------------------------------------------------------------------------
755
+ /**
756
+ * Copy repo files, directories, or globs to local disk.
757
+ *
758
+ * Sources may use a trailing `/` for "contents" mode (pour directory
759
+ * contents into `dest` without creating a subdirectory).
760
+ *
761
+ * With `dryRun: true`, no changes are written; the input FS is
762
+ * returned with `.changes` populated.
763
+ *
764
+ * With `delete: true`, local files under `dest` that are not covered
765
+ * by `sources` are removed (rsync `--delete` semantics).
766
+ *
767
+ * @param fs - Filesystem snapshot to copy from.
768
+ * @param sources - One or more repo paths to copy. A trailing `/` means "contents of directory".
769
+ * @param dest - Destination directory on local disk.
770
+ * @param opts - Copy options.
771
+ * @param opts.dryRun - Preview changes without writing. Default `false`.
772
+ * @param opts.ignoreExisting - Skip files that already exist at the destination. Default `false`.
773
+ * @param opts.delete - Remove local files not present in sources. Default `false`.
774
+ * @param opts.ignoreErrors - Continue on per-file errors, collecting them in `changes.errors`. Default `false`.
775
+ * @param opts.checksum - Use content hashing to detect changes. When `false`, uses mtime comparison. Default `true`.
776
+ * @param opts.operation - Operation name for the commit message.
777
+ * @returns FS snapshot with `.changes` set describing what was copied.
778
+ * @throws {FileNotFoundError} If a source path does not exist in the repo (unless `ignoreErrors` is set).
779
+ * @throws {NotADirectoryError} If a trailing-`/` source is not a directory.
780
+ */
781
+ export async function copyOut(fs, sources, dest, opts = {}) {
782
+ const srcList = typeof sources === 'string' ? [sources] : sources;
783
+ const fsModule = fs._store._fsModule;
784
+ const changes = emptyChangeReport();
785
+ let resolved;
786
+ if (opts.ignoreErrors) {
787
+ resolved = [];
788
+ for (const src of srcList) {
789
+ try {
790
+ resolved.push(...await resolveRepoSources(fs, [src]));
791
+ }
792
+ catch (err) {
793
+ changes.errors.push({ path: src, error: String(err.message ?? err) });
794
+ }
795
+ }
796
+ if (resolved.length === 0) {
797
+ if (changes.errors.length > 0) {
798
+ throw new Error(`All files failed to copy: ${changes.errors.map((e) => e.error).join(', ')}`);
799
+ }
800
+ fs._changes = finalizeChanges(changes);
801
+ return fs;
802
+ }
803
+ }
804
+ else {
805
+ resolved = await resolveRepoSources(fs, srcList);
806
+ }
807
+ let pairs = await enumRepoToDisk(fs, resolved, dest);
808
+ if (opts.delete) {
809
+ await fsModule.promises.mkdir(dest, { recursive: true });
810
+ const pairMap = new Map();
811
+ for (const [repoPath, localPath] of pairs) {
812
+ const rel = relative(dest, localPath).replace(/\\/g, '/');
813
+ if (!pairMap.has(rel))
814
+ pairMap.set(rel, repoPath);
815
+ }
816
+ const repoFiles = new Map();
817
+ for (const [rel, rp] of pairMap) {
818
+ const entry = await entryAtPath(fsModule, fs._store._gitdir, fs._treeOid, rp);
819
+ if (entry)
820
+ repoFiles.set(rel, entry);
821
+ }
822
+ const localPaths = await walkLocalPaths(fsModule, dest);
823
+ const sourceRels = new Set(pairMap.keys());
824
+ const addRels = [...sourceRels].filter((r) => !localPaths.has(r)).sort();
825
+ const deleteRels = [...localPaths].filter((r) => !sourceRels.has(r)).sort();
826
+ const both = [...sourceRels].filter((r) => localPaths.has(r)).sort();
827
+ const commitTs = opts.checksum === false ? await fs._getCommitTime() : 0;
828
+ const updateRels = [];
829
+ for (const rel of both) {
830
+ const repoInfo = repoFiles.get(rel);
831
+ if (!repoInfo)
832
+ continue;
833
+ try {
834
+ const localPath = join(dest, rel);
835
+ // mtime fast path: if file is older than commit, assume unchanged
836
+ if (opts.checksum === false) {
837
+ try {
838
+ const st = await fsModule.promises.stat(localPath);
839
+ if (Math.floor(st.mtimeMs / 1000) <= commitTs)
840
+ continue;
841
+ }
842
+ catch { /* fall through to hash */ }
843
+ }
844
+ const oid = await localFileOid(fsModule, localPath);
845
+ if (oid !== repoInfo.oid) {
846
+ updateRels.push(rel);
847
+ }
848
+ else if (repoInfo.mode !== MODE_LINK) {
849
+ const diskMode = await modeFromDisk(fsModule, localPath);
850
+ if (diskMode !== repoInfo.mode)
851
+ updateRels.push(rel);
852
+ }
853
+ }
854
+ catch {
855
+ updateRels.push(rel);
856
+ }
857
+ }
858
+ if (opts.dryRun) {
859
+ changes.add = await makeEntriesFromRepoDict(fs, addRels, pairMap);
860
+ changes.update = await makeEntriesFromRepoDict(fs, opts.ignoreExisting ? [] : updateRels, pairMap);
861
+ changes.delete = deleteRels.map((rel) => ({ path: rel, type: FileType.BLOB }));
862
+ fs._changes = finalizeChanges(changes);
863
+ return fs;
864
+ }
865
+ // Delete local files
866
+ for (const rel of deleteRels) {
867
+ try {
868
+ await fsModule.promises.unlink(join(dest, rel));
869
+ }
870
+ catch { /* ignore */ }
871
+ }
872
+ const writeRels = [...addRels, ...(opts.ignoreExisting ? [] : updateRels)];
873
+ const writePairs = writeRels.map((rel) => [
874
+ pairMap.get(rel),
875
+ join(dest, rel),
876
+ ]);
877
+ await writeFilesToDisk(fs, writePairs, {
878
+ ignoreErrors: opts.ignoreErrors,
879
+ errors: changes.errors,
880
+ });
881
+ await pruneEmptyDirs(fsModule, dest);
882
+ changes.add = await makeEntriesFromRepoDict(fs, addRels, pairMap);
883
+ changes.update = await makeEntriesFromRepoDict(fs, opts.ignoreExisting ? [] : updateRels, pairMap);
884
+ changes.delete = deleteRels.map((rel) => ({ path: rel, type: FileType.BLOB }));
885
+ fs._changes = finalizeChanges(changes);
886
+ return fs;
887
+ }
888
+ // Non-delete mode
889
+ if (opts.ignoreExisting) {
890
+ const filtered = [];
891
+ for (const [r, l] of pairs) {
892
+ try {
893
+ await fsModule.promises.access(l);
894
+ }
895
+ catch {
896
+ filtered.push([r, l]);
897
+ }
898
+ }
899
+ pairs = filtered;
900
+ }
901
+ if (pairs.length === 0) {
902
+ fs._changes = finalizeChanges(changes);
903
+ return fs;
904
+ }
905
+ if (opts.dryRun) {
906
+ const addRels = [];
907
+ const updateRels = [];
908
+ const relToRepo = new Map();
909
+ for (const [repoPath, localPath] of pairs) {
910
+ const rel = relative(dest, localPath).replace(/\\/g, '/');
911
+ relToRepo.set(rel, repoPath);
912
+ try {
913
+ await fsModule.promises.access(localPath);
914
+ updateRels.push(rel);
915
+ }
916
+ catch {
917
+ addRels.push(rel);
918
+ }
919
+ }
920
+ changes.add = await makeEntriesFromRepoDict(fs, addRels.sort(), relToRepo);
921
+ changes.update = await makeEntriesFromRepoDict(fs, updateRels.sort(), relToRepo);
922
+ fs._changes = finalizeChanges(changes);
923
+ return fs;
924
+ }
925
+ // Classify and write
926
+ const addRels = [];
927
+ const updateRels = [];
928
+ const relToRepo = new Map();
929
+ for (const [repoPath, localPath] of pairs) {
930
+ const rel = relative(dest, localPath).replace(/\\/g, '/');
931
+ relToRepo.set(rel, repoPath);
932
+ try {
933
+ await fsModule.promises.access(localPath);
934
+ updateRels.push(rel);
935
+ }
936
+ catch {
937
+ addRels.push(rel);
938
+ }
939
+ }
940
+ await writeFilesToDisk(fs, pairs, {
941
+ ignoreErrors: opts.ignoreErrors,
942
+ errors: changes.errors,
943
+ });
944
+ changes.add = await makeEntriesFromRepoDict(fs, addRels, relToRepo);
945
+ changes.update = await makeEntriesFromRepoDict(fs, updateRels, relToRepo);
946
+ fs._changes = finalizeChanges(changes);
947
+ return fs;
948
+ }
949
+ // ---------------------------------------------------------------------------
950
+ // Remove
951
+ // ---------------------------------------------------------------------------
952
+ async function collectRemovePaths(fs, sources, recursive = false) {
953
+ const resolved = await resolveRepoSources(fs, sources);
954
+ const deletePaths = [];
955
+ for (const { repoPath, mode } of resolved) {
956
+ if (mode === 'file') {
957
+ deletePaths.push(repoPath);
958
+ }
959
+ else if (mode === 'dir' || mode === 'contents') {
960
+ if (!recursive) {
961
+ throw new IsADirectoryError(`${repoPath} is a directory (use recursive=true)`);
962
+ }
963
+ const walkRoot = repoPath || null;
964
+ for await (const [dirpath, , files] of fs.walk(walkRoot)) {
965
+ for (const fe of files) {
966
+ deletePaths.push(dirpath ? `${dirpath}/${fe.name}` : fe.name);
967
+ }
968
+ }
969
+ }
970
+ }
971
+ return [...new Set(deletePaths)].sort();
972
+ }
973
+ /**
974
+ * Remove files or directories from the repo.
975
+ *
976
+ * With `dryRun: true`, no changes are written; the input FS is
977
+ * returned with `.changes` populated.
978
+ *
979
+ * @param fs - Filesystem snapshot (must be writable, i.e. a branch).
980
+ * @param sources - One or more repo paths to remove.
981
+ * @param opts - Remove options.
982
+ * @param opts.recursive - Allow removal of directories. Default `false`.
983
+ * @param opts.dryRun - Preview changes without writing. Default `false`.
984
+ * @param opts.message - Custom commit message.
985
+ * @returns FS snapshot after the removal, with `.changes` set.
986
+ * @throws {FileNotFoundError} If no source matches any file.
987
+ * @throws {IsADirectoryError} If a source is a directory and `recursive` is `false`.
988
+ */
989
+ export async function remove(fs, sources, opts = {}) {
990
+ const srcList = typeof sources === 'string' ? [sources] : sources;
991
+ const deletePaths = await collectRemovePaths(fs, srcList, opts.recursive);
992
+ if (deletePaths.length === 0)
993
+ throw new FileNotFoundError(`No matches for: ${srcList}`);
994
+ const changes = emptyChangeReport();
995
+ const relToRepo = new Map(deletePaths.map((p) => [p, p]));
996
+ changes.delete = await makeEntriesFromRepoDict(fs, deletePaths, relToRepo);
997
+ if (opts.dryRun) {
998
+ fs._changes = finalizeChanges(changes);
999
+ return fs;
1000
+ }
1001
+ const batch = fs.batch({ message: opts.message, operation: 'rm' });
1002
+ for (const path of deletePaths) {
1003
+ await batch.remove(path);
1004
+ }
1005
+ const result = await batch.commit();
1006
+ result._changes = finalizeChanges(changes);
1007
+ return result;
1008
+ }
1009
+ // ---------------------------------------------------------------------------
1010
+ // Sync
1011
+ // ---------------------------------------------------------------------------
1012
+ /**
1013
+ * Make `repoPath` identical to `localPath` (disk to repo sync).
1014
+ *
1015
+ * Copies new and changed files from `localPath` into `repoPath` and
1016
+ * deletes repo files that do not exist on disk. Equivalent to
1017
+ * `copyIn` with `delete: true`.
1018
+ *
1019
+ * If `localPath` does not exist, all files under `repoPath` are
1020
+ * deleted (treating the source as empty).
1021
+ *
1022
+ * With `dryRun: true`, no changes are written; the input FS is
1023
+ * returned with `.changes` populated.
1024
+ *
1025
+ * @param fs - Filesystem snapshot (must be writable, i.e. a branch).
1026
+ * @param localPath - Source directory on local disk.
1027
+ * @param repoPath - Destination path in the repo tree.
1028
+ * @param opts - Sync options.
1029
+ * @param opts.dryRun - Preview changes without writing. Default `false`.
1030
+ * @param opts.message - Custom commit message.
1031
+ * @param opts.ignoreErrors - Continue on per-file errors. Default `false`.
1032
+ * @param opts.checksum - Use content hashing. When `false`, uses mtime. Default `true`.
1033
+ * @returns FS snapshot after sync, with `.changes` set.
1034
+ * @throws {PermissionError} If the FS is read-only.
1035
+ */
1036
+ export async function syncIn(fs, localPath, repoPath, opts = {}) {
1037
+ const src = localPath.endsWith('/') ? localPath : localPath + '/';
1038
+ try {
1039
+ return await copyIn(fs, [src], repoPath, {
1040
+ ...opts,
1041
+ delete: true,
1042
+ operation: 'sync',
1043
+ });
1044
+ }
1045
+ catch (err) {
1046
+ if (err instanceof FileNotFoundError || err instanceof NotADirectoryError) {
1047
+ // Nonexistent local → delete everything under repoPath
1048
+ const dest = repoPath ? normalizePath(repoPath) : '';
1049
+ const repoFiles = await walkRepo(fs, dest);
1050
+ if (repoFiles.size === 0) {
1051
+ // Check if dest is a single file
1052
+ if (dest && (await fs.exists(dest)) && !(await fs.isDir(dest))) {
1053
+ if (opts.dryRun) {
1054
+ const entry = await entryAtPath(fs._store._fsModule, fs._store._gitdir, fs._treeOid, dest);
1055
+ const changes = emptyChangeReport();
1056
+ changes.delete = entry ? [fileEntryFromMode(dest, entry.mode)] : [{ path: dest, type: FileType.BLOB }];
1057
+ fs._changes = changes;
1058
+ return fs;
1059
+ }
1060
+ const batch = fs.batch({ message: opts.message, operation: 'sync' });
1061
+ await batch.remove(dest);
1062
+ return await batch.commit();
1063
+ }
1064
+ fs._changes = null;
1065
+ return fs;
1066
+ }
1067
+ if (opts.dryRun) {
1068
+ const changes = emptyChangeReport();
1069
+ changes.delete = await makeEntriesFromRepo(fs, [...repoFiles.keys()].sort(), dest);
1070
+ fs._changes = finalizeChanges(changes);
1071
+ return fs;
1072
+ }
1073
+ const batch = fs.batch({ message: opts.message });
1074
+ for (const rel of [...repoFiles.keys()].sort()) {
1075
+ const full = dest ? `${dest}/${rel}` : rel;
1076
+ await batch.remove(full);
1077
+ }
1078
+ const result = await batch.commit();
1079
+ result._changes = emptyChangeReport();
1080
+ result._changes.delete = await makeEntriesFromRepo(fs, [...repoFiles.keys()].sort(), dest);
1081
+ return result;
1082
+ }
1083
+ throw err;
1084
+ }
1085
+ }
1086
+ /**
1087
+ * Make `localPath` identical to `repoPath` (repo to disk sync).
1088
+ *
1089
+ * Copies new and changed files from the repo to `localPath` and
1090
+ * deletes local files that do not exist in the repo. Equivalent to
1091
+ * `copyOut` with `delete: true`.
1092
+ *
1093
+ * If `repoPath` does not exist in the repo, all files under
1094
+ * `localPath` are deleted (treating the source as empty).
1095
+ *
1096
+ * With `dryRun: true`, no changes are written; the input FS is
1097
+ * returned with `.changes` populated.
1098
+ *
1099
+ * @param fs - Filesystem snapshot to sync from.
1100
+ * @param repoPath - Source path in the repo tree.
1101
+ * @param localPath - Destination directory on local disk.
1102
+ * @param opts - Sync options.
1103
+ * @param opts.dryRun - Preview changes without writing. Default `false`.
1104
+ * @param opts.ignoreErrors - Continue on per-file errors. Default `false`.
1105
+ * @param opts.checksum - Use content hashing. When `false`, uses mtime. Default `true`.
1106
+ * @returns FS snapshot with `.changes` set describing what was synced.
1107
+ */
1108
+ export async function syncOut(fs, repoPath, localPath, opts = {}) {
1109
+ const src = repoPath.endsWith('/') ? repoPath : repoPath + '/';
1110
+ try {
1111
+ return await copyOut(fs, [src], localPath, { ...opts, delete: true });
1112
+ }
1113
+ catch (err) {
1114
+ if (err instanceof FileNotFoundError || err instanceof NotADirectoryError) {
1115
+ // Nonexistent repo path → delete everything local
1116
+ const fsModule = fs._store._fsModule;
1117
+ const localPaths = await walkLocalPaths(fsModule, localPath);
1118
+ if (localPaths.size === 0) {
1119
+ fs._changes = null;
1120
+ return fs;
1121
+ }
1122
+ if (opts.dryRun) {
1123
+ const changes = emptyChangeReport();
1124
+ changes.delete = [...localPaths].sort().map((p) => ({ path: p, type: FileType.BLOB }));
1125
+ fs._changes = finalizeChanges(changes);
1126
+ return fs;
1127
+ }
1128
+ for (const rel of [...localPaths].sort()) {
1129
+ try {
1130
+ await fsModule.promises.unlink(join(localPath, rel));
1131
+ }
1132
+ catch { /* ignore */ }
1133
+ }
1134
+ await pruneEmptyDirs(fsModule, localPath);
1135
+ const changes = emptyChangeReport();
1136
+ changes.delete = [...localPaths].sort().map((p) => ({ path: p, type: FileType.BLOB }));
1137
+ fs._changes = changes;
1138
+ return fs;
1139
+ }
1140
+ throw err;
1141
+ }
1142
+ }
1143
+ // ---------------------------------------------------------------------------
1144
+ // Move
1145
+ // ---------------------------------------------------------------------------
1146
+ /**
1147
+ * Move or rename files within the repo.
1148
+ *
1149
+ * Implements POSIX `mv` semantics: when there is a single source file
1150
+ * and `dest` is not an existing directory and does not end with `/`,
1151
+ * the destination is the exact target path (rename). Otherwise files
1152
+ * are placed inside `dest`.
1153
+ *
1154
+ * With `dryRun: true`, no changes are written; the input FS is
1155
+ * returned with `.changes` populated.
1156
+ *
1157
+ * @param fs - Filesystem snapshot (must be writable, i.e. a branch).
1158
+ * @param sources - One or more repo paths to move.
1159
+ * @param dest - Destination path in the repo tree.
1160
+ * @param opts - Move options.
1161
+ * @param opts.recursive - Allow moving directories. Default `false`.
1162
+ * @param opts.dryRun - Preview changes without writing. Default `false`.
1163
+ * @param opts.message - Custom commit message.
1164
+ * @returns FS snapshot after the move, with `.changes` set.
1165
+ * @throws {FileNotFoundError} If no source matches any file.
1166
+ * @throws {IsADirectoryError} If a source is a directory and `recursive` is `false`.
1167
+ * @throws {Error} If source and destination are the same path.
1168
+ */
1169
+ export async function move(fs, sources, dest, opts = {}) {
1170
+ const srcList = typeof sources === 'string' ? [sources] : sources;
1171
+ const resolved = await resolveRepoSources(fs, srcList);
1172
+ const destNorm = dest.replace(/\/+$/, '') ? normalizePath(dest.replace(/\/+$/, '')) : '';
1173
+ const destExistsAsDir = destNorm ? await fs.isDir(destNorm) : false;
1174
+ // POSIX mv rename detection
1175
+ const isRename = resolved.length === 1 &&
1176
+ (resolved[0].mode === 'file' || resolved[0].mode === 'dir') &&
1177
+ !dest.endsWith('/') &&
1178
+ !destExistsAsDir;
1179
+ let pairs;
1180
+ if (isRename && resolved[0].mode === 'file') {
1181
+ pairs = [[resolved[0].repoPath, destNorm || resolved[0].repoPath.split('/').pop()]];
1182
+ }
1183
+ else if (isRename && resolved[0].mode === 'dir') {
1184
+ const renamed = [
1185
+ { repoPath: resolved[0].repoPath, mode: 'contents', prefix: '' },
1186
+ ];
1187
+ pairs = await enumRepoToRepo(fs, renamed, destNorm);
1188
+ }
1189
+ else {
1190
+ pairs = await enumRepoToRepo(fs, resolved, destNorm);
1191
+ }
1192
+ if (pairs.length === 0)
1193
+ throw new FileNotFoundError(`No matches for: ${srcList}`);
1194
+ // Validate no src == dest
1195
+ for (const [src, dst] of pairs) {
1196
+ if (src === dst)
1197
+ throw new Error(`Source and destination are the same: ${src}`);
1198
+ }
1199
+ const deletePaths = await collectRemovePaths(fs, srcList, opts.recursive);
1200
+ const changes = emptyChangeReport();
1201
+ const destRelToRepo = new Map(pairs.map(([, dp]) => [dp, dp]));
1202
+ changes.add = await makeEntriesFromRepoDict(fs, pairs.map(([, dp]) => dp), destRelToRepo);
1203
+ const srcRelToRepo = new Map(deletePaths.map((p) => [p, p]));
1204
+ changes.delete = await makeEntriesFromRepoDict(fs, deletePaths, srcRelToRepo);
1205
+ if (opts.dryRun) {
1206
+ fs._changes = finalizeChanges(changes);
1207
+ return fs;
1208
+ }
1209
+ const batch = fs.batch({ message: opts.message, operation: 'mv' });
1210
+ for (const [src, dst] of pairs) {
1211
+ // Copy blob from src to dst
1212
+ const entry = await entryAtPath(fs._store._fsModule, fs._store._gitdir, fs._treeOid, src);
1213
+ if (entry) {
1214
+ const { blob } = await git.readBlob({
1215
+ fs: fs._store._fsModule,
1216
+ gitdir: fs._store._gitdir,
1217
+ oid: entry.oid,
1218
+ });
1219
+ await batch.write(dst, blob, { mode: entry.mode });
1220
+ }
1221
+ }
1222
+ for (const path of deletePaths) {
1223
+ await batch.remove(path);
1224
+ }
1225
+ const result = await batch.commit();
1226
+ result._changes = finalizeChanges(changes);
1227
+ return result;
1228
+ }
1229
+ //# sourceMappingURL=copy.js.map