isomorphic-jj 1.7.0 → 1.9.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.
@@ -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
@@ -540,7 +541,11 @@ export class IsomorphicGitBackend {
540
541
  * @param {boolean} [opts.force] - Allow non-fast-forward
541
542
  * @param {any} [opts.onProgress] - Progress callback
542
543
  * @param {any} [opts.onAuth] - Authentication callback
543
- * @returns {Promise<{pushedRefs: Array<any>, rejectedRefs: Array<any>}>} Push result
544
+ * @returns {Promise<{pushedRefs: Array<{name: string, oid: string}>, rejectedRefs: Array<{name: string, code: string, reason: string, message: string}>}>} Push result.
545
+ * Each rejected ref carries a `code` (one of `AUTH_FAILED`,
546
+ * `NETWORK_ERROR`, `NON_FAST_FORWARD`, `OTHER`) and a human-readable
547
+ * `reason`, so callers can tell an auth failure apart from a
548
+ * non-fast-forward rejection instead of guessing (see issue #14).
544
549
  */
545
550
  async push(opts) {
546
551
  if (!this.http) {
@@ -576,8 +581,13 @@ export class IsomorphicGitBackend {
576
581
  pushedRefs.push({ name: ref, oid });
577
582
  }
578
583
  } catch (pushError) {
579
- // Track rejected refs
580
- rejectedRefs.push(ref);
584
+ // Track rejected refs with the categorized reason so callers can
585
+ // distinguish an auth failure from a non-fast-forward rejection
586
+ // (or a network blip) instead of guessing — see issue #14. A
587
+ // caller that blindly retries with force:true on every rejection
588
+ // risks doing so on what was actually an auth/network problem.
589
+ const { code, reason } = this._categorizePushError(pushError);
590
+ rejectedRefs.push({ name: ref, code, reason, message: pushError.message });
581
591
  }
582
592
  }
583
593
 
@@ -609,6 +619,53 @@ export class IsomorphicGitBackend {
609
619
  }
610
620
  }
611
621
 
622
+ /**
623
+ * Categorize a per-ref push failure into a stable code/reason pair so
624
+ * callers can distinguish auth failures, network errors, and
625
+ * non-fast-forward rejections from each other and from other/unknown
626
+ * failures — see issue #14. isomorphic-git doesn't throw a single
627
+ * "AuthError" class; auth failures generally surface as an HttpError with
628
+ * a 401/403 status code (or a UserCanceledError if onAuth cancels).
629
+ *
630
+ * @private
631
+ * @param {any} error - Error thrown by git.push() for a single ref
632
+ * @returns {{code: string, reason: string}}
633
+ */
634
+ _categorizePushError(error) {
635
+ const errCode = error && error.code;
636
+
637
+ if (errCode === 'PushRejectedError') {
638
+ const reason = error.data && error.data.reason;
639
+ return {
640
+ code: 'NON_FAST_FORWARD',
641
+ reason: reason === 'tag-exists' ? 'tag already exists on remote' : 'non-fast-forward',
642
+ };
643
+ }
644
+
645
+ if (errCode === 'HttpError') {
646
+ const status = error.data && error.data.statusCode;
647
+ if (status === 401 || status === 403) {
648
+ return { code: 'AUTH_FAILED', reason: `HTTP ${status}` };
649
+ }
650
+ return { code: 'NETWORK_ERROR', reason: status ? `HTTP ${status}` : 'HTTP error' };
651
+ }
652
+
653
+ if (
654
+ errCode === 'AuthError' ||
655
+ errCode === 'UserCanceledError' ||
656
+ errCode === 'MissingUsernameError' ||
657
+ errCode === 'MissingPasswordTokenError'
658
+ ) {
659
+ return { code: 'AUTH_FAILED', reason: errCode };
660
+ }
661
+
662
+ if (errCode === 'NetworkError' || errCode === 'UnknownTransportError') {
663
+ return { code: 'NETWORK_ERROR', reason: errCode };
664
+ }
665
+
666
+ return { code: 'OTHER', reason: (error && error.message) || String(error) };
667
+ }
668
+
612
669
  /**
613
670
  * Create minimal .jj/repo structure for jj CLI compatibility
614
671
  * @private
@@ -618,14 +675,14 @@ export class IsomorphicGitBackend {
618
675
  const jjRepo = `${jjDir}/repo`;
619
676
 
620
677
  // Create directory structure
621
- await this.fs.promises.mkdir(`${jjRepo}/store`, { recursive: true });
622
- await this.fs.promises.mkdir(`${jjRepo}/store/extra/heads`, { recursive: true });
623
- await this.fs.promises.mkdir(`${jjRepo}/op_store/operations`, { recursive: true });
624
- await this.fs.promises.mkdir(`${jjRepo}/op_store/views`, { recursive: true });
625
- await this.fs.promises.mkdir(`${jjRepo}/op_heads/heads`, { recursive: true });
626
- await this.fs.promises.mkdir(`${jjRepo}/index`, { recursive: true });
627
- await this.fs.promises.mkdir(`${jjRepo}/submodule_store`, { recursive: true });
628
- 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`);
629
686
 
630
687
  // Write type files
631
688
  await this.fs.promises.writeFile(`${jjRepo}/store/type`, 'git');
@@ -652,8 +709,8 @@ export class IsomorphicGitBackend {
652
709
  */
653
710
  async _createInitialJJState() {
654
711
  // Generate unique IDs for initial state
655
- const operationId = crypto.randomBytes(64).toString('hex'); // 512-bit = 128 hex chars
656
- const viewId = crypto.randomBytes(64).toString('hex');
712
+ const operationId = randomHex(64); // 512-bit = 128 hex chars
713
+ const viewId = randomHex(64);
657
714
  const emptyTreeId = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; // Git's empty tree hash
658
715
 
659
716
  // Get the initial commit from Git
@@ -665,7 +722,7 @@ export class IsomorphicGitBackend {
665
722
  ref: 'HEAD',
666
723
  });
667
724
  if (headOid) {
668
- initialCommitId = crypto.randomBytes(64).toString('hex');
725
+ initialCommitId = randomHex(64);
669
726
  }
670
727
  } catch (error) {
671
728
  // No initial commit yet
@@ -5,24 +5,30 @@
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
20
- * import { createBrowserFS } from 'isomorphic-jj/browser';
21
- * import { createJJ } from 'isomorphic-jj';
26
+ * import { createBrowserFS } from '@johnhenry/isomorphic-jj/browser';
27
+ * import { createJJ } from '@johnhenry/isomorphic-jj';
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,18 +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
-
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 { atomicWriteFile } from '../utils/atomic-write.js';
9
+ import { getSimpleOpStoreRoot } from '../protos/schema.js';
10
+ import { hexToBytes } from '../utils/bytes.js';
20
11
 
21
12
  export class JJOperationStore {
22
13
  /**
@@ -26,7 +17,6 @@ export class JJOperationStore {
26
17
  constructor(fs, dir) {
27
18
  this.fs = fs;
28
19
  this.dir = dir;
29
- this.protoPath = path.join(protoDir, 'simple_op_store.proto');
30
20
  }
31
21
 
32
22
  /**
@@ -39,12 +29,12 @@ export class JJOperationStore {
39
29
  */
40
30
  async writeOperation(operationId, viewId, parentIds, metadata) {
41
31
  // Load protobuf schema
42
- const root = await protobuf.load(this.protoPath);
32
+ const root = getSimpleOpStoreRoot();
43
33
  const Operation = root.lookupType('simple_op_store.Operation');
44
34
 
45
35
  // Convert hex IDs to bytes
46
- const viewIdBuffer = Buffer.from(viewId, 'hex');
47
- const parentBuffers = parentIds.map((id) => Buffer.from(id, 'hex'));
36
+ const viewIdBuffer = hexToBytes(viewId);
37
+ const parentBuffers = parentIds.map((id) => hexToBytes(id));
48
38
 
49
39
  // Create metadata message (use camelCase for protobufjs)
50
40
  const metadataMsg = {
@@ -81,9 +71,11 @@ export class JJOperationStore {
81
71
  // Encode to binary
82
72
  const buffer = Operation.encode(message).finish();
83
73
 
84
- // Write to .jj/repo/op_store/operations/OPERATIONID
74
+ // Write to .jj/repo/op_store/operations/OPERATIONID (atomically — a
75
+ // crash mid-write must never leave a truncated/undecodable file, see
76
+ // issue #16).
85
77
  const opPath = `${this.dir}/.jj/repo/op_store/operations/${operationId}`;
86
- await this.fs.promises.writeFile(opPath, buffer);
78
+ await atomicWriteFile(this.fs, opPath, buffer);
87
79
  }
88
80
 
89
81
  /**
@@ -98,7 +90,7 @@ export class JJOperationStore {
98
90
  const buffer = await this.fs.promises.readFile(opPath);
99
91
 
100
92
  // Load protobuf schema
101
- const root = await protobuf.load(this.protoPath);
93
+ const root = getSimpleOpStoreRoot();
102
94
  const Operation = root.lookupType('simple_op_store.Operation');
103
95
 
104
96
  // Decode