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.

117 lines · 4.9 KBRaw History
1/**
2 * Pure planning helpers for the import mode (see importer.ts): how to cut a
3 * repository's history into pushes that each stay under a size budget, and how
4 * to read what the server said about a push.
5 */
6
7/**
8 * Binary search for the furthest point of a first-parent chain whose push stays
9 * within `budget`. `sizeAt(i)` is the size of everything reachable from the i-th
10 * commit that the server does not have yet, so it grows with `i`. Returns null
11 * when even the first commit is over budget.
12 */
13export async function furthestWithin(n: number, sizeAt: (i: number) => Promise<number>, budget: number): Promise<{ index: number; size: number } | null> {
14 if (n <= 0) return null;
15 const last = await sizeAt(n - 1);
16 if (last <= budget) return { index: n - 1, size: last };
17 let lo = 0;
18 let hi = n - 2;
19 let best: { index: number; size: number } | null = null;
20 while (lo <= hi) {
21 const mid = (lo + hi) >> 1;
22 const size = await sizeAt(mid);
23 if (size <= budget) {
24 best = { index: mid, size };
25 lo = mid + 1;
26 } else hi = mid - 1;
27 }
28 return best;
29}
30
31/** Greedy batches of at most `budget` bytes, keeping order; an item larger than the budget gets a batch of its own. */
32export function batchBySize<T extends { size: number }>(items: T[], budget: number): T[][] {
33 const out: T[][] = [];
34 let cur: T[] = [];
35 let used = 0;
36 for (const it of items) {
37 if (cur.length && used + it.size > budget) {
38 out.push(cur);
39 cur = [];
40 used = 0;
41 }
42 cur.push(it);
43 used += it.size;
44 }
45 if (cur.length) out.push(cur);
46 return out;
47}
48
49/** Push protection findings from `remote:` lines, e.g. "remote: AWS access key in blob 0123456789". */
50export function parseSecretFindings(output: string): Array<{ kind: string; blob: string }> {
51 const out: Array<{ kind: string; blob: string }> = [];
52 const seen = new Set<string>();
53 for (const m of output.matchAll(/^remote:\s+(.+?) in blob ([0-9a-f]{7,40})\s*$/gm)) {
54 const key = `${m[1]}:${m[2]}`;
55 if (seen.has(key)) continue;
56 seen.add(key);
57 out.push({ kind: m[1]!.trim(), blob: m[2]! });
58 }
59 return out;
60}
61
62/** The push was refused or cut off because the request was too big. */
63export function isSizeRejection(output: string): boolean {
64 return /exceeds \d+ ?MB|split it into smaller pushes|HTTP 413|Request Entity Too Large|RPC failed|remote end hung up|HTTP 50[023]|unexpected disconnect|pack exceeds/i.test(output);
65}
66
67/** The server's reason when the push was refused because the owner reached its storage limit, or null. */
68export function storageRejection(output: string): string | null {
69 return /^remote: Push refused: (.* has reached its storage limit .*?)\s*$/m.exec(output)?.[1] ?? null;
70}
71
72/** The push was refused by push protection. */
73export function isSecretRejection(output: string): boolean {
74 return /secret detected|Push blocked: possible secrets/i.test(output);
75}
76
77export type RefKind = "branch" | "tag" | "pull";
78
79/** Which refs an import copies: branches, tags and pull request heads (not `refs/pull/N/merge`, merge queue branches, notes or others). */
80export function importRefKind(ref: string): RefKind | null {
81 if (ref.startsWith("refs/heads/gh-readonly-queue/")) return null;
82 if (ref.startsWith("refs/heads/")) return "branch";
83 if (ref.startsWith("refs/tags/")) return "tag";
84 if (/^refs\/pull\/\d+\/head$/.test(ref)) return "pull";
85 return null;
86}
87
88/** Push order: the default branch first, then other branches, tags and pull request heads (by number). */
89export function sortRefsForImport(refs: string[], defaultBranch: string): string[] {
90 const rank = (r: string) => (r === `refs/heads/${defaultBranch}` ? 0 : r.startsWith("refs/heads/") ? 1 : r.startsWith("refs/tags/") ? 2 : 3);
91 const pr = (r: string) => Number(/^refs\/pull\/(\d+)\//.exec(r)?.[1] ?? 0);
92 return [...refs].sort((a, b) => rank(a) - rank(b) || pr(a) - pr(b) || (a < b ? -1 : a > b ? 1 : 0));
93}
94
95/**
96 * Import-only namespace on the Diffinery side. The server owns `refs/pull/*`, so
97 * pull request heads land under `pull/N` here and the server creates
98 * `refs/pull/N/head` from the imported pull request; chunks use `tmp/`.
99 */
100export const IMPORT_REF_PREFIX = "refs/diffinery-import/";
101
102/** Where a source ref is pushed: branches and tags as they are, `refs/pull/N/head` to `refs/diffinery-import/pull/N`. */
103export function importDestination(ref: string): string {
104 const m = /^refs\/pull\/(\d+)\/head$/.exec(ref);
105 return m ? `${IMPORT_REF_PREFIX}pull/${m[1]}` : ref;
106}
107
108/** `git push --porcelain` result lines: "<flag>\t<src>:<dst>\t<summary>". */
109export function parsePorcelain(output: string): Array<{ ref: string; ok: boolean; reason: string }> {
110 const out: Array<{ ref: string; ok: boolean; reason: string }> = [];
111 for (const line of output.split("\n")) {
112 const m = /^([ +\-*=!])\t([^\t]*):([^\t]+)\t(.*)$/.exec(line);
113 if (!m) continue;
114 out.push({ ref: m[3]!, ok: m[1] !== "!", reason: m[4]!.replace(/^\[[^\]]*\]\s*/, "").replace(/^\((.*)\)$/, "$1") || m[4]! });
115 }
116 return out;
117}