isomorphic-jj 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +971 -0
  3. package/dist/index.cjs +12609 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.mjs +12600 -0
  6. package/dist/index.mjs.map +1 -0
  7. package/package.json +90 -0
  8. package/src/api/repository.js +6885 -0
  9. package/src/backend/isomorphic-git-backend.js +687 -0
  10. package/src/backend/lazy-git-backend.js +180 -0
  11. package/src/browser/helpers.js +189 -0
  12. package/src/browser/index.d.ts +174 -0
  13. package/src/browser/index.js +12 -0
  14. package/src/core/background-ops.js +272 -0
  15. package/src/core/bookmark-store.js +232 -0
  16. package/src/core/change-graph.js +285 -0
  17. package/src/core/conflict-model.js +505 -0
  18. package/src/core/jj-checkout.js +78 -0
  19. package/src/core/jj-operation-store.js +120 -0
  20. package/src/core/jj-tree-state.js +94 -0
  21. package/src/core/jj-view-store.js +105 -0
  22. package/src/core/merge-driver-registry.js +432 -0
  23. package/src/core/operation-log.js +272 -0
  24. package/src/core/revset-engine.js +1081 -0
  25. package/src/core/storage-manager.js +207 -0
  26. package/src/core/tag-store.js +167 -0
  27. package/src/core/user-config.js +142 -0
  28. package/src/core/working-copy.js +314 -0
  29. package/src/core/workspace-manager.js +283 -0
  30. package/src/drivers/built-in-drivers.js +296 -0
  31. package/src/index.js +15 -0
  32. package/src/protos/local_working_copy.proto +76 -0
  33. package/src/protos/simple_op_store.proto +147 -0
  34. package/src/types.d.ts +1129 -0
  35. package/src/utils/errors.js +21 -0
  36. package/src/utils/id-generation.js +54 -0
  37. package/src/utils/validation.js +124 -0
@@ -0,0 +1,505 @@
1
+ /**
2
+ * ConflictModel - First-class conflict handling
3
+ *
4
+ * In JJ, conflicts are not errors but structured data that can be:
5
+ * - Stored in commits
6
+ * - Rebased along with changes
7
+ * - Resolved incrementally
8
+ * - Committed and shared
9
+ *
10
+ * This matches JJ's philosophy: conflicts never block you.
11
+ */
12
+
13
+ import path from 'path';
14
+ import { JJError } from '../utils/errors.js';
15
+ import { generateId } from '../utils/id-generation.js';
16
+
17
+ /**
18
+ * Conflict types supported by JJ
19
+ */
20
+ export const ConflictType = {
21
+ CONTENT: 'content', // File content conflicts (3-way merge)
22
+ PATH: 'path', // Path/rename conflicts
23
+ DELETE_MODIFY: 'delete-modify', // File deleted in one side, modified in other
24
+ MODIFY_DELETE: 'modify-delete', // File modified in one side, deleted in other
25
+ ADD_ADD: 'add-add', // Same path added in both sides
26
+ };
27
+
28
+ /**
29
+ * ConflictModel manages conflict detection, storage, and resolution
30
+ */
31
+ export class ConflictModel {
32
+ constructor(storage, fs, mergeDriverRegistry = null) {
33
+ this.storage = storage;
34
+ this.fs = fs;
35
+ this.mergeDriverRegistry = mergeDriverRegistry; // v0.5: merge drivers
36
+ this.conflicts = new Map(); // conflictId -> Conflict
37
+ this.fileConflicts = new Map(); // path -> conflictId
38
+ }
39
+
40
+ /**
41
+ * Initialize conflict storage
42
+ */
43
+ async init() {
44
+ try {
45
+ const data = await this.storage.read('repo/conflicts/conflicts.json');
46
+ if (data) {
47
+ // storage.read() already parses JSON
48
+ this.conflicts = new Map(Object.entries(data.conflicts || {}));
49
+ this.fileConflicts = new Map(Object.entries(data.fileConflicts || {}));
50
+ }
51
+ } catch (error) {
52
+ // No conflicts file yet, that's fine
53
+ this.conflicts = new Map();
54
+ this.fileConflicts = new Map();
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Load conflicts from storage
60
+ */
61
+ async load() {
62
+ await this.init();
63
+ }
64
+
65
+ /**
66
+ * Save conflicts to storage
67
+ */
68
+ async save() {
69
+ const data = {
70
+ conflicts: Object.fromEntries(this.conflicts),
71
+ fileConflicts: Object.fromEntries(this.fileConflicts),
72
+ };
73
+ // storage.write() will handle JSON stringification
74
+ await this.storage.write('repo/conflicts/conflicts.json', data);
75
+ }
76
+
77
+ /**
78
+ * Detect conflicts during merge/rebase operations
79
+ *
80
+ * @param {Object} opts - Merge options
81
+ * @param {string} opts.baseChange - Common ancestor change
82
+ * @param {string} opts.leftChange - Left side change
83
+ * @param {string} opts.rightChange - Right side change
84
+ * @param {Map} opts.baseFiles - Files in base
85
+ * @param {Map} opts.leftFiles - Files in left
86
+ * @param {Map} opts.rightFiles - Files in right
87
+ * @param {Object} opts.drivers - Custom merge drivers (v0.5)
88
+ * @param {string} opts.workingCopyDir - Working copy directory for file writes (v0.5)
89
+ * @returns {Array<Conflict>} Detected conflicts
90
+ */
91
+ async detectConflicts(opts) {
92
+ const { baseFiles, leftFiles, rightFiles, drivers = {}, workingCopyDir, baseChange, leftChange, rightChange } = opts;
93
+ const conflicts = [];
94
+
95
+ // Get all unique paths across all versions
96
+ const allPaths = new Set([
97
+ ...baseFiles.keys(),
98
+ ...leftFiles.keys(),
99
+ ...rightFiles.keys(),
100
+ ]);
101
+
102
+ for (const path of allPaths) {
103
+ const baseContent = baseFiles.get(path);
104
+ const leftContent = leftFiles.get(path);
105
+ const rightContent = rightFiles.get(path);
106
+
107
+ // v0.5: Try merge driver first if available
108
+ if (this.mergeDriverRegistry) {
109
+ const mergeResult = await this._tryMergeDriver(
110
+ path,
111
+ {base: baseContent, ours: leftContent, theirs: rightContent},
112
+ drivers,
113
+ { baseChange, leftChange, rightChange }
114
+ );
115
+
116
+ if (mergeResult) {
117
+ // Driver handled it
118
+ if (mergeResult.hasConflict) {
119
+ // Driver produced a conflict (possibly partial merge)
120
+ // Use conflict info from driver result if available
121
+ if (mergeResult.conflicts && mergeResult.conflicts.length > 0) {
122
+ // Driver provided detailed conflict info
123
+ for (const driverConflict of mergeResult.conflicts) {
124
+ const conflict = this._createConflict({
125
+ type: driverConflict.type || 'driver-conflict',
126
+ path,
127
+ sides: { base: baseContent, left: leftContent, right: rightContent },
128
+ message: driverConflict.message || mergeResult.message || `Merge driver detected conflicts`,
129
+ driverResult: mergeResult,
130
+ driverFailed: mergeResult.driverFailed,
131
+ driverError: mergeResult.driverError,
132
+ });
133
+ conflicts.push(conflict);
134
+ }
135
+ } else {
136
+ // Driver didn't provide detailed conflicts, create generic one
137
+ const conflict = this._createConflict({
138
+ type: 'driver-conflict',
139
+ path,
140
+ sides: { base: baseContent, left: leftContent, right: rightContent },
141
+ message: mergeResult.message || `Merge driver detected conflicts`,
142
+ driverResult: mergeResult,
143
+ driverFailed: mergeResult.driverFailed,
144
+ driverError: mergeResult.driverError,
145
+ });
146
+ conflicts.push(conflict);
147
+ }
148
+ } else {
149
+ // Driver successfully merged - write result to working copy
150
+ if (workingCopyDir && mergeResult.content !== null) {
151
+ await this._writeDriverResult(workingCopyDir, path, mergeResult);
152
+ }
153
+ }
154
+ continue; // Driver handled this file, skip default detection
155
+ }
156
+ }
157
+
158
+ // Fall back to default conflict detection
159
+ const conflict = this._detectPathConflict(path, baseContent, leftContent, rightContent);
160
+ if (conflict) {
161
+ conflicts.push(conflict);
162
+ }
163
+ }
164
+
165
+ return conflicts;
166
+ }
167
+
168
+ /**
169
+ * Detect conflict for a single path
170
+ */
171
+ _detectPathConflict(path, base, left, right) {
172
+ const baseExists = base !== undefined;
173
+ const leftExists = left !== undefined;
174
+ const rightExists = right !== undefined;
175
+
176
+ // No conflict if all three are the same
177
+ if (base === left && left === right) {
178
+ return null;
179
+ }
180
+
181
+ // No conflict if only one side changed
182
+ if (base === left && base !== right) {
183
+ return null; // Right side wins
184
+ }
185
+ if (base === right && base !== left) {
186
+ return null; // Left side wins
187
+ }
188
+ if (base !== left && base !== right && left === right) {
189
+ return null; // Both sides made the same change
190
+ }
191
+
192
+ // Now we have actual conflicts
193
+ if (!baseExists && leftExists && rightExists) {
194
+ // File added in both sides
195
+ if (left === right) {
196
+ return null; // Same content, no conflict
197
+ }
198
+ return this._createConflict({
199
+ type: ConflictType.ADD_ADD,
200
+ path,
201
+ sides: { left, right },
202
+ message: `File added with different content in both sides`,
203
+ });
204
+ }
205
+
206
+ if (baseExists && !leftExists && !rightExists) {
207
+ // File deleted in both sides
208
+ return null; // No conflict
209
+ }
210
+
211
+ if (baseExists && !leftExists && rightExists) {
212
+ // Deleted on left, modified on right
213
+ return this._createConflict({
214
+ type: ConflictType.DELETE_MODIFY,
215
+ path,
216
+ sides: { base, right },
217
+ message: `File deleted on one side but modified on the other`,
218
+ });
219
+ }
220
+
221
+ if (baseExists && leftExists && !rightExists) {
222
+ // Modified on left, deleted on right
223
+ return this._createConflict({
224
+ type: ConflictType.MODIFY_DELETE,
225
+ path,
226
+ sides: { base, left },
227
+ message: `File modified on one side but deleted on the other`,
228
+ });
229
+ }
230
+
231
+ if (baseExists && leftExists && rightExists) {
232
+ // Content conflict - all three versions exist but differ
233
+ return this._createConflict({
234
+ type: ConflictType.CONTENT,
235
+ path,
236
+ sides: { base, left, right },
237
+ message: `Conflicting changes to file content`,
238
+ });
239
+ }
240
+
241
+ return null;
242
+ }
243
+
244
+ /**
245
+ * Create a conflict object
246
+ */
247
+ _createConflict({ type, path, sides, message, driverFailed, driverError }) {
248
+ const conflictId = generateId('conflict');
249
+ const conflict = {
250
+ conflictId,
251
+ type,
252
+ path,
253
+ sides,
254
+ message,
255
+ resolved: false,
256
+ timestamp: new Date().toISOString(),
257
+ };
258
+
259
+ // Add driver failure metadata if present
260
+ if (driverFailed !== undefined) {
261
+ conflict.driverFailed = driverFailed;
262
+ }
263
+ if (driverError !== undefined) {
264
+ conflict.driverError = driverError;
265
+ }
266
+
267
+ return conflict;
268
+ }
269
+
270
+ /**
271
+ * Store a conflict
272
+ */
273
+ async addConflict(conflict) {
274
+ this.conflicts.set(conflict.conflictId, conflict);
275
+ this.fileConflicts.set(conflict.path, conflict.conflictId);
276
+ await this.save();
277
+ return conflict;
278
+ }
279
+
280
+ /**
281
+ * Get conflict by ID
282
+ */
283
+ getConflict(conflictId) {
284
+ return this.conflicts.get(conflictId);
285
+ }
286
+
287
+ /**
288
+ * Get conflict for a file path
289
+ */
290
+ getConflictForPath(path) {
291
+ const conflictId = this.fileConflicts.get(path);
292
+ if (!conflictId) return null;
293
+ return this.conflicts.get(conflictId);
294
+ }
295
+
296
+ /**
297
+ * List all conflicts
298
+ */
299
+ listConflicts(opts = {}) {
300
+ let conflicts = Array.from(this.conflicts.values());
301
+
302
+ if (opts.resolved !== undefined) {
303
+ conflicts = conflicts.filter(c => c.resolved === opts.resolved);
304
+ }
305
+
306
+ if (opts.type) {
307
+ conflicts = conflicts.filter(c => c.type === opts.type);
308
+ }
309
+
310
+ return conflicts;
311
+ }
312
+
313
+ /**
314
+ * Mark a conflict as resolved
315
+ */
316
+ async resolveConflict(conflictId, resolution) {
317
+ const conflict = this.conflicts.get(conflictId);
318
+ if (!conflict) {
319
+ throw new JJError('CONFLICT_NOT_FOUND', `Conflict ${conflictId} not found`);
320
+ }
321
+
322
+ // Support both object-based and legacy string resolutions
323
+ let resolvedContent;
324
+
325
+ if (typeof resolution === 'string') {
326
+ // Legacy string format: 'manual', 'ours', 'theirs', etc.
327
+ resolvedContent = { type: 'manual', value: resolution };
328
+ } else if (resolution && typeof resolution === 'object') {
329
+ // New object-based format
330
+ if (resolution.side) {
331
+ // { side: 'ours' | 'theirs' | 'base' }
332
+ if (!['ours', 'theirs', 'base'].includes(resolution.side)) {
333
+ throw new JJError('INVALID_RESOLUTION', `Invalid side: ${resolution.side}`, {
334
+ suggestion: 'Use "ours", "theirs", or "base"'
335
+ });
336
+ }
337
+ resolvedContent = { type: 'side', side: resolution.side };
338
+ } else if (resolution.content !== undefined) {
339
+ // { content: string | Uint8Array }
340
+ resolvedContent = { type: 'content', content: resolution.content };
341
+ } else if (resolution.hunks) {
342
+ // { hunks: [...] } for partial resolution
343
+ resolvedContent = { type: 'hunks', hunks: resolution.hunks };
344
+ } else {
345
+ // Store as-is for extensibility
346
+ resolvedContent = resolution;
347
+ }
348
+ } else {
349
+ throw new JJError('INVALID_RESOLUTION', 'Resolution must be a string or object');
350
+ }
351
+
352
+ conflict.resolved = true;
353
+ conflict.resolution = resolvedContent;
354
+ conflict.resolvedAt = new Date().toISOString();
355
+
356
+ await this.save();
357
+ return conflict;
358
+ }
359
+
360
+ /**
361
+ * Remove a conflict (after resolution is committed)
362
+ */
363
+ async removeConflict(conflictId) {
364
+ const conflict = this.conflicts.get(conflictId);
365
+ if (!conflict) return;
366
+
367
+ this.conflicts.delete(conflictId);
368
+ this.fileConflicts.delete(conflict.path);
369
+ await this.save();
370
+ }
371
+
372
+ /**
373
+ * Check if a change has conflicts
374
+ */
375
+ hasConflicts() {
376
+ return this.listConflicts({ resolved: false }).length > 0;
377
+ }
378
+
379
+ /**
380
+ * Create conflict markers for a content conflict (like Git's <<<<<<<, =======, >>>>>>>)
381
+ */
382
+ generateConflictMarkers(conflict) {
383
+ if (conflict.type !== ConflictType.CONTENT) {
384
+ throw new JJError('INVALID_CONFLICT_TYPE',
385
+ 'Can only generate markers for content conflicts');
386
+ }
387
+
388
+ const { base, left, right } = conflict.sides;
389
+
390
+ return [
391
+ '<<<<<<< Left',
392
+ left || '',
393
+ '||||||| Base',
394
+ base || '',
395
+ '=======',
396
+ right || '',
397
+ '>>>>>>> Right',
398
+ ].join('\n');
399
+ }
400
+
401
+ /**
402
+ * Parse conflict markers from file content
403
+ */
404
+ parseConflictMarkers(content) {
405
+ const markerRegex = /^<{7} Left\n([\s\S]*?)\n\|{7} Base\n([\s\S]*?)\n={7}\n([\s\S]*?)\n>{7} Right$/gm;
406
+ const matches = markerRegex.exec(content);
407
+
408
+ if (!matches) {
409
+ return null;
410
+ }
411
+
412
+ return {
413
+ left: matches[1],
414
+ base: matches[2],
415
+ right: matches[3],
416
+ };
417
+ }
418
+
419
+ /**
420
+ * Clear all conflicts
421
+ */
422
+ async clear() {
423
+ this.conflicts.clear();
424
+ this.fileConflicts.clear();
425
+ await this.save();
426
+ }
427
+
428
+ /**
429
+ * Try to use a merge driver for a file (v0.5)
430
+ *
431
+ * @param {string} path - File path
432
+ * @param {Object} content - Content versions { base, ours, theirs }
433
+ * @param {Object} customDrivers - Per-merge custom drivers
434
+ * @param {Object} metadata - Merge metadata
435
+ * @returns {Promise<Object|null>} Merge result or null if no driver
436
+ */
437
+ async _tryMergeDriver(path, content, customDrivers, metadata) {
438
+ if (!this.mergeDriverRegistry) {
439
+ return null; // No registry available
440
+ }
441
+
442
+ // Check if file is binary
443
+ const isBinary = this.mergeDriverRegistry.isBinaryFile(
444
+ path,
445
+ content.ours || content.theirs || content.base
446
+ );
447
+
448
+ // Find appropriate driver
449
+ const driver = this.mergeDriverRegistry.findDriver(path, customDrivers, isBinary);
450
+
451
+ if (!driver || driver === this.mergeDriverRegistry.defaultDriver) {
452
+ return null; // No custom driver found
453
+ }
454
+
455
+ // Execute driver (errors are handled by wrapDriver)
456
+ // In non-strict mode: returns result with driverFailed metadata
457
+ // In strict mode: throws error
458
+ const result = await this.mergeDriverRegistry.executeDriver(
459
+ driver,
460
+ { path, ...content, metadata },
461
+ isBinary
462
+ );
463
+
464
+ return result;
465
+ }
466
+
467
+ /**
468
+ * Write merge driver result to working copy (v0.5)
469
+ *
470
+ * @param {string} workingCopyDir - Working copy directory
471
+ * @param {string} filePath - File path
472
+ * @param {Object} result - Merge result
473
+ */
474
+ async _writeDriverResult(workingCopyDir, filePath, result) {
475
+ const fullPath = path.join(workingCopyDir, filePath);
476
+
477
+ // Ensure directory exists
478
+ const dir = path.dirname(fullPath);
479
+ await this.fs.promises.mkdir(dir, { recursive: true });
480
+
481
+ // Write main file
482
+ if (result.content !== null && result.content !== undefined) {
483
+ if (Buffer.isBuffer(result.content)) {
484
+ await this.fs.promises.writeFile(fullPath, result.content);
485
+ } else {
486
+ await this.fs.promises.writeFile(fullPath, result.content, 'utf-8');
487
+ }
488
+ }
489
+
490
+ // Write additional files if driver provided them
491
+ if (result.additionalFiles) {
492
+ for (const [additionalPath, additionalContent] of Object.entries(result.additionalFiles)) {
493
+ const additionalFullPath = path.join(workingCopyDir, additionalPath);
494
+ const additionalDir = path.dirname(additionalFullPath);
495
+ await this.fs.promises.mkdir(additionalDir, { recursive: true });
496
+
497
+ if (Buffer.isBuffer(additionalContent)) {
498
+ await this.fs.promises.writeFile(additionalFullPath, additionalContent);
499
+ } else {
500
+ await this.fs.promises.writeFile(additionalFullPath, additionalContent, 'utf-8');
501
+ }
502
+ }
503
+ }
504
+ }
505
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * JJ Checkout File Manager
3
+ *
4
+ * Handles encoding/decoding of .jj/working_copy/checkout file using protobuf.
5
+ * This file tracks which operation the working copy is at.
6
+ */
7
+
8
+ import protobuf from 'protobufjs';
9
+ import path from 'path';
10
+ import { fileURLToPath } from 'url';
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = path.dirname(__filename);
14
+
15
+ export class JJCheckout {
16
+ constructor(fs, dir) {
17
+ this.fs = fs;
18
+ this.dir = dir;
19
+ this.protoPath = path.join(__dirname, '..', 'protos', 'local_working_copy.proto');
20
+ }
21
+
22
+ /**
23
+ * Write checkout file
24
+ *
25
+ * @param {string} operationId - Operation ID as hex string (128 characters)
26
+ * @param {string} workspaceName - Workspace name (default: 'default')
27
+ */
28
+ async writeCheckout(operationId, workspaceName = 'default') {
29
+ // Load protobuf schema
30
+ const root = await protobuf.load(this.protoPath);
31
+ const Checkout = root.lookupType('local_working_copy.Checkout');
32
+
33
+ // Convert hex operation ID to bytes
34
+ const opIdBuffer = Buffer.from(operationId, 'hex');
35
+
36
+ // Create message (use camelCase for protobufjs)
37
+ const message = Checkout.create({
38
+ operationId: opIdBuffer,
39
+ workspaceName: workspaceName
40
+ });
41
+
42
+ // Verify the message
43
+ const errMsg = Checkout.verify(message);
44
+ if (errMsg) {
45
+ throw new Error(`Checkout message verification failed: ${errMsg}`);
46
+ }
47
+
48
+ // Encode to binary
49
+ const buffer = Checkout.encode(message).finish();
50
+
51
+ // Write to .jj/working_copy/checkout
52
+ const checkoutPath = `${this.dir}/.jj/working_copy/checkout`;
53
+ await this.fs.promises.writeFile(checkoutPath, buffer);
54
+ }
55
+
56
+ /**
57
+ * Read checkout file
58
+ *
59
+ * @returns {Object} Decoded checkout data
60
+ */
61
+ async readCheckout() {
62
+ const checkoutPath = `${this.dir}/.jj/working_copy/checkout`;
63
+ const buffer = await this.fs.promises.readFile(checkoutPath);
64
+
65
+ // Load protobuf schema
66
+ const root = await protobuf.load(this.protoPath);
67
+ const Checkout = root.lookupType('local_working_copy.Checkout');
68
+
69
+ // Decode
70
+ const message = Checkout.decode(buffer);
71
+
72
+ // Return the decoded message with proper field access
73
+ return {
74
+ operation_id: message.operationId,
75
+ workspace_name: message.workspaceName
76
+ };
77
+ }
78
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * JJ Operation Store Manager
3
+ *
4
+ * Handles encoding/decoding of .jj/repo/op_store/operations/* files using protobuf.
5
+ * Operations track the history of changes to the repository.
6
+ */
7
+
8
+ import protobuf from 'protobufjs';
9
+ import path from 'path';
10
+ import { fileURLToPath } from 'url';
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = path.dirname(__filename);
14
+
15
+ export class JJOperationStore {
16
+ constructor(fs, dir) {
17
+ this.fs = fs;
18
+ this.dir = dir;
19
+ this.protoPath = path.join(__dirname, '..', 'protos', 'simple_op_store.proto');
20
+ }
21
+
22
+ /**
23
+ * Write operation file
24
+ *
25
+ * @param {string} operationId - Operation ID as hex string (128 characters)
26
+ * @param {string} viewId - View ID as hex string (128 characters)
27
+ * @param {Array<string>} parentIds - Parent operation IDs as hex strings
28
+ * @param {Object} metadata - Operation metadata
29
+ */
30
+ async writeOperation(operationId, viewId, parentIds, metadata) {
31
+ // Load protobuf schema
32
+ const root = await protobuf.load(this.protoPath);
33
+ const Operation = root.lookupType('simple_op_store.Operation');
34
+
35
+ // Convert hex IDs to bytes
36
+ const viewIdBuffer = Buffer.from(viewId, 'hex');
37
+ const parentBuffers = parentIds.map(id => Buffer.from(id, 'hex'));
38
+
39
+ // Create metadata message (use camelCase for protobufjs)
40
+ const metadataMsg = {
41
+ startTime: {
42
+ millisSinceEpoch: metadata.start_time.millis_since_epoch,
43
+ tzOffset: metadata.start_time.tz_offset
44
+ },
45
+ endTime: {
46
+ millisSinceEpoch: metadata.end_time.millis_since_epoch,
47
+ tzOffset: metadata.end_time.tz_offset
48
+ },
49
+ description: metadata.description,
50
+ hostname: metadata.hostname,
51
+ username: metadata.username,
52
+ isSnapshot: metadata.is_snapshot,
53
+ tags: metadata.tags
54
+ };
55
+
56
+ // Create operation message (use camelCase for protobufjs)
57
+ const message = Operation.create({
58
+ viewId: viewIdBuffer,
59
+ parents: parentBuffers,
60
+ metadata: metadataMsg,
61
+ commitPredecessors: [], // Empty for now
62
+ storesCommitPredecessors: false
63
+ });
64
+
65
+ // Verify the message
66
+ const errMsg = Operation.verify(message);
67
+ if (errMsg) {
68
+ throw new Error(`Operation message verification failed: ${errMsg}`);
69
+ }
70
+
71
+ // Encode to binary
72
+ const buffer = Operation.encode(message).finish();
73
+
74
+ // Write to .jj/repo/op_store/operations/OPERATIONID
75
+ const opPath = `${this.dir}/.jj/repo/op_store/operations/${operationId}`;
76
+ await this.fs.promises.writeFile(opPath, buffer);
77
+ }
78
+
79
+ /**
80
+ * Read operation file
81
+ *
82
+ * @param {string} operationId - Operation ID as hex string (128 characters)
83
+ * @returns {Object} Decoded operation data
84
+ */
85
+ async readOperation(operationId) {
86
+ // Read from .jj/repo/op_store/operations/OPERATIONID
87
+ const opPath = `${this.dir}/.jj/repo/op_store/operations/${operationId}`;
88
+ const buffer = await this.fs.promises.readFile(opPath);
89
+
90
+ // Load protobuf schema
91
+ const root = await protobuf.load(this.protoPath);
92
+ const Operation = root.lookupType('simple_op_store.Operation');
93
+
94
+ // Decode
95
+ const message = Operation.decode(buffer);
96
+
97
+ // Return the decoded message with proper field access
98
+ return {
99
+ view_id: message.viewId,
100
+ parents: message.parents,
101
+ metadata: {
102
+ start_time: {
103
+ millis_since_epoch: message.metadata.startTime.millisSinceEpoch,
104
+ tz_offset: message.metadata.startTime.tzOffset
105
+ },
106
+ end_time: {
107
+ millis_since_epoch: message.metadata.endTime.millisSinceEpoch,
108
+ tz_offset: message.metadata.endTime.tzOffset
109
+ },
110
+ description: message.metadata.description,
111
+ hostname: message.metadata.hostname,
112
+ username: message.metadata.username,
113
+ is_snapshot: message.metadata.isSnapshot,
114
+ tags: message.metadata.tags
115
+ },
116
+ commit_predecessors: message.commitPredecessors,
117
+ stores_commit_predecessors: message.storesCommitPredecessors
118
+ };
119
+ }
120
+ }