| 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 | */ |
| 13 | export 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. */ |
| 32 | export 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". */ |
| 50 | export 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. */ |
| 63 | export 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 push was refused by push protection. */ |
| 68 | export function isSecretRejection(output: string): boolean { |
| 69 | return /secret detected|Push blocked: possible secrets/i.test(output); |
| 70 | } |
| 71 | |
| 72 | export type RefKind = "branch" | "tag" | "pull"; |
| 73 | |
| 74 | /** Which refs an import copies: branches, tags and pull request heads (not `refs/pull/N/merge`, merge queue branches, notes or others). */ |
| 75 | export function importRefKind(ref: string): RefKind | null { |
| 76 | if (ref.startsWith("refs/heads/gh-readonly-queue/")) return null; |
| 77 | if (ref.startsWith("refs/heads/")) return "branch"; |
| 78 | if (ref.startsWith("refs/tags/")) return "tag"; |
| 79 | if (/^refs\/pull\/\d+\/head$/.test(ref)) return "pull"; |
| 80 | return null; |
| 81 | } |
| 82 | |
| 83 | /** Push order: the default branch first, then other branches, tags and pull request heads (by number). */ |
| 84 | export function sortRefsForImport(refs: string[], defaultBranch: string): string[] { |
| 85 | const rank = (r: string) => (r === `refs/heads/${defaultBranch}` ? 0 : r.startsWith("refs/heads/") ? 1 : r.startsWith("refs/tags/") ? 2 : 3); |
| 86 | const pr = (r: string) => Number(/^refs\/pull\/(\d+)\//.exec(r)?.[1] ?? 0); |
| 87 | return [...refs].sort((a, b) => rank(a) - rank(b) || pr(a) - pr(b) || (a < b ? -1 : a > b ? 1 : 0)); |
| 88 | } |
| 89 | |
| 90 | /** |
| 91 | * Import-only namespace on the Diffinery side. The server owns `refs/pull/*`, so |
| 92 | * pull request heads land under `pull/N` here and the server creates |
| 93 | * `refs/pull/N/head` from the imported pull request; chunks use `tmp/`. |
| 94 | */ |
| 95 | export const IMPORT_REF_PREFIX = "refs/diffinery-import/"; |
| 96 | |
| 97 | /** Where a source ref is pushed: branches and tags as they are, `refs/pull/N/head` to `refs/diffinery-import/pull/N`. */ |
| 98 | export function importDestination(ref: string): string { |
| 99 | const m = /^refs\/pull\/(\d+)\/head$/.exec(ref); |
| 100 | return m ? `${IMPORT_REF_PREFIX}pull/${m[1]}` : ref; |
| 101 | } |
| 102 | |
| 103 | /** `git push --porcelain` result lines: "<flag>\t<src>:<dst>\t<summary>". */ |
| 104 | export function parsePorcelain(output: string): Array<{ ref: string; ok: boolean; reason: string }> { |
| 105 | const out: Array<{ ref: string; ok: boolean; reason: string }> = []; |
| 106 | for (const line of output.split("\n")) { |
| 107 | const m = /^([ +\-*=!])\t([^\t]*):([^\t]+)\t(.*)$/.exec(line); |
| 108 | if (!m) continue; |
| 109 | out.push({ ref: m[3]!, ok: m[1] !== "!", reason: m[4]!.replace(/^\[[^\]]*\]\s*/, "").replace(/^\((.*)\)$/, "$1") || m[4]! }); |
| 110 | } |
| 111 | return out; |
| 112 | } |