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/CHANGELOG.md +203 -1
- package/README.md +134 -23
- package/bin/isojj.js +4 -1
- package/dist/index.cjs +515 -41
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +515 -41
- package/dist/index.mjs.map +1 -1
- package/package.json +17 -4
- package/src/api/repository.js +205 -9
- package/src/backend/isomorphic-git-backend.js +59 -3
- package/src/browser/helpers.js +2 -2
- package/src/core/jj-operation-store.js +5 -2
- package/src/core/jj-tree-state.js +3 -2
- package/src/core/jj-view-store.js +3 -2
- package/src/core/revset-engine.js +48 -3
- package/src/core/storage-manager.js +47 -10
- package/src/core/tag-store.js +40 -10
- package/src/utils/atomic-write.js +44 -0
- package/src/utils/mutex.js +63 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "isomorphic-jj",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
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://
|
|
79
|
+
"homepage": "https://opensource.johnhenry.me/isomorphic-jj/",
|
|
67
80
|
"peerDependencies": {
|
|
68
81
|
"isomorphic-git": "^1.24.0"
|
|
69
82
|
},
|
package/src/api/repository.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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<
|
|
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<
|
|
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
|
-
|
|
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
|
package/src/browser/helpers.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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(),
|
|
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();
|