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.

149 lines · 8.7 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 } 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(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([
54 { ref: "refs/heads/a", ok: true, reason: "[new branch]" },
55 { ref: "refs/heads/b", ok: false, reason: "invalid ref name" },
56 ]);
57 // A failed push says why in the server's words, not git's generic last line.
58 const generic = "error: failed to push some refs to 'https://diffinery.com/o/r.git'";
59 expect(pushFailure({ stdout: "To x\n!\trefs/heads/b:refs/heads/b\t[remote rejected] (missing objects)\nDone\n", stderr: generic })).toBe("missing objects");
60 const unpack = "error: remote unpack failed: pack checksum mismatch";
61 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");
62 expect(pushFailure({ stdout: "", stderr: `remote: Push refused: bad tree\n${generic}` })).toBe("remote: Push refused: bad tree");
63 expect(pushFailure({ stdout: "", stderr: generic })).toBe(generic);
64 });
65});
66
67describe("import transfer (real git)", () => {
68 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" };
69 const g = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd, env, stdio: "pipe" }).toString().trim();
70
71 it("pushes history in chunks under a size limit the server enforces, then every ref, and cleans up", async () => {
72 const root = mkdtempSync(path.join(tmpdir(), "diffinery-import-"));
73 const work = path.join(root, "work");
74 g(root, "init", "-q", "-b", "main", work);
75 for (let i = 0; i < 12; i++) {
76 writeFileSync(path.join(work, `f${i}.bin`), randomBytes(40 * 1024));
77 g(work, "add", "-A");
78 g(work, "commit", "-q", "-m", `c${i}`);
79 }
80 // A side branch merged back, and one commit too large for any single push.
81 g(work, "checkout", "-q", "-b", "side", "HEAD~6");
82 for (let i = 0; i < 4; i++) {
83 writeFileSync(path.join(work, `s${i}.bin`), randomBytes(40 * 1024));
84 g(work, "add", "-A");
85 g(work, "commit", "-q", "-m", `s${i}`);
86 }
87 g(work, "checkout", "-q", "main");
88 g(work, "merge", "-q", "--no-ff", "-m", "merge side", "side");
89 for (let i = 0; i < 5; i++) writeFileSync(path.join(work, `big${i}.bin`), randomBytes(60 * 1024));
90 g(work, "add", "-A");
91 g(work, "commit", "-q", "-m", "big");
92 g(work, "tag", "-a", "v1", "-m", "release 1");
93 g(work, "tag", "light");
94 const source = path.join(root, "source.git");
95 g(root, "clone", "-q", "--bare", work, source);
96 g(source, "update-ref", "refs/pull/7/head", g(work, "rev-parse", "side"));
97 g(source, "update-ref", "refs/pull/7/merge", g(work, "rev-parse", "main"));
98 g(source, "update-ref", "refs/heads/gh-readonly-queue/main/pr-7-abc", g(work, "rev-parse", "main"));
99
100 // The "server": a bare repository whose pre-receive hook refuses packs over 150 KB.
101 const target = path.join(root, "target.git");
102 g(root, "init", "-q", "--bare", target);
103 g(target, "config", "receive.advertisePushOptions", "true");
104 const hook = path.join(target, "hooks", "pre-receive");
105 const sizes = path.join(root, "sizes.log");
106 writeFileSync(
107 hook,
108 `#!/bin/sh
109size=$(du -sb "$GIT_QUARANTINE_PATH" 2>/dev/null | cut -f1)
110echo "$size $GIT_PUSH_OPTION_0" >> "${sizes}"
111if [ "\${size:-0}" -gt 153600 ]; then echo "push exceeds 100 MB; split it into smaller pushes"; exit 1; fi
112`,
113 );
114 chmodSync(hook, 0o755);
115
116 const spec: ImportSpec = {
117 importId: "t1",
118 source: { fullName: "octo/repo", cloneUrl: `file://${source}`, defaultBranch: "main" },
119 githubToken: null,
120 target: { fullName: "me/repo", pushUrl: `file://${target}` },
121 pushToken: "dfy_test",
122 lfs: false,
123 secrets: "bypass",
124 bypassReason: "Imported from GitHub octo/repo",
125 // Deliberately larger than what the server accepts: the agent must split further.
126 chunkBytes: 1024 * 1024,
127 };
128 const phases: string[] = [];
129 const result = await transfer(spec, { workRoot: path.join(root, "agent"), progress: async (p) => void phases.push(p.phase), log: () => {} });
130 expect(result.ok).toBe(true);
131 expect(result.warnings).toEqual([]);
132 expect(result).toMatchObject({ branches: 2, tags: 2, pullRefs: 1, defaultBranch: "main" });
133 expect(result.chunks).toBeGreaterThan(3);
134 expect(phases).toContain("push");
135
136 const refsOf = (dir: string) => g(dir, "for-each-ref", "--format=%(refname) %(objectname)").split("\n").sort();
137 // Pull request heads wait in the import namespace for the server; merge refs and merge queue branches are skipped.
138 const expected = refsOf(source)
139 .filter((l) => !l.startsWith("refs/pull/7/merge") && !l.startsWith("refs/heads/gh-readonly-queue/"))
140 .map((l) => l.replace("refs/pull/7/head", "refs/diffinery-import/pull/7"))
141 .sort();
142 expect(refsOf(target)).toEqual(expected);
143 g(target, "fsck", "--no-progress");
144 // Every accepted push carried the import option, and the oversized ones were refused, then split.
145 const log = readFileSync(sizes, "utf8").trim().split("\n");
146 expect(log.every((l) => l.endsWith("diffinery-import"))).toBe(true);
147 expect(log.some((l) => Number(l.split(" ")[0]) > 153600)).toBe(true);
148 });
149});