import { env, waitUntil } from "cloudflare:workers"; import type { GitStore } from "./store"; import type { CommitSummary } from "~/lib/types"; const MAX_WALK = 1500; const MAX_RETURNED_COMMITS = 250; const COMPARISON_CACHE_SECONDS = 60 * 60 * 24 * 7; const FROM_HEAD = 1; const FROM_BASE = 2; const BOTH = FROM_HEAD | FROM_BASE; type Reader = (sha: string) => Promise; function readerFor(stores: GitStore[]): Reader { return async (sha) => { for (const store of stores) { const commit = await store.commit(sha); if (commit) return commit; } return null; }; } export type Comparison = { mergeBase: string | null; /** Commits reachable from head but not from base, newest first. */ commits: CommitSummary[]; /** Number of commits reachable from base but not head (how far head is behind). */ behindBy: number; truncated: boolean; }; /** * Paints commits reachable from head and base in committer-date order (like `git rev-list`), * yielding the head-only commits and the first commit reachable from both sides as merge base. */ export async function compareCommits( baseStore: GitStore, baseSha: string, headStore: GitStore, headSha: string, ): Promise { const key = `compare:v1:${baseStore.artifactsName}:${baseSha}:${headStore.artifactsName}:${headSha}`; const cached = await env.KV.get(key, "json"); if (cached) return cached; const result = await computeComparison(baseStore, baseSha, headStore, headSha); const stored: Comparison = { ...result, commits: result.commits.slice(0, MAX_RETURNED_COMMITS) }; waitUntil(env.KV.put(key, JSON.stringify(stored), { expirationTtl: COMPARISON_CACHE_SECONDS })); return stored; } async function computeComparison( baseStore: GitStore, baseSha: string, headStore: GitStore, headSha: string, ): Promise { const read = readerFor(headStore === baseStore ? [headStore] : [headStore, baseStore]); const flags = new Map(); const commits = new Map(); const queue: CommitSummary[] = []; async function enqueue(sha: string, flag: number) { const previous = flags.get(sha) ?? 0; const merged = previous | flag; if (merged === previous) return; flags.set(sha, merged); if (previous !== 0) return; const commit = await read(sha); if (!commit) return; commits.set(sha, commit); queue.push(commit); } await Promise.all([enqueue(headSha, FROM_HEAD), enqueue(baseSha, FROM_BASE)]); let mergeBase: string | null = null; let visited = 0; let truncated = false; while (queue.length > 0) { if (queue.every((commit) => (flags.get(commit.sha) ?? 0) === BOTH)) break; if (visited++ > MAX_WALK) { truncated = true; break; } queue.sort((left, right) => right.committedAt - left.committedAt); const current = queue.shift(); if (!current) break; const flag = flags.get(current.sha) ?? 0; if (flag === BOTH && mergeBase === null) mergeBase = current.sha; await Promise.all(current.parents.map((parent) => enqueue(parent, flag))); } if (mergeBase === null) { const remaining = queue .filter((commit) => flags.get(commit.sha) === BOTH) .sort((left, right) => right.committedAt - left.committedAt); mergeBase = remaining[0]?.sha ?? null; } const headOnly: CommitSummary[] = []; let behindBy = 0; for (const [sha, flag] of flags) { const commit = commits.get(sha); if (!commit) continue; if (flag === FROM_HEAD) headOnly.push(commit); if (flag === FROM_BASE) behindBy++; } headOnly.sort((left, right) => right.committedAt - left.committedAt); return { mergeBase, commits: headOnly, behindBy, truncated }; } /** True when `ancestor` is reachable from `descendant`. */ export async function isAncestor(store: GitStore, ancestor: string, descendant: string): Promise { if (ancestor === descendant) return true; const comparison = await compareCommits(store, ancestor, store, descendant); return comparison.mergeBase === ancestor; }