import { makeBlob, makeTree, TREE_MODE } from "./objects"; import type { RawObject, TreeItem } from "./objects"; import { mergeText, MergeTooComplexError } from "./merge3"; import type { ConflictHunk } from "./merge3"; import type { MergeLabels } from "./merge3"; const MAX_TREE_READS = 1500; const MAX_CONTENT_MERGES = 200; const MAX_MERGE_BLOB_BYTES = 1024 * 1024; const BINARY_SNIFF_BYTES = 8000; const REGULAR_MODES = new Set(["100644", "100755"]); const decoder = new TextDecoder("utf-8", { fatal: true }); const encoder = new TextEncoder(); type Entry = { name: string; mode: string; hash: string }; /** Minimal object reader so the merge runs against Artifacts in production and real git in tests. */ export type MergeSource = { tree(sha: string): Promise; blob(sha: string): Promise; }; export type MergeConflict = { path: string; /** "content" conflicts carry marker text that can be edited in the browser; others need a local merge. */ kind: "content" | "add_add" | "modify_delete" | "type" | "binary"; content: string | null; /** Base, ours and theirs for each marker block in `content`, for automated resolution. */ hunks?: ConflictHunk[]; }; export type TreeMergeResult = { treeSha: string; objects: RawObject[]; conflicts: MergeConflict[]; }; type Context = { source: MergeSource; labels: MergeLabels; objects: RawObject[]; conflicts: MergeConflict[]; resolutions: Map; budget: MergeBudget; }; /** Work counters shared by every merge that draws on them, so repeated merges (rebase) stay bounded in total. */ export type MergeBudget = { treeReads: number; contentMerges: number; diffWork: number; maxTreeReads: number; maxContentMerges: number; }; export function mergeBudget(maxTreeReads = MAX_TREE_READS, maxContentMerges = MAX_CONTENT_MERGES): MergeBudget { return { treeReads: 0, contentMerges: 0, diffWork: 0, maxTreeReads, maxContentMerges }; } function same(left: Entry | undefined, right: Entry | undefined): boolean { if (!left || !right) return left === right; return left.hash === right.hash && left.mode === right.mode; } async function entries(context: Context, sha: string | null): Promise> { if (!sha) return new Map(); if (++context.budget.treeReads > context.budget.maxTreeReads) throw new MergeTooComplexError("Too many trees to merge in the Worker"); const listing = await context.source.tree(sha); if (!listing) throw new MergeTooComplexError(`Tree ${sha} is missing`); return new Map(listing.map((entry) => [entry.name, entry])); } async function readText(context: Context, sha: string | null): Promise { if (!sha) return ""; const bytes = await context.source.blob(sha); if (!bytes) throw new MergeTooComplexError(`Blob ${sha} is missing`); if (bytes.byteLength > MAX_MERGE_BLOB_BYTES) throw new MergeTooComplexError("File too large to merge in the Worker"); if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return null; try { return decoder.decode(bytes); } catch { return null; } } function pickMode(base: Entry | undefined, ours: Entry, theirs: Entry): string | null { if (ours.mode === theirs.mode) return ours.mode; if (base && ours.mode === base.mode) return theirs.mode; if (base && theirs.mode === base.mode) return ours.mode; return null; } async function mergeFile( context: Context, path: string, base: Entry | undefined, ours: Entry, theirs: Entry, ): Promise { const mode = pickMode(base, ours, theirs); const name = ours.name; if (mode === null || !REGULAR_MODES.has(ours.mode) || !REGULAR_MODES.has(theirs.mode)) { context.conflicts.push({ path, kind: "type", content: null }); return { mode: ours.mode, name, sha: ours.hash }; } if (ours.hash === theirs.hash) return { mode, name, sha: ours.hash }; if (++context.budget.contentMerges > context.budget.maxContentMerges) throw new MergeTooComplexError("Too many files to merge"); const [baseText, ourText, theirText] = await Promise.all([ readText(context, base?.hash ?? null), readText(context, ours.hash), readText(context, theirs.hash), ]); if (baseText === null || ourText === null || theirText === null) { context.conflicts.push({ path, kind: "binary", content: null }); return { mode, name, sha: ours.hash }; } const merged = mergeText(baseText, ourText, theirText, context.labels, context.budget); const resolved = merged.clean ? undefined : context.resolutions.get(path); const blob = await makeBlob(encoder.encode(resolved ?? merged.text)); context.objects.push(blob); if (!merged.clean && resolved === undefined) { context.conflicts.push({ path, kind: base ? "content" : "add_add", content: merged.text, hunks: merged.hunks }); } return { mode, name, sha: blob.sha }; } async function mergeTrees( context: Context, baseSha: string | null, oursSha: string | null, theirsSha: string | null, prefix: string, ): Promise { if (oursSha === theirsSha || theirsSha === baseSha) return oursSha; if (oursSha === baseSha) return theirsSha; const [base, ours, theirs] = await Promise.all([ entries(context, baseSha), entries(context, oursSha), entries(context, theirsSha), ]); const names = [...new Set([...base.keys(), ...ours.keys(), ...theirs.keys()])].sort(); const items: TreeItem[] = []; for (const name of names) { const item = await mergeEntry(context, base.get(name), ours.get(name), theirs.get(name), prefix + name); if (item) items.push(item); } if (items.length === 0) return null; const tree = await makeTree(items); context.objects.push(tree); return tree.sha; } function asItem(entry: Entry | undefined): TreeItem | null { return entry ? { mode: entry.mode, name: entry.name, sha: entry.hash } : null; } function isTree(entry: Entry | undefined): boolean { return entry?.mode === TREE_MODE; } async function mergeEntry( context: Context, base: Entry | undefined, ours: Entry | undefined, theirs: Entry | undefined, path: string, ): Promise { if (same(ours, theirs) || same(base, theirs)) return asItem(ours); if (same(base, ours)) return asItem(theirs); const treeLike = [base, ours, theirs].every((entry) => !entry || isTree(entry)); if (treeLike && (ours || theirs)) { const sha = await mergeTrees(context, base?.hash ?? null, ours?.hash ?? null, theirs?.hash ?? null, `${path}/`); if (sha === null) return null; return { mode: TREE_MODE, name: (ours ?? theirs)?.name ?? "", sha }; } if (!ours || !theirs) { context.conflicts.push({ path, kind: "modify_delete", content: null }); return asItem(ours ?? theirs); } if (isTree(ours) || isTree(theirs)) { context.conflicts.push({ path, kind: "type", content: null }); return asItem(ours); } return mergeFile(context, path, base && !isTree(base) ? base : undefined, ours, theirs); } /** * Recursive three-way merge of root trees, producing a merged tree (conflicted files keep their * marker text) and the new objects to push. Bounded in tree reads and content merges; throws * `MergeTooComplexError` past those budgets so callers can fall back to the git container. */ export async function mergeRootTrees(input: { source: MergeSource; baseTree: string | null; oursTree: string; theirsTree: string; labels: MergeLabels; /** Replacement contents for conflicted text files, as submitted from the web conflict editor. */ resolutions?: Map; /** Shared across calls to bound the total work of several merges; a fresh budget by default. */ budget?: MergeBudget; }): Promise { const context: Context = { source: input.source, labels: input.labels, objects: [], conflicts: [], resolutions: input.resolutions ?? new Map(), budget: input.budget ?? mergeBudget(), }; let sha = await mergeTrees(context, input.baseTree, input.oursTree, input.theirsTree, ""); if (sha === null) { const empty = await makeTree([]); context.objects.push(empty); sha = empty.sha; } return { treeSha: sha, objects: context.objects, conflicts: context.conflicts }; }