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.

165 lines · 5.7 KBRaw History
1import { concat, fromHex, fromUtf8, toHex, utf8 } from "./bytes.js";
2import { sha1Hex } from "./sha1.js";
3
4export type ObjectType = "commit" | "tree" | "blob" | "tag";
5
6export const TYPE_CODES: Record<ObjectType, number> = { commit: 1, tree: 2, blob: 3, tag: 4 };
7export const CODE_TYPES: Record<number, ObjectType> = { 1: "commit", 2: "tree", 3: "blob", 4: "tag" };
8
9export const ZERO_OID = "0000000000000000000000000000000000000000";
10export const EMPTY_TREE_OID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
11
12export interface GitObject {
13 type: ObjectType;
14 content: Uint8Array;
15}
16
17export function objectHeader(type: ObjectType, size: number): Uint8Array {
18 return utf8(`${type} ${size}\0`);
19}
20
21export async function hashObject(type: ObjectType, content: Uint8Array): Promise<string> {
22 return sha1Hex(concat([objectHeader(type, content.length), content]));
23}
24
25export const isOid = (s: string): boolean => /^[0-9a-f]{40}$/.test(s);
26
27// ---------------------------------------------------------------- trees
28
29export interface TreeEntry {
30 mode: string; // "100644" | "100755" | "120000" | "40000" | "160000"
31 name: string;
32 oid: string;
33}
34
35export const isTreeMode = (mode: string): boolean => mode === "40000" || mode === "040000";
36
37export function parseTree(content: Uint8Array): TreeEntry[] {
38 const entries: TreeEntry[] = [];
39 let i = 0;
40 while (i < content.length) {
41 const sp = content.indexOf(0x20, i);
42 const nul = content.indexOf(0x00, sp);
43 if (sp < 0 || nul < 0) throw new Error("malformed tree");
44 const mode = fromUtf8(content.subarray(i, sp));
45 const name = fromUtf8(content.subarray(sp + 1, nul));
46 const oid = toHex(content.subarray(nul + 1, nul + 21));
47 entries.push({ mode: mode === "040000" ? "40000" : mode, name, oid });
48 i = nul + 21;
49 }
50 return entries;
51}
52
53/** Git sorts tree entries as if directory names had a trailing slash. */
54function treeSortKey(e: TreeEntry): string {
55 return isTreeMode(e.mode) ? `${e.name}/` : e.name;
56}
57
58export function compareTreeEntries(a: TreeEntry, b: TreeEntry): number {
59 const ka = utf8(treeSortKey(a));
60 const kb = utf8(treeSortKey(b));
61 const n = Math.min(ka.length, kb.length);
62 for (let i = 0; i < n; i++) if (ka[i] !== kb[i]) return ka[i]! - kb[i]!;
63 return ka.length - kb.length;
64}
65
66export function serializeTree(entries: readonly TreeEntry[]): Uint8Array {
67 const sorted = [...entries].sort(compareTreeEntries);
68 return concat(sorted.flatMap((e) => [utf8(`${e.mode} ${e.name}\0`), fromHex(e.oid)]));
69}
70
71// ---------------------------------------------------------------- commits & tags
72
73export interface Signature {
74 name: string;
75 email: string;
76 /** Seconds since epoch. */
77 timestamp: number;
78 /** e.g. "+0200" */
79 timezone: string;
80}
81
82export interface Commit {
83 tree: string;
84 parents: string[];
85 author: Signature;
86 committer: Signature;
87 message: string;
88 gpgsig?: string;
89 /** Extra headers we preserve but don't interpret. */
90 extraHeaders?: [string, string][];
91}
92
93export interface Tag {
94 object: string;
95 type: ObjectType;
96 tag: string;
97 tagger?: Signature;
98 message: string;
99}
100
101export function parseSignature(s: string): Signature {
102 const m = /^(.*) <(.*)> (-?\d+) ([+-]\d{4})$/.exec(s);
103 if (!m) return { name: s, email: "", timestamp: 0, timezone: "+0000" };
104 return { name: m[1]!, email: m[2]!, timestamp: Number(m[3]), timezone: m[4]! };
105}
106
107export function formatSignature(s: Signature): string {
108 return `${s.name} <${s.email}> ${s.timestamp} ${s.timezone}`;
109}
110
111function parseHeaders(text: string): { headers: [string, string][]; message: string } {
112 const headers: [string, string][] = [];
113 const end = text.indexOf("\n\n");
114 const head = end >= 0 ? text.slice(0, end) : text;
115 const message = end >= 0 ? text.slice(end + 2) : "";
116 for (const line of head.split("\n")) {
117 if (line.startsWith(" ") && headers.length) {
118 const last = headers[headers.length - 1]!;
119 last[1] += `\n${line.slice(1)}`;
120 } else {
121 const sp = line.indexOf(" ");
122 headers.push([line.slice(0, sp), line.slice(sp + 1)]);
123 }
124 }
125 return { headers, message };
126}
127
128export function parseCommit(content: Uint8Array): Commit {
129 const { headers, message } = parseHeaders(fromUtf8(content));
130 const c: Commit = { tree: "", parents: [], author: parseSignature(""), committer: parseSignature(""), message };
131 for (const [k, v] of headers) {
132 if (k === "tree") c.tree = v;
133 else if (k === "parent") c.parents.push(v);
134 else if (k === "author") c.author = parseSignature(v);
135 else if (k === "committer") c.committer = parseSignature(v);
136 else if (k === "gpgsig") c.gpgsig = v;
137 else (c.extraHeaders ??= []).push([k, v]);
138 }
139 return c;
140}
141
142export function serializeCommit(c: Commit): Uint8Array {
143 const lines = [`tree ${c.tree}`, ...c.parents.map((p) => `parent ${p}`), `author ${formatSignature(c.author)}`, `committer ${formatSignature(c.committer)}`];
144 for (const [k, v] of c.extraHeaders ?? []) lines.push(`${k} ${v.replace(/\n/g, "\n ")}`);
145 if (c.gpgsig) lines.push(`gpgsig ${c.gpgsig.replace(/\n/g, "\n ")}`);
146 return utf8(`${lines.join("\n")}\n\n${c.message}`);
147}
148
149export function parseTag(content: Uint8Array): Tag {
150 const { headers, message } = parseHeaders(fromUtf8(content));
151 const t: Tag = { object: "", type: "commit", tag: "", message };
152 for (const [k, v] of headers) {
153 if (k === "object") t.object = v;
154 else if (k === "type") t.type = v as ObjectType;
155 else if (k === "tag") t.tag = v;
156 else if (k === "tagger") t.tagger = parseSignature(v);
157 }
158 return t;
159}
160
161export function serializeTag(t: Tag): Uint8Array {
162 const lines = [`object ${t.object}`, `type ${t.type}`, `tag ${t.tag}`];
163 if (t.tagger) lines.push(`tagger ${formatSignature(t.tagger)}`);
164 return utf8(`${lines.join("\n")}\n\n${t.message}`);
165}