@anionex/dsh-turn-rewind 0.1.0 → 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.
@@ -0,0 +1,739 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { copyFile, lstat, mkdir, mkdtemp, open, readFile, readlink, realpath, rename, rm, stat, } from 'node:fs/promises';
4
+ import { basename, join, resolve } from 'node:path';
5
+ import { ChangeLedgerError } from './errors.js';
6
+ import { discoverRepository, ensureGitWorktreeIdentity, runGit, runGitBuffer, runGitInput, runGitOptional, sameRepositoryFence, } from './git.js';
7
+ import { isNodeError, resolveWorkspacePath, validateRelativePath } from './path-utils.js';
8
+ import { hashTree } from './snapshot.js';
9
+ import { ensureStoreId } from './store.js';
10
+ import { GIT_CHECKPOINT_FORMAT_VERSION, } from './types.js';
11
+ /** Create a complete Git-native turn checkpoint without writing the real index. */
12
+ export async function captureGitTurnCheckpoint(options) {
13
+ const source = await discoverRepository(options.cwd, options.signal);
14
+ await rejectExternalObjectStores(source.state.root, source.state.commonDir, options.signal);
15
+ const storeId = await ensureStoreId(options.config.storageDir);
16
+ const identity = await ensureGitWorktreeIdentity(source.state.root, options.signal);
17
+ if (identity.root !== source.state.root || identity.commonDir !== source.state.commonDir) {
18
+ throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', 'Git worktree identity changed before checkpoint capture');
19
+ }
20
+ const objectFormat = parseObjectFormat(await runGit(source.state.root, ['rev-parse', '--show-object-format'], options.signal));
21
+ const oidLength = objectFormat === 'sha1' ? 40 : 64;
22
+ const zeroOid = '0'.repeat(oidLength);
23
+ const workspaceDir = ledgerWorkspaceDir(options.config.storageDir, identity.commonDir, identity.worktreeId);
24
+ const transactionRoot = join(workspaceDir, 'transactions');
25
+ await mkdir(transactionRoot, { recursive: true });
26
+ const transactionDir = await mkdtemp(join(transactionRoot, 'git-'));
27
+ const hooksDir = join(transactionDir, 'hooks');
28
+ await mkdir(hooksDir, { mode: 0o700 });
29
+ const indexPath = join(transactionDir, 'index');
30
+ const gitEnv = {
31
+ GIT_INDEX_FILE: indexPath,
32
+ GIT_NO_LAZY_FETCH: '1',
33
+ GIT_NO_REPLACE_OBJECTS: '1',
34
+ };
35
+ const gitArgs = (args) => ['-c', `core.hooksPath=${hooksDir}`, '-c', 'core.fsmonitor=false', ...args];
36
+ let cache = await readCache(workspaceDir, storeId, identity.worktreeId);
37
+ const cachePaths = Object.assign(Object.create(null), cache.paths);
38
+ try {
39
+ const realIndex = await copyPrivateIndex(source.state.root, indexPath, transactionDir, options.signal);
40
+ if (realIndex.info === undefined) {
41
+ await runGit(source.state.root, gitArgs(['read-tree', '--empty']), options.signal, { env: gitEnv });
42
+ }
43
+ await runGit(source.state.root, gitArgs(['update-index', '--no-split-index', '--no-fsmonitor', '--no-untracked-cache']), options.signal, { env: gitEnv });
44
+ await rejectAmbiguousIndex(source.state.root, gitArgs, gitEnv, options.signal);
45
+ const stageOutput = await runGit(source.state.root, gitArgs(['ls-files', '--stage', '-z']), options.signal, { env: gitEnv });
46
+ const indexEntries = parseIndex(stageOutput);
47
+ const indexTree = strip(await runGit(source.state.root, gitArgs(['write-tree']), options.signal, { env: gitEnv }));
48
+ const headTree = strip(await runGitOptional(source.state.root, ['rev-parse', '--verify', '--quiet', 'HEAD^{tree}'], options.signal, { env: gitEnv })
49
+ ?? await runGitInput(source.state.root, gitArgs(['mktree']), '', options.signal, { env: gitEnv }));
50
+ const dirty = new Set(splitNul(await runGit(source.state.root, gitArgs(['diff-files', '--name-only', '-z']), options.signal, { env: gitEnv })).map(validateRelativePath));
51
+ const attributes = await effectiveAttributes(source.state.root, source.paths, hooksDir, options.signal);
52
+ const configFence = await transformConfigFence(source.state.root, hooksDir, options.signal);
53
+ const strict = options.config.turnCheckpointTrust === 'strict';
54
+ const entries = Object.create(null);
55
+ const indexInfo = [];
56
+ const observed = new Map();
57
+ let totalBytes = 0;
58
+ let newlyStoredBytes = 0;
59
+ for (const path of source.paths) {
60
+ options.signal?.throwIfAborted();
61
+ const target = resolveWorkspacePath(source.state.root, path);
62
+ const indexEntry = indexEntries.get(path);
63
+ let info;
64
+ try {
65
+ info = await lstat(target, { bigint: true });
66
+ }
67
+ catch (error) {
68
+ if (isNodeError(error, 'ENOENT')) {
69
+ observed.set(path, undefined);
70
+ if (indexEntry !== undefined)
71
+ indexInfo.push(`0 ${zeroOid}\t${path}\0`);
72
+ delete cachePaths[path];
73
+ continue;
74
+ }
75
+ throw error;
76
+ }
77
+ observed.set(path, info);
78
+ if (!info.isFile() && !info.isSymbolicLink()) {
79
+ throw new ChangeLedgerError('UNSUPPORTED_FILE_TYPE', `eligible path is not a regular file or symlink: ${JSON.stringify(path)}`);
80
+ }
81
+ const exactMode = Number(info.mode & 511n);
82
+ const size = info.isFile() ? Number(info.size) : undefined;
83
+ if (size !== undefined && size > options.config.maxFileBytes) {
84
+ throw new ChangeLedgerError('FILE_TOO_LARGE', `${JSON.stringify(path)} is ${size} bytes; configured maximum is ${options.config.maxFileBytes}`);
85
+ }
86
+ const pathAttributes = attributes.get(path) ?? '';
87
+ const attributeFence = `${configFence}\0${pathAttributes}`;
88
+ const identitySafe = !strict
89
+ && indexEntry !== undefined
90
+ && !dirty.has(path)
91
+ && isIndexEquivalent(info, indexEntry.mode)
92
+ && configFence.endsWith('\0safe')
93
+ && pathAttributes === 'safe';
94
+ if (identitySafe && info.isFile()) {
95
+ entries[path] = { kind: 'file', blob: indexEntry.oid, provider: 'git', size: size ?? 0, mode: exactMode };
96
+ totalBytes += size ?? 0;
97
+ enforceSnapshotLimit(totalBytes, options.config.maxSnapshotBytes);
98
+ continue;
99
+ }
100
+ if (identitySafe && info.isSymbolicLink()) {
101
+ const targetValue = await gitBlobText(source.state.root, indexEntry.oid, hooksDir, options.signal);
102
+ entries[path] = { kind: 'symlink', target: targetValue, mode: exactMode };
103
+ continue;
104
+ }
105
+ const cached = !strict ? cachePaths[path] : undefined;
106
+ const cachedOid = cached !== undefined && cacheMatches(cached, info, indexEntry?.oid, attributeFence)
107
+ && await gitBlobExists(source.state.root, cached.oid, hooksDir, options.signal)
108
+ ? cached.oid
109
+ : undefined;
110
+ if (info.isSymbolicLink()) {
111
+ const targetValue = cachedOid === undefined ? await stableReadlink(target, info, path, options.signal) : cached?.target;
112
+ if (targetValue === undefined)
113
+ throw new ChangeLedgerError('STATE_CORRUPT', `cached symlink target is absent for ${JSON.stringify(path)}`);
114
+ const content = Buffer.from(targetValue);
115
+ const oid = cachedOid ?? strip(await runGitInput(source.state.root, gitArgs(['hash-object', '-w', '--stdin']), content, options.signal, { env: gitEnv }));
116
+ if (cachedOid === undefined)
117
+ newlyStoredBytes += content.length;
118
+ enforceNewContentLimit(newlyStoredBytes, options.config.turnCheckpointMaxNewBytes);
119
+ entries[path] = { kind: 'symlink', target: targetValue, mode: exactMode };
120
+ indexInfo.push(`120000 ${oid}\t${path}\0`);
121
+ cachePaths[path] = makeCacheRecord(info, indexEntry?.oid, attributeFence, oid, targetValue);
122
+ continue;
123
+ }
124
+ if (size === undefined)
125
+ throw new ChangeLedgerError('STATE_CORRUPT', `regular file size is unavailable for ${JSON.stringify(path)}`);
126
+ let oid = cachedOid;
127
+ if (oid === undefined) {
128
+ const content = await stableReadFile(target, info, path, options.signal);
129
+ oid = strip(await runGitInput(source.state.root, gitArgs(['hash-object', '-w', '--stdin']), content, options.signal, { env: gitEnv }));
130
+ newlyStoredBytes += content.length;
131
+ }
132
+ enforceNewContentLimit(newlyStoredBytes, options.config.turnCheckpointMaxNewBytes);
133
+ totalBytes += size;
134
+ enforceSnapshotLimit(totalBytes, options.config.maxSnapshotBytes);
135
+ entries[path] = { kind: 'file', blob: oid, provider: 'git', size, mode: exactMode };
136
+ indexInfo.push(`${exactMode & 0o111 ? '100755' : '100644'} ${oid}\t${path}\0`);
137
+ cachePaths[path] = makeCacheRecord(info, indexEntry?.oid, attributeFence, oid);
138
+ }
139
+ if (Object.keys(entries).length > options.config.maxFiles) {
140
+ throw new ChangeLedgerError('TOO_MANY_FILES', `workspace exceeds configured maximum of ${options.config.maxFiles} files`);
141
+ }
142
+ if (indexInfo.length > 0) {
143
+ await runGitInput(source.state.root, gitArgs(['update-index', '-z', '--index-info']), indexInfo.join(''), options.signal, { env: gitEnv });
144
+ }
145
+ const worktreeTree = strip(await runGit(source.state.root, gitArgs(['write-tree']), options.signal, { env: gitEnv }));
146
+ const envelopeTree = strip(await runGitInput(source.state.root, gitArgs(['mktree']), `040000 tree ${headTree}\thead\n040000 tree ${indexTree}\tindex\n040000 tree ${worktreeTree}\tworktree\n`, options.signal, { env: gitEnv }));
147
+ const commit = strip(await runGitInput(source.state.root, gitArgs(['commit-tree', envelopeTree]), `Turn Rewind checkpoint ${options.id}\n`, options.signal, {
148
+ env: {
149
+ ...gitEnv,
150
+ GIT_AUTHOR_NAME: 'Turn Rewind',
151
+ GIT_AUTHOR_EMAIL: 'turn-rewind@localhost',
152
+ GIT_COMMITTER_NAME: 'Turn Rewind',
153
+ GIT_COMMITTER_EMAIL: 'turn-rewind@localhost',
154
+ },
155
+ }));
156
+ await verifyStableCapture({
157
+ source,
158
+ observed,
159
+ realIndex,
160
+ attributes,
161
+ configFence,
162
+ hooksDir,
163
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
164
+ });
165
+ if (identity.root !== source.state.root || identity.commonDir !== source.state.commonDir) {
166
+ throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', 'Git worktree identity changed during checkpoint capture');
167
+ }
168
+ const privateRef = `refs/dsh-turn-rewind/v2/${storeId}/${identity.worktreeId}/${options.id}`;
169
+ await runGit(source.state.root, gitArgs(['check-ref-format', privateRef]), options.signal, { env: gitEnv });
170
+ cache = makeCache(storeId, identity.worktreeId, cachePaths);
171
+ await writeCache(workspaceDir, cache);
172
+ const git = {
173
+ objectFormat,
174
+ trust: strict ? 'byte-verified' : 'metadata-fenced',
175
+ storeId,
176
+ worktreeId: identity.worktreeId,
177
+ headTree,
178
+ indexTree,
179
+ worktreeTree,
180
+ envelopeTree,
181
+ commit,
182
+ ref: privateRef,
183
+ newlyStoredBytes,
184
+ };
185
+ const manifest = {
186
+ version: GIT_CHECKPOINT_FORMAT_VERSION,
187
+ id: options.id,
188
+ kind: 'turn',
189
+ workspace: source.state.root,
190
+ repository: source.state,
191
+ sessionId: options.sessionId,
192
+ label: `Before turn ${String(options.turn)} checkpoint`,
193
+ turn: options.turn,
194
+ turnStartSeq: options.turnStartSeq,
195
+ createdAt: Date.now(),
196
+ treeHash: hashTree(entries),
197
+ fileCount: Object.keys(entries).length,
198
+ totalBytes,
199
+ entries,
200
+ restoreCount: 0,
201
+ git,
202
+ };
203
+ return { manifest, cache };
204
+ }
205
+ catch (error) {
206
+ await writeCache(workspaceDir, makeCache(storeId, identity.worktreeId, cachePaths)).catch(() => undefined);
207
+ throw error;
208
+ }
209
+ finally {
210
+ await rm(transactionDir, { recursive: true, force: true });
211
+ }
212
+ }
213
+ /** Publish a prepared checkpoint under its exact private ref. */
214
+ export async function publishGitCheckpoint(manifest, storageDir, signal) {
215
+ const zeroOid = '0'.repeat(manifest.git.objectFormat === 'sha1' ? 40 : 64);
216
+ const identity = await ensureGitWorktreeIdentity(manifest.workspace, signal);
217
+ if (identity.worktreeId !== manifest.git.worktreeId || identity.commonDir !== manifest.repository.commonDir) {
218
+ throw new ChangeLedgerError('STATE_CORRUPT', `Git worktree identity changed before publishing ${manifest.id}`);
219
+ }
220
+ await withHooksSandbox(storageDir, async (hooksDir) => {
221
+ await runGit(manifest.workspace, ['-c', `core.hooksPath=${hooksDir}`, 'update-ref', manifest.git.ref, manifest.git.commit, zeroOid], signal, {
222
+ env: { GIT_NO_LAZY_FETCH: '1', GIT_NO_REPLACE_OBJECTS: '1' },
223
+ });
224
+ });
225
+ }
226
+ /** Verify persisted Git metadata before exposing or restoring a v2 point. */
227
+ export async function verifyGitCheckpoint(manifest, signal) {
228
+ const env = { GIT_NO_LAZY_FETCH: '1', GIT_NO_REPLACE_OBJECTS: '1' };
229
+ const identity = await ensureGitWorktreeIdentity(manifest.workspace, signal);
230
+ if (identity.root !== manifest.workspace
231
+ || identity.commonDir !== manifest.repository.commonDir
232
+ || identity.worktreeId !== manifest.git.worktreeId) {
233
+ throw new ChangeLedgerError('STATE_CORRUPT', `Git worktree identity changed for ${manifest.id}`);
234
+ }
235
+ const ref = strip(await runGit(manifest.workspace, ['rev-parse', '--verify', manifest.git.ref], signal, { env }));
236
+ if (ref !== manifest.git.commit)
237
+ throw new ChangeLedgerError('STATE_CORRUPT', `private ref target changed for ${manifest.id}`);
238
+ const ancestry = strip(await runGit(manifest.workspace, ['rev-list', '--parents', '-n', '1', manifest.git.commit], signal, { env }));
239
+ if (ancestry !== manifest.git.commit)
240
+ throw new ChangeLedgerError('STATE_CORRUPT', `snapshot commit has unexpected parents for ${manifest.id}`);
241
+ const commitTree = strip(await runGit(manifest.workspace, ['show', '-s', '--format=%T', manifest.git.commit], signal, { env }));
242
+ if (commitTree !== manifest.git.envelopeTree)
243
+ throw new ChangeLedgerError('STATE_CORRUPT', `snapshot commit tree changed for ${manifest.id}`);
244
+ const envelope = await runGit(manifest.workspace, ['ls-tree', manifest.git.envelopeTree], signal, { env });
245
+ const expected = new Map([
246
+ ['head', manifest.git.headTree],
247
+ ['index', manifest.git.indexTree],
248
+ ['worktree', manifest.git.worktreeTree],
249
+ ]);
250
+ for (const line of envelope.trim().split('\n')) {
251
+ if (line === '')
252
+ continue;
253
+ const match = /^040000 tree ([0-9a-f]+)\t(head|index|worktree)$/.exec(line);
254
+ if (match === null || expected.get(match[2] ?? '') !== match[1]) {
255
+ throw new ChangeLedgerError('STATE_CORRUPT', `snapshot envelope is invalid for ${manifest.id}`);
256
+ }
257
+ expected.delete(match[2] ?? '');
258
+ }
259
+ if (expected.size !== 0)
260
+ throw new ChangeLedgerError('STATE_CORRUPT', `snapshot envelope is incomplete for ${manifest.id}`);
261
+ await verifyWorktreeEntries(manifest, signal);
262
+ }
263
+ /** Compare-and-swap delete a v2 private ref. */
264
+ export async function deleteGitCheckpoint(manifest, storageDir, signal) {
265
+ await deleteGitCheckpointRef(manifest.workspace, manifest.git.ref, manifest.git.commit, storageDir, signal);
266
+ }
267
+ /** Delete an exact private ref target, including during startup journal reconciliation. */
268
+ export async function deleteGitCheckpointRef(workspace, ref, commit, storageDir, signal) {
269
+ if (!/^refs\/dsh-turn-rewind\/v2\/[0-9a-f]{32}\/[0-9a-f]{32}\/rp_[0-9a-z]+_[0-9a-f]{12}$/.test(ref)
270
+ || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(commit)) {
271
+ throw new ChangeLedgerError('STATE_CORRUPT', 'ref deletion metadata is malformed');
272
+ }
273
+ await ensureGitWorktreeIdentity(workspace, signal);
274
+ await withHooksSandbox(storageDir, async (hooksDir) => {
275
+ await runGit(workspace, ['-c', `core.hooksPath=${hooksDir}`, 'update-ref', '-d', ref, commit], signal);
276
+ });
277
+ }
278
+ /** Read one v2 Git blob for selective restore. */
279
+ export async function readGitBlob(workspace, oid, expectedSize, signal) {
280
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(oid))
281
+ throw new ChangeLedgerError('STATE_CORRUPT', 'invalid Git blob id');
282
+ return runGitBuffer(workspace, ['cat-file', 'blob', oid], signal, {
283
+ maxBuffer: expectedSize + 1024,
284
+ env: { GIT_NO_LAZY_FETCH: '1', GIT_NO_REPLACE_OBJECTS: '1' },
285
+ });
286
+ }
287
+ async function verifyWorktreeEntries(manifest, signal) {
288
+ const output = await runGit(manifest.workspace, ['ls-tree', '-r', '-z', manifest.git.worktreeTree], signal, {
289
+ env: { GIT_NO_LAZY_FETCH: '1', GIT_NO_REPLACE_OBJECTS: '1' },
290
+ });
291
+ const actual = new Map();
292
+ for (const record of splitNul(output)) {
293
+ const match = /^(100644|100755|120000) blob ([0-9a-f]+)\t([\s\S]+)$/.exec(record);
294
+ if (match === null)
295
+ throw new ChangeLedgerError('STATE_CORRUPT', `unsupported worktree tree entry in ${manifest.id}`);
296
+ actual.set(validateRelativePath(match[3] ?? ''), { mode: match[1] ?? '', oid: match[2] ?? '' });
297
+ }
298
+ if (actual.size !== Object.keys(manifest.entries).length) {
299
+ throw new ChangeLedgerError('STATE_CORRUPT', `worktree tree path count does not match manifest ${manifest.id}`);
300
+ }
301
+ const sizes = new Map();
302
+ for (const [path, entry] of Object.entries(manifest.entries)) {
303
+ const treeEntry = actual.get(path);
304
+ if (treeEntry === undefined)
305
+ throw new ChangeLedgerError('STATE_CORRUPT', `worktree tree is missing ${JSON.stringify(path)}`);
306
+ const expectedMode = entry.kind === 'symlink' ? '120000' : entry.mode & 0o111 ? '100755' : '100644';
307
+ if (treeEntry.mode !== expectedMode)
308
+ throw new ChangeLedgerError('STATE_CORRUPT', `worktree tree mode mismatch at ${JSON.stringify(path)}`);
309
+ if (entry.kind === 'file') {
310
+ if (entry.provider !== 'git' || treeEntry.oid !== entry.blob) {
311
+ throw new ChangeLedgerError('STATE_CORRUPT', `worktree tree blob mismatch at ${JSON.stringify(path)}`);
312
+ }
313
+ sizes.set(entry.blob, entry.size);
314
+ }
315
+ else {
316
+ const oid = strip(await runGitInput(manifest.workspace, ['hash-object', '--stdin'], Buffer.from(entry.target), signal));
317
+ if (treeEntry.oid !== oid)
318
+ throw new ChangeLedgerError('STATE_CORRUPT', `worktree symlink blob mismatch at ${JSON.stringify(path)}`);
319
+ }
320
+ }
321
+ if (sizes.size === 0)
322
+ return;
323
+ const checks = await runGitInput(manifest.workspace, ['cat-file', '--batch-check=%(objectname) %(objecttype) %(objectsize)'], `${[...sizes.keys()].join('\n')}\n`, signal, { env: { GIT_NO_LAZY_FETCH: '1', GIT_NO_REPLACE_OBJECTS: '1' } });
324
+ for (const line of checks.trim().split('\n')) {
325
+ const match = /^([0-9a-f]+) blob (\d+)$/.exec(line);
326
+ if (match === null || sizes.get(match[1] ?? '') !== Number(match[2])) {
327
+ throw new ChangeLedgerError('STATE_CORRUPT', `Git blob size verification failed for ${manifest.id}`);
328
+ }
329
+ sizes.delete(match[1] ?? '');
330
+ }
331
+ if (sizes.size !== 0)
332
+ throw new ChangeLedgerError('STATE_CORRUPT', `Git blob verification was incomplete for ${manifest.id}`);
333
+ }
334
+ async function copyPrivateIndex(root, target, transactionDir, signal) {
335
+ const raw = strip(await runGit(root, ['rev-parse', '--git-path', 'index'], signal));
336
+ const candidate = resolve(root, raw);
337
+ try {
338
+ const source = await realpath(candidate);
339
+ const info = await lstat(source, { bigint: true });
340
+ await copyFile(source, target);
341
+ const shared = strip(await runGitOptional(root, ['rev-parse', '--shared-index-path'], signal) ?? '');
342
+ if (shared !== '')
343
+ await copyFile(await realpath(resolve(root, shared)), join(transactionDir, basename(shared)));
344
+ return { path: source, info };
345
+ }
346
+ catch (error) {
347
+ if (!isNodeError(error, 'ENOENT'))
348
+ throw error;
349
+ await open(target, 'wx', 0o600).then((handle) => handle.close());
350
+ return { path: candidate };
351
+ }
352
+ }
353
+ async function verifyStableCapture(options) {
354
+ const current = await discoverRepository(options.source.state.root, options.signal);
355
+ if (!sameRepositoryFence(options.source.state, current.state)
356
+ || !arraysEqual(options.source.paths, current.paths)
357
+ || !arraysEqual(options.source.state.stagedPaths, current.state.stagedPaths)) {
358
+ throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', 'repository or eligible path inventory changed during checkpoint capture');
359
+ }
360
+ if (options.realIndex.info !== undefined) {
361
+ const currentIndex = await lstat(options.realIndex.path, { bigint: true });
362
+ if (!sameStat(options.realIndex.info, currentIndex)) {
363
+ throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', 'real Git index changed during checkpoint capture');
364
+ }
365
+ }
366
+ else {
367
+ try {
368
+ await lstat(options.realIndex.path);
369
+ throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', 'real Git index appeared during checkpoint capture');
370
+ }
371
+ catch (error) {
372
+ if (!isNodeError(error, 'ENOENT'))
373
+ throw error;
374
+ }
375
+ }
376
+ for (const path of options.source.paths) {
377
+ const expected = options.observed.get(path);
378
+ try {
379
+ const currentInfo = await lstat(resolveWorkspacePath(options.source.state.root, path), { bigint: true });
380
+ if (expected === undefined || !sameStat(expected, currentInfo)) {
381
+ throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', `path changed during checkpoint capture: ${JSON.stringify(path)}`);
382
+ }
383
+ }
384
+ catch (error) {
385
+ if (isNodeError(error, 'ENOENT') && expected === undefined)
386
+ continue;
387
+ throw error;
388
+ }
389
+ }
390
+ const finalConfig = await transformConfigFence(options.source.state.root, options.hooksDir, options.signal);
391
+ const finalAttributes = await effectiveAttributes(options.source.state.root, options.source.paths, options.hooksDir, options.signal);
392
+ if (finalConfig !== options.configFence || !mapsEqual(options.attributes, finalAttributes)) {
393
+ throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', 'Git attributes or transformation configuration changed during checkpoint capture');
394
+ }
395
+ }
396
+ async function rejectAmbiguousIndex(root, gitArgs, env, signal) {
397
+ const tags = splitNul(await runGit(root, gitArgs(['ls-files', '-v', '-z']), signal, { env }));
398
+ const ambiguous = tags.find((record) => record.startsWith('S ') || /^[a-z] /.test(record));
399
+ if (ambiguous !== undefined) {
400
+ throw new ChangeLedgerError('GIT_INDEX_UNSUPPORTED', `skip-worktree/assume-unchanged path is unsupported: ${JSON.stringify(ambiguous.slice(2))}`);
401
+ }
402
+ const stages = parseIndexStages(await runGit(root, gitArgs(['ls-files', '--stage', '-z']), signal, { env }));
403
+ const invalid = stages.find((entry) => entry.stage !== 0 || /^0+$/.test(entry.oid));
404
+ if (invalid !== undefined)
405
+ throw new ChangeLedgerError('GIT_INDEX_UNSUPPORTED', `unmerged or intent-to-add path is unsupported: ${JSON.stringify(invalid.path)}`);
406
+ }
407
+ async function rejectExternalObjectStores(root, commonDir, signal) {
408
+ if (process.env.GIT_ALTERNATE_OBJECT_DIRECTORIES !== undefined) {
409
+ throw new ChangeLedgerError('GIT_OBJECT_STORE_UNSUPPORTED', 'Git alternates are unsupported for durable v2 checkpoints');
410
+ }
411
+ try {
412
+ if ((await stat(join(commonDir, 'objects', 'info', 'alternates'))).size > 0) {
413
+ throw new ChangeLedgerError('GIT_OBJECT_STORE_UNSUPPORTED', 'Git alternates are unsupported for durable v2 checkpoints');
414
+ }
415
+ }
416
+ catch (error) {
417
+ if (!isNodeError(error, 'ENOENT'))
418
+ throw error;
419
+ }
420
+ const promisor = await runGitOptional(root, ['config', '--get-regexp', '^remote\..*\.promisor$'], signal);
421
+ const partialClone = await runGitOptional(root, ['config', '--get', 'extensions.partialClone'], signal);
422
+ if ((promisor?.trim() ?? '') !== '' || (partialClone?.trim() ?? '') !== '') {
423
+ throw new ChangeLedgerError('GIT_OBJECT_STORE_UNSUPPORTED', 'partial-clone/promisor repositories are unsupported for durable v2 checkpoints');
424
+ }
425
+ }
426
+ async function effectiveAttributes(root, paths, hooksDir, signal) {
427
+ if (paths.length === 0)
428
+ return new Map();
429
+ const output = await runGitInput(root, ['-c', `core.hooksPath=${hooksDir}`, 'check-attr', '-z', '--stdin', 'filter', 'working-tree-encoding', 'ident', 'text', 'eol'], `${paths.join('\0')}\0`, signal);
430
+ const parts = splitNul(output);
431
+ if (parts.length % 3 !== 0)
432
+ throw new ChangeLedgerError('GIT_ATTRIBUTE_PARSE_FAILED', 'git check-attr returned malformed output');
433
+ const values = new Map();
434
+ for (let index = 0; index < parts.length; index += 3) {
435
+ const path = validateRelativePath(parts[index] ?? '');
436
+ const attr = parts[index + 1] ?? '';
437
+ const value = parts[index + 2] ?? '';
438
+ const current = values.get(path) ?? [];
439
+ current.push(`${attr}=${value}`);
440
+ values.set(path, current);
441
+ }
442
+ const result = new Map();
443
+ for (const path of paths) {
444
+ const attrs = values.get(path) ?? [];
445
+ const safe = attrs.every((item) => item.endsWith('=unspecified'));
446
+ result.set(path, safe ? 'safe' : attrs.join('\0'));
447
+ }
448
+ return result;
449
+ }
450
+ async function transformConfigFence(root, hooksDir, signal) {
451
+ const values = await Promise.all(['core.autocrlf', 'core.eol', 'core.symlinks'].map(async (key) => {
452
+ const value = strip(await runGitOptional(root, ['-c', `core.hooksPath=${hooksDir}`, 'config', '--get', key], signal) ?? '');
453
+ return `${key}=${value}`;
454
+ }));
455
+ const safe = (values[0] === 'core.autocrlf=' || values[0] === 'core.autocrlf=false')
456
+ && (values[1] === 'core.eol=' || values[1] === 'core.eol=native')
457
+ && (values[2] === 'core.symlinks=' || values[2] === 'core.symlinks=true');
458
+ return `${values.join('\0')}\0${safe ? 'safe' : 'unsafe'}`;
459
+ }
460
+ function parseIndex(output) {
461
+ const map = new Map();
462
+ for (const entry of parseIndexStages(output)) {
463
+ if (entry.stage === 0)
464
+ map.set(entry.path, { mode: entry.mode, oid: entry.oid });
465
+ }
466
+ return map;
467
+ }
468
+ function parseIndexStages(output) {
469
+ return splitNul(output).map((record) => {
470
+ const match = /^(\d{6}) ([0-9a-f]+) ([0-3])\t([\s\S]+)$/.exec(record);
471
+ if (match === null)
472
+ throw new ChangeLedgerError('GIT_INDEX_PARSE_FAILED', `cannot parse index record: ${JSON.stringify(record.slice(0, 200))}`);
473
+ return { mode: match[1] ?? '', oid: match[2] ?? '', stage: Number(match[3]), path: validateRelativePath(match[4] ?? '') };
474
+ });
475
+ }
476
+ function isIndexEquivalent(info, mode) {
477
+ if (info.isSymbolicLink())
478
+ return mode === '120000';
479
+ if (!info.isFile())
480
+ return false;
481
+ return mode === (Number(info.mode & 73n) === 0 ? '100644' : '100755');
482
+ }
483
+ async function withHooksSandbox(storageDir, task) {
484
+ const root = join(storageDir, 'hook-sandboxes');
485
+ await mkdir(root, { recursive: true, mode: 0o700 });
486
+ const hooksDir = await mkdtemp(join(root, 'hooks-'));
487
+ try {
488
+ return await task(hooksDir);
489
+ }
490
+ finally {
491
+ await rm(hooksDir, { recursive: true, force: true });
492
+ }
493
+ }
494
+ async function stableReadFile(target, before, path, signal) {
495
+ for (let attempt = 0; attempt < 3; attempt += 1) {
496
+ signal?.throwIfAborted();
497
+ let handle;
498
+ try {
499
+ handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
500
+ }
501
+ catch (error) {
502
+ if (isNodeError(error, 'ELOOP')) {
503
+ throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', `file became a symlink while captured: ${JSON.stringify(path)}`);
504
+ }
505
+ throw error;
506
+ }
507
+ try {
508
+ const opened = await handle.stat({ bigint: true });
509
+ if (!opened.isFile() || !sameStat(before, opened)) {
510
+ before = opened;
511
+ continue;
512
+ }
513
+ const content = await handle.readFile();
514
+ const after = await handle.stat({ bigint: true });
515
+ if (sameStat(opened, after) && BigInt(content.length) === after.size)
516
+ return content;
517
+ before = after;
518
+ }
519
+ finally {
520
+ await handle.close();
521
+ }
522
+ }
523
+ throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', `path changed repeatedly while captured: ${JSON.stringify(path)}`);
524
+ }
525
+ async function stableReadlink(target, before, path, signal) {
526
+ for (let attempt = 0; attempt < 3; attempt += 1) {
527
+ signal?.throwIfAborted();
528
+ const value = await readlink(target);
529
+ const after = await lstat(target, { bigint: true });
530
+ if (sameStat(before, after))
531
+ return value;
532
+ before = after;
533
+ }
534
+ throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', `symlink changed repeatedly while captured: ${JSON.stringify(path)}`);
535
+ }
536
+ function sameStat(left, right) {
537
+ return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.size === right.size
538
+ && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
539
+ }
540
+ function makeCacheRecord(info, indexOid, attributes, oid, target) {
541
+ return {
542
+ kind: info.isSymbolicLink() ? 'symlink' : 'file',
543
+ mode: Number(info.mode & 511n),
544
+ size: info.size.toString(),
545
+ mtimeNs: info.mtimeNs.toString(),
546
+ ctimeNs: info.ctimeNs.toString(),
547
+ dev: info.dev.toString(),
548
+ ino: info.ino.toString(),
549
+ ...(indexOid === undefined ? {} : { indexOid }),
550
+ attributes,
551
+ oid,
552
+ ...(target === undefined ? {} : { target }),
553
+ };
554
+ }
555
+ function cacheMatches(record, info, indexOid, attributes) {
556
+ return record.kind === (info.isSymbolicLink() ? 'symlink' : 'file')
557
+ && record.mode === Number(info.mode & 511n)
558
+ && record.size === info.size.toString()
559
+ && record.mtimeNs === info.mtimeNs.toString()
560
+ && record.ctimeNs === info.ctimeNs.toString()
561
+ && record.dev === info.dev.toString()
562
+ && record.ino === info.ino.toString()
563
+ && record.indexOid === indexOid
564
+ && record.attributes === attributes;
565
+ }
566
+ async function gitBlobExists(root, oid, hooksDir, signal) {
567
+ try {
568
+ await runGit(root, ['-c', `core.hooksPath=${hooksDir}`, 'cat-file', '-e', `${oid}^{blob}`], signal);
569
+ return true;
570
+ }
571
+ catch (error) {
572
+ if (error instanceof ChangeLedgerError && error.code === 'GIT_COMMAND_FAILED')
573
+ return false;
574
+ throw error;
575
+ }
576
+ }
577
+ async function gitBlobText(root, oid, hooksDir, signal) {
578
+ return runGit(root, ['-c', `core.hooksPath=${hooksDir}`, 'cat-file', 'blob', oid], signal);
579
+ }
580
+ async function readCache(workspaceDir, storeId, worktreeId) {
581
+ const empty = () => makeCache(storeId, worktreeId, {});
582
+ try {
583
+ const value = JSON.parse(await readFile(join(workspaceDir, 'git-cache.json'), 'utf8'));
584
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
585
+ return empty();
586
+ const record = value;
587
+ if (record.version !== 2
588
+ || record.storeId !== storeId
589
+ || record.worktreeId !== worktreeId
590
+ || record.paths === null
591
+ || typeof record.paths !== 'object'
592
+ || Array.isArray(record.paths)
593
+ || typeof record.checksum !== 'string'
594
+ || record.checksum !== cacheChecksum(storeId, worktreeId, record.paths)) {
595
+ return empty();
596
+ }
597
+ const paths = Object.create(null);
598
+ for (const [rawPath, rawRecord] of Object.entries(record.paths)) {
599
+ try {
600
+ const path = validateRelativePath(rawPath);
601
+ paths[path] = parseCacheRecord(rawRecord);
602
+ }
603
+ catch {
604
+ // Cache entries are advisory. Ignore malformed records instead of failing open or blocking capture.
605
+ }
606
+ }
607
+ return makeCache(storeId, worktreeId, paths);
608
+ }
609
+ catch (error) {
610
+ if (isNodeError(error, 'ENOENT') || error instanceof SyntaxError)
611
+ return empty();
612
+ throw error;
613
+ }
614
+ }
615
+ function makeCache(storeId, worktreeId, paths) {
616
+ const sortedPaths = Object.create(null);
617
+ for (const path of Object.keys(paths).sort((left, right) => Buffer.from(left).compare(Buffer.from(right)))) {
618
+ const record = paths[path];
619
+ if (record !== undefined)
620
+ sortedPaths[path] = record;
621
+ }
622
+ return {
623
+ version: 2,
624
+ storeId,
625
+ worktreeId,
626
+ paths: sortedPaths,
627
+ checksum: cacheChecksum(storeId, worktreeId, sortedPaths),
628
+ };
629
+ }
630
+ function cacheChecksum(storeId, worktreeId, paths) {
631
+ const sortedPaths = Object.create(null);
632
+ for (const path of Object.keys(paths).sort((left, right) => Buffer.from(left).compare(Buffer.from(right)))) {
633
+ sortedPaths[path] = paths[path];
634
+ }
635
+ return createHash('sha256').update(JSON.stringify({ version: 2, storeId, worktreeId, paths: sortedPaths })).digest('hex');
636
+ }
637
+ function parseCacheRecord(value) {
638
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
639
+ throw new Error('invalid cache record');
640
+ const record = value;
641
+ if (record.kind !== 'file' && record.kind !== 'symlink')
642
+ throw new Error('invalid cache kind');
643
+ if (!Number.isSafeInteger(record.mode) || record.mode < 0 || record.mode > 0o777) {
644
+ throw new Error('invalid cache mode');
645
+ }
646
+ const decimal = (key) => {
647
+ const field = record[key];
648
+ if (typeof field !== 'string' || !/^(?:0|[1-9]\d*)$/.test(field))
649
+ throw new Error(`invalid cache ${key}`);
650
+ return field;
651
+ };
652
+ const oid = cacheOid(record.oid);
653
+ const indexOid = record.indexOid === undefined ? undefined : cacheOid(record.indexOid);
654
+ if (typeof record.attributes !== 'string')
655
+ throw new Error('invalid cache attributes');
656
+ const target = record.target;
657
+ if (record.kind === 'symlink') {
658
+ if (typeof target !== 'string' || target.includes('\0'))
659
+ throw new Error('invalid cached symlink target');
660
+ }
661
+ else if (target !== undefined) {
662
+ throw new Error('file cache record carries a symlink target');
663
+ }
664
+ return {
665
+ kind: record.kind,
666
+ mode: record.mode,
667
+ size: decimal('size'),
668
+ mtimeNs: decimal('mtimeNs'),
669
+ ctimeNs: decimal('ctimeNs'),
670
+ dev: decimal('dev'),
671
+ ino: decimal('ino'),
672
+ ...(indexOid === undefined ? {} : { indexOid }),
673
+ attributes: record.attributes,
674
+ oid,
675
+ ...(target === undefined ? {} : { target }),
676
+ };
677
+ }
678
+ function cacheOid(value) {
679
+ if (typeof value !== 'string' || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value))
680
+ throw new Error('invalid cache object id');
681
+ return value;
682
+ }
683
+ async function writeCache(workspaceDir, cache) {
684
+ await mkdir(workspaceDir, { recursive: true });
685
+ const path = join(workspaceDir, 'git-cache.json');
686
+ const temporary = join(workspaceDir, `.git-cache-${randomBytes(6).toString('hex')}.tmp`);
687
+ await open(temporary, 'wx', 0o600).then(async (handle) => {
688
+ try {
689
+ await handle.writeFile(`${JSON.stringify(cache)}\n`);
690
+ await handle.sync();
691
+ }
692
+ finally {
693
+ await handle.close();
694
+ }
695
+ });
696
+ await rename(temporary, path);
697
+ }
698
+ function ledgerWorkspaceDir(storageDir, commonDir, worktreeId) {
699
+ const key = `git-${createHash('sha256').update(`${commonDir}\0${worktreeId}`).digest('hex')}`;
700
+ return join(storageDir, 'workspaces', key);
701
+ }
702
+ function enforceNewContentLimit(bytes, maximum) {
703
+ if (bytes > maximum) {
704
+ throw new ChangeLedgerError('TURN_CHECKPOINT_NEW_CONTENT_LIMIT', `automatic checkpoint needs ${bytes} new bytes; limit is ${maximum}`);
705
+ }
706
+ }
707
+ function enforceSnapshotLimit(bytes, maximum) {
708
+ if (bytes > maximum) {
709
+ throw new ChangeLedgerError('SNAPSHOT_TOO_LARGE', `eligible files exceed configured aggregate limit of ${maximum} bytes`);
710
+ }
711
+ }
712
+ function parseObjectFormat(value) {
713
+ const format = strip(value);
714
+ if (format !== 'sha1' && format !== 'sha256')
715
+ throw new ChangeLedgerError('GIT_OBJECT_FORMAT_UNSUPPORTED', `unsupported Git object format ${JSON.stringify(format)}`);
716
+ return format;
717
+ }
718
+ function splitNul(value) {
719
+ if (value === '')
720
+ return [];
721
+ const parts = value.split('\0');
722
+ if (parts.at(-1) === '')
723
+ parts.pop();
724
+ return parts;
725
+ }
726
+ function strip(value) {
727
+ return value.replace(/\r?\n$/, '');
728
+ }
729
+ function arraysEqual(left, right) {
730
+ return left.length === right.length && left.every((value, index) => value === right[index]);
731
+ }
732
+ function mapsEqual(left, right) {
733
+ if (left.size !== right.size)
734
+ return false;
735
+ for (const [key, value] of left)
736
+ if (right.get(key) !== value)
737
+ return false;
738
+ return true;
739
+ }