Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

196 lines · 9.4 KBRaw History
1import { execFileSync } from "node:child_process";
2import { mkdtempSync, writeFileSync, readFileSync, mkdirSync } from "node:fs";
3import { tmpdir } from "node:os";
4import { join } from "node:path";
5import { describe, expect, it } from "vitest";
6import {
7 MemoryStore,
8 applyChanges,
9 deflate,
10 diffTrees,
11 fileDiff,
12 mergeBase,
13 mergeText,
14 mergeTrees,
15 objectsForFetch,
16 parseCommit,
17 parsePack,
18 readCommit,
19 serializeCommit,
20 utf8,
21 writePack,
22 fromUtf8,
23 listFiles,
24 hashObject,
25 inflate,
26} from "../src/index.js";
27import { randomBytes } from "node:crypto";
28
29const git = (cwd: string, ...args: string[]) =>
30 execFileSync("git", args, { cwd, env: { ...process.env, GIT_AUTHOR_NAME: "T", GIT_AUTHOR_EMAIL: "t@x", GIT_COMMITTER_NAME: "T", GIT_COMMITTER_EMAIL: "t@x" } });
31
32function makeRepo(): string {
33 const dir = mkdtempSync(join(tmpdir(), "gc-"));
34 git(dir, "init", "-q", "-b", "main");
35 const big = Array.from({ length: 400 }, (_, i) => `line ${i} of a fairly long file to encourage deltas`).join("\n");
36 writeFileSync(join(dir, "a.txt"), big);
37 mkdirSync(join(dir, "src/deep"), { recursive: true });
38 writeFileSync(join(dir, "src/deep/x.js"), "console.log(1)\n");
39 writeFileSync(join(dir, "bin.dat"), Buffer.from([0, 1, 2, 3, 0, 255]));
40 git(dir, "add", "-A");
41 git(dir, "commit", "-q", "-m", "first");
42 writeFileSync(join(dir, "a.txt"), big.replace("line 200", "LINE 200"));
43 git(dir, "commit", "-qam", "second");
44 git(dir, "tag", "-a", "v1", "-m", "release");
45 return dir;
46}
47
48async function loadAll(dir: string): Promise<{ store: MemoryStore; expected: string[] }> {
49 const expected = git(dir, "rev-list", "--objects", "--all").toString().trim().split("\n").map((l) => l.split(" ")[0]!);
50 expected.push(git(dir, "rev-parse", "v1").toString().trim());
51 const pack = execFileSync("git", ["pack-objects", "--stdout", "--delta-base-offset"], { cwd: dir, input: expected.join("\n") + "\n" });
52 const objs = await parsePack(new Uint8Array(pack));
53 const store = new MemoryStore();
54 for (const o of objs) await store.write(o.type, o.content);
55 return { store, expected };
56}
57
58describe("packfiles", () => {
59 it("parses a real git pack with ofs deltas and verifies all oids", async () => {
60 const dir = makeRepo();
61 const { store, expected } = await loadAll(dir);
62 for (const oid of expected) expect(store.objects.has(oid), oid).toBe(true);
63 });
64
65 it("hands over the zlib data of every object that isn't a delta, which inflates to its content", async () => {
66 const dir = makeRepo();
67 const expected = git(dir, "rev-list", "--objects", "--all").toString().trim().split("\n").map((l) => l.split(" ")[0]!);
68 const pack = execFileSync("git", ["pack-objects", "--stdout", "--delta-base-offset"], { cwd: dir, input: expected.join("\n") + "\n" });
69 const objs = await parsePack(new Uint8Array(pack));
70 const whole = objs.filter((o) => o.zlib);
71 expect(whole.length).toBeGreaterThan(0);
72 // The second commit's a.txt is a delta against the first: rebuilt, so it has no zlib data of its own.
73 expect(objs.some((o) => !o.zlib)).toBe(true);
74 for (const o of whole) {
75 expect(inflate(o.zlib!)).toEqual(o.content);
76 expect(await hashObject(o.type, inflate(o.zlib!))).toBe(o.oid);
77 }
78 });
79
80 it("keeps large blobs compressed, with the same oids, and still resolves deltas against them", async () => {
81 const dir = makeRepo();
82 const expected = git(dir, "rev-list", "--objects", "--all").toString().trim().split("\n").map((l) => l.split(" ")[0]!);
83 const pack = new Uint8Array(execFileSync("git", ["pack-objects", "--stdout", "--delta-base-offset"], { cwd: dir, input: expected.join("\n") + "\n" }));
84 const plain = await parsePack(pack);
85 const lean = await parsePack(pack, undefined, { largeObjectBytes: 1000 });
86 expect(lean.map((o) => o.oid).sort()).toEqual(plain.map((o) => o.oid).sort());
87 const large = lean.filter((o) => o.deflated);
88 // a.txt in both commits: one is stored whole, the other is a delta against it.
89 expect(large.length).toBeGreaterThanOrEqual(1);
90 for (const o of large) {
91 const want = plain.find((p) => p.oid === o.oid)!;
92 expect(o.size).toBe(want.content.length);
93 expect(inflate(o.deflated!)).toEqual(want.content);
94 expect(o.content).toEqual(want.content);
95 }
96 for (const o of lean) expect(await hashObject(o.type, o.content)).toBe(o.oid);
97 });
98
99 it("parses a pack over 16 MB holding one large blob", async () => {
100 const dir = mkdtempSync(join(tmpdir(), "gc-"));
101 git(dir, "init", "-q", "-b", "main");
102 const data = randomBytes(17 * 1024 * 1024);
103 writeFileSync(join(dir, "big.bin"), data);
104 git(dir, "add", "-A");
105 git(dir, "commit", "-q", "-m", "big");
106 const expected = git(dir, "rev-list", "--objects", "--all").toString().trim().split("\n").map((l) => l.split(" ")[0]!);
107 const pack = new Uint8Array(execFileSync("git", ["pack-objects", "--stdout"], { cwd: dir, input: expected.join("\n") + "\n", maxBuffer: 64 * 1024 * 1024 }));
108 expect(pack.length).toBeGreaterThan(16 * 1024 * 1024);
109 const objs = await parsePack(pack, undefined, { largeObjectBytes: 4 * 1024 * 1024 });
110 const blob = objs.find((o) => o.type === "blob")!;
111 expect(blob.oid).toBe(git(dir, "rev-parse", "HEAD:big.bin").toString().trim());
112 expect(blob.size).toBe(data.length);
113 expect(Buffer.from(inflate(blob.deflated!)).equals(data)).toBe(true);
114 // A damaged pack still fails its checksum.
115 pack[pack.length - 30]! ^= 1;
116 await expect(parsePack(pack, undefined, { largeObjectBytes: 4 * 1024 * 1024 })).rejects.toThrow(/checksum/);
117 });
118
119 it("writes packs that git accepts", async () => {
120 const dir = makeRepo();
121 const { store } = await loadAll(dir);
122 const head = git(dir, "rev-parse", "HEAD").toString().trim();
123 const oids = await objectsForFetch(store, [head], []);
124 const pack = await writePack(
125 await Promise.all(oids.map(async (oid) => {
126 const o = (await store.read(oid))!;
127 return { type: o.type, size: o.content.length, deflated: deflate(o.content) };
128 })),
129 );
130 const out = mkdtempSync(join(tmpdir(), "gc-out-"));
131 git(out, "init", "-q", "--bare");
132 const p = join(out, "objects/pack/pack-x.pack");
133 writeFileSync(p, pack);
134 git(out, "index-pack", p);
135 git(out, "update-ref", "refs/heads/main", head);
136 git(out, "fsck", "--strict", "main");
137 });
138
139 it("sends only new objects on incremental fetch", async () => {
140 const dir = makeRepo();
141 const { store } = await loadAll(dir);
142 const head = git(dir, "rev-parse", "HEAD").toString().trim();
143 const first = git(dir, "rev-parse", "HEAD~1").toString().trim();
144 const oids = await objectsForFetch(store, [head], [first]);
145 // new commit + new root tree + new a.txt blob
146 expect(oids.length).toBe(3);
147 });
148});
149
150describe("trees, diffs and merges", () => {
151 it("builds commits from file changes and diffs them", async () => {
152 const store = new MemoryStore();
153 const t1 = await applyChanges(store, null, [
154 { path: "README.md", content: utf8("hello\n") },
155 { path: "src/a.ts", content: utf8("a\n") },
156 ]);
157 const t2 = await applyChanges(store, t1, [
158 { path: "src/a.ts", content: utf8("a\nb\n") },
159 { path: "README.md", delete: true },
160 { path: "src/new/b.ts", content: utf8("x") },
161 ]);
162 const changes = await diffTrees(store, t1, t2);
163 expect(changes.map((c) => `${c.status}:${c.path}`)).toEqual(["deleted:README.md", "modified:src/a.ts", "added:src/new/b.ts"]);
164 const d = await fileDiff(store, changes[1]!);
165 expect(d.additions).toBe(1);
166 expect((await listFiles(store, t2)).map((f) => f.path)).toEqual(["src/a.ts", "src/new/b.ts"]);
167 });
168
169 it("merges non-overlapping line edits and detects conflicts", () => {
170 const base = "a\nb\nc\nd\ne\n";
171 expect(mergeText(base, "A\nb\nc\nd\ne\n", "a\nb\nc\nd\nE\n")).toBe("A\nb\nc\nd\nE\n");
172 expect(mergeText(base, "a\nX\nc\nd\ne\n", "a\nY\nc\nd\ne\n")).toBeNull();
173 expect(mergeText(base, "a\nb\nc\nd\ne\nf\n", "z\na\nb\nc\nd\ne\n")).toBe("z\na\nb\nc\nd\ne\nf\n");
174 });
175
176 it("three-way merges trees and finds the merge base", async () => {
177 const store = new MemoryStore();
178 const sig = { name: "T", email: "t@x", timestamp: 1, timezone: "+0000" };
179 const commit = async (tree: string, parents: string[], ts: number) =>
180 store.write("commit", serializeCommit({ tree, parents, author: { ...sig, timestamp: ts }, committer: { ...sig, timestamp: ts }, message: "m\n" }));
181 const tb = await applyChanges(store, null, [{ path: "f.txt", content: utf8("1\n2\n3\n4\n5\n") }, { path: "g.txt", content: utf8("g") }]);
182 const base = await commit(tb, [], 1);
183 const to = await applyChanges(store, tb, [{ path: "f.txt", content: utf8("one\n2\n3\n4\n5\n") }, { path: "new.txt", content: utf8("n") }]);
184 const ours = await commit(to, [base], 2);
185 const tt = await applyChanges(store, tb, [{ path: "f.txt", content: utf8("1\n2\n3\n4\nfive\n") }, { path: "g.txt", delete: true }]);
186 const theirs = await commit(tt, [base], 3);
187 expect(await mergeBase(store, ours, theirs)).toBe(base);
188 const res = await mergeTrees(store, tb, to, tt);
189 expect(res.conflicts).toEqual([]);
190 const files = await listFiles(store, res.tree!);
191 expect(files.map((f) => f.path)).toEqual(["f.txt", "new.txt"]);
192 expect(fromUtf8((await store.read(files[0]!.oid))!.content)).toBe("one\n2\n3\n4\nfive\n");
193 expect(parseCommit((await store.read(ours))!.content).parents).toEqual([base]);
194 expect((await readCommit(store, theirs)).tree).toBe(tt);
195 });
196});