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