@git-stunts/git-warp 10.7.0 → 11.2.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.
- package/README.md +53 -32
- package/SECURITY.md +64 -0
- package/bin/cli/commands/check.js +168 -0
- package/bin/cli/commands/doctor/checks.js +422 -0
- package/bin/cli/commands/doctor/codes.js +46 -0
- package/bin/cli/commands/doctor/index.js +239 -0
- package/bin/cli/commands/doctor/types.js +89 -0
- package/bin/cli/commands/history.js +73 -0
- package/bin/cli/commands/info.js +139 -0
- package/bin/cli/commands/install-hooks.js +128 -0
- package/bin/cli/commands/materialize.js +99 -0
- package/bin/cli/commands/path.js +88 -0
- package/bin/cli/commands/query.js +194 -0
- package/bin/cli/commands/registry.js +28 -0
- package/bin/cli/commands/seek.js +592 -0
- package/bin/cli/commands/trust.js +154 -0
- package/bin/cli/commands/verify-audit.js +113 -0
- package/bin/cli/commands/view.js +45 -0
- package/bin/cli/infrastructure.js +336 -0
- package/bin/cli/schemas.js +177 -0
- package/bin/cli/shared.js +244 -0
- package/bin/cli/types.js +85 -0
- package/bin/presenters/index.js +214 -0
- package/bin/presenters/json.js +66 -0
- package/bin/presenters/text.js +543 -0
- package/bin/warp-graph.js +19 -2824
- package/index.d.ts +32 -2
- package/index.js +2 -0
- package/package.json +9 -7
- package/src/domain/WarpGraph.js +106 -3252
- package/src/domain/errors/QueryError.js +2 -2
- package/src/domain/errors/TrustError.js +29 -0
- package/src/domain/errors/index.js +1 -0
- package/src/domain/services/AuditMessageCodec.js +137 -0
- package/src/domain/services/AuditReceiptService.js +471 -0
- package/src/domain/services/AuditVerifierService.js +693 -0
- package/src/domain/services/HttpSyncServer.js +36 -22
- package/src/domain/services/MessageCodecInternal.js +3 -0
- package/src/domain/services/MessageSchemaDetector.js +2 -2
- package/src/domain/services/SyncAuthService.js +69 -3
- package/src/domain/services/WarpMessageCodec.js +4 -1
- package/src/domain/trust/TrustCanonical.js +42 -0
- package/src/domain/trust/TrustCrypto.js +111 -0
- package/src/domain/trust/TrustEvaluator.js +180 -0
- package/src/domain/trust/TrustRecordService.js +274 -0
- package/src/domain/trust/TrustStateBuilder.js +209 -0
- package/src/domain/trust/canonical.js +68 -0
- package/src/domain/trust/reasonCodes.js +64 -0
- package/src/domain/trust/schemas.js +160 -0
- package/src/domain/trust/verdict.js +42 -0
- package/src/domain/types/git-cas.d.ts +20 -0
- package/src/domain/utils/RefLayout.js +59 -0
- package/src/domain/warp/PatchSession.js +18 -0
- package/src/domain/warp/Writer.js +18 -3
- package/src/domain/warp/_internal.js +26 -0
- package/src/domain/warp/_wire.js +58 -0
- package/src/domain/warp/_wiredMethods.d.ts +100 -0
- package/src/domain/warp/checkpoint.methods.js +397 -0
- package/src/domain/warp/fork.methods.js +323 -0
- package/src/domain/warp/materialize.methods.js +188 -0
- package/src/domain/warp/materializeAdvanced.methods.js +339 -0
- package/src/domain/warp/patch.methods.js +529 -0
- package/src/domain/warp/provenance.methods.js +284 -0
- package/src/domain/warp/query.methods.js +279 -0
- package/src/domain/warp/subscribe.methods.js +272 -0
- package/src/domain/warp/sync.methods.js +549 -0
- package/src/infrastructure/adapters/GitGraphAdapter.js +67 -1
- package/src/infrastructure/adapters/InMemoryGraphAdapter.js +36 -0
- package/src/ports/CommitPort.js +10 -0
- package/src/ports/RefPort.js +17 -0
- package/src/hooks/post-merge.sh +0 -60
package/bin/warp-graph.js
CHANGED
|
@@ -1,2824 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import crypto from 'node:crypto';
|
|
4
|
-
import fs from 'node:fs';
|
|
5
|
-
import path from 'node:path';
|
|
6
3
|
import process from 'node:process';
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
import
|
|
11
|
-
import
|
|
12
|
-
import GitGraphAdapter from '../src/infrastructure/adapters/GitGraphAdapter.js';
|
|
13
|
-
import HealthCheckService from '../src/domain/services/HealthCheckService.js';
|
|
14
|
-
import ClockAdapter from '../src/infrastructure/adapters/ClockAdapter.js';
|
|
15
|
-
import NodeCryptoAdapter from '../src/infrastructure/adapters/NodeCryptoAdapter.js';
|
|
16
|
-
import {
|
|
17
|
-
REF_PREFIX,
|
|
18
|
-
buildCheckpointRef,
|
|
19
|
-
buildCoverageRef,
|
|
20
|
-
buildWritersPrefix,
|
|
21
|
-
parseWriterIdFromRef,
|
|
22
|
-
buildCursorActiveRef,
|
|
23
|
-
buildCursorSavedRef,
|
|
24
|
-
buildCursorSavedPrefix,
|
|
25
|
-
} from '../src/domain/utils/RefLayout.js';
|
|
26
|
-
import CasSeekCacheAdapter from '../src/infrastructure/adapters/CasSeekCacheAdapter.js';
|
|
27
|
-
import { HookInstaller, classifyExistingHook } from '../src/domain/services/HookInstaller.js';
|
|
28
|
-
import { renderInfoView } from '../src/visualization/renderers/ascii/info.js';
|
|
29
|
-
import { renderCheckView } from '../src/visualization/renderers/ascii/check.js';
|
|
30
|
-
import { renderHistoryView, summarizeOps } from '../src/visualization/renderers/ascii/history.js';
|
|
31
|
-
import { renderPathView } from '../src/visualization/renderers/ascii/path.js';
|
|
32
|
-
import { renderMaterializeView } from '../src/visualization/renderers/ascii/materialize.js';
|
|
33
|
-
import { parseCursorBlob } from '../src/domain/utils/parseCursorBlob.js';
|
|
34
|
-
import { diffStates } from '../src/domain/services/StateDiff.js';
|
|
35
|
-
import { renderSeekView, formatStructuralDiff } from '../src/visualization/renderers/ascii/seek.js';
|
|
36
|
-
import { renderGraphView } from '../src/visualization/renderers/ascii/graph.js';
|
|
37
|
-
import { renderSvg } from '../src/visualization/renderers/svg/index.js';
|
|
38
|
-
import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../src/visualization/layouts/index.js';
|
|
4
|
+
import { EXIT_CODES, HELP_TEXT, CliError, parseArgs, usageError } from './cli/infrastructure.js';
|
|
5
|
+
import { present } from './presenters/index.js';
|
|
6
|
+
import { stableStringify, compactStringify } from './presenters/json.js';
|
|
7
|
+
import { renderError } from './presenters/text.js';
|
|
8
|
+
import { COMMANDS } from './cli/commands/registry.js';
|
|
39
9
|
|
|
40
|
-
|
|
41
|
-
* @typedef {Object} Persistence
|
|
42
|
-
* @property {(prefix: string) => Promise<string[]>} listRefs
|
|
43
|
-
* @property {(ref: string) => Promise<string|null>} readRef
|
|
44
|
-
* @property {(ref: string, oid: string) => Promise<void>} updateRef
|
|
45
|
-
* @property {(ref: string) => Promise<void>} deleteRef
|
|
46
|
-
* @property {(oid: string) => Promise<Buffer>} readBlob
|
|
47
|
-
* @property {(buf: Buffer) => Promise<string>} writeBlob
|
|
48
|
-
* @property {(sha: string) => Promise<{date?: string|null}>} getNodeInfo
|
|
49
|
-
* @property {(sha: string, coverageSha: string) => Promise<boolean>} isAncestor
|
|
50
|
-
* @property {() => Promise<{ok: boolean}>} ping
|
|
51
|
-
* @property {*} plumbing
|
|
52
|
-
*/
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* @typedef {Object} WarpGraphInstance
|
|
56
|
-
* @property {(opts?: {ceiling?: number}) => Promise<void>} materialize
|
|
57
|
-
* @property {() => Promise<Array<{id: string}>>} getNodes
|
|
58
|
-
* @property {() => Promise<Array<{from: string, to: string, label?: string}>>} getEdges
|
|
59
|
-
* @property {() => Promise<string|null>} createCheckpoint
|
|
60
|
-
* @property {() => *} query
|
|
61
|
-
* @property {{ shortestPath: Function }} traverse
|
|
62
|
-
* @property {(writerId: string) => Promise<Array<{patch: any, sha: string}>>} getWriterPatches
|
|
63
|
-
* @property {() => Promise<{frontier: Record<string, any>}>} status
|
|
64
|
-
* @property {() => Promise<Map<string, any>>} getFrontier
|
|
65
|
-
* @property {() => {totalTombstones: number, tombstoneRatio: number}} getGCMetrics
|
|
66
|
-
* @property {() => Promise<number>} getPropertyCount
|
|
67
|
-
* @property {() => Promise<import('../src/domain/services/JoinReducer.js').WarpStateV5 | null>} getStateSnapshot
|
|
68
|
-
* @property {() => Promise<{ticks: number[], maxTick: number, perWriter: Map<string, WriterTickInfo>}>} discoverTicks
|
|
69
|
-
* @property {(sha: string) => Promise<{ops?: any[]}>} loadPatchBySha
|
|
70
|
-
* @property {(cache: any) => void} setSeekCache
|
|
71
|
-
* @property {*} seekCache
|
|
72
|
-
* @property {number} [_seekCeiling]
|
|
73
|
-
* @property {boolean} [_provenanceDegraded]
|
|
74
|
-
*/
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* @typedef {Object} WriterTickInfo
|
|
78
|
-
* @property {number[]} ticks
|
|
79
|
-
* @property {string|null} tipSha
|
|
80
|
-
* @property {Record<number, string>} [tickShas]
|
|
81
|
-
*/
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* @typedef {Object} CursorBlob
|
|
85
|
-
* @property {number} tick
|
|
86
|
-
* @property {string} [mode]
|
|
87
|
-
* @property {number} [nodes]
|
|
88
|
-
* @property {number} [edges]
|
|
89
|
-
* @property {string} [frontierHash]
|
|
90
|
-
*/
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* @typedef {Object} CliOptions
|
|
94
|
-
* @property {string} repo
|
|
95
|
-
* @property {boolean} json
|
|
96
|
-
* @property {string|null} view
|
|
97
|
-
* @property {string|null} graph
|
|
98
|
-
* @property {string} writer
|
|
99
|
-
* @property {boolean} help
|
|
100
|
-
*/
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* @typedef {Object} GraphInfoResult
|
|
104
|
-
* @property {string} name
|
|
105
|
-
* @property {{count: number, ids?: string[]}} writers
|
|
106
|
-
* @property {{ref: string, sha: string|null, date?: string|null}} [checkpoint]
|
|
107
|
-
* @property {{ref: string, sha: string|null}} [coverage]
|
|
108
|
-
* @property {Record<string, number>} [writerPatches]
|
|
109
|
-
* @property {{active: boolean, tick?: number, mode?: string}} [cursor]
|
|
110
|
-
*/
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* @typedef {Object} SeekSpec
|
|
114
|
-
* @property {string} action
|
|
115
|
-
* @property {string|null} tickValue
|
|
116
|
-
* @property {string|null} name
|
|
117
|
-
* @property {boolean} noPersistentCache
|
|
118
|
-
* @property {boolean} diff
|
|
119
|
-
* @property {number} diffLimit
|
|
120
|
-
*/
|
|
121
|
-
|
|
122
|
-
const EXIT_CODES = {
|
|
123
|
-
OK: 0,
|
|
124
|
-
USAGE: 1,
|
|
125
|
-
NOT_FOUND: 2,
|
|
126
|
-
INTERNAL: 3,
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
const HELP_TEXT = `warp-graph <command> [options]
|
|
130
|
-
(or: git warp <command> [options])
|
|
131
|
-
|
|
132
|
-
Commands:
|
|
133
|
-
info Summarize graphs in the repo
|
|
134
|
-
query Run a logical graph query
|
|
135
|
-
path Find a logical path between two nodes
|
|
136
|
-
history Show writer history
|
|
137
|
-
check Report graph health/GC status
|
|
138
|
-
materialize Materialize and checkpoint all graphs
|
|
139
|
-
seek Time-travel: step through graph history by Lamport tick
|
|
140
|
-
view Interactive TUI graph browser (requires @git-stunts/git-warp-tui)
|
|
141
|
-
install-hooks Install post-merge git hook
|
|
142
|
-
|
|
143
|
-
Options:
|
|
144
|
-
--repo <path> Path to git repo (default: cwd)
|
|
145
|
-
--json Emit JSON output
|
|
146
|
-
--view [mode] Visual output (ascii, browser, svg:FILE, html:FILE)
|
|
147
|
-
--graph <name> Graph name (required if repo has multiple graphs)
|
|
148
|
-
--writer <id> Writer id (default: cli)
|
|
149
|
-
-h, --help Show this help
|
|
150
|
-
|
|
151
|
-
Install-hooks options:
|
|
152
|
-
--force Replace existing hook (backs up original)
|
|
153
|
-
|
|
154
|
-
Query options:
|
|
155
|
-
--match <glob> Match node ids (default: *)
|
|
156
|
-
--outgoing [label] Traverse outgoing edge (repeatable)
|
|
157
|
-
--incoming [label] Traverse incoming edge (repeatable)
|
|
158
|
-
--where-prop k=v Filter nodes by prop equality (repeatable)
|
|
159
|
-
--select <fields> Fields to select (id, props)
|
|
160
|
-
|
|
161
|
-
Path options:
|
|
162
|
-
--from <id> Start node id
|
|
163
|
-
--to <id> End node id
|
|
164
|
-
--dir <out|in|both> Traversal direction (default: out)
|
|
165
|
-
--label <label> Filter by edge label (repeatable, comma-separated)
|
|
166
|
-
--max-depth <n> Maximum depth
|
|
167
|
-
|
|
168
|
-
History options:
|
|
169
|
-
--node <id> Filter patches touching node id
|
|
170
|
-
|
|
171
|
-
Seek options:
|
|
172
|
-
--tick <N|+N|-N> Jump to tick N, or step forward/backward
|
|
173
|
-
--latest Clear cursor, return to present
|
|
174
|
-
--save <name> Save current position as named cursor
|
|
175
|
-
--load <name> Restore a saved cursor
|
|
176
|
-
--list List all saved cursors
|
|
177
|
-
--drop <name> Delete a saved cursor
|
|
178
|
-
--diff Show structural diff (added/removed nodes, edges, props)
|
|
179
|
-
--diff-limit <N> Max diff entries (default 2000)
|
|
180
|
-
`;
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Structured CLI error with exit code and error code.
|
|
184
|
-
*/
|
|
185
|
-
class CliError extends Error {
|
|
186
|
-
/**
|
|
187
|
-
* @param {string} message - Human-readable error message
|
|
188
|
-
* @param {Object} [options]
|
|
189
|
-
* @param {string} [options.code='E_CLI'] - Machine-readable error code
|
|
190
|
-
* @param {number} [options.exitCode=3] - Process exit code
|
|
191
|
-
* @param {Error} [options.cause] - Underlying cause
|
|
192
|
-
*/
|
|
193
|
-
constructor(message, { code = 'E_CLI', exitCode = EXIT_CODES.INTERNAL, cause } = {}) {
|
|
194
|
-
super(message);
|
|
195
|
-
this.code = code;
|
|
196
|
-
this.exitCode = exitCode;
|
|
197
|
-
this.cause = cause;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/** @param {string} message */
|
|
202
|
-
function usageError(message) {
|
|
203
|
-
return new CliError(message, { code: 'E_USAGE', exitCode: EXIT_CODES.USAGE });
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/** @param {string} message */
|
|
207
|
-
function notFoundError(message) {
|
|
208
|
-
return new CliError(message, { code: 'E_NOT_FOUND', exitCode: EXIT_CODES.NOT_FOUND });
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/** @param {*} value */
|
|
212
|
-
function stableStringify(value) {
|
|
213
|
-
/** @param {*} input @returns {*} */
|
|
214
|
-
const normalize = (input) => {
|
|
215
|
-
if (Array.isArray(input)) {
|
|
216
|
-
return input.map(normalize);
|
|
217
|
-
}
|
|
218
|
-
if (input && typeof input === 'object') {
|
|
219
|
-
/** @type {Record<string, *>} */
|
|
220
|
-
const sorted = {};
|
|
221
|
-
for (const key of Object.keys(input).sort()) {
|
|
222
|
-
sorted[key] = normalize(input[key]);
|
|
223
|
-
}
|
|
224
|
-
return sorted;
|
|
225
|
-
}
|
|
226
|
-
return input;
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
return JSON.stringify(normalize(value), null, 2);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/** @param {string[]} argv */
|
|
233
|
-
function parseArgs(argv) {
|
|
234
|
-
const options = createDefaultOptions();
|
|
235
|
-
/** @type {string[]} */
|
|
236
|
-
const positionals = [];
|
|
237
|
-
const optionDefs = [
|
|
238
|
-
{ flag: '--repo', shortFlag: '-r', key: 'repo' },
|
|
239
|
-
{ flag: '--graph', key: 'graph' },
|
|
240
|
-
{ flag: '--writer', key: 'writer' },
|
|
241
|
-
];
|
|
242
|
-
|
|
243
|
-
for (let i = 0; i < argv.length; i += 1) {
|
|
244
|
-
const result = consumeBaseArg({ argv, index: i, options, optionDefs, positionals });
|
|
245
|
-
if (result.done) {
|
|
246
|
-
break;
|
|
247
|
-
}
|
|
248
|
-
i += result.consumed;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
options.repo = path.resolve(options.repo);
|
|
252
|
-
return { options, positionals };
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function createDefaultOptions() {
|
|
256
|
-
return {
|
|
257
|
-
repo: process.cwd(),
|
|
258
|
-
json: false,
|
|
259
|
-
view: null,
|
|
260
|
-
graph: null,
|
|
261
|
-
writer: 'cli',
|
|
262
|
-
help: false,
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* @param {Object} params
|
|
268
|
-
* @param {string[]} params.argv
|
|
269
|
-
* @param {number} params.index
|
|
270
|
-
* @param {Record<string, *>} params.options
|
|
271
|
-
* @param {Array<{flag: string, shortFlag?: string, key: string}>} params.optionDefs
|
|
272
|
-
* @param {string[]} params.positionals
|
|
273
|
-
*/
|
|
274
|
-
function consumeBaseArg({ argv, index, options, optionDefs, positionals }) {
|
|
275
|
-
const arg = argv[index];
|
|
276
|
-
|
|
277
|
-
if (arg === '--') {
|
|
278
|
-
positionals.push(...argv.slice(index + 1));
|
|
279
|
-
return { consumed: argv.length - index - 1, done: true };
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
if (arg === '--json') {
|
|
283
|
-
options.json = true;
|
|
284
|
-
return { consumed: 0 };
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
if (arg === '--view') {
|
|
288
|
-
// Valid view modes: ascii, browser, svg:FILE, html:FILE
|
|
289
|
-
// Don't consume known commands as modes
|
|
290
|
-
const KNOWN_COMMANDS = ['info', 'query', 'path', 'history', 'check', 'materialize', 'seek', 'install-hooks'];
|
|
291
|
-
const nextArg = argv[index + 1];
|
|
292
|
-
const isViewMode = nextArg &&
|
|
293
|
-
!nextArg.startsWith('-') &&
|
|
294
|
-
!KNOWN_COMMANDS.includes(nextArg);
|
|
295
|
-
if (isViewMode) {
|
|
296
|
-
// Validate the view mode value
|
|
297
|
-
const validModes = ['ascii', 'browser'];
|
|
298
|
-
const validPrefixes = ['svg:', 'html:'];
|
|
299
|
-
const isValid = validModes.includes(nextArg) ||
|
|
300
|
-
validPrefixes.some((prefix) => nextArg.startsWith(prefix));
|
|
301
|
-
if (!isValid) {
|
|
302
|
-
throw usageError(`Invalid view mode: ${nextArg}. Valid modes: ascii, browser, svg:FILE, html:FILE`);
|
|
303
|
-
}
|
|
304
|
-
options.view = nextArg;
|
|
305
|
-
return { consumed: 1 };
|
|
306
|
-
}
|
|
307
|
-
options.view = 'ascii'; // default mode
|
|
308
|
-
return { consumed: 0 };
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
if (arg === '-h' || arg === '--help') {
|
|
312
|
-
options.help = true;
|
|
313
|
-
return { consumed: 0 };
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
const matched = matchOptionDef(arg, optionDefs);
|
|
317
|
-
if (matched) {
|
|
318
|
-
const result = readOptionValue({
|
|
319
|
-
args: argv,
|
|
320
|
-
index,
|
|
321
|
-
flag: matched.flag,
|
|
322
|
-
shortFlag: matched.shortFlag,
|
|
323
|
-
allowEmpty: false,
|
|
324
|
-
});
|
|
325
|
-
if (result) {
|
|
326
|
-
options[matched.key] = result.value;
|
|
327
|
-
return { consumed: result.consumed };
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
if (arg.startsWith('-')) {
|
|
332
|
-
throw usageError(`Unknown option: ${arg}`);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
positionals.push(arg, ...argv.slice(index + 1));
|
|
336
|
-
return { consumed: argv.length - index - 1, done: true };
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* @param {string} arg
|
|
341
|
-
* @param {Array<{flag: string, shortFlag?: string, key: string}>} optionDefs
|
|
342
|
-
*/
|
|
343
|
-
function matchOptionDef(arg, optionDefs) {
|
|
344
|
-
return optionDefs.find((def) =>
|
|
345
|
-
arg === def.flag ||
|
|
346
|
-
arg === def.shortFlag ||
|
|
347
|
-
arg.startsWith(`${def.flag}=`)
|
|
348
|
-
);
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/** @param {string} repoPath @returns {Promise<{persistence: Persistence}>} */
|
|
352
|
-
async function createPersistence(repoPath) {
|
|
353
|
-
const runner = ShellRunnerFactory.create();
|
|
354
|
-
const plumbing = new GitPlumbing({ cwd: repoPath, runner });
|
|
355
|
-
const persistence = new GitGraphAdapter({ plumbing });
|
|
356
|
-
const ping = await persistence.ping();
|
|
357
|
-
if (!ping.ok) {
|
|
358
|
-
throw usageError(`Repository not accessible: ${repoPath}`);
|
|
359
|
-
}
|
|
360
|
-
return { persistence };
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/** @param {Persistence} persistence @returns {Promise<string[]>} */
|
|
364
|
-
async function listGraphNames(persistence) {
|
|
365
|
-
if (typeof persistence.listRefs !== 'function') {
|
|
366
|
-
return [];
|
|
367
|
-
}
|
|
368
|
-
const refs = await persistence.listRefs(REF_PREFIX);
|
|
369
|
-
const prefix = `${REF_PREFIX}/`;
|
|
370
|
-
const names = new Set();
|
|
371
|
-
|
|
372
|
-
for (const ref of refs) {
|
|
373
|
-
if (!ref.startsWith(prefix)) {
|
|
374
|
-
continue;
|
|
375
|
-
}
|
|
376
|
-
const rest = ref.slice(prefix.length);
|
|
377
|
-
const [graphName] = rest.split('/');
|
|
378
|
-
if (graphName) {
|
|
379
|
-
names.add(graphName);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
return [...names].sort();
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* @param {Persistence} persistence
|
|
388
|
-
* @param {string|null} explicitGraph
|
|
389
|
-
* @returns {Promise<string>}
|
|
390
|
-
*/
|
|
391
|
-
async function resolveGraphName(persistence, explicitGraph) {
|
|
392
|
-
if (explicitGraph) {
|
|
393
|
-
return explicitGraph;
|
|
394
|
-
}
|
|
395
|
-
const graphNames = await listGraphNames(persistence);
|
|
396
|
-
if (graphNames.length === 1) {
|
|
397
|
-
return graphNames[0];
|
|
398
|
-
}
|
|
399
|
-
if (graphNames.length === 0) {
|
|
400
|
-
throw notFoundError('No graphs found in repo; specify --graph');
|
|
401
|
-
}
|
|
402
|
-
throw usageError('Multiple graphs found; specify --graph');
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
/**
|
|
406
|
-
* Collects metadata about a single graph (writer count, refs, patches, checkpoint).
|
|
407
|
-
* @param {Persistence} persistence - GraphPersistencePort adapter
|
|
408
|
-
* @param {string} graphName - Name of the graph to inspect
|
|
409
|
-
* @param {Object} [options]
|
|
410
|
-
* @param {boolean} [options.includeWriterIds=false] - Include writer ID list
|
|
411
|
-
* @param {boolean} [options.includeRefs=false] - Include checkpoint/coverage refs
|
|
412
|
-
* @param {boolean} [options.includeWriterPatches=false] - Include per-writer patch counts
|
|
413
|
-
* @param {boolean} [options.includeCheckpointDate=false] - Include checkpoint date
|
|
414
|
-
* @returns {Promise<GraphInfoResult>} Graph info object
|
|
415
|
-
*/
|
|
416
|
-
async function getGraphInfo(persistence, graphName, {
|
|
417
|
-
includeWriterIds = false,
|
|
418
|
-
includeRefs = false,
|
|
419
|
-
includeWriterPatches = false,
|
|
420
|
-
includeCheckpointDate = false,
|
|
421
|
-
} = {}) {
|
|
422
|
-
const writersPrefix = buildWritersPrefix(graphName);
|
|
423
|
-
const writerRefs = typeof persistence.listRefs === 'function'
|
|
424
|
-
? await persistence.listRefs(writersPrefix)
|
|
425
|
-
: [];
|
|
426
|
-
const writerIds = /** @type {string[]} */ (writerRefs
|
|
427
|
-
.map((ref) => parseWriterIdFromRef(ref))
|
|
428
|
-
.filter(Boolean)
|
|
429
|
-
.sort());
|
|
430
|
-
|
|
431
|
-
/** @type {GraphInfoResult} */
|
|
432
|
-
const info = {
|
|
433
|
-
name: graphName,
|
|
434
|
-
writers: {
|
|
435
|
-
count: writerIds.length,
|
|
436
|
-
},
|
|
437
|
-
};
|
|
438
|
-
|
|
439
|
-
if (includeWriterIds) {
|
|
440
|
-
info.writers.ids = writerIds;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
if (includeRefs || includeCheckpointDate) {
|
|
444
|
-
const checkpointRef = buildCheckpointRef(graphName);
|
|
445
|
-
const checkpointSha = await persistence.readRef(checkpointRef);
|
|
446
|
-
|
|
447
|
-
/** @type {{ref: string, sha: string|null, date?: string|null}} */
|
|
448
|
-
const checkpoint = { ref: checkpointRef, sha: checkpointSha || null };
|
|
449
|
-
|
|
450
|
-
if (includeCheckpointDate && checkpointSha) {
|
|
451
|
-
const checkpointDate = await readCheckpointDate(persistence, checkpointSha);
|
|
452
|
-
checkpoint.date = checkpointDate;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
info.checkpoint = checkpoint;
|
|
456
|
-
|
|
457
|
-
if (includeRefs) {
|
|
458
|
-
const coverageRef = buildCoverageRef(graphName);
|
|
459
|
-
const coverageSha = await persistence.readRef(coverageRef);
|
|
460
|
-
info.coverage = { ref: coverageRef, sha: coverageSha || null };
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
if (includeWriterPatches && writerIds.length > 0) {
|
|
465
|
-
const graph = await WarpGraph.open({
|
|
466
|
-
persistence,
|
|
467
|
-
graphName,
|
|
468
|
-
writerId: 'cli',
|
|
469
|
-
crypto: new NodeCryptoAdapter(),
|
|
470
|
-
});
|
|
471
|
-
/** @type {Record<string, number>} */
|
|
472
|
-
const writerPatches = {};
|
|
473
|
-
for (const writerId of writerIds) {
|
|
474
|
-
const patches = await graph.getWriterPatches(writerId);
|
|
475
|
-
writerPatches[/** @type {string} */ (writerId)] = patches.length;
|
|
476
|
-
}
|
|
477
|
-
info.writerPatches = writerPatches;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
return info;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
/**
|
|
484
|
-
* Opens a WarpGraph for the given CLI options.
|
|
485
|
-
* @param {CliOptions} options - Parsed CLI options
|
|
486
|
-
* @returns {Promise<{graph: WarpGraphInstance, graphName: string, persistence: Persistence}>}
|
|
487
|
-
* @throws {CliError} If the specified graph is not found
|
|
488
|
-
*/
|
|
489
|
-
async function openGraph(options) {
|
|
490
|
-
const { persistence } = await createPersistence(options.repo);
|
|
491
|
-
const graphName = await resolveGraphName(persistence, options.graph);
|
|
492
|
-
if (options.graph) {
|
|
493
|
-
const graphNames = await listGraphNames(persistence);
|
|
494
|
-
if (!graphNames.includes(options.graph)) {
|
|
495
|
-
throw notFoundError(`Graph not found: ${options.graph}`);
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
const graph = /** @type {WarpGraphInstance} */ (/** @type {*} */ (await WarpGraph.open({ // TODO(ts-cleanup): narrow port type
|
|
499
|
-
persistence,
|
|
500
|
-
graphName,
|
|
501
|
-
writerId: options.writer,
|
|
502
|
-
crypto: new NodeCryptoAdapter(),
|
|
503
|
-
})));
|
|
504
|
-
return { graph, graphName, persistence };
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/** @param {string[]} args */
|
|
508
|
-
function parseQueryArgs(args) {
|
|
509
|
-
const spec = {
|
|
510
|
-
match: null,
|
|
511
|
-
select: null,
|
|
512
|
-
steps: [],
|
|
513
|
-
};
|
|
514
|
-
|
|
515
|
-
for (let i = 0; i < args.length; i += 1) {
|
|
516
|
-
const result = consumeQueryArg(args, i, spec);
|
|
517
|
-
if (!result) {
|
|
518
|
-
throw usageError(`Unknown query option: ${args[i]}`);
|
|
519
|
-
}
|
|
520
|
-
i += result.consumed;
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
return spec;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
/**
|
|
527
|
-
* @param {string[]} args
|
|
528
|
-
* @param {number} index
|
|
529
|
-
* @param {{match: string|null, select: string[]|null, steps: Array<{type: string, label?: string, key?: string, value?: string}>}} spec
|
|
530
|
-
*/
|
|
531
|
-
function consumeQueryArg(args, index, spec) {
|
|
532
|
-
const stepResult = readTraversalStep(args, index);
|
|
533
|
-
if (stepResult) {
|
|
534
|
-
spec.steps.push(stepResult.step);
|
|
535
|
-
return stepResult;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
const matchResult = readOptionValue({
|
|
539
|
-
args,
|
|
540
|
-
index,
|
|
541
|
-
flag: '--match',
|
|
542
|
-
allowEmpty: true,
|
|
543
|
-
});
|
|
544
|
-
if (matchResult) {
|
|
545
|
-
spec.match = matchResult.value;
|
|
546
|
-
return matchResult;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
const whereResult = readOptionValue({
|
|
550
|
-
args,
|
|
551
|
-
index,
|
|
552
|
-
flag: '--where-prop',
|
|
553
|
-
allowEmpty: false,
|
|
554
|
-
});
|
|
555
|
-
if (whereResult) {
|
|
556
|
-
spec.steps.push(parseWhereProp(whereResult.value));
|
|
557
|
-
return whereResult;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
const selectResult = readOptionValue({
|
|
561
|
-
args,
|
|
562
|
-
index,
|
|
563
|
-
flag: '--select',
|
|
564
|
-
allowEmpty: true,
|
|
565
|
-
});
|
|
566
|
-
if (selectResult) {
|
|
567
|
-
spec.select = parseSelectFields(selectResult.value);
|
|
568
|
-
return selectResult;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
return null;
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
/** @param {string} value */
|
|
575
|
-
function parseWhereProp(value) {
|
|
576
|
-
const [key, ...rest] = value.split('=');
|
|
577
|
-
if (!key || rest.length === 0) {
|
|
578
|
-
throw usageError('Expected --where-prop key=value');
|
|
579
|
-
}
|
|
580
|
-
return { type: 'where-prop', key, value: rest.join('=') };
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
/** @param {string} value */
|
|
584
|
-
function parseSelectFields(value) {
|
|
585
|
-
if (value === '') {
|
|
586
|
-
return [];
|
|
587
|
-
}
|
|
588
|
-
return value.split(',').map((field) => field.trim()).filter(Boolean);
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
/**
|
|
592
|
-
* @param {string[]} args
|
|
593
|
-
* @param {number} index
|
|
594
|
-
*/
|
|
595
|
-
function readTraversalStep(args, index) {
|
|
596
|
-
const arg = args[index];
|
|
597
|
-
if (arg !== '--outgoing' && arg !== '--incoming') {
|
|
598
|
-
return null;
|
|
599
|
-
}
|
|
600
|
-
const next = args[index + 1];
|
|
601
|
-
const label = next && !next.startsWith('-') ? next : undefined;
|
|
602
|
-
const consumed = label ? 1 : 0;
|
|
603
|
-
return { step: { type: arg.slice(2), label }, consumed };
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
/**
|
|
607
|
-
* @param {{args: string[], index: number, flag: string, shortFlag?: string, allowEmpty?: boolean}} params
|
|
608
|
-
*/
|
|
609
|
-
function readOptionValue({ args, index, flag, shortFlag, allowEmpty = false }) {
|
|
610
|
-
const arg = args[index];
|
|
611
|
-
if (matchesOptionFlag(arg, flag, shortFlag)) {
|
|
612
|
-
return readNextOptionValue({ args, index, flag, allowEmpty });
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
if (arg.startsWith(`${flag}=`)) {
|
|
616
|
-
return readInlineOptionValue({ arg, flag, allowEmpty });
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
return null;
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
/**
|
|
623
|
-
* @param {string} arg
|
|
624
|
-
* @param {string} flag
|
|
625
|
-
* @param {string} [shortFlag]
|
|
626
|
-
*/
|
|
627
|
-
function matchesOptionFlag(arg, flag, shortFlag) {
|
|
628
|
-
return arg === flag || (shortFlag && arg === shortFlag);
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
/** @param {{args: string[], index: number, flag: string, allowEmpty?: boolean}} params */
|
|
632
|
-
function readNextOptionValue({ args, index, flag, allowEmpty }) {
|
|
633
|
-
const value = args[index + 1];
|
|
634
|
-
if (value === undefined || (!allowEmpty && value === '')) {
|
|
635
|
-
throw usageError(`Missing value for ${flag}`);
|
|
636
|
-
}
|
|
637
|
-
return { value, consumed: 1 };
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
/** @param {{arg: string, flag: string, allowEmpty?: boolean}} params */
|
|
641
|
-
function readInlineOptionValue({ arg, flag, allowEmpty }) {
|
|
642
|
-
const value = arg.slice(flag.length + 1);
|
|
643
|
-
if (!allowEmpty && value === '') {
|
|
644
|
-
throw usageError(`Missing value for ${flag}`);
|
|
645
|
-
}
|
|
646
|
-
return { value, consumed: 0 };
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
/** @param {string[]} args */
|
|
650
|
-
function parsePathArgs(args) {
|
|
651
|
-
const options = createPathOptions();
|
|
652
|
-
/** @type {string[]} */
|
|
653
|
-
const labels = [];
|
|
654
|
-
/** @type {string[]} */
|
|
655
|
-
const positionals = [];
|
|
656
|
-
|
|
657
|
-
for (let i = 0; i < args.length; i += 1) {
|
|
658
|
-
const result = consumePathArg({ args, index: i, options, labels, positionals });
|
|
659
|
-
i += result.consumed;
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
finalizePathOptions(options, labels, positionals);
|
|
663
|
-
return options;
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
/** @returns {{from: string|null, to: string|null, dir: string|undefined, labelFilter: string|string[]|undefined, maxDepth: number|undefined}} */
|
|
667
|
-
function createPathOptions() {
|
|
668
|
-
return {
|
|
669
|
-
from: null,
|
|
670
|
-
to: null,
|
|
671
|
-
dir: undefined,
|
|
672
|
-
labelFilter: undefined,
|
|
673
|
-
maxDepth: undefined,
|
|
674
|
-
};
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
/**
|
|
678
|
-
* @param {{args: string[], index: number, options: ReturnType<typeof createPathOptions>, labels: string[], positionals: string[]}} params
|
|
679
|
-
*/
|
|
680
|
-
function consumePathArg({ args, index, options, labels, positionals }) {
|
|
681
|
-
const arg = args[index];
|
|
682
|
-
/** @type {Array<{flag: string, apply: (value: string) => void}>} */
|
|
683
|
-
const handlers = [
|
|
684
|
-
{ flag: '--from', apply: (value) => { options.from = value; } },
|
|
685
|
-
{ flag: '--to', apply: (value) => { options.to = value; } },
|
|
686
|
-
{ flag: '--dir', apply: (value) => { options.dir = value; } },
|
|
687
|
-
{ flag: '--label', apply: (value) => { labels.push(...parseLabels(value)); } },
|
|
688
|
-
{ flag: '--max-depth', apply: (value) => { options.maxDepth = parseMaxDepth(value); } },
|
|
689
|
-
];
|
|
690
|
-
|
|
691
|
-
for (const handler of handlers) {
|
|
692
|
-
const result = readOptionValue({ args, index, flag: handler.flag });
|
|
693
|
-
if (result) {
|
|
694
|
-
handler.apply(result.value);
|
|
695
|
-
return result;
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
if (arg.startsWith('-')) {
|
|
700
|
-
throw usageError(`Unknown path option: ${arg}`);
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
positionals.push(arg);
|
|
704
|
-
return { consumed: 0 };
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
/**
|
|
708
|
-
* @param {ReturnType<typeof createPathOptions>} options
|
|
709
|
-
* @param {string[]} labels
|
|
710
|
-
* @param {string[]} positionals
|
|
711
|
-
*/
|
|
712
|
-
function finalizePathOptions(options, labels, positionals) {
|
|
713
|
-
if (!options.from) {
|
|
714
|
-
options.from = positionals[0] || null;
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
if (!options.to) {
|
|
718
|
-
options.to = positionals[1] || null;
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
if (!options.from || !options.to) {
|
|
722
|
-
throw usageError('Path requires --from and --to (or two positional ids)');
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
if (labels.length === 1) {
|
|
726
|
-
options.labelFilter = labels[0];
|
|
727
|
-
} else if (labels.length > 1) {
|
|
728
|
-
options.labelFilter = labels;
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
/** @param {string} value */
|
|
733
|
-
function parseLabels(value) {
|
|
734
|
-
return value.split(',').map((label) => label.trim()).filter(Boolean);
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
/** @param {string} value */
|
|
738
|
-
function parseMaxDepth(value) {
|
|
739
|
-
const parsed = Number.parseInt(value, 10);
|
|
740
|
-
if (Number.isNaN(parsed)) {
|
|
741
|
-
throw usageError('Invalid value for --max-depth');
|
|
742
|
-
}
|
|
743
|
-
return parsed;
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
/** @param {string[]} args */
|
|
747
|
-
function parseHistoryArgs(args) {
|
|
748
|
-
/** @type {{node: string|null}} */
|
|
749
|
-
const options = { node: null };
|
|
750
|
-
|
|
751
|
-
for (let i = 0; i < args.length; i += 1) {
|
|
752
|
-
const arg = args[i];
|
|
753
|
-
|
|
754
|
-
if (arg === '--node') {
|
|
755
|
-
const value = args[i + 1];
|
|
756
|
-
if (!value) {
|
|
757
|
-
throw usageError('Missing value for --node');
|
|
758
|
-
}
|
|
759
|
-
options.node = value;
|
|
760
|
-
i += 1;
|
|
761
|
-
continue;
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
if (arg.startsWith('--node=')) {
|
|
765
|
-
options.node = arg.slice('--node='.length);
|
|
766
|
-
continue;
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
if (arg.startsWith('-')) {
|
|
770
|
-
throw usageError(`Unknown history option: ${arg}`);
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
throw usageError(`Unexpected history argument: ${arg}`);
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
return options;
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
/**
|
|
780
|
-
* @param {*} patch
|
|
781
|
-
* @param {string} nodeId
|
|
782
|
-
*/
|
|
783
|
-
function patchTouchesNode(patch, nodeId) {
|
|
784
|
-
const ops = Array.isArray(patch?.ops) ? patch.ops : [];
|
|
785
|
-
for (const op of ops) {
|
|
786
|
-
if (op.node === nodeId) {
|
|
787
|
-
return true;
|
|
788
|
-
}
|
|
789
|
-
if (op.from === nodeId || op.to === nodeId) {
|
|
790
|
-
return true;
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
return false;
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
/** @param {*} payload */
|
|
797
|
-
function renderInfo(payload) {
|
|
798
|
-
const lines = [`Repo: ${payload.repo}`];
|
|
799
|
-
lines.push(`Graphs: ${payload.graphs.length}`);
|
|
800
|
-
for (const graph of payload.graphs) {
|
|
801
|
-
const writers = graph.writers ? ` writers=${graph.writers.count}` : '';
|
|
802
|
-
lines.push(`- ${graph.name}${writers}`);
|
|
803
|
-
if (graph.checkpoint?.sha) {
|
|
804
|
-
lines.push(` checkpoint: ${graph.checkpoint.sha}`);
|
|
805
|
-
}
|
|
806
|
-
if (graph.coverage?.sha) {
|
|
807
|
-
lines.push(` coverage: ${graph.coverage.sha}`);
|
|
808
|
-
}
|
|
809
|
-
if (graph.cursor?.active) {
|
|
810
|
-
lines.push(` cursor: tick ${graph.cursor.tick} (${graph.cursor.mode})`);
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
return `${lines.join('\n')}\n`;
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
/** @param {*} payload */
|
|
817
|
-
function renderQuery(payload) {
|
|
818
|
-
const lines = [
|
|
819
|
-
`Graph: ${payload.graph}`,
|
|
820
|
-
`State: ${payload.stateHash}`,
|
|
821
|
-
`Nodes: ${payload.nodes.length}`,
|
|
822
|
-
];
|
|
823
|
-
|
|
824
|
-
for (const node of payload.nodes) {
|
|
825
|
-
const id = node.id ?? '(unknown)';
|
|
826
|
-
lines.push(`- ${id}`);
|
|
827
|
-
if (node.props && Object.keys(node.props).length > 0) {
|
|
828
|
-
lines.push(` props: ${JSON.stringify(node.props)}`);
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
return `${lines.join('\n')}\n`;
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
/** @param {*} payload */
|
|
836
|
-
function renderPath(payload) {
|
|
837
|
-
const lines = [
|
|
838
|
-
`Graph: ${payload.graph}`,
|
|
839
|
-
`From: ${payload.from}`,
|
|
840
|
-
`To: ${payload.to}`,
|
|
841
|
-
`Found: ${payload.found ? 'yes' : 'no'}`,
|
|
842
|
-
`Length: ${payload.length}`,
|
|
843
|
-
];
|
|
844
|
-
|
|
845
|
-
if (payload.path && payload.path.length > 0) {
|
|
846
|
-
lines.push(`Path: ${payload.path.join(' -> ')}`);
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
return `${lines.join('\n')}\n`;
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
const ANSI_GREEN = '\x1b[32m';
|
|
853
|
-
const ANSI_YELLOW = '\x1b[33m';
|
|
854
|
-
const ANSI_RED = '\x1b[31m';
|
|
855
|
-
const ANSI_DIM = '\x1b[2m';
|
|
856
|
-
const ANSI_RESET = '\x1b[0m';
|
|
857
|
-
|
|
858
|
-
/** @param {string} state */
|
|
859
|
-
function colorCachedState(state) {
|
|
860
|
-
if (state === 'fresh') {
|
|
861
|
-
return `${ANSI_GREEN}${state}${ANSI_RESET}`;
|
|
862
|
-
}
|
|
863
|
-
if (state === 'stale') {
|
|
864
|
-
return `${ANSI_YELLOW}${state}${ANSI_RESET}`;
|
|
865
|
-
}
|
|
866
|
-
return `${ANSI_RED}${ANSI_DIM}${state}${ANSI_RESET}`;
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
/** @param {*} payload */
|
|
870
|
-
function renderCheck(payload) {
|
|
871
|
-
const lines = [
|
|
872
|
-
`Graph: ${payload.graph}`,
|
|
873
|
-
`Health: ${payload.health.status}`,
|
|
874
|
-
];
|
|
875
|
-
|
|
876
|
-
if (payload.status) {
|
|
877
|
-
lines.push(`Cached State: ${colorCachedState(payload.status.cachedState)}`);
|
|
878
|
-
lines.push(`Patches Since Checkpoint: ${payload.status.patchesSinceCheckpoint}`);
|
|
879
|
-
lines.push(`Tombstone Ratio: ${payload.status.tombstoneRatio.toFixed(2)}`);
|
|
880
|
-
lines.push(`Writers: ${payload.status.writers}`);
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
if (payload.checkpoint?.sha) {
|
|
884
|
-
lines.push(`Checkpoint: ${payload.checkpoint.sha}`);
|
|
885
|
-
if (payload.checkpoint.ageSeconds !== null) {
|
|
886
|
-
lines.push(`Checkpoint Age: ${payload.checkpoint.ageSeconds}s`);
|
|
887
|
-
}
|
|
888
|
-
} else {
|
|
889
|
-
lines.push('Checkpoint: none');
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
if (!payload.status) {
|
|
893
|
-
lines.push(`Writers: ${payload.writers.count}`);
|
|
894
|
-
}
|
|
895
|
-
for (const head of payload.writers.heads) {
|
|
896
|
-
lines.push(`- ${head.writerId}: ${head.sha}`);
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
if (payload.coverage?.sha) {
|
|
900
|
-
lines.push(`Coverage: ${payload.coverage.sha}`);
|
|
901
|
-
lines.push(`Coverage Missing: ${payload.coverage.missingWriters.length}`);
|
|
902
|
-
} else {
|
|
903
|
-
lines.push('Coverage: none');
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
if (payload.gc) {
|
|
907
|
-
lines.push(`Tombstones: ${payload.gc.totalTombstones}`);
|
|
908
|
-
if (!payload.status) {
|
|
909
|
-
lines.push(`Tombstone Ratio: ${payload.gc.tombstoneRatio}`);
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
if (payload.hook) {
|
|
914
|
-
lines.push(formatHookStatusLine(payload.hook));
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
return `${lines.join('\n')}\n`;
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
/** @param {*} hook */
|
|
921
|
-
function formatHookStatusLine(hook) {
|
|
922
|
-
if (!hook.installed && hook.foreign) {
|
|
923
|
-
return "Hook: foreign hook present — run 'git warp install-hooks'";
|
|
924
|
-
}
|
|
925
|
-
if (!hook.installed) {
|
|
926
|
-
return "Hook: not installed — run 'git warp install-hooks'";
|
|
927
|
-
}
|
|
928
|
-
if (hook.current) {
|
|
929
|
-
return `Hook: installed (v${hook.version}) — up to date`;
|
|
930
|
-
}
|
|
931
|
-
return `Hook: installed (v${hook.version}) — upgrade available, run 'git warp install-hooks'`;
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
/** @param {*} payload */
|
|
935
|
-
function renderHistory(payload) {
|
|
936
|
-
const lines = [
|
|
937
|
-
`Graph: ${payload.graph}`,
|
|
938
|
-
`Writer: ${payload.writer}`,
|
|
939
|
-
`Entries: ${payload.entries.length}`,
|
|
940
|
-
];
|
|
941
|
-
|
|
942
|
-
if (payload.nodeFilter) {
|
|
943
|
-
lines.push(`Node Filter: ${payload.nodeFilter}`);
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
for (const entry of payload.entries) {
|
|
947
|
-
lines.push(`- ${entry.sha} (lamport: ${entry.lamport}, ops: ${entry.opCount})`);
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
return `${lines.join('\n')}\n`;
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
/** @param {*} payload */
|
|
954
|
-
function renderError(payload) {
|
|
955
|
-
return `Error: ${payload.error.message}\n`;
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
/**
|
|
959
|
-
* Wraps SVG content in a minimal HTML document and writes it to disk.
|
|
960
|
-
* @param {string} filePath - Destination file path
|
|
961
|
-
* @param {string} svgContent - SVG markup to embed
|
|
962
|
-
*/
|
|
963
|
-
function writeHtmlExport(filePath, svgContent) {
|
|
964
|
-
const html = `<!DOCTYPE html>\n<html><head><meta charset="utf-8"><title>git-warp</title></head><body>\n${svgContent}\n</body></html>`;
|
|
965
|
-
fs.writeFileSync(filePath, html);
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
/**
|
|
969
|
-
* Writes a command result to stdout/stderr in the appropriate format.
|
|
970
|
-
* Dispatches to JSON, SVG file, HTML file, ASCII view, or plain text
|
|
971
|
-
* based on the combination of flags.
|
|
972
|
-
* @param {*} payload - Command result payload
|
|
973
|
-
* @param {{json: boolean, command: string, view: string|null}} options
|
|
974
|
-
*/
|
|
975
|
-
function emit(payload, { json, command, view }) {
|
|
976
|
-
if (json) {
|
|
977
|
-
process.stdout.write(`${stableStringify(payload)}\n`);
|
|
978
|
-
return;
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
if (command === 'info') {
|
|
982
|
-
if (view) {
|
|
983
|
-
process.stdout.write(renderInfoView(payload));
|
|
984
|
-
} else {
|
|
985
|
-
process.stdout.write(renderInfo(payload));
|
|
986
|
-
}
|
|
987
|
-
return;
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
if (command === 'query') {
|
|
991
|
-
if (view && typeof view === 'string' && view.startsWith('svg:')) {
|
|
992
|
-
const svgPath = view.slice(4);
|
|
993
|
-
if (!payload._renderedSvg) {
|
|
994
|
-
process.stderr.write('No graph data — skipping SVG export.\n');
|
|
995
|
-
} else {
|
|
996
|
-
fs.writeFileSync(svgPath, payload._renderedSvg);
|
|
997
|
-
process.stderr.write(`SVG written to ${svgPath}\n`);
|
|
998
|
-
}
|
|
999
|
-
} else if (view && typeof view === 'string' && view.startsWith('html:')) {
|
|
1000
|
-
const htmlPath = view.slice(5);
|
|
1001
|
-
if (!payload._renderedSvg) {
|
|
1002
|
-
process.stderr.write('No graph data — skipping HTML export.\n');
|
|
1003
|
-
} else {
|
|
1004
|
-
writeHtmlExport(htmlPath, payload._renderedSvg);
|
|
1005
|
-
process.stderr.write(`HTML written to ${htmlPath}\n`);
|
|
1006
|
-
}
|
|
1007
|
-
} else if (view) {
|
|
1008
|
-
process.stdout.write(`${payload._renderedAscii}\n`);
|
|
1009
|
-
} else {
|
|
1010
|
-
process.stdout.write(renderQuery(payload));
|
|
1011
|
-
}
|
|
1012
|
-
return;
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
if (command === 'path') {
|
|
1016
|
-
if (view && typeof view === 'string' && view.startsWith('svg:')) {
|
|
1017
|
-
const svgPath = view.slice(4);
|
|
1018
|
-
if (!payload._renderedSvg) {
|
|
1019
|
-
process.stderr.write('No path found — skipping SVG export.\n');
|
|
1020
|
-
} else {
|
|
1021
|
-
fs.writeFileSync(svgPath, payload._renderedSvg);
|
|
1022
|
-
process.stderr.write(`SVG written to ${svgPath}\n`);
|
|
1023
|
-
}
|
|
1024
|
-
} else if (view && typeof view === 'string' && view.startsWith('html:')) {
|
|
1025
|
-
const htmlPath = view.slice(5);
|
|
1026
|
-
if (!payload._renderedSvg) {
|
|
1027
|
-
process.stderr.write('No path found — skipping HTML export.\n');
|
|
1028
|
-
} else {
|
|
1029
|
-
writeHtmlExport(htmlPath, payload._renderedSvg);
|
|
1030
|
-
process.stderr.write(`HTML written to ${htmlPath}\n`);
|
|
1031
|
-
}
|
|
1032
|
-
} else if (view) {
|
|
1033
|
-
process.stdout.write(renderPathView(payload));
|
|
1034
|
-
} else {
|
|
1035
|
-
process.stdout.write(renderPath(payload));
|
|
1036
|
-
}
|
|
1037
|
-
return;
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
if (command === 'check') {
|
|
1041
|
-
if (view) {
|
|
1042
|
-
process.stdout.write(renderCheckView(payload));
|
|
1043
|
-
} else {
|
|
1044
|
-
process.stdout.write(renderCheck(payload));
|
|
1045
|
-
}
|
|
1046
|
-
return;
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
if (command === 'history') {
|
|
1050
|
-
if (view) {
|
|
1051
|
-
process.stdout.write(renderHistoryView(payload));
|
|
1052
|
-
} else {
|
|
1053
|
-
process.stdout.write(renderHistory(payload));
|
|
1054
|
-
}
|
|
1055
|
-
return;
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
if (command === 'materialize') {
|
|
1059
|
-
if (view) {
|
|
1060
|
-
process.stdout.write(renderMaterializeView(payload));
|
|
1061
|
-
} else {
|
|
1062
|
-
process.stdout.write(renderMaterialize(payload));
|
|
1063
|
-
}
|
|
1064
|
-
return;
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
if (command === 'seek') {
|
|
1068
|
-
if (view) {
|
|
1069
|
-
process.stdout.write(renderSeekView(payload));
|
|
1070
|
-
} else {
|
|
1071
|
-
process.stdout.write(renderSeek(payload));
|
|
1072
|
-
}
|
|
1073
|
-
return;
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
if (command === 'install-hooks') {
|
|
1077
|
-
process.stdout.write(renderInstallHooks(payload));
|
|
1078
|
-
return;
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
if (payload?.error) {
|
|
1082
|
-
process.stderr.write(renderError(payload));
|
|
1083
|
-
return;
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
process.stdout.write(`${stableStringify(payload)}\n`);
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
/**
|
|
1090
|
-
* Handles the `info` command: summarizes graphs in the repository.
|
|
1091
|
-
* @param {{options: CliOptions}} params
|
|
1092
|
-
* @returns {Promise<{repo: string, graphs: GraphInfoResult[]}>} Info payload
|
|
1093
|
-
* @throws {CliError} If the specified graph is not found
|
|
1094
|
-
*/
|
|
1095
|
-
async function handleInfo({ options }) {
|
|
1096
|
-
const { persistence } = await createPersistence(options.repo);
|
|
1097
|
-
const graphNames = await listGraphNames(persistence);
|
|
1098
|
-
|
|
1099
|
-
if (options.graph && !graphNames.includes(options.graph)) {
|
|
1100
|
-
throw notFoundError(`Graph not found: ${options.graph}`);
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
const detailGraphs = new Set();
|
|
1104
|
-
if (options.graph) {
|
|
1105
|
-
detailGraphs.add(options.graph);
|
|
1106
|
-
} else if (graphNames.length === 1) {
|
|
1107
|
-
detailGraphs.add(graphNames[0]);
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
// In view mode, include extra data for visualization
|
|
1111
|
-
const isViewMode = Boolean(options.view);
|
|
1112
|
-
|
|
1113
|
-
const graphs = [];
|
|
1114
|
-
for (const name of graphNames) {
|
|
1115
|
-
const includeDetails = detailGraphs.has(name);
|
|
1116
|
-
const info = await getGraphInfo(persistence, name, {
|
|
1117
|
-
includeWriterIds: includeDetails || isViewMode,
|
|
1118
|
-
includeRefs: includeDetails || isViewMode,
|
|
1119
|
-
includeWriterPatches: isViewMode,
|
|
1120
|
-
includeCheckpointDate: isViewMode,
|
|
1121
|
-
});
|
|
1122
|
-
const activeCursor = await readActiveCursor(persistence, name);
|
|
1123
|
-
if (activeCursor) {
|
|
1124
|
-
info.cursor = { active: true, tick: activeCursor.tick, mode: activeCursor.mode };
|
|
1125
|
-
} else {
|
|
1126
|
-
info.cursor = { active: false };
|
|
1127
|
-
}
|
|
1128
|
-
graphs.push(info);
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
return {
|
|
1132
|
-
repo: options.repo,
|
|
1133
|
-
graphs,
|
|
1134
|
-
};
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
/**
|
|
1138
|
-
* Handles the `query` command: runs a logical graph query.
|
|
1139
|
-
* @param {{options: CliOptions, args: string[]}} params
|
|
1140
|
-
* @returns {Promise<{payload: *, exitCode: number}>} Query result payload
|
|
1141
|
-
* @throws {CliError} On invalid query options or query execution errors
|
|
1142
|
-
*/
|
|
1143
|
-
async function handleQuery({ options, args }) {
|
|
1144
|
-
const querySpec = parseQueryArgs(args);
|
|
1145
|
-
const { graph, graphName, persistence } = await openGraph(options);
|
|
1146
|
-
const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
|
|
1147
|
-
emitCursorWarning(cursorInfo, null);
|
|
1148
|
-
let builder = graph.query();
|
|
1149
|
-
|
|
1150
|
-
if (querySpec.match !== null) {
|
|
1151
|
-
builder = builder.match(querySpec.match);
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
builder = applyQuerySteps(builder, querySpec.steps);
|
|
1155
|
-
|
|
1156
|
-
if (querySpec.select !== null) {
|
|
1157
|
-
builder = builder.select(querySpec.select);
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
try {
|
|
1161
|
-
const result = await builder.run();
|
|
1162
|
-
const payload = buildQueryPayload(graphName, result);
|
|
1163
|
-
|
|
1164
|
-
if (options.view) {
|
|
1165
|
-
const edges = await graph.getEdges();
|
|
1166
|
-
const graphData = queryResultToGraphData(payload, edges);
|
|
1167
|
-
const positioned = await layoutGraph(graphData, { type: 'query' });
|
|
1168
|
-
if (typeof options.view === 'string' && (options.view.startsWith('svg:') || options.view.startsWith('html:'))) {
|
|
1169
|
-
payload._renderedSvg = renderSvg(positioned, { title: `${graphName} query` });
|
|
1170
|
-
} else {
|
|
1171
|
-
payload._renderedAscii = renderGraphView(positioned, { title: `QUERY: ${graphName}` });
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
return {
|
|
1176
|
-
payload,
|
|
1177
|
-
exitCode: EXIT_CODES.OK,
|
|
1178
|
-
};
|
|
1179
|
-
} catch (error) {
|
|
1180
|
-
throw mapQueryError(error);
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
/**
|
|
1185
|
-
* @param {*} builder
|
|
1186
|
-
* @param {Array<{type: string, label?: string, key?: string, value?: string}>} steps
|
|
1187
|
-
*/
|
|
1188
|
-
function applyQuerySteps(builder, steps) {
|
|
1189
|
-
let current = builder;
|
|
1190
|
-
for (const step of steps) {
|
|
1191
|
-
current = applyQueryStep(current, step);
|
|
1192
|
-
}
|
|
1193
|
-
return current;
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
/**
|
|
1197
|
-
* @param {*} builder
|
|
1198
|
-
* @param {{type: string, label?: string, key?: string, value?: string}} step
|
|
1199
|
-
*/
|
|
1200
|
-
function applyQueryStep(builder, step) {
|
|
1201
|
-
if (step.type === 'outgoing') {
|
|
1202
|
-
return builder.outgoing(step.label);
|
|
1203
|
-
}
|
|
1204
|
-
if (step.type === 'incoming') {
|
|
1205
|
-
return builder.incoming(step.label);
|
|
1206
|
-
}
|
|
1207
|
-
if (step.type === 'where-prop') {
|
|
1208
|
-
return builder.where((/** @type {*} */ node) => matchesPropFilter(node, /** @type {string} */ (step.key), /** @type {string} */ (step.value))); // TODO(ts-cleanup): type CLI payload
|
|
1209
|
-
}
|
|
1210
|
-
return builder;
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
/**
|
|
1214
|
-
* @param {*} node
|
|
1215
|
-
* @param {string} key
|
|
1216
|
-
* @param {string} value
|
|
1217
|
-
*/
|
|
1218
|
-
function matchesPropFilter(node, key, value) {
|
|
1219
|
-
const props = node.props || {};
|
|
1220
|
-
if (!Object.prototype.hasOwnProperty.call(props, key)) {
|
|
1221
|
-
return false;
|
|
1222
|
-
}
|
|
1223
|
-
return String(props[key]) === value;
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
/**
|
|
1227
|
-
* @param {string} graphName
|
|
1228
|
-
* @param {*} result
|
|
1229
|
-
* @returns {{graph: string, stateHash: *, nodes: *, _renderedSvg?: string, _renderedAscii?: string}}
|
|
1230
|
-
*/
|
|
1231
|
-
function buildQueryPayload(graphName, result) {
|
|
1232
|
-
return {
|
|
1233
|
-
graph: graphName,
|
|
1234
|
-
stateHash: result.stateHash,
|
|
1235
|
-
nodes: result.nodes,
|
|
1236
|
-
};
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
/** @param {*} error */
|
|
1240
|
-
function mapQueryError(error) {
|
|
1241
|
-
if (error && error.code && String(error.code).startsWith('E_QUERY')) {
|
|
1242
|
-
throw usageError(error.message);
|
|
1243
|
-
}
|
|
1244
|
-
throw error;
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
/**
|
|
1248
|
-
* Handles the `path` command: finds a shortest path between two nodes.
|
|
1249
|
-
* @param {{options: CliOptions, args: string[]}} params
|
|
1250
|
-
* @returns {Promise<{payload: *, exitCode: number}>} Path result payload
|
|
1251
|
-
* @throws {CliError} If --from/--to are missing or a node is not found
|
|
1252
|
-
*/
|
|
1253
|
-
async function handlePath({ options, args }) {
|
|
1254
|
-
const pathOptions = parsePathArgs(args);
|
|
1255
|
-
const { graph, graphName, persistence } = await openGraph(options);
|
|
1256
|
-
const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
|
|
1257
|
-
emitCursorWarning(cursorInfo, null);
|
|
1258
|
-
|
|
1259
|
-
try {
|
|
1260
|
-
const result = await graph.traverse.shortestPath(
|
|
1261
|
-
pathOptions.from,
|
|
1262
|
-
pathOptions.to,
|
|
1263
|
-
{
|
|
1264
|
-
dir: pathOptions.dir,
|
|
1265
|
-
labelFilter: pathOptions.labelFilter,
|
|
1266
|
-
maxDepth: pathOptions.maxDepth,
|
|
1267
|
-
}
|
|
1268
|
-
);
|
|
1269
|
-
|
|
1270
|
-
const payload = {
|
|
1271
|
-
graph: graphName,
|
|
1272
|
-
from: pathOptions.from,
|
|
1273
|
-
to: pathOptions.to,
|
|
1274
|
-
...result,
|
|
1275
|
-
};
|
|
1276
|
-
|
|
1277
|
-
if (options.view && result.found && typeof options.view === 'string' && (options.view.startsWith('svg:') || options.view.startsWith('html:'))) {
|
|
1278
|
-
const graphData = pathResultToGraphData(payload);
|
|
1279
|
-
const positioned = await layoutGraph(graphData, { type: 'path' });
|
|
1280
|
-
payload._renderedSvg = renderSvg(positioned, { title: `${graphName} path` });
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
return {
|
|
1284
|
-
payload,
|
|
1285
|
-
exitCode: result.found ? EXIT_CODES.OK : EXIT_CODES.NOT_FOUND,
|
|
1286
|
-
};
|
|
1287
|
-
} catch (/** @type {*} */ error) { // TODO(ts-cleanup): type error
|
|
1288
|
-
if (error && error.code === 'NODE_NOT_FOUND') {
|
|
1289
|
-
throw notFoundError(error.message);
|
|
1290
|
-
}
|
|
1291
|
-
throw error;
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
|
|
1295
|
-
/**
|
|
1296
|
-
* Handles the `check` command: reports graph health, GC, and hook status.
|
|
1297
|
-
* @param {{options: CliOptions}} params
|
|
1298
|
-
* @returns {Promise<{payload: *, exitCode: number}>} Health check payload
|
|
1299
|
-
*/
|
|
1300
|
-
async function handleCheck({ options }) {
|
|
1301
|
-
const { graph, graphName, persistence } = await openGraph(options);
|
|
1302
|
-
const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
|
|
1303
|
-
emitCursorWarning(cursorInfo, null);
|
|
1304
|
-
const health = await getHealth(persistence);
|
|
1305
|
-
const gcMetrics = await getGcMetrics(graph);
|
|
1306
|
-
const status = await graph.status();
|
|
1307
|
-
const writerHeads = await collectWriterHeads(graph);
|
|
1308
|
-
const checkpoint = await loadCheckpointInfo(persistence, graphName);
|
|
1309
|
-
const coverage = await loadCoverageInfo(persistence, graphName, writerHeads);
|
|
1310
|
-
const hook = getHookStatusForCheck(options.repo);
|
|
1311
|
-
|
|
1312
|
-
return {
|
|
1313
|
-
payload: buildCheckPayload({
|
|
1314
|
-
repo: options.repo,
|
|
1315
|
-
graphName,
|
|
1316
|
-
health,
|
|
1317
|
-
checkpoint,
|
|
1318
|
-
writerHeads,
|
|
1319
|
-
coverage,
|
|
1320
|
-
gcMetrics,
|
|
1321
|
-
hook,
|
|
1322
|
-
status,
|
|
1323
|
-
}),
|
|
1324
|
-
exitCode: EXIT_CODES.OK,
|
|
1325
|
-
};
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
/** @param {Persistence} persistence */
|
|
1329
|
-
async function getHealth(persistence) {
|
|
1330
|
-
const clock = ClockAdapter.node();
|
|
1331
|
-
const healthService = new HealthCheckService({ persistence: /** @type {*} */ (persistence), clock }); // TODO(ts-cleanup): narrow port type
|
|
1332
|
-
return await healthService.getHealth();
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
|
-
/** @param {WarpGraphInstance} graph */
|
|
1336
|
-
async function getGcMetrics(graph) {
|
|
1337
|
-
await graph.materialize();
|
|
1338
|
-
return graph.getGCMetrics();
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
/** @param {WarpGraphInstance} graph */
|
|
1342
|
-
async function collectWriterHeads(graph) {
|
|
1343
|
-
const frontier = await graph.getFrontier();
|
|
1344
|
-
return [...frontier.entries()]
|
|
1345
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
1346
|
-
.map(([writerId, sha]) => ({ writerId, sha }));
|
|
1347
|
-
}
|
|
1348
|
-
|
|
1349
|
-
/**
|
|
1350
|
-
* @param {Persistence} persistence
|
|
1351
|
-
* @param {string} graphName
|
|
1352
|
-
*/
|
|
1353
|
-
async function loadCheckpointInfo(persistence, graphName) {
|
|
1354
|
-
const checkpointRef = buildCheckpointRef(graphName);
|
|
1355
|
-
const checkpointSha = await persistence.readRef(checkpointRef);
|
|
1356
|
-
const checkpointDate = await readCheckpointDate(persistence, checkpointSha);
|
|
1357
|
-
const checkpointAgeSeconds = computeAgeSeconds(checkpointDate);
|
|
1358
|
-
|
|
1359
|
-
return {
|
|
1360
|
-
ref: checkpointRef,
|
|
1361
|
-
sha: checkpointSha || null,
|
|
1362
|
-
date: checkpointDate,
|
|
1363
|
-
ageSeconds: checkpointAgeSeconds,
|
|
1364
|
-
};
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
/**
|
|
1368
|
-
* @param {Persistence} persistence
|
|
1369
|
-
* @param {string|null} checkpointSha
|
|
1370
|
-
*/
|
|
1371
|
-
async function readCheckpointDate(persistence, checkpointSha) {
|
|
1372
|
-
if (!checkpointSha) {
|
|
1373
|
-
return null;
|
|
1374
|
-
}
|
|
1375
|
-
const info = await persistence.getNodeInfo(checkpointSha);
|
|
1376
|
-
return info.date || null;
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
/** @param {string|null} checkpointDate */
|
|
1380
|
-
function computeAgeSeconds(checkpointDate) {
|
|
1381
|
-
if (!checkpointDate) {
|
|
1382
|
-
return null;
|
|
1383
|
-
}
|
|
1384
|
-
const parsed = Date.parse(checkpointDate);
|
|
1385
|
-
if (Number.isNaN(parsed)) {
|
|
1386
|
-
return null;
|
|
1387
|
-
}
|
|
1388
|
-
return Math.max(0, Math.floor((Date.now() - parsed) / 1000));
|
|
1389
|
-
}
|
|
1390
|
-
|
|
1391
|
-
/**
|
|
1392
|
-
* @param {Persistence} persistence
|
|
1393
|
-
* @param {string} graphName
|
|
1394
|
-
* @param {Array<{writerId: string, sha: string}>} writerHeads
|
|
1395
|
-
*/
|
|
1396
|
-
async function loadCoverageInfo(persistence, graphName, writerHeads) {
|
|
1397
|
-
const coverageRef = buildCoverageRef(graphName);
|
|
1398
|
-
const coverageSha = await persistence.readRef(coverageRef);
|
|
1399
|
-
const missingWriters = coverageSha
|
|
1400
|
-
? await findMissingWriters(persistence, writerHeads, coverageSha)
|
|
1401
|
-
: [];
|
|
1402
|
-
|
|
1403
|
-
return {
|
|
1404
|
-
ref: coverageRef,
|
|
1405
|
-
sha: coverageSha || null,
|
|
1406
|
-
missingWriters: missingWriters.sort(),
|
|
1407
|
-
};
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
/**
|
|
1411
|
-
* @param {Persistence} persistence
|
|
1412
|
-
* @param {Array<{writerId: string, sha: string}>} writerHeads
|
|
1413
|
-
* @param {string} coverageSha
|
|
1414
|
-
*/
|
|
1415
|
-
async function findMissingWriters(persistence, writerHeads, coverageSha) {
|
|
1416
|
-
const missing = [];
|
|
1417
|
-
for (const head of writerHeads) {
|
|
1418
|
-
const reachable = await persistence.isAncestor(head.sha, coverageSha);
|
|
1419
|
-
if (!reachable) {
|
|
1420
|
-
missing.push(head.writerId);
|
|
1421
|
-
}
|
|
1422
|
-
}
|
|
1423
|
-
return missing;
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
|
-
/**
|
|
1427
|
-
* @param {{repo: string, graphName: string, health: *, checkpoint: *, writerHeads: Array<{writerId: string, sha: string}>, coverage: *, gcMetrics: *, hook: *|null, status: *|null}} params
|
|
1428
|
-
*/
|
|
1429
|
-
function buildCheckPayload({
|
|
1430
|
-
repo,
|
|
1431
|
-
graphName,
|
|
1432
|
-
health,
|
|
1433
|
-
checkpoint,
|
|
1434
|
-
writerHeads,
|
|
1435
|
-
coverage,
|
|
1436
|
-
gcMetrics,
|
|
1437
|
-
hook,
|
|
1438
|
-
status,
|
|
1439
|
-
}) {
|
|
1440
|
-
return {
|
|
1441
|
-
repo,
|
|
1442
|
-
graph: graphName,
|
|
1443
|
-
health,
|
|
1444
|
-
checkpoint,
|
|
1445
|
-
writers: {
|
|
1446
|
-
count: writerHeads.length,
|
|
1447
|
-
heads: writerHeads,
|
|
1448
|
-
},
|
|
1449
|
-
coverage,
|
|
1450
|
-
gc: gcMetrics,
|
|
1451
|
-
hook: hook || null,
|
|
1452
|
-
status: status || null,
|
|
1453
|
-
};
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
|
-
/**
|
|
1457
|
-
* Handles the `history` command: shows patch history for a writer.
|
|
1458
|
-
* @param {{options: CliOptions, args: string[]}} params
|
|
1459
|
-
* @returns {Promise<{payload: *, exitCode: number}>} History payload
|
|
1460
|
-
* @throws {CliError} If no patches are found for the writer
|
|
1461
|
-
*/
|
|
1462
|
-
async function handleHistory({ options, args }) {
|
|
1463
|
-
const historyOptions = parseHistoryArgs(args);
|
|
1464
|
-
const { graph, graphName, persistence } = await openGraph(options);
|
|
1465
|
-
const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
|
|
1466
|
-
emitCursorWarning(cursorInfo, null);
|
|
1467
|
-
|
|
1468
|
-
const writerId = options.writer;
|
|
1469
|
-
let patches = await graph.getWriterPatches(writerId);
|
|
1470
|
-
if (cursorInfo.active) {
|
|
1471
|
-
patches = patches.filter((/** @type {*} */ { patch }) => patch.lamport <= /** @type {number} */ (cursorInfo.tick)); // TODO(ts-cleanup): type CLI payload
|
|
1472
|
-
}
|
|
1473
|
-
if (patches.length === 0) {
|
|
1474
|
-
throw notFoundError(`No patches found for writer: ${writerId}`);
|
|
1475
|
-
}
|
|
1476
|
-
|
|
1477
|
-
const entries = patches
|
|
1478
|
-
.filter((/** @type {*} */ { patch }) => !historyOptions.node || patchTouchesNode(patch, historyOptions.node)) // TODO(ts-cleanup): type CLI payload
|
|
1479
|
-
.map((/** @type {*} */ { patch, sha }) => ({ // TODO(ts-cleanup): type CLI payload
|
|
1480
|
-
sha,
|
|
1481
|
-
schema: patch.schema,
|
|
1482
|
-
lamport: patch.lamport,
|
|
1483
|
-
opCount: Array.isArray(patch.ops) ? patch.ops.length : 0,
|
|
1484
|
-
opSummary: Array.isArray(patch.ops) ? summarizeOps(patch.ops) : undefined,
|
|
1485
|
-
}));
|
|
1486
|
-
|
|
1487
|
-
const payload = {
|
|
1488
|
-
graph: graphName,
|
|
1489
|
-
writer: writerId,
|
|
1490
|
-
nodeFilter: historyOptions.node,
|
|
1491
|
-
entries,
|
|
1492
|
-
};
|
|
1493
|
-
|
|
1494
|
-
return { payload, exitCode: EXIT_CODES.OK };
|
|
1495
|
-
}
|
|
1496
|
-
|
|
1497
|
-
/**
|
|
1498
|
-
* Materializes a single graph, creates a checkpoint, and returns summary stats.
|
|
1499
|
-
* When a ceiling tick is provided (seek cursor active), the checkpoint step is
|
|
1500
|
-
* skipped because the user is exploring historical state, not persisting it.
|
|
1501
|
-
* @param {{persistence: Persistence, graphName: string, writerId: string, ceiling?: number}} params
|
|
1502
|
-
* @returns {Promise<{graph: string, nodes: number, edges: number, properties: number, checkpoint: string|null, writers: Record<string, number>, patchCount: number}>}
|
|
1503
|
-
*/
|
|
1504
|
-
async function materializeOneGraph({ persistence, graphName, writerId, ceiling }) {
|
|
1505
|
-
const graph = await WarpGraph.open({ persistence, graphName, writerId, crypto: new NodeCryptoAdapter() });
|
|
1506
|
-
await graph.materialize(ceiling !== undefined ? { ceiling } : undefined);
|
|
1507
|
-
const nodes = await graph.getNodes();
|
|
1508
|
-
const edges = await graph.getEdges();
|
|
1509
|
-
const checkpoint = ceiling !== undefined ? null : await graph.createCheckpoint();
|
|
1510
|
-
const status = await graph.status();
|
|
1511
|
-
|
|
1512
|
-
// Build per-writer patch counts for the view renderer
|
|
1513
|
-
/** @type {Record<string, number>} */
|
|
1514
|
-
const writers = {};
|
|
1515
|
-
let totalPatchCount = 0;
|
|
1516
|
-
for (const wId of Object.keys(status.frontier)) {
|
|
1517
|
-
const patches = await graph.getWriterPatches(wId);
|
|
1518
|
-
writers[wId] = patches.length;
|
|
1519
|
-
totalPatchCount += patches.length;
|
|
1520
|
-
}
|
|
1521
|
-
|
|
1522
|
-
const properties = await graph.getPropertyCount();
|
|
1523
|
-
|
|
1524
|
-
return {
|
|
1525
|
-
graph: graphName,
|
|
1526
|
-
nodes: nodes.length,
|
|
1527
|
-
edges: edges.length,
|
|
1528
|
-
properties,
|
|
1529
|
-
checkpoint,
|
|
1530
|
-
writers,
|
|
1531
|
-
patchCount: totalPatchCount,
|
|
1532
|
-
};
|
|
1533
|
-
}
|
|
1534
|
-
|
|
1535
|
-
/**
|
|
1536
|
-
* Handles the `materialize` command: materializes and checkpoints all graphs.
|
|
1537
|
-
* @param {{options: CliOptions}} params
|
|
1538
|
-
* @returns {Promise<{payload: *, exitCode: number}>} Materialize result payload
|
|
1539
|
-
* @throws {CliError} If the specified graph is not found
|
|
1540
|
-
*/
|
|
1541
|
-
async function handleMaterialize({ options }) {
|
|
1542
|
-
const { persistence } = await createPersistence(options.repo);
|
|
1543
|
-
const graphNames = await listGraphNames(persistence);
|
|
1544
|
-
|
|
1545
|
-
if (graphNames.length === 0) {
|
|
1546
|
-
return {
|
|
1547
|
-
payload: { graphs: [] },
|
|
1548
|
-
exitCode: EXIT_CODES.OK,
|
|
1549
|
-
};
|
|
1550
|
-
}
|
|
1551
|
-
|
|
1552
|
-
const targets = options.graph
|
|
1553
|
-
? [options.graph]
|
|
1554
|
-
: graphNames;
|
|
1555
|
-
|
|
1556
|
-
if (options.graph && !graphNames.includes(options.graph)) {
|
|
1557
|
-
throw notFoundError(`Graph not found: ${options.graph}`);
|
|
1558
|
-
}
|
|
1559
|
-
|
|
1560
|
-
const results = [];
|
|
1561
|
-
let cursorWarningEmitted = false;
|
|
1562
|
-
for (const name of targets) {
|
|
1563
|
-
try {
|
|
1564
|
-
const cursor = await readActiveCursor(persistence, name);
|
|
1565
|
-
const ceiling = cursor ? cursor.tick : undefined;
|
|
1566
|
-
if (cursor && !cursorWarningEmitted) {
|
|
1567
|
-
emitCursorWarning({ active: true, tick: cursor.tick, maxTick: null }, null);
|
|
1568
|
-
cursorWarningEmitted = true;
|
|
1569
|
-
}
|
|
1570
|
-
const result = await materializeOneGraph({
|
|
1571
|
-
persistence,
|
|
1572
|
-
graphName: name,
|
|
1573
|
-
writerId: options.writer,
|
|
1574
|
-
ceiling,
|
|
1575
|
-
});
|
|
1576
|
-
results.push(result);
|
|
1577
|
-
} catch (error) {
|
|
1578
|
-
results.push({
|
|
1579
|
-
graph: name,
|
|
1580
|
-
error: error instanceof Error ? error.message : String(error),
|
|
1581
|
-
});
|
|
1582
|
-
}
|
|
1583
|
-
}
|
|
1584
|
-
|
|
1585
|
-
const allFailed = results.every((r) => /** @type {*} */ (r).error); // TODO(ts-cleanup): type CLI payload
|
|
1586
|
-
return {
|
|
1587
|
-
payload: { graphs: results },
|
|
1588
|
-
exitCode: allFailed ? EXIT_CODES.INTERNAL : EXIT_CODES.OK,
|
|
1589
|
-
};
|
|
1590
|
-
}
|
|
1591
|
-
|
|
1592
|
-
/** @param {*} payload */
|
|
1593
|
-
function renderMaterialize(payload) {
|
|
1594
|
-
if (payload.graphs.length === 0) {
|
|
1595
|
-
return 'No graphs found in repo.\n';
|
|
1596
|
-
}
|
|
1597
|
-
|
|
1598
|
-
const lines = [];
|
|
1599
|
-
for (const entry of payload.graphs) {
|
|
1600
|
-
if (entry.error) {
|
|
1601
|
-
lines.push(`${entry.graph}: error — ${entry.error}`);
|
|
1602
|
-
} else {
|
|
1603
|
-
lines.push(`${entry.graph}: ${entry.nodes} nodes, ${entry.edges} edges, checkpoint ${entry.checkpoint}`);
|
|
1604
|
-
}
|
|
1605
|
-
}
|
|
1606
|
-
return `${lines.join('\n')}\n`;
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
|
-
/** @param {*} payload */
|
|
1610
|
-
function renderInstallHooks(payload) {
|
|
1611
|
-
if (payload.action === 'up-to-date') {
|
|
1612
|
-
return `Hook: already up to date (v${payload.version}) at ${payload.hookPath}\n`;
|
|
1613
|
-
}
|
|
1614
|
-
if (payload.action === 'skipped') {
|
|
1615
|
-
return 'Hook: installation skipped\n';
|
|
1616
|
-
}
|
|
1617
|
-
const lines = [`Hook: ${payload.action} (v${payload.version})`, `Path: ${payload.hookPath}`];
|
|
1618
|
-
if (payload.backupPath) {
|
|
1619
|
-
lines.push(`Backup: ${payload.backupPath}`);
|
|
1620
|
-
}
|
|
1621
|
-
return `${lines.join('\n')}\n`;
|
|
1622
|
-
}
|
|
1623
|
-
|
|
1624
|
-
function createHookInstaller() {
|
|
1625
|
-
const __filename = new URL(import.meta.url).pathname;
|
|
1626
|
-
const __dirname = path.dirname(__filename);
|
|
1627
|
-
const templateDir = path.resolve(__dirname, '..', 'hooks');
|
|
1628
|
-
const { version } = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf8'));
|
|
1629
|
-
return new HookInstaller({
|
|
1630
|
-
fs: /** @type {*} */ (fs), // TODO(ts-cleanup): narrow port type
|
|
1631
|
-
execGitConfig: execGitConfigValue,
|
|
1632
|
-
version,
|
|
1633
|
-
templateDir,
|
|
1634
|
-
path,
|
|
1635
|
-
});
|
|
1636
|
-
}
|
|
1637
|
-
|
|
1638
|
-
/**
|
|
1639
|
-
* @param {string} repoPath
|
|
1640
|
-
* @param {string} key
|
|
1641
|
-
* @returns {string|null}
|
|
1642
|
-
*/
|
|
1643
|
-
function execGitConfigValue(repoPath, key) {
|
|
1644
|
-
try {
|
|
1645
|
-
if (key === '--git-dir') {
|
|
1646
|
-
return execFileSync('git', ['-C', repoPath, 'rev-parse', '--git-dir'], {
|
|
1647
|
-
encoding: 'utf8',
|
|
1648
|
-
}).trim();
|
|
1649
|
-
}
|
|
1650
|
-
return execFileSync('git', ['-C', repoPath, 'config', key], {
|
|
1651
|
-
encoding: 'utf8',
|
|
1652
|
-
}).trim();
|
|
1653
|
-
} catch {
|
|
1654
|
-
return null;
|
|
1655
|
-
}
|
|
1656
|
-
}
|
|
1657
|
-
|
|
1658
|
-
function isInteractive() {
|
|
1659
|
-
return Boolean(process.stderr.isTTY);
|
|
1660
|
-
}
|
|
1661
|
-
|
|
1662
|
-
/** @param {string} question @returns {Promise<string>} */
|
|
1663
|
-
function promptUser(question) {
|
|
1664
|
-
const rl = readline.createInterface({
|
|
1665
|
-
input: process.stdin,
|
|
1666
|
-
output: process.stderr,
|
|
1667
|
-
});
|
|
1668
|
-
return new Promise((resolve) => {
|
|
1669
|
-
rl.question(question, (answer) => {
|
|
1670
|
-
rl.close();
|
|
1671
|
-
resolve(answer.trim());
|
|
1672
|
-
});
|
|
1673
|
-
});
|
|
1674
|
-
}
|
|
1675
|
-
|
|
1676
|
-
/** @param {string[]} args */
|
|
1677
|
-
function parseInstallHooksArgs(args) {
|
|
1678
|
-
const options = { force: false };
|
|
1679
|
-
for (const arg of args) {
|
|
1680
|
-
if (arg === '--force') {
|
|
1681
|
-
options.force = true;
|
|
1682
|
-
} else if (arg.startsWith('-')) {
|
|
1683
|
-
throw usageError(`Unknown install-hooks option: ${arg}`);
|
|
1684
|
-
}
|
|
1685
|
-
}
|
|
1686
|
-
return options;
|
|
1687
|
-
}
|
|
1688
|
-
|
|
1689
|
-
/**
|
|
1690
|
-
* @param {*} classification
|
|
1691
|
-
* @param {{force: boolean}} hookOptions
|
|
1692
|
-
*/
|
|
1693
|
-
async function resolveStrategy(classification, hookOptions) {
|
|
1694
|
-
if (hookOptions.force) {
|
|
1695
|
-
return 'replace';
|
|
1696
|
-
}
|
|
1697
|
-
|
|
1698
|
-
if (classification.kind === 'none') {
|
|
1699
|
-
return 'install';
|
|
1700
|
-
}
|
|
1701
|
-
|
|
1702
|
-
if (classification.kind === 'ours') {
|
|
1703
|
-
return await promptForOursStrategy(classification);
|
|
1704
|
-
}
|
|
1705
|
-
|
|
1706
|
-
return await promptForForeignStrategy();
|
|
1707
|
-
}
|
|
1708
|
-
|
|
1709
|
-
/** @param {*} classification */
|
|
1710
|
-
async function promptForOursStrategy(classification) {
|
|
1711
|
-
const installer = createHookInstaller();
|
|
1712
|
-
if (classification.version === installer._version) {
|
|
1713
|
-
return 'up-to-date';
|
|
1714
|
-
}
|
|
1715
|
-
|
|
1716
|
-
if (!isInteractive()) {
|
|
1717
|
-
throw usageError('Existing hook found. Use --force or run interactively.');
|
|
1718
|
-
}
|
|
1719
|
-
|
|
1720
|
-
const answer = await promptUser(
|
|
1721
|
-
`Upgrade hook from v${classification.version} to v${installer._version}? [Y/n] `,
|
|
1722
|
-
);
|
|
1723
|
-
if (answer === '' || answer.toLowerCase() === 'y') {
|
|
1724
|
-
return 'upgrade';
|
|
1725
|
-
}
|
|
1726
|
-
return 'skip';
|
|
1727
|
-
}
|
|
1728
|
-
|
|
1729
|
-
async function promptForForeignStrategy() {
|
|
1730
|
-
if (!isInteractive()) {
|
|
1731
|
-
throw usageError('Existing hook found. Use --force or run interactively.');
|
|
1732
|
-
}
|
|
1733
|
-
|
|
1734
|
-
process.stderr.write('Existing post-merge hook found.\n');
|
|
1735
|
-
process.stderr.write(' 1) Append (keep existing hook, add warp section)\n');
|
|
1736
|
-
process.stderr.write(' 2) Replace (back up existing, install fresh)\n');
|
|
1737
|
-
process.stderr.write(' 3) Skip\n');
|
|
1738
|
-
const answer = await promptUser('Choose [1-3]: ');
|
|
1739
|
-
|
|
1740
|
-
if (answer === '1') {
|
|
1741
|
-
return 'append';
|
|
1742
|
-
}
|
|
1743
|
-
if (answer === '2') {
|
|
1744
|
-
return 'replace';
|
|
1745
|
-
}
|
|
1746
|
-
return 'skip';
|
|
1747
|
-
}
|
|
1748
|
-
|
|
1749
|
-
/**
|
|
1750
|
-
* Handles the `install-hooks` command: installs or upgrades the post-merge git hook.
|
|
1751
|
-
* @param {{options: CliOptions, args: string[]}} params
|
|
1752
|
-
* @returns {Promise<{payload: *, exitCode: number}>} Install result payload
|
|
1753
|
-
* @throws {CliError} If an existing hook is found and the session is not interactive
|
|
1754
|
-
*/
|
|
1755
|
-
async function handleInstallHooks({ options, args }) {
|
|
1756
|
-
const hookOptions = parseInstallHooksArgs(args);
|
|
1757
|
-
const installer = createHookInstaller();
|
|
1758
|
-
const status = installer.getHookStatus(options.repo);
|
|
1759
|
-
const content = readHookContent(status.hookPath);
|
|
1760
|
-
const classification = classifyExistingHook(content);
|
|
1761
|
-
const strategy = await resolveStrategy(classification, hookOptions);
|
|
1762
|
-
|
|
1763
|
-
if (strategy === 'up-to-date') {
|
|
1764
|
-
return {
|
|
1765
|
-
payload: {
|
|
1766
|
-
action: 'up-to-date',
|
|
1767
|
-
hookPath: status.hookPath,
|
|
1768
|
-
version: installer._version,
|
|
1769
|
-
},
|
|
1770
|
-
exitCode: EXIT_CODES.OK,
|
|
1771
|
-
};
|
|
1772
|
-
}
|
|
1773
|
-
|
|
1774
|
-
if (strategy === 'skip') {
|
|
1775
|
-
return {
|
|
1776
|
-
payload: { action: 'skipped' },
|
|
1777
|
-
exitCode: EXIT_CODES.OK,
|
|
1778
|
-
};
|
|
1779
|
-
}
|
|
1780
|
-
|
|
1781
|
-
const result = installer.install(options.repo, { strategy });
|
|
1782
|
-
return {
|
|
1783
|
-
payload: result,
|
|
1784
|
-
exitCode: EXIT_CODES.OK,
|
|
1785
|
-
};
|
|
1786
|
-
}
|
|
1787
|
-
|
|
1788
|
-
/** @param {string} hookPath */
|
|
1789
|
-
function readHookContent(hookPath) {
|
|
1790
|
-
try {
|
|
1791
|
-
return fs.readFileSync(hookPath, 'utf8');
|
|
1792
|
-
} catch {
|
|
1793
|
-
return null;
|
|
1794
|
-
}
|
|
1795
|
-
}
|
|
1796
|
-
|
|
1797
|
-
/** @param {string} repoPath */
|
|
1798
|
-
function getHookStatusForCheck(repoPath) {
|
|
1799
|
-
try {
|
|
1800
|
-
const installer = createHookInstaller();
|
|
1801
|
-
return installer.getHookStatus(repoPath);
|
|
1802
|
-
} catch {
|
|
1803
|
-
return null;
|
|
1804
|
-
}
|
|
1805
|
-
}
|
|
1806
|
-
|
|
1807
|
-
// ============================================================================
|
|
1808
|
-
// Cursor I/O Helpers
|
|
1809
|
-
// ============================================================================
|
|
1810
|
-
|
|
1811
|
-
/**
|
|
1812
|
-
* Reads the active seek cursor for a graph from Git ref storage.
|
|
1813
|
-
*
|
|
1814
|
-
* @param {Persistence} persistence - GraphPersistencePort adapter
|
|
1815
|
-
* @param {string} graphName - Name of the WARP graph
|
|
1816
|
-
* @returns {Promise<CursorBlob|null>} Cursor object, or null if no active cursor
|
|
1817
|
-
* @throws {Error} If the stored blob is corrupted or not valid JSON
|
|
1818
|
-
*/
|
|
1819
|
-
async function readActiveCursor(persistence, graphName) {
|
|
1820
|
-
const ref = buildCursorActiveRef(graphName);
|
|
1821
|
-
const oid = await persistence.readRef(ref);
|
|
1822
|
-
if (!oid) {
|
|
1823
|
-
return null;
|
|
1824
|
-
}
|
|
1825
|
-
const buf = await persistence.readBlob(oid);
|
|
1826
|
-
return parseCursorBlob(buf, 'active cursor');
|
|
1827
|
-
}
|
|
1828
|
-
|
|
1829
|
-
/**
|
|
1830
|
-
* Writes (creates or overwrites) the active seek cursor for a graph.
|
|
1831
|
-
*
|
|
1832
|
-
* Serializes the cursor as JSON, stores it as a Git blob, and points
|
|
1833
|
-
* the active cursor ref at that blob.
|
|
1834
|
-
*
|
|
1835
|
-
* @param {Persistence} persistence - GraphPersistencePort adapter
|
|
1836
|
-
* @param {string} graphName - Name of the WARP graph
|
|
1837
|
-
* @param {CursorBlob} cursor - Cursor state to persist
|
|
1838
|
-
* @returns {Promise<void>}
|
|
1839
|
-
*/
|
|
1840
|
-
async function writeActiveCursor(persistence, graphName, cursor) {
|
|
1841
|
-
const ref = buildCursorActiveRef(graphName);
|
|
1842
|
-
const json = JSON.stringify(cursor);
|
|
1843
|
-
const oid = await persistence.writeBlob(Buffer.from(json, 'utf8'));
|
|
1844
|
-
await persistence.updateRef(ref, oid);
|
|
1845
|
-
}
|
|
1846
|
-
|
|
1847
|
-
/**
|
|
1848
|
-
* Removes the active seek cursor for a graph, returning to present state.
|
|
1849
|
-
*
|
|
1850
|
-
* No-op if no active cursor exists.
|
|
1851
|
-
*
|
|
1852
|
-
* @param {Persistence} persistence - GraphPersistencePort adapter
|
|
1853
|
-
* @param {string} graphName - Name of the WARP graph
|
|
1854
|
-
* @returns {Promise<void>}
|
|
1855
|
-
*/
|
|
1856
|
-
async function clearActiveCursor(persistence, graphName) {
|
|
1857
|
-
const ref = buildCursorActiveRef(graphName);
|
|
1858
|
-
const exists = await persistence.readRef(ref);
|
|
1859
|
-
if (exists) {
|
|
1860
|
-
await persistence.deleteRef(ref);
|
|
1861
|
-
}
|
|
1862
|
-
}
|
|
1863
|
-
|
|
1864
|
-
/**
|
|
1865
|
-
* Reads a named saved cursor from Git ref storage.
|
|
1866
|
-
*
|
|
1867
|
-
* @param {Persistence} persistence - GraphPersistencePort adapter
|
|
1868
|
-
* @param {string} graphName - Name of the WARP graph
|
|
1869
|
-
* @param {string} name - Saved cursor name
|
|
1870
|
-
* @returns {Promise<CursorBlob|null>} Cursor object, or null if not found
|
|
1871
|
-
* @throws {Error} If the stored blob is corrupted or not valid JSON
|
|
1872
|
-
*/
|
|
1873
|
-
async function readSavedCursor(persistence, graphName, name) {
|
|
1874
|
-
const ref = buildCursorSavedRef(graphName, name);
|
|
1875
|
-
const oid = await persistence.readRef(ref);
|
|
1876
|
-
if (!oid) {
|
|
1877
|
-
return null;
|
|
1878
|
-
}
|
|
1879
|
-
const buf = await persistence.readBlob(oid);
|
|
1880
|
-
return parseCursorBlob(buf, `saved cursor '${name}'`);
|
|
1881
|
-
}
|
|
1882
|
-
|
|
1883
|
-
/**
|
|
1884
|
-
* Persists a cursor under a named saved-cursor ref.
|
|
1885
|
-
*
|
|
1886
|
-
* Serializes the cursor as JSON, stores it as a Git blob, and points
|
|
1887
|
-
* the named saved-cursor ref at that blob.
|
|
1888
|
-
*
|
|
1889
|
-
* @param {Persistence} persistence - GraphPersistencePort adapter
|
|
1890
|
-
* @param {string} graphName - Name of the WARP graph
|
|
1891
|
-
* @param {string} name - Saved cursor name
|
|
1892
|
-
* @param {CursorBlob} cursor - Cursor state to persist
|
|
1893
|
-
* @returns {Promise<void>}
|
|
1894
|
-
*/
|
|
1895
|
-
async function writeSavedCursor(persistence, graphName, name, cursor) {
|
|
1896
|
-
const ref = buildCursorSavedRef(graphName, name);
|
|
1897
|
-
const json = JSON.stringify(cursor);
|
|
1898
|
-
const oid = await persistence.writeBlob(Buffer.from(json, 'utf8'));
|
|
1899
|
-
await persistence.updateRef(ref, oid);
|
|
1900
|
-
}
|
|
1901
|
-
|
|
1902
|
-
/**
|
|
1903
|
-
* Deletes a named saved cursor from Git ref storage.
|
|
1904
|
-
*
|
|
1905
|
-
* No-op if the named cursor does not exist.
|
|
1906
|
-
*
|
|
1907
|
-
* @param {Persistence} persistence - GraphPersistencePort adapter
|
|
1908
|
-
* @param {string} graphName - Name of the WARP graph
|
|
1909
|
-
* @param {string} name - Saved cursor name to delete
|
|
1910
|
-
* @returns {Promise<void>}
|
|
1911
|
-
*/
|
|
1912
|
-
async function deleteSavedCursor(persistence, graphName, name) {
|
|
1913
|
-
const ref = buildCursorSavedRef(graphName, name);
|
|
1914
|
-
const exists = await persistence.readRef(ref);
|
|
1915
|
-
if (exists) {
|
|
1916
|
-
await persistence.deleteRef(ref);
|
|
1917
|
-
}
|
|
1918
|
-
}
|
|
1919
|
-
|
|
1920
|
-
/**
|
|
1921
|
-
* Lists all saved cursors for a graph, reading each blob to include full cursor state.
|
|
1922
|
-
*
|
|
1923
|
-
* @param {Persistence} persistence - GraphPersistencePort adapter
|
|
1924
|
-
* @param {string} graphName - Name of the WARP graph
|
|
1925
|
-
* @returns {Promise<Array<{name: string, tick: number, mode?: string}>>} Array of saved cursors with their names
|
|
1926
|
-
* @throws {Error} If any stored blob is corrupted or not valid JSON
|
|
1927
|
-
*/
|
|
1928
|
-
async function listSavedCursors(persistence, graphName) {
|
|
1929
|
-
const prefix = buildCursorSavedPrefix(graphName);
|
|
1930
|
-
const refs = await persistence.listRefs(prefix);
|
|
1931
|
-
const cursors = [];
|
|
1932
|
-
for (const ref of refs) {
|
|
1933
|
-
const name = ref.slice(prefix.length);
|
|
1934
|
-
if (name) {
|
|
1935
|
-
const oid = await persistence.readRef(ref);
|
|
1936
|
-
if (oid) {
|
|
1937
|
-
const buf = await persistence.readBlob(oid);
|
|
1938
|
-
const cursor = parseCursorBlob(buf, `saved cursor '${name}'`);
|
|
1939
|
-
cursors.push({ name, ...cursor });
|
|
1940
|
-
}
|
|
1941
|
-
}
|
|
1942
|
-
}
|
|
1943
|
-
return cursors;
|
|
1944
|
-
}
|
|
1945
|
-
|
|
1946
|
-
// ============================================================================
|
|
1947
|
-
// Seek Arg Parser
|
|
1948
|
-
// ============================================================================
|
|
1949
|
-
|
|
1950
|
-
/**
|
|
1951
|
-
* @param {string} arg
|
|
1952
|
-
* @param {SeekSpec} spec
|
|
1953
|
-
*/
|
|
1954
|
-
function handleSeekBooleanFlag(arg, spec) {
|
|
1955
|
-
if (arg === '--clear-cache') {
|
|
1956
|
-
if (spec.action !== 'status') {
|
|
1957
|
-
throw usageError('--clear-cache cannot be combined with other seek flags');
|
|
1958
|
-
}
|
|
1959
|
-
spec.action = 'clear-cache';
|
|
1960
|
-
} else if (arg === '--no-persistent-cache') {
|
|
1961
|
-
spec.noPersistentCache = true;
|
|
1962
|
-
} else if (arg === '--diff') {
|
|
1963
|
-
spec.diff = true;
|
|
1964
|
-
}
|
|
1965
|
-
}
|
|
1966
|
-
|
|
1967
|
-
/**
|
|
1968
|
-
* Parses --diff-limit / --diff-limit=N into the seek spec.
|
|
1969
|
-
* @param {string} arg
|
|
1970
|
-
* @param {string[]} args
|
|
1971
|
-
* @param {number} i
|
|
1972
|
-
* @param {SeekSpec} spec
|
|
1973
|
-
*/
|
|
1974
|
-
function handleDiffLimitFlag(arg, args, i, spec) {
|
|
1975
|
-
let raw;
|
|
1976
|
-
if (arg.startsWith('--diff-limit=')) {
|
|
1977
|
-
raw = arg.slice('--diff-limit='.length);
|
|
1978
|
-
} else {
|
|
1979
|
-
raw = args[i + 1];
|
|
1980
|
-
if (raw === undefined) {
|
|
1981
|
-
throw usageError('Missing value for --diff-limit');
|
|
1982
|
-
}
|
|
1983
|
-
}
|
|
1984
|
-
const n = Number(raw);
|
|
1985
|
-
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
|
|
1986
|
-
throw usageError(`Invalid --diff-limit value: ${raw}. Must be a positive integer.`);
|
|
1987
|
-
}
|
|
1988
|
-
spec.diffLimit = n;
|
|
1989
|
-
}
|
|
1990
|
-
|
|
1991
|
-
/**
|
|
1992
|
-
* Parses a named action flag (--save, --load, --drop) with its value.
|
|
1993
|
-
* @param {string} flagName - e.g. 'save'
|
|
1994
|
-
* @param {string} arg - Current arg token
|
|
1995
|
-
* @param {string[]} args - All args
|
|
1996
|
-
* @param {number} i - Current index
|
|
1997
|
-
* @param {SeekSpec} spec
|
|
1998
|
-
* @returns {number} Number of extra args consumed (0 or 1)
|
|
1999
|
-
*/
|
|
2000
|
-
function parseSeekNamedAction(flagName, arg, args, i, spec) {
|
|
2001
|
-
if (spec.action !== 'status') {
|
|
2002
|
-
throw usageError(`--${flagName} cannot be combined with other seek flags`);
|
|
2003
|
-
}
|
|
2004
|
-
spec.action = flagName;
|
|
2005
|
-
if (arg === `--${flagName}`) {
|
|
2006
|
-
const val = args[i + 1];
|
|
2007
|
-
if (val === undefined || val.startsWith('-')) {
|
|
2008
|
-
throw usageError(`Missing name for --${flagName}`);
|
|
2009
|
-
}
|
|
2010
|
-
spec.name = val;
|
|
2011
|
-
return 1;
|
|
2012
|
-
}
|
|
2013
|
-
spec.name = arg.slice(`--${flagName}=`.length);
|
|
2014
|
-
if (!spec.name) {
|
|
2015
|
-
throw usageError(`Missing name for --${flagName}`);
|
|
2016
|
-
}
|
|
2017
|
-
return 0;
|
|
2018
|
-
}
|
|
2019
|
-
|
|
2020
|
-
/**
|
|
2021
|
-
* Parses CLI arguments for the `seek` command into a structured spec.
|
|
2022
|
-
* @param {string[]} args - Raw CLI arguments following the `seek` subcommand
|
|
2023
|
-
* @returns {SeekSpec} Parsed spec
|
|
2024
|
-
*/
|
|
2025
|
-
function parseSeekArgs(args) {
|
|
2026
|
-
/** @type {SeekSpec} */
|
|
2027
|
-
const spec = {
|
|
2028
|
-
action: 'status', // status, tick, latest, save, load, list, drop, clear-cache
|
|
2029
|
-
tickValue: null,
|
|
2030
|
-
name: null,
|
|
2031
|
-
noPersistentCache: false,
|
|
2032
|
-
diff: false,
|
|
2033
|
-
diffLimit: 2000,
|
|
2034
|
-
};
|
|
2035
|
-
let diffLimitProvided = false;
|
|
2036
|
-
|
|
2037
|
-
for (let i = 0; i < args.length; i++) {
|
|
2038
|
-
const arg = args[i];
|
|
2039
|
-
|
|
2040
|
-
if (arg === '--tick') {
|
|
2041
|
-
if (spec.action !== 'status') {
|
|
2042
|
-
throw usageError('--tick cannot be combined with other seek flags');
|
|
2043
|
-
}
|
|
2044
|
-
spec.action = 'tick';
|
|
2045
|
-
const val = args[i + 1];
|
|
2046
|
-
if (val === undefined) {
|
|
2047
|
-
throw usageError('Missing value for --tick');
|
|
2048
|
-
}
|
|
2049
|
-
spec.tickValue = val;
|
|
2050
|
-
i += 1;
|
|
2051
|
-
} else if (arg.startsWith('--tick=')) {
|
|
2052
|
-
if (spec.action !== 'status') {
|
|
2053
|
-
throw usageError('--tick cannot be combined with other seek flags');
|
|
2054
|
-
}
|
|
2055
|
-
spec.action = 'tick';
|
|
2056
|
-
spec.tickValue = arg.slice('--tick='.length);
|
|
2057
|
-
} else if (arg === '--latest') {
|
|
2058
|
-
if (spec.action !== 'status') {
|
|
2059
|
-
throw usageError('--latest cannot be combined with other seek flags');
|
|
2060
|
-
}
|
|
2061
|
-
spec.action = 'latest';
|
|
2062
|
-
} else if (arg === '--save' || arg.startsWith('--save=')) {
|
|
2063
|
-
i += parseSeekNamedAction('save', arg, args, i, spec);
|
|
2064
|
-
} else if (arg === '--load' || arg.startsWith('--load=')) {
|
|
2065
|
-
i += parseSeekNamedAction('load', arg, args, i, spec);
|
|
2066
|
-
} else if (arg === '--list') {
|
|
2067
|
-
if (spec.action !== 'status') {
|
|
2068
|
-
throw usageError('--list cannot be combined with other seek flags');
|
|
2069
|
-
}
|
|
2070
|
-
spec.action = 'list';
|
|
2071
|
-
} else if (arg === '--drop' || arg.startsWith('--drop=')) {
|
|
2072
|
-
i += parseSeekNamedAction('drop', arg, args, i, spec);
|
|
2073
|
-
} else if (arg === '--clear-cache' || arg === '--no-persistent-cache' || arg === '--diff') {
|
|
2074
|
-
handleSeekBooleanFlag(arg, spec);
|
|
2075
|
-
} else if (arg === '--diff-limit' || arg.startsWith('--diff-limit=')) {
|
|
2076
|
-
handleDiffLimitFlag(arg, args, i, spec);
|
|
2077
|
-
diffLimitProvided = true;
|
|
2078
|
-
if (arg === '--diff-limit') {
|
|
2079
|
-
i += 1;
|
|
2080
|
-
}
|
|
2081
|
-
} else if (arg.startsWith('-')) {
|
|
2082
|
-
throw usageError(`Unknown seek option: ${arg}`);
|
|
2083
|
-
}
|
|
2084
|
-
}
|
|
2085
|
-
|
|
2086
|
-
// --diff is only meaningful for actions that navigate to a tick
|
|
2087
|
-
const DIFF_ACTIONS = new Set(['tick', 'latest', 'load']);
|
|
2088
|
-
if (spec.diff && !DIFF_ACTIONS.has(spec.action)) {
|
|
2089
|
-
throw usageError(`--diff cannot be used with --${spec.action}`);
|
|
2090
|
-
}
|
|
2091
|
-
if (diffLimitProvided && !spec.diff) {
|
|
2092
|
-
throw usageError('--diff-limit requires --diff');
|
|
2093
|
-
}
|
|
2094
|
-
|
|
2095
|
-
return spec;
|
|
2096
|
-
}
|
|
2097
|
-
|
|
2098
|
-
/**
|
|
2099
|
-
* Resolves a tick value (absolute or relative +N/-N) against available ticks.
|
|
2100
|
-
*
|
|
2101
|
-
* For relative values, steps through the sorted tick array (with 0 prepended
|
|
2102
|
-
* as a virtual "empty state" position) by the given delta from the current
|
|
2103
|
-
* position. For absolute values, clamps to maxTick.
|
|
2104
|
-
*
|
|
2105
|
-
* @private
|
|
2106
|
-
* @param {string} tickValue - Raw tick string from CLI args (e.g. "5", "+1", "-2")
|
|
2107
|
-
* @param {number|null} currentTick - Current cursor tick, or null if no active cursor
|
|
2108
|
-
* @param {number[]} ticks - Sorted ascending array of available Lamport ticks
|
|
2109
|
-
* @param {number} maxTick - Maximum tick across all writers
|
|
2110
|
-
* @returns {number} Resolved tick value (clamped to valid range)
|
|
2111
|
-
* @throws {CliError} If tickValue is not a valid integer or relative delta
|
|
2112
|
-
*/
|
|
2113
|
-
function resolveTickValue(tickValue, currentTick, ticks, maxTick) {
|
|
2114
|
-
// Relative: +N or -N
|
|
2115
|
-
if (tickValue.startsWith('+') || tickValue.startsWith('-')) {
|
|
2116
|
-
const delta = parseInt(tickValue, 10);
|
|
2117
|
-
if (!Number.isInteger(delta)) {
|
|
2118
|
-
throw usageError(`Invalid tick delta: ${tickValue}`);
|
|
2119
|
-
}
|
|
2120
|
-
const base = currentTick ?? 0;
|
|
2121
|
-
|
|
2122
|
-
// Find the current position in sorted ticks, then step by delta
|
|
2123
|
-
// Include tick 0 as a virtual "empty state" position (avoid duplicating if already present)
|
|
2124
|
-
const allPoints = (ticks.length > 0 && ticks[0] === 0) ? [...ticks] : [0, ...ticks];
|
|
2125
|
-
const currentIdx = allPoints.indexOf(base);
|
|
2126
|
-
const startIdx = currentIdx === -1 ? 0 : currentIdx;
|
|
2127
|
-
const targetIdx = Math.max(0, Math.min(allPoints.length - 1, startIdx + delta));
|
|
2128
|
-
return allPoints[targetIdx];
|
|
2129
|
-
}
|
|
2130
|
-
|
|
2131
|
-
// Absolute
|
|
2132
|
-
const n = parseInt(tickValue, 10);
|
|
2133
|
-
if (!Number.isInteger(n) || n < 0) {
|
|
2134
|
-
throw usageError(`Invalid tick value: ${tickValue}. Must be a non-negative integer, or +N/-N for relative.`);
|
|
2135
|
-
}
|
|
2136
|
-
|
|
2137
|
-
// Clamp to maxTick
|
|
2138
|
-
return Math.min(n, maxTick);
|
|
2139
|
-
}
|
|
2140
|
-
|
|
2141
|
-
// ============================================================================
|
|
2142
|
-
// Seek Handler
|
|
2143
|
-
// ============================================================================
|
|
2144
|
-
|
|
2145
|
-
/**
|
|
2146
|
-
* @param {WarpGraphInstance} graph
|
|
2147
|
-
* @param {Persistence} persistence
|
|
2148
|
-
* @param {string} graphName
|
|
2149
|
-
* @param {SeekSpec} seekSpec
|
|
2150
|
-
*/
|
|
2151
|
-
function wireSeekCache(graph, persistence, graphName, seekSpec) {
|
|
2152
|
-
if (seekSpec.noPersistentCache) {
|
|
2153
|
-
return;
|
|
2154
|
-
}
|
|
2155
|
-
graph.setSeekCache(new CasSeekCacheAdapter({
|
|
2156
|
-
persistence,
|
|
2157
|
-
plumbing: persistence.plumbing,
|
|
2158
|
-
graphName,
|
|
2159
|
-
}));
|
|
2160
|
-
}
|
|
2161
|
-
|
|
2162
|
-
/**
|
|
2163
|
-
* Handles the `git warp seek` command across all sub-actions.
|
|
2164
|
-
* @param {{options: CliOptions, args: string[]}} params
|
|
2165
|
-
* @returns {Promise<{payload: *, exitCode: number}>}
|
|
2166
|
-
*/
|
|
2167
|
-
async function handleSeek({ options, args }) {
|
|
2168
|
-
const seekSpec = parseSeekArgs(args);
|
|
2169
|
-
const { graph, graphName, persistence } = await openGraph(options);
|
|
2170
|
-
void wireSeekCache(graph, persistence, graphName, seekSpec);
|
|
2171
|
-
|
|
2172
|
-
// Handle --clear-cache before discovering ticks (no materialization needed)
|
|
2173
|
-
if (seekSpec.action === 'clear-cache') {
|
|
2174
|
-
if (graph.seekCache) {
|
|
2175
|
-
await graph.seekCache.clear();
|
|
2176
|
-
}
|
|
2177
|
-
return {
|
|
2178
|
-
payload: { graph: graphName, action: 'clear-cache', message: 'Seek cache cleared.' },
|
|
2179
|
-
exitCode: EXIT_CODES.OK,
|
|
2180
|
-
};
|
|
2181
|
-
}
|
|
2182
|
-
|
|
2183
|
-
const activeCursor = await readActiveCursor(persistence, graphName);
|
|
2184
|
-
const { ticks, maxTick, perWriter } = await graph.discoverTicks();
|
|
2185
|
-
const frontierHash = computeFrontierHash(perWriter);
|
|
2186
|
-
if (seekSpec.action === 'list') {
|
|
2187
|
-
const saved = await listSavedCursors(persistence, graphName);
|
|
2188
|
-
return {
|
|
2189
|
-
payload: {
|
|
2190
|
-
graph: graphName,
|
|
2191
|
-
action: 'list',
|
|
2192
|
-
cursors: saved,
|
|
2193
|
-
activeTick: activeCursor ? activeCursor.tick : null,
|
|
2194
|
-
maxTick,
|
|
2195
|
-
},
|
|
2196
|
-
exitCode: EXIT_CODES.OK,
|
|
2197
|
-
};
|
|
2198
|
-
}
|
|
2199
|
-
if (seekSpec.action === 'drop') {
|
|
2200
|
-
const dropName = /** @type {string} */ (seekSpec.name);
|
|
2201
|
-
const existing = await readSavedCursor(persistence, graphName, dropName);
|
|
2202
|
-
if (!existing) {
|
|
2203
|
-
throw notFoundError(`Saved cursor not found: ${dropName}`);
|
|
2204
|
-
}
|
|
2205
|
-
await deleteSavedCursor(persistence, graphName, dropName);
|
|
2206
|
-
return {
|
|
2207
|
-
payload: {
|
|
2208
|
-
graph: graphName,
|
|
2209
|
-
action: 'drop',
|
|
2210
|
-
name: seekSpec.name,
|
|
2211
|
-
tick: existing.tick,
|
|
2212
|
-
},
|
|
2213
|
-
exitCode: EXIT_CODES.OK,
|
|
2214
|
-
};
|
|
2215
|
-
}
|
|
2216
|
-
if (seekSpec.action === 'latest') {
|
|
2217
|
-
const prevTick = activeCursor ? activeCursor.tick : null;
|
|
2218
|
-
let sdResult = null;
|
|
2219
|
-
if (seekSpec.diff) {
|
|
2220
|
-
sdResult = await computeStructuralDiff({ graph, prevTick, currentTick: maxTick, diffLimit: seekSpec.diffLimit });
|
|
2221
|
-
}
|
|
2222
|
-
await clearActiveCursor(persistence, graphName);
|
|
2223
|
-
// When --diff already materialized at maxTick, skip redundant re-materialize
|
|
2224
|
-
if (!sdResult) {
|
|
2225
|
-
await graph.materialize({ ceiling: maxTick });
|
|
2226
|
-
}
|
|
2227
|
-
const nodes = await graph.getNodes();
|
|
2228
|
-
const edges = await graph.getEdges();
|
|
2229
|
-
const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
|
|
2230
|
-
const tickReceipt = await buildTickReceipt({ tick: maxTick, perWriter, graph });
|
|
2231
|
-
return {
|
|
2232
|
-
payload: {
|
|
2233
|
-
graph: graphName,
|
|
2234
|
-
action: 'latest',
|
|
2235
|
-
tick: maxTick,
|
|
2236
|
-
maxTick,
|
|
2237
|
-
ticks,
|
|
2238
|
-
nodes: nodes.length,
|
|
2239
|
-
edges: edges.length,
|
|
2240
|
-
perWriter: serializePerWriter(perWriter),
|
|
2241
|
-
patchCount: countPatchesAtTick(maxTick, perWriter),
|
|
2242
|
-
diff,
|
|
2243
|
-
tickReceipt,
|
|
2244
|
-
cursor: { active: false },
|
|
2245
|
-
...sdResult,
|
|
2246
|
-
},
|
|
2247
|
-
exitCode: EXIT_CODES.OK,
|
|
2248
|
-
};
|
|
2249
|
-
}
|
|
2250
|
-
if (seekSpec.action === 'save') {
|
|
2251
|
-
if (!activeCursor) {
|
|
2252
|
-
throw usageError('No active cursor to save. Use --tick first.');
|
|
2253
|
-
}
|
|
2254
|
-
await writeSavedCursor(persistence, graphName, /** @type {string} */ (seekSpec.name), activeCursor);
|
|
2255
|
-
return {
|
|
2256
|
-
payload: {
|
|
2257
|
-
graph: graphName,
|
|
2258
|
-
action: 'save',
|
|
2259
|
-
name: seekSpec.name,
|
|
2260
|
-
tick: activeCursor.tick,
|
|
2261
|
-
},
|
|
2262
|
-
exitCode: EXIT_CODES.OK,
|
|
2263
|
-
};
|
|
2264
|
-
}
|
|
2265
|
-
if (seekSpec.action === 'load') {
|
|
2266
|
-
const loadName = /** @type {string} */ (seekSpec.name);
|
|
2267
|
-
const saved = await readSavedCursor(persistence, graphName, loadName);
|
|
2268
|
-
if (!saved) {
|
|
2269
|
-
throw notFoundError(`Saved cursor not found: ${loadName}`);
|
|
2270
|
-
}
|
|
2271
|
-
const prevTick = activeCursor ? activeCursor.tick : null;
|
|
2272
|
-
let sdResult = null;
|
|
2273
|
-
if (seekSpec.diff) {
|
|
2274
|
-
sdResult = await computeStructuralDiff({ graph, prevTick, currentTick: saved.tick, diffLimit: seekSpec.diffLimit });
|
|
2275
|
-
}
|
|
2276
|
-
// When --diff already materialized at saved.tick, skip redundant call
|
|
2277
|
-
if (!sdResult) {
|
|
2278
|
-
await graph.materialize({ ceiling: saved.tick });
|
|
2279
|
-
}
|
|
2280
|
-
const nodes = await graph.getNodes();
|
|
2281
|
-
const edges = await graph.getEdges();
|
|
2282
|
-
await writeActiveCursor(persistence, graphName, { tick: saved.tick, mode: saved.mode ?? 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
|
|
2283
|
-
const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
|
|
2284
|
-
const tickReceipt = await buildTickReceipt({ tick: saved.tick, perWriter, graph });
|
|
2285
|
-
return {
|
|
2286
|
-
payload: {
|
|
2287
|
-
graph: graphName,
|
|
2288
|
-
action: 'load',
|
|
2289
|
-
name: seekSpec.name,
|
|
2290
|
-
tick: saved.tick,
|
|
2291
|
-
maxTick,
|
|
2292
|
-
ticks,
|
|
2293
|
-
nodes: nodes.length,
|
|
2294
|
-
edges: edges.length,
|
|
2295
|
-
perWriter: serializePerWriter(perWriter),
|
|
2296
|
-
patchCount: countPatchesAtTick(saved.tick, perWriter),
|
|
2297
|
-
diff,
|
|
2298
|
-
tickReceipt,
|
|
2299
|
-
cursor: { active: true, mode: saved.mode, tick: saved.tick, maxTick, name: seekSpec.name },
|
|
2300
|
-
...sdResult,
|
|
2301
|
-
},
|
|
2302
|
-
exitCode: EXIT_CODES.OK,
|
|
2303
|
-
};
|
|
2304
|
-
}
|
|
2305
|
-
if (seekSpec.action === 'tick') {
|
|
2306
|
-
const currentTick = activeCursor ? activeCursor.tick : null;
|
|
2307
|
-
const resolvedTick = resolveTickValue(/** @type {string} */ (seekSpec.tickValue), currentTick, ticks, maxTick);
|
|
2308
|
-
let sdResult = null;
|
|
2309
|
-
if (seekSpec.diff) {
|
|
2310
|
-
sdResult = await computeStructuralDiff({ graph, prevTick: currentTick, currentTick: resolvedTick, diffLimit: seekSpec.diffLimit });
|
|
2311
|
-
}
|
|
2312
|
-
// When --diff already materialized at resolvedTick, skip redundant call
|
|
2313
|
-
if (!sdResult) {
|
|
2314
|
-
await graph.materialize({ ceiling: resolvedTick });
|
|
2315
|
-
}
|
|
2316
|
-
const nodes = await graph.getNodes();
|
|
2317
|
-
const edges = await graph.getEdges();
|
|
2318
|
-
await writeActiveCursor(persistence, graphName, { tick: resolvedTick, mode: 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
|
|
2319
|
-
const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
|
|
2320
|
-
const tickReceipt = await buildTickReceipt({ tick: resolvedTick, perWriter, graph });
|
|
2321
|
-
return {
|
|
2322
|
-
payload: {
|
|
2323
|
-
graph: graphName,
|
|
2324
|
-
action: 'tick',
|
|
2325
|
-
tick: resolvedTick,
|
|
2326
|
-
maxTick,
|
|
2327
|
-
ticks,
|
|
2328
|
-
nodes: nodes.length,
|
|
2329
|
-
edges: edges.length,
|
|
2330
|
-
perWriter: serializePerWriter(perWriter),
|
|
2331
|
-
patchCount: countPatchesAtTick(resolvedTick, perWriter),
|
|
2332
|
-
diff,
|
|
2333
|
-
tickReceipt,
|
|
2334
|
-
cursor: { active: true, mode: 'lamport', tick: resolvedTick, maxTick, name: 'active' },
|
|
2335
|
-
...sdResult,
|
|
2336
|
-
},
|
|
2337
|
-
exitCode: EXIT_CODES.OK,
|
|
2338
|
-
};
|
|
2339
|
-
}
|
|
2340
|
-
|
|
2341
|
-
// status (bare seek)
|
|
2342
|
-
return await handleSeekStatus({ graph, graphName, persistence, activeCursor, ticks, maxTick, perWriter, frontierHash });
|
|
2343
|
-
}
|
|
2344
|
-
|
|
2345
|
-
/**
|
|
2346
|
-
* Handles the `status` sub-action of `seek` (bare seek with no action flag).
|
|
2347
|
-
* @param {{graph: WarpGraphInstance, graphName: string, persistence: Persistence, activeCursor: CursorBlob|null, ticks: number[], maxTick: number, perWriter: Map<string, WriterTickInfo>, frontierHash: string}} params
|
|
2348
|
-
* @returns {Promise<{payload: *, exitCode: number}>}
|
|
2349
|
-
*/
|
|
2350
|
-
async function handleSeekStatus({ graph, graphName, persistence, activeCursor, ticks, maxTick, perWriter, frontierHash }) {
|
|
2351
|
-
if (activeCursor) {
|
|
2352
|
-
await graph.materialize({ ceiling: activeCursor.tick });
|
|
2353
|
-
const nodes = await graph.getNodes();
|
|
2354
|
-
const edges = await graph.getEdges();
|
|
2355
|
-
const prevCounts = readSeekCounts(activeCursor);
|
|
2356
|
-
const prevFrontierHash = typeof activeCursor.frontierHash === 'string' ? activeCursor.frontierHash : null;
|
|
2357
|
-
if (prevCounts.nodes === null || prevCounts.edges === null || prevCounts.nodes !== nodes.length || prevCounts.edges !== edges.length || prevFrontierHash !== frontierHash) {
|
|
2358
|
-
await writeActiveCursor(persistence, graphName, { tick: activeCursor.tick, mode: activeCursor.mode ?? 'lamport', nodes: nodes.length, edges: edges.length, frontierHash });
|
|
2359
|
-
}
|
|
2360
|
-
const diff = computeSeekStateDiff(activeCursor, { nodes: nodes.length, edges: edges.length }, frontierHash);
|
|
2361
|
-
const tickReceipt = await buildTickReceipt({ tick: activeCursor.tick, perWriter, graph });
|
|
2362
|
-
return {
|
|
2363
|
-
payload: {
|
|
2364
|
-
graph: graphName,
|
|
2365
|
-
action: 'status',
|
|
2366
|
-
tick: activeCursor.tick,
|
|
2367
|
-
maxTick,
|
|
2368
|
-
ticks,
|
|
2369
|
-
nodes: nodes.length,
|
|
2370
|
-
edges: edges.length,
|
|
2371
|
-
perWriter: serializePerWriter(perWriter),
|
|
2372
|
-
patchCount: countPatchesAtTick(activeCursor.tick, perWriter),
|
|
2373
|
-
diff,
|
|
2374
|
-
tickReceipt,
|
|
2375
|
-
cursor: { active: true, mode: activeCursor.mode, tick: activeCursor.tick, maxTick, name: 'active' },
|
|
2376
|
-
},
|
|
2377
|
-
exitCode: EXIT_CODES.OK,
|
|
2378
|
-
};
|
|
2379
|
-
}
|
|
2380
|
-
await graph.materialize();
|
|
2381
|
-
const nodes = await graph.getNodes();
|
|
2382
|
-
const edges = await graph.getEdges();
|
|
2383
|
-
const tickReceipt = await buildTickReceipt({ tick: maxTick, perWriter, graph });
|
|
2384
|
-
return {
|
|
2385
|
-
payload: {
|
|
2386
|
-
graph: graphName,
|
|
2387
|
-
action: 'status',
|
|
2388
|
-
tick: maxTick,
|
|
2389
|
-
maxTick,
|
|
2390
|
-
ticks,
|
|
2391
|
-
nodes: nodes.length,
|
|
2392
|
-
edges: edges.length,
|
|
2393
|
-
perWriter: serializePerWriter(perWriter),
|
|
2394
|
-
patchCount: countPatchesAtTick(maxTick, perWriter),
|
|
2395
|
-
diff: null,
|
|
2396
|
-
tickReceipt,
|
|
2397
|
-
cursor: { active: false },
|
|
2398
|
-
},
|
|
2399
|
-
exitCode: EXIT_CODES.OK,
|
|
2400
|
-
};
|
|
2401
|
-
}
|
|
2402
|
-
|
|
2403
|
-
/**
|
|
2404
|
-
* Converts the per-writer Map from discoverTicks() into a plain object for JSON output.
|
|
2405
|
-
*
|
|
2406
|
-
* @param {Map<string, WriterTickInfo>} perWriter - Per-writer tick data
|
|
2407
|
-
* @returns {Record<string, WriterTickInfo>} Plain object keyed by writer ID
|
|
2408
|
-
*/
|
|
2409
|
-
function serializePerWriter(perWriter) {
|
|
2410
|
-
/** @type {Record<string, WriterTickInfo>} */
|
|
2411
|
-
const result = {};
|
|
2412
|
-
for (const [writerId, info] of perWriter) {
|
|
2413
|
-
result[writerId] = { ticks: info.ticks, tipSha: info.tipSha, tickShas: info.tickShas };
|
|
2414
|
-
}
|
|
2415
|
-
return result;
|
|
2416
|
-
}
|
|
2417
|
-
|
|
2418
|
-
/**
|
|
2419
|
-
* Counts the total number of patches across all writers at or before the given tick.
|
|
2420
|
-
*
|
|
2421
|
-
* @param {number} tick - Lamport tick ceiling (inclusive)
|
|
2422
|
-
* @param {Map<string, WriterTickInfo>} perWriter - Per-writer tick data
|
|
2423
|
-
* @returns {number} Total patch count at or before the given tick
|
|
2424
|
-
*/
|
|
2425
|
-
function countPatchesAtTick(tick, perWriter) {
|
|
2426
|
-
let count = 0;
|
|
2427
|
-
for (const [, info] of perWriter) {
|
|
2428
|
-
for (const t of info.ticks) {
|
|
2429
|
-
if (t <= tick) {
|
|
2430
|
-
count++;
|
|
2431
|
-
}
|
|
2432
|
-
}
|
|
2433
|
-
}
|
|
2434
|
-
return count;
|
|
2435
|
-
}
|
|
2436
|
-
|
|
2437
|
-
/**
|
|
2438
|
-
* Computes a stable fingerprint of the current graph frontier (writer tips).
|
|
2439
|
-
*
|
|
2440
|
-
* Used to suppress seek diffs when graph history may have changed since the
|
|
2441
|
-
* previous cursor snapshot (e.g. new writers/patches, rewritten refs).
|
|
2442
|
-
*
|
|
2443
|
-
* @param {Map<string, WriterTickInfo>} perWriter - Per-writer metadata from discoverTicks()
|
|
2444
|
-
* @returns {string} Hex digest of the frontier fingerprint
|
|
2445
|
-
*/
|
|
2446
|
-
function computeFrontierHash(perWriter) {
|
|
2447
|
-
/** @type {Record<string, string|null>} */
|
|
2448
|
-
const tips = {};
|
|
2449
|
-
for (const [writerId, info] of perWriter) {
|
|
2450
|
-
tips[writerId] = info?.tipSha || null;
|
|
2451
|
-
}
|
|
2452
|
-
return crypto.createHash('sha256').update(stableStringify(tips)).digest('hex');
|
|
2453
|
-
}
|
|
2454
|
-
|
|
2455
|
-
/**
|
|
2456
|
-
* Reads cached seek state counts from a cursor blob.
|
|
2457
|
-
*
|
|
2458
|
-
* Counts may be missing for older cursors (pre-diff support). In that case
|
|
2459
|
-
* callers should treat the counts as unknown and suppress diffs.
|
|
2460
|
-
*
|
|
2461
|
-
* @param {CursorBlob|null} cursor - Parsed cursor blob object
|
|
2462
|
-
* @returns {{nodes: number|null, edges: number|null}} Parsed counts
|
|
2463
|
-
*/
|
|
2464
|
-
function readSeekCounts(cursor) {
|
|
2465
|
-
if (!cursor || typeof cursor !== 'object') {
|
|
2466
|
-
return { nodes: null, edges: null };
|
|
2467
|
-
}
|
|
2468
|
-
|
|
2469
|
-
const nodes = typeof cursor.nodes === 'number' && Number.isFinite(cursor.nodes) ? cursor.nodes : null;
|
|
2470
|
-
const edges = typeof cursor.edges === 'number' && Number.isFinite(cursor.edges) ? cursor.edges : null;
|
|
2471
|
-
return { nodes, edges };
|
|
2472
|
-
}
|
|
2473
|
-
|
|
2474
|
-
/**
|
|
2475
|
-
* Computes node/edge deltas between the current seek position and the previous cursor.
|
|
2476
|
-
*
|
|
2477
|
-
* Returns null if the previous cursor is missing cached counts.
|
|
2478
|
-
*
|
|
2479
|
-
* @param {CursorBlob|null} prevCursor - Cursor object read before updating the position
|
|
2480
|
-
* @param {{nodes: number, edges: number}} next - Current materialized counts
|
|
2481
|
-
* @param {string} frontierHash - Frontier fingerprint of the current graph
|
|
2482
|
-
* @returns {{nodes: number, edges: number}|null} Diff object or null when unknown
|
|
2483
|
-
*/
|
|
2484
|
-
function computeSeekStateDiff(prevCursor, next, frontierHash) {
|
|
2485
|
-
const prev = readSeekCounts(prevCursor);
|
|
2486
|
-
if (prev.nodes === null || prev.edges === null) {
|
|
2487
|
-
return null;
|
|
2488
|
-
}
|
|
2489
|
-
const prevFrontierHash = typeof prevCursor?.frontierHash === 'string' ? prevCursor.frontierHash : null;
|
|
2490
|
-
if (!prevFrontierHash || prevFrontierHash !== frontierHash) {
|
|
2491
|
-
return null;
|
|
2492
|
-
}
|
|
2493
|
-
return {
|
|
2494
|
-
nodes: next.nodes - prev.nodes,
|
|
2495
|
-
edges: next.edges - prev.edges,
|
|
2496
|
-
};
|
|
2497
|
-
}
|
|
2498
|
-
|
|
2499
|
-
/**
|
|
2500
|
-
* Builds a per-writer operation summary for patches at an exact tick.
|
|
2501
|
-
*
|
|
2502
|
-
* Uses discoverTicks() tickShas mapping to locate patch SHAs, then loads and
|
|
2503
|
-
* summarizes patch ops. Typically only a handful of writers have a patch at any
|
|
2504
|
-
* single Lamport tick.
|
|
2505
|
-
*
|
|
2506
|
-
* @param {{tick: number, perWriter: Map<string, WriterTickInfo>, graph: WarpGraphInstance}} params
|
|
2507
|
-
* @returns {Promise<Record<string, {sha: string, opSummary: *}>|null>} Map of writerId to { sha, opSummary }, or null if empty
|
|
2508
|
-
*/
|
|
2509
|
-
async function buildTickReceipt({ tick, perWriter, graph }) {
|
|
2510
|
-
if (!Number.isInteger(tick) || tick <= 0) {
|
|
2511
|
-
return null;
|
|
2512
|
-
}
|
|
2513
|
-
|
|
2514
|
-
/** @type {Record<string, {sha: string, opSummary: *}>} */
|
|
2515
|
-
const receipt = {};
|
|
2516
|
-
|
|
2517
|
-
for (const [writerId, info] of perWriter) {
|
|
2518
|
-
const sha = /** @type {*} */ (info?.tickShas)?.[tick]; // TODO(ts-cleanup): type CLI payload
|
|
2519
|
-
if (!sha) {
|
|
2520
|
-
continue;
|
|
2521
|
-
}
|
|
2522
|
-
|
|
2523
|
-
const patch = await graph.loadPatchBySha(sha);
|
|
2524
|
-
const ops = Array.isArray(patch?.ops) ? patch.ops : [];
|
|
2525
|
-
receipt[writerId] = { sha, opSummary: summarizeOps(ops) };
|
|
2526
|
-
}
|
|
2527
|
-
|
|
2528
|
-
return Object.keys(receipt).length > 0 ? receipt : null;
|
|
2529
|
-
}
|
|
2530
|
-
|
|
2531
|
-
/**
|
|
2532
|
-
* Computes a structural diff between the state at a previous tick and
|
|
2533
|
-
* the state at the current tick.
|
|
2534
|
-
*
|
|
2535
|
-
* Materializes the baseline tick first, snapshots the state, then
|
|
2536
|
-
* materializes the target tick and calls diffStates() between the two.
|
|
2537
|
-
* Applies diffLimit truncation when the total change count exceeds the cap.
|
|
2538
|
-
*
|
|
2539
|
-
* @param {{graph: WarpGraphInstance, prevTick: number|null, currentTick: number, diffLimit: number}} params
|
|
2540
|
-
* @returns {Promise<{structuralDiff: *, diffBaseline: string, baselineTick: number|null, truncated: boolean, totalChanges: number, shownChanges: number}>}
|
|
2541
|
-
*/
|
|
2542
|
-
async function computeStructuralDiff({ graph, prevTick, currentTick, diffLimit }) {
|
|
2543
|
-
let beforeState = null;
|
|
2544
|
-
let diffBaseline = 'empty';
|
|
2545
|
-
let baselineTick = null;
|
|
2546
|
-
|
|
2547
|
-
// Short-circuit: same tick produces an empty diff
|
|
2548
|
-
if (prevTick !== null && prevTick === currentTick) {
|
|
2549
|
-
const empty = { nodes: { added: [], removed: [] }, edges: { added: [], removed: [] }, props: { set: [], removed: [] } };
|
|
2550
|
-
return { structuralDiff: empty, diffBaseline: 'tick', baselineTick: prevTick, truncated: false, totalChanges: 0, shownChanges: 0 };
|
|
2551
|
-
}
|
|
2552
|
-
|
|
2553
|
-
if (prevTick !== null && prevTick > 0) {
|
|
2554
|
-
await graph.materialize({ ceiling: prevTick });
|
|
2555
|
-
beforeState = await graph.getStateSnapshot();
|
|
2556
|
-
diffBaseline = 'tick';
|
|
2557
|
-
baselineTick = prevTick;
|
|
2558
|
-
}
|
|
2559
|
-
|
|
2560
|
-
await graph.materialize({ ceiling: currentTick });
|
|
2561
|
-
const afterState = /** @type {*} */ (await graph.getStateSnapshot()); // TODO(ts-cleanup): narrow WarpStateV5
|
|
2562
|
-
const diff = diffStates(beforeState, afterState);
|
|
2563
|
-
|
|
2564
|
-
return applyDiffLimit(diff, diffBaseline, baselineTick, diffLimit);
|
|
2565
|
-
}
|
|
2566
|
-
|
|
2567
|
-
/**
|
|
2568
|
-
* Applies truncation limits to a structural diff result.
|
|
2569
|
-
*
|
|
2570
|
-
* @param {*} diff
|
|
2571
|
-
* @param {string} diffBaseline
|
|
2572
|
-
* @param {number|null} baselineTick
|
|
2573
|
-
* @param {number} diffLimit
|
|
2574
|
-
* @returns {{structuralDiff: *, diffBaseline: string, baselineTick: number|null, truncated: boolean, totalChanges: number, shownChanges: number}}
|
|
2575
|
-
*/
|
|
2576
|
-
function applyDiffLimit(diff, diffBaseline, baselineTick, diffLimit) {
|
|
2577
|
-
const totalChanges =
|
|
2578
|
-
diff.nodes.added.length + diff.nodes.removed.length +
|
|
2579
|
-
diff.edges.added.length + diff.edges.removed.length +
|
|
2580
|
-
diff.props.set.length + diff.props.removed.length;
|
|
2581
|
-
|
|
2582
|
-
if (totalChanges <= diffLimit) {
|
|
2583
|
-
return { structuralDiff: diff, diffBaseline, baselineTick, truncated: false, totalChanges, shownChanges: totalChanges };
|
|
2584
|
-
}
|
|
2585
|
-
|
|
2586
|
-
// Truncate sequentially (nodes → edges → props), keeping sort order within each category
|
|
2587
|
-
let remaining = diffLimit;
|
|
2588
|
-
const cap = (/** @type {any[]} */ arr) => {
|
|
2589
|
-
const take = Math.min(arr.length, remaining);
|
|
2590
|
-
remaining -= take;
|
|
2591
|
-
return arr.slice(0, take);
|
|
2592
|
-
};
|
|
2593
|
-
|
|
2594
|
-
const capped = {
|
|
2595
|
-
nodes: { added: cap(diff.nodes.added), removed: cap(diff.nodes.removed) },
|
|
2596
|
-
edges: { added: cap(diff.edges.added), removed: cap(diff.edges.removed) },
|
|
2597
|
-
props: { set: cap(diff.props.set), removed: cap(diff.props.removed) },
|
|
2598
|
-
};
|
|
2599
|
-
|
|
2600
|
-
const shownChanges = diffLimit - remaining;
|
|
2601
|
-
return { structuralDiff: capped, diffBaseline, baselineTick, truncated: true, totalChanges, shownChanges };
|
|
2602
|
-
}
|
|
2603
|
-
|
|
2604
|
-
/**
|
|
2605
|
-
* Renders a seek command payload as a human-readable string for terminal output.
|
|
2606
|
-
*
|
|
2607
|
-
* Handles all seek actions: list, drop, save, latest, load, tick, and status.
|
|
2608
|
-
*
|
|
2609
|
-
* @param {*} payload - Seek result payload from handleSeek
|
|
2610
|
-
* @returns {string} Formatted output string (includes trailing newline)
|
|
2611
|
-
*/
|
|
2612
|
-
function renderSeek(payload) {
|
|
2613
|
-
const formatDelta = (/** @type {*} */ n) => { // TODO(ts-cleanup): type CLI payload
|
|
2614
|
-
if (typeof n !== 'number' || !Number.isFinite(n) || n === 0) {
|
|
2615
|
-
return '';
|
|
2616
|
-
}
|
|
2617
|
-
const sign = n > 0 ? '+' : '';
|
|
2618
|
-
return ` (${sign}${n})`;
|
|
2619
|
-
};
|
|
2620
|
-
|
|
2621
|
-
const formatOpSummaryPlain = (/** @type {*} */ summary) => { // TODO(ts-cleanup): type CLI payload
|
|
2622
|
-
const order = [
|
|
2623
|
-
['NodeAdd', '+', 'node'],
|
|
2624
|
-
['EdgeAdd', '+', 'edge'],
|
|
2625
|
-
['PropSet', '~', 'prop'],
|
|
2626
|
-
['NodeTombstone', '-', 'node'],
|
|
2627
|
-
['EdgeTombstone', '-', 'edge'],
|
|
2628
|
-
['BlobValue', '+', 'blob'],
|
|
2629
|
-
];
|
|
2630
|
-
|
|
2631
|
-
const parts = [];
|
|
2632
|
-
for (const [opType, symbol, label] of order) {
|
|
2633
|
-
const n = summary?.[opType];
|
|
2634
|
-
if (typeof n === 'number' && Number.isFinite(n) && n > 0) {
|
|
2635
|
-
parts.push(`${symbol}${n}${label}`);
|
|
2636
|
-
}
|
|
2637
|
-
}
|
|
2638
|
-
return parts.length > 0 ? parts.join(' ') : '(empty)';
|
|
2639
|
-
};
|
|
2640
|
-
|
|
2641
|
-
const appendReceiptSummary = (/** @type {string} */ baseLine) => {
|
|
2642
|
-
const tickReceipt = payload?.tickReceipt;
|
|
2643
|
-
if (!tickReceipt || typeof tickReceipt !== 'object') {
|
|
2644
|
-
return `${baseLine}\n`;
|
|
2645
|
-
}
|
|
2646
|
-
|
|
2647
|
-
const entries = Object.entries(tickReceipt)
|
|
2648
|
-
.filter(([writerId, entry]) => writerId && entry && typeof entry === 'object')
|
|
2649
|
-
.sort(([a], [b]) => a.localeCompare(b));
|
|
2650
|
-
|
|
2651
|
-
if (entries.length === 0) {
|
|
2652
|
-
return `${baseLine}\n`;
|
|
2653
|
-
}
|
|
2654
|
-
|
|
2655
|
-
const maxWriterLen = Math.max(5, ...entries.map(([writerId]) => writerId.length));
|
|
2656
|
-
const receiptLines = [` Tick ${payload.tick}:`];
|
|
2657
|
-
for (const [writerId, entry] of entries) {
|
|
2658
|
-
const sha = typeof entry.sha === 'string' ? entry.sha.slice(0, 7) : '';
|
|
2659
|
-
const opSummary = entry.opSummary && typeof entry.opSummary === 'object' ? entry.opSummary : entry;
|
|
2660
|
-
receiptLines.push(` ${writerId.padEnd(maxWriterLen)} ${sha.padEnd(7)} ${formatOpSummaryPlain(opSummary)}`);
|
|
2661
|
-
}
|
|
2662
|
-
|
|
2663
|
-
return `${baseLine}\n${receiptLines.join('\n')}\n`;
|
|
2664
|
-
};
|
|
2665
|
-
|
|
2666
|
-
const buildStateStrings = () => {
|
|
2667
|
-
const nodeLabel = payload.nodes === 1 ? 'node' : 'nodes';
|
|
2668
|
-
const edgeLabel = payload.edges === 1 ? 'edge' : 'edges';
|
|
2669
|
-
const patchLabel = payload.patchCount === 1 ? 'patch' : 'patches';
|
|
2670
|
-
return {
|
|
2671
|
-
nodesStr: `${payload.nodes} ${nodeLabel}${formatDelta(payload.diff?.nodes)}`,
|
|
2672
|
-
edgesStr: `${payload.edges} ${edgeLabel}${formatDelta(payload.diff?.edges)}`,
|
|
2673
|
-
patchesStr: `${payload.patchCount} ${patchLabel}`,
|
|
2674
|
-
};
|
|
2675
|
-
};
|
|
2676
|
-
|
|
2677
|
-
if (payload.action === 'clear-cache') {
|
|
2678
|
-
return `${payload.message}\n`;
|
|
2679
|
-
}
|
|
2680
|
-
|
|
2681
|
-
if (payload.action === 'list') {
|
|
2682
|
-
if (payload.cursors.length === 0) {
|
|
2683
|
-
return 'No saved cursors.\n';
|
|
2684
|
-
}
|
|
2685
|
-
const lines = [];
|
|
2686
|
-
for (const c of payload.cursors) {
|
|
2687
|
-
const active = c.tick === payload.activeTick ? ' (active)' : '';
|
|
2688
|
-
lines.push(` ${c.name}: tick ${c.tick}${active}`);
|
|
2689
|
-
}
|
|
2690
|
-
return `${lines.join('\n')}\n`;
|
|
2691
|
-
}
|
|
2692
|
-
|
|
2693
|
-
if (payload.action === 'drop') {
|
|
2694
|
-
return `Dropped cursor "${payload.name}" (was at tick ${payload.tick}).\n`;
|
|
2695
|
-
}
|
|
2696
|
-
|
|
2697
|
-
if (payload.action === 'save') {
|
|
2698
|
-
return `Saved cursor "${payload.name}" at tick ${payload.tick}.\n`;
|
|
2699
|
-
}
|
|
2700
|
-
|
|
2701
|
-
if (payload.action === 'latest') {
|
|
2702
|
-
const { nodesStr, edgesStr } = buildStateStrings();
|
|
2703
|
-
const base = appendReceiptSummary(
|
|
2704
|
-
`${payload.graph}: returned to present (tick ${payload.maxTick}, ${nodesStr}, ${edgesStr})`,
|
|
2705
|
-
);
|
|
2706
|
-
return base + formatStructuralDiff(payload);
|
|
2707
|
-
}
|
|
2708
|
-
|
|
2709
|
-
if (payload.action === 'load') {
|
|
2710
|
-
const { nodesStr, edgesStr } = buildStateStrings();
|
|
2711
|
-
const base = appendReceiptSummary(
|
|
2712
|
-
`${payload.graph}: loaded cursor "${payload.name}" at tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr})`,
|
|
2713
|
-
);
|
|
2714
|
-
return base + formatStructuralDiff(payload);
|
|
2715
|
-
}
|
|
2716
|
-
|
|
2717
|
-
if (payload.action === 'tick') {
|
|
2718
|
-
const { nodesStr, edgesStr, patchesStr } = buildStateStrings();
|
|
2719
|
-
const base = appendReceiptSummary(
|
|
2720
|
-
`${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`,
|
|
2721
|
-
);
|
|
2722
|
-
return base + formatStructuralDiff(payload);
|
|
2723
|
-
}
|
|
2724
|
-
|
|
2725
|
-
// status (structuralDiff is never populated here; no formatStructuralDiff call)
|
|
2726
|
-
if (payload.cursor && payload.cursor.active) {
|
|
2727
|
-
const { nodesStr, edgesStr, patchesStr } = buildStateStrings();
|
|
2728
|
-
return appendReceiptSummary(
|
|
2729
|
-
`${payload.graph}: tick ${payload.tick} of ${payload.maxTick} (${nodesStr}, ${edgesStr}, ${patchesStr})`,
|
|
2730
|
-
);
|
|
2731
|
-
}
|
|
2732
|
-
|
|
2733
|
-
return `${payload.graph}: no cursor active, ${payload.ticks.length} ticks available\n`;
|
|
2734
|
-
}
|
|
2735
|
-
|
|
2736
|
-
/**
|
|
2737
|
-
* Reads the active cursor and sets `_seekCeiling` on the graph instance
|
|
2738
|
-
* so that subsequent materialize calls respect the time-travel boundary.
|
|
2739
|
-
*
|
|
2740
|
-
* Called by non-seek commands (query, path, check, etc.) that should
|
|
2741
|
-
* honour an active seek cursor.
|
|
2742
|
-
*
|
|
2743
|
-
* @param {WarpGraphInstance} graph - WarpGraph instance
|
|
2744
|
-
* @param {Persistence} persistence - GraphPersistencePort adapter
|
|
2745
|
-
* @param {string} graphName - Name of the WARP graph
|
|
2746
|
-
* @returns {Promise<{active: boolean, tick: number|null, maxTick: number|null}>} Cursor info — maxTick is always null; non-seek commands intentionally skip discoverTicks() for performance
|
|
2747
|
-
*/
|
|
2748
|
-
async function applyCursorCeiling(graph, persistence, graphName) {
|
|
2749
|
-
const cursor = await readActiveCursor(persistence, graphName);
|
|
2750
|
-
if (cursor) {
|
|
2751
|
-
graph._seekCeiling = cursor.tick;
|
|
2752
|
-
return { active: true, tick: cursor.tick, maxTick: null };
|
|
2753
|
-
}
|
|
2754
|
-
return { active: false, tick: null, maxTick: null };
|
|
2755
|
-
}
|
|
2756
|
-
|
|
2757
|
-
/**
|
|
2758
|
-
* Prints a seek cursor warning banner to stderr when a cursor is active.
|
|
2759
|
-
*
|
|
2760
|
-
* No-op if the cursor is not active.
|
|
2761
|
-
*
|
|
2762
|
-
* Non-seek commands (query, path, check, history, materialize) pass null for
|
|
2763
|
-
* maxTick to avoid the cost of discoverTicks(); the banner then omits the
|
|
2764
|
-
* "of {maxTick}" suffix. Only the seek handler itself populates maxTick.
|
|
2765
|
-
*
|
|
2766
|
-
* @param {{active: boolean, tick: number|null, maxTick: number|null}} cursorInfo - Result from applyCursorCeiling
|
|
2767
|
-
* @param {number|null} maxTick - Maximum Lamport tick (from discoverTicks), or null if unknown
|
|
2768
|
-
* @returns {void}
|
|
2769
|
-
*/
|
|
2770
|
-
function emitCursorWarning(cursorInfo, maxTick) {
|
|
2771
|
-
if (cursorInfo.active) {
|
|
2772
|
-
const maxLabel = maxTick !== null && maxTick !== undefined ? ` of ${maxTick}` : '';
|
|
2773
|
-
process.stderr.write(`\u26A0 seek active (tick ${cursorInfo.tick}${maxLabel}) \u2014 run "git warp seek --latest" to return to present\n`);
|
|
2774
|
-
}
|
|
2775
|
-
}
|
|
2776
|
-
|
|
2777
|
-
/**
|
|
2778
|
-
* @param {{options: CliOptions, args: string[]}} params
|
|
2779
|
-
* @returns {Promise<{payload: *, exitCode: number}>}
|
|
2780
|
-
*/
|
|
2781
|
-
async function handleView({ options, args }) {
|
|
2782
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
2783
|
-
throw usageError('view command requires an interactive terminal (TTY)');
|
|
2784
|
-
}
|
|
2785
|
-
|
|
2786
|
-
const viewMode = (args[0] === '--list' || args[0] === 'list') ? 'list'
|
|
2787
|
-
: (args[0] === '--log' || args[0] === 'log') ? 'log'
|
|
2788
|
-
: 'list';
|
|
2789
|
-
|
|
2790
|
-
try {
|
|
2791
|
-
// @ts-expect-error — optional peer dependency, may not be installed
|
|
2792
|
-
const { startTui } = await import('@git-stunts/git-warp-tui');
|
|
2793
|
-
await startTui({
|
|
2794
|
-
repo: options.repo || '.',
|
|
2795
|
-
graph: options.graph || 'default',
|
|
2796
|
-
mode: viewMode,
|
|
2797
|
-
});
|
|
2798
|
-
} catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error
|
|
2799
|
-
if (err.code === 'ERR_MODULE_NOT_FOUND' || (err.message && err.message.includes('Cannot find module'))) {
|
|
2800
|
-
throw usageError(
|
|
2801
|
-
'Interactive TUI requires @git-stunts/git-warp-tui.\n' +
|
|
2802
|
-
' Install with: npm install -g @git-stunts/git-warp-tui',
|
|
2803
|
-
);
|
|
2804
|
-
}
|
|
2805
|
-
throw err;
|
|
2806
|
-
}
|
|
2807
|
-
return { payload: undefined, exitCode: 0 };
|
|
2808
|
-
}
|
|
2809
|
-
|
|
2810
|
-
/** @type {Map<string, Function>} */
|
|
2811
|
-
const COMMANDS = new Map(/** @type {[string, Function][]} */ ([
|
|
2812
|
-
['info', handleInfo],
|
|
2813
|
-
['query', handleQuery],
|
|
2814
|
-
['path', handlePath],
|
|
2815
|
-
['history', handleHistory],
|
|
2816
|
-
['check', handleCheck],
|
|
2817
|
-
['materialize', handleMaterialize],
|
|
2818
|
-
['seek', handleSeek],
|
|
2819
|
-
['view', handleView],
|
|
2820
|
-
['install-hooks', handleInstallHooks],
|
|
2821
|
-
]));
|
|
10
|
+
const VIEW_SUPPORTED_COMMANDS = ['info', 'check', 'history', 'path', 'materialize', 'query', 'seek'];
|
|
2822
11
|
|
|
2823
12
|
/**
|
|
2824
13
|
* CLI entry point. Parses arguments, dispatches to the appropriate command handler,
|
|
@@ -2826,7 +15,7 @@ const COMMANDS = new Map(/** @type {[string, Function][]} */ ([
|
|
|
2826
15
|
* @returns {Promise<void>}
|
|
2827
16
|
*/
|
|
2828
17
|
async function main() {
|
|
2829
|
-
const { options,
|
|
18
|
+
const { options, command, commandArgs } = parseArgs(process.argv.slice(2));
|
|
2830
19
|
|
|
2831
20
|
if (options.help) {
|
|
2832
21
|
process.stdout.write(HELP_TEXT);
|
|
@@ -2837,8 +26,13 @@ async function main() {
|
|
|
2837
26
|
if (options.json && options.view) {
|
|
2838
27
|
throw usageError('--json and --view are mutually exclusive');
|
|
2839
28
|
}
|
|
29
|
+
if (options.ndjson && options.view) {
|
|
30
|
+
throw usageError('--ndjson and --view are mutually exclusive');
|
|
31
|
+
}
|
|
32
|
+
if (options.json && options.ndjson) {
|
|
33
|
+
throw usageError('--json and --ndjson are mutually exclusive');
|
|
34
|
+
}
|
|
2840
35
|
|
|
2841
|
-
const command = positionals[0];
|
|
2842
36
|
if (!command) {
|
|
2843
37
|
process.stderr.write(HELP_TEXT);
|
|
2844
38
|
process.exitCode = EXIT_CODES.USAGE;
|
|
@@ -2850,14 +44,13 @@ async function main() {
|
|
|
2850
44
|
throw usageError(`Unknown command: ${command}`);
|
|
2851
45
|
}
|
|
2852
46
|
|
|
2853
|
-
const VIEW_SUPPORTED_COMMANDS = ['info', 'check', 'history', 'path', 'materialize', 'query', 'seek'];
|
|
2854
47
|
if (options.view && !VIEW_SUPPORTED_COMMANDS.includes(command)) {
|
|
2855
48
|
throw usageError(`--view is not supported for '${command}'. Supported commands: ${VIEW_SUPPORTED_COMMANDS.join(', ')}`);
|
|
2856
49
|
}
|
|
2857
50
|
|
|
2858
51
|
const result = await /** @type {Function} */ (handler)({
|
|
2859
52
|
command,
|
|
2860
|
-
args:
|
|
53
|
+
args: commandArgs,
|
|
2861
54
|
options,
|
|
2862
55
|
});
|
|
2863
56
|
|
|
@@ -2867,7 +60,8 @@ async function main() {
|
|
|
2867
60
|
: { payload: result, exitCode: EXIT_CODES.OK };
|
|
2868
61
|
|
|
2869
62
|
if (normalized.payload !== undefined) {
|
|
2870
|
-
|
|
63
|
+
const format = options.ndjson ? 'ndjson' : options.json ? 'json' : 'text';
|
|
64
|
+
present(normalized.payload, { format, command, view: options.view });
|
|
2871
65
|
}
|
|
2872
66
|
// Use process.exit() to avoid waiting for fire-and-forget I/O (e.g. seek cache writes).
|
|
2873
67
|
process.exit(normalized.exitCode ?? EXIT_CODES.OK);
|
|
@@ -2884,8 +78,9 @@ main().catch((error) => {
|
|
|
2884
78
|
payload.error.cause = error.cause instanceof Error ? error.cause.message : error.cause;
|
|
2885
79
|
}
|
|
2886
80
|
|
|
2887
|
-
if (process.argv.includes('--json')) {
|
|
2888
|
-
process.
|
|
81
|
+
if (process.argv.includes('--json') || process.argv.includes('--ndjson')) {
|
|
82
|
+
const stringify = process.argv.includes('--ndjson') ? compactStringify : stableStringify;
|
|
83
|
+
process.stdout.write(`${stringify(payload)}\n`);
|
|
2889
84
|
} else {
|
|
2890
85
|
process.stderr.write(renderError(payload));
|
|
2891
86
|
}
|