| 1 | import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; |
| 2 | import { structuredPatch } from "diff"; |
| 3 | |
| 4 | import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; |
| 5 | |
| 6 | /** |
| 7 | * How far a diff read got, and which push it was reading. Azure hangs a pull request's changed |
| 8 | * files off an iteration, so a push landing mid-read would renumber the list under a cursor that |
| 9 | * did not also pin the iteration. |
| 10 | */ |
| 11 | export interface AzureDevOpsDiffCursor { |
| 12 | readonly iterationId: number; |
| 13 | readonly fileIndex: number; |
| 14 | } |
| 15 | |
| 16 | const CURSOR_SEPARATOR = ":"; |
| 17 | |
| 18 | /** |
| 19 | * Restricts each half to plain decimal, so `Number` cannot read a foreign cursor's `0x3` as |
| 20 | * three. |
| 21 | */ |
| 22 | const CURSOR_COMPONENT = /^\d+$/; |
| 23 | |
| 24 | export function formatAzureDevOpsDiffCursor(cursor: AzureDevOpsDiffCursor): string { |
| 25 | return `${cursor.iterationId}${CURSOR_SEPARATOR}${cursor.fileIndex}`; |
| 26 | } |
| 27 | |
| 28 | /** Null for anything this did not write, which starts the read from the top rather than failing. */ |
| 29 | export function parseAzureDevOpsDiffCursor( |
| 30 | raw: string | null | undefined, |
| 31 | ): AzureDevOpsDiffCursor | null { |
| 32 | if (raw === null || raw === undefined) return null; |
| 33 | const [iteration, file, ...rest] = raw.split(CURSOR_SEPARATOR); |
| 34 | if (rest.length > 0) return null; |
| 35 | if (iteration === undefined || file === undefined) return null; |
| 36 | if (!CURSOR_COMPONENT.test(iteration) || !CURSOR_COMPONENT.test(file)) return null; |
| 37 | const iterationId = Number(iteration); |
| 38 | const fileIndex = Number(file); |
| 39 | if (!Number.isSafeInteger(iterationId) || iterationId <= 0) return null; |
| 40 | if (!Number.isSafeInteger(fileIndex) || fileIndex < 0) return null; |
| 41 | return { iterationId, fileIndex }; |
| 42 | } |
| 43 | |
| 44 | /** The two texts of one changed file, empty on whichever side the change does not have. */ |
| 45 | export interface AzureDevOpsFileTexts { |
| 46 | readonly oldContents: string; |
| 47 | readonly newContents: string; |
| 48 | /** Azure's own flag for a file it hands back base64-encoded instead of as text. */ |
| 49 | readonly binary: boolean; |
| 50 | } |
| 51 | |
| 52 | export interface AzureDevOpsFilePatch { |
| 53 | readonly section: string; |
| 54 | /** The file changed but its hunks are not in the section, so the patch has a hole in it. */ |
| 55 | readonly truncated: boolean; |
| 56 | /** |
| 57 | * The diff was given up on partway, having spent the whole edit budget, so a caller reading a |
| 58 | * run of files stops here rather than paying that again for each one behind it. |
| 59 | */ |
| 60 | readonly abandoned: boolean; |
| 61 | /** |
| 62 | * Lines added or removed, which is the edit distance the diff had to search out, and what a |
| 63 | * slice's budget is spent in: a file of short lines is cheap on the wire and dear to diff. |
| 64 | */ |
| 65 | readonly edits: number; |
| 66 | } |
| 67 | |
| 68 | /** |
| 69 | * Beyond this a file is shown as changed without its hunks. Azure hands back whole files rather |
| 70 | * than a patch, so an oversize file is paid for twice over before anything can be diffed. |
| 71 | */ |
| 72 | const MAX_FILE_BYTES = 512 * 1024; |
| 73 | |
| 74 | /** Git's own default, and what the hunks from this repo's other hosts are already cut to. */ |
| 75 | const PATCH_CONTEXT_LINES = 3; |
| 76 | |
| 77 | /** |
| 78 | * How far apart one file's two sides may be before it is listed without its hunks. The line diff |
| 79 | * costs about the square of the edit distance, so an unbounded pair sharing almost nothing could |
| 80 | * hold the whole server. Bounded in edits rather than milliseconds so a change slices the same |
| 81 | * way on every machine. |
| 82 | */ |
| 83 | export const MAX_FILE_DIFF_EDITS = 2_000; |
| 84 | |
| 85 | /** |
| 86 | * A backstop for a machine slower than the edit ceiling was tuned for. The ceiling rather than |
| 87 | * this is what decides a patch's shape: a timeout that fires would slice the same change one way |
| 88 | * here and another on a busier host. |
| 89 | */ |
| 90 | const MAX_FILE_DIFF_MILLIS = 2_000; |
| 91 | |
| 92 | /** How much diff work one slice does before the rest is left for the next request. */ |
| 93 | export const MAX_DIFF_SLICE_EDITS = 6_000; |
| 94 | |
| 95 | /** How much patch one slice carries before the rest is left for the next request. */ |
| 96 | export const MAX_DIFF_SLICE_BYTES = 256 * 1024; |
| 97 | |
| 98 | /** |
| 99 | * How many files one slice carries however little each one weighs. A binary, oversize, or |
| 100 | * unreadable entry is just a header, so neither budget above stops a run of thousands of them. |
| 101 | */ |
| 102 | export const MAX_DIFF_SLICE_FILES = 300; |
| 103 | |
| 104 | /** Git's own note for a side whose last line has no newline after it. */ |
| 105 | const NO_NEWLINE_MARKER = "\\ No newline at end of file"; |
| 106 | |
| 107 | /** A text's lines, without the empty one that a trailing newline leaves behind a split. */ |
| 108 | function contentLines(contents: string): ReadonlyArray<string> { |
| 109 | if (contents === "") return []; |
| 110 | const lines = contents.split("\n"); |
| 111 | if (lines.at(-1) === "") lines.pop(); |
| 112 | return lines; |
| 113 | } |
| 114 | |
| 115 | /** A NUL byte is git's own test for it, and it survives Azure's JSON envelope intact. */ |
| 116 | function isBinary(contents: string): boolean { |
| 117 | return contents.includes("\u0000"); |
| 118 | } |
| 119 | |
| 120 | /** |
| 121 | * What a file costs on the wire, which is its bytes rather than its code units: a ceiling counted |
| 122 | * in characters lets a file of three-byte glyphs through at three times the intended size. |
| 123 | */ |
| 124 | export const byteLength = (contents: string) => Buffer.byteLength(contents, "utf8"); |
| 125 | |
| 126 | /** |
| 127 | * Git points an empty range at the line before it, which is line zero for a file that is wholly |
| 128 | * new or wholly gone, and writes a single line as its number alone. |
| 129 | */ |
| 130 | function hunkRange(start: number, lines: number): string { |
| 131 | if (lines === 0) return `${start - 1},0`; |
| 132 | return lines === 1 ? String(start) : `${start},${lines}`; |
| 133 | } |
| 134 | |
| 135 | /** |
| 136 | * The `diff --git` preamble a viewer reads a file's identity and fate from. Azure reports no file |
| 137 | * mode, so the ordinary one stands in. Names are quoted the way git quotes them, prefix inside the |
| 138 | * quoting: a header reader stops a bare name at its first tab or newline, so a path holding either |
| 139 | * would be truncated and its viewed mark would land on the wrong file. |
| 140 | */ |
| 141 | function patchHeader(change: AzureDevOpsChangeEntry): string { |
| 142 | const oldSide = quoteGitPatchPath(`a/${change.oldPath}`); |
| 143 | const newSide = quoteGitPatchPath(`b/${change.path}`); |
| 144 | const lines = [`diff --git ${oldSide} ${newSide}`]; |
| 145 | if (change.changeKind === "new") lines.push("new file mode 100644"); |
| 146 | if (change.changeKind === "deleted") lines.push("deleted file mode 100644"); |
| 147 | if (change.changeKind === "rename-pure" || change.changeKind === "rename-changed") { |
| 148 | lines.push( |
| 149 | `rename from ${quoteGitPatchPath(change.oldPath)}`, |
| 150 | `rename to ${quoteGitPatchPath(change.path)}`, |
| 151 | ); |
| 152 | } |
| 153 | lines.push( |
| 154 | `--- ${change.changeKind === "new" ? "/dev/null" : oldSide}`, |
| 155 | `+++ ${change.changeKind === "deleted" ? "/dev/null" : newSide}`, |
| 156 | ); |
| 157 | return lines.join("\n"); |
| 158 | } |
| 159 | |
| 160 | /** |
| 161 | * A file written out as wholly replaced: every old line gone, every new line arrived, in one |
| 162 | * hunk, which needs no edit-distance search at all. |
| 163 | */ |
| 164 | function replacementSection(header: string, texts: AzureDevOpsFileTexts): string { |
| 165 | const oldLines = contentLines(texts.oldContents); |
| 166 | const newLines = contentLines(texts.newContents); |
| 167 | const noNewline = (contents: string, lines: ReadonlyArray<string>) => |
| 168 | lines.length > 0 && !contents.endsWith("\n") ? [NO_NEWLINE_MARKER] : []; |
| 169 | return [ |
| 170 | header, |
| 171 | `@@ -${hunkRange(1, oldLines.length)} +${hunkRange(1, newLines.length)} @@`, |
| 172 | ...oldLines.map((line) => `-${line}`), |
| 173 | ...noNewline(texts.oldContents, oldLines), |
| 174 | ...newLines.map((line) => `+${line}`), |
| 175 | ...noNewline(texts.newContents, newLines), |
| 176 | "", |
| 177 | ].join("\n"); |
| 178 | } |
| 179 | |
| 180 | /** |
| 181 | * One file's section of a unified patch, built here because Azure has no route that returns one: |
| 182 | * its diff routes name the files that changed, and the contents are a separate read per side. |
| 183 | */ |
| 184 | export function azureDevOpsFilePatch(input: { |
| 185 | readonly change: AzureDevOpsChangeEntry; |
| 186 | readonly texts: AzureDevOpsFileTexts; |
| 187 | }): AzureDevOpsFilePatch { |
| 188 | const header = patchHeader(input.change); |
| 189 | const { oldContents, newContents } = input.texts; |
| 190 | |
| 191 | if (input.texts.binary || isBinary(oldContents) || isBinary(newContents)) { |
| 192 | // Git's own wording for a file it will not spell out. |
| 193 | const oldSide = quoteGitPatchPath(`a/${input.change.oldPath}`); |
| 194 | const newSide = quoteGitPatchPath(`b/${input.change.path}`); |
| 195 | const binary = `Binary files ${oldSide} and ${newSide} differ`; |
| 196 | return { section: `${header}\n${binary}\n`, truncated: true, abandoned: false, edits: 0 }; |
| 197 | } |
| 198 | if (byteLength(oldContents) > MAX_FILE_BYTES || byteLength(newContents) > MAX_FILE_BYTES) { |
| 199 | return { section: `${header}\n`, truncated: true, abandoned: false, edits: 0 }; |
| 200 | } |
| 201 | |
| 202 | // A creation or deletion has nothing on one side, so writing both sides in full is already the |
| 203 | // minimal patch and needs no diff search. |
| 204 | const created = oldContents === "" && newContents !== ""; |
| 205 | const deleted = newContents === "" && oldContents !== ""; |
| 206 | if (created || deleted) { |
| 207 | const contents = created ? newContents : oldContents; |
| 208 | const lines = contentLines(contents); |
| 209 | // A `+`/`-` marker on every line can put a side that just fits the size ceiling over it. |
| 210 | // Checked against bytes-plus-marker-count rather than building the section, since that upper |
| 211 | // bound is cheaper and this is exactly the file shape that would cost the most to build. |
| 212 | if (byteLength(contents) + lines.length > MAX_FILE_BYTES) { |
| 213 | return { section: `${header}\n`, truncated: true, abandoned: false, edits: lines.length }; |
| 214 | } |
| 215 | const section = replacementSection(header, input.texts); |
| 216 | if (byteLength(section) > MAX_FILE_BYTES) { |
| 217 | return { section: `${header}\n`, truncated: true, abandoned: false, edits: lines.length }; |
| 218 | } |
| 219 | return { section, truncated: false, abandoned: false, edits: lines.length }; |
| 220 | } |
| 221 | |
| 222 | const patch = structuredPatch( |
| 223 | `a/${input.change.oldPath}`, |
| 224 | `b/${input.change.path}`, |
| 225 | oldContents, |
| 226 | newContents, |
| 227 | undefined, |
| 228 | undefined, |
| 229 | { |
| 230 | context: PATCH_CONTEXT_LINES, |
| 231 | maxEditLength: MAX_FILE_DIFF_EDITS, |
| 232 | timeout: MAX_FILE_DIFF_MILLIS, |
| 233 | }, |
| 234 | ); |
| 235 | // Hitting the edit ceiling lists the file without hunks rather than falling back to a full |
| 236 | // replacement: the ceiling is a distance, not a proportion, so a long file can reach it having |
| 237 | // changed in one small corner, and both sides in full would bury that corner in a wall of text. |
| 238 | if (patch === undefined) { |
| 239 | return { |
| 240 | section: `${header}\n`, |
| 241 | truncated: true, |
| 242 | abandoned: true, |
| 243 | edits: MAX_FILE_DIFF_EDITS, |
| 244 | }; |
| 245 | } |
| 246 | |
| 247 | let edits = 0; |
| 248 | const hunks = patch.hunks.map((hunk) => { |
| 249 | for (const line of hunk.lines) { |
| 250 | if (line.startsWith("+") || line.startsWith("-")) edits += 1; |
| 251 | } |
| 252 | return [ |
| 253 | `@@ -${hunkRange(hunk.oldStart, hunk.oldLines)} +${hunkRange(hunk.newStart, hunk.newLines)} @@`, |
| 254 | ...hunk.lines, |
| 255 | ].join("\n"); |
| 256 | }); |
| 257 | // A pure rename has no hunks to give but is still listed, since dropping it would take the file |
| 258 | // out of the change altogether. |
| 259 | const section = hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.join("\n")}\n`; |
| 260 | // The edit ceiling bounds edit distance, not the hunks' size: a handful of very long lines plus |
| 261 | // context can still exceed the size ceiling even well inside the edit ceiling. |
| 262 | if (byteLength(section) > MAX_FILE_BYTES) { |
| 263 | return { section: `${header}\n`, truncated: true, abandoned: false, edits }; |
| 264 | } |
| 265 | return { section, truncated: false, abandoned: false, edits }; |
| 266 | } |
| 267 | |
| 268 | /** A file listed without its hunks, for when the host would not hand one of its two sides over. */ |
| 269 | export function azureDevOpsUnreadableFilePatch( |
| 270 | change: AzureDevOpsChangeEntry, |
| 271 | ): AzureDevOpsFilePatch { |
| 272 | return { section: `${patchHeader(change)}\n`, truncated: true, abandoned: false, edits: 0 }; |
| 273 | } |