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/src/api/repository.js
CHANGED
|
@@ -16,8 +16,9 @@ import { BackgroundOps } from '../core/background-ops.js';
|
|
|
16
16
|
import { UserConfig } from '../core/user-config.js';
|
|
17
17
|
import { IsomorphicGitBackend } from '../backend/isomorphic-git-backend.js';
|
|
18
18
|
import { JJError } from '../utils/errors.js';
|
|
19
|
-
import { generateChangeId } from '../utils/id-generation.js';
|
|
20
|
-
import path from 'path';
|
|
19
|
+
import { generateChangeId, randomHex } from '../utils/id-generation.js';
|
|
20
|
+
import * as path from '../utils/posix-path.js';
|
|
21
|
+
import { mkdirp } from '../utils/mkdirp.js';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Create and initialize a JJ repository instance
|
|
@@ -116,7 +117,7 @@ export async function createJJ(options) {
|
|
|
116
117
|
break;
|
|
117
118
|
}
|
|
118
119
|
|
|
119
|
-
const content = await fs.promises.readFile(fullPath, '
|
|
120
|
+
const content = await fs.promises.readFile(fullPath, 'utf8');
|
|
120
121
|
fileSnapshot[filePath] = content;
|
|
121
122
|
totalSnapshotSize += stats.size;
|
|
122
123
|
} catch (error) {
|
|
@@ -130,6 +131,257 @@ export async function createJJ(options) {
|
|
|
130
131
|
return fileSnapshot;
|
|
131
132
|
};
|
|
132
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Fully sync the working directory to a target file snapshot: writes
|
|
136
|
+
* every file the target has (create/update), AND deletes every currently
|
|
137
|
+
* tracked file the target does NOT have.
|
|
138
|
+
*
|
|
139
|
+
* Both edit() and new() used to only overlay the target's files on top of
|
|
140
|
+
* whatever was already on disk, so a file that existed in the previously
|
|
141
|
+
* checked-out change but not in the target leaked into the next
|
|
142
|
+
* snapshot — see issue #30. undo()/redo() had the same gap restoring
|
|
143
|
+
* file state — see issue #29. This is the one place that does it
|
|
144
|
+
* correctly, used by all four.
|
|
145
|
+
*
|
|
146
|
+
* @param {Record<string, string>} targetSnapshot - path -> file content
|
|
147
|
+
* @returns {Promise<void>}
|
|
148
|
+
*/
|
|
149
|
+
const syncWorkingCopyFiles = async (targetSnapshot) => {
|
|
150
|
+
const snapshot = targetSnapshot || {};
|
|
151
|
+
const currentFiles = await workingCopy.listFiles();
|
|
152
|
+
|
|
153
|
+
// Remove files that aren't part of the target.
|
|
154
|
+
for (const filePath of currentFiles) {
|
|
155
|
+
if (!(filePath in snapshot)) {
|
|
156
|
+
try {
|
|
157
|
+
const fullPath = path.join(dir, filePath);
|
|
158
|
+
await fs.promises.unlink(fullPath);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
// Already gone / inaccessible — fine, we just want it absent.
|
|
161
|
+
}
|
|
162
|
+
await workingCopy.untrackFile(filePath);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Write/update every file the target has.
|
|
167
|
+
for (const [filePath, content] of Object.entries(snapshot)) {
|
|
168
|
+
const fullPath = path.join(dir, filePath);
|
|
169
|
+
const pathParts = filePath.split('/');
|
|
170
|
+
if (pathParts.length > 1) {
|
|
171
|
+
const fullDirPath = path.join(dir, pathParts.slice(0, -1).join('/'));
|
|
172
|
+
await mkdirp(fs, fullDirPath);
|
|
173
|
+
}
|
|
174
|
+
await fs.promises.writeFile(fullPath, content, 'utf8');
|
|
175
|
+
|
|
176
|
+
const stats = await fs.promises.stat(fullPath);
|
|
177
|
+
await workingCopy.trackFile(filePath, {
|
|
178
|
+
mtime: stats.mtime,
|
|
179
|
+
size: stats.size,
|
|
180
|
+
mode: stats.mode,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Compute the ChangeGraph/file/conflicts state that existed right after
|
|
187
|
+
* `landingOp` finished — i.e. what undo()/redo()/operations.restore()
|
|
188
|
+
* need to re-establish when winding the repository forward or back to
|
|
189
|
+
* that point (see issue #29).
|
|
190
|
+
*
|
|
191
|
+
* Every operation that mutates EXISTING change records in place records
|
|
192
|
+
* (additively — most operations touch none of this and that's fine,
|
|
193
|
+
* there's nothing to reverse for them):
|
|
194
|
+
* - changeSnapshot: { [changeId]: fullRecordBeforeThisOpRan }
|
|
195
|
+
* - view.fileSnapshot: on-disk file state right BEFORE this op ran
|
|
196
|
+
* - conflictsSnapshot: ConflictModel state right BEFORE this op ran
|
|
197
|
+
*
|
|
198
|
+
* Deliberately NOT reverted: changes an operation newly *created* (via
|
|
199
|
+
* graph.addChange() — new(), squash()'s synthetic empty working-copy
|
|
200
|
+
* change, ...). Undoing their creation only un-refs them (restores the
|
|
201
|
+
* working-copy pointer/heads that made them reachable) — it does not
|
|
202
|
+
* delete the change record — matching real jj, where an "unreachable"
|
|
203
|
+
* change stays resolvable by its change id (hidden, not gone) until
|
|
204
|
+
* explicitly gc'd, and this package's own history-editing tests rely on
|
|
205
|
+
* exactly that: rebasing onto a change created by a since-undone new()
|
|
206
|
+
* must still work.
|
|
207
|
+
*
|
|
208
|
+
* "The state right after landingOp" is therefore exactly what the FIRST
|
|
209
|
+
* operation after landingOp recorded as ITS "before" state, merged across
|
|
210
|
+
* every operation between landingOp and the current head in case
|
|
211
|
+
* different operations touched different changeIds (a single op's own
|
|
212
|
+
* snapshot only covers what IT touched). Concretely, per field:
|
|
213
|
+
* - changeSnapshot: first (earliest / closest-to-landingOp) entry per
|
|
214
|
+
* changeId wins — later entries in the range reflect that changeId's
|
|
215
|
+
* state at a LATER point than we're restoring to.
|
|
216
|
+
* - fileSnapshot / conflictsSnapshot: the first op in the range that
|
|
217
|
+
* recorded one, since whole-state snapshots aren't per-changeId and
|
|
218
|
+
* an op that didn't record one simply didn't change that state.
|
|
219
|
+
*
|
|
220
|
+
* This same helper backs undo() (landingOp = the parent of whatever's
|
|
221
|
+
* being undone), redo() (landingOp = the operation a prior undo()
|
|
222
|
+
* reverted), and operations.restore() (landingOp = the target operation
|
|
223
|
+
* itself) — one reversal primitive, not three.
|
|
224
|
+
*
|
|
225
|
+
* @param {Array<any>} ops - Full chronological operation list (oplog.list())
|
|
226
|
+
* @param {any} landingOp - The operation whose resulting state to recompute
|
|
227
|
+
* @returns {{
|
|
228
|
+
* changeSnapshotToApply: Record<string, any>,
|
|
229
|
+
* fileSnapshot: Record<string, string>|undefined,
|
|
230
|
+
* conflictsSnapshot: any,
|
|
231
|
+
* }}
|
|
232
|
+
*/
|
|
233
|
+
const computeGraphReversal = (/** @type {any[]} */ ops, /** @type {any} */ landingOp) => {
|
|
234
|
+
const landingIndex = ops.findIndex((op) => op.id === landingOp.id);
|
|
235
|
+
|
|
236
|
+
/** @type {Record<string, any>} */
|
|
237
|
+
const changeSnapshotToApply = {};
|
|
238
|
+
/** @type {Record<string, string>|undefined} */
|
|
239
|
+
let fileSnapshot;
|
|
240
|
+
/** @type {any} */
|
|
241
|
+
let conflictsSnapshot;
|
|
242
|
+
|
|
243
|
+
for (let i = landingIndex + 1; i < ops.length; i++) {
|
|
244
|
+
const op = ops[i];
|
|
245
|
+
|
|
246
|
+
if (op.changeSnapshot) {
|
|
247
|
+
for (const [changeId, snapshot] of Object.entries(op.changeSnapshot)) {
|
|
248
|
+
if (!(changeId in changeSnapshotToApply)) {
|
|
249
|
+
changeSnapshotToApply[changeId] = snapshot;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// `!= null` (not `!== undefined`) on purpose: some operations record
|
|
255
|
+
// an explicit `null` for "I ran, and I deliberately took no snapshot"
|
|
256
|
+
// (e.g. describe() of a non-working-copy revision) — that's exactly
|
|
257
|
+
// as uninformative as never having set the field, so keep scanning
|
|
258
|
+
// rather than "finding" a null snapshot.
|
|
259
|
+
if (fileSnapshot === undefined && op.view && op.view.fileSnapshot != null) {
|
|
260
|
+
fileSnapshot = op.view.fileSnapshot;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (conflictsSnapshot === undefined && op.conflictsSnapshot != null) {
|
|
264
|
+
conflictsSnapshot = op.conflictsSnapshot;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return { changeSnapshotToApply, fileSnapshot, conflictsSnapshot };
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Apply a reversal computed by computeGraphReversal() to the live graph,
|
|
273
|
+
* working-copy files, and conflict state. Does NOT touch the working-copy
|
|
274
|
+
* pointer or bookmarks — callers set those from the landing op's own
|
|
275
|
+
* `view` directly (pointers, unlike file/graph/conflict content, are
|
|
276
|
+
* already correct as recorded, see computeGraphReversal's doc comment).
|
|
277
|
+
*
|
|
278
|
+
* @param {ReturnType<typeof computeGraphReversal>} reversal
|
|
279
|
+
* @returns {Promise<void>}
|
|
280
|
+
*/
|
|
281
|
+
const applyGraphReversal = async (reversal) => {
|
|
282
|
+
await graph.load();
|
|
283
|
+
for (const [changeId, snapshot] of Object.entries(reversal.changeSnapshotToApply)) {
|
|
284
|
+
if (await graph.getChange(changeId)) {
|
|
285
|
+
await graph.updateChange(snapshot);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (reversal.fileSnapshot !== undefined) {
|
|
290
|
+
await syncWorkingCopyFiles(reversal.fileSnapshot);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
await conflicts.load();
|
|
294
|
+
if (reversal.conflictsSnapshot !== undefined) {
|
|
295
|
+
conflicts.conflicts = new Map(Object.entries(reversal.conflictsSnapshot.conflicts || {}));
|
|
296
|
+
conflicts.fileConflicts = new Map(
|
|
297
|
+
Object.entries(reversal.conflictsSnapshot.fileConflicts || {})
|
|
298
|
+
);
|
|
299
|
+
await conflicts.save();
|
|
300
|
+
}
|
|
301
|
+
// Else: no operation in the reverted range touched conflicts — leave
|
|
302
|
+
// current conflict state alone. (Previously undo() unconditionally
|
|
303
|
+
// cleared ALL conflicts whenever the op being undone hadn't recorded
|
|
304
|
+
// its own conflictsSnapshot, which is nearly every non-merge() op —
|
|
305
|
+
// undoing an unrelated describe() would silently wipe conflicts from
|
|
306
|
+
// an earlier, unrelated merge(). Fixed as part of issue #29.)
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Re-parent every child of `changeId` onto `newParents` instead — used
|
|
311
|
+
* when a change is being elided from the graph (abandon(), issue #30;
|
|
312
|
+
* converge(), issue #32) so its children don't end up pointing at
|
|
313
|
+
* something no longer meant to be there. Mutates `changeSnapshot` in
|
|
314
|
+
* place, adding each touched child's PRE-reparent record so the caller's
|
|
315
|
+
* own oplog entry lets undo()/redo()/operations.restore() reverse this
|
|
316
|
+
* too (the same mechanism the caller already uses for the elided
|
|
317
|
+
* change's own snapshot).
|
|
318
|
+
*
|
|
319
|
+
* @param {string} changeId - The change being elided
|
|
320
|
+
* @param {string[]} newParents - What its children should point at instead
|
|
321
|
+
* @param {Record<string, any>} changeSnapshot - Accumulator to add each touched child's pre-state to
|
|
322
|
+
* @returns {Promise<void>}
|
|
323
|
+
*/
|
|
324
|
+
const reparentChildrenOnto = async (changeId, newParents, changeSnapshot) => {
|
|
325
|
+
const childIds = graph.getChildren(changeId);
|
|
326
|
+
for (const childId of childIds) {
|
|
327
|
+
const child = await graph.getChange(childId);
|
|
328
|
+
if (!child) continue;
|
|
329
|
+
changeSnapshot[childId] = structuredClone(child);
|
|
330
|
+
const reparented = child.parents.flatMap((/** @type {string} */ p) =>
|
|
331
|
+
p === changeId ? newParents : [p]
|
|
332
|
+
);
|
|
333
|
+
// De-dupe (e.g. the child could already have one of the new parents
|
|
334
|
+
// as another of its own parents) while preserving first-seen order.
|
|
335
|
+
child.parents = [...new Set(reparented)];
|
|
336
|
+
await graph.updateChange(child);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Determine which operation a fresh undo() call should revert, matching
|
|
342
|
+
* real jj's progressive-undo semantics (`jj-undo(1)`: "If used once after
|
|
343
|
+
* a normal operation, this will undo that last operation by restoring its
|
|
344
|
+
* parent. If jj undo is used repeatedly, it will restore increasingly
|
|
345
|
+
* older operations, going further back into the past.").
|
|
346
|
+
*
|
|
347
|
+
* The raw op-log parent chain can't answer this directly once undo/redo
|
|
348
|
+
* bookkeeping operations are mixed into the log: an 'undo' operation's
|
|
349
|
+
* own parent is the operation it just undid, so naively targeting
|
|
350
|
+
* `ops[ops.length - 1]` on a SECOND undo() would undo the undo (toggle
|
|
351
|
+
* back to the future) instead of stepping one further step into the past
|
|
352
|
+
* — this was issue #29's "undo() twice only undoes the undo" bug.
|
|
353
|
+
*
|
|
354
|
+
* @param {Array<any>} ops - Full chronological operation list
|
|
355
|
+
* @returns {any} The operation object undo() should revert
|
|
356
|
+
*/
|
|
357
|
+
const resolveUndoTarget = (ops) => {
|
|
358
|
+
const head = ops[ops.length - 1];
|
|
359
|
+
const findOp = (/** @type {any} */ id) => ops.find((op) => op.id === id);
|
|
360
|
+
|
|
361
|
+
if (head.eventType === 'undo') {
|
|
362
|
+
// We're stepping further into the past: what should be undone now is
|
|
363
|
+
// the operation right BEFORE the one `head` already undid — not
|
|
364
|
+
// head's own parent, which IS the operation head undid (targeting it
|
|
365
|
+
// again would just toggle back to the present).
|
|
366
|
+
const previouslyUndone = head.targetOpId && findOp(head.targetOpId);
|
|
367
|
+
const stepBack = previouslyUndone && findOp(previouslyUndone.parents[0]);
|
|
368
|
+
return stepBack || previouslyUndone || head;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (head.eventType === 'redo') {
|
|
372
|
+
// A redo re-established some earlier operation as current; a fresh
|
|
373
|
+
// undo right after it should behave exactly as if that operation
|
|
374
|
+
// were freshly current and had never been touched by undo/redo at
|
|
375
|
+
// all — i.e. undo it again (real jj: redo "moves in the direction of
|
|
376
|
+
// the future"; undoing right after simply resumes stepping back).
|
|
377
|
+
const redoneUndo = head.redoOf && findOp(head.redoOf);
|
|
378
|
+
const target = redoneUndo && redoneUndo.targetOpId && findOp(redoneUndo.targetOpId);
|
|
379
|
+
return target || head;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return head;
|
|
383
|
+
};
|
|
384
|
+
|
|
133
385
|
/**
|
|
134
386
|
* Automatic working-copy snapshot (v1.6).
|
|
135
387
|
*
|
|
@@ -314,6 +566,25 @@ export async function createJJ(options) {
|
|
|
314
566
|
}
|
|
315
567
|
},
|
|
316
568
|
|
|
569
|
+
// Used by undo()/redo()/operations.restore() to reverse an
|
|
570
|
+
// addChange() when winding the graph back — see issue #29. No Git
|
|
571
|
+
// sync hook: a change created after the operation being reverted was
|
|
572
|
+
// never pushed anywhere to unwind, and the Git backend has no
|
|
573
|
+
// "forget this commit" operation (nor should it — Git objects are
|
|
574
|
+
// content-addressed and harmless to leave orphaned).
|
|
575
|
+
deleteChange: (/** @type {any} */ changeId) => baseGraph.deleteChange(changeId),
|
|
576
|
+
|
|
577
|
+
// See issue #32 / converge(): a divergent copy is another commit
|
|
578
|
+
// claiming an existing changeId; no Git sync hook, for the same
|
|
579
|
+
// reason addChange()'s primary copy already goes through
|
|
580
|
+
// onAddChange() when it's first created — the divergent copy needs
|
|
581
|
+
// no additional sync of its own.
|
|
582
|
+
addDivergentCopy: (/** @type {any} */ change) => baseGraph.addDivergentCopy(change),
|
|
583
|
+
getDivergentSiblings: (/** @type {any} */ changeId) =>
|
|
584
|
+
baseGraph.getDivergentSiblings(changeId),
|
|
585
|
+
deleteDivergentCopy: (/** @type {any} */ changeId, /** @type {any} */ commitId) =>
|
|
586
|
+
baseGraph.deleteDivergentCopy(changeId, commitId),
|
|
587
|
+
|
|
317
588
|
// Delegate other operations
|
|
318
589
|
init: () => baseGraph.init(),
|
|
319
590
|
};
|
|
@@ -471,6 +742,20 @@ export async function createJJ(options) {
|
|
|
471
742
|
/**
|
|
472
743
|
* Write a file to the working copy
|
|
473
744
|
*
|
|
745
|
+
* Note (issue #30): this does NOT update the working-copy change's
|
|
746
|
+
* `commitId`/`fileSnapshot` — those stay as they were until the next
|
|
747
|
+
* command that snapshots the working copy (describe(), new(), edit(),
|
|
748
|
+
* status(), or any other call that goes through
|
|
749
|
+
* autoSnapshotWorkingCopy()/snapshot()). This is intentional, not a
|
|
750
|
+
* bug: jj has no staging area, but it also doesn't recompute a git
|
|
751
|
+
* tree/commit hash on every single file write (that would mean
|
|
752
|
+
* hashing on every keystroke of an external editor) — real jj only
|
|
753
|
+
* takes a fresh working-copy snapshot when an actual `jj` command
|
|
754
|
+
* runs. write() is the equivalent of editing a file with your OS's own
|
|
755
|
+
* tools outside of jj entirely; describe()/new()/edit()/status()/etc.
|
|
756
|
+
* are the equivalent of running a jj command, which is what triggers
|
|
757
|
+
* the (auto) snapshot in both real jj and here.
|
|
758
|
+
*
|
|
474
759
|
* @param {Record<string, any>} args - Arguments
|
|
475
760
|
* @returns {Promise<Object>} File information including path, size, and mode
|
|
476
761
|
*/
|
|
@@ -494,7 +779,7 @@ export async function createJJ(options) {
|
|
|
494
779
|
if (pathParts.length > 1) {
|
|
495
780
|
const dirPath = pathParts.slice(0, -1).join('/');
|
|
496
781
|
const fullDirPath = `${dir}/${dirPath}`;
|
|
497
|
-
await fs
|
|
782
|
+
await mkdirp(fs, fullDirPath);
|
|
498
783
|
}
|
|
499
784
|
|
|
500
785
|
// Write the file
|
|
@@ -552,7 +837,7 @@ export async function createJJ(options) {
|
|
|
552
837
|
const fullPath = path.join(dir, args.path);
|
|
553
838
|
try {
|
|
554
839
|
if (encoding === 'utf-8' || encoding === 'utf8') {
|
|
555
|
-
return await fs.promises.readFile(fullPath, '
|
|
840
|
+
return await fs.promises.readFile(fullPath, 'utf8');
|
|
556
841
|
} else {
|
|
557
842
|
return await fs.promises.readFile(fullPath);
|
|
558
843
|
}
|
|
@@ -774,7 +1059,7 @@ export async function createJJ(options) {
|
|
|
774
1059
|
if (pathParts.length > 1) {
|
|
775
1060
|
const dirPath = pathParts.slice(0, -1).join('/');
|
|
776
1061
|
const fullDirPath = path.join(dir, dirPath);
|
|
777
|
-
await fs
|
|
1062
|
+
await mkdirp(fs, fullDirPath);
|
|
778
1063
|
}
|
|
779
1064
|
|
|
780
1065
|
// Create writable stream
|
|
@@ -887,7 +1172,7 @@ export async function createJJ(options) {
|
|
|
887
1172
|
// Ensure destination directory exists
|
|
888
1173
|
const toDir = toPath.substring(0, toPath.lastIndexOf('/'));
|
|
889
1174
|
try {
|
|
890
|
-
await fs
|
|
1175
|
+
await mkdirp(fs, toDir);
|
|
891
1176
|
} catch (error) {
|
|
892
1177
|
throw new JJError(
|
|
893
1178
|
'FILE_SYSTEM_ERROR',
|
|
@@ -1005,6 +1290,51 @@ export async function createJJ(options) {
|
|
|
1005
1290
|
// TODO: Prevent creating cycles in the change graph
|
|
1006
1291
|
// For now, we'll allow it but it should be validated
|
|
1007
1292
|
|
|
1293
|
+
// Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
|
|
1294
|
+
// reverse the reparenting — see issue #29.
|
|
1295
|
+
const changeSnapshotBefore = structuredClone(change);
|
|
1296
|
+
|
|
1297
|
+
// Three-way merge against the OLD base before reparenting (issue #31):
|
|
1298
|
+
// real jj's rebase re-derives the rebased change's content as
|
|
1299
|
+
// (old base, old commit, new base) — if a sibling changed the same
|
|
1300
|
+
// lines differently, that's a genuine conflict, not something
|
|
1301
|
+
// reparenting-and-copying-verbatim can silently paper over. This
|
|
1302
|
+
// reuses the exact same detectConflicts() machinery merge() already
|
|
1303
|
+
// uses (see merge(), a few hundred lines down) rather than
|
|
1304
|
+
// duplicating three-way-merge logic a third time in this codebase.
|
|
1305
|
+
const oldParentId = change.parents[0];
|
|
1306
|
+
const oldParentChange = oldParentId ? await graph.getChange(oldParentId) : null;
|
|
1307
|
+
const baseFiles = new Map(
|
|
1308
|
+
Object.entries((oldParentChange && oldParentChange.fileSnapshot) || {})
|
|
1309
|
+
);
|
|
1310
|
+
const leftFiles = new Map(Object.entries(change.fileSnapshot || {}));
|
|
1311
|
+
const rightFiles = new Map(Object.entries(newParentChange.fileSnapshot || {}));
|
|
1312
|
+
|
|
1313
|
+
await conflicts.load();
|
|
1314
|
+
const conflictsSnapshot = {
|
|
1315
|
+
conflicts: Object.fromEntries(conflicts.conflicts),
|
|
1316
|
+
fileConflicts: Object.fromEntries(conflicts.fileConflicts),
|
|
1317
|
+
};
|
|
1318
|
+
|
|
1319
|
+
const detectedConflicts = oldParentChange
|
|
1320
|
+
? await conflicts.detectConflicts({
|
|
1321
|
+
baseFiles,
|
|
1322
|
+
leftFiles,
|
|
1323
|
+
rightFiles,
|
|
1324
|
+
drivers: {},
|
|
1325
|
+
// Not necessarily the checked-out working copy, so don't let
|
|
1326
|
+
// merge drivers write to disk on its behalf.
|
|
1327
|
+
workingCopyDir: /** @type {any} */ (null),
|
|
1328
|
+
baseChange: oldParentId,
|
|
1329
|
+
leftChange: changeId,
|
|
1330
|
+
rightChange: newParent,
|
|
1331
|
+
})
|
|
1332
|
+
: [];
|
|
1333
|
+
|
|
1334
|
+
for (const conflict of detectedConflicts) {
|
|
1335
|
+
await conflicts.addConflict(conflict);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1008
1338
|
// Update parent
|
|
1009
1339
|
change.parents = [newParent];
|
|
1010
1340
|
await graph.updateChange(change);
|
|
@@ -1015,6 +1345,8 @@ export async function createJJ(options) {
|
|
|
1015
1345
|
user: await getUserOplogInfo(),
|
|
1016
1346
|
description: `move change ${changeId.slice(0, 8)} to ${newParent.slice(0, 8)}`,
|
|
1017
1347
|
parents: [],
|
|
1348
|
+
changeSnapshot: { [changeId]: changeSnapshotBefore },
|
|
1349
|
+
conflictsSnapshot,
|
|
1018
1350
|
view: {
|
|
1019
1351
|
bookmarks: {},
|
|
1020
1352
|
remoteBookmarks: {},
|
|
@@ -1023,7 +1355,7 @@ export async function createJJ(options) {
|
|
|
1023
1355
|
},
|
|
1024
1356
|
});
|
|
1025
1357
|
|
|
1026
|
-
return change;
|
|
1358
|
+
return { ...change, conflicts: detectedConflicts };
|
|
1027
1359
|
},
|
|
1028
1360
|
|
|
1029
1361
|
/**
|
|
@@ -1155,6 +1487,11 @@ export async function createJJ(options) {
|
|
|
1155
1487
|
const isWorkingCopy = targetChangeId === workingCopy.getCurrentChangeId();
|
|
1156
1488
|
const fileSnapshotBefore = isWorkingCopy ? await snapshotFilesystem() : null;
|
|
1157
1489
|
|
|
1490
|
+
// Snapshot the change record itself from BEFORE this operation mutates
|
|
1491
|
+
// it in place, so undo() can restore ChangeGraph content (not just
|
|
1492
|
+
// on-disk files/working-copy pointer) — mirrors fileSnapshotBefore above.
|
|
1493
|
+
const changeSnapshotBefore = structuredClone(change);
|
|
1494
|
+
|
|
1158
1495
|
// Dispatch change:updating event (preventable)
|
|
1159
1496
|
await dispatchEventAsync(jj, 'change:updating', {
|
|
1160
1497
|
operation: 'describe',
|
|
@@ -1203,7 +1540,7 @@ export async function createJJ(options) {
|
|
|
1203
1540
|
break;
|
|
1204
1541
|
}
|
|
1205
1542
|
|
|
1206
|
-
const content = await fs.promises.readFile(fullPath, '
|
|
1543
|
+
const content = await fs.promises.readFile(fullPath, 'utf8');
|
|
1207
1544
|
fileSnapshot[filePath] = content;
|
|
1208
1545
|
totalSnapshotSize += stats.size;
|
|
1209
1546
|
} catch (error) {
|
|
@@ -1235,6 +1572,10 @@ export async function createJJ(options) {
|
|
|
1235
1572
|
},
|
|
1236
1573
|
description: `describe change ${targetChangeId.slice(0, 8)}`,
|
|
1237
1574
|
parents: [],
|
|
1575
|
+
// Snapshot of the affected ChangeGraph node from BEFORE this
|
|
1576
|
+
// operation ran, so undo() can restore in-place content mutations
|
|
1577
|
+
// (e.g. the description change below) — not just files/pointers.
|
|
1578
|
+
changeSnapshot: { [targetChangeId]: changeSnapshotBefore },
|
|
1238
1579
|
view: {
|
|
1239
1580
|
bookmarks: {},
|
|
1240
1581
|
remoteBookmarks: {},
|
|
@@ -1397,6 +1738,8 @@ export async function createJJ(options) {
|
|
|
1397
1738
|
|
|
1398
1739
|
// Snapshot filesystem BEFORE operation (for undo) unless preserveSnapshot is true
|
|
1399
1740
|
let fileSnapshot;
|
|
1741
|
+
/** @type {Record<string, any>} */
|
|
1742
|
+
const changeSnapshot = {};
|
|
1400
1743
|
if (!args.preserveSnapshot) {
|
|
1401
1744
|
fileSnapshot = await snapshotFilesystem();
|
|
1402
1745
|
|
|
@@ -1405,6 +1748,10 @@ export async function createJJ(options) {
|
|
|
1405
1748
|
const currentChangeId = workingCopy.getCurrentChangeId();
|
|
1406
1749
|
const currentChange = await graph.getChange(currentChangeId);
|
|
1407
1750
|
if (currentChange && fileSnapshot) {
|
|
1751
|
+
// Snapshot BEFORE mutating so undo()/redo() can revert this
|
|
1752
|
+
// in-place update too, not just the pointer/new-change creation
|
|
1753
|
+
// below — see issue #29.
|
|
1754
|
+
changeSnapshot[currentChangeId] = structuredClone(currentChange);
|
|
1408
1755
|
currentChange.fileSnapshot = fileSnapshot;
|
|
1409
1756
|
await graph.updateChange(currentChange);
|
|
1410
1757
|
}
|
|
@@ -1432,6 +1779,7 @@ export async function createJJ(options) {
|
|
|
1432
1779
|
const user = /** @type {any} */ (userConfig.getUser());
|
|
1433
1780
|
|
|
1434
1781
|
// Initialize with parent's file snapshot
|
|
1782
|
+
/** @type {Record<string, string>} */
|
|
1435
1783
|
let initialSnapshot = {};
|
|
1436
1784
|
if (parents && parents.length > 0) {
|
|
1437
1785
|
const parent = await graph.getChange(parents[0]);
|
|
@@ -1475,10 +1823,19 @@ export async function createJJ(options) {
|
|
|
1475
1823
|
// Handle insertBefore: rebase target to have new change as parent
|
|
1476
1824
|
if (args.insertBefore) {
|
|
1477
1825
|
const targetChange = await graph.getChange(args.insertBefore);
|
|
1826
|
+
changeSnapshot[args.insertBefore] = structuredClone(targetChange);
|
|
1478
1827
|
targetChange.parents = [newChangeId];
|
|
1479
1828
|
await graph.updateChange(targetChange);
|
|
1480
1829
|
}
|
|
1481
1830
|
|
|
1831
|
+
// Sync the working directory to the new change's snapshot: write
|
|
1832
|
+
// everything it has AND remove anything it doesn't (issue #30 — this
|
|
1833
|
+
// used to never touch disk at all, so `new({ parents: [...] })`
|
|
1834
|
+
// targeting anything other than the current head silently left the
|
|
1835
|
+
// OLD working copy's files in place instead of checking out the new
|
|
1836
|
+
// parent's).
|
|
1837
|
+
await syncWorkingCopyFiles(initialSnapshot);
|
|
1838
|
+
|
|
1482
1839
|
await workingCopy.setCurrentChange(newChangeId);
|
|
1483
1840
|
|
|
1484
1841
|
// Record operation with filesystem snapshot
|
|
@@ -1491,6 +1848,13 @@ export async function createJJ(options) {
|
|
|
1491
1848
|
},
|
|
1492
1849
|
description: `new change ${newChangeId.slice(0, 8)}`,
|
|
1493
1850
|
parents: [],
|
|
1851
|
+
// See issue #29: lets undo()/redo()/operations.restore() reverse
|
|
1852
|
+
// the existing records this op updated in place (the old
|
|
1853
|
+
// working-copy change's refreshed fileSnapshot, and insertBefore's
|
|
1854
|
+
// target's reparented parents). The newly created change itself
|
|
1855
|
+
// (newChangeId) is intentionally NOT torn down on undo — see
|
|
1856
|
+
// computeGraphReversal's doc comment.
|
|
1857
|
+
changeSnapshot,
|
|
1494
1858
|
view: {
|
|
1495
1859
|
bookmarks: {},
|
|
1496
1860
|
remoteBookmarks: {},
|
|
@@ -1747,11 +2111,28 @@ export async function createJJ(options) {
|
|
|
1747
2111
|
// Try to resolve as revset
|
|
1748
2112
|
try {
|
|
1749
2113
|
const changes = await revset.evaluate(args.change);
|
|
2114
|
+
if (changes.length > 1) {
|
|
2115
|
+
// An id-prefix (or other) revset that matches more than one
|
|
2116
|
+
// change must not silently pick a "winner" — see issue #13.
|
|
2117
|
+
throw new JJError(
|
|
2118
|
+
'AMBIGUOUS_ID',
|
|
2119
|
+
`"${args.change}" is ambiguous: it resolves to ${changes.length} changes (${changes.join(', ')})`,
|
|
2120
|
+
{
|
|
2121
|
+
expression: args.change,
|
|
2122
|
+
matches: changes,
|
|
2123
|
+
suggestion: 'Use a longer prefix or an exact change ID to disambiguate',
|
|
2124
|
+
}
|
|
2125
|
+
);
|
|
2126
|
+
}
|
|
1750
2127
|
if (changes.length > 0) {
|
|
1751
2128
|
changeId = changes[0];
|
|
1752
2129
|
}
|
|
1753
|
-
} catch {
|
|
1754
|
-
|
|
2130
|
+
} catch (error) {
|
|
2131
|
+
if (error instanceof JJError && error.code === 'AMBIGUOUS_ID') {
|
|
2132
|
+
throw error;
|
|
2133
|
+
}
|
|
2134
|
+
// Not a valid revset expression - use args.change as-is (e.g. a
|
|
2135
|
+
// literal change ID that revset parsing rejected).
|
|
1755
2136
|
}
|
|
1756
2137
|
}
|
|
1757
2138
|
|
|
@@ -1854,6 +2235,13 @@ export async function createJJ(options) {
|
|
|
1854
2235
|
|
|
1855
2236
|
const previousChangeId = workingCopy.getCurrentChangeId();
|
|
1856
2237
|
|
|
2238
|
+
/** @type {Record<string, any>} */
|
|
2239
|
+
const changeSnapshot = {};
|
|
2240
|
+
|
|
2241
|
+
// Capture disk state right before we touch anything, for undo() —
|
|
2242
|
+
// see issue #29.
|
|
2243
|
+
const fileSnapshotBeforeSwitch = await snapshotFilesystem();
|
|
2244
|
+
|
|
1857
2245
|
// CRITICAL FIX: Clean working directory BEFORE snapshotting to prevent cross-branch pollution
|
|
1858
2246
|
// Remove files that don't belong to the current changeId before we snapshot it
|
|
1859
2247
|
if (previousChangeId !== args.changeId) {
|
|
@@ -1875,13 +2263,14 @@ export async function createJJ(options) {
|
|
|
1875
2263
|
}
|
|
1876
2264
|
}
|
|
1877
2265
|
}
|
|
1878
|
-
}
|
|
1879
2266
|
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
2267
|
+
// Now snapshot the CLEAN working directory (no pollution!)
|
|
2268
|
+
const currentSnapshot = await snapshotFilesystem();
|
|
2269
|
+
if (currentSnapshot && Object.keys(currentSnapshot).length > 0) {
|
|
2270
|
+
changeSnapshot[previousChangeId] = structuredClone(previousChange);
|
|
2271
|
+
previousChange.fileSnapshot = currentSnapshot;
|
|
2272
|
+
await graph.updateChange(previousChange);
|
|
2273
|
+
}
|
|
1885
2274
|
}
|
|
1886
2275
|
}
|
|
1887
2276
|
|
|
@@ -1894,38 +2283,17 @@ export async function createJJ(options) {
|
|
|
1894
2283
|
timestamp: new Date().toISOString(),
|
|
1895
2284
|
});
|
|
1896
2285
|
|
|
1897
|
-
//
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
await fs.promises.mkdir(fullDirPath, { recursive: true });
|
|
1909
|
-
}
|
|
1910
|
-
|
|
1911
|
-
// Write file content
|
|
1912
|
-
await fs.promises.writeFile(fullPath, content, 'utf8');
|
|
1913
|
-
|
|
1914
|
-
// Update working copy tracking
|
|
1915
|
-
const stats = await fs.promises.stat(fullPath);
|
|
1916
|
-
await workingCopy.trackFile(filePath, {
|
|
1917
|
-
mtime: stats.mtime,
|
|
1918
|
-
size: stats.size,
|
|
1919
|
-
mode: stats.mode,
|
|
1920
|
-
});
|
|
1921
|
-
} catch (error) {
|
|
1922
|
-
throw new JJError(
|
|
1923
|
-
'FILE_RESTORE_FAILED',
|
|
1924
|
-
`Failed to restore file ${filePath}: ${error.message}`,
|
|
1925
|
-
{ filePath, originalError: error.message }
|
|
1926
|
-
);
|
|
1927
|
-
}
|
|
1928
|
-
}
|
|
2286
|
+
// Fully sync the working directory to the target change's snapshot:
|
|
2287
|
+
// write everything it has AND remove anything it doesn't (issue #30
|
|
2288
|
+
// — this used to only overlay the target's files on top of whatever
|
|
2289
|
+
// was already on disk, so a file present in the PREVIOUS change but
|
|
2290
|
+
// absent from the target leaked into the next snapshot).
|
|
2291
|
+
try {
|
|
2292
|
+
await syncWorkingCopyFiles(change.fileSnapshot || {});
|
|
2293
|
+
} catch (error) {
|
|
2294
|
+
throw new JJError('FILE_RESTORE_FAILED', `Failed to restore files: ${error.message}`, {
|
|
2295
|
+
originalError: error.message,
|
|
2296
|
+
});
|
|
1929
2297
|
}
|
|
1930
2298
|
|
|
1931
2299
|
// Set this change as the working copy
|
|
@@ -1941,11 +2309,13 @@ export async function createJJ(options) {
|
|
|
1941
2309
|
},
|
|
1942
2310
|
description: `edit change ${args.changeId.slice(0, 8)}`,
|
|
1943
2311
|
parents: [],
|
|
2312
|
+
changeSnapshot,
|
|
1944
2313
|
view: {
|
|
1945
2314
|
bookmarks: {},
|
|
1946
2315
|
remoteBookmarks: {},
|
|
1947
2316
|
heads: [args.changeId],
|
|
1948
2317
|
workingCopy: args.changeId,
|
|
2318
|
+
fileSnapshot: fileSnapshotBeforeSwitch,
|
|
1949
2319
|
},
|
|
1950
2320
|
});
|
|
1951
2321
|
|
|
@@ -1976,91 +2346,107 @@ export async function createJJ(options) {
|
|
|
1976
2346
|
/**
|
|
1977
2347
|
* Undo last operation
|
|
1978
2348
|
*
|
|
2349
|
+
* Reverts the most recent operation — including graph-rewriting ones
|
|
2350
|
+
* (squash/rebase/abandon/new/edit), not just working-copy/file state
|
|
2351
|
+
* (see issue #29). Matches real jj's progressive-undo semantics
|
|
2352
|
+
* (`jj-undo(1)`): called again with nothing else in between, it steps
|
|
2353
|
+
* one further operation into the past rather than undoing itself.
|
|
2354
|
+
*
|
|
1979
2355
|
* @returns {Promise<Object>} Information about the undo including the undone operation and restored state
|
|
1980
2356
|
*/
|
|
1981
2357
|
async undo() {
|
|
1982
|
-
// Get the operation we're about to undo to access its pre-state
|
|
1983
2358
|
await oplog.load();
|
|
1984
2359
|
const ops = await oplog.list();
|
|
1985
2360
|
if (ops.length === 0) {
|
|
1986
2361
|
throw new JJError('NOTHING_TO_UNDO', 'No operations to undo');
|
|
1987
2362
|
}
|
|
1988
2363
|
|
|
1989
|
-
|
|
1990
|
-
const
|
|
1991
|
-
|
|
1992
|
-
const previousView = /** @type {any} */ (await oplog.undo());
|
|
1993
|
-
|
|
1994
|
-
// Restore state from previous view
|
|
1995
|
-
await workingCopy.setCurrentChange(previousView.workingCopy);
|
|
2364
|
+
const targetOp = resolveUndoTarget(ops);
|
|
2365
|
+
const landingOpId = targetOp.parents && targetOp.parents[0];
|
|
2366
|
+
const landingOp = landingOpId ? ops.find((op) => op.id === landingOpId) : null;
|
|
1996
2367
|
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2368
|
+
/** @type {ReturnType<typeof computeGraphReversal>} */
|
|
2369
|
+
let reversal;
|
|
2370
|
+
/** @type {any} */
|
|
2371
|
+
let landingView;
|
|
2372
|
+
if (landingOp) {
|
|
2373
|
+
reversal = computeGraphReversal(ops, landingOp);
|
|
2374
|
+
landingView = landingOp.view || {};
|
|
2375
|
+
} else {
|
|
2376
|
+
// Undoing the very first (root) operation: there's nothing before
|
|
2377
|
+
// it to restore to, so land on its own recorded state rather than
|
|
2378
|
+
// throwing (mirrors the previous no-parent fallback).
|
|
2379
|
+
reversal = {
|
|
2380
|
+
changeSnapshotToApply: {},
|
|
2381
|
+
fileSnapshot: undefined,
|
|
2382
|
+
conflictsSnapshot: undefined,
|
|
2383
|
+
};
|
|
2384
|
+
landingView = targetOp.view || {};
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
// Capture EXACTLY what's about to be overwritten, from ground truth,
|
|
2388
|
+
// before applying the reversal — this is what redo() re-applies
|
|
2389
|
+
// later. It can't just recompute "the state after targetOp" the way
|
|
2390
|
+
// undo() itself computes "the state after landingOp" (by scanning
|
|
2391
|
+
// for a LATER op that also touched the same changeId/files/
|
|
2392
|
+
// conflicts): that scan only works going backward, where "nothing
|
|
2393
|
+
// touched it since" means "it's already correct". Going forward,
|
|
2394
|
+
// "nothing touched it since targetOp" means the opposite — its
|
|
2395
|
+
// effect is still live and needs to be captured now, not rederived.
|
|
2396
|
+
await graph.load();
|
|
2397
|
+
/** @type {Record<string, any>} */
|
|
2398
|
+
const redoChangeSnapshot = {};
|
|
2399
|
+
for (const changeId of Object.keys(reversal.changeSnapshotToApply)) {
|
|
2400
|
+
const current = await graph.getChange(changeId);
|
|
2401
|
+
if (current) redoChangeSnapshot[changeId] = current;
|
|
2402
|
+
}
|
|
2403
|
+
const redoFileSnapshot =
|
|
2404
|
+
reversal.fileSnapshot !== undefined ? await snapshotFilesystem() : undefined;
|
|
2405
|
+
let redoConflictsSnapshot;
|
|
2406
|
+
if (reversal.conflictsSnapshot !== undefined) {
|
|
2407
|
+
await conflicts.load();
|
|
2408
|
+
redoConflictsSnapshot = {
|
|
2409
|
+
conflicts: Object.fromEntries(conflicts.conflicts),
|
|
2410
|
+
fileConflicts: Object.fromEntries(conflicts.fileConflicts),
|
|
2411
|
+
};
|
|
2022
2412
|
}
|
|
2023
2413
|
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
await conflicts.load();
|
|
2027
|
-
if (currentOp && currentOp.conflictsSnapshot) {
|
|
2028
|
-
// Restore conflicts from the operation we're undoing (its pre-state)
|
|
2029
|
-
conflicts.conflicts = new Map(Object.entries(currentOp.conflictsSnapshot.conflicts || {}));
|
|
2030
|
-
conflicts.fileConflicts = new Map(
|
|
2031
|
-
Object.entries(currentOp.conflictsSnapshot.fileConflicts || {})
|
|
2032
|
-
);
|
|
2033
|
-
await conflicts.save();
|
|
2034
|
-
} else {
|
|
2035
|
-
// No conflicts snapshot - clear conflicts
|
|
2036
|
-
await conflicts.clear();
|
|
2414
|
+
if (landingView.workingCopy) {
|
|
2415
|
+
await workingCopy.setCurrentChange(landingView.workingCopy);
|
|
2037
2416
|
}
|
|
2417
|
+
await applyGraphReversal(reversal);
|
|
2038
2418
|
|
|
2039
|
-
// Record undo
|
|
2040
|
-
//
|
|
2419
|
+
// Record undo as its own operation (matching real jj) — tagged with
|
|
2420
|
+
// which operation it targeted so a following undo() can walk one
|
|
2421
|
+
// step further back (resolveUndoTarget) instead of toggling, and
|
|
2422
|
+
// carrying exactly what redo() needs to re-apply (captured above).
|
|
2041
2423
|
await oplog.recordOperation({
|
|
2042
2424
|
timestamp: new Date().toISOString(),
|
|
2043
2425
|
user: await getUserOplogInfo(),
|
|
2044
2426
|
description: 'undo operation',
|
|
2045
2427
|
eventType: 'undo',
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2428
|
+
targetOpId: targetOp.id,
|
|
2429
|
+
undoneOpId: targetOp.id, // kept for API/type compatibility
|
|
2430
|
+
redoChangeSnapshot,
|
|
2431
|
+
redoFileSnapshot,
|
|
2432
|
+
redoConflictsSnapshot,
|
|
2049
2433
|
parents: [],
|
|
2050
|
-
view:
|
|
2434
|
+
view: landingView,
|
|
2051
2435
|
});
|
|
2052
2436
|
|
|
2053
|
-
|
|
2437
|
+
const restoredFileCount =
|
|
2438
|
+
reversal.fileSnapshot !== undefined ? Object.keys(reversal.fileSnapshot).length : 0;
|
|
2439
|
+
|
|
2054
2440
|
return {
|
|
2055
2441
|
undoneOperation: {
|
|
2056
|
-
description:
|
|
2057
|
-
timestamp:
|
|
2058
|
-
user:
|
|
2442
|
+
description: targetOp.description,
|
|
2443
|
+
timestamp: targetOp.timestamp,
|
|
2444
|
+
user: targetOp.user,
|
|
2059
2445
|
},
|
|
2060
2446
|
restoredState: {
|
|
2061
|
-
workingCopy:
|
|
2062
|
-
heads:
|
|
2063
|
-
fileCount:
|
|
2447
|
+
workingCopy: landingView.workingCopy,
|
|
2448
|
+
heads: landingView.heads,
|
|
2449
|
+
fileCount: restoredFileCount,
|
|
2064
2450
|
},
|
|
2065
2451
|
};
|
|
2066
2452
|
},
|
|
@@ -2068,9 +2454,9 @@ export async function createJJ(options) {
|
|
|
2068
2454
|
/**
|
|
2069
2455
|
* Redo an operation previously undone with undo() (v1.5)
|
|
2070
2456
|
*
|
|
2071
|
-
* Mirrors `jj redo` (jj v0.33):
|
|
2072
|
-
*
|
|
2073
|
-
*
|
|
2457
|
+
* Mirrors `jj redo` (jj v0.33): re-applies the operation that the most
|
|
2458
|
+
* recent not-yet-redone undo() reverted — including its graph rewrites,
|
|
2459
|
+
* not just working-copy/file state (see issue #29).
|
|
2074
2460
|
*
|
|
2075
2461
|
* @returns {Promise<Object>} Information about the redone operation
|
|
2076
2462
|
*/
|
|
@@ -2101,42 +2487,36 @@ export async function createJJ(options) {
|
|
|
2101
2487
|
});
|
|
2102
2488
|
}
|
|
2103
2489
|
|
|
2104
|
-
const
|
|
2490
|
+
const targetOpId = undoOp.targetOpId || undoOp.undoneOpId;
|
|
2491
|
+
const targetOp = targetOpId && ops.find((op) => op.id === targetOpId);
|
|
2492
|
+
const redoneView = (targetOp && targetOp.view) || {};
|
|
2105
2493
|
|
|
2106
|
-
// Restore the working-copy pointer.
|
|
2107
2494
|
if (redoneView.workingCopy) {
|
|
2108
2495
|
await workingCopy.setCurrentChange(redoneView.workingCopy);
|
|
2109
2496
|
}
|
|
2110
2497
|
|
|
2111
|
-
//
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
throw new JJError(
|
|
2124
|
-
'REDO_FILE_RESTORE_FAILED',
|
|
2125
|
-
`Failed to restore file ${filePath} during redo: ${error.message}`,
|
|
2126
|
-
{ filePath, originalError: error.message }
|
|
2127
|
-
);
|
|
2128
|
-
}
|
|
2498
|
+
// Re-apply exactly what this undo captured right before it reverted
|
|
2499
|
+
// things — NOT computeGraphReversal(ops, targetOp): that scans for a
|
|
2500
|
+
// LATER op touching the same changeId/files/conflicts, which is the
|
|
2501
|
+
// right question for undo() ("has anything since made my target
|
|
2502
|
+
// stale?") but the wrong one here — after an undo, NOTHING later
|
|
2503
|
+
// re-established targetOp's own effect, so there's nothing to find
|
|
2504
|
+
// by scanning forward. undo() captured it directly for exactly this
|
|
2505
|
+
// reason (see its own comment).
|
|
2506
|
+
await graph.load();
|
|
2507
|
+
for (const [changeId, snapshot] of Object.entries(undoOp.redoChangeSnapshot || {})) {
|
|
2508
|
+
if (await graph.getChange(changeId)) {
|
|
2509
|
+
await graph.updateChange(snapshot);
|
|
2129
2510
|
}
|
|
2130
2511
|
}
|
|
2131
|
-
|
|
2132
|
-
|
|
2512
|
+
if (undoOp.redoFileSnapshot !== undefined) {
|
|
2513
|
+
await syncWorkingCopyFiles(undoOp.redoFileSnapshot);
|
|
2514
|
+
}
|
|
2133
2515
|
await conflicts.load();
|
|
2134
|
-
if (undoOp.
|
|
2135
|
-
conflicts.conflicts = new Map(
|
|
2136
|
-
Object.entries(undoOp.undoneConflictsSnapshot.conflicts || {})
|
|
2137
|
-
);
|
|
2516
|
+
if (undoOp.redoConflictsSnapshot !== undefined) {
|
|
2517
|
+
conflicts.conflicts = new Map(Object.entries(undoOp.redoConflictsSnapshot.conflicts || {}));
|
|
2138
2518
|
conflicts.fileConflicts = new Map(
|
|
2139
|
-
Object.entries(undoOp.
|
|
2519
|
+
Object.entries(undoOp.redoConflictsSnapshot.fileConflicts || {})
|
|
2140
2520
|
);
|
|
2141
2521
|
await conflicts.save();
|
|
2142
2522
|
}
|
|
@@ -2151,15 +2531,18 @@ export async function createJJ(options) {
|
|
|
2151
2531
|
view: redoneView,
|
|
2152
2532
|
});
|
|
2153
2533
|
|
|
2534
|
+
const restoredFileCount =
|
|
2535
|
+
undoOp.redoFileSnapshot !== undefined ? Object.keys(undoOp.redoFileSnapshot).length : 0;
|
|
2536
|
+
|
|
2154
2537
|
return {
|
|
2155
2538
|
redoneOperation: {
|
|
2156
2539
|
description: undoOp.description,
|
|
2157
|
-
undoneOpId:
|
|
2540
|
+
undoneOpId: targetOpId,
|
|
2158
2541
|
},
|
|
2159
2542
|
restoredState: {
|
|
2160
2543
|
workingCopy: redoneView.workingCopy,
|
|
2161
2544
|
heads: redoneView.heads,
|
|
2162
|
-
fileCount:
|
|
2545
|
+
fileCount: restoredFileCount,
|
|
2163
2546
|
},
|
|
2164
2547
|
};
|
|
2165
2548
|
},
|
|
@@ -2386,6 +2769,12 @@ export async function createJJ(options) {
|
|
|
2386
2769
|
/**
|
|
2387
2770
|
* Restore repository to a specific operation (matches `jj operation restore`)
|
|
2388
2771
|
*
|
|
2772
|
+
* Fully reverts everything recorded after the target operation —
|
|
2773
|
+
* including graph rewrites (squash/rebase/abandon/new/edit), working
|
|
2774
|
+
* copy files, and conflict state, not just bookmarks/the working-copy
|
|
2775
|
+
* pointer (see issue #29) — using the same reversal primitive as
|
|
2776
|
+
* undo()/redo().
|
|
2777
|
+
*
|
|
2389
2778
|
* @param {Record<string, any>} args - Arguments
|
|
2390
2779
|
* @returns {Promise<Object>} Restore result
|
|
2391
2780
|
*/
|
|
@@ -2425,6 +2814,11 @@ export async function createJJ(options) {
|
|
|
2425
2814
|
await workingCopy.setCurrentChange(targetOp.view.workingCopy);
|
|
2426
2815
|
}
|
|
2427
2816
|
|
|
2817
|
+
// Revert the change graph, working-copy files, and conflict state
|
|
2818
|
+
// to exactly what they were right after targetOp ran.
|
|
2819
|
+
const reversal = computeGraphReversal(ops, targetOp);
|
|
2820
|
+
await applyGraphReversal(reversal);
|
|
2821
|
+
|
|
2428
2822
|
// Record this restoration as a new operation
|
|
2429
2823
|
await oplog.recordOperation({
|
|
2430
2824
|
timestamp: new Date().toISOString(),
|
|
@@ -2478,6 +2872,7 @@ export async function createJJ(options) {
|
|
|
2478
2872
|
// Compute the inverse changes
|
|
2479
2873
|
const inversChanges = /** @type {Record<string, any>} */ ({
|
|
2480
2874
|
bookmarks: {},
|
|
2875
|
+
tags: {},
|
|
2481
2876
|
heads: [],
|
|
2482
2877
|
});
|
|
2483
2878
|
|
|
@@ -2517,6 +2912,46 @@ export async function createJJ(options) {
|
|
|
2517
2912
|
|
|
2518
2913
|
await bookmarks.save();
|
|
2519
2914
|
|
|
2915
|
+
// Revert tag changes (mirrors the bookmark diff above — issue #12).
|
|
2916
|
+
// Unlike bookmarks, TagStore has no "move" primitive (tags are meant
|
|
2917
|
+
// to be immutable once created), so a moved tag is reverted via
|
|
2918
|
+
// delete+recreate, matching how tag.set() itself updates a tag.
|
|
2919
|
+
const prevTags = previousOp.view?.tags || {};
|
|
2920
|
+
const targetTags = targetOp.view?.tags || {};
|
|
2921
|
+
|
|
2922
|
+
for (const name of Object.keys(targetTags)) {
|
|
2923
|
+
if (!prevTags[name]) {
|
|
2924
|
+
// Tag was added - delete it
|
|
2925
|
+
if (await tags.exists(name)) {
|
|
2926
|
+
await tags.delete(name);
|
|
2927
|
+
}
|
|
2928
|
+
inversChanges.tags[name] = { action: 'deleted' };
|
|
2929
|
+
} else if (prevTags[name] !== targetTags[name]) {
|
|
2930
|
+
// Tag was moved - restore old target
|
|
2931
|
+
if (await tags.exists(name)) {
|
|
2932
|
+
await tags.delete(name);
|
|
2933
|
+
}
|
|
2934
|
+
await tags.create(name, prevTags[name]);
|
|
2935
|
+
inversChanges.tags[name] = {
|
|
2936
|
+
action: 'moved',
|
|
2937
|
+
from: targetTags[name],
|
|
2938
|
+
to: prevTags[name],
|
|
2939
|
+
};
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
// Check for deleted tags (existed before, don't exist after)
|
|
2944
|
+
for (const name of Object.keys(prevTags)) {
|
|
2945
|
+
if (!targetTags[name] && !(await tags.exists(name))) {
|
|
2946
|
+
// Tag was deleted - restore it
|
|
2947
|
+
await tags.create(name, prevTags[name]);
|
|
2948
|
+
inversChanges.tags[name] = {
|
|
2949
|
+
action: 'restored',
|
|
2950
|
+
to: prevTags[name],
|
|
2951
|
+
};
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2520
2955
|
// Revert working copy change if it changed
|
|
2521
2956
|
if (previousOp.view?.workingCopy !== targetOp.view?.workingCopy) {
|
|
2522
2957
|
await workingCopy.load();
|
|
@@ -2531,6 +2966,7 @@ export async function createJJ(options) {
|
|
|
2531
2966
|
parents: [],
|
|
2532
2967
|
view: {
|
|
2533
2968
|
bookmarks: prevBookmarks,
|
|
2969
|
+
tags: prevTags,
|
|
2534
2970
|
remoteBookmarks: previousOp.view?.remoteBookmarks || {},
|
|
2535
2971
|
heads: previousOp.view?.heads || [],
|
|
2536
2972
|
workingCopy: previousOp.view?.workingCopy,
|
|
@@ -2674,10 +3110,53 @@ export async function createJJ(options) {
|
|
|
2674
3110
|
timestamp: new Date().toISOString(),
|
|
2675
3111
|
});
|
|
2676
3112
|
|
|
3113
|
+
// Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
|
|
3114
|
+
// reverse the squash (un-abandon source, restore dest's description
|
|
3115
|
+
// and content) — see issue #29.
|
|
3116
|
+
/** @type {Record<string, any>} */
|
|
3117
|
+
const changeSnapshot = {
|
|
3118
|
+
[source]: structuredClone(sourceChange),
|
|
3119
|
+
[destChangeId]: structuredClone(destChange),
|
|
3120
|
+
};
|
|
3121
|
+
|
|
2677
3122
|
// Mark source as abandoned
|
|
2678
3123
|
sourceChange.abandoned = true;
|
|
2679
3124
|
await graph.updateChange(sourceChange);
|
|
2680
3125
|
|
|
3126
|
+
// Merge source's file content into dest (issue #30: squash() used to
|
|
3127
|
+
// only combine descriptions, leaving dest's tree untouched). Compute
|
|
3128
|
+
// what source itself actually changed relative to ITS OWN parent
|
|
3129
|
+
// (its "diff") and fold just that onto dest, rather than overwriting
|
|
3130
|
+
// dest's whole snapshot with source's — a path source never touched
|
|
3131
|
+
// (inherited unchanged from that parent) must not clobber a
|
|
3132
|
+
// dest-specific version of the same path.
|
|
3133
|
+
const sourceParentId = sourceChange.parents && sourceChange.parents[0];
|
|
3134
|
+
const sourceParentChange = sourceParentId ? await graph.getChange(sourceParentId) : null;
|
|
3135
|
+
const sourceBaseSnapshot = (sourceParentChange && sourceParentChange.fileSnapshot) || {};
|
|
3136
|
+
const sourceSnapshot = sourceChange.fileSnapshot || {};
|
|
3137
|
+
const mergedSnapshot = { ...(destChange.fileSnapshot || {}) };
|
|
3138
|
+
const touchedPaths = new Set([
|
|
3139
|
+
...Object.keys(sourceBaseSnapshot),
|
|
3140
|
+
...Object.keys(sourceSnapshot),
|
|
3141
|
+
]);
|
|
3142
|
+
for (const filePath of touchedPaths) {
|
|
3143
|
+
const baseValue = sourceBaseSnapshot[filePath];
|
|
3144
|
+
const sourceValue = sourceSnapshot[filePath];
|
|
3145
|
+
if (sourceValue === baseValue) {
|
|
3146
|
+
// source never touched this path (inherited as-is from its own
|
|
3147
|
+
// parent) — leave whatever dest already had for it alone.
|
|
3148
|
+
continue;
|
|
3149
|
+
}
|
|
3150
|
+
if (sourceValue === undefined) {
|
|
3151
|
+
// source deleted a file it inherited from its parent.
|
|
3152
|
+
delete mergedSnapshot[filePath];
|
|
3153
|
+
} else {
|
|
3154
|
+
// source added or modified this path.
|
|
3155
|
+
mergedSnapshot[filePath] = sourceValue;
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
destChange.fileSnapshot = mergedSnapshot;
|
|
3159
|
+
|
|
2681
3160
|
// Update description of dest to indicate squash (middleware will sync to Git)
|
|
2682
3161
|
destChange.description += `\n\n(squashed from ${source.slice(0, 8)})`;
|
|
2683
3162
|
await graph.updateChange(destChange);
|
|
@@ -2719,6 +3198,7 @@ export async function createJJ(options) {
|
|
|
2719
3198
|
user: await getUserOplogInfo(),
|
|
2720
3199
|
description: `squash ${source.slice(0, 8)} into ${destChangeId.slice(0, 8)}`,
|
|
2721
3200
|
parents: [],
|
|
3201
|
+
changeSnapshot,
|
|
2722
3202
|
view: {
|
|
2723
3203
|
bookmarks: {},
|
|
2724
3204
|
remoteBookmarks: {},
|
|
@@ -2797,15 +3277,29 @@ export async function createJJ(options) {
|
|
|
2797
3277
|
timestamp: new Date().toISOString(),
|
|
2798
3278
|
});
|
|
2799
3279
|
|
|
3280
|
+
// Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
|
|
3281
|
+
// reverse the abandon (un-abandon this change, un-reparent children)
|
|
3282
|
+
// — see issue #29.
|
|
3283
|
+
/** @type {Record<string, any>} */
|
|
3284
|
+
const changeSnapshot = { [changeId]: structuredClone(change) };
|
|
3285
|
+
|
|
2800
3286
|
change.abandoned = true;
|
|
2801
3287
|
await graph.updateChange(change);
|
|
2802
3288
|
|
|
3289
|
+
// Re-parent children onto the abandoned change's own parent(s)
|
|
3290
|
+
// (issue #30: this used to leave children pointing at the now-
|
|
3291
|
+
// abandoned commit). Matches real `jj abandon`: the abandoned
|
|
3292
|
+
// change is elided from the graph and its children attach directly
|
|
3293
|
+
// to whatever it was attached to.
|
|
3294
|
+
await reparentChildrenOnto(changeId, change.parents, changeSnapshot);
|
|
3295
|
+
|
|
2803
3296
|
// Record operation
|
|
2804
3297
|
await oplog.recordOperation({
|
|
2805
3298
|
timestamp: new Date().toISOString(),
|
|
2806
3299
|
user: { name: user.name, email: user.email, hostname: 'localhost' },
|
|
2807
3300
|
description: `abandon change ${changeId.slice(0, 8)}`,
|
|
2808
3301
|
parents: [],
|
|
3302
|
+
changeSnapshot,
|
|
2809
3303
|
view: {
|
|
2810
3304
|
bookmarks: {},
|
|
2811
3305
|
remoteBookmarks: {},
|
|
@@ -2830,6 +3324,206 @@ export async function createJJ(options) {
|
|
|
2830
3324
|
return change;
|
|
2831
3325
|
},
|
|
2832
3326
|
|
|
3327
|
+
/**
|
|
3328
|
+
* Converge divergent copies of a change (matches `jj converge`, jj
|
|
3329
|
+
* v0.45.0+ — issue #32, extended for N-way per the isomorphic-jj v0.45
|
|
3330
|
+
* catch-up follow-up) — resolves ANY number of divergent copies of one
|
|
3331
|
+
* changeId in a single call. Real jj's own CLI currently only
|
|
3332
|
+
* converges one change-id per invocation (an explicit TODO in its own
|
|
3333
|
+
* source as of v0.45.1: "consider adding logic to deal with more than
|
|
3334
|
+
* one divergent change-id in one invocation"), which is what fixed
|
|
3335
|
+
* this package's own changeId-at-a-time API design — but its
|
|
3336
|
+
* underlying engine (`TruncatedEvolutionGraph`/`find_divergent_changes`)
|
|
3337
|
+
* already resolves every divergent revision of THAT one change-id
|
|
3338
|
+
* together, not just a pair. This generalizes the same per-path rule
|
|
3339
|
+
* accordingly: two-way "does the other side's value differ from base"
|
|
3340
|
+
* becomes N-way "how many DISTINCT values, across every copy, differ
|
|
3341
|
+
* from base" — 0 means nobody touched it, 1 means exactly one copy (or
|
|
3342
|
+
* several copies agreeing on the same new value) changed it and that
|
|
3343
|
+
* value wins, 2+ is a genuine conflict between that many copies.
|
|
3344
|
+
*
|
|
3345
|
+
* A change is divergent when more than one visible commit shares its
|
|
3346
|
+
* change id (see the `divergent()` revset, and
|
|
3347
|
+
* ChangeGraph.addDivergentCopy() for how a divergent copy gets added
|
|
3348
|
+
* in the first place — nothing in this library creates one on its
|
|
3349
|
+
* own yet; this resolves one once something has, e.g. a future git
|
|
3350
|
+
* fetch/concurrent-operation integration).
|
|
3351
|
+
*
|
|
3352
|
+
* Non-interactive-mode convention, matching this package's other
|
|
3353
|
+
* ambiguous-outcome APIs: like merge(), a genuine per-path conflict is
|
|
3354
|
+
* returned as data (`{ resolved: false, conflicts }`), not thrown —
|
|
3355
|
+
* you get a result back either way and decide what to do with
|
|
3356
|
+
* unresolved conflicts. What DOES throw is anything that isn't even a
|
|
3357
|
+
* well-formed convergence attempt (no divergence to resolve at all) —
|
|
3358
|
+
* the same category as merge()'s own `MERGE_ERROR` throw for "no
|
|
3359
|
+
* common ancestor found".
|
|
3360
|
+
*
|
|
3361
|
+
* @param {Record<string, any>|string} args - `{ changeId }`, or a bare changeId string
|
|
3362
|
+
* @returns {Promise<Object>} `{ changeId, resolved, conflicts, commitId? }`
|
|
3363
|
+
*/
|
|
3364
|
+
async converge(args) {
|
|
3365
|
+
const changeId = typeof args === 'string' ? args : args && (args.changeId || args.change);
|
|
3366
|
+
if (!changeId || typeof changeId !== 'string') {
|
|
3367
|
+
throw new JJError('INVALID_ARGUMENT', 'Missing changeId argument', {
|
|
3368
|
+
suggestion: 'Provide the divergent changeId to converge: { changeId: "..." }',
|
|
3369
|
+
});
|
|
3370
|
+
}
|
|
3371
|
+
|
|
3372
|
+
await graph.load();
|
|
3373
|
+
await userConfig.load();
|
|
3374
|
+
const siblings = graph.getDivergentSiblings(changeId);
|
|
3375
|
+
|
|
3376
|
+
if (siblings.length < 2) {
|
|
3377
|
+
throw new JJError(
|
|
3378
|
+
'NOT_DIVERGENT',
|
|
3379
|
+
`Change ${changeId} has no divergent copies to converge`,
|
|
3380
|
+
{
|
|
3381
|
+
changeId,
|
|
3382
|
+
suggestion:
|
|
3383
|
+
'converge() is only meaningful for a changeId the divergent() revset reports',
|
|
3384
|
+
}
|
|
3385
|
+
);
|
|
3386
|
+
}
|
|
3387
|
+
|
|
3388
|
+
// The primary copy is whichever one getChange() resolves (stored
|
|
3389
|
+
// under the plain changeId key); every other one is a divergent copy
|
|
3390
|
+
// added via addDivergentCopy(). No cap on how many — see the doc
|
|
3391
|
+
// comment above.
|
|
3392
|
+
const primary = /** @type {any} */ (await graph.getChange(changeId));
|
|
3393
|
+
const others = siblings.filter((/** @type {any} */ s) => s.commitId !== primary.commitId);
|
|
3394
|
+
|
|
3395
|
+
// Base for the N-way merge: what this change looked like before it
|
|
3396
|
+
// diverged. This package doesn't retain historical snapshots (only
|
|
3397
|
+
// current content), so — same pragmatic choice rebase() makes for
|
|
3398
|
+
// "old base" — use the shared parent's current content; divergent
|
|
3399
|
+
// copies realistically share the same parent, since they're all
|
|
3400
|
+
// rewrites of one prior position in history, not unrelated commits
|
|
3401
|
+
// that happen to collide. Applied uniformly to every copy, not
|
|
3402
|
+
// per-copy — the same simplification the original two-way version
|
|
3403
|
+
// made, just not newly introduced by going N-way.
|
|
3404
|
+
const baseParentId = primary.parents && primary.parents[0];
|
|
3405
|
+
const baseParentChange = baseParentId ? await graph.getChange(baseParentId) : null;
|
|
3406
|
+
const baseFiles = new Map(
|
|
3407
|
+
Object.entries((baseParentChange && baseParentChange.fileSnapshot) || {})
|
|
3408
|
+
);
|
|
3409
|
+
|
|
3410
|
+
// Every copy (primary + others), each as its own file map, paired
|
|
3411
|
+
// with its commitId for conflict reporting.
|
|
3412
|
+
const copies = [primary, ...others].map((/** @type {any} */ c) => ({
|
|
3413
|
+
commitId: c.commitId,
|
|
3414
|
+
files: new Map(Object.entries(c.fileSnapshot || {})),
|
|
3415
|
+
}));
|
|
3416
|
+
|
|
3417
|
+
await conflicts.load();
|
|
3418
|
+
const conflictsSnapshot = {
|
|
3419
|
+
conflicts: Object.fromEntries(conflicts.conflicts),
|
|
3420
|
+
fileConflicts: Object.fromEntries(conflicts.fileConflicts),
|
|
3421
|
+
};
|
|
3422
|
+
|
|
3423
|
+
const allPaths = new Set([
|
|
3424
|
+
...baseFiles.keys(),
|
|
3425
|
+
...copies.flatMap((c) => [...c.files.keys()]),
|
|
3426
|
+
]);
|
|
3427
|
+
|
|
3428
|
+
/** @type {Record<string, string>} */
|
|
3429
|
+
const mergedSnapshot = {};
|
|
3430
|
+
/** @type {any[]} */
|
|
3431
|
+
const detectedConflicts = [];
|
|
3432
|
+
|
|
3433
|
+
for (const filePath of allPaths) {
|
|
3434
|
+
const baseValue = baseFiles.get(filePath);
|
|
3435
|
+
// Every DISTINCT value across all copies that differs from base,
|
|
3436
|
+
// each paired with (one of) the commitId(s) that has it — mirrors
|
|
3437
|
+
// ConflictModel._detectPathConflict's two-way "no conflict" rules,
|
|
3438
|
+
// generalized: 0 distinct changed values means nobody touched it
|
|
3439
|
+
// (base wins, i.e. nothing to write), 1 means only one side (or
|
|
3440
|
+
// several copies agreeing) changed it (that value wins), 2+ is a
|
|
3441
|
+
// genuine conflict.
|
|
3442
|
+
const changed = new Map();
|
|
3443
|
+
for (const copy of copies) {
|
|
3444
|
+
const value = copy.files.get(filePath);
|
|
3445
|
+
if (value !== baseValue && !changed.has(value)) {
|
|
3446
|
+
changed.set(value, copy.commitId);
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3450
|
+
if (changed.size === 0) {
|
|
3451
|
+
if (baseValue !== undefined) mergedSnapshot[filePath] = baseValue;
|
|
3452
|
+
} else if (changed.size === 1) {
|
|
3453
|
+
const [[value]] = changed;
|
|
3454
|
+
if (value !== undefined) mergedSnapshot[filePath] = value;
|
|
3455
|
+
} else {
|
|
3456
|
+
const versions = [...changed.entries()].map(([content, commitId]) => ({
|
|
3457
|
+
commitId,
|
|
3458
|
+
content,
|
|
3459
|
+
}));
|
|
3460
|
+
detectedConflicts.push(conflicts.createNWayConflict(filePath, baseValue, versions));
|
|
3461
|
+
}
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
if (detectedConflicts.length > 0) {
|
|
3465
|
+
// Matches real jj: non-interactive mode aborts automatic
|
|
3466
|
+
// resolution rather than guessing — but per this package's own
|
|
3467
|
+
// merge() convention, that's reported back as data, not thrown.
|
|
3468
|
+
for (const conflict of detectedConflicts) {
|
|
3469
|
+
await conflicts.addConflict(conflict);
|
|
3470
|
+
}
|
|
3471
|
+
await oplog.recordOperation({
|
|
3472
|
+
timestamp: new Date().toISOString(),
|
|
3473
|
+
user: await getUserOplogInfo(),
|
|
3474
|
+
description: `converge ${changeId.slice(0, 8)} (unresolved)`,
|
|
3475
|
+
parents: [],
|
|
3476
|
+
conflictsSnapshot,
|
|
3477
|
+
view: {
|
|
3478
|
+
bookmarks: {},
|
|
3479
|
+
remoteBookmarks: {},
|
|
3480
|
+
heads: [],
|
|
3481
|
+
workingCopy: workingCopy.getCurrentChangeId(),
|
|
3482
|
+
},
|
|
3483
|
+
});
|
|
3484
|
+
return { changeId, resolved: false, conflicts: detectedConflicts };
|
|
3485
|
+
}
|
|
3486
|
+
|
|
3487
|
+
const user = /** @type {any} */ (userConfig.getUser());
|
|
3488
|
+
const changeSnapshot = { [changeId]: structuredClone(primary) };
|
|
3489
|
+
const otherIds = others.map((/** @type {any} */ o) => o.commitId.slice(0, 8)).join(', ');
|
|
3490
|
+
|
|
3491
|
+
const convergedChange = {
|
|
3492
|
+
...primary,
|
|
3493
|
+
commitId: randomHex(20),
|
|
3494
|
+
fileSnapshot: mergedSnapshot,
|
|
3495
|
+
description: primary.description + `\n\n(converged divergent copies: ${otherIds})`,
|
|
3496
|
+
timestamp: new Date().toISOString(),
|
|
3497
|
+
committer: {
|
|
3498
|
+
name: user.name,
|
|
3499
|
+
email: user.email,
|
|
3500
|
+
timestamp: new Date().toISOString(),
|
|
3501
|
+
},
|
|
3502
|
+
divergent: false,
|
|
3503
|
+
};
|
|
3504
|
+
await graph.updateChange(convergedChange);
|
|
3505
|
+
for (const other of others) {
|
|
3506
|
+
await graph.deleteDivergentCopy(changeId, other.commitId);
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3509
|
+
await oplog.recordOperation({
|
|
3510
|
+
timestamp: new Date().toISOString(),
|
|
3511
|
+
user: await getUserOplogInfo(),
|
|
3512
|
+
description: `converge ${changeId.slice(0, 8)}`,
|
|
3513
|
+
parents: [],
|
|
3514
|
+
changeSnapshot,
|
|
3515
|
+
conflictsSnapshot,
|
|
3516
|
+
view: {
|
|
3517
|
+
bookmarks: {},
|
|
3518
|
+
remoteBookmarks: {},
|
|
3519
|
+
heads: [changeId],
|
|
3520
|
+
workingCopy: workingCopy.getCurrentChangeId(),
|
|
3521
|
+
},
|
|
3522
|
+
});
|
|
3523
|
+
|
|
3524
|
+
return { changeId, resolved: true, conflicts: [], commitId: convergedChange.commitId };
|
|
3525
|
+
},
|
|
3526
|
+
|
|
2833
3527
|
/**
|
|
2834
3528
|
* Backout a change - create a new change that reverses the effect of the specified change
|
|
2835
3529
|
*
|
|
@@ -4669,8 +5363,14 @@ export async function createJJ(options) {
|
|
|
4669
5363
|
* @returns {Promise<Object>} Repository root information
|
|
4670
5364
|
*/
|
|
4671
5365
|
async root() {
|
|
4672
|
-
//
|
|
4673
|
-
|
|
5366
|
+
// `dir` is a real host filesystem path (unlike the repo-relative
|
|
5367
|
+
// git/jj paths `path` — posix-path.js — is for), so it needs the
|
|
5368
|
+
// host's own separator here, not always '/': on Windows Node's real
|
|
5369
|
+
// `fs` still resolves a forward-slash-joined path fine, but this
|
|
5370
|
+
// method hands the string back to the caller as data, and jj's own
|
|
5371
|
+
// `git root` returns a platform-native path there.
|
|
5372
|
+
const isWin32 = typeof process !== 'undefined' && process.platform === 'win32';
|
|
5373
|
+
const gitDir = isWin32 ? `${dir.replace(/[\\/]+$/, '')}\\.git` : path.join(dir, '.git');
|
|
4674
5374
|
|
|
4675
5375
|
try {
|
|
4676
5376
|
await fs.promises.access(gitDir);
|
|
@@ -4749,7 +5449,7 @@ export async function createJJ(options) {
|
|
|
4749
5449
|
const wcFiles = await workingCopy.listFiles();
|
|
4750
5450
|
for (const file of wcFiles) {
|
|
4751
5451
|
try {
|
|
4752
|
-
const content = await fs.promises.readFile(path.join(dir, file), '
|
|
5452
|
+
const content = await fs.promises.readFile(path.join(dir, file), 'utf8');
|
|
4753
5453
|
leftFiles.set(file, content);
|
|
4754
5454
|
} catch (error) {
|
|
4755
5455
|
// File might be deleted or binary - throw error for explicit handling
|
|
@@ -4892,7 +5592,7 @@ export async function createJJ(options) {
|
|
|
4892
5592
|
}
|
|
4893
5593
|
|
|
4894
5594
|
// Write resolved content to file
|
|
4895
|
-
await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, '
|
|
5595
|
+
await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf8');
|
|
4896
5596
|
|
|
4897
5597
|
// Mark conflict as resolved
|
|
4898
5598
|
await conflicts.resolveConflict(args.conflictId, 'manual');
|
|
@@ -4935,7 +5635,7 @@ export async function createJJ(options) {
|
|
|
4935
5635
|
const resolvedContent = _resolveWithStrategy(conflict, args.strategy);
|
|
4936
5636
|
|
|
4937
5637
|
// Write resolved content
|
|
4938
|
-
await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, '
|
|
5638
|
+
await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf8');
|
|
4939
5639
|
|
|
4940
5640
|
// Mark as resolved
|
|
4941
5641
|
await conflicts.resolveConflict(conflict.conflictId, args.strategy);
|
|
@@ -4968,11 +5668,18 @@ export async function createJJ(options) {
|
|
|
4968
5668
|
throw new JJError('CONFLICT_NOT_FOUND', `Conflict ${args.conflictId} not found`);
|
|
4969
5669
|
}
|
|
4970
5670
|
|
|
4971
|
-
// Generate standard conflict markers
|
|
5671
|
+
// Generate standard conflict markers. Force a newline before each
|
|
5672
|
+
// boundary marker whenever there's non-empty content that doesn't
|
|
5673
|
+
// already end in one — otherwise the content fuses onto the
|
|
5674
|
+
// `=======`/`>>>>>>> theirs` marker, producing a corrupted/unparseable
|
|
5675
|
+
// line (see issue #15). Empty sides (e.g. a modify-delete conflict)
|
|
5676
|
+
// intentionally get no extra blank line before the marker.
|
|
4972
5677
|
const ours = conflict.sides.left || '';
|
|
4973
5678
|
const theirs = conflict.sides.right || '';
|
|
5679
|
+
const oursSep = ours && !ours.endsWith('\n') ? '\n' : '';
|
|
5680
|
+
const theirsSep = theirs && !theirs.endsWith('\n') ? '\n' : '';
|
|
4974
5681
|
|
|
4975
|
-
return `<<<<<<< ours\n${ours}=======\n${theirs}>>>>>>> theirs`;
|
|
5682
|
+
return `<<<<<<< ours\n${ours}${oursSep}=======\n${theirs}${theirsSep}>>>>>>> theirs`;
|
|
4976
5683
|
},
|
|
4977
5684
|
|
|
4978
5685
|
/**
|
|
@@ -5059,10 +5766,17 @@ export async function createJJ(options) {
|
|
|
5059
5766
|
* Searches the tracked files of a change (defaults to the working copy)
|
|
5060
5767
|
* for lines matching a pattern. Following jj v0.41, the pattern is treated
|
|
5061
5768
|
* as a regular expression by default; pass `{ kind: 'substring' }` for a
|
|
5062
|
-
* literal search.
|
|
5769
|
+
* literal search. isomorphic-jj has always returned the matched line
|
|
5770
|
+
* (and its 1-based `lineNumber`) alongside the path, which is what jj
|
|
5771
|
+
* v0.44 made the CLI's new default (`jj file search` used to print only
|
|
5772
|
+
* file paths). Pass `{ nameOnly: true }` for the pre-v0.44 CLI
|
|
5773
|
+
* behavior — a deduplicated list of matching file paths, matching the
|
|
5774
|
+
* new `--name-only` flag.
|
|
5063
5775
|
*
|
|
5064
|
-
* @param {Record<string, any>} args - Arguments
|
|
5065
|
-
*
|
|
5776
|
+
* @param {Record<string, any>} args - Arguments. Pass `nameOnly: true`
|
|
5777
|
+
* to return only unique matching file paths instead of per-line
|
|
5778
|
+
* matches (jj v0.44 `--name-only`)
|
|
5779
|
+
* @returns {Promise<Array<{path: string, lineNumber: number, line: string}>|Array<string>>}
|
|
5066
5780
|
*/
|
|
5067
5781
|
async search(args) {
|
|
5068
5782
|
if (!args || !args.pattern) {
|
|
@@ -5113,6 +5827,10 @@ export async function createJJ(options) {
|
|
|
5113
5827
|
}
|
|
5114
5828
|
});
|
|
5115
5829
|
}
|
|
5830
|
+
|
|
5831
|
+
if (args.nameOnly) {
|
|
5832
|
+
return Array.from(new Set(results.map((r) => r.path)));
|
|
5833
|
+
}
|
|
5116
5834
|
return results;
|
|
5117
5835
|
},
|
|
5118
5836
|
|
|
@@ -5357,7 +6075,7 @@ export async function createJJ(options) {
|
|
|
5357
6075
|
if (pathParts.length > 1) {
|
|
5358
6076
|
const dirPath = pathParts.slice(0, -1).join('/');
|
|
5359
6077
|
const fullDirPath = path.join(args.path, dirPath);
|
|
5360
|
-
await fs
|
|
6078
|
+
await mkdirp(fs, fullDirPath);
|
|
5361
6079
|
}
|
|
5362
6080
|
|
|
5363
6081
|
// Write file content
|
|
@@ -6194,6 +6912,10 @@ export async function createJJ(options) {
|
|
|
6194
6912
|
view: {
|
|
6195
6913
|
bookmarks: {},
|
|
6196
6914
|
remoteBookmarks: {},
|
|
6915
|
+
// Tag delta for this operation (mirrors how `bookmarks` records
|
|
6916
|
+
// its delta above), so operations.revert() can diff it — see
|
|
6917
|
+
// issue #12.
|
|
6918
|
+
tags: { [args.name]: changeId },
|
|
6197
6919
|
heads: [],
|
|
6198
6920
|
workingCopy: workingCopy.getCurrentChangeId(),
|
|
6199
6921
|
},
|
|
@@ -6239,6 +6961,7 @@ export async function createJJ(options) {
|
|
|
6239
6961
|
view: {
|
|
6240
6962
|
bookmarks: {},
|
|
6241
6963
|
remoteBookmarks: {},
|
|
6964
|
+
tags: { [args.name]: changeId },
|
|
6242
6965
|
heads: [],
|
|
6243
6966
|
workingCopy: workingCopy.getCurrentChangeId(),
|
|
6244
6967
|
},
|
|
@@ -6248,10 +6971,11 @@ export async function createJJ(options) {
|
|
|
6248
6971
|
},
|
|
6249
6972
|
|
|
6250
6973
|
/**
|
|
6251
|
-
* List all tags
|
|
6974
|
+
* List all tags. Each entry also carries a `tracking: { remote, ref }`
|
|
6975
|
+
* field when the tag is tracking a remote (jj v0.44 `jj tag track`).
|
|
6252
6976
|
*
|
|
6253
6977
|
* @param {Record<string, any>} [args] - Optional filter arguments
|
|
6254
|
-
* @returns {Promise<Array<
|
|
6978
|
+
* @returns {Promise<Array<Record<string, any>>>} List of tags
|
|
6255
6979
|
*/
|
|
6256
6980
|
async list(args) {
|
|
6257
6981
|
const pattern = args?.pattern;
|
|
@@ -6283,6 +7007,7 @@ export async function createJJ(options) {
|
|
|
6283
7007
|
view: {
|
|
6284
7008
|
bookmarks: {},
|
|
6285
7009
|
remoteBookmarks: {},
|
|
7010
|
+
tags: {},
|
|
6286
7011
|
heads: [],
|
|
6287
7012
|
workingCopy: workingCopy.getCurrentChangeId(),
|
|
6288
7013
|
},
|
|
@@ -6290,6 +7015,94 @@ export async function createJJ(options) {
|
|
|
6290
7015
|
|
|
6291
7016
|
return { deleted: args.name };
|
|
6292
7017
|
},
|
|
7018
|
+
|
|
7019
|
+
/**
|
|
7020
|
+
* Associate a local tag with a remote tag so it is updated on fetch
|
|
7021
|
+
* (matches `jj tag track`, new in jj v0.44).
|
|
7022
|
+
*
|
|
7023
|
+
* jj v0.44 made tags remote-trackable just like bookmarks: fetched
|
|
7024
|
+
* tags are tracked by default, and `jj tag track`/`untrack` let you
|
|
7025
|
+
* opt individual tags in or out by hand. isomorphic-jj doesn't (yet)
|
|
7026
|
+
* auto-import remote tags during `git.fetch()` — see CHANGELOG — so
|
|
7027
|
+
* this records tracking intent/state the same way `bookmark.track()`
|
|
7028
|
+
* does, without changing fetch's own behavior.
|
|
7029
|
+
*
|
|
7030
|
+
* @param {Record<string, any>} args - Tag arguments: `{ name, remote }`
|
|
7031
|
+
* (`name` is the local tag name to track; `remote` defaults to `'origin'`)
|
|
7032
|
+
* @returns {Promise<{name: string, remote: string, tracking: boolean}>}
|
|
7033
|
+
*/
|
|
7034
|
+
async track(args) {
|
|
7035
|
+
if (!args || !args.name) {
|
|
7036
|
+
throw new JJError('INVALID_ARGUMENT', 'Missing tag name', {
|
|
7037
|
+
suggestion: 'Provide tag name: { name: "v1.0.0", remote: "origin" }',
|
|
7038
|
+
});
|
|
7039
|
+
}
|
|
7040
|
+
|
|
7041
|
+
const remote = args.remote || 'origin';
|
|
7042
|
+
const remoteTagName = `${remote}/${args.name}`;
|
|
7043
|
+
|
|
7044
|
+
const tagMap = await tags.load();
|
|
7045
|
+
/** @type {Record<string, any>} */
|
|
7046
|
+
const tracking = tags.tracking || {};
|
|
7047
|
+
tracking[args.name] = { remote, remoteName: args.name };
|
|
7048
|
+
tags.tracking = tracking;
|
|
7049
|
+
await tags.save(tagMap);
|
|
7050
|
+
|
|
7051
|
+
// Record operation
|
|
7052
|
+
await oplog.recordOperation({
|
|
7053
|
+
timestamp: new Date().toISOString(),
|
|
7054
|
+
user: await getUserOplogInfo(),
|
|
7055
|
+
description: `tag track ${args.name} from ${remote}`,
|
|
7056
|
+
parents: [],
|
|
7057
|
+
view: {
|
|
7058
|
+
bookmarks: {},
|
|
7059
|
+
remoteBookmarks: { [remoteTagName]: true },
|
|
7060
|
+
heads: [],
|
|
7061
|
+
workingCopy: workingCopy.getCurrentChangeId(),
|
|
7062
|
+
},
|
|
7063
|
+
});
|
|
7064
|
+
|
|
7065
|
+
return { name: args.name, remote, tracking: true };
|
|
7066
|
+
},
|
|
7067
|
+
|
|
7068
|
+
/**
|
|
7069
|
+
* Stop tracking a remote tag (matches `jj tag untrack`, new in jj v0.44).
|
|
7070
|
+
*
|
|
7071
|
+
* @param {Record<string, any>} args - Tag arguments: `{ name }`, the
|
|
7072
|
+
* local tag name to stop tracking
|
|
7073
|
+
* @returns {Promise<{name: string, tracking: boolean, wasTracking: boolean}>}
|
|
7074
|
+
*/
|
|
7075
|
+
async untrack(args) {
|
|
7076
|
+
if (!args || !args.name) {
|
|
7077
|
+
throw new JJError('INVALID_ARGUMENT', 'Missing tag name', {
|
|
7078
|
+
suggestion: 'Provide tag name: { name: "v1.0.0" }',
|
|
7079
|
+
});
|
|
7080
|
+
}
|
|
7081
|
+
|
|
7082
|
+
const tagMap = await tags.load();
|
|
7083
|
+
/** @type {Record<string, any>} */
|
|
7084
|
+
const tracking = tags.tracking || {};
|
|
7085
|
+
const wasTracking = tracking[args.name];
|
|
7086
|
+
delete tracking[args.name];
|
|
7087
|
+
tags.tracking = tracking;
|
|
7088
|
+
await tags.save(tagMap);
|
|
7089
|
+
|
|
7090
|
+
// Record operation
|
|
7091
|
+
await oplog.recordOperation({
|
|
7092
|
+
timestamp: new Date().toISOString(),
|
|
7093
|
+
user: await getUserOplogInfo(),
|
|
7094
|
+
description: `tag untrack ${args.name}`,
|
|
7095
|
+
parents: [],
|
|
7096
|
+
view: {
|
|
7097
|
+
bookmarks: {},
|
|
7098
|
+
remoteBookmarks: {},
|
|
7099
|
+
heads: [],
|
|
7100
|
+
workingCopy: workingCopy.getCurrentChangeId(),
|
|
7101
|
+
},
|
|
7102
|
+
});
|
|
7103
|
+
|
|
7104
|
+
return { name: args.name, tracking: false, wasTracking: !!wasTracking };
|
|
7105
|
+
},
|
|
6293
7106
|
},
|
|
6294
7107
|
|
|
6295
7108
|
/**
|