| 1 | import { concat, fromHex, fromUtf8, toHex, utf8 } from "./bytes.js"; |
| 2 | import { Sha1, sha1Hex } from "./sha1.js"; |
| 3 | |
| 4 | export type ObjectType = "commit" | "tree" | "blob" | "tag"; |
| 5 | |
| 6 | export const TYPE_CODES: Record<ObjectType, number> = { commit: 1, tree: 2, blob: 3, tag: 4 }; |
| 7 | export const CODE_TYPES: Record<number, ObjectType> = { 1: "commit", 2: "tree", 3: "blob", 4: "tag" }; |
| 8 | |
| 9 | export const ZERO_OID = "0000000000000000000000000000000000000000"; |
| 10 | export const EMPTY_TREE_OID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; |
| 11 | |
| 12 | export interface GitObject { |
| 13 | type: ObjectType; |
| 14 | content: Uint8Array; |
| 15 | } |
| 16 | |
| 17 | export 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 | */ |
| 25 | export const HASH_COPY_MAX = 1024 * 1024; |
| 26 | |
| 27 | export 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 | |
| 33 | export const isOid = (s: string): boolean => /^[0-9a-f]{40}$/.test(s); |
| 34 | |
| 35 | // ---------------------------------------------------------------- trees |
| 36 | |
| 37 | export interface TreeEntry { |
| 38 | mode: string; // "100644" | "100755" | "120000" | "40000" | "160000" |
| 39 | name: string; |
| 40 | oid: string; |
| 41 | } |
| 42 | |
| 43 | export const isTreeMode = (mode: string): boolean => mode === "40000" || mode === "040000"; |
| 44 | |
| 45 | export 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. */ |
| 62 | function treeSortKey(e: TreeEntry): string { |
| 63 | return isTreeMode(e.mode) ? `${e.name}/` : e.name; |
| 64 | } |
| 65 | |
| 66 | export 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 | |
| 74 | export 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 | |
| 81 | export interface Signature { |
| 82 | name: string; |
| 83 | email: string; |
| 84 | /** Seconds since epoch. */ |
| 85 | timestamp: number; |
| 86 | /** e.g. "+0200" */ |
| 87 | timezone: string; |
| 88 | } |
| 89 | |
| 90 | export 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 | |
| 101 | export interface Tag { |
| 102 | object: string; |
| 103 | type: ObjectType; |
| 104 | tag: string; |
| 105 | tagger?: Signature; |
| 106 | message: string; |
| 107 | } |
| 108 | |
| 109 | export 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 | |
| 115 | export function formatSignature(s: Signature): string { |
| 116 | return `${s.name} <${s.email}> ${s.timestamp} ${s.timezone}`; |
| 117 | } |
| 118 | |
| 119 | function 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 | |
| 136 | export 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 | |
| 150 | export 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 | |
| 157 | export 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 | |
| 169 | export 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 | } |