@anionex/dsh-turn-rewind 0.1.1 → 0.2.0

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.
Files changed (47) hide show
  1. package/README.md +19 -6
  2. package/README.zh.md +19 -6
  3. package/SECURITY.md +3 -1
  4. package/docs/LARGE_REPOSITORY_CHECKPOINT_DESIGN.md +340 -0
  5. package/docs/assets/turn-rewind-dialog.png +0 -0
  6. package/lib/client.js +291 -22
  7. package/lib/client.js.map +1 -1
  8. package/lib/deadline.js +13 -0
  9. package/lib/engine.js +622 -121
  10. package/lib/git-checkpoint.js +739 -0
  11. package/lib/git.js +295 -22
  12. package/lib/index.js +18 -2
  13. package/lib/path-utils.js +9 -0
  14. package/lib/rewind-host.js +250 -48
  15. package/lib/settings.js +58 -0
  16. package/lib/snapshot.js +113 -13
  17. package/lib/store.js +1361 -119
  18. package/lib/types/client/index.d.ts +74 -7
  19. package/lib/types/deadline.d.ts +6 -0
  20. package/lib/types/engine.d.ts +48 -2
  21. package/lib/types/git-checkpoint.d.ts +46 -0
  22. package/lib/types/git.d.ts +31 -0
  23. package/lib/types/index.d.ts +7 -0
  24. package/lib/types/path-utils.d.ts +2 -0
  25. package/lib/types/rewind-host.d.ts +8 -1
  26. package/lib/types/settings.d.ts +44 -0
  27. package/lib/types/snapshot.d.ts +6 -1
  28. package/lib/types/store.d.ts +90 -6
  29. package/lib/types/types.d.ts +63 -1
  30. package/lib/types.js +2 -0
  31. package/lib/validate.js +68 -9
  32. package/package.json +21 -3
  33. package/scripts/benchmark-git-checkpoint.mjs +149 -0
  34. package/scripts/wrap-client.mjs +18 -0
  35. package/src/client/index.tsx +536 -41
  36. package/src/deadline.ts +18 -0
  37. package/src/engine.ts +695 -126
  38. package/src/git-checkpoint.ts +853 -0
  39. package/src/git.ts +337 -22
  40. package/src/index.ts +21 -2
  41. package/src/path-utils.ts +10 -0
  42. package/src/rewind-host.ts +278 -50
  43. package/src/settings.ts +93 -0
  44. package/src/snapshot.ts +122 -12
  45. package/src/store.ts +1502 -104
  46. package/src/types.ts +69 -1
  47. package/src/validate.ts +67 -7
@@ -1,5 +1,7 @@
1
1
  /** Durable format version. Readers reject every other version. */
2
2
  export declare const LEDGER_FORMAT_VERSION: 1;
3
+ /** Git-native checkpoint format used by bounded automatic turn capture. */
4
+ export declare const GIT_CHECKPOINT_FORMAT_VERSION: 2;
3
5
  /** A persisted restore-point identifier. */
4
6
  export type RestorePointId = string;
5
7
  /** A persisted restore-operation identifier. */
@@ -12,6 +14,8 @@ export interface FileSnapshotEntry {
12
14
  readonly blob: string;
13
15
  readonly size: number;
14
16
  readonly mode: number;
17
+ /** Absent for v1 Ledger blobs; `git` means `blob` is a Git object ID. */
18
+ readonly provider?: 'git';
15
19
  }
16
20
  /** One symbolic-link snapshot. */
17
21
  export interface SymlinkSnapshotEntry {
@@ -33,7 +37,7 @@ export interface RepositoryState {
33
37
  /** Why a restore point exists. */
34
38
  export type RestorePointKind = 'user' | 'rescue' | 'turn';
35
39
  /** Durable workspace restore point. */
36
- export interface RestorePointManifest {
40
+ export interface RestorePointManifestV1 {
37
41
  readonly version: typeof LEDGER_FORMAT_VERSION;
38
42
  readonly id: RestorePointId;
39
43
  readonly kind: RestorePointKind;
@@ -56,6 +60,31 @@ export interface RestorePointManifest {
56
60
  readonly restoreCount: number;
57
61
  readonly lastRestoredAt?: number;
58
62
  }
63
+ /** Git-native metadata pinned by a private ref. */
64
+ export interface GitCheckpointMetadata {
65
+ readonly objectFormat: 'sha1' | 'sha256';
66
+ readonly trust: 'metadata-fenced' | 'byte-verified';
67
+ /** Stable owner of refs created by one Change Ledger storage root. */
68
+ readonly storeId: string;
69
+ /** Durable identity stored inside this specific Git worktree metadata directory. */
70
+ readonly worktreeId: string;
71
+ readonly headTree: string;
72
+ readonly indexTree: string;
73
+ readonly worktreeTree: string;
74
+ readonly envelopeTree: string;
75
+ readonly commit: string;
76
+ readonly ref: string;
77
+ readonly newlyStoredBytes: number;
78
+ }
79
+ /** Automatic turn checkpoint whose content is stored in the repository object database. */
80
+ export interface RestorePointManifestV2 extends Omit<RestorePointManifestV1, 'version' | 'treeHash'> {
81
+ readonly version: typeof GIT_CHECKPOINT_FORMAT_VERSION;
82
+ /** Deterministic sidecar identity; Git tree integrity is verified separately. */
83
+ readonly treeHash: string;
84
+ readonly git: GitCheckpointMetadata;
85
+ }
86
+ /** Durable workspace restore point in either supported format. */
87
+ export type RestorePointManifest = RestorePointManifestV1 | RestorePointManifestV2;
59
88
  /** Observable difference between a restore point and the current workspace. */
60
89
  export interface WorkspaceChange {
61
90
  readonly path: string;
@@ -77,6 +106,7 @@ export interface RestorePointInspection {
77
106
  }
78
107
  /** Compact restore-point metadata for lists and tool results. */
79
108
  export interface RestorePointSummary {
109
+ readonly format: typeof LEDGER_FORMAT_VERSION | typeof GIT_CHECKPOINT_FORMAT_VERSION;
80
110
  readonly id: RestorePointId;
81
111
  readonly kind: RestorePointKind;
82
112
  readonly workspace: string;
@@ -96,6 +126,7 @@ export interface RestorePointSummary {
96
126
  readonly branch?: string;
97
127
  readonly operation?: string;
98
128
  readonly stagedPathCount: number;
129
+ readonly trust?: GitCheckpointMetadata['trust'];
99
130
  }
100
131
  /** An expiring restore plan whose confirmation must be echoed exactly. */
101
132
  export interface RestorePlan {
@@ -152,6 +183,14 @@ export interface ChangeLedgerConfig {
152
183
  readonly planTtlMs?: number;
153
184
  /** Age after which a lock whose owner is gone may be reclaimed. */
154
185
  readonly staleLockMs?: number;
186
+ /** Automatic turn-checkpoint implementation. Git-native is bounded and avoids rereading clean tracked content. */
187
+ readonly turnCheckpointMode?: 'off' | 'git-native' | 'legacy';
188
+ /** Maximum time one automatic checkpoint may block the first Agent step. */
189
+ readonly turnCheckpointTimeoutMs?: number;
190
+ /** Maximum uncached worktree bytes read by one automatic Git-native checkpoint. */
191
+ readonly turnCheckpointMaxNewBytes?: number;
192
+ /** Fast trusts fenced Git/stat metadata; strict rereads every eligible path. */
193
+ readonly turnCheckpointTrust?: 'fast' | 'strict';
155
194
  }
156
195
  /** Fully resolved plugin configuration. */
157
196
  export interface ResolvedChangeLedgerConfig {
@@ -163,6 +202,10 @@ export interface ResolvedChangeLedgerConfig {
163
202
  readonly maxSnapshotBytes: number;
164
203
  readonly planTtlMs: number;
165
204
  readonly staleLockMs: number;
205
+ readonly turnCheckpointMode: 'off' | 'git-native' | 'legacy';
206
+ readonly turnCheckpointTimeoutMs: number;
207
+ readonly turnCheckpointMaxNewBytes: number;
208
+ readonly turnCheckpointTrust: 'fast' | 'strict';
166
209
  }
167
210
  /** Current incomplete restore operation. */
168
211
  export interface RecoverySummary {
@@ -175,3 +218,22 @@ export interface RecoverySummary {
175
218
  readonly error?: string;
176
219
  readonly rollbackError?: string;
177
220
  }
221
+ /** Aggregated checkpoint inventory for one workspace, addressed by its recorded path. */
222
+ export interface WorkspaceOverview {
223
+ /** Canonical workspace path recorded in the persisted manifests. */
224
+ readonly workspace: string;
225
+ /** Every restore point stored for the workspace, newest first. */
226
+ readonly restorePoints: readonly RestorePointSummary[];
227
+ /** Sum of every restore point's logical bytes. */
228
+ readonly totalBytes: number;
229
+ /** Incomplete restore operations that still reference this workspace's points. */
230
+ readonly recoveryCount: number;
231
+ }
232
+ /** Aggregate cleanup report for one workspace purge. */
233
+ export interface WorkspacePurgeReport {
234
+ readonly workspace: string;
235
+ readonly deletedRestorePoints: number;
236
+ readonly retainedRestorePoints: number;
237
+ readonly deletedBlobs: number;
238
+ readonly retainedBlobs: number;
239
+ }
package/lib/types.js CHANGED
@@ -1,2 +1,4 @@
1
1
  /** Durable format version. Readers reject every other version. */
2
2
  export const LEDGER_FORMAT_VERSION = 1;
3
+ /** Git-native checkpoint format used by bounded automatic turn capture. */
4
+ export const GIT_CHECKPOINT_FORMAT_VERSION = 2;
package/lib/validate.js CHANGED
@@ -2,7 +2,7 @@ import { ChangeLedgerError } from './errors.js';
2
2
  import { isAbsolute } from 'node:path';
3
3
  import { validateRelativePath } from './path-utils.js';
4
4
  import { hashTree } from './snapshot.js';
5
- import { LEDGER_FORMAT_VERSION, } from './types.js';
5
+ import { GIT_CHECKPOINT_FORMAT_VERSION, LEDGER_FORMAT_VERSION, } from './types.js';
6
6
  const HASH_PATTERN = /^[0-9a-f]{64}$/;
7
7
  const GIT_OBJECT_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
8
8
  const ID_PATTERN = /^(?:rp|op)_[0-9a-z]+_[0-9a-f]{12}$/;
@@ -23,14 +23,17 @@ export function validateOperationId(value) {
23
23
  /** Parse an untrusted durable restore-point manifest. */
24
24
  export function parseManifest(value) {
25
25
  const record = objectRecord(value, 'restore-point manifest');
26
- if (record.version !== LEDGER_FORMAT_VERSION)
27
- corrupt(`unsupported restore-point version ${String(record.version)}`);
26
+ const version = record.version;
27
+ if (version !== LEDGER_FORMAT_VERSION && version !== GIT_CHECKPOINT_FORMAT_VERSION) {
28
+ corrupt(`unsupported restore-point version ${String(version)}`);
29
+ }
28
30
  const id = stringField(record, 'id');
29
31
  validateRestorePointId(id);
30
32
  const kind = record.kind;
31
33
  if (kind !== 'user' && kind !== 'rescue' && kind !== 'turn') {
32
34
  corrupt('restore-point kind must be "user", "rescue", or "turn"');
33
35
  }
36
+ const restorePointKind = kind;
34
37
  const workspace = absoluteString(record, 'workspace');
35
38
  const repository = parseRepository(record.repository);
36
39
  if (repository.root !== workspace)
@@ -40,7 +43,7 @@ export function parseManifest(value) {
40
43
  let totalBytes = 0;
41
44
  for (const [path, entryValue] of Object.entries(entriesRecord)) {
42
45
  validateRelativePath(path);
43
- const entry = parseEntry(entryValue, path);
46
+ const entry = parseEntry(entryValue, path, version);
44
47
  entries[path] = entry;
45
48
  if (entry.kind === 'file')
46
49
  totalBytes += entry.size;
@@ -73,10 +76,9 @@ export function parseManifest(value) {
73
76
  corrupt('only turn restore points may carry turn metadata');
74
77
  }
75
78
  const lastRestoredAt = optionalNonNegativeInteger(record, 'lastRestoredAt');
76
- return {
77
- version: LEDGER_FORMAT_VERSION,
79
+ const common = {
78
80
  id,
79
- kind,
81
+ kind: restorePointKind,
80
82
  workspace,
81
83
  repository,
82
84
  ...(sessionId === undefined ? {} : { sessionId }),
@@ -93,6 +95,11 @@ export function parseManifest(value) {
93
95
  restoreCount,
94
96
  ...(lastRestoredAt === undefined ? {} : { lastRestoredAt }),
95
97
  };
98
+ if (version === LEDGER_FORMAT_VERSION)
99
+ return { version, ...common };
100
+ if (restorePointKind !== 'turn')
101
+ corrupt('Git-native v2 restore points are automatic turn checkpoints');
102
+ return { version, ...common, git: parseGitCheckpoint(record.git, id) };
96
103
  }
97
104
  /** Parse an untrusted durable restore-operation journal. */
98
105
  export function parseOperation(value) {
@@ -168,18 +175,24 @@ function parseRepository(value) {
168
175
  stagedPaths,
169
176
  };
170
177
  }
171
- function parseEntry(value, path) {
178
+ function parseEntry(value, path, version) {
172
179
  const record = objectRecord(value, `snapshot entry ${JSON.stringify(path)}`);
173
180
  const kind = record.kind;
174
181
  const mode = nonNegativeInteger(record, 'mode');
175
182
  if (mode > 0o777)
176
183
  corrupt(`snapshot mode is out of range for ${JSON.stringify(path)}`);
177
184
  if (kind === 'file') {
185
+ const blob = stringField(record, 'blob');
186
+ if (version === LEDGER_FORMAT_VERSION)
187
+ validateBlobHash(blob);
188
+ else
189
+ validateGitObject(blob);
178
190
  return {
179
191
  kind,
180
- blob: validateBlobHash(stringField(record, 'blob')),
192
+ blob,
181
193
  size: nonNegativeInteger(record, 'size'),
182
194
  mode,
195
+ ...(version === GIT_CHECKPOINT_FORMAT_VERSION ? { provider: 'git' } : {}),
183
196
  };
184
197
  }
185
198
  if (kind === 'symlink') {
@@ -190,6 +203,52 @@ function parseEntry(value, path) {
190
203
  }
191
204
  corrupt(`invalid snapshot entry kind for ${JSON.stringify(path)}`);
192
205
  }
206
+ function parseGitCheckpoint(value, restorePointId) {
207
+ const record = objectRecord(value, 'Git checkpoint metadata');
208
+ const objectFormat = record.objectFormat;
209
+ if (objectFormat !== 'sha1' && objectFormat !== 'sha256')
210
+ corrupt('Git checkpoint objectFormat must be sha1 or sha256');
211
+ const trust = record.trust;
212
+ if (trust !== 'metadata-fenced' && trust !== 'byte-verified')
213
+ corrupt('Git checkpoint trust is invalid');
214
+ const expectedLength = objectFormat === 'sha1' ? 40 : 64;
215
+ const object = (key) => {
216
+ const oid = validateGitObject(stringField(record, key));
217
+ if (oid.length !== expectedLength)
218
+ corrupt(`${key} length does not match Git object format`);
219
+ return oid;
220
+ };
221
+ const storeId = hexIdentifier(record, 'storeId');
222
+ const worktreeId = hexIdentifier(record, 'worktreeId');
223
+ const ref = stringField(record, 'ref');
224
+ if (ref !== `refs/dsh-turn-rewind/v2/${storeId}/${worktreeId}/${restorePointId}`) {
225
+ corrupt('Git checkpoint ref does not match its durable store/worktree/restore-point identity');
226
+ }
227
+ return {
228
+ objectFormat,
229
+ trust,
230
+ storeId,
231
+ worktreeId,
232
+ headTree: object('headTree'),
233
+ indexTree: object('indexTree'),
234
+ worktreeTree: object('worktreeTree'),
235
+ envelopeTree: object('envelopeTree'),
236
+ commit: object('commit'),
237
+ ref,
238
+ newlyStoredBytes: nonNegativeInteger(record, 'newlyStoredBytes'),
239
+ };
240
+ }
241
+ function hexIdentifier(record, key) {
242
+ const value = stringField(record, key);
243
+ if (!/^[0-9a-f]{32}$/.test(value))
244
+ corrupt(`${key} must be a 128-bit lowercase hexadecimal identifier`);
245
+ return value;
246
+ }
247
+ function validateGitObject(value) {
248
+ if (!GIT_OBJECT_PATTERN.test(value))
249
+ corrupt('invalid Git object id');
250
+ return value;
251
+ }
193
252
  function objectRecord(value, label) {
194
253
  if (value === null || typeof value !== 'object' || Array.isArray(value))
195
254
  corrupt(`${label} must be an object`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anionex/dsh-turn-rewind",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Turn-level conversation and workspace rewind for DeepSeek Harness, powered by a persistent Change Ledger",
5
5
  "repository": {
6
6
  "type": "git",
@@ -39,6 +39,7 @@
39
39
  "files": [
40
40
  "lib",
41
41
  "src",
42
+ "scripts",
42
43
  "docs",
43
44
  "cordis.patch.yml",
44
45
  "README.md",
@@ -54,11 +55,24 @@
54
55
  "platform": "web",
55
56
  "inject": [
56
57
  "@deepseek-ai/dsh-client-runtime",
57
- "@deepseek-ai/dsh-client-ui-conversation"
58
+ "@deepseek-ai/dsh-client-ui-conversation",
59
+ "@deepseek-ai/dsh-client-ui-settings-plugins"
60
+ ]
61
+ },
62
+ "compatibility": {
63
+ "dsh": ">=0.1.0-rc.8 <0.2.0",
64
+ "dshReleases": {
65
+ "0.1.0-rc.8": "compatible",
66
+ "0.1.1-rc.1": "compatible",
67
+ "0.1.1-rc.2": "compatible"
68
+ },
69
+ "profiles": [
70
+ "web"
58
71
  ]
59
72
  }
60
73
  },
61
74
  "scripts": {
75
+ "benchmark:git-checkpoint": "pnpm run build && node scripts/benchmark-git-checkpoint.mjs",
62
76
  "build": "tsc -p tsconfig.build.json && tsc -p tsconfig.client.json && node scripts/wrap-client.mjs",
63
77
  "check": "tsc -p tsconfig.json && pnpm run build && node --test tests/*.test.mjs && pnpm pack --dry-run",
64
78
  "prepack": "pnpm run build",
@@ -75,10 +89,14 @@
75
89
  ],
76
90
  "license": "BSD-3-Clause",
77
91
  "peerDependencies": {
78
- "@deepseek-ai/cordis": "^4.0.1"
92
+ "@deepseek-ai/cordis": "^4.0.1",
93
+ "@deepseek-ai/dsh-settings": ">=0.1.0-rc.8 <0.2.0",
94
+ "@deepseek-ai/schemastery": "^3.18.1"
79
95
  },
80
96
  "devDependencies": {
81
97
  "@deepseek-ai/cordis": "^4.0.1",
98
+ "@deepseek-ai/dsh-settings": ">=0.1.0-rc.8 <0.2.0",
99
+ "@deepseek-ai/schemastery": "^3.18.1",
82
100
  "@types/node": "^24.0.0",
83
101
  "@types/react": "18.3.28",
84
102
  "react": "18.3.1",
@@ -0,0 +1,149 @@
1
+ import assert from 'node:assert/strict'
2
+ import { execFile } from 'node:child_process'
3
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { performance } from 'node:perf_hooks'
7
+ import { promisify } from 'node:util'
8
+ import { ChangeLedgerEngine } from '../lib/index.js'
9
+
10
+ const execFileAsync = promisify(execFile)
11
+ const options = parseArgs(process.argv.slice(2))
12
+ const outer = await mkdtemp(join(tmpdir(), 'dsh-turn-rewind-bench-'))
13
+ const workspace = join(outer, 'workspace')
14
+ const storageDir = join(outer, 'state')
15
+ const result = {
16
+ fixture: options,
17
+ status: 'running',
18
+ captures: [],
19
+ }
20
+
21
+ try {
22
+ await mkdir(workspace)
23
+ await git(workspace, 'init', '-b', 'main')
24
+ await git(workspace, 'config', 'user.name', 'Turn Rewind Benchmark')
25
+ await git(workspace, 'config', 'user.email', 'turn-rewind-benchmark@example.invalid')
26
+ const content = Buffer.alloc(options.bytesPerFile, 0x61)
27
+ await parallelRange(options.files, 64, async (index) => {
28
+ const directory = join(workspace, 'fixture', String(Math.floor(index / 250)).padStart(4, '0'))
29
+ await mkdir(directory, { recursive: true })
30
+ await writeFile(join(directory, `${String(index).padStart(6, '0')}.bin`), content)
31
+ })
32
+ await git(workspace, 'add', '--all')
33
+ await git(workspace, 'commit', '-m', 'benchmark fixture')
34
+
35
+ const engine = new ChangeLedgerEngine({
36
+ storageDir,
37
+ turnCheckpointMode: 'git-native',
38
+ maxFiles: options.files + 100,
39
+ maxSnapshotBytes: options.files * options.bytesPerFile + 1024 * 1024,
40
+ turnCheckpointTimeoutMs: options.timeoutMs,
41
+ turnCheckpointMaxNewBytes: Math.max(options.dirtyFiles * options.bytesPerFile * 2, 1024 * 1024),
42
+ })
43
+ await engine.initialize()
44
+ result.captures.push(await measure(engine, workspace, 'clean', 1))
45
+ await parallelRange(options.dirtyFiles, 32, async (index) => {
46
+ const path = join(workspace, 'fixture', String(Math.floor(index / 250)).padStart(4, '0'), `${String(index).padStart(6, '0')}.bin`)
47
+ await writeFile(path, Buffer.alloc(options.bytesPerFile, 0x62))
48
+ })
49
+ result.captures.push(await measure(engine, workspace, 'dirty-cold-cache', 2))
50
+ result.captures.push(await measure(engine, workspace, 'dirty-warm-cache', 3))
51
+ const warmPoint = await engine.findTurnCheckpoint({ cwd: workspace, sessionId: 'benchmark-session', turn: 3 })
52
+ assert.notEqual(warmPoint, undefined)
53
+ const comparisonStarted = performance.now()
54
+ const inspection = await engine.inspect({ cwd: workspace, restorePointId: warmPoint.id })
55
+ assert.deepEqual(inspection.changes, [])
56
+ result.comparison = {
57
+ label: 'warm-checkpoint-vs-unchanged-worktree',
58
+ wallMs: Math.round((performance.now() - comparisonStarted) * 100) / 100,
59
+ changeCount: inspection.changes.length,
60
+ }
61
+
62
+ result.status = 'passed'
63
+ } catch (error) {
64
+ result.status = 'failed'
65
+ result.error = error instanceof Error ? `${error.name}: ${error.message}` : String(error)
66
+ process.exitCode = 1
67
+ } finally {
68
+ try {
69
+ await rm(outer, { recursive: true, force: true })
70
+ result.cleanup = 'passed'
71
+ } catch (error) {
72
+ result.cleanup = 'failed'
73
+ result.cleanupError = error instanceof Error ? error.message : String(error)
74
+ process.exitCode = 1
75
+ }
76
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
77
+ }
78
+
79
+ async function measure(engine, workspace, label, turn) {
80
+ const statusBefore = await git(workspace, 'status', '--porcelain=v2', '--untracked-files=all')
81
+ const headsBefore = await git(workspace, 'for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads')
82
+ const indexBefore = await readFile(join(workspace, '.git', 'index'))
83
+ const started = performance.now()
84
+ const point = await engine.createTurnCheckpoint({
85
+ cwd: workspace,
86
+ sessionId: 'benchmark-session',
87
+ turn,
88
+ turnStartSeq: turn * 4 - 3,
89
+ })
90
+ const manifest = await engine.store.readManifest(point.workspace, point.id)
91
+ assert.equal(manifest.version, 2)
92
+ assert.equal(await git(workspace, 'status', '--porcelain=v2', '--untracked-files=all'), statusBefore)
93
+ assert.equal(await git(workspace, 'for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads'), headsBefore)
94
+ assert.deepEqual(await readFile(join(workspace, '.git', 'index')), indexBefore)
95
+ return {
96
+ label,
97
+ wallMs: Math.round((performance.now() - started) * 100) / 100,
98
+ fileCount: manifest.fileCount,
99
+ logicalBytes: manifest.totalBytes,
100
+ worktreeBytesRead: manifest.git.newlyStoredBytes,
101
+ }
102
+ }
103
+
104
+ async function git(cwd, ...args) {
105
+ const { stdout } = await execFileAsync('git', ['-C', cwd, ...args], {
106
+ encoding: 'utf8',
107
+ maxBuffer: 64 * 1024 * 1024,
108
+ env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' },
109
+ })
110
+ return stdout.trim()
111
+ }
112
+
113
+ async function parallelRange(count, concurrency, task) {
114
+ let next = 0
115
+ await Promise.all(Array.from({ length: Math.min(count, concurrency) }, async () => {
116
+ while (next < count) {
117
+ const current = next
118
+ next += 1
119
+ await task(current)
120
+ }
121
+ }))
122
+ }
123
+
124
+ function parseArgs(args) {
125
+ if (args[0] === '--') args = args.slice(1)
126
+ const values = { files: 15_500, bytesPerFile: 24 * 1024, dirtyFiles: 10, timeoutMs: 5_000 }
127
+ for (let index = 0; index < args.length; index += 2) {
128
+ const flag = args[index]
129
+ const raw = args[index + 1]
130
+ const key = {
131
+ '--files': 'files',
132
+ '--bytes-per-file': 'bytesPerFile',
133
+ '--dirty-files': 'dirtyFiles',
134
+ '--timeout-ms': 'timeoutMs',
135
+ }[flag]
136
+ if (key === undefined || raw === undefined || !/^\d+$/.test(raw)) usage()
137
+ values[key] = Number(raw)
138
+ }
139
+ if (!Number.isSafeInteger(values.files) || values.files <= 0
140
+ || !Number.isSafeInteger(values.bytesPerFile) || values.bytesPerFile <= 0
141
+ || !Number.isSafeInteger(values.dirtyFiles) || values.dirtyFiles < 0 || values.dirtyFiles > values.files
142
+ || !Number.isSafeInteger(values.timeoutMs) || values.timeoutMs <= 0) usage()
143
+ return values
144
+ }
145
+
146
+ function usage() {
147
+ process.stderr.write('usage: pnpm benchmark:git-checkpoint -- [--files N] [--bytes-per-file N] [--dirty-files N] [--timeout-ms N]\n')
148
+ process.exit(2)
149
+ }
@@ -0,0 +1,18 @@
1
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ const built = new URL('../.client-build/index.js', import.meta.url)
6
+ const builtMap = new URL('../.client-build/index.js.map', import.meta.url)
7
+ const lib = new URL('../lib/', import.meta.url)
8
+ const source = (await readFile(built, 'utf8')).replace(/\n?\/\/# sourceMappingURL=.*\n?$/, '\n')
9
+ const sourceMap = JSON.parse(await readFile(builtMap, 'utf8'))
10
+ sourceMap.file = 'client.js'
11
+ sourceMap.sources = sourceMap.sources.map(sourcePath => `../src/client/${sourcePath.replace(/^\.\.\//, '')}`)
12
+ const banner = 'window.__ModuleLoader__.load({ id: "@anionex/dsh-turn-rewind", factory: (require) => {\nvar module = { exports: {} }; var exports = module.exports;\n'
13
+ const footer = '\nreturn module.exports; } });\n//# sourceMappingURL=client.js.map\n'
14
+
15
+ await mkdir(lib, { recursive: true })
16
+ await writeFile(join(fileURLToPath(lib), 'client.js'), `${banner}${source}${footer}`)
17
+ await writeFile(join(fileURLToPath(lib), 'client.js.map'), `${JSON.stringify(sourceMap)}\n`)
18
+ await rm(new URL('../.client-build/', import.meta.url), { recursive: true, force: true })