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.

373 lines · 13.6 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
112async function ancestors(store: ObjectReader, oid: string): Promise<Set<string>> {
113 const set = new Set<string>();
114 const stack = [oid];
115 while (stack.length) {
116 const cur = stack.pop()!;
117 if (set.has(cur)) continue;
118 set.add(cur);
119 const o = await store.read(cur);
120 if (!o || o.type !== "commit") continue;
121 stack.push(...parseCommit(o.content).parents);
122 }
123 return set;
124}
125
126export async function isAncestor(store: ObjectReader, ancestor: string, descendant: string): Promise<boolean> {
127 if (ancestor === descendant) return true;
128 const stack = [descendant];
129 const seen = new Set<string>();
130 while (stack.length) {
131 const cur = stack.pop()!;
132 if (cur === ancestor) return true;
133 if (seen.has(cur)) continue;
134 seen.add(cur);
135 const o = await store.read(cur);
136 if (!o || o.type !== "commit") continue;
137 stack.push(...parseCommit(o.content).parents);
138 }
139 return false;
140}
141
142/** Best common ancestor of two commits (null if unrelated). */
143export async function mergeBase(store: ObjectReader, a: string, b: string): Promise<string | null> {
144 const ancA = await ancestors(store, a);
145 // BFS from b by date; the first commit found in ancA that is not an ancestor of another candidate wins.
146 const candidates: string[] = [];
147 for (const { oid } of await walkCommits(store, [b])) {
148 if (ancA.has(oid)) candidates.push(oid);
149 }
150 if (!candidates.length) return null;
151 for (const c of candidates) {
152 let dominated = false;
153 for (const other of candidates) {
154 if (other !== c && (await isAncestor(store, c, other))) {
155 dominated = true;
156 break;
157 }
158 }
159 if (!dominated) return c;
160 }
161 return candidates[0]!;
162}
163
164// ---------------------------------------------------------------- pack selection
165
166async function markTree(store: ObjectReader, treeOid: string, into: Set<string>): Promise<void> {
167 if (into.has(treeOid)) return;
168 into.add(treeOid);
169 for (const e of await readTree(store, treeOid)) {
170 if (e.mode === "160000") continue; // submodule commit, not stored here
171 if (isTreeMode(e.mode)) await markTree(store, e.oid, into);
172 else into.add(e.oid);
173 }
174}
175
176/**
177 * Objects a client needs given what it wants and what it already has, in an
178 * order suitable for a pack (commits, then trees and blobs, then tags).
179 */
180export interface FetchOptions {
181 /** `deepen <n>`: keep at most n commits of history from each want. */
182 depth?: number;
183 /** `deepen-since <unix time>`: keep commits committed at or after this time. */
184 since?: number;
185 /** Commits the client reported as its shallow boundary (`shallow <oid>`). */
186 clientShallow?: ReadonlySet<string>;
187}
188
189export interface FetchPlan {
190 oids: string[];
191 /** Commits that become the client's new shallow boundary. */
192 shallow: string[];
193 /** Client shallow commits whose parents are now being sent. */
194 unshallow: string[];
195}
196
197/** Objects to send for a fetch: everything reachable from wants and not from haves. */
198export async function objectsForFetch(store: ObjectReader, wants: readonly string[], haves: readonly string[]): Promise<string[]> {
199 return (await planFetch(store, wants, haves)).oids;
200}
201
202export async function planFetch(store: ObjectReader, wants: readonly string[], haves: readonly string[], opts: FetchOptions = {}): Promise<FetchPlan> {
203 const clientShallow = opts.clientShallow ?? new Set<string>();
204 // History the client has, cut at its shallow boundary (it lacks those commits' parents).
205 const haveCommits = new Set<string>();
206 const haveTips: string[] = [];
207 for (const h of haves) {
208 const o = await store.read(h);
209 if (o?.type !== "commit") continue;
210 haveTips.push(h);
211 const stack = [h];
212 while (stack.length) {
213 const cur = stack.pop()!;
214 if (haveCommits.has(cur)) continue;
215 const c = await store.read(cur);
216 if (!c || c.type !== "commit") continue;
217 haveCommits.add(cur);
218 if (!clientShallow.has(cur)) stack.push(...parseCommit(c.content).parents);
219 }
220 }
221 const tags: string[] = [];
222 const commitTips: string[] = [];
223 const extra: string[] = [];
224 for (const w of wants) {
225 let oid = w;
226 for (let i = 0; i < 16; i++) {
227 const o = await mustRead(store, oid);
228 if (o.type === "tag") {
229 tags.push(oid);
230 oid = parseTag(o.content).object;
231 continue;
232 }
233 if (o.type === "commit") commitTips.push(oid);
234 else extra.push(oid);
235 break;
236 }
237 }
238
239 const shallow: string[] = [];
240 const unshallow: string[] = [];
241 let commits: Array<{ oid: string; commit: Commit }>;
242 if (opts.depth !== undefined || opts.since !== undefined) {
243 // Breadth-first by depth so each commit gets its shortest distance from a want.
244 const depthLimit = opts.depth ?? Number.POSITIVE_INFINITY;
245 const since = opts.since ?? Number.NEGATIVE_INFINITY;
246 const best = new Map<string, number>();
247 const parsed = new Map<string, Commit>();
248 let frontier = commitTips.map((oid) => ({ oid, depth: 1 }));
249 const order: string[] = [];
250 while (frontier.length) {
251 const next: Array<{ oid: string; depth: number }> = [];
252 for (const { oid, depth } of frontier) {
253 if ((best.get(oid) ?? Number.POSITIVE_INFINITY) <= depth) continue;
254 const o = await store.read(oid);
255 if (!o || o.type !== "commit") continue;
256 const commit = parsed.get(oid) ?? parseCommit(o.content);
257 if (commit.committer.timestamp < since && depth > 1) continue;
258 if (!best.has(oid)) order.push(oid);
259 best.set(oid, depth);
260 parsed.set(oid, commit);
261 if (depth < depthLimit) for (const p of commit.parents) next.push({ oid: p, depth: depth + 1 });
262 }
263 frontier = next;
264 }
265 commits = [];
266 for (const oid of order) {
267 const commit = parsed.get(oid)!;
268 const depth = best.get(oid)!;
269 let cut = false;
270 for (const p of commit.parents) {
271 if (!best.has(p)) cut = true;
272 }
273 if (cut && commit.parents.length) shallow.push(oid);
274 else if (clientShallow.has(oid) && commit.parents.length && depth < depthLimit) unshallow.push(oid);
275 if (!haveCommits.has(oid) || unshallow.includes(oid)) commits.push({ oid, commit });
276 }
277 } else {
278 commits = await walkCommits(store, commitTips, { stop: haveCommits });
279 }
280
281 // Trees and blobs the client already has: everything under the trees of commits it has at the edge.
282 const uninteresting = new Set<string>();
283 const boundary = new Set<string>(haveTips);
284 for (const { commit } of commits) for (const p of commit.parents) if (haveCommits.has(p)) boundary.add(p);
285 for (const b of boundary) await markTree(store, (await readCommit(store, b)).tree, uninteresting);
286
287 const out: string[] = [];
288 const added = new Set<string>();
289 const add = (oid: string) => {
290 if (!added.has(oid) && !uninteresting.has(oid)) {
291 added.add(oid);
292 out.push(oid);
293 }
294 };
295 for (const { oid } of commits) if (!haveCommits.has(oid)) add(oid);
296 const walkTree = async (treeOid: string) => {
297 if (added.has(treeOid) || uninteresting.has(treeOid)) return;
298 add(treeOid);
299 for (const e of await readTree(store, treeOid)) {
300 if (e.mode === "160000") continue;
301 if (isTreeMode(e.mode)) await walkTree(e.oid);
302 else add(e.oid);
303 }
304 };
305 for (const { oid, commit } of commits) if (!haveCommits.has(oid)) await walkTree(commit.tree);
306 for (const oid of extra) {
307 const o = await mustRead(store, oid);
308 if (o.type === "tree") await walkTree(oid);
309 else add(oid);
310 }
311 for (const t of tags) add(t);
312 return { oids: out, shallow, unshallow };
313}
314
315// ---------------------------------------------------------------- writing trees
316
317export interface FileChange {
318 path: string;
319 /** New content; omit together with `delete: true` to remove the path. */
320 content?: Uint8Array;
321 /** Use an existing blob instead of content. */
322 oid?: string;
323 mode?: "100644" | "100755" | "120000";
324 delete?: boolean;
325}
326
327/** Apply file changes to a tree (or an empty tree) and return the new tree oid. */
328export async function applyChanges(store: ObjectStore, baseTree: string | null, changes: readonly FileChange[]): Promise<string> {
329 type Node = { entries: Map<string, TreeEntry>; children: Map<string, Node>; base: string | null };
330 const load = async (oid: string | null): Promise<Node> => ({
331 entries: new Map(oid ? (await readTree(store, oid)).map((e) => [e.name, e]) : []),
332 children: new Map(),
333 base: oid,
334 });
335 const root = await load(baseTree);
336 const dirFor = async (parts: string[]): Promise<Node> => {
337 let node = root;
338 for (const part of parts) {
339 let child = node.children.get(part);
340 if (!child) {
341 const existing = node.entries.get(part);
342 child = await load(existing && isTreeMode(existing.mode) ? existing.oid : null);
343 node.children.set(part, child);
344 }
345 node = child;
346 }
347 return node;
348 };
349 for (const ch of changes) {
350 const parts = ch.path.split("/").filter(Boolean);
351 const name = parts.pop();
352 if (!name) throw new Error(`invalid path: ${ch.path}`);
353 const dir = await dirFor(parts);
354 if (ch.delete) {
355 dir.entries.delete(name);
356 dir.children.delete(name);
357 continue;
358 }
359 const oid = ch.oid ?? (await store.write("blob", ch.content ?? new Uint8Array(0)));
360 dir.children.delete(name);
361 dir.entries.set(name, { mode: ch.mode ?? dir.entries.get(name)?.mode ?? "100644", name, oid });
362 }
363 const finish = async (node: Node): Promise<string | null> => {
364 for (const [name, child] of node.children) {
365 const oid = await finish(child);
366 if (oid) node.entries.set(name, { mode: "40000", name, oid });
367 else node.entries.delete(name);
368 }
369 if (node.entries.size === 0) return null;
370 return store.write("tree", serializeTree([...node.entries.values()]));
371 };
372 return (await finish(root)) ?? (await store.write("tree", new Uint8Array(0)));
373}