@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/fs.js
ADDED
|
@@ -0,0 +1,1318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FS: immutable snapshot of a committed tree state.
|
|
3
|
+
*
|
|
4
|
+
* Read-only when writable is false (tag/detached snapshot).
|
|
5
|
+
* Writable when writable is true — writes auto-commit and return a new FS.
|
|
6
|
+
*/
|
|
7
|
+
import git from 'isomorphic-git';
|
|
8
|
+
import { MODE_TREE, MODE_BLOB, MODE_LINK, modeToInt, FileNotFoundError, NotADirectoryError, PermissionError, StaleSnapshotError, fileTypeFromMode, fileModeFromType, fileEntryFromMode, emptyChangeReport, formatCommitMessage, finalizeChanges, } from './types.js';
|
|
9
|
+
import { normalizePath, isRootPath } from './paths.js';
|
|
10
|
+
import { entryAtPath, walkTo, readBlobAtPath, listTreeAtPath, listEntriesAtPath, walkTree, existsAtPath, rebuildTree, countSubdirs, } from './tree.js';
|
|
11
|
+
import { globMatch } from './glob.js';
|
|
12
|
+
import { withRepoLock } from './lock.js';
|
|
13
|
+
import { readReflog, writeReflogEntry, ZERO_SHA } from './reflog.js';
|
|
14
|
+
import { Batch } from './batch.js';
|
|
15
|
+
import { FsWriter } from './fileobj.js';
|
|
16
|
+
/**
|
|
17
|
+
* An immutable snapshot of a committed tree.
|
|
18
|
+
*
|
|
19
|
+
* Read-only when `writable` is false (tag snapshot).
|
|
20
|
+
* Writable when `writable` is true -- writes auto-commit and return a new FS.
|
|
21
|
+
*/
|
|
22
|
+
export class FS {
|
|
23
|
+
/** @internal */
|
|
24
|
+
_store;
|
|
25
|
+
/** @internal */
|
|
26
|
+
_commitOid;
|
|
27
|
+
/** @internal */
|
|
28
|
+
_refName;
|
|
29
|
+
/** @internal */
|
|
30
|
+
_writable;
|
|
31
|
+
/** @internal */
|
|
32
|
+
_treeOid;
|
|
33
|
+
/** @internal */
|
|
34
|
+
_changes = null;
|
|
35
|
+
/** @internal */
|
|
36
|
+
_commitTime = null;
|
|
37
|
+
/** @internal */
|
|
38
|
+
get _fsModule() {
|
|
39
|
+
return this._store._fsModule;
|
|
40
|
+
}
|
|
41
|
+
/** @internal */
|
|
42
|
+
get _gitdir() {
|
|
43
|
+
return this._store._gitdir;
|
|
44
|
+
}
|
|
45
|
+
constructor(store, commitOid, treeOid, refName, writable) {
|
|
46
|
+
this._store = store;
|
|
47
|
+
this._commitOid = commitOid;
|
|
48
|
+
this._refName = refName;
|
|
49
|
+
this._writable = writable ?? (refName !== null);
|
|
50
|
+
this._treeOid = treeOid;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* @internal Create an FS from a commit OID (reads the commit to get tree OID).
|
|
54
|
+
*/
|
|
55
|
+
static async _fromCommit(store, commitOid, refName, writable) {
|
|
56
|
+
const { commit } = await git.readCommit({
|
|
57
|
+
fs: store._fsModule,
|
|
58
|
+
gitdir: store._gitdir,
|
|
59
|
+
oid: commitOid,
|
|
60
|
+
});
|
|
61
|
+
return new FS(store, commitOid, commit.tree, refName, writable);
|
|
62
|
+
}
|
|
63
|
+
toString() {
|
|
64
|
+
const short = this._commitOid.slice(0, 7);
|
|
65
|
+
const parts = [];
|
|
66
|
+
if (this._refName)
|
|
67
|
+
parts.push(`refName='${this._refName}'`);
|
|
68
|
+
parts.push(`commit=${short}`);
|
|
69
|
+
if (!this._writable)
|
|
70
|
+
parts.push('readonly');
|
|
71
|
+
return `FS(${parts.join(', ')})`;
|
|
72
|
+
}
|
|
73
|
+
/** @internal */
|
|
74
|
+
_readonlyError(verb) {
|
|
75
|
+
if (this._refName) {
|
|
76
|
+
return new PermissionError(`Cannot ${verb} read-only snapshot (ref '${this._refName}')`);
|
|
77
|
+
}
|
|
78
|
+
return new PermissionError(`Cannot ${verb} read-only snapshot`);
|
|
79
|
+
}
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Properties
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
/** The 40-character hex SHA of this snapshot's commit. */
|
|
84
|
+
get commitHash() {
|
|
85
|
+
return this._commitOid;
|
|
86
|
+
}
|
|
87
|
+
/** The branch or tag name, or `null` for detached snapshots. */
|
|
88
|
+
get refName() {
|
|
89
|
+
return this._refName;
|
|
90
|
+
}
|
|
91
|
+
/** Whether this snapshot can be written to. */
|
|
92
|
+
get writable() {
|
|
93
|
+
return this._writable;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Fetch commit metadata (message, time, author name/email) in a single read.
|
|
97
|
+
*
|
|
98
|
+
* @returns Commit info object with message, time, authorName, and authorEmail.
|
|
99
|
+
*/
|
|
100
|
+
async getCommitInfo() {
|
|
101
|
+
const { commit } = await git.readCommit({
|
|
102
|
+
fs: this._fsModule,
|
|
103
|
+
gitdir: this._gitdir,
|
|
104
|
+
oid: this._commitOid,
|
|
105
|
+
});
|
|
106
|
+
const offsetMs = commit.author.timezoneOffset * 60 * 1000;
|
|
107
|
+
return {
|
|
108
|
+
message: commit.message.replace(/\n$/, ''),
|
|
109
|
+
time: new Date(commit.author.timestamp * 1000 - offsetMs),
|
|
110
|
+
authorName: commit.author.name,
|
|
111
|
+
authorEmail: commit.author.email,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/** The commit message (trailing newline stripped). */
|
|
115
|
+
async getMessage() {
|
|
116
|
+
return (await this.getCommitInfo()).message;
|
|
117
|
+
}
|
|
118
|
+
/** Timezone-aware commit timestamp. */
|
|
119
|
+
async getTime() {
|
|
120
|
+
return (await this.getCommitInfo()).time;
|
|
121
|
+
}
|
|
122
|
+
/** The commit author's name. */
|
|
123
|
+
async getAuthorName() {
|
|
124
|
+
return (await this.getCommitInfo()).authorName;
|
|
125
|
+
}
|
|
126
|
+
/** The commit author's email address. */
|
|
127
|
+
async getAuthorEmail() {
|
|
128
|
+
return (await this.getCommitInfo()).authorEmail;
|
|
129
|
+
}
|
|
130
|
+
/** Report of the operation that created this snapshot, or `null`. */
|
|
131
|
+
get changes() {
|
|
132
|
+
return this._changes;
|
|
133
|
+
}
|
|
134
|
+
/** The 40-char hex SHA of the root tree. */
|
|
135
|
+
get treeHash() {
|
|
136
|
+
return this._treeOid;
|
|
137
|
+
}
|
|
138
|
+
/** @internal */
|
|
139
|
+
async _getCommitTime() {
|
|
140
|
+
if (this._commitTime !== null)
|
|
141
|
+
return this._commitTime;
|
|
142
|
+
const { commit } = await git.readCommit({
|
|
143
|
+
fs: this._fsModule,
|
|
144
|
+
gitdir: this._gitdir,
|
|
145
|
+
oid: this._commitOid,
|
|
146
|
+
});
|
|
147
|
+
this._commitTime = commit.committer.timestamp;
|
|
148
|
+
return this._commitTime;
|
|
149
|
+
}
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Read operations
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
/**
|
|
154
|
+
* Read file contents as bytes.
|
|
155
|
+
*
|
|
156
|
+
* @param path - File path in the repo.
|
|
157
|
+
* @param opts - Optional read options.
|
|
158
|
+
* @param opts.offset - Byte offset to start reading from.
|
|
159
|
+
* @param opts.size - Maximum bytes to return (undefined for all).
|
|
160
|
+
* @returns Raw file contents as Uint8Array.
|
|
161
|
+
* @throws {FileNotFoundError} If path does not exist.
|
|
162
|
+
* @throws {IsADirectoryError} If path is a directory.
|
|
163
|
+
*/
|
|
164
|
+
async read(path, opts) {
|
|
165
|
+
const blob = await readBlobAtPath(this._fsModule, this._gitdir, this._treeOid, path);
|
|
166
|
+
if (opts && (opts.offset !== undefined || opts.size !== undefined)) {
|
|
167
|
+
const offset = opts.offset ?? 0;
|
|
168
|
+
const end = opts.size !== undefined ? offset + opts.size : blob.length;
|
|
169
|
+
return blob.subarray(offset, end);
|
|
170
|
+
}
|
|
171
|
+
return blob;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Read file contents as a string.
|
|
175
|
+
*
|
|
176
|
+
* @param path - File path in the repo.
|
|
177
|
+
* @param encoding - Text encoding (default `"utf-8"`).
|
|
178
|
+
* @returns Decoded text content.
|
|
179
|
+
* @throws {FileNotFoundError} If path does not exist.
|
|
180
|
+
* @throws {IsADirectoryError} If path is a directory.
|
|
181
|
+
*/
|
|
182
|
+
async readText(path, encoding = 'utf-8') {
|
|
183
|
+
const data = await this.read(path);
|
|
184
|
+
return new TextDecoder(encoding).decode(data);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* List entry names at path (or root if null/undefined).
|
|
188
|
+
*
|
|
189
|
+
* @param path - Directory path, or null/undefined for the repo root.
|
|
190
|
+
* @returns Array of entry names (files and subdirectories).
|
|
191
|
+
* @throws {NotADirectoryError} If path is a file.
|
|
192
|
+
*/
|
|
193
|
+
async ls(path) {
|
|
194
|
+
return listTreeAtPath(this._fsModule, this._gitdir, this._treeOid, path);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Walk the repo tree recursively, like `os.walk`.
|
|
198
|
+
*
|
|
199
|
+
* Yields `[dirpath, dirnames, fileEntries]` tuples. Each file entry is a
|
|
200
|
+
* WalkEntry with `name`, `oid`, and `mode`.
|
|
201
|
+
*
|
|
202
|
+
* @param path - Subtree to walk, or null/undefined for root.
|
|
203
|
+
* @throws {NotADirectoryError} If path is a file.
|
|
204
|
+
*/
|
|
205
|
+
async *walk(path) {
|
|
206
|
+
if (path == null || isRootPath(path)) {
|
|
207
|
+
yield* walkTree(this._fsModule, this._gitdir, this._treeOid);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
const normalized = normalizePath(path);
|
|
211
|
+
const entry = await walkTo(this._fsModule, this._gitdir, this._treeOid, normalized);
|
|
212
|
+
if (entry.mode !== MODE_TREE)
|
|
213
|
+
throw new NotADirectoryError(normalized);
|
|
214
|
+
yield* walkTree(this._fsModule, this._gitdir, entry.oid, normalized);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Return true if path exists (file or directory).
|
|
219
|
+
*
|
|
220
|
+
* @param path - Path to check.
|
|
221
|
+
*/
|
|
222
|
+
async exists(path) {
|
|
223
|
+
return existsAtPath(this._fsModule, this._gitdir, this._treeOid, path);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Return true if path is a directory (tree) in the repo.
|
|
227
|
+
*
|
|
228
|
+
* @param path - Path to check.
|
|
229
|
+
*/
|
|
230
|
+
async isDir(path) {
|
|
231
|
+
const normalized = normalizePath(path);
|
|
232
|
+
const entry = await entryAtPath(this._fsModule, this._gitdir, this._treeOid, normalized);
|
|
233
|
+
if (entry === null)
|
|
234
|
+
return false;
|
|
235
|
+
return entry.mode === MODE_TREE;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Return the FileType of path.
|
|
239
|
+
*
|
|
240
|
+
* Returns `'blob'`, `'executable'`, `'link'`, or `'tree'`.
|
|
241
|
+
*
|
|
242
|
+
* @param path - Path to check.
|
|
243
|
+
* @throws {FileNotFoundError} If path does not exist.
|
|
244
|
+
*/
|
|
245
|
+
async fileType(path) {
|
|
246
|
+
const normalized = normalizePath(path);
|
|
247
|
+
const entry = await entryAtPath(this._fsModule, this._gitdir, this._treeOid, normalized);
|
|
248
|
+
if (entry === null)
|
|
249
|
+
throw new FileNotFoundError(normalized);
|
|
250
|
+
return fileTypeFromMode(entry.mode);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Return the size in bytes of the object at path.
|
|
254
|
+
*
|
|
255
|
+
* @param path - Path to check.
|
|
256
|
+
* @returns Size in bytes.
|
|
257
|
+
* @throws {FileNotFoundError} If path does not exist.
|
|
258
|
+
*/
|
|
259
|
+
async size(path) {
|
|
260
|
+
const normalized = normalizePath(path);
|
|
261
|
+
const entry = await entryAtPath(this._fsModule, this._gitdir, this._treeOid, normalized);
|
|
262
|
+
if (entry === null)
|
|
263
|
+
throw new FileNotFoundError(normalized);
|
|
264
|
+
const { blob } = await git.readBlob({ fs: this._fsModule, gitdir: this._gitdir, oid: entry.oid });
|
|
265
|
+
return blob.length;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Return the 40-character hex SHA of the object at path.
|
|
269
|
+
*
|
|
270
|
+
* For files this is the blob SHA; for directories the tree SHA.
|
|
271
|
+
*
|
|
272
|
+
* @param path - Path to check.
|
|
273
|
+
* @returns 40-char hex SHA string.
|
|
274
|
+
* @throws {FileNotFoundError} If path does not exist.
|
|
275
|
+
*/
|
|
276
|
+
async objectHash(path) {
|
|
277
|
+
const normalized = normalizePath(path);
|
|
278
|
+
const entry = await entryAtPath(this._fsModule, this._gitdir, this._treeOid, normalized);
|
|
279
|
+
if (entry === null)
|
|
280
|
+
throw new FileNotFoundError(normalized);
|
|
281
|
+
return entry.oid;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Read the target of a symlink.
|
|
285
|
+
*
|
|
286
|
+
* @param path - Symlink path in the repo.
|
|
287
|
+
* @returns The symlink target string.
|
|
288
|
+
* @throws {FileNotFoundError} If path does not exist.
|
|
289
|
+
* @throws {Error} If path is not a symlink.
|
|
290
|
+
*/
|
|
291
|
+
async readlink(path) {
|
|
292
|
+
const normalized = normalizePath(path);
|
|
293
|
+
const entry = await entryAtPath(this._fsModule, this._gitdir, this._treeOid, normalized);
|
|
294
|
+
if (entry === null)
|
|
295
|
+
throw new FileNotFoundError(normalized);
|
|
296
|
+
if (entry.mode !== MODE_LINK)
|
|
297
|
+
throw new Error(`Not a symlink: ${normalized}`);
|
|
298
|
+
const { blob } = await git.readBlob({ fs: this._fsModule, gitdir: this._gitdir, oid: entry.oid });
|
|
299
|
+
return new TextDecoder().decode(blob);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Read raw blob data by hash, bypassing tree lookup.
|
|
303
|
+
*
|
|
304
|
+
* FUSE pattern: `stat()` -> cache hash -> `readByHash(hash)`.
|
|
305
|
+
*
|
|
306
|
+
* @param hash - 40-char hex SHA of the blob.
|
|
307
|
+
* @param opts - Optional read options.
|
|
308
|
+
* @param opts.offset - Byte offset to start reading from.
|
|
309
|
+
* @param opts.size - Maximum bytes to return (undefined for all).
|
|
310
|
+
* @returns Raw blob contents as Uint8Array.
|
|
311
|
+
*/
|
|
312
|
+
async readByHash(hash, opts) {
|
|
313
|
+
const { blob } = await git.readBlob({ fs: this._fsModule, gitdir: this._gitdir, oid: hash });
|
|
314
|
+
if (opts && (opts.offset !== undefined || opts.size !== undefined)) {
|
|
315
|
+
const offset = opts.offset ?? 0;
|
|
316
|
+
const end = opts.size !== undefined ? offset + opts.size : blob.length;
|
|
317
|
+
return blob.subarray(offset, end);
|
|
318
|
+
}
|
|
319
|
+
return blob;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Return a StatResult for path (or root if null/undefined).
|
|
323
|
+
*
|
|
324
|
+
* Combines fileType, size, oid, nlink, and mtime in a single call --
|
|
325
|
+
* the hot path for FUSE `getattr`.
|
|
326
|
+
*
|
|
327
|
+
* @param path - Path to stat, or null/undefined for root.
|
|
328
|
+
* @returns StatResult with mode, fileType, size, hash, nlink, and mtime.
|
|
329
|
+
* @throws {FileNotFoundError} If path does not exist.
|
|
330
|
+
*/
|
|
331
|
+
async stat(path) {
|
|
332
|
+
const mtime = await this._getCommitTime();
|
|
333
|
+
if (path == null || isRootPath(path)) {
|
|
334
|
+
const nlink = 2 + await countSubdirs(this._fsModule, this._gitdir, this._treeOid);
|
|
335
|
+
return {
|
|
336
|
+
mode: modeToInt(MODE_TREE),
|
|
337
|
+
fileType: fileTypeFromMode(MODE_TREE),
|
|
338
|
+
size: 0,
|
|
339
|
+
hash: this._treeOid,
|
|
340
|
+
nlink,
|
|
341
|
+
mtime,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
const normalized = normalizePath(path);
|
|
345
|
+
const entry = await entryAtPath(this._fsModule, this._gitdir, this._treeOid, normalized);
|
|
346
|
+
if (entry === null)
|
|
347
|
+
throw new FileNotFoundError(normalized);
|
|
348
|
+
if (entry.mode === MODE_TREE) {
|
|
349
|
+
const nlink = 2 + await countSubdirs(this._fsModule, this._gitdir, entry.oid);
|
|
350
|
+
return {
|
|
351
|
+
mode: modeToInt(entry.mode),
|
|
352
|
+
fileType: fileTypeFromMode(entry.mode),
|
|
353
|
+
size: 0,
|
|
354
|
+
hash: entry.oid,
|
|
355
|
+
nlink,
|
|
356
|
+
mtime,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
const { blob } = await git.readBlob({ fs: this._fsModule, gitdir: this._gitdir, oid: entry.oid });
|
|
360
|
+
return {
|
|
361
|
+
mode: modeToInt(entry.mode),
|
|
362
|
+
fileType: fileTypeFromMode(entry.mode),
|
|
363
|
+
size: blob.length,
|
|
364
|
+
hash: entry.oid,
|
|
365
|
+
nlink: 1,
|
|
366
|
+
mtime,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* List directory entries with name, oid, and mode.
|
|
371
|
+
*
|
|
372
|
+
* Like `ls()` but returns WalkEntry objects so callers get entry types
|
|
373
|
+
* (useful for FUSE `readdir` d_type).
|
|
374
|
+
*
|
|
375
|
+
* @param path - Directory path, or null/undefined for root.
|
|
376
|
+
* @returns Array of WalkEntry objects.
|
|
377
|
+
*/
|
|
378
|
+
async listdir(path) {
|
|
379
|
+
return listEntriesAtPath(this._fsModule, this._gitdir, this._treeOid, path);
|
|
380
|
+
}
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
// Glob
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
/**
|
|
385
|
+
* Expand a glob pattern against the repo tree.
|
|
386
|
+
*
|
|
387
|
+
* Supports `*`, `?`, and `**`. `*` and `?` do not match a leading `.`
|
|
388
|
+
* unless the pattern segment itself starts with `.`. `**` matches zero or
|
|
389
|
+
* more directory levels, skipping directories whose names start with `.`.
|
|
390
|
+
*
|
|
391
|
+
* @param pattern - Glob pattern to match.
|
|
392
|
+
* @returns Sorted, deduplicated list of matching paths (files and directories).
|
|
393
|
+
*/
|
|
394
|
+
async glob(pattern) {
|
|
395
|
+
const results = [];
|
|
396
|
+
for await (const path of this.iglob(pattern)) {
|
|
397
|
+
results.push(path);
|
|
398
|
+
}
|
|
399
|
+
return results.sort();
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Expand a glob pattern against the repo tree, yielding unique matches.
|
|
403
|
+
*
|
|
404
|
+
* Like `glob()` but returns an unordered async iterator instead of a
|
|
405
|
+
* sorted list. Useful when you only need to iterate once and don't
|
|
406
|
+
* need sorted output.
|
|
407
|
+
*
|
|
408
|
+
* A `/./` pivot marker (rsync `-R` style) is preserved in the output
|
|
409
|
+
* so that callers can reconstruct partial source paths.
|
|
410
|
+
*
|
|
411
|
+
* @param pattern - Glob pattern to match.
|
|
412
|
+
*/
|
|
413
|
+
async *iglob(pattern) {
|
|
414
|
+
pattern = pattern.replace(/^\/+|\/+$/g, '');
|
|
415
|
+
if (!pattern)
|
|
416
|
+
return;
|
|
417
|
+
// Handle /./ pivot marker (rsync -R style)
|
|
418
|
+
const pivotIdx = pattern.indexOf('/./');
|
|
419
|
+
if (pivotIdx > 0) {
|
|
420
|
+
const base = pattern.slice(0, pivotIdx);
|
|
421
|
+
const rest = pattern.slice(pivotIdx + 3);
|
|
422
|
+
const flat = rest ? `${base}/${rest}` : base;
|
|
423
|
+
const basePrefix = base + '/';
|
|
424
|
+
const seen = new Set();
|
|
425
|
+
for await (const path of this._iglobWalk(flat.split('/'), null, this._treeOid)) {
|
|
426
|
+
if (!seen.has(path)) {
|
|
427
|
+
seen.add(path);
|
|
428
|
+
yield path.startsWith(basePrefix)
|
|
429
|
+
? `${base}/./${path.slice(basePrefix.length)}`
|
|
430
|
+
: `${base}/./${path}`;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const seen = new Set();
|
|
436
|
+
for await (const path of this._iglobWalk(pattern.split('/'), null, this._treeOid)) {
|
|
437
|
+
if (!seen.has(path)) {
|
|
438
|
+
seen.add(path);
|
|
439
|
+
yield path;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/** @internal */
|
|
444
|
+
async _iglobEntries(treeOid) {
|
|
445
|
+
try {
|
|
446
|
+
const { tree } = await git.readTree({ fs: this._fsModule, gitdir: this._gitdir, oid: treeOid });
|
|
447
|
+
return tree.map((e) => [e.path, e.mode === MODE_TREE, e.oid]);
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
return [];
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/** @internal */
|
|
454
|
+
async *_iglobWalk(segments, prefix, treeOid) {
|
|
455
|
+
if (segments.length === 0)
|
|
456
|
+
return;
|
|
457
|
+
const seg = segments[0];
|
|
458
|
+
const rest = segments.slice(1);
|
|
459
|
+
if (seg === '**') {
|
|
460
|
+
const entries = await this._iglobEntries(treeOid);
|
|
461
|
+
if (rest.length > 0) {
|
|
462
|
+
yield* this._iglobMatchEntries(rest, prefix, entries);
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
for (const [name, ,] of entries) {
|
|
466
|
+
if (name.startsWith('.'))
|
|
467
|
+
continue;
|
|
468
|
+
yield prefix ? `${prefix}/${name}` : name;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
for (const [name, isDir, oid] of entries) {
|
|
472
|
+
if (name.startsWith('.'))
|
|
473
|
+
continue;
|
|
474
|
+
const full = prefix ? `${prefix}/${name}` : name;
|
|
475
|
+
if (isDir) {
|
|
476
|
+
yield* this._iglobWalk(segments, full, oid); // keep **
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const hasWild = seg.includes('*') || seg.includes('?');
|
|
482
|
+
if (hasWild) {
|
|
483
|
+
const entries = await this._iglobEntries(treeOid);
|
|
484
|
+
for (const [name, , oid] of entries) {
|
|
485
|
+
if (!globMatch(seg, name))
|
|
486
|
+
continue;
|
|
487
|
+
const full = prefix ? `${prefix}/${name}` : name;
|
|
488
|
+
if (rest.length > 0) {
|
|
489
|
+
yield* this._iglobWalk(rest, full, oid);
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
yield full;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
// Literal segment — look up directly
|
|
498
|
+
try {
|
|
499
|
+
const { tree } = await git.readTree({ fs: this._fsModule, gitdir: this._gitdir, oid: treeOid });
|
|
500
|
+
const entry = tree.find((e) => e.path === seg);
|
|
501
|
+
if (!entry)
|
|
502
|
+
return;
|
|
503
|
+
const full = prefix ? `${prefix}/${seg}` : seg;
|
|
504
|
+
if (rest.length > 0) {
|
|
505
|
+
yield* this._iglobWalk(rest, full, entry.oid);
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
yield full;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
/** @internal */
|
|
517
|
+
async *_iglobMatchEntries(segments, prefix, entries) {
|
|
518
|
+
const seg = segments[0];
|
|
519
|
+
const rest = segments.slice(1);
|
|
520
|
+
const hasWild = seg.includes('*') || seg.includes('?');
|
|
521
|
+
if (hasWild) {
|
|
522
|
+
for (const [name, , oid] of entries) {
|
|
523
|
+
if (!globMatch(seg, name))
|
|
524
|
+
continue;
|
|
525
|
+
const full = prefix ? `${prefix}/${name}` : name;
|
|
526
|
+
if (rest.length > 0) {
|
|
527
|
+
yield* this._iglobWalk(rest, full, oid);
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
yield full;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
for (const [name, , oid] of entries) {
|
|
536
|
+
if (name === seg) {
|
|
537
|
+
const full = prefix ? `${prefix}/${seg}` : seg;
|
|
538
|
+
if (rest.length > 0) {
|
|
539
|
+
yield* this._iglobWalk(rest, full, oid);
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
yield full;
|
|
543
|
+
}
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
// ---------------------------------------------------------------------------
|
|
550
|
+
// Write operations
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
/**
|
|
553
|
+
* @internal Build ChangeReport from writes and removes with type detection.
|
|
554
|
+
*/
|
|
555
|
+
async _buildChanges(writes, removes) {
|
|
556
|
+
const changes = emptyChangeReport();
|
|
557
|
+
for (const [path, write] of writes) {
|
|
558
|
+
const existing = await entryAtPath(this._fsModule, this._gitdir, this._treeOid, path);
|
|
559
|
+
if (existing !== null) {
|
|
560
|
+
// Compare OID + mode to skip unchanged
|
|
561
|
+
const newOid = write.oid ?? (write.data
|
|
562
|
+
? await git.writeBlob({ fs: this._fsModule, gitdir: this._gitdir, blob: write.data })
|
|
563
|
+
: null);
|
|
564
|
+
if (newOid === existing.oid && write.mode === existing.mode)
|
|
565
|
+
continue;
|
|
566
|
+
changes.update.push(fileEntryFromMode(path, write.mode));
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
changes.add.push(fileEntryFromMode(path, write.mode));
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
for (const path of removes) {
|
|
573
|
+
const existing = await entryAtPath(this._fsModule, this._gitdir, this._treeOid, path);
|
|
574
|
+
if (existing) {
|
|
575
|
+
changes.delete.push(fileEntryFromMode(path, existing.mode));
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
changes.delete.push({ path, type: 'blob' });
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return changes;
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* @internal Commit changes: rebuild tree, create commit, update ref atomically.
|
|
585
|
+
*/
|
|
586
|
+
async _commitChanges(writes, removes, message, operation) {
|
|
587
|
+
if (!this._writable)
|
|
588
|
+
throw this._readonlyError('write to');
|
|
589
|
+
const changes = await this._buildChanges(writes, removes);
|
|
590
|
+
const finalMessage = formatCommitMessage(changes, message, operation);
|
|
591
|
+
const newTreeOid = await rebuildTree(this._fsModule, this._gitdir, this._treeOid, writes, removes);
|
|
592
|
+
// Atomic check-and-update under lock
|
|
593
|
+
const refName = `refs/heads/${this._refName}`;
|
|
594
|
+
const sig = this._store._signature;
|
|
595
|
+
const committerStr = `${sig.name} <${sig.email}>`;
|
|
596
|
+
const commitOid = this._commitOid;
|
|
597
|
+
const store = this._store;
|
|
598
|
+
const newCommitOid = await withRepoLock(this._fsModule, this._gitdir, async () => {
|
|
599
|
+
// Check for stale snapshot
|
|
600
|
+
const currentOid = await git.resolveRef({
|
|
601
|
+
fs: this._fsModule,
|
|
602
|
+
gitdir: this._gitdir,
|
|
603
|
+
ref: refName,
|
|
604
|
+
});
|
|
605
|
+
if (currentOid !== commitOid) {
|
|
606
|
+
throw new StaleSnapshotError(`Branch '${this._refName}' has advanced since this snapshot`);
|
|
607
|
+
}
|
|
608
|
+
if (newTreeOid === this._treeOid) {
|
|
609
|
+
return null; // nothing changed
|
|
610
|
+
}
|
|
611
|
+
// Create commit
|
|
612
|
+
const now = Math.floor(Date.now() / 1000);
|
|
613
|
+
const oid = await git.writeCommit({
|
|
614
|
+
fs: this._fsModule,
|
|
615
|
+
gitdir: this._gitdir,
|
|
616
|
+
commit: {
|
|
617
|
+
message: finalMessage + '\n',
|
|
618
|
+
tree: newTreeOid,
|
|
619
|
+
parent: [commitOid],
|
|
620
|
+
author: { name: sig.name, email: sig.email, timestamp: now, timezoneOffset: 0 },
|
|
621
|
+
committer: { name: sig.name, email: sig.email, timestamp: now, timezoneOffset: 0 },
|
|
622
|
+
},
|
|
623
|
+
});
|
|
624
|
+
// Update ref
|
|
625
|
+
await git.writeRef({
|
|
626
|
+
fs: this._fsModule,
|
|
627
|
+
gitdir: this._gitdir,
|
|
628
|
+
ref: refName,
|
|
629
|
+
value: oid,
|
|
630
|
+
force: true,
|
|
631
|
+
});
|
|
632
|
+
// Write reflog entry
|
|
633
|
+
await writeReflogEntry(this._fsModule, this._gitdir, refName, commitOid, oid, committerStr, `commit: ${finalMessage}`);
|
|
634
|
+
return oid;
|
|
635
|
+
});
|
|
636
|
+
if (newCommitOid === null)
|
|
637
|
+
return this; // nothing changed
|
|
638
|
+
const newFs = new FS(store, newCommitOid, newTreeOid, this._refName, this._writable);
|
|
639
|
+
newFs._changes = changes;
|
|
640
|
+
return newFs;
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Write data to path and commit, returning a new FS.
|
|
644
|
+
*
|
|
645
|
+
* @param path - Destination path in the repo.
|
|
646
|
+
* @param data - Raw bytes to write.
|
|
647
|
+
* @param opts - Optional write options.
|
|
648
|
+
* @param opts.message - Commit message (auto-generated if omitted).
|
|
649
|
+
* @param opts.mode - File mode override (e.g. `'executable'`).
|
|
650
|
+
* @returns New FS snapshot with the write committed.
|
|
651
|
+
* @throws {PermissionError} If this snapshot is read-only.
|
|
652
|
+
* @throws {StaleSnapshotError} If the branch has advanced since this snapshot.
|
|
653
|
+
*/
|
|
654
|
+
async write(path, data, opts) {
|
|
655
|
+
const normalized = normalizePath(path);
|
|
656
|
+
const mode = opts?.mode
|
|
657
|
+
? resolveMode(opts.mode)
|
|
658
|
+
: MODE_BLOB;
|
|
659
|
+
const writes = new Map([[normalized, { data, mode }]]);
|
|
660
|
+
return this._commitChanges(writes, new Set(), opts?.message);
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Write text to path and commit, returning a new FS.
|
|
664
|
+
*
|
|
665
|
+
* @param path - Destination path in the repo.
|
|
666
|
+
* @param text - String content (encoded as UTF-8).
|
|
667
|
+
* @param opts - Optional write options.
|
|
668
|
+
* @param opts.message - Commit message (auto-generated if omitted).
|
|
669
|
+
* @param opts.mode - File mode override (e.g. `'executable'`).
|
|
670
|
+
* @returns New FS snapshot with the write committed.
|
|
671
|
+
* @throws {PermissionError} If this snapshot is read-only.
|
|
672
|
+
* @throws {StaleSnapshotError} If the branch has advanced since this snapshot.
|
|
673
|
+
*/
|
|
674
|
+
async writeText(path, text, opts) {
|
|
675
|
+
const data = new TextEncoder().encode(text);
|
|
676
|
+
return this.write(path, data, opts);
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Write a local file into the repo and commit, returning a new FS.
|
|
680
|
+
*
|
|
681
|
+
* Executable permission is auto-detected from disk unless `mode` is set.
|
|
682
|
+
*
|
|
683
|
+
* @param path - Destination path in the repo.
|
|
684
|
+
* @param localPath - Path to the local file.
|
|
685
|
+
* @param opts - Optional write options.
|
|
686
|
+
* @param opts.message - Commit message (auto-generated if omitted).
|
|
687
|
+
* @param opts.mode - File mode override (e.g. `'executable'`).
|
|
688
|
+
* @returns New FS snapshot with the write committed.
|
|
689
|
+
* @throws {PermissionError} If this snapshot is read-only.
|
|
690
|
+
* @throws {StaleSnapshotError} If the branch has advanced since this snapshot.
|
|
691
|
+
*/
|
|
692
|
+
async writeFromFile(path, localPath, opts) {
|
|
693
|
+
const normalized = normalizePath(path);
|
|
694
|
+
const detectedMode = await modeFromDisk(this._fsModule, localPath);
|
|
695
|
+
const mode = opts?.mode
|
|
696
|
+
? resolveMode(opts.mode)
|
|
697
|
+
: detectedMode;
|
|
698
|
+
const data = (await this._fsModule.promises.readFile(localPath));
|
|
699
|
+
const blobOid = await git.writeBlob({ fs: this._fsModule, gitdir: this._gitdir, blob: data });
|
|
700
|
+
const writes = new Map([[normalized, { oid: blobOid, mode }]]);
|
|
701
|
+
return this._commitChanges(writes, new Set(), opts?.message);
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Create a symbolic link entry and commit, returning a new FS.
|
|
705
|
+
*
|
|
706
|
+
* @param path - Symlink path in the repo.
|
|
707
|
+
* @param target - The symlink target string.
|
|
708
|
+
* @param opts - Optional write options.
|
|
709
|
+
* @param opts.message - Commit message (auto-generated if omitted).
|
|
710
|
+
* @returns New FS snapshot with the symlink committed.
|
|
711
|
+
* @throws {PermissionError} If this snapshot is read-only.
|
|
712
|
+
* @throws {StaleSnapshotError} If the branch has advanced since this snapshot.
|
|
713
|
+
*/
|
|
714
|
+
async writeSymlink(path, target, opts) {
|
|
715
|
+
const normalized = normalizePath(path);
|
|
716
|
+
const data = new TextEncoder().encode(target);
|
|
717
|
+
const writes = new Map([[normalized, { data, mode: MODE_LINK }]]);
|
|
718
|
+
return this._commitChanges(writes, new Set(), opts?.message);
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Apply multiple writes and removes in a single atomic commit.
|
|
722
|
+
*
|
|
723
|
+
* `writes` maps repo paths to content. Values may be:
|
|
724
|
+
* - `Uint8Array` -- raw blob data
|
|
725
|
+
* - `string` -- UTF-8 text (encoded automatically)
|
|
726
|
+
* - `WriteEntry` -- full control over source, mode, and symlinks
|
|
727
|
+
*
|
|
728
|
+
* `removes` lists repo paths to delete (string, array, or Set).
|
|
729
|
+
*
|
|
730
|
+
* @param writes - Map of repo paths to content.
|
|
731
|
+
* @param removes - Path(s) to delete.
|
|
732
|
+
* @param opts - Optional apply options.
|
|
733
|
+
* @param opts.message - Commit message (auto-generated if omitted).
|
|
734
|
+
* @param opts.operation - Operation name for auto-generated messages.
|
|
735
|
+
* @returns New FS snapshot with the changes committed.
|
|
736
|
+
* @throws {PermissionError} If this snapshot is read-only.
|
|
737
|
+
* @throws {StaleSnapshotError} If the branch has advanced since this snapshot.
|
|
738
|
+
*/
|
|
739
|
+
async apply(writes, removes, opts) {
|
|
740
|
+
const internalWrites = new Map();
|
|
741
|
+
for (const [path, value] of Object.entries(writes ?? {})) {
|
|
742
|
+
const normalized = normalizePath(path);
|
|
743
|
+
// Normalize to WriteEntry
|
|
744
|
+
let entry;
|
|
745
|
+
if (value instanceof Uint8Array) {
|
|
746
|
+
entry = { data: value };
|
|
747
|
+
}
|
|
748
|
+
else if (typeof value === 'string') {
|
|
749
|
+
entry = { data: value };
|
|
750
|
+
}
|
|
751
|
+
else if (typeof value === 'object' && value !== null) {
|
|
752
|
+
entry = value;
|
|
753
|
+
}
|
|
754
|
+
else {
|
|
755
|
+
throw new TypeError(`Expected WriteEntry, Uint8Array, or string for '${path}', got ${typeof value}`);
|
|
756
|
+
}
|
|
757
|
+
if (entry.target != null) {
|
|
758
|
+
// Symlink
|
|
759
|
+
const data = new TextEncoder().encode(entry.target);
|
|
760
|
+
const blobOid = await git.writeBlob({
|
|
761
|
+
fs: this._fsModule,
|
|
762
|
+
gitdir: this._gitdir,
|
|
763
|
+
blob: data,
|
|
764
|
+
});
|
|
765
|
+
internalWrites.set(normalized, { oid: blobOid, mode: MODE_LINK });
|
|
766
|
+
}
|
|
767
|
+
else if (entry.data != null) {
|
|
768
|
+
const data = typeof entry.data === 'string'
|
|
769
|
+
? new TextEncoder().encode(entry.data)
|
|
770
|
+
: entry.data;
|
|
771
|
+
const mode = entry.mode
|
|
772
|
+
? resolveMode(entry.mode)
|
|
773
|
+
: MODE_BLOB;
|
|
774
|
+
internalWrites.set(normalized, { data, mode });
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
// Normalize removes
|
|
778
|
+
let removeSet;
|
|
779
|
+
if (removes == null) {
|
|
780
|
+
removeSet = new Set();
|
|
781
|
+
}
|
|
782
|
+
else if (typeof removes === 'string') {
|
|
783
|
+
removeSet = new Set([normalizePath(removes)]);
|
|
784
|
+
}
|
|
785
|
+
else if (removes instanceof Set) {
|
|
786
|
+
removeSet = new Set([...removes].map(normalizePath));
|
|
787
|
+
}
|
|
788
|
+
else {
|
|
789
|
+
removeSet = new Set(removes.map(normalizePath));
|
|
790
|
+
}
|
|
791
|
+
return this._commitChanges(internalWrites, removeSet, opts?.message, opts?.operation);
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Return a Batch for accumulating multiple writes in one commit.
|
|
795
|
+
*
|
|
796
|
+
* @param opts - Optional batch options.
|
|
797
|
+
* @param opts.message - Commit message (auto-generated if omitted).
|
|
798
|
+
* @param opts.operation - Operation name for auto-generated messages.
|
|
799
|
+
* @returns A Batch instance. Call `batch.commit()` to finalize.
|
|
800
|
+
* @throws {PermissionError} If this snapshot is read-only.
|
|
801
|
+
*/
|
|
802
|
+
batch(opts) {
|
|
803
|
+
return new Batch(this, opts?.message, opts?.operation);
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Return a buffered writer that commits on close.
|
|
807
|
+
*
|
|
808
|
+
* Accepts `Uint8Array` or `string` via `write()`. Strings are UTF-8 encoded.
|
|
809
|
+
*
|
|
810
|
+
* @param path - Destination path in the repo.
|
|
811
|
+
* @returns An FsWriter instance. Call `close()` to flush and commit.
|
|
812
|
+
* @throws {PermissionError} If this snapshot is read-only.
|
|
813
|
+
*/
|
|
814
|
+
writer(path) {
|
|
815
|
+
if (!this._writable)
|
|
816
|
+
throw this._readonlyError('write to');
|
|
817
|
+
return new FsWriter(this, path);
|
|
818
|
+
}
|
|
819
|
+
// ---------------------------------------------------------------------------
|
|
820
|
+
// Copy / Sync / Remove / Move (delegates to copy module)
|
|
821
|
+
// ---------------------------------------------------------------------------
|
|
822
|
+
/**
|
|
823
|
+
* Copy local files into the repo.
|
|
824
|
+
*
|
|
825
|
+
* Sources must be literal paths; use `diskGlob()` to expand patterns
|
|
826
|
+
* before calling.
|
|
827
|
+
*
|
|
828
|
+
* @param sources - Local path(s). Trailing `/` copies contents; `/./` is a pivot marker.
|
|
829
|
+
* @param dest - Destination path in the repo.
|
|
830
|
+
* @param opts - Copy-in options.
|
|
831
|
+
* @param opts.dryRun - Preview only; returned FS has `.changes` set.
|
|
832
|
+
* @param opts.followSymlinks - Dereference symlinks on disk.
|
|
833
|
+
* @param opts.message - Commit message (auto-generated if omitted).
|
|
834
|
+
* @param opts.mode - Override file mode for all files.
|
|
835
|
+
* @param opts.ignoreExisting - Skip files that already exist at dest.
|
|
836
|
+
* @param opts.delete - Remove repo files under dest not in source.
|
|
837
|
+
* @param opts.ignoreErrors - Collect errors instead of aborting.
|
|
838
|
+
* @param opts.checksum - Compare by content hash (default true).
|
|
839
|
+
* @returns New FS with `.changes` set.
|
|
840
|
+
* @throws {PermissionError} If this snapshot is read-only.
|
|
841
|
+
*/
|
|
842
|
+
async copyIn(sources, dest, opts = {}) {
|
|
843
|
+
const { copyIn } = await import('./copy.js');
|
|
844
|
+
return copyIn(this, sources, dest, opts);
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Copy repo files to local disk.
|
|
848
|
+
*
|
|
849
|
+
* Sources must be literal repo paths; use `glob()` to expand patterns
|
|
850
|
+
* before calling.
|
|
851
|
+
*
|
|
852
|
+
* @param sources - Repo path(s). Trailing `/` copies contents; `/./` is a pivot marker.
|
|
853
|
+
* @param dest - Local destination directory.
|
|
854
|
+
* @param opts - Copy-out options.
|
|
855
|
+
* @param opts.dryRun - Preview only; returned FS has `.changes` set.
|
|
856
|
+
* @param opts.ignoreExisting - Skip files that already exist at dest.
|
|
857
|
+
* @param opts.delete - Remove local files under dest not in source.
|
|
858
|
+
* @param opts.ignoreErrors - Collect errors instead of aborting.
|
|
859
|
+
* @param opts.checksum - Compare by content hash (default true).
|
|
860
|
+
* @returns This FS with `.changes` set.
|
|
861
|
+
*/
|
|
862
|
+
async copyOut(sources, dest, opts = {}) {
|
|
863
|
+
const { copyOut } = await import('./copy.js');
|
|
864
|
+
return copyOut(this, sources, dest, opts);
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Make repoPath identical to localPath (including deletes).
|
|
868
|
+
*
|
|
869
|
+
* @param localPath - Local directory to sync from.
|
|
870
|
+
* @param repoPath - Repo directory to sync to.
|
|
871
|
+
* @param opts - Sync-in options.
|
|
872
|
+
* @param opts.dryRun - Preview only; returned FS has `.changes` set.
|
|
873
|
+
* @param opts.message - Commit message (auto-generated if omitted).
|
|
874
|
+
* @param opts.ignoreErrors - Collect errors instead of aborting.
|
|
875
|
+
* @param opts.checksum - Compare by content hash (default true).
|
|
876
|
+
* @returns New FS with `.changes` set.
|
|
877
|
+
* @throws {PermissionError} If this snapshot is read-only.
|
|
878
|
+
*/
|
|
879
|
+
async syncIn(localPath, repoPath, opts = {}) {
|
|
880
|
+
const { syncIn } = await import('./copy.js');
|
|
881
|
+
return syncIn(this, localPath, repoPath, opts);
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Make localPath identical to repoPath (including deletes).
|
|
885
|
+
*
|
|
886
|
+
* @param repoPath - Repo directory to sync from.
|
|
887
|
+
* @param localPath - Local directory to sync to.
|
|
888
|
+
* @param opts - Sync-out options.
|
|
889
|
+
* @param opts.dryRun - Preview only; returned FS has `.changes` set.
|
|
890
|
+
* @param opts.ignoreErrors - Collect errors instead of aborting.
|
|
891
|
+
* @param opts.checksum - Compare by content hash (default true).
|
|
892
|
+
* @returns This FS with `.changes` set.
|
|
893
|
+
*/
|
|
894
|
+
async syncOut(repoPath, localPath, opts = {}) {
|
|
895
|
+
const { syncOut } = await import('./copy.js');
|
|
896
|
+
return syncOut(this, repoPath, localPath, opts);
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Remove files from the repo.
|
|
900
|
+
*
|
|
901
|
+
* Sources must be literal paths; use `glob()` to expand patterns before calling.
|
|
902
|
+
*
|
|
903
|
+
* @param sources - Repo path(s) to remove.
|
|
904
|
+
* @param opts - Remove options.
|
|
905
|
+
* @param opts.recursive - Allow removing directories.
|
|
906
|
+
* @param opts.dryRun - Preview only; returned FS has `.changes` set.
|
|
907
|
+
* @param opts.message - Commit message (auto-generated if omitted).
|
|
908
|
+
* @returns New FS with `.changes` set.
|
|
909
|
+
* @throws {PermissionError} If this snapshot is read-only.
|
|
910
|
+
* @throws {FileNotFoundError} If no source paths match.
|
|
911
|
+
*/
|
|
912
|
+
async remove(sources, opts = {}) {
|
|
913
|
+
const { remove } = await import('./copy.js');
|
|
914
|
+
return remove(this, sources, opts);
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Move or rename files within the repo.
|
|
918
|
+
*
|
|
919
|
+
* Sources must be literal paths; use `glob()` to expand patterns before calling.
|
|
920
|
+
*
|
|
921
|
+
* @param sources - Repo path(s) to move.
|
|
922
|
+
* @param dest - Destination path in the repo.
|
|
923
|
+
* @param opts - Move options.
|
|
924
|
+
* @param opts.recursive - Allow moving directories.
|
|
925
|
+
* @param opts.dryRun - Preview only; returned FS has `.changes` set.
|
|
926
|
+
* @param opts.message - Commit message (auto-generated if omitted).
|
|
927
|
+
* @returns New FS with `.changes` set.
|
|
928
|
+
* @throws {PermissionError} If this snapshot is read-only.
|
|
929
|
+
*/
|
|
930
|
+
async move(sources, dest, opts = {}) {
|
|
931
|
+
const { move } = await import('./copy.js');
|
|
932
|
+
return move(this, sources, dest, opts);
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Copy files from source FS into this branch in a single atomic commit.
|
|
936
|
+
*
|
|
937
|
+
* Follows the same rsync trailing-slash conventions as `copyIn`/`copyOut`:
|
|
938
|
+
*
|
|
939
|
+
* - `"config"` → directory mode — copies `config/` *as* `config/` under dest.
|
|
940
|
+
* - `"config/"` → contents mode — pours the *contents* of `config/` into dest.
|
|
941
|
+
* - `"file.txt"` → file mode — copies the single file into dest.
|
|
942
|
+
* - `""` or `"/"` → root contents mode — copies everything.
|
|
943
|
+
*
|
|
944
|
+
* Since both snapshots share the same object store, blobs are referenced
|
|
945
|
+
* by OID — no data is read into memory regardless of file size.
|
|
946
|
+
*
|
|
947
|
+
* @param source - Any FS (branch, tag, detached commit). Read-only; not modified.
|
|
948
|
+
* @param sources - Source path(s) in source. Accepts a single string or array. Defaults to `""` (root).
|
|
949
|
+
* @param dest - Destination path in this branch. Defaults to `""` (root).
|
|
950
|
+
* @param opts - Copy-ref options.
|
|
951
|
+
* @param opts.delete - Remove dest files under the target that aren't in source.
|
|
952
|
+
* @param opts.dryRun - Compute changes but don't commit. Returned FS has `.changes` set.
|
|
953
|
+
* @param opts.message - Commit message (auto-generated if omitted).
|
|
954
|
+
* @returns New FS for the dest branch with the commit applied.
|
|
955
|
+
* @throws {Error} If source belongs to a different repo.
|
|
956
|
+
* @throws {PermissionError} If this FS is read-only.
|
|
957
|
+
*/
|
|
958
|
+
async copyFromRef(source, sources, dest, opts) {
|
|
959
|
+
if (!this._writable)
|
|
960
|
+
throw this._readonlyError('write to');
|
|
961
|
+
// Validate same repo
|
|
962
|
+
const selfPath = this._fsModule.realpathSync(this._gitdir);
|
|
963
|
+
const srcFsPath = this._fsModule.realpathSync(source._gitdir);
|
|
964
|
+
if (selfPath !== srcFsPath) {
|
|
965
|
+
throw new Error('source must belong to the same repo as self');
|
|
966
|
+
}
|
|
967
|
+
// Normalize sources to list
|
|
968
|
+
const sourcesList = sources === undefined || sources === null
|
|
969
|
+
? ['']
|
|
970
|
+
: typeof sources === 'string' ? [sources] : sources;
|
|
971
|
+
// Normalize dest
|
|
972
|
+
const destNorm = dest !== undefined && dest !== null && dest !== ''
|
|
973
|
+
? (isRootPath(dest) ? '' : normalizePath(dest))
|
|
974
|
+
: '';
|
|
975
|
+
const { resolveRepoSources, walkRepo } = await import('./copy.js');
|
|
976
|
+
// Resolve sources using rsync conventions
|
|
977
|
+
const resolved = await resolveRepoSources(source, sourcesList);
|
|
978
|
+
// Enumerate source files → Map<dest_path, {oid, mode}>
|
|
979
|
+
const srcMapped = new Map();
|
|
980
|
+
for (const { repoPath, mode, prefix } of resolved) {
|
|
981
|
+
const _dest = [destNorm, prefix].filter(Boolean).join('/');
|
|
982
|
+
if (mode === 'file') {
|
|
983
|
+
const name = repoPath.includes('/') ? repoPath.split('/').pop() : repoPath;
|
|
984
|
+
const destFile = _dest ? `${_dest}/${name}` : name;
|
|
985
|
+
const entry = await entryAtPath(this._fsModule, this._gitdir, source._treeOid, repoPath);
|
|
986
|
+
if (entry) {
|
|
987
|
+
srcMapped.set(normalizePath(destFile), { oid: entry.oid, mode: entry.mode });
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
else if (mode === 'dir') {
|
|
991
|
+
const dirName = repoPath.includes('/') ? repoPath.split('/').pop() : repoPath;
|
|
992
|
+
const target = _dest ? `${_dest}/${dirName}` : dirName;
|
|
993
|
+
for await (const [dirpath, , files] of source.walk(repoPath)) {
|
|
994
|
+
for (const fe of files) {
|
|
995
|
+
const storePath = dirpath ? `${dirpath}/${fe.name}` : fe.name;
|
|
996
|
+
const rel = repoPath && storePath.startsWith(repoPath + '/')
|
|
997
|
+
? storePath.slice(repoPath.length + 1)
|
|
998
|
+
: storePath;
|
|
999
|
+
srcMapped.set(normalizePath(`${target}/${rel}`), { oid: fe.oid, mode: fe.mode });
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
else {
|
|
1004
|
+
// contents
|
|
1005
|
+
const walkPath = repoPath || null;
|
|
1006
|
+
for await (const [dirpath, , files] of source.walk(walkPath)) {
|
|
1007
|
+
for (const fe of files) {
|
|
1008
|
+
const storePath = dirpath ? `${dirpath}/${fe.name}` : fe.name;
|
|
1009
|
+
const rel = repoPath && storePath.startsWith(repoPath + '/')
|
|
1010
|
+
? storePath.slice(repoPath.length + 1)
|
|
1011
|
+
: storePath;
|
|
1012
|
+
const destFile = _dest ? `${_dest}/${rel}` : rel;
|
|
1013
|
+
srcMapped.set(normalizePath(destFile), { oid: fe.oid, mode: fe.mode });
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
// Determine dest subtree(s) to walk for diff/delete
|
|
1019
|
+
const destPrefixes = new Set();
|
|
1020
|
+
for (const { repoPath, mode, prefix } of resolved) {
|
|
1021
|
+
const _dest = [destNorm, prefix].filter(Boolean).join('/');
|
|
1022
|
+
if (mode === 'dir') {
|
|
1023
|
+
const dirName = repoPath.includes('/') ? repoPath.split('/').pop() : repoPath;
|
|
1024
|
+
destPrefixes.add(_dest ? `${_dest}/${dirName}` : dirName);
|
|
1025
|
+
}
|
|
1026
|
+
else {
|
|
1027
|
+
destPrefixes.add(_dest);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
const destFiles = new Map();
|
|
1031
|
+
for (const dp of destPrefixes) {
|
|
1032
|
+
const walked = await walkRepo(this, dp);
|
|
1033
|
+
for (const [rel, entry] of walked) {
|
|
1034
|
+
const full = dp ? `${dp}/${rel}` : rel;
|
|
1035
|
+
destFiles.set(full, entry);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
// Build writes and removes
|
|
1039
|
+
const writes = new Map();
|
|
1040
|
+
const removes = new Set();
|
|
1041
|
+
for (const [destPath, srcEntry] of srcMapped) {
|
|
1042
|
+
const destEntry = destFiles.get(destPath);
|
|
1043
|
+
if (!destEntry || destEntry.oid !== srcEntry.oid || destEntry.mode !== srcEntry.mode) {
|
|
1044
|
+
writes.set(destPath, { oid: srcEntry.oid, mode: srcEntry.mode });
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
if (opts?.delete) {
|
|
1048
|
+
for (const full of destFiles.keys()) {
|
|
1049
|
+
if (!srcMapped.has(full)) {
|
|
1050
|
+
removes.add(full);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
if (opts?.dryRun) {
|
|
1055
|
+
const changes = await this._buildChanges(writes, removes);
|
|
1056
|
+
this._changes = finalizeChanges(changes);
|
|
1057
|
+
return this;
|
|
1058
|
+
}
|
|
1059
|
+
return this._commitChanges(writes, removes, opts?.message, 'cp');
|
|
1060
|
+
}
|
|
1061
|
+
// ---------------------------------------------------------------------------
|
|
1062
|
+
// History
|
|
1063
|
+
// ---------------------------------------------------------------------------
|
|
1064
|
+
/**
|
|
1065
|
+
* The parent snapshot, or `null` for the initial commit.
|
|
1066
|
+
*
|
|
1067
|
+
* @returns The parent FS, or `null` if this is the initial commit.
|
|
1068
|
+
*/
|
|
1069
|
+
async getParent() {
|
|
1070
|
+
const { commit } = await git.readCommit({
|
|
1071
|
+
fs: this._fsModule,
|
|
1072
|
+
gitdir: this._gitdir,
|
|
1073
|
+
oid: this._commitOid,
|
|
1074
|
+
});
|
|
1075
|
+
if (!commit.parent || commit.parent.length === 0)
|
|
1076
|
+
return null;
|
|
1077
|
+
return FS._fromCommit(this._store, commit.parent[0], this._refName, this._writable);
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Return the FS at the n-th ancestor commit.
|
|
1081
|
+
*
|
|
1082
|
+
* @param n - Number of commits to go back (default 1).
|
|
1083
|
+
* @returns FS at the ancestor commit.
|
|
1084
|
+
* @throws {Error} If n < 0 or history is too short.
|
|
1085
|
+
*/
|
|
1086
|
+
async back(n = 1) {
|
|
1087
|
+
if (n < 0)
|
|
1088
|
+
throw new Error(`back() requires n >= 0, got ${n}`);
|
|
1089
|
+
let fs = this;
|
|
1090
|
+
for (let i = 0; i < n; i++) {
|
|
1091
|
+
const p = await fs.getParent();
|
|
1092
|
+
if (p === null)
|
|
1093
|
+
throw new Error(`Cannot go back ${n} commits - history too short`);
|
|
1094
|
+
fs = p;
|
|
1095
|
+
}
|
|
1096
|
+
return fs;
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Move branch back N commits.
|
|
1100
|
+
*
|
|
1101
|
+
* Walks back through parent commits and updates the branch pointer.
|
|
1102
|
+
* Automatically writes a reflog entry.
|
|
1103
|
+
*
|
|
1104
|
+
* @param steps - Number of commits to undo (default 1).
|
|
1105
|
+
* @returns New FS snapshot at the ancestor commit.
|
|
1106
|
+
* @throws {PermissionError} If called on a read-only snapshot (tag).
|
|
1107
|
+
* @throws {Error} If not enough history exists.
|
|
1108
|
+
* @throws {StaleSnapshotError} If the branch has advanced since this snapshot.
|
|
1109
|
+
*/
|
|
1110
|
+
async undo(steps = 1) {
|
|
1111
|
+
if (steps < 1)
|
|
1112
|
+
throw new Error(`steps must be >= 1, got ${steps}`);
|
|
1113
|
+
if (!this._writable)
|
|
1114
|
+
throw this._readonlyError('undo');
|
|
1115
|
+
let current = this;
|
|
1116
|
+
for (let i = 0; i < steps; i++) {
|
|
1117
|
+
const parent = await current.getParent();
|
|
1118
|
+
if (parent === null) {
|
|
1119
|
+
throw new Error(`Cannot undo ${steps} steps - only ${i} commit(s) in history`);
|
|
1120
|
+
}
|
|
1121
|
+
current = parent;
|
|
1122
|
+
}
|
|
1123
|
+
const refName = `refs/heads/${this._refName}`;
|
|
1124
|
+
const sig = this._store._signature;
|
|
1125
|
+
const committerStr = `${sig.name} <${sig.email}>`;
|
|
1126
|
+
const myOid = this._commitOid;
|
|
1127
|
+
await withRepoLock(this._fsModule, this._gitdir, async () => {
|
|
1128
|
+
const currentOid = await git.resolveRef({
|
|
1129
|
+
fs: this._fsModule,
|
|
1130
|
+
gitdir: this._gitdir,
|
|
1131
|
+
ref: refName,
|
|
1132
|
+
});
|
|
1133
|
+
if (currentOid !== myOid) {
|
|
1134
|
+
throw new StaleSnapshotError(`Branch '${this._refName}' has advanced since this snapshot`);
|
|
1135
|
+
}
|
|
1136
|
+
await git.writeRef({
|
|
1137
|
+
fs: this._fsModule,
|
|
1138
|
+
gitdir: this._gitdir,
|
|
1139
|
+
ref: refName,
|
|
1140
|
+
value: current._commitOid,
|
|
1141
|
+
force: true,
|
|
1142
|
+
});
|
|
1143
|
+
await writeReflogEntry(this._fsModule, this._gitdir, refName, myOid, current._commitOid, committerStr, 'undo: move back');
|
|
1144
|
+
});
|
|
1145
|
+
return current;
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Move branch forward N steps using reflog.
|
|
1149
|
+
*
|
|
1150
|
+
* Reads the reflog to find where the branch was before the last N movements.
|
|
1151
|
+
* This can resurrect "orphaned" commits after undo.
|
|
1152
|
+
*
|
|
1153
|
+
* @param steps - Number of reflog entries to go back (default 1).
|
|
1154
|
+
* @returns New FS snapshot at the target position.
|
|
1155
|
+
* @throws {PermissionError} If called on a read-only snapshot (tag).
|
|
1156
|
+
* @throws {Error} If not enough redo history exists.
|
|
1157
|
+
* @throws {StaleSnapshotError} If the branch has advanced since this snapshot.
|
|
1158
|
+
*/
|
|
1159
|
+
async redo(steps = 1) {
|
|
1160
|
+
if (steps < 1)
|
|
1161
|
+
throw new Error(`steps must be >= 1, got ${steps}`);
|
|
1162
|
+
if (!this._writable)
|
|
1163
|
+
throw this._readonlyError('redo');
|
|
1164
|
+
const refName = `refs/heads/${this._refName}`;
|
|
1165
|
+
// Read reflog
|
|
1166
|
+
const entries = await readReflog(this._fsModule, this._gitdir, this._refName);
|
|
1167
|
+
if (entries.length === 0)
|
|
1168
|
+
throw new Error('Reflog is empty');
|
|
1169
|
+
// Find current position in reflog
|
|
1170
|
+
let currentIndex = null;
|
|
1171
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
1172
|
+
if (entries[i].newSha === this._commitOid) {
|
|
1173
|
+
currentIndex = i;
|
|
1174
|
+
break;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
if (currentIndex === null) {
|
|
1178
|
+
throw new Error('Cannot redo - current commit not in reflog');
|
|
1179
|
+
}
|
|
1180
|
+
// Walk back through reflog entries to find target
|
|
1181
|
+
let targetSha = this._commitOid;
|
|
1182
|
+
let index = currentIndex;
|
|
1183
|
+
for (let step = 0; step < steps; step++) {
|
|
1184
|
+
if (index < 0) {
|
|
1185
|
+
throw new Error(`Cannot redo ${steps} steps - only ${step} step(s) available`);
|
|
1186
|
+
}
|
|
1187
|
+
targetSha = entries[index].oldSha;
|
|
1188
|
+
if (targetSha === ZERO_SHA) {
|
|
1189
|
+
throw new Error(`Cannot redo ${steps} step(s) - reaches branch creation point`);
|
|
1190
|
+
}
|
|
1191
|
+
index--;
|
|
1192
|
+
}
|
|
1193
|
+
const targetFs = await FS._fromCommit(this._store, targetSha, this._refName, this._writable);
|
|
1194
|
+
const sig = this._store._signature;
|
|
1195
|
+
const committerStr = `${sig.name} <${sig.email}>`;
|
|
1196
|
+
const myOid = this._commitOid;
|
|
1197
|
+
await withRepoLock(this._fsModule, this._gitdir, async () => {
|
|
1198
|
+
const currentOid = await git.resolveRef({
|
|
1199
|
+
fs: this._fsModule,
|
|
1200
|
+
gitdir: this._gitdir,
|
|
1201
|
+
ref: refName,
|
|
1202
|
+
});
|
|
1203
|
+
if (currentOid !== myOid) {
|
|
1204
|
+
throw new StaleSnapshotError(`Branch '${this._refName}' has advanced since this snapshot`);
|
|
1205
|
+
}
|
|
1206
|
+
await git.writeRef({
|
|
1207
|
+
fs: this._fsModule,
|
|
1208
|
+
gitdir: this._gitdir,
|
|
1209
|
+
ref: refName,
|
|
1210
|
+
value: targetSha,
|
|
1211
|
+
force: true,
|
|
1212
|
+
});
|
|
1213
|
+
await writeReflogEntry(this._fsModule, this._gitdir, refName, myOid, targetSha, committerStr, 'redo: move forward');
|
|
1214
|
+
});
|
|
1215
|
+
return targetFs;
|
|
1216
|
+
}
|
|
1217
|
+
/**
|
|
1218
|
+
* Walk the commit history, yielding ancestor FS snapshots.
|
|
1219
|
+
*
|
|
1220
|
+
* All filters are optional and combine with AND.
|
|
1221
|
+
*
|
|
1222
|
+
* @param opts - Log filter options.
|
|
1223
|
+
* @param opts.path - Only yield commits that changed this file.
|
|
1224
|
+
* @param opts.match - Message pattern (`*`/`?` wildcards).
|
|
1225
|
+
* @param opts.before - Only yield commits on or before this time.
|
|
1226
|
+
*/
|
|
1227
|
+
async *log(opts) {
|
|
1228
|
+
const filterPath = opts?.path ? normalizePath(opts.path) : null;
|
|
1229
|
+
const match = opts?.match ?? null;
|
|
1230
|
+
const before = opts?.before ?? null;
|
|
1231
|
+
let pastCutoff = false;
|
|
1232
|
+
let current = this;
|
|
1233
|
+
while (current !== null) {
|
|
1234
|
+
if (!pastCutoff && before !== null) {
|
|
1235
|
+
const time = await current.getTime();
|
|
1236
|
+
if (time > before) {
|
|
1237
|
+
current = await current.getParent();
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
pastCutoff = true;
|
|
1241
|
+
}
|
|
1242
|
+
if (filterPath !== null) {
|
|
1243
|
+
const currentEntry = await entryAtPath(this._fsModule, this._gitdir, current._treeOid, filterPath);
|
|
1244
|
+
const parent = await current.getParent();
|
|
1245
|
+
const parentEntry = parent
|
|
1246
|
+
? await entryAtPath(this._fsModule, this._gitdir, parent._treeOid, filterPath)
|
|
1247
|
+
: null;
|
|
1248
|
+
if (currentEntry?.oid === parentEntry?.oid &&
|
|
1249
|
+
currentEntry?.mode === parentEntry?.mode) {
|
|
1250
|
+
current = parent;
|
|
1251
|
+
continue;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
if (match !== null) {
|
|
1255
|
+
const msg = await current.getMessage();
|
|
1256
|
+
if (!globMatch(match, msg)) {
|
|
1257
|
+
current = await current.getParent();
|
|
1258
|
+
continue;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
yield current;
|
|
1262
|
+
current = await current.getParent();
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
// ---------------------------------------------------------------------------
|
|
1267
|
+
// Standalone helpers
|
|
1268
|
+
// ---------------------------------------------------------------------------
|
|
1269
|
+
import { modeFromDisk } from './tree.js';
|
|
1270
|
+
/**
|
|
1271
|
+
* Resolve a mode that may be a FileType name ('blob', 'executable', 'link')
|
|
1272
|
+
* or a git mode string ('100644', '100755', '120000').
|
|
1273
|
+
*/
|
|
1274
|
+
function resolveMode(mode) {
|
|
1275
|
+
// Git mode strings are 6-digit octal like '100644'
|
|
1276
|
+
if (typeof mode === 'string' && /^\d{6}$/.test(mode))
|
|
1277
|
+
return mode;
|
|
1278
|
+
return fileModeFromType(mode);
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Write data to a branch with automatic retry on concurrent modification.
|
|
1282
|
+
*
|
|
1283
|
+
* Re-fetches the branch FS on each attempt. Uses exponential backoff
|
|
1284
|
+
* with jitter (base 10ms, factor 2x, cap 200ms) to avoid thundering-herd.
|
|
1285
|
+
*
|
|
1286
|
+
* @param store - The GitStore instance.
|
|
1287
|
+
* @param branch - Branch name to write to.
|
|
1288
|
+
* @param path - Destination path in the repo.
|
|
1289
|
+
* @param data - Raw bytes to write.
|
|
1290
|
+
* @param opts - Optional retry-write options.
|
|
1291
|
+
* @param opts.message - Commit message (auto-generated if omitted).
|
|
1292
|
+
* @param opts.mode - File mode override (e.g. `'executable'`).
|
|
1293
|
+
* @param opts.retries - Maximum number of attempts (default 5).
|
|
1294
|
+
* @returns New FS snapshot with the write committed.
|
|
1295
|
+
* @throws {StaleSnapshotError} If all attempts are exhausted.
|
|
1296
|
+
* @throws {Error} If the branch does not exist.
|
|
1297
|
+
*/
|
|
1298
|
+
export async function retryWrite(store, branch, path, data, opts) {
|
|
1299
|
+
const retries = opts?.retries ?? 5;
|
|
1300
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
1301
|
+
const fs = await store.branches.get(branch);
|
|
1302
|
+
try {
|
|
1303
|
+
return await fs.write(path, data, opts);
|
|
1304
|
+
}
|
|
1305
|
+
catch (err) {
|
|
1306
|
+
if (err instanceof StaleSnapshotError) {
|
|
1307
|
+
if (attempt === retries - 1)
|
|
1308
|
+
throw err;
|
|
1309
|
+
const delay = Math.min(10 * 2 ** attempt, 200);
|
|
1310
|
+
await new Promise((r) => setTimeout(r, Math.random() * delay));
|
|
1311
|
+
continue;
|
|
1312
|
+
}
|
|
1313
|
+
throw err;
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
throw new Error('unreachable');
|
|
1317
|
+
}
|
|
1318
|
+
//# sourceMappingURL=fs.js.map
|