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.

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