@anionex/dsh-turn-rewind 0.1.1 → 0.1.2

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/lib/snapshot.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { lstat, readFile, readlink } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, open, readlink } from 'node:fs/promises';
3
4
  import { ChangeLedgerError } from './errors.js';
4
5
  import { discoverRepository, sameRepositoryFence } from './git.js';
5
6
  import { isNodeError, resolveWorkspacePath } from './path-utils.js';
7
+ const COMPARISON_READ_BUDGET_BYTES = 128 * 1024 * 1024;
6
8
  /** Capture the current tracked and non-ignored Git working tree. */
7
9
  export async function captureTree(options) {
8
10
  throwIfAborted(options.signal);
@@ -11,12 +13,19 @@ export async function captureTree(options) {
11
13
  throw new ChangeLedgerError('TOO_MANY_FILES', `workspace has ${source.paths.length} eligible paths; configured maximum is ${options.config.maxFiles}`);
12
14
  }
13
15
  const entries = Object.create(null);
16
+ const gitObjectFormat = options.gitObjectFormat;
17
+ const gitCapture = gitObjectFormat === undefined
18
+ ? undefined
19
+ : {
20
+ objectFormat: gitObjectFormat,
21
+ entries: Object.create(null),
22
+ };
14
23
  let totalBytes = 0;
15
- for (const path of source.paths) {
24
+ const capturePath = async (path) => {
16
25
  throwIfAborted(options.signal);
17
26
  const entry = await captureEntry(source.state.root, path, options.config.maxFileBytes, options.signal);
18
27
  if (entry === undefined)
19
- continue;
28
+ return;
20
29
  if (entry.kind === 'file') {
21
30
  totalBytes += entry.content.length;
22
31
  if (totalBytes > options.config.maxSnapshotBytes) {
@@ -26,13 +35,50 @@ export async function captureTree(options) {
26
35
  await options.store.putBlob(source.state.root, entry.snapshot.blob, entry.content);
27
36
  }
28
37
  entries[path] = entry.snapshot;
29
- continue;
38
+ if (gitCapture !== undefined) {
39
+ gitCapture.entries[path] = gitFileSnapshot(entry.snapshot, entry.content, gitCapture.objectFormat);
40
+ }
41
+ return;
30
42
  }
31
43
  entries[path] = entry.snapshot;
44
+ if (gitCapture !== undefined)
45
+ gitCapture.entries[path] = entry.snapshot;
46
+ };
47
+ if (gitCapture === undefined) {
48
+ for (const path of source.paths)
49
+ await capturePath(path);
50
+ }
51
+ else {
52
+ let next = 0;
53
+ let failed = false;
54
+ let firstError = new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', 'parallel comparison capture failed');
55
+ const concurrency = Math.min(32, source.paths.length, Math.max(1, Math.floor(COMPARISON_READ_BUDGET_BYTES / options.config.maxFileBytes)));
56
+ const workers = Array.from({ length: concurrency }, async () => {
57
+ while (!failed && next < source.paths.length) {
58
+ const index = next;
59
+ next += 1;
60
+ const path = source.paths[index];
61
+ if (path === undefined)
62
+ continue;
63
+ try {
64
+ await capturePath(path);
65
+ }
66
+ catch (error) {
67
+ if (!failed) {
68
+ failed = true;
69
+ firstError = error;
70
+ }
71
+ }
72
+ }
73
+ });
74
+ await Promise.all(workers);
75
+ if (failed)
76
+ throw firstError;
32
77
  }
33
78
  return {
34
79
  source,
35
80
  entries,
81
+ ...(gitCapture === undefined ? {} : { gitEntries: gitCapture.entries }),
36
82
  treeHash: hashTree(entries),
37
83
  fileCount: Object.keys(entries).length,
38
84
  totalBytes,
@@ -48,6 +94,7 @@ export async function captureStableTree(options) {
48
94
  const first = await captureTree({
49
95
  cwd: options.cwd,
50
96
  config: options.config,
97
+ ...(options.gitObjectFormat === undefined ? {} : { gitObjectFormat: options.gitObjectFormat }),
51
98
  ...(options.signal === undefined ? {} : { signal: options.signal }),
52
99
  });
53
100
  const second = await captureTree(options);
@@ -61,12 +108,14 @@ export async function captureStableTree(options) {
61
108
  throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', 'workspace did not remain stable across repeated full-tree captures');
62
109
  }
63
110
  /** Compute stable path-level differences between two captured trees. */
64
- export function diffTrees(before, after) {
111
+ export function diffTrees(before, after, comparisonBefore = before, comparisonAfter = after) {
65
112
  const paths = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort(comparePaths);
66
113
  const changes = [];
67
114
  for (const path of paths) {
68
115
  const left = before[path];
69
116
  const right = after[path];
117
+ const comparisonLeft = comparisonBefore[path];
118
+ const comparisonRight = comparisonAfter[path];
70
119
  if (left === undefined && right !== undefined) {
71
120
  changes.push({ path, kind: 'added', after: right });
72
121
  continue;
@@ -75,17 +124,27 @@ export function diffTrees(before, after) {
75
124
  changes.push({ path, kind: 'deleted', before: left });
76
125
  continue;
77
126
  }
78
- if (left === undefined || right === undefined || entriesEqual(left, right))
127
+ if (left === undefined || right === undefined || entriesEqual(comparisonLeft, comparisonRight))
79
128
  continue;
80
129
  if (left.kind !== right.kind) {
81
130
  changes.push({ path, kind: 'type-changed', before: left, after: right });
82
131
  continue;
83
132
  }
84
- if (left.kind === 'file' && right.kind === 'file' && left.blob === right.blob && left.mode !== right.mode) {
133
+ if (left.kind === 'file'
134
+ && right.kind === 'file'
135
+ && comparisonLeft?.kind === 'file'
136
+ && comparisonRight?.kind === 'file'
137
+ && comparisonLeft.blob === comparisonRight.blob
138
+ && left.mode !== right.mode) {
85
139
  changes.push({ path, kind: 'mode-changed', before: left, after: right });
86
140
  continue;
87
141
  }
88
- if (left.kind === 'symlink' && right.kind === 'symlink' && left.target === right.target && left.mode !== right.mode) {
142
+ if (left.kind === 'symlink'
143
+ && right.kind === 'symlink'
144
+ && comparisonLeft?.kind === 'symlink'
145
+ && comparisonRight?.kind === 'symlink'
146
+ && comparisonLeft.target === comparisonRight.target
147
+ && left.mode !== right.mode) {
89
148
  changes.push({ path, kind: 'mode-changed', before: left, after: right });
90
149
  continue;
91
150
  }
@@ -122,6 +181,18 @@ export function hashTree(entries) {
122
181
  }
123
182
  return hash.digest('hex');
124
183
  }
184
+ /** Byte-verify one workspace-relative path without following a final symlink. */
185
+ export async function captureSnapshotEntry(root, path, maxFileBytes, signal, gitObjectFormat) {
186
+ const entry = await captureEntry(root, path, maxFileBytes, signal);
187
+ if (entry === undefined || entry.kind === 'symlink' || gitObjectFormat === undefined)
188
+ return entry?.snapshot;
189
+ return gitFileSnapshot(entry.snapshot, entry.content, gitObjectFormat);
190
+ }
191
+ function gitFileSnapshot(snapshot, content, objectFormat) {
192
+ const header = Buffer.from(`blob ${String(content.length)}\0`);
193
+ const blob = createHash(objectFormat).update(header).update(content).digest('hex');
194
+ return { ...snapshot, blob, provider: 'git' };
195
+ }
125
196
  async function captureEntry(root, path, maxFileBytes, signal) {
126
197
  const target = resolveWorkspacePath(root, path);
127
198
  for (let attempt = 0; attempt < 3; attempt += 1) {
@@ -149,11 +220,29 @@ async function captureEntry(root, path, maxFileBytes, signal) {
149
220
  if (before.size > BigInt(maxFileBytes)) {
150
221
  throw new ChangeLedgerError('FILE_TOO_LARGE', `${JSON.stringify(path)} is ${before.size.toString()} bytes; configured per-file maximum is ${maxFileBytes}`);
151
222
  }
152
- const content = await readFile(target);
153
- throwIfAborted(signal);
154
- const after = await lstat(target, { bigint: true });
155
- if (!sameStat(before, after) || BigInt(content.length) !== after.size)
156
- continue;
223
+ let handle;
224
+ try {
225
+ handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
226
+ }
227
+ catch (error) {
228
+ if (isNodeError(error, 'ELOOP'))
229
+ continue;
230
+ throw error;
231
+ }
232
+ let content;
233
+ try {
234
+ const opened = await handle.stat({ bigint: true });
235
+ if (!opened.isFile() || !sameStat(before, opened))
236
+ continue;
237
+ content = await readBoundedFile(handle, Number(opened.size));
238
+ throwIfAborted(signal);
239
+ const after = await handle.stat({ bigint: true });
240
+ if (!sameStat(opened, after) || BigInt(content.length) !== after.size)
241
+ continue;
242
+ }
243
+ finally {
244
+ await handle.close();
245
+ }
157
246
  const blob = createHash('sha256').update(content).digest('hex');
158
247
  return {
159
248
  kind: 'file',
@@ -163,6 +252,17 @@ async function captureEntry(root, path, maxFileBytes, signal) {
163
252
  }
164
253
  throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', `path changed repeatedly while being captured: ${JSON.stringify(path)}`);
165
254
  }
255
+ async function readBoundedFile(handle, expectedSize) {
256
+ const buffer = Buffer.allocUnsafe(expectedSize + 1);
257
+ let offset = 0;
258
+ while (offset < buffer.length) {
259
+ const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
260
+ if (bytesRead === 0)
261
+ break;
262
+ offset += bytesRead;
263
+ }
264
+ return buffer.subarray(0, offset);
265
+ }
166
266
  function sameStat(left, right) {
167
267
  return left.dev === right.dev
168
268
  && left.ino === right.ino