| 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 | |
| 8 | import type {Map as ImMap} from 'immutable'; |
| 9 | import type {AbsorbEdit, AbsorbEditId} from '../absorb'; |
| 10 | import type {FileRev} from '../fileStackState'; |
| 11 | |
| 12 | import {splitLines} from 'shared/diff'; |
| 13 | import { |
| 14 | analyseFileStack, |
| 15 | applyFileStackEditsWithAbsorbId, |
| 16 | calculateAbsorbEditsForFileStack, |
| 17 | embedAbsorbId, |
| 18 | extractRevAbsorbId, |
| 19 | revWithAbsorb, |
| 20 | } from '../absorb'; |
| 21 | import {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) |
| 24 | describe('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↵". */ |
| 327 | function compactTexts(texts: Iterable<string>): string { |
| 328 | return [...texts].map(t => t.replaceAll('\n', '↵')).join(' '); |
| 329 | } |
| 330 | |
| 331 | export 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 | |