1import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
2import { structuredPatch } from "diff";
3
4import 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 */
11export interface AzureDevOpsDiffCursor {
12 readonly iterationId: number;
13 readonly fileIndex: number;
14}
15
16const CURSOR_SEPARATOR = ":";
17
18/**
19 * Restricts each half to plain decimal, so `Number` cannot read a foreign cursor's `0x3` as
20 * three.
21 */
22const CURSOR_COMPONENT = /^\d+$/;
23
24export 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. */
29export 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. */
45export 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
52export 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 */
72const 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. */
75const 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 */
83export 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 */
90const MAX_FILE_DIFF_MILLIS = 2_000;
91
92/** How much diff work one slice does before the rest is left for the next request. */
93export const MAX_DIFF_SLICE_EDITS = 6_000;
94
95/** How much patch one slice carries before the rest is left for the next request. */
96export 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 */
102export const MAX_DIFF_SLICE_FILES = 300;
103
104/** Git's own note for a side whose last line has no newline after it. */
105const 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. */
108function 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. */
116function 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 */
124export 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 */
130function 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 */
141function 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 */
164function 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 */
184export 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. */
269export function azureDevOpsUnreadableFilePatch(
270 change: AzureDevOpsChangeEntry,
271): AzureDevOpsFilePatch {
272 return { section: `${patchHeader(change)}\n`, truncated: true, abandoned: false, edits: 0 };
273}