isomorphic-jj 1.8.0 → 1.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,7 +11,8 @@ import { JJCheckout } from '../core/jj-checkout.js';
11
11
  import { JJTreeState } from '../core/jj-tree-state.js';
12
12
  import { JJOperationStore } from '../core/jj-operation-store.js';
13
13
  import { JJViewStore } from '../core/jj-view-store.js';
14
- import crypto from 'crypto';
14
+ import { randomHex } from '../utils/id-generation.js';
15
+ import { mkdirp } from '../utils/mkdirp.js';
15
16
 
16
17
  /**
17
18
  * Backend adapter for isomorphic-git
@@ -674,14 +675,14 @@ export class IsomorphicGitBackend {
674
675
  const jjRepo = `${jjDir}/repo`;
675
676
 
676
677
  // Create directory structure
677
- await this.fs.promises.mkdir(`${jjRepo}/store`, { recursive: true });
678
- await this.fs.promises.mkdir(`${jjRepo}/store/extra/heads`, { recursive: true });
679
- await this.fs.promises.mkdir(`${jjRepo}/op_store/operations`, { recursive: true });
680
- await this.fs.promises.mkdir(`${jjRepo}/op_store/views`, { recursive: true });
681
- await this.fs.promises.mkdir(`${jjRepo}/op_heads/heads`, { recursive: true });
682
- await this.fs.promises.mkdir(`${jjRepo}/index`, { recursive: true });
683
- await this.fs.promises.mkdir(`${jjRepo}/submodule_store`, { recursive: true });
684
- await this.fs.promises.mkdir(`${jjDir}/working_copy`, { recursive: true });
678
+ await mkdirp(this.fs, `${jjRepo}/store`);
679
+ await mkdirp(this.fs, `${jjRepo}/store/extra/heads`);
680
+ await mkdirp(this.fs, `${jjRepo}/op_store/operations`);
681
+ await mkdirp(this.fs, `${jjRepo}/op_store/views`);
682
+ await mkdirp(this.fs, `${jjRepo}/op_heads/heads`);
683
+ await mkdirp(this.fs, `${jjRepo}/index`);
684
+ await mkdirp(this.fs, `${jjRepo}/submodule_store`);
685
+ await mkdirp(this.fs, `${jjDir}/working_copy`);
685
686
 
686
687
  // Write type files
687
688
  await this.fs.promises.writeFile(`${jjRepo}/store/type`, 'git');
@@ -708,8 +709,8 @@ export class IsomorphicGitBackend {
708
709
  */
709
710
  async _createInitialJJState() {
710
711
  // Generate unique IDs for initial state
711
- const operationId = crypto.randomBytes(64).toString('hex'); // 512-bit = 128 hex chars
712
- const viewId = crypto.randomBytes(64).toString('hex');
712
+ const operationId = randomHex(64); // 512-bit = 128 hex chars
713
+ const viewId = randomHex(64);
713
714
  const emptyTreeId = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; // Git's empty tree hash
714
715
 
715
716
  // Get the initial commit from Git
@@ -721,7 +722,7 @@ export class IsomorphicGitBackend {
721
722
  ref: 'HEAD',
722
723
  });
723
724
  if (headOid) {
724
- initialCommitId = crypto.randomBytes(64).toString('hex');
725
+ initialCommitId = randomHex(64);
725
726
  }
726
727
  } catch (error) {
727
728
  // No initial commit yet
@@ -5,15 +5,21 @@
5
5
  */
6
6
 
7
7
  /**
8
- * Create a browser-compatible filesystem using LightningFS
8
+ * Create a browser-compatible filesystem, defaulting to LightningFS.
9
9
  *
10
- * Requires: @isomorphic-git/lightning-fs
10
+ * Requires (unless `opts.fs` is supplied): @isomorphic-git/lightning-fs
11
11
  *
12
- * @param {Object} opts - Options
12
+ * @param {Object} [opts] - Options
13
13
  * @param {string} [opts.backend='idb'] - Storage backend ('idb' or 'memory')
14
14
  * @param {string} [opts.name='jj'] - Database name for IndexedDB
15
15
  * @param {boolean} [opts.wipe=false] - Wipe existing data
16
- * @returns {Object} LightningFS instance
16
+ * @param {any} [opts.fs] - Bring your own filesystem instead of the default
17
+ * LightningFS auto-import. Pass either an already-constructed
18
+ * isomorphic-git-compatible fs (e.g. a `memfs` volume, or your own
19
+ * LightningFS instance) — returned as-is — or a constructor with the
20
+ * LightningFS signature (`new Ctor(name, { wipe })`), which is
21
+ * instantiated the same way the default LightningFS would be.
22
+ * @returns {Promise<Object>} Filesystem instance compatible with isomorphic-git
17
23
  *
18
24
  * @example
19
25
  * ```javascript
@@ -22,7 +28,7 @@
22
28
  * import git from 'isomorphic-git';
23
29
  * import http from 'isomorphic-git/http/web';
24
30
  *
25
- * const fs = createBrowserFS({ backend: 'idb', name: 'my-repo' });
31
+ * const fs = await createBrowserFS({ backend: 'idb', name: 'my-repo' });
26
32
  * const jj = await createJJ({
27
33
  * fs,
28
34
  * dir: '/repo',
@@ -30,24 +36,36 @@
30
36
  * http
31
37
  * });
32
38
  * ```
39
+ *
40
+ * @example Bring your own fs (e.g. memfs)
41
+ * ```javascript
42
+ * import { fs as memfs } from 'memfs';
43
+ * const fs = await createBrowserFS({ fs: memfs });
44
+ * ```
33
45
  */
34
- export function createBrowserFS(opts = {}) {
46
+ export async function createBrowserFS(opts = {}) {
35
47
  // Check if we're in a browser environment
36
48
  if (typeof window === 'undefined') {
37
49
  throw new Error('createBrowserFS() should only be used in browser environments');
38
50
  }
39
51
 
40
- // Dynamic import to avoid issues in Node.js
41
- // Users must install @isomorphic-git/lightning-fs separately
42
- try {
43
- // This will be resolved by the bundler
44
- const LightningFS = require('@isomorphic-git/lightning-fs');
45
-
46
- const fs = new LightningFS(opts.name || 'jj', {
47
- wipe: opts.wipe || false,
48
- });
52
+ // Caller supplied their own filesystem — use it directly instead of
53
+ // pulling in LightningFS at all.
54
+ if (opts.fs) {
55
+ if (typeof opts.fs === 'function') {
56
+ return new opts.fs(opts.name || 'jj', { wipe: opts.wipe || false });
57
+ }
58
+ return opts.fs;
59
+ }
49
60
 
50
- return fs;
61
+ // Dynamic `import()` (not `require`) so this resolves in an ESM browser
62
+ // bundle — `require` does not exist there (see issue #28). Users must
63
+ // install @isomorphic-git/lightning-fs separately; it's an optional
64
+ // peer/dev dependency of this package.
65
+ let LightningFS;
66
+ try {
67
+ const mod = await import('@isomorphic-git/lightning-fs');
68
+ LightningFS = mod.default ?? mod;
51
69
  } catch (error) {
52
70
  throw new Error(
53
71
  'LightningFS not found. Install it with: npm install @isomorphic-git/lightning-fs\n' +
@@ -55,6 +73,10 @@ export function createBrowserFS(opts = {}) {
55
73
  error.message
56
74
  );
57
75
  }
76
+
77
+ return new LightningFS(opts.name || 'jj', {
78
+ wipe: opts.wipe || false,
79
+ });
58
80
  }
59
81
 
60
82
  /**
@@ -14,6 +14,13 @@ export interface BrowserFSOptions {
14
14
  name?: string;
15
15
  /** Wipe existing data on initialization */
16
16
  wipe?: boolean;
17
+ /**
18
+ * Bring your own filesystem instead of the default LightningFS
19
+ * auto-import. Pass either an already-constructed isomorphic-git
20
+ * compatible fs (returned as-is) or a LightningFS-shaped constructor
21
+ * (`new Ctor(name, { wipe })`).
22
+ */
23
+ fs?: any;
17
24
  }
18
25
 
19
26
  /**
@@ -99,11 +106,11 @@ export interface ServiceWorkerUtils {
99
106
  * import git from 'isomorphic-git';
100
107
  * import http from 'isomorphic-git/http/web';
101
108
  *
102
- * const fs = createBrowserFS({ backend: 'idb', name: 'my-repo' });
109
+ * const fs = await createBrowserFS({ backend: 'idb', name: 'my-repo' });
103
110
  * const jj = await createJJ({ fs, dir: '/repo', git, http });
104
111
  * ```
105
112
  */
106
- export function createBrowserFS(opts?: BrowserFSOptions): any;
113
+ export function createBrowserFS(opts?: BrowserFSOptions): Promise<any>;
107
114
 
108
115
  /**
109
116
  * Get storage quota information (browser only)
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { JJError } from '../utils/errors.js';
8
8
  import { validateChangeId } from '../utils/validation.js';
9
+ import { randomHex } from '../utils/id-generation.js';
9
10
 
10
11
  export class ChangeGraph {
11
12
  /**
@@ -95,6 +96,68 @@ export class ChangeGraph {
95
96
  await this.save();
96
97
  }
97
98
 
99
+ /**
100
+ * Add a DIVERGENT copy of an existing change: another commit that also
101
+ * claims `change.changeId` (see issue #32 / the `divergent()` revset —
102
+ * "multiple visible commits sharing one change id"). `addChange()`
103
+ * can't represent this: `nodes` is a `Map<changeId, change>`, one entry
104
+ * per key, by design — every other API (getChange, getParents,
105
+ * getChildren, ...) keys on plain changeId and would be ambiguous if
106
+ * that stopped being unique.
107
+ *
108
+ * Divergent copies are therefore stored under a synthetic internal key
109
+ * (`${changeId}\0${commitId}`) so the `nodes` Map stays a true map, but
110
+ * each copy's *own* `changeId` field is unchanged — so `getAll()` (and
111
+ * therefore the existing `divergent()` revset filter, which counts
112
+ * occurrences of `c.changeId` across `getAll()`) sees both/all of them
113
+ * and correctly reports the change as divergent. `getChange(changeId)`
114
+ * deliberately keeps resolving to just the primary copy (the one
115
+ * actually stored under the plain `changeId` key) — for anything that
116
+ * needs every copy, see getDivergentSiblings().
117
+ *
118
+ * @param {Record<string, any>} change - The divergent copy (must have
119
+ * the same `changeId` as an existing change, and its own `commitId`)
120
+ */
121
+ async addDivergentCopy(change) {
122
+ validateChangeId(change.changeId);
123
+
124
+ if (!this.nodes.has(change.changeId)) {
125
+ throw new JJError(
126
+ 'CHANGE_NOT_FOUND',
127
+ `Cannot add a divergent copy of ${change.changeId}: no existing change with that id`,
128
+ { changeId: change.changeId, suggestion: 'Use addChange() to create the first copy' }
129
+ );
130
+ }
131
+
132
+ const key = `${change.changeId}\0${change.commitId}`;
133
+ if (this.nodes.has(key)) {
134
+ throw new JJError(
135
+ 'CHANGE_EXISTS',
136
+ `Change ${change.changeId} already has a divergent copy with commit ${change.commitId}`,
137
+ { changeId: change.changeId, commitId: change.commitId }
138
+ );
139
+ }
140
+
141
+ change.divergent = true;
142
+ this.nodes.set(key, change);
143
+ this.commitIndex.set(change.commitId, change.changeId);
144
+
145
+ await this.save();
146
+ }
147
+
148
+ /**
149
+ * Get every visible copy of a (possibly divergent) change id — just the
150
+ * one copy in the common case, or the primary plus every divergent copy
151
+ * added via addDivergentCopy().
152
+ *
153
+ * @param {string} changeId - Change ID
154
+ * @returns {Array<any>} All copies sharing this changeId, primary first
155
+ */
156
+ getDivergentSiblings(changeId) {
157
+ validateChangeId(changeId);
158
+ return this.getAll().filter((change) => change.changeId === changeId);
159
+ }
160
+
98
161
  /**
99
162
  * Get a change by ID
100
163
  *
@@ -206,6 +269,65 @@ export class ChangeGraph {
206
269
  await this.save();
207
270
  }
208
271
 
272
+ /**
273
+ * Remove one divergent copy added via addDivergentCopy() — e.g. after
274
+ * converge() (issue #32) resolves a divergence and only needs to drop
275
+ * the now-superseded copy. The *primary* copy (stored under the plain
276
+ * `changeId` key) is never touched by this — use deleteChange() (or
277
+ * updateChange() to overwrite it with a resolved result) for that.
278
+ *
279
+ * @param {string} changeId
280
+ * @param {string} commitId - The divergent copy's own commit id
281
+ * @returns {Promise<boolean>} Whether a copy was actually removed
282
+ */
283
+ async deleteDivergentCopy(changeId, commitId) {
284
+ validateChangeId(changeId);
285
+
286
+ const key = `${changeId}\0${commitId}`;
287
+ const change = this.nodes.get(key);
288
+ if (!change) {
289
+ return false;
290
+ }
291
+
292
+ this.nodes.delete(key);
293
+ if (this.commitIndex.get(commitId) === changeId) {
294
+ this.commitIndex.delete(commitId);
295
+ }
296
+
297
+ await this.save();
298
+ return true;
299
+ }
300
+
301
+ /**
302
+ * Remove a change from the graph outright.
303
+ *
304
+ * Used to reverse an addChange() — e.g. undoing an operation that created
305
+ * a change (new(), or squash()'s synthetic empty working-copy change) —
306
+ * where restoring a "before" snapshot doesn't apply because there was no
307
+ * "before": the change simply didn't exist yet. A soft-delete (setting
308
+ * `abandoned: true` via updateChange()) is NOT the same thing and is used
309
+ * separately for abandon() itself.
310
+ *
311
+ * @param {string} changeId - Change ID to remove
312
+ * @returns {Promise<boolean>} Whether a change was actually removed
313
+ */
314
+ async deleteChange(changeId) {
315
+ validateChangeId(changeId);
316
+
317
+ const change = this.nodes.get(changeId);
318
+ if (!change) {
319
+ return false;
320
+ }
321
+
322
+ this.nodes.delete(changeId);
323
+ if (this.commitIndex.get(change.commitId) === changeId) {
324
+ this.commitIndex.delete(change.commitId);
325
+ }
326
+
327
+ await this.save();
328
+ return true;
329
+ }
330
+
209
331
  /**
210
332
  * Get all ancestors of a change (recursive parent traversal)
211
333
  *
@@ -243,10 +365,9 @@ export class ChangeGraph {
243
365
  * @returns {Promise<Record<string, any>>} Created change object
244
366
  */
245
367
  async createChange(params = {}) {
246
- const crypto = await import('crypto');
247
- const changeId = params.changeId || crypto.randomBytes(16).toString('hex');
248
- const commitId = params.commitId || crypto.randomBytes(20).toString('hex');
249
- const tree = params.tree || crypto.randomBytes(20).toString('hex');
368
+ const changeId = params.changeId || randomHex(16);
369
+ const commitId = params.commitId || randomHex(20);
370
+ const tree = params.tree || randomHex(20);
250
371
  const timestamp = params.timestamp || new Date().toISOString();
251
372
 
252
373
  /** @type {Record<string, any>} */
@@ -10,9 +10,11 @@
10
10
  * This matches JJ's philosophy: conflicts never block you.
11
11
  */
12
12
 
13
- import path from 'path';
13
+ import * as path from '../utils/posix-path.js';
14
14
  import { JJError } from '../utils/errors.js';
15
+ import { isBytes } from '../utils/bytes.js';
15
16
  import { generateId } from '../utils/id-generation.js';
17
+ import { mkdirp } from '../utils/mkdirp.js';
16
18
 
17
19
  /**
18
20
  * Conflict types supported by JJ
@@ -174,6 +176,15 @@ export class ConflictModel {
174
176
  const conflict = this._detectPathConflict(path, baseContent, leftContent, rightContent);
175
177
  if (conflict) {
176
178
  conflicts.push(conflict);
179
+ } else if (workingCopyDir) {
180
+ // No conflict, but "no conflict" doesn't mean "nothing to do": of
181
+ // _detectPathConflict's null-returning cases, every one *except*
182
+ // "right side changed, left didn't" already matches what's on disk,
183
+ // because `leftContent` **is** what's on disk (read straight from
184
+ // the working copy above) -- so only that one case needs a write.
185
+ if (baseContent === leftContent && baseContent !== rightContent) {
186
+ await this._applyCleanResolution(workingCopyDir, path, rightContent);
187
+ }
177
188
  }
178
189
  }
179
190
 
@@ -261,6 +272,30 @@ export class ConflictModel {
261
272
  return null;
262
273
  }
263
274
 
275
+ /**
276
+ * Create a content conflict with more than two disagreeing versions —
277
+ * every other conflict in this module has exactly two sides (`left`/
278
+ * `right`, or `left`/`right` on an add-add), because every OTHER caller
279
+ * (merge, rebase) only ever three-way-merges two changes at a time.
280
+ * converge() (issue #32) can face more than two divergent copies of one
281
+ * change in a single call, so `sides` here is `{ base, versions }`
282
+ * instead: `versions` is every distinct value found across all copies
283
+ * that differs from `base`, each paired with (one of) the commitId(s)
284
+ * that has it.
285
+ *
286
+ * @param {string} path
287
+ * @param {any} base
288
+ * @param {Array<{commitId: string, content: any}>} versions
289
+ */
290
+ createNWayConflict(path, base, versions) {
291
+ return this._createConflict({
292
+ type: ConflictType.CONTENT,
293
+ path,
294
+ sides: { base, versions },
295
+ message: `${versions.length} divergent copies disagree on this file's content`,
296
+ });
297
+ }
298
+
264
299
  /**
265
300
  * Create a conflict object
266
301
  *
@@ -504,6 +539,36 @@ export class ConflictModel {
504
539
  return result;
505
540
  }
506
541
 
542
+ /**
543
+ * Apply a clean (non-conflicting) three-way resolution to the working
544
+ * copy, for the default (no merge driver) path in detectConflicts(): the
545
+ * "right side wins" case, where the resolved content differs from what's
546
+ * currently on disk (see the call site for why this is the only clean
547
+ * case that needs one).
548
+ *
549
+ * @param {string} workingCopyDir - Working copy directory
550
+ * @param {string} filePath - File path
551
+ * @param {any} content - The winning (right) side's content, or
552
+ * `undefined` to mean the file was deleted on that side
553
+ */
554
+ async _applyCleanResolution(workingCopyDir, filePath, content) {
555
+ const fullPath = path.join(workingCopyDir, filePath);
556
+
557
+ if (content === undefined) {
558
+ await this.fs.promises.unlink(fullPath).catch(() => {});
559
+ return;
560
+ }
561
+
562
+ const dir = path.dirname(fullPath);
563
+ await mkdirp(this.fs, dir);
564
+
565
+ if (isBytes(content)) {
566
+ await this.fs.promises.writeFile(fullPath, content);
567
+ } else {
568
+ await this.fs.promises.writeFile(fullPath, content, 'utf8');
569
+ }
570
+ }
571
+
507
572
  /**
508
573
  * Write merge driver result to working copy (v0.5)
509
574
  *
@@ -516,14 +581,14 @@ export class ConflictModel {
516
581
 
517
582
  // Ensure directory exists
518
583
  const dir = path.dirname(fullPath);
519
- await this.fs.promises.mkdir(dir, { recursive: true });
584
+ await mkdirp(this.fs, dir);
520
585
 
521
586
  // Write main file
522
587
  if (result.content !== null && result.content !== undefined) {
523
- if (Buffer.isBuffer(result.content)) {
588
+ if (isBytes(result.content)) {
524
589
  await this.fs.promises.writeFile(fullPath, result.content);
525
590
  } else {
526
- await this.fs.promises.writeFile(fullPath, result.content, 'utf-8');
591
+ await this.fs.promises.writeFile(fullPath, result.content, 'utf8');
527
592
  }
528
593
  }
529
594
 
@@ -532,12 +597,12 @@ export class ConflictModel {
532
597
  for (const [additionalPath, additionalContent] of Object.entries(result.additionalFiles)) {
533
598
  const additionalFullPath = path.join(workingCopyDir, additionalPath);
534
599
  const additionalDir = path.dirname(additionalFullPath);
535
- await this.fs.promises.mkdir(additionalDir, { recursive: true });
600
+ await mkdirp(this.fs, additionalDir);
536
601
 
537
- if (Buffer.isBuffer(additionalContent)) {
602
+ if (isBytes(additionalContent)) {
538
603
  await this.fs.promises.writeFile(additionalFullPath, additionalContent);
539
604
  } else {
540
- await this.fs.promises.writeFile(additionalFullPath, additionalContent, 'utf-8');
605
+ await this.fs.promises.writeFile(additionalFullPath, additionalContent, 'utf8');
541
606
  }
542
607
  }
543
608
  }
@@ -5,18 +5,8 @@
5
5
  * This file tracks which operation the working copy is at.
6
6
  */
7
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
- // Determine proto path based on whether we're running from dist or src
16
- const inDist = __dirname.includes('/dist') || __dirname.includes('\\dist');
17
- const protoDir = inDist
18
- ? path.join(__dirname, '..', 'src', 'protos')
19
- : path.join(__dirname, '..', 'protos');
8
+ import { getLocalWorkingCopyRoot } from '../protos/schema.js';
9
+ import { hexToBytes } from '../utils/bytes.js';
20
10
 
21
11
  export class JJCheckout {
22
12
  /**
@@ -26,7 +16,6 @@ export class JJCheckout {
26
16
  constructor(fs, dir) {
27
17
  this.fs = fs;
28
18
  this.dir = dir;
29
- this.protoPath = path.join(protoDir, 'local_working_copy.proto');
30
19
  }
31
20
 
32
21
  /**
@@ -37,11 +26,11 @@ export class JJCheckout {
37
26
  */
38
27
  async writeCheckout(operationId, workspaceName = 'default') {
39
28
  // Load protobuf schema
40
- const root = await protobuf.load(this.protoPath);
29
+ const root = getLocalWorkingCopyRoot();
41
30
  const Checkout = root.lookupType('local_working_copy.Checkout');
42
31
 
43
32
  // Convert hex operation ID to bytes
44
- const opIdBuffer = Buffer.from(operationId, 'hex');
33
+ const opIdBuffer = hexToBytes(operationId);
45
34
 
46
35
  // Create message (use camelCase for protobufjs)
47
36
  const message = Checkout.create({
@@ -73,7 +62,7 @@ export class JJCheckout {
73
62
  const buffer = await this.fs.promises.readFile(checkoutPath);
74
63
 
75
64
  // Load protobuf schema
76
- const root = await protobuf.load(this.protoPath);
65
+ const root = getLocalWorkingCopyRoot();
77
66
  const Checkout = root.lookupType('local_working_copy.Checkout');
78
67
 
79
68
  // Decode
@@ -5,19 +5,9 @@
5
5
  * Operations track the history of changes to the repository.
6
6
  */
7
7
 
8
- import protobuf from 'protobufjs';
9
- import path from 'path';
10
- import { fileURLToPath } from 'url';
11
8
  import { atomicWriteFile } from '../utils/atomic-write.js';
12
-
13
- const __filename = fileURLToPath(import.meta.url);
14
- const __dirname = path.dirname(__filename);
15
-
16
- // Determine proto path based on whether we're running from dist or src
17
- const inDist = __dirname.includes('/dist') || __dirname.includes('\\dist');
18
- const protoDir = inDist
19
- ? path.join(__dirname, '..', 'src', 'protos')
20
- : path.join(__dirname, '..', 'protos');
9
+ import { getSimpleOpStoreRoot } from '../protos/schema.js';
10
+ import { hexToBytes } from '../utils/bytes.js';
21
11
 
22
12
  export class JJOperationStore {
23
13
  /**
@@ -27,7 +17,6 @@ export class JJOperationStore {
27
17
  constructor(fs, dir) {
28
18
  this.fs = fs;
29
19
  this.dir = dir;
30
- this.protoPath = path.join(protoDir, 'simple_op_store.proto');
31
20
  }
32
21
 
33
22
  /**
@@ -40,12 +29,12 @@ export class JJOperationStore {
40
29
  */
41
30
  async writeOperation(operationId, viewId, parentIds, metadata) {
42
31
  // Load protobuf schema
43
- const root = await protobuf.load(this.protoPath);
32
+ const root = getSimpleOpStoreRoot();
44
33
  const Operation = root.lookupType('simple_op_store.Operation');
45
34
 
46
35
  // Convert hex IDs to bytes
47
- const viewIdBuffer = Buffer.from(viewId, 'hex');
48
- const parentBuffers = parentIds.map((id) => Buffer.from(id, 'hex'));
36
+ const viewIdBuffer = hexToBytes(viewId);
37
+ const parentBuffers = parentIds.map((id) => hexToBytes(id));
49
38
 
50
39
  // Create metadata message (use camelCase for protobufjs)
51
40
  const metadataMsg = {
@@ -101,7 +90,7 @@ export class JJOperationStore {
101
90
  const buffer = await this.fs.promises.readFile(opPath);
102
91
 
103
92
  // Load protobuf schema
104
- const root = await protobuf.load(this.protoPath);
93
+ const root = getSimpleOpStoreRoot();
105
94
  const Operation = root.lookupType('simple_op_store.Operation');
106
95
 
107
96
  // Decode
@@ -5,19 +5,9 @@
5
5
  * This file tracks the working copy tree and file states.
6
6
  */
7
7
 
8
- import protobuf from 'protobufjs';
9
- import path from 'path';
10
- import { fileURLToPath } from 'url';
11
8
  import { atomicWriteFile } from '../utils/atomic-write.js';
12
-
13
- const __filename = fileURLToPath(import.meta.url);
14
- const __dirname = path.dirname(__filename);
15
-
16
- // Determine proto path based on whether we're running from dist or src
17
- const inDist = __dirname.includes('/dist') || __dirname.includes('\\dist');
18
- const protoDir = inDist
19
- ? path.join(__dirname, '..', 'src', 'protos')
20
- : path.join(__dirname, '..', 'protos');
9
+ import { getLocalWorkingCopyRoot } from '../protos/schema.js';
10
+ import { hexToBytes } from '../utils/bytes.js';
21
11
 
22
12
  export class JJTreeState {
23
13
  /**
@@ -27,7 +17,6 @@ export class JJTreeState {
27
17
  constructor(fs, dir) {
28
18
  this.fs = fs;
29
19
  this.dir = dir;
30
- this.protoPath = path.join(protoDir, 'local_working_copy.proto');
31
20
  }
32
21
 
33
22
  /**
@@ -38,11 +27,11 @@ export class JJTreeState {
38
27
  */
39
28
  async writeTreeState(treeId, fileStates = []) {
40
29
  // Load protobuf schema
41
- const root = await protobuf.load(this.protoPath);
30
+ const root = getLocalWorkingCopyRoot();
42
31
  const TreeState = root.lookupType('local_working_copy.TreeState');
43
32
 
44
33
  // Convert hex tree ID to bytes
45
- const treeIdBuffer = Buffer.from(treeId, 'hex');
34
+ const treeIdBuffer = hexToBytes(treeId);
46
35
 
47
36
  // Convert file states to protobuf format
48
37
  const fileStateEntries = fileStates.map((fs) => ({
@@ -88,7 +77,7 @@ export class JJTreeState {
88
77
  const buffer = await this.fs.promises.readFile(treeStatePath);
89
78
 
90
79
  // Load protobuf schema
91
- const root = await protobuf.load(this.protoPath);
80
+ const root = getLocalWorkingCopyRoot();
92
81
  const TreeState = root.lookupType('local_working_copy.TreeState');
93
82
 
94
83
  // Decode
@@ -5,19 +5,9 @@
5
5
  * Views represent the state of the repository at a given operation.
6
6
  */
7
7
 
8
- import protobuf from 'protobufjs';
9
- import path from 'path';
10
- import { fileURLToPath } from 'url';
11
8
  import { atomicWriteFile } from '../utils/atomic-write.js';
12
-
13
- const __filename = fileURLToPath(import.meta.url);
14
- const __dirname = path.dirname(__filename);
15
-
16
- // Determine proto path based on whether we're running from dist or src
17
- const inDist = __dirname.includes('/dist') || __dirname.includes('\\dist');
18
- const protoDir = inDist
19
- ? path.join(__dirname, '..', 'src', 'protos')
20
- : path.join(__dirname, '..', 'protos');
9
+ import { getSimpleOpStoreRoot } from '../protos/schema.js';
10
+ import { hexToBytes } from '../utils/bytes.js';
21
11
 
22
12
  export class JJViewStore {
23
13
  /**
@@ -27,7 +17,6 @@ export class JJViewStore {
27
17
  constructor(fs, dir) {
28
18
  this.fs = fs;
29
19
  this.dir = dir;
30
- this.protoPath = path.join(protoDir, 'simple_op_store.proto');
31
20
  }
32
21
 
33
22
  /**
@@ -39,17 +28,17 @@ export class JJViewStore {
39
28
  */
40
29
  async writeView(viewId, headIds, wcCommitIds = {}) {
41
30
  // Load protobuf schema
42
- const root = await protobuf.load(this.protoPath);
31
+ const root = getSimpleOpStoreRoot();
43
32
  const View = root.lookupType('simple_op_store.View');
44
33
 
45
34
  // Convert hex IDs to bytes
46
- const headBuffers = headIds.map((id) => Buffer.from(id, 'hex'));
35
+ const headBuffers = headIds.map((id) => hexToBytes(id));
47
36
 
48
37
  // Convert wcCommitIds object to map
49
38
  /** @type {Record<string, any>} */
50
39
  const wcCommitIdsMap = {};
51
40
  for (const [workspace, commitId] of Object.entries(wcCommitIds)) {
52
- wcCommitIdsMap[workspace] = Buffer.from(commitId, 'hex');
41
+ wcCommitIdsMap[workspace] = hexToBytes(commitId);
53
42
  }
54
43
 
55
44
  // Create view message (use camelCase for protobufjs)
@@ -90,7 +79,7 @@ export class JJViewStore {
90
79
  const buffer = await this.fs.promises.readFile(viewPath);
91
80
 
92
81
  // Load protobuf schema
93
- const root = await protobuf.load(this.protoPath);
82
+ const root = getSimpleOpStoreRoot();
94
83
  const View = root.lookupType('simple_op_store.View');
95
84
 
96
85
  // Decode