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.

190 lines · 11.0 KBRaw History
1import { execFileSync } from "node:child_process";
2import { randomBytes } from "node:crypto";
3import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
4import { tmpdir } from "node:os";
5import path from "node:path";
6import { describe, expect, it } from "vitest";
7import type { ImportSpec } from "@diffinery/shared";
8import { batchBySize, furthestWithin, importDestination, importRefKind, isSecretRejection, isSizeRejection, parsePorcelain, parseSecretFindings, sortRefsForImport, storageRejection } from "../src/import-plan.js";
9import { pushFailure, transfer } from "../src/importer.js";
10
11describe("import chunk planning", () => {
12 it("finds the furthest commit within budget with few probes", async () => {
13 const sizes = [10, 25, 40, 70, 90, 140, 200];
14 const probes: number[] = [];
15 const at = async (i: number) => (probes.push(i), sizes[i]!);
16 expect(await furthestWithin(sizes.length, at, 100)).toEqual({ index: 4, size: 90 });
17 expect(probes.length).toBeLessThanOrEqual(4);
18 expect(await furthestWithin(sizes.length, at, 1000)).toEqual({ index: 6, size: 200 });
19 expect(await furthestWithin(sizes.length, at, 5)).toBeNull();
20 expect(await furthestWithin(0, at, 100)).toBeNull();
21 // Exactly on the budget counts as fitting.
22 expect(await furthestWithin(sizes.length, at, 40)).toEqual({ index: 2, size: 40 });
23 });
24
25 it("batches objects by size, keeping oversized ones alone", () => {
26 const items = [{ size: 30 }, { size: 30 }, { size: 50 }, { size: 500 }, { size: 10 }];
27 expect(batchBySize(items, 100).map((b) => b.map((x) => x.size))).toEqual([[30, 30], [50], [500], [10]]);
28 expect(batchBySize([], 100)).toEqual([]);
29 });
30
31 it("orders refs: default branch, branches, tags, then pull heads by number", () => {
32 const refs = ["refs/pull/10/head", "refs/tags/v1", "refs/heads/zeta", "refs/pull/2/head", "refs/heads/main", "refs/heads/alpha"];
33 expect(sortRefsForImport(refs, "main")).toEqual(["refs/heads/main", "refs/heads/alpha", "refs/heads/zeta", "refs/tags/v1", "refs/pull/2/head", "refs/pull/10/head"]);
34 expect(importRefKind("refs/pull/3/merge")).toBeNull();
35 expect(importRefKind("refs/notes/commits")).toBeNull();
36 expect(importRefKind("refs/pull/3/head")).toBe("pull");
37 expect(importRefKind("refs/heads/gh-readonly-queue/main/pr-3-abc")).toBeNull();
38 expect(importDestination("refs/pull/3/head")).toBe("refs/diffinery-import/pull/3");
39 expect(importDestination("refs/heads/main")).toBe("refs/heads/main");
40 });
41
42 it("reads push protection and size rejections from git output", () => {
43 const out = "remote: Push protection bypassed by alice: Imported from GitHub o/r\nremote: AWS access key in blob 0123456789\nremote: Private key in blob abcdef0123\n";
44 expect(parseSecretFindings(out)).toEqual([
45 { kind: "AWS access key", blob: "0123456789" },
46 { kind: "Private key", blob: "abcdef0123" },
47 ]);
48 expect(isSecretRejection("! [remote rejected] main -> main (secret detected (push protection))")).toBe(true);
49 expect(isSizeRejection("remote: push exceeds 100 MB; split it into smaller pushes")).toBe(true);
50 expect(isSizeRejection("error: RPC failed; HTTP 413 curl 22")).toBe(true);
51 expect(isSizeRejection("! [rejected] main (non-fast-forward)")).toBe(false);
52 expect(isSizeRejection("error: remote unpack failed: the push needs more memory than one push may use; split it into smaller pushes (Array buffer allocation failed)")).toBe(true);
53 expect(storageRejection("remote: Push refused: acme has reached its storage limit of 1000 bytes (git objects); delete some \nerror: failed to push some refs")).toBe("acme has reached its storage limit of 1000 bytes (git objects); delete some");
54 expect(storageRejection("error: failed to push some refs")).toBeNull();
55 expect(parsePorcelain("To x\n*\trefs/heads/a:refs/heads/a\t[new branch]\n!\trefs/heads/b:refs/heads/b\t[remote rejected] (invalid ref name)\nDone\n")).toEqual([
56 { ref: "refs/heads/a", ok: true, reason: "[new branch]" },
57 { ref: "refs/heads/b", ok: false, reason: "invalid ref name" },
58 ]);
59 // A failed push says why in the server's words, not git's generic last line.
60 const generic = "error: failed to push some refs to 'https://diffinery.com/o/r.git'";
61 expect(pushFailure({ stdout: "To x\n!\trefs/heads/b:refs/heads/b\t[remote rejected] (missing objects)\nDone\n", stderr: generic })).toBe("missing objects");
62 const unpack = "error: remote unpack failed: pack checksum mismatch";
63 expect(pushFailure({ stdout: "To x\n!\trefs/heads/b:refs/heads/b\t[remote rejected] (unpacker error)\nDone\n", stderr: `${unpack}\n${generic}` })).toBe("pack checksum mismatch");
64 expect(pushFailure({ stdout: "", stderr: `remote: Push refused: bad tree\n${generic}` })).toBe("remote: Push refused: bad tree");
65 expect(pushFailure({ stdout: "", stderr: generic })).toBe(generic);
66 });
67});
68
69describe("import transfer (real git)", () => {
70 const env = { ...process.env, GIT_AUTHOR_NAME: "T", GIT_AUTHOR_EMAIL: "t@example.com", GIT_COMMITTER_NAME: "T", GIT_COMMITTER_EMAIL: "t@example.com", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" };
71 const g = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd, env, stdio: "pipe" }).toString().trim();
72
73 it("pushes history in chunks under a size limit the server enforces, then every ref, and cleans up", async () => {
74 const root = mkdtempSync(path.join(tmpdir(), "diffinery-import-"));
75 const work = path.join(root, "work");
76 g(root, "init", "-q", "-b", "main", work);
77 for (let i = 0; i < 12; i++) {
78 writeFileSync(path.join(work, `f${i}.bin`), randomBytes(40 * 1024));
79 g(work, "add", "-A");
80 g(work, "commit", "-q", "-m", `c${i}`);
81 }
82 // A side branch merged back, and one commit too large for any single push.
83 g(work, "checkout", "-q", "-b", "side", "HEAD~6");
84 for (let i = 0; i < 4; i++) {
85 writeFileSync(path.join(work, `s${i}.bin`), randomBytes(40 * 1024));
86 g(work, "add", "-A");
87 g(work, "commit", "-q", "-m", `s${i}`);
88 }
89 g(work, "checkout", "-q", "main");
90 g(work, "merge", "-q", "--no-ff", "-m", "merge side", "side");
91 for (let i = 0; i < 5; i++) writeFileSync(path.join(work, `big${i}.bin`), randomBytes(60 * 1024));
92 g(work, "add", "-A");
93 g(work, "commit", "-q", "-m", "big");
94 g(work, "tag", "-a", "v1", "-m", "release 1");
95 g(work, "tag", "light");
96 const source = path.join(root, "source.git");
97 g(root, "clone", "-q", "--bare", work, source);
98 g(source, "update-ref", "refs/pull/7/head", g(work, "rev-parse", "side"));
99 g(source, "update-ref", "refs/pull/7/merge", g(work, "rev-parse", "main"));
100 g(source, "update-ref", "refs/heads/gh-readonly-queue/main/pr-7-abc", g(work, "rev-parse", "main"));
101
102 // The "server": a bare repository whose pre-receive hook refuses packs over 150 KB.
103 const target = path.join(root, "target.git");
104 g(root, "init", "-q", "--bare", target);
105 g(target, "config", "receive.advertisePushOptions", "true");
106 const hook = path.join(target, "hooks", "pre-receive");
107 const sizes = path.join(root, "sizes.log");
108 writeFileSync(
109 hook,
110 `#!/bin/sh
111size=$(du -sb "$GIT_QUARANTINE_PATH" 2>/dev/null | cut -f1)
112echo "$size $GIT_PUSH_OPTION_0" >> "${sizes}"
113if [ "\${size:-0}" -gt 153600 ]; then echo "push exceeds 100 MB; split it into smaller pushes"; exit 1; fi
114`,
115 );
116 chmodSync(hook, 0o755);
117
118 const spec: ImportSpec = {
119 importId: "t1",
120 source: { fullName: "octo/repo", cloneUrl: `file://${source}`, defaultBranch: "main" },
121 githubToken: null,
122 target: { fullName: "me/repo", pushUrl: `file://${target}` },
123 pushToken: "dfy_test",
124 lfs: false,
125 secrets: "bypass",
126 bypassReason: "Imported from GitHub octo/repo",
127 // Deliberately larger than what the server accepts: the agent must split further.
128 chunkBytes: 1024 * 1024,
129 };
130 const phases: string[] = [];
131 const result = await transfer(spec, { workRoot: path.join(root, "agent"), progress: async (p) => void phases.push(p.phase), log: () => {} });
132 expect(result.ok).toBe(true);
133 expect(result.warnings).toEqual([]);
134 expect(result).toMatchObject({ branches: 2, tags: 2, pullRefs: 1, defaultBranch: "main" });
135 expect(result.chunks).toBeGreaterThan(3);
136 expect(phases).toContain("push");
137
138 const refsOf = (dir: string) => g(dir, "for-each-ref", "--format=%(refname) %(objectname)").split("\n").sort();
139 // Pull request heads wait in the import namespace for the server; merge refs and merge queue branches are skipped.
140 const expected = refsOf(source)
141 .filter((l) => !l.startsWith("refs/pull/7/merge") && !l.startsWith("refs/heads/gh-readonly-queue/"))
142 .map((l) => l.replace("refs/pull/7/head", "refs/diffinery-import/pull/7"))
143 .sort();
144 expect(refsOf(target)).toEqual(expected);
145 g(target, "fsck", "--no-progress");
146 // Every accepted push carried the import option, and the oversized ones were refused, then split.
147 const log = readFileSync(sizes, "utf8").trim().split("\n");
148 expect(log.every((l) => l.endsWith("diffinery-import"))).toBe(true);
149 expect(log.some((l) => Number(l.split(" ")[0]) > 153600)).toBe(true);
150 });
151 it("splits a refused batch of a large commit's files again, rather than retrying it whole", async () => {
152 const root = mkdtempSync(path.join(tmpdir(), "diffinery-import-"));
153 const work = path.join(root, "work");
154 g(root, "init", "-q", "-b", "main", work);
155 // One commit over the budget, of files that each fit in a push the server takes, but not all together.
156 for (let i = 0; i < 8; i++) writeFileSync(path.join(work, `f${i}.bin`), randomBytes(40 * 1024));
157 g(work, "add", "-A");
158 g(work, "commit", "-q", "-m", "many files");
159 const source = path.join(root, "source.git");
160 g(root, "clone", "-q", "--bare", work, source);
161 const target = path.join(root, "target.git");
162 g(root, "init", "-q", "--bare", target);
163 g(target, "config", "receive.advertisePushOptions", "true");
164 const hook = path.join(target, "hooks", "pre-receive");
165 writeFileSync(
166 hook,
167 `#!/bin/sh
168size=$(du -sb "$GIT_QUARANTINE_PATH" 2>/dev/null | cut -f1)
169if [ "\${size:-0}" -gt 153600 ]; then echo "pack takes more than 500 reads (or 1 bytes) of storage to unpack; split it into smaller pushes"; exit 1; fi
170`,
171 );
172 chmodSync(hook, 0o755);
173 const spec: ImportSpec = {
174 importId: "t2",
175 source: { fullName: "octo/many", cloneUrl: `file://${source}`, defaultBranch: "main" },
176 githubToken: null,
177 target: { fullName: "me/many", pushUrl: `file://${target}` },
178 pushToken: "dfy_test",
179 lfs: false,
180 secrets: "bypass",
181 bypassReason: "Imported from GitHub octo/many",
182 // The commit is batched at 256 KB (six files), which the server refuses.
183 chunkBytes: 256 * 1024,
184 };
185 const result = await transfer(spec, { workRoot: path.join(root, "agent"), progress: async () => {}, log: () => {} });
186 expect(result.ok).toBe(true);
187 expect(g(target, "rev-parse", "refs/heads/main")).toBe(g(work, "rev-parse", "HEAD"));
188 g(target, "fsck", "--no-progress");
189 });
190});