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.

181 lines · 8.5 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("keeps large blobs compressed, with the same oids, and still resolves deltas against them", 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 = new Uint8Array(execFileSync("git", ["pack-objects", "--stdout", "--delta-base-offset"], { cwd: dir, input: expected.join("\n") + "\n" }));
69 const plain = await parsePack(pack);
70 const lean = await parsePack(pack, undefined, { largeObjectBytes: 1000 });
71 expect(lean.map((o) => o.oid).sort()).toEqual(plain.map((o) => o.oid).sort());
72 const large = lean.filter((o) => o.deflated);
73 // a.txt in both commits: one is stored whole, the other is a delta against it.
74 expect(large.length).toBeGreaterThanOrEqual(1);
75 for (const o of large) {
76 const want = plain.find((p) => p.oid === o.oid)!;
77 expect(o.size).toBe(want.content.length);
78 expect(inflate(o.deflated!)).toEqual(want.content);
79 expect(o.content).toEqual(want.content);
80 }
81 for (const o of lean) expect(await hashObject(o.type, o.content)).toBe(o.oid);
82 });
83
84 it("parses a pack over 16 MB holding one large blob", async () => {
85 const dir = mkdtempSync(join(tmpdir(), "gc-"));
86 git(dir, "init", "-q", "-b", "main");
87 const data = randomBytes(17 * 1024 * 1024);
88 writeFileSync(join(dir, "big.bin"), data);
89 git(dir, "add", "-A");
90 git(dir, "commit", "-q", "-m", "big");
91 const expected = git(dir, "rev-list", "--objects", "--all").toString().trim().split("\n").map((l) => l.split(" ")[0]!);
92 const pack = new Uint8Array(execFileSync("git", ["pack-objects", "--stdout"], { cwd: dir, input: expected.join("\n") + "\n", maxBuffer: 64 * 1024 * 1024 }));
93 expect(pack.length).toBeGreaterThan(16 * 1024 * 1024);
94 const objs = await parsePack(pack, undefined, { largeObjectBytes: 4 * 1024 * 1024 });
95 const blob = objs.find((o) => o.type === "blob")!;
96 expect(blob.oid).toBe(git(dir, "rev-parse", "HEAD:big.bin").toString().trim());
97 expect(blob.size).toBe(data.length);
98 expect(Buffer.from(inflate(blob.deflated!)).equals(data)).toBe(true);
99 // A damaged pack still fails its checksum.
100 pack[pack.length - 30]! ^= 1;
101 await expect(parsePack(pack, undefined, { largeObjectBytes: 4 * 1024 * 1024 })).rejects.toThrow(/checksum/);
102 });
103
104 it("writes packs that git accepts", async () => {
105 const dir = makeRepo();
106 const { store } = await loadAll(dir);
107 const head = git(dir, "rev-parse", "HEAD").toString().trim();
108 const oids = await objectsForFetch(store, [head], []);
109 const pack = await writePack(
110 await Promise.all(oids.map(async (oid) => {
111 const o = (await store.read(oid))!;
112 return { type: o.type, size: o.content.length, deflated: deflate(o.content) };
113 })),
114 );
115 const out = mkdtempSync(join(tmpdir(), "gc-out-"));
116 git(out, "init", "-q", "--bare");
117 const p = join(out, "objects/pack/pack-x.pack");
118 writeFileSync(p, pack);
119 git(out, "index-pack", p);
120 git(out, "update-ref", "refs/heads/main", head);
121 git(out, "fsck", "--strict", "main");
122 });
123
124 it("sends only new objects on incremental fetch", async () => {
125 const dir = makeRepo();
126 const { store } = await loadAll(dir);
127 const head = git(dir, "rev-parse", "HEAD").toString().trim();
128 const first = git(dir, "rev-parse", "HEAD~1").toString().trim();
129 const oids = await objectsForFetch(store, [head], [first]);
130 // new commit + new root tree + new a.txt blob
131 expect(oids.length).toBe(3);
132 });
133});
134
135describe("trees, diffs and merges", () => {
136 it("builds commits from file changes and diffs them", async () => {
137 const store = new MemoryStore();
138 const t1 = await applyChanges(store, null, [
139 { path: "README.md", content: utf8("hello\n") },
140 { path: "src/a.ts", content: utf8("a\n") },
141 ]);
142 const t2 = await applyChanges(store, t1, [
143 { path: "src/a.ts", content: utf8("a\nb\n") },
144 { path: "README.md", delete: true },
145 { path: "src/new/b.ts", content: utf8("x") },
146 ]);
147 const changes = await diffTrees(store, t1, t2);
148 expect(changes.map((c) => `${c.status}:${c.path}`)).toEqual(["deleted:README.md", "modified:src/a.ts", "added:src/new/b.ts"]);
149 const d = await fileDiff(store, changes[1]!);
150 expect(d.additions).toBe(1);
151 expect((await listFiles(store, t2)).map((f) => f.path)).toEqual(["src/a.ts", "src/new/b.ts"]);
152 });
153
154 it("merges non-overlapping line edits and detects conflicts", () => {
155 const base = "a\nb\nc\nd\ne\n";
156 expect(mergeText(base, "A\nb\nc\nd\ne\n", "a\nb\nc\nd\nE\n")).toBe("A\nb\nc\nd\nE\n");
157 expect(mergeText(base, "a\nX\nc\nd\ne\n", "a\nY\nc\nd\ne\n")).toBeNull();
158 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");
159 });
160
161 it("three-way merges trees and finds the merge base", async () => {
162 const store = new MemoryStore();
163 const sig = { name: "T", email: "t@x", timestamp: 1, timezone: "+0000" };
164 const commit = async (tree: string, parents: string[], ts: number) =>
165 store.write("commit", serializeCommit({ tree, parents, author: { ...sig, timestamp: ts }, committer: { ...sig, timestamp: ts }, message: "m\n" }));
166 const tb = await applyChanges(store, null, [{ path: "f.txt", content: utf8("1\n2\n3\n4\n5\n") }, { path: "g.txt", content: utf8("g") }]);
167 const base = await commit(tb, [], 1);
168 const to = await applyChanges(store, tb, [{ path: "f.txt", content: utf8("one\n2\n3\n4\n5\n") }, { path: "new.txt", content: utf8("n") }]);
169 const ours = await commit(to, [base], 2);
170 const tt = await applyChanges(store, tb, [{ path: "f.txt", content: utf8("1\n2\n3\n4\nfive\n") }, { path: "g.txt", delete: true }]);
171 const theirs = await commit(tt, [base], 3);
172 expect(await mergeBase(store, ours, theirs)).toBe(base);
173 const res = await mergeTrees(store, tb, to, tt);
174 expect(res.conflicts).toEqual([]);
175 const files = await listFiles(store, res.tree!);
176 expect(files.map((f) => f.path)).toEqual(["f.txt", "new.txt"]);
177 expect(fromUtf8((await store.read(files[0]!.oid))!.content)).toBe("one\n2\n3\n4\nfive\n");
178 expect(parseCommit((await store.read(ours))!.content).parents).toEqual([base]);
179 expect((await readCommit(store, theirs)).tree).toBe(tt);
180 });
181});