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.

437 lines · 16.2 KBRaw History
1import {
2 isTreeMode,
3 parseCommit,
4 parseTag,
5 parseTree,
6 serializeTree,
7 type Commit,
8 type GitObject,
9 type ObjectType,
10 type TreeEntry,
11} from "./objects.js";
12
13export interface ObjectReader {
14 read(oid: string): Promise<GitObject | null>;
15}
16
17export interface ObjectWriter {
18 /** Store an object and return its oid. Must be idempotent. */
19 write(type: ObjectType, content: Uint8Array): Promise<string>;
20}
21
22export type ObjectStore = ObjectReader & ObjectWriter;
23
24export class MissingObjectError extends Error {
25 constructor(public oid: string) {
26 super(`missing object ${oid}`);
27 }
28}
29
30export async function mustRead(store: ObjectReader, oid: string, type?: ObjectType): Promise<GitObject> {
31 const o = await store.read(oid);
32 if (!o) throw new MissingObjectError(oid);
33 if (type && o.type !== type) throw new Error(`object ${oid} is a ${o.type}, expected ${type}`);
34 return o;
35}
36
37export async function readCommit(store: ObjectReader, oid: string): Promise<Commit> {
38 return parseCommit((await mustRead(store, oid, "commit")).content);
39}
40
41export async function readTree(store: ObjectReader, oid: string): Promise<TreeEntry[]> {
42 return parseTree((await mustRead(store, oid, "tree")).content);
43}
44
45/** Peel tags until a commit oid is reached. */
46export async function peelToCommit(store: ObjectReader, oid: string): Promise<string> {
47 for (let i = 0; i < 16; i++) {
48 const o = await mustRead(store, oid);
49 if (o.type === "commit") return oid;
50 if (o.type !== "tag") throw new Error(`${oid} does not point to a commit`);
51 oid = parseTag(o.content).object;
52 }
53 throw new Error("tag chain too deep");
54}
55
56/** Resolve a path inside a tree. Returns null when it does not exist. */
57export async function resolvePath(store: ObjectReader, treeOid: string, path: string): Promise<TreeEntry | null> {
58 const parts = path.split("/").filter(Boolean);
59 if (parts.length === 0) return { mode: "40000", name: "", oid: treeOid };
60 let current = treeOid;
61 for (let i = 0; i < parts.length; i++) {
62 const entries = await readTree(store, current);
63 const entry = entries.find((e) => e.name === parts[i]);
64 if (!entry) return null;
65 if (i === parts.length - 1) return entry;
66 if (!isTreeMode(entry.mode)) return null;
67 current = entry.oid;
68 }
69 return null;
70}
71
72/** Walk all files of a tree recursively. */
73export async function listFiles(store: ObjectReader, treeOid: string, prefix = ""): Promise<Array<TreeEntry & { path: string }>> {
74 const out: Array<TreeEntry & { path: string }> = [];
75 for (const e of await readTree(store, treeOid)) {
76 const path = prefix ? `${prefix}/${e.name}` : e.name;
77 if (isTreeMode(e.mode)) out.push(...(await listFiles(store, e.oid, path)));
78 else out.push({ ...e, path });
79 }
80 return out;
81}
82
83// ---------------------------------------------------------------- history
84
85/** Commits reachable from `tips`, newest first by committer date, stopping at `stop`. */
86export async function walkCommits(
87 store: ObjectReader,
88 tips: readonly string[],
89 opts: { stop?: Set<string>; limit?: number; firstParent?: boolean } = {},
90): Promise<Array<{ oid: string; commit: Commit }>> {
91 const seen = new Set<string>(opts.stop ?? []);
92 const queue: Array<{ oid: string; commit: Commit }> = [];
93 const out: Array<{ oid: string; commit: Commit }> = [];
94 const push = async (oid: string) => {
95 if (seen.has(oid)) return;
96 seen.add(oid);
97 const o = await store.read(oid);
98 if (!o || o.type !== "commit") return; // shallow / missing parents are tolerated
99 queue.push({ oid, commit: parseCommit(o.content) });
100 };
101 for (const t of tips) await push(t);
102 while (queue.length && (opts.limit === undefined || out.length < opts.limit)) {
103 queue.sort((a, b) => b.commit.committer.timestamp - a.commit.committer.timestamp);
104 const next = queue.shift()!;
105 out.push(next);
106 const parents = opts.firstParent ? next.commit.parents.slice(0, 1) : next.commit.parents;
107 for (const p of parents) await push(p);
108 }
109 return out;
110}
111
112export async function isAncestor(store: ObjectReader, ancestor: string, descendant: string): Promise<boolean> {
113 if (ancestor === descendant) return true;
114 return (await commonAncestors(store, ancestor, descendant, ancestor)).includes(ancestor);
115}
116
117/** Best common ancestor of two commits (null if unrelated). */
118export async function mergeBase(store: ObjectReader, a: string, b: string): Promise<string | null> {
119 const found = await commonAncestors(store, a, b);
120 if (found.length < 2) return found[0] ?? null;
121 // More than one (criss-cross merges, or clock skew left one unmarked): drop those below another.
122 for (const c of found) {
123 let below = false;
124 for (const other of found) if (other !== c && (await isAncestor(store, c, other))) below = true;
125 if (!below) return c;
126 }
127 return found[0]!;
128}
129
130const FROM_A = 1;
131const FROM_B = 2;
132const STALE = 4;
133const FOUND = 8;
134
135/**
136 * The common ancestors of `a` and `b` that no other found one descends from, newest first (git's
137 * paint_down_to_common). Walks both histories newest first, marking which side reaches each commit, and
138 * stops once every commit left to visit lies below a common one: the work is the commits since the
139 * merge base, not the whole history. Dates only order the walk, so clock skew can leave an extra
140 * (lower) common ancestor in the list but never misses the best one. Commits that can't be read
141 * (shallow history) are left out. `stop`: return as soon as that commit is found common.
142 */
143async function commonAncestors(store: ObjectReader, a: string, b: string, stop?: string): Promise<string[]> {
144 const commits = new Map<string, { time: number; parents: string[] } | null>();
145 const load = async (oid: string) => {
146 if (commits.has(oid)) return commits.get(oid)!;
147 const o = await store.read(oid);
148 const c = o && o.type === "commit" ? parseCommit(o.content) : null;
149 const node = c ? { time: c.committer.timestamp, parents: c.parents } : null;
150 commits.set(oid, node);
151 return node;
152 };
153 const flags = new Map<string, number>();
154 const queue = new CommitQueue();
155 const paint = async (oid: string, f: number) => {
156 const node = await load(oid);
157 if (!node || ((flags.get(oid) ?? 0) & f) === f) return;
158 flags.set(oid, (flags.get(oid) ?? 0) | f);
159 queue.push(oid, node.time);
160 };
161 await paint(a, FROM_A);
162 await paint(b, FROM_B);
163 const found: string[] = [];
164 while (queue.some((oid) => !((flags.get(oid) ?? 0) & STALE))) {
165 const oid = queue.pop()!;
166 let f = flags.get(oid)! & (FROM_A | FROM_B | STALE);
167 if (f === (FROM_A | FROM_B)) {
168 if (!(flags.get(oid)! & FOUND)) {
169 flags.set(oid, flags.get(oid)! | FOUND);
170 found.push(oid);
171 if (oid === stop) return [oid];
172 }
173 f |= STALE;
174 }
175 for (const p of commits.get(oid)!.parents) await paint(p, f);
176 }
177 // One found later below another found one was marked stale on the way down.
178 return found.filter((oid) => !(flags.get(oid)! & STALE)).sort((x, y) => commits.get(y)!.time - commits.get(x)!.time);
179}
180
181/** Commits to visit, newest first by committer date; equal dates in the order they were added. */
182class CommitQueue {
183 private heap: Array<{ oid: string; time: number; seq: number }> = [];
184 private seq = 0;
185
186 push(oid: string, time: number): void {
187 const h = this.heap;
188 h.push({ oid, time, seq: this.seq++ });
189 let i = h.length - 1;
190 while (i > 0) {
191 const up = (i - 1) >> 1;
192 if (!this.before(h[i]!, h[up]!)) break;
193 [h[i], h[up]] = [h[up]!, h[i]!];
194 i = up;
195 }
196 }
197
198 pop(): string | undefined {
199 const h = this.heap;
200 const top = h[0];
201 const last = h.pop();
202 if (top && last && h.length) {
203 h[0] = last;
204 let i = 0;
205 for (;;) {
206 const l = 2 * i + 1;
207 const r = l + 1;
208 let m = i;
209 if (l < h.length && this.before(h[l]!, h[m]!)) m = l;
210 if (r < h.length && this.before(h[r]!, h[m]!)) m = r;
211 if (m === i) break;
212 [h[i], h[m]] = [h[m]!, h[i]!];
213 i = m;
214 }
215 }
216 return top?.oid;
217 }
218
219 some(test: (oid: string) => boolean): boolean {
220 return this.heap.some((e) => test(e.oid));
221 }
222
223 private before(x: { time: number; seq: number }, y: { time: number; seq: number }): boolean {
224 return x.time !== y.time ? x.time > y.time : x.seq < y.seq;
225 }
226}
227
228// ---------------------------------------------------------------- pack selection
229
230async function markTree(store: ObjectReader, treeOid: string, into: Set<string>): Promise<void> {
231 if (into.has(treeOid)) return;
232 into.add(treeOid);
233 for (const e of await readTree(store, treeOid)) {
234 if (e.mode === "160000") continue; // submodule commit, not stored here
235 if (isTreeMode(e.mode)) await markTree(store, e.oid, into);
236 else into.add(e.oid);
237 }
238}
239
240/**
241 * Objects a client needs given what it wants and what it already has, in an
242 * order suitable for a pack (commits, then trees and blobs, then tags).
243 */
244export interface FetchOptions {
245 /** `deepen <n>`: keep at most n commits of history from each want. */
246 depth?: number;
247 /** `deepen-since <unix time>`: keep commits committed at or after this time. */
248 since?: number;
249 /** Commits the client reported as its shallow boundary (`shallow <oid>`). */
250 clientShallow?: ReadonlySet<string>;
251}
252
253export interface FetchPlan {
254 oids: string[];
255 /** Commits that become the client's new shallow boundary. */
256 shallow: string[];
257 /** Client shallow commits whose parents are now being sent. */
258 unshallow: string[];
259}
260
261/** Objects to send for a fetch: everything reachable from wants and not from haves. */
262export async function objectsForFetch(store: ObjectReader, wants: readonly string[], haves: readonly string[]): Promise<string[]> {
263 return (await planFetch(store, wants, haves)).oids;
264}
265
266export async function planFetch(store: ObjectReader, wants: readonly string[], haves: readonly string[], opts: FetchOptions = {}): Promise<FetchPlan> {
267 const clientShallow = opts.clientShallow ?? new Set<string>();
268 // History the client has, cut at its shallow boundary (it lacks those commits' parents).
269 const haveCommits = new Set<string>();
270 const haveTips: string[] = [];
271 for (const h of haves) {
272 const o = await store.read(h);
273 if (o?.type !== "commit") continue;
274 haveTips.push(h);
275 const stack = [h];
276 while (stack.length) {
277 const cur = stack.pop()!;
278 if (haveCommits.has(cur)) continue;
279 const c = await store.read(cur);
280 if (!c || c.type !== "commit") continue;
281 haveCommits.add(cur);
282 if (!clientShallow.has(cur)) stack.push(...parseCommit(c.content).parents);
283 }
284 }
285 const tags: string[] = [];
286 const commitTips: string[] = [];
287 const extra: string[] = [];
288 for (const w of wants) {
289 let oid = w;
290 for (let i = 0; i < 16; i++) {
291 const o = await mustRead(store, oid);
292 if (o.type === "tag") {
293 tags.push(oid);
294 oid = parseTag(o.content).object;
295 continue;
296 }
297 if (o.type === "commit") commitTips.push(oid);
298 else extra.push(oid);
299 break;
300 }
301 }
302
303 const shallow: string[] = [];
304 const unshallow: string[] = [];
305 let commits: Array<{ oid: string; commit: Commit }>;
306 if (opts.depth !== undefined || opts.since !== undefined) {
307 // Breadth-first by depth so each commit gets its shortest distance from a want.
308 const depthLimit = opts.depth ?? Number.POSITIVE_INFINITY;
309 const since = opts.since ?? Number.NEGATIVE_INFINITY;
310 const best = new Map<string, number>();
311 const parsed = new Map<string, Commit>();
312 let frontier = commitTips.map((oid) => ({ oid, depth: 1 }));
313 const order: string[] = [];
314 while (frontier.length) {
315 const next: Array<{ oid: string; depth: number }> = [];
316 for (const { oid, depth } of frontier) {
317 if ((best.get(oid) ?? Number.POSITIVE_INFINITY) <= depth) continue;
318 const o = await store.read(oid);
319 if (!o || o.type !== "commit") continue;
320 const commit = parsed.get(oid) ?? parseCommit(o.content);
321 if (commit.committer.timestamp < since && depth > 1) continue;
322 if (!best.has(oid)) order.push(oid);
323 best.set(oid, depth);
324 parsed.set(oid, commit);
325 if (depth < depthLimit) for (const p of commit.parents) next.push({ oid: p, depth: depth + 1 });
326 }
327 frontier = next;
328 }
329 commits = [];
330 for (const oid of order) {
331 const commit = parsed.get(oid)!;
332 const depth = best.get(oid)!;
333 let cut = false;
334 for (const p of commit.parents) {
335 if (!best.has(p)) cut = true;
336 }
337 if (cut && commit.parents.length) shallow.push(oid);
338 else if (clientShallow.has(oid) && commit.parents.length && depth < depthLimit) unshallow.push(oid);
339 if (!haveCommits.has(oid) || unshallow.includes(oid)) commits.push({ oid, commit });
340 }
341 } else {
342 commits = await walkCommits(store, commitTips, { stop: haveCommits });
343 }
344
345 // Trees and blobs the client already has: everything under the trees of commits it has at the edge.
346 const uninteresting = new Set<string>();
347 const boundary = new Set<string>(haveTips);
348 for (const { commit } of commits) for (const p of commit.parents) if (haveCommits.has(p)) boundary.add(p);
349 for (const b of boundary) await markTree(store, (await readCommit(store, b)).tree, uninteresting);
350
351 const out: string[] = [];
352 const added = new Set<string>();
353 const add = (oid: string) => {
354 if (!added.has(oid) && !uninteresting.has(oid)) {
355 added.add(oid);
356 out.push(oid);
357 }
358 };
359 for (const { oid } of commits) if (!haveCommits.has(oid)) add(oid);
360 const walkTree = async (treeOid: string) => {
361 if (added.has(treeOid) || uninteresting.has(treeOid)) return;
362 add(treeOid);
363 for (const e of await readTree(store, treeOid)) {
364 if (e.mode === "160000") continue;
365 if (isTreeMode(e.mode)) await walkTree(e.oid);
366 else add(e.oid);
367 }
368 };
369 for (const { oid, commit } of commits) if (!haveCommits.has(oid)) await walkTree(commit.tree);
370 for (const oid of extra) {
371 const o = await mustRead(store, oid);
372 if (o.type === "tree") await walkTree(oid);
373 else add(oid);
374 }
375 for (const t of tags) add(t);
376 return { oids: out, shallow, unshallow };
377}
378
379// ---------------------------------------------------------------- writing trees
380
381export interface FileChange {
382 path: string;
383 /** New content; omit together with `delete: true` to remove the path. */
384 content?: Uint8Array;
385 /** Use an existing blob instead of content. */
386 oid?: string;
387 mode?: "100644" | "100755" | "120000";
388 delete?: boolean;
389}
390
391/** Apply file changes to a tree (or an empty tree) and return the new tree oid. */
392export async function applyChanges(store: ObjectStore, baseTree: string | null, changes: readonly FileChange[]): Promise<string> {
393 type Node = { entries: Map<string, TreeEntry>; children: Map<string, Node>; base: string | null };
394 const load = async (oid: string | null): Promise<Node> => ({
395 entries: new Map(oid ? (await readTree(store, oid)).map((e) => [e.name, e]) : []),
396 children: new Map(),
397 base: oid,
398 });
399 const root = await load(baseTree);
400 const dirFor = async (parts: string[]): Promise<Node> => {
401 let node = root;
402 for (const part of parts) {
403 let child = node.children.get(part);
404 if (!child) {
405 const existing = node.entries.get(part);
406 child = await load(existing && isTreeMode(existing.mode) ? existing.oid : null);
407 node.children.set(part, child);
408 }
409 node = child;
410 }
411 return node;
412 };
413 for (const ch of changes) {
414 const parts = ch.path.split("/").filter(Boolean);
415 const name = parts.pop();
416 if (!name) throw new Error(`invalid path: ${ch.path}`);
417 const dir = await dirFor(parts);
418 if (ch.delete) {
419 dir.entries.delete(name);
420 dir.children.delete(name);
421 continue;
422 }
423 const oid = ch.oid ?? (await store.write("blob", ch.content ?? new Uint8Array(0)));
424 dir.children.delete(name);
425 dir.entries.set(name, { mode: ch.mode ?? dir.entries.get(name)?.mode ?? "100644", name, oid });
426 }
427 const finish = async (node: Node): Promise<string | null> => {
428 for (const [name, child] of node.children) {
429 const oid = await finish(child);
430 if (oid) node.entries.set(name, { mode: "40000", name, oid });
431 else node.entries.delete(name);
432 }
433 if (node.entries.size === 0) return null;
434 return store.write("tree", serializeTree([...node.entries.values()]));
435 };
436 return (await finish(root)) ?? (await store.write("tree", new Uint8Array(0)));
437}