isomorphic-jj 1.7.0 → 1.8.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "isomorphic-jj",
3
- "version": "1.7.0",
4
- "description": "A pure-JavaScript library that brings Jujutsu (jj) version control semantics to Node.js and browsers",
3
+ "version": "1.8.0",
4
+ "description": "Jujutsu (jj) version control semantics in pure JavaScript — stable change IDs, fearless undo, no staging area — for Node.js and browsers",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "isojj": "./bin/isojj.js"
@@ -30,6 +30,19 @@
30
30
  ],
31
31
  "scripts": {
32
32
  "demo": "node demo.mjs",
33
+ "examples": "for f in examples/*.mjs; do echo \"=== $f\"; node \"$f\" || exit 1; done",
34
+ "example:01": "node examples/01-init-commit-log.mjs",
35
+ "example:02": "node examples/02-stacked-changes.mjs",
36
+ "example:03": "node examples/03-history-editing.mjs",
37
+ "example:04": "node examples/04-branching-and-merging.mjs",
38
+ "example:05": "node examples/05-conflicts.mjs",
39
+ "example:06": "node examples/06-revsets.mjs",
40
+ "example:07": "node examples/07-bookmarks-and-tags.mjs",
41
+ "example:08": "node examples/08-git-interop.mjs",
42
+ "example:09": "node examples/09-config.mjs",
43
+ "example:10": "node examples/10-undo-and-oplog.mjs",
44
+ "example:11": "node examples/11-file-operations.mjs",
45
+ "example:12": "node examples/12-browser.mjs",
33
46
  "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
34
47
  "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch",
35
48
  "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
@@ -58,12 +71,12 @@
58
71
  "license": "MIT",
59
72
  "repository": {
60
73
  "type": "git",
61
- "url": "https://github.com/johnhenry/isomorphic-jj.git"
74
+ "url": "git+https://github.com/johnhenry/isomorphic-jj.git"
62
75
  },
63
76
  "bugs": {
64
77
  "url": "https://github.com/johnhenry/isomorphic-jj/issues"
65
78
  },
66
- "homepage": "https://github.com/johnhenry/isomorphic-jj#readme",
79
+ "homepage": "https://opensource.johnhenry.me/isomorphic-jj/",
67
80
  "peerDependencies": {
68
81
  "isomorphic-git": "^1.24.0"
69
82
  },
@@ -1155,6 +1155,11 @@ export async function createJJ(options) {
1155
1155
  const isWorkingCopy = targetChangeId === workingCopy.getCurrentChangeId();
1156
1156
  const fileSnapshotBefore = isWorkingCopy ? await snapshotFilesystem() : null;
1157
1157
 
1158
+ // Snapshot the change record itself from BEFORE this operation mutates
1159
+ // it in place, so undo() can restore ChangeGraph content (not just
1160
+ // on-disk files/working-copy pointer) — mirrors fileSnapshotBefore above.
1161
+ const changeSnapshotBefore = structuredClone(change);
1162
+
1158
1163
  // Dispatch change:updating event (preventable)
1159
1164
  await dispatchEventAsync(jj, 'change:updating', {
1160
1165
  operation: 'describe',
@@ -1235,6 +1240,10 @@ export async function createJJ(options) {
1235
1240
  },
1236
1241
  description: `describe change ${targetChangeId.slice(0, 8)}`,
1237
1242
  parents: [],
1243
+ // Snapshot of the affected ChangeGraph node from BEFORE this
1244
+ // operation ran, so undo() can restore in-place content mutations
1245
+ // (e.g. the description change below) — not just files/pointers.
1246
+ changeSnapshot: { [targetChangeId]: changeSnapshotBefore },
1238
1247
  view: {
1239
1248
  bookmarks: {},
1240
1249
  remoteBookmarks: {},
@@ -1747,11 +1756,28 @@ export async function createJJ(options) {
1747
1756
  // Try to resolve as revset
1748
1757
  try {
1749
1758
  const changes = await revset.evaluate(args.change);
1759
+ if (changes.length > 1) {
1760
+ // An id-prefix (or other) revset that matches more than one
1761
+ // change must not silently pick a "winner" — see issue #13.
1762
+ throw new JJError(
1763
+ 'AMBIGUOUS_ID',
1764
+ `"${args.change}" is ambiguous: it resolves to ${changes.length} changes (${changes.join(', ')})`,
1765
+ {
1766
+ expression: args.change,
1767
+ matches: changes,
1768
+ suggestion: 'Use a longer prefix or an exact change ID to disambiguate',
1769
+ }
1770
+ );
1771
+ }
1750
1772
  if (changes.length > 0) {
1751
1773
  changeId = changes[0];
1752
1774
  }
1753
- } catch {
1754
- // Use as-is
1775
+ } catch (error) {
1776
+ if (error instanceof JJError && error.code === 'AMBIGUOUS_ID') {
1777
+ throw error;
1778
+ }
1779
+ // Not a valid revset expression - use args.change as-is (e.g. a
1780
+ // literal change ID that revset parsing rejected).
1755
1781
  }
1756
1782
  }
1757
1783
 
@@ -1994,6 +2020,21 @@ export async function createJJ(options) {
1994
2020
  // Restore state from previous view
1995
2021
  await workingCopy.setCurrentChange(previousView.workingCopy);
1996
2022
 
2023
+ // Restore ChangeGraph content mutated in place by the operation being
2024
+ // undone (e.g. describe() editing change.description) — see issue #12.
2025
+ // currentOp.changeSnapshot holds the affected change record(s) as they
2026
+ // were BEFORE currentOp ran, captured the same way fileSnapshot is.
2027
+ await graph.load();
2028
+ if (currentOp && currentOp.changeSnapshot) {
2029
+ for (const snapshot of Object.values(
2030
+ /** @type {Record<string, any>} */ (currentOp.changeSnapshot)
2031
+ )) {
2032
+ if (snapshot && (await graph.getChange(snapshot.changeId))) {
2033
+ await graph.updateChange(snapshot);
2034
+ }
2035
+ }
2036
+ }
2037
+
1997
2038
  // Restore filesystem from previous operation's snapshot (taken BEFORE that operation)
1998
2039
  // This is how JJ snapshots the working copy before every command
1999
2040
  if (previousView.fileSnapshot) {
@@ -2478,6 +2519,7 @@ export async function createJJ(options) {
2478
2519
  // Compute the inverse changes
2479
2520
  const inversChanges = /** @type {Record<string, any>} */ ({
2480
2521
  bookmarks: {},
2522
+ tags: {},
2481
2523
  heads: [],
2482
2524
  });
2483
2525
 
@@ -2517,6 +2559,46 @@ export async function createJJ(options) {
2517
2559
 
2518
2560
  await bookmarks.save();
2519
2561
 
2562
+ // Revert tag changes (mirrors the bookmark diff above — issue #12).
2563
+ // Unlike bookmarks, TagStore has no "move" primitive (tags are meant
2564
+ // to be immutable once created), so a moved tag is reverted via
2565
+ // delete+recreate, matching how tag.set() itself updates a tag.
2566
+ const prevTags = previousOp.view?.tags || {};
2567
+ const targetTags = targetOp.view?.tags || {};
2568
+
2569
+ for (const name of Object.keys(targetTags)) {
2570
+ if (!prevTags[name]) {
2571
+ // Tag was added - delete it
2572
+ if (await tags.exists(name)) {
2573
+ await tags.delete(name);
2574
+ }
2575
+ inversChanges.tags[name] = { action: 'deleted' };
2576
+ } else if (prevTags[name] !== targetTags[name]) {
2577
+ // Tag was moved - restore old target
2578
+ if (await tags.exists(name)) {
2579
+ await tags.delete(name);
2580
+ }
2581
+ await tags.create(name, prevTags[name]);
2582
+ inversChanges.tags[name] = {
2583
+ action: 'moved',
2584
+ from: targetTags[name],
2585
+ to: prevTags[name],
2586
+ };
2587
+ }
2588
+ }
2589
+
2590
+ // Check for deleted tags (existed before, don't exist after)
2591
+ for (const name of Object.keys(prevTags)) {
2592
+ if (!targetTags[name] && !(await tags.exists(name))) {
2593
+ // Tag was deleted - restore it
2594
+ await tags.create(name, prevTags[name]);
2595
+ inversChanges.tags[name] = {
2596
+ action: 'restored',
2597
+ to: prevTags[name],
2598
+ };
2599
+ }
2600
+ }
2601
+
2520
2602
  // Revert working copy change if it changed
2521
2603
  if (previousOp.view?.workingCopy !== targetOp.view?.workingCopy) {
2522
2604
  await workingCopy.load();
@@ -2531,6 +2613,7 @@ export async function createJJ(options) {
2531
2613
  parents: [],
2532
2614
  view: {
2533
2615
  bookmarks: prevBookmarks,
2616
+ tags: prevTags,
2534
2617
  remoteBookmarks: previousOp.view?.remoteBookmarks || {},
2535
2618
  heads: previousOp.view?.heads || [],
2536
2619
  workingCopy: previousOp.view?.workingCopy,
@@ -4968,11 +5051,18 @@ export async function createJJ(options) {
4968
5051
  throw new JJError('CONFLICT_NOT_FOUND', `Conflict ${args.conflictId} not found`);
4969
5052
  }
4970
5053
 
4971
- // Generate standard conflict markers
5054
+ // Generate standard conflict markers. Force a newline before each
5055
+ // boundary marker whenever there's non-empty content that doesn't
5056
+ // already end in one — otherwise the content fuses onto the
5057
+ // `=======`/`>>>>>>> theirs` marker, producing a corrupted/unparseable
5058
+ // line (see issue #15). Empty sides (e.g. a modify-delete conflict)
5059
+ // intentionally get no extra blank line before the marker.
4972
5060
  const ours = conflict.sides.left || '';
4973
5061
  const theirs = conflict.sides.right || '';
5062
+ const oursSep = ours && !ours.endsWith('\n') ? '\n' : '';
5063
+ const theirsSep = theirs && !theirs.endsWith('\n') ? '\n' : '';
4974
5064
 
4975
- return `<<<<<<< ours\n${ours}=======\n${theirs}>>>>>>> theirs`;
5065
+ return `<<<<<<< ours\n${ours}${oursSep}=======\n${theirs}${theirsSep}>>>>>>> theirs`;
4976
5066
  },
4977
5067
 
4978
5068
  /**
@@ -5059,10 +5149,17 @@ export async function createJJ(options) {
5059
5149
  * Searches the tracked files of a change (defaults to the working copy)
5060
5150
  * for lines matching a pattern. Following jj v0.41, the pattern is treated
5061
5151
  * as a regular expression by default; pass `{ kind: 'substring' }` for a
5062
- * literal search.
5152
+ * literal search. isomorphic-jj has always returned the matched line
5153
+ * (and its 1-based `lineNumber`) alongside the path, which is what jj
5154
+ * v0.44 made the CLI's new default (`jj file search` used to print only
5155
+ * file paths). Pass `{ nameOnly: true }` for the pre-v0.44 CLI
5156
+ * behavior — a deduplicated list of matching file paths, matching the
5157
+ * new `--name-only` flag.
5063
5158
  *
5064
- * @param {Record<string, any>} args - Arguments
5065
- * @returns {Promise<Array<{path: string, lineNumber: number, line: string}>>}
5159
+ * @param {Record<string, any>} args - Arguments. Pass `nameOnly: true`
5160
+ * to return only unique matching file paths instead of per-line
5161
+ * matches (jj v0.44 `--name-only`)
5162
+ * @returns {Promise<Array<{path: string, lineNumber: number, line: string}>|Array<string>>}
5066
5163
  */
5067
5164
  async search(args) {
5068
5165
  if (!args || !args.pattern) {
@@ -5113,6 +5210,10 @@ export async function createJJ(options) {
5113
5210
  }
5114
5211
  });
5115
5212
  }
5213
+
5214
+ if (args.nameOnly) {
5215
+ return Array.from(new Set(results.map((r) => r.path)));
5216
+ }
5116
5217
  return results;
5117
5218
  },
5118
5219
 
@@ -6194,6 +6295,10 @@ export async function createJJ(options) {
6194
6295
  view: {
6195
6296
  bookmarks: {},
6196
6297
  remoteBookmarks: {},
6298
+ // Tag delta for this operation (mirrors how `bookmarks` records
6299
+ // its delta above), so operations.revert() can diff it — see
6300
+ // issue #12.
6301
+ tags: { [args.name]: changeId },
6197
6302
  heads: [],
6198
6303
  workingCopy: workingCopy.getCurrentChangeId(),
6199
6304
  },
@@ -6239,6 +6344,7 @@ export async function createJJ(options) {
6239
6344
  view: {
6240
6345
  bookmarks: {},
6241
6346
  remoteBookmarks: {},
6347
+ tags: { [args.name]: changeId },
6242
6348
  heads: [],
6243
6349
  workingCopy: workingCopy.getCurrentChangeId(),
6244
6350
  },
@@ -6248,10 +6354,11 @@ export async function createJJ(options) {
6248
6354
  },
6249
6355
 
6250
6356
  /**
6251
- * List all tags
6357
+ * List all tags. Each entry also carries a `tracking: { remote, ref }`
6358
+ * field when the tag is tracking a remote (jj v0.44 `jj tag track`).
6252
6359
  *
6253
6360
  * @param {Record<string, any>} [args] - Optional filter arguments
6254
- * @returns {Promise<Array<{name: string, changeId: string}>>} List of tags
6361
+ * @returns {Promise<Array<Record<string, any>>>} List of tags
6255
6362
  */
6256
6363
  async list(args) {
6257
6364
  const pattern = args?.pattern;
@@ -6283,6 +6390,7 @@ export async function createJJ(options) {
6283
6390
  view: {
6284
6391
  bookmarks: {},
6285
6392
  remoteBookmarks: {},
6393
+ tags: {},
6286
6394
  heads: [],
6287
6395
  workingCopy: workingCopy.getCurrentChangeId(),
6288
6396
  },
@@ -6290,6 +6398,94 @@ export async function createJJ(options) {
6290
6398
 
6291
6399
  return { deleted: args.name };
6292
6400
  },
6401
+
6402
+ /**
6403
+ * Associate a local tag with a remote tag so it is updated on fetch
6404
+ * (matches `jj tag track`, new in jj v0.44).
6405
+ *
6406
+ * jj v0.44 made tags remote-trackable just like bookmarks: fetched
6407
+ * tags are tracked by default, and `jj tag track`/`untrack` let you
6408
+ * opt individual tags in or out by hand. isomorphic-jj doesn't (yet)
6409
+ * auto-import remote tags during `git.fetch()` — see CHANGELOG — so
6410
+ * this records tracking intent/state the same way `bookmark.track()`
6411
+ * does, without changing fetch's own behavior.
6412
+ *
6413
+ * @param {Record<string, any>} args - Tag arguments: `{ name, remote }`
6414
+ * (`name` is the local tag name to track; `remote` defaults to `'origin'`)
6415
+ * @returns {Promise<{name: string, remote: string, tracking: boolean}>}
6416
+ */
6417
+ async track(args) {
6418
+ if (!args || !args.name) {
6419
+ throw new JJError('INVALID_ARGUMENT', 'Missing tag name', {
6420
+ suggestion: 'Provide tag name: { name: "v1.0.0", remote: "origin" }',
6421
+ });
6422
+ }
6423
+
6424
+ const remote = args.remote || 'origin';
6425
+ const remoteTagName = `${remote}/${args.name}`;
6426
+
6427
+ const tagMap = await tags.load();
6428
+ /** @type {Record<string, any>} */
6429
+ const tracking = tags.tracking || {};
6430
+ tracking[args.name] = { remote, remoteName: args.name };
6431
+ tags.tracking = tracking;
6432
+ await tags.save(tagMap);
6433
+
6434
+ // Record operation
6435
+ await oplog.recordOperation({
6436
+ timestamp: new Date().toISOString(),
6437
+ user: await getUserOplogInfo(),
6438
+ description: `tag track ${args.name} from ${remote}`,
6439
+ parents: [],
6440
+ view: {
6441
+ bookmarks: {},
6442
+ remoteBookmarks: { [remoteTagName]: true },
6443
+ heads: [],
6444
+ workingCopy: workingCopy.getCurrentChangeId(),
6445
+ },
6446
+ });
6447
+
6448
+ return { name: args.name, remote, tracking: true };
6449
+ },
6450
+
6451
+ /**
6452
+ * Stop tracking a remote tag (matches `jj tag untrack`, new in jj v0.44).
6453
+ *
6454
+ * @param {Record<string, any>} args - Tag arguments: `{ name }`, the
6455
+ * local tag name to stop tracking
6456
+ * @returns {Promise<{name: string, tracking: boolean, wasTracking: boolean}>}
6457
+ */
6458
+ async untrack(args) {
6459
+ if (!args || !args.name) {
6460
+ throw new JJError('INVALID_ARGUMENT', 'Missing tag name', {
6461
+ suggestion: 'Provide tag name: { name: "v1.0.0" }',
6462
+ });
6463
+ }
6464
+
6465
+ const tagMap = await tags.load();
6466
+ /** @type {Record<string, any>} */
6467
+ const tracking = tags.tracking || {};
6468
+ const wasTracking = tracking[args.name];
6469
+ delete tracking[args.name];
6470
+ tags.tracking = tracking;
6471
+ await tags.save(tagMap);
6472
+
6473
+ // Record operation
6474
+ await oplog.recordOperation({
6475
+ timestamp: new Date().toISOString(),
6476
+ user: await getUserOplogInfo(),
6477
+ description: `tag untrack ${args.name}`,
6478
+ parents: [],
6479
+ view: {
6480
+ bookmarks: {},
6481
+ remoteBookmarks: {},
6482
+ heads: [],
6483
+ workingCopy: workingCopy.getCurrentChangeId(),
6484
+ },
6485
+ });
6486
+
6487
+ return { name: args.name, tracking: false, wasTracking: !!wasTracking };
6488
+ },
6293
6489
  },
6294
6490
 
6295
6491
  /**
@@ -540,7 +540,11 @@ export class IsomorphicGitBackend {
540
540
  * @param {boolean} [opts.force] - Allow non-fast-forward
541
541
  * @param {any} [opts.onProgress] - Progress callback
542
542
  * @param {any} [opts.onAuth] - Authentication callback
543
- * @returns {Promise<{pushedRefs: Array<any>, rejectedRefs: Array<any>}>} Push result
543
+ * @returns {Promise<{pushedRefs: Array<{name: string, oid: string}>, rejectedRefs: Array<{name: string, code: string, reason: string, message: string}>}>} Push result.
544
+ * Each rejected ref carries a `code` (one of `AUTH_FAILED`,
545
+ * `NETWORK_ERROR`, `NON_FAST_FORWARD`, `OTHER`) and a human-readable
546
+ * `reason`, so callers can tell an auth failure apart from a
547
+ * non-fast-forward rejection instead of guessing (see issue #14).
544
548
  */
545
549
  async push(opts) {
546
550
  if (!this.http) {
@@ -576,8 +580,13 @@ export class IsomorphicGitBackend {
576
580
  pushedRefs.push({ name: ref, oid });
577
581
  }
578
582
  } catch (pushError) {
579
- // Track rejected refs
580
- rejectedRefs.push(ref);
583
+ // Track rejected refs with the categorized reason so callers can
584
+ // distinguish an auth failure from a non-fast-forward rejection
585
+ // (or a network blip) instead of guessing — see issue #14. A
586
+ // caller that blindly retries with force:true on every rejection
587
+ // risks doing so on what was actually an auth/network problem.
588
+ const { code, reason } = this._categorizePushError(pushError);
589
+ rejectedRefs.push({ name: ref, code, reason, message: pushError.message });
581
590
  }
582
591
  }
583
592
 
@@ -609,6 +618,53 @@ export class IsomorphicGitBackend {
609
618
  }
610
619
  }
611
620
 
621
+ /**
622
+ * Categorize a per-ref push failure into a stable code/reason pair so
623
+ * callers can distinguish auth failures, network errors, and
624
+ * non-fast-forward rejections from each other and from other/unknown
625
+ * failures — see issue #14. isomorphic-git doesn't throw a single
626
+ * "AuthError" class; auth failures generally surface as an HttpError with
627
+ * a 401/403 status code (or a UserCanceledError if onAuth cancels).
628
+ *
629
+ * @private
630
+ * @param {any} error - Error thrown by git.push() for a single ref
631
+ * @returns {{code: string, reason: string}}
632
+ */
633
+ _categorizePushError(error) {
634
+ const errCode = error && error.code;
635
+
636
+ if (errCode === 'PushRejectedError') {
637
+ const reason = error.data && error.data.reason;
638
+ return {
639
+ code: 'NON_FAST_FORWARD',
640
+ reason: reason === 'tag-exists' ? 'tag already exists on remote' : 'non-fast-forward',
641
+ };
642
+ }
643
+
644
+ if (errCode === 'HttpError') {
645
+ const status = error.data && error.data.statusCode;
646
+ if (status === 401 || status === 403) {
647
+ return { code: 'AUTH_FAILED', reason: `HTTP ${status}` };
648
+ }
649
+ return { code: 'NETWORK_ERROR', reason: status ? `HTTP ${status}` : 'HTTP error' };
650
+ }
651
+
652
+ if (
653
+ errCode === 'AuthError' ||
654
+ errCode === 'UserCanceledError' ||
655
+ errCode === 'MissingUsernameError' ||
656
+ errCode === 'MissingPasswordTokenError'
657
+ ) {
658
+ return { code: 'AUTH_FAILED', reason: errCode };
659
+ }
660
+
661
+ if (errCode === 'NetworkError' || errCode === 'UnknownTransportError') {
662
+ return { code: 'NETWORK_ERROR', reason: errCode };
663
+ }
664
+
665
+ return { code: 'OTHER', reason: (error && error.message) || String(error) };
666
+ }
667
+
612
668
  /**
613
669
  * Create minimal .jj/repo structure for jj CLI compatibility
614
670
  * @private
@@ -17,8 +17,8 @@
17
17
  *
18
18
  * @example
19
19
  * ```javascript
20
- * import { createBrowserFS } from 'isomorphic-jj/browser';
21
- * import { createJJ } from 'isomorphic-jj';
20
+ * import { createBrowserFS } from '@johnhenry/isomorphic-jj/browser';
21
+ * import { createJJ } from '@johnhenry/isomorphic-jj';
22
22
  * import git from 'isomorphic-git';
23
23
  * import http from 'isomorphic-git/http/web';
24
24
  *
@@ -8,6 +8,7 @@
8
8
  import protobuf from 'protobufjs';
9
9
  import path from 'path';
10
10
  import { fileURLToPath } from 'url';
11
+ import { atomicWriteFile } from '../utils/atomic-write.js';
11
12
 
12
13
  const __filename = fileURLToPath(import.meta.url);
13
14
  const __dirname = path.dirname(__filename);
@@ -81,9 +82,11 @@ export class JJOperationStore {
81
82
  // Encode to binary
82
83
  const buffer = Operation.encode(message).finish();
83
84
 
84
- // Write to .jj/repo/op_store/operations/OPERATIONID
85
+ // Write to .jj/repo/op_store/operations/OPERATIONID (atomically — a
86
+ // crash mid-write must never leave a truncated/undecodable file, see
87
+ // issue #16).
85
88
  const opPath = `${this.dir}/.jj/repo/op_store/operations/${operationId}`;
86
- await this.fs.promises.writeFile(opPath, buffer);
89
+ await atomicWriteFile(this.fs, opPath, buffer);
87
90
  }
88
91
 
89
92
  /**
@@ -8,6 +8,7 @@
8
8
  import protobuf from 'protobufjs';
9
9
  import path from 'path';
10
10
  import { fileURLToPath } from 'url';
11
+ import { atomicWriteFile } from '../utils/atomic-write.js';
11
12
 
12
13
  const __filename = fileURLToPath(import.meta.url);
13
14
  const __dirname = path.dirname(__filename);
@@ -72,9 +73,9 @@ export class JJTreeState {
72
73
  // Encode to binary
73
74
  const buffer = TreeState.encode(message).finish();
74
75
 
75
- // Write to .jj/working_copy/tree_state
76
+ // Write to .jj/working_copy/tree_state (atomically — see issue #16).
76
77
  const treeStatePath = `${this.dir}/.jj/working_copy/tree_state`;
77
- await this.fs.promises.writeFile(treeStatePath, buffer);
78
+ await atomicWriteFile(this.fs, treeStatePath, buffer);
78
79
  }
79
80
 
80
81
  /**
@@ -8,6 +8,7 @@
8
8
  import protobuf from 'protobufjs';
9
9
  import path from 'path';
10
10
  import { fileURLToPath } from 'url';
11
+ import { atomicWriteFile } from '../utils/atomic-write.js';
11
12
 
12
13
  const __filename = fileURLToPath(import.meta.url);
13
14
  const __dirname = path.dirname(__filename);
@@ -72,9 +73,9 @@ export class JJViewStore {
72
73
  // Encode to binary
73
74
  const buffer = View.encode(message).finish();
74
75
 
75
- // Write to .jj/repo/op_store/views/VIEWID
76
+ // Write to .jj/repo/op_store/views/VIEWID (atomically — see issue #16).
76
77
  const viewPath = `${this.dir}/.jj/repo/op_store/views/${viewId}`;
77
- await this.fs.promises.writeFile(viewPath, buffer);
78
+ await atomicWriteFile(this.fs, viewPath, buffer);
78
79
  }
79
80
 
80
81
  /**
@@ -312,11 +312,30 @@ class RevsetParser {
312
312
  this.next();
313
313
  const value = /** @type {string} */ (tok.value);
314
314
 
315
- // A bare "base..tip" range, e.g. `abcd1234..ef567890`.
315
+ // `::` (bare, no operands) means "all visible changes", matching jj's
316
+ // `::` alias for all().
317
+ if (value === '::') {
318
+ return { type: 'rangeAll' };
319
+ }
320
+
321
+ // A bare "base..tip" range, e.g. `abcd1234..ef567890` — and the
322
+ // open-ended forms `..tip` (ancestors(tip)) and `base..` (everything
323
+ // not an ancestor of base, approximated here as descendants(base)).
316
324
  if (value.includes('..')) {
317
325
  const parts = value.split('..');
318
326
  if (parts.length === 2) {
319
- return { type: 'range', base: parts[0].trim(), tip: parts[1].trim() };
327
+ const base = parts[0].trim();
328
+ const tip = parts[1].trim();
329
+ if (base === '' && tip === '') {
330
+ return { type: 'rangeAll' };
331
+ }
332
+ if (base === '') {
333
+ return { type: 'rangeAncestors', tip };
334
+ }
335
+ if (tip === '') {
336
+ return { type: 'rangeDescendants', base };
337
+ }
338
+ return { type: 'range', base, tip };
320
339
  }
321
340
  }
322
341
 
@@ -385,7 +404,7 @@ export class RevsetEngine {
385
404
  * @returns {string}
386
405
  */
387
406
  _suggestion() {
388
- return 'Use @, @-, @+, bookmark(name), all(), none(), root(), visible_heads(), git_refs(), git_head(), ancestors(revset[, depth]), author(name), author_name(x), author_email(x), committer(x), committer_name(x), committer_email(x), subject(pattern), description(text), change_id(prefix), commit_id(prefix), empty(), mine(), merge(), merges(), forks(), signed(), divergent(), file(pattern), roots(revset), heads(revset), parents(revset), children(revset), first_parent(revset), first_ancestors(revset), fork_point(revset), merge_point(revset), exactly(revset, n), present(revset), coalesce(a, b, ...), latest(revset, [count]), tags([pattern]), remote_tags([pattern]), bookmarks([pattern]), last(N[dh]), since(date), between(start, end), descendants(rev[, depth]), common_ancestor(rev1, rev2), range(base..tip), diverge_point(rev1, rev2), connected(rev1, rev2), operators (x-, x+), set operations (& | ~), or a direct change ID';
407
+ return 'Use @, @-, @+, bookmark(name), all(), builtin_log(), none(), root(), visible_heads(), ancestors(revset[, depth]), author(name), author_name(x), author_email(x), committer(x), committer_name(x), committer_email(x), subject(pattern), description(text), change_id(prefix), commit_id(prefix), empty(), mine(), merge(), merges(), forks(), signed(), divergent(), file(pattern), roots(revset), heads(revset), parents(revset), children(revset), first_parent(revset), first_ancestors(revset), fork_point(revset), merge_point(revset), exactly(revset, n), present(revset), coalesce(a, b, ...), latest(revset, [count]), tags([pattern]), remote_tags([pattern]), bookmarks([pattern]), last(N[dh]), since(date), between(start, end), descendants(rev[, depth]), common_ancestor(rev1, rev2), range(base..tip), diverge_point(rev1, rev2), connected(rev1, rev2), operators (x-, x+), set operations (& | ~), or a direct change ID (git_refs()/git_head() still work but are deprecated — jj removed them in v0.43; use bookmarks()/tags()/@ instead)';
389
408
  }
390
409
 
391
410
  /**
@@ -442,6 +461,15 @@ export class RevsetEngine {
442
461
  }
443
462
  case 'range':
444
463
  return await this.evalRange(node.base, node.tip);
464
+ // Open-ended ranges (issue #18): `..tip` / `base..` / `::` delegate to
465
+ // the equivalent ancestors()/descendants()/all() functions rather than
466
+ // failing with a misleading "invalid IDs" error.
467
+ case 'rangeAll':
468
+ return await this.evaluateFunctionCall('all', '');
469
+ case 'rangeAncestors':
470
+ return await this.evaluateFunctionCall('ancestors', node.tip);
471
+ case 'rangeDescendants':
472
+ return await this.evaluateFunctionCall('descendants', node.base);
445
473
  case 'call':
446
474
  return await this.evaluateFunctionCall(node.name, node.argsRaw);
447
475
  default:
@@ -515,6 +543,16 @@ export class RevsetEngine {
515
543
  await this.graph.load();
516
544
  return this.graph.getAll().map((c) => c.changeId);
517
545
 
546
+ // `builtin_log()` (jj v0.44) is the revset the built-in `jj log` uses
547
+ // when no user override is configured. isomorphic-jj's `log()` already
548
+ // defaults to `all()` when no revset is given (see `jj.log()`), so
549
+ // `builtin_log()` is a pure alias for that same default — this lets
550
+ // callers compose it (e.g. `builtin_log() & mine()`) instead of
551
+ // duplicating `all()`.
552
+ case 'builtin_log':
553
+ await this.graph.load();
554
+ return this.graph.getAll().map((c) => c.changeId);
555
+
518
556
  case 'none':
519
557
  return [];
520
558
 
@@ -545,6 +583,13 @@ export class RevsetEngine {
545
583
  return allChanges.filter((c) => !hasChildren.has(c.changeId)).map((c) => c.changeId);
546
584
  }
547
585
 
586
+ // `git_refs()` and `git_head()` are deprecated compatibility shims.
587
+ // Upstream jj deprecated both in favor of `bookmarks()`/`tags()` and
588
+ // `@`, then removed them outright in jj v0.43 (revset/template calls
589
+ // to either now error in real jj). isomorphic-jj keeps them working
590
+ // rather than hard-breaking existing callers, but they are no longer
591
+ // advertised in `_suggestion()` — prefer `bookmarks([pattern])` /
592
+ // `tags([pattern])` / `@` instead.
548
593
  case 'git_refs': {
549
594
  if (!this.bookmarkStore) return [];
550
595
  await this.bookmarkStore.load();