@git-stunts/git-warp 10.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +16 -0
  3. package/README.md +480 -0
  4. package/SECURITY.md +30 -0
  5. package/bin/git-warp +24 -0
  6. package/bin/warp-graph.js +1574 -0
  7. package/index.d.ts +2366 -0
  8. package/index.js +180 -0
  9. package/package.json +129 -0
  10. package/scripts/install-git-warp.sh +258 -0
  11. package/scripts/uninstall-git-warp.sh +139 -0
  12. package/src/domain/WarpGraph.js +3157 -0
  13. package/src/domain/crdt/Dot.js +160 -0
  14. package/src/domain/crdt/LWW.js +154 -0
  15. package/src/domain/crdt/ORSet.js +371 -0
  16. package/src/domain/crdt/VersionVector.js +222 -0
  17. package/src/domain/entities/GraphNode.js +60 -0
  18. package/src/domain/errors/EmptyMessageError.js +47 -0
  19. package/src/domain/errors/ForkError.js +30 -0
  20. package/src/domain/errors/IndexError.js +23 -0
  21. package/src/domain/errors/OperationAbortedError.js +22 -0
  22. package/src/domain/errors/QueryError.js +39 -0
  23. package/src/domain/errors/SchemaUnsupportedError.js +17 -0
  24. package/src/domain/errors/ShardCorruptionError.js +56 -0
  25. package/src/domain/errors/ShardLoadError.js +57 -0
  26. package/src/domain/errors/ShardValidationError.js +61 -0
  27. package/src/domain/errors/StorageError.js +57 -0
  28. package/src/domain/errors/SyncError.js +30 -0
  29. package/src/domain/errors/TraversalError.js +23 -0
  30. package/src/domain/errors/WarpError.js +31 -0
  31. package/src/domain/errors/WormholeError.js +28 -0
  32. package/src/domain/errors/WriterError.js +39 -0
  33. package/src/domain/errors/index.js +21 -0
  34. package/src/domain/services/AnchorMessageCodec.js +99 -0
  35. package/src/domain/services/BitmapIndexBuilder.js +225 -0
  36. package/src/domain/services/BitmapIndexReader.js +435 -0
  37. package/src/domain/services/BoundaryTransitionRecord.js +463 -0
  38. package/src/domain/services/CheckpointMessageCodec.js +147 -0
  39. package/src/domain/services/CheckpointSerializerV5.js +281 -0
  40. package/src/domain/services/CheckpointService.js +384 -0
  41. package/src/domain/services/CommitDagTraversalService.js +156 -0
  42. package/src/domain/services/DagPathFinding.js +712 -0
  43. package/src/domain/services/DagTopology.js +239 -0
  44. package/src/domain/services/DagTraversal.js +245 -0
  45. package/src/domain/services/Frontier.js +108 -0
  46. package/src/domain/services/GCMetrics.js +101 -0
  47. package/src/domain/services/GCPolicy.js +122 -0
  48. package/src/domain/services/GitLogParser.js +205 -0
  49. package/src/domain/services/HealthCheckService.js +246 -0
  50. package/src/domain/services/HookInstaller.js +326 -0
  51. package/src/domain/services/HttpSyncServer.js +262 -0
  52. package/src/domain/services/IndexRebuildService.js +426 -0
  53. package/src/domain/services/IndexStalenessChecker.js +103 -0
  54. package/src/domain/services/JoinReducer.js +582 -0
  55. package/src/domain/services/KeyCodec.js +113 -0
  56. package/src/domain/services/LegacyAnchorDetector.js +67 -0
  57. package/src/domain/services/LogicalTraversal.js +351 -0
  58. package/src/domain/services/MessageCodecInternal.js +132 -0
  59. package/src/domain/services/MessageSchemaDetector.js +145 -0
  60. package/src/domain/services/MigrationService.js +55 -0
  61. package/src/domain/services/ObserverView.js +265 -0
  62. package/src/domain/services/PatchBuilderV2.js +669 -0
  63. package/src/domain/services/PatchMessageCodec.js +140 -0
  64. package/src/domain/services/ProvenanceIndex.js +337 -0
  65. package/src/domain/services/ProvenancePayload.js +242 -0
  66. package/src/domain/services/QueryBuilder.js +835 -0
  67. package/src/domain/services/StateDiff.js +300 -0
  68. package/src/domain/services/StateSerializerV5.js +156 -0
  69. package/src/domain/services/StreamingBitmapIndexBuilder.js +709 -0
  70. package/src/domain/services/SyncProtocol.js +593 -0
  71. package/src/domain/services/TemporalQuery.js +201 -0
  72. package/src/domain/services/TranslationCost.js +221 -0
  73. package/src/domain/services/TraversalService.js +8 -0
  74. package/src/domain/services/WarpMessageCodec.js +29 -0
  75. package/src/domain/services/WarpStateIndexBuilder.js +127 -0
  76. package/src/domain/services/WormholeService.js +353 -0
  77. package/src/domain/types/TickReceipt.js +285 -0
  78. package/src/domain/types/WarpTypes.js +209 -0
  79. package/src/domain/types/WarpTypesV2.js +200 -0
  80. package/src/domain/utils/CachedValue.js +140 -0
  81. package/src/domain/utils/EventId.js +89 -0
  82. package/src/domain/utils/LRUCache.js +112 -0
  83. package/src/domain/utils/MinHeap.js +114 -0
  84. package/src/domain/utils/RefLayout.js +280 -0
  85. package/src/domain/utils/WriterId.js +205 -0
  86. package/src/domain/utils/cancellation.js +33 -0
  87. package/src/domain/utils/canonicalStringify.js +42 -0
  88. package/src/domain/utils/defaultClock.js +20 -0
  89. package/src/domain/utils/defaultCodec.js +51 -0
  90. package/src/domain/utils/nullLogger.js +21 -0
  91. package/src/domain/utils/roaring.js +181 -0
  92. package/src/domain/utils/shardVersion.js +9 -0
  93. package/src/domain/warp/PatchSession.js +217 -0
  94. package/src/domain/warp/Writer.js +181 -0
  95. package/src/hooks/post-merge.sh +60 -0
  96. package/src/infrastructure/adapters/BunHttpAdapter.js +225 -0
  97. package/src/infrastructure/adapters/ClockAdapter.js +57 -0
  98. package/src/infrastructure/adapters/ConsoleLogger.js +150 -0
  99. package/src/infrastructure/adapters/DenoHttpAdapter.js +230 -0
  100. package/src/infrastructure/adapters/GitGraphAdapter.js +787 -0
  101. package/src/infrastructure/adapters/GlobalClockAdapter.js +5 -0
  102. package/src/infrastructure/adapters/NoOpLogger.js +62 -0
  103. package/src/infrastructure/adapters/NodeCryptoAdapter.js +32 -0
  104. package/src/infrastructure/adapters/NodeHttpAdapter.js +98 -0
  105. package/src/infrastructure/adapters/PerformanceClockAdapter.js +5 -0
  106. package/src/infrastructure/adapters/WebCryptoAdapter.js +121 -0
  107. package/src/infrastructure/codecs/CborCodec.js +384 -0
  108. package/src/ports/BlobPort.js +30 -0
  109. package/src/ports/ClockPort.js +25 -0
  110. package/src/ports/CodecPort.js +25 -0
  111. package/src/ports/CommitPort.js +114 -0
  112. package/src/ports/ConfigPort.js +31 -0
  113. package/src/ports/CryptoPort.js +38 -0
  114. package/src/ports/GraphPersistencePort.js +57 -0
  115. package/src/ports/HttpServerPort.js +25 -0
  116. package/src/ports/IndexStoragePort.js +39 -0
  117. package/src/ports/LoggerPort.js +68 -0
  118. package/src/ports/RefPort.js +51 -0
  119. package/src/ports/TreePort.js +51 -0
  120. package/src/visualization/index.js +26 -0
  121. package/src/visualization/layouts/converters.js +75 -0
  122. package/src/visualization/layouts/elkAdapter.js +86 -0
  123. package/src/visualization/layouts/elkLayout.js +95 -0
  124. package/src/visualization/layouts/index.js +29 -0
  125. package/src/visualization/renderers/ascii/box.js +16 -0
  126. package/src/visualization/renderers/ascii/check.js +271 -0
  127. package/src/visualization/renderers/ascii/colors.js +13 -0
  128. package/src/visualization/renderers/ascii/formatters.js +73 -0
  129. package/src/visualization/renderers/ascii/graph.js +344 -0
  130. package/src/visualization/renderers/ascii/history.js +335 -0
  131. package/src/visualization/renderers/ascii/index.js +14 -0
  132. package/src/visualization/renderers/ascii/info.js +245 -0
  133. package/src/visualization/renderers/ascii/materialize.js +255 -0
  134. package/src/visualization/renderers/ascii/path.js +240 -0
  135. package/src/visualization/renderers/ascii/progress.js +32 -0
  136. package/src/visualization/renderers/ascii/symbols.js +33 -0
  137. package/src/visualization/renderers/ascii/table.js +19 -0
  138. package/src/visualization/renderers/browser/index.js +1 -0
  139. package/src/visualization/renderers/svg/index.js +159 -0
  140. package/src/visualization/utils/ansi.js +14 -0
  141. package/src/visualization/utils/time.js +40 -0
  142. package/src/visualization/utils/truncate.js +40 -0
  143. package/src/visualization/utils/unicode.js +52 -0
@@ -0,0 +1,1574 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import process from 'node:process';
6
+ import readline from 'node:readline';
7
+ import { execFileSync } from 'node:child_process';
8
+ import GitPlumbing, { ShellRunnerFactory } from '@git-stunts/plumbing';
9
+ import WarpGraph from '../src/domain/WarpGraph.js';
10
+ import GitGraphAdapter from '../src/infrastructure/adapters/GitGraphAdapter.js';
11
+ import HealthCheckService from '../src/domain/services/HealthCheckService.js';
12
+ import ClockAdapter from '../src/infrastructure/adapters/ClockAdapter.js';
13
+ import {
14
+ REF_PREFIX,
15
+ buildCheckpointRef,
16
+ buildCoverageRef,
17
+ buildWritersPrefix,
18
+ parseWriterIdFromRef,
19
+ } from '../src/domain/utils/RefLayout.js';
20
+ import { HookInstaller, classifyExistingHook } from '../src/domain/services/HookInstaller.js';
21
+ import { renderInfoView } from '../src/visualization/renderers/ascii/info.js';
22
+ import { renderCheckView } from '../src/visualization/renderers/ascii/check.js';
23
+ import { renderHistoryView, summarizeOps } from '../src/visualization/renderers/ascii/history.js';
24
+ import { renderPathView } from '../src/visualization/renderers/ascii/path.js';
25
+ import { renderMaterializeView } from '../src/visualization/renderers/ascii/materialize.js';
26
+ import { renderGraphView } from '../src/visualization/renderers/ascii/graph.js';
27
+ import { renderSvg } from '../src/visualization/renderers/svg/index.js';
28
+ import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../src/visualization/layouts/index.js';
29
+
30
+ const EXIT_CODES = {
31
+ OK: 0,
32
+ USAGE: 1,
33
+ NOT_FOUND: 2,
34
+ INTERNAL: 3,
35
+ };
36
+
37
+ const HELP_TEXT = `warp-graph <command> [options]
38
+ (or: git warp <command> [options])
39
+
40
+ Commands:
41
+ info Summarize graphs in the repo
42
+ query Run a logical graph query
43
+ path Find a logical path between two nodes
44
+ history Show writer history
45
+ check Report graph health/GC status
46
+ materialize Materialize and checkpoint all graphs
47
+ view Interactive TUI graph browser (requires @git-stunts/git-warp-tui)
48
+ install-hooks Install post-merge git hook
49
+
50
+ Options:
51
+ --repo <path> Path to git repo (default: cwd)
52
+ --json Emit JSON output
53
+ --view [mode] Visual output (ascii, browser, svg:FILE, html:FILE)
54
+ --graph <name> Graph name (required if repo has multiple graphs)
55
+ --writer <id> Writer id (default: cli)
56
+ -h, --help Show this help
57
+
58
+ Install-hooks options:
59
+ --force Replace existing hook (backs up original)
60
+
61
+ Query options:
62
+ --match <glob> Match node ids (default: *)
63
+ --outgoing [label] Traverse outgoing edge (repeatable)
64
+ --incoming [label] Traverse incoming edge (repeatable)
65
+ --where-prop k=v Filter nodes by prop equality (repeatable)
66
+ --select <fields> Fields to select (id, props)
67
+
68
+ Path options:
69
+ --from <id> Start node id
70
+ --to <id> End node id
71
+ --dir <out|in|both> Traversal direction (default: out)
72
+ --label <label> Filter by edge label (repeatable, comma-separated)
73
+ --max-depth <n> Maximum depth
74
+
75
+ History options:
76
+ --node <id> Filter patches touching node id
77
+ `;
78
+
79
+ /**
80
+ * Structured CLI error with exit code and error code.
81
+ */
82
+ class CliError extends Error {
83
+ /**
84
+ * @param {string} message - Human-readable error message
85
+ * @param {Object} [options]
86
+ * @param {string} [options.code='E_CLI'] - Machine-readable error code
87
+ * @param {number} [options.exitCode=3] - Process exit code
88
+ * @param {Error} [options.cause] - Underlying cause
89
+ */
90
+ constructor(message, { code = 'E_CLI', exitCode = EXIT_CODES.INTERNAL, cause } = {}) {
91
+ super(message);
92
+ this.code = code;
93
+ this.exitCode = exitCode;
94
+ this.cause = cause;
95
+ }
96
+ }
97
+
98
+ function usageError(message) {
99
+ return new CliError(message, { code: 'E_USAGE', exitCode: EXIT_CODES.USAGE });
100
+ }
101
+
102
+ function notFoundError(message) {
103
+ return new CliError(message, { code: 'E_NOT_FOUND', exitCode: EXIT_CODES.NOT_FOUND });
104
+ }
105
+
106
+ function stableStringify(value) {
107
+ const normalize = (input) => {
108
+ if (Array.isArray(input)) {
109
+ return input.map(normalize);
110
+ }
111
+ if (input && typeof input === 'object') {
112
+ const sorted = {};
113
+ for (const key of Object.keys(input).sort()) {
114
+ sorted[key] = normalize(input[key]);
115
+ }
116
+ return sorted;
117
+ }
118
+ return input;
119
+ };
120
+
121
+ return JSON.stringify(normalize(value), null, 2);
122
+ }
123
+
124
+ function parseArgs(argv) {
125
+ const options = createDefaultOptions();
126
+ const positionals = [];
127
+ const optionDefs = [
128
+ { flag: '--repo', shortFlag: '-r', key: 'repo' },
129
+ { flag: '--graph', key: 'graph' },
130
+ { flag: '--writer', key: 'writer' },
131
+ ];
132
+
133
+ for (let i = 0; i < argv.length; i += 1) {
134
+ const result = consumeBaseArg({ argv, index: i, options, optionDefs, positionals });
135
+ if (result.done) {
136
+ break;
137
+ }
138
+ i += result.consumed;
139
+ }
140
+
141
+ options.repo = path.resolve(options.repo);
142
+ return { options, positionals };
143
+ }
144
+
145
+ function createDefaultOptions() {
146
+ return {
147
+ repo: process.cwd(),
148
+ json: false,
149
+ view: null,
150
+ graph: null,
151
+ writer: 'cli',
152
+ help: false,
153
+ };
154
+ }
155
+
156
+ function consumeBaseArg({ argv, index, options, optionDefs, positionals }) {
157
+ const arg = argv[index];
158
+
159
+ if (arg === '--') {
160
+ positionals.push(...argv.slice(index + 1));
161
+ return { consumed: argv.length - index - 1, done: true };
162
+ }
163
+
164
+ if (arg === '--json') {
165
+ options.json = true;
166
+ return { consumed: 0 };
167
+ }
168
+
169
+ if (arg === '--view') {
170
+ // Valid view modes: ascii, browser, svg:FILE, html:FILE
171
+ // Don't consume known commands as modes
172
+ const KNOWN_COMMANDS = ['info', 'query', 'path', 'history', 'check', 'materialize', 'install-hooks'];
173
+ const nextArg = argv[index + 1];
174
+ const isViewMode = nextArg &&
175
+ !nextArg.startsWith('-') &&
176
+ !KNOWN_COMMANDS.includes(nextArg);
177
+ if (isViewMode) {
178
+ // Validate the view mode value
179
+ const validModes = ['ascii', 'browser'];
180
+ const validPrefixes = ['svg:', 'html:'];
181
+ const isValid = validModes.includes(nextArg) ||
182
+ validPrefixes.some((prefix) => nextArg.startsWith(prefix));
183
+ if (!isValid) {
184
+ throw usageError(`Invalid view mode: ${nextArg}. Valid modes: ascii, browser, svg:FILE, html:FILE`);
185
+ }
186
+ options.view = nextArg;
187
+ return { consumed: 1 };
188
+ }
189
+ options.view = 'ascii'; // default mode
190
+ return { consumed: 0 };
191
+ }
192
+
193
+ if (arg === '-h' || arg === '--help') {
194
+ options.help = true;
195
+ return { consumed: 0 };
196
+ }
197
+
198
+ const matched = matchOptionDef(arg, optionDefs);
199
+ if (matched) {
200
+ const result = readOptionValue({
201
+ args: argv,
202
+ index,
203
+ flag: matched.flag,
204
+ shortFlag: matched.shortFlag,
205
+ allowEmpty: false,
206
+ });
207
+ options[matched.key] = result.value;
208
+ return { consumed: result.consumed };
209
+ }
210
+
211
+ if (arg.startsWith('-')) {
212
+ throw usageError(`Unknown option: ${arg}`);
213
+ }
214
+
215
+ positionals.push(arg, ...argv.slice(index + 1));
216
+ return { consumed: argv.length - index - 1, done: true };
217
+ }
218
+
219
+ function matchOptionDef(arg, optionDefs) {
220
+ return optionDefs.find((def) =>
221
+ arg === def.flag ||
222
+ arg === def.shortFlag ||
223
+ arg.startsWith(`${def.flag}=`)
224
+ );
225
+ }
226
+
227
+ async function createPersistence(repoPath) {
228
+ const runner = ShellRunnerFactory.create();
229
+ const plumbing = new GitPlumbing({ cwd: repoPath, runner });
230
+ const persistence = new GitGraphAdapter({ plumbing });
231
+ const ping = await persistence.ping();
232
+ if (!ping.ok) {
233
+ throw usageError(`Repository not accessible: ${repoPath}`);
234
+ }
235
+ return { persistence };
236
+ }
237
+
238
+ async function listGraphNames(persistence) {
239
+ if (typeof persistence.listRefs !== 'function') {
240
+ return [];
241
+ }
242
+ const refs = await persistence.listRefs(REF_PREFIX);
243
+ const prefix = `${REF_PREFIX}/`;
244
+ const names = new Set();
245
+
246
+ for (const ref of refs) {
247
+ if (!ref.startsWith(prefix)) {
248
+ continue;
249
+ }
250
+ const rest = ref.slice(prefix.length);
251
+ const [graphName] = rest.split('/');
252
+ if (graphName) {
253
+ names.add(graphName);
254
+ }
255
+ }
256
+
257
+ return [...names].sort();
258
+ }
259
+
260
+ async function resolveGraphName(persistence, explicitGraph) {
261
+ if (explicitGraph) {
262
+ return explicitGraph;
263
+ }
264
+ const graphNames = await listGraphNames(persistence);
265
+ if (graphNames.length === 1) {
266
+ return graphNames[0];
267
+ }
268
+ if (graphNames.length === 0) {
269
+ throw notFoundError('No graphs found in repo; specify --graph');
270
+ }
271
+ throw usageError('Multiple graphs found; specify --graph');
272
+ }
273
+
274
+ async function getGraphInfo(persistence, graphName, {
275
+ includeWriterIds = false,
276
+ includeRefs = false,
277
+ includeWriterPatches = false,
278
+ includeCheckpointDate = false,
279
+ } = {}) {
280
+ const writersPrefix = buildWritersPrefix(graphName);
281
+ const writerRefs = typeof persistence.listRefs === 'function'
282
+ ? await persistence.listRefs(writersPrefix)
283
+ : [];
284
+ const writerIds = writerRefs
285
+ .map((ref) => parseWriterIdFromRef(ref))
286
+ .filter(Boolean)
287
+ .sort();
288
+
289
+ const info = {
290
+ name: graphName,
291
+ writers: {
292
+ count: writerIds.length,
293
+ },
294
+ };
295
+
296
+ if (includeWriterIds) {
297
+ info.writers.ids = writerIds;
298
+ }
299
+
300
+ if (includeRefs || includeCheckpointDate) {
301
+ const checkpointRef = buildCheckpointRef(graphName);
302
+ const checkpointSha = await persistence.readRef(checkpointRef);
303
+
304
+ const checkpoint = { ref: checkpointRef, sha: checkpointSha || null };
305
+
306
+ if (includeCheckpointDate && checkpointSha) {
307
+ const checkpointDate = await readCheckpointDate(persistence, checkpointSha);
308
+ checkpoint.date = checkpointDate;
309
+ }
310
+
311
+ info.checkpoint = checkpoint;
312
+
313
+ if (includeRefs) {
314
+ const coverageRef = buildCoverageRef(graphName);
315
+ const coverageSha = await persistence.readRef(coverageRef);
316
+ info.coverage = { ref: coverageRef, sha: coverageSha || null };
317
+ }
318
+ }
319
+
320
+ if (includeWriterPatches && writerIds.length > 0) {
321
+ const graph = await WarpGraph.open({
322
+ persistence,
323
+ graphName,
324
+ writerId: 'cli',
325
+ });
326
+ const writerPatches = {};
327
+ for (const writerId of writerIds) {
328
+ const patches = await graph.getWriterPatches(writerId);
329
+ writerPatches[writerId] = patches.length;
330
+ }
331
+ info.writerPatches = writerPatches;
332
+ }
333
+
334
+ return info;
335
+ }
336
+
337
+ async function openGraph(options) {
338
+ const { persistence } = await createPersistence(options.repo);
339
+ const graphName = await resolveGraphName(persistence, options.graph);
340
+ const graph = await WarpGraph.open({
341
+ persistence,
342
+ graphName,
343
+ writerId: options.writer,
344
+ });
345
+ return { graph, graphName, persistence };
346
+ }
347
+
348
+ function parseQueryArgs(args) {
349
+ const spec = {
350
+ match: null,
351
+ select: null,
352
+ steps: [],
353
+ };
354
+
355
+ for (let i = 0; i < args.length; i += 1) {
356
+ const result = consumeQueryArg(args, i, spec);
357
+ if (!result) {
358
+ throw usageError(`Unknown query option: ${args[i]}`);
359
+ }
360
+ i += result.consumed;
361
+ }
362
+
363
+ return spec;
364
+ }
365
+
366
+ function consumeQueryArg(args, index, spec) {
367
+ const stepResult = readTraversalStep(args, index);
368
+ if (stepResult) {
369
+ spec.steps.push(stepResult.step);
370
+ return stepResult;
371
+ }
372
+
373
+ const matchResult = readOptionValue({
374
+ args,
375
+ index,
376
+ flag: '--match',
377
+ allowEmpty: true,
378
+ });
379
+ if (matchResult) {
380
+ spec.match = matchResult.value;
381
+ return matchResult;
382
+ }
383
+
384
+ const whereResult = readOptionValue({
385
+ args,
386
+ index,
387
+ flag: '--where-prop',
388
+ allowEmpty: false,
389
+ });
390
+ if (whereResult) {
391
+ spec.steps.push(parseWhereProp(whereResult.value));
392
+ return whereResult;
393
+ }
394
+
395
+ const selectResult = readOptionValue({
396
+ args,
397
+ index,
398
+ flag: '--select',
399
+ allowEmpty: true,
400
+ });
401
+ if (selectResult) {
402
+ spec.select = parseSelectFields(selectResult.value);
403
+ return selectResult;
404
+ }
405
+
406
+ return null;
407
+ }
408
+
409
+ function parseWhereProp(value) {
410
+ const [key, ...rest] = value.split('=');
411
+ if (!key || rest.length === 0) {
412
+ throw usageError('Expected --where-prop key=value');
413
+ }
414
+ return { type: 'where-prop', key, value: rest.join('=') };
415
+ }
416
+
417
+ function parseSelectFields(value) {
418
+ if (value === '') {
419
+ return [];
420
+ }
421
+ return value.split(',').map((field) => field.trim()).filter(Boolean);
422
+ }
423
+
424
+ function readTraversalStep(args, index) {
425
+ const arg = args[index];
426
+ if (arg !== '--outgoing' && arg !== '--incoming') {
427
+ return null;
428
+ }
429
+ const next = args[index + 1];
430
+ const label = next && !next.startsWith('-') ? next : undefined;
431
+ const consumed = label ? 1 : 0;
432
+ return { step: { type: arg.slice(2), label }, consumed };
433
+ }
434
+
435
+ function readOptionValue({ args, index, flag, shortFlag, allowEmpty = false }) {
436
+ const arg = args[index];
437
+ if (matchesOptionFlag(arg, flag, shortFlag)) {
438
+ return readNextOptionValue({ args, index, flag, allowEmpty });
439
+ }
440
+
441
+ if (arg.startsWith(`${flag}=`)) {
442
+ return readInlineOptionValue({ arg, flag, allowEmpty });
443
+ }
444
+
445
+ return null;
446
+ }
447
+
448
+ function matchesOptionFlag(arg, flag, shortFlag) {
449
+ return arg === flag || (shortFlag && arg === shortFlag);
450
+ }
451
+
452
+ function readNextOptionValue({ args, index, flag, allowEmpty }) {
453
+ const value = args[index + 1];
454
+ if (value === undefined || (!allowEmpty && value === '')) {
455
+ throw usageError(`Missing value for ${flag}`);
456
+ }
457
+ return { value, consumed: 1 };
458
+ }
459
+
460
+ function readInlineOptionValue({ arg, flag, allowEmpty }) {
461
+ const value = arg.slice(flag.length + 1);
462
+ if (!allowEmpty && value === '') {
463
+ throw usageError(`Missing value for ${flag}`);
464
+ }
465
+ return { value, consumed: 0 };
466
+ }
467
+
468
+ function parsePathArgs(args) {
469
+ const options = createPathOptions();
470
+ const labels = [];
471
+ const positionals = [];
472
+
473
+ for (let i = 0; i < args.length; i += 1) {
474
+ const result = consumePathArg({ args, index: i, options, labels, positionals });
475
+ i += result.consumed;
476
+ }
477
+
478
+ finalizePathOptions(options, labels, positionals);
479
+ return options;
480
+ }
481
+
482
+ function createPathOptions() {
483
+ return {
484
+ from: null,
485
+ to: null,
486
+ dir: undefined,
487
+ labelFilter: undefined,
488
+ maxDepth: undefined,
489
+ };
490
+ }
491
+
492
+ function consumePathArg({ args, index, options, labels, positionals }) {
493
+ const arg = args[index];
494
+ const handlers = [
495
+ { flag: '--from', apply: (value) => { options.from = value; } },
496
+ { flag: '--to', apply: (value) => { options.to = value; } },
497
+ { flag: '--dir', apply: (value) => { options.dir = value; } },
498
+ { flag: '--label', apply: (value) => { labels.push(...parseLabels(value)); } },
499
+ { flag: '--max-depth', apply: (value) => { options.maxDepth = parseMaxDepth(value); } },
500
+ ];
501
+
502
+ for (const handler of handlers) {
503
+ const result = readOptionValue({ args, index, flag: handler.flag });
504
+ if (result) {
505
+ handler.apply(result.value);
506
+ return result;
507
+ }
508
+ }
509
+
510
+ if (arg.startsWith('-')) {
511
+ throw usageError(`Unknown path option: ${arg}`);
512
+ }
513
+
514
+ positionals.push(arg);
515
+ return { consumed: 0 };
516
+ }
517
+
518
+ function finalizePathOptions(options, labels, positionals) {
519
+ if (!options.from) {
520
+ options.from = positionals[0] || null;
521
+ }
522
+
523
+ if (!options.to) {
524
+ options.to = positionals[1] || null;
525
+ }
526
+
527
+ if (!options.from || !options.to) {
528
+ throw usageError('Path requires --from and --to (or two positional ids)');
529
+ }
530
+
531
+ if (labels.length === 1) {
532
+ options.labelFilter = labels[0];
533
+ } else if (labels.length > 1) {
534
+ options.labelFilter = labels;
535
+ }
536
+ }
537
+
538
+ function parseLabels(value) {
539
+ return value.split(',').map((label) => label.trim()).filter(Boolean);
540
+ }
541
+
542
+ function parseMaxDepth(value) {
543
+ const parsed = Number.parseInt(value, 10);
544
+ if (Number.isNaN(parsed)) {
545
+ throw usageError('Invalid value for --max-depth');
546
+ }
547
+ return parsed;
548
+ }
549
+
550
+ function parseHistoryArgs(args) {
551
+ const options = { node: null };
552
+
553
+ for (let i = 0; i < args.length; i += 1) {
554
+ const arg = args[i];
555
+
556
+ if (arg === '--node') {
557
+ const value = args[i + 1];
558
+ if (!value) {
559
+ throw usageError('Missing value for --node');
560
+ }
561
+ options.node = value;
562
+ i += 1;
563
+ continue;
564
+ }
565
+
566
+ if (arg.startsWith('--node=')) {
567
+ options.node = arg.slice('--node='.length);
568
+ continue;
569
+ }
570
+
571
+ if (arg.startsWith('-')) {
572
+ throw usageError(`Unknown history option: ${arg}`);
573
+ }
574
+
575
+ throw usageError(`Unexpected history argument: ${arg}`);
576
+ }
577
+
578
+ return options;
579
+ }
580
+
581
+ function patchTouchesNode(patch, nodeId) {
582
+ const ops = Array.isArray(patch?.ops) ? patch.ops : [];
583
+ for (const op of ops) {
584
+ if (op.node === nodeId) {
585
+ return true;
586
+ }
587
+ if (op.from === nodeId || op.to === nodeId) {
588
+ return true;
589
+ }
590
+ }
591
+ return false;
592
+ }
593
+
594
+ function renderInfo(payload) {
595
+ const lines = [`Repo: ${payload.repo}`];
596
+ lines.push(`Graphs: ${payload.graphs.length}`);
597
+ for (const graph of payload.graphs) {
598
+ const writers = graph.writers ? ` writers=${graph.writers.count}` : '';
599
+ lines.push(`- ${graph.name}${writers}`);
600
+ if (graph.checkpoint?.sha) {
601
+ lines.push(` checkpoint: ${graph.checkpoint.sha}`);
602
+ }
603
+ if (graph.coverage?.sha) {
604
+ lines.push(` coverage: ${graph.coverage.sha}`);
605
+ }
606
+ }
607
+ return `${lines.join('\n')}\n`;
608
+ }
609
+
610
+ function renderQuery(payload) {
611
+ const lines = [
612
+ `Graph: ${payload.graph}`,
613
+ `State: ${payload.stateHash}`,
614
+ `Nodes: ${payload.nodes.length}`,
615
+ ];
616
+
617
+ for (const node of payload.nodes) {
618
+ const id = node.id ?? '(unknown)';
619
+ lines.push(`- ${id}`);
620
+ if (node.props && Object.keys(node.props).length > 0) {
621
+ lines.push(` props: ${JSON.stringify(node.props)}`);
622
+ }
623
+ }
624
+
625
+ return `${lines.join('\n')}\n`;
626
+ }
627
+
628
+ function renderPath(payload) {
629
+ const lines = [
630
+ `Graph: ${payload.graph}`,
631
+ `From: ${payload.from}`,
632
+ `To: ${payload.to}`,
633
+ `Found: ${payload.found ? 'yes' : 'no'}`,
634
+ `Length: ${payload.length}`,
635
+ ];
636
+
637
+ if (payload.path && payload.path.length > 0) {
638
+ lines.push(`Path: ${payload.path.join(' -> ')}`);
639
+ }
640
+
641
+ return `${lines.join('\n')}\n`;
642
+ }
643
+
644
+ const ANSI_GREEN = '\x1b[32m';
645
+ const ANSI_YELLOW = '\x1b[33m';
646
+ const ANSI_RED = '\x1b[31m';
647
+ const ANSI_DIM = '\x1b[2m';
648
+ const ANSI_RESET = '\x1b[0m';
649
+
650
+ function colorCachedState(state) {
651
+ if (state === 'fresh') {
652
+ return `${ANSI_GREEN}${state}${ANSI_RESET}`;
653
+ }
654
+ if (state === 'stale') {
655
+ return `${ANSI_YELLOW}${state}${ANSI_RESET}`;
656
+ }
657
+ return `${ANSI_RED}${ANSI_DIM}${state}${ANSI_RESET}`;
658
+ }
659
+
660
+ function renderCheck(payload) {
661
+ const lines = [
662
+ `Graph: ${payload.graph}`,
663
+ `Health: ${payload.health.status}`,
664
+ ];
665
+
666
+ if (payload.status) {
667
+ lines.push(`Cached State: ${colorCachedState(payload.status.cachedState)}`);
668
+ lines.push(`Patches Since Checkpoint: ${payload.status.patchesSinceCheckpoint}`);
669
+ lines.push(`Tombstone Ratio: ${payload.status.tombstoneRatio.toFixed(2)}`);
670
+ lines.push(`Writers: ${payload.status.writers}`);
671
+ }
672
+
673
+ if (payload.checkpoint?.sha) {
674
+ lines.push(`Checkpoint: ${payload.checkpoint.sha}`);
675
+ if (payload.checkpoint.ageSeconds !== null) {
676
+ lines.push(`Checkpoint Age: ${payload.checkpoint.ageSeconds}s`);
677
+ }
678
+ } else {
679
+ lines.push('Checkpoint: none');
680
+ }
681
+
682
+ if (!payload.status) {
683
+ lines.push(`Writers: ${payload.writers.count}`);
684
+ }
685
+ for (const head of payload.writers.heads) {
686
+ lines.push(`- ${head.writerId}: ${head.sha}`);
687
+ }
688
+
689
+ if (payload.coverage?.sha) {
690
+ lines.push(`Coverage: ${payload.coverage.sha}`);
691
+ lines.push(`Coverage Missing: ${payload.coverage.missingWriters.length}`);
692
+ } else {
693
+ lines.push('Coverage: none');
694
+ }
695
+
696
+ if (payload.gc) {
697
+ lines.push(`Tombstones: ${payload.gc.totalTombstones}`);
698
+ if (!payload.status) {
699
+ lines.push(`Tombstone Ratio: ${payload.gc.tombstoneRatio}`);
700
+ }
701
+ }
702
+
703
+ if (payload.hook) {
704
+ lines.push(formatHookStatusLine(payload.hook));
705
+ }
706
+
707
+ return `${lines.join('\n')}\n`;
708
+ }
709
+
710
+ function formatHookStatusLine(hook) {
711
+ if (!hook.installed && hook.foreign) {
712
+ return "Hook: foreign hook present — run 'git warp install-hooks'";
713
+ }
714
+ if (!hook.installed) {
715
+ return "Hook: not installed — run 'git warp install-hooks'";
716
+ }
717
+ if (hook.current) {
718
+ return `Hook: installed (v${hook.version}) — up to date`;
719
+ }
720
+ return `Hook: installed (v${hook.version}) — upgrade available, run 'git warp install-hooks'`;
721
+ }
722
+
723
+ function renderHistory(payload) {
724
+ const lines = [
725
+ `Graph: ${payload.graph}`,
726
+ `Writer: ${payload.writer}`,
727
+ `Entries: ${payload.entries.length}`,
728
+ ];
729
+
730
+ if (payload.nodeFilter) {
731
+ lines.push(`Node Filter: ${payload.nodeFilter}`);
732
+ }
733
+
734
+ for (const entry of payload.entries) {
735
+ lines.push(`- ${entry.sha} (lamport: ${entry.lamport}, ops: ${entry.opCount})`);
736
+ }
737
+
738
+ return `${lines.join('\n')}\n`;
739
+ }
740
+
741
+ function renderError(payload) {
742
+ return `Error: ${payload.error.message}\n`;
743
+ }
744
+
745
+ function emit(payload, { json, command, view }) {
746
+ if (json) {
747
+ process.stdout.write(`${stableStringify(payload)}\n`);
748
+ return;
749
+ }
750
+
751
+ if (command === 'info') {
752
+ if (view) {
753
+ process.stdout.write(renderInfoView(payload));
754
+ } else {
755
+ process.stdout.write(renderInfo(payload));
756
+ }
757
+ return;
758
+ }
759
+
760
+ if (command === 'query') {
761
+ if (view && typeof view === 'string' && view.startsWith('svg:')) {
762
+ const svgPath = view.slice(4);
763
+ if (!payload._renderedSvg) {
764
+ process.stderr.write('No graph data — skipping SVG export.\n');
765
+ } else {
766
+ fs.writeFileSync(svgPath, payload._renderedSvg);
767
+ process.stderr.write(`SVG written to ${svgPath}\n`);
768
+ }
769
+ } else if (view) {
770
+ process.stdout.write(`${payload._renderedAscii}\n`);
771
+ } else {
772
+ process.stdout.write(renderQuery(payload));
773
+ }
774
+ return;
775
+ }
776
+
777
+ if (command === 'path') {
778
+ if (view && typeof view === 'string' && view.startsWith('svg:')) {
779
+ const svgPath = view.slice(4);
780
+ if (!payload._renderedSvg) {
781
+ process.stderr.write('No path found — skipping SVG export.\n');
782
+ } else {
783
+ fs.writeFileSync(svgPath, payload._renderedSvg);
784
+ process.stderr.write(`SVG written to ${svgPath}\n`);
785
+ }
786
+ } else if (view) {
787
+ process.stdout.write(renderPathView(payload));
788
+ } else {
789
+ process.stdout.write(renderPath(payload));
790
+ }
791
+ return;
792
+ }
793
+
794
+ if (command === 'check') {
795
+ if (view) {
796
+ process.stdout.write(renderCheckView(payload));
797
+ } else {
798
+ process.stdout.write(renderCheck(payload));
799
+ }
800
+ return;
801
+ }
802
+
803
+ if (command === 'history') {
804
+ if (view) {
805
+ process.stdout.write(renderHistoryView(payload));
806
+ } else {
807
+ process.stdout.write(renderHistory(payload));
808
+ }
809
+ return;
810
+ }
811
+
812
+ if (command === 'materialize') {
813
+ if (view) {
814
+ process.stdout.write(renderMaterializeView(payload));
815
+ } else {
816
+ process.stdout.write(renderMaterialize(payload));
817
+ }
818
+ return;
819
+ }
820
+
821
+ if (command === 'install-hooks') {
822
+ process.stdout.write(renderInstallHooks(payload));
823
+ return;
824
+ }
825
+
826
+ if (payload?.error) {
827
+ process.stderr.write(renderError(payload));
828
+ return;
829
+ }
830
+
831
+ process.stdout.write(`${stableStringify(payload)}\n`);
832
+ }
833
+
834
+ /**
835
+ * Handles the `info` command: summarizes graphs in the repository.
836
+ * @param {Object} params
837
+ * @param {Object} params.options - Parsed CLI options
838
+ * @returns {Promise<{repo: string, graphs: Object[]}>} Info payload
839
+ * @throws {CliError} If the specified graph is not found
840
+ */
841
+ async function handleInfo({ options }) {
842
+ const { persistence } = await createPersistence(options.repo);
843
+ const graphNames = await listGraphNames(persistence);
844
+
845
+ if (options.graph && !graphNames.includes(options.graph)) {
846
+ throw notFoundError(`Graph not found: ${options.graph}`);
847
+ }
848
+
849
+ const detailGraphs = new Set();
850
+ if (options.graph) {
851
+ detailGraphs.add(options.graph);
852
+ } else if (graphNames.length === 1) {
853
+ detailGraphs.add(graphNames[0]);
854
+ }
855
+
856
+ // In view mode, include extra data for visualization
857
+ const isViewMode = Boolean(options.view);
858
+
859
+ const graphs = [];
860
+ for (const name of graphNames) {
861
+ const includeDetails = detailGraphs.has(name);
862
+ graphs.push(await getGraphInfo(persistence, name, {
863
+ includeWriterIds: includeDetails || isViewMode,
864
+ includeRefs: includeDetails || isViewMode,
865
+ includeWriterPatches: isViewMode,
866
+ includeCheckpointDate: isViewMode,
867
+ }));
868
+ }
869
+
870
+ return {
871
+ repo: options.repo,
872
+ graphs,
873
+ };
874
+ }
875
+
876
+ /**
877
+ * Handles the `query` command: runs a logical graph query.
878
+ * @param {Object} params
879
+ * @param {Object} params.options - Parsed CLI options
880
+ * @param {string[]} params.args - Remaining positional arguments (query spec)
881
+ * @returns {Promise<{payload: Object, exitCode: number}>} Query result payload
882
+ * @throws {CliError} On invalid query options or query execution errors
883
+ */
884
+ async function handleQuery({ options, args }) {
885
+ const querySpec = parseQueryArgs(args);
886
+ const { graph, graphName } = await openGraph(options);
887
+ let builder = graph.query();
888
+
889
+ if (querySpec.match !== null) {
890
+ builder = builder.match(querySpec.match);
891
+ }
892
+
893
+ builder = applyQuerySteps(builder, querySpec.steps);
894
+
895
+ if (querySpec.select !== null) {
896
+ builder = builder.select(querySpec.select);
897
+ }
898
+
899
+ try {
900
+ const result = await builder.run();
901
+ const payload = buildQueryPayload(graphName, result);
902
+
903
+ if (options.view) {
904
+ const edges = await graph.getEdges();
905
+ const graphData = queryResultToGraphData(payload, edges);
906
+ const positioned = await layoutGraph(graphData, { type: 'query' });
907
+ if (typeof options.view === 'string' && options.view.startsWith('svg:')) {
908
+ payload._renderedSvg = renderSvg(positioned, { title: `${graphName} query` });
909
+ } else {
910
+ payload._renderedAscii = renderGraphView(positioned, { title: `QUERY: ${graphName}` });
911
+ }
912
+ }
913
+
914
+ return {
915
+ payload,
916
+ exitCode: EXIT_CODES.OK,
917
+ };
918
+ } catch (error) {
919
+ throw mapQueryError(error);
920
+ }
921
+ }
922
+
923
+ function applyQuerySteps(builder, steps) {
924
+ let current = builder;
925
+ for (const step of steps) {
926
+ current = applyQueryStep(current, step);
927
+ }
928
+ return current;
929
+ }
930
+
931
+ function applyQueryStep(builder, step) {
932
+ if (step.type === 'outgoing') {
933
+ return builder.outgoing(step.label);
934
+ }
935
+ if (step.type === 'incoming') {
936
+ return builder.incoming(step.label);
937
+ }
938
+ if (step.type === 'where-prop') {
939
+ return builder.where((node) => matchesPropFilter(node, step.key, step.value));
940
+ }
941
+ return builder;
942
+ }
943
+
944
+ function matchesPropFilter(node, key, value) {
945
+ const props = node.props || {};
946
+ if (!Object.prototype.hasOwnProperty.call(props, key)) {
947
+ return false;
948
+ }
949
+ return String(props[key]) === value;
950
+ }
951
+
952
+ function buildQueryPayload(graphName, result) {
953
+ return {
954
+ graph: graphName,
955
+ stateHash: result.stateHash,
956
+ nodes: result.nodes,
957
+ };
958
+ }
959
+
960
+ function mapQueryError(error) {
961
+ if (error && error.code && String(error.code).startsWith('E_QUERY')) {
962
+ throw usageError(error.message);
963
+ }
964
+ throw error;
965
+ }
966
+
967
+ /**
968
+ * Handles the `path` command: finds a shortest path between two nodes.
969
+ * @param {Object} params
970
+ * @param {Object} params.options - Parsed CLI options
971
+ * @param {string[]} params.args - Remaining positional arguments (path spec)
972
+ * @returns {Promise<{payload: Object, exitCode: number}>} Path result payload
973
+ * @throws {CliError} If --from/--to are missing or a node is not found
974
+ */
975
+ async function handlePath({ options, args }) {
976
+ const pathOptions = parsePathArgs(args);
977
+ const { graph, graphName } = await openGraph(options);
978
+
979
+ try {
980
+ const result = await graph.traverse.shortestPath(
981
+ pathOptions.from,
982
+ pathOptions.to,
983
+ {
984
+ dir: pathOptions.dir,
985
+ labelFilter: pathOptions.labelFilter,
986
+ maxDepth: pathOptions.maxDepth,
987
+ }
988
+ );
989
+
990
+ const payload = {
991
+ graph: graphName,
992
+ from: pathOptions.from,
993
+ to: pathOptions.to,
994
+ ...result,
995
+ };
996
+
997
+ if (options.view && result.found && typeof options.view === 'string' && options.view.startsWith('svg:')) {
998
+ const graphData = pathResultToGraphData(payload);
999
+ const positioned = await layoutGraph(graphData, { type: 'path' });
1000
+ payload._renderedSvg = renderSvg(positioned, { title: `${graphName} path` });
1001
+ }
1002
+
1003
+ return {
1004
+ payload,
1005
+ exitCode: result.found ? EXIT_CODES.OK : EXIT_CODES.NOT_FOUND,
1006
+ };
1007
+ } catch (error) {
1008
+ if (error && error.code === 'NODE_NOT_FOUND') {
1009
+ throw notFoundError(error.message);
1010
+ }
1011
+ throw error;
1012
+ }
1013
+ }
1014
+
1015
+ /**
1016
+ * Handles the `check` command: reports graph health, GC, and hook status.
1017
+ * @param {Object} params
1018
+ * @param {Object} params.options - Parsed CLI options
1019
+ * @returns {Promise<{payload: Object, exitCode: number}>} Health check payload
1020
+ */
1021
+ async function handleCheck({ options }) {
1022
+ const { graph, graphName, persistence } = await openGraph(options);
1023
+ const health = await getHealth(persistence);
1024
+ const gcMetrics = await getGcMetrics(graph);
1025
+ const status = await graph.status();
1026
+ const writerHeads = await collectWriterHeads(graph);
1027
+ const checkpoint = await loadCheckpointInfo(persistence, graphName);
1028
+ const coverage = await loadCoverageInfo(persistence, graphName, writerHeads);
1029
+ const hook = getHookStatusForCheck(options.repo);
1030
+
1031
+ return {
1032
+ payload: buildCheckPayload({
1033
+ repo: options.repo,
1034
+ graphName,
1035
+ health,
1036
+ checkpoint,
1037
+ writerHeads,
1038
+ coverage,
1039
+ gcMetrics,
1040
+ hook,
1041
+ status,
1042
+ }),
1043
+ exitCode: EXIT_CODES.OK,
1044
+ };
1045
+ }
1046
+
1047
+ async function getHealth(persistence) {
1048
+ const clock = ClockAdapter.node();
1049
+ const healthService = new HealthCheckService({ persistence, clock });
1050
+ return await healthService.getHealth();
1051
+ }
1052
+
1053
+ async function getGcMetrics(graph) {
1054
+ await graph.materialize();
1055
+ return graph.getGCMetrics();
1056
+ }
1057
+
1058
+ async function collectWriterHeads(graph) {
1059
+ const frontier = await graph.getFrontier();
1060
+ return [...frontier.entries()]
1061
+ .sort(([a], [b]) => a.localeCompare(b))
1062
+ .map(([writerId, sha]) => ({ writerId, sha }));
1063
+ }
1064
+
1065
+ async function loadCheckpointInfo(persistence, graphName) {
1066
+ const checkpointRef = buildCheckpointRef(graphName);
1067
+ const checkpointSha = await persistence.readRef(checkpointRef);
1068
+ const checkpointDate = await readCheckpointDate(persistence, checkpointSha);
1069
+ const checkpointAgeSeconds = computeAgeSeconds(checkpointDate);
1070
+
1071
+ return {
1072
+ ref: checkpointRef,
1073
+ sha: checkpointSha || null,
1074
+ date: checkpointDate,
1075
+ ageSeconds: checkpointAgeSeconds,
1076
+ };
1077
+ }
1078
+
1079
+ async function readCheckpointDate(persistence, checkpointSha) {
1080
+ if (!checkpointSha) {
1081
+ return null;
1082
+ }
1083
+ const info = await persistence.getNodeInfo(checkpointSha);
1084
+ return info.date || null;
1085
+ }
1086
+
1087
+ function computeAgeSeconds(checkpointDate) {
1088
+ if (!checkpointDate) {
1089
+ return null;
1090
+ }
1091
+ const parsed = Date.parse(checkpointDate);
1092
+ if (Number.isNaN(parsed)) {
1093
+ return null;
1094
+ }
1095
+ return Math.max(0, Math.floor((Date.now() - parsed) / 1000));
1096
+ }
1097
+
1098
+ async function loadCoverageInfo(persistence, graphName, writerHeads) {
1099
+ const coverageRef = buildCoverageRef(graphName);
1100
+ const coverageSha = await persistence.readRef(coverageRef);
1101
+ const missingWriters = coverageSha
1102
+ ? await findMissingWriters(persistence, writerHeads, coverageSha)
1103
+ : [];
1104
+
1105
+ return {
1106
+ ref: coverageRef,
1107
+ sha: coverageSha || null,
1108
+ missingWriters: missingWriters.sort(),
1109
+ };
1110
+ }
1111
+
1112
+ async function findMissingWriters(persistence, writerHeads, coverageSha) {
1113
+ const missing = [];
1114
+ for (const head of writerHeads) {
1115
+ const reachable = await persistence.isAncestor(head.sha, coverageSha);
1116
+ if (!reachable) {
1117
+ missing.push(head.writerId);
1118
+ }
1119
+ }
1120
+ return missing;
1121
+ }
1122
+
1123
+ function buildCheckPayload({
1124
+ repo,
1125
+ graphName,
1126
+ health,
1127
+ checkpoint,
1128
+ writerHeads,
1129
+ coverage,
1130
+ gcMetrics,
1131
+ hook,
1132
+ status,
1133
+ }) {
1134
+ return {
1135
+ repo,
1136
+ graph: graphName,
1137
+ health,
1138
+ checkpoint,
1139
+ writers: {
1140
+ count: writerHeads.length,
1141
+ heads: writerHeads,
1142
+ },
1143
+ coverage,
1144
+ gc: gcMetrics,
1145
+ hook: hook || null,
1146
+ status: status || null,
1147
+ };
1148
+ }
1149
+
1150
+ /**
1151
+ * Handles the `history` command: shows patch history for a writer.
1152
+ * @param {Object} params
1153
+ * @param {Object} params.options - Parsed CLI options
1154
+ * @param {string[]} params.args - Remaining positional arguments (history options)
1155
+ * @returns {Promise<{payload: Object, exitCode: number}>} History payload
1156
+ * @throws {CliError} If no patches are found for the writer
1157
+ */
1158
+ async function handleHistory({ options, args }) {
1159
+ const historyOptions = parseHistoryArgs(args);
1160
+ const { graph, graphName } = await openGraph(options);
1161
+ const writerId = options.writer;
1162
+ const patches = await graph.getWriterPatches(writerId);
1163
+ if (patches.length === 0) {
1164
+ throw notFoundError(`No patches found for writer: ${writerId}`);
1165
+ }
1166
+
1167
+ const entries = patches
1168
+ .filter(({ patch }) => !historyOptions.node || patchTouchesNode(patch, historyOptions.node))
1169
+ .map(({ patch, sha }) => ({
1170
+ sha,
1171
+ schema: patch.schema,
1172
+ lamport: patch.lamport,
1173
+ opCount: Array.isArray(patch.ops) ? patch.ops.length : 0,
1174
+ opSummary: Array.isArray(patch.ops) ? summarizeOps(patch.ops) : undefined,
1175
+ }));
1176
+
1177
+ const payload = {
1178
+ graph: graphName,
1179
+ writer: writerId,
1180
+ nodeFilter: historyOptions.node,
1181
+ entries,
1182
+ };
1183
+
1184
+ return { payload, exitCode: EXIT_CODES.OK };
1185
+ }
1186
+
1187
+ async function materializeOneGraph({ persistence, graphName, writerId }) {
1188
+ const graph = await WarpGraph.open({ persistence, graphName, writerId });
1189
+ await graph.materialize();
1190
+ const nodes = await graph.getNodes();
1191
+ const edges = await graph.getEdges();
1192
+ const checkpoint = await graph.createCheckpoint();
1193
+ const status = await graph.status();
1194
+
1195
+ // Build per-writer patch counts for the view renderer
1196
+ const writers = {};
1197
+ let totalPatchCount = 0;
1198
+ for (const wId of Object.keys(status.frontier)) {
1199
+ const patches = await graph.getWriterPatches(wId);
1200
+ writers[wId] = patches.length;
1201
+ totalPatchCount += patches.length;
1202
+ }
1203
+
1204
+ const properties = await graph.getPropertyCount();
1205
+
1206
+ return {
1207
+ graph: graphName,
1208
+ nodes: nodes.length,
1209
+ edges: edges.length,
1210
+ properties,
1211
+ checkpoint,
1212
+ writers,
1213
+ patchCount: totalPatchCount,
1214
+ };
1215
+ }
1216
+
1217
+ /**
1218
+ * Handles the `materialize` command: materializes and checkpoints all graphs.
1219
+ * @param {Object} params
1220
+ * @param {Object} params.options - Parsed CLI options
1221
+ * @returns {Promise<{payload: Object, exitCode: number}>} Materialize result payload
1222
+ * @throws {CliError} If the specified graph is not found
1223
+ */
1224
+ async function handleMaterialize({ options }) {
1225
+ const { persistence } = await createPersistence(options.repo);
1226
+ const graphNames = await listGraphNames(persistence);
1227
+
1228
+ if (graphNames.length === 0) {
1229
+ return {
1230
+ payload: { graphs: [] },
1231
+ exitCode: EXIT_CODES.OK,
1232
+ };
1233
+ }
1234
+
1235
+ const targets = options.graph
1236
+ ? [options.graph]
1237
+ : graphNames;
1238
+
1239
+ if (options.graph && !graphNames.includes(options.graph)) {
1240
+ throw notFoundError(`Graph not found: ${options.graph}`);
1241
+ }
1242
+
1243
+ const results = [];
1244
+ for (const name of targets) {
1245
+ try {
1246
+ const result = await materializeOneGraph({
1247
+ persistence,
1248
+ graphName: name,
1249
+ writerId: options.writer,
1250
+ });
1251
+ results.push(result);
1252
+ } catch (error) {
1253
+ results.push({
1254
+ graph: name,
1255
+ error: error instanceof Error ? error.message : String(error),
1256
+ });
1257
+ }
1258
+ }
1259
+
1260
+ const allFailed = results.every((r) => r.error);
1261
+ return {
1262
+ payload: { graphs: results },
1263
+ exitCode: allFailed ? EXIT_CODES.INTERNAL : EXIT_CODES.OK,
1264
+ };
1265
+ }
1266
+
1267
+ function renderMaterialize(payload) {
1268
+ if (payload.graphs.length === 0) {
1269
+ return 'No graphs found in repo.\n';
1270
+ }
1271
+
1272
+ const lines = [];
1273
+ for (const entry of payload.graphs) {
1274
+ if (entry.error) {
1275
+ lines.push(`${entry.graph}: error — ${entry.error}`);
1276
+ } else {
1277
+ lines.push(`${entry.graph}: ${entry.nodes} nodes, ${entry.edges} edges, checkpoint ${entry.checkpoint}`);
1278
+ }
1279
+ }
1280
+ return `${lines.join('\n')}\n`;
1281
+ }
1282
+
1283
+ function renderInstallHooks(payload) {
1284
+ if (payload.action === 'up-to-date') {
1285
+ return `Hook: already up to date (v${payload.version}) at ${payload.hookPath}\n`;
1286
+ }
1287
+ if (payload.action === 'skipped') {
1288
+ return 'Hook: installation skipped\n';
1289
+ }
1290
+ const lines = [`Hook: ${payload.action} (v${payload.version})`, `Path: ${payload.hookPath}`];
1291
+ if (payload.backupPath) {
1292
+ lines.push(`Backup: ${payload.backupPath}`);
1293
+ }
1294
+ return `${lines.join('\n')}\n`;
1295
+ }
1296
+
1297
+ function createHookInstaller() {
1298
+ const __filename = new URL(import.meta.url).pathname;
1299
+ const __dirname = path.dirname(__filename);
1300
+ const templateDir = path.resolve(__dirname, '..', 'hooks');
1301
+ const { version } = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf8'));
1302
+ return new HookInstaller({
1303
+ fs,
1304
+ execGitConfig: execGitConfigValue,
1305
+ version,
1306
+ templateDir,
1307
+ path,
1308
+ });
1309
+ }
1310
+
1311
+ function execGitConfigValue(repoPath, key) {
1312
+ try {
1313
+ if (key === '--git-dir') {
1314
+ return execFileSync('git', ['-C', repoPath, 'rev-parse', '--git-dir'], {
1315
+ encoding: 'utf8',
1316
+ }).trim();
1317
+ }
1318
+ return execFileSync('git', ['-C', repoPath, 'config', key], {
1319
+ encoding: 'utf8',
1320
+ }).trim();
1321
+ } catch {
1322
+ return null;
1323
+ }
1324
+ }
1325
+
1326
+ function isInteractive() {
1327
+ return Boolean(process.stderr.isTTY);
1328
+ }
1329
+
1330
+ function promptUser(question) {
1331
+ const rl = readline.createInterface({
1332
+ input: process.stdin,
1333
+ output: process.stderr,
1334
+ });
1335
+ return new Promise((resolve) => {
1336
+ rl.question(question, (answer) => {
1337
+ rl.close();
1338
+ resolve(answer.trim());
1339
+ });
1340
+ });
1341
+ }
1342
+
1343
+ function parseInstallHooksArgs(args) {
1344
+ const options = { force: false };
1345
+ for (const arg of args) {
1346
+ if (arg === '--force') {
1347
+ options.force = true;
1348
+ } else if (arg.startsWith('-')) {
1349
+ throw usageError(`Unknown install-hooks option: ${arg}`);
1350
+ }
1351
+ }
1352
+ return options;
1353
+ }
1354
+
1355
+ async function resolveStrategy(classification, hookOptions) {
1356
+ if (hookOptions.force) {
1357
+ return 'replace';
1358
+ }
1359
+
1360
+ if (classification.kind === 'none') {
1361
+ return 'install';
1362
+ }
1363
+
1364
+ if (classification.kind === 'ours') {
1365
+ return await promptForOursStrategy(classification);
1366
+ }
1367
+
1368
+ return await promptForForeignStrategy();
1369
+ }
1370
+
1371
+ async function promptForOursStrategy(classification) {
1372
+ const installer = createHookInstaller();
1373
+ if (classification.version === installer._version) {
1374
+ return 'up-to-date';
1375
+ }
1376
+
1377
+ if (!isInteractive()) {
1378
+ throw usageError('Existing hook found. Use --force or run interactively.');
1379
+ }
1380
+
1381
+ const answer = await promptUser(
1382
+ `Upgrade hook from v${classification.version} to v${installer._version}? [Y/n] `,
1383
+ );
1384
+ if (answer === '' || answer.toLowerCase() === 'y') {
1385
+ return 'upgrade';
1386
+ }
1387
+ return 'skip';
1388
+ }
1389
+
1390
+ async function promptForForeignStrategy() {
1391
+ if (!isInteractive()) {
1392
+ throw usageError('Existing hook found. Use --force or run interactively.');
1393
+ }
1394
+
1395
+ process.stderr.write('Existing post-merge hook found.\n');
1396
+ process.stderr.write(' 1) Append (keep existing hook, add warp section)\n');
1397
+ process.stderr.write(' 2) Replace (back up existing, install fresh)\n');
1398
+ process.stderr.write(' 3) Skip\n');
1399
+ const answer = await promptUser('Choose [1-3]: ');
1400
+
1401
+ if (answer === '1') {
1402
+ return 'append';
1403
+ }
1404
+ if (answer === '2') {
1405
+ return 'replace';
1406
+ }
1407
+ return 'skip';
1408
+ }
1409
+
1410
+ /**
1411
+ * Handles the `install-hooks` command: installs or upgrades the post-merge git hook.
1412
+ * @param {Object} params
1413
+ * @param {Object} params.options - Parsed CLI options
1414
+ * @param {string[]} params.args - Remaining positional arguments (install-hooks options)
1415
+ * @returns {Promise<{payload: Object, exitCode: number}>} Install result payload
1416
+ * @throws {CliError} If an existing hook is found and the session is not interactive
1417
+ */
1418
+ async function handleInstallHooks({ options, args }) {
1419
+ const hookOptions = parseInstallHooksArgs(args);
1420
+ const installer = createHookInstaller();
1421
+ const status = installer.getHookStatus(options.repo);
1422
+ const content = readHookContent(status.hookPath);
1423
+ const classification = classifyExistingHook(content);
1424
+ const strategy = await resolveStrategy(classification, hookOptions);
1425
+
1426
+ if (strategy === 'up-to-date') {
1427
+ return {
1428
+ payload: {
1429
+ action: 'up-to-date',
1430
+ hookPath: status.hookPath,
1431
+ version: installer._version,
1432
+ },
1433
+ exitCode: EXIT_CODES.OK,
1434
+ };
1435
+ }
1436
+
1437
+ if (strategy === 'skip') {
1438
+ return {
1439
+ payload: { action: 'skipped' },
1440
+ exitCode: EXIT_CODES.OK,
1441
+ };
1442
+ }
1443
+
1444
+ const result = installer.install(options.repo, { strategy });
1445
+ return {
1446
+ payload: result,
1447
+ exitCode: EXIT_CODES.OK,
1448
+ };
1449
+ }
1450
+
1451
+ function readHookContent(hookPath) {
1452
+ try {
1453
+ return fs.readFileSync(hookPath, 'utf8');
1454
+ } catch {
1455
+ return null;
1456
+ }
1457
+ }
1458
+
1459
+ function getHookStatusForCheck(repoPath) {
1460
+ try {
1461
+ const installer = createHookInstaller();
1462
+ return installer.getHookStatus(repoPath);
1463
+ } catch {
1464
+ return null;
1465
+ }
1466
+ }
1467
+
1468
+ async function handleView({ options, args }) {
1469
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1470
+ throw usageError('view command requires an interactive terminal (TTY)');
1471
+ }
1472
+
1473
+ const viewMode = (args[0] === '--list' || args[0] === 'list') ? 'list'
1474
+ : (args[0] === '--log' || args[0] === 'log') ? 'log'
1475
+ : 'list';
1476
+
1477
+ try {
1478
+ const { startTui } = await import('@git-stunts/git-warp-tui');
1479
+ await startTui({
1480
+ repo: options.repo || '.',
1481
+ graph: options.graph || 'default',
1482
+ mode: viewMode,
1483
+ });
1484
+ } catch (err) {
1485
+ if (err.code === 'ERR_MODULE_NOT_FOUND' || (err.message && err.message.includes('Cannot find module'))) {
1486
+ throw usageError(
1487
+ 'Interactive TUI requires @git-stunts/git-warp-tui.\n' +
1488
+ ' Install with: npm install -g @git-stunts/git-warp-tui',
1489
+ );
1490
+ }
1491
+ throw err;
1492
+ }
1493
+ return { payload: undefined, exitCode: 0 };
1494
+ }
1495
+
1496
+ const COMMANDS = new Map([
1497
+ ['info', handleInfo],
1498
+ ['query', handleQuery],
1499
+ ['path', handlePath],
1500
+ ['history', handleHistory],
1501
+ ['check', handleCheck],
1502
+ ['materialize', handleMaterialize],
1503
+ ['view', handleView],
1504
+ ['install-hooks', handleInstallHooks],
1505
+ ]);
1506
+
1507
+ /**
1508
+ * CLI entry point. Parses arguments, dispatches to the appropriate command handler,
1509
+ * and emits the result to stdout (JSON or human-readable).
1510
+ * @returns {Promise<void>}
1511
+ */
1512
+ async function main() {
1513
+ const { options, positionals } = parseArgs(process.argv.slice(2));
1514
+
1515
+ if (options.help) {
1516
+ process.stdout.write(HELP_TEXT);
1517
+ process.exitCode = EXIT_CODES.OK;
1518
+ return;
1519
+ }
1520
+
1521
+ if (options.json && options.view) {
1522
+ throw usageError('--json and --view are mutually exclusive');
1523
+ }
1524
+
1525
+ const command = positionals[0];
1526
+ if (!command) {
1527
+ process.stderr.write(HELP_TEXT);
1528
+ process.exitCode = EXIT_CODES.USAGE;
1529
+ return;
1530
+ }
1531
+
1532
+ const handler = COMMANDS.get(command);
1533
+ if (!handler) {
1534
+ throw usageError(`Unknown command: ${command}`);
1535
+ }
1536
+
1537
+ const VIEW_SUPPORTED_COMMANDS = ['info', 'check', 'history', 'path', 'materialize', 'query'];
1538
+ if (options.view && !VIEW_SUPPORTED_COMMANDS.includes(command)) {
1539
+ throw usageError(`--view is not supported for '${command}'. Supported commands: ${VIEW_SUPPORTED_COMMANDS.join(', ')}`);
1540
+ }
1541
+
1542
+ const result = await handler({
1543
+ command,
1544
+ args: positionals.slice(1),
1545
+ options,
1546
+ });
1547
+
1548
+ const normalized = result && typeof result === 'object' && 'payload' in result
1549
+ ? result
1550
+ : { payload: result, exitCode: EXIT_CODES.OK };
1551
+
1552
+ if (normalized.payload !== undefined) {
1553
+ emit(normalized.payload, { json: options.json, command, view: options.view });
1554
+ }
1555
+ process.exitCode = normalized.exitCode ?? EXIT_CODES.OK;
1556
+ }
1557
+
1558
+ main().catch((error) => {
1559
+ const exitCode = error instanceof CliError ? error.exitCode : EXIT_CODES.INTERNAL;
1560
+ const code = error instanceof CliError ? error.code : 'E_INTERNAL';
1561
+ const message = error instanceof Error ? error.message : 'Unknown error';
1562
+ const payload = { error: { code, message } };
1563
+
1564
+ if (error && error.cause) {
1565
+ payload.error.cause = error.cause instanceof Error ? error.cause.message : error.cause;
1566
+ }
1567
+
1568
+ if (process.argv.includes('--json')) {
1569
+ process.stdout.write(`${stableStringify(payload)}\n`);
1570
+ } else {
1571
+ process.stderr.write(renderError(payload));
1572
+ }
1573
+ process.exitCode = exitCode;
1574
+ });