@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.
- package/LICENSE +191 -0
- package/README.md +24 -0
- package/dist/batch.d.ts +82 -0
- package/dist/batch.js +152 -0
- package/dist/batch.js.map +1 -0
- package/dist/copy.d.ts +242 -0
- package/dist/copy.js +1229 -0
- package/dist/copy.js.map +1 -0
- package/dist/exclude.d.ts +68 -0
- package/dist/exclude.js +157 -0
- package/dist/exclude.js.map +1 -0
- package/dist/fileobj.d.ts +82 -0
- package/dist/fileobj.js +127 -0
- package/dist/fileobj.js.map +1 -0
- package/dist/fs.d.ts +581 -0
- package/dist/fs.js +1318 -0
- package/dist/fs.js.map +1 -0
- package/dist/gitstore.d.ts +74 -0
- package/dist/gitstore.js +131 -0
- package/dist/gitstore.js.map +1 -0
- package/dist/glob.d.ts +14 -0
- package/dist/glob.js +68 -0
- package/dist/glob.js.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +51 -0
- package/dist/index.js.map +1 -0
- package/dist/lock.d.ts +15 -0
- package/dist/lock.js +71 -0
- package/dist/lock.js.map +1 -0
- package/dist/mirror.d.ts +53 -0
- package/dist/mirror.js +270 -0
- package/dist/mirror.js.map +1 -0
- package/dist/notes.d.ts +148 -0
- package/dist/notes.js +508 -0
- package/dist/notes.js.map +1 -0
- package/dist/paths.d.ts +16 -0
- package/dist/paths.js +44 -0
- package/dist/paths.js.map +1 -0
- package/dist/refdict.d.ts +117 -0
- package/dist/refdict.js +267 -0
- package/dist/refdict.js.map +1 -0
- package/dist/reflog.d.ts +33 -0
- package/dist/reflog.js +83 -0
- package/dist/reflog.js.map +1 -0
- package/dist/tree.d.ts +79 -0
- package/dist/tree.js +283 -0
- package/dist/tree.js.map +1 -0
- package/dist/types.d.ts +354 -0
- package/dist/types.js +302 -0
- package/dist/types.js.map +1 -0
- 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
|