Skip to content
File

Blob: test/commit-diff.worker.test.ts

typescript432 lines
1import { describe, it, expect } from "vitest";
2import { env, exports as workerExports } from "cloudflare:workers";
3import { encodeGitObject, listCommitChangedFiles, readCommitFilePatch } from "@/worker/git";
4import { uniqueRepoId, runDOWithRetry, type RepoDOStubFactory } from "./util/test-helpers";
5import { setupRepoForTests } from "./util/repoSeed";
6import { registerTestPack } from "./util/packed-repo";
7 
8type TreeSpec = {
9 mode: string;
10 name: string;
11 oid: string;
12};
13 
14function hexToBytes(hex: string): Uint8Array {
15 const out = new Uint8Array(hex.length / 2);
16 for (let i = 0; i < out.length; i++) {
17 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
18 }
19 return out;
20}
21 
22type GitObject = {
23 oid: string;
24 zdata: Uint8Array;
25 type: "commit" | "tree" | "blob" | "tag";
26 payload: Uint8Array;
27};
28 
29async function createBlob(content: string): Promise<GitObject> {
30 const payload = new TextEncoder().encode(content);
31 const result = await encodeGitObject("blob", payload);
32 return { ...result, type: "blob", payload };
33}
34 
35async function createTree(entries: TreeSpec[]): Promise<GitObject> {
36 const encoder = new TextEncoder();
37 const chunks: Uint8Array[] = [];
38 let total = 0;
39 for (const entry of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
40 const head = encoder.encode(`${entry.mode} ${entry.name}\0`);
41 const oidBytes = hexToBytes(entry.oid);
42 const chunk = new Uint8Array(head.length + oidBytes.length);
43 chunk.set(head, 0);
44 chunk.set(oidBytes, head.length);
45 chunks.push(chunk);
46 total += chunk.length;
47 }
48 const payload = new Uint8Array(total);
49 let offset = 0;
50 for (const chunk of chunks) {
51 payload.set(chunk, offset);
52 offset += chunk.length;
53 }
54 const result = await encodeGitObject("tree", payload);
55 return { ...result, type: "tree", payload };
56}
57 
58async function createCommit(args: {
59 treeOid: string;
60 parents?: string[];
61 message: string;
62}): Promise<GitObject> {
63 const author = "You <you@example.com> 0 +0000";
64 const parentLines = (args.parents || []).map((parent) => `parent ${parent}\n`).join("");
65 const payload = new TextEncoder().encode(
66 `tree ${args.treeOid}\n${parentLines}author ${author}\ncommitter ${author}\n\n${args.message}\n`
67 );
68 const result = await encodeGitObject("commit", payload);
69 return { ...result, type: "commit", payload };
70}
71 
72/** Register all accumulated objects as a pack so the pack-first read path can find them. */
73async function packAll(
74 repoId: string,
75 getStub: RepoDOStubFactory,
76 objects: GitObject[]
77): Promise<void> {
78 await registerTestPack({
79 env,
80 repoId,
81 getStub,
82 packName: `pack-diff-${Date.now()}.pack`,
83 objects: objects.map((o) => ({ type: o.type, payload: o.payload })),
84 });
85}
86 
87async function setMainRef(getStub: RepoDOStubFactory, oid: string): Promise<void> {
88 await runDOWithRetry(getStub, async (instance) => {
89 await instance.setRefs([{ name: "refs/heads/main", oid }]);
90 await instance.setHead({ target: "refs/heads/main" });
91 });
92}
93 
94describe("commit diff v1", () => {
95 it("lists added files for a root commit", async () => {
96 const owner = "o";
97 const repo = uniqueRepoId("r-diff-root");
98 await setupRepoForTests(env, owner, repo);
99 const repoId = `${owner}/${repo}`;
100 const id = env.REPO_DO.idFromName(repoId);
101 const getStub = () => env.REPO_DO.get(id);
102 
103 const readme = await createBlob("hello\n");
104 
105 const tree = await createTree([{ mode: "100644", name: "README.md", oid: readme.oid }]);
106 
107 const commit = await createCommit({ treeOid: tree.oid, message: "root" });
108 await setMainRef(getStub, commit.oid);
109 await packAll(repoId, getStub, [readme, tree, commit]);
110 
111 const diff = await listCommitChangedFiles(env as Env, repoId, commit.oid);
112 
113 expect(diff.compareMode).toBe("root");
114 expect(diff.baseCommitOid).toBeUndefined();
115 expect(diff.truncated).toBe(false);
116 expect(diff.total).toBe(1);
117 expect(diff.added).toBe(1);
118 expect(diff.modified).toBe(0);
119 expect(diff.deleted).toBe(0);
120 expect(diff.entries).toEqual([
121 {
122 path: "README.md",
123 changeType: "A",
124 oldOid: undefined,
125 newOid: readme.oid,
126 oldMode: undefined,
127 newMode: "100644",
128 },
129 ]);
130 });
131 
132 it("detects modifications and file-to-directory transitions", async () => {
133 const owner = "o";
134 const repo = uniqueRepoId("r-diff-transition");
135 await setupRepoForTests(env, owner, repo);
136 const repoId = `${owner}/${repo}`;
137 const id = env.REPO_DO.idFromName(repoId);
138 const getStub = () => env.REPO_DO.get(id);
139 
140 const oldFile = await createBlob("before\n");
141 const oldConfig = await createBlob("legacy\n");
142 
143 const baseTree = await createTree([
144 { mode: "100644", name: "app.txt", oid: oldFile.oid },
145 { mode: "100644", name: "config", oid: oldConfig.oid },
146 ]);
147 
148 const baseCommit = await createCommit({ treeOid: baseTree.oid, message: "base" });
149 
150 const newFile = await createBlob("after\n");
151 const nestedConfig = await createBlob("nested\n");
152 
153 const configDir = await createTree([
154 { mode: "100644", name: "settings.json", oid: nestedConfig.oid },
155 ]);
156 
157 const nextTree = await createTree([
158 { mode: "100644", name: "app.txt", oid: newFile.oid },
159 { mode: "40000", name: "config", oid: configDir.oid },
160 ]);
161 
162 const nextCommit = await createCommit({
163 treeOid: nextTree.oid,
164 parents: [baseCommit.oid],
165 message: "next",
166 });
167 await setMainRef(getStub, nextCommit.oid);
168 await packAll(repoId, getStub, [
169 oldFile,
170 oldConfig,
171 baseTree,
172 baseCommit,
173 newFile,
174 nestedConfig,
175 configDir,
176 nextTree,
177 nextCommit,
178 ]);
179 
180 const diff = await listCommitChangedFiles(env as Env, repoId, nextCommit.oid);
181 
182 expect(diff.compareMode).toBe("first-parent");
183 expect(diff.baseCommitOid).toBe(baseCommit.oid);
184 expect(diff.total).toBe(3);
185 expect(diff.modified).toBe(1);
186 expect(diff.added).toBe(1);
187 expect(diff.deleted).toBe(1);
188 expect(
189 diff.entries.map((entry) => ({ path: entry.path, changeType: entry.changeType }))
190 ).toEqual([
191 { path: "app.txt", changeType: "M" },
192 { path: "config", changeType: "D" },
193 { path: "config/settings.json", changeType: "A" },
194 ]);
195 });
196 
197 it("truncates when maxFiles is exceeded", async () => {
198 const owner = "o";
199 const repo = uniqueRepoId("r-diff-truncate-files");
200 await setupRepoForTests(env, owner, repo);
201 const repoId = `${owner}/${repo}`;
202 const id = env.REPO_DO.idFromName(repoId);
203 const getStub = () => env.REPO_DO.get(id);
204 
205 const alpha = await createBlob("a\n");
206 const beta = await createBlob("b\n");
207 const gamma = await createBlob("c\n");
208 
209 const tree = await createTree([
210 { mode: "100644", name: "alpha.txt", oid: alpha.oid },
211 { mode: "100644", name: "beta.txt", oid: beta.oid },
212 { mode: "100644", name: "gamma.txt", oid: gamma.oid },
213 ]);
214 
215 const commit = await createCommit({ treeOid: tree.oid, message: "root" });
216 await packAll(repoId, getStub, [alpha, beta, gamma, tree, commit]);
217 
218 const diff = await listCommitChangedFiles(env as Env, repoId, commit.oid, undefined, {
219 maxFiles: 2,
220 });
221 
222 expect(diff.truncated).toBe(true);
223 expect(diff.truncateReason).toBe("max_files");
224 expect(diff.total).toBe(2);
225 expect(diff.entries).toHaveLength(2);
226 });
227 
228 it("truncates when the time budget is exceeded", async () => {
229 const owner = "o";
230 const repo = uniqueRepoId("r-diff-truncate-time");
231 await setupRepoForTests(env, owner, repo);
232 const repoId = `${owner}/${repo}`;
233 const id = env.REPO_DO.idFromName(repoId);
234 const getStub = () => env.REPO_DO.get(id);
235 
236 const alpha = await createBlob("a\n");
237 const beta = await createBlob("b\n");
238 
239 const tree = await createTree([
240 { mode: "100644", name: "alpha.txt", oid: alpha.oid },
241 { mode: "100644", name: "beta.txt", oid: beta.oid },
242 ]);
243 
244 const commit = await createCommit({ treeOid: tree.oid, message: "root" });
245 await packAll(repoId, getStub, [alpha, beta, tree, commit]);
246 
247 const realNow = Date.now;
248 let tick = 0;
249 Date.now = () => {
250 tick += 5;
251 return tick;
252 };
253 try {
254 const diff = await listCommitChangedFiles(env as Env, repoId, commit.oid, undefined, {
255 timeBudgetMs: 1,
256 });
257 expect(diff.truncated).toBe(true);
258 expect(diff.truncateReason).toBe("time_budget");
259 } finally {
260 Date.now = realNow;
261 }
262 });
263 
264 it("generates a lazy patch for a modified text file", async () => {
265 const owner = "o";
266 const repo = uniqueRepoId("r-diff-patch");
267 await setupRepoForTests(env, owner, repo);
268 const repoId = `${owner}/${repo}`;
269 const id = env.REPO_DO.idFromName(repoId);
270 const getStub = () => env.REPO_DO.get(id);
271 
272 const before = await createBlob("before\nshared\n");
273 const after = await createBlob("after\nshared\n");
274 
275 const baseTree = await createTree([{ mode: "100644", name: "note.txt", oid: before.oid }]);
276 const baseCommit = await createCommit({ treeOid: baseTree.oid, message: "base" });
277 
278 const headTree = await createTree([{ mode: "100644", name: "note.txt", oid: after.oid }]);
279 const headCommit = await createCommit({
280 treeOid: headTree.oid,
281 parents: [baseCommit.oid],
282 message: "head",
283 });
284 await packAll(repoId, getStub, [before, after, baseTree, baseCommit, headTree, headCommit]);
285 
286 const patch = await readCommitFilePatch(env as Env, repoId, headCommit.oid, "note.txt");
287 
288 expect(patch.changeType).toBe("M");
289 expect(patch.skipped).toBeUndefined();
290 expect(patch.patch).toContain("--- a/note.txt");
291 expect(patch.patch).toContain("+++ b/note.txt");
292 expect(patch.patch).toContain("-before");
293 expect(patch.patch).toContain("+after");
294 });
295 
296 it("skips binary patch previews", async () => {
297 const owner = "o";
298 const repo = uniqueRepoId("r-diff-binary");
299 await setupRepoForTests(env, owner, repo);
300 const repoId = `${owner}/${repo}`;
301 const id = env.REPO_DO.idFromName(repoId);
302 const getStub = () => env.REPO_DO.get(id);
303 
304 const before = await createBlob("\u0000old");
305 const after = await createBlob("\u0000new");
306 
307 const baseTree = await createTree([{ mode: "100644", name: "data.bin", oid: before.oid }]);
308 const baseCommit = await createCommit({ treeOid: baseTree.oid, message: "base" });
309 
310 const headTree = await createTree([{ mode: "100644", name: "data.bin", oid: after.oid }]);
311 const headCommit = await createCommit({
312 treeOid: headTree.oid,
313 parents: [baseCommit.oid],
314 message: "head",
315 });
316 await packAll(repoId, getStub, [before, after, baseTree, baseCommit, headTree, headCommit]);
317 
318 const patch = await readCommitFilePatch(env as Env, repoId, headCommit.oid, "data.bin");
319 
320 expect(patch.binary).toBe(true);
321 expect(patch.skipped).toBe(true);
322 expect(patch.skipReason).toBe("binary");
323 expect(patch.patch).toBeUndefined();
324 });
325 
326 it("returns lazy patch JSON for a commit path", async () => {
327 const owner = "o";
328 const repo = uniqueRepoId("r-diff-route-patch");
329 await setupRepoForTests(env, owner, repo);
330 const repoId = `${owner}/${repo}`;
331 const id = env.REPO_DO.idFromName(repoId);
332 const getStub = () => env.REPO_DO.get(id);
333 
334 const readme = await createBlob("hello\n");
335 
336 const tree = await createTree([{ mode: "100644", name: "README.md", oid: readme.oid }]);
337 const commit = await createCommit({ treeOid: tree.oid, message: "root" });
338 await setMainRef(getStub, commit.oid);
339 await packAll(repoId, getStub, [readme, tree, commit]);
340 
341 const res = await workerExports.default.fetch(
342 `https://example.com/${owner}/${repo}/commit/${commit.oid}/diff?path=${encodeURIComponent("README.md")}`
343 );
344 
345 expect(res.status).toBe(200);
346 expect(res.headers.get("Content-Type")).toContain("application/json");
347 
348 const payload = (await res.json()) as {
349 path: string;
350 changeType: string;
351 patch?: string;
352 };
353 expect(payload.path).toBe("README.md");
354 expect(payload.changeType).toBe("A");
355 expect(payload.patch).toContain("--- /dev/null");
356 expect(payload.patch).toContain("+++ b/README.md");
357 expect(payload.patch).toContain("+hello");
358 });
359 
360 it("commit page renders files changed and first-parent note for merge commits", async () => {
361 const owner = "o";
362 const repo = uniqueRepoId("r-diff-route");
363 await setupRepoForTests(env, owner, repo);
364 const repoId = `${owner}/${repo}`;
365 const id = env.REPO_DO.idFromName(repoId);
366 const getStub = () => env.REPO_DO.get(id);
367 
368 const alpha = await createBlob("alpha\n");
369 const beta = await createBlob("beta\n");
370 
371 const rootTree = await createTree([]);
372 
373 const rootCommit = await createCommit({ treeOid: rootTree.oid, message: "root" });
374 
375 const firstParentTree = await createTree([
376 { mode: "100644", name: "alpha.txt", oid: alpha.oid },
377 ]);
378 const firstParentCommit = await createCommit({
379 treeOid: firstParentTree.oid,
380 parents: [rootCommit.oid],
381 message: "first",
382 });
383 
384 const secondParentTree = await createTree([
385 { mode: "100644", name: "beta.txt", oid: beta.oid },
386 ]);
387 const secondParentCommit = await createCommit({
388 treeOid: secondParentTree.oid,
389 parents: [rootCommit.oid],
390 message: "second",
391 });
392 
393 const mergeTree = await createTree([
394 { mode: "100644", name: "alpha.txt", oid: alpha.oid },
395 { mode: "100644", name: "beta.txt", oid: beta.oid },
396 ]);
397 const mergeCommit = await createCommit({
398 treeOid: mergeTree.oid,
399 parents: [firstParentCommit.oid, secondParentCommit.oid],
400 message: "merge",
401 });
402 await setMainRef(getStub, mergeCommit.oid);
403 await packAll(repoId, getStub, [
404 alpha,
405 beta,
406 rootTree,
407 rootCommit,
408 firstParentTree,
409 firstParentCommit,
410 secondParentTree,
411 secondParentCommit,
412 mergeTree,
413 mergeCommit,
414 ]);
415 
416 const res = await workerExports.default.fetch(
417 `https://example.com/${owner}/${repo}/commit/${mergeCommit.oid}`
418 );
419 expect(res.status).toBe(200);
420 expect(res.headers.get("Content-Type")).toContain("text/html");
421 
422 const html = await res.text();
423 expect(html).toContain("Files changed");
424 expect(html).toContain("Compared against first parent");
425 expect(html).toContain("Show patch");
426 expect(html).toContain("beta.txt");
427 expect(html).toContain(
428 `/${owner}/${repo}/blob?ref=${encodeURIComponent(mergeCommit.oid)}&amp;path=beta.txt`
429 );
430 });
431});