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