14.2 KB349 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 {Map as ImMap} from 'immutable';
9import type {AbsorbEdit, AbsorbEditId} from '../absorb';
10import type {FileRev} from '../fileStackState';
11
12import {splitLines} from 'shared/diff';
13import {
14 analyseFileStack,
15 applyFileStackEditsWithAbsorbId,
16 calculateAbsorbEditsForFileStack,
17 embedAbsorbId,
18 extractRevAbsorbId,
19 revWithAbsorb,
20} from '../absorb';
21import {FileStackState} from '../fileStackState';
22
23// See also [test-fb-ext-absorb-filefixupstate.py](https://github.com/facebook/sapling/blob/eb3d35d/eden/scm/tests/test-fb-ext-absorb-filefixupstate.py#L75)
24describe('analyseFileStack', () => {
25 it('edits an empty file', () => {
26 // Public: empty.
27 const stack = createStack(['']);
28 // No Selectedion - cannot edit the public (rev 0) content.
29 expect(analyseFile(stack, 'a')).toMatchInlineSnapshot(`"0:0=>'a': Rev 0+ Selected null"`);
30 });
31
32 it('edits 2 lines by one insertion', () => {
33 // Public: empty. Rev 1: "1\n1\n".
34 const stack = createStack(['', '1↵1↵']);
35 // Delete the chunk.
36 expect(analyseFile(stack, '')).toMatchInlineSnapshot(`"0:2=>'': Rev 1+ Selected 1"`);
37 // Replace to 1 line.
38 expect(analyseFile(stack, '2')).toMatchInlineSnapshot(`"0:2=>'2': Rev 1+ Selected 1"`);
39 // Replace to 2 lines.
40 expect(analyseFile(stack, '22')).toMatchInlineSnapshot(`"0:2=>'22': Rev 1+ Selected 1"`);
41 // Replace to 3 lines.
42 expect(analyseFile(stack, '222')).toMatchInlineSnapshot(`"0:2=>'222': Rev 1+ Selected 1"`);
43 });
44
45 it('edits 3 lines by 3 insertions', () => {
46 // Public: empty. Rev 1: "1". Rev 2: "1", "2". Rev 3: "1", "2", "3".
47 const stack = createStack(['', '1↵', '1↵2↵', '1↵2↵3↵']);
48 // No change.
49 expect(analyseFile(stack, '1↵2↵3↵')).toMatchInlineSnapshot(`""`);
50 // Replave the last line.
51 expect(analyseFile(stack, '1↵2↵c↵')).toMatchInlineSnapshot(`"2:3=>'c': Rev 3+ Selected 3"`);
52 // Replave the 2nd line.
53 expect(analyseFile(stack, '1↵b↵3↵')).toMatchInlineSnapshot(`"1:2=>'b': Rev 2+ Selected 2"`);
54 // Replace the last 2 lines.
55 expect(analyseFile(stack, '1↵b↵c↵')).toMatchInlineSnapshot(`
56 "1:2=>'b': Rev 2+ Selected 2
57 2:3=>'c': Rev 3+ Selected 3"
58 `);
59 // Replace the first line.
60 expect(analyseFile(stack, 'a↵2↵3↵')).toMatchInlineSnapshot(`"0:1=>'a': Rev 1+ Selected 1"`);
61 // Replace the first and the last lines.
62 expect(analyseFile(stack, 'a↵2↵c↵')).toMatchInlineSnapshot(`
63 "0:1=>'a': Rev 1+ Selected 1
64 2:3=>'c': Rev 3+ Selected 3"
65 `);
66 // Replace the first 2 lines.
67 expect(analyseFile(stack, 'a↵b↵3↵')).toMatchInlineSnapshot(`
68 "0:1=>'a': Rev 1+ Selected 1
69 1:2=>'b': Rev 2+ Selected 2"
70 `);
71 // Replace all 3 lines.
72 expect(analyseFile(stack, 'a↵b↵c↵')).toMatchInlineSnapshot(`
73 "0:1=>'a': Rev 1+ Selected 1
74 1:2=>'b': Rev 2+ Selected 2
75 2:3=>'c': Rev 3+ Selected 3"
76 `);
77 // Non 1:1 line mapping.
78 expect(analyseFile(stack, 'a↵b↵c↵d↵')).toMatchInlineSnapshot(
79 `"0:3=>'abcd': Rev 3+ Selected null"`,
80 );
81 expect(analyseFile(stack, 'a↵b↵')).toMatchInlineSnapshot(`"0:3=>'ab': Rev 3+ Selected null"`);
82 // Deletion.
83 expect(analyseFile(stack, '')).toMatchInlineSnapshot(`
84 "0:1=>'': Rev 1+ Selected 1
85 1:2=>'': Rev 2+ Selected 2
86 2:3=>'': Rev 3+ Selected 3"
87 `);
88 expect(analyseFile(stack, '1↵')).toMatchInlineSnapshot(`
89 "1:2=>'': Rev 2+ Selected 2
90 2:3=>'': Rev 3+ Selected 3"
91 `);
92 expect(analyseFile(stack, '2↵')).toMatchInlineSnapshot(`
93 "0:1=>'': Rev 1+ Selected 1
94 2:3=>'': Rev 3+ Selected 3"
95 `);
96 expect(analyseFile(stack, '3↵')).toMatchInlineSnapshot(`
97 "0:1=>'': Rev 1+ Selected 1
98 1:2=>'': Rev 2+ Selected 2"
99 `);
100 expect(analyseFile(stack, '1↵3↵')).toMatchInlineSnapshot(`"1:2=>'': Rev 2+ Selected 2"`);
101 // Replace the 2nd line with multiple lines.
102 expect(analyseFile(stack, '1↵b↵b↵3↵')).toMatchInlineSnapshot(`"1:2=>'bb': Rev 2+ Selected 2"`);
103 // "Confusing" replaces.
104 expect(analyseFile(stack, '1↵b↵b↵b↵')).toMatchInlineSnapshot(
105 `"1:3=>'bbb': Rev 3+ Selected null"`,
106 );
107 expect(analyseFile(stack, 'b↵b↵b↵3↵')).toMatchInlineSnapshot(
108 `"0:2=>'bbb': Rev 2+ Selected null"`,
109 );
110 expect(analyseFile(stack, '1↵b↵')).toMatchInlineSnapshot(`"1:3=>'b': Rev 3+ Selected null"`);
111 expect(analyseFile(stack, 'b↵3↵')).toMatchInlineSnapshot(`"0:2=>'b': Rev 2+ Selected null"`);
112 // Insertion at the beginning and the end.
113 expect(analyseFile(stack, '1↵2↵3↵c↵')).toMatchInlineSnapshot(`"3:3=>'c': Rev 3+ Selected 3"`);
114 expect(analyseFile(stack, 'a↵1↵2↵3↵')).toMatchInlineSnapshot(`"0:0=>'a': Rev 1+ Selected 1"`);
115 // "Confusing" insertions.
116 expect(analyseFile(stack, '1↵a↵2↵3↵')).toMatchInlineSnapshot(
117 `"1:1=>'a': Rev 2+ Selected null"`,
118 );
119 expect(analyseFile(stack, '1↵2↵b↵3↵')).toMatchInlineSnapshot(
120 `"2:2=>'b': Rev 3+ Selected null"`,
121 );
122 });
123
124 it('does not edit the public commit', () => {
125 const stack = createStack(['1↵3↵5↵7↵', '0↵1↵2↵5↵6↵7↵8↵']);
126 // Nothing changed.
127 expect(analyseFile(stack, '0↵1↵2↵5↵6↵7↵8↵')).toMatchInlineSnapshot(`""`);
128 // No Selectedion. "1" (from public) is changed to "a".
129 expect(analyseFile(stack, '0↵a↵2↵5↵6↵7↵8↵')).toMatchInlineSnapshot(
130 `"1:2=>'a': Rev 0+ Selected null"`,
131 );
132 // Whole block changed. NOTE: This is different from the Python behavior.
133 expect(analyseFile(stack, 'a↵b↵c↵d↵e↵f↵g↵')).toMatchInlineSnapshot(
134 `"0:7=>'abcdefg': Rev 1+ Selected 1"`,
135 );
136 expect(analyseFile(stack, 'a↵b↵c↵d↵e↵f↵')).toMatchInlineSnapshot(
137 `"0:7=>'abcdef': Rev 1+ Selected 1"`,
138 );
139 expect(analyseFile(stack, '')).toMatchInlineSnapshot(`"0:7=>'': Rev 1+ Selected 1"`);
140 // Insert 2 lines.
141 expect(analyseFile(stack, '0↵1↵2↵3↵4↵5↵6↵7↵8↵9↵')).toMatchInlineSnapshot(`
142 "3:3=>'34': Rev 1+ Selected 1
143 7:7=>'9': Rev 1+ Selected 1"
144 `);
145 });
146
147 describe('applyFileStackEdits', () => {
148 it('edits 3 lines by 3 insertions', () => {
149 // Replace ['1','2','3'] to ['a','b','c'], 1->a, 2->b, 3->c.
150 const fullStack = createStack(['', '1↵', '1↵2↵', '1↵2↵3↵', 'a↵b↵c↵']);
151 const edits = calculateAbsorbEditsForFileStack(fullStack)[1];
152 const stack = fullStack.truncate((fullStack.revLength - 1) as FileRev);
153 expect(applyEdits(stack, edits.values())).toMatchInlineSnapshot(`" a↵ a↵b↵ a↵b↵c↵"`);
154 // Tweak the `selectedRev` so the 1->a, 2->b changes happen at the last rev.
155 const edits2 = edits.map(c => c.set('selectedRev', 3 as FileRev));
156 expect(applyEdits(stack, edits2.values())).toMatchInlineSnapshot(`" 1↵ 1↵2↵ a↵b↵c↵"`);
157 // Drop the "2->b" change by setting selectedRev to `null`.
158 const edits3 = edits.map(c => (c.oldStart === 1 ? c.set('selectedRev', null) : c));
159 expect(applyEdits(stack, edits3.values())).toMatchInlineSnapshot(`" a↵ a↵2↵ a↵2↵c↵ a↵b↵c↵"`);
160 });
161
162 it('edits do not need to be 1:1 line mapping', () => {
163 // Replace ['111','2','333'] to ['aaaa','2','cc']. 111->aaaa. 333->cc.
164 const fullStack = createStack(['', '2↵', '1↵1↵1↵2↵3↵3↵3↵', 'a↵a↵a↵a↵2↵c↵c↵']);
165 const edits = calculateAbsorbEditsForFileStack(fullStack)[1];
166 const stack = fullStack.truncate((fullStack.revLength - 1) as FileRev);
167 expect(applyEdits(stack, edits.values())).toMatchInlineSnapshot(`" 2↵ a↵a↵a↵a↵2↵c↵c↵"`);
168 // Drop the "1->aaa" change by setting selectedRev to `null`.
169 const edits2 = edits.map(c => (c.oldStart === 0 ? c.set('selectedRev', null) : c));
170 expect(applyEdits(stack, edits2.values())).toMatchInlineSnapshot(
171 `" 2↵ 1↵1↵1↵2↵c↵c↵ a↵a↵a↵a↵2↵c↵c↵"`,
172 );
173 });
174 });
175
176 describe('absorbId', () => {
177 it('can be embedded into rev, and extracted out', () => {
178 const plainRev = 567;
179 const absorbEditId = 890;
180 const rev = embedAbsorbId(plainRev as FileRev, absorbEditId);
181 expect(extractRevAbsorbId(rev)).toEqual([plainRev, absorbEditId]);
182 });
183 });
184
185 describe('calculateAbsorbEditsForFileStack', () => {
186 it('analyses a stack', () => {
187 const stack = createStack([
188 'p↵u↵b↵',
189 'p↵u↵b↵1↵2↵3↵4↵',
190 'p↵u↵b↵2↵3↵4↵5↵6↵',
191 'p↵U↵b↵x↵3↵4↵6↵y↵',
192 ]);
193 const [analysedStack, absorbMap] = calculateAbsorbEditsForFileStack(stack);
194 expect(describeAbsorbIdChunkMap(absorbMap)).toMatchInlineSnapshot(`
195 [
196 "0: -u↵ +U↵ Introduced=0",
197 "1: -2↵ +x↵ Selected=1 Introduced=1",
198 "2: -5↵ Selected=2 Introduced=2",
199 "3: +y↵ Selected=2 Introduced=2",
200 ]
201 `);
202 const show = (rev: number) => compactText(analysedStack.getRev(rev as FileRev));
203 // Rev 1 original.
204 expect(show(1)).toMatchInlineSnapshot(`"p↵u↵b↵1↵2↵3↵4↵"`);
205 // Rev 1.99 is Rev 1 with the absorb "-2 -x" chunk applied.
206 expect(show(1.99)).toMatchInlineSnapshot(`"p↵u↵b↵1↵x↵3↵4↵"`);
207 // Rev 2 original.
208 expect(show(2)).toMatchInlineSnapshot(`"p↵u↵b↵x↵3↵4↵5↵6↵"`);
209 // Rev 2.99 is Rev 2 with the absorb "-5 +y" applied.
210 const rev299 = revWithAbsorb(2 as FileRev);
211 expect(show(rev299)).toMatchInlineSnapshot(`"p↵u↵b↵x↵3↵4↵6↵y↵"`);
212 // Rev 3 "wdir()" is dropped - no changes from 2.99.
213 expect(show(3)).toMatchInlineSnapshot(`"p↵u↵b↵x↵3↵4↵6↵y↵"`);
214 // Rev 3.99 includes changes left in "wdir()": "pub" -> "pUb".
215 // This edit changes the "public" portion so it wasn't absorbed by default.
216 expect(show(3.99)).toMatchInlineSnapshot(`"p↵U↵b↵x↵3↵4↵6↵y↵"`);
217 expect(analysedStack.convertToLineLog().code.describeHumanReadableInstructions())
218 .toMatchInlineSnapshot(`
219 [
220 "0: J 1",
221 "1: JL 0 5",
222 "2: LINE 0 "p"",
223 "3: J 30",
224 "4: LINE 0 "b"",
225 "5: J 6",
226 "6: JL 1 11",
227 "7: J 16",
228 "8: J 25",
229 "9: LINE 1 "3"",
230 "10: LINE 1 "4"",
231 "11: J 12",
232 "12: JL 2 15",
233 "13: J 22",
234 "14: LINE 2 "6"",
235 "15: J 19",
236 "16: JGE 2 8",
237 "17: LINE 1 "1"",
238 "18: J 8",
239 "19: J 21",
240 "20: J 21",
241 "21: J 35",
242 "22: J 23",
243 "23: J 38",
244 "24: J 14",
245 "25: J 27",
246 "26: J 27",
247 "27: J 28",
248 "28: J 41",
249 "29: J 9",
250 "30: J 32",
251 "31: J 32",
252 "32: J 33",
253 "33: J 46",
254 "34: J 4",
255 "35: JL 2.0000038146972656 37",
256 "36: LINE 2.0000038146972656 "y"",
257 "37: END",
258 "38: JGE 2.000002861022949 24",
259 "39: LINE 2 "5"",
260 "40: J 24",
261 "41: JL 1.0000019073486328 43",
262 "42: LINE 1.0000019073486328 "x"",
263 "43: JGE 1.0000019073486328 29",
264 "44: LINE 1 "2"",
265 "45: J 29",
266 "46: JL 3.0000009536743164 48",
267 "47: LINE 3.0000009536743164 "U"",
268 "48: JGE 3.0000009536743164 34",
269 "49: LINE 0 "u"",
270 "50: J 34",
271 ]
272 `);
273 });
274
275 it('1:1 line mapping edit can include immutable lines', () => {
276 const stack = createStack(['p↵', 'p↵1↵', 'p↵1↵2↵', 'P↵X↵Y↵']);
277 const [, absorbMap] = calculateAbsorbEditsForFileStack(stack);
278 // Absorbed edits: "1 => X"; "2 => Y" (selected is set)
279 // "p => P" is left in the working copy, since "p" is considered immutable.
280 expect(describeAbsorbIdChunkMap(absorbMap)).toMatchInlineSnapshot(`
281 [
282 "0: -p↵ +P↵ Introduced=0",
283 "1: -1↵ +X↵ Selected=1 Introduced=1",
284 "2: -2↵ +Y↵ Selected=2 Introduced=2",
285 ]
286 `);
287 });
288 });
289
290 function createStack(texts: string[]): FileStackState {
291 return new FileStackState(texts.map(t => injectNewLines(t)));
292 }
293
294 function analyseFile(stack: FileStackState, newText: string): string {
295 const text = injectNewLines(newText);
296 const oldLines = splitLines(stack.getRev((stack.revLength - 1) as FileRev));
297 const newLines = splitLines(text);
298 const chunks = analyseFileStack(stack, text);
299 return chunks
300 .map(c => {
301 // Check the old and new line numbers and content match.
302 expect(oldLines.slice(c.oldStart, c.oldEnd)).toEqual(c.oldLines.toArray());
303 expect(newLines.slice(c.newStart, c.newEnd)).toEqual(c.newLines.toArray());
304 return `${c.oldStart}:${c.oldEnd}=>'${c.newLines
305 .map(l => l.replace('\n', ''))
306 .join('')}': Rev ${c.introductionRev}+ Selected ${c.selectedRev}`;
307 })
308 .join('\n');
309 }
310
311 function applyEdits(stack: FileStackState, edits: Iterable<AbsorbEdit>): string {
312 const editedStack = applyFileStackEditsWithAbsorbId(stack, edits);
313 return compactTexts(editedStack.revs().map(rev => editedStack.getRev(revWithAbsorb(rev))));
314 }
315
316 /** Replace "↵" with "\n" */
317 function injectNewLines(text: string): string {
318 return text.replaceAll('↵', '\n');
319 }
320
321 function compactText(text: string): string {
322 return text.replaceAll('\n', '↵');
323 }
324});
325
326/** Turn ["a\n", "a\nb\n"] to "a↵ ab↵". */
327function compactTexts(texts: Iterable<string>): string {
328 return [...texts].map(t => t.replaceAll('\n', '↵')).join(' ');
329}
330
331export function describeAbsorbIdChunkMap(map: ImMap<AbsorbEditId, AbsorbEdit>): string[] {
332 const result: string[] = [];
333 map.forEach((chunk, id) => {
334 const words: string[] = [`${id}:`];
335 if (!chunk.oldLines.isEmpty()) {
336 words.push(`-${compactTexts(chunk.oldLines)}`);
337 }
338 if (!chunk.newLines.isEmpty()) {
339 words.push(`+${compactTexts(chunk.newLines)}`);
340 }
341 if (chunk.selectedRev != null) {
342 words.push(`Selected=${chunk.selectedRev}`);
343 }
344 words.push(`Introduced=${chunk.introductionRev}`);
345 result.push(words.join(' '));
346 });
347 return result;
348}
349