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.

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