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