isomorphic-jj 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +425 -1
- package/LICENSE +1 -1
- package/README.md +233 -35
- package/bin/isojj.js +4 -1
- package/dist/index.cjs +2419 -378
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +2419 -377
- package/dist/index.mjs.map +1 -1
- package/package.json +24 -10
- package/src/api/repository.js +966 -153
- package/src/backend/isomorphic-git-backend.js +72 -15
- package/src/browser/helpers.js +40 -18
- 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 +11 -19
- package/src/core/jj-tree-state.js +8 -18
- package/src/core/jj-view-store.js +9 -19
- package/src/core/merge-driver-registry.js +11 -10
- package/src/core/revset-engine.js +63 -6
- package/src/core/storage-manager.js +56 -18
- package/src/core/tag-store.js +42 -15
- 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 +46 -0
- 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/mutex.js +63 -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
|
|
@@ -26,6 +23,112 @@ class JJError extends Error {
|
|
|
26
23
|
}
|
|
27
24
|
}
|
|
28
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Small in-process locking utilities.
|
|
28
|
+
*
|
|
29
|
+
* These provide same-process concurrency safety only — they do not protect
|
|
30
|
+
* against races between separate OS processes sharing the same on-disk
|
|
31
|
+
* repository. Cross-process locking is a separate concern (e.g. a lockfile
|
|
32
|
+
* with retry/backoff) and is intentionally out of scope here.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A simple FIFO async mutex: `run(fn)` queues `fn` to execute only once all
|
|
37
|
+
* previously-queued work on this mutex has settled, serializing access to
|
|
38
|
+
* whatever critical section `fn` represents.
|
|
39
|
+
*/
|
|
40
|
+
class Mutex {
|
|
41
|
+
constructor() {
|
|
42
|
+
/** @type {Promise<any>} */
|
|
43
|
+
this._queue = Promise.resolve();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @template T
|
|
48
|
+
* @param {() => Promise<T>} fn
|
|
49
|
+
* @returns {Promise<T>}
|
|
50
|
+
*/
|
|
51
|
+
run(fn) {
|
|
52
|
+
const result = this._queue.then(() => fn());
|
|
53
|
+
// Keep the queue moving even if `fn` rejects — a failed critical section
|
|
54
|
+
// must not permanently wedge the lock for subsequent callers.
|
|
55
|
+
this._queue = result.then(
|
|
56
|
+
() => undefined,
|
|
57
|
+
() => undefined
|
|
58
|
+
);
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A registry of mutexes keyed by an arbitrary string (typically a file path),
|
|
65
|
+
* so unrelated paths don't contend with each other while operations on the
|
|
66
|
+
* *same* path are fully serialized.
|
|
67
|
+
*/
|
|
68
|
+
class KeyedMutex {
|
|
69
|
+
constructor() {
|
|
70
|
+
/** @type {Map<string, Mutex>} */
|
|
71
|
+
this._mutexes = new Map();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @template T
|
|
76
|
+
* @param {string} key
|
|
77
|
+
* @param {() => Promise<T>} fn
|
|
78
|
+
* @returns {Promise<T>}
|
|
79
|
+
*/
|
|
80
|
+
run(key, fn) {
|
|
81
|
+
let mutex = this._mutexes.get(key);
|
|
82
|
+
if (!mutex) {
|
|
83
|
+
mutex = new Mutex();
|
|
84
|
+
this._mutexes.set(key, mutex);
|
|
85
|
+
}
|
|
86
|
+
return mutex.run(fn);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
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
|
+
|
|
29
132
|
/**
|
|
30
133
|
* Storage Manager for isomorphic-jj
|
|
31
134
|
*
|
|
@@ -45,6 +148,13 @@ class Storage {
|
|
|
45
148
|
this.repoDir = `${dir}/.jj/repo`; // Core repo data
|
|
46
149
|
this.workingCopyDir = `${dir}/.jj/working_copy`; // Default workspace working copy
|
|
47
150
|
this.cache = new Map();
|
|
151
|
+
|
|
152
|
+
// Serializes write()/appendLine() critical sections per path so that
|
|
153
|
+
// concurrent same-process callers (e.g. a user command racing
|
|
154
|
+
// BackgroundOps's autosnapshot) can't interleave a read-modify-write
|
|
155
|
+
// cycle and silently lose an update. This only protects callers that
|
|
156
|
+
// share this Storage instance — cross-process locking is out of scope.
|
|
157
|
+
this._locks = new KeyedMutex();
|
|
48
158
|
}
|
|
49
159
|
|
|
50
160
|
/**
|
|
@@ -59,17 +169,17 @@ class Storage {
|
|
|
59
169
|
async init() {
|
|
60
170
|
try {
|
|
61
171
|
// Create main directories
|
|
62
|
-
await this.fs
|
|
63
|
-
await this.fs
|
|
64
|
-
await this.fs
|
|
172
|
+
await mkdirp(this.fs, this.jjDir);
|
|
173
|
+
await mkdirp(this.fs, this.repoDir);
|
|
174
|
+
await mkdirp(this.fs, this.workingCopyDir);
|
|
65
175
|
|
|
66
176
|
// Create repo subdirectories
|
|
67
|
-
await this.fs
|
|
68
|
-
await this.fs
|
|
69
|
-
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`);
|
|
70
180
|
|
|
71
181
|
// Create default workspace directory
|
|
72
|
-
await this.fs
|
|
182
|
+
await mkdirp(this.fs, `${this.workingCopyDir}/default`);
|
|
73
183
|
} catch (error) {
|
|
74
184
|
throw new JJError(
|
|
75
185
|
'STORAGE_INIT_FAILED',
|
|
@@ -119,14 +229,34 @@ class Storage {
|
|
|
119
229
|
* @param {Object|string} data - Data to write (object will be stringified)
|
|
120
230
|
*/
|
|
121
231
|
async write(path, data) {
|
|
232
|
+
return this._locks.run(path, () => this._writeLocked(path, data));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Unlocked implementation of write(). Only call this from within a
|
|
237
|
+
* `this._locks.run(path, ...)` critical section (directly from write()
|
|
238
|
+
* itself, or from appendLine() which holds the same path's lock already).
|
|
239
|
+
*
|
|
240
|
+
* @param {string} path - Relative path from .jj directory
|
|
241
|
+
* @param {Object|string} data - Data to write (object will be stringified)
|
|
242
|
+
*/
|
|
243
|
+
async _writeLocked(path, data) {
|
|
122
244
|
const fullPath = `${this.jjDir}/${path}`;
|
|
123
|
-
|
|
245
|
+
// Random suffix (not just a millisecond timestamp) so two concurrent
|
|
246
|
+
// writes to the same path never collide on the temp filename — a
|
|
247
|
+
// collision would let one write's error-cleanup unlink() delete the
|
|
248
|
+
// other's still in-flight temp file.
|
|
249
|
+
const tmpSuffix =
|
|
250
|
+
typeof crypto !== 'undefined' && crypto.randomUUID
|
|
251
|
+
? crypto.randomUUID()
|
|
252
|
+
: `${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
|
|
253
|
+
const tmpPath = `${fullPath}.tmp.${Date.now()}.${tmpSuffix}`;
|
|
124
254
|
const jsonData = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
|
125
255
|
|
|
126
256
|
try {
|
|
127
257
|
// Ensure parent directory exists
|
|
128
258
|
const dirPath = fullPath.substring(0, fullPath.lastIndexOf('/'));
|
|
129
|
-
await this.fs
|
|
259
|
+
await mkdirp(this.fs, dirPath);
|
|
130
260
|
|
|
131
261
|
// Write to temp file
|
|
132
262
|
await this.fs.promises.writeFile(tmpPath, jsonData, 'utf8');
|
|
@@ -190,16 +320,25 @@ class Storage {
|
|
|
190
320
|
const fullPath = `${this.jjDir}/${path}`;
|
|
191
321
|
|
|
192
322
|
try {
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
323
|
+
// Hold the same per-path lock write() uses for the *entire*
|
|
324
|
+
// read-modify-write cycle (not just the write half). Without this,
|
|
325
|
+
// two concurrent appendLine() calls on the same path can both read
|
|
326
|
+
// the pre-append content, and the loser's write silently drops its
|
|
327
|
+
// line even though both calls report success.
|
|
328
|
+
await this._locks.run(path, async () => {
|
|
329
|
+
// Read existing content
|
|
330
|
+
let existing = '';
|
|
331
|
+
try {
|
|
332
|
+
existing = await this.fs.promises.readFile(fullPath, 'utf8');
|
|
333
|
+
} catch (error) {
|
|
334
|
+
if (error.code !== 'ENOENT') throw error;
|
|
335
|
+
}
|
|
200
336
|
|
|
201
|
-
|
|
202
|
-
|
|
337
|
+
// Write atomically (already inside this path's lock, so use the
|
|
338
|
+
// unlocked implementation to avoid deadlocking on a non-reentrant
|
|
339
|
+
// per-key mutex).
|
|
340
|
+
await this._writeLocked(path, existing + line + '\n');
|
|
341
|
+
});
|
|
203
342
|
} catch (error) {
|
|
204
343
|
throw new JJError('STORAGE_WRITE_FAILED', `Failed to append to ${path}: ${error.message}`, {
|
|
205
344
|
path: fullPath,
|
|
@@ -373,6 +512,75 @@ function validateBookmarkName(name) {
|
|
|
373
512
|
}
|
|
374
513
|
}
|
|
375
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
|
+
|
|
376
584
|
/**
|
|
377
585
|
* ChangeGraph - Manages the change graph with stable change IDs
|
|
378
586
|
*
|
|
@@ -468,6 +676,68 @@ class ChangeGraph {
|
|
|
468
676
|
await this.save();
|
|
469
677
|
}
|
|
470
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
|
+
|
|
471
741
|
/**
|
|
472
742
|
* Get a change by ID
|
|
473
743
|
*
|
|
@@ -579,6 +849,65 @@ class ChangeGraph {
|
|
|
579
849
|
await this.save();
|
|
580
850
|
}
|
|
581
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
|
+
|
|
582
911
|
/**
|
|
583
912
|
* Get all ancestors of a change (recursive parent traversal)
|
|
584
913
|
*
|
|
@@ -616,10 +945,9 @@ class ChangeGraph {
|
|
|
616
945
|
* @returns {Promise<Record<string, any>>} Created change object
|
|
617
946
|
*/
|
|
618
947
|
async createChange(params = {}) {
|
|
619
|
-
const
|
|
620
|
-
const
|
|
621
|
-
const
|
|
622
|
-
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);
|
|
623
951
|
const timestamp = params.timestamp || new Date().toISOString();
|
|
624
952
|
|
|
625
953
|
/** @type {Record<string, any>} */
|
|
@@ -1083,61 +1411,6 @@ class WorkingCopy {
|
|
|
1083
1411
|
}
|
|
1084
1412
|
}
|
|
1085
1413
|
|
|
1086
|
-
/**
|
|
1087
|
-
* ID Generation utilities for isomorphic-jj
|
|
1088
|
-
*
|
|
1089
|
-
* Generates stable change IDs and operation IDs using cryptographically secure random values.
|
|
1090
|
-
*/
|
|
1091
|
-
|
|
1092
|
-
/**
|
|
1093
|
-
* Generate a stable change ID
|
|
1094
|
-
*
|
|
1095
|
-
* Uses 128-bit cryptographically random value encoded as 32-character hex string.
|
|
1096
|
-
* Collision probability is negligible (2^128 space).
|
|
1097
|
-
*
|
|
1098
|
-
* @returns {string} 32-character lowercase hex string
|
|
1099
|
-
*/
|
|
1100
|
-
function generateChangeId() {
|
|
1101
|
-
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
1102
|
-
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
/**
|
|
1106
|
-
* Generate an operation ID from operation content
|
|
1107
|
-
*
|
|
1108
|
-
* Uses SHA-256 hash of operation content for integrity verification.
|
|
1109
|
-
*
|
|
1110
|
-
* @param {Object.<string, any>} operation - Operation data
|
|
1111
|
-
* @returns {Promise<string>} 64-character lowercase hex string (SHA-256)
|
|
1112
|
-
*/
|
|
1113
|
-
async function generateOperationId(operation) {
|
|
1114
|
-
const content = JSON.stringify({
|
|
1115
|
-
timestamp: operation.timestamp,
|
|
1116
|
-
user: operation.user,
|
|
1117
|
-
description: operation.description,
|
|
1118
|
-
parents: operation.parents,
|
|
1119
|
-
view: operation.view,
|
|
1120
|
-
});
|
|
1121
|
-
|
|
1122
|
-
const encoder = new TextEncoder();
|
|
1123
|
-
const data = encoder.encode(content);
|
|
1124
|
-
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
1125
|
-
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
1126
|
-
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
/**
|
|
1130
|
-
* Generate a generic ID with optional prefix
|
|
1131
|
-
*
|
|
1132
|
-
* @param {string} prefix - Optional prefix (e.g., 'conflict', 'bookmark')
|
|
1133
|
-
* @returns {string} ID string
|
|
1134
|
-
*/
|
|
1135
|
-
function generateId(prefix = '') {
|
|
1136
|
-
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
1137
|
-
const id = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
1138
|
-
return prefix ? `${prefix}-${id}` : id;
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
1414
|
/**
|
|
1142
1415
|
* OperationLog - Manages the append-only operation log
|
|
1143
1416
|
*
|
|
@@ -1710,18 +1983,36 @@ class TagStore {
|
|
|
1710
1983
|
this.fs = fs;
|
|
1711
1984
|
this.jjDir = jjDir;
|
|
1712
1985
|
this.tagsFile = `${jjDir}/store/tags.json`;
|
|
1986
|
+
/**
|
|
1987
|
+
* Tracking info for local tags mirrored from a remote (jj v0.44 `jj tag
|
|
1988
|
+
* track`/`untrack`). Keyed by local tag name: { remote, remoteName }.
|
|
1989
|
+
* @type {Record<string, any>}
|
|
1990
|
+
*/
|
|
1991
|
+
this.tracking = {};
|
|
1713
1992
|
}
|
|
1714
1993
|
|
|
1715
1994
|
/**
|
|
1716
|
-
* Loads tags from storage
|
|
1995
|
+
* Loads tags (and their remote-tracking info, jj v0.44) from storage
|
|
1717
1996
|
* @returns {Promise<Record<string, any>>} Map of tag name to changeId
|
|
1718
1997
|
*/
|
|
1719
1998
|
async load() {
|
|
1720
1999
|
try {
|
|
1721
2000
|
const data = await this.fs.promises.readFile(this.tagsFile, 'utf8');
|
|
1722
|
-
|
|
2001
|
+
const parsed = JSON.parse(data);
|
|
2002
|
+
|
|
2003
|
+
// v0.44: tags.json may be either the legacy flat `{ name: changeId }`
|
|
2004
|
+
// map, or the newer `{ tags: {...}, tracked: {...} }` shape that also
|
|
2005
|
+
// carries remote-tracking state. Support both for backward compat.
|
|
2006
|
+
if (parsed && typeof parsed === 'object' && 'tags' in parsed) {
|
|
2007
|
+
this.tracking = parsed.tracked || {};
|
|
2008
|
+
return parsed.tags || {};
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
this.tracking = {};
|
|
2012
|
+
return parsed || {};
|
|
1723
2013
|
} catch (error) {
|
|
1724
2014
|
if (error.code === 'ENOENT') {
|
|
2015
|
+
this.tracking = {};
|
|
1725
2016
|
return {};
|
|
1726
2017
|
}
|
|
1727
2018
|
throw error;
|
|
@@ -1729,19 +2020,18 @@ class TagStore {
|
|
|
1729
2020
|
}
|
|
1730
2021
|
|
|
1731
2022
|
/**
|
|
1732
|
-
* Saves tags to storage
|
|
2023
|
+
* Saves tags (and their remote-tracking info, jj v0.44) to storage
|
|
1733
2024
|
* @param {Object} tags - Map of tag name to changeId
|
|
1734
2025
|
* @returns {Promise<void>}
|
|
1735
2026
|
*/
|
|
1736
2027
|
async save(tags) {
|
|
1737
2028
|
// Ensure the store directory exists
|
|
1738
2029
|
const storeDir = `${this.jjDir}/store`;
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
await this.fs.promises.writeFile(this.tagsFile, JSON.stringify(tags, null, 2));
|
|
2030
|
+
await mkdirp(this.fs, storeDir);
|
|
2031
|
+
await this.fs.promises.writeFile(
|
|
2032
|
+
this.tagsFile,
|
|
2033
|
+
JSON.stringify({ tags, tracked: this.tracking || {} }, null, 2)
|
|
2034
|
+
);
|
|
1745
2035
|
}
|
|
1746
2036
|
|
|
1747
2037
|
/**
|
|
@@ -1770,9 +2060,11 @@ class TagStore {
|
|
|
1770
2060
|
}
|
|
1771
2061
|
|
|
1772
2062
|
/**
|
|
1773
|
-
* Lists all tags, optionally filtered by pattern
|
|
2063
|
+
* Lists all tags, optionally filtered by pattern. Each entry also carries
|
|
2064
|
+
* a `tracking: { remote, ref }` field when the tag is tracking a remote
|
|
2065
|
+
* (jj v0.44 `jj tag track`).
|
|
1774
2066
|
* @param {string} [pattern] - Optional glob pattern
|
|
1775
|
-
* @returns {Promise<Array<
|
|
2067
|
+
* @returns {Promise<Array<Record<string, any>>>}
|
|
1776
2068
|
*/
|
|
1777
2069
|
async list(pattern) {
|
|
1778
2070
|
const tags = await this.load();
|
|
@@ -1783,10 +2075,17 @@ class TagStore {
|
|
|
1783
2075
|
tagNames = tagNames.filter((name) => matchesPattern$1(name, pattern));
|
|
1784
2076
|
}
|
|
1785
2077
|
|
|
1786
|
-
return tagNames.map((name) =>
|
|
1787
|
-
|
|
1788
|
-
changeId: tags[name]
|
|
1789
|
-
|
|
2078
|
+
return tagNames.map((name) => {
|
|
2079
|
+
/** @type {Record<string, any>} */
|
|
2080
|
+
const tag = { name, changeId: tags[name] };
|
|
2081
|
+
if (this.tracking && this.tracking[name]) {
|
|
2082
|
+
tag.tracking = {
|
|
2083
|
+
remote: this.tracking[name].remote,
|
|
2084
|
+
ref: this.tracking[name].remoteName || name,
|
|
2085
|
+
};
|
|
2086
|
+
}
|
|
2087
|
+
return tag;
|
|
2088
|
+
});
|
|
1790
2089
|
}
|
|
1791
2090
|
|
|
1792
2091
|
/**
|
|
@@ -2140,11 +2439,30 @@ class RevsetParser {
|
|
|
2140
2439
|
this.next();
|
|
2141
2440
|
const value = /** @type {string} */ (tok.value);
|
|
2142
2441
|
|
|
2143
|
-
//
|
|
2442
|
+
// `::` (bare, no operands) means "all visible changes", matching jj's
|
|
2443
|
+
// `::` alias for all().
|
|
2444
|
+
if (value === '::') {
|
|
2445
|
+
return { type: 'rangeAll' };
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
// A bare "base..tip" range, e.g. `abcd1234..ef567890` — and the
|
|
2449
|
+
// open-ended forms `..tip` (ancestors(tip)) and `base..` (everything
|
|
2450
|
+
// not an ancestor of base, approximated here as descendants(base)).
|
|
2144
2451
|
if (value.includes('..')) {
|
|
2145
2452
|
const parts = value.split('..');
|
|
2146
2453
|
if (parts.length === 2) {
|
|
2147
|
-
|
|
2454
|
+
const base = parts[0].trim();
|
|
2455
|
+
const tip = parts[1].trim();
|
|
2456
|
+
if (base === '' && tip === '') {
|
|
2457
|
+
return { type: 'rangeAll' };
|
|
2458
|
+
}
|
|
2459
|
+
if (base === '') {
|
|
2460
|
+
return { type: 'rangeAncestors', tip };
|
|
2461
|
+
}
|
|
2462
|
+
if (tip === '') {
|
|
2463
|
+
return { type: 'rangeDescendants', base };
|
|
2464
|
+
}
|
|
2465
|
+
return { type: 'range', base, tip };
|
|
2148
2466
|
}
|
|
2149
2467
|
}
|
|
2150
2468
|
|
|
@@ -2213,7 +2531,7 @@ class RevsetEngine {
|
|
|
2213
2531
|
* @returns {string}
|
|
2214
2532
|
*/
|
|
2215
2533
|
_suggestion() {
|
|
2216
|
-
return 'Use @, @-, @+, bookmark(name), all(),
|
|
2534
|
+
return 'Use @, @-, @+, bookmark(name), all(), builtin_log(), none(), root(), visible_heads(), ancestors(revset[, depth]), author(name), author_name(x), author_email(x), committer(x), committer_name(x), committer_email(x), subject(pattern), description(text), change_id(prefix), commit_id(prefix), empty(), mine(), merge(), merges(), forks(), signed(), divergent(), file(pattern), roots(revset), heads(revset), parents(revset), children(revset), first_parent(revset), first_ancestors(revset), fork_point(revset), merge_point(revset), exactly(revset, n), present(revset), coalesce(a, b, ...), latest(revset, [count]), tags([pattern]), remote_tags([pattern]), bookmarks([pattern]), last(N[dh]), since(date), between(start, end), descendants(rev[, depth]), common_ancestor(rev1, rev2), range(base..tip), diverge_point(rev1, rev2), connected(rev1, rev2), operators (x-, x+), set operations (& | ~), or a direct change ID (git_refs()/git_head() still work but are deprecated — jj removed them in v0.43; use bookmarks()/tags()/@ instead)';
|
|
2217
2535
|
}
|
|
2218
2536
|
|
|
2219
2537
|
/**
|
|
@@ -2270,6 +2588,15 @@ class RevsetEngine {
|
|
|
2270
2588
|
}
|
|
2271
2589
|
case 'range':
|
|
2272
2590
|
return await this.evalRange(node.base, node.tip);
|
|
2591
|
+
// Open-ended ranges (issue #18): `..tip` / `base..` / `::` delegate to
|
|
2592
|
+
// the equivalent ancestors()/descendants()/all() functions rather than
|
|
2593
|
+
// failing with a misleading "invalid IDs" error.
|
|
2594
|
+
case 'rangeAll':
|
|
2595
|
+
return await this.evaluateFunctionCall('all', '');
|
|
2596
|
+
case 'rangeAncestors':
|
|
2597
|
+
return await this.evaluateFunctionCall('ancestors', node.tip);
|
|
2598
|
+
case 'rangeDescendants':
|
|
2599
|
+
return await this.evaluateFunctionCall('descendants', node.base);
|
|
2273
2600
|
case 'call':
|
|
2274
2601
|
return await this.evaluateFunctionCall(node.name, node.argsRaw);
|
|
2275
2602
|
default:
|
|
@@ -2343,6 +2670,16 @@ class RevsetEngine {
|
|
|
2343
2670
|
await this.graph.load();
|
|
2344
2671
|
return this.graph.getAll().map((c) => c.changeId);
|
|
2345
2672
|
|
|
2673
|
+
// `builtin_log()` (jj v0.44) is the revset the built-in `jj log` uses
|
|
2674
|
+
// when no user override is configured. isomorphic-jj's `log()` already
|
|
2675
|
+
// defaults to `all()` when no revset is given (see `jj.log()`), so
|
|
2676
|
+
// `builtin_log()` is a pure alias for that same default — this lets
|
|
2677
|
+
// callers compose it (e.g. `builtin_log() & mine()`) instead of
|
|
2678
|
+
// duplicating `all()`.
|
|
2679
|
+
case 'builtin_log':
|
|
2680
|
+
await this.graph.load();
|
|
2681
|
+
return this.graph.getAll().map((c) => c.changeId);
|
|
2682
|
+
|
|
2346
2683
|
case 'none':
|
|
2347
2684
|
return [];
|
|
2348
2685
|
|
|
@@ -2373,6 +2710,13 @@ class RevsetEngine {
|
|
|
2373
2710
|
return allChanges.filter((c) => !hasChildren.has(c.changeId)).map((c) => c.changeId);
|
|
2374
2711
|
}
|
|
2375
2712
|
|
|
2713
|
+
// `git_refs()` and `git_head()` are deprecated compatibility shims.
|
|
2714
|
+
// Upstream jj deprecated both in favor of `bookmarks()`/`tags()` and
|
|
2715
|
+
// `@`, then removed them outright in jj v0.43 (revset/template calls
|
|
2716
|
+
// to either now error in real jj). isomorphic-jj keeps them working
|
|
2717
|
+
// rather than hard-breaking existing callers, but they are no longer
|
|
2718
|
+
// advertised in `_suggestion()` — prefer `bookmarks([pattern])` /
|
|
2719
|
+
// `tags([pattern])` / `@` instead.
|
|
2376
2720
|
case 'git_refs': {
|
|
2377
2721
|
if (!this.bookmarkStore) return [];
|
|
2378
2722
|
await this.bookmarkStore.load();
|
|
@@ -2449,15 +2793,27 @@ class RevsetEngine {
|
|
|
2449
2793
|
}
|
|
2450
2794
|
|
|
2451
2795
|
case 'divergent': {
|
|
2796
|
+
// See issue #32 / converge(): a divergent change has more than one
|
|
2797
|
+
// visible commit sharing its changeId (added via
|
|
2798
|
+
// ChangeGraph.addDivergentCopy(), which is why counting `changeId`
|
|
2799
|
+
// occurrences across getAll() actually finds something now —
|
|
2800
|
+
// every other change has exactly one entry here). `c.divergent ===
|
|
2801
|
+
// true` also matches directly, since addDivergentCopy() sets that
|
|
2802
|
+
// flag on the copy it adds; the count check catches the *primary*
|
|
2803
|
+
// copy too (it's never itself flagged, since nothing about IT
|
|
2804
|
+
// changed) so both/all copies of a divergent changeId are
|
|
2805
|
+
// reported, not just the one(s) added after the first.
|
|
2452
2806
|
await this.graph.load();
|
|
2453
2807
|
const all = this.graph.getAll();
|
|
2454
2808
|
const counts = new Map();
|
|
2455
2809
|
for (const c of all) {
|
|
2456
2810
|
counts.set(c.changeId, (counts.get(c.changeId) || 0) + 1);
|
|
2457
2811
|
}
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2812
|
+
const matched = all.filter((c) => c.divergent === true || counts.get(c.changeId) > 1);
|
|
2813
|
+
// De-duplicate: multiple copies of the same divergent changeId
|
|
2814
|
+
// would otherwise each contribute their own (identical) changeId
|
|
2815
|
+
// to the result.
|
|
2816
|
+
return Array.from(new Set(matched.map((c) => c.changeId)));
|
|
2461
2817
|
}
|
|
2462
2818
|
|
|
2463
2819
|
case 'merges':
|
|
@@ -3511,23 +3867,201 @@ class RevsetEngine {
|
|
|
3511
3867
|
}
|
|
3512
3868
|
|
|
3513
3869
|
/**
|
|
3514
|
-
*
|
|
3870
|
+
* Minimal POSIX-only path utilities (join / dirname / resolve).
|
|
3515
3871
|
*
|
|
3516
|
-
*
|
|
3517
|
-
*
|
|
3518
|
-
* -
|
|
3519
|
-
* -
|
|
3520
|
-
*
|
|
3872
|
+
* Why not Node's `path` module: this library is isomorphic — it also needs
|
|
3873
|
+
* to run in browser bundles (see issue #28), where bare `import path from
|
|
3874
|
+
* 'path'` fails unless a bundler-specific polyfill is configured. Git/jj
|
|
3875
|
+
* repository paths are always POSIX-style (forward slashes), regardless of
|
|
3876
|
+
* host OS, so depending on Node's `path` module was also subtly wrong on
|
|
3877
|
+
* Windows: `path.join()` there uses `path.win32` semantics and would
|
|
3878
|
+
* produce backslash-joined paths for repository-relative paths, which is
|
|
3879
|
+
* not what git/jj object storage or working-copy paths want.
|
|
3521
3880
|
*
|
|
3522
|
-
*
|
|
3881
|
+
* These implement the same normalization rules as Node's `path.posix` for
|
|
3882
|
+
* the operations this codebase actually uses.
|
|
3523
3883
|
*/
|
|
3524
3884
|
|
|
3525
|
-
|
|
3526
3885
|
/**
|
|
3527
|
-
*
|
|
3886
|
+
* @param {string} path
|
|
3887
|
+
* @returns {string} `path` with any run of `/` collapsed to a single `/`,
|
|
3888
|
+
* and `.`/`..` segments resolved. Preserves a leading `/` (absolute) and
|
|
3889
|
+
* a trailing `/` when present in the input.
|
|
3528
3890
|
*/
|
|
3529
|
-
|
|
3530
|
-
|
|
3891
|
+
function normalize(path) {
|
|
3892
|
+
if (path === '') return '.';
|
|
3893
|
+
|
|
3894
|
+
const isAbsolute = path.charCodeAt(0) === 47; // '/'
|
|
3895
|
+
const trailingSlash = path.charCodeAt(path.length - 1) === 47;
|
|
3896
|
+
|
|
3897
|
+
const segments = path.split('/');
|
|
3898
|
+
/** @type {string[]} */
|
|
3899
|
+
const out = [];
|
|
3900
|
+
for (const segment of segments) {
|
|
3901
|
+
if (segment === '' || segment === '.') continue;
|
|
3902
|
+
if (segment === '..') {
|
|
3903
|
+
if (out.length && out[out.length - 1] !== '..') {
|
|
3904
|
+
out.pop();
|
|
3905
|
+
} else if (!isAbsolute) {
|
|
3906
|
+
out.push('..');
|
|
3907
|
+
}
|
|
3908
|
+
continue;
|
|
3909
|
+
}
|
|
3910
|
+
out.push(segment);
|
|
3911
|
+
}
|
|
3912
|
+
|
|
3913
|
+
let result = out.join('/');
|
|
3914
|
+
if (!result && !isAbsolute) result = '.';
|
|
3915
|
+
if (isAbsolute) result = '/' + result;
|
|
3916
|
+
if (trailingSlash && result.charCodeAt(result.length - 1) !== 47) result += '/';
|
|
3917
|
+
return result;
|
|
3918
|
+
}
|
|
3919
|
+
|
|
3920
|
+
/**
|
|
3921
|
+
* @param {...string} parts
|
|
3922
|
+
* @returns {string} All `parts` joined with `/` and normalized.
|
|
3923
|
+
*/
|
|
3924
|
+
function join(...parts) {
|
|
3925
|
+
if (parts.length === 0) return '.';
|
|
3926
|
+
const joined = parts.filter((p) => p !== '').join('/');
|
|
3927
|
+
return joined === '' ? '.' : normalize(joined);
|
|
3928
|
+
}
|
|
3929
|
+
|
|
3930
|
+
/**
|
|
3931
|
+
* @param {string} path
|
|
3932
|
+
* @returns {string} The directory portion of `path` (Node `path.dirname` semantics).
|
|
3933
|
+
*/
|
|
3934
|
+
function dirname(path) {
|
|
3935
|
+
if (path.length === 0) return '.';
|
|
3936
|
+
const isAbsolute = path.charCodeAt(0) === 47;
|
|
3937
|
+
let end = -1;
|
|
3938
|
+
let matchedSlash = true;
|
|
3939
|
+
for (let i = path.length - 1; i >= 1; i--) {
|
|
3940
|
+
if (path.charCodeAt(i) === 47) {
|
|
3941
|
+
if (!matchedSlash) {
|
|
3942
|
+
end = i;
|
|
3943
|
+
break;
|
|
3944
|
+
}
|
|
3945
|
+
} else {
|
|
3946
|
+
matchedSlash = false;
|
|
3947
|
+
}
|
|
3948
|
+
}
|
|
3949
|
+
|
|
3950
|
+
if (end === -1) return isAbsolute ? '/' : '.';
|
|
3951
|
+
if (isAbsolute && end === 0) return '/';
|
|
3952
|
+
return path.slice(0, end);
|
|
3953
|
+
}
|
|
3954
|
+
|
|
3955
|
+
/**
|
|
3956
|
+
* Resolves a sequence of path segments into an absolute path.
|
|
3957
|
+
*
|
|
3958
|
+
* Unlike Node's `path.resolve`, this never consults the real process cwd —
|
|
3959
|
+
* segments are resolved right-to-left, and if no absolute segment is found
|
|
3960
|
+
* the result is left relative to a virtual `/` root (this library always
|
|
3961
|
+
* deals in absolute repository-relative paths, so callers are expected to
|
|
3962
|
+
* supply an absolute base).
|
|
3963
|
+
*
|
|
3964
|
+
* @param {...string} parts
|
|
3965
|
+
* @returns {string}
|
|
3966
|
+
*/
|
|
3967
|
+
function resolve(...parts) {
|
|
3968
|
+
let resolved = '';
|
|
3969
|
+
let isAbsolute = false;
|
|
3970
|
+
|
|
3971
|
+
for (let i = parts.length - 1; i >= -1 && !isAbsolute; i--) {
|
|
3972
|
+
const path = i >= 0 ? parts[i] : '/';
|
|
3973
|
+
if (path.length === 0) continue;
|
|
3974
|
+
resolved = path + '/' + resolved;
|
|
3975
|
+
isAbsolute = path.charCodeAt(0) === 47;
|
|
3976
|
+
}
|
|
3977
|
+
|
|
3978
|
+
resolved = normalize(resolved);
|
|
3979
|
+
// normalize() preserves a trailing slash; path.resolve() never returns one
|
|
3980
|
+
// (except for the root itself).
|
|
3981
|
+
if (resolved.length > 1 && resolved.endsWith('/')) {
|
|
3982
|
+
resolved = resolved.slice(0, -1);
|
|
3983
|
+
}
|
|
3984
|
+
return isAbsolute ? resolved : '/' + resolved;
|
|
3985
|
+
}
|
|
3986
|
+
|
|
3987
|
+
/**
|
|
3988
|
+
* Cross-environment byte/string conversion helpers.
|
|
3989
|
+
*
|
|
3990
|
+
* Why not just Node's `Buffer`: it's a Node global that most browser
|
|
3991
|
+
* bundlers (Vite, esbuild, webpack 5+) do not polyfill by default, so code
|
|
3992
|
+
* that calls `Buffer.from(...)` compiles fine but throws `ReferenceError:
|
|
3993
|
+
* Buffer is not defined` at runtime in a browser bundle (see issue #28).
|
|
3994
|
+
*
|
|
3995
|
+
* `utf8Encode`/`utf8Decode` still prefer the real `Buffer` when it's present
|
|
3996
|
+
* (i.e. under Node) so existing behavior — including `.toString()` producing
|
|
3997
|
+
* the original text back, which a plain `Uint8Array` cannot do — is
|
|
3998
|
+
* unchanged there; `typeof Buffer` is safe to evaluate even where `Buffer`
|
|
3999
|
+
* was never declared (unlike referencing it directly), so this doesn't
|
|
4000
|
+
* reintroduce the crash outside Node. `hexToBytes`/`bytesToHex`/`isBytes`
|
|
4001
|
+
* only ever need `Uint8Array`, so they don't need the same branch.
|
|
4002
|
+
*/
|
|
4003
|
+
const hasBuffer = typeof Buffer !== 'undefined';
|
|
4004
|
+
|
|
4005
|
+
/**
|
|
4006
|
+
* @param {string} hex - Even-length hex string
|
|
4007
|
+
* @returns {Uint8Array}
|
|
4008
|
+
*/
|
|
4009
|
+
function hexToBytes(hex) {
|
|
4010
|
+
const length = hex.length / 2;
|
|
4011
|
+
const bytes = new Uint8Array(length);
|
|
4012
|
+
for (let i = 0; i < length; i++) {
|
|
4013
|
+
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
|
4014
|
+
}
|
|
4015
|
+
return bytes;
|
|
4016
|
+
}
|
|
4017
|
+
|
|
4018
|
+
/**
|
|
4019
|
+
* @param {string} str
|
|
4020
|
+
* @returns {Uint8Array} UTF-8 encoded bytes (a real `Buffer` under Node)
|
|
4021
|
+
*/
|
|
4022
|
+
function utf8Encode(str) {
|
|
4023
|
+
return hasBuffer ? Buffer.from(str, 'utf-8') : new TextEncoder().encode(str);
|
|
4024
|
+
}
|
|
4025
|
+
|
|
4026
|
+
/**
|
|
4027
|
+
* @param {Uint8Array} bytes
|
|
4028
|
+
* @returns {string} UTF-8 decoded string
|
|
4029
|
+
*/
|
|
4030
|
+
function utf8Decode(bytes) {
|
|
4031
|
+
return hasBuffer ? Buffer.from(bytes).toString('utf-8') : new TextDecoder('utf-8').decode(bytes);
|
|
4032
|
+
}
|
|
4033
|
+
|
|
4034
|
+
/**
|
|
4035
|
+
* True for anything that walks/quacks like binary content — a real Node
|
|
4036
|
+
* `Buffer` (which is itself a `Uint8Array` subclass) or a plain
|
|
4037
|
+
* `Uint8Array`/typed array, as produced by protobufjs in environments
|
|
4038
|
+
* without `Buffer` (e.g. a browser bundle).
|
|
4039
|
+
*
|
|
4040
|
+
* @param {any} value
|
|
4041
|
+
* @returns {boolean}
|
|
4042
|
+
*/
|
|
4043
|
+
function isBytes(value) {
|
|
4044
|
+
return value instanceof Uint8Array;
|
|
4045
|
+
}
|
|
4046
|
+
|
|
4047
|
+
/**
|
|
4048
|
+
* ConflictModel - First-class conflict handling
|
|
4049
|
+
*
|
|
4050
|
+
* In JJ, conflicts are not errors but structured data that can be:
|
|
4051
|
+
* - Stored in commits
|
|
4052
|
+
* - Rebased along with changes
|
|
4053
|
+
* - Resolved incrementally
|
|
4054
|
+
* - Committed and shared
|
|
4055
|
+
*
|
|
4056
|
+
* This matches JJ's philosophy: conflicts never block you.
|
|
4057
|
+
*/
|
|
4058
|
+
|
|
4059
|
+
|
|
4060
|
+
/**
|
|
4061
|
+
* Conflict types supported by JJ
|
|
4062
|
+
*/
|
|
4063
|
+
const ConflictType = {
|
|
4064
|
+
CONTENT: 'content', // File content conflicts (3-way merge)
|
|
3531
4065
|
DELETE_MODIFY: 'delete-modify', // File deleted in one side, modified in other
|
|
3532
4066
|
MODIFY_DELETE: 'modify-delete', // File modified in one side, deleted in other
|
|
3533
4067
|
ADD_ADD: 'add-add', // Same path added in both sides
|
|
@@ -3682,6 +4216,15 @@ class ConflictModel {
|
|
|
3682
4216
|
const conflict = this._detectPathConflict(path, baseContent, leftContent, rightContent);
|
|
3683
4217
|
if (conflict) {
|
|
3684
4218
|
conflicts.push(conflict);
|
|
4219
|
+
} else if (workingCopyDir) {
|
|
4220
|
+
// No conflict, but "no conflict" doesn't mean "nothing to do": of
|
|
4221
|
+
// _detectPathConflict's null-returning cases, every one *except*
|
|
4222
|
+
// "right side changed, left didn't" already matches what's on disk,
|
|
4223
|
+
// because `leftContent` **is** what's on disk (read straight from
|
|
4224
|
+
// the working copy above) -- so only that one case needs a write.
|
|
4225
|
+
if (baseContent === leftContent && baseContent !== rightContent) {
|
|
4226
|
+
await this._applyCleanResolution(workingCopyDir, path, rightContent);
|
|
4227
|
+
}
|
|
3685
4228
|
}
|
|
3686
4229
|
}
|
|
3687
4230
|
|
|
@@ -3769,6 +4312,30 @@ class ConflictModel {
|
|
|
3769
4312
|
return null;
|
|
3770
4313
|
}
|
|
3771
4314
|
|
|
4315
|
+
/**
|
|
4316
|
+
* Create a content conflict with more than two disagreeing versions —
|
|
4317
|
+
* every other conflict in this module has exactly two sides (`left`/
|
|
4318
|
+
* `right`, or `left`/`right` on an add-add), because every OTHER caller
|
|
4319
|
+
* (merge, rebase) only ever three-way-merges two changes at a time.
|
|
4320
|
+
* converge() (issue #32) can face more than two divergent copies of one
|
|
4321
|
+
* change in a single call, so `sides` here is `{ base, versions }`
|
|
4322
|
+
* instead: `versions` is every distinct value found across all copies
|
|
4323
|
+
* that differs from `base`, each paired with (one of) the commitId(s)
|
|
4324
|
+
* that has it.
|
|
4325
|
+
*
|
|
4326
|
+
* @param {string} path
|
|
4327
|
+
* @param {any} base
|
|
4328
|
+
* @param {Array<{commitId: string, content: any}>} versions
|
|
4329
|
+
*/
|
|
4330
|
+
createNWayConflict(path, base, versions) {
|
|
4331
|
+
return this._createConflict({
|
|
4332
|
+
type: ConflictType.CONTENT,
|
|
4333
|
+
path,
|
|
4334
|
+
sides: { base, versions },
|
|
4335
|
+
message: `${versions.length} divergent copies disagree on this file's content`,
|
|
4336
|
+
});
|
|
4337
|
+
}
|
|
4338
|
+
|
|
3772
4339
|
/**
|
|
3773
4340
|
* Create a conflict object
|
|
3774
4341
|
*
|
|
@@ -4012,6 +4579,36 @@ class ConflictModel {
|
|
|
4012
4579
|
return result;
|
|
4013
4580
|
}
|
|
4014
4581
|
|
|
4582
|
+
/**
|
|
4583
|
+
* Apply a clean (non-conflicting) three-way resolution to the working
|
|
4584
|
+
* copy, for the default (no merge driver) path in detectConflicts(): the
|
|
4585
|
+
* "right side wins" case, where the resolved content differs from what's
|
|
4586
|
+
* currently on disk (see the call site for why this is the only clean
|
|
4587
|
+
* case that needs one).
|
|
4588
|
+
*
|
|
4589
|
+
* @param {string} workingCopyDir - Working copy directory
|
|
4590
|
+
* @param {string} filePath - File path
|
|
4591
|
+
* @param {any} content - The winning (right) side's content, or
|
|
4592
|
+
* `undefined` to mean the file was deleted on that side
|
|
4593
|
+
*/
|
|
4594
|
+
async _applyCleanResolution(workingCopyDir, filePath, content) {
|
|
4595
|
+
const fullPath = join(workingCopyDir, filePath);
|
|
4596
|
+
|
|
4597
|
+
if (content === undefined) {
|
|
4598
|
+
await this.fs.promises.unlink(fullPath).catch(() => {});
|
|
4599
|
+
return;
|
|
4600
|
+
}
|
|
4601
|
+
|
|
4602
|
+
const dir = dirname(fullPath);
|
|
4603
|
+
await mkdirp(this.fs, dir);
|
|
4604
|
+
|
|
4605
|
+
if (isBytes(content)) {
|
|
4606
|
+
await this.fs.promises.writeFile(fullPath, content);
|
|
4607
|
+
} else {
|
|
4608
|
+
await this.fs.promises.writeFile(fullPath, content, 'utf8');
|
|
4609
|
+
}
|
|
4610
|
+
}
|
|
4611
|
+
|
|
4015
4612
|
/**
|
|
4016
4613
|
* Write merge driver result to working copy (v0.5)
|
|
4017
4614
|
*
|
|
@@ -4020,32 +4617,32 @@ class ConflictModel {
|
|
|
4020
4617
|
* @param {Record<string, any>} result - Merge result
|
|
4021
4618
|
*/
|
|
4022
4619
|
async _writeDriverResult(workingCopyDir, filePath, result) {
|
|
4023
|
-
const fullPath =
|
|
4620
|
+
const fullPath = join(workingCopyDir, filePath);
|
|
4024
4621
|
|
|
4025
4622
|
// Ensure directory exists
|
|
4026
|
-
const dir =
|
|
4027
|
-
await this.fs
|
|
4623
|
+
const dir = dirname(fullPath);
|
|
4624
|
+
await mkdirp(this.fs, dir);
|
|
4028
4625
|
|
|
4029
4626
|
// Write main file
|
|
4030
4627
|
if (result.content !== null && result.content !== undefined) {
|
|
4031
|
-
if (
|
|
4628
|
+
if (isBytes(result.content)) {
|
|
4032
4629
|
await this.fs.promises.writeFile(fullPath, result.content);
|
|
4033
4630
|
} else {
|
|
4034
|
-
await this.fs.promises.writeFile(fullPath, result.content, '
|
|
4631
|
+
await this.fs.promises.writeFile(fullPath, result.content, 'utf8');
|
|
4035
4632
|
}
|
|
4036
4633
|
}
|
|
4037
4634
|
|
|
4038
4635
|
// Write additional files if driver provided them
|
|
4039
4636
|
if (result.additionalFiles) {
|
|
4040
4637
|
for (const [additionalPath, additionalContent] of Object.entries(result.additionalFiles)) {
|
|
4041
|
-
const additionalFullPath =
|
|
4042
|
-
const additionalDir =
|
|
4043
|
-
await this.fs
|
|
4638
|
+
const additionalFullPath = join(workingCopyDir, additionalPath);
|
|
4639
|
+
const additionalDir = dirname(additionalFullPath);
|
|
4640
|
+
await mkdirp(this.fs, additionalDir);
|
|
4044
4641
|
|
|
4045
|
-
if (
|
|
4642
|
+
if (isBytes(additionalContent)) {
|
|
4046
4643
|
await this.fs.promises.writeFile(additionalFullPath, additionalContent);
|
|
4047
4644
|
} else {
|
|
4048
|
-
await this.fs.promises.writeFile(additionalFullPath, additionalContent, '
|
|
4645
|
+
await this.fs.promises.writeFile(additionalFullPath, additionalContent, 'utf8');
|
|
4049
4646
|
}
|
|
4050
4647
|
}
|
|
4051
4648
|
}
|
|
@@ -4091,11 +4688,11 @@ function matchesPattern(path, pattern) {
|
|
|
4091
4688
|
/**
|
|
4092
4689
|
* Check if a file is likely binary based on content
|
|
4093
4690
|
*
|
|
4094
|
-
* @param {string|
|
|
4691
|
+
* @param {string|Uint8Array} content - File content
|
|
4095
4692
|
* @returns {boolean} Whether content appears binary
|
|
4096
4693
|
*/
|
|
4097
4694
|
function isBinaryContent(content) {
|
|
4098
|
-
if (
|
|
4695
|
+
if (isBytes(content)) return true;
|
|
4099
4696
|
if (typeof content !== 'string') return false;
|
|
4100
4697
|
|
|
4101
4698
|
// Check for null bytes (common in binary files)
|
|
@@ -4410,9 +5007,9 @@ class MergeDriverRegistry {
|
|
|
4410
5007
|
/**
|
|
4411
5008
|
* Prepare content for driver based on binary/text
|
|
4412
5009
|
*
|
|
4413
|
-
* @param {string|
|
|
4414
|
-
* @param {boolean} isBinary - Whether to return as
|
|
4415
|
-
* @returns {string|
|
|
5010
|
+
* @param {string|Uint8Array|null} content - Raw content
|
|
5011
|
+
* @param {boolean} isBinary - Whether to return as bytes
|
|
5012
|
+
* @returns {string|Uint8Array|null} Prepared content
|
|
4416
5013
|
*/
|
|
4417
5014
|
prepareContent(content, isBinary) {
|
|
4418
5015
|
if (content === null || content === undefined) {
|
|
@@ -4420,14 +5017,14 @@ class MergeDriverRegistry {
|
|
|
4420
5017
|
}
|
|
4421
5018
|
|
|
4422
5019
|
if (isBinary) {
|
|
4423
|
-
// Convert to
|
|
4424
|
-
if (
|
|
4425
|
-
if (typeof content === 'string') return
|
|
4426
|
-
return
|
|
5020
|
+
// Convert to bytes if needed
|
|
5021
|
+
if (isBytes(content)) return content;
|
|
5022
|
+
if (typeof content === 'string') return utf8Encode(content);
|
|
5023
|
+
return utf8Encode(String(content));
|
|
4427
5024
|
} else {
|
|
4428
5025
|
// Convert to string if needed
|
|
4429
5026
|
if (typeof content === 'string') return content;
|
|
4430
|
-
if (
|
|
5027
|
+
if (isBytes(content)) return utf8Decode(content);
|
|
4431
5028
|
return String(content);
|
|
4432
5029
|
}
|
|
4433
5030
|
}
|
|
@@ -4588,25 +5185,25 @@ class WorkspaceManager {
|
|
|
4588
5185
|
};
|
|
4589
5186
|
|
|
4590
5187
|
// Create workspace directory
|
|
4591
|
-
await this.fs
|
|
5188
|
+
await mkdirp(this.fs, args.path);
|
|
4592
5189
|
|
|
4593
5190
|
// Create workspace-specific working_copy directory in .jj
|
|
4594
|
-
const workspaceCopyDir =
|
|
4595
|
-
await this.fs
|
|
5191
|
+
const workspaceCopyDir = join(this.repoDir, '.jj', 'working_copy', workspaceId);
|
|
5192
|
+
await mkdirp(this.fs, workspaceCopyDir);
|
|
4596
5193
|
|
|
4597
5194
|
// Create .git file pointing to main repo's .git directory
|
|
4598
5195
|
// This allows Git tools to work in the workspace
|
|
4599
5196
|
// IMPORTANT: Must use absolute paths for Git compatibility
|
|
4600
|
-
const gitFilePath =
|
|
4601
|
-
const mainGitDir =
|
|
5197
|
+
const gitFilePath = join(args.path, '.git');
|
|
5198
|
+
const mainGitDir = resolve(this.repoDir, '.git');
|
|
4602
5199
|
const gitFileContent = `gitdir: ${mainGitDir}\n`;
|
|
4603
5200
|
await this.fs.promises.writeFile(gitFilePath, gitFileContent, 'utf8');
|
|
4604
5201
|
|
|
4605
5202
|
// Create .jj file pointing to main repo's .jj directory
|
|
4606
5203
|
// This allows JJ tools to work in the workspace
|
|
4607
5204
|
// IMPORTANT: Must use absolute paths for JJ CLI compatibility
|
|
4608
|
-
const jjFilePath =
|
|
4609
|
-
const mainJJDir =
|
|
5205
|
+
const jjFilePath = join(args.path, '.jj');
|
|
5206
|
+
const mainJJDir = resolve(this.repoDir, '.jj');
|
|
4610
5207
|
const jjFileContent = `jjdir: ${mainJJDir}\n`;
|
|
4611
5208
|
await this.fs.promises.writeFile(jjFilePath, jjFileContent, 'utf8');
|
|
4612
5209
|
|
|
@@ -5241,6 +5838,575 @@ class UserConfig {
|
|
|
5241
5838
|
}
|
|
5242
5839
|
}
|
|
5243
5840
|
|
|
5841
|
+
/**
|
|
5842
|
+
* Precompiled protobuf reflection JSON for local_working_copy.proto
|
|
5843
|
+
*
|
|
5844
|
+
* Generated from src/protos/local_working_copy.proto by scripts/build-protos.mjs. This
|
|
5845
|
+
* is bundled directly (instead of loaded from a filesystem path at runtime)
|
|
5846
|
+
* so that protobuf schema loading works identically in Node and in browser
|
|
5847
|
+
* bundles — protobuf.load() requires filesystem/network access that does
|
|
5848
|
+
* not exist in a browser bundle (see issue #28). Regenerate with:
|
|
5849
|
+
* node scripts/build-protos.mjs
|
|
5850
|
+
*/
|
|
5851
|
+
|
|
5852
|
+
var localWorkingCopyJson = {
|
|
5853
|
+
nested: {
|
|
5854
|
+
local_working_copy: {
|
|
5855
|
+
nested: {
|
|
5856
|
+
FileType: {
|
|
5857
|
+
valuesOptions: {
|
|
5858
|
+
Conflict: {
|
|
5859
|
+
deprecated: true,
|
|
5860
|
+
},
|
|
5861
|
+
},
|
|
5862
|
+
values: {
|
|
5863
|
+
Normal: 0,
|
|
5864
|
+
Symlink: 1,
|
|
5865
|
+
Executable: 2,
|
|
5866
|
+
Conflict: 3,
|
|
5867
|
+
GitSubmodule: 4,
|
|
5868
|
+
},
|
|
5869
|
+
},
|
|
5870
|
+
MaterializedConflictData: {
|
|
5871
|
+
fields: {
|
|
5872
|
+
conflictMarkerLen: {
|
|
5873
|
+
type: 'uint32',
|
|
5874
|
+
id: 1,
|
|
5875
|
+
},
|
|
5876
|
+
},
|
|
5877
|
+
},
|
|
5878
|
+
FileState: {
|
|
5879
|
+
fields: {
|
|
5880
|
+
mtimeMillisSinceEpoch: {
|
|
5881
|
+
type: 'int64',
|
|
5882
|
+
id: 1,
|
|
5883
|
+
},
|
|
5884
|
+
size: {
|
|
5885
|
+
type: 'uint64',
|
|
5886
|
+
id: 2,
|
|
5887
|
+
},
|
|
5888
|
+
fileType: {
|
|
5889
|
+
type: 'FileType',
|
|
5890
|
+
id: 3,
|
|
5891
|
+
},
|
|
5892
|
+
materializedConflictData: {
|
|
5893
|
+
type: 'MaterializedConflictData',
|
|
5894
|
+
id: 5,
|
|
5895
|
+
},
|
|
5896
|
+
},
|
|
5897
|
+
reserved: [[4, 4]],
|
|
5898
|
+
},
|
|
5899
|
+
FileStateEntry: {
|
|
5900
|
+
fields: {
|
|
5901
|
+
path: {
|
|
5902
|
+
type: 'string',
|
|
5903
|
+
id: 1,
|
|
5904
|
+
},
|
|
5905
|
+
state: {
|
|
5906
|
+
type: 'FileState',
|
|
5907
|
+
id: 2,
|
|
5908
|
+
},
|
|
5909
|
+
},
|
|
5910
|
+
},
|
|
5911
|
+
SparsePatterns: {
|
|
5912
|
+
fields: {
|
|
5913
|
+
prefixes: {
|
|
5914
|
+
rule: 'repeated',
|
|
5915
|
+
type: 'string',
|
|
5916
|
+
id: 1,
|
|
5917
|
+
},
|
|
5918
|
+
},
|
|
5919
|
+
},
|
|
5920
|
+
TreeState: {
|
|
5921
|
+
fields: {
|
|
5922
|
+
legacyTreeId: {
|
|
5923
|
+
type: 'bytes',
|
|
5924
|
+
id: 1,
|
|
5925
|
+
options: {
|
|
5926
|
+
deprecated: true,
|
|
5927
|
+
},
|
|
5928
|
+
},
|
|
5929
|
+
treeIds: {
|
|
5930
|
+
rule: 'repeated',
|
|
5931
|
+
type: 'bytes',
|
|
5932
|
+
id: 5,
|
|
5933
|
+
},
|
|
5934
|
+
fileStates: {
|
|
5935
|
+
rule: 'repeated',
|
|
5936
|
+
type: 'FileStateEntry',
|
|
5937
|
+
id: 2,
|
|
5938
|
+
},
|
|
5939
|
+
isFileStatesSorted: {
|
|
5940
|
+
type: 'bool',
|
|
5941
|
+
id: 6,
|
|
5942
|
+
},
|
|
5943
|
+
sparsePatterns: {
|
|
5944
|
+
type: 'SparsePatterns',
|
|
5945
|
+
id: 3,
|
|
5946
|
+
},
|
|
5947
|
+
watchmanClock: {
|
|
5948
|
+
type: 'WatchmanClock',
|
|
5949
|
+
id: 4,
|
|
5950
|
+
},
|
|
5951
|
+
},
|
|
5952
|
+
},
|
|
5953
|
+
WatchmanClock: {
|
|
5954
|
+
oneofs: {
|
|
5955
|
+
watchmanClock: {
|
|
5956
|
+
oneof: ['stringClock', 'unixTimestamp'],
|
|
5957
|
+
},
|
|
5958
|
+
},
|
|
5959
|
+
fields: {
|
|
5960
|
+
stringClock: {
|
|
5961
|
+
type: 'string',
|
|
5962
|
+
id: 1,
|
|
5963
|
+
},
|
|
5964
|
+
unixTimestamp: {
|
|
5965
|
+
type: 'int64',
|
|
5966
|
+
id: 2,
|
|
5967
|
+
},
|
|
5968
|
+
},
|
|
5969
|
+
},
|
|
5970
|
+
Checkout: {
|
|
5971
|
+
fields: {
|
|
5972
|
+
operationId: {
|
|
5973
|
+
type: 'bytes',
|
|
5974
|
+
id: 2,
|
|
5975
|
+
},
|
|
5976
|
+
workspaceName: {
|
|
5977
|
+
type: 'string',
|
|
5978
|
+
id: 3,
|
|
5979
|
+
},
|
|
5980
|
+
},
|
|
5981
|
+
reserved: [[1, 1]],
|
|
5982
|
+
},
|
|
5983
|
+
},
|
|
5984
|
+
},
|
|
5985
|
+
},
|
|
5986
|
+
};
|
|
5987
|
+
|
|
5988
|
+
/**
|
|
5989
|
+
* Precompiled protobuf reflection JSON for simple_op_store.proto
|
|
5990
|
+
*
|
|
5991
|
+
* Generated from src/protos/simple_op_store.proto by scripts/build-protos.mjs. This
|
|
5992
|
+
* is bundled directly (instead of loaded from a filesystem path at runtime)
|
|
5993
|
+
* so that protobuf schema loading works identically in Node and in browser
|
|
5994
|
+
* bundles — protobuf.load() requires filesystem/network access that does
|
|
5995
|
+
* not exist in a browser bundle (see issue #28). Regenerate with:
|
|
5996
|
+
* node scripts/build-protos.mjs
|
|
5997
|
+
*/
|
|
5998
|
+
|
|
5999
|
+
var simpleOpStoreJson = {
|
|
6000
|
+
nested: {
|
|
6001
|
+
simple_op_store: {
|
|
6002
|
+
nested: {
|
|
6003
|
+
RefConflictLegacy: {
|
|
6004
|
+
fields: {
|
|
6005
|
+
removes: {
|
|
6006
|
+
rule: 'repeated',
|
|
6007
|
+
type: 'bytes',
|
|
6008
|
+
id: 1,
|
|
6009
|
+
options: {
|
|
6010
|
+
deprecated: true,
|
|
6011
|
+
},
|
|
6012
|
+
},
|
|
6013
|
+
adds: {
|
|
6014
|
+
rule: 'repeated',
|
|
6015
|
+
type: 'bytes',
|
|
6016
|
+
id: 2,
|
|
6017
|
+
options: {
|
|
6018
|
+
deprecated: true,
|
|
6019
|
+
},
|
|
6020
|
+
},
|
|
6021
|
+
},
|
|
6022
|
+
},
|
|
6023
|
+
RefConflict: {
|
|
6024
|
+
fields: {
|
|
6025
|
+
removes: {
|
|
6026
|
+
rule: 'repeated',
|
|
6027
|
+
type: 'Term',
|
|
6028
|
+
id: 1,
|
|
6029
|
+
},
|
|
6030
|
+
adds: {
|
|
6031
|
+
rule: 'repeated',
|
|
6032
|
+
type: 'Term',
|
|
6033
|
+
id: 2,
|
|
6034
|
+
},
|
|
6035
|
+
},
|
|
6036
|
+
nested: {
|
|
6037
|
+
Term: {
|
|
6038
|
+
oneofs: {
|
|
6039
|
+
_value: {
|
|
6040
|
+
oneof: ['value'],
|
|
6041
|
+
},
|
|
6042
|
+
},
|
|
6043
|
+
fields: {
|
|
6044
|
+
value: {
|
|
6045
|
+
type: 'bytes',
|
|
6046
|
+
id: 1,
|
|
6047
|
+
options: {
|
|
6048
|
+
proto3_optional: true,
|
|
6049
|
+
},
|
|
6050
|
+
},
|
|
6051
|
+
},
|
|
6052
|
+
},
|
|
6053
|
+
},
|
|
6054
|
+
},
|
|
6055
|
+
RefTarget: {
|
|
6056
|
+
oneofs: {
|
|
6057
|
+
value: {
|
|
6058
|
+
oneof: ['commitId', 'conflictLegacy', 'conflict'],
|
|
6059
|
+
},
|
|
6060
|
+
},
|
|
6061
|
+
fields: {
|
|
6062
|
+
commitId: {
|
|
6063
|
+
type: 'bytes',
|
|
6064
|
+
id: 1,
|
|
6065
|
+
options: {
|
|
6066
|
+
deprecated: true,
|
|
6067
|
+
},
|
|
6068
|
+
},
|
|
6069
|
+
conflictLegacy: {
|
|
6070
|
+
type: 'RefConflictLegacy',
|
|
6071
|
+
id: 2,
|
|
6072
|
+
options: {
|
|
6073
|
+
deprecated: true,
|
|
6074
|
+
},
|
|
6075
|
+
},
|
|
6076
|
+
conflict: {
|
|
6077
|
+
type: 'RefConflict',
|
|
6078
|
+
id: 3,
|
|
6079
|
+
},
|
|
6080
|
+
},
|
|
6081
|
+
},
|
|
6082
|
+
RefTargetTerm: {
|
|
6083
|
+
oneofs: {
|
|
6084
|
+
_value: {
|
|
6085
|
+
oneof: ['value'],
|
|
6086
|
+
},
|
|
6087
|
+
},
|
|
6088
|
+
fields: {
|
|
6089
|
+
value: {
|
|
6090
|
+
type: 'bytes',
|
|
6091
|
+
id: 1,
|
|
6092
|
+
options: {
|
|
6093
|
+
proto3_optional: true,
|
|
6094
|
+
},
|
|
6095
|
+
},
|
|
6096
|
+
},
|
|
6097
|
+
},
|
|
6098
|
+
RemoteRefState: {
|
|
6099
|
+
values: {
|
|
6100
|
+
New: 0,
|
|
6101
|
+
Tracked: 1,
|
|
6102
|
+
},
|
|
6103
|
+
},
|
|
6104
|
+
RemoteBookmark: {
|
|
6105
|
+
oneofs: {
|
|
6106
|
+
_state: {
|
|
6107
|
+
oneof: ['state'],
|
|
6108
|
+
},
|
|
6109
|
+
},
|
|
6110
|
+
fields: {
|
|
6111
|
+
remoteName: {
|
|
6112
|
+
type: 'string',
|
|
6113
|
+
id: 1,
|
|
6114
|
+
},
|
|
6115
|
+
target: {
|
|
6116
|
+
type: 'RefTarget',
|
|
6117
|
+
id: 2,
|
|
6118
|
+
},
|
|
6119
|
+
state: {
|
|
6120
|
+
type: 'RemoteRefState',
|
|
6121
|
+
id: 3,
|
|
6122
|
+
options: {
|
|
6123
|
+
proto3_optional: true,
|
|
6124
|
+
},
|
|
6125
|
+
},
|
|
6126
|
+
},
|
|
6127
|
+
},
|
|
6128
|
+
Bookmark: {
|
|
6129
|
+
fields: {
|
|
6130
|
+
name: {
|
|
6131
|
+
type: 'string',
|
|
6132
|
+
id: 1,
|
|
6133
|
+
},
|
|
6134
|
+
localTarget: {
|
|
6135
|
+
type: 'RefTarget',
|
|
6136
|
+
id: 2,
|
|
6137
|
+
},
|
|
6138
|
+
remoteBookmarks: {
|
|
6139
|
+
rule: 'repeated',
|
|
6140
|
+
type: 'RemoteBookmark',
|
|
6141
|
+
id: 3,
|
|
6142
|
+
options: {
|
|
6143
|
+
deprecated: true,
|
|
6144
|
+
},
|
|
6145
|
+
},
|
|
6146
|
+
},
|
|
6147
|
+
},
|
|
6148
|
+
GitRef: {
|
|
6149
|
+
fields: {
|
|
6150
|
+
name: {
|
|
6151
|
+
type: 'string',
|
|
6152
|
+
id: 1,
|
|
6153
|
+
},
|
|
6154
|
+
commitId: {
|
|
6155
|
+
type: 'bytes',
|
|
6156
|
+
id: 2,
|
|
6157
|
+
options: {
|
|
6158
|
+
deprecated: true,
|
|
6159
|
+
},
|
|
6160
|
+
},
|
|
6161
|
+
target: {
|
|
6162
|
+
type: 'RefTarget',
|
|
6163
|
+
id: 3,
|
|
6164
|
+
},
|
|
6165
|
+
},
|
|
6166
|
+
},
|
|
6167
|
+
RemoteRef: {
|
|
6168
|
+
fields: {
|
|
6169
|
+
name: {
|
|
6170
|
+
type: 'string',
|
|
6171
|
+
id: 1,
|
|
6172
|
+
},
|
|
6173
|
+
targetTerms: {
|
|
6174
|
+
rule: 'repeated',
|
|
6175
|
+
type: 'RefTargetTerm',
|
|
6176
|
+
id: 2,
|
|
6177
|
+
},
|
|
6178
|
+
state: {
|
|
6179
|
+
type: 'RemoteRefState',
|
|
6180
|
+
id: 3,
|
|
6181
|
+
},
|
|
6182
|
+
},
|
|
6183
|
+
},
|
|
6184
|
+
Tag: {
|
|
6185
|
+
fields: {
|
|
6186
|
+
name: {
|
|
6187
|
+
type: 'string',
|
|
6188
|
+
id: 1,
|
|
6189
|
+
},
|
|
6190
|
+
target: {
|
|
6191
|
+
type: 'RefTarget',
|
|
6192
|
+
id: 2,
|
|
6193
|
+
},
|
|
6194
|
+
},
|
|
6195
|
+
},
|
|
6196
|
+
View: {
|
|
6197
|
+
fields: {
|
|
6198
|
+
headIds: {
|
|
6199
|
+
rule: 'repeated',
|
|
6200
|
+
type: 'bytes',
|
|
6201
|
+
id: 1,
|
|
6202
|
+
},
|
|
6203
|
+
wcCommitId: {
|
|
6204
|
+
type: 'bytes',
|
|
6205
|
+
id: 2,
|
|
6206
|
+
options: {
|
|
6207
|
+
deprecated: true,
|
|
6208
|
+
},
|
|
6209
|
+
},
|
|
6210
|
+
wcCommitIds: {
|
|
6211
|
+
keyType: 'string',
|
|
6212
|
+
type: 'bytes',
|
|
6213
|
+
id: 8,
|
|
6214
|
+
},
|
|
6215
|
+
bookmarks: {
|
|
6216
|
+
rule: 'repeated',
|
|
6217
|
+
type: 'Bookmark',
|
|
6218
|
+
id: 5,
|
|
6219
|
+
},
|
|
6220
|
+
localTags: {
|
|
6221
|
+
rule: 'repeated',
|
|
6222
|
+
type: 'Tag',
|
|
6223
|
+
id: 6,
|
|
6224
|
+
},
|
|
6225
|
+
remoteViews: {
|
|
6226
|
+
rule: 'repeated',
|
|
6227
|
+
type: 'RemoteView',
|
|
6228
|
+
id: 11,
|
|
6229
|
+
},
|
|
6230
|
+
gitRefs: {
|
|
6231
|
+
rule: 'repeated',
|
|
6232
|
+
type: 'GitRef',
|
|
6233
|
+
id: 3,
|
|
6234
|
+
},
|
|
6235
|
+
gitHeadLegacy: {
|
|
6236
|
+
type: 'bytes',
|
|
6237
|
+
id: 7,
|
|
6238
|
+
options: {
|
|
6239
|
+
deprecated: true,
|
|
6240
|
+
},
|
|
6241
|
+
},
|
|
6242
|
+
gitHead: {
|
|
6243
|
+
type: 'RefTarget',
|
|
6244
|
+
id: 9,
|
|
6245
|
+
},
|
|
6246
|
+
hasGitRefsMigratedToRemoteTags: {
|
|
6247
|
+
type: 'bool',
|
|
6248
|
+
id: 12,
|
|
6249
|
+
},
|
|
6250
|
+
},
|
|
6251
|
+
reserved: [
|
|
6252
|
+
[4, 4],
|
|
6253
|
+
[10, 10],
|
|
6254
|
+
],
|
|
6255
|
+
},
|
|
6256
|
+
RemoteView: {
|
|
6257
|
+
fields: {
|
|
6258
|
+
name: {
|
|
6259
|
+
type: 'string',
|
|
6260
|
+
id: 1,
|
|
6261
|
+
},
|
|
6262
|
+
bookmarks: {
|
|
6263
|
+
rule: 'repeated',
|
|
6264
|
+
type: 'RemoteRef',
|
|
6265
|
+
id: 2,
|
|
6266
|
+
},
|
|
6267
|
+
tags: {
|
|
6268
|
+
rule: 'repeated',
|
|
6269
|
+
type: 'RemoteRef',
|
|
6270
|
+
id: 3,
|
|
6271
|
+
},
|
|
6272
|
+
},
|
|
6273
|
+
},
|
|
6274
|
+
Operation: {
|
|
6275
|
+
fields: {
|
|
6276
|
+
viewId: {
|
|
6277
|
+
type: 'bytes',
|
|
6278
|
+
id: 1,
|
|
6279
|
+
},
|
|
6280
|
+
parents: {
|
|
6281
|
+
rule: 'repeated',
|
|
6282
|
+
type: 'bytes',
|
|
6283
|
+
id: 2,
|
|
6284
|
+
},
|
|
6285
|
+
metadata: {
|
|
6286
|
+
type: 'OperationMetadata',
|
|
6287
|
+
id: 3,
|
|
6288
|
+
},
|
|
6289
|
+
commitPredecessors: {
|
|
6290
|
+
rule: 'repeated',
|
|
6291
|
+
type: 'CommitPredecessors',
|
|
6292
|
+
id: 4,
|
|
6293
|
+
},
|
|
6294
|
+
storesCommitPredecessors: {
|
|
6295
|
+
type: 'bool',
|
|
6296
|
+
id: 5,
|
|
6297
|
+
},
|
|
6298
|
+
},
|
|
6299
|
+
},
|
|
6300
|
+
Timestamp: {
|
|
6301
|
+
fields: {
|
|
6302
|
+
millisSinceEpoch: {
|
|
6303
|
+
type: 'int64',
|
|
6304
|
+
id: 1,
|
|
6305
|
+
},
|
|
6306
|
+
tzOffset: {
|
|
6307
|
+
type: 'int32',
|
|
6308
|
+
id: 2,
|
|
6309
|
+
},
|
|
6310
|
+
},
|
|
6311
|
+
},
|
|
6312
|
+
OperationMetadata: {
|
|
6313
|
+
fields: {
|
|
6314
|
+
startTime: {
|
|
6315
|
+
type: 'Timestamp',
|
|
6316
|
+
id: 1,
|
|
6317
|
+
},
|
|
6318
|
+
endTime: {
|
|
6319
|
+
type: 'Timestamp',
|
|
6320
|
+
id: 2,
|
|
6321
|
+
},
|
|
6322
|
+
description: {
|
|
6323
|
+
type: 'string',
|
|
6324
|
+
id: 3,
|
|
6325
|
+
},
|
|
6326
|
+
hostname: {
|
|
6327
|
+
type: 'string',
|
|
6328
|
+
id: 4,
|
|
6329
|
+
},
|
|
6330
|
+
username: {
|
|
6331
|
+
type: 'string',
|
|
6332
|
+
id: 5,
|
|
6333
|
+
},
|
|
6334
|
+
isSnapshot: {
|
|
6335
|
+
type: 'bool',
|
|
6336
|
+
id: 7,
|
|
6337
|
+
},
|
|
6338
|
+
tags: {
|
|
6339
|
+
keyType: 'string',
|
|
6340
|
+
type: 'string',
|
|
6341
|
+
id: 6,
|
|
6342
|
+
},
|
|
6343
|
+
},
|
|
6344
|
+
},
|
|
6345
|
+
CommitPredecessors: {
|
|
6346
|
+
fields: {
|
|
6347
|
+
commitId: {
|
|
6348
|
+
type: 'bytes',
|
|
6349
|
+
id: 1,
|
|
6350
|
+
},
|
|
6351
|
+
predecessorIds: {
|
|
6352
|
+
rule: 'repeated',
|
|
6353
|
+
type: 'bytes',
|
|
6354
|
+
id: 2,
|
|
6355
|
+
},
|
|
6356
|
+
},
|
|
6357
|
+
},
|
|
6358
|
+
},
|
|
6359
|
+
},
|
|
6360
|
+
},
|
|
6361
|
+
};
|
|
6362
|
+
|
|
6363
|
+
/**
|
|
6364
|
+
* Protobuf schema loader
|
|
6365
|
+
*
|
|
6366
|
+
* Builds protobuf.js `Root` instances from precompiled reflection JSON
|
|
6367
|
+
* (see local_working_copy.json.js / simple_op_store.json.js, generated from
|
|
6368
|
+
* the .proto files in this directory) instead of calling `protobuf.load()`
|
|
6369
|
+
* against a filesystem path.
|
|
6370
|
+
*
|
|
6371
|
+
* Why: `protobuf.load()` resolves its argument as a file path (Node `fs`) or
|
|
6372
|
+
* URL (XHR/fetch), which requires either a real filesystem or a network
|
|
6373
|
+
* request. In a bundled browser build there is no filesystem path to read
|
|
6374
|
+
* (see issue #28), and shipping the raw .proto text and fetching it at
|
|
6375
|
+
* runtime would require bundler-specific asset handling. Reflection JSON is
|
|
6376
|
+
* plain data, so it can be imported like any other JS module and works
|
|
6377
|
+
* identically under Node, Jest, and any browser bundler (Vite, esbuild,
|
|
6378
|
+
* webpack, Rollup, ...).
|
|
6379
|
+
*
|
|
6380
|
+
* Regenerate the JSON files after editing a .proto with:
|
|
6381
|
+
* npm run build:protos
|
|
6382
|
+
*/
|
|
6383
|
+
|
|
6384
|
+
|
|
6385
|
+
/** @type {protobuf.Root|undefined} */
|
|
6386
|
+
let localWorkingCopyRoot;
|
|
6387
|
+
/** @type {protobuf.Root|undefined} */
|
|
6388
|
+
let simpleOpStoreRoot;
|
|
6389
|
+
|
|
6390
|
+
/**
|
|
6391
|
+
* @returns {protobuf.Root} Root for local_working_copy.proto (Checkout, TreeState, ...)
|
|
6392
|
+
*/
|
|
6393
|
+
function getLocalWorkingCopyRoot() {
|
|
6394
|
+
if (!localWorkingCopyRoot) {
|
|
6395
|
+
localWorkingCopyRoot = protobuf.Root.fromJSON(localWorkingCopyJson);
|
|
6396
|
+
}
|
|
6397
|
+
return localWorkingCopyRoot;
|
|
6398
|
+
}
|
|
6399
|
+
|
|
6400
|
+
/**
|
|
6401
|
+
* @returns {protobuf.Root} Root for simple_op_store.proto (Operation, View, ...)
|
|
6402
|
+
*/
|
|
6403
|
+
function getSimpleOpStoreRoot() {
|
|
6404
|
+
if (!simpleOpStoreRoot) {
|
|
6405
|
+
simpleOpStoreRoot = protobuf.Root.fromJSON(simpleOpStoreJson);
|
|
6406
|
+
}
|
|
6407
|
+
return simpleOpStoreRoot;
|
|
6408
|
+
}
|
|
6409
|
+
|
|
5244
6410
|
/**
|
|
5245
6411
|
* JJ Checkout File Manager
|
|
5246
6412
|
*
|
|
@@ -5249,15 +6415,6 @@ class UserConfig {
|
|
|
5249
6415
|
*/
|
|
5250
6416
|
|
|
5251
6417
|
|
|
5252
|
-
const __filename$4 = fileURLToPath(import.meta.url);
|
|
5253
|
-
const __dirname$4 = path.dirname(__filename$4);
|
|
5254
|
-
|
|
5255
|
-
// Determine proto path based on whether we're running from dist or src
|
|
5256
|
-
const inDist$3 = __dirname$4.includes('/dist') || __dirname$4.includes('\\dist');
|
|
5257
|
-
const protoDir$3 = inDist$3
|
|
5258
|
-
? path.join(__dirname$4, '..', 'src', 'protos')
|
|
5259
|
-
: path.join(__dirname$4, '..', 'protos');
|
|
5260
|
-
|
|
5261
6418
|
class JJCheckout {
|
|
5262
6419
|
/**
|
|
5263
6420
|
* @param {any} fs - File system module (isomorphic-git compatible)
|
|
@@ -5266,7 +6423,6 @@ class JJCheckout {
|
|
|
5266
6423
|
constructor(fs, dir) {
|
|
5267
6424
|
this.fs = fs;
|
|
5268
6425
|
this.dir = dir;
|
|
5269
|
-
this.protoPath = path.join(protoDir$3, 'local_working_copy.proto');
|
|
5270
6426
|
}
|
|
5271
6427
|
|
|
5272
6428
|
/**
|
|
@@ -5277,11 +6433,11 @@ class JJCheckout {
|
|
|
5277
6433
|
*/
|
|
5278
6434
|
async writeCheckout(operationId, workspaceName = 'default') {
|
|
5279
6435
|
// Load protobuf schema
|
|
5280
|
-
const root =
|
|
6436
|
+
const root = getLocalWorkingCopyRoot();
|
|
5281
6437
|
const Checkout = root.lookupType('local_working_copy.Checkout');
|
|
5282
6438
|
|
|
5283
6439
|
// Convert hex operation ID to bytes
|
|
5284
|
-
const opIdBuffer =
|
|
6440
|
+
const opIdBuffer = hexToBytes(operationId);
|
|
5285
6441
|
|
|
5286
6442
|
// Create message (use camelCase for protobufjs)
|
|
5287
6443
|
const message = Checkout.create({
|
|
@@ -5313,7 +6469,7 @@ class JJCheckout {
|
|
|
5313
6469
|
const buffer = await this.fs.promises.readFile(checkoutPath);
|
|
5314
6470
|
|
|
5315
6471
|
// Load protobuf schema
|
|
5316
|
-
const root =
|
|
6472
|
+
const root = getLocalWorkingCopyRoot();
|
|
5317
6473
|
const Checkout = root.lookupType('local_working_copy.Checkout');
|
|
5318
6474
|
|
|
5319
6475
|
// Decode
|
|
@@ -5327,6 +6483,52 @@ class JJCheckout {
|
|
|
5327
6483
|
}
|
|
5328
6484
|
}
|
|
5329
6485
|
|
|
6486
|
+
/**
|
|
6487
|
+
* Shared atomic-write helper for filesystem writes.
|
|
6488
|
+
*
|
|
6489
|
+
* Writes content to a uniquely-named temp file, then renames it over the
|
|
6490
|
+
* canonical path. A crash/error between those two steps leaves the
|
|
6491
|
+
* canonical file untouched (never a truncated/partial write) — the temp
|
|
6492
|
+
* file is best-effort cleaned up on failure.
|
|
6493
|
+
*
|
|
6494
|
+
* This mirrors the pattern `Storage.write()` (src/core/storage-manager.js)
|
|
6495
|
+
* already uses for JSON stores, factored out so the protobuf-backed stores
|
|
6496
|
+
* (JJOperationStore, JJViewStore, JJTreeState) can use it too instead of
|
|
6497
|
+
* calling `writeFile()` directly on the canonical path — see issue #16.
|
|
6498
|
+
*/
|
|
6499
|
+
|
|
6500
|
+
|
|
6501
|
+
/**
|
|
6502
|
+
* @param {any} fs - Filesystem implementation (Node fs, LightningFS, etc.)
|
|
6503
|
+
* @param {string} targetPath - Canonical path to write to
|
|
6504
|
+
* @param {string|Uint8Array} content - Content to write
|
|
6505
|
+
*/
|
|
6506
|
+
async function atomicWriteFile(fs, targetPath, content) {
|
|
6507
|
+
const tmpSuffix =
|
|
6508
|
+
typeof crypto !== 'undefined' && crypto.randomUUID
|
|
6509
|
+
? crypto.randomUUID()
|
|
6510
|
+
: `${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
|
|
6511
|
+
const tmpPath = `${targetPath}.tmp.${Date.now()}.${tmpSuffix}`;
|
|
6512
|
+
|
|
6513
|
+
try {
|
|
6514
|
+
const dirPath = targetPath.substring(0, targetPath.lastIndexOf('/'));
|
|
6515
|
+
if (dirPath) {
|
|
6516
|
+
await mkdirp(fs, dirPath);
|
|
6517
|
+
}
|
|
6518
|
+
|
|
6519
|
+
await fs.promises.writeFile(tmpPath, content);
|
|
6520
|
+
await fs.promises.rename(tmpPath, targetPath);
|
|
6521
|
+
} catch (error) {
|
|
6522
|
+
try {
|
|
6523
|
+
await fs.promises.unlink(tmpPath);
|
|
6524
|
+
} catch {
|
|
6525
|
+
// Best-effort cleanup; ignore if the temp file was never created (or
|
|
6526
|
+
// is already gone).
|
|
6527
|
+
}
|
|
6528
|
+
throw error;
|
|
6529
|
+
}
|
|
6530
|
+
}
|
|
6531
|
+
|
|
5330
6532
|
/**
|
|
5331
6533
|
* JJ TreeState File Manager
|
|
5332
6534
|
*
|
|
@@ -5335,15 +6537,6 @@ class JJCheckout {
|
|
|
5335
6537
|
*/
|
|
5336
6538
|
|
|
5337
6539
|
|
|
5338
|
-
const __filename$3 = fileURLToPath(import.meta.url);
|
|
5339
|
-
const __dirname$3 = path.dirname(__filename$3);
|
|
5340
|
-
|
|
5341
|
-
// Determine proto path based on whether we're running from dist or src
|
|
5342
|
-
const inDist$2 = __dirname$3.includes('/dist') || __dirname$3.includes('\\dist');
|
|
5343
|
-
const protoDir$2 = inDist$2
|
|
5344
|
-
? path.join(__dirname$3, '..', 'src', 'protos')
|
|
5345
|
-
: path.join(__dirname$3, '..', 'protos');
|
|
5346
|
-
|
|
5347
6540
|
class JJTreeState {
|
|
5348
6541
|
/**
|
|
5349
6542
|
* @param {any} fs - File system module (isomorphic-git compatible)
|
|
@@ -5352,7 +6545,6 @@ class JJTreeState {
|
|
|
5352
6545
|
constructor(fs, dir) {
|
|
5353
6546
|
this.fs = fs;
|
|
5354
6547
|
this.dir = dir;
|
|
5355
|
-
this.protoPath = path.join(protoDir$2, 'local_working_copy.proto');
|
|
5356
6548
|
}
|
|
5357
6549
|
|
|
5358
6550
|
/**
|
|
@@ -5363,11 +6555,11 @@ class JJTreeState {
|
|
|
5363
6555
|
*/
|
|
5364
6556
|
async writeTreeState(treeId, fileStates = []) {
|
|
5365
6557
|
// Load protobuf schema
|
|
5366
|
-
const root =
|
|
6558
|
+
const root = getLocalWorkingCopyRoot();
|
|
5367
6559
|
const TreeState = root.lookupType('local_working_copy.TreeState');
|
|
5368
6560
|
|
|
5369
6561
|
// Convert hex tree ID to bytes
|
|
5370
|
-
const treeIdBuffer =
|
|
6562
|
+
const treeIdBuffer = hexToBytes(treeId);
|
|
5371
6563
|
|
|
5372
6564
|
// Convert file states to protobuf format
|
|
5373
6565
|
const fileStateEntries = fileStates.map((fs) => ({
|
|
@@ -5398,9 +6590,9 @@ class JJTreeState {
|
|
|
5398
6590
|
// Encode to binary
|
|
5399
6591
|
const buffer = TreeState.encode(message).finish();
|
|
5400
6592
|
|
|
5401
|
-
// Write to .jj/working_copy/tree_state
|
|
6593
|
+
// Write to .jj/working_copy/tree_state (atomically — see issue #16).
|
|
5402
6594
|
const treeStatePath = `${this.dir}/.jj/working_copy/tree_state`;
|
|
5403
|
-
await this.fs
|
|
6595
|
+
await atomicWriteFile(this.fs, treeStatePath, buffer);
|
|
5404
6596
|
}
|
|
5405
6597
|
|
|
5406
6598
|
/**
|
|
@@ -5413,7 +6605,7 @@ class JJTreeState {
|
|
|
5413
6605
|
const buffer = await this.fs.promises.readFile(treeStatePath);
|
|
5414
6606
|
|
|
5415
6607
|
// Load protobuf schema
|
|
5416
|
-
const root =
|
|
6608
|
+
const root = getLocalWorkingCopyRoot();
|
|
5417
6609
|
const TreeState = root.lookupType('local_working_copy.TreeState');
|
|
5418
6610
|
|
|
5419
6611
|
// Decode
|
|
@@ -5437,15 +6629,6 @@ class JJTreeState {
|
|
|
5437
6629
|
*/
|
|
5438
6630
|
|
|
5439
6631
|
|
|
5440
|
-
const __filename$2 = fileURLToPath(import.meta.url);
|
|
5441
|
-
const __dirname$2 = path.dirname(__filename$2);
|
|
5442
|
-
|
|
5443
|
-
// Determine proto path based on whether we're running from dist or src
|
|
5444
|
-
const inDist$1 = __dirname$2.includes('/dist') || __dirname$2.includes('\\dist');
|
|
5445
|
-
const protoDir$1 = inDist$1
|
|
5446
|
-
? path.join(__dirname$2, '..', 'src', 'protos')
|
|
5447
|
-
: path.join(__dirname$2, '..', 'protos');
|
|
5448
|
-
|
|
5449
6632
|
class JJOperationStore {
|
|
5450
6633
|
/**
|
|
5451
6634
|
* @param {any} fs - File system module (isomorphic-git compatible)
|
|
@@ -5454,7 +6637,6 @@ class JJOperationStore {
|
|
|
5454
6637
|
constructor(fs, dir) {
|
|
5455
6638
|
this.fs = fs;
|
|
5456
6639
|
this.dir = dir;
|
|
5457
|
-
this.protoPath = path.join(protoDir$1, 'simple_op_store.proto');
|
|
5458
6640
|
}
|
|
5459
6641
|
|
|
5460
6642
|
/**
|
|
@@ -5467,12 +6649,12 @@ class JJOperationStore {
|
|
|
5467
6649
|
*/
|
|
5468
6650
|
async writeOperation(operationId, viewId, parentIds, metadata) {
|
|
5469
6651
|
// Load protobuf schema
|
|
5470
|
-
const root =
|
|
6652
|
+
const root = getSimpleOpStoreRoot();
|
|
5471
6653
|
const Operation = root.lookupType('simple_op_store.Operation');
|
|
5472
6654
|
|
|
5473
6655
|
// Convert hex IDs to bytes
|
|
5474
|
-
const viewIdBuffer =
|
|
5475
|
-
const parentBuffers = parentIds.map((id) =>
|
|
6656
|
+
const viewIdBuffer = hexToBytes(viewId);
|
|
6657
|
+
const parentBuffers = parentIds.map((id) => hexToBytes(id));
|
|
5476
6658
|
|
|
5477
6659
|
// Create metadata message (use camelCase for protobufjs)
|
|
5478
6660
|
const metadataMsg = {
|
|
@@ -5509,9 +6691,11 @@ class JJOperationStore {
|
|
|
5509
6691
|
// Encode to binary
|
|
5510
6692
|
const buffer = Operation.encode(message).finish();
|
|
5511
6693
|
|
|
5512
|
-
// Write to .jj/repo/op_store/operations/OPERATIONID
|
|
6694
|
+
// Write to .jj/repo/op_store/operations/OPERATIONID (atomically — a
|
|
6695
|
+
// crash mid-write must never leave a truncated/undecodable file, see
|
|
6696
|
+
// issue #16).
|
|
5513
6697
|
const opPath = `${this.dir}/.jj/repo/op_store/operations/${operationId}`;
|
|
5514
|
-
await this.fs
|
|
6698
|
+
await atomicWriteFile(this.fs, opPath, buffer);
|
|
5515
6699
|
}
|
|
5516
6700
|
|
|
5517
6701
|
/**
|
|
@@ -5526,7 +6710,7 @@ class JJOperationStore {
|
|
|
5526
6710
|
const buffer = await this.fs.promises.readFile(opPath);
|
|
5527
6711
|
|
|
5528
6712
|
// Load protobuf schema
|
|
5529
|
-
const root =
|
|
6713
|
+
const root = getSimpleOpStoreRoot();
|
|
5530
6714
|
const Operation = root.lookupType('simple_op_store.Operation');
|
|
5531
6715
|
|
|
5532
6716
|
// Decode
|
|
@@ -5565,15 +6749,6 @@ class JJOperationStore {
|
|
|
5565
6749
|
*/
|
|
5566
6750
|
|
|
5567
6751
|
|
|
5568
|
-
const __filename$1 = fileURLToPath(import.meta.url);
|
|
5569
|
-
const __dirname$1 = path.dirname(__filename$1);
|
|
5570
|
-
|
|
5571
|
-
// Determine proto path based on whether we're running from dist or src
|
|
5572
|
-
const inDist = __dirname$1.includes('/dist') || __dirname$1.includes('\\dist');
|
|
5573
|
-
const protoDir = inDist
|
|
5574
|
-
? path.join(__dirname$1, '..', 'src', 'protos')
|
|
5575
|
-
: path.join(__dirname$1, '..', 'protos');
|
|
5576
|
-
|
|
5577
6752
|
class JJViewStore {
|
|
5578
6753
|
/**
|
|
5579
6754
|
* @param {any} fs - File system module (isomorphic-git compatible)
|
|
@@ -5582,7 +6757,6 @@ class JJViewStore {
|
|
|
5582
6757
|
constructor(fs, dir) {
|
|
5583
6758
|
this.fs = fs;
|
|
5584
6759
|
this.dir = dir;
|
|
5585
|
-
this.protoPath = path.join(protoDir, 'simple_op_store.proto');
|
|
5586
6760
|
}
|
|
5587
6761
|
|
|
5588
6762
|
/**
|
|
@@ -5594,17 +6768,17 @@ class JJViewStore {
|
|
|
5594
6768
|
*/
|
|
5595
6769
|
async writeView(viewId, headIds, wcCommitIds = {}) {
|
|
5596
6770
|
// Load protobuf schema
|
|
5597
|
-
const root =
|
|
6771
|
+
const root = getSimpleOpStoreRoot();
|
|
5598
6772
|
const View = root.lookupType('simple_op_store.View');
|
|
5599
6773
|
|
|
5600
6774
|
// Convert hex IDs to bytes
|
|
5601
|
-
const headBuffers = headIds.map((id) =>
|
|
6775
|
+
const headBuffers = headIds.map((id) => hexToBytes(id));
|
|
5602
6776
|
|
|
5603
6777
|
// Convert wcCommitIds object to map
|
|
5604
6778
|
/** @type {Record<string, any>} */
|
|
5605
6779
|
const wcCommitIdsMap = {};
|
|
5606
6780
|
for (const [workspace, commitId] of Object.entries(wcCommitIds)) {
|
|
5607
|
-
wcCommitIdsMap[workspace] =
|
|
6781
|
+
wcCommitIdsMap[workspace] = hexToBytes(commitId);
|
|
5608
6782
|
}
|
|
5609
6783
|
|
|
5610
6784
|
// Create view message (use camelCase for protobufjs)
|
|
@@ -5628,9 +6802,9 @@ class JJViewStore {
|
|
|
5628
6802
|
// Encode to binary
|
|
5629
6803
|
const buffer = View.encode(message).finish();
|
|
5630
6804
|
|
|
5631
|
-
// Write to .jj/repo/op_store/views/VIEWID
|
|
6805
|
+
// Write to .jj/repo/op_store/views/VIEWID (atomically — see issue #16).
|
|
5632
6806
|
const viewPath = `${this.dir}/.jj/repo/op_store/views/${viewId}`;
|
|
5633
|
-
await this.fs
|
|
6807
|
+
await atomicWriteFile(this.fs, viewPath, buffer);
|
|
5634
6808
|
}
|
|
5635
6809
|
|
|
5636
6810
|
/**
|
|
@@ -5645,7 +6819,7 @@ class JJViewStore {
|
|
|
5645
6819
|
const buffer = await this.fs.promises.readFile(viewPath);
|
|
5646
6820
|
|
|
5647
6821
|
// Load protobuf schema
|
|
5648
|
-
const root =
|
|
6822
|
+
const root = getSimpleOpStoreRoot();
|
|
5649
6823
|
const View = root.lookupType('simple_op_store.View');
|
|
5650
6824
|
|
|
5651
6825
|
// Decode
|
|
@@ -6207,7 +7381,11 @@ class IsomorphicGitBackend {
|
|
|
6207
7381
|
* @param {boolean} [opts.force] - Allow non-fast-forward
|
|
6208
7382
|
* @param {any} [opts.onProgress] - Progress callback
|
|
6209
7383
|
* @param {any} [opts.onAuth] - Authentication callback
|
|
6210
|
-
* @returns {Promise<{pushedRefs: Array<
|
|
7384
|
+
* @returns {Promise<{pushedRefs: Array<{name: string, oid: string}>, rejectedRefs: Array<{name: string, code: string, reason: string, message: string}>}>} Push result.
|
|
7385
|
+
* Each rejected ref carries a `code` (one of `AUTH_FAILED`,
|
|
7386
|
+
* `NETWORK_ERROR`, `NON_FAST_FORWARD`, `OTHER`) and a human-readable
|
|
7387
|
+
* `reason`, so callers can tell an auth failure apart from a
|
|
7388
|
+
* non-fast-forward rejection instead of guessing (see issue #14).
|
|
6211
7389
|
*/
|
|
6212
7390
|
async push(opts) {
|
|
6213
7391
|
if (!this.http) {
|
|
@@ -6243,8 +7421,13 @@ class IsomorphicGitBackend {
|
|
|
6243
7421
|
pushedRefs.push({ name: ref, oid });
|
|
6244
7422
|
}
|
|
6245
7423
|
} catch (pushError) {
|
|
6246
|
-
// Track rejected refs
|
|
6247
|
-
|
|
7424
|
+
// Track rejected refs with the categorized reason so callers can
|
|
7425
|
+
// distinguish an auth failure from a non-fast-forward rejection
|
|
7426
|
+
// (or a network blip) instead of guessing — see issue #14. A
|
|
7427
|
+
// caller that blindly retries with force:true on every rejection
|
|
7428
|
+
// risks doing so on what was actually an auth/network problem.
|
|
7429
|
+
const { code, reason } = this._categorizePushError(pushError);
|
|
7430
|
+
rejectedRefs.push({ name: ref, code, reason, message: pushError.message });
|
|
6248
7431
|
}
|
|
6249
7432
|
}
|
|
6250
7433
|
|
|
@@ -6276,6 +7459,53 @@ class IsomorphicGitBackend {
|
|
|
6276
7459
|
}
|
|
6277
7460
|
}
|
|
6278
7461
|
|
|
7462
|
+
/**
|
|
7463
|
+
* Categorize a per-ref push failure into a stable code/reason pair so
|
|
7464
|
+
* callers can distinguish auth failures, network errors, and
|
|
7465
|
+
* non-fast-forward rejections from each other and from other/unknown
|
|
7466
|
+
* failures — see issue #14. isomorphic-git doesn't throw a single
|
|
7467
|
+
* "AuthError" class; auth failures generally surface as an HttpError with
|
|
7468
|
+
* a 401/403 status code (or a UserCanceledError if onAuth cancels).
|
|
7469
|
+
*
|
|
7470
|
+
* @private
|
|
7471
|
+
* @param {any} error - Error thrown by git.push() for a single ref
|
|
7472
|
+
* @returns {{code: string, reason: string}}
|
|
7473
|
+
*/
|
|
7474
|
+
_categorizePushError(error) {
|
|
7475
|
+
const errCode = error && error.code;
|
|
7476
|
+
|
|
7477
|
+
if (errCode === 'PushRejectedError') {
|
|
7478
|
+
const reason = error.data && error.data.reason;
|
|
7479
|
+
return {
|
|
7480
|
+
code: 'NON_FAST_FORWARD',
|
|
7481
|
+
reason: reason === 'tag-exists' ? 'tag already exists on remote' : 'non-fast-forward',
|
|
7482
|
+
};
|
|
7483
|
+
}
|
|
7484
|
+
|
|
7485
|
+
if (errCode === 'HttpError') {
|
|
7486
|
+
const status = error.data && error.data.statusCode;
|
|
7487
|
+
if (status === 401 || status === 403) {
|
|
7488
|
+
return { code: 'AUTH_FAILED', reason: `HTTP ${status}` };
|
|
7489
|
+
}
|
|
7490
|
+
return { code: 'NETWORK_ERROR', reason: status ? `HTTP ${status}` : 'HTTP error' };
|
|
7491
|
+
}
|
|
7492
|
+
|
|
7493
|
+
if (
|
|
7494
|
+
errCode === 'AuthError' ||
|
|
7495
|
+
errCode === 'UserCanceledError' ||
|
|
7496
|
+
errCode === 'MissingUsernameError' ||
|
|
7497
|
+
errCode === 'MissingPasswordTokenError'
|
|
7498
|
+
) {
|
|
7499
|
+
return { code: 'AUTH_FAILED', reason: errCode };
|
|
7500
|
+
}
|
|
7501
|
+
|
|
7502
|
+
if (errCode === 'NetworkError' || errCode === 'UnknownTransportError') {
|
|
7503
|
+
return { code: 'NETWORK_ERROR', reason: errCode };
|
|
7504
|
+
}
|
|
7505
|
+
|
|
7506
|
+
return { code: 'OTHER', reason: (error && error.message) || String(error) };
|
|
7507
|
+
}
|
|
7508
|
+
|
|
6279
7509
|
/**
|
|
6280
7510
|
* Create minimal .jj/repo structure for jj CLI compatibility
|
|
6281
7511
|
* @private
|
|
@@ -6285,14 +7515,14 @@ class IsomorphicGitBackend {
|
|
|
6285
7515
|
const jjRepo = `${jjDir}/repo`;
|
|
6286
7516
|
|
|
6287
7517
|
// Create directory structure
|
|
6288
|
-
await this.fs
|
|
6289
|
-
await this.fs
|
|
6290
|
-
await this.fs
|
|
6291
|
-
await this.fs
|
|
6292
|
-
await this.fs
|
|
6293
|
-
await this.fs
|
|
6294
|
-
await this.fs
|
|
6295
|
-
await this.fs
|
|
7518
|
+
await mkdirp(this.fs, `${jjRepo}/store`);
|
|
7519
|
+
await mkdirp(this.fs, `${jjRepo}/store/extra/heads`);
|
|
7520
|
+
await mkdirp(this.fs, `${jjRepo}/op_store/operations`);
|
|
7521
|
+
await mkdirp(this.fs, `${jjRepo}/op_store/views`);
|
|
7522
|
+
await mkdirp(this.fs, `${jjRepo}/op_heads/heads`);
|
|
7523
|
+
await mkdirp(this.fs, `${jjRepo}/index`);
|
|
7524
|
+
await mkdirp(this.fs, `${jjRepo}/submodule_store`);
|
|
7525
|
+
await mkdirp(this.fs, `${jjDir}/working_copy`);
|
|
6296
7526
|
|
|
6297
7527
|
// Write type files
|
|
6298
7528
|
await this.fs.promises.writeFile(`${jjRepo}/store/type`, 'git');
|
|
@@ -6319,8 +7549,8 @@ class IsomorphicGitBackend {
|
|
|
6319
7549
|
*/
|
|
6320
7550
|
async _createInitialJJState() {
|
|
6321
7551
|
// Generate unique IDs for initial state
|
|
6322
|
-
const operationId =
|
|
6323
|
-
const viewId =
|
|
7552
|
+
const operationId = randomHex(64); // 512-bit = 128 hex chars
|
|
7553
|
+
const viewId = randomHex(64);
|
|
6324
7554
|
const emptyTreeId = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; // Git's empty tree hash
|
|
6325
7555
|
|
|
6326
7556
|
// Get the initial commit from Git
|
|
@@ -6332,7 +7562,7 @@ class IsomorphicGitBackend {
|
|
|
6332
7562
|
ref: 'HEAD',
|
|
6333
7563
|
});
|
|
6334
7564
|
if (headOid) {
|
|
6335
|
-
initialCommitId =
|
|
7565
|
+
initialCommitId = randomHex(64);
|
|
6336
7566
|
}
|
|
6337
7567
|
} catch (error) {
|
|
6338
7568
|
// No initial commit yet
|
|
@@ -6489,7 +7719,7 @@ async function createJJ(options) {
|
|
|
6489
7719
|
|
|
6490
7720
|
for (const filePath of trackedFiles) {
|
|
6491
7721
|
try {
|
|
6492
|
-
const fullPath =
|
|
7722
|
+
const fullPath = join(dir, filePath);
|
|
6493
7723
|
const stats = await fs.promises.stat(fullPath);
|
|
6494
7724
|
|
|
6495
7725
|
// Skip files that are too large
|
|
@@ -6502,7 +7732,7 @@ async function createJJ(options) {
|
|
|
6502
7732
|
break;
|
|
6503
7733
|
}
|
|
6504
7734
|
|
|
6505
|
-
const content = await fs.promises.readFile(fullPath, '
|
|
7735
|
+
const content = await fs.promises.readFile(fullPath, 'utf8');
|
|
6506
7736
|
fileSnapshot[filePath] = content;
|
|
6507
7737
|
totalSnapshotSize += stats.size;
|
|
6508
7738
|
} catch (error) {
|
|
@@ -6513,7 +7743,258 @@ async function createJJ(options) {
|
|
|
6513
7743
|
// If we can't list files, return empty snapshot
|
|
6514
7744
|
}
|
|
6515
7745
|
|
|
6516
|
-
return fileSnapshot;
|
|
7746
|
+
return fileSnapshot;
|
|
7747
|
+
};
|
|
7748
|
+
|
|
7749
|
+
/**
|
|
7750
|
+
* Fully sync the working directory to a target file snapshot: writes
|
|
7751
|
+
* every file the target has (create/update), AND deletes every currently
|
|
7752
|
+
* tracked file the target does NOT have.
|
|
7753
|
+
*
|
|
7754
|
+
* Both edit() and new() used to only overlay the target's files on top of
|
|
7755
|
+
* whatever was already on disk, so a file that existed in the previously
|
|
7756
|
+
* checked-out change but not in the target leaked into the next
|
|
7757
|
+
* snapshot — see issue #30. undo()/redo() had the same gap restoring
|
|
7758
|
+
* file state — see issue #29. This is the one place that does it
|
|
7759
|
+
* correctly, used by all four.
|
|
7760
|
+
*
|
|
7761
|
+
* @param {Record<string, string>} targetSnapshot - path -> file content
|
|
7762
|
+
* @returns {Promise<void>}
|
|
7763
|
+
*/
|
|
7764
|
+
const syncWorkingCopyFiles = async (targetSnapshot) => {
|
|
7765
|
+
const snapshot = targetSnapshot || {};
|
|
7766
|
+
const currentFiles = await workingCopy.listFiles();
|
|
7767
|
+
|
|
7768
|
+
// Remove files that aren't part of the target.
|
|
7769
|
+
for (const filePath of currentFiles) {
|
|
7770
|
+
if (!(filePath in snapshot)) {
|
|
7771
|
+
try {
|
|
7772
|
+
const fullPath = join(dir, filePath);
|
|
7773
|
+
await fs.promises.unlink(fullPath);
|
|
7774
|
+
} catch (error) {
|
|
7775
|
+
// Already gone / inaccessible — fine, we just want it absent.
|
|
7776
|
+
}
|
|
7777
|
+
await workingCopy.untrackFile(filePath);
|
|
7778
|
+
}
|
|
7779
|
+
}
|
|
7780
|
+
|
|
7781
|
+
// Write/update every file the target has.
|
|
7782
|
+
for (const [filePath, content] of Object.entries(snapshot)) {
|
|
7783
|
+
const fullPath = join(dir, filePath);
|
|
7784
|
+
const pathParts = filePath.split('/');
|
|
7785
|
+
if (pathParts.length > 1) {
|
|
7786
|
+
const fullDirPath = join(dir, pathParts.slice(0, -1).join('/'));
|
|
7787
|
+
await mkdirp(fs, fullDirPath);
|
|
7788
|
+
}
|
|
7789
|
+
await fs.promises.writeFile(fullPath, content, 'utf8');
|
|
7790
|
+
|
|
7791
|
+
const stats = await fs.promises.stat(fullPath);
|
|
7792
|
+
await workingCopy.trackFile(filePath, {
|
|
7793
|
+
mtime: stats.mtime,
|
|
7794
|
+
size: stats.size,
|
|
7795
|
+
mode: stats.mode,
|
|
7796
|
+
});
|
|
7797
|
+
}
|
|
7798
|
+
};
|
|
7799
|
+
|
|
7800
|
+
/**
|
|
7801
|
+
* Compute the ChangeGraph/file/conflicts state that existed right after
|
|
7802
|
+
* `landingOp` finished — i.e. what undo()/redo()/operations.restore()
|
|
7803
|
+
* need to re-establish when winding the repository forward or back to
|
|
7804
|
+
* that point (see issue #29).
|
|
7805
|
+
*
|
|
7806
|
+
* Every operation that mutates EXISTING change records in place records
|
|
7807
|
+
* (additively — most operations touch none of this and that's fine,
|
|
7808
|
+
* there's nothing to reverse for them):
|
|
7809
|
+
* - changeSnapshot: { [changeId]: fullRecordBeforeThisOpRan }
|
|
7810
|
+
* - view.fileSnapshot: on-disk file state right BEFORE this op ran
|
|
7811
|
+
* - conflictsSnapshot: ConflictModel state right BEFORE this op ran
|
|
7812
|
+
*
|
|
7813
|
+
* Deliberately NOT reverted: changes an operation newly *created* (via
|
|
7814
|
+
* graph.addChange() — new(), squash()'s synthetic empty working-copy
|
|
7815
|
+
* change, ...). Undoing their creation only un-refs them (restores the
|
|
7816
|
+
* working-copy pointer/heads that made them reachable) — it does not
|
|
7817
|
+
* delete the change record — matching real jj, where an "unreachable"
|
|
7818
|
+
* change stays resolvable by its change id (hidden, not gone) until
|
|
7819
|
+
* explicitly gc'd, and this package's own history-editing tests rely on
|
|
7820
|
+
* exactly that: rebasing onto a change created by a since-undone new()
|
|
7821
|
+
* must still work.
|
|
7822
|
+
*
|
|
7823
|
+
* "The state right after landingOp" is therefore exactly what the FIRST
|
|
7824
|
+
* operation after landingOp recorded as ITS "before" state, merged across
|
|
7825
|
+
* every operation between landingOp and the current head in case
|
|
7826
|
+
* different operations touched different changeIds (a single op's own
|
|
7827
|
+
* snapshot only covers what IT touched). Concretely, per field:
|
|
7828
|
+
* - changeSnapshot: first (earliest / closest-to-landingOp) entry per
|
|
7829
|
+
* changeId wins — later entries in the range reflect that changeId's
|
|
7830
|
+
* state at a LATER point than we're restoring to.
|
|
7831
|
+
* - fileSnapshot / conflictsSnapshot: the first op in the range that
|
|
7832
|
+
* recorded one, since whole-state snapshots aren't per-changeId and
|
|
7833
|
+
* an op that didn't record one simply didn't change that state.
|
|
7834
|
+
*
|
|
7835
|
+
* This same helper backs undo() (landingOp = the parent of whatever's
|
|
7836
|
+
* being undone), redo() (landingOp = the operation a prior undo()
|
|
7837
|
+
* reverted), and operations.restore() (landingOp = the target operation
|
|
7838
|
+
* itself) — one reversal primitive, not three.
|
|
7839
|
+
*
|
|
7840
|
+
* @param {Array<any>} ops - Full chronological operation list (oplog.list())
|
|
7841
|
+
* @param {any} landingOp - The operation whose resulting state to recompute
|
|
7842
|
+
* @returns {{
|
|
7843
|
+
* changeSnapshotToApply: Record<string, any>,
|
|
7844
|
+
* fileSnapshot: Record<string, string>|undefined,
|
|
7845
|
+
* conflictsSnapshot: any,
|
|
7846
|
+
* }}
|
|
7847
|
+
*/
|
|
7848
|
+
const computeGraphReversal = (/** @type {any[]} */ ops, /** @type {any} */ landingOp) => {
|
|
7849
|
+
const landingIndex = ops.findIndex((op) => op.id === landingOp.id);
|
|
7850
|
+
|
|
7851
|
+
/** @type {Record<string, any>} */
|
|
7852
|
+
const changeSnapshotToApply = {};
|
|
7853
|
+
/** @type {Record<string, string>|undefined} */
|
|
7854
|
+
let fileSnapshot;
|
|
7855
|
+
/** @type {any} */
|
|
7856
|
+
let conflictsSnapshot;
|
|
7857
|
+
|
|
7858
|
+
for (let i = landingIndex + 1; i < ops.length; i++) {
|
|
7859
|
+
const op = ops[i];
|
|
7860
|
+
|
|
7861
|
+
if (op.changeSnapshot) {
|
|
7862
|
+
for (const [changeId, snapshot] of Object.entries(op.changeSnapshot)) {
|
|
7863
|
+
if (!(changeId in changeSnapshotToApply)) {
|
|
7864
|
+
changeSnapshotToApply[changeId] = snapshot;
|
|
7865
|
+
}
|
|
7866
|
+
}
|
|
7867
|
+
}
|
|
7868
|
+
|
|
7869
|
+
// `!= null` (not `!== undefined`) on purpose: some operations record
|
|
7870
|
+
// an explicit `null` for "I ran, and I deliberately took no snapshot"
|
|
7871
|
+
// (e.g. describe() of a non-working-copy revision) — that's exactly
|
|
7872
|
+
// as uninformative as never having set the field, so keep scanning
|
|
7873
|
+
// rather than "finding" a null snapshot.
|
|
7874
|
+
if (fileSnapshot === undefined && op.view && op.view.fileSnapshot != null) {
|
|
7875
|
+
fileSnapshot = op.view.fileSnapshot;
|
|
7876
|
+
}
|
|
7877
|
+
|
|
7878
|
+
if (conflictsSnapshot === undefined && op.conflictsSnapshot != null) {
|
|
7879
|
+
conflictsSnapshot = op.conflictsSnapshot;
|
|
7880
|
+
}
|
|
7881
|
+
}
|
|
7882
|
+
|
|
7883
|
+
return { changeSnapshotToApply, fileSnapshot, conflictsSnapshot };
|
|
7884
|
+
};
|
|
7885
|
+
|
|
7886
|
+
/**
|
|
7887
|
+
* Apply a reversal computed by computeGraphReversal() to the live graph,
|
|
7888
|
+
* working-copy files, and conflict state. Does NOT touch the working-copy
|
|
7889
|
+
* pointer or bookmarks — callers set those from the landing op's own
|
|
7890
|
+
* `view` directly (pointers, unlike file/graph/conflict content, are
|
|
7891
|
+
* already correct as recorded, see computeGraphReversal's doc comment).
|
|
7892
|
+
*
|
|
7893
|
+
* @param {ReturnType<typeof computeGraphReversal>} reversal
|
|
7894
|
+
* @returns {Promise<void>}
|
|
7895
|
+
*/
|
|
7896
|
+
const applyGraphReversal = async (reversal) => {
|
|
7897
|
+
await graph.load();
|
|
7898
|
+
for (const [changeId, snapshot] of Object.entries(reversal.changeSnapshotToApply)) {
|
|
7899
|
+
if (await graph.getChange(changeId)) {
|
|
7900
|
+
await graph.updateChange(snapshot);
|
|
7901
|
+
}
|
|
7902
|
+
}
|
|
7903
|
+
|
|
7904
|
+
if (reversal.fileSnapshot !== undefined) {
|
|
7905
|
+
await syncWorkingCopyFiles(reversal.fileSnapshot);
|
|
7906
|
+
}
|
|
7907
|
+
|
|
7908
|
+
await conflicts.load();
|
|
7909
|
+
if (reversal.conflictsSnapshot !== undefined) {
|
|
7910
|
+
conflicts.conflicts = new Map(Object.entries(reversal.conflictsSnapshot.conflicts || {}));
|
|
7911
|
+
conflicts.fileConflicts = new Map(
|
|
7912
|
+
Object.entries(reversal.conflictsSnapshot.fileConflicts || {})
|
|
7913
|
+
);
|
|
7914
|
+
await conflicts.save();
|
|
7915
|
+
}
|
|
7916
|
+
// Else: no operation in the reverted range touched conflicts — leave
|
|
7917
|
+
// current conflict state alone. (Previously undo() unconditionally
|
|
7918
|
+
// cleared ALL conflicts whenever the op being undone hadn't recorded
|
|
7919
|
+
// its own conflictsSnapshot, which is nearly every non-merge() op —
|
|
7920
|
+
// undoing an unrelated describe() would silently wipe conflicts from
|
|
7921
|
+
// an earlier, unrelated merge(). Fixed as part of issue #29.)
|
|
7922
|
+
};
|
|
7923
|
+
|
|
7924
|
+
/**
|
|
7925
|
+
* Re-parent every child of `changeId` onto `newParents` instead — used
|
|
7926
|
+
* when a change is being elided from the graph (abandon(), issue #30;
|
|
7927
|
+
* converge(), issue #32) so its children don't end up pointing at
|
|
7928
|
+
* something no longer meant to be there. Mutates `changeSnapshot` in
|
|
7929
|
+
* place, adding each touched child's PRE-reparent record so the caller's
|
|
7930
|
+
* own oplog entry lets undo()/redo()/operations.restore() reverse this
|
|
7931
|
+
* too (the same mechanism the caller already uses for the elided
|
|
7932
|
+
* change's own snapshot).
|
|
7933
|
+
*
|
|
7934
|
+
* @param {string} changeId - The change being elided
|
|
7935
|
+
* @param {string[]} newParents - What its children should point at instead
|
|
7936
|
+
* @param {Record<string, any>} changeSnapshot - Accumulator to add each touched child's pre-state to
|
|
7937
|
+
* @returns {Promise<void>}
|
|
7938
|
+
*/
|
|
7939
|
+
const reparentChildrenOnto = async (changeId, newParents, changeSnapshot) => {
|
|
7940
|
+
const childIds = graph.getChildren(changeId);
|
|
7941
|
+
for (const childId of childIds) {
|
|
7942
|
+
const child = await graph.getChange(childId);
|
|
7943
|
+
if (!child) continue;
|
|
7944
|
+
changeSnapshot[childId] = structuredClone(child);
|
|
7945
|
+
const reparented = child.parents.flatMap((/** @type {string} */ p) =>
|
|
7946
|
+
p === changeId ? newParents : [p]
|
|
7947
|
+
);
|
|
7948
|
+
// De-dupe (e.g. the child could already have one of the new parents
|
|
7949
|
+
// as another of its own parents) while preserving first-seen order.
|
|
7950
|
+
child.parents = [...new Set(reparented)];
|
|
7951
|
+
await graph.updateChange(child);
|
|
7952
|
+
}
|
|
7953
|
+
};
|
|
7954
|
+
|
|
7955
|
+
/**
|
|
7956
|
+
* Determine which operation a fresh undo() call should revert, matching
|
|
7957
|
+
* real jj's progressive-undo semantics (`jj-undo(1)`: "If used once after
|
|
7958
|
+
* a normal operation, this will undo that last operation by restoring its
|
|
7959
|
+
* parent. If jj undo is used repeatedly, it will restore increasingly
|
|
7960
|
+
* older operations, going further back into the past.").
|
|
7961
|
+
*
|
|
7962
|
+
* The raw op-log parent chain can't answer this directly once undo/redo
|
|
7963
|
+
* bookkeeping operations are mixed into the log: an 'undo' operation's
|
|
7964
|
+
* own parent is the operation it just undid, so naively targeting
|
|
7965
|
+
* `ops[ops.length - 1]` on a SECOND undo() would undo the undo (toggle
|
|
7966
|
+
* back to the future) instead of stepping one further step into the past
|
|
7967
|
+
* — this was issue #29's "undo() twice only undoes the undo" bug.
|
|
7968
|
+
*
|
|
7969
|
+
* @param {Array<any>} ops - Full chronological operation list
|
|
7970
|
+
* @returns {any} The operation object undo() should revert
|
|
7971
|
+
*/
|
|
7972
|
+
const resolveUndoTarget = (ops) => {
|
|
7973
|
+
const head = ops[ops.length - 1];
|
|
7974
|
+
const findOp = (/** @type {any} */ id) => ops.find((op) => op.id === id);
|
|
7975
|
+
|
|
7976
|
+
if (head.eventType === 'undo') {
|
|
7977
|
+
// We're stepping further into the past: what should be undone now is
|
|
7978
|
+
// the operation right BEFORE the one `head` already undid — not
|
|
7979
|
+
// head's own parent, which IS the operation head undid (targeting it
|
|
7980
|
+
// again would just toggle back to the present).
|
|
7981
|
+
const previouslyUndone = head.targetOpId && findOp(head.targetOpId);
|
|
7982
|
+
const stepBack = previouslyUndone && findOp(previouslyUndone.parents[0]);
|
|
7983
|
+
return stepBack || previouslyUndone || head;
|
|
7984
|
+
}
|
|
7985
|
+
|
|
7986
|
+
if (head.eventType === 'redo') {
|
|
7987
|
+
// A redo re-established some earlier operation as current; a fresh
|
|
7988
|
+
// undo right after it should behave exactly as if that operation
|
|
7989
|
+
// were freshly current and had never been touched by undo/redo at
|
|
7990
|
+
// all — i.e. undo it again (real jj: redo "moves in the direction of
|
|
7991
|
+
// the future"; undoing right after simply resumes stepping back).
|
|
7992
|
+
const redoneUndo = head.redoOf && findOp(head.redoOf);
|
|
7993
|
+
const target = redoneUndo && redoneUndo.targetOpId && findOp(redoneUndo.targetOpId);
|
|
7994
|
+
return target || head;
|
|
7995
|
+
}
|
|
7996
|
+
|
|
7997
|
+
return head;
|
|
6517
7998
|
};
|
|
6518
7999
|
|
|
6519
8000
|
/**
|
|
@@ -6700,6 +8181,25 @@ async function createJJ(options) {
|
|
|
6700
8181
|
}
|
|
6701
8182
|
},
|
|
6702
8183
|
|
|
8184
|
+
// Used by undo()/redo()/operations.restore() to reverse an
|
|
8185
|
+
// addChange() when winding the graph back — see issue #29. No Git
|
|
8186
|
+
// sync hook: a change created after the operation being reverted was
|
|
8187
|
+
// never pushed anywhere to unwind, and the Git backend has no
|
|
8188
|
+
// "forget this commit" operation (nor should it — Git objects are
|
|
8189
|
+
// content-addressed and harmless to leave orphaned).
|
|
8190
|
+
deleteChange: (/** @type {any} */ changeId) => baseGraph.deleteChange(changeId),
|
|
8191
|
+
|
|
8192
|
+
// See issue #32 / converge(): a divergent copy is another commit
|
|
8193
|
+
// claiming an existing changeId; no Git sync hook, for the same
|
|
8194
|
+
// reason addChange()'s primary copy already goes through
|
|
8195
|
+
// onAddChange() when it's first created — the divergent copy needs
|
|
8196
|
+
// no additional sync of its own.
|
|
8197
|
+
addDivergentCopy: (/** @type {any} */ change) => baseGraph.addDivergentCopy(change),
|
|
8198
|
+
getDivergentSiblings: (/** @type {any} */ changeId) =>
|
|
8199
|
+
baseGraph.getDivergentSiblings(changeId),
|
|
8200
|
+
deleteDivergentCopy: (/** @type {any} */ changeId, /** @type {any} */ commitId) =>
|
|
8201
|
+
baseGraph.deleteDivergentCopy(changeId, commitId),
|
|
8202
|
+
|
|
6703
8203
|
// Delegate other operations
|
|
6704
8204
|
init: () => baseGraph.init(),
|
|
6705
8205
|
};
|
|
@@ -6857,6 +8357,20 @@ async function createJJ(options) {
|
|
|
6857
8357
|
/**
|
|
6858
8358
|
* Write a file to the working copy
|
|
6859
8359
|
*
|
|
8360
|
+
* Note (issue #30): this does NOT update the working-copy change's
|
|
8361
|
+
* `commitId`/`fileSnapshot` — those stay as they were until the next
|
|
8362
|
+
* command that snapshots the working copy (describe(), new(), edit(),
|
|
8363
|
+
* status(), or any other call that goes through
|
|
8364
|
+
* autoSnapshotWorkingCopy()/snapshot()). This is intentional, not a
|
|
8365
|
+
* bug: jj has no staging area, but it also doesn't recompute a git
|
|
8366
|
+
* tree/commit hash on every single file write (that would mean
|
|
8367
|
+
* hashing on every keystroke of an external editor) — real jj only
|
|
8368
|
+
* takes a fresh working-copy snapshot when an actual `jj` command
|
|
8369
|
+
* runs. write() is the equivalent of editing a file with your OS's own
|
|
8370
|
+
* tools outside of jj entirely; describe()/new()/edit()/status()/etc.
|
|
8371
|
+
* are the equivalent of running a jj command, which is what triggers
|
|
8372
|
+
* the (auto) snapshot in both real jj and here.
|
|
8373
|
+
*
|
|
6860
8374
|
* @param {Record<string, any>} args - Arguments
|
|
6861
8375
|
* @returns {Promise<Object>} File information including path, size, and mode
|
|
6862
8376
|
*/
|
|
@@ -6880,7 +8394,7 @@ async function createJJ(options) {
|
|
|
6880
8394
|
if (pathParts.length > 1) {
|
|
6881
8395
|
const dirPath = pathParts.slice(0, -1).join('/');
|
|
6882
8396
|
const fullDirPath = `${dir}/${dirPath}`;
|
|
6883
|
-
await fs
|
|
8397
|
+
await mkdirp(fs, fullDirPath);
|
|
6884
8398
|
}
|
|
6885
8399
|
|
|
6886
8400
|
// Write the file
|
|
@@ -6935,10 +8449,10 @@ async function createJJ(options) {
|
|
|
6935
8449
|
});
|
|
6936
8450
|
}
|
|
6937
8451
|
|
|
6938
|
-
const fullPath =
|
|
8452
|
+
const fullPath = join(dir, args.path);
|
|
6939
8453
|
try {
|
|
6940
8454
|
if (encoding === 'utf-8' || encoding === 'utf8') {
|
|
6941
|
-
return await fs.promises.readFile(fullPath, '
|
|
8455
|
+
return await fs.promises.readFile(fullPath, 'utf8');
|
|
6942
8456
|
} else {
|
|
6943
8457
|
return await fs.promises.readFile(fullPath);
|
|
6944
8458
|
}
|
|
@@ -7103,7 +8617,7 @@ async function createJJ(options) {
|
|
|
7103
8617
|
|
|
7104
8618
|
// Reading from working copy
|
|
7105
8619
|
if (!args.changeId) {
|
|
7106
|
-
const fullPath =
|
|
8620
|
+
const fullPath = join(dir, args.path);
|
|
7107
8621
|
try {
|
|
7108
8622
|
const stream = fs.createReadStream(
|
|
7109
8623
|
fullPath,
|
|
@@ -7153,14 +8667,14 @@ async function createJJ(options) {
|
|
|
7153
8667
|
});
|
|
7154
8668
|
}
|
|
7155
8669
|
|
|
7156
|
-
const fullPath =
|
|
8670
|
+
const fullPath = join(dir, args.path);
|
|
7157
8671
|
|
|
7158
8672
|
// Ensure directory exists
|
|
7159
8673
|
const pathParts = args.path.split('/');
|
|
7160
8674
|
if (pathParts.length > 1) {
|
|
7161
8675
|
const dirPath = pathParts.slice(0, -1).join('/');
|
|
7162
|
-
const fullDirPath =
|
|
7163
|
-
await fs
|
|
8676
|
+
const fullDirPath = join(dir, dirPath);
|
|
8677
|
+
await mkdirp(fs, fullDirPath);
|
|
7164
8678
|
}
|
|
7165
8679
|
|
|
7166
8680
|
// Create writable stream
|
|
@@ -7273,7 +8787,7 @@ async function createJJ(options) {
|
|
|
7273
8787
|
// Ensure destination directory exists
|
|
7274
8788
|
const toDir = toPath.substring(0, toPath.lastIndexOf('/'));
|
|
7275
8789
|
try {
|
|
7276
|
-
await fs
|
|
8790
|
+
await mkdirp(fs, toDir);
|
|
7277
8791
|
} catch (error) {
|
|
7278
8792
|
throw new JJError(
|
|
7279
8793
|
'FILE_SYSTEM_ERROR',
|
|
@@ -7391,6 +8905,51 @@ async function createJJ(options) {
|
|
|
7391
8905
|
// TODO: Prevent creating cycles in the change graph
|
|
7392
8906
|
// For now, we'll allow it but it should be validated
|
|
7393
8907
|
|
|
8908
|
+
// Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
|
|
8909
|
+
// reverse the reparenting — see issue #29.
|
|
8910
|
+
const changeSnapshotBefore = structuredClone(change);
|
|
8911
|
+
|
|
8912
|
+
// Three-way merge against the OLD base before reparenting (issue #31):
|
|
8913
|
+
// real jj's rebase re-derives the rebased change's content as
|
|
8914
|
+
// (old base, old commit, new base) — if a sibling changed the same
|
|
8915
|
+
// lines differently, that's a genuine conflict, not something
|
|
8916
|
+
// reparenting-and-copying-verbatim can silently paper over. This
|
|
8917
|
+
// reuses the exact same detectConflicts() machinery merge() already
|
|
8918
|
+
// uses (see merge(), a few hundred lines down) rather than
|
|
8919
|
+
// duplicating three-way-merge logic a third time in this codebase.
|
|
8920
|
+
const oldParentId = change.parents[0];
|
|
8921
|
+
const oldParentChange = oldParentId ? await graph.getChange(oldParentId) : null;
|
|
8922
|
+
const baseFiles = new Map(
|
|
8923
|
+
Object.entries((oldParentChange && oldParentChange.fileSnapshot) || {})
|
|
8924
|
+
);
|
|
8925
|
+
const leftFiles = new Map(Object.entries(change.fileSnapshot || {}));
|
|
8926
|
+
const rightFiles = new Map(Object.entries(newParentChange.fileSnapshot || {}));
|
|
8927
|
+
|
|
8928
|
+
await conflicts.load();
|
|
8929
|
+
const conflictsSnapshot = {
|
|
8930
|
+
conflicts: Object.fromEntries(conflicts.conflicts),
|
|
8931
|
+
fileConflicts: Object.fromEntries(conflicts.fileConflicts),
|
|
8932
|
+
};
|
|
8933
|
+
|
|
8934
|
+
const detectedConflicts = oldParentChange
|
|
8935
|
+
? await conflicts.detectConflicts({
|
|
8936
|
+
baseFiles,
|
|
8937
|
+
leftFiles,
|
|
8938
|
+
rightFiles,
|
|
8939
|
+
drivers: {},
|
|
8940
|
+
// Not necessarily the checked-out working copy, so don't let
|
|
8941
|
+
// merge drivers write to disk on its behalf.
|
|
8942
|
+
workingCopyDir: /** @type {any} */ (null),
|
|
8943
|
+
baseChange: oldParentId,
|
|
8944
|
+
leftChange: changeId,
|
|
8945
|
+
rightChange: newParent,
|
|
8946
|
+
})
|
|
8947
|
+
: [];
|
|
8948
|
+
|
|
8949
|
+
for (const conflict of detectedConflicts) {
|
|
8950
|
+
await conflicts.addConflict(conflict);
|
|
8951
|
+
}
|
|
8952
|
+
|
|
7394
8953
|
// Update parent
|
|
7395
8954
|
change.parents = [newParent];
|
|
7396
8955
|
await graph.updateChange(change);
|
|
@@ -7401,6 +8960,8 @@ async function createJJ(options) {
|
|
|
7401
8960
|
user: await getUserOplogInfo(),
|
|
7402
8961
|
description: `move change ${changeId.slice(0, 8)} to ${newParent.slice(0, 8)}`,
|
|
7403
8962
|
parents: [],
|
|
8963
|
+
changeSnapshot: { [changeId]: changeSnapshotBefore },
|
|
8964
|
+
conflictsSnapshot,
|
|
7404
8965
|
view: {
|
|
7405
8966
|
bookmarks: {},
|
|
7406
8967
|
remoteBookmarks: {},
|
|
@@ -7409,7 +8970,7 @@ async function createJJ(options) {
|
|
|
7409
8970
|
},
|
|
7410
8971
|
});
|
|
7411
8972
|
|
|
7412
|
-
return change;
|
|
8973
|
+
return { ...change, conflicts: detectedConflicts };
|
|
7413
8974
|
},
|
|
7414
8975
|
|
|
7415
8976
|
/**
|
|
@@ -7541,6 +9102,11 @@ async function createJJ(options) {
|
|
|
7541
9102
|
const isWorkingCopy = targetChangeId === workingCopy.getCurrentChangeId();
|
|
7542
9103
|
const fileSnapshotBefore = isWorkingCopy ? await snapshotFilesystem() : null;
|
|
7543
9104
|
|
|
9105
|
+
// Snapshot the change record itself from BEFORE this operation mutates
|
|
9106
|
+
// it in place, so undo() can restore ChangeGraph content (not just
|
|
9107
|
+
// on-disk files/working-copy pointer) — mirrors fileSnapshotBefore above.
|
|
9108
|
+
const changeSnapshotBefore = structuredClone(change);
|
|
9109
|
+
|
|
7544
9110
|
// Dispatch change:updating event (preventable)
|
|
7545
9111
|
await dispatchEventAsync(jj, 'change:updating', {
|
|
7546
9112
|
operation: 'describe',
|
|
@@ -7576,7 +9142,7 @@ async function createJJ(options) {
|
|
|
7576
9142
|
|
|
7577
9143
|
for (const filePath of trackedFiles) {
|
|
7578
9144
|
try {
|
|
7579
|
-
const fullPath =
|
|
9145
|
+
const fullPath = join(dir, filePath);
|
|
7580
9146
|
const stats = await fs.promises.stat(fullPath);
|
|
7581
9147
|
|
|
7582
9148
|
// Skip files that are too large (silently - this is an optimization)
|
|
@@ -7589,7 +9155,7 @@ async function createJJ(options) {
|
|
|
7589
9155
|
break;
|
|
7590
9156
|
}
|
|
7591
9157
|
|
|
7592
|
-
const content = await fs.promises.readFile(fullPath, '
|
|
9158
|
+
const content = await fs.promises.readFile(fullPath, 'utf8');
|
|
7593
9159
|
fileSnapshot[filePath] = content;
|
|
7594
9160
|
totalSnapshotSize += stats.size;
|
|
7595
9161
|
} catch (error) {
|
|
@@ -7621,6 +9187,10 @@ async function createJJ(options) {
|
|
|
7621
9187
|
},
|
|
7622
9188
|
description: `describe change ${targetChangeId.slice(0, 8)}`,
|
|
7623
9189
|
parents: [],
|
|
9190
|
+
// Snapshot of the affected ChangeGraph node from BEFORE this
|
|
9191
|
+
// operation ran, so undo() can restore in-place content mutations
|
|
9192
|
+
// (e.g. the description change below) — not just files/pointers.
|
|
9193
|
+
changeSnapshot: { [targetChangeId]: changeSnapshotBefore },
|
|
7624
9194
|
view: {
|
|
7625
9195
|
bookmarks: {},
|
|
7626
9196
|
remoteBookmarks: {},
|
|
@@ -7783,6 +9353,8 @@ async function createJJ(options) {
|
|
|
7783
9353
|
|
|
7784
9354
|
// Snapshot filesystem BEFORE operation (for undo) unless preserveSnapshot is true
|
|
7785
9355
|
let fileSnapshot;
|
|
9356
|
+
/** @type {Record<string, any>} */
|
|
9357
|
+
const changeSnapshot = {};
|
|
7786
9358
|
if (!args.preserveSnapshot) {
|
|
7787
9359
|
fileSnapshot = await snapshotFilesystem();
|
|
7788
9360
|
|
|
@@ -7791,6 +9363,10 @@ async function createJJ(options) {
|
|
|
7791
9363
|
const currentChangeId = workingCopy.getCurrentChangeId();
|
|
7792
9364
|
const currentChange = await graph.getChange(currentChangeId);
|
|
7793
9365
|
if (currentChange && fileSnapshot) {
|
|
9366
|
+
// Snapshot BEFORE mutating so undo()/redo() can revert this
|
|
9367
|
+
// in-place update too, not just the pointer/new-change creation
|
|
9368
|
+
// below — see issue #29.
|
|
9369
|
+
changeSnapshot[currentChangeId] = structuredClone(currentChange);
|
|
7794
9370
|
currentChange.fileSnapshot = fileSnapshot;
|
|
7795
9371
|
await graph.updateChange(currentChange);
|
|
7796
9372
|
}
|
|
@@ -7818,6 +9394,7 @@ async function createJJ(options) {
|
|
|
7818
9394
|
const user = /** @type {any} */ (userConfig.getUser());
|
|
7819
9395
|
|
|
7820
9396
|
// Initialize with parent's file snapshot
|
|
9397
|
+
/** @type {Record<string, string>} */
|
|
7821
9398
|
let initialSnapshot = {};
|
|
7822
9399
|
if (parents && parents.length > 0) {
|
|
7823
9400
|
const parent = await graph.getChange(parents[0]);
|
|
@@ -7861,10 +9438,19 @@ async function createJJ(options) {
|
|
|
7861
9438
|
// Handle insertBefore: rebase target to have new change as parent
|
|
7862
9439
|
if (args.insertBefore) {
|
|
7863
9440
|
const targetChange = await graph.getChange(args.insertBefore);
|
|
9441
|
+
changeSnapshot[args.insertBefore] = structuredClone(targetChange);
|
|
7864
9442
|
targetChange.parents = [newChangeId];
|
|
7865
9443
|
await graph.updateChange(targetChange);
|
|
7866
9444
|
}
|
|
7867
9445
|
|
|
9446
|
+
// Sync the working directory to the new change's snapshot: write
|
|
9447
|
+
// everything it has AND remove anything it doesn't (issue #30 — this
|
|
9448
|
+
// used to never touch disk at all, so `new({ parents: [...] })`
|
|
9449
|
+
// targeting anything other than the current head silently left the
|
|
9450
|
+
// OLD working copy's files in place instead of checking out the new
|
|
9451
|
+
// parent's).
|
|
9452
|
+
await syncWorkingCopyFiles(initialSnapshot);
|
|
9453
|
+
|
|
7868
9454
|
await workingCopy.setCurrentChange(newChangeId);
|
|
7869
9455
|
|
|
7870
9456
|
// Record operation with filesystem snapshot
|
|
@@ -7877,6 +9463,13 @@ async function createJJ(options) {
|
|
|
7877
9463
|
},
|
|
7878
9464
|
description: `new change ${newChangeId.slice(0, 8)}`,
|
|
7879
9465
|
parents: [],
|
|
9466
|
+
// See issue #29: lets undo()/redo()/operations.restore() reverse
|
|
9467
|
+
// the existing records this op updated in place (the old
|
|
9468
|
+
// working-copy change's refreshed fileSnapshot, and insertBefore's
|
|
9469
|
+
// target's reparented parents). The newly created change itself
|
|
9470
|
+
// (newChangeId) is intentionally NOT torn down on undo — see
|
|
9471
|
+
// computeGraphReversal's doc comment.
|
|
9472
|
+
changeSnapshot,
|
|
7880
9473
|
view: {
|
|
7881
9474
|
bookmarks: {},
|
|
7882
9475
|
remoteBookmarks: {},
|
|
@@ -8133,11 +9726,28 @@ async function createJJ(options) {
|
|
|
8133
9726
|
// Try to resolve as revset
|
|
8134
9727
|
try {
|
|
8135
9728
|
const changes = await revset.evaluate(args.change);
|
|
9729
|
+
if (changes.length > 1) {
|
|
9730
|
+
// An id-prefix (or other) revset that matches more than one
|
|
9731
|
+
// change must not silently pick a "winner" — see issue #13.
|
|
9732
|
+
throw new JJError(
|
|
9733
|
+
'AMBIGUOUS_ID',
|
|
9734
|
+
`"${args.change}" is ambiguous: it resolves to ${changes.length} changes (${changes.join(', ')})`,
|
|
9735
|
+
{
|
|
9736
|
+
expression: args.change,
|
|
9737
|
+
matches: changes,
|
|
9738
|
+
suggestion: 'Use a longer prefix or an exact change ID to disambiguate',
|
|
9739
|
+
}
|
|
9740
|
+
);
|
|
9741
|
+
}
|
|
8136
9742
|
if (changes.length > 0) {
|
|
8137
9743
|
changeId = changes[0];
|
|
8138
9744
|
}
|
|
8139
|
-
} catch {
|
|
8140
|
-
|
|
9745
|
+
} catch (error) {
|
|
9746
|
+
if (error instanceof JJError && error.code === 'AMBIGUOUS_ID') {
|
|
9747
|
+
throw error;
|
|
9748
|
+
}
|
|
9749
|
+
// Not a valid revset expression - use args.change as-is (e.g. a
|
|
9750
|
+
// literal change ID that revset parsing rejected).
|
|
8141
9751
|
}
|
|
8142
9752
|
}
|
|
8143
9753
|
|
|
@@ -8240,6 +9850,13 @@ async function createJJ(options) {
|
|
|
8240
9850
|
|
|
8241
9851
|
const previousChangeId = workingCopy.getCurrentChangeId();
|
|
8242
9852
|
|
|
9853
|
+
/** @type {Record<string, any>} */
|
|
9854
|
+
const changeSnapshot = {};
|
|
9855
|
+
|
|
9856
|
+
// Capture disk state right before we touch anything, for undo() —
|
|
9857
|
+
// see issue #29.
|
|
9858
|
+
const fileSnapshotBeforeSwitch = await snapshotFilesystem();
|
|
9859
|
+
|
|
8243
9860
|
// CRITICAL FIX: Clean working directory BEFORE snapshotting to prevent cross-branch pollution
|
|
8244
9861
|
// Remove files that don't belong to the current changeId before we snapshot it
|
|
8245
9862
|
if (previousChangeId !== args.changeId) {
|
|
@@ -8253,7 +9870,7 @@ async function createJJ(options) {
|
|
|
8253
9870
|
for (const filePath of workingDirFiles) {
|
|
8254
9871
|
if (!previousSnapshot[filePath]) {
|
|
8255
9872
|
try {
|
|
8256
|
-
const fullPath =
|
|
9873
|
+
const fullPath = join(dir, filePath);
|
|
8257
9874
|
await fs.promises.unlink(fullPath);
|
|
8258
9875
|
await workingCopy.untrackFile(filePath);
|
|
8259
9876
|
} catch (error) {
|
|
@@ -8261,13 +9878,14 @@ async function createJJ(options) {
|
|
|
8261
9878
|
}
|
|
8262
9879
|
}
|
|
8263
9880
|
}
|
|
8264
|
-
}
|
|
8265
9881
|
|
|
8266
|
-
|
|
8267
|
-
|
|
8268
|
-
|
|
8269
|
-
|
|
8270
|
-
|
|
9882
|
+
// Now snapshot the CLEAN working directory (no pollution!)
|
|
9883
|
+
const currentSnapshot = await snapshotFilesystem();
|
|
9884
|
+
if (currentSnapshot && Object.keys(currentSnapshot).length > 0) {
|
|
9885
|
+
changeSnapshot[previousChangeId] = structuredClone(previousChange);
|
|
9886
|
+
previousChange.fileSnapshot = currentSnapshot;
|
|
9887
|
+
await graph.updateChange(previousChange);
|
|
9888
|
+
}
|
|
8271
9889
|
}
|
|
8272
9890
|
}
|
|
8273
9891
|
|
|
@@ -8280,38 +9898,17 @@ async function createJJ(options) {
|
|
|
8280
9898
|
timestamp: new Date().toISOString(),
|
|
8281
9899
|
});
|
|
8282
9900
|
|
|
8283
|
-
//
|
|
8284
|
-
|
|
8285
|
-
|
|
8286
|
-
|
|
8287
|
-
|
|
8288
|
-
|
|
8289
|
-
|
|
8290
|
-
|
|
8291
|
-
|
|
8292
|
-
|
|
8293
|
-
|
|
8294
|
-
await fs.promises.mkdir(fullDirPath, { recursive: true });
|
|
8295
|
-
}
|
|
8296
|
-
|
|
8297
|
-
// Write file content
|
|
8298
|
-
await fs.promises.writeFile(fullPath, content, 'utf8');
|
|
8299
|
-
|
|
8300
|
-
// Update working copy tracking
|
|
8301
|
-
const stats = await fs.promises.stat(fullPath);
|
|
8302
|
-
await workingCopy.trackFile(filePath, {
|
|
8303
|
-
mtime: stats.mtime,
|
|
8304
|
-
size: stats.size,
|
|
8305
|
-
mode: stats.mode,
|
|
8306
|
-
});
|
|
8307
|
-
} catch (error) {
|
|
8308
|
-
throw new JJError(
|
|
8309
|
-
'FILE_RESTORE_FAILED',
|
|
8310
|
-
`Failed to restore file ${filePath}: ${error.message}`,
|
|
8311
|
-
{ filePath, originalError: error.message }
|
|
8312
|
-
);
|
|
8313
|
-
}
|
|
8314
|
-
}
|
|
9901
|
+
// Fully sync the working directory to the target change's snapshot:
|
|
9902
|
+
// write everything it has AND remove anything it doesn't (issue #30
|
|
9903
|
+
// — this used to only overlay the target's files on top of whatever
|
|
9904
|
+
// was already on disk, so a file present in the PREVIOUS change but
|
|
9905
|
+
// absent from the target leaked into the next snapshot).
|
|
9906
|
+
try {
|
|
9907
|
+
await syncWorkingCopyFiles(change.fileSnapshot || {});
|
|
9908
|
+
} catch (error) {
|
|
9909
|
+
throw new JJError('FILE_RESTORE_FAILED', `Failed to restore files: ${error.message}`, {
|
|
9910
|
+
originalError: error.message,
|
|
9911
|
+
});
|
|
8315
9912
|
}
|
|
8316
9913
|
|
|
8317
9914
|
// Set this change as the working copy
|
|
@@ -8327,11 +9924,13 @@ async function createJJ(options) {
|
|
|
8327
9924
|
},
|
|
8328
9925
|
description: `edit change ${args.changeId.slice(0, 8)}`,
|
|
8329
9926
|
parents: [],
|
|
9927
|
+
changeSnapshot,
|
|
8330
9928
|
view: {
|
|
8331
9929
|
bookmarks: {},
|
|
8332
9930
|
remoteBookmarks: {},
|
|
8333
9931
|
heads: [args.changeId],
|
|
8334
9932
|
workingCopy: args.changeId,
|
|
9933
|
+
fileSnapshot: fileSnapshotBeforeSwitch,
|
|
8335
9934
|
},
|
|
8336
9935
|
});
|
|
8337
9936
|
|
|
@@ -8362,91 +9961,107 @@ async function createJJ(options) {
|
|
|
8362
9961
|
/**
|
|
8363
9962
|
* Undo last operation
|
|
8364
9963
|
*
|
|
9964
|
+
* Reverts the most recent operation — including graph-rewriting ones
|
|
9965
|
+
* (squash/rebase/abandon/new/edit), not just working-copy/file state
|
|
9966
|
+
* (see issue #29). Matches real jj's progressive-undo semantics
|
|
9967
|
+
* (`jj-undo(1)`): called again with nothing else in between, it steps
|
|
9968
|
+
* one further operation into the past rather than undoing itself.
|
|
9969
|
+
*
|
|
8365
9970
|
* @returns {Promise<Object>} Information about the undo including the undone operation and restored state
|
|
8366
9971
|
*/
|
|
8367
9972
|
async undo() {
|
|
8368
|
-
// Get the operation we're about to undo to access its pre-state
|
|
8369
9973
|
await oplog.load();
|
|
8370
9974
|
const ops = await oplog.list();
|
|
8371
9975
|
if (ops.length === 0) {
|
|
8372
9976
|
throw new JJError('NOTHING_TO_UNDO', 'No operations to undo');
|
|
8373
9977
|
}
|
|
8374
9978
|
|
|
8375
|
-
|
|
8376
|
-
const
|
|
8377
|
-
|
|
8378
|
-
const previousView = /** @type {any} */ (await oplog.undo());
|
|
8379
|
-
|
|
8380
|
-
// Restore state from previous view
|
|
8381
|
-
await workingCopy.setCurrentChange(previousView.workingCopy);
|
|
9979
|
+
const targetOp = resolveUndoTarget(ops);
|
|
9980
|
+
const landingOpId = targetOp.parents && targetOp.parents[0];
|
|
9981
|
+
const landingOp = landingOpId ? ops.find((op) => op.id === landingOpId) : null;
|
|
8382
9982
|
|
|
8383
|
-
|
|
8384
|
-
|
|
8385
|
-
|
|
8386
|
-
|
|
8387
|
-
|
|
8388
|
-
|
|
8389
|
-
|
|
8390
|
-
|
|
8391
|
-
|
|
8392
|
-
|
|
8393
|
-
|
|
8394
|
-
|
|
8395
|
-
|
|
8396
|
-
|
|
8397
|
-
|
|
8398
|
-
|
|
8399
|
-
|
|
8400
|
-
|
|
8401
|
-
|
|
8402
|
-
|
|
8403
|
-
|
|
8404
|
-
|
|
8405
|
-
|
|
8406
|
-
|
|
8407
|
-
|
|
9983
|
+
/** @type {ReturnType<typeof computeGraphReversal>} */
|
|
9984
|
+
let reversal;
|
|
9985
|
+
/** @type {any} */
|
|
9986
|
+
let landingView;
|
|
9987
|
+
if (landingOp) {
|
|
9988
|
+
reversal = computeGraphReversal(ops, landingOp);
|
|
9989
|
+
landingView = landingOp.view || {};
|
|
9990
|
+
} else {
|
|
9991
|
+
// Undoing the very first (root) operation: there's nothing before
|
|
9992
|
+
// it to restore to, so land on its own recorded state rather than
|
|
9993
|
+
// throwing (mirrors the previous no-parent fallback).
|
|
9994
|
+
reversal = {
|
|
9995
|
+
changeSnapshotToApply: {},
|
|
9996
|
+
fileSnapshot: undefined,
|
|
9997
|
+
conflictsSnapshot: undefined,
|
|
9998
|
+
};
|
|
9999
|
+
landingView = targetOp.view || {};
|
|
10000
|
+
}
|
|
10001
|
+
|
|
10002
|
+
// Capture EXACTLY what's about to be overwritten, from ground truth,
|
|
10003
|
+
// before applying the reversal — this is what redo() re-applies
|
|
10004
|
+
// later. It can't just recompute "the state after targetOp" the way
|
|
10005
|
+
// undo() itself computes "the state after landingOp" (by scanning
|
|
10006
|
+
// for a LATER op that also touched the same changeId/files/
|
|
10007
|
+
// conflicts): that scan only works going backward, where "nothing
|
|
10008
|
+
// touched it since" means "it's already correct". Going forward,
|
|
10009
|
+
// "nothing touched it since targetOp" means the opposite — its
|
|
10010
|
+
// effect is still live and needs to be captured now, not rederived.
|
|
10011
|
+
await graph.load();
|
|
10012
|
+
/** @type {Record<string, any>} */
|
|
10013
|
+
const redoChangeSnapshot = {};
|
|
10014
|
+
for (const changeId of Object.keys(reversal.changeSnapshotToApply)) {
|
|
10015
|
+
const current = await graph.getChange(changeId);
|
|
10016
|
+
if (current) redoChangeSnapshot[changeId] = current;
|
|
10017
|
+
}
|
|
10018
|
+
const redoFileSnapshot =
|
|
10019
|
+
reversal.fileSnapshot !== undefined ? await snapshotFilesystem() : undefined;
|
|
10020
|
+
let redoConflictsSnapshot;
|
|
10021
|
+
if (reversal.conflictsSnapshot !== undefined) {
|
|
10022
|
+
await conflicts.load();
|
|
10023
|
+
redoConflictsSnapshot = {
|
|
10024
|
+
conflicts: Object.fromEntries(conflicts.conflicts),
|
|
10025
|
+
fileConflicts: Object.fromEntries(conflicts.fileConflicts),
|
|
10026
|
+
};
|
|
8408
10027
|
}
|
|
8409
10028
|
|
|
8410
|
-
|
|
8411
|
-
|
|
8412
|
-
await conflicts.load();
|
|
8413
|
-
if (currentOp && currentOp.conflictsSnapshot) {
|
|
8414
|
-
// Restore conflicts from the operation we're undoing (its pre-state)
|
|
8415
|
-
conflicts.conflicts = new Map(Object.entries(currentOp.conflictsSnapshot.conflicts || {}));
|
|
8416
|
-
conflicts.fileConflicts = new Map(
|
|
8417
|
-
Object.entries(currentOp.conflictsSnapshot.fileConflicts || {})
|
|
8418
|
-
);
|
|
8419
|
-
await conflicts.save();
|
|
8420
|
-
} else {
|
|
8421
|
-
// No conflicts snapshot - clear conflicts
|
|
8422
|
-
await conflicts.clear();
|
|
10029
|
+
if (landingView.workingCopy) {
|
|
10030
|
+
await workingCopy.setCurrentChange(landingView.workingCopy);
|
|
8423
10031
|
}
|
|
10032
|
+
await applyGraphReversal(reversal);
|
|
8424
10033
|
|
|
8425
|
-
// Record undo
|
|
8426
|
-
//
|
|
10034
|
+
// Record undo as its own operation (matching real jj) — tagged with
|
|
10035
|
+
// which operation it targeted so a following undo() can walk one
|
|
10036
|
+
// step further back (resolveUndoTarget) instead of toggling, and
|
|
10037
|
+
// carrying exactly what redo() needs to re-apply (captured above).
|
|
8427
10038
|
await oplog.recordOperation({
|
|
8428
10039
|
timestamp: new Date().toISOString(),
|
|
8429
10040
|
user: await getUserOplogInfo(),
|
|
8430
10041
|
description: 'undo operation',
|
|
8431
10042
|
eventType: 'undo',
|
|
8432
|
-
|
|
8433
|
-
|
|
8434
|
-
|
|
10043
|
+
targetOpId: targetOp.id,
|
|
10044
|
+
undoneOpId: targetOp.id, // kept for API/type compatibility
|
|
10045
|
+
redoChangeSnapshot,
|
|
10046
|
+
redoFileSnapshot,
|
|
10047
|
+
redoConflictsSnapshot,
|
|
8435
10048
|
parents: [],
|
|
8436
|
-
view:
|
|
10049
|
+
view: landingView,
|
|
8437
10050
|
});
|
|
8438
10051
|
|
|
8439
|
-
|
|
10052
|
+
const restoredFileCount =
|
|
10053
|
+
reversal.fileSnapshot !== undefined ? Object.keys(reversal.fileSnapshot).length : 0;
|
|
10054
|
+
|
|
8440
10055
|
return {
|
|
8441
10056
|
undoneOperation: {
|
|
8442
|
-
description:
|
|
8443
|
-
timestamp:
|
|
8444
|
-
user:
|
|
10057
|
+
description: targetOp.description,
|
|
10058
|
+
timestamp: targetOp.timestamp,
|
|
10059
|
+
user: targetOp.user,
|
|
8445
10060
|
},
|
|
8446
10061
|
restoredState: {
|
|
8447
|
-
workingCopy:
|
|
8448
|
-
heads:
|
|
8449
|
-
fileCount:
|
|
10062
|
+
workingCopy: landingView.workingCopy,
|
|
10063
|
+
heads: landingView.heads,
|
|
10064
|
+
fileCount: restoredFileCount,
|
|
8450
10065
|
},
|
|
8451
10066
|
};
|
|
8452
10067
|
},
|
|
@@ -8454,9 +10069,9 @@ async function createJJ(options) {
|
|
|
8454
10069
|
/**
|
|
8455
10070
|
* Redo an operation previously undone with undo() (v1.5)
|
|
8456
10071
|
*
|
|
8457
|
-
* Mirrors `jj redo` (jj v0.33):
|
|
8458
|
-
*
|
|
8459
|
-
*
|
|
10072
|
+
* Mirrors `jj redo` (jj v0.33): re-applies the operation that the most
|
|
10073
|
+
* recent not-yet-redone undo() reverted — including its graph rewrites,
|
|
10074
|
+
* not just working-copy/file state (see issue #29).
|
|
8460
10075
|
*
|
|
8461
10076
|
* @returns {Promise<Object>} Information about the redone operation
|
|
8462
10077
|
*/
|
|
@@ -8487,42 +10102,36 @@ async function createJJ(options) {
|
|
|
8487
10102
|
});
|
|
8488
10103
|
}
|
|
8489
10104
|
|
|
8490
|
-
const
|
|
10105
|
+
const targetOpId = undoOp.targetOpId || undoOp.undoneOpId;
|
|
10106
|
+
const targetOp = targetOpId && ops.find((op) => op.id === targetOpId);
|
|
10107
|
+
const redoneView = (targetOp && targetOp.view) || {};
|
|
8491
10108
|
|
|
8492
|
-
// Restore the working-copy pointer.
|
|
8493
10109
|
if (redoneView.workingCopy) {
|
|
8494
10110
|
await workingCopy.setCurrentChange(redoneView.workingCopy);
|
|
8495
10111
|
}
|
|
8496
10112
|
|
|
8497
|
-
//
|
|
8498
|
-
|
|
8499
|
-
|
|
8500
|
-
|
|
8501
|
-
|
|
8502
|
-
|
|
8503
|
-
|
|
8504
|
-
|
|
8505
|
-
|
|
8506
|
-
|
|
8507
|
-
|
|
8508
|
-
|
|
8509
|
-
throw new JJError(
|
|
8510
|
-
'REDO_FILE_RESTORE_FAILED',
|
|
8511
|
-
`Failed to restore file ${filePath} during redo: ${error.message}`,
|
|
8512
|
-
{ filePath, originalError: error.message }
|
|
8513
|
-
);
|
|
8514
|
-
}
|
|
10113
|
+
// Re-apply exactly what this undo captured right before it reverted
|
|
10114
|
+
// things — NOT computeGraphReversal(ops, targetOp): that scans for a
|
|
10115
|
+
// LATER op touching the same changeId/files/conflicts, which is the
|
|
10116
|
+
// right question for undo() ("has anything since made my target
|
|
10117
|
+
// stale?") but the wrong one here — after an undo, NOTHING later
|
|
10118
|
+
// re-established targetOp's own effect, so there's nothing to find
|
|
10119
|
+
// by scanning forward. undo() captured it directly for exactly this
|
|
10120
|
+
// reason (see its own comment).
|
|
10121
|
+
await graph.load();
|
|
10122
|
+
for (const [changeId, snapshot] of Object.entries(undoOp.redoChangeSnapshot || {})) {
|
|
10123
|
+
if (await graph.getChange(changeId)) {
|
|
10124
|
+
await graph.updateChange(snapshot);
|
|
8515
10125
|
}
|
|
8516
10126
|
}
|
|
8517
|
-
|
|
8518
|
-
|
|
10127
|
+
if (undoOp.redoFileSnapshot !== undefined) {
|
|
10128
|
+
await syncWorkingCopyFiles(undoOp.redoFileSnapshot);
|
|
10129
|
+
}
|
|
8519
10130
|
await conflicts.load();
|
|
8520
|
-
if (undoOp.
|
|
8521
|
-
conflicts.conflicts = new Map(
|
|
8522
|
-
Object.entries(undoOp.undoneConflictsSnapshot.conflicts || {})
|
|
8523
|
-
);
|
|
10131
|
+
if (undoOp.redoConflictsSnapshot !== undefined) {
|
|
10132
|
+
conflicts.conflicts = new Map(Object.entries(undoOp.redoConflictsSnapshot.conflicts || {}));
|
|
8524
10133
|
conflicts.fileConflicts = new Map(
|
|
8525
|
-
Object.entries(undoOp.
|
|
10134
|
+
Object.entries(undoOp.redoConflictsSnapshot.fileConflicts || {})
|
|
8526
10135
|
);
|
|
8527
10136
|
await conflicts.save();
|
|
8528
10137
|
}
|
|
@@ -8537,15 +10146,18 @@ async function createJJ(options) {
|
|
|
8537
10146
|
view: redoneView,
|
|
8538
10147
|
});
|
|
8539
10148
|
|
|
10149
|
+
const restoredFileCount =
|
|
10150
|
+
undoOp.redoFileSnapshot !== undefined ? Object.keys(undoOp.redoFileSnapshot).length : 0;
|
|
10151
|
+
|
|
8540
10152
|
return {
|
|
8541
10153
|
redoneOperation: {
|
|
8542
10154
|
description: undoOp.description,
|
|
8543
|
-
undoneOpId:
|
|
10155
|
+
undoneOpId: targetOpId,
|
|
8544
10156
|
},
|
|
8545
10157
|
restoredState: {
|
|
8546
10158
|
workingCopy: redoneView.workingCopy,
|
|
8547
10159
|
heads: redoneView.heads,
|
|
8548
|
-
fileCount:
|
|
10160
|
+
fileCount: restoredFileCount,
|
|
8549
10161
|
},
|
|
8550
10162
|
};
|
|
8551
10163
|
},
|
|
@@ -8772,6 +10384,12 @@ async function createJJ(options) {
|
|
|
8772
10384
|
/**
|
|
8773
10385
|
* Restore repository to a specific operation (matches `jj operation restore`)
|
|
8774
10386
|
*
|
|
10387
|
+
* Fully reverts everything recorded after the target operation —
|
|
10388
|
+
* including graph rewrites (squash/rebase/abandon/new/edit), working
|
|
10389
|
+
* copy files, and conflict state, not just bookmarks/the working-copy
|
|
10390
|
+
* pointer (see issue #29) — using the same reversal primitive as
|
|
10391
|
+
* undo()/redo().
|
|
10392
|
+
*
|
|
8775
10393
|
* @param {Record<string, any>} args - Arguments
|
|
8776
10394
|
* @returns {Promise<Object>} Restore result
|
|
8777
10395
|
*/
|
|
@@ -8811,6 +10429,11 @@ async function createJJ(options) {
|
|
|
8811
10429
|
await workingCopy.setCurrentChange(targetOp.view.workingCopy);
|
|
8812
10430
|
}
|
|
8813
10431
|
|
|
10432
|
+
// Revert the change graph, working-copy files, and conflict state
|
|
10433
|
+
// to exactly what they were right after targetOp ran.
|
|
10434
|
+
const reversal = computeGraphReversal(ops, targetOp);
|
|
10435
|
+
await applyGraphReversal(reversal);
|
|
10436
|
+
|
|
8814
10437
|
// Record this restoration as a new operation
|
|
8815
10438
|
await oplog.recordOperation({
|
|
8816
10439
|
timestamp: new Date().toISOString(),
|
|
@@ -8864,6 +10487,7 @@ async function createJJ(options) {
|
|
|
8864
10487
|
// Compute the inverse changes
|
|
8865
10488
|
const inversChanges = /** @type {Record<string, any>} */ ({
|
|
8866
10489
|
bookmarks: {},
|
|
10490
|
+
tags: {},
|
|
8867
10491
|
heads: [],
|
|
8868
10492
|
});
|
|
8869
10493
|
|
|
@@ -8903,6 +10527,46 @@ async function createJJ(options) {
|
|
|
8903
10527
|
|
|
8904
10528
|
await bookmarks.save();
|
|
8905
10529
|
|
|
10530
|
+
// Revert tag changes (mirrors the bookmark diff above — issue #12).
|
|
10531
|
+
// Unlike bookmarks, TagStore has no "move" primitive (tags are meant
|
|
10532
|
+
// to be immutable once created), so a moved tag is reverted via
|
|
10533
|
+
// delete+recreate, matching how tag.set() itself updates a tag.
|
|
10534
|
+
const prevTags = previousOp.view?.tags || {};
|
|
10535
|
+
const targetTags = targetOp.view?.tags || {};
|
|
10536
|
+
|
|
10537
|
+
for (const name of Object.keys(targetTags)) {
|
|
10538
|
+
if (!prevTags[name]) {
|
|
10539
|
+
// Tag was added - delete it
|
|
10540
|
+
if (await tags.exists(name)) {
|
|
10541
|
+
await tags.delete(name);
|
|
10542
|
+
}
|
|
10543
|
+
inversChanges.tags[name] = { action: 'deleted' };
|
|
10544
|
+
} else if (prevTags[name] !== targetTags[name]) {
|
|
10545
|
+
// Tag was moved - restore old target
|
|
10546
|
+
if (await tags.exists(name)) {
|
|
10547
|
+
await tags.delete(name);
|
|
10548
|
+
}
|
|
10549
|
+
await tags.create(name, prevTags[name]);
|
|
10550
|
+
inversChanges.tags[name] = {
|
|
10551
|
+
action: 'moved',
|
|
10552
|
+
from: targetTags[name],
|
|
10553
|
+
to: prevTags[name],
|
|
10554
|
+
};
|
|
10555
|
+
}
|
|
10556
|
+
}
|
|
10557
|
+
|
|
10558
|
+
// Check for deleted tags (existed before, don't exist after)
|
|
10559
|
+
for (const name of Object.keys(prevTags)) {
|
|
10560
|
+
if (!targetTags[name] && !(await tags.exists(name))) {
|
|
10561
|
+
// Tag was deleted - restore it
|
|
10562
|
+
await tags.create(name, prevTags[name]);
|
|
10563
|
+
inversChanges.tags[name] = {
|
|
10564
|
+
action: 'restored',
|
|
10565
|
+
to: prevTags[name],
|
|
10566
|
+
};
|
|
10567
|
+
}
|
|
10568
|
+
}
|
|
10569
|
+
|
|
8906
10570
|
// Revert working copy change if it changed
|
|
8907
10571
|
if (previousOp.view?.workingCopy !== targetOp.view?.workingCopy) {
|
|
8908
10572
|
await workingCopy.load();
|
|
@@ -8917,6 +10581,7 @@ async function createJJ(options) {
|
|
|
8917
10581
|
parents: [],
|
|
8918
10582
|
view: {
|
|
8919
10583
|
bookmarks: prevBookmarks,
|
|
10584
|
+
tags: prevTags,
|
|
8920
10585
|
remoteBookmarks: previousOp.view?.remoteBookmarks || {},
|
|
8921
10586
|
heads: previousOp.view?.heads || [],
|
|
8922
10587
|
workingCopy: previousOp.view?.workingCopy,
|
|
@@ -9060,10 +10725,53 @@ async function createJJ(options) {
|
|
|
9060
10725
|
timestamp: new Date().toISOString(),
|
|
9061
10726
|
});
|
|
9062
10727
|
|
|
10728
|
+
// Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
|
|
10729
|
+
// reverse the squash (un-abandon source, restore dest's description
|
|
10730
|
+
// and content) — see issue #29.
|
|
10731
|
+
/** @type {Record<string, any>} */
|
|
10732
|
+
const changeSnapshot = {
|
|
10733
|
+
[source]: structuredClone(sourceChange),
|
|
10734
|
+
[destChangeId]: structuredClone(destChange),
|
|
10735
|
+
};
|
|
10736
|
+
|
|
9063
10737
|
// Mark source as abandoned
|
|
9064
10738
|
sourceChange.abandoned = true;
|
|
9065
10739
|
await graph.updateChange(sourceChange);
|
|
9066
10740
|
|
|
10741
|
+
// Merge source's file content into dest (issue #30: squash() used to
|
|
10742
|
+
// only combine descriptions, leaving dest's tree untouched). Compute
|
|
10743
|
+
// what source itself actually changed relative to ITS OWN parent
|
|
10744
|
+
// (its "diff") and fold just that onto dest, rather than overwriting
|
|
10745
|
+
// dest's whole snapshot with source's — a path source never touched
|
|
10746
|
+
// (inherited unchanged from that parent) must not clobber a
|
|
10747
|
+
// dest-specific version of the same path.
|
|
10748
|
+
const sourceParentId = sourceChange.parents && sourceChange.parents[0];
|
|
10749
|
+
const sourceParentChange = sourceParentId ? await graph.getChange(sourceParentId) : null;
|
|
10750
|
+
const sourceBaseSnapshot = (sourceParentChange && sourceParentChange.fileSnapshot) || {};
|
|
10751
|
+
const sourceSnapshot = sourceChange.fileSnapshot || {};
|
|
10752
|
+
const mergedSnapshot = { ...(destChange.fileSnapshot || {}) };
|
|
10753
|
+
const touchedPaths = new Set([
|
|
10754
|
+
...Object.keys(sourceBaseSnapshot),
|
|
10755
|
+
...Object.keys(sourceSnapshot),
|
|
10756
|
+
]);
|
|
10757
|
+
for (const filePath of touchedPaths) {
|
|
10758
|
+
const baseValue = sourceBaseSnapshot[filePath];
|
|
10759
|
+
const sourceValue = sourceSnapshot[filePath];
|
|
10760
|
+
if (sourceValue === baseValue) {
|
|
10761
|
+
// source never touched this path (inherited as-is from its own
|
|
10762
|
+
// parent) — leave whatever dest already had for it alone.
|
|
10763
|
+
continue;
|
|
10764
|
+
}
|
|
10765
|
+
if (sourceValue === undefined) {
|
|
10766
|
+
// source deleted a file it inherited from its parent.
|
|
10767
|
+
delete mergedSnapshot[filePath];
|
|
10768
|
+
} else {
|
|
10769
|
+
// source added or modified this path.
|
|
10770
|
+
mergedSnapshot[filePath] = sourceValue;
|
|
10771
|
+
}
|
|
10772
|
+
}
|
|
10773
|
+
destChange.fileSnapshot = mergedSnapshot;
|
|
10774
|
+
|
|
9067
10775
|
// Update description of dest to indicate squash (middleware will sync to Git)
|
|
9068
10776
|
destChange.description += `\n\n(squashed from ${source.slice(0, 8)})`;
|
|
9069
10777
|
await graph.updateChange(destChange);
|
|
@@ -9105,6 +10813,7 @@ async function createJJ(options) {
|
|
|
9105
10813
|
user: await getUserOplogInfo(),
|
|
9106
10814
|
description: `squash ${source.slice(0, 8)} into ${destChangeId.slice(0, 8)}`,
|
|
9107
10815
|
parents: [],
|
|
10816
|
+
changeSnapshot,
|
|
9108
10817
|
view: {
|
|
9109
10818
|
bookmarks: {},
|
|
9110
10819
|
remoteBookmarks: {},
|
|
@@ -9183,15 +10892,29 @@ async function createJJ(options) {
|
|
|
9183
10892
|
timestamp: new Date().toISOString(),
|
|
9184
10893
|
});
|
|
9185
10894
|
|
|
10895
|
+
// Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
|
|
10896
|
+
// reverse the abandon (un-abandon this change, un-reparent children)
|
|
10897
|
+
// — see issue #29.
|
|
10898
|
+
/** @type {Record<string, any>} */
|
|
10899
|
+
const changeSnapshot = { [changeId]: structuredClone(change) };
|
|
10900
|
+
|
|
9186
10901
|
change.abandoned = true;
|
|
9187
10902
|
await graph.updateChange(change);
|
|
9188
10903
|
|
|
10904
|
+
// Re-parent children onto the abandoned change's own parent(s)
|
|
10905
|
+
// (issue #30: this used to leave children pointing at the now-
|
|
10906
|
+
// abandoned commit). Matches real `jj abandon`: the abandoned
|
|
10907
|
+
// change is elided from the graph and its children attach directly
|
|
10908
|
+
// to whatever it was attached to.
|
|
10909
|
+
await reparentChildrenOnto(changeId, change.parents, changeSnapshot);
|
|
10910
|
+
|
|
9189
10911
|
// Record operation
|
|
9190
10912
|
await oplog.recordOperation({
|
|
9191
10913
|
timestamp: new Date().toISOString(),
|
|
9192
10914
|
user: { name: user.name, email: user.email, hostname: 'localhost' },
|
|
9193
10915
|
description: `abandon change ${changeId.slice(0, 8)}`,
|
|
9194
10916
|
parents: [],
|
|
10917
|
+
changeSnapshot,
|
|
9195
10918
|
view: {
|
|
9196
10919
|
bookmarks: {},
|
|
9197
10920
|
remoteBookmarks: {},
|
|
@@ -9216,6 +10939,206 @@ async function createJJ(options) {
|
|
|
9216
10939
|
return change;
|
|
9217
10940
|
},
|
|
9218
10941
|
|
|
10942
|
+
/**
|
|
10943
|
+
* Converge divergent copies of a change (matches `jj converge`, jj
|
|
10944
|
+
* v0.45.0+ — issue #32, extended for N-way per the isomorphic-jj v0.45
|
|
10945
|
+
* catch-up follow-up) — resolves ANY number of divergent copies of one
|
|
10946
|
+
* changeId in a single call. Real jj's own CLI currently only
|
|
10947
|
+
* converges one change-id per invocation (an explicit TODO in its own
|
|
10948
|
+
* source as of v0.45.1: "consider adding logic to deal with more than
|
|
10949
|
+
* one divergent change-id in one invocation"), which is what fixed
|
|
10950
|
+
* this package's own changeId-at-a-time API design — but its
|
|
10951
|
+
* underlying engine (`TruncatedEvolutionGraph`/`find_divergent_changes`)
|
|
10952
|
+
* already resolves every divergent revision of THAT one change-id
|
|
10953
|
+
* together, not just a pair. This generalizes the same per-path rule
|
|
10954
|
+
* accordingly: two-way "does the other side's value differ from base"
|
|
10955
|
+
* becomes N-way "how many DISTINCT values, across every copy, differ
|
|
10956
|
+
* from base" — 0 means nobody touched it, 1 means exactly one copy (or
|
|
10957
|
+
* several copies agreeing on the same new value) changed it and that
|
|
10958
|
+
* value wins, 2+ is a genuine conflict between that many copies.
|
|
10959
|
+
*
|
|
10960
|
+
* A change is divergent when more than one visible commit shares its
|
|
10961
|
+
* change id (see the `divergent()` revset, and
|
|
10962
|
+
* ChangeGraph.addDivergentCopy() for how a divergent copy gets added
|
|
10963
|
+
* in the first place — nothing in this library creates one on its
|
|
10964
|
+
* own yet; this resolves one once something has, e.g. a future git
|
|
10965
|
+
* fetch/concurrent-operation integration).
|
|
10966
|
+
*
|
|
10967
|
+
* Non-interactive-mode convention, matching this package's other
|
|
10968
|
+
* ambiguous-outcome APIs: like merge(), a genuine per-path conflict is
|
|
10969
|
+
* returned as data (`{ resolved: false, conflicts }`), not thrown —
|
|
10970
|
+
* you get a result back either way and decide what to do with
|
|
10971
|
+
* unresolved conflicts. What DOES throw is anything that isn't even a
|
|
10972
|
+
* well-formed convergence attempt (no divergence to resolve at all) —
|
|
10973
|
+
* the same category as merge()'s own `MERGE_ERROR` throw for "no
|
|
10974
|
+
* common ancestor found".
|
|
10975
|
+
*
|
|
10976
|
+
* @param {Record<string, any>|string} args - `{ changeId }`, or a bare changeId string
|
|
10977
|
+
* @returns {Promise<Object>} `{ changeId, resolved, conflicts, commitId? }`
|
|
10978
|
+
*/
|
|
10979
|
+
async converge(args) {
|
|
10980
|
+
const changeId = typeof args === 'string' ? args : args && (args.changeId || args.change);
|
|
10981
|
+
if (!changeId || typeof changeId !== 'string') {
|
|
10982
|
+
throw new JJError('INVALID_ARGUMENT', 'Missing changeId argument', {
|
|
10983
|
+
suggestion: 'Provide the divergent changeId to converge: { changeId: "..." }',
|
|
10984
|
+
});
|
|
10985
|
+
}
|
|
10986
|
+
|
|
10987
|
+
await graph.load();
|
|
10988
|
+
await userConfig.load();
|
|
10989
|
+
const siblings = graph.getDivergentSiblings(changeId);
|
|
10990
|
+
|
|
10991
|
+
if (siblings.length < 2) {
|
|
10992
|
+
throw new JJError(
|
|
10993
|
+
'NOT_DIVERGENT',
|
|
10994
|
+
`Change ${changeId} has no divergent copies to converge`,
|
|
10995
|
+
{
|
|
10996
|
+
changeId,
|
|
10997
|
+
suggestion:
|
|
10998
|
+
'converge() is only meaningful for a changeId the divergent() revset reports',
|
|
10999
|
+
}
|
|
11000
|
+
);
|
|
11001
|
+
}
|
|
11002
|
+
|
|
11003
|
+
// The primary copy is whichever one getChange() resolves (stored
|
|
11004
|
+
// under the plain changeId key); every other one is a divergent copy
|
|
11005
|
+
// added via addDivergentCopy(). No cap on how many — see the doc
|
|
11006
|
+
// comment above.
|
|
11007
|
+
const primary = /** @type {any} */ (await graph.getChange(changeId));
|
|
11008
|
+
const others = siblings.filter((/** @type {any} */ s) => s.commitId !== primary.commitId);
|
|
11009
|
+
|
|
11010
|
+
// Base for the N-way merge: what this change looked like before it
|
|
11011
|
+
// diverged. This package doesn't retain historical snapshots (only
|
|
11012
|
+
// current content), so — same pragmatic choice rebase() makes for
|
|
11013
|
+
// "old base" — use the shared parent's current content; divergent
|
|
11014
|
+
// copies realistically share the same parent, since they're all
|
|
11015
|
+
// rewrites of one prior position in history, not unrelated commits
|
|
11016
|
+
// that happen to collide. Applied uniformly to every copy, not
|
|
11017
|
+
// per-copy — the same simplification the original two-way version
|
|
11018
|
+
// made, just not newly introduced by going N-way.
|
|
11019
|
+
const baseParentId = primary.parents && primary.parents[0];
|
|
11020
|
+
const baseParentChange = baseParentId ? await graph.getChange(baseParentId) : null;
|
|
11021
|
+
const baseFiles = new Map(
|
|
11022
|
+
Object.entries((baseParentChange && baseParentChange.fileSnapshot) || {})
|
|
11023
|
+
);
|
|
11024
|
+
|
|
11025
|
+
// Every copy (primary + others), each as its own file map, paired
|
|
11026
|
+
// with its commitId for conflict reporting.
|
|
11027
|
+
const copies = [primary, ...others].map((/** @type {any} */ c) => ({
|
|
11028
|
+
commitId: c.commitId,
|
|
11029
|
+
files: new Map(Object.entries(c.fileSnapshot || {})),
|
|
11030
|
+
}));
|
|
11031
|
+
|
|
11032
|
+
await conflicts.load();
|
|
11033
|
+
const conflictsSnapshot = {
|
|
11034
|
+
conflicts: Object.fromEntries(conflicts.conflicts),
|
|
11035
|
+
fileConflicts: Object.fromEntries(conflicts.fileConflicts),
|
|
11036
|
+
};
|
|
11037
|
+
|
|
11038
|
+
const allPaths = new Set([
|
|
11039
|
+
...baseFiles.keys(),
|
|
11040
|
+
...copies.flatMap((c) => [...c.files.keys()]),
|
|
11041
|
+
]);
|
|
11042
|
+
|
|
11043
|
+
/** @type {Record<string, string>} */
|
|
11044
|
+
const mergedSnapshot = {};
|
|
11045
|
+
/** @type {any[]} */
|
|
11046
|
+
const detectedConflicts = [];
|
|
11047
|
+
|
|
11048
|
+
for (const filePath of allPaths) {
|
|
11049
|
+
const baseValue = baseFiles.get(filePath);
|
|
11050
|
+
// Every DISTINCT value across all copies that differs from base,
|
|
11051
|
+
// each paired with (one of) the commitId(s) that has it — mirrors
|
|
11052
|
+
// ConflictModel._detectPathConflict's two-way "no conflict" rules,
|
|
11053
|
+
// generalized: 0 distinct changed values means nobody touched it
|
|
11054
|
+
// (base wins, i.e. nothing to write), 1 means only one side (or
|
|
11055
|
+
// several copies agreeing) changed it (that value wins), 2+ is a
|
|
11056
|
+
// genuine conflict.
|
|
11057
|
+
const changed = new Map();
|
|
11058
|
+
for (const copy of copies) {
|
|
11059
|
+
const value = copy.files.get(filePath);
|
|
11060
|
+
if (value !== baseValue && !changed.has(value)) {
|
|
11061
|
+
changed.set(value, copy.commitId);
|
|
11062
|
+
}
|
|
11063
|
+
}
|
|
11064
|
+
|
|
11065
|
+
if (changed.size === 0) {
|
|
11066
|
+
if (baseValue !== undefined) mergedSnapshot[filePath] = baseValue;
|
|
11067
|
+
} else if (changed.size === 1) {
|
|
11068
|
+
const [[value]] = changed;
|
|
11069
|
+
if (value !== undefined) mergedSnapshot[filePath] = value;
|
|
11070
|
+
} else {
|
|
11071
|
+
const versions = [...changed.entries()].map(([content, commitId]) => ({
|
|
11072
|
+
commitId,
|
|
11073
|
+
content,
|
|
11074
|
+
}));
|
|
11075
|
+
detectedConflicts.push(conflicts.createNWayConflict(filePath, baseValue, versions));
|
|
11076
|
+
}
|
|
11077
|
+
}
|
|
11078
|
+
|
|
11079
|
+
if (detectedConflicts.length > 0) {
|
|
11080
|
+
// Matches real jj: non-interactive mode aborts automatic
|
|
11081
|
+
// resolution rather than guessing — but per this package's own
|
|
11082
|
+
// merge() convention, that's reported back as data, not thrown.
|
|
11083
|
+
for (const conflict of detectedConflicts) {
|
|
11084
|
+
await conflicts.addConflict(conflict);
|
|
11085
|
+
}
|
|
11086
|
+
await oplog.recordOperation({
|
|
11087
|
+
timestamp: new Date().toISOString(),
|
|
11088
|
+
user: await getUserOplogInfo(),
|
|
11089
|
+
description: `converge ${changeId.slice(0, 8)} (unresolved)`,
|
|
11090
|
+
parents: [],
|
|
11091
|
+
conflictsSnapshot,
|
|
11092
|
+
view: {
|
|
11093
|
+
bookmarks: {},
|
|
11094
|
+
remoteBookmarks: {},
|
|
11095
|
+
heads: [],
|
|
11096
|
+
workingCopy: workingCopy.getCurrentChangeId(),
|
|
11097
|
+
},
|
|
11098
|
+
});
|
|
11099
|
+
return { changeId, resolved: false, conflicts: detectedConflicts };
|
|
11100
|
+
}
|
|
11101
|
+
|
|
11102
|
+
const user = /** @type {any} */ (userConfig.getUser());
|
|
11103
|
+
const changeSnapshot = { [changeId]: structuredClone(primary) };
|
|
11104
|
+
const otherIds = others.map((/** @type {any} */ o) => o.commitId.slice(0, 8)).join(', ');
|
|
11105
|
+
|
|
11106
|
+
const convergedChange = {
|
|
11107
|
+
...primary,
|
|
11108
|
+
commitId: randomHex(20),
|
|
11109
|
+
fileSnapshot: mergedSnapshot,
|
|
11110
|
+
description: primary.description + `\n\n(converged divergent copies: ${otherIds})`,
|
|
11111
|
+
timestamp: new Date().toISOString(),
|
|
11112
|
+
committer: {
|
|
11113
|
+
name: user.name,
|
|
11114
|
+
email: user.email,
|
|
11115
|
+
timestamp: new Date().toISOString(),
|
|
11116
|
+
},
|
|
11117
|
+
divergent: false,
|
|
11118
|
+
};
|
|
11119
|
+
await graph.updateChange(convergedChange);
|
|
11120
|
+
for (const other of others) {
|
|
11121
|
+
await graph.deleteDivergentCopy(changeId, other.commitId);
|
|
11122
|
+
}
|
|
11123
|
+
|
|
11124
|
+
await oplog.recordOperation({
|
|
11125
|
+
timestamp: new Date().toISOString(),
|
|
11126
|
+
user: await getUserOplogInfo(),
|
|
11127
|
+
description: `converge ${changeId.slice(0, 8)}`,
|
|
11128
|
+
parents: [],
|
|
11129
|
+
changeSnapshot,
|
|
11130
|
+
conflictsSnapshot,
|
|
11131
|
+
view: {
|
|
11132
|
+
bookmarks: {},
|
|
11133
|
+
remoteBookmarks: {},
|
|
11134
|
+
heads: [changeId],
|
|
11135
|
+
workingCopy: workingCopy.getCurrentChangeId(),
|
|
11136
|
+
},
|
|
11137
|
+
});
|
|
11138
|
+
|
|
11139
|
+
return { changeId, resolved: true, conflicts: [], commitId: convergedChange.commitId };
|
|
11140
|
+
},
|
|
11141
|
+
|
|
9219
11142
|
/**
|
|
9220
11143
|
* Backout a change - create a new change that reverses the effect of the specified change
|
|
9221
11144
|
*
|
|
@@ -10677,7 +12600,7 @@ async function createJJ(options) {
|
|
|
10677
12600
|
.split('/')
|
|
10678
12601
|
.pop()
|
|
10679
12602
|
.replace(/\.git$/, '');
|
|
10680
|
-
const fullCloneDir =
|
|
12603
|
+
const fullCloneDir = join(dir, cloneDir);
|
|
10681
12604
|
|
|
10682
12605
|
// Clone using isomorphic-git
|
|
10683
12606
|
await git.clone({
|
|
@@ -11055,8 +12978,14 @@ async function createJJ(options) {
|
|
|
11055
12978
|
* @returns {Promise<Object>} Repository root information
|
|
11056
12979
|
*/
|
|
11057
12980
|
async root() {
|
|
11058
|
-
//
|
|
11059
|
-
|
|
12981
|
+
// `dir` is a real host filesystem path (unlike the repo-relative
|
|
12982
|
+
// git/jj paths `path` — posix-path.js — is for), so it needs the
|
|
12983
|
+
// host's own separator here, not always '/': on Windows Node's real
|
|
12984
|
+
// `fs` still resolves a forward-slash-joined path fine, but this
|
|
12985
|
+
// method hands the string back to the caller as data, and jj's own
|
|
12986
|
+
// `git root` returns a platform-native path there.
|
|
12987
|
+
const isWin32 = typeof process !== 'undefined' && process.platform === 'win32';
|
|
12988
|
+
const gitDir = isWin32 ? `${dir.replace(/[\\/]+$/, '')}\\.git` : join(dir, '.git');
|
|
11060
12989
|
|
|
11061
12990
|
try {
|
|
11062
12991
|
await fs.promises.access(gitDir);
|
|
@@ -11135,7 +13064,7 @@ async function createJJ(options) {
|
|
|
11135
13064
|
const wcFiles = await workingCopy.listFiles();
|
|
11136
13065
|
for (const file of wcFiles) {
|
|
11137
13066
|
try {
|
|
11138
|
-
const content = await fs.promises.readFile(
|
|
13067
|
+
const content = await fs.promises.readFile(join(dir, file), 'utf8');
|
|
11139
13068
|
leftFiles.set(file, content);
|
|
11140
13069
|
} catch (error) {
|
|
11141
13070
|
// File might be deleted or binary - throw error for explicit handling
|
|
@@ -11278,7 +13207,7 @@ async function createJJ(options) {
|
|
|
11278
13207
|
}
|
|
11279
13208
|
|
|
11280
13209
|
// Write resolved content to file
|
|
11281
|
-
await fs.promises.writeFile(
|
|
13210
|
+
await fs.promises.writeFile(join(dir, conflict.path), resolvedContent, 'utf8');
|
|
11282
13211
|
|
|
11283
13212
|
// Mark conflict as resolved
|
|
11284
13213
|
await conflicts.resolveConflict(args.conflictId, 'manual');
|
|
@@ -11321,7 +13250,7 @@ async function createJJ(options) {
|
|
|
11321
13250
|
const resolvedContent = _resolveWithStrategy(conflict, args.strategy);
|
|
11322
13251
|
|
|
11323
13252
|
// Write resolved content
|
|
11324
|
-
await fs.promises.writeFile(
|
|
13253
|
+
await fs.promises.writeFile(join(dir, conflict.path), resolvedContent, 'utf8');
|
|
11325
13254
|
|
|
11326
13255
|
// Mark as resolved
|
|
11327
13256
|
await conflicts.resolveConflict(conflict.conflictId, args.strategy);
|
|
@@ -11354,11 +13283,18 @@ async function createJJ(options) {
|
|
|
11354
13283
|
throw new JJError('CONFLICT_NOT_FOUND', `Conflict ${args.conflictId} not found`);
|
|
11355
13284
|
}
|
|
11356
13285
|
|
|
11357
|
-
// Generate standard conflict markers
|
|
13286
|
+
// Generate standard conflict markers. Force a newline before each
|
|
13287
|
+
// boundary marker whenever there's non-empty content that doesn't
|
|
13288
|
+
// already end in one — otherwise the content fuses onto the
|
|
13289
|
+
// `=======`/`>>>>>>> theirs` marker, producing a corrupted/unparseable
|
|
13290
|
+
// line (see issue #15). Empty sides (e.g. a modify-delete conflict)
|
|
13291
|
+
// intentionally get no extra blank line before the marker.
|
|
11358
13292
|
const ours = conflict.sides.left || '';
|
|
11359
13293
|
const theirs = conflict.sides.right || '';
|
|
13294
|
+
const oursSep = ours && !ours.endsWith('\n') ? '\n' : '';
|
|
13295
|
+
const theirsSep = theirs && !theirs.endsWith('\n') ? '\n' : '';
|
|
11360
13296
|
|
|
11361
|
-
return `<<<<<<< ours\n${ours}=======\n${theirs}>>>>>>> theirs`;
|
|
13297
|
+
return `<<<<<<< ours\n${ours}${oursSep}=======\n${theirs}${theirsSep}>>>>>>> theirs`;
|
|
11362
13298
|
},
|
|
11363
13299
|
|
|
11364
13300
|
/**
|
|
@@ -11445,10 +13381,17 @@ async function createJJ(options) {
|
|
|
11445
13381
|
* Searches the tracked files of a change (defaults to the working copy)
|
|
11446
13382
|
* for lines matching a pattern. Following jj v0.41, the pattern is treated
|
|
11447
13383
|
* as a regular expression by default; pass `{ kind: 'substring' }` for a
|
|
11448
|
-
* literal search.
|
|
13384
|
+
* literal search. isomorphic-jj has always returned the matched line
|
|
13385
|
+
* (and its 1-based `lineNumber`) alongside the path, which is what jj
|
|
13386
|
+
* v0.44 made the CLI's new default (`jj file search` used to print only
|
|
13387
|
+
* file paths). Pass `{ nameOnly: true }` for the pre-v0.44 CLI
|
|
13388
|
+
* behavior — a deduplicated list of matching file paths, matching the
|
|
13389
|
+
* new `--name-only` flag.
|
|
11449
13390
|
*
|
|
11450
|
-
* @param {Record<string, any>} args - Arguments
|
|
11451
|
-
*
|
|
13391
|
+
* @param {Record<string, any>} args - Arguments. Pass `nameOnly: true`
|
|
13392
|
+
* to return only unique matching file paths instead of per-line
|
|
13393
|
+
* matches (jj v0.44 `--name-only`)
|
|
13394
|
+
* @returns {Promise<Array<{path: string, lineNumber: number, line: string}>|Array<string>>}
|
|
11452
13395
|
*/
|
|
11453
13396
|
async search(args) {
|
|
11454
13397
|
if (!args || !args.pattern) {
|
|
@@ -11499,6 +13442,10 @@ async function createJJ(options) {
|
|
|
11499
13442
|
}
|
|
11500
13443
|
});
|
|
11501
13444
|
}
|
|
13445
|
+
|
|
13446
|
+
if (args.nameOnly) {
|
|
13447
|
+
return Array.from(new Set(results.map((r) => r.path)));
|
|
13448
|
+
}
|
|
11502
13449
|
return results;
|
|
11503
13450
|
},
|
|
11504
13451
|
|
|
@@ -11625,7 +13572,7 @@ async function createJJ(options) {
|
|
|
11625
13572
|
);
|
|
11626
13573
|
}
|
|
11627
13574
|
|
|
11628
|
-
const fullPath =
|
|
13575
|
+
const fullPath = join(dir, args.path);
|
|
11629
13576
|
|
|
11630
13577
|
// Convert string mode to number if needed
|
|
11631
13578
|
const mode = typeof args.mode === 'string' ? parseInt(args.mode, 8) : args.mode;
|
|
@@ -11736,14 +13683,14 @@ async function createJJ(options) {
|
|
|
11736
13683
|
if (change.fileSnapshot) {
|
|
11737
13684
|
for (const [filePath, content] of Object.entries(change.fileSnapshot)) {
|
|
11738
13685
|
try {
|
|
11739
|
-
const fullPath =
|
|
13686
|
+
const fullPath = join(args.path, filePath);
|
|
11740
13687
|
|
|
11741
13688
|
// Ensure directory exists
|
|
11742
13689
|
const pathParts = filePath.split('/');
|
|
11743
13690
|
if (pathParts.length > 1) {
|
|
11744
13691
|
const dirPath = pathParts.slice(0, -1).join('/');
|
|
11745
|
-
const fullDirPath =
|
|
11746
|
-
await fs
|
|
13692
|
+
const fullDirPath = join(args.path, dirPath);
|
|
13693
|
+
await mkdirp(fs, fullDirPath);
|
|
11747
13694
|
}
|
|
11748
13695
|
|
|
11749
13696
|
// Write file content
|
|
@@ -12580,6 +14527,10 @@ async function createJJ(options) {
|
|
|
12580
14527
|
view: {
|
|
12581
14528
|
bookmarks: {},
|
|
12582
14529
|
remoteBookmarks: {},
|
|
14530
|
+
// Tag delta for this operation (mirrors how `bookmarks` records
|
|
14531
|
+
// its delta above), so operations.revert() can diff it — see
|
|
14532
|
+
// issue #12.
|
|
14533
|
+
tags: { [args.name]: changeId },
|
|
12583
14534
|
heads: [],
|
|
12584
14535
|
workingCopy: workingCopy.getCurrentChangeId(),
|
|
12585
14536
|
},
|
|
@@ -12625,6 +14576,7 @@ async function createJJ(options) {
|
|
|
12625
14576
|
view: {
|
|
12626
14577
|
bookmarks: {},
|
|
12627
14578
|
remoteBookmarks: {},
|
|
14579
|
+
tags: { [args.name]: changeId },
|
|
12628
14580
|
heads: [],
|
|
12629
14581
|
workingCopy: workingCopy.getCurrentChangeId(),
|
|
12630
14582
|
},
|
|
@@ -12634,10 +14586,11 @@ async function createJJ(options) {
|
|
|
12634
14586
|
},
|
|
12635
14587
|
|
|
12636
14588
|
/**
|
|
12637
|
-
* List all tags
|
|
14589
|
+
* List all tags. Each entry also carries a `tracking: { remote, ref }`
|
|
14590
|
+
* field when the tag is tracking a remote (jj v0.44 `jj tag track`).
|
|
12638
14591
|
*
|
|
12639
14592
|
* @param {Record<string, any>} [args] - Optional filter arguments
|
|
12640
|
-
* @returns {Promise<Array<
|
|
14593
|
+
* @returns {Promise<Array<Record<string, any>>>} List of tags
|
|
12641
14594
|
*/
|
|
12642
14595
|
async list(args) {
|
|
12643
14596
|
const pattern = args?.pattern;
|
|
@@ -12669,6 +14622,7 @@ async function createJJ(options) {
|
|
|
12669
14622
|
view: {
|
|
12670
14623
|
bookmarks: {},
|
|
12671
14624
|
remoteBookmarks: {},
|
|
14625
|
+
tags: {},
|
|
12672
14626
|
heads: [],
|
|
12673
14627
|
workingCopy: workingCopy.getCurrentChangeId(),
|
|
12674
14628
|
},
|
|
@@ -12676,6 +14630,94 @@ async function createJJ(options) {
|
|
|
12676
14630
|
|
|
12677
14631
|
return { deleted: args.name };
|
|
12678
14632
|
},
|
|
14633
|
+
|
|
14634
|
+
/**
|
|
14635
|
+
* Associate a local tag with a remote tag so it is updated on fetch
|
|
14636
|
+
* (matches `jj tag track`, new in jj v0.44).
|
|
14637
|
+
*
|
|
14638
|
+
* jj v0.44 made tags remote-trackable just like bookmarks: fetched
|
|
14639
|
+
* tags are tracked by default, and `jj tag track`/`untrack` let you
|
|
14640
|
+
* opt individual tags in or out by hand. isomorphic-jj doesn't (yet)
|
|
14641
|
+
* auto-import remote tags during `git.fetch()` — see CHANGELOG — so
|
|
14642
|
+
* this records tracking intent/state the same way `bookmark.track()`
|
|
14643
|
+
* does, without changing fetch's own behavior.
|
|
14644
|
+
*
|
|
14645
|
+
* @param {Record<string, any>} args - Tag arguments: `{ name, remote }`
|
|
14646
|
+
* (`name` is the local tag name to track; `remote` defaults to `'origin'`)
|
|
14647
|
+
* @returns {Promise<{name: string, remote: string, tracking: boolean}>}
|
|
14648
|
+
*/
|
|
14649
|
+
async track(args) {
|
|
14650
|
+
if (!args || !args.name) {
|
|
14651
|
+
throw new JJError('INVALID_ARGUMENT', 'Missing tag name', {
|
|
14652
|
+
suggestion: 'Provide tag name: { name: "v1.0.0", remote: "origin" }',
|
|
14653
|
+
});
|
|
14654
|
+
}
|
|
14655
|
+
|
|
14656
|
+
const remote = args.remote || 'origin';
|
|
14657
|
+
const remoteTagName = `${remote}/${args.name}`;
|
|
14658
|
+
|
|
14659
|
+
const tagMap = await tags.load();
|
|
14660
|
+
/** @type {Record<string, any>} */
|
|
14661
|
+
const tracking = tags.tracking || {};
|
|
14662
|
+
tracking[args.name] = { remote, remoteName: args.name };
|
|
14663
|
+
tags.tracking = tracking;
|
|
14664
|
+
await tags.save(tagMap);
|
|
14665
|
+
|
|
14666
|
+
// Record operation
|
|
14667
|
+
await oplog.recordOperation({
|
|
14668
|
+
timestamp: new Date().toISOString(),
|
|
14669
|
+
user: await getUserOplogInfo(),
|
|
14670
|
+
description: `tag track ${args.name} from ${remote}`,
|
|
14671
|
+
parents: [],
|
|
14672
|
+
view: {
|
|
14673
|
+
bookmarks: {},
|
|
14674
|
+
remoteBookmarks: { [remoteTagName]: true },
|
|
14675
|
+
heads: [],
|
|
14676
|
+
workingCopy: workingCopy.getCurrentChangeId(),
|
|
14677
|
+
},
|
|
14678
|
+
});
|
|
14679
|
+
|
|
14680
|
+
return { name: args.name, remote, tracking: true };
|
|
14681
|
+
},
|
|
14682
|
+
|
|
14683
|
+
/**
|
|
14684
|
+
* Stop tracking a remote tag (matches `jj tag untrack`, new in jj v0.44).
|
|
14685
|
+
*
|
|
14686
|
+
* @param {Record<string, any>} args - Tag arguments: `{ name }`, the
|
|
14687
|
+
* local tag name to stop tracking
|
|
14688
|
+
* @returns {Promise<{name: string, tracking: boolean, wasTracking: boolean}>}
|
|
14689
|
+
*/
|
|
14690
|
+
async untrack(args) {
|
|
14691
|
+
if (!args || !args.name) {
|
|
14692
|
+
throw new JJError('INVALID_ARGUMENT', 'Missing tag name', {
|
|
14693
|
+
suggestion: 'Provide tag name: { name: "v1.0.0" }',
|
|
14694
|
+
});
|
|
14695
|
+
}
|
|
14696
|
+
|
|
14697
|
+
const tagMap = await tags.load();
|
|
14698
|
+
/** @type {Record<string, any>} */
|
|
14699
|
+
const tracking = tags.tracking || {};
|
|
14700
|
+
const wasTracking = tracking[args.name];
|
|
14701
|
+
delete tracking[args.name];
|
|
14702
|
+
tags.tracking = tracking;
|
|
14703
|
+
await tags.save(tagMap);
|
|
14704
|
+
|
|
14705
|
+
// Record operation
|
|
14706
|
+
await oplog.recordOperation({
|
|
14707
|
+
timestamp: new Date().toISOString(),
|
|
14708
|
+
user: await getUserOplogInfo(),
|
|
14709
|
+
description: `tag untrack ${args.name}`,
|
|
14710
|
+
parents: [],
|
|
14711
|
+
view: {
|
|
14712
|
+
bookmarks: {},
|
|
14713
|
+
remoteBookmarks: {},
|
|
14714
|
+
heads: [],
|
|
14715
|
+
workingCopy: workingCopy.getCurrentChangeId(),
|
|
14716
|
+
},
|
|
14717
|
+
});
|
|
14718
|
+
|
|
14719
|
+
return { name: args.name, tracking: false, wasTracking: !!wasTracking };
|
|
14720
|
+
},
|
|
12679
14721
|
},
|
|
12680
14722
|
|
|
12681
14723
|
/**
|