isomorphic-jj 1.8.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.
- package/CHANGELOG.md +222 -0
- package/LICENSE +1 -1
- package/README.md +117 -30
- package/dist/index.cjs +1911 -344
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +1911 -343
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -7
- package/src/api/repository.js +775 -158
- package/src/backend/isomorphic-git-backend.js +13 -12
- package/src/browser/helpers.js +38 -16
- package/src/browser/index.d.ts +9 -2
- package/src/core/change-graph.js +125 -4
- package/src/core/conflict-model.js +72 -7
- package/src/core/jj-checkout.js +5 -16
- package/src/core/jj-operation-store.js +6 -17
- package/src/core/jj-tree-state.js +5 -16
- package/src/core/jj-view-store.js +6 -17
- package/src/core/merge-driver-registry.js +11 -10
- package/src/core/revset-engine.js +15 -3
- package/src/core/storage-manager.js +9 -8
- package/src/core/tag-store.js +2 -5
- package/src/core/workspace-manager.js +4 -3
- package/src/protos/local_working_copy.json.js +146 -0
- package/src/protos/schema.js +49 -0
- package/src/protos/simple_op_store.json.js +374 -0
- package/src/types.d.ts +33 -0
- package/src/utils/atomic-write.js +3 -1
- package/src/utils/bytes.js +67 -0
- package/src/utils/id-generation.js +18 -4
- package/src/utils/mkdirp.js +41 -0
- package/src/utils/posix-path.js +117 -0
package/dist/index.cjs
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var path = require('path');
|
|
4
3
|
var git = require('isomorphic-git');
|
|
5
|
-
var protobuf = require('protobufjs');
|
|
6
|
-
var url = require('url');
|
|
7
|
-
var crypto$1 = require('crypto');
|
|
4
|
+
var protobuf = require('protobufjs/light.js');
|
|
8
5
|
|
|
9
|
-
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
|
|
10
6
|
/**
|
|
11
7
|
* JJError - Custom error class for isomorphic-jj
|
|
12
8
|
*
|
|
@@ -93,6 +89,48 @@ class KeyedMutex {
|
|
|
93
89
|
}
|
|
94
90
|
}
|
|
95
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Recursive directory creation that works across filesystem backends.
|
|
94
|
+
*
|
|
95
|
+
* Why not `fs.promises.mkdir(path, { recursive: true })`: that's a Node
|
|
96
|
+
* `fs` feature. `@isomorphic-git/lightning-fs` (the filesystem this library
|
|
97
|
+
* documents for browser use — see issue #28) implements `mkdir()` but
|
|
98
|
+
* silently ignores the `recursive` option and throws `ENOENT` if any
|
|
99
|
+
* intermediate directory is missing, so code that assumed Node's recursive
|
|
100
|
+
* mkdir semantics would work identically against LightningFS actually
|
|
101
|
+
* broke the very first real repo init in a browser. This walks the path
|
|
102
|
+
* one segment at a time and creates each directory that doesn't exist yet,
|
|
103
|
+
* which is correct against both Node's `fs` and LightningFS (and is a
|
|
104
|
+
* harmless no-op-per-segment against Node's `fs` too).
|
|
105
|
+
*
|
|
106
|
+
* @param {any} fs - Filesystem implementation (isomorphic-git compatible;
|
|
107
|
+
* needs `fs.promises.mkdir(path)`)
|
|
108
|
+
* @param {string} dirPath - Absolute, POSIX-style directory path to create
|
|
109
|
+
* @returns {Promise<void>}
|
|
110
|
+
*/
|
|
111
|
+
async function mkdirp(fs, dirPath) {
|
|
112
|
+
const isAbsolute = dirPath.charCodeAt(0) === 47; // '/'
|
|
113
|
+
const prefix = isAbsolute ? '/' : '';
|
|
114
|
+
const segments = dirPath.split('/').filter(Boolean);
|
|
115
|
+
|
|
116
|
+
// Compare against `current === ''` (not a truthiness check) below: the
|
|
117
|
+
// first accumulated segment of an absolute path is itself just `/x`,
|
|
118
|
+
// which parses fine, but a naive `current ? ... : ...` truthiness check
|
|
119
|
+
// would treat the *string* `''` the same as "unset" forever and never
|
|
120
|
+
// prepend the leading `/`.
|
|
121
|
+
let current = '';
|
|
122
|
+
for (const segment of segments) {
|
|
123
|
+
current = current === '' ? prefix + segment : `${current}/${segment}`;
|
|
124
|
+
try {
|
|
125
|
+
await fs.promises.mkdir(current);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
// Already exists — fine, that's exactly what "recursive" means.
|
|
128
|
+
if (err && (err.code === 'EEXIST' || err.code === 'EISDIR')) continue;
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
96
134
|
/**
|
|
97
135
|
* Storage Manager for isomorphic-jj
|
|
98
136
|
*
|
|
@@ -133,17 +171,17 @@ class Storage {
|
|
|
133
171
|
async init() {
|
|
134
172
|
try {
|
|
135
173
|
// Create main directories
|
|
136
|
-
await this.fs
|
|
137
|
-
await this.fs
|
|
138
|
-
await this.fs
|
|
174
|
+
await mkdirp(this.fs, this.jjDir);
|
|
175
|
+
await mkdirp(this.fs, this.repoDir);
|
|
176
|
+
await mkdirp(this.fs, this.workingCopyDir);
|
|
139
177
|
|
|
140
178
|
// Create repo subdirectories
|
|
141
|
-
await this.fs
|
|
142
|
-
await this.fs
|
|
143
|
-
await this.fs
|
|
179
|
+
await mkdirp(this.fs, `${this.repoDir}/store`);
|
|
180
|
+
await mkdirp(this.fs, `${this.repoDir}/op_log`);
|
|
181
|
+
await mkdirp(this.fs, `${this.repoDir}/conflicts`);
|
|
144
182
|
|
|
145
183
|
// Create default workspace directory
|
|
146
|
-
await this.fs
|
|
184
|
+
await mkdirp(this.fs, `${this.workingCopyDir}/default`);
|
|
147
185
|
} catch (error) {
|
|
148
186
|
throw new JJError(
|
|
149
187
|
'STORAGE_INIT_FAILED',
|
|
@@ -220,7 +258,7 @@ class Storage {
|
|
|
220
258
|
try {
|
|
221
259
|
// Ensure parent directory exists
|
|
222
260
|
const dirPath = fullPath.substring(0, fullPath.lastIndexOf('/'));
|
|
223
|
-
await this.fs
|
|
261
|
+
await mkdirp(this.fs, dirPath);
|
|
224
262
|
|
|
225
263
|
// Write to temp file
|
|
226
264
|
await this.fs.promises.writeFile(tmpPath, jsonData, 'utf8');
|
|
@@ -476,6 +514,75 @@ function validateBookmarkName(name) {
|
|
|
476
514
|
}
|
|
477
515
|
}
|
|
478
516
|
|
|
517
|
+
/**
|
|
518
|
+
* ID Generation utilities for isomorphic-jj
|
|
519
|
+
*
|
|
520
|
+
* Generates stable change IDs and operation IDs using cryptographically secure random values.
|
|
521
|
+
*/
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Generate `byteLength` cryptographically random bytes as a lowercase hex string.
|
|
525
|
+
*
|
|
526
|
+
* Uses the standard Web Crypto API (`crypto.getRandomValues`), available as
|
|
527
|
+
* a global in both Node.js (18.14+) and every browser — unlike Node's
|
|
528
|
+
* `crypto.randomBytes`, which requires `import 'crypto'` and does not exist
|
|
529
|
+
* in a browser bundle (see issue #28).
|
|
530
|
+
*
|
|
531
|
+
* @param {number} byteLength - Number of random bytes to generate
|
|
532
|
+
* @returns {string} `byteLength * 2`-character lowercase hex string
|
|
533
|
+
*/
|
|
534
|
+
function randomHex(byteLength) {
|
|
535
|
+
const bytes = crypto.getRandomValues(new Uint8Array(byteLength));
|
|
536
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Generate a stable change ID
|
|
541
|
+
*
|
|
542
|
+
* Uses 128-bit cryptographically random value encoded as 32-character hex string.
|
|
543
|
+
* Collision probability is negligible (2^128 space).
|
|
544
|
+
*
|
|
545
|
+
* @returns {string} 32-character lowercase hex string
|
|
546
|
+
*/
|
|
547
|
+
function generateChangeId() {
|
|
548
|
+
return randomHex(16);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Generate an operation ID from operation content
|
|
553
|
+
*
|
|
554
|
+
* Uses SHA-256 hash of operation content for integrity verification.
|
|
555
|
+
*
|
|
556
|
+
* @param {Object.<string, any>} operation - Operation data
|
|
557
|
+
* @returns {Promise<string>} 64-character lowercase hex string (SHA-256)
|
|
558
|
+
*/
|
|
559
|
+
async function generateOperationId(operation) {
|
|
560
|
+
const content = JSON.stringify({
|
|
561
|
+
timestamp: operation.timestamp,
|
|
562
|
+
user: operation.user,
|
|
563
|
+
description: operation.description,
|
|
564
|
+
parents: operation.parents,
|
|
565
|
+
view: operation.view,
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
const encoder = new TextEncoder();
|
|
569
|
+
const data = encoder.encode(content);
|
|
570
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
571
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
572
|
+
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Generate a generic ID with optional prefix
|
|
577
|
+
*
|
|
578
|
+
* @param {string} prefix - Optional prefix (e.g., 'conflict', 'bookmark')
|
|
579
|
+
* @returns {string} ID string
|
|
580
|
+
*/
|
|
581
|
+
function generateId(prefix = '') {
|
|
582
|
+
const id = randomHex(16);
|
|
583
|
+
return prefix ? `${prefix}-${id}` : id;
|
|
584
|
+
}
|
|
585
|
+
|
|
479
586
|
/**
|
|
480
587
|
* ChangeGraph - Manages the change graph with stable change IDs
|
|
481
588
|
*
|
|
@@ -571,6 +678,68 @@ class ChangeGraph {
|
|
|
571
678
|
await this.save();
|
|
572
679
|
}
|
|
573
680
|
|
|
681
|
+
/**
|
|
682
|
+
* Add a DIVERGENT copy of an existing change: another commit that also
|
|
683
|
+
* claims `change.changeId` (see issue #32 / the `divergent()` revset —
|
|
684
|
+
* "multiple visible commits sharing one change id"). `addChange()`
|
|
685
|
+
* can't represent this: `nodes` is a `Map<changeId, change>`, one entry
|
|
686
|
+
* per key, by design — every other API (getChange, getParents,
|
|
687
|
+
* getChildren, ...) keys on plain changeId and would be ambiguous if
|
|
688
|
+
* that stopped being unique.
|
|
689
|
+
*
|
|
690
|
+
* Divergent copies are therefore stored under a synthetic internal key
|
|
691
|
+
* (`${changeId}\0${commitId}`) so the `nodes` Map stays a true map, but
|
|
692
|
+
* each copy's *own* `changeId` field is unchanged — so `getAll()` (and
|
|
693
|
+
* therefore the existing `divergent()` revset filter, which counts
|
|
694
|
+
* occurrences of `c.changeId` across `getAll()`) sees both/all of them
|
|
695
|
+
* and correctly reports the change as divergent. `getChange(changeId)`
|
|
696
|
+
* deliberately keeps resolving to just the primary copy (the one
|
|
697
|
+
* actually stored under the plain `changeId` key) — for anything that
|
|
698
|
+
* needs every copy, see getDivergentSiblings().
|
|
699
|
+
*
|
|
700
|
+
* @param {Record<string, any>} change - The divergent copy (must have
|
|
701
|
+
* the same `changeId` as an existing change, and its own `commitId`)
|
|
702
|
+
*/
|
|
703
|
+
async addDivergentCopy(change) {
|
|
704
|
+
validateChangeId(change.changeId);
|
|
705
|
+
|
|
706
|
+
if (!this.nodes.has(change.changeId)) {
|
|
707
|
+
throw new JJError(
|
|
708
|
+
'CHANGE_NOT_FOUND',
|
|
709
|
+
`Cannot add a divergent copy of ${change.changeId}: no existing change with that id`,
|
|
710
|
+
{ changeId: change.changeId, suggestion: 'Use addChange() to create the first copy' }
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const key = `${change.changeId}\0${change.commitId}`;
|
|
715
|
+
if (this.nodes.has(key)) {
|
|
716
|
+
throw new JJError(
|
|
717
|
+
'CHANGE_EXISTS',
|
|
718
|
+
`Change ${change.changeId} already has a divergent copy with commit ${change.commitId}`,
|
|
719
|
+
{ changeId: change.changeId, commitId: change.commitId }
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
change.divergent = true;
|
|
724
|
+
this.nodes.set(key, change);
|
|
725
|
+
this.commitIndex.set(change.commitId, change.changeId);
|
|
726
|
+
|
|
727
|
+
await this.save();
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Get every visible copy of a (possibly divergent) change id — just the
|
|
732
|
+
* one copy in the common case, or the primary plus every divergent copy
|
|
733
|
+
* added via addDivergentCopy().
|
|
734
|
+
*
|
|
735
|
+
* @param {string} changeId - Change ID
|
|
736
|
+
* @returns {Array<any>} All copies sharing this changeId, primary first
|
|
737
|
+
*/
|
|
738
|
+
getDivergentSiblings(changeId) {
|
|
739
|
+
validateChangeId(changeId);
|
|
740
|
+
return this.getAll().filter((change) => change.changeId === changeId);
|
|
741
|
+
}
|
|
742
|
+
|
|
574
743
|
/**
|
|
575
744
|
* Get a change by ID
|
|
576
745
|
*
|
|
@@ -682,6 +851,65 @@ class ChangeGraph {
|
|
|
682
851
|
await this.save();
|
|
683
852
|
}
|
|
684
853
|
|
|
854
|
+
/**
|
|
855
|
+
* Remove one divergent copy added via addDivergentCopy() — e.g. after
|
|
856
|
+
* converge() (issue #32) resolves a divergence and only needs to drop
|
|
857
|
+
* the now-superseded copy. The *primary* copy (stored under the plain
|
|
858
|
+
* `changeId` key) is never touched by this — use deleteChange() (or
|
|
859
|
+
* updateChange() to overwrite it with a resolved result) for that.
|
|
860
|
+
*
|
|
861
|
+
* @param {string} changeId
|
|
862
|
+
* @param {string} commitId - The divergent copy's own commit id
|
|
863
|
+
* @returns {Promise<boolean>} Whether a copy was actually removed
|
|
864
|
+
*/
|
|
865
|
+
async deleteDivergentCopy(changeId, commitId) {
|
|
866
|
+
validateChangeId(changeId);
|
|
867
|
+
|
|
868
|
+
const key = `${changeId}\0${commitId}`;
|
|
869
|
+
const change = this.nodes.get(key);
|
|
870
|
+
if (!change) {
|
|
871
|
+
return false;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
this.nodes.delete(key);
|
|
875
|
+
if (this.commitIndex.get(commitId) === changeId) {
|
|
876
|
+
this.commitIndex.delete(commitId);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
await this.save();
|
|
880
|
+
return true;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Remove a change from the graph outright.
|
|
885
|
+
*
|
|
886
|
+
* Used to reverse an addChange() — e.g. undoing an operation that created
|
|
887
|
+
* a change (new(), or squash()'s synthetic empty working-copy change) —
|
|
888
|
+
* where restoring a "before" snapshot doesn't apply because there was no
|
|
889
|
+
* "before": the change simply didn't exist yet. A soft-delete (setting
|
|
890
|
+
* `abandoned: true` via updateChange()) is NOT the same thing and is used
|
|
891
|
+
* separately for abandon() itself.
|
|
892
|
+
*
|
|
893
|
+
* @param {string} changeId - Change ID to remove
|
|
894
|
+
* @returns {Promise<boolean>} Whether a change was actually removed
|
|
895
|
+
*/
|
|
896
|
+
async deleteChange(changeId) {
|
|
897
|
+
validateChangeId(changeId);
|
|
898
|
+
|
|
899
|
+
const change = this.nodes.get(changeId);
|
|
900
|
+
if (!change) {
|
|
901
|
+
return false;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
this.nodes.delete(changeId);
|
|
905
|
+
if (this.commitIndex.get(change.commitId) === changeId) {
|
|
906
|
+
this.commitIndex.delete(change.commitId);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
await this.save();
|
|
910
|
+
return true;
|
|
911
|
+
}
|
|
912
|
+
|
|
685
913
|
/**
|
|
686
914
|
* Get all ancestors of a change (recursive parent traversal)
|
|
687
915
|
*
|
|
@@ -719,10 +947,9 @@ class ChangeGraph {
|
|
|
719
947
|
* @returns {Promise<Record<string, any>>} Created change object
|
|
720
948
|
*/
|
|
721
949
|
async createChange(params = {}) {
|
|
722
|
-
const
|
|
723
|
-
const
|
|
724
|
-
const
|
|
725
|
-
const tree = params.tree || crypto.randomBytes(20).toString('hex');
|
|
950
|
+
const changeId = params.changeId || randomHex(16);
|
|
951
|
+
const commitId = params.commitId || randomHex(20);
|
|
952
|
+
const tree = params.tree || randomHex(20);
|
|
726
953
|
const timestamp = params.timestamp || new Date().toISOString();
|
|
727
954
|
|
|
728
955
|
/** @type {Record<string, any>} */
|
|
@@ -1186,61 +1413,6 @@ class WorkingCopy {
|
|
|
1186
1413
|
}
|
|
1187
1414
|
}
|
|
1188
1415
|
|
|
1189
|
-
/**
|
|
1190
|
-
* ID Generation utilities for isomorphic-jj
|
|
1191
|
-
*
|
|
1192
|
-
* Generates stable change IDs and operation IDs using cryptographically secure random values.
|
|
1193
|
-
*/
|
|
1194
|
-
|
|
1195
|
-
/**
|
|
1196
|
-
* Generate a stable change ID
|
|
1197
|
-
*
|
|
1198
|
-
* Uses 128-bit cryptographically random value encoded as 32-character hex string.
|
|
1199
|
-
* Collision probability is negligible (2^128 space).
|
|
1200
|
-
*
|
|
1201
|
-
* @returns {string} 32-character lowercase hex string
|
|
1202
|
-
*/
|
|
1203
|
-
function generateChangeId() {
|
|
1204
|
-
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
1205
|
-
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
/**
|
|
1209
|
-
* Generate an operation ID from operation content
|
|
1210
|
-
*
|
|
1211
|
-
* Uses SHA-256 hash of operation content for integrity verification.
|
|
1212
|
-
*
|
|
1213
|
-
* @param {Object.<string, any>} operation - Operation data
|
|
1214
|
-
* @returns {Promise<string>} 64-character lowercase hex string (SHA-256)
|
|
1215
|
-
*/
|
|
1216
|
-
async function generateOperationId(operation) {
|
|
1217
|
-
const content = JSON.stringify({
|
|
1218
|
-
timestamp: operation.timestamp,
|
|
1219
|
-
user: operation.user,
|
|
1220
|
-
description: operation.description,
|
|
1221
|
-
parents: operation.parents,
|
|
1222
|
-
view: operation.view,
|
|
1223
|
-
});
|
|
1224
|
-
|
|
1225
|
-
const encoder = new TextEncoder();
|
|
1226
|
-
const data = encoder.encode(content);
|
|
1227
|
-
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
1228
|
-
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
1229
|
-
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
/**
|
|
1233
|
-
* Generate a generic ID with optional prefix
|
|
1234
|
-
*
|
|
1235
|
-
* @param {string} prefix - Optional prefix (e.g., 'conflict', 'bookmark')
|
|
1236
|
-
* @returns {string} ID string
|
|
1237
|
-
*/
|
|
1238
|
-
function generateId(prefix = '') {
|
|
1239
|
-
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
1240
|
-
const id = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
1241
|
-
return prefix ? `${prefix}-${id}` : id;
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
1416
|
/**
|
|
1245
1417
|
* OperationLog - Manages the append-only operation log
|
|
1246
1418
|
*
|
|
@@ -1857,11 +2029,7 @@ class TagStore {
|
|
|
1857
2029
|
async save(tags) {
|
|
1858
2030
|
// Ensure the store directory exists
|
|
1859
2031
|
const storeDir = `${this.jjDir}/store`;
|
|
1860
|
-
|
|
1861
|
-
await this.fs.promises.mkdir(storeDir, { recursive: true });
|
|
1862
|
-
} catch (err) {
|
|
1863
|
-
// Directory might already exist, ignore
|
|
1864
|
-
}
|
|
2032
|
+
await mkdirp(this.fs, storeDir);
|
|
1865
2033
|
await this.fs.promises.writeFile(
|
|
1866
2034
|
this.tagsFile,
|
|
1867
2035
|
JSON.stringify({ tags, tracked: this.tracking || {} }, null, 2)
|
|
@@ -2627,15 +2795,27 @@ class RevsetEngine {
|
|
|
2627
2795
|
}
|
|
2628
2796
|
|
|
2629
2797
|
case 'divergent': {
|
|
2798
|
+
// See issue #32 / converge(): a divergent change has more than one
|
|
2799
|
+
// visible commit sharing its changeId (added via
|
|
2800
|
+
// ChangeGraph.addDivergentCopy(), which is why counting `changeId`
|
|
2801
|
+
// occurrences across getAll() actually finds something now —
|
|
2802
|
+
// every other change has exactly one entry here). `c.divergent ===
|
|
2803
|
+
// true` also matches directly, since addDivergentCopy() sets that
|
|
2804
|
+
// flag on the copy it adds; the count check catches the *primary*
|
|
2805
|
+
// copy too (it's never itself flagged, since nothing about IT
|
|
2806
|
+
// changed) so both/all copies of a divergent changeId are
|
|
2807
|
+
// reported, not just the one(s) added after the first.
|
|
2630
2808
|
await this.graph.load();
|
|
2631
2809
|
const all = this.graph.getAll();
|
|
2632
2810
|
const counts = new Map();
|
|
2633
2811
|
for (const c of all) {
|
|
2634
2812
|
counts.set(c.changeId, (counts.get(c.changeId) || 0) + 1);
|
|
2635
2813
|
}
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2814
|
+
const matched = all.filter((c) => c.divergent === true || counts.get(c.changeId) > 1);
|
|
2815
|
+
// De-duplicate: multiple copies of the same divergent changeId
|
|
2816
|
+
// would otherwise each contribute their own (identical) changeId
|
|
2817
|
+
// to the result.
|
|
2818
|
+
return Array.from(new Set(matched.map((c) => c.changeId)));
|
|
2639
2819
|
}
|
|
2640
2820
|
|
|
2641
2821
|
case 'merges':
|
|
@@ -3688,6 +3868,184 @@ class RevsetEngine {
|
|
|
3688
3868
|
}
|
|
3689
3869
|
}
|
|
3690
3870
|
|
|
3871
|
+
/**
|
|
3872
|
+
* Minimal POSIX-only path utilities (join / dirname / resolve).
|
|
3873
|
+
*
|
|
3874
|
+
* Why not Node's `path` module: this library is isomorphic — it also needs
|
|
3875
|
+
* to run in browser bundles (see issue #28), where bare `import path from
|
|
3876
|
+
* 'path'` fails unless a bundler-specific polyfill is configured. Git/jj
|
|
3877
|
+
* repository paths are always POSIX-style (forward slashes), regardless of
|
|
3878
|
+
* host OS, so depending on Node's `path` module was also subtly wrong on
|
|
3879
|
+
* Windows: `path.join()` there uses `path.win32` semantics and would
|
|
3880
|
+
* produce backslash-joined paths for repository-relative paths, which is
|
|
3881
|
+
* not what git/jj object storage or working-copy paths want.
|
|
3882
|
+
*
|
|
3883
|
+
* These implement the same normalization rules as Node's `path.posix` for
|
|
3884
|
+
* the operations this codebase actually uses.
|
|
3885
|
+
*/
|
|
3886
|
+
|
|
3887
|
+
/**
|
|
3888
|
+
* @param {string} path
|
|
3889
|
+
* @returns {string} `path` with any run of `/` collapsed to a single `/`,
|
|
3890
|
+
* and `.`/`..` segments resolved. Preserves a leading `/` (absolute) and
|
|
3891
|
+
* a trailing `/` when present in the input.
|
|
3892
|
+
*/
|
|
3893
|
+
function normalize(path) {
|
|
3894
|
+
if (path === '') return '.';
|
|
3895
|
+
|
|
3896
|
+
const isAbsolute = path.charCodeAt(0) === 47; // '/'
|
|
3897
|
+
const trailingSlash = path.charCodeAt(path.length - 1) === 47;
|
|
3898
|
+
|
|
3899
|
+
const segments = path.split('/');
|
|
3900
|
+
/** @type {string[]} */
|
|
3901
|
+
const out = [];
|
|
3902
|
+
for (const segment of segments) {
|
|
3903
|
+
if (segment === '' || segment === '.') continue;
|
|
3904
|
+
if (segment === '..') {
|
|
3905
|
+
if (out.length && out[out.length - 1] !== '..') {
|
|
3906
|
+
out.pop();
|
|
3907
|
+
} else if (!isAbsolute) {
|
|
3908
|
+
out.push('..');
|
|
3909
|
+
}
|
|
3910
|
+
continue;
|
|
3911
|
+
}
|
|
3912
|
+
out.push(segment);
|
|
3913
|
+
}
|
|
3914
|
+
|
|
3915
|
+
let result = out.join('/');
|
|
3916
|
+
if (!result && !isAbsolute) result = '.';
|
|
3917
|
+
if (isAbsolute) result = '/' + result;
|
|
3918
|
+
if (trailingSlash && result.charCodeAt(result.length - 1) !== 47) result += '/';
|
|
3919
|
+
return result;
|
|
3920
|
+
}
|
|
3921
|
+
|
|
3922
|
+
/**
|
|
3923
|
+
* @param {...string} parts
|
|
3924
|
+
* @returns {string} All `parts` joined with `/` and normalized.
|
|
3925
|
+
*/
|
|
3926
|
+
function join(...parts) {
|
|
3927
|
+
if (parts.length === 0) return '.';
|
|
3928
|
+
const joined = parts.filter((p) => p !== '').join('/');
|
|
3929
|
+
return joined === '' ? '.' : normalize(joined);
|
|
3930
|
+
}
|
|
3931
|
+
|
|
3932
|
+
/**
|
|
3933
|
+
* @param {string} path
|
|
3934
|
+
* @returns {string} The directory portion of `path` (Node `path.dirname` semantics).
|
|
3935
|
+
*/
|
|
3936
|
+
function dirname(path) {
|
|
3937
|
+
if (path.length === 0) return '.';
|
|
3938
|
+
const isAbsolute = path.charCodeAt(0) === 47;
|
|
3939
|
+
let end = -1;
|
|
3940
|
+
let matchedSlash = true;
|
|
3941
|
+
for (let i = path.length - 1; i >= 1; i--) {
|
|
3942
|
+
if (path.charCodeAt(i) === 47) {
|
|
3943
|
+
if (!matchedSlash) {
|
|
3944
|
+
end = i;
|
|
3945
|
+
break;
|
|
3946
|
+
}
|
|
3947
|
+
} else {
|
|
3948
|
+
matchedSlash = false;
|
|
3949
|
+
}
|
|
3950
|
+
}
|
|
3951
|
+
|
|
3952
|
+
if (end === -1) return isAbsolute ? '/' : '.';
|
|
3953
|
+
if (isAbsolute && end === 0) return '/';
|
|
3954
|
+
return path.slice(0, end);
|
|
3955
|
+
}
|
|
3956
|
+
|
|
3957
|
+
/**
|
|
3958
|
+
* Resolves a sequence of path segments into an absolute path.
|
|
3959
|
+
*
|
|
3960
|
+
* Unlike Node's `path.resolve`, this never consults the real process cwd —
|
|
3961
|
+
* segments are resolved right-to-left, and if no absolute segment is found
|
|
3962
|
+
* the result is left relative to a virtual `/` root (this library always
|
|
3963
|
+
* deals in absolute repository-relative paths, so callers are expected to
|
|
3964
|
+
* supply an absolute base).
|
|
3965
|
+
*
|
|
3966
|
+
* @param {...string} parts
|
|
3967
|
+
* @returns {string}
|
|
3968
|
+
*/
|
|
3969
|
+
function resolve(...parts) {
|
|
3970
|
+
let resolved = '';
|
|
3971
|
+
let isAbsolute = false;
|
|
3972
|
+
|
|
3973
|
+
for (let i = parts.length - 1; i >= -1 && !isAbsolute; i--) {
|
|
3974
|
+
const path = i >= 0 ? parts[i] : '/';
|
|
3975
|
+
if (path.length === 0) continue;
|
|
3976
|
+
resolved = path + '/' + resolved;
|
|
3977
|
+
isAbsolute = path.charCodeAt(0) === 47;
|
|
3978
|
+
}
|
|
3979
|
+
|
|
3980
|
+
resolved = normalize(resolved);
|
|
3981
|
+
// normalize() preserves a trailing slash; path.resolve() never returns one
|
|
3982
|
+
// (except for the root itself).
|
|
3983
|
+
if (resolved.length > 1 && resolved.endsWith('/')) {
|
|
3984
|
+
resolved = resolved.slice(0, -1);
|
|
3985
|
+
}
|
|
3986
|
+
return isAbsolute ? resolved : '/' + resolved;
|
|
3987
|
+
}
|
|
3988
|
+
|
|
3989
|
+
/**
|
|
3990
|
+
* Cross-environment byte/string conversion helpers.
|
|
3991
|
+
*
|
|
3992
|
+
* Why not just Node's `Buffer`: it's a Node global that most browser
|
|
3993
|
+
* bundlers (Vite, esbuild, webpack 5+) do not polyfill by default, so code
|
|
3994
|
+
* that calls `Buffer.from(...)` compiles fine but throws `ReferenceError:
|
|
3995
|
+
* Buffer is not defined` at runtime in a browser bundle (see issue #28).
|
|
3996
|
+
*
|
|
3997
|
+
* `utf8Encode`/`utf8Decode` still prefer the real `Buffer` when it's present
|
|
3998
|
+
* (i.e. under Node) so existing behavior — including `.toString()` producing
|
|
3999
|
+
* the original text back, which a plain `Uint8Array` cannot do — is
|
|
4000
|
+
* unchanged there; `typeof Buffer` is safe to evaluate even where `Buffer`
|
|
4001
|
+
* was never declared (unlike referencing it directly), so this doesn't
|
|
4002
|
+
* reintroduce the crash outside Node. `hexToBytes`/`bytesToHex`/`isBytes`
|
|
4003
|
+
* only ever need `Uint8Array`, so they don't need the same branch.
|
|
4004
|
+
*/
|
|
4005
|
+
const hasBuffer = typeof Buffer !== 'undefined';
|
|
4006
|
+
|
|
4007
|
+
/**
|
|
4008
|
+
* @param {string} hex - Even-length hex string
|
|
4009
|
+
* @returns {Uint8Array}
|
|
4010
|
+
*/
|
|
4011
|
+
function hexToBytes(hex) {
|
|
4012
|
+
const length = hex.length / 2;
|
|
4013
|
+
const bytes = new Uint8Array(length);
|
|
4014
|
+
for (let i = 0; i < length; i++) {
|
|
4015
|
+
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
|
4016
|
+
}
|
|
4017
|
+
return bytes;
|
|
4018
|
+
}
|
|
4019
|
+
|
|
4020
|
+
/**
|
|
4021
|
+
* @param {string} str
|
|
4022
|
+
* @returns {Uint8Array} UTF-8 encoded bytes (a real `Buffer` under Node)
|
|
4023
|
+
*/
|
|
4024
|
+
function utf8Encode(str) {
|
|
4025
|
+
return hasBuffer ? Buffer.from(str, 'utf-8') : new TextEncoder().encode(str);
|
|
4026
|
+
}
|
|
4027
|
+
|
|
4028
|
+
/**
|
|
4029
|
+
* @param {Uint8Array} bytes
|
|
4030
|
+
* @returns {string} UTF-8 decoded string
|
|
4031
|
+
*/
|
|
4032
|
+
function utf8Decode(bytes) {
|
|
4033
|
+
return hasBuffer ? Buffer.from(bytes).toString('utf-8') : new TextDecoder('utf-8').decode(bytes);
|
|
4034
|
+
}
|
|
4035
|
+
|
|
4036
|
+
/**
|
|
4037
|
+
* True for anything that walks/quacks like binary content — a real Node
|
|
4038
|
+
* `Buffer` (which is itself a `Uint8Array` subclass) or a plain
|
|
4039
|
+
* `Uint8Array`/typed array, as produced by protobufjs in environments
|
|
4040
|
+
* without `Buffer` (e.g. a browser bundle).
|
|
4041
|
+
*
|
|
4042
|
+
* @param {any} value
|
|
4043
|
+
* @returns {boolean}
|
|
4044
|
+
*/
|
|
4045
|
+
function isBytes(value) {
|
|
4046
|
+
return value instanceof Uint8Array;
|
|
4047
|
+
}
|
|
4048
|
+
|
|
3691
4049
|
/**
|
|
3692
4050
|
* ConflictModel - First-class conflict handling
|
|
3693
4051
|
*
|
|
@@ -3860,6 +4218,15 @@ class ConflictModel {
|
|
|
3860
4218
|
const conflict = this._detectPathConflict(path, baseContent, leftContent, rightContent);
|
|
3861
4219
|
if (conflict) {
|
|
3862
4220
|
conflicts.push(conflict);
|
|
4221
|
+
} else if (workingCopyDir) {
|
|
4222
|
+
// No conflict, but "no conflict" doesn't mean "nothing to do": of
|
|
4223
|
+
// _detectPathConflict's null-returning cases, every one *except*
|
|
4224
|
+
// "right side changed, left didn't" already matches what's on disk,
|
|
4225
|
+
// because `leftContent` **is** what's on disk (read straight from
|
|
4226
|
+
// the working copy above) -- so only that one case needs a write.
|
|
4227
|
+
if (baseContent === leftContent && baseContent !== rightContent) {
|
|
4228
|
+
await this._applyCleanResolution(workingCopyDir, path, rightContent);
|
|
4229
|
+
}
|
|
3863
4230
|
}
|
|
3864
4231
|
}
|
|
3865
4232
|
|
|
@@ -3947,6 +4314,30 @@ class ConflictModel {
|
|
|
3947
4314
|
return null;
|
|
3948
4315
|
}
|
|
3949
4316
|
|
|
4317
|
+
/**
|
|
4318
|
+
* Create a content conflict with more than two disagreeing versions —
|
|
4319
|
+
* every other conflict in this module has exactly two sides (`left`/
|
|
4320
|
+
* `right`, or `left`/`right` on an add-add), because every OTHER caller
|
|
4321
|
+
* (merge, rebase) only ever three-way-merges two changes at a time.
|
|
4322
|
+
* converge() (issue #32) can face more than two divergent copies of one
|
|
4323
|
+
* change in a single call, so `sides` here is `{ base, versions }`
|
|
4324
|
+
* instead: `versions` is every distinct value found across all copies
|
|
4325
|
+
* that differs from `base`, each paired with (one of) the commitId(s)
|
|
4326
|
+
* that has it.
|
|
4327
|
+
*
|
|
4328
|
+
* @param {string} path
|
|
4329
|
+
* @param {any} base
|
|
4330
|
+
* @param {Array<{commitId: string, content: any}>} versions
|
|
4331
|
+
*/
|
|
4332
|
+
createNWayConflict(path, base, versions) {
|
|
4333
|
+
return this._createConflict({
|
|
4334
|
+
type: ConflictType.CONTENT,
|
|
4335
|
+
path,
|
|
4336
|
+
sides: { base, versions },
|
|
4337
|
+
message: `${versions.length} divergent copies disagree on this file's content`,
|
|
4338
|
+
});
|
|
4339
|
+
}
|
|
4340
|
+
|
|
3950
4341
|
/**
|
|
3951
4342
|
* Create a conflict object
|
|
3952
4343
|
*
|
|
@@ -4191,39 +4582,69 @@ class ConflictModel {
|
|
|
4191
4582
|
}
|
|
4192
4583
|
|
|
4193
4584
|
/**
|
|
4194
|
-
*
|
|
4585
|
+
* Apply a clean (non-conflicting) three-way resolution to the working
|
|
4586
|
+
* copy, for the default (no merge driver) path in detectConflicts(): the
|
|
4587
|
+
* "right side wins" case, where the resolved content differs from what's
|
|
4588
|
+
* currently on disk (see the call site for why this is the only clean
|
|
4589
|
+
* case that needs one).
|
|
4195
4590
|
*
|
|
4196
4591
|
* @param {string} workingCopyDir - Working copy directory
|
|
4197
4592
|
* @param {string} filePath - File path
|
|
4198
|
-
* @param {
|
|
4593
|
+
* @param {any} content - The winning (right) side's content, or
|
|
4594
|
+
* `undefined` to mean the file was deleted on that side
|
|
4199
4595
|
*/
|
|
4200
|
-
async
|
|
4201
|
-
const fullPath =
|
|
4596
|
+
async _applyCleanResolution(workingCopyDir, filePath, content) {
|
|
4597
|
+
const fullPath = join(workingCopyDir, filePath);
|
|
4202
4598
|
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4599
|
+
if (content === undefined) {
|
|
4600
|
+
await this.fs.promises.unlink(fullPath).catch(() => {});
|
|
4601
|
+
return;
|
|
4602
|
+
}
|
|
4603
|
+
|
|
4604
|
+
const dir = dirname(fullPath);
|
|
4605
|
+
await mkdirp(this.fs, dir);
|
|
4606
|
+
|
|
4607
|
+
if (isBytes(content)) {
|
|
4608
|
+
await this.fs.promises.writeFile(fullPath, content);
|
|
4609
|
+
} else {
|
|
4610
|
+
await this.fs.promises.writeFile(fullPath, content, 'utf8');
|
|
4611
|
+
}
|
|
4612
|
+
}
|
|
4613
|
+
|
|
4614
|
+
/**
|
|
4615
|
+
* Write merge driver result to working copy (v0.5)
|
|
4616
|
+
*
|
|
4617
|
+
* @param {string} workingCopyDir - Working copy directory
|
|
4618
|
+
* @param {string} filePath - File path
|
|
4619
|
+
* @param {Record<string, any>} result - Merge result
|
|
4620
|
+
*/
|
|
4621
|
+
async _writeDriverResult(workingCopyDir, filePath, result) {
|
|
4622
|
+
const fullPath = join(workingCopyDir, filePath);
|
|
4623
|
+
|
|
4624
|
+
// Ensure directory exists
|
|
4625
|
+
const dir = dirname(fullPath);
|
|
4626
|
+
await mkdirp(this.fs, dir);
|
|
4206
4627
|
|
|
4207
4628
|
// Write main file
|
|
4208
4629
|
if (result.content !== null && result.content !== undefined) {
|
|
4209
|
-
if (
|
|
4630
|
+
if (isBytes(result.content)) {
|
|
4210
4631
|
await this.fs.promises.writeFile(fullPath, result.content);
|
|
4211
4632
|
} else {
|
|
4212
|
-
await this.fs.promises.writeFile(fullPath, result.content, '
|
|
4633
|
+
await this.fs.promises.writeFile(fullPath, result.content, 'utf8');
|
|
4213
4634
|
}
|
|
4214
4635
|
}
|
|
4215
4636
|
|
|
4216
4637
|
// Write additional files if driver provided them
|
|
4217
4638
|
if (result.additionalFiles) {
|
|
4218
4639
|
for (const [additionalPath, additionalContent] of Object.entries(result.additionalFiles)) {
|
|
4219
|
-
const additionalFullPath =
|
|
4220
|
-
const additionalDir =
|
|
4221
|
-
await this.fs
|
|
4640
|
+
const additionalFullPath = join(workingCopyDir, additionalPath);
|
|
4641
|
+
const additionalDir = dirname(additionalFullPath);
|
|
4642
|
+
await mkdirp(this.fs, additionalDir);
|
|
4222
4643
|
|
|
4223
|
-
if (
|
|
4644
|
+
if (isBytes(additionalContent)) {
|
|
4224
4645
|
await this.fs.promises.writeFile(additionalFullPath, additionalContent);
|
|
4225
4646
|
} else {
|
|
4226
|
-
await this.fs.promises.writeFile(additionalFullPath, additionalContent, '
|
|
4647
|
+
await this.fs.promises.writeFile(additionalFullPath, additionalContent, 'utf8');
|
|
4227
4648
|
}
|
|
4228
4649
|
}
|
|
4229
4650
|
}
|
|
@@ -4269,11 +4690,11 @@ function matchesPattern(path, pattern) {
|
|
|
4269
4690
|
/**
|
|
4270
4691
|
* Check if a file is likely binary based on content
|
|
4271
4692
|
*
|
|
4272
|
-
* @param {string|
|
|
4693
|
+
* @param {string|Uint8Array} content - File content
|
|
4273
4694
|
* @returns {boolean} Whether content appears binary
|
|
4274
4695
|
*/
|
|
4275
4696
|
function isBinaryContent(content) {
|
|
4276
|
-
if (
|
|
4697
|
+
if (isBytes(content)) return true;
|
|
4277
4698
|
if (typeof content !== 'string') return false;
|
|
4278
4699
|
|
|
4279
4700
|
// Check for null bytes (common in binary files)
|
|
@@ -4588,9 +5009,9 @@ class MergeDriverRegistry {
|
|
|
4588
5009
|
/**
|
|
4589
5010
|
* Prepare content for driver based on binary/text
|
|
4590
5011
|
*
|
|
4591
|
-
* @param {string|
|
|
4592
|
-
* @param {boolean} isBinary - Whether to return as
|
|
4593
|
-
* @returns {string|
|
|
5012
|
+
* @param {string|Uint8Array|null} content - Raw content
|
|
5013
|
+
* @param {boolean} isBinary - Whether to return as bytes
|
|
5014
|
+
* @returns {string|Uint8Array|null} Prepared content
|
|
4594
5015
|
*/
|
|
4595
5016
|
prepareContent(content, isBinary) {
|
|
4596
5017
|
if (content === null || content === undefined) {
|
|
@@ -4598,14 +5019,14 @@ class MergeDriverRegistry {
|
|
|
4598
5019
|
}
|
|
4599
5020
|
|
|
4600
5021
|
if (isBinary) {
|
|
4601
|
-
// Convert to
|
|
4602
|
-
if (
|
|
4603
|
-
if (typeof content === 'string') return
|
|
4604
|
-
return
|
|
5022
|
+
// Convert to bytes if needed
|
|
5023
|
+
if (isBytes(content)) return content;
|
|
5024
|
+
if (typeof content === 'string') return utf8Encode(content);
|
|
5025
|
+
return utf8Encode(String(content));
|
|
4605
5026
|
} else {
|
|
4606
5027
|
// Convert to string if needed
|
|
4607
5028
|
if (typeof content === 'string') return content;
|
|
4608
|
-
if (
|
|
5029
|
+
if (isBytes(content)) return utf8Decode(content);
|
|
4609
5030
|
return String(content);
|
|
4610
5031
|
}
|
|
4611
5032
|
}
|
|
@@ -4766,25 +5187,25 @@ class WorkspaceManager {
|
|
|
4766
5187
|
};
|
|
4767
5188
|
|
|
4768
5189
|
// Create workspace directory
|
|
4769
|
-
await this.fs
|
|
5190
|
+
await mkdirp(this.fs, args.path);
|
|
4770
5191
|
|
|
4771
5192
|
// Create workspace-specific working_copy directory in .jj
|
|
4772
|
-
const workspaceCopyDir =
|
|
4773
|
-
await this.fs
|
|
5193
|
+
const workspaceCopyDir = join(this.repoDir, '.jj', 'working_copy', workspaceId);
|
|
5194
|
+
await mkdirp(this.fs, workspaceCopyDir);
|
|
4774
5195
|
|
|
4775
5196
|
// Create .git file pointing to main repo's .git directory
|
|
4776
5197
|
// This allows Git tools to work in the workspace
|
|
4777
5198
|
// IMPORTANT: Must use absolute paths for Git compatibility
|
|
4778
|
-
const gitFilePath =
|
|
4779
|
-
const mainGitDir =
|
|
5199
|
+
const gitFilePath = join(args.path, '.git');
|
|
5200
|
+
const mainGitDir = resolve(this.repoDir, '.git');
|
|
4780
5201
|
const gitFileContent = `gitdir: ${mainGitDir}\n`;
|
|
4781
5202
|
await this.fs.promises.writeFile(gitFilePath, gitFileContent, 'utf8');
|
|
4782
5203
|
|
|
4783
5204
|
// Create .jj file pointing to main repo's .jj directory
|
|
4784
5205
|
// This allows JJ tools to work in the workspace
|
|
4785
5206
|
// IMPORTANT: Must use absolute paths for JJ CLI compatibility
|
|
4786
|
-
const jjFilePath =
|
|
4787
|
-
const mainJJDir =
|
|
5207
|
+
const jjFilePath = join(args.path, '.jj');
|
|
5208
|
+
const mainJJDir = resolve(this.repoDir, '.jj');
|
|
4788
5209
|
const jjFileContent = `jjdir: ${mainJJDir}\n`;
|
|
4789
5210
|
await this.fs.promises.writeFile(jjFilePath, jjFileContent, 'utf8');
|
|
4790
5211
|
|
|
@@ -5419,6 +5840,575 @@ class UserConfig {
|
|
|
5419
5840
|
}
|
|
5420
5841
|
}
|
|
5421
5842
|
|
|
5843
|
+
/**
|
|
5844
|
+
* Precompiled protobuf reflection JSON for local_working_copy.proto
|
|
5845
|
+
*
|
|
5846
|
+
* Generated from src/protos/local_working_copy.proto by scripts/build-protos.mjs. This
|
|
5847
|
+
* is bundled directly (instead of loaded from a filesystem path at runtime)
|
|
5848
|
+
* so that protobuf schema loading works identically in Node and in browser
|
|
5849
|
+
* bundles — protobuf.load() requires filesystem/network access that does
|
|
5850
|
+
* not exist in a browser bundle (see issue #28). Regenerate with:
|
|
5851
|
+
* node scripts/build-protos.mjs
|
|
5852
|
+
*/
|
|
5853
|
+
|
|
5854
|
+
var localWorkingCopyJson = {
|
|
5855
|
+
nested: {
|
|
5856
|
+
local_working_copy: {
|
|
5857
|
+
nested: {
|
|
5858
|
+
FileType: {
|
|
5859
|
+
valuesOptions: {
|
|
5860
|
+
Conflict: {
|
|
5861
|
+
deprecated: true,
|
|
5862
|
+
},
|
|
5863
|
+
},
|
|
5864
|
+
values: {
|
|
5865
|
+
Normal: 0,
|
|
5866
|
+
Symlink: 1,
|
|
5867
|
+
Executable: 2,
|
|
5868
|
+
Conflict: 3,
|
|
5869
|
+
GitSubmodule: 4,
|
|
5870
|
+
},
|
|
5871
|
+
},
|
|
5872
|
+
MaterializedConflictData: {
|
|
5873
|
+
fields: {
|
|
5874
|
+
conflictMarkerLen: {
|
|
5875
|
+
type: 'uint32',
|
|
5876
|
+
id: 1,
|
|
5877
|
+
},
|
|
5878
|
+
},
|
|
5879
|
+
},
|
|
5880
|
+
FileState: {
|
|
5881
|
+
fields: {
|
|
5882
|
+
mtimeMillisSinceEpoch: {
|
|
5883
|
+
type: 'int64',
|
|
5884
|
+
id: 1,
|
|
5885
|
+
},
|
|
5886
|
+
size: {
|
|
5887
|
+
type: 'uint64',
|
|
5888
|
+
id: 2,
|
|
5889
|
+
},
|
|
5890
|
+
fileType: {
|
|
5891
|
+
type: 'FileType',
|
|
5892
|
+
id: 3,
|
|
5893
|
+
},
|
|
5894
|
+
materializedConflictData: {
|
|
5895
|
+
type: 'MaterializedConflictData',
|
|
5896
|
+
id: 5,
|
|
5897
|
+
},
|
|
5898
|
+
},
|
|
5899
|
+
reserved: [[4, 4]],
|
|
5900
|
+
},
|
|
5901
|
+
FileStateEntry: {
|
|
5902
|
+
fields: {
|
|
5903
|
+
path: {
|
|
5904
|
+
type: 'string',
|
|
5905
|
+
id: 1,
|
|
5906
|
+
},
|
|
5907
|
+
state: {
|
|
5908
|
+
type: 'FileState',
|
|
5909
|
+
id: 2,
|
|
5910
|
+
},
|
|
5911
|
+
},
|
|
5912
|
+
},
|
|
5913
|
+
SparsePatterns: {
|
|
5914
|
+
fields: {
|
|
5915
|
+
prefixes: {
|
|
5916
|
+
rule: 'repeated',
|
|
5917
|
+
type: 'string',
|
|
5918
|
+
id: 1,
|
|
5919
|
+
},
|
|
5920
|
+
},
|
|
5921
|
+
},
|
|
5922
|
+
TreeState: {
|
|
5923
|
+
fields: {
|
|
5924
|
+
legacyTreeId: {
|
|
5925
|
+
type: 'bytes',
|
|
5926
|
+
id: 1,
|
|
5927
|
+
options: {
|
|
5928
|
+
deprecated: true,
|
|
5929
|
+
},
|
|
5930
|
+
},
|
|
5931
|
+
treeIds: {
|
|
5932
|
+
rule: 'repeated',
|
|
5933
|
+
type: 'bytes',
|
|
5934
|
+
id: 5,
|
|
5935
|
+
},
|
|
5936
|
+
fileStates: {
|
|
5937
|
+
rule: 'repeated',
|
|
5938
|
+
type: 'FileStateEntry',
|
|
5939
|
+
id: 2,
|
|
5940
|
+
},
|
|
5941
|
+
isFileStatesSorted: {
|
|
5942
|
+
type: 'bool',
|
|
5943
|
+
id: 6,
|
|
5944
|
+
},
|
|
5945
|
+
sparsePatterns: {
|
|
5946
|
+
type: 'SparsePatterns',
|
|
5947
|
+
id: 3,
|
|
5948
|
+
},
|
|
5949
|
+
watchmanClock: {
|
|
5950
|
+
type: 'WatchmanClock',
|
|
5951
|
+
id: 4,
|
|
5952
|
+
},
|
|
5953
|
+
},
|
|
5954
|
+
},
|
|
5955
|
+
WatchmanClock: {
|
|
5956
|
+
oneofs: {
|
|
5957
|
+
watchmanClock: {
|
|
5958
|
+
oneof: ['stringClock', 'unixTimestamp'],
|
|
5959
|
+
},
|
|
5960
|
+
},
|
|
5961
|
+
fields: {
|
|
5962
|
+
stringClock: {
|
|
5963
|
+
type: 'string',
|
|
5964
|
+
id: 1,
|
|
5965
|
+
},
|
|
5966
|
+
unixTimestamp: {
|
|
5967
|
+
type: 'int64',
|
|
5968
|
+
id: 2,
|
|
5969
|
+
},
|
|
5970
|
+
},
|
|
5971
|
+
},
|
|
5972
|
+
Checkout: {
|
|
5973
|
+
fields: {
|
|
5974
|
+
operationId: {
|
|
5975
|
+
type: 'bytes',
|
|
5976
|
+
id: 2,
|
|
5977
|
+
},
|
|
5978
|
+
workspaceName: {
|
|
5979
|
+
type: 'string',
|
|
5980
|
+
id: 3,
|
|
5981
|
+
},
|
|
5982
|
+
},
|
|
5983
|
+
reserved: [[1, 1]],
|
|
5984
|
+
},
|
|
5985
|
+
},
|
|
5986
|
+
},
|
|
5987
|
+
},
|
|
5988
|
+
};
|
|
5989
|
+
|
|
5990
|
+
/**
|
|
5991
|
+
* Precompiled protobuf reflection JSON for simple_op_store.proto
|
|
5992
|
+
*
|
|
5993
|
+
* Generated from src/protos/simple_op_store.proto by scripts/build-protos.mjs. This
|
|
5994
|
+
* is bundled directly (instead of loaded from a filesystem path at runtime)
|
|
5995
|
+
* so that protobuf schema loading works identically in Node and in browser
|
|
5996
|
+
* bundles — protobuf.load() requires filesystem/network access that does
|
|
5997
|
+
* not exist in a browser bundle (see issue #28). Regenerate with:
|
|
5998
|
+
* node scripts/build-protos.mjs
|
|
5999
|
+
*/
|
|
6000
|
+
|
|
6001
|
+
var simpleOpStoreJson = {
|
|
6002
|
+
nested: {
|
|
6003
|
+
simple_op_store: {
|
|
6004
|
+
nested: {
|
|
6005
|
+
RefConflictLegacy: {
|
|
6006
|
+
fields: {
|
|
6007
|
+
removes: {
|
|
6008
|
+
rule: 'repeated',
|
|
6009
|
+
type: 'bytes',
|
|
6010
|
+
id: 1,
|
|
6011
|
+
options: {
|
|
6012
|
+
deprecated: true,
|
|
6013
|
+
},
|
|
6014
|
+
},
|
|
6015
|
+
adds: {
|
|
6016
|
+
rule: 'repeated',
|
|
6017
|
+
type: 'bytes',
|
|
6018
|
+
id: 2,
|
|
6019
|
+
options: {
|
|
6020
|
+
deprecated: true,
|
|
6021
|
+
},
|
|
6022
|
+
},
|
|
6023
|
+
},
|
|
6024
|
+
},
|
|
6025
|
+
RefConflict: {
|
|
6026
|
+
fields: {
|
|
6027
|
+
removes: {
|
|
6028
|
+
rule: 'repeated',
|
|
6029
|
+
type: 'Term',
|
|
6030
|
+
id: 1,
|
|
6031
|
+
},
|
|
6032
|
+
adds: {
|
|
6033
|
+
rule: 'repeated',
|
|
6034
|
+
type: 'Term',
|
|
6035
|
+
id: 2,
|
|
6036
|
+
},
|
|
6037
|
+
},
|
|
6038
|
+
nested: {
|
|
6039
|
+
Term: {
|
|
6040
|
+
oneofs: {
|
|
6041
|
+
_value: {
|
|
6042
|
+
oneof: ['value'],
|
|
6043
|
+
},
|
|
6044
|
+
},
|
|
6045
|
+
fields: {
|
|
6046
|
+
value: {
|
|
6047
|
+
type: 'bytes',
|
|
6048
|
+
id: 1,
|
|
6049
|
+
options: {
|
|
6050
|
+
proto3_optional: true,
|
|
6051
|
+
},
|
|
6052
|
+
},
|
|
6053
|
+
},
|
|
6054
|
+
},
|
|
6055
|
+
},
|
|
6056
|
+
},
|
|
6057
|
+
RefTarget: {
|
|
6058
|
+
oneofs: {
|
|
6059
|
+
value: {
|
|
6060
|
+
oneof: ['commitId', 'conflictLegacy', 'conflict'],
|
|
6061
|
+
},
|
|
6062
|
+
},
|
|
6063
|
+
fields: {
|
|
6064
|
+
commitId: {
|
|
6065
|
+
type: 'bytes',
|
|
6066
|
+
id: 1,
|
|
6067
|
+
options: {
|
|
6068
|
+
deprecated: true,
|
|
6069
|
+
},
|
|
6070
|
+
},
|
|
6071
|
+
conflictLegacy: {
|
|
6072
|
+
type: 'RefConflictLegacy',
|
|
6073
|
+
id: 2,
|
|
6074
|
+
options: {
|
|
6075
|
+
deprecated: true,
|
|
6076
|
+
},
|
|
6077
|
+
},
|
|
6078
|
+
conflict: {
|
|
6079
|
+
type: 'RefConflict',
|
|
6080
|
+
id: 3,
|
|
6081
|
+
},
|
|
6082
|
+
},
|
|
6083
|
+
},
|
|
6084
|
+
RefTargetTerm: {
|
|
6085
|
+
oneofs: {
|
|
6086
|
+
_value: {
|
|
6087
|
+
oneof: ['value'],
|
|
6088
|
+
},
|
|
6089
|
+
},
|
|
6090
|
+
fields: {
|
|
6091
|
+
value: {
|
|
6092
|
+
type: 'bytes',
|
|
6093
|
+
id: 1,
|
|
6094
|
+
options: {
|
|
6095
|
+
proto3_optional: true,
|
|
6096
|
+
},
|
|
6097
|
+
},
|
|
6098
|
+
},
|
|
6099
|
+
},
|
|
6100
|
+
RemoteRefState: {
|
|
6101
|
+
values: {
|
|
6102
|
+
New: 0,
|
|
6103
|
+
Tracked: 1,
|
|
6104
|
+
},
|
|
6105
|
+
},
|
|
6106
|
+
RemoteBookmark: {
|
|
6107
|
+
oneofs: {
|
|
6108
|
+
_state: {
|
|
6109
|
+
oneof: ['state'],
|
|
6110
|
+
},
|
|
6111
|
+
},
|
|
6112
|
+
fields: {
|
|
6113
|
+
remoteName: {
|
|
6114
|
+
type: 'string',
|
|
6115
|
+
id: 1,
|
|
6116
|
+
},
|
|
6117
|
+
target: {
|
|
6118
|
+
type: 'RefTarget',
|
|
6119
|
+
id: 2,
|
|
6120
|
+
},
|
|
6121
|
+
state: {
|
|
6122
|
+
type: 'RemoteRefState',
|
|
6123
|
+
id: 3,
|
|
6124
|
+
options: {
|
|
6125
|
+
proto3_optional: true,
|
|
6126
|
+
},
|
|
6127
|
+
},
|
|
6128
|
+
},
|
|
6129
|
+
},
|
|
6130
|
+
Bookmark: {
|
|
6131
|
+
fields: {
|
|
6132
|
+
name: {
|
|
6133
|
+
type: 'string',
|
|
6134
|
+
id: 1,
|
|
6135
|
+
},
|
|
6136
|
+
localTarget: {
|
|
6137
|
+
type: 'RefTarget',
|
|
6138
|
+
id: 2,
|
|
6139
|
+
},
|
|
6140
|
+
remoteBookmarks: {
|
|
6141
|
+
rule: 'repeated',
|
|
6142
|
+
type: 'RemoteBookmark',
|
|
6143
|
+
id: 3,
|
|
6144
|
+
options: {
|
|
6145
|
+
deprecated: true,
|
|
6146
|
+
},
|
|
6147
|
+
},
|
|
6148
|
+
},
|
|
6149
|
+
},
|
|
6150
|
+
GitRef: {
|
|
6151
|
+
fields: {
|
|
6152
|
+
name: {
|
|
6153
|
+
type: 'string',
|
|
6154
|
+
id: 1,
|
|
6155
|
+
},
|
|
6156
|
+
commitId: {
|
|
6157
|
+
type: 'bytes',
|
|
6158
|
+
id: 2,
|
|
6159
|
+
options: {
|
|
6160
|
+
deprecated: true,
|
|
6161
|
+
},
|
|
6162
|
+
},
|
|
6163
|
+
target: {
|
|
6164
|
+
type: 'RefTarget',
|
|
6165
|
+
id: 3,
|
|
6166
|
+
},
|
|
6167
|
+
},
|
|
6168
|
+
},
|
|
6169
|
+
RemoteRef: {
|
|
6170
|
+
fields: {
|
|
6171
|
+
name: {
|
|
6172
|
+
type: 'string',
|
|
6173
|
+
id: 1,
|
|
6174
|
+
},
|
|
6175
|
+
targetTerms: {
|
|
6176
|
+
rule: 'repeated',
|
|
6177
|
+
type: 'RefTargetTerm',
|
|
6178
|
+
id: 2,
|
|
6179
|
+
},
|
|
6180
|
+
state: {
|
|
6181
|
+
type: 'RemoteRefState',
|
|
6182
|
+
id: 3,
|
|
6183
|
+
},
|
|
6184
|
+
},
|
|
6185
|
+
},
|
|
6186
|
+
Tag: {
|
|
6187
|
+
fields: {
|
|
6188
|
+
name: {
|
|
6189
|
+
type: 'string',
|
|
6190
|
+
id: 1,
|
|
6191
|
+
},
|
|
6192
|
+
target: {
|
|
6193
|
+
type: 'RefTarget',
|
|
6194
|
+
id: 2,
|
|
6195
|
+
},
|
|
6196
|
+
},
|
|
6197
|
+
},
|
|
6198
|
+
View: {
|
|
6199
|
+
fields: {
|
|
6200
|
+
headIds: {
|
|
6201
|
+
rule: 'repeated',
|
|
6202
|
+
type: 'bytes',
|
|
6203
|
+
id: 1,
|
|
6204
|
+
},
|
|
6205
|
+
wcCommitId: {
|
|
6206
|
+
type: 'bytes',
|
|
6207
|
+
id: 2,
|
|
6208
|
+
options: {
|
|
6209
|
+
deprecated: true,
|
|
6210
|
+
},
|
|
6211
|
+
},
|
|
6212
|
+
wcCommitIds: {
|
|
6213
|
+
keyType: 'string',
|
|
6214
|
+
type: 'bytes',
|
|
6215
|
+
id: 8,
|
|
6216
|
+
},
|
|
6217
|
+
bookmarks: {
|
|
6218
|
+
rule: 'repeated',
|
|
6219
|
+
type: 'Bookmark',
|
|
6220
|
+
id: 5,
|
|
6221
|
+
},
|
|
6222
|
+
localTags: {
|
|
6223
|
+
rule: 'repeated',
|
|
6224
|
+
type: 'Tag',
|
|
6225
|
+
id: 6,
|
|
6226
|
+
},
|
|
6227
|
+
remoteViews: {
|
|
6228
|
+
rule: 'repeated',
|
|
6229
|
+
type: 'RemoteView',
|
|
6230
|
+
id: 11,
|
|
6231
|
+
},
|
|
6232
|
+
gitRefs: {
|
|
6233
|
+
rule: 'repeated',
|
|
6234
|
+
type: 'GitRef',
|
|
6235
|
+
id: 3,
|
|
6236
|
+
},
|
|
6237
|
+
gitHeadLegacy: {
|
|
6238
|
+
type: 'bytes',
|
|
6239
|
+
id: 7,
|
|
6240
|
+
options: {
|
|
6241
|
+
deprecated: true,
|
|
6242
|
+
},
|
|
6243
|
+
},
|
|
6244
|
+
gitHead: {
|
|
6245
|
+
type: 'RefTarget',
|
|
6246
|
+
id: 9,
|
|
6247
|
+
},
|
|
6248
|
+
hasGitRefsMigratedToRemoteTags: {
|
|
6249
|
+
type: 'bool',
|
|
6250
|
+
id: 12,
|
|
6251
|
+
},
|
|
6252
|
+
},
|
|
6253
|
+
reserved: [
|
|
6254
|
+
[4, 4],
|
|
6255
|
+
[10, 10],
|
|
6256
|
+
],
|
|
6257
|
+
},
|
|
6258
|
+
RemoteView: {
|
|
6259
|
+
fields: {
|
|
6260
|
+
name: {
|
|
6261
|
+
type: 'string',
|
|
6262
|
+
id: 1,
|
|
6263
|
+
},
|
|
6264
|
+
bookmarks: {
|
|
6265
|
+
rule: 'repeated',
|
|
6266
|
+
type: 'RemoteRef',
|
|
6267
|
+
id: 2,
|
|
6268
|
+
},
|
|
6269
|
+
tags: {
|
|
6270
|
+
rule: 'repeated',
|
|
6271
|
+
type: 'RemoteRef',
|
|
6272
|
+
id: 3,
|
|
6273
|
+
},
|
|
6274
|
+
},
|
|
6275
|
+
},
|
|
6276
|
+
Operation: {
|
|
6277
|
+
fields: {
|
|
6278
|
+
viewId: {
|
|
6279
|
+
type: 'bytes',
|
|
6280
|
+
id: 1,
|
|
6281
|
+
},
|
|
6282
|
+
parents: {
|
|
6283
|
+
rule: 'repeated',
|
|
6284
|
+
type: 'bytes',
|
|
6285
|
+
id: 2,
|
|
6286
|
+
},
|
|
6287
|
+
metadata: {
|
|
6288
|
+
type: 'OperationMetadata',
|
|
6289
|
+
id: 3,
|
|
6290
|
+
},
|
|
6291
|
+
commitPredecessors: {
|
|
6292
|
+
rule: 'repeated',
|
|
6293
|
+
type: 'CommitPredecessors',
|
|
6294
|
+
id: 4,
|
|
6295
|
+
},
|
|
6296
|
+
storesCommitPredecessors: {
|
|
6297
|
+
type: 'bool',
|
|
6298
|
+
id: 5,
|
|
6299
|
+
},
|
|
6300
|
+
},
|
|
6301
|
+
},
|
|
6302
|
+
Timestamp: {
|
|
6303
|
+
fields: {
|
|
6304
|
+
millisSinceEpoch: {
|
|
6305
|
+
type: 'int64',
|
|
6306
|
+
id: 1,
|
|
6307
|
+
},
|
|
6308
|
+
tzOffset: {
|
|
6309
|
+
type: 'int32',
|
|
6310
|
+
id: 2,
|
|
6311
|
+
},
|
|
6312
|
+
},
|
|
6313
|
+
},
|
|
6314
|
+
OperationMetadata: {
|
|
6315
|
+
fields: {
|
|
6316
|
+
startTime: {
|
|
6317
|
+
type: 'Timestamp',
|
|
6318
|
+
id: 1,
|
|
6319
|
+
},
|
|
6320
|
+
endTime: {
|
|
6321
|
+
type: 'Timestamp',
|
|
6322
|
+
id: 2,
|
|
6323
|
+
},
|
|
6324
|
+
description: {
|
|
6325
|
+
type: 'string',
|
|
6326
|
+
id: 3,
|
|
6327
|
+
},
|
|
6328
|
+
hostname: {
|
|
6329
|
+
type: 'string',
|
|
6330
|
+
id: 4,
|
|
6331
|
+
},
|
|
6332
|
+
username: {
|
|
6333
|
+
type: 'string',
|
|
6334
|
+
id: 5,
|
|
6335
|
+
},
|
|
6336
|
+
isSnapshot: {
|
|
6337
|
+
type: 'bool',
|
|
6338
|
+
id: 7,
|
|
6339
|
+
},
|
|
6340
|
+
tags: {
|
|
6341
|
+
keyType: 'string',
|
|
6342
|
+
type: 'string',
|
|
6343
|
+
id: 6,
|
|
6344
|
+
},
|
|
6345
|
+
},
|
|
6346
|
+
},
|
|
6347
|
+
CommitPredecessors: {
|
|
6348
|
+
fields: {
|
|
6349
|
+
commitId: {
|
|
6350
|
+
type: 'bytes',
|
|
6351
|
+
id: 1,
|
|
6352
|
+
},
|
|
6353
|
+
predecessorIds: {
|
|
6354
|
+
rule: 'repeated',
|
|
6355
|
+
type: 'bytes',
|
|
6356
|
+
id: 2,
|
|
6357
|
+
},
|
|
6358
|
+
},
|
|
6359
|
+
},
|
|
6360
|
+
},
|
|
6361
|
+
},
|
|
6362
|
+
},
|
|
6363
|
+
};
|
|
6364
|
+
|
|
6365
|
+
/**
|
|
6366
|
+
* Protobuf schema loader
|
|
6367
|
+
*
|
|
6368
|
+
* Builds protobuf.js `Root` instances from precompiled reflection JSON
|
|
6369
|
+
* (see local_working_copy.json.js / simple_op_store.json.js, generated from
|
|
6370
|
+
* the .proto files in this directory) instead of calling `protobuf.load()`
|
|
6371
|
+
* against a filesystem path.
|
|
6372
|
+
*
|
|
6373
|
+
* Why: `protobuf.load()` resolves its argument as a file path (Node `fs`) or
|
|
6374
|
+
* URL (XHR/fetch), which requires either a real filesystem or a network
|
|
6375
|
+
* request. In a bundled browser build there is no filesystem path to read
|
|
6376
|
+
* (see issue #28), and shipping the raw .proto text and fetching it at
|
|
6377
|
+
* runtime would require bundler-specific asset handling. Reflection JSON is
|
|
6378
|
+
* plain data, so it can be imported like any other JS module and works
|
|
6379
|
+
* identically under Node, Jest, and any browser bundler (Vite, esbuild,
|
|
6380
|
+
* webpack, Rollup, ...).
|
|
6381
|
+
*
|
|
6382
|
+
* Regenerate the JSON files after editing a .proto with:
|
|
6383
|
+
* npm run build:protos
|
|
6384
|
+
*/
|
|
6385
|
+
|
|
6386
|
+
|
|
6387
|
+
/** @type {protobuf.Root|undefined} */
|
|
6388
|
+
let localWorkingCopyRoot;
|
|
6389
|
+
/** @type {protobuf.Root|undefined} */
|
|
6390
|
+
let simpleOpStoreRoot;
|
|
6391
|
+
|
|
6392
|
+
/**
|
|
6393
|
+
* @returns {protobuf.Root} Root for local_working_copy.proto (Checkout, TreeState, ...)
|
|
6394
|
+
*/
|
|
6395
|
+
function getLocalWorkingCopyRoot() {
|
|
6396
|
+
if (!localWorkingCopyRoot) {
|
|
6397
|
+
localWorkingCopyRoot = protobuf.Root.fromJSON(localWorkingCopyJson);
|
|
6398
|
+
}
|
|
6399
|
+
return localWorkingCopyRoot;
|
|
6400
|
+
}
|
|
6401
|
+
|
|
6402
|
+
/**
|
|
6403
|
+
* @returns {protobuf.Root} Root for simple_op_store.proto (Operation, View, ...)
|
|
6404
|
+
*/
|
|
6405
|
+
function getSimpleOpStoreRoot() {
|
|
6406
|
+
if (!simpleOpStoreRoot) {
|
|
6407
|
+
simpleOpStoreRoot = protobuf.Root.fromJSON(simpleOpStoreJson);
|
|
6408
|
+
}
|
|
6409
|
+
return simpleOpStoreRoot;
|
|
6410
|
+
}
|
|
6411
|
+
|
|
5422
6412
|
/**
|
|
5423
6413
|
* JJ Checkout File Manager
|
|
5424
6414
|
*
|
|
@@ -5427,15 +6417,6 @@ class UserConfig {
|
|
|
5427
6417
|
*/
|
|
5428
6418
|
|
|
5429
6419
|
|
|
5430
|
-
const __filename$4 = url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
|
|
5431
|
-
const __dirname$4 = path.dirname(__filename$4);
|
|
5432
|
-
|
|
5433
|
-
// Determine proto path based on whether we're running from dist or src
|
|
5434
|
-
const inDist$3 = __dirname$4.includes('/dist') || __dirname$4.includes('\\dist');
|
|
5435
|
-
const protoDir$3 = inDist$3
|
|
5436
|
-
? path.join(__dirname$4, '..', 'src', 'protos')
|
|
5437
|
-
: path.join(__dirname$4, '..', 'protos');
|
|
5438
|
-
|
|
5439
6420
|
class JJCheckout {
|
|
5440
6421
|
/**
|
|
5441
6422
|
* @param {any} fs - File system module (isomorphic-git compatible)
|
|
@@ -5444,7 +6425,6 @@ class JJCheckout {
|
|
|
5444
6425
|
constructor(fs, dir) {
|
|
5445
6426
|
this.fs = fs;
|
|
5446
6427
|
this.dir = dir;
|
|
5447
|
-
this.protoPath = path.join(protoDir$3, 'local_working_copy.proto');
|
|
5448
6428
|
}
|
|
5449
6429
|
|
|
5450
6430
|
/**
|
|
@@ -5455,11 +6435,11 @@ class JJCheckout {
|
|
|
5455
6435
|
*/
|
|
5456
6436
|
async writeCheckout(operationId, workspaceName = 'default') {
|
|
5457
6437
|
// Load protobuf schema
|
|
5458
|
-
const root =
|
|
6438
|
+
const root = getLocalWorkingCopyRoot();
|
|
5459
6439
|
const Checkout = root.lookupType('local_working_copy.Checkout');
|
|
5460
6440
|
|
|
5461
6441
|
// Convert hex operation ID to bytes
|
|
5462
|
-
const opIdBuffer =
|
|
6442
|
+
const opIdBuffer = hexToBytes(operationId);
|
|
5463
6443
|
|
|
5464
6444
|
// Create message (use camelCase for protobufjs)
|
|
5465
6445
|
const message = Checkout.create({
|
|
@@ -5491,7 +6471,7 @@ class JJCheckout {
|
|
|
5491
6471
|
const buffer = await this.fs.promises.readFile(checkoutPath);
|
|
5492
6472
|
|
|
5493
6473
|
// Load protobuf schema
|
|
5494
|
-
const root =
|
|
6474
|
+
const root = getLocalWorkingCopyRoot();
|
|
5495
6475
|
const Checkout = root.lookupType('local_working_copy.Checkout');
|
|
5496
6476
|
|
|
5497
6477
|
// Decode
|
|
@@ -5519,6 +6499,7 @@ class JJCheckout {
|
|
|
5519
6499
|
* calling `writeFile()` directly on the canonical path — see issue #16.
|
|
5520
6500
|
*/
|
|
5521
6501
|
|
|
6502
|
+
|
|
5522
6503
|
/**
|
|
5523
6504
|
* @param {any} fs - Filesystem implementation (Node fs, LightningFS, etc.)
|
|
5524
6505
|
* @param {string} targetPath - Canonical path to write to
|
|
@@ -5534,7 +6515,7 @@ async function atomicWriteFile(fs, targetPath, content) {
|
|
|
5534
6515
|
try {
|
|
5535
6516
|
const dirPath = targetPath.substring(0, targetPath.lastIndexOf('/'));
|
|
5536
6517
|
if (dirPath) {
|
|
5537
|
-
await fs
|
|
6518
|
+
await mkdirp(fs, dirPath);
|
|
5538
6519
|
}
|
|
5539
6520
|
|
|
5540
6521
|
await fs.promises.writeFile(tmpPath, content);
|
|
@@ -5558,15 +6539,6 @@ async function atomicWriteFile(fs, targetPath, content) {
|
|
|
5558
6539
|
*/
|
|
5559
6540
|
|
|
5560
6541
|
|
|
5561
|
-
const __filename$3 = url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
|
|
5562
|
-
const __dirname$3 = path.dirname(__filename$3);
|
|
5563
|
-
|
|
5564
|
-
// Determine proto path based on whether we're running from dist or src
|
|
5565
|
-
const inDist$2 = __dirname$3.includes('/dist') || __dirname$3.includes('\\dist');
|
|
5566
|
-
const protoDir$2 = inDist$2
|
|
5567
|
-
? path.join(__dirname$3, '..', 'src', 'protos')
|
|
5568
|
-
: path.join(__dirname$3, '..', 'protos');
|
|
5569
|
-
|
|
5570
6542
|
class JJTreeState {
|
|
5571
6543
|
/**
|
|
5572
6544
|
* @param {any} fs - File system module (isomorphic-git compatible)
|
|
@@ -5575,7 +6547,6 @@ class JJTreeState {
|
|
|
5575
6547
|
constructor(fs, dir) {
|
|
5576
6548
|
this.fs = fs;
|
|
5577
6549
|
this.dir = dir;
|
|
5578
|
-
this.protoPath = path.join(protoDir$2, 'local_working_copy.proto');
|
|
5579
6550
|
}
|
|
5580
6551
|
|
|
5581
6552
|
/**
|
|
@@ -5586,11 +6557,11 @@ class JJTreeState {
|
|
|
5586
6557
|
*/
|
|
5587
6558
|
async writeTreeState(treeId, fileStates = []) {
|
|
5588
6559
|
// Load protobuf schema
|
|
5589
|
-
const root =
|
|
6560
|
+
const root = getLocalWorkingCopyRoot();
|
|
5590
6561
|
const TreeState = root.lookupType('local_working_copy.TreeState');
|
|
5591
6562
|
|
|
5592
6563
|
// Convert hex tree ID to bytes
|
|
5593
|
-
const treeIdBuffer =
|
|
6564
|
+
const treeIdBuffer = hexToBytes(treeId);
|
|
5594
6565
|
|
|
5595
6566
|
// Convert file states to protobuf format
|
|
5596
6567
|
const fileStateEntries = fileStates.map((fs) => ({
|
|
@@ -5636,7 +6607,7 @@ class JJTreeState {
|
|
|
5636
6607
|
const buffer = await this.fs.promises.readFile(treeStatePath);
|
|
5637
6608
|
|
|
5638
6609
|
// Load protobuf schema
|
|
5639
|
-
const root =
|
|
6610
|
+
const root = getLocalWorkingCopyRoot();
|
|
5640
6611
|
const TreeState = root.lookupType('local_working_copy.TreeState');
|
|
5641
6612
|
|
|
5642
6613
|
// Decode
|
|
@@ -5660,15 +6631,6 @@ class JJTreeState {
|
|
|
5660
6631
|
*/
|
|
5661
6632
|
|
|
5662
6633
|
|
|
5663
|
-
const __filename$2 = url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
|
|
5664
|
-
const __dirname$2 = path.dirname(__filename$2);
|
|
5665
|
-
|
|
5666
|
-
// Determine proto path based on whether we're running from dist or src
|
|
5667
|
-
const inDist$1 = __dirname$2.includes('/dist') || __dirname$2.includes('\\dist');
|
|
5668
|
-
const protoDir$1 = inDist$1
|
|
5669
|
-
? path.join(__dirname$2, '..', 'src', 'protos')
|
|
5670
|
-
: path.join(__dirname$2, '..', 'protos');
|
|
5671
|
-
|
|
5672
6634
|
class JJOperationStore {
|
|
5673
6635
|
/**
|
|
5674
6636
|
* @param {any} fs - File system module (isomorphic-git compatible)
|
|
@@ -5677,7 +6639,6 @@ class JJOperationStore {
|
|
|
5677
6639
|
constructor(fs, dir) {
|
|
5678
6640
|
this.fs = fs;
|
|
5679
6641
|
this.dir = dir;
|
|
5680
|
-
this.protoPath = path.join(protoDir$1, 'simple_op_store.proto');
|
|
5681
6642
|
}
|
|
5682
6643
|
|
|
5683
6644
|
/**
|
|
@@ -5690,12 +6651,12 @@ class JJOperationStore {
|
|
|
5690
6651
|
*/
|
|
5691
6652
|
async writeOperation(operationId, viewId, parentIds, metadata) {
|
|
5692
6653
|
// Load protobuf schema
|
|
5693
|
-
const root =
|
|
6654
|
+
const root = getSimpleOpStoreRoot();
|
|
5694
6655
|
const Operation = root.lookupType('simple_op_store.Operation');
|
|
5695
6656
|
|
|
5696
6657
|
// Convert hex IDs to bytes
|
|
5697
|
-
const viewIdBuffer =
|
|
5698
|
-
const parentBuffers = parentIds.map((id) =>
|
|
6658
|
+
const viewIdBuffer = hexToBytes(viewId);
|
|
6659
|
+
const parentBuffers = parentIds.map((id) => hexToBytes(id));
|
|
5699
6660
|
|
|
5700
6661
|
// Create metadata message (use camelCase for protobufjs)
|
|
5701
6662
|
const metadataMsg = {
|
|
@@ -5751,7 +6712,7 @@ class JJOperationStore {
|
|
|
5751
6712
|
const buffer = await this.fs.promises.readFile(opPath);
|
|
5752
6713
|
|
|
5753
6714
|
// Load protobuf schema
|
|
5754
|
-
const root =
|
|
6715
|
+
const root = getSimpleOpStoreRoot();
|
|
5755
6716
|
const Operation = root.lookupType('simple_op_store.Operation');
|
|
5756
6717
|
|
|
5757
6718
|
// Decode
|
|
@@ -5790,15 +6751,6 @@ class JJOperationStore {
|
|
|
5790
6751
|
*/
|
|
5791
6752
|
|
|
5792
6753
|
|
|
5793
|
-
const __filename$1 = url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
|
|
5794
|
-
const __dirname$1 = path.dirname(__filename$1);
|
|
5795
|
-
|
|
5796
|
-
// Determine proto path based on whether we're running from dist or src
|
|
5797
|
-
const inDist = __dirname$1.includes('/dist') || __dirname$1.includes('\\dist');
|
|
5798
|
-
const protoDir = inDist
|
|
5799
|
-
? path.join(__dirname$1, '..', 'src', 'protos')
|
|
5800
|
-
: path.join(__dirname$1, '..', 'protos');
|
|
5801
|
-
|
|
5802
6754
|
class JJViewStore {
|
|
5803
6755
|
/**
|
|
5804
6756
|
* @param {any} fs - File system module (isomorphic-git compatible)
|
|
@@ -5807,7 +6759,6 @@ class JJViewStore {
|
|
|
5807
6759
|
constructor(fs, dir) {
|
|
5808
6760
|
this.fs = fs;
|
|
5809
6761
|
this.dir = dir;
|
|
5810
|
-
this.protoPath = path.join(protoDir, 'simple_op_store.proto');
|
|
5811
6762
|
}
|
|
5812
6763
|
|
|
5813
6764
|
/**
|
|
@@ -5819,17 +6770,17 @@ class JJViewStore {
|
|
|
5819
6770
|
*/
|
|
5820
6771
|
async writeView(viewId, headIds, wcCommitIds = {}) {
|
|
5821
6772
|
// Load protobuf schema
|
|
5822
|
-
const root =
|
|
6773
|
+
const root = getSimpleOpStoreRoot();
|
|
5823
6774
|
const View = root.lookupType('simple_op_store.View');
|
|
5824
6775
|
|
|
5825
6776
|
// Convert hex IDs to bytes
|
|
5826
|
-
const headBuffers = headIds.map((id) =>
|
|
6777
|
+
const headBuffers = headIds.map((id) => hexToBytes(id));
|
|
5827
6778
|
|
|
5828
6779
|
// Convert wcCommitIds object to map
|
|
5829
6780
|
/** @type {Record<string, any>} */
|
|
5830
6781
|
const wcCommitIdsMap = {};
|
|
5831
6782
|
for (const [workspace, commitId] of Object.entries(wcCommitIds)) {
|
|
5832
|
-
wcCommitIdsMap[workspace] =
|
|
6783
|
+
wcCommitIdsMap[workspace] = hexToBytes(commitId);
|
|
5833
6784
|
}
|
|
5834
6785
|
|
|
5835
6786
|
// Create view message (use camelCase for protobufjs)
|
|
@@ -5870,7 +6821,7 @@ class JJViewStore {
|
|
|
5870
6821
|
const buffer = await this.fs.promises.readFile(viewPath);
|
|
5871
6822
|
|
|
5872
6823
|
// Load protobuf schema
|
|
5873
|
-
const root =
|
|
6824
|
+
const root = getSimpleOpStoreRoot();
|
|
5874
6825
|
const View = root.lookupType('simple_op_store.View');
|
|
5875
6826
|
|
|
5876
6827
|
// Decode
|
|
@@ -6566,14 +7517,14 @@ class IsomorphicGitBackend {
|
|
|
6566
7517
|
const jjRepo = `${jjDir}/repo`;
|
|
6567
7518
|
|
|
6568
7519
|
// Create directory structure
|
|
6569
|
-
await this.fs
|
|
6570
|
-
await this.fs
|
|
6571
|
-
await this.fs
|
|
6572
|
-
await this.fs
|
|
6573
|
-
await this.fs
|
|
6574
|
-
await this.fs
|
|
6575
|
-
await this.fs
|
|
6576
|
-
await this.fs
|
|
7520
|
+
await mkdirp(this.fs, `${jjRepo}/store`);
|
|
7521
|
+
await mkdirp(this.fs, `${jjRepo}/store/extra/heads`);
|
|
7522
|
+
await mkdirp(this.fs, `${jjRepo}/op_store/operations`);
|
|
7523
|
+
await mkdirp(this.fs, `${jjRepo}/op_store/views`);
|
|
7524
|
+
await mkdirp(this.fs, `${jjRepo}/op_heads/heads`);
|
|
7525
|
+
await mkdirp(this.fs, `${jjRepo}/index`);
|
|
7526
|
+
await mkdirp(this.fs, `${jjRepo}/submodule_store`);
|
|
7527
|
+
await mkdirp(this.fs, `${jjDir}/working_copy`);
|
|
6577
7528
|
|
|
6578
7529
|
// Write type files
|
|
6579
7530
|
await this.fs.promises.writeFile(`${jjRepo}/store/type`, 'git');
|
|
@@ -6600,8 +7551,8 @@ class IsomorphicGitBackend {
|
|
|
6600
7551
|
*/
|
|
6601
7552
|
async _createInitialJJState() {
|
|
6602
7553
|
// Generate unique IDs for initial state
|
|
6603
|
-
const operationId =
|
|
6604
|
-
const viewId =
|
|
7554
|
+
const operationId = randomHex(64); // 512-bit = 128 hex chars
|
|
7555
|
+
const viewId = randomHex(64);
|
|
6605
7556
|
const emptyTreeId = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; // Git's empty tree hash
|
|
6606
7557
|
|
|
6607
7558
|
// Get the initial commit from Git
|
|
@@ -6613,7 +7564,7 @@ class IsomorphicGitBackend {
|
|
|
6613
7564
|
ref: 'HEAD',
|
|
6614
7565
|
});
|
|
6615
7566
|
if (headOid) {
|
|
6616
|
-
initialCommitId =
|
|
7567
|
+
initialCommitId = randomHex(64);
|
|
6617
7568
|
}
|
|
6618
7569
|
} catch (error) {
|
|
6619
7570
|
// No initial commit yet
|
|
@@ -6770,7 +7721,7 @@ async function createJJ(options) {
|
|
|
6770
7721
|
|
|
6771
7722
|
for (const filePath of trackedFiles) {
|
|
6772
7723
|
try {
|
|
6773
|
-
const fullPath =
|
|
7724
|
+
const fullPath = join(dir, filePath);
|
|
6774
7725
|
const stats = await fs.promises.stat(fullPath);
|
|
6775
7726
|
|
|
6776
7727
|
// Skip files that are too large
|
|
@@ -6783,7 +7734,7 @@ async function createJJ(options) {
|
|
|
6783
7734
|
break;
|
|
6784
7735
|
}
|
|
6785
7736
|
|
|
6786
|
-
const content = await fs.promises.readFile(fullPath, '
|
|
7737
|
+
const content = await fs.promises.readFile(fullPath, 'utf8');
|
|
6787
7738
|
fileSnapshot[filePath] = content;
|
|
6788
7739
|
totalSnapshotSize += stats.size;
|
|
6789
7740
|
} catch (error) {
|
|
@@ -6797,6 +7748,257 @@ async function createJJ(options) {
|
|
|
6797
7748
|
return fileSnapshot;
|
|
6798
7749
|
};
|
|
6799
7750
|
|
|
7751
|
+
/**
|
|
7752
|
+
* Fully sync the working directory to a target file snapshot: writes
|
|
7753
|
+
* every file the target has (create/update), AND deletes every currently
|
|
7754
|
+
* tracked file the target does NOT have.
|
|
7755
|
+
*
|
|
7756
|
+
* Both edit() and new() used to only overlay the target's files on top of
|
|
7757
|
+
* whatever was already on disk, so a file that existed in the previously
|
|
7758
|
+
* checked-out change but not in the target leaked into the next
|
|
7759
|
+
* snapshot — see issue #30. undo()/redo() had the same gap restoring
|
|
7760
|
+
* file state — see issue #29. This is the one place that does it
|
|
7761
|
+
* correctly, used by all four.
|
|
7762
|
+
*
|
|
7763
|
+
* @param {Record<string, string>} targetSnapshot - path -> file content
|
|
7764
|
+
* @returns {Promise<void>}
|
|
7765
|
+
*/
|
|
7766
|
+
const syncWorkingCopyFiles = async (targetSnapshot) => {
|
|
7767
|
+
const snapshot = targetSnapshot || {};
|
|
7768
|
+
const currentFiles = await workingCopy.listFiles();
|
|
7769
|
+
|
|
7770
|
+
// Remove files that aren't part of the target.
|
|
7771
|
+
for (const filePath of currentFiles) {
|
|
7772
|
+
if (!(filePath in snapshot)) {
|
|
7773
|
+
try {
|
|
7774
|
+
const fullPath = join(dir, filePath);
|
|
7775
|
+
await fs.promises.unlink(fullPath);
|
|
7776
|
+
} catch (error) {
|
|
7777
|
+
// Already gone / inaccessible — fine, we just want it absent.
|
|
7778
|
+
}
|
|
7779
|
+
await workingCopy.untrackFile(filePath);
|
|
7780
|
+
}
|
|
7781
|
+
}
|
|
7782
|
+
|
|
7783
|
+
// Write/update every file the target has.
|
|
7784
|
+
for (const [filePath, content] of Object.entries(snapshot)) {
|
|
7785
|
+
const fullPath = join(dir, filePath);
|
|
7786
|
+
const pathParts = filePath.split('/');
|
|
7787
|
+
if (pathParts.length > 1) {
|
|
7788
|
+
const fullDirPath = join(dir, pathParts.slice(0, -1).join('/'));
|
|
7789
|
+
await mkdirp(fs, fullDirPath);
|
|
7790
|
+
}
|
|
7791
|
+
await fs.promises.writeFile(fullPath, content, 'utf8');
|
|
7792
|
+
|
|
7793
|
+
const stats = await fs.promises.stat(fullPath);
|
|
7794
|
+
await workingCopy.trackFile(filePath, {
|
|
7795
|
+
mtime: stats.mtime,
|
|
7796
|
+
size: stats.size,
|
|
7797
|
+
mode: stats.mode,
|
|
7798
|
+
});
|
|
7799
|
+
}
|
|
7800
|
+
};
|
|
7801
|
+
|
|
7802
|
+
/**
|
|
7803
|
+
* Compute the ChangeGraph/file/conflicts state that existed right after
|
|
7804
|
+
* `landingOp` finished — i.e. what undo()/redo()/operations.restore()
|
|
7805
|
+
* need to re-establish when winding the repository forward or back to
|
|
7806
|
+
* that point (see issue #29).
|
|
7807
|
+
*
|
|
7808
|
+
* Every operation that mutates EXISTING change records in place records
|
|
7809
|
+
* (additively — most operations touch none of this and that's fine,
|
|
7810
|
+
* there's nothing to reverse for them):
|
|
7811
|
+
* - changeSnapshot: { [changeId]: fullRecordBeforeThisOpRan }
|
|
7812
|
+
* - view.fileSnapshot: on-disk file state right BEFORE this op ran
|
|
7813
|
+
* - conflictsSnapshot: ConflictModel state right BEFORE this op ran
|
|
7814
|
+
*
|
|
7815
|
+
* Deliberately NOT reverted: changes an operation newly *created* (via
|
|
7816
|
+
* graph.addChange() — new(), squash()'s synthetic empty working-copy
|
|
7817
|
+
* change, ...). Undoing their creation only un-refs them (restores the
|
|
7818
|
+
* working-copy pointer/heads that made them reachable) — it does not
|
|
7819
|
+
* delete the change record — matching real jj, where an "unreachable"
|
|
7820
|
+
* change stays resolvable by its change id (hidden, not gone) until
|
|
7821
|
+
* explicitly gc'd, and this package's own history-editing tests rely on
|
|
7822
|
+
* exactly that: rebasing onto a change created by a since-undone new()
|
|
7823
|
+
* must still work.
|
|
7824
|
+
*
|
|
7825
|
+
* "The state right after landingOp" is therefore exactly what the FIRST
|
|
7826
|
+
* operation after landingOp recorded as ITS "before" state, merged across
|
|
7827
|
+
* every operation between landingOp and the current head in case
|
|
7828
|
+
* different operations touched different changeIds (a single op's own
|
|
7829
|
+
* snapshot only covers what IT touched). Concretely, per field:
|
|
7830
|
+
* - changeSnapshot: first (earliest / closest-to-landingOp) entry per
|
|
7831
|
+
* changeId wins — later entries in the range reflect that changeId's
|
|
7832
|
+
* state at a LATER point than we're restoring to.
|
|
7833
|
+
* - fileSnapshot / conflictsSnapshot: the first op in the range that
|
|
7834
|
+
* recorded one, since whole-state snapshots aren't per-changeId and
|
|
7835
|
+
* an op that didn't record one simply didn't change that state.
|
|
7836
|
+
*
|
|
7837
|
+
* This same helper backs undo() (landingOp = the parent of whatever's
|
|
7838
|
+
* being undone), redo() (landingOp = the operation a prior undo()
|
|
7839
|
+
* reverted), and operations.restore() (landingOp = the target operation
|
|
7840
|
+
* itself) — one reversal primitive, not three.
|
|
7841
|
+
*
|
|
7842
|
+
* @param {Array<any>} ops - Full chronological operation list (oplog.list())
|
|
7843
|
+
* @param {any} landingOp - The operation whose resulting state to recompute
|
|
7844
|
+
* @returns {{
|
|
7845
|
+
* changeSnapshotToApply: Record<string, any>,
|
|
7846
|
+
* fileSnapshot: Record<string, string>|undefined,
|
|
7847
|
+
* conflictsSnapshot: any,
|
|
7848
|
+
* }}
|
|
7849
|
+
*/
|
|
7850
|
+
const computeGraphReversal = (/** @type {any[]} */ ops, /** @type {any} */ landingOp) => {
|
|
7851
|
+
const landingIndex = ops.findIndex((op) => op.id === landingOp.id);
|
|
7852
|
+
|
|
7853
|
+
/** @type {Record<string, any>} */
|
|
7854
|
+
const changeSnapshotToApply = {};
|
|
7855
|
+
/** @type {Record<string, string>|undefined} */
|
|
7856
|
+
let fileSnapshot;
|
|
7857
|
+
/** @type {any} */
|
|
7858
|
+
let conflictsSnapshot;
|
|
7859
|
+
|
|
7860
|
+
for (let i = landingIndex + 1; i < ops.length; i++) {
|
|
7861
|
+
const op = ops[i];
|
|
7862
|
+
|
|
7863
|
+
if (op.changeSnapshot) {
|
|
7864
|
+
for (const [changeId, snapshot] of Object.entries(op.changeSnapshot)) {
|
|
7865
|
+
if (!(changeId in changeSnapshotToApply)) {
|
|
7866
|
+
changeSnapshotToApply[changeId] = snapshot;
|
|
7867
|
+
}
|
|
7868
|
+
}
|
|
7869
|
+
}
|
|
7870
|
+
|
|
7871
|
+
// `!= null` (not `!== undefined`) on purpose: some operations record
|
|
7872
|
+
// an explicit `null` for "I ran, and I deliberately took no snapshot"
|
|
7873
|
+
// (e.g. describe() of a non-working-copy revision) — that's exactly
|
|
7874
|
+
// as uninformative as never having set the field, so keep scanning
|
|
7875
|
+
// rather than "finding" a null snapshot.
|
|
7876
|
+
if (fileSnapshot === undefined && op.view && op.view.fileSnapshot != null) {
|
|
7877
|
+
fileSnapshot = op.view.fileSnapshot;
|
|
7878
|
+
}
|
|
7879
|
+
|
|
7880
|
+
if (conflictsSnapshot === undefined && op.conflictsSnapshot != null) {
|
|
7881
|
+
conflictsSnapshot = op.conflictsSnapshot;
|
|
7882
|
+
}
|
|
7883
|
+
}
|
|
7884
|
+
|
|
7885
|
+
return { changeSnapshotToApply, fileSnapshot, conflictsSnapshot };
|
|
7886
|
+
};
|
|
7887
|
+
|
|
7888
|
+
/**
|
|
7889
|
+
* Apply a reversal computed by computeGraphReversal() to the live graph,
|
|
7890
|
+
* working-copy files, and conflict state. Does NOT touch the working-copy
|
|
7891
|
+
* pointer or bookmarks — callers set those from the landing op's own
|
|
7892
|
+
* `view` directly (pointers, unlike file/graph/conflict content, are
|
|
7893
|
+
* already correct as recorded, see computeGraphReversal's doc comment).
|
|
7894
|
+
*
|
|
7895
|
+
* @param {ReturnType<typeof computeGraphReversal>} reversal
|
|
7896
|
+
* @returns {Promise<void>}
|
|
7897
|
+
*/
|
|
7898
|
+
const applyGraphReversal = async (reversal) => {
|
|
7899
|
+
await graph.load();
|
|
7900
|
+
for (const [changeId, snapshot] of Object.entries(reversal.changeSnapshotToApply)) {
|
|
7901
|
+
if (await graph.getChange(changeId)) {
|
|
7902
|
+
await graph.updateChange(snapshot);
|
|
7903
|
+
}
|
|
7904
|
+
}
|
|
7905
|
+
|
|
7906
|
+
if (reversal.fileSnapshot !== undefined) {
|
|
7907
|
+
await syncWorkingCopyFiles(reversal.fileSnapshot);
|
|
7908
|
+
}
|
|
7909
|
+
|
|
7910
|
+
await conflicts.load();
|
|
7911
|
+
if (reversal.conflictsSnapshot !== undefined) {
|
|
7912
|
+
conflicts.conflicts = new Map(Object.entries(reversal.conflictsSnapshot.conflicts || {}));
|
|
7913
|
+
conflicts.fileConflicts = new Map(
|
|
7914
|
+
Object.entries(reversal.conflictsSnapshot.fileConflicts || {})
|
|
7915
|
+
);
|
|
7916
|
+
await conflicts.save();
|
|
7917
|
+
}
|
|
7918
|
+
// Else: no operation in the reverted range touched conflicts — leave
|
|
7919
|
+
// current conflict state alone. (Previously undo() unconditionally
|
|
7920
|
+
// cleared ALL conflicts whenever the op being undone hadn't recorded
|
|
7921
|
+
// its own conflictsSnapshot, which is nearly every non-merge() op —
|
|
7922
|
+
// undoing an unrelated describe() would silently wipe conflicts from
|
|
7923
|
+
// an earlier, unrelated merge(). Fixed as part of issue #29.)
|
|
7924
|
+
};
|
|
7925
|
+
|
|
7926
|
+
/**
|
|
7927
|
+
* Re-parent every child of `changeId` onto `newParents` instead — used
|
|
7928
|
+
* when a change is being elided from the graph (abandon(), issue #30;
|
|
7929
|
+
* converge(), issue #32) so its children don't end up pointing at
|
|
7930
|
+
* something no longer meant to be there. Mutates `changeSnapshot` in
|
|
7931
|
+
* place, adding each touched child's PRE-reparent record so the caller's
|
|
7932
|
+
* own oplog entry lets undo()/redo()/operations.restore() reverse this
|
|
7933
|
+
* too (the same mechanism the caller already uses for the elided
|
|
7934
|
+
* change's own snapshot).
|
|
7935
|
+
*
|
|
7936
|
+
* @param {string} changeId - The change being elided
|
|
7937
|
+
* @param {string[]} newParents - What its children should point at instead
|
|
7938
|
+
* @param {Record<string, any>} changeSnapshot - Accumulator to add each touched child's pre-state to
|
|
7939
|
+
* @returns {Promise<void>}
|
|
7940
|
+
*/
|
|
7941
|
+
const reparentChildrenOnto = async (changeId, newParents, changeSnapshot) => {
|
|
7942
|
+
const childIds = graph.getChildren(changeId);
|
|
7943
|
+
for (const childId of childIds) {
|
|
7944
|
+
const child = await graph.getChange(childId);
|
|
7945
|
+
if (!child) continue;
|
|
7946
|
+
changeSnapshot[childId] = structuredClone(child);
|
|
7947
|
+
const reparented = child.parents.flatMap((/** @type {string} */ p) =>
|
|
7948
|
+
p === changeId ? newParents : [p]
|
|
7949
|
+
);
|
|
7950
|
+
// De-dupe (e.g. the child could already have one of the new parents
|
|
7951
|
+
// as another of its own parents) while preserving first-seen order.
|
|
7952
|
+
child.parents = [...new Set(reparented)];
|
|
7953
|
+
await graph.updateChange(child);
|
|
7954
|
+
}
|
|
7955
|
+
};
|
|
7956
|
+
|
|
7957
|
+
/**
|
|
7958
|
+
* Determine which operation a fresh undo() call should revert, matching
|
|
7959
|
+
* real jj's progressive-undo semantics (`jj-undo(1)`: "If used once after
|
|
7960
|
+
* a normal operation, this will undo that last operation by restoring its
|
|
7961
|
+
* parent. If jj undo is used repeatedly, it will restore increasingly
|
|
7962
|
+
* older operations, going further back into the past.").
|
|
7963
|
+
*
|
|
7964
|
+
* The raw op-log parent chain can't answer this directly once undo/redo
|
|
7965
|
+
* bookkeeping operations are mixed into the log: an 'undo' operation's
|
|
7966
|
+
* own parent is the operation it just undid, so naively targeting
|
|
7967
|
+
* `ops[ops.length - 1]` on a SECOND undo() would undo the undo (toggle
|
|
7968
|
+
* back to the future) instead of stepping one further step into the past
|
|
7969
|
+
* — this was issue #29's "undo() twice only undoes the undo" bug.
|
|
7970
|
+
*
|
|
7971
|
+
* @param {Array<any>} ops - Full chronological operation list
|
|
7972
|
+
* @returns {any} The operation object undo() should revert
|
|
7973
|
+
*/
|
|
7974
|
+
const resolveUndoTarget = (ops) => {
|
|
7975
|
+
const head = ops[ops.length - 1];
|
|
7976
|
+
const findOp = (/** @type {any} */ id) => ops.find((op) => op.id === id);
|
|
7977
|
+
|
|
7978
|
+
if (head.eventType === 'undo') {
|
|
7979
|
+
// We're stepping further into the past: what should be undone now is
|
|
7980
|
+
// the operation right BEFORE the one `head` already undid — not
|
|
7981
|
+
// head's own parent, which IS the operation head undid (targeting it
|
|
7982
|
+
// again would just toggle back to the present).
|
|
7983
|
+
const previouslyUndone = head.targetOpId && findOp(head.targetOpId);
|
|
7984
|
+
const stepBack = previouslyUndone && findOp(previouslyUndone.parents[0]);
|
|
7985
|
+
return stepBack || previouslyUndone || head;
|
|
7986
|
+
}
|
|
7987
|
+
|
|
7988
|
+
if (head.eventType === 'redo') {
|
|
7989
|
+
// A redo re-established some earlier operation as current; a fresh
|
|
7990
|
+
// undo right after it should behave exactly as if that operation
|
|
7991
|
+
// were freshly current and had never been touched by undo/redo at
|
|
7992
|
+
// all — i.e. undo it again (real jj: redo "moves in the direction of
|
|
7993
|
+
// the future"; undoing right after simply resumes stepping back).
|
|
7994
|
+
const redoneUndo = head.redoOf && findOp(head.redoOf);
|
|
7995
|
+
const target = redoneUndo && redoneUndo.targetOpId && findOp(redoneUndo.targetOpId);
|
|
7996
|
+
return target || head;
|
|
7997
|
+
}
|
|
7998
|
+
|
|
7999
|
+
return head;
|
|
8000
|
+
};
|
|
8001
|
+
|
|
6800
8002
|
/**
|
|
6801
8003
|
* Automatic working-copy snapshot (v1.6).
|
|
6802
8004
|
*
|
|
@@ -6981,6 +8183,25 @@ async function createJJ(options) {
|
|
|
6981
8183
|
}
|
|
6982
8184
|
},
|
|
6983
8185
|
|
|
8186
|
+
// Used by undo()/redo()/operations.restore() to reverse an
|
|
8187
|
+
// addChange() when winding the graph back — see issue #29. No Git
|
|
8188
|
+
// sync hook: a change created after the operation being reverted was
|
|
8189
|
+
// never pushed anywhere to unwind, and the Git backend has no
|
|
8190
|
+
// "forget this commit" operation (nor should it — Git objects are
|
|
8191
|
+
// content-addressed and harmless to leave orphaned).
|
|
8192
|
+
deleteChange: (/** @type {any} */ changeId) => baseGraph.deleteChange(changeId),
|
|
8193
|
+
|
|
8194
|
+
// See issue #32 / converge(): a divergent copy is another commit
|
|
8195
|
+
// claiming an existing changeId; no Git sync hook, for the same
|
|
8196
|
+
// reason addChange()'s primary copy already goes through
|
|
8197
|
+
// onAddChange() when it's first created — the divergent copy needs
|
|
8198
|
+
// no additional sync of its own.
|
|
8199
|
+
addDivergentCopy: (/** @type {any} */ change) => baseGraph.addDivergentCopy(change),
|
|
8200
|
+
getDivergentSiblings: (/** @type {any} */ changeId) =>
|
|
8201
|
+
baseGraph.getDivergentSiblings(changeId),
|
|
8202
|
+
deleteDivergentCopy: (/** @type {any} */ changeId, /** @type {any} */ commitId) =>
|
|
8203
|
+
baseGraph.deleteDivergentCopy(changeId, commitId),
|
|
8204
|
+
|
|
6984
8205
|
// Delegate other operations
|
|
6985
8206
|
init: () => baseGraph.init(),
|
|
6986
8207
|
};
|
|
@@ -7138,6 +8359,20 @@ async function createJJ(options) {
|
|
|
7138
8359
|
/**
|
|
7139
8360
|
* Write a file to the working copy
|
|
7140
8361
|
*
|
|
8362
|
+
* Note (issue #30): this does NOT update the working-copy change's
|
|
8363
|
+
* `commitId`/`fileSnapshot` — those stay as they were until the next
|
|
8364
|
+
* command that snapshots the working copy (describe(), new(), edit(),
|
|
8365
|
+
* status(), or any other call that goes through
|
|
8366
|
+
* autoSnapshotWorkingCopy()/snapshot()). This is intentional, not a
|
|
8367
|
+
* bug: jj has no staging area, but it also doesn't recompute a git
|
|
8368
|
+
* tree/commit hash on every single file write (that would mean
|
|
8369
|
+
* hashing on every keystroke of an external editor) — real jj only
|
|
8370
|
+
* takes a fresh working-copy snapshot when an actual `jj` command
|
|
8371
|
+
* runs. write() is the equivalent of editing a file with your OS's own
|
|
8372
|
+
* tools outside of jj entirely; describe()/new()/edit()/status()/etc.
|
|
8373
|
+
* are the equivalent of running a jj command, which is what triggers
|
|
8374
|
+
* the (auto) snapshot in both real jj and here.
|
|
8375
|
+
*
|
|
7141
8376
|
* @param {Record<string, any>} args - Arguments
|
|
7142
8377
|
* @returns {Promise<Object>} File information including path, size, and mode
|
|
7143
8378
|
*/
|
|
@@ -7161,7 +8396,7 @@ async function createJJ(options) {
|
|
|
7161
8396
|
if (pathParts.length > 1) {
|
|
7162
8397
|
const dirPath = pathParts.slice(0, -1).join('/');
|
|
7163
8398
|
const fullDirPath = `${dir}/${dirPath}`;
|
|
7164
|
-
await fs
|
|
8399
|
+
await mkdirp(fs, fullDirPath);
|
|
7165
8400
|
}
|
|
7166
8401
|
|
|
7167
8402
|
// Write the file
|
|
@@ -7216,10 +8451,10 @@ async function createJJ(options) {
|
|
|
7216
8451
|
});
|
|
7217
8452
|
}
|
|
7218
8453
|
|
|
7219
|
-
const fullPath =
|
|
8454
|
+
const fullPath = join(dir, args.path);
|
|
7220
8455
|
try {
|
|
7221
8456
|
if (encoding === 'utf-8' || encoding === 'utf8') {
|
|
7222
|
-
return await fs.promises.readFile(fullPath, '
|
|
8457
|
+
return await fs.promises.readFile(fullPath, 'utf8');
|
|
7223
8458
|
} else {
|
|
7224
8459
|
return await fs.promises.readFile(fullPath);
|
|
7225
8460
|
}
|
|
@@ -7384,7 +8619,7 @@ async function createJJ(options) {
|
|
|
7384
8619
|
|
|
7385
8620
|
// Reading from working copy
|
|
7386
8621
|
if (!args.changeId) {
|
|
7387
|
-
const fullPath =
|
|
8622
|
+
const fullPath = join(dir, args.path);
|
|
7388
8623
|
try {
|
|
7389
8624
|
const stream = fs.createReadStream(
|
|
7390
8625
|
fullPath,
|
|
@@ -7434,14 +8669,14 @@ async function createJJ(options) {
|
|
|
7434
8669
|
});
|
|
7435
8670
|
}
|
|
7436
8671
|
|
|
7437
|
-
const fullPath =
|
|
8672
|
+
const fullPath = join(dir, args.path);
|
|
7438
8673
|
|
|
7439
8674
|
// Ensure directory exists
|
|
7440
8675
|
const pathParts = args.path.split('/');
|
|
7441
8676
|
if (pathParts.length > 1) {
|
|
7442
8677
|
const dirPath = pathParts.slice(0, -1).join('/');
|
|
7443
|
-
const fullDirPath =
|
|
7444
|
-
await fs
|
|
8678
|
+
const fullDirPath = join(dir, dirPath);
|
|
8679
|
+
await mkdirp(fs, fullDirPath);
|
|
7445
8680
|
}
|
|
7446
8681
|
|
|
7447
8682
|
// Create writable stream
|
|
@@ -7554,7 +8789,7 @@ async function createJJ(options) {
|
|
|
7554
8789
|
// Ensure destination directory exists
|
|
7555
8790
|
const toDir = toPath.substring(0, toPath.lastIndexOf('/'));
|
|
7556
8791
|
try {
|
|
7557
|
-
await fs
|
|
8792
|
+
await mkdirp(fs, toDir);
|
|
7558
8793
|
} catch (error) {
|
|
7559
8794
|
throw new JJError(
|
|
7560
8795
|
'FILE_SYSTEM_ERROR',
|
|
@@ -7672,6 +8907,51 @@ async function createJJ(options) {
|
|
|
7672
8907
|
// TODO: Prevent creating cycles in the change graph
|
|
7673
8908
|
// For now, we'll allow it but it should be validated
|
|
7674
8909
|
|
|
8910
|
+
// Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
|
|
8911
|
+
// reverse the reparenting — see issue #29.
|
|
8912
|
+
const changeSnapshotBefore = structuredClone(change);
|
|
8913
|
+
|
|
8914
|
+
// Three-way merge against the OLD base before reparenting (issue #31):
|
|
8915
|
+
// real jj's rebase re-derives the rebased change's content as
|
|
8916
|
+
// (old base, old commit, new base) — if a sibling changed the same
|
|
8917
|
+
// lines differently, that's a genuine conflict, not something
|
|
8918
|
+
// reparenting-and-copying-verbatim can silently paper over. This
|
|
8919
|
+
// reuses the exact same detectConflicts() machinery merge() already
|
|
8920
|
+
// uses (see merge(), a few hundred lines down) rather than
|
|
8921
|
+
// duplicating three-way-merge logic a third time in this codebase.
|
|
8922
|
+
const oldParentId = change.parents[0];
|
|
8923
|
+
const oldParentChange = oldParentId ? await graph.getChange(oldParentId) : null;
|
|
8924
|
+
const baseFiles = new Map(
|
|
8925
|
+
Object.entries((oldParentChange && oldParentChange.fileSnapshot) || {})
|
|
8926
|
+
);
|
|
8927
|
+
const leftFiles = new Map(Object.entries(change.fileSnapshot || {}));
|
|
8928
|
+
const rightFiles = new Map(Object.entries(newParentChange.fileSnapshot || {}));
|
|
8929
|
+
|
|
8930
|
+
await conflicts.load();
|
|
8931
|
+
const conflictsSnapshot = {
|
|
8932
|
+
conflicts: Object.fromEntries(conflicts.conflicts),
|
|
8933
|
+
fileConflicts: Object.fromEntries(conflicts.fileConflicts),
|
|
8934
|
+
};
|
|
8935
|
+
|
|
8936
|
+
const detectedConflicts = oldParentChange
|
|
8937
|
+
? await conflicts.detectConflicts({
|
|
8938
|
+
baseFiles,
|
|
8939
|
+
leftFiles,
|
|
8940
|
+
rightFiles,
|
|
8941
|
+
drivers: {},
|
|
8942
|
+
// Not necessarily the checked-out working copy, so don't let
|
|
8943
|
+
// merge drivers write to disk on its behalf.
|
|
8944
|
+
workingCopyDir: /** @type {any} */ (null),
|
|
8945
|
+
baseChange: oldParentId,
|
|
8946
|
+
leftChange: changeId,
|
|
8947
|
+
rightChange: newParent,
|
|
8948
|
+
})
|
|
8949
|
+
: [];
|
|
8950
|
+
|
|
8951
|
+
for (const conflict of detectedConflicts) {
|
|
8952
|
+
await conflicts.addConflict(conflict);
|
|
8953
|
+
}
|
|
8954
|
+
|
|
7675
8955
|
// Update parent
|
|
7676
8956
|
change.parents = [newParent];
|
|
7677
8957
|
await graph.updateChange(change);
|
|
@@ -7682,6 +8962,8 @@ async function createJJ(options) {
|
|
|
7682
8962
|
user: await getUserOplogInfo(),
|
|
7683
8963
|
description: `move change ${changeId.slice(0, 8)} to ${newParent.slice(0, 8)}`,
|
|
7684
8964
|
parents: [],
|
|
8965
|
+
changeSnapshot: { [changeId]: changeSnapshotBefore },
|
|
8966
|
+
conflictsSnapshot,
|
|
7685
8967
|
view: {
|
|
7686
8968
|
bookmarks: {},
|
|
7687
8969
|
remoteBookmarks: {},
|
|
@@ -7690,7 +8972,7 @@ async function createJJ(options) {
|
|
|
7690
8972
|
},
|
|
7691
8973
|
});
|
|
7692
8974
|
|
|
7693
|
-
return change;
|
|
8975
|
+
return { ...change, conflicts: detectedConflicts };
|
|
7694
8976
|
},
|
|
7695
8977
|
|
|
7696
8978
|
/**
|
|
@@ -7862,7 +9144,7 @@ async function createJJ(options) {
|
|
|
7862
9144
|
|
|
7863
9145
|
for (const filePath of trackedFiles) {
|
|
7864
9146
|
try {
|
|
7865
|
-
const fullPath =
|
|
9147
|
+
const fullPath = join(dir, filePath);
|
|
7866
9148
|
const stats = await fs.promises.stat(fullPath);
|
|
7867
9149
|
|
|
7868
9150
|
// Skip files that are too large (silently - this is an optimization)
|
|
@@ -7875,7 +9157,7 @@ async function createJJ(options) {
|
|
|
7875
9157
|
break;
|
|
7876
9158
|
}
|
|
7877
9159
|
|
|
7878
|
-
const content = await fs.promises.readFile(fullPath, '
|
|
9160
|
+
const content = await fs.promises.readFile(fullPath, 'utf8');
|
|
7879
9161
|
fileSnapshot[filePath] = content;
|
|
7880
9162
|
totalSnapshotSize += stats.size;
|
|
7881
9163
|
} catch (error) {
|
|
@@ -8073,6 +9355,8 @@ async function createJJ(options) {
|
|
|
8073
9355
|
|
|
8074
9356
|
// Snapshot filesystem BEFORE operation (for undo) unless preserveSnapshot is true
|
|
8075
9357
|
let fileSnapshot;
|
|
9358
|
+
/** @type {Record<string, any>} */
|
|
9359
|
+
const changeSnapshot = {};
|
|
8076
9360
|
if (!args.preserveSnapshot) {
|
|
8077
9361
|
fileSnapshot = await snapshotFilesystem();
|
|
8078
9362
|
|
|
@@ -8081,6 +9365,10 @@ async function createJJ(options) {
|
|
|
8081
9365
|
const currentChangeId = workingCopy.getCurrentChangeId();
|
|
8082
9366
|
const currentChange = await graph.getChange(currentChangeId);
|
|
8083
9367
|
if (currentChange && fileSnapshot) {
|
|
9368
|
+
// Snapshot BEFORE mutating so undo()/redo() can revert this
|
|
9369
|
+
// in-place update too, not just the pointer/new-change creation
|
|
9370
|
+
// below — see issue #29.
|
|
9371
|
+
changeSnapshot[currentChangeId] = structuredClone(currentChange);
|
|
8084
9372
|
currentChange.fileSnapshot = fileSnapshot;
|
|
8085
9373
|
await graph.updateChange(currentChange);
|
|
8086
9374
|
}
|
|
@@ -8108,6 +9396,7 @@ async function createJJ(options) {
|
|
|
8108
9396
|
const user = /** @type {any} */ (userConfig.getUser());
|
|
8109
9397
|
|
|
8110
9398
|
// Initialize with parent's file snapshot
|
|
9399
|
+
/** @type {Record<string, string>} */
|
|
8111
9400
|
let initialSnapshot = {};
|
|
8112
9401
|
if (parents && parents.length > 0) {
|
|
8113
9402
|
const parent = await graph.getChange(parents[0]);
|
|
@@ -8151,10 +9440,19 @@ async function createJJ(options) {
|
|
|
8151
9440
|
// Handle insertBefore: rebase target to have new change as parent
|
|
8152
9441
|
if (args.insertBefore) {
|
|
8153
9442
|
const targetChange = await graph.getChange(args.insertBefore);
|
|
9443
|
+
changeSnapshot[args.insertBefore] = structuredClone(targetChange);
|
|
8154
9444
|
targetChange.parents = [newChangeId];
|
|
8155
9445
|
await graph.updateChange(targetChange);
|
|
8156
9446
|
}
|
|
8157
9447
|
|
|
9448
|
+
// Sync the working directory to the new change's snapshot: write
|
|
9449
|
+
// everything it has AND remove anything it doesn't (issue #30 — this
|
|
9450
|
+
// used to never touch disk at all, so `new({ parents: [...] })`
|
|
9451
|
+
// targeting anything other than the current head silently left the
|
|
9452
|
+
// OLD working copy's files in place instead of checking out the new
|
|
9453
|
+
// parent's).
|
|
9454
|
+
await syncWorkingCopyFiles(initialSnapshot);
|
|
9455
|
+
|
|
8158
9456
|
await workingCopy.setCurrentChange(newChangeId);
|
|
8159
9457
|
|
|
8160
9458
|
// Record operation with filesystem snapshot
|
|
@@ -8167,6 +9465,13 @@ async function createJJ(options) {
|
|
|
8167
9465
|
},
|
|
8168
9466
|
description: `new change ${newChangeId.slice(0, 8)}`,
|
|
8169
9467
|
parents: [],
|
|
9468
|
+
// See issue #29: lets undo()/redo()/operations.restore() reverse
|
|
9469
|
+
// the existing records this op updated in place (the old
|
|
9470
|
+
// working-copy change's refreshed fileSnapshot, and insertBefore's
|
|
9471
|
+
// target's reparented parents). The newly created change itself
|
|
9472
|
+
// (newChangeId) is intentionally NOT torn down on undo — see
|
|
9473
|
+
// computeGraphReversal's doc comment.
|
|
9474
|
+
changeSnapshot,
|
|
8170
9475
|
view: {
|
|
8171
9476
|
bookmarks: {},
|
|
8172
9477
|
remoteBookmarks: {},
|
|
@@ -8547,6 +9852,13 @@ async function createJJ(options) {
|
|
|
8547
9852
|
|
|
8548
9853
|
const previousChangeId = workingCopy.getCurrentChangeId();
|
|
8549
9854
|
|
|
9855
|
+
/** @type {Record<string, any>} */
|
|
9856
|
+
const changeSnapshot = {};
|
|
9857
|
+
|
|
9858
|
+
// Capture disk state right before we touch anything, for undo() —
|
|
9859
|
+
// see issue #29.
|
|
9860
|
+
const fileSnapshotBeforeSwitch = await snapshotFilesystem();
|
|
9861
|
+
|
|
8550
9862
|
// CRITICAL FIX: Clean working directory BEFORE snapshotting to prevent cross-branch pollution
|
|
8551
9863
|
// Remove files that don't belong to the current changeId before we snapshot it
|
|
8552
9864
|
if (previousChangeId !== args.changeId) {
|
|
@@ -8560,7 +9872,7 @@ async function createJJ(options) {
|
|
|
8560
9872
|
for (const filePath of workingDirFiles) {
|
|
8561
9873
|
if (!previousSnapshot[filePath]) {
|
|
8562
9874
|
try {
|
|
8563
|
-
const fullPath =
|
|
9875
|
+
const fullPath = join(dir, filePath);
|
|
8564
9876
|
await fs.promises.unlink(fullPath);
|
|
8565
9877
|
await workingCopy.untrackFile(filePath);
|
|
8566
9878
|
} catch (error) {
|
|
@@ -8568,13 +9880,14 @@ async function createJJ(options) {
|
|
|
8568
9880
|
}
|
|
8569
9881
|
}
|
|
8570
9882
|
}
|
|
8571
|
-
}
|
|
8572
9883
|
|
|
8573
|
-
|
|
8574
|
-
|
|
8575
|
-
|
|
8576
|
-
|
|
8577
|
-
|
|
9884
|
+
// Now snapshot the CLEAN working directory (no pollution!)
|
|
9885
|
+
const currentSnapshot = await snapshotFilesystem();
|
|
9886
|
+
if (currentSnapshot && Object.keys(currentSnapshot).length > 0) {
|
|
9887
|
+
changeSnapshot[previousChangeId] = structuredClone(previousChange);
|
|
9888
|
+
previousChange.fileSnapshot = currentSnapshot;
|
|
9889
|
+
await graph.updateChange(previousChange);
|
|
9890
|
+
}
|
|
8578
9891
|
}
|
|
8579
9892
|
}
|
|
8580
9893
|
|
|
@@ -8587,38 +9900,17 @@ async function createJJ(options) {
|
|
|
8587
9900
|
timestamp: new Date().toISOString(),
|
|
8588
9901
|
});
|
|
8589
9902
|
|
|
8590
|
-
//
|
|
8591
|
-
|
|
8592
|
-
|
|
8593
|
-
|
|
8594
|
-
|
|
8595
|
-
|
|
8596
|
-
|
|
8597
|
-
|
|
8598
|
-
|
|
8599
|
-
|
|
8600
|
-
|
|
8601
|
-
await fs.promises.mkdir(fullDirPath, { recursive: true });
|
|
8602
|
-
}
|
|
8603
|
-
|
|
8604
|
-
// Write file content
|
|
8605
|
-
await fs.promises.writeFile(fullPath, content, 'utf8');
|
|
8606
|
-
|
|
8607
|
-
// Update working copy tracking
|
|
8608
|
-
const stats = await fs.promises.stat(fullPath);
|
|
8609
|
-
await workingCopy.trackFile(filePath, {
|
|
8610
|
-
mtime: stats.mtime,
|
|
8611
|
-
size: stats.size,
|
|
8612
|
-
mode: stats.mode,
|
|
8613
|
-
});
|
|
8614
|
-
} catch (error) {
|
|
8615
|
-
throw new JJError(
|
|
8616
|
-
'FILE_RESTORE_FAILED',
|
|
8617
|
-
`Failed to restore file ${filePath}: ${error.message}`,
|
|
8618
|
-
{ filePath, originalError: error.message }
|
|
8619
|
-
);
|
|
8620
|
-
}
|
|
8621
|
-
}
|
|
9903
|
+
// Fully sync the working directory to the target change's snapshot:
|
|
9904
|
+
// write everything it has AND remove anything it doesn't (issue #30
|
|
9905
|
+
// — this used to only overlay the target's files on top of whatever
|
|
9906
|
+
// was already on disk, so a file present in the PREVIOUS change but
|
|
9907
|
+
// absent from the target leaked into the next snapshot).
|
|
9908
|
+
try {
|
|
9909
|
+
await syncWorkingCopyFiles(change.fileSnapshot || {});
|
|
9910
|
+
} catch (error) {
|
|
9911
|
+
throw new JJError('FILE_RESTORE_FAILED', `Failed to restore files: ${error.message}`, {
|
|
9912
|
+
originalError: error.message,
|
|
9913
|
+
});
|
|
8622
9914
|
}
|
|
8623
9915
|
|
|
8624
9916
|
// Set this change as the working copy
|
|
@@ -8634,11 +9926,13 @@ async function createJJ(options) {
|
|
|
8634
9926
|
},
|
|
8635
9927
|
description: `edit change ${args.changeId.slice(0, 8)}`,
|
|
8636
9928
|
parents: [],
|
|
9929
|
+
changeSnapshot,
|
|
8637
9930
|
view: {
|
|
8638
9931
|
bookmarks: {},
|
|
8639
9932
|
remoteBookmarks: {},
|
|
8640
9933
|
heads: [args.changeId],
|
|
8641
9934
|
workingCopy: args.changeId,
|
|
9935
|
+
fileSnapshot: fileSnapshotBeforeSwitch,
|
|
8642
9936
|
},
|
|
8643
9937
|
});
|
|
8644
9938
|
|
|
@@ -8669,106 +9963,107 @@ async function createJJ(options) {
|
|
|
8669
9963
|
/**
|
|
8670
9964
|
* Undo last operation
|
|
8671
9965
|
*
|
|
9966
|
+
* Reverts the most recent operation — including graph-rewriting ones
|
|
9967
|
+
* (squash/rebase/abandon/new/edit), not just working-copy/file state
|
|
9968
|
+
* (see issue #29). Matches real jj's progressive-undo semantics
|
|
9969
|
+
* (`jj-undo(1)`): called again with nothing else in between, it steps
|
|
9970
|
+
* one further operation into the past rather than undoing itself.
|
|
9971
|
+
*
|
|
8672
9972
|
* @returns {Promise<Object>} Information about the undo including the undone operation and restored state
|
|
8673
9973
|
*/
|
|
8674
9974
|
async undo() {
|
|
8675
|
-
// Get the operation we're about to undo to access its pre-state
|
|
8676
9975
|
await oplog.load();
|
|
8677
9976
|
const ops = await oplog.list();
|
|
8678
9977
|
if (ops.length === 0) {
|
|
8679
9978
|
throw new JJError('NOTHING_TO_UNDO', 'No operations to undo');
|
|
8680
9979
|
}
|
|
8681
9980
|
|
|
8682
|
-
|
|
8683
|
-
const
|
|
8684
|
-
|
|
8685
|
-
const previousView = /** @type {any} */ (await oplog.undo());
|
|
8686
|
-
|
|
8687
|
-
// Restore state from previous view
|
|
8688
|
-
await workingCopy.setCurrentChange(previousView.workingCopy);
|
|
9981
|
+
const targetOp = resolveUndoTarget(ops);
|
|
9982
|
+
const landingOpId = targetOp.parents && targetOp.parents[0];
|
|
9983
|
+
const landingOp = landingOpId ? ops.find((op) => op.id === landingOpId) : null;
|
|
8689
9984
|
|
|
8690
|
-
|
|
8691
|
-
|
|
8692
|
-
|
|
8693
|
-
|
|
9985
|
+
/** @type {ReturnType<typeof computeGraphReversal>} */
|
|
9986
|
+
let reversal;
|
|
9987
|
+
/** @type {any} */
|
|
9988
|
+
let landingView;
|
|
9989
|
+
if (landingOp) {
|
|
9990
|
+
reversal = computeGraphReversal(ops, landingOp);
|
|
9991
|
+
landingView = landingOp.view || {};
|
|
9992
|
+
} else {
|
|
9993
|
+
// Undoing the very first (root) operation: there's nothing before
|
|
9994
|
+
// it to restore to, so land on its own recorded state rather than
|
|
9995
|
+
// throwing (mirrors the previous no-parent fallback).
|
|
9996
|
+
reversal = {
|
|
9997
|
+
changeSnapshotToApply: {},
|
|
9998
|
+
fileSnapshot: undefined,
|
|
9999
|
+
conflictsSnapshot: undefined,
|
|
10000
|
+
};
|
|
10001
|
+
landingView = targetOp.view || {};
|
|
10002
|
+
}
|
|
10003
|
+
|
|
10004
|
+
// Capture EXACTLY what's about to be overwritten, from ground truth,
|
|
10005
|
+
// before applying the reversal — this is what redo() re-applies
|
|
10006
|
+
// later. It can't just recompute "the state after targetOp" the way
|
|
10007
|
+
// undo() itself computes "the state after landingOp" (by scanning
|
|
10008
|
+
// for a LATER op that also touched the same changeId/files/
|
|
10009
|
+
// conflicts): that scan only works going backward, where "nothing
|
|
10010
|
+
// touched it since" means "it's already correct". Going forward,
|
|
10011
|
+
// "nothing touched it since targetOp" means the opposite — its
|
|
10012
|
+
// effect is still live and needs to be captured now, not rederived.
|
|
8694
10013
|
await graph.load();
|
|
8695
|
-
|
|
8696
|
-
|
|
8697
|
-
|
|
8698
|
-
)
|
|
8699
|
-
|
|
8700
|
-
|
|
8701
|
-
|
|
8702
|
-
|
|
8703
|
-
|
|
8704
|
-
|
|
8705
|
-
|
|
8706
|
-
|
|
8707
|
-
|
|
8708
|
-
|
|
8709
|
-
|
|
8710
|
-
const fullPath = path.join(dir, filePath);
|
|
8711
|
-
|
|
8712
|
-
// Ensure directory exists
|
|
8713
|
-
const pathParts = filePath.split('/');
|
|
8714
|
-
if (pathParts.length > 1) {
|
|
8715
|
-
const dirPath = pathParts.slice(0, -1).join('/');
|
|
8716
|
-
const fullDirPath = path.join(dir, dirPath);
|
|
8717
|
-
await fs.promises.mkdir(fullDirPath, { recursive: true });
|
|
8718
|
-
}
|
|
8719
|
-
|
|
8720
|
-
// Write file content
|
|
8721
|
-
await fs.promises.writeFile(fullPath, content, 'utf8');
|
|
8722
|
-
} catch (error) {
|
|
8723
|
-
throw new JJError(
|
|
8724
|
-
'UNDO_FILE_RESTORE_FAILED',
|
|
8725
|
-
`Failed to restore file ${filePath} during undo: ${error.message}`,
|
|
8726
|
-
{ filePath, originalError: error.message }
|
|
8727
|
-
);
|
|
8728
|
-
}
|
|
8729
|
-
}
|
|
10014
|
+
/** @type {Record<string, any>} */
|
|
10015
|
+
const redoChangeSnapshot = {};
|
|
10016
|
+
for (const changeId of Object.keys(reversal.changeSnapshotToApply)) {
|
|
10017
|
+
const current = await graph.getChange(changeId);
|
|
10018
|
+
if (current) redoChangeSnapshot[changeId] = current;
|
|
10019
|
+
}
|
|
10020
|
+
const redoFileSnapshot =
|
|
10021
|
+
reversal.fileSnapshot !== undefined ? await snapshotFilesystem() : undefined;
|
|
10022
|
+
let redoConflictsSnapshot;
|
|
10023
|
+
if (reversal.conflictsSnapshot !== undefined) {
|
|
10024
|
+
await conflicts.load();
|
|
10025
|
+
redoConflictsSnapshot = {
|
|
10026
|
+
conflicts: Object.fromEntries(conflicts.conflicts),
|
|
10027
|
+
fileConflicts: Object.fromEntries(conflicts.fileConflicts),
|
|
10028
|
+
};
|
|
8730
10029
|
}
|
|
8731
10030
|
|
|
8732
|
-
|
|
8733
|
-
|
|
8734
|
-
await conflicts.load();
|
|
8735
|
-
if (currentOp && currentOp.conflictsSnapshot) {
|
|
8736
|
-
// Restore conflicts from the operation we're undoing (its pre-state)
|
|
8737
|
-
conflicts.conflicts = new Map(Object.entries(currentOp.conflictsSnapshot.conflicts || {}));
|
|
8738
|
-
conflicts.fileConflicts = new Map(
|
|
8739
|
-
Object.entries(currentOp.conflictsSnapshot.fileConflicts || {})
|
|
8740
|
-
);
|
|
8741
|
-
await conflicts.save();
|
|
8742
|
-
} else {
|
|
8743
|
-
// No conflicts snapshot - clear conflicts
|
|
8744
|
-
await conflicts.clear();
|
|
10031
|
+
if (landingView.workingCopy) {
|
|
10032
|
+
await workingCopy.setCurrentChange(landingView.workingCopy);
|
|
8745
10033
|
}
|
|
10034
|
+
await applyGraphReversal(reversal);
|
|
8746
10035
|
|
|
8747
|
-
// Record undo
|
|
8748
|
-
//
|
|
10036
|
+
// Record undo as its own operation (matching real jj) — tagged with
|
|
10037
|
+
// which operation it targeted so a following undo() can walk one
|
|
10038
|
+
// step further back (resolveUndoTarget) instead of toggling, and
|
|
10039
|
+
// carrying exactly what redo() needs to re-apply (captured above).
|
|
8749
10040
|
await oplog.recordOperation({
|
|
8750
10041
|
timestamp: new Date().toISOString(),
|
|
8751
10042
|
user: await getUserOplogInfo(),
|
|
8752
10043
|
description: 'undo operation',
|
|
8753
10044
|
eventType: 'undo',
|
|
8754
|
-
|
|
8755
|
-
|
|
8756
|
-
|
|
10045
|
+
targetOpId: targetOp.id,
|
|
10046
|
+
undoneOpId: targetOp.id, // kept for API/type compatibility
|
|
10047
|
+
redoChangeSnapshot,
|
|
10048
|
+
redoFileSnapshot,
|
|
10049
|
+
redoConflictsSnapshot,
|
|
8757
10050
|
parents: [],
|
|
8758
|
-
view:
|
|
10051
|
+
view: landingView,
|
|
8759
10052
|
});
|
|
8760
10053
|
|
|
8761
|
-
|
|
10054
|
+
const restoredFileCount =
|
|
10055
|
+
reversal.fileSnapshot !== undefined ? Object.keys(reversal.fileSnapshot).length : 0;
|
|
10056
|
+
|
|
8762
10057
|
return {
|
|
8763
10058
|
undoneOperation: {
|
|
8764
|
-
description:
|
|
8765
|
-
timestamp:
|
|
8766
|
-
user:
|
|
10059
|
+
description: targetOp.description,
|
|
10060
|
+
timestamp: targetOp.timestamp,
|
|
10061
|
+
user: targetOp.user,
|
|
8767
10062
|
},
|
|
8768
10063
|
restoredState: {
|
|
8769
|
-
workingCopy:
|
|
8770
|
-
heads:
|
|
8771
|
-
fileCount:
|
|
10064
|
+
workingCopy: landingView.workingCopy,
|
|
10065
|
+
heads: landingView.heads,
|
|
10066
|
+
fileCount: restoredFileCount,
|
|
8772
10067
|
},
|
|
8773
10068
|
};
|
|
8774
10069
|
},
|
|
@@ -8776,9 +10071,9 @@ async function createJJ(options) {
|
|
|
8776
10071
|
/**
|
|
8777
10072
|
* Redo an operation previously undone with undo() (v1.5)
|
|
8778
10073
|
*
|
|
8779
|
-
* Mirrors `jj redo` (jj v0.33):
|
|
8780
|
-
*
|
|
8781
|
-
*
|
|
10074
|
+
* Mirrors `jj redo` (jj v0.33): re-applies the operation that the most
|
|
10075
|
+
* recent not-yet-redone undo() reverted — including its graph rewrites,
|
|
10076
|
+
* not just working-copy/file state (see issue #29).
|
|
8782
10077
|
*
|
|
8783
10078
|
* @returns {Promise<Object>} Information about the redone operation
|
|
8784
10079
|
*/
|
|
@@ -8809,42 +10104,36 @@ async function createJJ(options) {
|
|
|
8809
10104
|
});
|
|
8810
10105
|
}
|
|
8811
10106
|
|
|
8812
|
-
const
|
|
10107
|
+
const targetOpId = undoOp.targetOpId || undoOp.undoneOpId;
|
|
10108
|
+
const targetOp = targetOpId && ops.find((op) => op.id === targetOpId);
|
|
10109
|
+
const redoneView = (targetOp && targetOp.view) || {};
|
|
8813
10110
|
|
|
8814
|
-
// Restore the working-copy pointer.
|
|
8815
10111
|
if (redoneView.workingCopy) {
|
|
8816
10112
|
await workingCopy.setCurrentChange(redoneView.workingCopy);
|
|
8817
10113
|
}
|
|
8818
10114
|
|
|
8819
|
-
//
|
|
8820
|
-
|
|
8821
|
-
|
|
8822
|
-
|
|
8823
|
-
|
|
8824
|
-
|
|
8825
|
-
|
|
8826
|
-
|
|
8827
|
-
|
|
8828
|
-
|
|
8829
|
-
|
|
8830
|
-
|
|
8831
|
-
throw new JJError(
|
|
8832
|
-
'REDO_FILE_RESTORE_FAILED',
|
|
8833
|
-
`Failed to restore file ${filePath} during redo: ${error.message}`,
|
|
8834
|
-
{ filePath, originalError: error.message }
|
|
8835
|
-
);
|
|
8836
|
-
}
|
|
10115
|
+
// Re-apply exactly what this undo captured right before it reverted
|
|
10116
|
+
// things — NOT computeGraphReversal(ops, targetOp): that scans for a
|
|
10117
|
+
// LATER op touching the same changeId/files/conflicts, which is the
|
|
10118
|
+
// right question for undo() ("has anything since made my target
|
|
10119
|
+
// stale?") but the wrong one here — after an undo, NOTHING later
|
|
10120
|
+
// re-established targetOp's own effect, so there's nothing to find
|
|
10121
|
+
// by scanning forward. undo() captured it directly for exactly this
|
|
10122
|
+
// reason (see its own comment).
|
|
10123
|
+
await graph.load();
|
|
10124
|
+
for (const [changeId, snapshot] of Object.entries(undoOp.redoChangeSnapshot || {})) {
|
|
10125
|
+
if (await graph.getChange(changeId)) {
|
|
10126
|
+
await graph.updateChange(snapshot);
|
|
8837
10127
|
}
|
|
8838
10128
|
}
|
|
8839
|
-
|
|
8840
|
-
|
|
10129
|
+
if (undoOp.redoFileSnapshot !== undefined) {
|
|
10130
|
+
await syncWorkingCopyFiles(undoOp.redoFileSnapshot);
|
|
10131
|
+
}
|
|
8841
10132
|
await conflicts.load();
|
|
8842
|
-
if (undoOp.
|
|
8843
|
-
conflicts.conflicts = new Map(
|
|
8844
|
-
Object.entries(undoOp.undoneConflictsSnapshot.conflicts || {})
|
|
8845
|
-
);
|
|
10133
|
+
if (undoOp.redoConflictsSnapshot !== undefined) {
|
|
10134
|
+
conflicts.conflicts = new Map(Object.entries(undoOp.redoConflictsSnapshot.conflicts || {}));
|
|
8846
10135
|
conflicts.fileConflicts = new Map(
|
|
8847
|
-
Object.entries(undoOp.
|
|
10136
|
+
Object.entries(undoOp.redoConflictsSnapshot.fileConflicts || {})
|
|
8848
10137
|
);
|
|
8849
10138
|
await conflicts.save();
|
|
8850
10139
|
}
|
|
@@ -8859,15 +10148,18 @@ async function createJJ(options) {
|
|
|
8859
10148
|
view: redoneView,
|
|
8860
10149
|
});
|
|
8861
10150
|
|
|
10151
|
+
const restoredFileCount =
|
|
10152
|
+
undoOp.redoFileSnapshot !== undefined ? Object.keys(undoOp.redoFileSnapshot).length : 0;
|
|
10153
|
+
|
|
8862
10154
|
return {
|
|
8863
10155
|
redoneOperation: {
|
|
8864
10156
|
description: undoOp.description,
|
|
8865
|
-
undoneOpId:
|
|
10157
|
+
undoneOpId: targetOpId,
|
|
8866
10158
|
},
|
|
8867
10159
|
restoredState: {
|
|
8868
10160
|
workingCopy: redoneView.workingCopy,
|
|
8869
10161
|
heads: redoneView.heads,
|
|
8870
|
-
fileCount:
|
|
10162
|
+
fileCount: restoredFileCount,
|
|
8871
10163
|
},
|
|
8872
10164
|
};
|
|
8873
10165
|
},
|
|
@@ -9094,6 +10386,12 @@ async function createJJ(options) {
|
|
|
9094
10386
|
/**
|
|
9095
10387
|
* Restore repository to a specific operation (matches `jj operation restore`)
|
|
9096
10388
|
*
|
|
10389
|
+
* Fully reverts everything recorded after the target operation —
|
|
10390
|
+
* including graph rewrites (squash/rebase/abandon/new/edit), working
|
|
10391
|
+
* copy files, and conflict state, not just bookmarks/the working-copy
|
|
10392
|
+
* pointer (see issue #29) — using the same reversal primitive as
|
|
10393
|
+
* undo()/redo().
|
|
10394
|
+
*
|
|
9097
10395
|
* @param {Record<string, any>} args - Arguments
|
|
9098
10396
|
* @returns {Promise<Object>} Restore result
|
|
9099
10397
|
*/
|
|
@@ -9133,6 +10431,11 @@ async function createJJ(options) {
|
|
|
9133
10431
|
await workingCopy.setCurrentChange(targetOp.view.workingCopy);
|
|
9134
10432
|
}
|
|
9135
10433
|
|
|
10434
|
+
// Revert the change graph, working-copy files, and conflict state
|
|
10435
|
+
// to exactly what they were right after targetOp ran.
|
|
10436
|
+
const reversal = computeGraphReversal(ops, targetOp);
|
|
10437
|
+
await applyGraphReversal(reversal);
|
|
10438
|
+
|
|
9136
10439
|
// Record this restoration as a new operation
|
|
9137
10440
|
await oplog.recordOperation({
|
|
9138
10441
|
timestamp: new Date().toISOString(),
|
|
@@ -9424,10 +10727,53 @@ async function createJJ(options) {
|
|
|
9424
10727
|
timestamp: new Date().toISOString(),
|
|
9425
10728
|
});
|
|
9426
10729
|
|
|
10730
|
+
// Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
|
|
10731
|
+
// reverse the squash (un-abandon source, restore dest's description
|
|
10732
|
+
// and content) — see issue #29.
|
|
10733
|
+
/** @type {Record<string, any>} */
|
|
10734
|
+
const changeSnapshot = {
|
|
10735
|
+
[source]: structuredClone(sourceChange),
|
|
10736
|
+
[destChangeId]: structuredClone(destChange),
|
|
10737
|
+
};
|
|
10738
|
+
|
|
9427
10739
|
// Mark source as abandoned
|
|
9428
10740
|
sourceChange.abandoned = true;
|
|
9429
10741
|
await graph.updateChange(sourceChange);
|
|
9430
10742
|
|
|
10743
|
+
// Merge source's file content into dest (issue #30: squash() used to
|
|
10744
|
+
// only combine descriptions, leaving dest's tree untouched). Compute
|
|
10745
|
+
// what source itself actually changed relative to ITS OWN parent
|
|
10746
|
+
// (its "diff") and fold just that onto dest, rather than overwriting
|
|
10747
|
+
// dest's whole snapshot with source's — a path source never touched
|
|
10748
|
+
// (inherited unchanged from that parent) must not clobber a
|
|
10749
|
+
// dest-specific version of the same path.
|
|
10750
|
+
const sourceParentId = sourceChange.parents && sourceChange.parents[0];
|
|
10751
|
+
const sourceParentChange = sourceParentId ? await graph.getChange(sourceParentId) : null;
|
|
10752
|
+
const sourceBaseSnapshot = (sourceParentChange && sourceParentChange.fileSnapshot) || {};
|
|
10753
|
+
const sourceSnapshot = sourceChange.fileSnapshot || {};
|
|
10754
|
+
const mergedSnapshot = { ...(destChange.fileSnapshot || {}) };
|
|
10755
|
+
const touchedPaths = new Set([
|
|
10756
|
+
...Object.keys(sourceBaseSnapshot),
|
|
10757
|
+
...Object.keys(sourceSnapshot),
|
|
10758
|
+
]);
|
|
10759
|
+
for (const filePath of touchedPaths) {
|
|
10760
|
+
const baseValue = sourceBaseSnapshot[filePath];
|
|
10761
|
+
const sourceValue = sourceSnapshot[filePath];
|
|
10762
|
+
if (sourceValue === baseValue) {
|
|
10763
|
+
// source never touched this path (inherited as-is from its own
|
|
10764
|
+
// parent) — leave whatever dest already had for it alone.
|
|
10765
|
+
continue;
|
|
10766
|
+
}
|
|
10767
|
+
if (sourceValue === undefined) {
|
|
10768
|
+
// source deleted a file it inherited from its parent.
|
|
10769
|
+
delete mergedSnapshot[filePath];
|
|
10770
|
+
} else {
|
|
10771
|
+
// source added or modified this path.
|
|
10772
|
+
mergedSnapshot[filePath] = sourceValue;
|
|
10773
|
+
}
|
|
10774
|
+
}
|
|
10775
|
+
destChange.fileSnapshot = mergedSnapshot;
|
|
10776
|
+
|
|
9431
10777
|
// Update description of dest to indicate squash (middleware will sync to Git)
|
|
9432
10778
|
destChange.description += `\n\n(squashed from ${source.slice(0, 8)})`;
|
|
9433
10779
|
await graph.updateChange(destChange);
|
|
@@ -9469,6 +10815,7 @@ async function createJJ(options) {
|
|
|
9469
10815
|
user: await getUserOplogInfo(),
|
|
9470
10816
|
description: `squash ${source.slice(0, 8)} into ${destChangeId.slice(0, 8)}`,
|
|
9471
10817
|
parents: [],
|
|
10818
|
+
changeSnapshot,
|
|
9472
10819
|
view: {
|
|
9473
10820
|
bookmarks: {},
|
|
9474
10821
|
remoteBookmarks: {},
|
|
@@ -9547,15 +10894,29 @@ async function createJJ(options) {
|
|
|
9547
10894
|
timestamp: new Date().toISOString(),
|
|
9548
10895
|
});
|
|
9549
10896
|
|
|
10897
|
+
// Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
|
|
10898
|
+
// reverse the abandon (un-abandon this change, un-reparent children)
|
|
10899
|
+
// — see issue #29.
|
|
10900
|
+
/** @type {Record<string, any>} */
|
|
10901
|
+
const changeSnapshot = { [changeId]: structuredClone(change) };
|
|
10902
|
+
|
|
9550
10903
|
change.abandoned = true;
|
|
9551
10904
|
await graph.updateChange(change);
|
|
9552
10905
|
|
|
10906
|
+
// Re-parent children onto the abandoned change's own parent(s)
|
|
10907
|
+
// (issue #30: this used to leave children pointing at the now-
|
|
10908
|
+
// abandoned commit). Matches real `jj abandon`: the abandoned
|
|
10909
|
+
// change is elided from the graph and its children attach directly
|
|
10910
|
+
// to whatever it was attached to.
|
|
10911
|
+
await reparentChildrenOnto(changeId, change.parents, changeSnapshot);
|
|
10912
|
+
|
|
9553
10913
|
// Record operation
|
|
9554
10914
|
await oplog.recordOperation({
|
|
9555
10915
|
timestamp: new Date().toISOString(),
|
|
9556
10916
|
user: { name: user.name, email: user.email, hostname: 'localhost' },
|
|
9557
10917
|
description: `abandon change ${changeId.slice(0, 8)}`,
|
|
9558
10918
|
parents: [],
|
|
10919
|
+
changeSnapshot,
|
|
9559
10920
|
view: {
|
|
9560
10921
|
bookmarks: {},
|
|
9561
10922
|
remoteBookmarks: {},
|
|
@@ -9580,6 +10941,206 @@ async function createJJ(options) {
|
|
|
9580
10941
|
return change;
|
|
9581
10942
|
},
|
|
9582
10943
|
|
|
10944
|
+
/**
|
|
10945
|
+
* Converge divergent copies of a change (matches `jj converge`, jj
|
|
10946
|
+
* v0.45.0+ — issue #32, extended for N-way per the isomorphic-jj v0.45
|
|
10947
|
+
* catch-up follow-up) — resolves ANY number of divergent copies of one
|
|
10948
|
+
* changeId in a single call. Real jj's own CLI currently only
|
|
10949
|
+
* converges one change-id per invocation (an explicit TODO in its own
|
|
10950
|
+
* source as of v0.45.1: "consider adding logic to deal with more than
|
|
10951
|
+
* one divergent change-id in one invocation"), which is what fixed
|
|
10952
|
+
* this package's own changeId-at-a-time API design — but its
|
|
10953
|
+
* underlying engine (`TruncatedEvolutionGraph`/`find_divergent_changes`)
|
|
10954
|
+
* already resolves every divergent revision of THAT one change-id
|
|
10955
|
+
* together, not just a pair. This generalizes the same per-path rule
|
|
10956
|
+
* accordingly: two-way "does the other side's value differ from base"
|
|
10957
|
+
* becomes N-way "how many DISTINCT values, across every copy, differ
|
|
10958
|
+
* from base" — 0 means nobody touched it, 1 means exactly one copy (or
|
|
10959
|
+
* several copies agreeing on the same new value) changed it and that
|
|
10960
|
+
* value wins, 2+ is a genuine conflict between that many copies.
|
|
10961
|
+
*
|
|
10962
|
+
* A change is divergent when more than one visible commit shares its
|
|
10963
|
+
* change id (see the `divergent()` revset, and
|
|
10964
|
+
* ChangeGraph.addDivergentCopy() for how a divergent copy gets added
|
|
10965
|
+
* in the first place — nothing in this library creates one on its
|
|
10966
|
+
* own yet; this resolves one once something has, e.g. a future git
|
|
10967
|
+
* fetch/concurrent-operation integration).
|
|
10968
|
+
*
|
|
10969
|
+
* Non-interactive-mode convention, matching this package's other
|
|
10970
|
+
* ambiguous-outcome APIs: like merge(), a genuine per-path conflict is
|
|
10971
|
+
* returned as data (`{ resolved: false, conflicts }`), not thrown —
|
|
10972
|
+
* you get a result back either way and decide what to do with
|
|
10973
|
+
* unresolved conflicts. What DOES throw is anything that isn't even a
|
|
10974
|
+
* well-formed convergence attempt (no divergence to resolve at all) —
|
|
10975
|
+
* the same category as merge()'s own `MERGE_ERROR` throw for "no
|
|
10976
|
+
* common ancestor found".
|
|
10977
|
+
*
|
|
10978
|
+
* @param {Record<string, any>|string} args - `{ changeId }`, or a bare changeId string
|
|
10979
|
+
* @returns {Promise<Object>} `{ changeId, resolved, conflicts, commitId? }`
|
|
10980
|
+
*/
|
|
10981
|
+
async converge(args) {
|
|
10982
|
+
const changeId = typeof args === 'string' ? args : args && (args.changeId || args.change);
|
|
10983
|
+
if (!changeId || typeof changeId !== 'string') {
|
|
10984
|
+
throw new JJError('INVALID_ARGUMENT', 'Missing changeId argument', {
|
|
10985
|
+
suggestion: 'Provide the divergent changeId to converge: { changeId: "..." }',
|
|
10986
|
+
});
|
|
10987
|
+
}
|
|
10988
|
+
|
|
10989
|
+
await graph.load();
|
|
10990
|
+
await userConfig.load();
|
|
10991
|
+
const siblings = graph.getDivergentSiblings(changeId);
|
|
10992
|
+
|
|
10993
|
+
if (siblings.length < 2) {
|
|
10994
|
+
throw new JJError(
|
|
10995
|
+
'NOT_DIVERGENT',
|
|
10996
|
+
`Change ${changeId} has no divergent copies to converge`,
|
|
10997
|
+
{
|
|
10998
|
+
changeId,
|
|
10999
|
+
suggestion:
|
|
11000
|
+
'converge() is only meaningful for a changeId the divergent() revset reports',
|
|
11001
|
+
}
|
|
11002
|
+
);
|
|
11003
|
+
}
|
|
11004
|
+
|
|
11005
|
+
// The primary copy is whichever one getChange() resolves (stored
|
|
11006
|
+
// under the plain changeId key); every other one is a divergent copy
|
|
11007
|
+
// added via addDivergentCopy(). No cap on how many — see the doc
|
|
11008
|
+
// comment above.
|
|
11009
|
+
const primary = /** @type {any} */ (await graph.getChange(changeId));
|
|
11010
|
+
const others = siblings.filter((/** @type {any} */ s) => s.commitId !== primary.commitId);
|
|
11011
|
+
|
|
11012
|
+
// Base for the N-way merge: what this change looked like before it
|
|
11013
|
+
// diverged. This package doesn't retain historical snapshots (only
|
|
11014
|
+
// current content), so — same pragmatic choice rebase() makes for
|
|
11015
|
+
// "old base" — use the shared parent's current content; divergent
|
|
11016
|
+
// copies realistically share the same parent, since they're all
|
|
11017
|
+
// rewrites of one prior position in history, not unrelated commits
|
|
11018
|
+
// that happen to collide. Applied uniformly to every copy, not
|
|
11019
|
+
// per-copy — the same simplification the original two-way version
|
|
11020
|
+
// made, just not newly introduced by going N-way.
|
|
11021
|
+
const baseParentId = primary.parents && primary.parents[0];
|
|
11022
|
+
const baseParentChange = baseParentId ? await graph.getChange(baseParentId) : null;
|
|
11023
|
+
const baseFiles = new Map(
|
|
11024
|
+
Object.entries((baseParentChange && baseParentChange.fileSnapshot) || {})
|
|
11025
|
+
);
|
|
11026
|
+
|
|
11027
|
+
// Every copy (primary + others), each as its own file map, paired
|
|
11028
|
+
// with its commitId for conflict reporting.
|
|
11029
|
+
const copies = [primary, ...others].map((/** @type {any} */ c) => ({
|
|
11030
|
+
commitId: c.commitId,
|
|
11031
|
+
files: new Map(Object.entries(c.fileSnapshot || {})),
|
|
11032
|
+
}));
|
|
11033
|
+
|
|
11034
|
+
await conflicts.load();
|
|
11035
|
+
const conflictsSnapshot = {
|
|
11036
|
+
conflicts: Object.fromEntries(conflicts.conflicts),
|
|
11037
|
+
fileConflicts: Object.fromEntries(conflicts.fileConflicts),
|
|
11038
|
+
};
|
|
11039
|
+
|
|
11040
|
+
const allPaths = new Set([
|
|
11041
|
+
...baseFiles.keys(),
|
|
11042
|
+
...copies.flatMap((c) => [...c.files.keys()]),
|
|
11043
|
+
]);
|
|
11044
|
+
|
|
11045
|
+
/** @type {Record<string, string>} */
|
|
11046
|
+
const mergedSnapshot = {};
|
|
11047
|
+
/** @type {any[]} */
|
|
11048
|
+
const detectedConflicts = [];
|
|
11049
|
+
|
|
11050
|
+
for (const filePath of allPaths) {
|
|
11051
|
+
const baseValue = baseFiles.get(filePath);
|
|
11052
|
+
// Every DISTINCT value across all copies that differs from base,
|
|
11053
|
+
// each paired with (one of) the commitId(s) that has it — mirrors
|
|
11054
|
+
// ConflictModel._detectPathConflict's two-way "no conflict" rules,
|
|
11055
|
+
// generalized: 0 distinct changed values means nobody touched it
|
|
11056
|
+
// (base wins, i.e. nothing to write), 1 means only one side (or
|
|
11057
|
+
// several copies agreeing) changed it (that value wins), 2+ is a
|
|
11058
|
+
// genuine conflict.
|
|
11059
|
+
const changed = new Map();
|
|
11060
|
+
for (const copy of copies) {
|
|
11061
|
+
const value = copy.files.get(filePath);
|
|
11062
|
+
if (value !== baseValue && !changed.has(value)) {
|
|
11063
|
+
changed.set(value, copy.commitId);
|
|
11064
|
+
}
|
|
11065
|
+
}
|
|
11066
|
+
|
|
11067
|
+
if (changed.size === 0) {
|
|
11068
|
+
if (baseValue !== undefined) mergedSnapshot[filePath] = baseValue;
|
|
11069
|
+
} else if (changed.size === 1) {
|
|
11070
|
+
const [[value]] = changed;
|
|
11071
|
+
if (value !== undefined) mergedSnapshot[filePath] = value;
|
|
11072
|
+
} else {
|
|
11073
|
+
const versions = [...changed.entries()].map(([content, commitId]) => ({
|
|
11074
|
+
commitId,
|
|
11075
|
+
content,
|
|
11076
|
+
}));
|
|
11077
|
+
detectedConflicts.push(conflicts.createNWayConflict(filePath, baseValue, versions));
|
|
11078
|
+
}
|
|
11079
|
+
}
|
|
11080
|
+
|
|
11081
|
+
if (detectedConflicts.length > 0) {
|
|
11082
|
+
// Matches real jj: non-interactive mode aborts automatic
|
|
11083
|
+
// resolution rather than guessing — but per this package's own
|
|
11084
|
+
// merge() convention, that's reported back as data, not thrown.
|
|
11085
|
+
for (const conflict of detectedConflicts) {
|
|
11086
|
+
await conflicts.addConflict(conflict);
|
|
11087
|
+
}
|
|
11088
|
+
await oplog.recordOperation({
|
|
11089
|
+
timestamp: new Date().toISOString(),
|
|
11090
|
+
user: await getUserOplogInfo(),
|
|
11091
|
+
description: `converge ${changeId.slice(0, 8)} (unresolved)`,
|
|
11092
|
+
parents: [],
|
|
11093
|
+
conflictsSnapshot,
|
|
11094
|
+
view: {
|
|
11095
|
+
bookmarks: {},
|
|
11096
|
+
remoteBookmarks: {},
|
|
11097
|
+
heads: [],
|
|
11098
|
+
workingCopy: workingCopy.getCurrentChangeId(),
|
|
11099
|
+
},
|
|
11100
|
+
});
|
|
11101
|
+
return { changeId, resolved: false, conflicts: detectedConflicts };
|
|
11102
|
+
}
|
|
11103
|
+
|
|
11104
|
+
const user = /** @type {any} */ (userConfig.getUser());
|
|
11105
|
+
const changeSnapshot = { [changeId]: structuredClone(primary) };
|
|
11106
|
+
const otherIds = others.map((/** @type {any} */ o) => o.commitId.slice(0, 8)).join(', ');
|
|
11107
|
+
|
|
11108
|
+
const convergedChange = {
|
|
11109
|
+
...primary,
|
|
11110
|
+
commitId: randomHex(20),
|
|
11111
|
+
fileSnapshot: mergedSnapshot,
|
|
11112
|
+
description: primary.description + `\n\n(converged divergent copies: ${otherIds})`,
|
|
11113
|
+
timestamp: new Date().toISOString(),
|
|
11114
|
+
committer: {
|
|
11115
|
+
name: user.name,
|
|
11116
|
+
email: user.email,
|
|
11117
|
+
timestamp: new Date().toISOString(),
|
|
11118
|
+
},
|
|
11119
|
+
divergent: false,
|
|
11120
|
+
};
|
|
11121
|
+
await graph.updateChange(convergedChange);
|
|
11122
|
+
for (const other of others) {
|
|
11123
|
+
await graph.deleteDivergentCopy(changeId, other.commitId);
|
|
11124
|
+
}
|
|
11125
|
+
|
|
11126
|
+
await oplog.recordOperation({
|
|
11127
|
+
timestamp: new Date().toISOString(),
|
|
11128
|
+
user: await getUserOplogInfo(),
|
|
11129
|
+
description: `converge ${changeId.slice(0, 8)}`,
|
|
11130
|
+
parents: [],
|
|
11131
|
+
changeSnapshot,
|
|
11132
|
+
conflictsSnapshot,
|
|
11133
|
+
view: {
|
|
11134
|
+
bookmarks: {},
|
|
11135
|
+
remoteBookmarks: {},
|
|
11136
|
+
heads: [changeId],
|
|
11137
|
+
workingCopy: workingCopy.getCurrentChangeId(),
|
|
11138
|
+
},
|
|
11139
|
+
});
|
|
11140
|
+
|
|
11141
|
+
return { changeId, resolved: true, conflicts: [], commitId: convergedChange.commitId };
|
|
11142
|
+
},
|
|
11143
|
+
|
|
9583
11144
|
/**
|
|
9584
11145
|
* Backout a change - create a new change that reverses the effect of the specified change
|
|
9585
11146
|
*
|
|
@@ -11041,7 +12602,7 @@ async function createJJ(options) {
|
|
|
11041
12602
|
.split('/')
|
|
11042
12603
|
.pop()
|
|
11043
12604
|
.replace(/\.git$/, '');
|
|
11044
|
-
const fullCloneDir =
|
|
12605
|
+
const fullCloneDir = join(dir, cloneDir);
|
|
11045
12606
|
|
|
11046
12607
|
// Clone using isomorphic-git
|
|
11047
12608
|
await git.clone({
|
|
@@ -11419,8 +12980,14 @@ async function createJJ(options) {
|
|
|
11419
12980
|
* @returns {Promise<Object>} Repository root information
|
|
11420
12981
|
*/
|
|
11421
12982
|
async root() {
|
|
11422
|
-
//
|
|
11423
|
-
|
|
12983
|
+
// `dir` is a real host filesystem path (unlike the repo-relative
|
|
12984
|
+
// git/jj paths `path` — posix-path.js — is for), so it needs the
|
|
12985
|
+
// host's own separator here, not always '/': on Windows Node's real
|
|
12986
|
+
// `fs` still resolves a forward-slash-joined path fine, but this
|
|
12987
|
+
// method hands the string back to the caller as data, and jj's own
|
|
12988
|
+
// `git root` returns a platform-native path there.
|
|
12989
|
+
const isWin32 = typeof process !== 'undefined' && process.platform === 'win32';
|
|
12990
|
+
const gitDir = isWin32 ? `${dir.replace(/[\\/]+$/, '')}\\.git` : join(dir, '.git');
|
|
11424
12991
|
|
|
11425
12992
|
try {
|
|
11426
12993
|
await fs.promises.access(gitDir);
|
|
@@ -11499,7 +13066,7 @@ async function createJJ(options) {
|
|
|
11499
13066
|
const wcFiles = await workingCopy.listFiles();
|
|
11500
13067
|
for (const file of wcFiles) {
|
|
11501
13068
|
try {
|
|
11502
|
-
const content = await fs.promises.readFile(
|
|
13069
|
+
const content = await fs.promises.readFile(join(dir, file), 'utf8');
|
|
11503
13070
|
leftFiles.set(file, content);
|
|
11504
13071
|
} catch (error) {
|
|
11505
13072
|
// File might be deleted or binary - throw error for explicit handling
|
|
@@ -11642,7 +13209,7 @@ async function createJJ(options) {
|
|
|
11642
13209
|
}
|
|
11643
13210
|
|
|
11644
13211
|
// Write resolved content to file
|
|
11645
|
-
await fs.promises.writeFile(
|
|
13212
|
+
await fs.promises.writeFile(join(dir, conflict.path), resolvedContent, 'utf8');
|
|
11646
13213
|
|
|
11647
13214
|
// Mark conflict as resolved
|
|
11648
13215
|
await conflicts.resolveConflict(args.conflictId, 'manual');
|
|
@@ -11685,7 +13252,7 @@ async function createJJ(options) {
|
|
|
11685
13252
|
const resolvedContent = _resolveWithStrategy(conflict, args.strategy);
|
|
11686
13253
|
|
|
11687
13254
|
// Write resolved content
|
|
11688
|
-
await fs.promises.writeFile(
|
|
13255
|
+
await fs.promises.writeFile(join(dir, conflict.path), resolvedContent, 'utf8');
|
|
11689
13256
|
|
|
11690
13257
|
// Mark as resolved
|
|
11691
13258
|
await conflicts.resolveConflict(conflict.conflictId, args.strategy);
|
|
@@ -12007,7 +13574,7 @@ async function createJJ(options) {
|
|
|
12007
13574
|
);
|
|
12008
13575
|
}
|
|
12009
13576
|
|
|
12010
|
-
const fullPath =
|
|
13577
|
+
const fullPath = join(dir, args.path);
|
|
12011
13578
|
|
|
12012
13579
|
// Convert string mode to number if needed
|
|
12013
13580
|
const mode = typeof args.mode === 'string' ? parseInt(args.mode, 8) : args.mode;
|
|
@@ -12118,14 +13685,14 @@ async function createJJ(options) {
|
|
|
12118
13685
|
if (change.fileSnapshot) {
|
|
12119
13686
|
for (const [filePath, content] of Object.entries(change.fileSnapshot)) {
|
|
12120
13687
|
try {
|
|
12121
|
-
const fullPath =
|
|
13688
|
+
const fullPath = join(args.path, filePath);
|
|
12122
13689
|
|
|
12123
13690
|
// Ensure directory exists
|
|
12124
13691
|
const pathParts = filePath.split('/');
|
|
12125
13692
|
if (pathParts.length > 1) {
|
|
12126
13693
|
const dirPath = pathParts.slice(0, -1).join('/');
|
|
12127
|
-
const fullDirPath =
|
|
12128
|
-
await fs
|
|
13694
|
+
const fullDirPath = join(args.path, dirPath);
|
|
13695
|
+
await mkdirp(fs, fullDirPath);
|
|
12129
13696
|
}
|
|
12130
13697
|
|
|
12131
13698
|
// Write file content
|