53.4 KB1472 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {RepoPath} from 'shared/types/common';
9import type {ExportCommit, ExportFile, ExportStack} from 'shared/types/stack';
10import type {CommitRev, FileRev} from '../commitStackState';
11
12import {Map as ImMap, Set as ImSet, List} from 'immutable';
13import {nullthrows} from 'shared/utils';
14import {WDIR_NODE} from '../../dag/virtualCommit';
15import {
16 ABSENT_FILE,
17 CommitIdx,
18 CommitStackState,
19 CommitState,
20 FileIdx,
21 FileState,
22} from '../commitStackState';
23import {FileStackState} from '../fileStackState';
24import {describeAbsorbIdChunkMap} from './absorb.test';
25
26export const exportCommitDefault: ExportCommit = {
27 requested: true,
28 immutable: false,
29 author: 'test <test@example.com>',
30 date: [0, 0],
31 node: '',
32 text: '',
33};
34
35// In this test we tend to use uppercase for commits (ex. A, B, C),
36// and lowercase for files (ex. x, y, z).
37
38/**
39 * Created by `drawdag --no-files`:
40 *
41 * C # C/z.txt=(removed)
42 * |
43 * B # B/y.txt=33 (renamed from x.txt)
44 * |
45 * A # A/x.txt=33
46 * | # A/z.txt=22
47 * /
48 * Z # Z/z.txt=11
49 *
50 * and exported via `debugexportstack -r 'desc(A)::'`.
51 */
52const exportStack1: ExportStack = [
53 {
54 ...exportCommitDefault,
55 immutable: true,
56 node: 'Z_NODE',
57 relevantFiles: {
58 'x.txt': null,
59 'z.txt': {data: '11'},
60 },
61 requested: false,
62 text: 'Z',
63 },
64 {
65 ...exportCommitDefault,
66 files: {
67 'x.txt': {data: '33'},
68 'z.txt': {data: '22'},
69 },
70 node: 'A_NODE',
71 parents: ['Z_NODE'],
72 relevantFiles: {'y.txt': null},
73 text: 'A',
74 },
75 {
76 ...exportCommitDefault,
77 files: {
78 'x.txt': null,
79 'y.txt': {copyFrom: 'x.txt', data: '33'},
80 },
81 node: 'B_NODE',
82 parents: ['A_NODE'],
83 relevantFiles: {'z.txt': {data: '22'}},
84 text: 'B',
85 },
86 {
87 ...exportCommitDefault,
88 date: [0.0, 0],
89 files: {'z.txt': null},
90 node: 'C_NODE',
91 parents: ['B_NODE'],
92 text: 'C',
93 },
94];
95
96/** Construct `CommitStackState` from a stack of files for testing purpose. */
97export function linearStackWithFiles(
98 stackFiles: Array<{[path: RepoPath]: ExportFile | null}>,
99): CommitStackState {
100 return new CommitStackState(
101 stackFiles.map((files, i) => {
102 const nextFiles = stackFiles.at(i + 1) ?? {};
103 return {
104 ...exportCommitDefault,
105 node: `NODE_${i}`,
106 parents: i === 0 ? [] : [`NODE_${i - 1}`],
107 text: `Commit ${i}`,
108 files,
109 relevantFiles: Object.fromEntries(
110 Object.entries(nextFiles).filter(([path, _file]) => !Object.hasOwn(files, path)),
111 ),
112 } as ExportCommit;
113 }),
114 );
115}
116
117describe('CommitStackState', () => {
118 it('accepts an empty stack', () => {
119 const stack = new CommitStackState([]);
120 expect(stack.revs()).toStrictEqual([]);
121 });
122
123 it('accepts a stack without a public commit', () => {
124 const stack = new CommitStackState([
125 {
126 ...exportCommitDefault,
127 files: {'a.txt': {data: 'a'}},
128 node: 'x',
129 parents: [],
130 text: 'A',
131 },
132 ]);
133 expect(stack.revs()).toStrictEqual([0]);
134 });
135
136 it('rejects a stack with multiple roots', () => {
137 const stack = [
138 {...exportCommitDefault, node: 'Z1'},
139 {...exportCommitDefault, node: 'Z2'},
140 ];
141 expect(() => new CommitStackState(stack)).toThrowError(
142 'Multiple roots ["Z1","Z2"] is not supported',
143 );
144 });
145
146 it('rejects a stack with merges', () => {
147 const stack = [
148 {...exportCommitDefault, node: 'A', parents: []},
149 {...exportCommitDefault, node: 'B', parents: ['A']},
150 {...exportCommitDefault, node: 'C', parents: ['A', 'B']},
151 ];
152 expect(() => new CommitStackState(stack)).toThrowError('Merge commit C is not supported');
153 });
154
155 it('rejects circular stack', () => {
156 const stack = [
157 {...exportCommitDefault, node: 'A', parents: ['B']},
158 {...exportCommitDefault, node: 'B', parents: ['A']},
159 ];
160 expect(() => new CommitStackState(stack)).toThrowError();
161 });
162
163 it('provides file paths', () => {
164 const stack = new CommitStackState(exportStack1);
165 expect(stack.getAllPaths()).toStrictEqual(['x.txt', 'y.txt', 'z.txt']);
166 });
167
168 it('logs commit history', () => {
169 const stack = new CommitStackState(exportStack1);
170 expect(stack.revs()).toStrictEqual([0, 1, 2, 3]);
171 expect([...stack.log(1 as CommitRev)]).toStrictEqual([1, 0]);
172 expect([...stack.log(3 as CommitRev)]).toStrictEqual([3, 2, 1, 0]);
173 });
174
175 it('finds child commits via childRevs', () => {
176 const stack = new CommitStackState(exportStack1);
177 expect(stack.childRevs(0 as CommitRev)).toMatchInlineSnapshot(`
178 [
179 1,
180 ]
181 `);
182 expect(stack.childRevs(1 as CommitRev)).toMatchInlineSnapshot(`
183 [
184 2,
185 ]
186 `);
187 expect(stack.childRevs(2 as CommitRev)).toMatchInlineSnapshot(`
188 [
189 3,
190 ]
191 `);
192 expect(stack.childRevs(3 as CommitRev)).toMatchInlineSnapshot(`[]`);
193 });
194
195 describe('log file history', () => {
196 // [rev, path] => [rev, path, file]
197 const extend = (stack: CommitStackState, revPathPairs: Array<[number, string]>) => {
198 return revPathPairs.map(([rev, path]) => {
199 const file =
200 rev >= 0 ? stack.get(rev as CommitRev)?.files.get(path) : stack.bottomFiles.get(path);
201 expect(file).toBe(stack.getFile(rev as CommitRev, path));
202 return [rev, path, file];
203 });
204 };
205
206 it('logs file history', () => {
207 const stack = new CommitStackState(exportStack1);
208 expect([...stack.logFile(3 as CommitRev, 'x.txt')]).toStrictEqual(
209 extend(stack, [
210 [2, 'x.txt'],
211 [1, 'x.txt'],
212 ]),
213 );
214 expect([...stack.logFile(3 as CommitRev, 'y.txt')]).toStrictEqual(
215 extend(stack, [[2, 'y.txt']]),
216 );
217 expect([...stack.logFile(3 as CommitRev, 'z.txt')]).toStrictEqual(
218 extend(stack, [
219 [3, 'z.txt'],
220 [1, 'z.txt'],
221 ]),
222 );
223 // Changes in not requested commits (rev 0) are ignored.
224 expect([...stack.logFile(3 as CommitRev, 'k.txt')]).toStrictEqual([]);
225 });
226
227 it('logs file history following renames', () => {
228 const stack = new CommitStackState(exportStack1);
229 expect([...stack.logFile(3 as CommitRev, 'y.txt', true)]).toStrictEqual(
230 extend(stack, [
231 [2, 'y.txt'],
232 [1, 'x.txt'],
233 ]),
234 );
235 });
236
237 it('logs file history including the bottom', () => {
238 const stack = new CommitStackState(exportStack1);
239 ['x.txt', 'z.txt'].forEach(path => {
240 expect([...stack.logFile(1 as CommitRev, path, true, true)]).toStrictEqual(
241 extend(stack, [
242 [1, path],
243 // rev 0 does not change x.txt or z.txt
244 [-1, path],
245 ]),
246 );
247 });
248 });
249
250 it('parentFile follows rename to bottomFile', () => {
251 const stack = new CommitStackState([
252 {
253 ...exportCommitDefault,
254 relevantFiles: {
255 'x.txt': {data: '11'},
256 'z.txt': {data: '22'},
257 },
258 files: {
259 'z.txt': {data: '33', copyFrom: 'x.txt'},
260 },
261 text: 'Commit Foo',
262 },
263 ]);
264 const file = stack.getFile(0 as CommitRev, 'z.txt');
265 expect(stack.getUtf8Data(file)).toBe('33');
266 const [, , parentFileWithRename] = stack.parentFile(0 as CommitRev, 'z.txt', true);
267 expect(stack.getUtf8Data(parentFileWithRename)).toBe('11');
268 const [, , parentFile] = stack.parentFile(0 as CommitRev, 'z.txt', false);
269 expect(stack.getUtf8Data(parentFile)).toBe('22');
270 });
271 });
272
273 it('provides file contents at given revs', () => {
274 const stack = new CommitStackState(exportStack1);
275 expect(stack.getFile(0 as CommitRev, 'x.txt')).toBe(ABSENT_FILE);
276 expect(stack.getFile(0 as CommitRev, 'y.txt')).toBe(ABSENT_FILE);
277 expect(stack.getFile(0 as CommitRev, 'z.txt')).toMatchObject({data: '11'});
278 expect(stack.getFile(1 as CommitRev, 'x.txt')).toMatchObject({data: '33'});
279 expect(stack.getFile(1 as CommitRev, 'y.txt')).toBe(ABSENT_FILE);
280 expect(stack.getFile(1 as CommitRev, 'z.txt')).toMatchObject({data: '22'});
281 expect(stack.getFile(2 as CommitRev, 'x.txt')).toBe(ABSENT_FILE);
282 expect(stack.getFile(2 as CommitRev, 'y.txt')).toMatchObject({data: '33'});
283 expect(stack.getFile(2 as CommitRev, 'z.txt')).toMatchObject({data: '22'});
284 expect(stack.getFile(3 as CommitRev, 'x.txt')).toBe(ABSENT_FILE);
285 expect(stack.getFile(3 as CommitRev, 'y.txt')).toMatchObject({data: '33'});
286 expect(stack.getFile(3 as CommitRev, 'z.txt')).toBe(ABSENT_FILE);
287 });
288
289 describe('builds FileStack', () => {
290 it('for double renames', () => {
291 // x.txt renamed to both y.txt and z.txt.
292 const stack = new CommitStackState([
293 {...exportCommitDefault, node: 'A', files: {'x.txt': {data: 'xx'}}},
294 {
295 ...exportCommitDefault,
296 node: 'B',
297 parents: ['A'],
298 files: {
299 'x.txt': null,
300 'y.txt': {data: 'yy', copyFrom: 'x.txt'},
301 'z.txt': {data: 'zz', copyFrom: 'x.txt'},
302 },
303 },
304 ]);
305 expect(stack.describeFileStacks()).toStrictEqual([
306 // y.txt inherits x.txt's history.
307 '0:./x.txt 1:A/x.txt(xx) 2:B/y.txt(yy)',
308 // z.txt does not inherit x.txt's history (but still has a parent for diff rendering purpose).
309 '0:A/x.txt(xx) 1:B/z.txt(zz)',
310 ]);
311 });
312
313 it('for double copies', () => {
314 // x.txt copied to both y.txt and z.txt.
315 const stack = new CommitStackState([
316 {...exportCommitDefault, node: 'A', files: {'x.txt': {data: 'xx'}}},
317 {
318 ...exportCommitDefault,
319 node: 'B',
320 parents: ['A'],
321 files: {
322 'y.txt': {data: 'yy', copyFrom: 'x.txt'},
323 'z.txt': {data: 'zz', copyFrom: 'y.txt'},
324 },
325 },
326 ]);
327 expect(stack.describeFileStacks()).toStrictEqual([
328 // y.txt connects to x.txt's history.
329 '0:./x.txt 1:A/x.txt(xx) 2:B/y.txt(yy)',
330 // z.txt does not connect to x.txt's history (but still have one parent for diff).
331 '0:./y.txt 1:B/z.txt(zz)',
332 ]);
333 });
334
335 it('for changes and copies', () => {
336 // x.txt is changed, and copied to both y.txt and z.txt.
337 const stack = new CommitStackState([
338 {...exportCommitDefault, node: 'A', files: {'x.txt': {data: 'xx'}}},
339 {
340 ...exportCommitDefault,
341 node: 'B',
342 parents: ['A'],
343 files: {
344 'x.txt': {data: 'yy'},
345 'y.txt': {data: 'xx', copyFrom: 'x.txt'},
346 'z.txt': {data: 'xx', copyFrom: 'x.txt'},
347 },
348 },
349 ]);
350 expect(stack.describeFileStacks()).toStrictEqual([
351 // x.txt has its own history.
352 '0:./x.txt 1:A/x.txt(xx) 2:B/x.txt(yy)',
353 // y.txt and z.txt do not share x.txt's history (but still have one parent for diff).
354 '0:A/x.txt(xx) 1:B/y.txt(xx)',
355 '0:A/x.txt(xx) 1:B/z.txt(xx)',
356 ]);
357 });
358
359 it('for the the example stack', () => {
360 const stack = new CommitStackState(exportStack1);
361 expect(stack.describeFileStacks()).toStrictEqual([
362 // x.txt: added by A, modified and renamed by B.
363 '0:./x.txt 1:A/x.txt(33) 2:B/y.txt(33)',
364 // z.txt: modified by A, deleted by C.
365 '0:./z.txt(11) 1:A/z.txt(22) 2:C/z.txt',
366 ]);
367 });
368
369 it('with rename tracking disabled', () => {
370 const stack = new CommitStackState(exportStack1).buildFileStacks({followRenames: false});
371 // no x.txt -> y.txt rename
372 expect(stack.describeFileStacks()).toStrictEqual([
373 '0:./x.txt 1:A/x.txt(33) 2:B/x.txt',
374 '0:./z.txt(11) 1:A/z.txt(22) 2:C/z.txt',
375 '0:./y.txt 1:B/y.txt(33)',
376 ]);
377 });
378 });
379
380 describe('calculates dependencies', () => {
381 const e = exportCommitDefault;
382
383 it('for content changes', () => {
384 const stack = new CommitStackState([
385 {...e, node: 'Z', requested: false, relevantFiles: {'x.txt': null}},
386 {...e, node: 'A', parents: ['Z'], files: {'x.txt': {data: 'b\n'}}},
387 {...e, node: 'B', parents: ['A'], files: {'x.txt': {data: 'a\nb\n'}}},
388 {...e, node: 'C', parents: ['B'], files: {'x.txt': {data: 'a\nB\n'}}},
389 ]);
390 expect(stack.calculateDepMap()).toStrictEqual(
391 new Map([
392 [0, new Set()],
393 [1, new Set()],
394 [2, new Set()], // insertion at other insertion boundary is dependency-free
395 [3, new Set([1])],
396 ]),
397 );
398 });
399
400 it('for file addition and deletion', () => {
401 const stack = new CommitStackState([
402 {...e, node: 'Z', requested: false, relevantFiles: {'x.txt': {data: 'a'}}},
403 {...e, node: 'A', parents: ['Z'], files: {'x.txt': null}},
404 {...e, node: 'B', parents: ['A'], files: {'x.txt': {data: 'a'}}},
405 {...e, node: 'C', parents: ['B'], files: {'x.txt': null}},
406 ]);
407 expect(stack.calculateDepMap()).toStrictEqual(
408 new Map([
409 [0, new Set()],
410 [1, new Set()],
411 [2, new Set([1])], // commit B adds x.txt, depends on commit A's deletion.
412 [3, new Set([2])], // commit C deletes x.txt, depends on commit B's addition.
413 ]),
414 );
415 });
416
417 it('for copies', () => {
418 const stack = new CommitStackState([
419 {...e, node: 'A', files: {'x.txt': {data: 'a'}}},
420 {...e, node: 'B', parents: ['A'], files: {'y.txt': {data: 'a', copyFrom: 'x.txt'}}},
421 {...e, node: 'C', parents: ['B'], files: {'z.txt': {data: 'a', copyFrom: 'x.txt'}}},
422 {
423 ...e,
424 node: 'D',
425 parents: ['C'],
426 files: {'p.txt': {data: 'a', copyFrom: 'x.txt'}, 'q.txt': {data: 'a', copyFrom: 'z.txt'}},
427 },
428 ]);
429 expect(stack.calculateDepMap()).toStrictEqual(
430 new Map([
431 [0, new Set()],
432 [1, new Set([0])], // commit B copies commit A's x.txt to y.txt.
433 [2, new Set([0])], // commit C copies commit A's x.txt to z.txt.
434 [3, new Set([0, 2])], // commit D copies commit A's x.txt to p.txt, and commit C's z.txt to q.txt.
435 ]),
436 );
437 });
438 });
439
440 describe('folding commits', () => {
441 const e = exportCommitDefault;
442
443 it('cannot be used for immutable commits', () => {
444 const stack = new CommitStackState([
445 {...e, node: 'A', immutable: true},
446 {...e, node: 'B', parents: ['A'], immutable: false},
447 {...e, node: 'C', parents: ['B'], immutable: false},
448 ]);
449 expect(stack.canFoldDown(1 as CommitRev)).toBeFalsy();
450 expect(stack.canFoldDown(2 as CommitRev)).toBeTruthy();
451 });
452
453 it('cannot be used for out-of-range commits', () => {
454 const stack = new CommitStackState([
455 {...e, node: 'A'},
456 {...e, node: 'B', parents: ['A']},
457 ]);
458 expect(stack.canFoldDown(0 as CommitRev)).toBeFalsy();
459 expect(stack.canFoldDown(1 as CommitRev)).toBeTruthy();
460 expect(stack.canFoldDown(2 as CommitRev)).toBeFalsy();
461 });
462
463 it('cannot be used for forks', () => {
464 const stack = new CommitStackState([
465 {...e, node: 'A'},
466 {...e, node: 'B', parents: ['A']},
467 {...e, node: 'C', parents: ['A']},
468 ]);
469 expect(stack.canFoldDown(1 as CommitRev)).toBeFalsy();
470 expect(stack.canFoldDown(2 as CommitRev)).toBeFalsy();
471 });
472
473 it('works for simple edits', () => {
474 let stack = new CommitStackState([
475 {
476 ...e,
477 node: 'A',
478 text: 'Commit A',
479 parents: [],
480 files: {'x.txt': {data: 'xx'}, 'y.txt': {data: 'yy'}},
481 },
482 {...e, node: 'B', text: 'Commit B', parents: ['A'], files: {'x.txt': {data: 'yy'}}},
483 {...e, node: 'C', text: 'Commit C', parents: ['B'], files: {'x.txt': {data: 'zz'}}},
484 ]);
485 expect(stack.canFoldDown(1 as CommitRev)).toBeTruthy();
486 stack = stack.foldDown(1 as CommitRev);
487 expect(stack.stack.size).toBe(2);
488 expect(stack.stack.get(0)?.toJS()).toMatchObject({
489 key: 'A',
490 files: {
491 'x.txt': {data: 'yy'},
492 'y.txt': {data: 'yy'},
493 },
494 originalNodes: new Set(['A', 'B']),
495 text: 'Commit A, Commit B',
496 parents: [],
497 });
498 expect(stack.stack.get(1)?.toJS()).toMatchObject({
499 key: 'C',
500 text: 'Commit C',
501 parents: [0], // Commit C's parent is updated to Commit A.
502 });
503 });
504
505 it('removes copyFrom appropriately', () => {
506 let stack = new CommitStackState([
507 {...e, node: 'A', parents: [], files: {'x.txt': {data: 'xx'}}},
508 {...e, node: 'B', parents: ['A'], files: {'y.txt': {data: 'yy', copyFrom: 'x.txt'}}},
509 ]);
510 expect(stack.canFoldDown(1 as CommitRev)).toBeTruthy();
511 stack = stack.foldDown(1 as CommitRev);
512 expect(stack.stack.get(0)?.toJS()).toMatchObject({
513 files: {
514 'x.txt': {data: 'xx'},
515 'y.txt': {data: 'yy'}, // no longer has "copyFrom", since 'x.txt' does not exist in commit A.
516 },
517 });
518 });
519
520 it('keeps copyFrom appropriately', () => {
521 let stack = new CommitStackState([
522 {...e, node: 'A', parents: [], files: {xt: {data: 'xx'}, yt: {data: 'yy'}}},
523 {...e, node: 'B', parents: ['A'], files: {y1t: {data: 'yy', copyFrom: 'yt'}}},
524 {
525 ...e,
526 node: 'C',
527 parents: ['B'],
528 files: {x1t: {data: 'x1', copyFrom: 'xt'}, y1t: {data: 'y1'}},
529 },
530 ]);
531 // Fold B+C.
532 expect(stack.canFoldDown(2 as CommitRev)).toBeTruthy();
533 stack = stack.foldDown(2 as CommitRev);
534 expect(stack.stack.get(1)?.toJS()).toMatchObject({
535 files: {
536 y1t: {data: 'y1', copyFrom: 'yt'}, // reuse copyFrom: 'yt' from commit B.
537 x1t: {data: 'x1', copyFrom: 'xt'}, // reuse copyFrom: 'xt' from commit C.
538 },
539 });
540 });
541
542 it('chains renames', () => {
543 let stack = new CommitStackState([
544 {...e, node: 'A', parents: [], files: {xt: {data: 'xx'}}},
545 {...e, node: 'B', parents: ['A'], files: {yt: {data: 'yy', copyFrom: 'xt'}, xt: null}},
546 {...e, node: 'C', parents: ['B'], files: {zt: {data: 'zz', copyFrom: 'yt'}, yt: null}},
547 ]);
548 // Fold B+C.
549 expect(stack.canFoldDown(2 as CommitRev)).toBeTruthy();
550 stack = stack.foldDown(2 as CommitRev);
551 expect(stack.stack.get(1)?.toJS()).toMatchObject({
552 files: {
553 xt: ABSENT_FILE.toJS(),
554 // 'yt' is no longer considered changed.
555 zt: {data: 'zz', copyFrom: 'xt'}, // 'xt'->'yt'->'zt' is folded to 'xt'->'zt'.
556 },
557 });
558 });
559
560 it('removes cancel-out changes', () => {
561 let stack = new CommitStackState([
562 {...e, node: 'A', parents: [], files: {xt: {data: 'xx'}}},
563 {...e, node: 'B', parents: ['A'], files: {xt: {data: 'yy'}, zt: {data: 'zz'}}},
564 {...e, node: 'C', parents: ['B'], files: {xt: {data: 'xx'}}},
565 ]);
566 // Fold B+C.
567 expect(stack.canFoldDown(2 as CommitRev)).toBeTruthy();
568 stack = stack.foldDown(2 as CommitRev);
569 expect(stack.stack.get(1)?.toJS()).toMatchObject({
570 files: {zt: {data: 'zz'}}, // changes to 'yt' is removed.
571 });
572 });
573 });
574
575 describe('dropping commits', () => {
576 const e = exportCommitDefault;
577
578 it('cannot be used for immutable commits', () => {
579 const stack = new CommitStackState([
580 {...e, node: 'A', immutable: true},
581 {...e, node: 'B', parents: ['A'], immutable: true},
582 {...e, node: 'C', parents: ['B'], immutable: false},
583 ]);
584 expect(stack.canDrop(0 as CommitRev)).toBeFalsy();
585 expect(stack.canDrop(1 as CommitRev)).toBeFalsy();
586 expect(stack.canDrop(2 as CommitRev)).toBeTruthy();
587 });
588
589 it('detects content dependencies', () => {
590 const stack = new CommitStackState([
591 {...e, node: 'A', files: {xx: {data: '0\n2\n'}}},
592 {...e, node: 'B', parents: ['A'], files: {xx: {data: '0\n1\n2\n'}}},
593 {...e, node: 'C', parents: ['B'], files: {xx: {data: '0\n1\n2\n3\n'}}},
594 {...e, node: 'D', parents: ['C'], files: {xx: {data: '0\n1\n2\n4\n'}}},
595 ]);
596 expect(stack.canDrop(0 as CommitRev)).toBeFalsy();
597 expect(stack.canDrop(1 as CommitRev)).toBeTruthy();
598 expect(stack.canDrop(2 as CommitRev)).toBeFalsy(); // D depends on C
599 expect(stack.canDrop(3 as CommitRev)).toBeTruthy();
600 });
601
602 it('detects commit graph dependencies', () => {
603 const stack = new CommitStackState([
604 {...e, node: 'A', files: {xx: {data: '1'}}},
605 {...e, node: 'B', parents: ['A'], files: {xx: {data: '2'}}},
606 {...e, node: 'C', parents: ['A'], files: {xx: {data: '3'}}},
607 {...e, node: 'D', parents: ['C'], files: {xx: {data: '4'}}},
608 ]);
609 expect(stack.canDrop(0 as CommitRev)).toBeFalsy();
610 expect(stack.canDrop(1 as CommitRev)).toBeTruthy();
611 expect(stack.canDrop(2 as CommitRev)).toBeFalsy();
612 expect(stack.canDrop(3 as CommitRev)).toBeTruthy();
613 });
614
615 it('for a change in the middle of a stack', () => {
616 let stack = new CommitStackState([
617 {...e, node: 'A', files: {xx: {data: 'p\ny\n'}}},
618 {...e, node: 'B', parents: ['A'], files: {xx: {data: 'p\nx\ny\n'}}},
619 {...e, node: 'C', parents: ['B'], files: {xx: {data: 'p\nx\ny\nz\n'}}},
620 ]);
621 expect(stack.canDrop(0 as CommitRev)).toBeFalsy();
622 expect(stack.canDrop(1 as CommitRev)).toBeTruthy();
623 expect(stack.canDrop(2 as CommitRev)).toBeTruthy();
624 stack = stack.drop(1 as CommitRev);
625 expect(stack.stack.size).toBe(2);
626 expect(stack.stack.get(1)?.toJS()).toMatchObject({
627 originalNodes: ['C'],
628 files: {xx: {data: 'p\ny\nz\n'}},
629 });
630 expect(stack.stack.toArray().map(c => c.key)).toMatchObject(['A', 'C']);
631 });
632 });
633
634 describe('reordering commits', () => {
635 const e = exportCommitDefault;
636
637 it('cannot be used for immutable commits', () => {
638 const stack = new CommitStackState([
639 {...e, node: 'A', immutable: true},
640 {...e, node: 'B', parents: ['A'], immutable: true},
641 {...e, node: 'C', parents: ['B'], immutable: false},
642 ]);
643 expect(stack.canReorder([0, 2, 1] as CommitRev[])).toBeFalsy();
644 expect(stack.canReorder([1, 0, 2] as CommitRev[])).toBeFalsy();
645 expect(stack.canReorder([0, 1, 2] as CommitRev[])).toBeTruthy();
646 });
647
648 it('respects content dependencies', () => {
649 const stack = new CommitStackState([
650 {...e, node: 'A', files: {xx: {data: '0\n2\n'}}},
651 {...e, node: 'B', parents: ['A'], files: {xx: {data: '0\n1\n2\n'}}},
652 {...e, node: 'C', parents: ['B'], files: {xx: {data: '0\n1\n2\n3\n'}}},
653 {...e, node: 'D', parents: ['C'], files: {xx: {data: '0\n1\n2\n4\n'}}},
654 ]);
655 expect(stack.canReorder([0, 2, 3, 1] as CommitRev[])).toBeTruthy();
656 expect(stack.canReorder([0, 2, 1, 3] as CommitRev[])).toBeTruthy();
657 expect(stack.canReorder([0, 3, 2, 1] as CommitRev[])).toBeFalsy();
658 expect(stack.canReorder([0, 3, 1, 2] as CommitRev[])).toBeFalsy();
659 });
660
661 it('refuses to reorder non-linear stack', () => {
662 const stack = new CommitStackState([
663 {...e, node: 'A', files: {xx: {data: '1'}}},
664 {...e, node: 'B', parents: ['A'], files: {xx: {data: '2'}}},
665 {...e, node: 'C', parents: ['A'], files: {xx: {data: '3'}}},
666 {...e, node: 'D', parents: ['C'], files: {xx: {data: '4'}}},
667 ]);
668 expect(stack.canReorder([0, 2, 3, 1] as CommitRev[])).toBeFalsy();
669 expect(stack.canReorder([0, 2, 1, 3] as CommitRev[])).toBeFalsy();
670 expect(stack.canReorder([0, 1, 2, 3] as CommitRev[])).toBeFalsy();
671 });
672
673 it('can reorder a long stack', () => {
674 const exportStack: ExportStack = [...Array(20).keys()].map(i => {
675 return {...e, node: `A${i}`, parents: i === 0 ? [] : [`A${i - 1}`], files: {}};
676 });
677 const stack = new CommitStackState(exportStack);
678 expect(stack.canReorder(stack.revs().reverse())).toBeTruthy();
679 });
680
681 it('reorders adjacent changes', () => {
682 // Note: usually rev 0 is a public parent commit, rev 0 is not usually reordered.
683 // But this test reorders rev 0 and triggers some interesting code paths.
684 let stack = new CommitStackState([
685 {...e, node: 'A', files: {xx: {data: '1\n'}}},
686 {...e, node: 'B', parents: ['A'], files: {xx: {data: '1\n2\n'}}},
687 ]);
688 expect(stack.canReorder([1, 0] as CommitRev[])).toBeTruthy();
689 stack = stack.reorder([1, 0] as CommitRev[]);
690 expect(stack.stack.toArray().map(c => c.files.get('xx')?.data)).toMatchObject([
691 '2\n',
692 '1\n2\n',
693 ]);
694 expect(stack.stack.toArray().map(c => c.key)).toMatchObject(['B', 'A']);
695 // Reorder back.
696 expect(stack.canReorder([1, 0] as CommitRev[])).toBeTruthy();
697 stack = stack.reorder([1, 0] as CommitRev[]);
698 expect(stack.stack.toArray().map(c => c.files.get('xx')?.data)).toMatchObject([
699 '1\n',
700 '1\n2\n',
701 ]);
702 expect(stack.stack.toArray().map(c => c.key)).toMatchObject(['A', 'B']);
703 });
704
705 it('reorders content changes', () => {
706 let stack = new CommitStackState([
707 {...e, node: 'A', files: {xx: {data: '1\n1\n'}}},
708 {...e, node: 'B', parents: ['A'], files: {xx: {data: '0\n1\n1\n'}}},
709 {...e, node: 'C', parents: ['B'], files: {yy: {data: '0'}}}, // Does not change 'xx'.
710 {...e, node: 'D', parents: ['C'], files: {xx: {data: '0\n1\n1\n2\n'}}},
711 {...e, node: 'E', parents: ['D'], files: {xx: {data: '0\n1\n3\n1\n2\n'}}},
712 ]);
713
714 // A-B-C-D-E => A-C-E-B-D.
715 let order = [0, 2, 4, 1, 3] as CommitRev[];
716 expect(stack.canReorder(order)).toBeTruthy();
717 stack = stack.reorder(order);
718 const getNode = (r: CommitRev) => stack.stack.get(r)?.originalNodes?.first();
719 const getParents = (r: CommitRev) => stack.stack.get(r)?.parents?.toJS();
720 expect(stack.revs().map(getNode)).toMatchObject(['A', 'C', 'E', 'B', 'D']);
721 expect(stack.revs().map(getParents)).toMatchObject([[], [0], [1], [2], [3]]);
722 expect(stack.revs().map(r => stack.getFile(r, 'xx').data)).toMatchObject([
723 '1\n1\n',
724 '1\n1\n', // Not changed by 'C'.
725 '1\n3\n1\n',
726 '0\n1\n3\n1\n',
727 '0\n1\n3\n1\n2\n',
728 ]);
729 expect(stack.revs().map(r => stack.getFile(r, 'yy').data)).toMatchObject([
730 '',
731 '0',
732 '0',
733 '0',
734 '0',
735 ]);
736
737 // Reorder back. A-C-E-B-D => A-B-C-D-E.
738 order = [0, 3, 1, 4, 2] as CommitRev[];
739 expect(stack.canReorder(order)).toBeTruthy();
740 stack = stack.reorder(order);
741 expect(stack.revs().map(getNode)).toMatchObject(['A', 'B', 'C', 'D', 'E']);
742 expect(stack.revs().map(getParents)).toMatchObject([[], [0], [1], [2], [3]]);
743 expect(stack.revs().map(r => stack.getFile(r, 'xx').data)).toMatchObject([
744 '1\n1\n',
745 '0\n1\n1\n',
746 '0\n1\n1\n',
747 '0\n1\n1\n2\n',
748 '0\n1\n3\n1\n2\n',
749 ]);
750 });
751 });
752
753 describe('calculating ImportStack', () => {
754 it('skips all if nothing changed', () => {
755 const stack = new CommitStackState(exportStack1);
756 expect(stack.calculateImportStack()).toMatchObject([]);
757 });
758
759 it('skips unchanged commits', () => {
760 // Edits B/y.txt, affects descendants C.
761 const stack = new CommitStackState(exportStack1).updateEachFile((_rev, file, path) =>
762 path === 'y.txt' ? file.set('data', '333') : file,
763 );
764 expect(stack.calculateImportStack()).toMatchObject([
765 [
766 'commit',
767 {
768 mark: ':r2',
769 date: [0, 0],
770 text: 'B',
771 parents: ['A_NODE'],
772 predecessors: ['B_NODE'],
773 files: {
774 'x.txt': null,
775 'y.txt': {data: '333', copyFrom: 'x.txt', flags: ''},
776 },
777 },
778 ],
779 [
780 'commit',
781 {
782 mark: ':r3',
783 date: [0, 0],
784 text: 'C',
785 parents: [':r2'],
786 predecessors: ['C_NODE'],
787 files: {'z.txt': null},
788 },
789 ],
790 ]);
791 });
792
793 it('follows reorder', () => {
794 // Reorder B and C in the example stack.
795 const stack = new CommitStackState(exportStack1).reorder([0, 1, 3, 2] as CommitRev[]);
796 expect(stack.calculateImportStack({goto: 'B_NODE', preserveDirtyFiles: true})).toMatchObject([
797 ['commit', {text: 'C'}],
798 ['commit', {mark: ':r3', text: 'B'}],
799 ['reset', {mark: ':r3'}],
800 ]);
801 });
802
803 it('stays at the stack top on reorder', () => {
804 // Reorder B and C in the example stack.
805 const stack = new CommitStackState(exportStack1).reorder([0, 1, 3, 2] as CommitRev[]);
806 expect(stack.calculateImportStack({goto: 'C_NODE'})).toMatchObject([
807 ['commit', {text: 'C'}],
808 ['commit', {mark: ':r3', text: 'B'}],
809 ['goto', {mark: ':r3'}],
810 ]);
811 });
812
813 it('hides dropped commits', () => {
814 let stack = new CommitStackState(exportStack1);
815 const revs = stack.revs();
816 // Drop the last 2 commits: B and C.
817 stack = stack.drop(revs[revs.length - 1]).drop(revs[revs.length - 2]);
818 expect(stack.calculateImportStack()).toMatchObject([
819 [
820 'hide',
821 {
822 nodes: ['B_NODE', 'C_NODE'],
823 },
824 ],
825 ]);
826 });
827
828 it('produces goto or reset command', () => {
829 const stack = new CommitStackState(exportStack1).updateEachFile((_rev, file, path) =>
830 path === 'y.txt' ? file.set('data', '333') : file,
831 );
832 expect(stack.calculateImportStack({goto: 3 as CommitRev})).toMatchObject([
833 ['commit', {}],
834 ['commit', {}],
835 ['goto', {mark: ':r3'}],
836 ]);
837 expect(
838 stack.calculateImportStack({goto: 3 as CommitRev, preserveDirtyFiles: true}),
839 ).toMatchObject([
840 ['commit', {}],
841 ['commit', {}],
842 ['reset', {mark: ':r3'}],
843 ]);
844 });
845
846 it('optionally rewrites commit date', () => {
847 // Swap the last 2 commits.
848 const stack = new CommitStackState(exportStack1).reorder([0, 1, 3, 2] as CommitRev[]);
849 expect(stack.calculateImportStack({rewriteDate: 40})).toMatchObject([
850 ['commit', {date: [40, 0], text: 'C'}],
851 ['commit', {date: [40, 0], text: 'B'}],
852 ]);
853 });
854
855 it('setFile drops invalid "copyFrom"s', () => {
856 // Commit A (x.txt) -> Commit B (y.txt, renamed from x.txt).
857 const stack = new CommitStackState([
858 {
859 ...exportCommitDefault,
860 files: {'x.txt': {data: '33'}},
861 node: 'A_NODE',
862 parents: [],
863 relevantFiles: {'y.txt': null},
864 text: 'A',
865 },
866 {
867 ...exportCommitDefault,
868 files: {'x.txt': null, 'y.txt': {data: '33', copyFrom: 'x.txt'}},
869 node: 'B_NODE',
870 parents: ['A_NODE'],
871 text: 'B',
872 },
873 ]);
874
875 // Invalid copyFrom is dropped.
876 expect(
877 stack
878 .setFile(0 as CommitRev, 'x.txt', f => f.set('copyFrom', 'z.txt'))
879 .getFile(0 as CommitRev, 'x.txt').copyFrom,
880 ).toBeUndefined();
881
882 // Creating "y.txt" in the parent commit (0) makes the child commit (1) drop copyFrom of "y.txt".
883 expect(
884 stack
885 .setFile(0 as CommitRev, 'y.txt', f => f.merge({data: '33', flags: ''}))
886 .getFile(1 as CommitRev, 'y.txt').copyFrom,
887 ).toBeUndefined();
888
889 // Dropping "x.txt" in the parent commit (0) makes the child commit (1) not copying from "x.txt".
890 // The content of "y.txt" is not changed.
891 const fileY = stack
892 .setFile(0 as CommitRev, 'x.txt', _f => ABSENT_FILE)
893 .getFile(1 as CommitRev, 'y.txt');
894 expect(fileY.copyFrom).toBeUndefined();
895 expect(fileY.data).toBe('33');
896 });
897
898 it('optionally skips wdir()', () => {
899 const stack = new CommitStackState([
900 {
901 ...exportCommitDefault,
902 files: {
903 'x.txt': {data: '11'},
904 },
905 node: WDIR_NODE,
906 parents: [],
907 text: 'Temp commit',
908 },
909 ]).setFile(0 as CommitRev, 'x.txt', f => f.set('data', '22'));
910 expect(stack.calculateImportStack()).toMatchInlineSnapshot(`
911 [
912 [
913 "commit",
914 {
915 "author": "test <test@example.com>",
916 "date": [
917 0,
918 0,
919 ],
920 "files": {
921 "x.txt": {
922 "data": "22",
923 "flags": "",
924 },
925 },
926 "mark": ":r0",
927 "parents": [],
928 "predecessors": [],
929 "text": "Temp commit",
930 },
931 ],
932 ]
933 `);
934 expect(stack.calculateImportStack({skipWdir: true})).toMatchInlineSnapshot(`[]`);
935 });
936 });
937
938 describe('denseSubStack', () => {
939 it('provides bottomFiles', () => {
940 const stack = new CommitStackState(exportStack1);
941 let subStack = stack.denseSubStack(List([3 as CommitRev])); // C
942 // The bottom files contains z (deleted) and its content is before deletion.
943 expect([...subStack.bottomFiles.keys()].sort()).toEqual(['z.txt']);
944 expect(subStack.bottomFiles.get('z.txt')?.data).toBe('22');
945
946 subStack = stack.denseSubStack(List([2, 3] as CommitRev[])); // B, C
947 // The bottom files contains x (deleted), y (modified) and z (deleted).
948 expect([...subStack.bottomFiles.keys()].sort()).toEqual(['x.txt', 'y.txt', 'z.txt']);
949 });
950
951 it('marks all files at every commit as changed', () => {
952 const stack = new CommitStackState(exportStack1);
953 const subStack = stack.denseSubStack(List([2, 3] as CommitRev[])); // B, C
954 // All commits (B, C) should have 3 files (x.txt, y.txt, z.txt) marked as "changed".
955 expect(subStack.stack.map(c => c.files.size).toJS()).toEqual([3, 3]);
956 // All file stacks (x.txt, y.txt, z.txt) should have 3 revs (bottomFile, B, C).
957 expect(subStack.fileStacks.map(f => f.revLength).toJS()).toEqual([3, 3, 3]);
958 });
959 });
960
961 describe('insertEmpty', () => {
962 const stack = new CommitStackState(exportStack1);
963 const getRevs = (stack: CommitStackState) =>
964 stack.stack.map(c => [c.rev, c.parents.toArray()]).toArray();
965
966 it('updates revs of commits', () => {
967 expect(getRevs(stack)).toEqual([
968 [0, []],
969 [1, [0]],
970 [2, [1]],
971 [3, [2]],
972 ]);
973 expect(getRevs(stack.insertEmpty(2 as CommitRev, 'foo'))).toEqual([
974 [0, []],
975 [1, [0]],
976 [2, [1]],
977 [3, [2]],
978 [4, [3]],
979 ]);
980 });
981
982 it('inserts at stack top', () => {
983 expect(getRevs(stack.insertEmpty(4 as CommitRev, 'foo'))).toEqual([
984 [0, []],
985 [1, [0]],
986 [2, [1]],
987 [3, [2]],
988 [4, [3]],
989 ]);
990 });
991
992 it('uses the provided commit message', () => {
993 const msg = 'provided message\nfoobar';
994 ([0, 2, 4] as CommitRev[]).forEach(i => {
995 expect(stack.insertEmpty(i, msg).stack.get(i)?.text).toBe(msg);
996 });
997 });
998
999 it('provides unique keys for inserted commits', () => {
1000 const newStack = stack
1001 .insertEmpty(1 as CommitRev, '')
1002 .insertEmpty(1 as CommitRev, '')
1003 .insertEmpty(1 as CommitRev, '');
1004 const keys = newStack.stack.map(c => c.key);
1005 expect(keys.size).toBe(ImSet(keys).size);
1006 });
1007
1008 // The "originalNodes" are useful for split to set predecessors correctly.
1009 it('preserves the originalNodes with splitFromRev', () => {
1010 ([1, 4] as CommitRev[]).forEach(i => {
1011 const newStack = stack.insertEmpty(i, '', 2 as CommitRev);
1012 expect(newStack.get(i)?.originalNodes).toBe(stack.get(2 as CommitRev)?.originalNodes);
1013 expect(newStack.get(i)?.originalNodes?.isEmpty()).toBeFalsy();
1014 const anotherStack = stack.insertEmpty(i, '');
1015 expect(anotherStack.get(i)?.originalNodes?.isEmpty()).toBeTruthy();
1016 });
1017 });
1018 });
1019
1020 describe('applySubStack', () => {
1021 const stack = new CommitStackState(exportStack1);
1022 const subStack = stack.denseSubStack(List([2, 3] as CommitRev[]));
1023 const emptyStack = subStack.set('stack', List());
1024
1025 const getChangedFiles = (state: CommitStackState, rev: number): Array<string> => {
1026 return [...nullthrows(state.stack.get(rev as CommitRev)).files.keys()].sort();
1027 };
1028
1029 it('optimizes file changes by removing unmodified changes', () => {
1030 const newStack = stack.applySubStack(2 as CommitRev, 4 as CommitRev, subStack);
1031 expect(newStack.stack.size).toBe(stack.stack.size);
1032 // The original `stack` does not have unmodified changes.
1033 // To verify that `newStack` does not have unmodified changes, check it
1034 // against the original `stack`.
1035 stack.revs().forEach(i => {
1036 expect(getChangedFiles(newStack, i)).toEqual(getChangedFiles(stack, i));
1037 });
1038 });
1039
1040 it('drops empty commits at the end of subStack', () => {
1041 // Change the 2nd commit in subStack to empty.
1042 const newSubStack = subStack.set(
1043 'stack',
1044 subStack.stack.setIn([1, 'files'], nullthrows(subStack.stack.get(0)).files),
1045 );
1046 // `applySubStack` should drop the 2nd commit in `newSubStack`.
1047 const newStack = stack.applySubStack(2 as CommitRev, 4 as CommitRev, newSubStack);
1048 newStack.assertRevOrder();
1049 expect(newStack.stack.size).toBe(stack.stack.size - 1);
1050 });
1051
1052 it('rewrites revs for the remaining of the stack', () => {
1053 const newStack = stack.applySubStack(1 as CommitRev, 2 as CommitRev, emptyStack);
1054 newStack.assertRevOrder();
1055 [1, 2].forEach(i => {
1056 expect(newStack.stack.get(i)?.toJS()).toMatchObject({rev: i, parents: [i - 1]});
1057 });
1058 });
1059
1060 it('rewrites revs for the inserted stack', () => {
1061 const newStack = stack.applySubStack(2 as CommitRev, 3 as CommitRev, subStack);
1062 newStack.assertRevOrder();
1063 [2, 3, 4].forEach(i => {
1064 expect(newStack.stack.get(i)?.toJS()).toMatchObject({rev: i, parents: [i - 1]});
1065 });
1066 });
1067
1068 it('preserves file contents of the old stack', () => {
1069 // Add a file 'x.txt' deleted by the original stack.
1070 const newSubStack = subStack.set(
1071 'stack',
1072 List([
1073 CommitState({
1074 key: 'foo',
1075 files: ImMap([['x.txt', stack.getFile(1 as CommitRev, 'x.txt')]]),
1076 }),
1077 ]),
1078 );
1079 const newStack = stack.applySubStack(1 as CommitRev, 3 as CommitRev, newSubStack);
1080
1081 // 'y.txt' was added by the old stack, not the new stack. So it is re-added
1082 // to preserve its old content.
1083 // 'x.txt' was added by the new stack, deleted by the old stack. So it is
1084 // re-deleted.
1085 expect(getChangedFiles(newStack, 2)).toEqual(['x.txt', 'y.txt', 'z.txt']);
1086 expect(newStack.getFile(2 as CommitRev, 'y.txt').data).toBe('33');
1087 expect(newStack.getFile(2 as CommitRev, 'x.txt')).toBe(ABSENT_FILE);
1088 });
1089
1090 it('update keys to avoid conflict', () => {
1091 const oldKey = nullthrows(stack.stack.get(1)).key;
1092 const newSubStack = subStack.set('stack', subStack.stack.setIn([0, 'key'], oldKey));
1093 const newStack = stack.applySubStack(2 as CommitRev, 3 as CommitRev, newSubStack);
1094
1095 // Keys are still unique.
1096 const keys = newStack.stack.map(c => c.key);
1097 const keysSet = ImSet(keys);
1098 expect(keys.size).toBe(keysSet.size);
1099 });
1100
1101 it('drops ABSENT flag if content is not empty', () => {
1102 // x.txt was deleted by subStack rev 0 (B). We are moving it to be deleted by rev 1 (C).
1103 expect(subStack.getFile(0 as CommitRev, 'x.txt').flags).toBe(ABSENT_FILE.flags);
1104 // To break the deletion into done by 2 commits, we edit the file stack of 'x.txt'.
1105 const fileIdx = nullthrows(
1106 subStack.commitToFile.get(CommitIdx({rev: 0 as CommitRev, path: 'x.txt'})),
1107 ).fileIdx;
1108 const fileStack = nullthrows(subStack.fileStacks.get(fileIdx));
1109 // The file stack has 3 revs: (base, before deletion), (deleted at rev 0), (deleted at rev 1).
1110 expect(fileStack.convertToPlainText().toArray()).toEqual(['33', '', '']);
1111 const newFileStack = new FileStackState(['33', '3', '']);
1112 const newSubStack = subStack.setFileStack(fileIdx, newFileStack);
1113 expect(newSubStack.getUtf8Data(newSubStack.getFile(0 as CommitRev, 'x.txt'))).toBe('3');
1114 // Apply the file stack back to the main stack.
1115 const newStack = stack.applySubStack(2 as CommitRev, 4 as CommitRev, newSubStack);
1116 expect(newStack.stack.size).toBe(4);
1117 // Check that x.txt in rev 2 (B) is '3', not absent.
1118 const file = newStack.getFile(2 as CommitRev, 'x.txt');
1119 expect(file.data).toBe('3');
1120 expect(file.flags ?? '').not.toContain(ABSENT_FILE.flags);
1121
1122 // Compare the old and new file stacks.
1123 // - x.txt deletion is now by commit 'C', not 'B'.
1124 // - x.txt -> y.txt rename is preserved.
1125 expect(stack.describeFileStacks(true)).toEqual([
1126 '0:./x.txt 1:A/x.txt(33) 2:B/y.txt(33)',
1127 '0:./z.txt(11) 1:A/z.txt(22) 2:C/z.txt',
1128 ]);
1129 expect(newStack.describeFileStacks(true)).toEqual([
1130 '0:./x.txt 1:A/x.txt(33) 2:B/x.txt(3) 3:C/x.txt',
1131 '0:./z.txt(11) 1:A/z.txt(22) 2:C/z.txt',
1132 '0:A/x.txt(33) 1:B/y.txt(33)',
1133 ]);
1134 });
1135
1136 it('does not add ABSENT flag if content becomes empty', () => {
1137 // This was a herustics when `flags` are not handled properly. Now it is no longer needed.
1138 // y.txt was added by subStack rev 0 (B). We are moving it to be added by rev 1 (C).
1139 const fileIdx = nullthrows(
1140 subStack.commitToFile.get(CommitIdx({rev: 0 as CommitRev, path: 'y.txt'})),
1141 ).fileIdx;
1142 const fileStack = nullthrows(subStack.fileStacks.get(fileIdx));
1143 // The file stack has 3 revs: (base, before add), (add by rev 0), (unchanged by rev 1).
1144 expect(fileStack.convertToPlainText().toArray()).toEqual(['', '33', '33']);
1145 const newFileStack = new FileStackState(['', '', '33']);
1146 const newSubStack = subStack.setFileStack(fileIdx, newFileStack);
1147 // Apply the file stack back to the main stack.
1148 const newStack = stack.applySubStack(2 as CommitRev, 4 as CommitRev, newSubStack);
1149 // Check that y.txt in rev 2 (B) is absent, not just empty.
1150 const file = newStack.getFile(2 as CommitRev, 'y.txt');
1151 expect(file.data).toBe('');
1152 expect(file.flags).toBe('');
1153 });
1154 });
1155
1156 describe('absorb', () => {
1157 const absorbStack1: ExportStack = [
1158 {
1159 ...exportCommitDefault,
1160 immutable: true,
1161 node: 'Z_NODE',
1162 relevantFiles: {
1163 'seq.txt': {data: '0\n'},
1164 'rename_from.txt': null,
1165 },
1166 requested: false,
1167 text: 'PublicCommit',
1168 },
1169 {
1170 ...exportCommitDefault,
1171 files: {
1172 'seq.txt': {data: '0\n1\n'},
1173 'rename_from.txt': {data: '1\n'},
1174 },
1175 node: 'A_NODE',
1176 parents: ['Z_NODE'],
1177 relevantFiles: {'rename_to.txt': null},
1178 text: 'CommitA',
1179 },
1180 {
1181 ...exportCommitDefault,
1182 files: {
1183 'seq.txt': {data: '0\n1\n2\n'},
1184 'rename_to.txt': {copyFrom: 'rename_from.txt', data: '1\n'},
1185 'rename_from.txt': null,
1186 },
1187 node: 'B_NODE',
1188 parents: ['A_NODE'],
1189 text: 'CommitB',
1190 },
1191 {
1192 ...exportCommitDefault,
1193 // Working copy changes. 012 => xyz.
1194 files: {
1195 'seq.txt': {data: 'x\ny\nz\n'},
1196 'rename_to.txt': {data: 'p\n'},
1197 },
1198 node: 'WDIR',
1199 parents: ['B_NODE'],
1200 text: 'Wdir',
1201 },
1202 ];
1203
1204 it('can prepare for absorb', () => {
1205 const stack = new CommitStackState(absorbStack1);
1206 expect(stack.describeFileStacks()).toMatchInlineSnapshot(`
1207 [
1208 "0:./rename_from.txt 1:CommitA/rename_from.txt(1↵) 2:CommitB/rename_to.txt(1↵) 3:Wdir/rename_to.txt(p↵)",
1209 "0:./seq.txt(0↵) 1:CommitA/seq.txt(0↵1↵) 2:CommitB/seq.txt(0↵1↵2↵) 3:Wdir/seq.txt(x↵y↵z↵)",
1210 ]
1211 `);
1212 const stackWithAbsorb = stack.analyseAbsorb();
1213 expect(stackWithAbsorb.hasPendingAbsorb()).toBeTruthy();
1214 // The "1 => p" change in "rename_to.txt" is absorbed following file renames into rename_from.txt.
1215 // The "1 => y", "2 => z" changes in "seq.txt" are absorbed to CommitA and CommitB.
1216 // The "0 => x" change in "seq.txt" is left in the working copy as "0" is an immutable line (public commit).
1217 expect(stackWithAbsorb.describeFileStacks()).toMatchInlineSnapshot(`
1218 [
1219 "0:./rename_from.txt 1:CommitA/rename_from.txt(1↵;absorbed:p↵)",
1220 "0:./seq.txt(0↵) 1:CommitA/seq.txt(0↵1↵;absorbed:0↵y↵) 2:CommitB/seq.txt(0↵y↵2↵;absorbed:0↵y↵z↵) 3:Wdir/seq.txt(0↵y↵z↵;absorbed:x↵y↵z↵)",
1221 ]
1222 `);
1223 expect(describeAbsorbExtra(stackWithAbsorb)).toMatchInlineSnapshot(`
1224 {
1225 "0": [
1226 "0: -1↵ +p↵ Selected=1 Introduced=1",
1227 ],
1228 "1": [
1229 "0: -0↵ +x↵ Introduced=0",
1230 "1: -1↵ +y↵ Selected=1 Introduced=1",
1231 "2: -2↵ +z↵ Selected=2 Introduced=2",
1232 ],
1233 }
1234 `);
1235 });
1236
1237 const absorbStack2: ExportStack = [
1238 {
1239 ...exportCommitDefault,
1240 immutable: true,
1241 node: 'Z_NODE',
1242 relevantFiles: {
1243 'a.txt': null,
1244 },
1245 requested: false,
1246 text: 'PublicCommit',
1247 },
1248 {
1249 ...exportCommitDefault,
1250 files: {
1251 'a.txt': {data: 'a1\na2\na3\n'},
1252 },
1253 node: 'A_NODE',
1254 parents: ['Z_NODE'],
1255 relevantFiles: {'b.txt': null},
1256 text: 'CommitA',
1257 },
1258 {
1259 ...exportCommitDefault,
1260 files: {
1261 'b.txt': {data: 'b1\nb2\nb3\n'},
1262 },
1263 relevantFiles: {
1264 'a.txt': {data: 'a1\na2\na3\n'},
1265 'c.txt': {data: 'c1\nc2\nc3\n'},
1266 },
1267 node: 'B_NODE',
1268 parents: ['A_NODE'],
1269 text: 'CommitB',
1270 },
1271 {
1272 ...exportCommitDefault,
1273 files: {
1274 'a.txt': {data: 'a1\na2\na3\nx1\n'},
1275 'b.txt': {data: 'b1\nb2\nb3\ny1\n'},
1276 'c.txt': {data: 'c1\nc2\nc3\nz1\n'},
1277 },
1278 node: 'C_NODE',
1279 parents: ['B_NODE'],
1280 text: 'CommitC',
1281 },
1282 {
1283 ...exportCommitDefault,
1284 files: {
1285 'a.txt': {data: 'A1\na2\na3\nX1\n'},
1286 'b.txt': {data: 'B1\nb2\nb3\nY1\n'},
1287 'c.txt': {data: 'C1\nC2\nc3\nz1\n'},
1288 },
1289 node: 'WDIR',
1290 parents: ['C_NODE'],
1291 text: 'Wdir',
1292 },
1293 ];
1294
1295 it('provides absorb candidate revs', () => {
1296 const stack = new CommitStackState(absorbStack2).analyseAbsorb();
1297 expect(describeAbsorbExtra(stack)).toMatchInlineSnapshot(`
1298 {
1299 "0": [
1300 "0: -a1↵ +A1↵ Selected=1 Introduced=1",
1301 "1: -x1↵ +X1↵ Selected=2 Introduced=2",
1302 ],
1303 "1": [
1304 "0: -b1↵ +B1↵ Selected=1 Introduced=1",
1305 "1: -y1↵ +Y1↵ Selected=2 Introduced=2",
1306 ],
1307 "2": [
1308 "0: -c1↵ c2↵ +C1↵ C2↵ Introduced=0",
1309 ],
1310 }
1311 `);
1312 expect(describeAbsorbEditCommits(stack)).toEqual([
1313 {
1314 // The "a1 -> A1" change is applied to "CommitA" which introduced "a".
1315 // It can be applied to "CommitC" which changes "a.txt" too.
1316 // It cannot be applied to "CommitB" which didn't change "a.txt" (and
1317 // therefore not tracked by linelog).
1318 id: 'a.txt/0',
1319 diff: ['a1↵', 'A1↵'],
1320 selected: 'CommitA',
1321 candidates: ['CommitA', 'CommitC', 'Wdir'],
1322 },
1323 {
1324 // The "x1 -> X1" change is applied to "CommitC" which introduced "c".
1325 id: 'a.txt/1',
1326 diff: ['x1↵', 'X1↵'],
1327 selected: 'CommitC',
1328 candidates: ['CommitC', 'Wdir'],
1329 },
1330 {
1331 // The "b1 -> B1" change belongs to CommitB.
1332 id: 'b.txt/0',
1333 diff: ['b1↵', 'B1↵'],
1334 selected: 'CommitB',
1335 candidates: ['CommitB', 'CommitC', 'Wdir'],
1336 },
1337 {
1338 // The "y1 -> Y1" change belongs to CommitB.
1339 id: 'b.txt/1',
1340 diff: ['y1↵', 'Y1↵'],
1341 selected: 'CommitC',
1342 candidates: ['CommitC', 'Wdir'],
1343 },
1344 {
1345 // The "c1c2 -> C1C2" change is not automatically absorbed, since
1346 // "ccc" is public/immutable.
1347 id: 'c.txt/0',
1348 diff: ['c1↵c2↵', 'C1↵C2↵'],
1349 selected: undefined,
1350 // CommitC is a candidate because it modifies c.txt.
1351 candidates: ['CommitC', 'Wdir'],
1352 },
1353 ]);
1354 });
1355
1356 it('updates absorb destination commit', () => {
1357 const stack = new CommitStackState(absorbStack2).analyseAbsorb();
1358 // Current state. Note the "-a1 +A1" has "Selected=1" where 1 is the "file stack rev".
1359 expect(stack.absorbExtra.get(0)?.get(0)?.selectedRev).toBe(1);
1360 // Move the "a1 -> A1" change from CommitA to CommitC.
1361 // See the above test's "describeAbsorbExtra" to confirm that "a1 -> A1"
1362 // has fileIdx=0 and absorbEditId=0.
1363 // CommitC has rev=3.
1364 const newStack = stack.setAbsorbEditDestination(0, 0, 3 as CommitRev);
1365 // "-a1 +A1" now has "Selected=2":
1366 expect(newStack.absorbExtra.get(0)?.get(0)?.selectedRev).toBe(2);
1367 expect(describeAbsorbExtra(newStack)).toMatchInlineSnapshot(`
1368 {
1369 "0": [
1370 "0: -a1↵ +A1↵ Selected=2 Introduced=1",
1371 "1: -x1↵ +X1↵ Selected=2 Introduced=2",
1372 ],
1373 "1": [
1374 "0: -b1↵ +B1↵ Selected=1 Introduced=1",
1375 "1: -y1↵ +Y1↵ Selected=2 Introduced=2",
1376 ],
1377 "2": [
1378 "0: -c1↵ c2↵ +C1↵ C2↵ Introduced=0",
1379 ],
1380 }
1381 `);
1382 // The A1 is now absorbed at CommitC.
1383 expect(newStack.describeFileStacks()).toMatchInlineSnapshot(`
1384 [
1385 "0:./a.txt 1:CommitA/a.txt(a1↵a2↵a3↵) 2:CommitC/a.txt(a1↵a2↵a3↵x1↵;absorbed:A1↵a2↵a3↵X1↵)",
1386 "0:./b.txt 1:CommitB/b.txt(b1↵b2↵b3↵;absorbed:B1↵b2↵b3↵) 2:CommitC/b.txt(B1↵b2↵b3↵y1↵;absorbed:B1↵b2↵b3↵Y1↵)",
1387 "0:./c.txt(c1↵c2↵c3↵) 1:CommitC/c.txt(c1↵c2↵c3↵z1↵) 2:Wdir/c.txt(c1↵c2↵c3↵z1↵;absorbed:C1↵C2↵c3↵z1↵)",
1388 ]
1389 `);
1390 // It can be moved back.
1391 const newStack2 = newStack.setAbsorbEditDestination(0, 0, 1 as CommitRev);
1392 expect(newStack2.absorbExtra.get(0)?.get(0)?.selectedRev).toBe(1);
1393 // It can be moved to wdir(), the top rev.
1394 const topRev = nullthrows(newStack2.revs().at(-1));
1395 const newStack3 = newStack2.setAbsorbEditDestination(0, 0, topRev);
1396 expect(newStack3.getAbsorbCommitRevs(0, 0).selectedRev).toBe(topRev);
1397 });
1398
1399 it('updates getUtf8 with pending absorb edits', () => {
1400 const stack1 = new CommitStackState(absorbStack2).useFileStack();
1401 const get = (
1402 stack: CommitStackState,
1403 fileIdx: number,
1404 fileRev: number,
1405 considerAbsorb?: boolean,
1406 ) =>
1407 replaceNewLines(
1408 stack.getUtf8Data(
1409 FileState({data: FileIdx({fileIdx, fileRev: fileRev as FileRev})}),
1410 considerAbsorb,
1411 ),
1412 );
1413 expect(get(stack1, 0, 1)).toMatchInlineSnapshot(`"a1↵a2↵a3↵"`);
1414 // getUtf8Data considers the pending absorb (a1 -> A1).
1415 const stack2 = stack1.analyseAbsorb();
1416 expect(get(stack2, 0, 1)).toMatchInlineSnapshot(`"A1↵a2↵a3↵"`);
1417 // Can still ask for the content without absorb explicitly.
1418 expect(get(stack2, 0, 1, false)).toMatchInlineSnapshot(`"a1↵a2↵a3↵"`);
1419 });
1420
1421 it('can apply absorb edits', () => {
1422 const beforeStack = new CommitStackState(absorbStack2).useFileStack().analyseAbsorb();
1423 expect(beforeStack.useFileStack().describeFileStacks()).toMatchInlineSnapshot(`
1424 [
1425 "0:./a.txt 1:CommitA/a.txt(a1↵a2↵a3↵;absorbed:A1↵a2↵a3↵) 2:CommitC/a.txt(A1↵a2↵a3↵x1↵;absorbed:A1↵a2↵a3↵X1↵)",
1426 "0:./b.txt 1:CommitB/b.txt(b1↵b2↵b3↵;absorbed:B1↵b2↵b3↵) 2:CommitC/b.txt(B1↵b2↵b3↵y1↵;absorbed:B1↵b2↵b3↵Y1↵)",
1427 "0:./c.txt(c1↵c2↵c3↵) 1:CommitC/c.txt(c1↵c2↵c3↵z1↵) 2:Wdir/c.txt(c1↵c2↵c3↵z1↵;absorbed:C1↵C2↵c3↵z1↵)",
1428 ]
1429 `);
1430 // After `applyAbsorbEdits`, "absorbed:" contents become real contents.
1431 const afterStack = beforeStack.applyAbsorbEdits();
1432 expect(afterStack.hasPendingAbsorb()).toBeFalsy();
1433 expect(describeAbsorbExtra(afterStack)).toMatchInlineSnapshot(`{}`);
1434 expect(afterStack.useFileStack().describeFileStacks()).toMatchInlineSnapshot(`
1435 [
1436 "0:./a.txt 1:CommitA/a.txt(A1↵a2↵a3↵) 2:CommitC/a.txt(A1↵a2↵a3↵X1↵) 3:Wdir/a.txt(A1↵a2↵a3↵X1↵)",
1437 "0:./b.txt 1:CommitB/b.txt(B1↵b2↵b3↵) 2:CommitC/b.txt(B1↵b2↵b3↵Y1↵) 3:Wdir/b.txt(B1↵b2↵b3↵Y1↵)",
1438 "0:./c.txt(c1↵c2↵c3↵) 1:CommitC/c.txt(c1↵c2↵c3↵z1↵) 2:Wdir/c.txt(C1↵C2↵c3↵z1↵)",
1439 ]
1440 `);
1441 });
1442
1443 function describeAbsorbExtra(stack: CommitStackState) {
1444 return stack.absorbExtra.map(describeAbsorbIdChunkMap).toJS();
1445 }
1446
1447 function replaceNewLines(text: string): string {
1448 return text.replaceAll('\n', '↵');
1449 }
1450
1451 function describeAbsorbEditCommits(stack: CommitStackState) {
1452 const describeCommit = (rev: CommitRev) => nullthrows(stack.get(rev)).text;
1453 const result: object[] = [];
1454 stack.absorbExtra.forEach((absorbEdits, fileIdx) => {
1455 absorbEdits.forEach((absorbEdit, absorbEditId) => {
1456 const {candidateRevs, selectedRev} = stack.getAbsorbCommitRevs(fileIdx, absorbEditId);
1457 result.push({
1458 id: `${stack.getFileStackPath(fileIdx, absorbEdit.introductionRev)}/${absorbEditId}`,
1459 diff: [
1460 replaceNewLines(absorbEdit.oldLines.join('')),
1461 replaceNewLines(absorbEdit.newLines.join('')),
1462 ],
1463 candidates: candidateRevs.map(describeCommit),
1464 selected: selectedRev && describeCommit(selectedRev),
1465 });
1466 });
1467 });
1468 return result;
1469 }
1470 });
1471});
1472