isomorphic-jj 1.8.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 +222 -0
- package/LICENSE +1 -1
- package/README.md +117 -30
- package/dist/index.cjs +1911 -344
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +1911 -343
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -7
- package/src/api/repository.js +775 -158
- 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 +15 -3
- package/src/core/storage-manager.js +9 -8
- package/src/core/tag-store.js +2 -5
- 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,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
|
/**
|
|
@@ -1208,7 +1540,7 @@ export async function createJJ(options) {
|
|
|
1208
1540
|
break;
|
|
1209
1541
|
}
|
|
1210
1542
|
|
|
1211
|
-
const content = await fs.promises.readFile(fullPath, '
|
|
1543
|
+
const content = await fs.promises.readFile(fullPath, 'utf8');
|
|
1212
1544
|
fileSnapshot[filePath] = content;
|
|
1213
1545
|
totalSnapshotSize += stats.size;
|
|
1214
1546
|
} catch (error) {
|
|
@@ -1406,6 +1738,8 @@ export async function createJJ(options) {
|
|
|
1406
1738
|
|
|
1407
1739
|
// Snapshot filesystem BEFORE operation (for undo) unless preserveSnapshot is true
|
|
1408
1740
|
let fileSnapshot;
|
|
1741
|
+
/** @type {Record<string, any>} */
|
|
1742
|
+
const changeSnapshot = {};
|
|
1409
1743
|
if (!args.preserveSnapshot) {
|
|
1410
1744
|
fileSnapshot = await snapshotFilesystem();
|
|
1411
1745
|
|
|
@@ -1414,6 +1748,10 @@ export async function createJJ(options) {
|
|
|
1414
1748
|
const currentChangeId = workingCopy.getCurrentChangeId();
|
|
1415
1749
|
const currentChange = await graph.getChange(currentChangeId);
|
|
1416
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);
|
|
1417
1755
|
currentChange.fileSnapshot = fileSnapshot;
|
|
1418
1756
|
await graph.updateChange(currentChange);
|
|
1419
1757
|
}
|
|
@@ -1441,6 +1779,7 @@ export async function createJJ(options) {
|
|
|
1441
1779
|
const user = /** @type {any} */ (userConfig.getUser());
|
|
1442
1780
|
|
|
1443
1781
|
// Initialize with parent's file snapshot
|
|
1782
|
+
/** @type {Record<string, string>} */
|
|
1444
1783
|
let initialSnapshot = {};
|
|
1445
1784
|
if (parents && parents.length > 0) {
|
|
1446
1785
|
const parent = await graph.getChange(parents[0]);
|
|
@@ -1484,10 +1823,19 @@ export async function createJJ(options) {
|
|
|
1484
1823
|
// Handle insertBefore: rebase target to have new change as parent
|
|
1485
1824
|
if (args.insertBefore) {
|
|
1486
1825
|
const targetChange = await graph.getChange(args.insertBefore);
|
|
1826
|
+
changeSnapshot[args.insertBefore] = structuredClone(targetChange);
|
|
1487
1827
|
targetChange.parents = [newChangeId];
|
|
1488
1828
|
await graph.updateChange(targetChange);
|
|
1489
1829
|
}
|
|
1490
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
|
+
|
|
1491
1839
|
await workingCopy.setCurrentChange(newChangeId);
|
|
1492
1840
|
|
|
1493
1841
|
// Record operation with filesystem snapshot
|
|
@@ -1500,6 +1848,13 @@ export async function createJJ(options) {
|
|
|
1500
1848
|
},
|
|
1501
1849
|
description: `new change ${newChangeId.slice(0, 8)}`,
|
|
1502
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,
|
|
1503
1858
|
view: {
|
|
1504
1859
|
bookmarks: {},
|
|
1505
1860
|
remoteBookmarks: {},
|
|
@@ -1880,6 +2235,13 @@ export async function createJJ(options) {
|
|
|
1880
2235
|
|
|
1881
2236
|
const previousChangeId = workingCopy.getCurrentChangeId();
|
|
1882
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
|
+
|
|
1883
2245
|
// CRITICAL FIX: Clean working directory BEFORE snapshotting to prevent cross-branch pollution
|
|
1884
2246
|
// Remove files that don't belong to the current changeId before we snapshot it
|
|
1885
2247
|
if (previousChangeId !== args.changeId) {
|
|
@@ -1901,13 +2263,14 @@ export async function createJJ(options) {
|
|
|
1901
2263
|
}
|
|
1902
2264
|
}
|
|
1903
2265
|
}
|
|
1904
|
-
}
|
|
1905
2266
|
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
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
|
+
}
|
|
1911
2274
|
}
|
|
1912
2275
|
}
|
|
1913
2276
|
|
|
@@ -1920,38 +2283,17 @@ export async function createJJ(options) {
|
|
|
1920
2283
|
timestamp: new Date().toISOString(),
|
|
1921
2284
|
});
|
|
1922
2285
|
|
|
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
|
-
}
|
|
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
|
+
});
|
|
1955
2297
|
}
|
|
1956
2298
|
|
|
1957
2299
|
// Set this change as the working copy
|
|
@@ -1967,11 +2309,13 @@ export async function createJJ(options) {
|
|
|
1967
2309
|
},
|
|
1968
2310
|
description: `edit change ${args.changeId.slice(0, 8)}`,
|
|
1969
2311
|
parents: [],
|
|
2312
|
+
changeSnapshot,
|
|
1970
2313
|
view: {
|
|
1971
2314
|
bookmarks: {},
|
|
1972
2315
|
remoteBookmarks: {},
|
|
1973
2316
|
heads: [args.changeId],
|
|
1974
2317
|
workingCopy: args.changeId,
|
|
2318
|
+
fileSnapshot: fileSnapshotBeforeSwitch,
|
|
1975
2319
|
},
|
|
1976
2320
|
});
|
|
1977
2321
|
|
|
@@ -2002,106 +2346,107 @@ export async function createJJ(options) {
|
|
|
2002
2346
|
/**
|
|
2003
2347
|
* Undo last operation
|
|
2004
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
|
+
*
|
|
2005
2355
|
* @returns {Promise<Object>} Information about the undo including the undone operation and restored state
|
|
2006
2356
|
*/
|
|
2007
2357
|
async undo() {
|
|
2008
|
-
// Get the operation we're about to undo to access its pre-state
|
|
2009
2358
|
await oplog.load();
|
|
2010
2359
|
const ops = await oplog.list();
|
|
2011
2360
|
if (ops.length === 0) {
|
|
2012
2361
|
throw new JJError('NOTHING_TO_UNDO', 'No operations to undo');
|
|
2013
2362
|
}
|
|
2014
2363
|
|
|
2015
|
-
|
|
2016
|
-
const
|
|
2017
|
-
|
|
2018
|
-
const previousView = /** @type {any} */ (await oplog.undo());
|
|
2364
|
+
const targetOp = resolveUndoTarget(ops);
|
|
2365
|
+
const landingOpId = targetOp.parents && targetOp.parents[0];
|
|
2366
|
+
const landingOp = landingOpId ? ops.find((op) => op.id === landingOpId) : null;
|
|
2019
2367
|
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
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.
|
|
2027
2396
|
await graph.load();
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
)
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
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
|
-
}
|
|
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
|
+
};
|
|
2063
2412
|
}
|
|
2064
2413
|
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
await conflicts.load();
|
|
2068
|
-
if (currentOp && currentOp.conflictsSnapshot) {
|
|
2069
|
-
// Restore conflicts from the operation we're undoing (its pre-state)
|
|
2070
|
-
conflicts.conflicts = new Map(Object.entries(currentOp.conflictsSnapshot.conflicts || {}));
|
|
2071
|
-
conflicts.fileConflicts = new Map(
|
|
2072
|
-
Object.entries(currentOp.conflictsSnapshot.fileConflicts || {})
|
|
2073
|
-
);
|
|
2074
|
-
await conflicts.save();
|
|
2075
|
-
} else {
|
|
2076
|
-
// No conflicts snapshot - clear conflicts
|
|
2077
|
-
await conflicts.clear();
|
|
2414
|
+
if (landingView.workingCopy) {
|
|
2415
|
+
await workingCopy.setCurrentChange(landingView.workingCopy);
|
|
2078
2416
|
}
|
|
2417
|
+
await applyGraphReversal(reversal);
|
|
2079
2418
|
|
|
2080
|
-
// Record undo
|
|
2081
|
-
//
|
|
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).
|
|
2082
2423
|
await oplog.recordOperation({
|
|
2083
2424
|
timestamp: new Date().toISOString(),
|
|
2084
2425
|
user: await getUserOplogInfo(),
|
|
2085
2426
|
description: 'undo operation',
|
|
2086
2427
|
eventType: 'undo',
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2428
|
+
targetOpId: targetOp.id,
|
|
2429
|
+
undoneOpId: targetOp.id, // kept for API/type compatibility
|
|
2430
|
+
redoChangeSnapshot,
|
|
2431
|
+
redoFileSnapshot,
|
|
2432
|
+
redoConflictsSnapshot,
|
|
2090
2433
|
parents: [],
|
|
2091
|
-
view:
|
|
2434
|
+
view: landingView,
|
|
2092
2435
|
});
|
|
2093
2436
|
|
|
2094
|
-
|
|
2437
|
+
const restoredFileCount =
|
|
2438
|
+
reversal.fileSnapshot !== undefined ? Object.keys(reversal.fileSnapshot).length : 0;
|
|
2439
|
+
|
|
2095
2440
|
return {
|
|
2096
2441
|
undoneOperation: {
|
|
2097
|
-
description:
|
|
2098
|
-
timestamp:
|
|
2099
|
-
user:
|
|
2442
|
+
description: targetOp.description,
|
|
2443
|
+
timestamp: targetOp.timestamp,
|
|
2444
|
+
user: targetOp.user,
|
|
2100
2445
|
},
|
|
2101
2446
|
restoredState: {
|
|
2102
|
-
workingCopy:
|
|
2103
|
-
heads:
|
|
2104
|
-
fileCount:
|
|
2447
|
+
workingCopy: landingView.workingCopy,
|
|
2448
|
+
heads: landingView.heads,
|
|
2449
|
+
fileCount: restoredFileCount,
|
|
2105
2450
|
},
|
|
2106
2451
|
};
|
|
2107
2452
|
},
|
|
@@ -2109,9 +2454,9 @@ export async function createJJ(options) {
|
|
|
2109
2454
|
/**
|
|
2110
2455
|
* Redo an operation previously undone with undo() (v1.5)
|
|
2111
2456
|
*
|
|
2112
|
-
* Mirrors `jj redo` (jj v0.33):
|
|
2113
|
-
*
|
|
2114
|
-
*
|
|
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).
|
|
2115
2460
|
*
|
|
2116
2461
|
* @returns {Promise<Object>} Information about the redone operation
|
|
2117
2462
|
*/
|
|
@@ -2142,42 +2487,36 @@ export async function createJJ(options) {
|
|
|
2142
2487
|
});
|
|
2143
2488
|
}
|
|
2144
2489
|
|
|
2145
|
-
const
|
|
2490
|
+
const targetOpId = undoOp.targetOpId || undoOp.undoneOpId;
|
|
2491
|
+
const targetOp = targetOpId && ops.find((op) => op.id === targetOpId);
|
|
2492
|
+
const redoneView = (targetOp && targetOp.view) || {};
|
|
2146
2493
|
|
|
2147
|
-
// Restore the working-copy pointer.
|
|
2148
2494
|
if (redoneView.workingCopy) {
|
|
2149
2495
|
await workingCopy.setCurrentChange(redoneView.workingCopy);
|
|
2150
2496
|
}
|
|
2151
2497
|
|
|
2152
|
-
//
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
throw new JJError(
|
|
2165
|
-
'REDO_FILE_RESTORE_FAILED',
|
|
2166
|
-
`Failed to restore file ${filePath} during redo: ${error.message}`,
|
|
2167
|
-
{ filePath, originalError: error.message }
|
|
2168
|
-
);
|
|
2169
|
-
}
|
|
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);
|
|
2170
2510
|
}
|
|
2171
2511
|
}
|
|
2172
|
-
|
|
2173
|
-
|
|
2512
|
+
if (undoOp.redoFileSnapshot !== undefined) {
|
|
2513
|
+
await syncWorkingCopyFiles(undoOp.redoFileSnapshot);
|
|
2514
|
+
}
|
|
2174
2515
|
await conflicts.load();
|
|
2175
|
-
if (undoOp.
|
|
2176
|
-
conflicts.conflicts = new Map(
|
|
2177
|
-
Object.entries(undoOp.undoneConflictsSnapshot.conflicts || {})
|
|
2178
|
-
);
|
|
2516
|
+
if (undoOp.redoConflictsSnapshot !== undefined) {
|
|
2517
|
+
conflicts.conflicts = new Map(Object.entries(undoOp.redoConflictsSnapshot.conflicts || {}));
|
|
2179
2518
|
conflicts.fileConflicts = new Map(
|
|
2180
|
-
Object.entries(undoOp.
|
|
2519
|
+
Object.entries(undoOp.redoConflictsSnapshot.fileConflicts || {})
|
|
2181
2520
|
);
|
|
2182
2521
|
await conflicts.save();
|
|
2183
2522
|
}
|
|
@@ -2192,15 +2531,18 @@ export async function createJJ(options) {
|
|
|
2192
2531
|
view: redoneView,
|
|
2193
2532
|
});
|
|
2194
2533
|
|
|
2534
|
+
const restoredFileCount =
|
|
2535
|
+
undoOp.redoFileSnapshot !== undefined ? Object.keys(undoOp.redoFileSnapshot).length : 0;
|
|
2536
|
+
|
|
2195
2537
|
return {
|
|
2196
2538
|
redoneOperation: {
|
|
2197
2539
|
description: undoOp.description,
|
|
2198
|
-
undoneOpId:
|
|
2540
|
+
undoneOpId: targetOpId,
|
|
2199
2541
|
},
|
|
2200
2542
|
restoredState: {
|
|
2201
2543
|
workingCopy: redoneView.workingCopy,
|
|
2202
2544
|
heads: redoneView.heads,
|
|
2203
|
-
fileCount:
|
|
2545
|
+
fileCount: restoredFileCount,
|
|
2204
2546
|
},
|
|
2205
2547
|
};
|
|
2206
2548
|
},
|
|
@@ -2427,6 +2769,12 @@ export async function createJJ(options) {
|
|
|
2427
2769
|
/**
|
|
2428
2770
|
* Restore repository to a specific operation (matches `jj operation restore`)
|
|
2429
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
|
+
*
|
|
2430
2778
|
* @param {Record<string, any>} args - Arguments
|
|
2431
2779
|
* @returns {Promise<Object>} Restore result
|
|
2432
2780
|
*/
|
|
@@ -2466,6 +2814,11 @@ export async function createJJ(options) {
|
|
|
2466
2814
|
await workingCopy.setCurrentChange(targetOp.view.workingCopy);
|
|
2467
2815
|
}
|
|
2468
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
|
+
|
|
2469
2822
|
// Record this restoration as a new operation
|
|
2470
2823
|
await oplog.recordOperation({
|
|
2471
2824
|
timestamp: new Date().toISOString(),
|
|
@@ -2757,10 +3110,53 @@ export async function createJJ(options) {
|
|
|
2757
3110
|
timestamp: new Date().toISOString(),
|
|
2758
3111
|
});
|
|
2759
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
|
+
|
|
2760
3122
|
// Mark source as abandoned
|
|
2761
3123
|
sourceChange.abandoned = true;
|
|
2762
3124
|
await graph.updateChange(sourceChange);
|
|
2763
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
|
+
|
|
2764
3160
|
// Update description of dest to indicate squash (middleware will sync to Git)
|
|
2765
3161
|
destChange.description += `\n\n(squashed from ${source.slice(0, 8)})`;
|
|
2766
3162
|
await graph.updateChange(destChange);
|
|
@@ -2802,6 +3198,7 @@ export async function createJJ(options) {
|
|
|
2802
3198
|
user: await getUserOplogInfo(),
|
|
2803
3199
|
description: `squash ${source.slice(0, 8)} into ${destChangeId.slice(0, 8)}`,
|
|
2804
3200
|
parents: [],
|
|
3201
|
+
changeSnapshot,
|
|
2805
3202
|
view: {
|
|
2806
3203
|
bookmarks: {},
|
|
2807
3204
|
remoteBookmarks: {},
|
|
@@ -2880,15 +3277,29 @@ export async function createJJ(options) {
|
|
|
2880
3277
|
timestamp: new Date().toISOString(),
|
|
2881
3278
|
});
|
|
2882
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
|
+
|
|
2883
3286
|
change.abandoned = true;
|
|
2884
3287
|
await graph.updateChange(change);
|
|
2885
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
|
+
|
|
2886
3296
|
// Record operation
|
|
2887
3297
|
await oplog.recordOperation({
|
|
2888
3298
|
timestamp: new Date().toISOString(),
|
|
2889
3299
|
user: { name: user.name, email: user.email, hostname: 'localhost' },
|
|
2890
3300
|
description: `abandon change ${changeId.slice(0, 8)}`,
|
|
2891
3301
|
parents: [],
|
|
3302
|
+
changeSnapshot,
|
|
2892
3303
|
view: {
|
|
2893
3304
|
bookmarks: {},
|
|
2894
3305
|
remoteBookmarks: {},
|
|
@@ -2913,6 +3324,206 @@ export async function createJJ(options) {
|
|
|
2913
3324
|
return change;
|
|
2914
3325
|
},
|
|
2915
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
|
+
|
|
2916
3527
|
/**
|
|
2917
3528
|
* Backout a change - create a new change that reverses the effect of the specified change
|
|
2918
3529
|
*
|
|
@@ -4752,8 +5363,14 @@ export async function createJJ(options) {
|
|
|
4752
5363
|
* @returns {Promise<Object>} Repository root information
|
|
4753
5364
|
*/
|
|
4754
5365
|
async root() {
|
|
4755
|
-
//
|
|
4756
|
-
|
|
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');
|
|
4757
5374
|
|
|
4758
5375
|
try {
|
|
4759
5376
|
await fs.promises.access(gitDir);
|
|
@@ -4832,7 +5449,7 @@ export async function createJJ(options) {
|
|
|
4832
5449
|
const wcFiles = await workingCopy.listFiles();
|
|
4833
5450
|
for (const file of wcFiles) {
|
|
4834
5451
|
try {
|
|
4835
|
-
const content = await fs.promises.readFile(path.join(dir, file), '
|
|
5452
|
+
const content = await fs.promises.readFile(path.join(dir, file), 'utf8');
|
|
4836
5453
|
leftFiles.set(file, content);
|
|
4837
5454
|
} catch (error) {
|
|
4838
5455
|
// File might be deleted or binary - throw error for explicit handling
|
|
@@ -4975,7 +5592,7 @@ export async function createJJ(options) {
|
|
|
4975
5592
|
}
|
|
4976
5593
|
|
|
4977
5594
|
// Write resolved content to file
|
|
4978
|
-
await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, '
|
|
5595
|
+
await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf8');
|
|
4979
5596
|
|
|
4980
5597
|
// Mark conflict as resolved
|
|
4981
5598
|
await conflicts.resolveConflict(args.conflictId, 'manual');
|
|
@@ -5018,7 +5635,7 @@ export async function createJJ(options) {
|
|
|
5018
5635
|
const resolvedContent = _resolveWithStrategy(conflict, args.strategy);
|
|
5019
5636
|
|
|
5020
5637
|
// Write resolved content
|
|
5021
|
-
await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, '
|
|
5638
|
+
await fs.promises.writeFile(path.join(dir, conflict.path), resolvedContent, 'utf8');
|
|
5022
5639
|
|
|
5023
5640
|
// Mark as resolved
|
|
5024
5641
|
await conflicts.resolveConflict(conflict.conflictId, args.strategy);
|
|
@@ -5458,7 +6075,7 @@ export async function createJJ(options) {
|
|
|
5458
6075
|
if (pathParts.length > 1) {
|
|
5459
6076
|
const dirPath = pathParts.slice(0, -1).join('/');
|
|
5460
6077
|
const fullDirPath = path.join(args.path, dirPath);
|
|
5461
|
-
await fs
|
|
6078
|
+
await mkdirp(fs, fullDirPath);
|
|
5462
6079
|
}
|
|
5463
6080
|
|
|
5464
6081
|
// Write file content
|