isomorphic-jj 1.8.0 → 1.9.2
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 +371 -0
- package/LICENSE +1 -1
- package/README.md +117 -30
- package/dist/index.cjs +2225 -419
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +2225 -418
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -7
- package/src/api/repository.js +862 -161
- 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 +127 -7
- package/src/core/storage-manager.js +9 -8
- package/src/core/tag-store.js +2 -5
- package/src/core/working-copy.js +51 -4
- package/src/core/workspace-manager.js +4 -3
- package/src/protos/local_working_copy.json.js +146 -0
- package/src/protos/schema.js +49 -0
- package/src/protos/simple_op_store.json.js +374 -0
- package/src/types.d.ts +33 -0
- package/src/utils/atomic-write.js +3 -1
- package/src/utils/bytes.js +67 -0
- package/src/utils/id-generation.js +18 -4
- package/src/utils/mkdirp.js +41 -0
- package/src/utils/posix-path.js +117 -0
package/dist/index.cjs
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var path = require('path');
|
|
4
3
|
var git = require('isomorphic-git');
|
|
5
|
-
var protobuf = require('protobufjs');
|
|
6
|
-
var url = require('url');
|
|
7
|
-
var crypto$1 = require('crypto');
|
|
4
|
+
var protobuf = require('protobufjs/light.js');
|
|
8
5
|
|
|
9
|
-
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
|
|
10
6
|
/**
|
|
11
7
|
* JJError - Custom error class for isomorphic-jj
|
|
12
8
|
*
|
|
@@ -93,6 +89,48 @@ class KeyedMutex {
|
|
|
93
89
|
}
|
|
94
90
|
}
|
|
95
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Recursive directory creation that works across filesystem backends.
|
|
94
|
+
*
|
|
95
|
+
* Why not `fs.promises.mkdir(path, { recursive: true })`: that's a Node
|
|
96
|
+
* `fs` feature. `@isomorphic-git/lightning-fs` (the filesystem this library
|
|
97
|
+
* documents for browser use — see issue #28) implements `mkdir()` but
|
|
98
|
+
* silently ignores the `recursive` option and throws `ENOENT` if any
|
|
99
|
+
* intermediate directory is missing, so code that assumed Node's recursive
|
|
100
|
+
* mkdir semantics would work identically against LightningFS actually
|
|
101
|
+
* broke the very first real repo init in a browser. This walks the path
|
|
102
|
+
* one segment at a time and creates each directory that doesn't exist yet,
|
|
103
|
+
* which is correct against both Node's `fs` and LightningFS (and is a
|
|
104
|
+
* harmless no-op-per-segment against Node's `fs` too).
|
|
105
|
+
*
|
|
106
|
+
* @param {any} fs - Filesystem implementation (isomorphic-git compatible;
|
|
107
|
+
* needs `fs.promises.mkdir(path)`)
|
|
108
|
+
* @param {string} dirPath - Absolute, POSIX-style directory path to create
|
|
109
|
+
* @returns {Promise<void>}
|
|
110
|
+
*/
|
|
111
|
+
async function mkdirp(fs, dirPath) {
|
|
112
|
+
const isAbsolute = dirPath.charCodeAt(0) === 47; // '/'
|
|
113
|
+
const prefix = isAbsolute ? '/' : '';
|
|
114
|
+
const segments = dirPath.split('/').filter(Boolean);
|
|
115
|
+
|
|
116
|
+
// Compare against `current === ''` (not a truthiness check) below: the
|
|
117
|
+
// first accumulated segment of an absolute path is itself just `/x`,
|
|
118
|
+
// which parses fine, but a naive `current ? ... : ...` truthiness check
|
|
119
|
+
// would treat the *string* `''` the same as "unset" forever and never
|
|
120
|
+
// prepend the leading `/`.
|
|
121
|
+
let current = '';
|
|
122
|
+
for (const segment of segments) {
|
|
123
|
+
current = current === '' ? prefix + segment : `${current}/${segment}`;
|
|
124
|
+
try {
|
|
125
|
+
await fs.promises.mkdir(current);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
// Already exists — fine, that's exactly what "recursive" means.
|
|
128
|
+
if (err && (err.code === 'EEXIST' || err.code === 'EISDIR')) continue;
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
96
134
|
/**
|
|
97
135
|
* Storage Manager for isomorphic-jj
|
|
98
136
|
*
|
|
@@ -133,17 +171,17 @@ class Storage {
|
|
|
133
171
|
async init() {
|
|
134
172
|
try {
|
|
135
173
|
// Create main directories
|
|
136
|
-
await this.fs
|
|
137
|
-
await this.fs
|
|
138
|
-
await this.fs
|
|
174
|
+
await mkdirp(this.fs, this.jjDir);
|
|
175
|
+
await mkdirp(this.fs, this.repoDir);
|
|
176
|
+
await mkdirp(this.fs, this.workingCopyDir);
|
|
139
177
|
|
|
140
178
|
// Create repo subdirectories
|
|
141
|
-
await this.fs
|
|
142
|
-
await this.fs
|
|
143
|
-
await this.fs
|
|
179
|
+
await mkdirp(this.fs, `${this.repoDir}/store`);
|
|
180
|
+
await mkdirp(this.fs, `${this.repoDir}/op_log`);
|
|
181
|
+
await mkdirp(this.fs, `${this.repoDir}/conflicts`);
|
|
144
182
|
|
|
145
183
|
// Create default workspace directory
|
|
146
|
-
await this.fs
|
|
184
|
+
await mkdirp(this.fs, `${this.workingCopyDir}/default`);
|
|
147
185
|
} catch (error) {
|
|
148
186
|
throw new JJError(
|
|
149
187
|
'STORAGE_INIT_FAILED',
|
|
@@ -220,7 +258,7 @@ class Storage {
|
|
|
220
258
|
try {
|
|
221
259
|
// Ensure parent directory exists
|
|
222
260
|
const dirPath = fullPath.substring(0, fullPath.lastIndexOf('/'));
|
|
223
|
-
await this.fs
|
|
261
|
+
await mkdirp(this.fs, dirPath);
|
|
224
262
|
|
|
225
263
|
// Write to temp file
|
|
226
264
|
await this.fs.promises.writeFile(tmpPath, jsonData, 'utf8');
|
|
@@ -476,6 +514,75 @@ function validateBookmarkName(name) {
|
|
|
476
514
|
}
|
|
477
515
|
}
|
|
478
516
|
|
|
517
|
+
/**
|
|
518
|
+
* ID Generation utilities for isomorphic-jj
|
|
519
|
+
*
|
|
520
|
+
* Generates stable change IDs and operation IDs using cryptographically secure random values.
|
|
521
|
+
*/
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Generate `byteLength` cryptographically random bytes as a lowercase hex string.
|
|
525
|
+
*
|
|
526
|
+
* Uses the standard Web Crypto API (`crypto.getRandomValues`), available as
|
|
527
|
+
* a global in both Node.js (18.14+) and every browser — unlike Node's
|
|
528
|
+
* `crypto.randomBytes`, which requires `import 'crypto'` and does not exist
|
|
529
|
+
* in a browser bundle (see issue #28).
|
|
530
|
+
*
|
|
531
|
+
* @param {number} byteLength - Number of random bytes to generate
|
|
532
|
+
* @returns {string} `byteLength * 2`-character lowercase hex string
|
|
533
|
+
*/
|
|
534
|
+
function randomHex(byteLength) {
|
|
535
|
+
const bytes = crypto.getRandomValues(new Uint8Array(byteLength));
|
|
536
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Generate a stable change ID
|
|
541
|
+
*
|
|
542
|
+
* Uses 128-bit cryptographically random value encoded as 32-character hex string.
|
|
543
|
+
* Collision probability is negligible (2^128 space).
|
|
544
|
+
*
|
|
545
|
+
* @returns {string} 32-character lowercase hex string
|
|
546
|
+
*/
|
|
547
|
+
function generateChangeId() {
|
|
548
|
+
return randomHex(16);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Generate an operation ID from operation content
|
|
553
|
+
*
|
|
554
|
+
* Uses SHA-256 hash of operation content for integrity verification.
|
|
555
|
+
*
|
|
556
|
+
* @param {Object.<string, any>} operation - Operation data
|
|
557
|
+
* @returns {Promise<string>} 64-character lowercase hex string (SHA-256)
|
|
558
|
+
*/
|
|
559
|
+
async function generateOperationId(operation) {
|
|
560
|
+
const content = JSON.stringify({
|
|
561
|
+
timestamp: operation.timestamp,
|
|
562
|
+
user: operation.user,
|
|
563
|
+
description: operation.description,
|
|
564
|
+
parents: operation.parents,
|
|
565
|
+
view: operation.view,
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
const encoder = new TextEncoder();
|
|
569
|
+
const data = encoder.encode(content);
|
|
570
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
571
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
572
|
+
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Generate a generic ID with optional prefix
|
|
577
|
+
*
|
|
578
|
+
* @param {string} prefix - Optional prefix (e.g., 'conflict', 'bookmark')
|
|
579
|
+
* @returns {string} ID string
|
|
580
|
+
*/
|
|
581
|
+
function generateId(prefix = '') {
|
|
582
|
+
const id = randomHex(16);
|
|
583
|
+
return prefix ? `${prefix}-${id}` : id;
|
|
584
|
+
}
|
|
585
|
+
|
|
479
586
|
/**
|
|
480
587
|
* ChangeGraph - Manages the change graph with stable change IDs
|
|
481
588
|
*
|
|
@@ -571,6 +678,68 @@ class ChangeGraph {
|
|
|
571
678
|
await this.save();
|
|
572
679
|
}
|
|
573
680
|
|
|
681
|
+
/**
|
|
682
|
+
* Add a DIVERGENT copy of an existing change: another commit that also
|
|
683
|
+
* claims `change.changeId` (see issue #32 / the `divergent()` revset —
|
|
684
|
+
* "multiple visible commits sharing one change id"). `addChange()`
|
|
685
|
+
* can't represent this: `nodes` is a `Map<changeId, change>`, one entry
|
|
686
|
+
* per key, by design — every other API (getChange, getParents,
|
|
687
|
+
* getChildren, ...) keys on plain changeId and would be ambiguous if
|
|
688
|
+
* that stopped being unique.
|
|
689
|
+
*
|
|
690
|
+
* Divergent copies are therefore stored under a synthetic internal key
|
|
691
|
+
* (`${changeId}\0${commitId}`) so the `nodes` Map stays a true map, but
|
|
692
|
+
* each copy's *own* `changeId` field is unchanged — so `getAll()` (and
|
|
693
|
+
* therefore the existing `divergent()` revset filter, which counts
|
|
694
|
+
* occurrences of `c.changeId` across `getAll()`) sees both/all of them
|
|
695
|
+
* and correctly reports the change as divergent. `getChange(changeId)`
|
|
696
|
+
* deliberately keeps resolving to just the primary copy (the one
|
|
697
|
+
* actually stored under the plain `changeId` key) — for anything that
|
|
698
|
+
* needs every copy, see getDivergentSiblings().
|
|
699
|
+
*
|
|
700
|
+
* @param {Record<string, any>} change - The divergent copy (must have
|
|
701
|
+
* the same `changeId` as an existing change, and its own `commitId`)
|
|
702
|
+
*/
|
|
703
|
+
async addDivergentCopy(change) {
|
|
704
|
+
validateChangeId(change.changeId);
|
|
705
|
+
|
|
706
|
+
if (!this.nodes.has(change.changeId)) {
|
|
707
|
+
throw new JJError(
|
|
708
|
+
'CHANGE_NOT_FOUND',
|
|
709
|
+
`Cannot add a divergent copy of ${change.changeId}: no existing change with that id`,
|
|
710
|
+
{ changeId: change.changeId, suggestion: 'Use addChange() to create the first copy' }
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const key = `${change.changeId}\0${change.commitId}`;
|
|
715
|
+
if (this.nodes.has(key)) {
|
|
716
|
+
throw new JJError(
|
|
717
|
+
'CHANGE_EXISTS',
|
|
718
|
+
`Change ${change.changeId} already has a divergent copy with commit ${change.commitId}`,
|
|
719
|
+
{ changeId: change.changeId, commitId: change.commitId }
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
change.divergent = true;
|
|
724
|
+
this.nodes.set(key, change);
|
|
725
|
+
this.commitIndex.set(change.commitId, change.changeId);
|
|
726
|
+
|
|
727
|
+
await this.save();
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Get every visible copy of a (possibly divergent) change id — just the
|
|
732
|
+
* one copy in the common case, or the primary plus every divergent copy
|
|
733
|
+
* added via addDivergentCopy().
|
|
734
|
+
*
|
|
735
|
+
* @param {string} changeId - Change ID
|
|
736
|
+
* @returns {Array<any>} All copies sharing this changeId, primary first
|
|
737
|
+
*/
|
|
738
|
+
getDivergentSiblings(changeId) {
|
|
739
|
+
validateChangeId(changeId);
|
|
740
|
+
return this.getAll().filter((change) => change.changeId === changeId);
|
|
741
|
+
}
|
|
742
|
+
|
|
574
743
|
/**
|
|
575
744
|
* Get a change by ID
|
|
576
745
|
*
|
|
@@ -682,6 +851,65 @@ class ChangeGraph {
|
|
|
682
851
|
await this.save();
|
|
683
852
|
}
|
|
684
853
|
|
|
854
|
+
/**
|
|
855
|
+
* Remove one divergent copy added via addDivergentCopy() — e.g. after
|
|
856
|
+
* converge() (issue #32) resolves a divergence and only needs to drop
|
|
857
|
+
* the now-superseded copy. The *primary* copy (stored under the plain
|
|
858
|
+
* `changeId` key) is never touched by this — use deleteChange() (or
|
|
859
|
+
* updateChange() to overwrite it with a resolved result) for that.
|
|
860
|
+
*
|
|
861
|
+
* @param {string} changeId
|
|
862
|
+
* @param {string} commitId - The divergent copy's own commit id
|
|
863
|
+
* @returns {Promise<boolean>} Whether a copy was actually removed
|
|
864
|
+
*/
|
|
865
|
+
async deleteDivergentCopy(changeId, commitId) {
|
|
866
|
+
validateChangeId(changeId);
|
|
867
|
+
|
|
868
|
+
const key = `${changeId}\0${commitId}`;
|
|
869
|
+
const change = this.nodes.get(key);
|
|
870
|
+
if (!change) {
|
|
871
|
+
return false;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
this.nodes.delete(key);
|
|
875
|
+
if (this.commitIndex.get(commitId) === changeId) {
|
|
876
|
+
this.commitIndex.delete(commitId);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
await this.save();
|
|
880
|
+
return true;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Remove a change from the graph outright.
|
|
885
|
+
*
|
|
886
|
+
* Used to reverse an addChange() — e.g. undoing an operation that created
|
|
887
|
+
* a change (new(), or squash()'s synthetic empty working-copy change) —
|
|
888
|
+
* where restoring a "before" snapshot doesn't apply because there was no
|
|
889
|
+
* "before": the change simply didn't exist yet. A soft-delete (setting
|
|
890
|
+
* `abandoned: true` via updateChange()) is NOT the same thing and is used
|
|
891
|
+
* separately for abandon() itself.
|
|
892
|
+
*
|
|
893
|
+
* @param {string} changeId - Change ID to remove
|
|
894
|
+
* @returns {Promise<boolean>} Whether a change was actually removed
|
|
895
|
+
*/
|
|
896
|
+
async deleteChange(changeId) {
|
|
897
|
+
validateChangeId(changeId);
|
|
898
|
+
|
|
899
|
+
const change = this.nodes.get(changeId);
|
|
900
|
+
if (!change) {
|
|
901
|
+
return false;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
this.nodes.delete(changeId);
|
|
905
|
+
if (this.commitIndex.get(change.commitId) === changeId) {
|
|
906
|
+
this.commitIndex.delete(change.commitId);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
await this.save();
|
|
910
|
+
return true;
|
|
911
|
+
}
|
|
912
|
+
|
|
685
913
|
/**
|
|
686
914
|
* Get all ancestors of a change (recursive parent traversal)
|
|
687
915
|
*
|
|
@@ -719,10 +947,9 @@ class ChangeGraph {
|
|
|
719
947
|
* @returns {Promise<Record<string, any>>} Created change object
|
|
720
948
|
*/
|
|
721
949
|
async createChange(params = {}) {
|
|
722
|
-
const
|
|
723
|
-
const
|
|
724
|
-
const
|
|
725
|
-
const tree = params.tree || crypto.randomBytes(20).toString('hex');
|
|
950
|
+
const changeId = params.changeId || randomHex(16);
|
|
951
|
+
const commitId = params.commitId || randomHex(20);
|
|
952
|
+
const tree = params.tree || randomHex(20);
|
|
726
953
|
const timestamp = params.timestamp || new Date().toISOString();
|
|
727
954
|
|
|
728
955
|
/** @type {Record<string, any>} */
|
|
@@ -764,6 +991,44 @@ class ChangeGraph {
|
|
|
764
991
|
*/
|
|
765
992
|
|
|
766
993
|
|
|
994
|
+
/**
|
|
995
|
+
* Normalize an mtime value to a millisecond epoch number so two stat()
|
|
996
|
+
* results (or a stat() result and a stored value) can be compared by VALUE
|
|
997
|
+
* instead of by object identity (see issue #41).
|
|
998
|
+
*
|
|
999
|
+
* `fs.promises.stat()` returns a fresh `Date` instance on every call, so two
|
|
1000
|
+
* separate stats of the same unchanged file are never `===`/`!==`-equal even
|
|
1001
|
+
* when the underlying timestamp is identical. Additionally, once a tracked
|
|
1002
|
+
* mtime has been through a `JSON.stringify`/`JSON.parse` round-trip (Storage
|
|
1003
|
+
* persists working-copy state as JSON), a `Date` becomes an ISO string, not a
|
|
1004
|
+
* `Date` — so the stored side of the comparison may be a `Date`, a plain
|
|
1005
|
+
* epoch-ms number (this module now stores numbers going forward), or an ISO
|
|
1006
|
+
* string (older/reloaded-from-disk state). This normalizes all three to a
|
|
1007
|
+
* comparable number.
|
|
1008
|
+
*
|
|
1009
|
+
* Deliberately duck-types the Date case (`typeof value.getTime ===
|
|
1010
|
+
* 'function'`) instead of `value instanceof Date`: Node's built-in `fs`
|
|
1011
|
+
* module constructs `Stats.mtime` using its own realm's `Date`, which is not
|
|
1012
|
+
* always the same `Date` constructor as this module's under test runners
|
|
1013
|
+
* that sandbox each test file in its own VM context (observed under Jest
|
|
1014
|
+
* with `--experimental-vm-modules`) — there, a real `Date` instance from
|
|
1015
|
+
* `fs.promises.stat()` fails `instanceof Date` here even though it's a
|
|
1016
|
+
* genuine Date. Duck-typing survives that realm mismatch.
|
|
1017
|
+
*
|
|
1018
|
+
* @param {Date|number|string|undefined} value
|
|
1019
|
+
* @returns {number}
|
|
1020
|
+
*/
|
|
1021
|
+
function mtimeMs(value) {
|
|
1022
|
+
if (value === null || value === undefined) return NaN;
|
|
1023
|
+
if (typeof value === 'number') return value;
|
|
1024
|
+
if (typeof value === 'string') return new Date(value).getTime();
|
|
1025
|
+
// Per the type contract above, anything left is Date-like -- duck-typed
|
|
1026
|
+
// (see the comment above) rather than `instanceof Date`, so no runtime
|
|
1027
|
+
// check is needed here either; a value outside this contract is a caller
|
|
1028
|
+
// bug, not something to silently coerce.
|
|
1029
|
+
return /** @type {any} */ (value).getTime();
|
|
1030
|
+
}
|
|
1031
|
+
|
|
767
1032
|
class WorkingCopy {
|
|
768
1033
|
/**
|
|
769
1034
|
* @param {Storage} storage - Storage manager instance
|
|
@@ -935,8 +1200,10 @@ class WorkingCopy {
|
|
|
935
1200
|
try {
|
|
936
1201
|
const stats = await this.fs.promises.stat(fullPath);
|
|
937
1202
|
|
|
938
|
-
// Fast path: check mtime and size
|
|
939
|
-
|
|
1203
|
+
// Fast path: check mtime and size. Compare by VALUE (see mtimeMs
|
|
1204
|
+
// above) — comparing the raw stat()/stored values with !== is
|
|
1205
|
+
// always "different" even when nothing changed (issue #41).
|
|
1206
|
+
if (mtimeMs(stats.mtime) !== mtimeMs(tracked.mtime) || stats.size !== tracked.size) {
|
|
940
1207
|
modified.push(path);
|
|
941
1208
|
}
|
|
942
1209
|
} catch (error) {
|
|
@@ -1047,12 +1314,19 @@ class WorkingCopy {
|
|
|
1047
1314
|
}
|
|
1048
1315
|
|
|
1049
1316
|
const prev = this.state.fileStates[rel];
|
|
1050
|
-
|
|
1317
|
+
// Store mtime as a plain epoch-ms number (not the raw stat() value):
|
|
1318
|
+
// numbers round-trip through JSON exactly, so state reloaded from disk
|
|
1319
|
+
// later compares identically to state still cached in-memory. Goes
|
|
1320
|
+
// through mtimeMs() rather than assuming `.getTime()` exists directly —
|
|
1321
|
+
// some fs implementations (e.g. the in-memory mock used in tests)
|
|
1322
|
+
// return mtime as a plain epoch-ms number already, not a Date (see
|
|
1323
|
+
// mtimeMs above and issue #41).
|
|
1324
|
+
const next = { mtime: mtimeMs(stats.mtime), size: stats.size, mode: stats.mode };
|
|
1051
1325
|
|
|
1052
1326
|
if (!prev) {
|
|
1053
1327
|
this.state.fileStates[rel] = next;
|
|
1054
1328
|
added.push(rel);
|
|
1055
|
-
} else if (
|
|
1329
|
+
} else if (mtimeMs(stats.mtime) !== mtimeMs(prev.mtime) || prev.size !== stats.size) {
|
|
1056
1330
|
this.state.fileStates[rel] = next;
|
|
1057
1331
|
modified.push(rel);
|
|
1058
1332
|
}
|
|
@@ -1186,61 +1460,6 @@ class WorkingCopy {
|
|
|
1186
1460
|
}
|
|
1187
1461
|
}
|
|
1188
1462
|
|
|
1189
|
-
/**
|
|
1190
|
-
* ID Generation utilities for isomorphic-jj
|
|
1191
|
-
*
|
|
1192
|
-
* Generates stable change IDs and operation IDs using cryptographically secure random values.
|
|
1193
|
-
*/
|
|
1194
|
-
|
|
1195
|
-
/**
|
|
1196
|
-
* Generate a stable change ID
|
|
1197
|
-
*
|
|
1198
|
-
* Uses 128-bit cryptographically random value encoded as 32-character hex string.
|
|
1199
|
-
* Collision probability is negligible (2^128 space).
|
|
1200
|
-
*
|
|
1201
|
-
* @returns {string} 32-character lowercase hex string
|
|
1202
|
-
*/
|
|
1203
|
-
function generateChangeId() {
|
|
1204
|
-
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
1205
|
-
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
/**
|
|
1209
|
-
* Generate an operation ID from operation content
|
|
1210
|
-
*
|
|
1211
|
-
* Uses SHA-256 hash of operation content for integrity verification.
|
|
1212
|
-
*
|
|
1213
|
-
* @param {Object.<string, any>} operation - Operation data
|
|
1214
|
-
* @returns {Promise<string>} 64-character lowercase hex string (SHA-256)
|
|
1215
|
-
*/
|
|
1216
|
-
async function generateOperationId(operation) {
|
|
1217
|
-
const content = JSON.stringify({
|
|
1218
|
-
timestamp: operation.timestamp,
|
|
1219
|
-
user: operation.user,
|
|
1220
|
-
description: operation.description,
|
|
1221
|
-
parents: operation.parents,
|
|
1222
|
-
view: operation.view,
|
|
1223
|
-
});
|
|
1224
|
-
|
|
1225
|
-
const encoder = new TextEncoder();
|
|
1226
|
-
const data = encoder.encode(content);
|
|
1227
|
-
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
1228
|
-
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
1229
|
-
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
/**
|
|
1233
|
-
* Generate a generic ID with optional prefix
|
|
1234
|
-
*
|
|
1235
|
-
* @param {string} prefix - Optional prefix (e.g., 'conflict', 'bookmark')
|
|
1236
|
-
* @returns {string} ID string
|
|
1237
|
-
*/
|
|
1238
|
-
function generateId(prefix = '') {
|
|
1239
|
-
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
1240
|
-
const id = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
1241
|
-
return prefix ? `${prefix}-${id}` : id;
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
1463
|
/**
|
|
1245
1464
|
* OperationLog - Manages the append-only operation log
|
|
1246
1465
|
*
|
|
@@ -1857,11 +2076,7 @@ class TagStore {
|
|
|
1857
2076
|
async save(tags) {
|
|
1858
2077
|
// Ensure the store directory exists
|
|
1859
2078
|
const storeDir = `${this.jjDir}/store`;
|
|
1860
|
-
|
|
1861
|
-
await this.fs.promises.mkdir(storeDir, { recursive: true });
|
|
1862
|
-
} catch (err) {
|
|
1863
|
-
// Directory might already exist, ignore
|
|
1864
|
-
}
|
|
2079
|
+
await mkdirp(this.fs, storeDir);
|
|
1865
2080
|
await this.fs.promises.writeFile(
|
|
1866
2081
|
this.tagsFile,
|
|
1867
2082
|
JSON.stringify({ tags, tracked: this.tracking || {} }, null, 2)
|
|
@@ -2500,9 +2715,12 @@ class RevsetEngine {
|
|
|
2500
2715
|
const unquote = (/** @type {string} */ s) => s.trim().replace(/^['"]|['"]$/g, '');
|
|
2501
2716
|
|
|
2502
2717
|
switch (name) {
|
|
2503
|
-
case 'all':
|
|
2718
|
+
case 'all': {
|
|
2504
2719
|
await this.graph.load();
|
|
2505
|
-
|
|
2720
|
+
const allChanges = this.graph.getAll();
|
|
2721
|
+
const hidden = await this._computeHiddenOrphans(allChanges);
|
|
2722
|
+
return allChanges.filter((c) => !hidden.has(c.changeId)).map((c) => c.changeId);
|
|
2723
|
+
}
|
|
2506
2724
|
|
|
2507
2725
|
// `builtin_log()` (jj v0.44) is the revset the built-in `jj log` uses
|
|
2508
2726
|
// when no user override is configured. isomorphic-jj's `log()` already
|
|
@@ -2510,9 +2728,12 @@ class RevsetEngine {
|
|
|
2510
2728
|
// `builtin_log()` is a pure alias for that same default — this lets
|
|
2511
2729
|
// callers compose it (e.g. `builtin_log() & mine()`) instead of
|
|
2512
2730
|
// duplicating `all()`.
|
|
2513
|
-
case 'builtin_log':
|
|
2731
|
+
case 'builtin_log': {
|
|
2514
2732
|
await this.graph.load();
|
|
2515
|
-
|
|
2733
|
+
const allChanges = this.graph.getAll();
|
|
2734
|
+
const hidden = await this._computeHiddenOrphans(allChanges);
|
|
2735
|
+
return allChanges.filter((c) => !hidden.has(c.changeId)).map((c) => c.changeId);
|
|
2736
|
+
}
|
|
2516
2737
|
|
|
2517
2738
|
case 'none':
|
|
2518
2739
|
return [];
|
|
@@ -2627,15 +2848,27 @@ class RevsetEngine {
|
|
|
2627
2848
|
}
|
|
2628
2849
|
|
|
2629
2850
|
case 'divergent': {
|
|
2851
|
+
// See issue #32 / converge(): a divergent change has more than one
|
|
2852
|
+
// visible commit sharing its changeId (added via
|
|
2853
|
+
// ChangeGraph.addDivergentCopy(), which is why counting `changeId`
|
|
2854
|
+
// occurrences across getAll() actually finds something now —
|
|
2855
|
+
// every other change has exactly one entry here). `c.divergent ===
|
|
2856
|
+
// true` also matches directly, since addDivergentCopy() sets that
|
|
2857
|
+
// flag on the copy it adds; the count check catches the *primary*
|
|
2858
|
+
// copy too (it's never itself flagged, since nothing about IT
|
|
2859
|
+
// changed) so both/all copies of a divergent changeId are
|
|
2860
|
+
// reported, not just the one(s) added after the first.
|
|
2630
2861
|
await this.graph.load();
|
|
2631
2862
|
const all = this.graph.getAll();
|
|
2632
2863
|
const counts = new Map();
|
|
2633
2864
|
for (const c of all) {
|
|
2634
2865
|
counts.set(c.changeId, (counts.get(c.changeId) || 0) + 1);
|
|
2635
2866
|
}
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2867
|
+
const matched = all.filter((c) => c.divergent === true || counts.get(c.changeId) > 1);
|
|
2868
|
+
// De-duplicate: multiple copies of the same divergent changeId
|
|
2869
|
+
// would otherwise each contribute their own (identical) changeId
|
|
2870
|
+
// to the result.
|
|
2871
|
+
return Array.from(new Set(matched.map((c) => c.changeId)));
|
|
2639
2872
|
}
|
|
2640
2873
|
|
|
2641
2874
|
case 'merges':
|
|
@@ -2961,6 +3194,108 @@ class RevsetEngine {
|
|
|
2961
3194
|
return ancestors;
|
|
2962
3195
|
}
|
|
2963
3196
|
|
|
3197
|
+
/**
|
|
3198
|
+
* Change IDs that `all()`/`builtin_log()` (and therefore the default
|
|
3199
|
+
* `jj.log()`) should exclude — issue #37(b).
|
|
3200
|
+
*
|
|
3201
|
+
* undo()/redo()/operations.restore() deliberately don't delete a change
|
|
3202
|
+
* record for a `new()`/`squash()` that gets reverted — they only un-ref
|
|
3203
|
+
* it (restore the working-copy pointer/heads that made it reachable),
|
|
3204
|
+
* matching real jj where an unreachable change stays resolvable by its
|
|
3205
|
+
* change id (hidden, not gone) until explicitly gc'd — see
|
|
3206
|
+
* computeGraphReversal's doc comment in repository.js. But that means
|
|
3207
|
+
* the orphaned change record lingers in the graph, and before this fix
|
|
3208
|
+
* `all()` (a flat `graph.getAll()`) surfaced it in `log()` right
|
|
3209
|
+
* alongside everything still actually reachable.
|
|
3210
|
+
*
|
|
3211
|
+
* Real jj hides a commit from the default log when it is BOTH:
|
|
3212
|
+
* - unreachable from any visible head (not itself a head, and not an
|
|
3213
|
+
* ancestor of one), AND
|
|
3214
|
+
* - "discardable": empty (no file diff versus its own parent) and
|
|
3215
|
+
* undescribed.
|
|
3216
|
+
* Merge commits (2+ parents) are conservatively never treated as
|
|
3217
|
+
* "empty" here — real jj's diff-emptiness rule for merges is more
|
|
3218
|
+
* involved and isn't needed by anything this package creates today.
|
|
3219
|
+
*
|
|
3220
|
+
* "Visible head" here deliberately does NOT mean "any change with no
|
|
3221
|
+
* children" (that's what the `visible_heads()` revset function computes,
|
|
3222
|
+
* structurally, from the raw graph) — an orphan `new()`/`squash()`
|
|
3223
|
+
* change that undo() un-referenced has no children EITHER, so it would
|
|
3224
|
+
* trivially count as its own head under that definition and never be
|
|
3225
|
+
* excludable. Real jj instead tracks an explicit, maintained set of
|
|
3226
|
+
* heads in its View (updated as operations run — abandoning/squashing a
|
|
3227
|
+
* change removes it, rebasing children onto a new parent removes the
|
|
3228
|
+
* old parent, etc.). This package doesn't thread the operation log's
|
|
3229
|
+
* `view.heads` through to the revset engine, but the working-copy
|
|
3230
|
+
* pointer plus every bookmark/tag target is the closest available
|
|
3231
|
+
* proxy for "what's actually still in play" — anything only reachable
|
|
3232
|
+
* from those, not from every dangling graph leaf.
|
|
3233
|
+
*
|
|
3234
|
+
* @param {any[]} allChanges - `graph.getAll()`
|
|
3235
|
+
* @returns {Promise<Set<string>>} change IDs to exclude
|
|
3236
|
+
*/
|
|
3237
|
+
async _computeHiddenOrphans(allChanges) {
|
|
3238
|
+
const trackedHeads = new Set();
|
|
3239
|
+
try {
|
|
3240
|
+
const wcId = this.workingCopy && this.workingCopy.getCurrentChangeId();
|
|
3241
|
+
if (wcId) trackedHeads.add(wcId);
|
|
3242
|
+
} catch {
|
|
3243
|
+
// working copy not loaded/available — fine, just no wc-based root.
|
|
3244
|
+
}
|
|
3245
|
+
if (this.bookmarkStore) {
|
|
3246
|
+
await this.bookmarkStore.load();
|
|
3247
|
+
for (const bookmark of await this.bookmarkStore.list()) {
|
|
3248
|
+
if (bookmark && bookmark.changeId) trackedHeads.add(bookmark.changeId);
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
if (this.tagStore) {
|
|
3252
|
+
for (const tag of await this.tagStore.list()) {
|
|
3253
|
+
if (tag && tag.changeId) trackedHeads.add(tag.changeId);
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
const reachable = new Set();
|
|
3258
|
+
for (const headId of trackedHeads) {
|
|
3259
|
+
for (const id of await this.getAncestors(headId)) {
|
|
3260
|
+
reachable.add(id);
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
// Sentinel "no message given" descriptions this package itself writes
|
|
3265
|
+
// (new()'s '(no description)', squash()'s post-squash working-copy
|
|
3266
|
+
// change '(no description set)') are, for visibility purposes, just
|
|
3267
|
+
// as undescribed as a genuinely empty/missing description field.
|
|
3268
|
+
const UNDESCRIBED = new Set([undefined, null, '', '(no description)', '(no description set)']);
|
|
3269
|
+
|
|
3270
|
+
const byId = new Map(allChanges.map((c) => [c.changeId, c]));
|
|
3271
|
+
const isEmpty = (/** @type {any} */ change) => {
|
|
3272
|
+
const parents = change.parents || [];
|
|
3273
|
+
if (parents.length === 0) {
|
|
3274
|
+
return !change.fileSnapshot || Object.keys(change.fileSnapshot).length === 0;
|
|
3275
|
+
}
|
|
3276
|
+
if (parents.length > 1) return false; // merges: not handled by this rule
|
|
3277
|
+
const parent = byId.get(parents[0]);
|
|
3278
|
+
const parentSnapshot = (parent && parent.fileSnapshot) || {};
|
|
3279
|
+
const ownSnapshot = change.fileSnapshot || {};
|
|
3280
|
+
const parentKeys = Object.keys(parentSnapshot);
|
|
3281
|
+
const ownKeys = Object.keys(ownSnapshot);
|
|
3282
|
+
if (parentKeys.length !== ownKeys.length) return false;
|
|
3283
|
+
return parentKeys.every((k) => parentSnapshot[k] === ownSnapshot[k]);
|
|
3284
|
+
};
|
|
3285
|
+
|
|
3286
|
+
const hidden = new Set();
|
|
3287
|
+
for (const change of allChanges) {
|
|
3288
|
+
if (
|
|
3289
|
+
!reachable.has(change.changeId) &&
|
|
3290
|
+
isEmpty(change) &&
|
|
3291
|
+
UNDESCRIBED.has(change.description)
|
|
3292
|
+
) {
|
|
3293
|
+
hidden.add(change.changeId);
|
|
3294
|
+
}
|
|
3295
|
+
}
|
|
3296
|
+
return hidden;
|
|
3297
|
+
}
|
|
3298
|
+
|
|
2964
3299
|
/**
|
|
2965
3300
|
* Resolve a revset argument to a set of change IDs (v1.5).
|
|
2966
3301
|
*
|
|
@@ -3689,43 +4024,221 @@ class RevsetEngine {
|
|
|
3689
4024
|
}
|
|
3690
4025
|
|
|
3691
4026
|
/**
|
|
3692
|
-
*
|
|
4027
|
+
* Minimal POSIX-only path utilities (join / dirname / resolve).
|
|
3693
4028
|
*
|
|
3694
|
-
*
|
|
3695
|
-
*
|
|
3696
|
-
* -
|
|
3697
|
-
* -
|
|
3698
|
-
*
|
|
4029
|
+
* Why not Node's `path` module: this library is isomorphic — it also needs
|
|
4030
|
+
* to run in browser bundles (see issue #28), where bare `import path from
|
|
4031
|
+
* 'path'` fails unless a bundler-specific polyfill is configured. Git/jj
|
|
4032
|
+
* repository paths are always POSIX-style (forward slashes), regardless of
|
|
4033
|
+
* host OS, so depending on Node's `path` module was also subtly wrong on
|
|
4034
|
+
* Windows: `path.join()` there uses `path.win32` semantics and would
|
|
4035
|
+
* produce backslash-joined paths for repository-relative paths, which is
|
|
4036
|
+
* not what git/jj object storage or working-copy paths want.
|
|
3699
4037
|
*
|
|
3700
|
-
*
|
|
4038
|
+
* These implement the same normalization rules as Node's `path.posix` for
|
|
4039
|
+
* the operations this codebase actually uses.
|
|
4040
|
+
*/
|
|
4041
|
+
|
|
4042
|
+
/**
|
|
4043
|
+
* @param {string} path
|
|
4044
|
+
* @returns {string} `path` with any run of `/` collapsed to a single `/`,
|
|
4045
|
+
* and `.`/`..` segments resolved. Preserves a leading `/` (absolute) and
|
|
4046
|
+
* a trailing `/` when present in the input.
|
|
3701
4047
|
*/
|
|
4048
|
+
function normalize(path) {
|
|
4049
|
+
if (path === '') return '.';
|
|
4050
|
+
|
|
4051
|
+
const isAbsolute = path.charCodeAt(0) === 47; // '/'
|
|
4052
|
+
const trailingSlash = path.charCodeAt(path.length - 1) === 47;
|
|
4053
|
+
|
|
4054
|
+
const segments = path.split('/');
|
|
4055
|
+
/** @type {string[]} */
|
|
4056
|
+
const out = [];
|
|
4057
|
+
for (const segment of segments) {
|
|
4058
|
+
if (segment === '' || segment === '.') continue;
|
|
4059
|
+
if (segment === '..') {
|
|
4060
|
+
if (out.length && out[out.length - 1] !== '..') {
|
|
4061
|
+
out.pop();
|
|
4062
|
+
} else if (!isAbsolute) {
|
|
4063
|
+
out.push('..');
|
|
4064
|
+
}
|
|
4065
|
+
continue;
|
|
4066
|
+
}
|
|
4067
|
+
out.push(segment);
|
|
4068
|
+
}
|
|
3702
4069
|
|
|
4070
|
+
let result = out.join('/');
|
|
4071
|
+
if (!result && !isAbsolute) result = '.';
|
|
4072
|
+
if (isAbsolute) result = '/' + result;
|
|
4073
|
+
if (trailingSlash && result.charCodeAt(result.length - 1) !== 47) result += '/';
|
|
4074
|
+
return result;
|
|
4075
|
+
}
|
|
3703
4076
|
|
|
3704
4077
|
/**
|
|
3705
|
-
*
|
|
4078
|
+
* @param {...string} parts
|
|
4079
|
+
* @returns {string} All `parts` joined with `/` and normalized.
|
|
3706
4080
|
*/
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
};
|
|
4081
|
+
function join(...parts) {
|
|
4082
|
+
if (parts.length === 0) return '.';
|
|
4083
|
+
const joined = parts.filter((p) => p !== '').join('/');
|
|
4084
|
+
return joined === '' ? '.' : normalize(joined);
|
|
4085
|
+
}
|
|
3713
4086
|
|
|
3714
4087
|
/**
|
|
3715
|
-
*
|
|
4088
|
+
* @param {string} path
|
|
4089
|
+
* @returns {string} The directory portion of `path` (Node `path.dirname` semantics).
|
|
3716
4090
|
*/
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
4091
|
+
function dirname(path) {
|
|
4092
|
+
if (path.length === 0) return '.';
|
|
4093
|
+
const isAbsolute = path.charCodeAt(0) === 47;
|
|
4094
|
+
let end = -1;
|
|
4095
|
+
let matchedSlash = true;
|
|
4096
|
+
for (let i = path.length - 1; i >= 1; i--) {
|
|
4097
|
+
if (path.charCodeAt(i) === 47) {
|
|
4098
|
+
if (!matchedSlash) {
|
|
4099
|
+
end = i;
|
|
4100
|
+
break;
|
|
4101
|
+
}
|
|
4102
|
+
} else {
|
|
4103
|
+
matchedSlash = false;
|
|
4104
|
+
}
|
|
4105
|
+
}
|
|
4106
|
+
|
|
4107
|
+
if (end === -1) return isAbsolute ? '/' : '.';
|
|
4108
|
+
if (isAbsolute && end === 0) return '/';
|
|
4109
|
+
return path.slice(0, end);
|
|
4110
|
+
}
|
|
4111
|
+
|
|
4112
|
+
/**
|
|
4113
|
+
* Resolves a sequence of path segments into an absolute path.
|
|
4114
|
+
*
|
|
4115
|
+
* Unlike Node's `path.resolve`, this never consults the real process cwd —
|
|
4116
|
+
* segments are resolved right-to-left, and if no absolute segment is found
|
|
4117
|
+
* the result is left relative to a virtual `/` root (this library always
|
|
4118
|
+
* deals in absolute repository-relative paths, so callers are expected to
|
|
4119
|
+
* supply an absolute base).
|
|
4120
|
+
*
|
|
4121
|
+
* @param {...string} parts
|
|
4122
|
+
* @returns {string}
|
|
4123
|
+
*/
|
|
4124
|
+
function resolve(...parts) {
|
|
4125
|
+
let resolved = '';
|
|
4126
|
+
let isAbsolute = false;
|
|
4127
|
+
|
|
4128
|
+
for (let i = parts.length - 1; i >= -1 && !isAbsolute; i--) {
|
|
4129
|
+
const path = i >= 0 ? parts[i] : '/';
|
|
4130
|
+
if (path.length === 0) continue;
|
|
4131
|
+
resolved = path + '/' + resolved;
|
|
4132
|
+
isAbsolute = path.charCodeAt(0) === 47;
|
|
4133
|
+
}
|
|
4134
|
+
|
|
4135
|
+
resolved = normalize(resolved);
|
|
4136
|
+
// normalize() preserves a trailing slash; path.resolve() never returns one
|
|
4137
|
+
// (except for the root itself).
|
|
4138
|
+
if (resolved.length > 1 && resolved.endsWith('/')) {
|
|
4139
|
+
resolved = resolved.slice(0, -1);
|
|
4140
|
+
}
|
|
4141
|
+
return isAbsolute ? resolved : '/' + resolved;
|
|
4142
|
+
}
|
|
4143
|
+
|
|
4144
|
+
/**
|
|
4145
|
+
* Cross-environment byte/string conversion helpers.
|
|
4146
|
+
*
|
|
4147
|
+
* Why not just Node's `Buffer`: it's a Node global that most browser
|
|
4148
|
+
* bundlers (Vite, esbuild, webpack 5+) do not polyfill by default, so code
|
|
4149
|
+
* that calls `Buffer.from(...)` compiles fine but throws `ReferenceError:
|
|
4150
|
+
* Buffer is not defined` at runtime in a browser bundle (see issue #28).
|
|
4151
|
+
*
|
|
4152
|
+
* `utf8Encode`/`utf8Decode` still prefer the real `Buffer` when it's present
|
|
4153
|
+
* (i.e. under Node) so existing behavior — including `.toString()` producing
|
|
4154
|
+
* the original text back, which a plain `Uint8Array` cannot do — is
|
|
4155
|
+
* unchanged there; `typeof Buffer` is safe to evaluate even where `Buffer`
|
|
4156
|
+
* was never declared (unlike referencing it directly), so this doesn't
|
|
4157
|
+
* reintroduce the crash outside Node. `hexToBytes`/`bytesToHex`/`isBytes`
|
|
4158
|
+
* only ever need `Uint8Array`, so they don't need the same branch.
|
|
4159
|
+
*/
|
|
4160
|
+
const hasBuffer = typeof Buffer !== 'undefined';
|
|
4161
|
+
|
|
4162
|
+
/**
|
|
4163
|
+
* @param {string} hex - Even-length hex string
|
|
4164
|
+
* @returns {Uint8Array}
|
|
4165
|
+
*/
|
|
4166
|
+
function hexToBytes(hex) {
|
|
4167
|
+
const length = hex.length / 2;
|
|
4168
|
+
const bytes = new Uint8Array(length);
|
|
4169
|
+
for (let i = 0; i < length; i++) {
|
|
4170
|
+
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
|
4171
|
+
}
|
|
4172
|
+
return bytes;
|
|
4173
|
+
}
|
|
4174
|
+
|
|
4175
|
+
/**
|
|
4176
|
+
* @param {string} str
|
|
4177
|
+
* @returns {Uint8Array} UTF-8 encoded bytes (a real `Buffer` under Node)
|
|
4178
|
+
*/
|
|
4179
|
+
function utf8Encode(str) {
|
|
4180
|
+
return hasBuffer ? Buffer.from(str, 'utf-8') : new TextEncoder().encode(str);
|
|
4181
|
+
}
|
|
4182
|
+
|
|
4183
|
+
/**
|
|
4184
|
+
* @param {Uint8Array} bytes
|
|
4185
|
+
* @returns {string} UTF-8 decoded string
|
|
4186
|
+
*/
|
|
4187
|
+
function utf8Decode(bytes) {
|
|
4188
|
+
return hasBuffer ? Buffer.from(bytes).toString('utf-8') : new TextDecoder('utf-8').decode(bytes);
|
|
4189
|
+
}
|
|
4190
|
+
|
|
4191
|
+
/**
|
|
4192
|
+
* True for anything that walks/quacks like binary content — a real Node
|
|
4193
|
+
* `Buffer` (which is itself a `Uint8Array` subclass) or a plain
|
|
4194
|
+
* `Uint8Array`/typed array, as produced by protobufjs in environments
|
|
4195
|
+
* without `Buffer` (e.g. a browser bundle).
|
|
4196
|
+
*
|
|
4197
|
+
* @param {any} value
|
|
4198
|
+
* @returns {boolean}
|
|
4199
|
+
*/
|
|
4200
|
+
function isBytes(value) {
|
|
4201
|
+
return value instanceof Uint8Array;
|
|
4202
|
+
}
|
|
4203
|
+
|
|
4204
|
+
/**
|
|
4205
|
+
* ConflictModel - First-class conflict handling
|
|
4206
|
+
*
|
|
4207
|
+
* In JJ, conflicts are not errors but structured data that can be:
|
|
4208
|
+
* - Stored in commits
|
|
4209
|
+
* - Rebased along with changes
|
|
4210
|
+
* - Resolved incrementally
|
|
4211
|
+
* - Committed and shared
|
|
4212
|
+
*
|
|
4213
|
+
* This matches JJ's philosophy: conflicts never block you.
|
|
4214
|
+
*/
|
|
4215
|
+
|
|
4216
|
+
|
|
4217
|
+
/**
|
|
4218
|
+
* Conflict types supported by JJ
|
|
4219
|
+
*/
|
|
4220
|
+
const ConflictType = {
|
|
4221
|
+
CONTENT: 'content', // File content conflicts (3-way merge)
|
|
4222
|
+
DELETE_MODIFY: 'delete-modify', // File deleted in one side, modified in other
|
|
4223
|
+
MODIFY_DELETE: 'modify-delete', // File modified in one side, deleted in other
|
|
4224
|
+
ADD_ADD: 'add-add', // Same path added in both sides
|
|
4225
|
+
};
|
|
4226
|
+
|
|
4227
|
+
/**
|
|
4228
|
+
* ConflictModel manages conflict detection, storage, and resolution
|
|
4229
|
+
*/
|
|
4230
|
+
class ConflictModel {
|
|
4231
|
+
/**
|
|
4232
|
+
* @param {any} storage
|
|
4233
|
+
* @param {any} fs
|
|
4234
|
+
* @param {any} [mergeDriverRegistry]
|
|
4235
|
+
*/
|
|
4236
|
+
constructor(storage, fs, mergeDriverRegistry = null) {
|
|
4237
|
+
this.storage = storage;
|
|
4238
|
+
this.fs = fs;
|
|
4239
|
+
/** @type {any} */
|
|
4240
|
+
this.mergeDriverRegistry = mergeDriverRegistry; // v0.5: merge drivers
|
|
4241
|
+
this.conflicts = new Map(); // conflictId -> Conflict
|
|
3729
4242
|
this.fileConflicts = new Map(); // path -> conflictId
|
|
3730
4243
|
}
|
|
3731
4244
|
|
|
@@ -3860,6 +4373,15 @@ class ConflictModel {
|
|
|
3860
4373
|
const conflict = this._detectPathConflict(path, baseContent, leftContent, rightContent);
|
|
3861
4374
|
if (conflict) {
|
|
3862
4375
|
conflicts.push(conflict);
|
|
4376
|
+
} else if (workingCopyDir) {
|
|
4377
|
+
// No conflict, but "no conflict" doesn't mean "nothing to do": of
|
|
4378
|
+
// _detectPathConflict's null-returning cases, every one *except*
|
|
4379
|
+
// "right side changed, left didn't" already matches what's on disk,
|
|
4380
|
+
// because `leftContent` **is** what's on disk (read straight from
|
|
4381
|
+
// the working copy above) -- so only that one case needs a write.
|
|
4382
|
+
if (baseContent === leftContent && baseContent !== rightContent) {
|
|
4383
|
+
await this._applyCleanResolution(workingCopyDir, path, rightContent);
|
|
4384
|
+
}
|
|
3863
4385
|
}
|
|
3864
4386
|
}
|
|
3865
4387
|
|
|
@@ -3947,6 +4469,30 @@ class ConflictModel {
|
|
|
3947
4469
|
return null;
|
|
3948
4470
|
}
|
|
3949
4471
|
|
|
4472
|
+
/**
|
|
4473
|
+
* Create a content conflict with more than two disagreeing versions —
|
|
4474
|
+
* every other conflict in this module has exactly two sides (`left`/
|
|
4475
|
+
* `right`, or `left`/`right` on an add-add), because every OTHER caller
|
|
4476
|
+
* (merge, rebase) only ever three-way-merges two changes at a time.
|
|
4477
|
+
* converge() (issue #32) can face more than two divergent copies of one
|
|
4478
|
+
* change in a single call, so `sides` here is `{ base, versions }`
|
|
4479
|
+
* instead: `versions` is every distinct value found across all copies
|
|
4480
|
+
* that differs from `base`, each paired with (one of) the commitId(s)
|
|
4481
|
+
* that has it.
|
|
4482
|
+
*
|
|
4483
|
+
* @param {string} path
|
|
4484
|
+
* @param {any} base
|
|
4485
|
+
* @param {Array<{commitId: string, content: any}>} versions
|
|
4486
|
+
*/
|
|
4487
|
+
createNWayConflict(path, base, versions) {
|
|
4488
|
+
return this._createConflict({
|
|
4489
|
+
type: ConflictType.CONTENT,
|
|
4490
|
+
path,
|
|
4491
|
+
sides: { base, versions },
|
|
4492
|
+
message: `${versions.length} divergent copies disagree on this file's content`,
|
|
4493
|
+
});
|
|
4494
|
+
}
|
|
4495
|
+
|
|
3950
4496
|
/**
|
|
3951
4497
|
* Create a conflict object
|
|
3952
4498
|
*
|
|
@@ -4190,6 +4736,36 @@ class ConflictModel {
|
|
|
4190
4736
|
return result;
|
|
4191
4737
|
}
|
|
4192
4738
|
|
|
4739
|
+
/**
|
|
4740
|
+
* Apply a clean (non-conflicting) three-way resolution to the working
|
|
4741
|
+
* copy, for the default (no merge driver) path in detectConflicts(): the
|
|
4742
|
+
* "right side wins" case, where the resolved content differs from what's
|
|
4743
|
+
* currently on disk (see the call site for why this is the only clean
|
|
4744
|
+
* case that needs one).
|
|
4745
|
+
*
|
|
4746
|
+
* @param {string} workingCopyDir - Working copy directory
|
|
4747
|
+
* @param {string} filePath - File path
|
|
4748
|
+
* @param {any} content - The winning (right) side's content, or
|
|
4749
|
+
* `undefined` to mean the file was deleted on that side
|
|
4750
|
+
*/
|
|
4751
|
+
async _applyCleanResolution(workingCopyDir, filePath, content) {
|
|
4752
|
+
const fullPath = join(workingCopyDir, filePath);
|
|
4753
|
+
|
|
4754
|
+
if (content === undefined) {
|
|
4755
|
+
await this.fs.promises.unlink(fullPath).catch(() => {});
|
|
4756
|
+
return;
|
|
4757
|
+
}
|
|
4758
|
+
|
|
4759
|
+
const dir = dirname(fullPath);
|
|
4760
|
+
await mkdirp(this.fs, dir);
|
|
4761
|
+
|
|
4762
|
+
if (isBytes(content)) {
|
|
4763
|
+
await this.fs.promises.writeFile(fullPath, content);
|
|
4764
|
+
} else {
|
|
4765
|
+
await this.fs.promises.writeFile(fullPath, content, 'utf8');
|
|
4766
|
+
}
|
|
4767
|
+
}
|
|
4768
|
+
|
|
4193
4769
|
/**
|
|
4194
4770
|
* Write merge driver result to working copy (v0.5)
|
|
4195
4771
|
*
|
|
@@ -4198,32 +4774,32 @@ class ConflictModel {
|
|
|
4198
4774
|
* @param {Record<string, any>} result - Merge result
|
|
4199
4775
|
*/
|
|
4200
4776
|
async _writeDriverResult(workingCopyDir, filePath, result) {
|
|
4201
|
-
const fullPath =
|
|
4777
|
+
const fullPath = join(workingCopyDir, filePath);
|
|
4202
4778
|
|
|
4203
4779
|
// Ensure directory exists
|
|
4204
|
-
const dir =
|
|
4205
|
-
await this.fs
|
|
4780
|
+
const dir = dirname(fullPath);
|
|
4781
|
+
await mkdirp(this.fs, dir);
|
|
4206
4782
|
|
|
4207
4783
|
// Write main file
|
|
4208
4784
|
if (result.content !== null && result.content !== undefined) {
|
|
4209
|
-
if (
|
|
4785
|
+
if (isBytes(result.content)) {
|
|
4210
4786
|
await this.fs.promises.writeFile(fullPath, result.content);
|
|
4211
4787
|
} else {
|
|
4212
|
-
await this.fs.promises.writeFile(fullPath, result.content, '
|
|
4788
|
+
await this.fs.promises.writeFile(fullPath, result.content, 'utf8');
|
|
4213
4789
|
}
|
|
4214
4790
|
}
|
|
4215
4791
|
|
|
4216
4792
|
// Write additional files if driver provided them
|
|
4217
4793
|
if (result.additionalFiles) {
|
|
4218
4794
|
for (const [additionalPath, additionalContent] of Object.entries(result.additionalFiles)) {
|
|
4219
|
-
const additionalFullPath =
|
|
4220
|
-
const additionalDir =
|
|
4221
|
-
await this.fs
|
|
4795
|
+
const additionalFullPath = join(workingCopyDir, additionalPath);
|
|
4796
|
+
const additionalDir = dirname(additionalFullPath);
|
|
4797
|
+
await mkdirp(this.fs, additionalDir);
|
|
4222
4798
|
|
|
4223
|
-
if (
|
|
4799
|
+
if (isBytes(additionalContent)) {
|
|
4224
4800
|
await this.fs.promises.writeFile(additionalFullPath, additionalContent);
|
|
4225
4801
|
} else {
|
|
4226
|
-
await this.fs.promises.writeFile(additionalFullPath, additionalContent, '
|
|
4802
|
+
await this.fs.promises.writeFile(additionalFullPath, additionalContent, 'utf8');
|
|
4227
4803
|
}
|
|
4228
4804
|
}
|
|
4229
4805
|
}
|
|
@@ -4269,11 +4845,11 @@ function matchesPattern(path, pattern) {
|
|
|
4269
4845
|
/**
|
|
4270
4846
|
* Check if a file is likely binary based on content
|
|
4271
4847
|
*
|
|
4272
|
-
* @param {string|
|
|
4848
|
+
* @param {string|Uint8Array} content - File content
|
|
4273
4849
|
* @returns {boolean} Whether content appears binary
|
|
4274
4850
|
*/
|
|
4275
4851
|
function isBinaryContent(content) {
|
|
4276
|
-
if (
|
|
4852
|
+
if (isBytes(content)) return true;
|
|
4277
4853
|
if (typeof content !== 'string') return false;
|
|
4278
4854
|
|
|
4279
4855
|
// Check for null bytes (common in binary files)
|
|
@@ -4588,9 +5164,9 @@ class MergeDriverRegistry {
|
|
|
4588
5164
|
/**
|
|
4589
5165
|
* Prepare content for driver based on binary/text
|
|
4590
5166
|
*
|
|
4591
|
-
* @param {string|
|
|
4592
|
-
* @param {boolean} isBinary - Whether to return as
|
|
4593
|
-
* @returns {string|
|
|
5167
|
+
* @param {string|Uint8Array|null} content - Raw content
|
|
5168
|
+
* @param {boolean} isBinary - Whether to return as bytes
|
|
5169
|
+
* @returns {string|Uint8Array|null} Prepared content
|
|
4594
5170
|
*/
|
|
4595
5171
|
prepareContent(content, isBinary) {
|
|
4596
5172
|
if (content === null || content === undefined) {
|
|
@@ -4598,14 +5174,14 @@ class MergeDriverRegistry {
|
|
|
4598
5174
|
}
|
|
4599
5175
|
|
|
4600
5176
|
if (isBinary) {
|
|
4601
|
-
// Convert to
|
|
4602
|
-
if (
|
|
4603
|
-
if (typeof content === 'string') return
|
|
4604
|
-
return
|
|
5177
|
+
// Convert to bytes if needed
|
|
5178
|
+
if (isBytes(content)) return content;
|
|
5179
|
+
if (typeof content === 'string') return utf8Encode(content);
|
|
5180
|
+
return utf8Encode(String(content));
|
|
4605
5181
|
} else {
|
|
4606
5182
|
// Convert to string if needed
|
|
4607
5183
|
if (typeof content === 'string') return content;
|
|
4608
|
-
if (
|
|
5184
|
+
if (isBytes(content)) return utf8Decode(content);
|
|
4609
5185
|
return String(content);
|
|
4610
5186
|
}
|
|
4611
5187
|
}
|
|
@@ -4766,25 +5342,25 @@ class WorkspaceManager {
|
|
|
4766
5342
|
};
|
|
4767
5343
|
|
|
4768
5344
|
// Create workspace directory
|
|
4769
|
-
await this.fs
|
|
5345
|
+
await mkdirp(this.fs, args.path);
|
|
4770
5346
|
|
|
4771
5347
|
// Create workspace-specific working_copy directory in .jj
|
|
4772
|
-
const workspaceCopyDir =
|
|
4773
|
-
await this.fs
|
|
5348
|
+
const workspaceCopyDir = join(this.repoDir, '.jj', 'working_copy', workspaceId);
|
|
5349
|
+
await mkdirp(this.fs, workspaceCopyDir);
|
|
4774
5350
|
|
|
4775
5351
|
// Create .git file pointing to main repo's .git directory
|
|
4776
5352
|
// This allows Git tools to work in the workspace
|
|
4777
5353
|
// IMPORTANT: Must use absolute paths for Git compatibility
|
|
4778
|
-
const gitFilePath =
|
|
4779
|
-
const mainGitDir =
|
|
5354
|
+
const gitFilePath = join(args.path, '.git');
|
|
5355
|
+
const mainGitDir = resolve(this.repoDir, '.git');
|
|
4780
5356
|
const gitFileContent = `gitdir: ${mainGitDir}\n`;
|
|
4781
5357
|
await this.fs.promises.writeFile(gitFilePath, gitFileContent, 'utf8');
|
|
4782
5358
|
|
|
4783
5359
|
// Create .jj file pointing to main repo's .jj directory
|
|
4784
5360
|
// This allows JJ tools to work in the workspace
|
|
4785
5361
|
// IMPORTANT: Must use absolute paths for JJ CLI compatibility
|
|
4786
|
-
const jjFilePath =
|
|
4787
|
-
const mainJJDir =
|
|
5362
|
+
const jjFilePath = join(args.path, '.jj');
|
|
5363
|
+
const mainJJDir = resolve(this.repoDir, '.jj');
|
|
4788
5364
|
const jjFileContent = `jjdir: ${mainJJDir}\n`;
|
|
4789
5365
|
await this.fs.promises.writeFile(jjFilePath, jjFileContent, 'utf8');
|
|
4790
5366
|
|
|
@@ -5419,6 +5995,575 @@ class UserConfig {
|
|
|
5419
5995
|
}
|
|
5420
5996
|
}
|
|
5421
5997
|
|
|
5998
|
+
/**
|
|
5999
|
+
* Precompiled protobuf reflection JSON for local_working_copy.proto
|
|
6000
|
+
*
|
|
6001
|
+
* Generated from src/protos/local_working_copy.proto by scripts/build-protos.mjs. This
|
|
6002
|
+
* is bundled directly (instead of loaded from a filesystem path at runtime)
|
|
6003
|
+
* so that protobuf schema loading works identically in Node and in browser
|
|
6004
|
+
* bundles — protobuf.load() requires filesystem/network access that does
|
|
6005
|
+
* not exist in a browser bundle (see issue #28). Regenerate with:
|
|
6006
|
+
* node scripts/build-protos.mjs
|
|
6007
|
+
*/
|
|
6008
|
+
|
|
6009
|
+
var localWorkingCopyJson = {
|
|
6010
|
+
nested: {
|
|
6011
|
+
local_working_copy: {
|
|
6012
|
+
nested: {
|
|
6013
|
+
FileType: {
|
|
6014
|
+
valuesOptions: {
|
|
6015
|
+
Conflict: {
|
|
6016
|
+
deprecated: true,
|
|
6017
|
+
},
|
|
6018
|
+
},
|
|
6019
|
+
values: {
|
|
6020
|
+
Normal: 0,
|
|
6021
|
+
Symlink: 1,
|
|
6022
|
+
Executable: 2,
|
|
6023
|
+
Conflict: 3,
|
|
6024
|
+
GitSubmodule: 4,
|
|
6025
|
+
},
|
|
6026
|
+
},
|
|
6027
|
+
MaterializedConflictData: {
|
|
6028
|
+
fields: {
|
|
6029
|
+
conflictMarkerLen: {
|
|
6030
|
+
type: 'uint32',
|
|
6031
|
+
id: 1,
|
|
6032
|
+
},
|
|
6033
|
+
},
|
|
6034
|
+
},
|
|
6035
|
+
FileState: {
|
|
6036
|
+
fields: {
|
|
6037
|
+
mtimeMillisSinceEpoch: {
|
|
6038
|
+
type: 'int64',
|
|
6039
|
+
id: 1,
|
|
6040
|
+
},
|
|
6041
|
+
size: {
|
|
6042
|
+
type: 'uint64',
|
|
6043
|
+
id: 2,
|
|
6044
|
+
},
|
|
6045
|
+
fileType: {
|
|
6046
|
+
type: 'FileType',
|
|
6047
|
+
id: 3,
|
|
6048
|
+
},
|
|
6049
|
+
materializedConflictData: {
|
|
6050
|
+
type: 'MaterializedConflictData',
|
|
6051
|
+
id: 5,
|
|
6052
|
+
},
|
|
6053
|
+
},
|
|
6054
|
+
reserved: [[4, 4]],
|
|
6055
|
+
},
|
|
6056
|
+
FileStateEntry: {
|
|
6057
|
+
fields: {
|
|
6058
|
+
path: {
|
|
6059
|
+
type: 'string',
|
|
6060
|
+
id: 1,
|
|
6061
|
+
},
|
|
6062
|
+
state: {
|
|
6063
|
+
type: 'FileState',
|
|
6064
|
+
id: 2,
|
|
6065
|
+
},
|
|
6066
|
+
},
|
|
6067
|
+
},
|
|
6068
|
+
SparsePatterns: {
|
|
6069
|
+
fields: {
|
|
6070
|
+
prefixes: {
|
|
6071
|
+
rule: 'repeated',
|
|
6072
|
+
type: 'string',
|
|
6073
|
+
id: 1,
|
|
6074
|
+
},
|
|
6075
|
+
},
|
|
6076
|
+
},
|
|
6077
|
+
TreeState: {
|
|
6078
|
+
fields: {
|
|
6079
|
+
legacyTreeId: {
|
|
6080
|
+
type: 'bytes',
|
|
6081
|
+
id: 1,
|
|
6082
|
+
options: {
|
|
6083
|
+
deprecated: true,
|
|
6084
|
+
},
|
|
6085
|
+
},
|
|
6086
|
+
treeIds: {
|
|
6087
|
+
rule: 'repeated',
|
|
6088
|
+
type: 'bytes',
|
|
6089
|
+
id: 5,
|
|
6090
|
+
},
|
|
6091
|
+
fileStates: {
|
|
6092
|
+
rule: 'repeated',
|
|
6093
|
+
type: 'FileStateEntry',
|
|
6094
|
+
id: 2,
|
|
6095
|
+
},
|
|
6096
|
+
isFileStatesSorted: {
|
|
6097
|
+
type: 'bool',
|
|
6098
|
+
id: 6,
|
|
6099
|
+
},
|
|
6100
|
+
sparsePatterns: {
|
|
6101
|
+
type: 'SparsePatterns',
|
|
6102
|
+
id: 3,
|
|
6103
|
+
},
|
|
6104
|
+
watchmanClock: {
|
|
6105
|
+
type: 'WatchmanClock',
|
|
6106
|
+
id: 4,
|
|
6107
|
+
},
|
|
6108
|
+
},
|
|
6109
|
+
},
|
|
6110
|
+
WatchmanClock: {
|
|
6111
|
+
oneofs: {
|
|
6112
|
+
watchmanClock: {
|
|
6113
|
+
oneof: ['stringClock', 'unixTimestamp'],
|
|
6114
|
+
},
|
|
6115
|
+
},
|
|
6116
|
+
fields: {
|
|
6117
|
+
stringClock: {
|
|
6118
|
+
type: 'string',
|
|
6119
|
+
id: 1,
|
|
6120
|
+
},
|
|
6121
|
+
unixTimestamp: {
|
|
6122
|
+
type: 'int64',
|
|
6123
|
+
id: 2,
|
|
6124
|
+
},
|
|
6125
|
+
},
|
|
6126
|
+
},
|
|
6127
|
+
Checkout: {
|
|
6128
|
+
fields: {
|
|
6129
|
+
operationId: {
|
|
6130
|
+
type: 'bytes',
|
|
6131
|
+
id: 2,
|
|
6132
|
+
},
|
|
6133
|
+
workspaceName: {
|
|
6134
|
+
type: 'string',
|
|
6135
|
+
id: 3,
|
|
6136
|
+
},
|
|
6137
|
+
},
|
|
6138
|
+
reserved: [[1, 1]],
|
|
6139
|
+
},
|
|
6140
|
+
},
|
|
6141
|
+
},
|
|
6142
|
+
},
|
|
6143
|
+
};
|
|
6144
|
+
|
|
6145
|
+
/**
|
|
6146
|
+
* Precompiled protobuf reflection JSON for simple_op_store.proto
|
|
6147
|
+
*
|
|
6148
|
+
* Generated from src/protos/simple_op_store.proto by scripts/build-protos.mjs. This
|
|
6149
|
+
* is bundled directly (instead of loaded from a filesystem path at runtime)
|
|
6150
|
+
* so that protobuf schema loading works identically in Node and in browser
|
|
6151
|
+
* bundles — protobuf.load() requires filesystem/network access that does
|
|
6152
|
+
* not exist in a browser bundle (see issue #28). Regenerate with:
|
|
6153
|
+
* node scripts/build-protos.mjs
|
|
6154
|
+
*/
|
|
6155
|
+
|
|
6156
|
+
var simpleOpStoreJson = {
|
|
6157
|
+
nested: {
|
|
6158
|
+
simple_op_store: {
|
|
6159
|
+
nested: {
|
|
6160
|
+
RefConflictLegacy: {
|
|
6161
|
+
fields: {
|
|
6162
|
+
removes: {
|
|
6163
|
+
rule: 'repeated',
|
|
6164
|
+
type: 'bytes',
|
|
6165
|
+
id: 1,
|
|
6166
|
+
options: {
|
|
6167
|
+
deprecated: true,
|
|
6168
|
+
},
|
|
6169
|
+
},
|
|
6170
|
+
adds: {
|
|
6171
|
+
rule: 'repeated',
|
|
6172
|
+
type: 'bytes',
|
|
6173
|
+
id: 2,
|
|
6174
|
+
options: {
|
|
6175
|
+
deprecated: true,
|
|
6176
|
+
},
|
|
6177
|
+
},
|
|
6178
|
+
},
|
|
6179
|
+
},
|
|
6180
|
+
RefConflict: {
|
|
6181
|
+
fields: {
|
|
6182
|
+
removes: {
|
|
6183
|
+
rule: 'repeated',
|
|
6184
|
+
type: 'Term',
|
|
6185
|
+
id: 1,
|
|
6186
|
+
},
|
|
6187
|
+
adds: {
|
|
6188
|
+
rule: 'repeated',
|
|
6189
|
+
type: 'Term',
|
|
6190
|
+
id: 2,
|
|
6191
|
+
},
|
|
6192
|
+
},
|
|
6193
|
+
nested: {
|
|
6194
|
+
Term: {
|
|
6195
|
+
oneofs: {
|
|
6196
|
+
_value: {
|
|
6197
|
+
oneof: ['value'],
|
|
6198
|
+
},
|
|
6199
|
+
},
|
|
6200
|
+
fields: {
|
|
6201
|
+
value: {
|
|
6202
|
+
type: 'bytes',
|
|
6203
|
+
id: 1,
|
|
6204
|
+
options: {
|
|
6205
|
+
proto3_optional: true,
|
|
6206
|
+
},
|
|
6207
|
+
},
|
|
6208
|
+
},
|
|
6209
|
+
},
|
|
6210
|
+
},
|
|
6211
|
+
},
|
|
6212
|
+
RefTarget: {
|
|
6213
|
+
oneofs: {
|
|
6214
|
+
value: {
|
|
6215
|
+
oneof: ['commitId', 'conflictLegacy', 'conflict'],
|
|
6216
|
+
},
|
|
6217
|
+
},
|
|
6218
|
+
fields: {
|
|
6219
|
+
commitId: {
|
|
6220
|
+
type: 'bytes',
|
|
6221
|
+
id: 1,
|
|
6222
|
+
options: {
|
|
6223
|
+
deprecated: true,
|
|
6224
|
+
},
|
|
6225
|
+
},
|
|
6226
|
+
conflictLegacy: {
|
|
6227
|
+
type: 'RefConflictLegacy',
|
|
6228
|
+
id: 2,
|
|
6229
|
+
options: {
|
|
6230
|
+
deprecated: true,
|
|
6231
|
+
},
|
|
6232
|
+
},
|
|
6233
|
+
conflict: {
|
|
6234
|
+
type: 'RefConflict',
|
|
6235
|
+
id: 3,
|
|
6236
|
+
},
|
|
6237
|
+
},
|
|
6238
|
+
},
|
|
6239
|
+
RefTargetTerm: {
|
|
6240
|
+
oneofs: {
|
|
6241
|
+
_value: {
|
|
6242
|
+
oneof: ['value'],
|
|
6243
|
+
},
|
|
6244
|
+
},
|
|
6245
|
+
fields: {
|
|
6246
|
+
value: {
|
|
6247
|
+
type: 'bytes',
|
|
6248
|
+
id: 1,
|
|
6249
|
+
options: {
|
|
6250
|
+
proto3_optional: true,
|
|
6251
|
+
},
|
|
6252
|
+
},
|
|
6253
|
+
},
|
|
6254
|
+
},
|
|
6255
|
+
RemoteRefState: {
|
|
6256
|
+
values: {
|
|
6257
|
+
New: 0,
|
|
6258
|
+
Tracked: 1,
|
|
6259
|
+
},
|
|
6260
|
+
},
|
|
6261
|
+
RemoteBookmark: {
|
|
6262
|
+
oneofs: {
|
|
6263
|
+
_state: {
|
|
6264
|
+
oneof: ['state'],
|
|
6265
|
+
},
|
|
6266
|
+
},
|
|
6267
|
+
fields: {
|
|
6268
|
+
remoteName: {
|
|
6269
|
+
type: 'string',
|
|
6270
|
+
id: 1,
|
|
6271
|
+
},
|
|
6272
|
+
target: {
|
|
6273
|
+
type: 'RefTarget',
|
|
6274
|
+
id: 2,
|
|
6275
|
+
},
|
|
6276
|
+
state: {
|
|
6277
|
+
type: 'RemoteRefState',
|
|
6278
|
+
id: 3,
|
|
6279
|
+
options: {
|
|
6280
|
+
proto3_optional: true,
|
|
6281
|
+
},
|
|
6282
|
+
},
|
|
6283
|
+
},
|
|
6284
|
+
},
|
|
6285
|
+
Bookmark: {
|
|
6286
|
+
fields: {
|
|
6287
|
+
name: {
|
|
6288
|
+
type: 'string',
|
|
6289
|
+
id: 1,
|
|
6290
|
+
},
|
|
6291
|
+
localTarget: {
|
|
6292
|
+
type: 'RefTarget',
|
|
6293
|
+
id: 2,
|
|
6294
|
+
},
|
|
6295
|
+
remoteBookmarks: {
|
|
6296
|
+
rule: 'repeated',
|
|
6297
|
+
type: 'RemoteBookmark',
|
|
6298
|
+
id: 3,
|
|
6299
|
+
options: {
|
|
6300
|
+
deprecated: true,
|
|
6301
|
+
},
|
|
6302
|
+
},
|
|
6303
|
+
},
|
|
6304
|
+
},
|
|
6305
|
+
GitRef: {
|
|
6306
|
+
fields: {
|
|
6307
|
+
name: {
|
|
6308
|
+
type: 'string',
|
|
6309
|
+
id: 1,
|
|
6310
|
+
},
|
|
6311
|
+
commitId: {
|
|
6312
|
+
type: 'bytes',
|
|
6313
|
+
id: 2,
|
|
6314
|
+
options: {
|
|
6315
|
+
deprecated: true,
|
|
6316
|
+
},
|
|
6317
|
+
},
|
|
6318
|
+
target: {
|
|
6319
|
+
type: 'RefTarget',
|
|
6320
|
+
id: 3,
|
|
6321
|
+
},
|
|
6322
|
+
},
|
|
6323
|
+
},
|
|
6324
|
+
RemoteRef: {
|
|
6325
|
+
fields: {
|
|
6326
|
+
name: {
|
|
6327
|
+
type: 'string',
|
|
6328
|
+
id: 1,
|
|
6329
|
+
},
|
|
6330
|
+
targetTerms: {
|
|
6331
|
+
rule: 'repeated',
|
|
6332
|
+
type: 'RefTargetTerm',
|
|
6333
|
+
id: 2,
|
|
6334
|
+
},
|
|
6335
|
+
state: {
|
|
6336
|
+
type: 'RemoteRefState',
|
|
6337
|
+
id: 3,
|
|
6338
|
+
},
|
|
6339
|
+
},
|
|
6340
|
+
},
|
|
6341
|
+
Tag: {
|
|
6342
|
+
fields: {
|
|
6343
|
+
name: {
|
|
6344
|
+
type: 'string',
|
|
6345
|
+
id: 1,
|
|
6346
|
+
},
|
|
6347
|
+
target: {
|
|
6348
|
+
type: 'RefTarget',
|
|
6349
|
+
id: 2,
|
|
6350
|
+
},
|
|
6351
|
+
},
|
|
6352
|
+
},
|
|
6353
|
+
View: {
|
|
6354
|
+
fields: {
|
|
6355
|
+
headIds: {
|
|
6356
|
+
rule: 'repeated',
|
|
6357
|
+
type: 'bytes',
|
|
6358
|
+
id: 1,
|
|
6359
|
+
},
|
|
6360
|
+
wcCommitId: {
|
|
6361
|
+
type: 'bytes',
|
|
6362
|
+
id: 2,
|
|
6363
|
+
options: {
|
|
6364
|
+
deprecated: true,
|
|
6365
|
+
},
|
|
6366
|
+
},
|
|
6367
|
+
wcCommitIds: {
|
|
6368
|
+
keyType: 'string',
|
|
6369
|
+
type: 'bytes',
|
|
6370
|
+
id: 8,
|
|
6371
|
+
},
|
|
6372
|
+
bookmarks: {
|
|
6373
|
+
rule: 'repeated',
|
|
6374
|
+
type: 'Bookmark',
|
|
6375
|
+
id: 5,
|
|
6376
|
+
},
|
|
6377
|
+
localTags: {
|
|
6378
|
+
rule: 'repeated',
|
|
6379
|
+
type: 'Tag',
|
|
6380
|
+
id: 6,
|
|
6381
|
+
},
|
|
6382
|
+
remoteViews: {
|
|
6383
|
+
rule: 'repeated',
|
|
6384
|
+
type: 'RemoteView',
|
|
6385
|
+
id: 11,
|
|
6386
|
+
},
|
|
6387
|
+
gitRefs: {
|
|
6388
|
+
rule: 'repeated',
|
|
6389
|
+
type: 'GitRef',
|
|
6390
|
+
id: 3,
|
|
6391
|
+
},
|
|
6392
|
+
gitHeadLegacy: {
|
|
6393
|
+
type: 'bytes',
|
|
6394
|
+
id: 7,
|
|
6395
|
+
options: {
|
|
6396
|
+
deprecated: true,
|
|
6397
|
+
},
|
|
6398
|
+
},
|
|
6399
|
+
gitHead: {
|
|
6400
|
+
type: 'RefTarget',
|
|
6401
|
+
id: 9,
|
|
6402
|
+
},
|
|
6403
|
+
hasGitRefsMigratedToRemoteTags: {
|
|
6404
|
+
type: 'bool',
|
|
6405
|
+
id: 12,
|
|
6406
|
+
},
|
|
6407
|
+
},
|
|
6408
|
+
reserved: [
|
|
6409
|
+
[4, 4],
|
|
6410
|
+
[10, 10],
|
|
6411
|
+
],
|
|
6412
|
+
},
|
|
6413
|
+
RemoteView: {
|
|
6414
|
+
fields: {
|
|
6415
|
+
name: {
|
|
6416
|
+
type: 'string',
|
|
6417
|
+
id: 1,
|
|
6418
|
+
},
|
|
6419
|
+
bookmarks: {
|
|
6420
|
+
rule: 'repeated',
|
|
6421
|
+
type: 'RemoteRef',
|
|
6422
|
+
id: 2,
|
|
6423
|
+
},
|
|
6424
|
+
tags: {
|
|
6425
|
+
rule: 'repeated',
|
|
6426
|
+
type: 'RemoteRef',
|
|
6427
|
+
id: 3,
|
|
6428
|
+
},
|
|
6429
|
+
},
|
|
6430
|
+
},
|
|
6431
|
+
Operation: {
|
|
6432
|
+
fields: {
|
|
6433
|
+
viewId: {
|
|
6434
|
+
type: 'bytes',
|
|
6435
|
+
id: 1,
|
|
6436
|
+
},
|
|
6437
|
+
parents: {
|
|
6438
|
+
rule: 'repeated',
|
|
6439
|
+
type: 'bytes',
|
|
6440
|
+
id: 2,
|
|
6441
|
+
},
|
|
6442
|
+
metadata: {
|
|
6443
|
+
type: 'OperationMetadata',
|
|
6444
|
+
id: 3,
|
|
6445
|
+
},
|
|
6446
|
+
commitPredecessors: {
|
|
6447
|
+
rule: 'repeated',
|
|
6448
|
+
type: 'CommitPredecessors',
|
|
6449
|
+
id: 4,
|
|
6450
|
+
},
|
|
6451
|
+
storesCommitPredecessors: {
|
|
6452
|
+
type: 'bool',
|
|
6453
|
+
id: 5,
|
|
6454
|
+
},
|
|
6455
|
+
},
|
|
6456
|
+
},
|
|
6457
|
+
Timestamp: {
|
|
6458
|
+
fields: {
|
|
6459
|
+
millisSinceEpoch: {
|
|
6460
|
+
type: 'int64',
|
|
6461
|
+
id: 1,
|
|
6462
|
+
},
|
|
6463
|
+
tzOffset: {
|
|
6464
|
+
type: 'int32',
|
|
6465
|
+
id: 2,
|
|
6466
|
+
},
|
|
6467
|
+
},
|
|
6468
|
+
},
|
|
6469
|
+
OperationMetadata: {
|
|
6470
|
+
fields: {
|
|
6471
|
+
startTime: {
|
|
6472
|
+
type: 'Timestamp',
|
|
6473
|
+
id: 1,
|
|
6474
|
+
},
|
|
6475
|
+
endTime: {
|
|
6476
|
+
type: 'Timestamp',
|
|
6477
|
+
id: 2,
|
|
6478
|
+
},
|
|
6479
|
+
description: {
|
|
6480
|
+
type: 'string',
|
|
6481
|
+
id: 3,
|
|
6482
|
+
},
|
|
6483
|
+
hostname: {
|
|
6484
|
+
type: 'string',
|
|
6485
|
+
id: 4,
|
|
6486
|
+
},
|
|
6487
|
+
username: {
|
|
6488
|
+
type: 'string',
|
|
6489
|
+
id: 5,
|
|
6490
|
+
},
|
|
6491
|
+
isSnapshot: {
|
|
6492
|
+
type: 'bool',
|
|
6493
|
+
id: 7,
|
|
6494
|
+
},
|
|
6495
|
+
tags: {
|
|
6496
|
+
keyType: 'string',
|
|
6497
|
+
type: 'string',
|
|
6498
|
+
id: 6,
|
|
6499
|
+
},
|
|
6500
|
+
},
|
|
6501
|
+
},
|
|
6502
|
+
CommitPredecessors: {
|
|
6503
|
+
fields: {
|
|
6504
|
+
commitId: {
|
|
6505
|
+
type: 'bytes',
|
|
6506
|
+
id: 1,
|
|
6507
|
+
},
|
|
6508
|
+
predecessorIds: {
|
|
6509
|
+
rule: 'repeated',
|
|
6510
|
+
type: 'bytes',
|
|
6511
|
+
id: 2,
|
|
6512
|
+
},
|
|
6513
|
+
},
|
|
6514
|
+
},
|
|
6515
|
+
},
|
|
6516
|
+
},
|
|
6517
|
+
},
|
|
6518
|
+
};
|
|
6519
|
+
|
|
6520
|
+
/**
|
|
6521
|
+
* Protobuf schema loader
|
|
6522
|
+
*
|
|
6523
|
+
* Builds protobuf.js `Root` instances from precompiled reflection JSON
|
|
6524
|
+
* (see local_working_copy.json.js / simple_op_store.json.js, generated from
|
|
6525
|
+
* the .proto files in this directory) instead of calling `protobuf.load()`
|
|
6526
|
+
* against a filesystem path.
|
|
6527
|
+
*
|
|
6528
|
+
* Why: `protobuf.load()` resolves its argument as a file path (Node `fs`) or
|
|
6529
|
+
* URL (XHR/fetch), which requires either a real filesystem or a network
|
|
6530
|
+
* request. In a bundled browser build there is no filesystem path to read
|
|
6531
|
+
* (see issue #28), and shipping the raw .proto text and fetching it at
|
|
6532
|
+
* runtime would require bundler-specific asset handling. Reflection JSON is
|
|
6533
|
+
* plain data, so it can be imported like any other JS module and works
|
|
6534
|
+
* identically under Node, Jest, and any browser bundler (Vite, esbuild,
|
|
6535
|
+
* webpack, Rollup, ...).
|
|
6536
|
+
*
|
|
6537
|
+
* Regenerate the JSON files after editing a .proto with:
|
|
6538
|
+
* npm run build:protos
|
|
6539
|
+
*/
|
|
6540
|
+
|
|
6541
|
+
|
|
6542
|
+
/** @type {protobuf.Root|undefined} */
|
|
6543
|
+
let localWorkingCopyRoot;
|
|
6544
|
+
/** @type {protobuf.Root|undefined} */
|
|
6545
|
+
let simpleOpStoreRoot;
|
|
6546
|
+
|
|
6547
|
+
/**
|
|
6548
|
+
* @returns {protobuf.Root} Root for local_working_copy.proto (Checkout, TreeState, ...)
|
|
6549
|
+
*/
|
|
6550
|
+
function getLocalWorkingCopyRoot() {
|
|
6551
|
+
if (!localWorkingCopyRoot) {
|
|
6552
|
+
localWorkingCopyRoot = protobuf.Root.fromJSON(localWorkingCopyJson);
|
|
6553
|
+
}
|
|
6554
|
+
return localWorkingCopyRoot;
|
|
6555
|
+
}
|
|
6556
|
+
|
|
6557
|
+
/**
|
|
6558
|
+
* @returns {protobuf.Root} Root for simple_op_store.proto (Operation, View, ...)
|
|
6559
|
+
*/
|
|
6560
|
+
function getSimpleOpStoreRoot() {
|
|
6561
|
+
if (!simpleOpStoreRoot) {
|
|
6562
|
+
simpleOpStoreRoot = protobuf.Root.fromJSON(simpleOpStoreJson);
|
|
6563
|
+
}
|
|
6564
|
+
return simpleOpStoreRoot;
|
|
6565
|
+
}
|
|
6566
|
+
|
|
5422
6567
|
/**
|
|
5423
6568
|
* JJ Checkout File Manager
|
|
5424
6569
|
*
|
|
@@ -5427,15 +6572,6 @@ class UserConfig {
|
|
|
5427
6572
|
*/
|
|
5428
6573
|
|
|
5429
6574
|
|
|
5430
|
-
const __filename$4 = url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
|
|
5431
|
-
const __dirname$4 = path.dirname(__filename$4);
|
|
5432
|
-
|
|
5433
|
-
// Determine proto path based on whether we're running from dist or src
|
|
5434
|
-
const inDist$3 = __dirname$4.includes('/dist') || __dirname$4.includes('\\dist');
|
|
5435
|
-
const protoDir$3 = inDist$3
|
|
5436
|
-
? path.join(__dirname$4, '..', 'src', 'protos')
|
|
5437
|
-
: path.join(__dirname$4, '..', 'protos');
|
|
5438
|
-
|
|
5439
6575
|
class JJCheckout {
|
|
5440
6576
|
/**
|
|
5441
6577
|
* @param {any} fs - File system module (isomorphic-git compatible)
|
|
@@ -5444,7 +6580,6 @@ class JJCheckout {
|
|
|
5444
6580
|
constructor(fs, dir) {
|
|
5445
6581
|
this.fs = fs;
|
|
5446
6582
|
this.dir = dir;
|
|
5447
|
-
this.protoPath = path.join(protoDir$3, 'local_working_copy.proto');
|
|
5448
6583
|
}
|
|
5449
6584
|
|
|
5450
6585
|
/**
|
|
@@ -5455,11 +6590,11 @@ class JJCheckout {
|
|
|
5455
6590
|
*/
|
|
5456
6591
|
async writeCheckout(operationId, workspaceName = 'default') {
|
|
5457
6592
|
// Load protobuf schema
|
|
5458
|
-
const root =
|
|
6593
|
+
const root = getLocalWorkingCopyRoot();
|
|
5459
6594
|
const Checkout = root.lookupType('local_working_copy.Checkout');
|
|
5460
6595
|
|
|
5461
6596
|
// Convert hex operation ID to bytes
|
|
5462
|
-
const opIdBuffer =
|
|
6597
|
+
const opIdBuffer = hexToBytes(operationId);
|
|
5463
6598
|
|
|
5464
6599
|
// Create message (use camelCase for protobufjs)
|
|
5465
6600
|
const message = Checkout.create({
|
|
@@ -5491,7 +6626,7 @@ class JJCheckout {
|
|
|
5491
6626
|
const buffer = await this.fs.promises.readFile(checkoutPath);
|
|
5492
6627
|
|
|
5493
6628
|
// Load protobuf schema
|
|
5494
|
-
const root =
|
|
6629
|
+
const root = getLocalWorkingCopyRoot();
|
|
5495
6630
|
const Checkout = root.lookupType('local_working_copy.Checkout');
|
|
5496
6631
|
|
|
5497
6632
|
// Decode
|
|
@@ -5519,6 +6654,7 @@ class JJCheckout {
|
|
|
5519
6654
|
* calling `writeFile()` directly on the canonical path — see issue #16.
|
|
5520
6655
|
*/
|
|
5521
6656
|
|
|
6657
|
+
|
|
5522
6658
|
/**
|
|
5523
6659
|
* @param {any} fs - Filesystem implementation (Node fs, LightningFS, etc.)
|
|
5524
6660
|
* @param {string} targetPath - Canonical path to write to
|
|
@@ -5534,7 +6670,7 @@ async function atomicWriteFile(fs, targetPath, content) {
|
|
|
5534
6670
|
try {
|
|
5535
6671
|
const dirPath = targetPath.substring(0, targetPath.lastIndexOf('/'));
|
|
5536
6672
|
if (dirPath) {
|
|
5537
|
-
await fs
|
|
6673
|
+
await mkdirp(fs, dirPath);
|
|
5538
6674
|
}
|
|
5539
6675
|
|
|
5540
6676
|
await fs.promises.writeFile(tmpPath, content);
|
|
@@ -5558,15 +6694,6 @@ async function atomicWriteFile(fs, targetPath, content) {
|
|
|
5558
6694
|
*/
|
|
5559
6695
|
|
|
5560
6696
|
|
|
5561
|
-
const __filename$3 = url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
|
|
5562
|
-
const __dirname$3 = path.dirname(__filename$3);
|
|
5563
|
-
|
|
5564
|
-
// Determine proto path based on whether we're running from dist or src
|
|
5565
|
-
const inDist$2 = __dirname$3.includes('/dist') || __dirname$3.includes('\\dist');
|
|
5566
|
-
const protoDir$2 = inDist$2
|
|
5567
|
-
? path.join(__dirname$3, '..', 'src', 'protos')
|
|
5568
|
-
: path.join(__dirname$3, '..', 'protos');
|
|
5569
|
-
|
|
5570
6697
|
class JJTreeState {
|
|
5571
6698
|
/**
|
|
5572
6699
|
* @param {any} fs - File system module (isomorphic-git compatible)
|
|
@@ -5575,7 +6702,6 @@ class JJTreeState {
|
|
|
5575
6702
|
constructor(fs, dir) {
|
|
5576
6703
|
this.fs = fs;
|
|
5577
6704
|
this.dir = dir;
|
|
5578
|
-
this.protoPath = path.join(protoDir$2, 'local_working_copy.proto');
|
|
5579
6705
|
}
|
|
5580
6706
|
|
|
5581
6707
|
/**
|
|
@@ -5586,11 +6712,11 @@ class JJTreeState {
|
|
|
5586
6712
|
*/
|
|
5587
6713
|
async writeTreeState(treeId, fileStates = []) {
|
|
5588
6714
|
// Load protobuf schema
|
|
5589
|
-
const root =
|
|
6715
|
+
const root = getLocalWorkingCopyRoot();
|
|
5590
6716
|
const TreeState = root.lookupType('local_working_copy.TreeState');
|
|
5591
6717
|
|
|
5592
6718
|
// Convert hex tree ID to bytes
|
|
5593
|
-
const treeIdBuffer =
|
|
6719
|
+
const treeIdBuffer = hexToBytes(treeId);
|
|
5594
6720
|
|
|
5595
6721
|
// Convert file states to protobuf format
|
|
5596
6722
|
const fileStateEntries = fileStates.map((fs) => ({
|
|
@@ -5636,7 +6762,7 @@ class JJTreeState {
|
|
|
5636
6762
|
const buffer = await this.fs.promises.readFile(treeStatePath);
|
|
5637
6763
|
|
|
5638
6764
|
// Load protobuf schema
|
|
5639
|
-
const root =
|
|
6765
|
+
const root = getLocalWorkingCopyRoot();
|
|
5640
6766
|
const TreeState = root.lookupType('local_working_copy.TreeState');
|
|
5641
6767
|
|
|
5642
6768
|
// Decode
|
|
@@ -5660,15 +6786,6 @@ class JJTreeState {
|
|
|
5660
6786
|
*/
|
|
5661
6787
|
|
|
5662
6788
|
|
|
5663
|
-
const __filename$2 = url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
|
|
5664
|
-
const __dirname$2 = path.dirname(__filename$2);
|
|
5665
|
-
|
|
5666
|
-
// Determine proto path based on whether we're running from dist or src
|
|
5667
|
-
const inDist$1 = __dirname$2.includes('/dist') || __dirname$2.includes('\\dist');
|
|
5668
|
-
const protoDir$1 = inDist$1
|
|
5669
|
-
? path.join(__dirname$2, '..', 'src', 'protos')
|
|
5670
|
-
: path.join(__dirname$2, '..', 'protos');
|
|
5671
|
-
|
|
5672
6789
|
class JJOperationStore {
|
|
5673
6790
|
/**
|
|
5674
6791
|
* @param {any} fs - File system module (isomorphic-git compatible)
|
|
@@ -5677,7 +6794,6 @@ class JJOperationStore {
|
|
|
5677
6794
|
constructor(fs, dir) {
|
|
5678
6795
|
this.fs = fs;
|
|
5679
6796
|
this.dir = dir;
|
|
5680
|
-
this.protoPath = path.join(protoDir$1, 'simple_op_store.proto');
|
|
5681
6797
|
}
|
|
5682
6798
|
|
|
5683
6799
|
/**
|
|
@@ -5690,12 +6806,12 @@ class JJOperationStore {
|
|
|
5690
6806
|
*/
|
|
5691
6807
|
async writeOperation(operationId, viewId, parentIds, metadata) {
|
|
5692
6808
|
// Load protobuf schema
|
|
5693
|
-
const root =
|
|
6809
|
+
const root = getSimpleOpStoreRoot();
|
|
5694
6810
|
const Operation = root.lookupType('simple_op_store.Operation');
|
|
5695
6811
|
|
|
5696
6812
|
// Convert hex IDs to bytes
|
|
5697
|
-
const viewIdBuffer =
|
|
5698
|
-
const parentBuffers = parentIds.map((id) =>
|
|
6813
|
+
const viewIdBuffer = hexToBytes(viewId);
|
|
6814
|
+
const parentBuffers = parentIds.map((id) => hexToBytes(id));
|
|
5699
6815
|
|
|
5700
6816
|
// Create metadata message (use camelCase for protobufjs)
|
|
5701
6817
|
const metadataMsg = {
|
|
@@ -5751,7 +6867,7 @@ class JJOperationStore {
|
|
|
5751
6867
|
const buffer = await this.fs.promises.readFile(opPath);
|
|
5752
6868
|
|
|
5753
6869
|
// Load protobuf schema
|
|
5754
|
-
const root =
|
|
6870
|
+
const root = getSimpleOpStoreRoot();
|
|
5755
6871
|
const Operation = root.lookupType('simple_op_store.Operation');
|
|
5756
6872
|
|
|
5757
6873
|
// Decode
|
|
@@ -5790,15 +6906,6 @@ class JJOperationStore {
|
|
|
5790
6906
|
*/
|
|
5791
6907
|
|
|
5792
6908
|
|
|
5793
|
-
const __filename$1 = url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
|
|
5794
|
-
const __dirname$1 = path.dirname(__filename$1);
|
|
5795
|
-
|
|
5796
|
-
// Determine proto path based on whether we're running from dist or src
|
|
5797
|
-
const inDist = __dirname$1.includes('/dist') || __dirname$1.includes('\\dist');
|
|
5798
|
-
const protoDir = inDist
|
|
5799
|
-
? path.join(__dirname$1, '..', 'src', 'protos')
|
|
5800
|
-
: path.join(__dirname$1, '..', 'protos');
|
|
5801
|
-
|
|
5802
6909
|
class JJViewStore {
|
|
5803
6910
|
/**
|
|
5804
6911
|
* @param {any} fs - File system module (isomorphic-git compatible)
|
|
@@ -5807,7 +6914,6 @@ class JJViewStore {
|
|
|
5807
6914
|
constructor(fs, dir) {
|
|
5808
6915
|
this.fs = fs;
|
|
5809
6916
|
this.dir = dir;
|
|
5810
|
-
this.protoPath = path.join(protoDir, 'simple_op_store.proto');
|
|
5811
6917
|
}
|
|
5812
6918
|
|
|
5813
6919
|
/**
|
|
@@ -5819,17 +6925,17 @@ class JJViewStore {
|
|
|
5819
6925
|
*/
|
|
5820
6926
|
async writeView(viewId, headIds, wcCommitIds = {}) {
|
|
5821
6927
|
// Load protobuf schema
|
|
5822
|
-
const root =
|
|
6928
|
+
const root = getSimpleOpStoreRoot();
|
|
5823
6929
|
const View = root.lookupType('simple_op_store.View');
|
|
5824
6930
|
|
|
5825
6931
|
// Convert hex IDs to bytes
|
|
5826
|
-
const headBuffers = headIds.map((id) =>
|
|
6932
|
+
const headBuffers = headIds.map((id) => hexToBytes(id));
|
|
5827
6933
|
|
|
5828
6934
|
// Convert wcCommitIds object to map
|
|
5829
6935
|
/** @type {Record<string, any>} */
|
|
5830
6936
|
const wcCommitIdsMap = {};
|
|
5831
6937
|
for (const [workspace, commitId] of Object.entries(wcCommitIds)) {
|
|
5832
|
-
wcCommitIdsMap[workspace] =
|
|
6938
|
+
wcCommitIdsMap[workspace] = hexToBytes(commitId);
|
|
5833
6939
|
}
|
|
5834
6940
|
|
|
5835
6941
|
// Create view message (use camelCase for protobufjs)
|
|
@@ -5870,7 +6976,7 @@ class JJViewStore {
|
|
|
5870
6976
|
const buffer = await this.fs.promises.readFile(viewPath);
|
|
5871
6977
|
|
|
5872
6978
|
// Load protobuf schema
|
|
5873
|
-
const root =
|
|
6979
|
+
const root = getSimpleOpStoreRoot();
|
|
5874
6980
|
const View = root.lookupType('simple_op_store.View');
|
|
5875
6981
|
|
|
5876
6982
|
// Decode
|
|
@@ -6566,14 +7672,14 @@ class IsomorphicGitBackend {
|
|
|
6566
7672
|
const jjRepo = `${jjDir}/repo`;
|
|
6567
7673
|
|
|
6568
7674
|
// Create directory structure
|
|
6569
|
-
await this.fs
|
|
6570
|
-
await this.fs
|
|
6571
|
-
await this.fs
|
|
6572
|
-
await this.fs
|
|
6573
|
-
await this.fs
|
|
6574
|
-
await this.fs
|
|
6575
|
-
await this.fs
|
|
6576
|
-
await this.fs
|
|
7675
|
+
await mkdirp(this.fs, `${jjRepo}/store`);
|
|
7676
|
+
await mkdirp(this.fs, `${jjRepo}/store/extra/heads`);
|
|
7677
|
+
await mkdirp(this.fs, `${jjRepo}/op_store/operations`);
|
|
7678
|
+
await mkdirp(this.fs, `${jjRepo}/op_store/views`);
|
|
7679
|
+
await mkdirp(this.fs, `${jjRepo}/op_heads/heads`);
|
|
7680
|
+
await mkdirp(this.fs, `${jjRepo}/index`);
|
|
7681
|
+
await mkdirp(this.fs, `${jjRepo}/submodule_store`);
|
|
7682
|
+
await mkdirp(this.fs, `${jjDir}/working_copy`);
|
|
6577
7683
|
|
|
6578
7684
|
// Write type files
|
|
6579
7685
|
await this.fs.promises.writeFile(`${jjRepo}/store/type`, 'git');
|
|
@@ -6600,8 +7706,8 @@ class IsomorphicGitBackend {
|
|
|
6600
7706
|
*/
|
|
6601
7707
|
async _createInitialJJState() {
|
|
6602
7708
|
// Generate unique IDs for initial state
|
|
6603
|
-
const operationId =
|
|
6604
|
-
const viewId =
|
|
7709
|
+
const operationId = randomHex(64); // 512-bit = 128 hex chars
|
|
7710
|
+
const viewId = randomHex(64);
|
|
6605
7711
|
const emptyTreeId = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; // Git's empty tree hash
|
|
6606
7712
|
|
|
6607
7713
|
// Get the initial commit from Git
|
|
@@ -6613,7 +7719,7 @@ class IsomorphicGitBackend {
|
|
|
6613
7719
|
ref: 'HEAD',
|
|
6614
7720
|
});
|
|
6615
7721
|
if (headOid) {
|
|
6616
|
-
initialCommitId =
|
|
7722
|
+
initialCommitId = randomHex(64);
|
|
6617
7723
|
}
|
|
6618
7724
|
} catch (error) {
|
|
6619
7725
|
// No initial commit yet
|
|
@@ -6741,60 +7847,320 @@ async function createJJ(options) {
|
|
|
6741
7847
|
const workspaces = new WorkspaceManager(storage, fs, dir);
|
|
6742
7848
|
const userConfig = new UserConfig(storage);
|
|
6743
7849
|
|
|
6744
|
-
/**
|
|
6745
|
-
* Helper to get user info for oplog operations
|
|
6746
|
-
* @returns {Promise<import('../types').OperationUser>}
|
|
6747
|
-
*/
|
|
6748
|
-
const getUserOplogInfo = async () => {
|
|
6749
|
-
await userConfig.load();
|
|
6750
|
-
const user = /** @type {any} */ (userConfig.getUser());
|
|
6751
|
-
return { name: user.name, email: user.email, hostname: 'localhost' };
|
|
7850
|
+
/**
|
|
7851
|
+
* Helper to get user info for oplog operations
|
|
7852
|
+
* @returns {Promise<import('../types').OperationUser>}
|
|
7853
|
+
*/
|
|
7854
|
+
const getUserOplogInfo = async () => {
|
|
7855
|
+
await userConfig.load();
|
|
7856
|
+
const user = /** @type {any} */ (userConfig.getUser());
|
|
7857
|
+
return { name: user.name, email: user.email, hostname: 'localhost' };
|
|
7858
|
+
};
|
|
7859
|
+
|
|
7860
|
+
/**
|
|
7861
|
+
* Helper to snapshot current filesystem state
|
|
7862
|
+
* This is called BEFORE every operation to enable undo
|
|
7863
|
+
* @returns {Promise<Record<string, string>>}
|
|
7864
|
+
*/
|
|
7865
|
+
const snapshotFilesystem = async () => {
|
|
7866
|
+
/** @type {Record<string, string>} */
|
|
7867
|
+
const fileSnapshot = {};
|
|
7868
|
+
|
|
7869
|
+
try {
|
|
7870
|
+
const trackedFiles = await workingCopy.listFiles();
|
|
7871
|
+
|
|
7872
|
+
// Safeguards to prevent excessive memory usage
|
|
7873
|
+
const MAX_FILE_SIZE = 1 * 1024 * 1024; // 1MB per file
|
|
7874
|
+
const MAX_TOTAL_SIZE = 10 * 1024 * 1024; // 10MB total snapshot size
|
|
7875
|
+
let totalSnapshotSize = 0;
|
|
7876
|
+
|
|
7877
|
+
for (const filePath of trackedFiles) {
|
|
7878
|
+
try {
|
|
7879
|
+
const fullPath = join(dir, filePath);
|
|
7880
|
+
const stats = await fs.promises.stat(fullPath);
|
|
7881
|
+
|
|
7882
|
+
// Skip files that are too large
|
|
7883
|
+
if (stats.size > MAX_FILE_SIZE) {
|
|
7884
|
+
continue;
|
|
7885
|
+
}
|
|
7886
|
+
|
|
7887
|
+
// Stop if total snapshot size would exceed limit
|
|
7888
|
+
if (totalSnapshotSize + stats.size > MAX_TOTAL_SIZE) {
|
|
7889
|
+
break;
|
|
7890
|
+
}
|
|
7891
|
+
|
|
7892
|
+
const content = await fs.promises.readFile(fullPath, 'utf8');
|
|
7893
|
+
fileSnapshot[filePath] = content;
|
|
7894
|
+
totalSnapshotSize += stats.size;
|
|
7895
|
+
} catch (error) {
|
|
7896
|
+
// Skip files that can't be read (binary, deleted, etc.)
|
|
7897
|
+
}
|
|
7898
|
+
}
|
|
7899
|
+
} catch (error) {
|
|
7900
|
+
// If we can't list files, return empty snapshot
|
|
7901
|
+
}
|
|
7902
|
+
|
|
7903
|
+
return fileSnapshot;
|
|
7904
|
+
};
|
|
7905
|
+
|
|
7906
|
+
/**
|
|
7907
|
+
* Fully sync the working directory to a target file snapshot: writes
|
|
7908
|
+
* every file the target has (create/update), AND deletes every currently
|
|
7909
|
+
* tracked file the target does NOT have.
|
|
7910
|
+
*
|
|
7911
|
+
* Both edit() and new() used to only overlay the target's files on top of
|
|
7912
|
+
* whatever was already on disk, so a file that existed in the previously
|
|
7913
|
+
* checked-out change but not in the target leaked into the next
|
|
7914
|
+
* snapshot — see issue #30. undo()/redo() had the same gap restoring
|
|
7915
|
+
* file state — see issue #29. This is the one place that does it
|
|
7916
|
+
* correctly, used by all four.
|
|
7917
|
+
*
|
|
7918
|
+
* @param {Record<string, string>} targetSnapshot - path -> file content
|
|
7919
|
+
* @returns {Promise<void>}
|
|
7920
|
+
*/
|
|
7921
|
+
const syncWorkingCopyFiles = async (targetSnapshot) => {
|
|
7922
|
+
const snapshot = targetSnapshot || {};
|
|
7923
|
+
const currentFiles = await workingCopy.listFiles();
|
|
7924
|
+
|
|
7925
|
+
// Remove files that aren't part of the target.
|
|
7926
|
+
for (const filePath of currentFiles) {
|
|
7927
|
+
if (!(filePath in snapshot)) {
|
|
7928
|
+
try {
|
|
7929
|
+
const fullPath = join(dir, filePath);
|
|
7930
|
+
await fs.promises.unlink(fullPath);
|
|
7931
|
+
} catch (error) {
|
|
7932
|
+
// Already gone / inaccessible — fine, we just want it absent.
|
|
7933
|
+
}
|
|
7934
|
+
await workingCopy.untrackFile(filePath);
|
|
7935
|
+
}
|
|
7936
|
+
}
|
|
7937
|
+
|
|
7938
|
+
// Write/update every file the target has.
|
|
7939
|
+
for (const [filePath, content] of Object.entries(snapshot)) {
|
|
7940
|
+
const fullPath = join(dir, filePath);
|
|
7941
|
+
const pathParts = filePath.split('/');
|
|
7942
|
+
if (pathParts.length > 1) {
|
|
7943
|
+
const fullDirPath = join(dir, pathParts.slice(0, -1).join('/'));
|
|
7944
|
+
await mkdirp(fs, fullDirPath);
|
|
7945
|
+
}
|
|
7946
|
+
await fs.promises.writeFile(fullPath, content, 'utf8');
|
|
7947
|
+
|
|
7948
|
+
const stats = await fs.promises.stat(fullPath);
|
|
7949
|
+
await workingCopy.trackFile(filePath, {
|
|
7950
|
+
mtime: stats.mtime,
|
|
7951
|
+
size: stats.size,
|
|
7952
|
+
mode: stats.mode,
|
|
7953
|
+
});
|
|
7954
|
+
}
|
|
7955
|
+
};
|
|
7956
|
+
|
|
7957
|
+
/**
|
|
7958
|
+
* Compute the ChangeGraph/file/conflicts state that existed right after
|
|
7959
|
+
* `landingOp` finished — i.e. what undo()/redo()/operations.restore()
|
|
7960
|
+
* need to re-establish when winding the repository forward or back to
|
|
7961
|
+
* that point (see issue #29).
|
|
7962
|
+
*
|
|
7963
|
+
* Every operation that mutates EXISTING change records in place records
|
|
7964
|
+
* (additively — most operations touch none of this and that's fine,
|
|
7965
|
+
* there's nothing to reverse for them):
|
|
7966
|
+
* - changeSnapshot: { [changeId]: fullRecordBeforeThisOpRan }
|
|
7967
|
+
* - view.fileSnapshot: on-disk file state right BEFORE this op ran
|
|
7968
|
+
* - conflictsSnapshot: ConflictModel state right BEFORE this op ran
|
|
7969
|
+
*
|
|
7970
|
+
* Deliberately NOT reverted: changes an operation newly *created* (via
|
|
7971
|
+
* graph.addChange() — new(), squash()'s synthetic empty working-copy
|
|
7972
|
+
* change, ...). Undoing their creation only un-refs them (restores the
|
|
7973
|
+
* working-copy pointer/heads that made them reachable) — it does not
|
|
7974
|
+
* delete the change record — matching real jj, where an "unreachable"
|
|
7975
|
+
* change stays resolvable by its change id (hidden, not gone) until
|
|
7976
|
+
* explicitly gc'd, and this package's own history-editing tests rely on
|
|
7977
|
+
* exactly that: rebasing onto a change created by a since-undone new()
|
|
7978
|
+
* must still work.
|
|
7979
|
+
*
|
|
7980
|
+
* "The state right after landingOp" is therefore exactly what the FIRST
|
|
7981
|
+
* operation after landingOp recorded as ITS "before" state, merged across
|
|
7982
|
+
* every operation between landingOp and the current head in case
|
|
7983
|
+
* different operations touched different changeIds (a single op's own
|
|
7984
|
+
* snapshot only covers what IT touched). Concretely, per field:
|
|
7985
|
+
* - changeSnapshot: first (earliest / closest-to-landingOp) entry per
|
|
7986
|
+
* changeId wins — later entries in the range reflect that changeId's
|
|
7987
|
+
* state at a LATER point than we're restoring to.
|
|
7988
|
+
* - fileSnapshot / conflictsSnapshot: the first op in the range that
|
|
7989
|
+
* recorded one, since whole-state snapshots aren't per-changeId and
|
|
7990
|
+
* an op that didn't record one simply didn't change that state.
|
|
7991
|
+
*
|
|
7992
|
+
* This same helper backs undo() (landingOp = the parent of whatever's
|
|
7993
|
+
* being undone), redo() (landingOp = the operation a prior undo()
|
|
7994
|
+
* reverted), and operations.restore() (landingOp = the target operation
|
|
7995
|
+
* itself) — one reversal primitive, not three.
|
|
7996
|
+
*
|
|
7997
|
+
* @param {Array<any>} ops - Full chronological operation list (oplog.list())
|
|
7998
|
+
* @param {any} landingOp - The operation whose resulting state to recompute
|
|
7999
|
+
* @returns {{
|
|
8000
|
+
* changeSnapshotToApply: Record<string, any>,
|
|
8001
|
+
* fileSnapshot: Record<string, string>|undefined,
|
|
8002
|
+
* conflictsSnapshot: any,
|
|
8003
|
+
* }}
|
|
8004
|
+
*/
|
|
8005
|
+
const computeGraphReversal = (/** @type {any[]} */ ops, /** @type {any} */ landingOp) => {
|
|
8006
|
+
const landingIndex = ops.findIndex((op) => op.id === landingOp.id);
|
|
8007
|
+
|
|
8008
|
+
/** @type {Record<string, any>} */
|
|
8009
|
+
const changeSnapshotToApply = {};
|
|
8010
|
+
/** @type {Record<string, string>|undefined} */
|
|
8011
|
+
let fileSnapshot;
|
|
8012
|
+
/** @type {any} */
|
|
8013
|
+
let conflictsSnapshot;
|
|
8014
|
+
|
|
8015
|
+
for (let i = landingIndex + 1; i < ops.length; i++) {
|
|
8016
|
+
const op = ops[i];
|
|
8017
|
+
|
|
8018
|
+
if (op.changeSnapshot) {
|
|
8019
|
+
for (const [changeId, snapshot] of Object.entries(op.changeSnapshot)) {
|
|
8020
|
+
if (!(changeId in changeSnapshotToApply)) {
|
|
8021
|
+
changeSnapshotToApply[changeId] = snapshot;
|
|
8022
|
+
}
|
|
8023
|
+
}
|
|
8024
|
+
}
|
|
8025
|
+
|
|
8026
|
+
// `!= null` (not `!== undefined`) on purpose: some operations record
|
|
8027
|
+
// an explicit `null` for "I ran, and I deliberately took no snapshot"
|
|
8028
|
+
// (e.g. describe() of a non-working-copy revision) — that's exactly
|
|
8029
|
+
// as uninformative as never having set the field, so keep scanning
|
|
8030
|
+
// rather than "finding" a null snapshot.
|
|
8031
|
+
if (fileSnapshot === undefined && op.view && op.view.fileSnapshot != null) {
|
|
8032
|
+
fileSnapshot = op.view.fileSnapshot;
|
|
8033
|
+
}
|
|
8034
|
+
|
|
8035
|
+
if (conflictsSnapshot === undefined && op.conflictsSnapshot != null) {
|
|
8036
|
+
conflictsSnapshot = op.conflictsSnapshot;
|
|
8037
|
+
}
|
|
8038
|
+
}
|
|
8039
|
+
|
|
8040
|
+
return { changeSnapshotToApply, fileSnapshot, conflictsSnapshot };
|
|
6752
8041
|
};
|
|
6753
8042
|
|
|
6754
8043
|
/**
|
|
6755
|
-
*
|
|
6756
|
-
*
|
|
6757
|
-
*
|
|
8044
|
+
* Apply a reversal computed by computeGraphReversal() to the live graph,
|
|
8045
|
+
* working-copy files, and conflict state. Does NOT touch the working-copy
|
|
8046
|
+
* pointer or bookmarks — callers set those from the landing op's own
|
|
8047
|
+
* `view` directly (pointers, unlike file/graph/conflict content, are
|
|
8048
|
+
* already correct as recorded, see computeGraphReversal's doc comment).
|
|
8049
|
+
*
|
|
8050
|
+
* @param {ReturnType<typeof computeGraphReversal>} reversal
|
|
8051
|
+
* @returns {Promise<void>}
|
|
6758
8052
|
*/
|
|
6759
|
-
const
|
|
6760
|
-
|
|
6761
|
-
const
|
|
6762
|
-
|
|
6763
|
-
|
|
6764
|
-
|
|
8053
|
+
const applyGraphReversal = async (reversal) => {
|
|
8054
|
+
await graph.load();
|
|
8055
|
+
for (const [changeId, snapshot] of Object.entries(reversal.changeSnapshotToApply)) {
|
|
8056
|
+
if (await graph.getChange(changeId)) {
|
|
8057
|
+
// Bypass the git-sync middleware (baseGraph, not the wrapped
|
|
8058
|
+
// `graph`): undo/restore are pure reverts, and `snapshot` already
|
|
8059
|
+
// carries the change's ORIGINAL commitId from before the reverted
|
|
8060
|
+
// operation(s) ran. Going through graph.updateChange() would fire
|
|
8061
|
+
// onUpdateChange -> syncChangeToGit(), which mints a brand-new
|
|
8062
|
+
// commit (and thus a new commitId) from the CURRENT tree instead
|
|
8063
|
+
// of restoring the one that was actually there — see issue #37(c):
|
|
8064
|
+
// "undo/restore give reverted commits new commit IDs". Writing the
|
|
8065
|
+
// snapshot verbatim via baseGraph puts the original commitId back.
|
|
8066
|
+
await baseGraph.updateChange(snapshot);
|
|
8067
|
+
}
|
|
8068
|
+
}
|
|
6765
8069
|
|
|
6766
|
-
|
|
6767
|
-
|
|
6768
|
-
|
|
6769
|
-
let totalSnapshotSize = 0;
|
|
8070
|
+
if (reversal.fileSnapshot !== undefined) {
|
|
8071
|
+
await syncWorkingCopyFiles(reversal.fileSnapshot);
|
|
8072
|
+
}
|
|
6770
8073
|
|
|
6771
|
-
|
|
6772
|
-
|
|
6773
|
-
|
|
6774
|
-
|
|
8074
|
+
await conflicts.load();
|
|
8075
|
+
if (reversal.conflictsSnapshot !== undefined) {
|
|
8076
|
+
conflicts.conflicts = new Map(Object.entries(reversal.conflictsSnapshot.conflicts || {}));
|
|
8077
|
+
conflicts.fileConflicts = new Map(
|
|
8078
|
+
Object.entries(reversal.conflictsSnapshot.fileConflicts || {})
|
|
8079
|
+
);
|
|
8080
|
+
await conflicts.save();
|
|
8081
|
+
}
|
|
8082
|
+
// Else: no operation in the reverted range touched conflicts — leave
|
|
8083
|
+
// current conflict state alone. (Previously undo() unconditionally
|
|
8084
|
+
// cleared ALL conflicts whenever the op being undone hadn't recorded
|
|
8085
|
+
// its own conflictsSnapshot, which is nearly every non-merge() op —
|
|
8086
|
+
// undoing an unrelated describe() would silently wipe conflicts from
|
|
8087
|
+
// an earlier, unrelated merge(). Fixed as part of issue #29.)
|
|
8088
|
+
};
|
|
6775
8089
|
|
|
6776
|
-
|
|
6777
|
-
|
|
6778
|
-
|
|
6779
|
-
|
|
8090
|
+
/**
|
|
8091
|
+
* Re-parent every child of `changeId` onto `newParents` instead — used
|
|
8092
|
+
* when a change is being elided from the graph (abandon(), issue #30;
|
|
8093
|
+
* converge(), issue #32) so its children don't end up pointing at
|
|
8094
|
+
* something no longer meant to be there. Mutates `changeSnapshot` in
|
|
8095
|
+
* place, adding each touched child's PRE-reparent record so the caller's
|
|
8096
|
+
* own oplog entry lets undo()/redo()/operations.restore() reverse this
|
|
8097
|
+
* too (the same mechanism the caller already uses for the elided
|
|
8098
|
+
* change's own snapshot).
|
|
8099
|
+
*
|
|
8100
|
+
* @param {string} changeId - The change being elided
|
|
8101
|
+
* @param {string[]} newParents - What its children should point at instead
|
|
8102
|
+
* @param {Record<string, any>} changeSnapshot - Accumulator to add each touched child's pre-state to
|
|
8103
|
+
* @returns {Promise<void>}
|
|
8104
|
+
*/
|
|
8105
|
+
const reparentChildrenOnto = async (changeId, newParents, changeSnapshot) => {
|
|
8106
|
+
const childIds = graph.getChildren(changeId);
|
|
8107
|
+
for (const childId of childIds) {
|
|
8108
|
+
const child = await graph.getChange(childId);
|
|
8109
|
+
if (!child) continue;
|
|
8110
|
+
changeSnapshot[childId] = structuredClone(child);
|
|
8111
|
+
const reparented = child.parents.flatMap((/** @type {string} */ p) =>
|
|
8112
|
+
p === changeId ? newParents : [p]
|
|
8113
|
+
);
|
|
8114
|
+
// De-dupe (e.g. the child could already have one of the new parents
|
|
8115
|
+
// as another of its own parents) while preserving first-seen order.
|
|
8116
|
+
child.parents = [...new Set(reparented)];
|
|
8117
|
+
await graph.updateChange(child);
|
|
8118
|
+
}
|
|
8119
|
+
};
|
|
6780
8120
|
|
|
6781
|
-
|
|
6782
|
-
|
|
6783
|
-
|
|
6784
|
-
|
|
8121
|
+
/**
|
|
8122
|
+
* Determine which operation a fresh undo() call should revert, matching
|
|
8123
|
+
* real jj's progressive-undo semantics (`jj-undo(1)`: "If used once after
|
|
8124
|
+
* a normal operation, this will undo that last operation by restoring its
|
|
8125
|
+
* parent. If jj undo is used repeatedly, it will restore increasingly
|
|
8126
|
+
* older operations, going further back into the past.").
|
|
8127
|
+
*
|
|
8128
|
+
* The raw op-log parent chain can't answer this directly once undo/redo
|
|
8129
|
+
* bookkeeping operations are mixed into the log: an 'undo' operation's
|
|
8130
|
+
* own parent is the operation it just undid, so naively targeting
|
|
8131
|
+
* `ops[ops.length - 1]` on a SECOND undo() would undo the undo (toggle
|
|
8132
|
+
* back to the future) instead of stepping one further step into the past
|
|
8133
|
+
* — this was issue #29's "undo() twice only undoes the undo" bug.
|
|
8134
|
+
*
|
|
8135
|
+
* @param {Array<any>} ops - Full chronological operation list
|
|
8136
|
+
* @returns {any} The operation object undo() should revert
|
|
8137
|
+
*/
|
|
8138
|
+
const resolveUndoTarget = (ops) => {
|
|
8139
|
+
const head = ops[ops.length - 1];
|
|
8140
|
+
const findOp = (/** @type {any} */ id) => ops.find((op) => op.id === id);
|
|
8141
|
+
|
|
8142
|
+
if (head.eventType === 'undo') {
|
|
8143
|
+
// We're stepping further into the past: what should be undone now is
|
|
8144
|
+
// the operation right BEFORE the one `head` already undid — not
|
|
8145
|
+
// head's own parent, which IS the operation head undid (targeting it
|
|
8146
|
+
// again would just toggle back to the present).
|
|
8147
|
+
const previouslyUndone = head.targetOpId && findOp(head.targetOpId);
|
|
8148
|
+
const stepBack = previouslyUndone && findOp(previouslyUndone.parents[0]);
|
|
8149
|
+
return stepBack || previouslyUndone || head;
|
|
8150
|
+
}
|
|
6785
8151
|
|
|
6786
|
-
|
|
6787
|
-
|
|
6788
|
-
|
|
6789
|
-
|
|
6790
|
-
|
|
6791
|
-
|
|
6792
|
-
|
|
6793
|
-
|
|
6794
|
-
|
|
8152
|
+
if (head.eventType === 'redo') {
|
|
8153
|
+
// A redo re-established some earlier operation as current; a fresh
|
|
8154
|
+
// undo right after it should behave exactly as if that operation
|
|
8155
|
+
// were freshly current and had never been touched by undo/redo at
|
|
8156
|
+
// all — i.e. undo it again (real jj: redo "moves in the direction of
|
|
8157
|
+
// the future"; undoing right after simply resumes stepping back).
|
|
8158
|
+
const redoneUndo = head.redoOf && findOp(head.redoOf);
|
|
8159
|
+
const target = redoneUndo && redoneUndo.targetOpId && findOp(redoneUndo.targetOpId);
|
|
8160
|
+
return target || head;
|
|
6795
8161
|
}
|
|
6796
8162
|
|
|
6797
|
-
return
|
|
8163
|
+
return head;
|
|
6798
8164
|
};
|
|
6799
8165
|
|
|
6800
8166
|
/**
|
|
@@ -6981,6 +8347,25 @@ async function createJJ(options) {
|
|
|
6981
8347
|
}
|
|
6982
8348
|
},
|
|
6983
8349
|
|
|
8350
|
+
// Used by undo()/redo()/operations.restore() to reverse an
|
|
8351
|
+
// addChange() when winding the graph back — see issue #29. No Git
|
|
8352
|
+
// sync hook: a change created after the operation being reverted was
|
|
8353
|
+
// never pushed anywhere to unwind, and the Git backend has no
|
|
8354
|
+
// "forget this commit" operation (nor should it — Git objects are
|
|
8355
|
+
// content-addressed and harmless to leave orphaned).
|
|
8356
|
+
deleteChange: (/** @type {any} */ changeId) => baseGraph.deleteChange(changeId),
|
|
8357
|
+
|
|
8358
|
+
// See issue #32 / converge(): a divergent copy is another commit
|
|
8359
|
+
// claiming an existing changeId; no Git sync hook, for the same
|
|
8360
|
+
// reason addChange()'s primary copy already goes through
|
|
8361
|
+
// onAddChange() when it's first created — the divergent copy needs
|
|
8362
|
+
// no additional sync of its own.
|
|
8363
|
+
addDivergentCopy: (/** @type {any} */ change) => baseGraph.addDivergentCopy(change),
|
|
8364
|
+
getDivergentSiblings: (/** @type {any} */ changeId) =>
|
|
8365
|
+
baseGraph.getDivergentSiblings(changeId),
|
|
8366
|
+
deleteDivergentCopy: (/** @type {any} */ changeId, /** @type {any} */ commitId) =>
|
|
8367
|
+
baseGraph.deleteDivergentCopy(changeId, commitId),
|
|
8368
|
+
|
|
6984
8369
|
// Delegate other operations
|
|
6985
8370
|
init: () => baseGraph.init(),
|
|
6986
8371
|
};
|
|
@@ -7138,6 +8523,20 @@ async function createJJ(options) {
|
|
|
7138
8523
|
/**
|
|
7139
8524
|
* Write a file to the working copy
|
|
7140
8525
|
*
|
|
8526
|
+
* Note (issue #30): this does NOT update the working-copy change's
|
|
8527
|
+
* `commitId`/`fileSnapshot` — those stay as they were until the next
|
|
8528
|
+
* command that snapshots the working copy (describe(), new(), edit(),
|
|
8529
|
+
* status(), or any other call that goes through
|
|
8530
|
+
* autoSnapshotWorkingCopy()/snapshot()). This is intentional, not a
|
|
8531
|
+
* bug: jj has no staging area, but it also doesn't recompute a git
|
|
8532
|
+
* tree/commit hash on every single file write (that would mean
|
|
8533
|
+
* hashing on every keystroke of an external editor) — real jj only
|
|
8534
|
+
* takes a fresh working-copy snapshot when an actual `jj` command
|
|
8535
|
+
* runs. write() is the equivalent of editing a file with your OS's own
|
|
8536
|
+
* tools outside of jj entirely; describe()/new()/edit()/status()/etc.
|
|
8537
|
+
* are the equivalent of running a jj command, which is what triggers
|
|
8538
|
+
* the (auto) snapshot in both real jj and here.
|
|
8539
|
+
*
|
|
7141
8540
|
* @param {Record<string, any>} args - Arguments
|
|
7142
8541
|
* @returns {Promise<Object>} File information including path, size, and mode
|
|
7143
8542
|
*/
|
|
@@ -7161,7 +8560,7 @@ async function createJJ(options) {
|
|
|
7161
8560
|
if (pathParts.length > 1) {
|
|
7162
8561
|
const dirPath = pathParts.slice(0, -1).join('/');
|
|
7163
8562
|
const fullDirPath = `${dir}/${dirPath}`;
|
|
7164
|
-
await fs
|
|
8563
|
+
await mkdirp(fs, fullDirPath);
|
|
7165
8564
|
}
|
|
7166
8565
|
|
|
7167
8566
|
// Write the file
|
|
@@ -7216,10 +8615,10 @@ async function createJJ(options) {
|
|
|
7216
8615
|
});
|
|
7217
8616
|
}
|
|
7218
8617
|
|
|
7219
|
-
const fullPath =
|
|
8618
|
+
const fullPath = join(dir, args.path);
|
|
7220
8619
|
try {
|
|
7221
8620
|
if (encoding === 'utf-8' || encoding === 'utf8') {
|
|
7222
|
-
return await fs.promises.readFile(fullPath, '
|
|
8621
|
+
return await fs.promises.readFile(fullPath, 'utf8');
|
|
7223
8622
|
} else {
|
|
7224
8623
|
return await fs.promises.readFile(fullPath);
|
|
7225
8624
|
}
|
|
@@ -7384,7 +8783,7 @@ async function createJJ(options) {
|
|
|
7384
8783
|
|
|
7385
8784
|
// Reading from working copy
|
|
7386
8785
|
if (!args.changeId) {
|
|
7387
|
-
const fullPath =
|
|
8786
|
+
const fullPath = join(dir, args.path);
|
|
7388
8787
|
try {
|
|
7389
8788
|
const stream = fs.createReadStream(
|
|
7390
8789
|
fullPath,
|
|
@@ -7434,14 +8833,14 @@ async function createJJ(options) {
|
|
|
7434
8833
|
});
|
|
7435
8834
|
}
|
|
7436
8835
|
|
|
7437
|
-
const fullPath =
|
|
8836
|
+
const fullPath = join(dir, args.path);
|
|
7438
8837
|
|
|
7439
8838
|
// Ensure directory exists
|
|
7440
8839
|
const pathParts = args.path.split('/');
|
|
7441
8840
|
if (pathParts.length > 1) {
|
|
7442
8841
|
const dirPath = pathParts.slice(0, -1).join('/');
|
|
7443
|
-
const fullDirPath =
|
|
7444
|
-
await fs
|
|
8842
|
+
const fullDirPath = join(dir, dirPath);
|
|
8843
|
+
await mkdirp(fs, fullDirPath);
|
|
7445
8844
|
}
|
|
7446
8845
|
|
|
7447
8846
|
// Create writable stream
|
|
@@ -7554,7 +8953,7 @@ async function createJJ(options) {
|
|
|
7554
8953
|
// Ensure destination directory exists
|
|
7555
8954
|
const toDir = toPath.substring(0, toPath.lastIndexOf('/'));
|
|
7556
8955
|
try {
|
|
7557
|
-
await fs
|
|
8956
|
+
await mkdirp(fs, toDir);
|
|
7558
8957
|
} catch (error) {
|
|
7559
8958
|
throw new JJError(
|
|
7560
8959
|
'FILE_SYSTEM_ERROR',
|
|
@@ -7672,6 +9071,51 @@ async function createJJ(options) {
|
|
|
7672
9071
|
// TODO: Prevent creating cycles in the change graph
|
|
7673
9072
|
// For now, we'll allow it but it should be validated
|
|
7674
9073
|
|
|
9074
|
+
// Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
|
|
9075
|
+
// reverse the reparenting — see issue #29.
|
|
9076
|
+
const changeSnapshotBefore = structuredClone(change);
|
|
9077
|
+
|
|
9078
|
+
// Three-way merge against the OLD base before reparenting (issue #31):
|
|
9079
|
+
// real jj's rebase re-derives the rebased change's content as
|
|
9080
|
+
// (old base, old commit, new base) — if a sibling changed the same
|
|
9081
|
+
// lines differently, that's a genuine conflict, not something
|
|
9082
|
+
// reparenting-and-copying-verbatim can silently paper over. This
|
|
9083
|
+
// reuses the exact same detectConflicts() machinery merge() already
|
|
9084
|
+
// uses (see merge(), a few hundred lines down) rather than
|
|
9085
|
+
// duplicating three-way-merge logic a third time in this codebase.
|
|
9086
|
+
const oldParentId = change.parents[0];
|
|
9087
|
+
const oldParentChange = oldParentId ? await graph.getChange(oldParentId) : null;
|
|
9088
|
+
const baseFiles = new Map(
|
|
9089
|
+
Object.entries((oldParentChange && oldParentChange.fileSnapshot) || {})
|
|
9090
|
+
);
|
|
9091
|
+
const leftFiles = new Map(Object.entries(change.fileSnapshot || {}));
|
|
9092
|
+
const rightFiles = new Map(Object.entries(newParentChange.fileSnapshot || {}));
|
|
9093
|
+
|
|
9094
|
+
await conflicts.load();
|
|
9095
|
+
const conflictsSnapshot = {
|
|
9096
|
+
conflicts: Object.fromEntries(conflicts.conflicts),
|
|
9097
|
+
fileConflicts: Object.fromEntries(conflicts.fileConflicts),
|
|
9098
|
+
};
|
|
9099
|
+
|
|
9100
|
+
const detectedConflicts = oldParentChange
|
|
9101
|
+
? await conflicts.detectConflicts({
|
|
9102
|
+
baseFiles,
|
|
9103
|
+
leftFiles,
|
|
9104
|
+
rightFiles,
|
|
9105
|
+
drivers: {},
|
|
9106
|
+
// Not necessarily the checked-out working copy, so don't let
|
|
9107
|
+
// merge drivers write to disk on its behalf.
|
|
9108
|
+
workingCopyDir: /** @type {any} */ (null),
|
|
9109
|
+
baseChange: oldParentId,
|
|
9110
|
+
leftChange: changeId,
|
|
9111
|
+
rightChange: newParent,
|
|
9112
|
+
})
|
|
9113
|
+
: [];
|
|
9114
|
+
|
|
9115
|
+
for (const conflict of detectedConflicts) {
|
|
9116
|
+
await conflicts.addConflict(conflict);
|
|
9117
|
+
}
|
|
9118
|
+
|
|
7675
9119
|
// Update parent
|
|
7676
9120
|
change.parents = [newParent];
|
|
7677
9121
|
await graph.updateChange(change);
|
|
@@ -7682,6 +9126,8 @@ async function createJJ(options) {
|
|
|
7682
9126
|
user: await getUserOplogInfo(),
|
|
7683
9127
|
description: `move change ${changeId.slice(0, 8)} to ${newParent.slice(0, 8)}`,
|
|
7684
9128
|
parents: [],
|
|
9129
|
+
changeSnapshot: { [changeId]: changeSnapshotBefore },
|
|
9130
|
+
conflictsSnapshot,
|
|
7685
9131
|
view: {
|
|
7686
9132
|
bookmarks: {},
|
|
7687
9133
|
remoteBookmarks: {},
|
|
@@ -7690,7 +9136,7 @@ async function createJJ(options) {
|
|
|
7690
9136
|
},
|
|
7691
9137
|
});
|
|
7692
9138
|
|
|
7693
|
-
return change;
|
|
9139
|
+
return { ...change, conflicts: detectedConflicts };
|
|
7694
9140
|
},
|
|
7695
9141
|
|
|
7696
9142
|
/**
|
|
@@ -7862,7 +9308,7 @@ async function createJJ(options) {
|
|
|
7862
9308
|
|
|
7863
9309
|
for (const filePath of trackedFiles) {
|
|
7864
9310
|
try {
|
|
7865
|
-
const fullPath =
|
|
9311
|
+
const fullPath = join(dir, filePath);
|
|
7866
9312
|
const stats = await fs.promises.stat(fullPath);
|
|
7867
9313
|
|
|
7868
9314
|
// Skip files that are too large (silently - this is an optimization)
|
|
@@ -7875,7 +9321,7 @@ async function createJJ(options) {
|
|
|
7875
9321
|
break;
|
|
7876
9322
|
}
|
|
7877
9323
|
|
|
7878
|
-
const content = await fs.promises.readFile(fullPath, '
|
|
9324
|
+
const content = await fs.promises.readFile(fullPath, 'utf8');
|
|
7879
9325
|
fileSnapshot[filePath] = content;
|
|
7880
9326
|
totalSnapshotSize += stats.size;
|
|
7881
9327
|
} catch (error) {
|
|
@@ -8073,6 +9519,8 @@ async function createJJ(options) {
|
|
|
8073
9519
|
|
|
8074
9520
|
// Snapshot filesystem BEFORE operation (for undo) unless preserveSnapshot is true
|
|
8075
9521
|
let fileSnapshot;
|
|
9522
|
+
/** @type {Record<string, any>} */
|
|
9523
|
+
const changeSnapshot = {};
|
|
8076
9524
|
if (!args.preserveSnapshot) {
|
|
8077
9525
|
fileSnapshot = await snapshotFilesystem();
|
|
8078
9526
|
|
|
@@ -8081,6 +9529,10 @@ async function createJJ(options) {
|
|
|
8081
9529
|
const currentChangeId = workingCopy.getCurrentChangeId();
|
|
8082
9530
|
const currentChange = await graph.getChange(currentChangeId);
|
|
8083
9531
|
if (currentChange && fileSnapshot) {
|
|
9532
|
+
// Snapshot BEFORE mutating so undo()/redo() can revert this
|
|
9533
|
+
// in-place update too, not just the pointer/new-change creation
|
|
9534
|
+
// below — see issue #29.
|
|
9535
|
+
changeSnapshot[currentChangeId] = structuredClone(currentChange);
|
|
8084
9536
|
currentChange.fileSnapshot = fileSnapshot;
|
|
8085
9537
|
await graph.updateChange(currentChange);
|
|
8086
9538
|
}
|
|
@@ -8108,6 +9560,7 @@ async function createJJ(options) {
|
|
|
8108
9560
|
const user = /** @type {any} */ (userConfig.getUser());
|
|
8109
9561
|
|
|
8110
9562
|
// Initialize with parent's file snapshot
|
|
9563
|
+
/** @type {Record<string, string>} */
|
|
8111
9564
|
let initialSnapshot = {};
|
|
8112
9565
|
if (parents && parents.length > 0) {
|
|
8113
9566
|
const parent = await graph.getChange(parents[0]);
|
|
@@ -8151,10 +9604,19 @@ async function createJJ(options) {
|
|
|
8151
9604
|
// Handle insertBefore: rebase target to have new change as parent
|
|
8152
9605
|
if (args.insertBefore) {
|
|
8153
9606
|
const targetChange = await graph.getChange(args.insertBefore);
|
|
9607
|
+
changeSnapshot[args.insertBefore] = structuredClone(targetChange);
|
|
8154
9608
|
targetChange.parents = [newChangeId];
|
|
8155
9609
|
await graph.updateChange(targetChange);
|
|
8156
9610
|
}
|
|
8157
9611
|
|
|
9612
|
+
// Sync the working directory to the new change's snapshot: write
|
|
9613
|
+
// everything it has AND remove anything it doesn't (issue #30 — this
|
|
9614
|
+
// used to never touch disk at all, so `new({ parents: [...] })`
|
|
9615
|
+
// targeting anything other than the current head silently left the
|
|
9616
|
+
// OLD working copy's files in place instead of checking out the new
|
|
9617
|
+
// parent's).
|
|
9618
|
+
await syncWorkingCopyFiles(initialSnapshot);
|
|
9619
|
+
|
|
8158
9620
|
await workingCopy.setCurrentChange(newChangeId);
|
|
8159
9621
|
|
|
8160
9622
|
// Record operation with filesystem snapshot
|
|
@@ -8167,6 +9629,13 @@ async function createJJ(options) {
|
|
|
8167
9629
|
},
|
|
8168
9630
|
description: `new change ${newChangeId.slice(0, 8)}`,
|
|
8169
9631
|
parents: [],
|
|
9632
|
+
// See issue #29: lets undo()/redo()/operations.restore() reverse
|
|
9633
|
+
// the existing records this op updated in place (the old
|
|
9634
|
+
// working-copy change's refreshed fileSnapshot, and insertBefore's
|
|
9635
|
+
// target's reparented parents). The newly created change itself
|
|
9636
|
+
// (newChangeId) is intentionally NOT torn down on undo — see
|
|
9637
|
+
// computeGraphReversal's doc comment.
|
|
9638
|
+
changeSnapshot,
|
|
8170
9639
|
view: {
|
|
8171
9640
|
bookmarks: {},
|
|
8172
9641
|
remoteBookmarks: {},
|
|
@@ -8547,6 +10016,13 @@ async function createJJ(options) {
|
|
|
8547
10016
|
|
|
8548
10017
|
const previousChangeId = workingCopy.getCurrentChangeId();
|
|
8549
10018
|
|
|
10019
|
+
/** @type {Record<string, any>} */
|
|
10020
|
+
const changeSnapshot = {};
|
|
10021
|
+
|
|
10022
|
+
// Capture disk state right before we touch anything, for undo() —
|
|
10023
|
+
// see issue #29.
|
|
10024
|
+
const fileSnapshotBeforeSwitch = await snapshotFilesystem();
|
|
10025
|
+
|
|
8550
10026
|
// CRITICAL FIX: Clean working directory BEFORE snapshotting to prevent cross-branch pollution
|
|
8551
10027
|
// Remove files that don't belong to the current changeId before we snapshot it
|
|
8552
10028
|
if (previousChangeId !== args.changeId) {
|
|
@@ -8560,7 +10036,7 @@ async function createJJ(options) {
|
|
|
8560
10036
|
for (const filePath of workingDirFiles) {
|
|
8561
10037
|
if (!previousSnapshot[filePath]) {
|
|
8562
10038
|
try {
|
|
8563
|
-
const fullPath =
|
|
10039
|
+
const fullPath = join(dir, filePath);
|
|
8564
10040
|
await fs.promises.unlink(fullPath);
|
|
8565
10041
|
await workingCopy.untrackFile(filePath);
|
|
8566
10042
|
} catch (error) {
|
|
@@ -8568,13 +10044,14 @@ async function createJJ(options) {
|
|
|
8568
10044
|
}
|
|
8569
10045
|
}
|
|
8570
10046
|
}
|
|
8571
|
-
}
|
|
8572
10047
|
|
|
8573
|
-
|
|
8574
|
-
|
|
8575
|
-
|
|
8576
|
-
|
|
8577
|
-
|
|
10048
|
+
// Now snapshot the CLEAN working directory (no pollution!)
|
|
10049
|
+
const currentSnapshot = await snapshotFilesystem();
|
|
10050
|
+
if (currentSnapshot && Object.keys(currentSnapshot).length > 0) {
|
|
10051
|
+
changeSnapshot[previousChangeId] = structuredClone(previousChange);
|
|
10052
|
+
previousChange.fileSnapshot = currentSnapshot;
|
|
10053
|
+
await graph.updateChange(previousChange);
|
|
10054
|
+
}
|
|
8578
10055
|
}
|
|
8579
10056
|
}
|
|
8580
10057
|
|
|
@@ -8587,38 +10064,17 @@ async function createJJ(options) {
|
|
|
8587
10064
|
timestamp: new Date().toISOString(),
|
|
8588
10065
|
});
|
|
8589
10066
|
|
|
8590
|
-
//
|
|
8591
|
-
|
|
8592
|
-
|
|
8593
|
-
|
|
8594
|
-
|
|
8595
|
-
|
|
8596
|
-
|
|
8597
|
-
|
|
8598
|
-
|
|
8599
|
-
|
|
8600
|
-
|
|
8601
|
-
await fs.promises.mkdir(fullDirPath, { recursive: true });
|
|
8602
|
-
}
|
|
8603
|
-
|
|
8604
|
-
// Write file content
|
|
8605
|
-
await fs.promises.writeFile(fullPath, content, 'utf8');
|
|
8606
|
-
|
|
8607
|
-
// Update working copy tracking
|
|
8608
|
-
const stats = await fs.promises.stat(fullPath);
|
|
8609
|
-
await workingCopy.trackFile(filePath, {
|
|
8610
|
-
mtime: stats.mtime,
|
|
8611
|
-
size: stats.size,
|
|
8612
|
-
mode: stats.mode,
|
|
8613
|
-
});
|
|
8614
|
-
} catch (error) {
|
|
8615
|
-
throw new JJError(
|
|
8616
|
-
'FILE_RESTORE_FAILED',
|
|
8617
|
-
`Failed to restore file ${filePath}: ${error.message}`,
|
|
8618
|
-
{ filePath, originalError: error.message }
|
|
8619
|
-
);
|
|
8620
|
-
}
|
|
8621
|
-
}
|
|
10067
|
+
// Fully sync the working directory to the target change's snapshot:
|
|
10068
|
+
// write everything it has AND remove anything it doesn't (issue #30
|
|
10069
|
+
// — this used to only overlay the target's files on top of whatever
|
|
10070
|
+
// was already on disk, so a file present in the PREVIOUS change but
|
|
10071
|
+
// absent from the target leaked into the next snapshot).
|
|
10072
|
+
try {
|
|
10073
|
+
await syncWorkingCopyFiles(change.fileSnapshot || {});
|
|
10074
|
+
} catch (error) {
|
|
10075
|
+
throw new JJError('FILE_RESTORE_FAILED', `Failed to restore files: ${error.message}`, {
|
|
10076
|
+
originalError: error.message,
|
|
10077
|
+
});
|
|
8622
10078
|
}
|
|
8623
10079
|
|
|
8624
10080
|
// Set this change as the working copy
|
|
@@ -8634,11 +10090,13 @@ async function createJJ(options) {
|
|
|
8634
10090
|
},
|
|
8635
10091
|
description: `edit change ${args.changeId.slice(0, 8)}`,
|
|
8636
10092
|
parents: [],
|
|
10093
|
+
changeSnapshot,
|
|
8637
10094
|
view: {
|
|
8638
10095
|
bookmarks: {},
|
|
8639
10096
|
remoteBookmarks: {},
|
|
8640
10097
|
heads: [args.changeId],
|
|
8641
10098
|
workingCopy: args.changeId,
|
|
10099
|
+
fileSnapshot: fileSnapshotBeforeSwitch,
|
|
8642
10100
|
},
|
|
8643
10101
|
});
|
|
8644
10102
|
|
|
@@ -8669,106 +10127,118 @@ async function createJJ(options) {
|
|
|
8669
10127
|
/**
|
|
8670
10128
|
* Undo last operation
|
|
8671
10129
|
*
|
|
10130
|
+
* Reverts the most recent operation — including graph-rewriting ones
|
|
10131
|
+
* (squash/rebase/abandon/new/edit), not just working-copy/file state
|
|
10132
|
+
* (see issue #29). Matches real jj's progressive-undo semantics
|
|
10133
|
+
* (`jj-undo(1)`): called again with nothing else in between, it steps
|
|
10134
|
+
* one further operation into the past rather than undoing itself.
|
|
10135
|
+
*
|
|
8672
10136
|
* @returns {Promise<Object>} Information about the undo including the undone operation and restored state
|
|
8673
10137
|
*/
|
|
8674
10138
|
async undo() {
|
|
8675
|
-
// Get the operation we're about to undo to access its pre-state
|
|
8676
10139
|
await oplog.load();
|
|
8677
10140
|
const ops = await oplog.list();
|
|
8678
10141
|
if (ops.length === 0) {
|
|
8679
10142
|
throw new JJError('NOTHING_TO_UNDO', 'No operations to undo');
|
|
8680
10143
|
}
|
|
8681
10144
|
|
|
8682
|
-
|
|
8683
|
-
const
|
|
8684
|
-
|
|
8685
|
-
const previousView = /** @type {any} */ (await oplog.undo());
|
|
8686
|
-
|
|
8687
|
-
// Restore state from previous view
|
|
8688
|
-
await workingCopy.setCurrentChange(previousView.workingCopy);
|
|
8689
|
-
|
|
8690
|
-
// Restore ChangeGraph content mutated in place by the operation being
|
|
8691
|
-
// undone (e.g. describe() editing change.description) — see issue #12.
|
|
8692
|
-
// currentOp.changeSnapshot holds the affected change record(s) as they
|
|
8693
|
-
// were BEFORE currentOp ran, captured the same way fileSnapshot is.
|
|
8694
|
-
await graph.load();
|
|
8695
|
-
if (currentOp && currentOp.changeSnapshot) {
|
|
8696
|
-
for (const snapshot of Object.values(
|
|
8697
|
-
/** @type {Record<string, any>} */ (currentOp.changeSnapshot)
|
|
8698
|
-
)) {
|
|
8699
|
-
if (snapshot && (await graph.getChange(snapshot.changeId))) {
|
|
8700
|
-
await graph.updateChange(snapshot);
|
|
8701
|
-
}
|
|
8702
|
-
}
|
|
8703
|
-
}
|
|
8704
|
-
|
|
8705
|
-
// Restore filesystem from previous operation's snapshot (taken BEFORE that operation)
|
|
8706
|
-
// This is how JJ snapshots the working copy before every command
|
|
8707
|
-
if (previousView.fileSnapshot) {
|
|
8708
|
-
for (const [filePath, content] of Object.entries(previousView.fileSnapshot)) {
|
|
8709
|
-
try {
|
|
8710
|
-
const fullPath = path.join(dir, filePath);
|
|
8711
|
-
|
|
8712
|
-
// Ensure directory exists
|
|
8713
|
-
const pathParts = filePath.split('/');
|
|
8714
|
-
if (pathParts.length > 1) {
|
|
8715
|
-
const dirPath = pathParts.slice(0, -1).join('/');
|
|
8716
|
-
const fullDirPath = path.join(dir, dirPath);
|
|
8717
|
-
await fs.promises.mkdir(fullDirPath, { recursive: true });
|
|
8718
|
-
}
|
|
8719
|
-
|
|
8720
|
-
// Write file content
|
|
8721
|
-
await fs.promises.writeFile(fullPath, content, 'utf8');
|
|
8722
|
-
} catch (error) {
|
|
8723
|
-
throw new JJError(
|
|
8724
|
-
'UNDO_FILE_RESTORE_FAILED',
|
|
8725
|
-
`Failed to restore file ${filePath} during undo: ${error.message}`,
|
|
8726
|
-
{ filePath, originalError: error.message }
|
|
8727
|
-
);
|
|
8728
|
-
}
|
|
8729
|
-
}
|
|
8730
|
-
}
|
|
10145
|
+
const targetOp = resolveUndoTarget(ops);
|
|
10146
|
+
const landingOpId = targetOp.parents && targetOp.parents[0];
|
|
10147
|
+
const landingOp = landingOpId ? ops.find((op) => op.id === landingOpId) : null;
|
|
8731
10148
|
|
|
8732
|
-
|
|
8733
|
-
|
|
8734
|
-
|
|
8735
|
-
|
|
8736
|
-
|
|
8737
|
-
|
|
8738
|
-
|
|
8739
|
-
Object.entries(currentOp.conflictsSnapshot.fileConflicts || {})
|
|
8740
|
-
);
|
|
8741
|
-
await conflicts.save();
|
|
10149
|
+
/** @type {ReturnType<typeof computeGraphReversal>} */
|
|
10150
|
+
let reversal;
|
|
10151
|
+
/** @type {any} */
|
|
10152
|
+
let landingView;
|
|
10153
|
+
if (landingOp) {
|
|
10154
|
+
reversal = computeGraphReversal(ops, landingOp);
|
|
10155
|
+
landingView = landingOp.view || {};
|
|
8742
10156
|
} else {
|
|
8743
|
-
//
|
|
8744
|
-
|
|
10157
|
+
// Undoing the very first (root) operation: there's nothing before
|
|
10158
|
+
// it to restore to, so land on its own recorded state rather than
|
|
10159
|
+
// throwing (mirrors the previous no-parent fallback).
|
|
10160
|
+
reversal = {
|
|
10161
|
+
changeSnapshotToApply: {},
|
|
10162
|
+
fileSnapshot: undefined,
|
|
10163
|
+
conflictsSnapshot: undefined,
|
|
10164
|
+
};
|
|
10165
|
+
landingView = targetOp.view || {};
|
|
10166
|
+
}
|
|
10167
|
+
|
|
10168
|
+
// Capture EXACTLY what's about to be overwritten, from ground truth,
|
|
10169
|
+
// before applying the reversal — this is what redo() re-applies
|
|
10170
|
+
// later. It can't just recompute "the state after targetOp" the way
|
|
10171
|
+
// undo() itself computes "the state after landingOp" (by scanning
|
|
10172
|
+
// for a LATER op that also touched the same changeId/files/
|
|
10173
|
+
// conflicts): that scan only works going backward, where "nothing
|
|
10174
|
+
// touched it since" means "it's already correct". Going forward,
|
|
10175
|
+
// "nothing touched it since targetOp" means the opposite — its
|
|
10176
|
+
// effect is still live and needs to be captured now, not rederived.
|
|
10177
|
+
await graph.load();
|
|
10178
|
+
/** @type {Record<string, any>} */
|
|
10179
|
+
const redoChangeSnapshot = {};
|
|
10180
|
+
for (const changeId of Object.keys(reversal.changeSnapshotToApply)) {
|
|
10181
|
+
const current = await graph.getChange(changeId);
|
|
10182
|
+
if (current) redoChangeSnapshot[changeId] = current;
|
|
10183
|
+
}
|
|
10184
|
+
const redoFileSnapshot =
|
|
10185
|
+
reversal.fileSnapshot !== undefined ? await snapshotFilesystem() : undefined;
|
|
10186
|
+
let redoConflictsSnapshot;
|
|
10187
|
+
if (reversal.conflictsSnapshot !== undefined) {
|
|
10188
|
+
await conflicts.load();
|
|
10189
|
+
redoConflictsSnapshot = {
|
|
10190
|
+
conflicts: Object.fromEntries(conflicts.conflicts),
|
|
10191
|
+
fileConflicts: Object.fromEntries(conflicts.fileConflicts),
|
|
10192
|
+
};
|
|
8745
10193
|
}
|
|
8746
10194
|
|
|
8747
|
-
|
|
8748
|
-
|
|
10195
|
+
if (landingView.workingCopy) {
|
|
10196
|
+
await workingCopy.setCurrentChange(landingView.workingCopy);
|
|
10197
|
+
}
|
|
10198
|
+
await applyGraphReversal(reversal);
|
|
10199
|
+
|
|
10200
|
+
// Record undo as its own operation (matching real jj) — tagged with
|
|
10201
|
+
// which operation it targeted so a following undo() can walk one
|
|
10202
|
+
// step further back (resolveUndoTarget) instead of toggling, and
|
|
10203
|
+
// carrying exactly what redo() needs to re-apply (captured above).
|
|
10204
|
+
//
|
|
10205
|
+
// Also record `changeSnapshot: redoChangeSnapshot` — the SAME
|
|
10206
|
+
// ground-truth-before-this-op values, but under the field name
|
|
10207
|
+
// computeGraphReversal() actually scans for. Without this, undo()
|
|
10208
|
+
// itself was the one mutating operation in the whole API that never
|
|
10209
|
+
// recorded its own changeSnapshot, so a LATER undo() targeting THIS
|
|
10210
|
+
// undo (to step back past it) had nothing to restore: it would
|
|
10211
|
+
// repoint the working copy and files, but leave whatever this
|
|
10212
|
+
// undo() overwrote (e.g. a change's description) exactly as this
|
|
10213
|
+
// undo() left it. See issue #37(a).
|
|
8749
10214
|
await oplog.recordOperation({
|
|
8750
10215
|
timestamp: new Date().toISOString(),
|
|
8751
10216
|
user: await getUserOplogInfo(),
|
|
8752
10217
|
description: 'undo operation',
|
|
8753
10218
|
eventType: 'undo',
|
|
8754
|
-
|
|
8755
|
-
|
|
8756
|
-
|
|
10219
|
+
targetOpId: targetOp.id,
|
|
10220
|
+
undoneOpId: targetOp.id, // kept for API/type compatibility
|
|
10221
|
+
changeSnapshot: redoChangeSnapshot,
|
|
10222
|
+
redoChangeSnapshot,
|
|
10223
|
+
redoFileSnapshot,
|
|
10224
|
+
redoConflictsSnapshot,
|
|
8757
10225
|
parents: [],
|
|
8758
|
-
view:
|
|
10226
|
+
view: landingView,
|
|
8759
10227
|
});
|
|
8760
10228
|
|
|
8761
|
-
|
|
10229
|
+
const restoredFileCount =
|
|
10230
|
+
reversal.fileSnapshot !== undefined ? Object.keys(reversal.fileSnapshot).length : 0;
|
|
10231
|
+
|
|
8762
10232
|
return {
|
|
8763
10233
|
undoneOperation: {
|
|
8764
|
-
description:
|
|
8765
|
-
timestamp:
|
|
8766
|
-
user:
|
|
10234
|
+
description: targetOp.description,
|
|
10235
|
+
timestamp: targetOp.timestamp,
|
|
10236
|
+
user: targetOp.user,
|
|
8767
10237
|
},
|
|
8768
10238
|
restoredState: {
|
|
8769
|
-
workingCopy:
|
|
8770
|
-
heads:
|
|
8771
|
-
fileCount:
|
|
10239
|
+
workingCopy: landingView.workingCopy,
|
|
10240
|
+
heads: landingView.heads,
|
|
10241
|
+
fileCount: restoredFileCount,
|
|
8772
10242
|
},
|
|
8773
10243
|
};
|
|
8774
10244
|
},
|
|
@@ -8776,9 +10246,9 @@ async function createJJ(options) {
|
|
|
8776
10246
|
/**
|
|
8777
10247
|
* Redo an operation previously undone with undo() (v1.5)
|
|
8778
10248
|
*
|
|
8779
|
-
* Mirrors `jj redo` (jj v0.33):
|
|
8780
|
-
*
|
|
8781
|
-
*
|
|
10249
|
+
* Mirrors `jj redo` (jj v0.33): re-applies the operation that the most
|
|
10250
|
+
* recent not-yet-redone undo() reverted — including its graph rewrites,
|
|
10251
|
+
* not just working-copy/file state (see issue #29).
|
|
8782
10252
|
*
|
|
8783
10253
|
* @returns {Promise<Object>} Information about the redone operation
|
|
8784
10254
|
*/
|
|
@@ -8809,42 +10279,52 @@ async function createJJ(options) {
|
|
|
8809
10279
|
});
|
|
8810
10280
|
}
|
|
8811
10281
|
|
|
8812
|
-
const
|
|
10282
|
+
const targetOpId = undoOp.targetOpId || undoOp.undoneOpId;
|
|
10283
|
+
const targetOp = targetOpId && ops.find((op) => op.id === targetOpId);
|
|
10284
|
+
const redoneView = (targetOp && targetOp.view) || {};
|
|
8813
10285
|
|
|
8814
|
-
// Restore the working-copy pointer.
|
|
8815
10286
|
if (redoneView.workingCopy) {
|
|
8816
10287
|
await workingCopy.setCurrentChange(redoneView.workingCopy);
|
|
8817
10288
|
}
|
|
8818
10289
|
|
|
8819
|
-
//
|
|
8820
|
-
|
|
8821
|
-
|
|
8822
|
-
|
|
8823
|
-
|
|
8824
|
-
|
|
8825
|
-
|
|
8826
|
-
|
|
8827
|
-
|
|
8828
|
-
|
|
8829
|
-
|
|
8830
|
-
|
|
8831
|
-
|
|
8832
|
-
|
|
8833
|
-
|
|
8834
|
-
|
|
8835
|
-
|
|
8836
|
-
|
|
8837
|
-
|
|
10290
|
+
// Re-apply exactly what this undo captured right before it reverted
|
|
10291
|
+
// things — NOT computeGraphReversal(ops, targetOp): that scans for a
|
|
10292
|
+
// LATER op touching the same changeId/files/conflicts, which is the
|
|
10293
|
+
// right question for undo() ("has anything since made my target
|
|
10294
|
+
// stale?") but the wrong one here — after an undo, NOTHING later
|
|
10295
|
+
// re-established targetOp's own effect, so there's nothing to find
|
|
10296
|
+
// by scanning forward. undo() captured it directly for exactly this
|
|
10297
|
+
// reason (see its own comment).
|
|
10298
|
+
await graph.load();
|
|
10299
|
+
|
|
10300
|
+
// Capture ground truth right before overwriting, same as undo()
|
|
10301
|
+
// does for its own changeSnapshot (issue #37a) — lets a later
|
|
10302
|
+
// undo() targeting THIS redo op restore what redo() is about to
|
|
10303
|
+
// clobber, instead of finding no changeSnapshot at all.
|
|
10304
|
+
/** @type {Record<string, any>} */
|
|
10305
|
+
const undoChangeSnapshot = {};
|
|
10306
|
+
for (const changeId of Object.keys(undoOp.redoChangeSnapshot || {})) {
|
|
10307
|
+
const current = await graph.getChange(changeId);
|
|
10308
|
+
if (current) undoChangeSnapshot[changeId] = current;
|
|
8838
10309
|
}
|
|
8839
10310
|
|
|
8840
|
-
|
|
10311
|
+
for (const [changeId, snapshot] of Object.entries(undoOp.redoChangeSnapshot || {})) {
|
|
10312
|
+
if (await graph.getChange(changeId)) {
|
|
10313
|
+
// Bypass the git-sync middleware — see the matching comment in
|
|
10314
|
+
// applyGraphReversal(). `snapshot` already carries the change's
|
|
10315
|
+
// correct commitId from before the undo it's reversing ran
|
|
10316
|
+
// (issue #37c applies to redo() too).
|
|
10317
|
+
await baseGraph.updateChange(snapshot);
|
|
10318
|
+
}
|
|
10319
|
+
}
|
|
10320
|
+
if (undoOp.redoFileSnapshot !== undefined) {
|
|
10321
|
+
await syncWorkingCopyFiles(undoOp.redoFileSnapshot);
|
|
10322
|
+
}
|
|
8841
10323
|
await conflicts.load();
|
|
8842
|
-
if (undoOp.
|
|
8843
|
-
conflicts.conflicts = new Map(
|
|
8844
|
-
Object.entries(undoOp.undoneConflictsSnapshot.conflicts || {})
|
|
8845
|
-
);
|
|
10324
|
+
if (undoOp.redoConflictsSnapshot !== undefined) {
|
|
10325
|
+
conflicts.conflicts = new Map(Object.entries(undoOp.redoConflictsSnapshot.conflicts || {}));
|
|
8846
10326
|
conflicts.fileConflicts = new Map(
|
|
8847
|
-
Object.entries(undoOp.
|
|
10327
|
+
Object.entries(undoOp.redoConflictsSnapshot.fileConflicts || {})
|
|
8848
10328
|
);
|
|
8849
10329
|
await conflicts.save();
|
|
8850
10330
|
}
|
|
@@ -8855,19 +10335,23 @@ async function createJJ(options) {
|
|
|
8855
10335
|
description: 'redo operation',
|
|
8856
10336
|
eventType: 'redo',
|
|
8857
10337
|
redoOf: undoOp.id,
|
|
10338
|
+
changeSnapshot: undoChangeSnapshot,
|
|
8858
10339
|
parents: [],
|
|
8859
10340
|
view: redoneView,
|
|
8860
10341
|
});
|
|
8861
10342
|
|
|
10343
|
+
const restoredFileCount =
|
|
10344
|
+
undoOp.redoFileSnapshot !== undefined ? Object.keys(undoOp.redoFileSnapshot).length : 0;
|
|
10345
|
+
|
|
8862
10346
|
return {
|
|
8863
10347
|
redoneOperation: {
|
|
8864
10348
|
description: undoOp.description,
|
|
8865
|
-
undoneOpId:
|
|
10349
|
+
undoneOpId: targetOpId,
|
|
8866
10350
|
},
|
|
8867
10351
|
restoredState: {
|
|
8868
10352
|
workingCopy: redoneView.workingCopy,
|
|
8869
10353
|
heads: redoneView.heads,
|
|
8870
|
-
fileCount:
|
|
10354
|
+
fileCount: restoredFileCount,
|
|
8871
10355
|
},
|
|
8872
10356
|
};
|
|
8873
10357
|
},
|
|
@@ -9094,6 +10578,12 @@ async function createJJ(options) {
|
|
|
9094
10578
|
/**
|
|
9095
10579
|
* Restore repository to a specific operation (matches `jj operation restore`)
|
|
9096
10580
|
*
|
|
10581
|
+
* Fully reverts everything recorded after the target operation —
|
|
10582
|
+
* including graph rewrites (squash/rebase/abandon/new/edit), working
|
|
10583
|
+
* copy files, and conflict state, not just bookmarks/the working-copy
|
|
10584
|
+
* pointer (see issue #29) — using the same reversal primitive as
|
|
10585
|
+
* undo()/redo().
|
|
10586
|
+
*
|
|
9097
10587
|
* @param {Record<string, any>} args - Arguments
|
|
9098
10588
|
* @returns {Promise<Object>} Restore result
|
|
9099
10589
|
*/
|
|
@@ -9133,12 +10623,35 @@ async function createJJ(options) {
|
|
|
9133
10623
|
await workingCopy.setCurrentChange(targetOp.view.workingCopy);
|
|
9134
10624
|
}
|
|
9135
10625
|
|
|
10626
|
+
// Revert the change graph, working-copy files, and conflict state
|
|
10627
|
+
// to exactly what they were right after targetOp ran.
|
|
10628
|
+
const reversal = computeGraphReversal(ops, targetOp);
|
|
10629
|
+
|
|
10630
|
+
// Capture ground truth for every changeId this restore is about to
|
|
10631
|
+
// overwrite, BEFORE applying the reversal — exactly like undo()
|
|
10632
|
+
// does for its own changeSnapshot. Without this, restore()'s own
|
|
10633
|
+
// oplog entry carried no changeSnapshot at all, so undoing the
|
|
10634
|
+
// restore itself had nothing to put back: a later undo() would
|
|
10635
|
+
// repoint the working copy and files, but leave whatever the
|
|
10636
|
+
// restore had just overwritten (e.g. a change's description)
|
|
10637
|
+
// exactly as the restore left it. See issue #37(a).
|
|
10638
|
+
await graph.load();
|
|
10639
|
+
/** @type {Record<string, any>} */
|
|
10640
|
+
const changeSnapshot = {};
|
|
10641
|
+
for (const changeId of Object.keys(reversal.changeSnapshotToApply)) {
|
|
10642
|
+
const current = await graph.getChange(changeId);
|
|
10643
|
+
if (current) changeSnapshot[changeId] = current;
|
|
10644
|
+
}
|
|
10645
|
+
|
|
10646
|
+
await applyGraphReversal(reversal);
|
|
10647
|
+
|
|
9136
10648
|
// Record this restoration as a new operation
|
|
9137
10649
|
await oplog.recordOperation({
|
|
9138
10650
|
timestamp: new Date().toISOString(),
|
|
9139
10651
|
user: await getUserOplogInfo(),
|
|
9140
10652
|
description: `restore to operation ${args.operation}`,
|
|
9141
10653
|
parents: [],
|
|
10654
|
+
changeSnapshot,
|
|
9142
10655
|
view: targetOp.view,
|
|
9143
10656
|
});
|
|
9144
10657
|
|
|
@@ -9424,10 +10937,53 @@ async function createJJ(options) {
|
|
|
9424
10937
|
timestamp: new Date().toISOString(),
|
|
9425
10938
|
});
|
|
9426
10939
|
|
|
10940
|
+
// Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
|
|
10941
|
+
// reverse the squash (un-abandon source, restore dest's description
|
|
10942
|
+
// and content) — see issue #29.
|
|
10943
|
+
/** @type {Record<string, any>} */
|
|
10944
|
+
const changeSnapshot = {
|
|
10945
|
+
[source]: structuredClone(sourceChange),
|
|
10946
|
+
[destChangeId]: structuredClone(destChange),
|
|
10947
|
+
};
|
|
10948
|
+
|
|
9427
10949
|
// Mark source as abandoned
|
|
9428
10950
|
sourceChange.abandoned = true;
|
|
9429
10951
|
await graph.updateChange(sourceChange);
|
|
9430
10952
|
|
|
10953
|
+
// Merge source's file content into dest (issue #30: squash() used to
|
|
10954
|
+
// only combine descriptions, leaving dest's tree untouched). Compute
|
|
10955
|
+
// what source itself actually changed relative to ITS OWN parent
|
|
10956
|
+
// (its "diff") and fold just that onto dest, rather than overwriting
|
|
10957
|
+
// dest's whole snapshot with source's — a path source never touched
|
|
10958
|
+
// (inherited unchanged from that parent) must not clobber a
|
|
10959
|
+
// dest-specific version of the same path.
|
|
10960
|
+
const sourceParentId = sourceChange.parents && sourceChange.parents[0];
|
|
10961
|
+
const sourceParentChange = sourceParentId ? await graph.getChange(sourceParentId) : null;
|
|
10962
|
+
const sourceBaseSnapshot = (sourceParentChange && sourceParentChange.fileSnapshot) || {};
|
|
10963
|
+
const sourceSnapshot = sourceChange.fileSnapshot || {};
|
|
10964
|
+
const mergedSnapshot = { ...(destChange.fileSnapshot || {}) };
|
|
10965
|
+
const touchedPaths = new Set([
|
|
10966
|
+
...Object.keys(sourceBaseSnapshot),
|
|
10967
|
+
...Object.keys(sourceSnapshot),
|
|
10968
|
+
]);
|
|
10969
|
+
for (const filePath of touchedPaths) {
|
|
10970
|
+
const baseValue = sourceBaseSnapshot[filePath];
|
|
10971
|
+
const sourceValue = sourceSnapshot[filePath];
|
|
10972
|
+
if (sourceValue === baseValue) {
|
|
10973
|
+
// source never touched this path (inherited as-is from its own
|
|
10974
|
+
// parent) — leave whatever dest already had for it alone.
|
|
10975
|
+
continue;
|
|
10976
|
+
}
|
|
10977
|
+
if (sourceValue === undefined) {
|
|
10978
|
+
// source deleted a file it inherited from its parent.
|
|
10979
|
+
delete mergedSnapshot[filePath];
|
|
10980
|
+
} else {
|
|
10981
|
+
// source added or modified this path.
|
|
10982
|
+
mergedSnapshot[filePath] = sourceValue;
|
|
10983
|
+
}
|
|
10984
|
+
}
|
|
10985
|
+
destChange.fileSnapshot = mergedSnapshot;
|
|
10986
|
+
|
|
9431
10987
|
// Update description of dest to indicate squash (middleware will sync to Git)
|
|
9432
10988
|
destChange.description += `\n\n(squashed from ${source.slice(0, 8)})`;
|
|
9433
10989
|
await graph.updateChange(destChange);
|
|
@@ -9455,10 +11011,22 @@ async function createJJ(options) {
|
|
|
9455
11011
|
},
|
|
9456
11012
|
description: '(no description set)',
|
|
9457
11013
|
timestamp: new Date().toISOString(),
|
|
9458
|
-
|
|
11014
|
+
// Start from the DESTINATION's tree (which now includes source's
|
|
11015
|
+
// folded-in changes, set above), not an empty object — matches
|
|
11016
|
+
// real `jj squash`, where the new working-copy change looks
|
|
11017
|
+
// identical to its new parent until you touch something. Issue
|
|
11018
|
+
// #39: this used to hard-code `{}`, so status()/snapshot() saw
|
|
11019
|
+
// every on-disk file as untracked/new until something re-wrote
|
|
11020
|
+
// it and triggered a fresh auto-snapshot.
|
|
11021
|
+
fileSnapshot: { ...destChange.fileSnapshot },
|
|
9459
11022
|
};
|
|
9460
11023
|
|
|
9461
11024
|
await graph.addChange(newChange); // Middleware will sync to Git
|
|
11025
|
+
// Sync the working directory to match (issue #30's fix for
|
|
11026
|
+
// new()/edit(); squash()'s own synthetic post-squash change never
|
|
11027
|
+
// did this) — a no-op when source's parent was already dest (the
|
|
11028
|
+
// common case), but necessary whenever source/dest aren't adjacent.
|
|
11029
|
+
await syncWorkingCopyFiles(newChange.fileSnapshot);
|
|
9462
11030
|
await workingCopy.setCurrentChange(newChangeId);
|
|
9463
11031
|
newWorkingCopyId = newChangeId;
|
|
9464
11032
|
}
|
|
@@ -9469,6 +11037,7 @@ async function createJJ(options) {
|
|
|
9469
11037
|
user: await getUserOplogInfo(),
|
|
9470
11038
|
description: `squash ${source.slice(0, 8)} into ${destChangeId.slice(0, 8)}`,
|
|
9471
11039
|
parents: [],
|
|
11040
|
+
changeSnapshot,
|
|
9472
11041
|
view: {
|
|
9473
11042
|
bookmarks: {},
|
|
9474
11043
|
remoteBookmarks: {},
|
|
@@ -9539,6 +11108,12 @@ async function createJJ(options) {
|
|
|
9539
11108
|
throw new JJError('CHANGE_NOT_FOUND', `Change ${changeId} not found`);
|
|
9540
11109
|
}
|
|
9541
11110
|
|
|
11111
|
+
// Captured BEFORE anything mutates — see issue #38: abandoning the
|
|
11112
|
+
// change @ currently points at must move @ off it (real jj checks
|
|
11113
|
+
// out a fresh empty change on the abandoned change's own parent(s));
|
|
11114
|
+
// abandoning any OTHER change must leave @ exactly where it was.
|
|
11115
|
+
const wasWorkingCopy = changeId === workingCopy.getCurrentChangeId();
|
|
11116
|
+
|
|
9542
11117
|
// Dispatch change:abandoning event (preventable)
|
|
9543
11118
|
await dispatchEventAsync(jj, 'change:abandoning', {
|
|
9544
11119
|
operation: 'abandon',
|
|
@@ -9547,15 +11122,29 @@ async function createJJ(options) {
|
|
|
9547
11122
|
timestamp: new Date().toISOString(),
|
|
9548
11123
|
});
|
|
9549
11124
|
|
|
11125
|
+
// Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
|
|
11126
|
+
// reverse the abandon (un-abandon this change, un-reparent children)
|
|
11127
|
+
// — see issue #29.
|
|
11128
|
+
/** @type {Record<string, any>} */
|
|
11129
|
+
const changeSnapshot = { [changeId]: structuredClone(change) };
|
|
11130
|
+
|
|
9550
11131
|
change.abandoned = true;
|
|
9551
11132
|
await graph.updateChange(change);
|
|
9552
11133
|
|
|
11134
|
+
// Re-parent children onto the abandoned change's own parent(s)
|
|
11135
|
+
// (issue #30: this used to leave children pointing at the now-
|
|
11136
|
+
// abandoned commit). Matches real `jj abandon`: the abandoned
|
|
11137
|
+
// change is elided from the graph and its children attach directly
|
|
11138
|
+
// to whatever it was attached to.
|
|
11139
|
+
await reparentChildrenOnto(changeId, change.parents, changeSnapshot);
|
|
11140
|
+
|
|
9553
11141
|
// Record operation
|
|
9554
11142
|
await oplog.recordOperation({
|
|
9555
11143
|
timestamp: new Date().toISOString(),
|
|
9556
11144
|
user: { name: user.name, email: user.email, hostname: 'localhost' },
|
|
9557
11145
|
description: `abandon change ${changeId.slice(0, 8)}`,
|
|
9558
11146
|
parents: [],
|
|
11147
|
+
changeSnapshot,
|
|
9559
11148
|
view: {
|
|
9560
11149
|
bookmarks: {},
|
|
9561
11150
|
remoteBookmarks: {},
|
|
@@ -9577,9 +11166,220 @@ async function createJJ(options) {
|
|
|
9577
11166
|
{ cancelable: false }
|
|
9578
11167
|
);
|
|
9579
11168
|
|
|
11169
|
+
// If @ was pointing at the change we just abandoned, it can't stay
|
|
11170
|
+
// there — real jj moves @ to a new empty change on the abandoned
|
|
11171
|
+
// change's own parent(s) (issue #38). Reuse new()'s own "create an
|
|
11172
|
+
// empty change with given parents" logic rather than duplicating it
|
|
11173
|
+
// — this is exactly the workaround callers had to reach for
|
|
11174
|
+
// themselves (`new({ parents: [parentOfAt] })` before abandoning).
|
|
11175
|
+
if (wasWorkingCopy) {
|
|
11176
|
+
const newWorkingCopyChange = await jj.new({ parents: change.parents || [] });
|
|
11177
|
+
return { ...change, newWorkingCopyChangeId: newWorkingCopyChange.changeId };
|
|
11178
|
+
}
|
|
11179
|
+
|
|
9580
11180
|
return change;
|
|
9581
11181
|
},
|
|
9582
11182
|
|
|
11183
|
+
/**
|
|
11184
|
+
* Converge divergent copies of a change (matches `jj converge`, jj
|
|
11185
|
+
* v0.45.0+ — issue #32, extended for N-way per the isomorphic-jj v0.45
|
|
11186
|
+
* catch-up follow-up) — resolves ANY number of divergent copies of one
|
|
11187
|
+
* changeId in a single call. Real jj's own CLI currently only
|
|
11188
|
+
* converges one change-id per invocation (an explicit TODO in its own
|
|
11189
|
+
* source as of v0.45.1: "consider adding logic to deal with more than
|
|
11190
|
+
* one divergent change-id in one invocation"), which is what fixed
|
|
11191
|
+
* this package's own changeId-at-a-time API design — but its
|
|
11192
|
+
* underlying engine (`TruncatedEvolutionGraph`/`find_divergent_changes`)
|
|
11193
|
+
* already resolves every divergent revision of THAT one change-id
|
|
11194
|
+
* together, not just a pair. This generalizes the same per-path rule
|
|
11195
|
+
* accordingly: two-way "does the other side's value differ from base"
|
|
11196
|
+
* becomes N-way "how many DISTINCT values, across every copy, differ
|
|
11197
|
+
* from base" — 0 means nobody touched it, 1 means exactly one copy (or
|
|
11198
|
+
* several copies agreeing on the same new value) changed it and that
|
|
11199
|
+
* value wins, 2+ is a genuine conflict between that many copies.
|
|
11200
|
+
*
|
|
11201
|
+
* A change is divergent when more than one visible commit shares its
|
|
11202
|
+
* change id (see the `divergent()` revset, and
|
|
11203
|
+
* ChangeGraph.addDivergentCopy() for how a divergent copy gets added
|
|
11204
|
+
* in the first place — nothing in this library creates one on its
|
|
11205
|
+
* own yet; this resolves one once something has, e.g. a future git
|
|
11206
|
+
* fetch/concurrent-operation integration).
|
|
11207
|
+
*
|
|
11208
|
+
* Non-interactive-mode convention, matching this package's other
|
|
11209
|
+
* ambiguous-outcome APIs: like merge(), a genuine per-path conflict is
|
|
11210
|
+
* returned as data (`{ resolved: false, conflicts }`), not thrown —
|
|
11211
|
+
* you get a result back either way and decide what to do with
|
|
11212
|
+
* unresolved conflicts. What DOES throw is anything that isn't even a
|
|
11213
|
+
* well-formed convergence attempt (no divergence to resolve at all) —
|
|
11214
|
+
* the same category as merge()'s own `MERGE_ERROR` throw for "no
|
|
11215
|
+
* common ancestor found".
|
|
11216
|
+
*
|
|
11217
|
+
* @param {Record<string, any>|string} args - `{ changeId }`, or a bare changeId string
|
|
11218
|
+
* @returns {Promise<Object>} `{ changeId, resolved, conflicts, commitId? }`
|
|
11219
|
+
*/
|
|
11220
|
+
async converge(args) {
|
|
11221
|
+
const changeId = typeof args === 'string' ? args : args && (args.changeId || args.change);
|
|
11222
|
+
if (!changeId || typeof changeId !== 'string') {
|
|
11223
|
+
throw new JJError('INVALID_ARGUMENT', 'Missing changeId argument', {
|
|
11224
|
+
suggestion: 'Provide the divergent changeId to converge: { changeId: "..." }',
|
|
11225
|
+
});
|
|
11226
|
+
}
|
|
11227
|
+
|
|
11228
|
+
await graph.load();
|
|
11229
|
+
await userConfig.load();
|
|
11230
|
+
const siblings = graph.getDivergentSiblings(changeId);
|
|
11231
|
+
|
|
11232
|
+
if (siblings.length < 2) {
|
|
11233
|
+
throw new JJError(
|
|
11234
|
+
'NOT_DIVERGENT',
|
|
11235
|
+
`Change ${changeId} has no divergent copies to converge`,
|
|
11236
|
+
{
|
|
11237
|
+
changeId,
|
|
11238
|
+
suggestion:
|
|
11239
|
+
'converge() is only meaningful for a changeId the divergent() revset reports',
|
|
11240
|
+
}
|
|
11241
|
+
);
|
|
11242
|
+
}
|
|
11243
|
+
|
|
11244
|
+
// The primary copy is whichever one getChange() resolves (stored
|
|
11245
|
+
// under the plain changeId key); every other one is a divergent copy
|
|
11246
|
+
// added via addDivergentCopy(). No cap on how many — see the doc
|
|
11247
|
+
// comment above.
|
|
11248
|
+
const primary = /** @type {any} */ (await graph.getChange(changeId));
|
|
11249
|
+
const others = siblings.filter((/** @type {any} */ s) => s.commitId !== primary.commitId);
|
|
11250
|
+
|
|
11251
|
+
// Base for the N-way merge: what this change looked like before it
|
|
11252
|
+
// diverged. This package doesn't retain historical snapshots (only
|
|
11253
|
+
// current content), so — same pragmatic choice rebase() makes for
|
|
11254
|
+
// "old base" — use the shared parent's current content; divergent
|
|
11255
|
+
// copies realistically share the same parent, since they're all
|
|
11256
|
+
// rewrites of one prior position in history, not unrelated commits
|
|
11257
|
+
// that happen to collide. Applied uniformly to every copy, not
|
|
11258
|
+
// per-copy — the same simplification the original two-way version
|
|
11259
|
+
// made, just not newly introduced by going N-way.
|
|
11260
|
+
const baseParentId = primary.parents && primary.parents[0];
|
|
11261
|
+
const baseParentChange = baseParentId ? await graph.getChange(baseParentId) : null;
|
|
11262
|
+
const baseFiles = new Map(
|
|
11263
|
+
Object.entries((baseParentChange && baseParentChange.fileSnapshot) || {})
|
|
11264
|
+
);
|
|
11265
|
+
|
|
11266
|
+
// Every copy (primary + others), each as its own file map, paired
|
|
11267
|
+
// with its commitId for conflict reporting.
|
|
11268
|
+
const copies = [primary, ...others].map((/** @type {any} */ c) => ({
|
|
11269
|
+
commitId: c.commitId,
|
|
11270
|
+
files: new Map(Object.entries(c.fileSnapshot || {})),
|
|
11271
|
+
}));
|
|
11272
|
+
|
|
11273
|
+
await conflicts.load();
|
|
11274
|
+
const conflictsSnapshot = {
|
|
11275
|
+
conflicts: Object.fromEntries(conflicts.conflicts),
|
|
11276
|
+
fileConflicts: Object.fromEntries(conflicts.fileConflicts),
|
|
11277
|
+
};
|
|
11278
|
+
|
|
11279
|
+
const allPaths = new Set([
|
|
11280
|
+
...baseFiles.keys(),
|
|
11281
|
+
...copies.flatMap((c) => [...c.files.keys()]),
|
|
11282
|
+
]);
|
|
11283
|
+
|
|
11284
|
+
/** @type {Record<string, string>} */
|
|
11285
|
+
const mergedSnapshot = {};
|
|
11286
|
+
/** @type {any[]} */
|
|
11287
|
+
const detectedConflicts = [];
|
|
11288
|
+
|
|
11289
|
+
for (const filePath of allPaths) {
|
|
11290
|
+
const baseValue = baseFiles.get(filePath);
|
|
11291
|
+
// Every DISTINCT value across all copies that differs from base,
|
|
11292
|
+
// each paired with (one of) the commitId(s) that has it — mirrors
|
|
11293
|
+
// ConflictModel._detectPathConflict's two-way "no conflict" rules,
|
|
11294
|
+
// generalized: 0 distinct changed values means nobody touched it
|
|
11295
|
+
// (base wins, i.e. nothing to write), 1 means only one side (or
|
|
11296
|
+
// several copies agreeing) changed it (that value wins), 2+ is a
|
|
11297
|
+
// genuine conflict.
|
|
11298
|
+
const changed = new Map();
|
|
11299
|
+
for (const copy of copies) {
|
|
11300
|
+
const value = copy.files.get(filePath);
|
|
11301
|
+
if (value !== baseValue && !changed.has(value)) {
|
|
11302
|
+
changed.set(value, copy.commitId);
|
|
11303
|
+
}
|
|
11304
|
+
}
|
|
11305
|
+
|
|
11306
|
+
if (changed.size === 0) {
|
|
11307
|
+
if (baseValue !== undefined) mergedSnapshot[filePath] = baseValue;
|
|
11308
|
+
} else if (changed.size === 1) {
|
|
11309
|
+
const [[value]] = changed;
|
|
11310
|
+
if (value !== undefined) mergedSnapshot[filePath] = value;
|
|
11311
|
+
} else {
|
|
11312
|
+
const versions = [...changed.entries()].map(([content, commitId]) => ({
|
|
11313
|
+
commitId,
|
|
11314
|
+
content,
|
|
11315
|
+
}));
|
|
11316
|
+
detectedConflicts.push(conflicts.createNWayConflict(filePath, baseValue, versions));
|
|
11317
|
+
}
|
|
11318
|
+
}
|
|
11319
|
+
|
|
11320
|
+
if (detectedConflicts.length > 0) {
|
|
11321
|
+
// Matches real jj: non-interactive mode aborts automatic
|
|
11322
|
+
// resolution rather than guessing — but per this package's own
|
|
11323
|
+
// merge() convention, that's reported back as data, not thrown.
|
|
11324
|
+
for (const conflict of detectedConflicts) {
|
|
11325
|
+
await conflicts.addConflict(conflict);
|
|
11326
|
+
}
|
|
11327
|
+
await oplog.recordOperation({
|
|
11328
|
+
timestamp: new Date().toISOString(),
|
|
11329
|
+
user: await getUserOplogInfo(),
|
|
11330
|
+
description: `converge ${changeId.slice(0, 8)} (unresolved)`,
|
|
11331
|
+
parents: [],
|
|
11332
|
+
conflictsSnapshot,
|
|
11333
|
+
view: {
|
|
11334
|
+
bookmarks: {},
|
|
11335
|
+
remoteBookmarks: {},
|
|
11336
|
+
heads: [],
|
|
11337
|
+
workingCopy: workingCopy.getCurrentChangeId(),
|
|
11338
|
+
},
|
|
11339
|
+
});
|
|
11340
|
+
return { changeId, resolved: false, conflicts: detectedConflicts };
|
|
11341
|
+
}
|
|
11342
|
+
|
|
11343
|
+
const user = /** @type {any} */ (userConfig.getUser());
|
|
11344
|
+
const changeSnapshot = { [changeId]: structuredClone(primary) };
|
|
11345
|
+
const otherIds = others.map((/** @type {any} */ o) => o.commitId.slice(0, 8)).join(', ');
|
|
11346
|
+
|
|
11347
|
+
const convergedChange = {
|
|
11348
|
+
...primary,
|
|
11349
|
+
commitId: randomHex(20),
|
|
11350
|
+
fileSnapshot: mergedSnapshot,
|
|
11351
|
+
description: primary.description + `\n\n(converged divergent copies: ${otherIds})`,
|
|
11352
|
+
timestamp: new Date().toISOString(),
|
|
11353
|
+
committer: {
|
|
11354
|
+
name: user.name,
|
|
11355
|
+
email: user.email,
|
|
11356
|
+
timestamp: new Date().toISOString(),
|
|
11357
|
+
},
|
|
11358
|
+
divergent: false,
|
|
11359
|
+
};
|
|
11360
|
+
await graph.updateChange(convergedChange);
|
|
11361
|
+
for (const other of others) {
|
|
11362
|
+
await graph.deleteDivergentCopy(changeId, other.commitId);
|
|
11363
|
+
}
|
|
11364
|
+
|
|
11365
|
+
await oplog.recordOperation({
|
|
11366
|
+
timestamp: new Date().toISOString(),
|
|
11367
|
+
user: await getUserOplogInfo(),
|
|
11368
|
+
description: `converge ${changeId.slice(0, 8)}`,
|
|
11369
|
+
parents: [],
|
|
11370
|
+
changeSnapshot,
|
|
11371
|
+
conflictsSnapshot,
|
|
11372
|
+
view: {
|
|
11373
|
+
bookmarks: {},
|
|
11374
|
+
remoteBookmarks: {},
|
|
11375
|
+
heads: [changeId],
|
|
11376
|
+
workingCopy: workingCopy.getCurrentChangeId(),
|
|
11377
|
+
},
|
|
11378
|
+
});
|
|
11379
|
+
|
|
11380
|
+
return { changeId, resolved: true, conflicts: [], commitId: convergedChange.commitId };
|
|
11381
|
+
},
|
|
11382
|
+
|
|
9583
11383
|
/**
|
|
9584
11384
|
* Backout a change - create a new change that reverses the effect of the specified change
|
|
9585
11385
|
*
|
|
@@ -11041,7 +12841,7 @@ async function createJJ(options) {
|
|
|
11041
12841
|
.split('/')
|
|
11042
12842
|
.pop()
|
|
11043
12843
|
.replace(/\.git$/, '');
|
|
11044
|
-
const fullCloneDir =
|
|
12844
|
+
const fullCloneDir = join(dir, cloneDir);
|
|
11045
12845
|
|
|
11046
12846
|
// Clone using isomorphic-git
|
|
11047
12847
|
await git.clone({
|
|
@@ -11419,8 +13219,14 @@ async function createJJ(options) {
|
|
|
11419
13219
|
* @returns {Promise<Object>} Repository root information
|
|
11420
13220
|
*/
|
|
11421
13221
|
async root() {
|
|
11422
|
-
//
|
|
11423
|
-
|
|
13222
|
+
// `dir` is a real host filesystem path (unlike the repo-relative
|
|
13223
|
+
// git/jj paths `path` — posix-path.js — is for), so it needs the
|
|
13224
|
+
// host's own separator here, not always '/': on Windows Node's real
|
|
13225
|
+
// `fs` still resolves a forward-slash-joined path fine, but this
|
|
13226
|
+
// method hands the string back to the caller as data, and jj's own
|
|
13227
|
+
// `git root` returns a platform-native path there.
|
|
13228
|
+
const isWin32 = typeof process !== 'undefined' && process.platform === 'win32';
|
|
13229
|
+
const gitDir = isWin32 ? `${dir.replace(/[\\/]+$/, '')}\\.git` : join(dir, '.git');
|
|
11424
13230
|
|
|
11425
13231
|
try {
|
|
11426
13232
|
await fs.promises.access(gitDir);
|
|
@@ -11499,7 +13305,7 @@ async function createJJ(options) {
|
|
|
11499
13305
|
const wcFiles = await workingCopy.listFiles();
|
|
11500
13306
|
for (const file of wcFiles) {
|
|
11501
13307
|
try {
|
|
11502
|
-
const content = await fs.promises.readFile(
|
|
13308
|
+
const content = await fs.promises.readFile(join(dir, file), 'utf8');
|
|
11503
13309
|
leftFiles.set(file, content);
|
|
11504
13310
|
} catch (error) {
|
|
11505
13311
|
// File might be deleted or binary - throw error for explicit handling
|
|
@@ -11642,7 +13448,7 @@ async function createJJ(options) {
|
|
|
11642
13448
|
}
|
|
11643
13449
|
|
|
11644
13450
|
// Write resolved content to file
|
|
11645
|
-
await fs.promises.writeFile(
|
|
13451
|
+
await fs.promises.writeFile(join(dir, conflict.path), resolvedContent, 'utf8');
|
|
11646
13452
|
|
|
11647
13453
|
// Mark conflict as resolved
|
|
11648
13454
|
await conflicts.resolveConflict(args.conflictId, 'manual');
|
|
@@ -11685,7 +13491,7 @@ async function createJJ(options) {
|
|
|
11685
13491
|
const resolvedContent = _resolveWithStrategy(conflict, args.strategy);
|
|
11686
13492
|
|
|
11687
13493
|
// Write resolved content
|
|
11688
|
-
await fs.promises.writeFile(
|
|
13494
|
+
await fs.promises.writeFile(join(dir, conflict.path), resolvedContent, 'utf8');
|
|
11689
13495
|
|
|
11690
13496
|
// Mark as resolved
|
|
11691
13497
|
await conflicts.resolveConflict(conflict.conflictId, args.strategy);
|
|
@@ -12007,7 +13813,7 @@ async function createJJ(options) {
|
|
|
12007
13813
|
);
|
|
12008
13814
|
}
|
|
12009
13815
|
|
|
12010
|
-
const fullPath =
|
|
13816
|
+
const fullPath = join(dir, args.path);
|
|
12011
13817
|
|
|
12012
13818
|
// Convert string mode to number if needed
|
|
12013
13819
|
const mode = typeof args.mode === 'string' ? parseInt(args.mode, 8) : args.mode;
|
|
@@ -12118,14 +13924,14 @@ async function createJJ(options) {
|
|
|
12118
13924
|
if (change.fileSnapshot) {
|
|
12119
13925
|
for (const [filePath, content] of Object.entries(change.fileSnapshot)) {
|
|
12120
13926
|
try {
|
|
12121
|
-
const fullPath =
|
|
13927
|
+
const fullPath = join(args.path, filePath);
|
|
12122
13928
|
|
|
12123
13929
|
// Ensure directory exists
|
|
12124
13930
|
const pathParts = filePath.split('/');
|
|
12125
13931
|
if (pathParts.length > 1) {
|
|
12126
13932
|
const dirPath = pathParts.slice(0, -1).join('/');
|
|
12127
|
-
const fullDirPath =
|
|
12128
|
-
await fs
|
|
13933
|
+
const fullDirPath = join(args.path, dirPath);
|
|
13934
|
+
await mkdirp(fs, fullDirPath);
|
|
12129
13935
|
}
|
|
12130
13936
|
|
|
12131
13937
|
// Write file content
|