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