Skip to content
File

Blob: src/worker/git/operations/read/diff.ts

typescript537 lines
1import type { CacheContext } from "@/worker/cache";
2import type {
3 CommitDiffChangeType,
4 CommitDiffEntry,
5 CommitDiffResult,
6 CommitFilePatchResult,
7 TreeEntry,
8} from "./types";
9import { readTree, isTreeMode, joinTreePath } from "./tree";
10import { readCommitInfo } from "./commits";
11import { readBlob } from "./objects";
12import { bytesToText, detectBinary } from "@/shared/web";
13import { buildCacheKeyFrom, cacheOrLoadJSONForRequestWithTTL } from "@/worker/cache";
14 
15export async function listCommitChangedFiles(
16 env: Env,
17 repoId: string,
18 oid: string,
19 cacheCtx?: CacheContext,
20 opts?: {
21 maxFiles?: number;
22 maxTreePairs?: number;
23 timeBudgetMs?: number;
24 }
25): Promise<CommitDiffResult> {
26 const maxFiles = Math.max(1, Math.floor(opts?.maxFiles ?? 300));
27 const maxTreePairs = Math.max(1, Math.floor(opts?.maxTreePairs ?? 2000));
28 const timeBudgetMs = Math.max(1, Math.floor(opts?.timeBudgetMs ?? 2000));
29 const startedAt = Date.now();
30 const treeMemo = new Map<string, TreeEntry[]>();
31 const entries: CommitDiffEntry[] = [];
32 
33 let added = 0;
34 let modified = 0;
35 let deleted = 0;
36 let treePairs = 0;
37 let truncated = false;
38 let truncateReason: CommitDiffResult["truncateReason"];
39 
40 const setTruncated = (reason: NonNullable<CommitDiffResult["truncateReason"]>) => {
41 if (!truncated) {
42 truncated = true;
43 truncateReason = reason;
44 }
45 };
46 
47 const shouldStop = () => {
48 if (truncated) return true;
49 if (entries.length >= maxFiles) {
50 setTruncated("max_files");
51 return true;
52 }
53 if (treePairs >= maxTreePairs) {
54 setTruncated("max_tree_pairs");
55 return true;
56 }
57 if (Date.now() - startedAt >= timeBudgetMs) {
58 setTruncated("time_budget");
59 return true;
60 }
61 if ((cacheCtx?.memo?.subreqBudget ?? 0) < 0) {
62 setTruncated("soft_budget");
63 return true;
64 }
65 return false;
66 };
67 
68 const readTreeEntriesMemoized = async (treeOid?: string): Promise<TreeEntry[] | null> => {
69 if (!treeOid) return [];
70 const cached = treeMemo.get(treeOid);
71 if (cached) return cached;
72 try {
73 const treeEntries = await readTree(env, repoId, treeOid, cacheCtx);
74 treeMemo.set(treeOid, treeEntries);
75 return treeEntries;
76 } catch (error) {
77 if (Date.now() - startedAt >= timeBudgetMs) {
78 setTruncated("time_budget");
79 return null;
80 }
81 if ((cacheCtx?.memo?.subreqBudget ?? 0) < 0) {
82 setTruncated("soft_budget");
83 return null;
84 }
85 throw error;
86 }
87 };
88 
89 const addEntry = (
90 changeType: CommitDiffChangeType,
91 path: string,
92 oldEntry?: TreeEntry,
93 newEntry?: TreeEntry
94 ) => {
95 if (shouldStop()) return;
96 entries.push({
97 path,
98 changeType,
99 oldOid: oldEntry?.oid,
100 newOid: newEntry?.oid,
101 oldMode: oldEntry?.mode,
102 newMode: newEntry?.mode,
103 });
104 if (changeType === "A") added++;
105 else if (changeType === "M") modified++;
106 else deleted++;
107 };
108 
109 const walkAddedOrDeletedSubtree = async (
110 entry: TreeEntry,
111 basePath: string,
112 changeType: Exclude<CommitDiffChangeType, "M">
113 ): Promise<void> => {
114 if (shouldStop()) return;
115 const path = joinTreePath(basePath, entry.name);
116 if (!isTreeMode(entry.mode)) {
117 addEntry(
118 changeType,
119 path,
120 changeType === "D" ? entry : undefined,
121 changeType === "A" ? entry : undefined
122 );
123 return;
124 }
125 const childEntries = await readTreeEntriesMemoized(entry.oid);
126 if (!childEntries || shouldStop()) return;
127 const sortedChildren = [...childEntries].sort((a, b) =>
128 a.name === b.name ? a.oid.localeCompare(b.oid) : a.name.localeCompare(b.name)
129 );
130 for (const child of sortedChildren) {
131 await walkAddedOrDeletedSubtree(child, path, changeType);
132 if (shouldStop()) return;
133 }
134 };
135 
136 const diffTreePair = async (
137 oldTreeOid?: string,
138 newTreeOid?: string,
139 basePath = ""
140 ): Promise<void> => {
141 if (shouldStop()) return;
142 if (oldTreeOid && newTreeOid && oldTreeOid === newTreeOid) return;
143 
144 if (!oldTreeOid && !newTreeOid) return;
145 
146 if (!oldTreeOid) {
147 const newEntries = await readTreeEntriesMemoized(newTreeOid);
148 if (!newEntries || shouldStop()) return;
149 const sortedNewEntries = [...newEntries].sort((a, b) =>
150 a.name === b.name ? a.oid.localeCompare(b.oid) : a.name.localeCompare(b.name)
151 );
152 for (const entry of sortedNewEntries) {
153 await walkAddedOrDeletedSubtree(entry, basePath, "A");
154 if (shouldStop()) return;
155 }
156 return;
157 }
158 
159 if (!newTreeOid) {
160 const oldEntries = await readTreeEntriesMemoized(oldTreeOid);
161 if (!oldEntries || shouldStop()) return;
162 const sortedOldEntries = [...oldEntries].sort((a, b) =>
163 a.name === b.name ? a.oid.localeCompare(b.oid) : a.name.localeCompare(b.name)
164 );
165 for (const entry of sortedOldEntries) {
166 await walkAddedOrDeletedSubtree(entry, basePath, "D");
167 if (shouldStop()) return;
168 }
169 return;
170 }
171 
172 treePairs++;
173 if (shouldStop()) return;
174 
175 const [oldEntries, newEntries] = await Promise.all([
176 readTreeEntriesMemoized(oldTreeOid),
177 readTreeEntriesMemoized(newTreeOid),
178 ]);
179 if (!oldEntries || !newEntries || shouldStop()) return;
180 
181 const oldByName = new Map(oldEntries.map((entry) => [entry.name, entry]));
182 const newByName = new Map(newEntries.map((entry) => [entry.name, entry]));
183 const names = [...new Set([...oldByName.keys(), ...newByName.keys()])].sort((a, b) =>
184 a.localeCompare(b)
185 );
186 
187 for (const name of names) {
188 if (shouldStop()) return;
189 const oldEntry = oldByName.get(name);
190 const newEntry = newByName.get(name);
191 if (!oldEntry && newEntry) {
192 await walkAddedOrDeletedSubtree(newEntry, basePath, "A");
193 continue;
194 }
195 if (oldEntry && !newEntry) {
196 await walkAddedOrDeletedSubtree(oldEntry, basePath, "D");
197 continue;
198 }
199 if (!oldEntry || !newEntry) continue;
200 
201 const path = joinTreePath(basePath, name);
202 const oldIsTree = isTreeMode(oldEntry.mode);
203 const newIsTree = isTreeMode(newEntry.mode);
204 
205 if (oldIsTree && newIsTree) {
206 await diffTreePair(oldEntry.oid, newEntry.oid, path);
207 continue;
208 }
209 
210 if (!oldIsTree && !newIsTree) {
211 if (oldEntry.oid !== newEntry.oid || oldEntry.mode !== newEntry.mode) {
212 addEntry("M", path, oldEntry, newEntry);
213 }
214 continue;
215 }
216 
217 if (!oldIsTree && newIsTree) {
218 addEntry("D", path, oldEntry, undefined);
219 const childEntries = await readTreeEntriesMemoized(newEntry.oid);
220 if (!childEntries || shouldStop()) return;
221 const sortedChildren = [...childEntries].sort((a, b) =>
222 a.name === b.name ? a.oid.localeCompare(b.oid) : a.name.localeCompare(b.name)
223 );
224 for (const child of sortedChildren) {
225 await walkAddedOrDeletedSubtree(child, path, "A");
226 if (shouldStop()) return;
227 }
228 continue;
229 }
230 
231 const childEntries = await readTreeEntriesMemoized(oldEntry.oid);
232 if (!childEntries || shouldStop()) return;
233 const sortedChildren = [...childEntries].sort((a, b) =>
234 a.name === b.name ? a.oid.localeCompare(b.oid) : a.name.localeCompare(b.name)
235 );
236 for (const child of sortedChildren) {
237 await walkAddedOrDeletedSubtree(child, path, "D");
238 if (shouldStop()) return;
239 }
240 addEntry("A", path, undefined, newEntry);
241 }
242 };
243 
244 const commit = await readCommitInfo(env, repoId, oid, cacheCtx);
245 const baseCommitOid = commit.parents[0];
246 const baseCommit = baseCommitOid
247 ? await readCommitInfo(env, repoId, baseCommitOid, cacheCtx)
248 : undefined;
249 
250 await diffTreePair(baseCommit?.tree, commit.tree);
251 
252 entries.sort((a, b) =>
253 a.path === b.path ? a.changeType.localeCompare(b.changeType) : a.path.localeCompare(b.path)
254 );
255 
256 return {
257 baseCommitOid,
258 compareMode: baseCommitOid ? "first-parent" : "root",
259 entries,
260 added,
261 modified,
262 deleted,
263 total: entries.length,
264 truncated,
265 truncateReason,
266 };
267}
268 
269function toPatchLines(text: string): string[] {
270 if (!text) return [];
271 const normalized = text.replace(/\r\n/g, "\n");
272 if (!normalized) return [];
273 const lines = normalized.split("\n");
274 if (normalized.endsWith("\n")) lines.pop();
275 return lines;
276}
277 
278type PatchOp = {
279 kind: " " | "+" | "-";
280 line: string;
281};
282 
283function diffLines(oldLines: string[], newLines: string[]): PatchOp[] {
284 const oldCount = oldLines.length;
285 const newCount = newLines.length;
286 if (!oldCount && !newCount) return [];
287 if (!oldCount) return newLines.map((line) => ({ kind: "+", line }));
288 if (!newCount) return oldLines.map((line) => ({ kind: "-", line }));
289 
290 const directions = new Uint8Array(oldCount * newCount);
291 let previous = new Uint16Array(newCount + 1);
292 let current = new Uint16Array(newCount + 1);
293 
294 for (let oldIndex = 1; oldIndex <= oldCount; oldIndex++) {
295 current.fill(0);
296 for (let newIndex = 1; newIndex <= newCount; newIndex++) {
297 const directionIndex = (oldIndex - 1) * newCount + (newIndex - 1);
298 if (oldLines[oldIndex - 1] === newLines[newIndex - 1]) {
299 current[newIndex] = previous[newIndex - 1] + 1;
300 directions[directionIndex] = 1;
301 } else if (previous[newIndex] >= current[newIndex - 1]) {
302 current[newIndex] = previous[newIndex];
303 directions[directionIndex] = 2;
304 } else {
305 current[newIndex] = current[newIndex - 1];
306 directions[directionIndex] = 3;
307 }
308 }
309 const swap = previous;
310 previous = current;
311 current = swap;
312 }
313 
314 const ops: PatchOp[] = [];
315 let oldIndex = oldCount;
316 let newIndex = newCount;
317 while (oldIndex > 0 || newIndex > 0) {
318 if (oldIndex > 0 && newIndex > 0) {
319 const direction = directions[(oldIndex - 1) * newCount + (newIndex - 1)];
320 if (direction === 1) {
321 ops.push({ kind: " ", line: oldLines[oldIndex - 1] });
322 oldIndex--;
323 newIndex--;
324 continue;
325 }
326 if (direction === 3) {
327 ops.push({ kind: "+", line: newLines[newIndex - 1] });
328 newIndex--;
329 continue;
330 }
331 }
332 if (oldIndex > 0) {
333 ops.push({ kind: "-", line: oldLines[oldIndex - 1] });
334 oldIndex--;
335 } else {
336 ops.push({ kind: "+", line: newLines[newIndex - 1] });
337 newIndex--;
338 }
339 }
340 
341 ops.reverse();
342 return ops;
343}
344 
345function formatUnifiedRange(start: number, count: number): string {
346 if (count === 0) return `${Math.max(0, start - 1)},0`;
347 if (count === 1) return `${start}`;
348 return `${start},${count}`;
349}
350 
351function buildUnifiedPatch(
352 path: string,
353 changeType: CommitDiffChangeType,
354 oldText: string,
355 newText: string
356): string {
357 const oldLines = toPatchLines(oldText);
358 const newLines = toPatchLines(newText);
359 const ops = diffLines(oldLines, newLines);
360 const changeIndexes = ops
361 .map((op, index) => ({ op, index }))
362 .filter(({ op }) => op.kind !== " ")
363 .map(({ index }) => index);
364 const oldLabel = changeType === "A" ? "/dev/null" : `a/${path}`;
365 const newLabel = changeType === "D" ? "/dev/null" : `b/${path}`;
366 if (!changeIndexes.length) {
367 return `--- ${oldLabel}\n+++ ${newLabel}\n`;
368 }
369 
370 const contextLines = 3;
371 const ranges: Array<{ start: number; end: number }> = [];
372 for (const index of changeIndexes) {
373 const start = Math.max(0, index - contextLines);
374 const end = Math.min(ops.length, index + contextLines + 1);
375 const lastRange = ranges[ranges.length - 1];
376 if (lastRange && start <= lastRange.end) {
377 lastRange.end = Math.max(lastRange.end, end);
378 continue;
379 }
380 ranges.push({ start, end });
381 }
382 
383 const oldLineStarts = new Uint32Array(ops.length + 1);
384 const newLineStarts = new Uint32Array(ops.length + 1);
385 oldLineStarts[0] = 1;
386 newLineStarts[0] = 1;
387 for (let index = 0; index < ops.length; index++) {
388 oldLineStarts[index + 1] = oldLineStarts[index] + (ops[index].kind === "+" ? 0 : 1);
389 newLineStarts[index + 1] = newLineStarts[index] + (ops[index].kind === "-" ? 0 : 1);
390 }
391 
392 const out: string[] = [`--- ${oldLabel}`, `+++ ${newLabel}`];
393 for (const range of ranges) {
394 const hunkOps = ops.slice(range.start, range.end);
395 const oldStart = oldLineStarts[range.start];
396 const newStart = newLineStarts[range.start];
397 const oldCount = hunkOps.reduce((count, op) => count + (op.kind === "+" ? 0 : 1), 0);
398 const newCount = hunkOps.reduce((count, op) => count + (op.kind === "-" ? 0 : 1), 0);
399 out.push(
400 `@@ -${formatUnifiedRange(oldStart, oldCount)} +${formatUnifiedRange(newStart, newCount)} @@`
401 );
402 for (const op of hunkOps) {
403 out.push(`${op.kind}${op.line}`);
404 }
405 }
406 
407 return `${out.join("\n")}\n`;
408}
409 
410async function loadCommitDiffResultCached(
411 env: Env,
412 repoId: string,
413 oid: string,
414 cacheCtx?: CacheContext
415): Promise<CommitDiffResult> {
416 if (!cacheCtx) {
417 return await listCommitChangedFiles(env, repoId, oid);
418 }
419 const diffCacheKey = buildCacheKeyFrom(cacheCtx.req, "/_cache/commit-diff", {
420 repo: repoId,
421 oid,
422 v: "1",
423 });
424 const diff = await cacheOrLoadJSONForRequestWithTTL<CommitDiffResult>(
425 cacheCtx,
426 diffCacheKey,
427 async () => await listCommitChangedFiles(env, repoId, oid, cacheCtx),
428 () => 86400
429 );
430 if (!diff) {
431 throw new Error("Commit diff not found");
432 }
433 return diff;
434}
435 
436export async function readCommitFilePatch(
437 env: Env,
438 repoId: string,
439 oid: string,
440 path: string,
441 cacheCtx?: CacheContext,
442 opts?: {
443 maxBlobBytes?: number;
444 maxPatchBytes?: number;
445 maxLines?: number;
446 }
447): Promise<CommitFilePatchResult> {
448 const maxBlobBytes = Math.max(1, Math.floor(opts?.maxBlobBytes ?? 128 * 1024));
449 const maxPatchBytes = Math.max(1, Math.floor(opts?.maxPatchBytes ?? 256 * 1024));
450 const maxLines = Math.max(1, Math.floor(opts?.maxLines ?? 4000));
451 const diff = await loadCommitDiffResultCached(env, repoId, oid, cacheCtx);
452 const entry = diff.entries.find((candidate) => candidate.path === path);
453 if (!entry) {
454 return {
455 path,
456 changeType: "M",
457 skipped: true,
458 skipReason: "not_found",
459 };
460 }
461 
462 const result: CommitFilePatchResult = {
463 path: entry.path,
464 changeType: entry.changeType,
465 oldOid: entry.oldOid,
466 newOid: entry.newOid,
467 };
468 
469 const [oldBlob, newBlob] = await Promise.all([
470 entry.oldOid
471 ? readBlob(env, repoId, entry.oldOid, cacheCtx)
472 : Promise.resolve({ content: null, type: null }),
473 entry.newOid
474 ? readBlob(env, repoId, entry.newOid, cacheCtx)
475 : Promise.resolve({ content: null, type: null }),
476 ]);
477 
478 if (
479 (entry.oldOid && (oldBlob.type !== "blob" || !oldBlob.content)) ||
480 (entry.newOid && (newBlob.type !== "blob" || !newBlob.content))
481 ) {
482 return {
483 ...result,
484 skipped: true,
485 skipReason: "not_found",
486 };
487 }
488 
489 const oldContent = oldBlob.content ?? new Uint8Array(0);
490 const newContent = newBlob.content ?? new Uint8Array(0);
491 
492 if (oldContent.byteLength > maxBlobBytes) result.oldTooLarge = true;
493 if (newContent.byteLength > maxBlobBytes) result.newTooLarge = true;
494 if (result.oldTooLarge || result.newTooLarge) {
495 return {
496 ...result,
497 skipped: true,
498 skipReason: "too_large",
499 };
500 }
501 
502 if (detectBinary(oldContent) || detectBinary(newContent)) {
503 return {
504 ...result,
505 binary: true,
506 skipped: true,
507 skipReason: "binary",
508 };
509 }
510 
511 const oldText = bytesToText(oldContent);
512 const newText = bytesToText(newContent);
513 const oldLines = toPatchLines(oldText);
514 const newLines = toPatchLines(newText);
515 if (oldLines.length > maxLines || newLines.length > maxLines) {
516 return {
517 ...result,
518 skipped: true,
519 skipReason: "too_many_lines",
520 };
521 }
522 
523 const patch = buildUnifiedPatch(path, entry.changeType, oldText, newText);
524 if (new TextEncoder().encode(patch).byteLength > maxPatchBytes) {
525 return {
526 ...result,
527 skipped: true,
528 skipReason: "too_large",
529 };
530 }
531 
532 return {
533 ...result,
534 patch,
535 };
536}