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