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.

297 lines · 10.5 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 async function objectsForFetch(store: ObjectReader, wants: readonly string[], haves: readonly string[]): Promise<string[]> {
181 const haveCommits = new Set<string>();
182 for (const h of haves) {
183 const o = await store.read(h);
184 if (o?.type === "commit") for (const a of await ancestors(store, h)) haveCommits.add(a);
185 }
186 const tags: string[] = [];
187 const commitTips: string[] = [];
188 const extra: string[] = [];
189 for (const w of wants) {
190 let oid = w;
191 for (let i = 0; i < 16; i++) {
192 const o = await mustRead(store, oid);
193 if (o.type === "tag") {
194 tags.push(oid);
195 oid = parseTag(o.content).object;
196 continue;
197 }
198 if (o.type === "commit") commitTips.push(oid);
199 else extra.push(oid);
200 break;
201 }
202 }
203 const commits = await walkCommits(store, commitTips, { stop: haveCommits });
204
205 // Trees and blobs the client already has: everything under the boundary commits' trees.
206 const uninteresting = new Set<string>();
207 const boundary = new Set<string>();
208 for (const { commit } of commits) for (const p of commit.parents) if (haveCommits.has(p)) boundary.add(p);
209 for (const b of boundary) await markTree(store, (await readCommit(store, b)).tree, uninteresting);
210
211 const out: string[] = [];
212 const added = new Set<string>();
213 const add = (oid: string) => {
214 if (!added.has(oid) && !uninteresting.has(oid)) {
215 added.add(oid);
216 out.push(oid);
217 }
218 };
219 for (const { oid } of commits) add(oid);
220 const walkTree = async (treeOid: string) => {
221 if (added.has(treeOid) || uninteresting.has(treeOid)) return;
222 add(treeOid);
223 for (const e of await readTree(store, treeOid)) {
224 if (e.mode === "160000") continue;
225 if (isTreeMode(e.mode)) await walkTree(e.oid);
226 else add(e.oid);
227 }
228 };
229 for (const { commit } of commits) await walkTree(commit.tree);
230 for (const oid of extra) {
231 const o = await mustRead(store, oid);
232 if (o.type === "tree") await walkTree(oid);
233 else add(oid);
234 }
235 for (const t of tags) add(t);
236 return out;
237}
238
239// ---------------------------------------------------------------- writing trees
240
241export interface FileChange {
242 path: string;
243 /** New content; omit together with `delete: true` to remove the path. */
244 content?: Uint8Array;
245 /** Use an existing blob instead of content. */
246 oid?: string;
247 mode?: "100644" | "100755" | "120000";
248 delete?: boolean;
249}
250
251/** Apply file changes to a tree (or an empty tree) and return the new tree oid. */
252export async function applyChanges(store: ObjectStore, baseTree: string | null, changes: readonly FileChange[]): Promise<string> {
253 type Node = { entries: Map<string, TreeEntry>; children: Map<string, Node>; base: string | null };
254 const load = async (oid: string | null): Promise<Node> => ({
255 entries: new Map(oid ? (await readTree(store, oid)).map((e) => [e.name, e]) : []),
256 children: new Map(),
257 base: oid,
258 });
259 const root = await load(baseTree);
260 const dirFor = async (parts: string[]): Promise<Node> => {
261 let node = root;
262 for (const part of parts) {
263 let child = node.children.get(part);
264 if (!child) {
265 const existing = node.entries.get(part);
266 child = await load(existing && isTreeMode(existing.mode) ? existing.oid : null);
267 node.children.set(part, child);
268 }
269 node = child;
270 }
271 return node;
272 };
273 for (const ch of changes) {
274 const parts = ch.path.split("/").filter(Boolean);
275 const name = parts.pop();
276 if (!name) throw new Error(`invalid path: ${ch.path}`);
277 const dir = await dirFor(parts);
278 if (ch.delete) {
279 dir.entries.delete(name);
280 dir.children.delete(name);
281 continue;
282 }
283 const oid = ch.oid ?? (await store.write("blob", ch.content ?? new Uint8Array(0)));
284 dir.children.delete(name);
285 dir.entries.set(name, { mode: ch.mode ?? dir.entries.get(name)?.mode ?? "100644", name, oid });
286 }
287 const finish = async (node: Node): Promise<string | null> => {
288 for (const [name, child] of node.children) {
289 const oid = await finish(child);
290 if (oid) node.entries.set(name, { mode: "40000", name, oid });
291 else node.entries.delete(name);
292 }
293 if (node.entries.size === 0) return null;
294 return store.write("tree", serializeTree([...node.entries.values()]));
295 };
296 return (await finish(root)) ?? (await store.write("tree", new Uint8Array(0)));
297}