isomorphic-jj 1.8.0 → 1.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +371 -0
- package/LICENSE +1 -1
- package/README.md +117 -30
- package/dist/index.cjs +2225 -419
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +2225 -418
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -7
- package/src/api/repository.js +862 -161
- package/src/backend/isomorphic-git-backend.js +13 -12
- package/src/browser/helpers.js +38 -16
- package/src/browser/index.d.ts +9 -2
- package/src/core/change-graph.js +125 -4
- package/src/core/conflict-model.js +72 -7
- package/src/core/jj-checkout.js +5 -16
- package/src/core/jj-operation-store.js +6 -17
- package/src/core/jj-tree-state.js +5 -16
- package/src/core/jj-view-store.js +6 -17
- package/src/core/merge-driver-registry.js +11 -10
- package/src/core/revset-engine.js +127 -7
- package/src/core/storage-manager.js +9 -8
- package/src/core/tag-store.js +2 -5
- package/src/core/working-copy.js +51 -4
- package/src/core/workspace-manager.js +4 -3
- package/src/protos/local_working_copy.json.js +146 -0
- package/src/protos/schema.js +49 -0
- package/src/protos/simple_op_store.json.js +374 -0
- package/src/types.d.ts +33 -0
- package/src/utils/atomic-write.js +3 -1
- package/src/utils/bytes.js +67 -0
- package/src/utils/id-generation.js +18 -4
- package/src/utils/mkdirp.js +41 -0
- package/src/utils/posix-path.js +117 -0
package/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,266 @@ 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
|
+
// Bypass the git-sync middleware (baseGraph, not the wrapped
|
|
286
|
+
// `graph`): undo/restore are pure reverts, and `snapshot` already
|
|
287
|
+
// carries the change's ORIGINAL commitId from before the reverted
|
|
288
|
+
// operation(s) ran. Going through graph.updateChange() would fire
|
|
289
|
+
// onUpdateChange -> syncChangeToGit(), which mints a brand-new
|
|
290
|
+
// commit (and thus a new commitId) from the CURRENT tree instead
|
|
291
|
+
// of restoring the one that was actually there — see issue #37(c):
|
|
292
|
+
// "undo/restore give reverted commits new commit IDs". Writing the
|
|
293
|
+
// snapshot verbatim via baseGraph puts the original commitId back.
|
|
294
|
+
await baseGraph.updateChange(snapshot);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (reversal.fileSnapshot !== undefined) {
|
|
299
|
+
await syncWorkingCopyFiles(reversal.fileSnapshot);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
await conflicts.load();
|
|
303
|
+
if (reversal.conflictsSnapshot !== undefined) {
|
|
304
|
+
conflicts.conflicts = new Map(Object.entries(reversal.conflictsSnapshot.conflicts || {}));
|
|
305
|
+
conflicts.fileConflicts = new Map(
|
|
306
|
+
Object.entries(reversal.conflictsSnapshot.fileConflicts || {})
|
|
307
|
+
);
|
|
308
|
+
await conflicts.save();
|
|
309
|
+
}
|
|
310
|
+
// Else: no operation in the reverted range touched conflicts — leave
|
|
311
|
+
// current conflict state alone. (Previously undo() unconditionally
|
|
312
|
+
// cleared ALL conflicts whenever the op being undone hadn't recorded
|
|
313
|
+
// its own conflictsSnapshot, which is nearly every non-merge() op —
|
|
314
|
+
// undoing an unrelated describe() would silently wipe conflicts from
|
|
315
|
+
// an earlier, unrelated merge(). Fixed as part of issue #29.)
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Re-parent every child of `changeId` onto `newParents` instead — used
|
|
320
|
+
* when a change is being elided from the graph (abandon(), issue #30;
|
|
321
|
+
* converge(), issue #32) so its children don't end up pointing at
|
|
322
|
+
* something no longer meant to be there. Mutates `changeSnapshot` in
|
|
323
|
+
* place, adding each touched child's PRE-reparent record so the caller's
|
|
324
|
+
* own oplog entry lets undo()/redo()/operations.restore() reverse this
|
|
325
|
+
* too (the same mechanism the caller already uses for the elided
|
|
326
|
+
* change's own snapshot).
|
|
327
|
+
*
|
|
328
|
+
* @param {string} changeId - The change being elided
|
|
329
|
+
* @param {string[]} newParents - What its children should point at instead
|
|
330
|
+
* @param {Record<string, any>} changeSnapshot - Accumulator to add each touched child's pre-state to
|
|
331
|
+
* @returns {Promise<void>}
|
|
332
|
+
*/
|
|
333
|
+
const reparentChildrenOnto = async (changeId, newParents, changeSnapshot) => {
|
|
334
|
+
const childIds = graph.getChildren(changeId);
|
|
335
|
+
for (const childId of childIds) {
|
|
336
|
+
const child = await graph.getChange(childId);
|
|
337
|
+
if (!child) continue;
|
|
338
|
+
changeSnapshot[childId] = structuredClone(child);
|
|
339
|
+
const reparented = child.parents.flatMap((/** @type {string} */ p) =>
|
|
340
|
+
p === changeId ? newParents : [p]
|
|
341
|
+
);
|
|
342
|
+
// De-dupe (e.g. the child could already have one of the new parents
|
|
343
|
+
// as another of its own parents) while preserving first-seen order.
|
|
344
|
+
child.parents = [...new Set(reparented)];
|
|
345
|
+
await graph.updateChange(child);
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Determine which operation a fresh undo() call should revert, matching
|
|
351
|
+
* real jj's progressive-undo semantics (`jj-undo(1)`: "If used once after
|
|
352
|
+
* a normal operation, this will undo that last operation by restoring its
|
|
353
|
+
* parent. If jj undo is used repeatedly, it will restore increasingly
|
|
354
|
+
* older operations, going further back into the past.").
|
|
355
|
+
*
|
|
356
|
+
* The raw op-log parent chain can't answer this directly once undo/redo
|
|
357
|
+
* bookkeeping operations are mixed into the log: an 'undo' operation's
|
|
358
|
+
* own parent is the operation it just undid, so naively targeting
|
|
359
|
+
* `ops[ops.length - 1]` on a SECOND undo() would undo the undo (toggle
|
|
360
|
+
* back to the future) instead of stepping one further step into the past
|
|
361
|
+
* — this was issue #29's "undo() twice only undoes the undo" bug.
|
|
362
|
+
*
|
|
363
|
+
* @param {Array<any>} ops - Full chronological operation list
|
|
364
|
+
* @returns {any} The operation object undo() should revert
|
|
365
|
+
*/
|
|
366
|
+
const resolveUndoTarget = (ops) => {
|
|
367
|
+
const head = ops[ops.length - 1];
|
|
368
|
+
const findOp = (/** @type {any} */ id) => ops.find((op) => op.id === id);
|
|
369
|
+
|
|
370
|
+
if (head.eventType === 'undo') {
|
|
371
|
+
// We're stepping further into the past: what should be undone now is
|
|
372
|
+
// the operation right BEFORE the one `head` already undid — not
|
|
373
|
+
// head's own parent, which IS the operation head undid (targeting it
|
|
374
|
+
// again would just toggle back to the present).
|
|
375
|
+
const previouslyUndone = head.targetOpId && findOp(head.targetOpId);
|
|
376
|
+
const stepBack = previouslyUndone && findOp(previouslyUndone.parents[0]);
|
|
377
|
+
return stepBack || previouslyUndone || head;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (head.eventType === 'redo') {
|
|
381
|
+
// A redo re-established some earlier operation as current; a fresh
|
|
382
|
+
// undo right after it should behave exactly as if that operation
|
|
383
|
+
// were freshly current and had never been touched by undo/redo at
|
|
384
|
+
// all — i.e. undo it again (real jj: redo "moves in the direction of
|
|
385
|
+
// the future"; undoing right after simply resumes stepping back).
|
|
386
|
+
const redoneUndo = head.redoOf && findOp(head.redoOf);
|
|
387
|
+
const target = redoneUndo && redoneUndo.targetOpId && findOp(redoneUndo.targetOpId);
|
|
388
|
+
return target || head;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return head;
|
|
392
|
+
};
|
|
393
|
+
|
|
133
394
|
/**
|
|
134
395
|
* Automatic working-copy snapshot (v1.6).
|
|
135
396
|
*
|
|
@@ -314,6 +575,25 @@ export async function createJJ(options) {
|
|
|
314
575
|
}
|
|
315
576
|
},
|
|
316
577
|
|
|
578
|
+
// Used by undo()/redo()/operations.restore() to reverse an
|
|
579
|
+
// addChange() when winding the graph back — see issue #29. No Git
|
|
580
|
+
// sync hook: a change created after the operation being reverted was
|
|
581
|
+
// never pushed anywhere to unwind, and the Git backend has no
|
|
582
|
+
// "forget this commit" operation (nor should it — Git objects are
|
|
583
|
+
// content-addressed and harmless to leave orphaned).
|
|
584
|
+
deleteChange: (/** @type {any} */ changeId) => baseGraph.deleteChange(changeId),
|
|
585
|
+
|
|
586
|
+
// See issue #32 / converge(): a divergent copy is another commit
|
|
587
|
+
// claiming an existing changeId; no Git sync hook, for the same
|
|
588
|
+
// reason addChange()'s primary copy already goes through
|
|
589
|
+
// onAddChange() when it's first created — the divergent copy needs
|
|
590
|
+
// no additional sync of its own.
|
|
591
|
+
addDivergentCopy: (/** @type {any} */ change) => baseGraph.addDivergentCopy(change),
|
|
592
|
+
getDivergentSiblings: (/** @type {any} */ changeId) =>
|
|
593
|
+
baseGraph.getDivergentSiblings(changeId),
|
|
594
|
+
deleteDivergentCopy: (/** @type {any} */ changeId, /** @type {any} */ commitId) =>
|
|
595
|
+
baseGraph.deleteDivergentCopy(changeId, commitId),
|
|
596
|
+
|
|
317
597
|
// Delegate other operations
|
|
318
598
|
init: () => baseGraph.init(),
|
|
319
599
|
};
|
|
@@ -471,6 +751,20 @@ export async function createJJ(options) {
|
|
|
471
751
|
/**
|
|
472
752
|
* Write a file to the working copy
|
|
473
753
|
*
|
|
754
|
+
* Note (issue #30): this does NOT update the working-copy change's
|
|
755
|
+
* `commitId`/`fileSnapshot` — those stay as they were until the next
|
|
756
|
+
* command that snapshots the working copy (describe(), new(), edit(),
|
|
757
|
+
* status(), or any other call that goes through
|
|
758
|
+
* autoSnapshotWorkingCopy()/snapshot()). This is intentional, not a
|
|
759
|
+
* bug: jj has no staging area, but it also doesn't recompute a git
|
|
760
|
+
* tree/commit hash on every single file write (that would mean
|
|
761
|
+
* hashing on every keystroke of an external editor) — real jj only
|
|
762
|
+
* takes a fresh working-copy snapshot when an actual `jj` command
|
|
763
|
+
* runs. write() is the equivalent of editing a file with your OS's own
|
|
764
|
+
* tools outside of jj entirely; describe()/new()/edit()/status()/etc.
|
|
765
|
+
* are the equivalent of running a jj command, which is what triggers
|
|
766
|
+
* the (auto) snapshot in both real jj and here.
|
|
767
|
+
*
|
|
474
768
|
* @param {Record<string, any>} args - Arguments
|
|
475
769
|
* @returns {Promise<Object>} File information including path, size, and mode
|
|
476
770
|
*/
|
|
@@ -494,7 +788,7 @@ export async function createJJ(options) {
|
|
|
494
788
|
if (pathParts.length > 1) {
|
|
495
789
|
const dirPath = pathParts.slice(0, -1).join('/');
|
|
496
790
|
const fullDirPath = `${dir}/${dirPath}`;
|
|
497
|
-
await fs
|
|
791
|
+
await mkdirp(fs, fullDirPath);
|
|
498
792
|
}
|
|
499
793
|
|
|
500
794
|
// Write the file
|
|
@@ -552,7 +846,7 @@ export async function createJJ(options) {
|
|
|
552
846
|
const fullPath = path.join(dir, args.path);
|
|
553
847
|
try {
|
|
554
848
|
if (encoding === 'utf-8' || encoding === 'utf8') {
|
|
555
|
-
return await fs.promises.readFile(fullPath, '
|
|
849
|
+
return await fs.promises.readFile(fullPath, 'utf8');
|
|
556
850
|
} else {
|
|
557
851
|
return await fs.promises.readFile(fullPath);
|
|
558
852
|
}
|
|
@@ -774,7 +1068,7 @@ export async function createJJ(options) {
|
|
|
774
1068
|
if (pathParts.length > 1) {
|
|
775
1069
|
const dirPath = pathParts.slice(0, -1).join('/');
|
|
776
1070
|
const fullDirPath = path.join(dir, dirPath);
|
|
777
|
-
await fs
|
|
1071
|
+
await mkdirp(fs, fullDirPath);
|
|
778
1072
|
}
|
|
779
1073
|
|
|
780
1074
|
// Create writable stream
|
|
@@ -887,7 +1181,7 @@ export async function createJJ(options) {
|
|
|
887
1181
|
// Ensure destination directory exists
|
|
888
1182
|
const toDir = toPath.substring(0, toPath.lastIndexOf('/'));
|
|
889
1183
|
try {
|
|
890
|
-
await fs
|
|
1184
|
+
await mkdirp(fs, toDir);
|
|
891
1185
|
} catch (error) {
|
|
892
1186
|
throw new JJError(
|
|
893
1187
|
'FILE_SYSTEM_ERROR',
|
|
@@ -1005,6 +1299,51 @@ export async function createJJ(options) {
|
|
|
1005
1299
|
// TODO: Prevent creating cycles in the change graph
|
|
1006
1300
|
// For now, we'll allow it but it should be validated
|
|
1007
1301
|
|
|
1302
|
+
// Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
|
|
1303
|
+
// reverse the reparenting — see issue #29.
|
|
1304
|
+
const changeSnapshotBefore = structuredClone(change);
|
|
1305
|
+
|
|
1306
|
+
// Three-way merge against the OLD base before reparenting (issue #31):
|
|
1307
|
+
// real jj's rebase re-derives the rebased change's content as
|
|
1308
|
+
// (old base, old commit, new base) — if a sibling changed the same
|
|
1309
|
+
// lines differently, that's a genuine conflict, not something
|
|
1310
|
+
// reparenting-and-copying-verbatim can silently paper over. This
|
|
1311
|
+
// reuses the exact same detectConflicts() machinery merge() already
|
|
1312
|
+
// uses (see merge(), a few hundred lines down) rather than
|
|
1313
|
+
// duplicating three-way-merge logic a third time in this codebase.
|
|
1314
|
+
const oldParentId = change.parents[0];
|
|
1315
|
+
const oldParentChange = oldParentId ? await graph.getChange(oldParentId) : null;
|
|
1316
|
+
const baseFiles = new Map(
|
|
1317
|
+
Object.entries((oldParentChange && oldParentChange.fileSnapshot) || {})
|
|
1318
|
+
);
|
|
1319
|
+
const leftFiles = new Map(Object.entries(change.fileSnapshot || {}));
|
|
1320
|
+
const rightFiles = new Map(Object.entries(newParentChange.fileSnapshot || {}));
|
|
1321
|
+
|
|
1322
|
+
await conflicts.load();
|
|
1323
|
+
const conflictsSnapshot = {
|
|
1324
|
+
conflicts: Object.fromEntries(conflicts.conflicts),
|
|
1325
|
+
fileConflicts: Object.fromEntries(conflicts.fileConflicts),
|
|
1326
|
+
};
|
|
1327
|
+
|
|
1328
|
+
const detectedConflicts = oldParentChange
|
|
1329
|
+
? await conflicts.detectConflicts({
|
|
1330
|
+
baseFiles,
|
|
1331
|
+
leftFiles,
|
|
1332
|
+
rightFiles,
|
|
1333
|
+
drivers: {},
|
|
1334
|
+
// Not necessarily the checked-out working copy, so don't let
|
|
1335
|
+
// merge drivers write to disk on its behalf.
|
|
1336
|
+
workingCopyDir: /** @type {any} */ (null),
|
|
1337
|
+
baseChange: oldParentId,
|
|
1338
|
+
leftChange: changeId,
|
|
1339
|
+
rightChange: newParent,
|
|
1340
|
+
})
|
|
1341
|
+
: [];
|
|
1342
|
+
|
|
1343
|
+
for (const conflict of detectedConflicts) {
|
|
1344
|
+
await conflicts.addConflict(conflict);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1008
1347
|
// Update parent
|
|
1009
1348
|
change.parents = [newParent];
|
|
1010
1349
|
await graph.updateChange(change);
|
|
@@ -1015,6 +1354,8 @@ export async function createJJ(options) {
|
|
|
1015
1354
|
user: await getUserOplogInfo(),
|
|
1016
1355
|
description: `move change ${changeId.slice(0, 8)} to ${newParent.slice(0, 8)}`,
|
|
1017
1356
|
parents: [],
|
|
1357
|
+
changeSnapshot: { [changeId]: changeSnapshotBefore },
|
|
1358
|
+
conflictsSnapshot,
|
|
1018
1359
|
view: {
|
|
1019
1360
|
bookmarks: {},
|
|
1020
1361
|
remoteBookmarks: {},
|
|
@@ -1023,7 +1364,7 @@ export async function createJJ(options) {
|
|
|
1023
1364
|
},
|
|
1024
1365
|
});
|
|
1025
1366
|
|
|
1026
|
-
return change;
|
|
1367
|
+
return { ...change, conflicts: detectedConflicts };
|
|
1027
1368
|
},
|
|
1028
1369
|
|
|
1029
1370
|
/**
|
|
@@ -1208,7 +1549,7 @@ export async function createJJ(options) {
|
|
|
1208
1549
|
break;
|
|
1209
1550
|
}
|
|
1210
1551
|
|
|
1211
|
-
const content = await fs.promises.readFile(fullPath, '
|
|
1552
|
+
const content = await fs.promises.readFile(fullPath, 'utf8');
|
|
1212
1553
|
fileSnapshot[filePath] = content;
|
|
1213
1554
|
totalSnapshotSize += stats.size;
|
|
1214
1555
|
} catch (error) {
|
|
@@ -1406,6 +1747,8 @@ export async function createJJ(options) {
|
|
|
1406
1747
|
|
|
1407
1748
|
// Snapshot filesystem BEFORE operation (for undo) unless preserveSnapshot is true
|
|
1408
1749
|
let fileSnapshot;
|
|
1750
|
+
/** @type {Record<string, any>} */
|
|
1751
|
+
const changeSnapshot = {};
|
|
1409
1752
|
if (!args.preserveSnapshot) {
|
|
1410
1753
|
fileSnapshot = await snapshotFilesystem();
|
|
1411
1754
|
|
|
@@ -1414,6 +1757,10 @@ export async function createJJ(options) {
|
|
|
1414
1757
|
const currentChangeId = workingCopy.getCurrentChangeId();
|
|
1415
1758
|
const currentChange = await graph.getChange(currentChangeId);
|
|
1416
1759
|
if (currentChange && fileSnapshot) {
|
|
1760
|
+
// Snapshot BEFORE mutating so undo()/redo() can revert this
|
|
1761
|
+
// in-place update too, not just the pointer/new-change creation
|
|
1762
|
+
// below — see issue #29.
|
|
1763
|
+
changeSnapshot[currentChangeId] = structuredClone(currentChange);
|
|
1417
1764
|
currentChange.fileSnapshot = fileSnapshot;
|
|
1418
1765
|
await graph.updateChange(currentChange);
|
|
1419
1766
|
}
|
|
@@ -1441,6 +1788,7 @@ export async function createJJ(options) {
|
|
|
1441
1788
|
const user = /** @type {any} */ (userConfig.getUser());
|
|
1442
1789
|
|
|
1443
1790
|
// Initialize with parent's file snapshot
|
|
1791
|
+
/** @type {Record<string, string>} */
|
|
1444
1792
|
let initialSnapshot = {};
|
|
1445
1793
|
if (parents && parents.length > 0) {
|
|
1446
1794
|
const parent = await graph.getChange(parents[0]);
|
|
@@ -1484,10 +1832,19 @@ export async function createJJ(options) {
|
|
|
1484
1832
|
// Handle insertBefore: rebase target to have new change as parent
|
|
1485
1833
|
if (args.insertBefore) {
|
|
1486
1834
|
const targetChange = await graph.getChange(args.insertBefore);
|
|
1835
|
+
changeSnapshot[args.insertBefore] = structuredClone(targetChange);
|
|
1487
1836
|
targetChange.parents = [newChangeId];
|
|
1488
1837
|
await graph.updateChange(targetChange);
|
|
1489
1838
|
}
|
|
1490
1839
|
|
|
1840
|
+
// Sync the working directory to the new change's snapshot: write
|
|
1841
|
+
// everything it has AND remove anything it doesn't (issue #30 — this
|
|
1842
|
+
// used to never touch disk at all, so `new({ parents: [...] })`
|
|
1843
|
+
// targeting anything other than the current head silently left the
|
|
1844
|
+
// OLD working copy's files in place instead of checking out the new
|
|
1845
|
+
// parent's).
|
|
1846
|
+
await syncWorkingCopyFiles(initialSnapshot);
|
|
1847
|
+
|
|
1491
1848
|
await workingCopy.setCurrentChange(newChangeId);
|
|
1492
1849
|
|
|
1493
1850
|
// Record operation with filesystem snapshot
|
|
@@ -1500,6 +1857,13 @@ export async function createJJ(options) {
|
|
|
1500
1857
|
},
|
|
1501
1858
|
description: `new change ${newChangeId.slice(0, 8)}`,
|
|
1502
1859
|
parents: [],
|
|
1860
|
+
// See issue #29: lets undo()/redo()/operations.restore() reverse
|
|
1861
|
+
// the existing records this op updated in place (the old
|
|
1862
|
+
// working-copy change's refreshed fileSnapshot, and insertBefore's
|
|
1863
|
+
// target's reparented parents). The newly created change itself
|
|
1864
|
+
// (newChangeId) is intentionally NOT torn down on undo — see
|
|
1865
|
+
// computeGraphReversal's doc comment.
|
|
1866
|
+
changeSnapshot,
|
|
1503
1867
|
view: {
|
|
1504
1868
|
bookmarks: {},
|
|
1505
1869
|
remoteBookmarks: {},
|
|
@@ -1880,6 +2244,13 @@ export async function createJJ(options) {
|
|
|
1880
2244
|
|
|
1881
2245
|
const previousChangeId = workingCopy.getCurrentChangeId();
|
|
1882
2246
|
|
|
2247
|
+
/** @type {Record<string, any>} */
|
|
2248
|
+
const changeSnapshot = {};
|
|
2249
|
+
|
|
2250
|
+
// Capture disk state right before we touch anything, for undo() —
|
|
2251
|
+
// see issue #29.
|
|
2252
|
+
const fileSnapshotBeforeSwitch = await snapshotFilesystem();
|
|
2253
|
+
|
|
1883
2254
|
// CRITICAL FIX: Clean working directory BEFORE snapshotting to prevent cross-branch pollution
|
|
1884
2255
|
// Remove files that don't belong to the current changeId before we snapshot it
|
|
1885
2256
|
if (previousChangeId !== args.changeId) {
|
|
@@ -1901,13 +2272,14 @@ export async function createJJ(options) {
|
|
|
1901
2272
|
}
|
|
1902
2273
|
}
|
|
1903
2274
|
}
|
|
1904
|
-
}
|
|
1905
2275
|
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
2276
|
+
// Now snapshot the CLEAN working directory (no pollution!)
|
|
2277
|
+
const currentSnapshot = await snapshotFilesystem();
|
|
2278
|
+
if (currentSnapshot && Object.keys(currentSnapshot).length > 0) {
|
|
2279
|
+
changeSnapshot[previousChangeId] = structuredClone(previousChange);
|
|
2280
|
+
previousChange.fileSnapshot = currentSnapshot;
|
|
2281
|
+
await graph.updateChange(previousChange);
|
|
2282
|
+
}
|
|
1911
2283
|
}
|
|
1912
2284
|
}
|
|
1913
2285
|
|
|
@@ -1920,38 +2292,17 @@ export async function createJJ(options) {
|
|
|
1920
2292
|
timestamp: new Date().toISOString(),
|
|
1921
2293
|
});
|
|
1922
2294
|
|
|
1923
|
-
//
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
await fs.promises.mkdir(fullDirPath, { recursive: true });
|
|
1935
|
-
}
|
|
1936
|
-
|
|
1937
|
-
// Write file content
|
|
1938
|
-
await fs.promises.writeFile(fullPath, content, 'utf8');
|
|
1939
|
-
|
|
1940
|
-
// Update working copy tracking
|
|
1941
|
-
const stats = await fs.promises.stat(fullPath);
|
|
1942
|
-
await workingCopy.trackFile(filePath, {
|
|
1943
|
-
mtime: stats.mtime,
|
|
1944
|
-
size: stats.size,
|
|
1945
|
-
mode: stats.mode,
|
|
1946
|
-
});
|
|
1947
|
-
} catch (error) {
|
|
1948
|
-
throw new JJError(
|
|
1949
|
-
'FILE_RESTORE_FAILED',
|
|
1950
|
-
`Failed to restore file ${filePath}: ${error.message}`,
|
|
1951
|
-
{ filePath, originalError: error.message }
|
|
1952
|
-
);
|
|
1953
|
-
}
|
|
1954
|
-
}
|
|
2295
|
+
// Fully sync the working directory to the target change's snapshot:
|
|
2296
|
+
// write everything it has AND remove anything it doesn't (issue #30
|
|
2297
|
+
// — this used to only overlay the target's files on top of whatever
|
|
2298
|
+
// was already on disk, so a file present in the PREVIOUS change but
|
|
2299
|
+
// absent from the target leaked into the next snapshot).
|
|
2300
|
+
try {
|
|
2301
|
+
await syncWorkingCopyFiles(change.fileSnapshot || {});
|
|
2302
|
+
} catch (error) {
|
|
2303
|
+
throw new JJError('FILE_RESTORE_FAILED', `Failed to restore files: ${error.message}`, {
|
|
2304
|
+
originalError: error.message,
|
|
2305
|
+
});
|
|
1955
2306
|
}
|
|
1956
2307
|
|
|
1957
2308
|
// Set this change as the working copy
|
|
@@ -1967,11 +2318,13 @@ export async function createJJ(options) {
|
|
|
1967
2318
|
},
|
|
1968
2319
|
description: `edit change ${args.changeId.slice(0, 8)}`,
|
|
1969
2320
|
parents: [],
|
|
2321
|
+
changeSnapshot,
|
|
1970
2322
|
view: {
|
|
1971
2323
|
bookmarks: {},
|
|
1972
2324
|
remoteBookmarks: {},
|
|
1973
2325
|
heads: [args.changeId],
|
|
1974
2326
|
workingCopy: args.changeId,
|
|
2327
|
+
fileSnapshot: fileSnapshotBeforeSwitch,
|
|
1975
2328
|
},
|
|
1976
2329
|
});
|
|
1977
2330
|
|
|
@@ -2002,106 +2355,118 @@ export async function createJJ(options) {
|
|
|
2002
2355
|
/**
|
|
2003
2356
|
* Undo last operation
|
|
2004
2357
|
*
|
|
2358
|
+
* Reverts the most recent operation — including graph-rewriting ones
|
|
2359
|
+
* (squash/rebase/abandon/new/edit), not just working-copy/file state
|
|
2360
|
+
* (see issue #29). Matches real jj's progressive-undo semantics
|
|
2361
|
+
* (`jj-undo(1)`): called again with nothing else in between, it steps
|
|
2362
|
+
* one further operation into the past rather than undoing itself.
|
|
2363
|
+
*
|
|
2005
2364
|
* @returns {Promise<Object>} Information about the undo including the undone operation and restored state
|
|
2006
2365
|
*/
|
|
2007
2366
|
async undo() {
|
|
2008
|
-
// Get the operation we're about to undo to access its pre-state
|
|
2009
2367
|
await oplog.load();
|
|
2010
2368
|
const ops = await oplog.list();
|
|
2011
2369
|
if (ops.length === 0) {
|
|
2012
2370
|
throw new JJError('NOTHING_TO_UNDO', 'No operations to undo');
|
|
2013
2371
|
}
|
|
2014
2372
|
|
|
2015
|
-
|
|
2016
|
-
const
|
|
2017
|
-
|
|
2018
|
-
const previousView = /** @type {any} */ (await oplog.undo());
|
|
2019
|
-
|
|
2020
|
-
// Restore state from previous view
|
|
2021
|
-
await workingCopy.setCurrentChange(previousView.workingCopy);
|
|
2022
|
-
|
|
2023
|
-
// Restore ChangeGraph content mutated in place by the operation being
|
|
2024
|
-
// undone (e.g. describe() editing change.description) — see issue #12.
|
|
2025
|
-
// currentOp.changeSnapshot holds the affected change record(s) as they
|
|
2026
|
-
// were BEFORE currentOp ran, captured the same way fileSnapshot is.
|
|
2027
|
-
await graph.load();
|
|
2028
|
-
if (currentOp && currentOp.changeSnapshot) {
|
|
2029
|
-
for (const snapshot of Object.values(
|
|
2030
|
-
/** @type {Record<string, any>} */ (currentOp.changeSnapshot)
|
|
2031
|
-
)) {
|
|
2032
|
-
if (snapshot && (await graph.getChange(snapshot.changeId))) {
|
|
2033
|
-
await graph.updateChange(snapshot);
|
|
2034
|
-
}
|
|
2035
|
-
}
|
|
2036
|
-
}
|
|
2037
|
-
|
|
2038
|
-
// Restore filesystem from previous operation's snapshot (taken BEFORE that operation)
|
|
2039
|
-
// This is how JJ snapshots the working copy before every command
|
|
2040
|
-
if (previousView.fileSnapshot) {
|
|
2041
|
-
for (const [filePath, content] of Object.entries(previousView.fileSnapshot)) {
|
|
2042
|
-
try {
|
|
2043
|
-
const fullPath = path.join(dir, filePath);
|
|
2044
|
-
|
|
2045
|
-
// Ensure directory exists
|
|
2046
|
-
const pathParts = filePath.split('/');
|
|
2047
|
-
if (pathParts.length > 1) {
|
|
2048
|
-
const dirPath = pathParts.slice(0, -1).join('/');
|
|
2049
|
-
const fullDirPath = path.join(dir, dirPath);
|
|
2050
|
-
await fs.promises.mkdir(fullDirPath, { recursive: true });
|
|
2051
|
-
}
|
|
2052
|
-
|
|
2053
|
-
// Write file content
|
|
2054
|
-
await fs.promises.writeFile(fullPath, content, 'utf8');
|
|
2055
|
-
} catch (error) {
|
|
2056
|
-
throw new JJError(
|
|
2057
|
-
'UNDO_FILE_RESTORE_FAILED',
|
|
2058
|
-
`Failed to restore file ${filePath} during undo: ${error.message}`,
|
|
2059
|
-
{ filePath, originalError: error.message }
|
|
2060
|
-
);
|
|
2061
|
-
}
|
|
2062
|
-
}
|
|
2063
|
-
}
|
|
2373
|
+
const targetOp = resolveUndoTarget(ops);
|
|
2374
|
+
const landingOpId = targetOp.parents && targetOp.parents[0];
|
|
2375
|
+
const landingOp = landingOpId ? ops.find((op) => op.id === landingOpId) : null;
|
|
2064
2376
|
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
Object.entries(currentOp.conflictsSnapshot.fileConflicts || {})
|
|
2073
|
-
);
|
|
2074
|
-
await conflicts.save();
|
|
2377
|
+
/** @type {ReturnType<typeof computeGraphReversal>} */
|
|
2378
|
+
let reversal;
|
|
2379
|
+
/** @type {any} */
|
|
2380
|
+
let landingView;
|
|
2381
|
+
if (landingOp) {
|
|
2382
|
+
reversal = computeGraphReversal(ops, landingOp);
|
|
2383
|
+
landingView = landingOp.view || {};
|
|
2075
2384
|
} else {
|
|
2076
|
-
//
|
|
2077
|
-
|
|
2385
|
+
// Undoing the very first (root) operation: there's nothing before
|
|
2386
|
+
// it to restore to, so land on its own recorded state rather than
|
|
2387
|
+
// throwing (mirrors the previous no-parent fallback).
|
|
2388
|
+
reversal = {
|
|
2389
|
+
changeSnapshotToApply: {},
|
|
2390
|
+
fileSnapshot: undefined,
|
|
2391
|
+
conflictsSnapshot: undefined,
|
|
2392
|
+
};
|
|
2393
|
+
landingView = targetOp.view || {};
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
// Capture EXACTLY what's about to be overwritten, from ground truth,
|
|
2397
|
+
// before applying the reversal — this is what redo() re-applies
|
|
2398
|
+
// later. It can't just recompute "the state after targetOp" the way
|
|
2399
|
+
// undo() itself computes "the state after landingOp" (by scanning
|
|
2400
|
+
// for a LATER op that also touched the same changeId/files/
|
|
2401
|
+
// conflicts): that scan only works going backward, where "nothing
|
|
2402
|
+
// touched it since" means "it's already correct". Going forward,
|
|
2403
|
+
// "nothing touched it since targetOp" means the opposite — its
|
|
2404
|
+
// effect is still live and needs to be captured now, not rederived.
|
|
2405
|
+
await graph.load();
|
|
2406
|
+
/** @type {Record<string, any>} */
|
|
2407
|
+
const redoChangeSnapshot = {};
|
|
2408
|
+
for (const changeId of Object.keys(reversal.changeSnapshotToApply)) {
|
|
2409
|
+
const current = await graph.getChange(changeId);
|
|
2410
|
+
if (current) redoChangeSnapshot[changeId] = current;
|
|
2411
|
+
}
|
|
2412
|
+
const redoFileSnapshot =
|
|
2413
|
+
reversal.fileSnapshot !== undefined ? await snapshotFilesystem() : undefined;
|
|
2414
|
+
let redoConflictsSnapshot;
|
|
2415
|
+
if (reversal.conflictsSnapshot !== undefined) {
|
|
2416
|
+
await conflicts.load();
|
|
2417
|
+
redoConflictsSnapshot = {
|
|
2418
|
+
conflicts: Object.fromEntries(conflicts.conflicts),
|
|
2419
|
+
fileConflicts: Object.fromEntries(conflicts.fileConflicts),
|
|
2420
|
+
};
|
|
2078
2421
|
}
|
|
2079
2422
|
|
|
2080
|
-
|
|
2081
|
-
|
|
2423
|
+
if (landingView.workingCopy) {
|
|
2424
|
+
await workingCopy.setCurrentChange(landingView.workingCopy);
|
|
2425
|
+
}
|
|
2426
|
+
await applyGraphReversal(reversal);
|
|
2427
|
+
|
|
2428
|
+
// Record undo as its own operation (matching real jj) — tagged with
|
|
2429
|
+
// which operation it targeted so a following undo() can walk one
|
|
2430
|
+
// step further back (resolveUndoTarget) instead of toggling, and
|
|
2431
|
+
// carrying exactly what redo() needs to re-apply (captured above).
|
|
2432
|
+
//
|
|
2433
|
+
// Also record `changeSnapshot: redoChangeSnapshot` — the SAME
|
|
2434
|
+
// ground-truth-before-this-op values, but under the field name
|
|
2435
|
+
// computeGraphReversal() actually scans for. Without this, undo()
|
|
2436
|
+
// itself was the one mutating operation in the whole API that never
|
|
2437
|
+
// recorded its own changeSnapshot, so a LATER undo() targeting THIS
|
|
2438
|
+
// undo (to step back past it) had nothing to restore: it would
|
|
2439
|
+
// repoint the working copy and files, but leave whatever this
|
|
2440
|
+
// undo() overwrote (e.g. a change's description) exactly as this
|
|
2441
|
+
// undo() left it. See issue #37(a).
|
|
2082
2442
|
await oplog.recordOperation({
|
|
2083
2443
|
timestamp: new Date().toISOString(),
|
|
2084
2444
|
user: await getUserOplogInfo(),
|
|
2085
2445
|
description: 'undo operation',
|
|
2086
2446
|
eventType: 'undo',
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2447
|
+
targetOpId: targetOp.id,
|
|
2448
|
+
undoneOpId: targetOp.id, // kept for API/type compatibility
|
|
2449
|
+
changeSnapshot: redoChangeSnapshot,
|
|
2450
|
+
redoChangeSnapshot,
|
|
2451
|
+
redoFileSnapshot,
|
|
2452
|
+
redoConflictsSnapshot,
|
|
2090
2453
|
parents: [],
|
|
2091
|
-
view:
|
|
2454
|
+
view: landingView,
|
|
2092
2455
|
});
|
|
2093
2456
|
|
|
2094
|
-
|
|
2457
|
+
const restoredFileCount =
|
|
2458
|
+
reversal.fileSnapshot !== undefined ? Object.keys(reversal.fileSnapshot).length : 0;
|
|
2459
|
+
|
|
2095
2460
|
return {
|
|
2096
2461
|
undoneOperation: {
|
|
2097
|
-
description:
|
|
2098
|
-
timestamp:
|
|
2099
|
-
user:
|
|
2462
|
+
description: targetOp.description,
|
|
2463
|
+
timestamp: targetOp.timestamp,
|
|
2464
|
+
user: targetOp.user,
|
|
2100
2465
|
},
|
|
2101
2466
|
restoredState: {
|
|
2102
|
-
workingCopy:
|
|
2103
|
-
heads:
|
|
2104
|
-
fileCount:
|
|
2467
|
+
workingCopy: landingView.workingCopy,
|
|
2468
|
+
heads: landingView.heads,
|
|
2469
|
+
fileCount: restoredFileCount,
|
|
2105
2470
|
},
|
|
2106
2471
|
};
|
|
2107
2472
|
},
|
|
@@ -2109,9 +2474,9 @@ export async function createJJ(options) {
|
|
|
2109
2474
|
/**
|
|
2110
2475
|
* Redo an operation previously undone with undo() (v1.5)
|
|
2111
2476
|
*
|
|
2112
|
-
* Mirrors `jj redo` (jj v0.33):
|
|
2113
|
-
*
|
|
2114
|
-
*
|
|
2477
|
+
* Mirrors `jj redo` (jj v0.33): re-applies the operation that the most
|
|
2478
|
+
* recent not-yet-redone undo() reverted — including its graph rewrites,
|
|
2479
|
+
* not just working-copy/file state (see issue #29).
|
|
2115
2480
|
*
|
|
2116
2481
|
* @returns {Promise<Object>} Information about the redone operation
|
|
2117
2482
|
*/
|
|
@@ -2142,42 +2507,52 @@ export async function createJJ(options) {
|
|
|
2142
2507
|
});
|
|
2143
2508
|
}
|
|
2144
2509
|
|
|
2145
|
-
const
|
|
2510
|
+
const targetOpId = undoOp.targetOpId || undoOp.undoneOpId;
|
|
2511
|
+
const targetOp = targetOpId && ops.find((op) => op.id === targetOpId);
|
|
2512
|
+
const redoneView = (targetOp && targetOp.view) || {};
|
|
2146
2513
|
|
|
2147
|
-
// Restore the working-copy pointer.
|
|
2148
2514
|
if (redoneView.workingCopy) {
|
|
2149
2515
|
await workingCopy.setCurrentChange(redoneView.workingCopy);
|
|
2150
2516
|
}
|
|
2151
2517
|
|
|
2152
|
-
//
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2518
|
+
// Re-apply exactly what this undo captured right before it reverted
|
|
2519
|
+
// things — NOT computeGraphReversal(ops, targetOp): that scans for a
|
|
2520
|
+
// LATER op touching the same changeId/files/conflicts, which is the
|
|
2521
|
+
// right question for undo() ("has anything since made my target
|
|
2522
|
+
// stale?") but the wrong one here — after an undo, NOTHING later
|
|
2523
|
+
// re-established targetOp's own effect, so there's nothing to find
|
|
2524
|
+
// by scanning forward. undo() captured it directly for exactly this
|
|
2525
|
+
// reason (see its own comment).
|
|
2526
|
+
await graph.load();
|
|
2527
|
+
|
|
2528
|
+
// Capture ground truth right before overwriting, same as undo()
|
|
2529
|
+
// does for its own changeSnapshot (issue #37a) — lets a later
|
|
2530
|
+
// undo() targeting THIS redo op restore what redo() is about to
|
|
2531
|
+
// clobber, instead of finding no changeSnapshot at all.
|
|
2532
|
+
/** @type {Record<string, any>} */
|
|
2533
|
+
const undoChangeSnapshot = {};
|
|
2534
|
+
for (const changeId of Object.keys(undoOp.redoChangeSnapshot || {})) {
|
|
2535
|
+
const current = await graph.getChange(changeId);
|
|
2536
|
+
if (current) undoChangeSnapshot[changeId] = current;
|
|
2171
2537
|
}
|
|
2172
2538
|
|
|
2173
|
-
|
|
2539
|
+
for (const [changeId, snapshot] of Object.entries(undoOp.redoChangeSnapshot || {})) {
|
|
2540
|
+
if (await graph.getChange(changeId)) {
|
|
2541
|
+
// Bypass the git-sync middleware — see the matching comment in
|
|
2542
|
+
// applyGraphReversal(). `snapshot` already carries the change's
|
|
2543
|
+
// correct commitId from before the undo it's reversing ran
|
|
2544
|
+
// (issue #37c applies to redo() too).
|
|
2545
|
+
await baseGraph.updateChange(snapshot);
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
if (undoOp.redoFileSnapshot !== undefined) {
|
|
2549
|
+
await syncWorkingCopyFiles(undoOp.redoFileSnapshot);
|
|
2550
|
+
}
|
|
2174
2551
|
await conflicts.load();
|
|
2175
|
-
if (undoOp.
|
|
2176
|
-
conflicts.conflicts = new Map(
|
|
2177
|
-
Object.entries(undoOp.undoneConflictsSnapshot.conflicts || {})
|
|
2178
|
-
);
|
|
2552
|
+
if (undoOp.redoConflictsSnapshot !== undefined) {
|
|
2553
|
+
conflicts.conflicts = new Map(Object.entries(undoOp.redoConflictsSnapshot.conflicts || {}));
|
|
2179
2554
|
conflicts.fileConflicts = new Map(
|
|
2180
|
-
Object.entries(undoOp.
|
|
2555
|
+
Object.entries(undoOp.redoConflictsSnapshot.fileConflicts || {})
|
|
2181
2556
|
);
|
|
2182
2557
|
await conflicts.save();
|
|
2183
2558
|
}
|
|
@@ -2188,19 +2563,23 @@ export async function createJJ(options) {
|
|
|
2188
2563
|
description: 'redo operation',
|
|
2189
2564
|
eventType: 'redo',
|
|
2190
2565
|
redoOf: undoOp.id,
|
|
2566
|
+
changeSnapshot: undoChangeSnapshot,
|
|
2191
2567
|
parents: [],
|
|
2192
2568
|
view: redoneView,
|
|
2193
2569
|
});
|
|
2194
2570
|
|
|
2571
|
+
const restoredFileCount =
|
|
2572
|
+
undoOp.redoFileSnapshot !== undefined ? Object.keys(undoOp.redoFileSnapshot).length : 0;
|
|
2573
|
+
|
|
2195
2574
|
return {
|
|
2196
2575
|
redoneOperation: {
|
|
2197
2576
|
description: undoOp.description,
|
|
2198
|
-
undoneOpId:
|
|
2577
|
+
undoneOpId: targetOpId,
|
|
2199
2578
|
},
|
|
2200
2579
|
restoredState: {
|
|
2201
2580
|
workingCopy: redoneView.workingCopy,
|
|
2202
2581
|
heads: redoneView.heads,
|
|
2203
|
-
fileCount:
|
|
2582
|
+
fileCount: restoredFileCount,
|
|
2204
2583
|
},
|
|
2205
2584
|
};
|
|
2206
2585
|
},
|
|
@@ -2427,6 +2806,12 @@ export async function createJJ(options) {
|
|
|
2427
2806
|
/**
|
|
2428
2807
|
* Restore repository to a specific operation (matches `jj operation restore`)
|
|
2429
2808
|
*
|
|
2809
|
+
* Fully reverts everything recorded after the target operation —
|
|
2810
|
+
* including graph rewrites (squash/rebase/abandon/new/edit), working
|
|
2811
|
+
* copy files, and conflict state, not just bookmarks/the working-copy
|
|
2812
|
+
* pointer (see issue #29) — using the same reversal primitive as
|
|
2813
|
+
* undo()/redo().
|
|
2814
|
+
*
|
|
2430
2815
|
* @param {Record<string, any>} args - Arguments
|
|
2431
2816
|
* @returns {Promise<Object>} Restore result
|
|
2432
2817
|
*/
|
|
@@ -2466,12 +2851,35 @@ export async function createJJ(options) {
|
|
|
2466
2851
|
await workingCopy.setCurrentChange(targetOp.view.workingCopy);
|
|
2467
2852
|
}
|
|
2468
2853
|
|
|
2854
|
+
// Revert the change graph, working-copy files, and conflict state
|
|
2855
|
+
// to exactly what they were right after targetOp ran.
|
|
2856
|
+
const reversal = computeGraphReversal(ops, targetOp);
|
|
2857
|
+
|
|
2858
|
+
// Capture ground truth for every changeId this restore is about to
|
|
2859
|
+
// overwrite, BEFORE applying the reversal — exactly like undo()
|
|
2860
|
+
// does for its own changeSnapshot. Without this, restore()'s own
|
|
2861
|
+
// oplog entry carried no changeSnapshot at all, so undoing the
|
|
2862
|
+
// restore itself had nothing to put back: a later undo() would
|
|
2863
|
+
// repoint the working copy and files, but leave whatever the
|
|
2864
|
+
// restore had just overwritten (e.g. a change's description)
|
|
2865
|
+
// exactly as the restore left it. See issue #37(a).
|
|
2866
|
+
await graph.load();
|
|
2867
|
+
/** @type {Record<string, any>} */
|
|
2868
|
+
const changeSnapshot = {};
|
|
2869
|
+
for (const changeId of Object.keys(reversal.changeSnapshotToApply)) {
|
|
2870
|
+
const current = await graph.getChange(changeId);
|
|
2871
|
+
if (current) changeSnapshot[changeId] = current;
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
await applyGraphReversal(reversal);
|
|
2875
|
+
|
|
2469
2876
|
// Record this restoration as a new operation
|
|
2470
2877
|
await oplog.recordOperation({
|
|
2471
2878
|
timestamp: new Date().toISOString(),
|
|
2472
2879
|
user: await getUserOplogInfo(),
|
|
2473
2880
|
description: `restore to operation ${args.operation}`,
|
|
2474
2881
|
parents: [],
|
|
2882
|
+
changeSnapshot,
|
|
2475
2883
|
view: targetOp.view,
|
|
2476
2884
|
});
|
|
2477
2885
|
|
|
@@ -2757,10 +3165,53 @@ export async function createJJ(options) {
|
|
|
2757
3165
|
timestamp: new Date().toISOString(),
|
|
2758
3166
|
});
|
|
2759
3167
|
|
|
3168
|
+
// Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
|
|
3169
|
+
// reverse the squash (un-abandon source, restore dest's description
|
|
3170
|
+
// and content) — see issue #29.
|
|
3171
|
+
/** @type {Record<string, any>} */
|
|
3172
|
+
const changeSnapshot = {
|
|
3173
|
+
[source]: structuredClone(sourceChange),
|
|
3174
|
+
[destChangeId]: structuredClone(destChange),
|
|
3175
|
+
};
|
|
3176
|
+
|
|
2760
3177
|
// Mark source as abandoned
|
|
2761
3178
|
sourceChange.abandoned = true;
|
|
2762
3179
|
await graph.updateChange(sourceChange);
|
|
2763
3180
|
|
|
3181
|
+
// Merge source's file content into dest (issue #30: squash() used to
|
|
3182
|
+
// only combine descriptions, leaving dest's tree untouched). Compute
|
|
3183
|
+
// what source itself actually changed relative to ITS OWN parent
|
|
3184
|
+
// (its "diff") and fold just that onto dest, rather than overwriting
|
|
3185
|
+
// dest's whole snapshot with source's — a path source never touched
|
|
3186
|
+
// (inherited unchanged from that parent) must not clobber a
|
|
3187
|
+
// dest-specific version of the same path.
|
|
3188
|
+
const sourceParentId = sourceChange.parents && sourceChange.parents[0];
|
|
3189
|
+
const sourceParentChange = sourceParentId ? await graph.getChange(sourceParentId) : null;
|
|
3190
|
+
const sourceBaseSnapshot = (sourceParentChange && sourceParentChange.fileSnapshot) || {};
|
|
3191
|
+
const sourceSnapshot = sourceChange.fileSnapshot || {};
|
|
3192
|
+
const mergedSnapshot = { ...(destChange.fileSnapshot || {}) };
|
|
3193
|
+
const touchedPaths = new Set([
|
|
3194
|
+
...Object.keys(sourceBaseSnapshot),
|
|
3195
|
+
...Object.keys(sourceSnapshot),
|
|
3196
|
+
]);
|
|
3197
|
+
for (const filePath of touchedPaths) {
|
|
3198
|
+
const baseValue = sourceBaseSnapshot[filePath];
|
|
3199
|
+
const sourceValue = sourceSnapshot[filePath];
|
|
3200
|
+
if (sourceValue === baseValue) {
|
|
3201
|
+
// source never touched this path (inherited as-is from its own
|
|
3202
|
+
// parent) — leave whatever dest already had for it alone.
|
|
3203
|
+
continue;
|
|
3204
|
+
}
|
|
3205
|
+
if (sourceValue === undefined) {
|
|
3206
|
+
// source deleted a file it inherited from its parent.
|
|
3207
|
+
delete mergedSnapshot[filePath];
|
|
3208
|
+
} else {
|
|
3209
|
+
// source added or modified this path.
|
|
3210
|
+
mergedSnapshot[filePath] = sourceValue;
|
|
3211
|
+
}
|
|
3212
|
+
}
|
|
3213
|
+
destChange.fileSnapshot = mergedSnapshot;
|
|
3214
|
+
|
|
2764
3215
|
// Update description of dest to indicate squash (middleware will sync to Git)
|
|
2765
3216
|
destChange.description += `\n\n(squashed from ${source.slice(0, 8)})`;
|
|
2766
3217
|
await graph.updateChange(destChange);
|
|
@@ -2788,10 +3239,22 @@ export async function createJJ(options) {
|
|
|
2788
3239
|
},
|
|
2789
3240
|
description: '(no description set)',
|
|
2790
3241
|
timestamp: new Date().toISOString(),
|
|
2791
|
-
|
|
3242
|
+
// Start from the DESTINATION's tree (which now includes source's
|
|
3243
|
+
// folded-in changes, set above), not an empty object — matches
|
|
3244
|
+
// real `jj squash`, where the new working-copy change looks
|
|
3245
|
+
// identical to its new parent until you touch something. Issue
|
|
3246
|
+
// #39: this used to hard-code `{}`, so status()/snapshot() saw
|
|
3247
|
+
// every on-disk file as untracked/new until something re-wrote
|
|
3248
|
+
// it and triggered a fresh auto-snapshot.
|
|
3249
|
+
fileSnapshot: { ...destChange.fileSnapshot },
|
|
2792
3250
|
};
|
|
2793
3251
|
|
|
2794
3252
|
await graph.addChange(newChange); // Middleware will sync to Git
|
|
3253
|
+
// Sync the working directory to match (issue #30's fix for
|
|
3254
|
+
// new()/edit(); squash()'s own synthetic post-squash change never
|
|
3255
|
+
// did this) — a no-op when source's parent was already dest (the
|
|
3256
|
+
// common case), but necessary whenever source/dest aren't adjacent.
|
|
3257
|
+
await syncWorkingCopyFiles(newChange.fileSnapshot);
|
|
2795
3258
|
await workingCopy.setCurrentChange(newChangeId);
|
|
2796
3259
|
newWorkingCopyId = newChangeId;
|
|
2797
3260
|
}
|
|
@@ -2802,6 +3265,7 @@ export async function createJJ(options) {
|
|
|
2802
3265
|
user: await getUserOplogInfo(),
|
|
2803
3266
|
description: `squash ${source.slice(0, 8)} into ${destChangeId.slice(0, 8)}`,
|
|
2804
3267
|
parents: [],
|
|
3268
|
+
changeSnapshot,
|
|
2805
3269
|
view: {
|
|
2806
3270
|
bookmarks: {},
|
|
2807
3271
|
remoteBookmarks: {},
|
|
@@ -2872,6 +3336,12 @@ export async function createJJ(options) {
|
|
|
2872
3336
|
throw new JJError('CHANGE_NOT_FOUND', `Change ${changeId} not found`);
|
|
2873
3337
|
}
|
|
2874
3338
|
|
|
3339
|
+
// Captured BEFORE anything mutates — see issue #38: abandoning the
|
|
3340
|
+
// change @ currently points at must move @ off it (real jj checks
|
|
3341
|
+
// out a fresh empty change on the abandoned change's own parent(s));
|
|
3342
|
+
// abandoning any OTHER change must leave @ exactly where it was.
|
|
3343
|
+
const wasWorkingCopy = changeId === workingCopy.getCurrentChangeId();
|
|
3344
|
+
|
|
2875
3345
|
// Dispatch change:abandoning event (preventable)
|
|
2876
3346
|
await dispatchEventAsync(jj, 'change:abandoning', {
|
|
2877
3347
|
operation: 'abandon',
|
|
@@ -2880,15 +3350,29 @@ export async function createJJ(options) {
|
|
|
2880
3350
|
timestamp: new Date().toISOString(),
|
|
2881
3351
|
});
|
|
2882
3352
|
|
|
3353
|
+
// Snapshot BEFORE mutating so undo()/redo()/operations.restore() can
|
|
3354
|
+
// reverse the abandon (un-abandon this change, un-reparent children)
|
|
3355
|
+
// — see issue #29.
|
|
3356
|
+
/** @type {Record<string, any>} */
|
|
3357
|
+
const changeSnapshot = { [changeId]: structuredClone(change) };
|
|
3358
|
+
|
|
2883
3359
|
change.abandoned = true;
|
|
2884
3360
|
await graph.updateChange(change);
|
|
2885
3361
|
|
|
3362
|
+
// Re-parent children onto the abandoned change's own parent(s)
|
|
3363
|
+
// (issue #30: this used to leave children pointing at the now-
|
|
3364
|
+
// abandoned commit). Matches real `jj abandon`: the abandoned
|
|
3365
|
+
// change is elided from the graph and its children attach directly
|
|
3366
|
+
// to whatever it was attached to.
|
|
3367
|
+
await reparentChildrenOnto(changeId, change.parents, changeSnapshot);
|
|
3368
|
+
|
|
2886
3369
|
// Record operation
|
|
2887
3370
|
await oplog.recordOperation({
|
|
2888
3371
|
timestamp: new Date().toISOString(),
|
|
2889
3372
|
user: { name: user.name, email: user.email, hostname: 'localhost' },
|
|
2890
3373
|
description: `abandon change ${changeId.slice(0, 8)}`,
|
|
2891
3374
|
parents: [],
|
|
3375
|
+
changeSnapshot,
|
|
2892
3376
|
view: {
|
|
2893
3377
|
bookmarks: {},
|
|
2894
3378
|
remoteBookmarks: {},
|
|
@@ -2910,9 +3394,220 @@ export async function createJJ(options) {
|
|
|
2910
3394
|
{ cancelable: false }
|
|
2911
3395
|
);
|
|
2912
3396
|
|
|
3397
|
+
// If @ was pointing at the change we just abandoned, it can't stay
|
|
3398
|
+
// there — real jj moves @ to a new empty change on the abandoned
|
|
3399
|
+
// change's own parent(s) (issue #38). Reuse new()'s own "create an
|
|
3400
|
+
// empty change with given parents" logic rather than duplicating it
|
|
3401
|
+
// — this is exactly the workaround callers had to reach for
|
|
3402
|
+
// themselves (`new({ parents: [parentOfAt] })` before abandoning).
|
|
3403
|
+
if (wasWorkingCopy) {
|
|
3404
|
+
const newWorkingCopyChange = await jj.new({ parents: change.parents || [] });
|
|
3405
|
+
return { ...change, newWorkingCopyChangeId: newWorkingCopyChange.changeId };
|
|
3406
|
+
}
|
|
3407
|
+
|
|
2913
3408
|
return change;
|
|
2914
3409
|
},
|
|
2915
3410
|
|
|
3411
|
+
/**
|
|
3412
|
+
* Converge divergent copies of a change (matches `jj converge`, jj
|
|
3413
|
+
* v0.45.0+ — issue #32, extended for N-way per the isomorphic-jj v0.45
|
|
3414
|
+
* catch-up follow-up) — resolves ANY number of divergent copies of one
|
|
3415
|
+
* changeId in a single call. Real jj's own CLI currently only
|
|
3416
|
+
* converges one change-id per invocation (an explicit TODO in its own
|
|
3417
|
+
* source as of v0.45.1: "consider adding logic to deal with more than
|
|
3418
|
+
* one divergent change-id in one invocation"), which is what fixed
|
|
3419
|
+
* this package's own changeId-at-a-time API design — but its
|
|
3420
|
+
* underlying engine (`TruncatedEvolutionGraph`/`find_divergent_changes`)
|
|
3421
|
+
* already resolves every divergent revision of THAT one change-id
|
|
3422
|
+
* together, not just a pair. This generalizes the same per-path rule
|
|
3423
|
+
* accordingly: two-way "does the other side's value differ from base"
|
|
3424
|
+
* becomes N-way "how many DISTINCT values, across every copy, differ
|
|
3425
|
+
* from base" — 0 means nobody touched it, 1 means exactly one copy (or
|
|
3426
|
+
* several copies agreeing on the same new value) changed it and that
|
|
3427
|
+
* value wins, 2+ is a genuine conflict between that many copies.
|
|
3428
|
+
*
|
|
3429
|
+
* A change is divergent when more than one visible commit shares its
|
|
3430
|
+
* change id (see the `divergent()` revset, and
|
|
3431
|
+
* ChangeGraph.addDivergentCopy() for how a divergent copy gets added
|
|
3432
|
+
* in the first place — nothing in this library creates one on its
|
|
3433
|
+
* own yet; this resolves one once something has, e.g. a future git
|
|
3434
|
+
* fetch/concurrent-operation integration).
|
|
3435
|
+
*
|
|
3436
|
+
* Non-interactive-mode convention, matching this package's other
|
|
3437
|
+
* ambiguous-outcome APIs: like merge(), a genuine per-path conflict is
|
|
3438
|
+
* returned as data (`{ resolved: false, conflicts }`), not thrown —
|
|
3439
|
+
* you get a result back either way and decide what to do with
|
|
3440
|
+
* unresolved conflicts. What DOES throw is anything that isn't even a
|
|
3441
|
+
* well-formed convergence attempt (no divergence to resolve at all) —
|
|
3442
|
+
* the same category as merge()'s own `MERGE_ERROR` throw for "no
|
|
3443
|
+
* common ancestor found".
|
|
3444
|
+
*
|
|
3445
|
+
* @param {Record<string, any>|string} args - `{ changeId }`, or a bare changeId string
|
|
3446
|
+
* @returns {Promise<Object>} `{ changeId, resolved, conflicts, commitId? }`
|
|
3447
|
+
*/
|
|
3448
|
+
async converge(args) {
|
|
3449
|
+
const changeId = typeof args === 'string' ? args : args && (args.changeId || args.change);
|
|
3450
|
+
if (!changeId || typeof changeId !== 'string') {
|
|
3451
|
+
throw new JJError('INVALID_ARGUMENT', 'Missing changeId argument', {
|
|
3452
|
+
suggestion: 'Provide the divergent changeId to converge: { changeId: "..." }',
|
|
3453
|
+
});
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3456
|
+
await graph.load();
|
|
3457
|
+
await userConfig.load();
|
|
3458
|
+
const siblings = graph.getDivergentSiblings(changeId);
|
|
3459
|
+
|
|
3460
|
+
if (siblings.length < 2) {
|
|
3461
|
+
throw new JJError(
|
|
3462
|
+
'NOT_DIVERGENT',
|
|
3463
|
+
`Change ${changeId} has no divergent copies to converge`,
|
|
3464
|
+
{
|
|
3465
|
+
changeId,
|
|
3466
|
+
suggestion:
|
|
3467
|
+
'converge() is only meaningful for a changeId the divergent() revset reports',
|
|
3468
|
+
}
|
|
3469
|
+
);
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
// The primary copy is whichever one getChange() resolves (stored
|
|
3473
|
+
// under the plain changeId key); every other one is a divergent copy
|
|
3474
|
+
// added via addDivergentCopy(). No cap on how many — see the doc
|
|
3475
|
+
// comment above.
|
|
3476
|
+
const primary = /** @type {any} */ (await graph.getChange(changeId));
|
|
3477
|
+
const others = siblings.filter((/** @type {any} */ s) => s.commitId !== primary.commitId);
|
|
3478
|
+
|
|
3479
|
+
// Base for the N-way merge: what this change looked like before it
|
|
3480
|
+
// diverged. This package doesn't retain historical snapshots (only
|
|
3481
|
+
// current content), so — same pragmatic choice rebase() makes for
|
|
3482
|
+
// "old base" — use the shared parent's current content; divergent
|
|
3483
|
+
// copies realistically share the same parent, since they're all
|
|
3484
|
+
// rewrites of one prior position in history, not unrelated commits
|
|
3485
|
+
// that happen to collide. Applied uniformly to every copy, not
|
|
3486
|
+
// per-copy — the same simplification the original two-way version
|
|
3487
|
+
// made, just not newly introduced by going N-way.
|
|
3488
|
+
const baseParentId = primary.parents && primary.parents[0];
|
|
3489
|
+
const baseParentChange = baseParentId ? await graph.getChange(baseParentId) : null;
|
|
3490
|
+
const baseFiles = new Map(
|
|
3491
|
+
Object.entries((baseParentChange && baseParentChange.fileSnapshot) || {})
|
|
3492
|
+
);
|
|
3493
|
+
|
|
3494
|
+
// Every copy (primary + others), each as its own file map, paired
|
|
3495
|
+
// with its commitId for conflict reporting.
|
|
3496
|
+
const copies = [primary, ...others].map((/** @type {any} */ c) => ({
|
|
3497
|
+
commitId: c.commitId,
|
|
3498
|
+
files: new Map(Object.entries(c.fileSnapshot || {})),
|
|
3499
|
+
}));
|
|
3500
|
+
|
|
3501
|
+
await conflicts.load();
|
|
3502
|
+
const conflictsSnapshot = {
|
|
3503
|
+
conflicts: Object.fromEntries(conflicts.conflicts),
|
|
3504
|
+
fileConflicts: Object.fromEntries(conflicts.fileConflicts),
|
|
3505
|
+
};
|
|
3506
|
+
|
|
3507
|
+
const allPaths = new Set([
|
|
3508
|
+
...baseFiles.keys(),
|
|
3509
|
+
...copies.flatMap((c) => [...c.files.keys()]),
|
|
3510
|
+
]);
|
|
3511
|
+
|
|
3512
|
+
/** @type {Record<string, string>} */
|
|
3513
|
+
const mergedSnapshot = {};
|
|
3514
|
+
/** @type {any[]} */
|
|
3515
|
+
const detectedConflicts = [];
|
|
3516
|
+
|
|
3517
|
+
for (const filePath of allPaths) {
|
|
3518
|
+
const baseValue = baseFiles.get(filePath);
|
|
3519
|
+
// Every DISTINCT value across all copies that differs from base,
|
|
3520
|
+
// each paired with (one of) the commitId(s) that has it — mirrors
|
|
3521
|
+
// ConflictModel._detectPathConflict's two-way "no conflict" rules,
|
|
3522
|
+
// generalized: 0 distinct changed values means nobody touched it
|
|
3523
|
+
// (base wins, i.e. nothing to write), 1 means only one side (or
|
|
3524
|
+
// several copies agreeing) changed it (that value wins), 2+ is a
|
|
3525
|
+
// genuine conflict.
|
|
3526
|
+
const changed = new Map();
|
|
3527
|
+
for (const copy of copies) {
|
|
3528
|
+
const value = copy.files.get(filePath);
|
|
3529
|
+
if (value !== baseValue && !changed.has(value)) {
|
|
3530
|
+
changed.set(value, copy.commitId);
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3533
|
+
|
|
3534
|
+
if (changed.size === 0) {
|
|
3535
|
+
if (baseValue !== undefined) mergedSnapshot[filePath] = baseValue;
|
|
3536
|
+
} else if (changed.size === 1) {
|
|
3537
|
+
const [[value]] = changed;
|
|
3538
|
+
if (value !== undefined) mergedSnapshot[filePath] = value;
|
|
3539
|
+
} else {
|
|
3540
|
+
const versions = [...changed.entries()].map(([content, commitId]) => ({
|
|
3541
|
+
commitId,
|
|
3542
|
+
content,
|
|
3543
|
+
}));
|
|
3544
|
+
detectedConflicts.push(conflicts.createNWayConflict(filePath, baseValue, versions));
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
3547
|
+
|
|
3548
|
+
if (detectedConflicts.length > 0) {
|
|
3549
|
+
// Matches real jj: non-interactive mode aborts automatic
|
|
3550
|
+
// resolution rather than guessing — but per this package's own
|
|
3551
|
+
// merge() convention, that's reported back as data, not thrown.
|
|
3552
|
+
for (const conflict of detectedConflicts) {
|
|
3553
|
+
await conflicts.addConflict(conflict);
|
|
3554
|
+
}
|
|
3555
|
+
await oplog.recordOperation({
|
|
3556
|
+
timestamp: new Date().toISOString(),
|
|
3557
|
+
user: await getUserOplogInfo(),
|
|
3558
|
+
description: `converge ${changeId.slice(0, 8)} (unresolved)`,
|
|
3559
|
+
parents: [],
|
|
3560
|
+
conflictsSnapshot,
|
|
3561
|
+
view: {
|
|
3562
|
+
bookmarks: {},
|
|
3563
|
+
remoteBookmarks: {},
|
|
3564
|
+
heads: [],
|
|
3565
|
+
workingCopy: workingCopy.getCurrentChangeId(),
|
|
3566
|
+
},
|
|
3567
|
+
});
|
|
3568
|
+
return { changeId, resolved: false, conflicts: detectedConflicts };
|
|
3569
|
+
}
|
|
3570
|
+
|
|
3571
|
+
const user = /** @type {any} */ (userConfig.getUser());
|
|
3572
|
+
const changeSnapshot = { [changeId]: structuredClone(primary) };
|
|
3573
|
+
const otherIds = others.map((/** @type {any} */ o) => o.commitId.slice(0, 8)).join(', ');
|
|
3574
|
+
|
|
3575
|
+
const convergedChange = {
|
|
3576
|
+
...primary,
|
|
3577
|
+
commitId: randomHex(20),
|
|
3578
|
+
fileSnapshot: mergedSnapshot,
|
|
3579
|
+
description: primary.description + `\n\n(converged divergent copies: ${otherIds})`,
|
|
3580
|
+
timestamp: new Date().toISOString(),
|
|
3581
|
+
committer: {
|
|
3582
|
+
name: user.name,
|
|
3583
|
+
email: user.email,
|
|
3584
|
+
timestamp: new Date().toISOString(),
|
|
3585
|
+
},
|
|
3586
|
+
divergent: false,
|
|
3587
|
+
};
|
|
3588
|
+
await graph.updateChange(convergedChange);
|
|
3589
|
+
for (const other of others) {
|
|
3590
|
+
await graph.deleteDivergentCopy(changeId, other.commitId);
|
|
3591
|
+
}
|
|
3592
|
+
|
|
3593
|
+
await oplog.recordOperation({
|
|
3594
|
+
timestamp: new Date().toISOString(),
|
|
3595
|
+
user: await getUserOplogInfo(),
|
|
3596
|
+
description: `converge ${changeId.slice(0, 8)}`,
|
|
3597
|
+
parents: [],
|
|
3598
|
+
changeSnapshot,
|
|
3599
|
+
conflictsSnapshot,
|
|
3600
|
+
view: {
|
|
3601
|
+
bookmarks: {},
|
|
3602
|
+
remoteBookmarks: {},
|
|
3603
|
+
heads: [changeId],
|
|
3604
|
+
workingCopy: workingCopy.getCurrentChangeId(),
|
|
3605
|
+
},
|
|
3606
|
+
});
|
|
3607
|
+
|
|
3608
|
+
return { changeId, resolved: true, conflicts: [], commitId: convergedChange.commitId };
|
|
3609
|
+
},
|
|
3610
|
+
|
|
2916
3611
|
/**
|
|
2917
3612
|
* Backout a change - create a new change that reverses the effect of the specified change
|
|
2918
3613
|
*
|
|
@@ -4752,8 +5447,14 @@ export async function createJJ(options) {
|
|
|
4752
5447
|
* @returns {Promise<Object>} Repository root information
|
|
4753
5448
|
*/
|
|
4754
5449
|
async root() {
|
|
4755
|
-
//
|
|
4756
|
-
|
|
5450
|
+
// `dir` is a real host filesystem path (unlike the repo-relative
|
|
5451
|
+
// git/jj paths `path` — posix-path.js — is for), so it needs the
|
|
5452
|
+
// host's own separator here, not always '/': on Windows Node's real
|
|
5453
|
+
// `fs` still resolves a forward-slash-joined path fine, but this
|
|
5454
|
+
// method hands the string back to the caller as data, and jj's own
|
|
5455
|
+
// `git root` returns a platform-native path there.
|
|
5456
|
+
const isWin32 = typeof process !== 'undefined' && process.platform === 'win32';
|
|
5457
|
+
const gitDir = isWin32 ? `${dir.replace(/[\\/]+$/, '')}\\.git` : path.join(dir, '.git');
|
|
4757
5458
|
|
|
4758
5459
|
try {
|
|
4759
5460
|
await fs.promises.access(gitDir);
|
|
@@ -4832,7 +5533,7 @@ export async function createJJ(options) {
|
|
|
4832
5533
|
const wcFiles = await workingCopy.listFiles();
|
|
4833
5534
|
for (const file of wcFiles) {
|
|
4834
5535
|
try {
|
|
4835
|
-
const content = await fs.promises.readFile(path.join(dir, file), '
|
|
5536
|
+
const content = await fs.promises.readFile(path.join(dir, file), 'utf8');
|
|
4836
5537
|
leftFiles.set(file, content);
|
|
4837
5538
|
} catch (error) {
|
|
4838
5539
|
// File might be deleted or binary - throw error for explicit handling
|
|
@@ -4975,7 +5676,7 @@ export async function createJJ(options) {
|
|
|
4975
5676
|
}
|
|
4976
5677
|
|
|
4977
5678
|
// Write resolved content to file
|
|
4978
|
-
await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, '
|
|
5679
|
+
await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf8');
|
|
4979
5680
|
|
|
4980
5681
|
// Mark conflict as resolved
|
|
4981
5682
|
await conflicts.resolveConflict(args.conflictId, 'manual');
|
|
@@ -5018,7 +5719,7 @@ export async function createJJ(options) {
|
|
|
5018
5719
|
const resolvedContent = _resolveWithStrategy(conflict, args.strategy);
|
|
5019
5720
|
|
|
5020
5721
|
// Write resolved content
|
|
5021
|
-
await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, '
|
|
5722
|
+
await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf8');
|
|
5022
5723
|
|
|
5023
5724
|
// Mark as resolved
|
|
5024
5725
|
await conflicts.resolveConflict(conflict.conflictId, args.strategy);
|
|
@@ -5458,7 +6159,7 @@ export async function createJJ(options) {
|
|
|
5458
6159
|
if (pathParts.length > 1) {
|
|
5459
6160
|
const dirPath = pathParts.slice(0, -1).join('/');
|
|
5460
6161
|
const fullDirPath = path.join(args.path, dirPath);
|
|
5461
|
-
await fs
|
|
6162
|
+
await mkdirp(fs, fullDirPath);
|
|
5462
6163
|
}
|
|
5463
6164
|
|
|
5464
6165
|
// Write file content
|