File
Blob: src/worker/git/core/objects.ts
| 1 | /** |
| 2 | * Git object encoding/decoding utilities |
| 3 | */ |
| 4 | |
| 5 | import { deflate } from "@/worker/common/compression"; |
| 6 | import { bytesToHex } from "@/worker/common/hex"; |
| 7 | import { asBufferSource, createDigestStream } from "@/worker/common/webtypes"; |
| 8 | |
| 9 | /** |
| 10 | * Git object types |
| 11 | */ |
| 12 | export type GitObjectType = "blob" | "tree" | "commit" | "tag"; |
| 13 | |
| 14 | const textEncoder = new TextEncoder(); |
| 15 | |
| 16 | /** |
| 17 | * Map Git object type to pack type code |
| 18 | */ |
| 19 | export function objTypeCode(type: GitObjectType): number { |
| 20 | switch (type) { |
| 21 | case "commit": |
| 22 | return 1; |
| 23 | case "tree": |
| 24 | return 2; |
| 25 | case "blob": |
| 26 | return 3; |
| 27 | case "tag": |
| 28 | return 4; |
| 29 | default: |
| 30 | throw new Error(`Unknown object type: ${type}`); |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | /** |
| 35 | * Encode Git pack object header |
| 36 | * @param type - Object type code (1=commit, 2=tree, 3=blob, 4=tag) |
| 37 | * @param size - Object size in bytes |
| 38 | * @returns Encoded header bytes |
| 39 | */ |
| 40 | export function encodeObjHeader(type: number, size: number): Uint8Array { |
| 41 | let first = (type << 4) | (size & 0x0f); |
| 42 | size >>= 4; |
| 43 | const bytes: number[] = []; |
| 44 | if (size > 0) first |= 0x80; |
| 45 | bytes.push(first); |
| 46 | while (size > 0) { |
| 47 | let b = size & 0x7f; |
| 48 | size >>= 7; |
| 49 | if (size > 0) b |= 0x80; |
| 50 | bytes.push(b); |
| 51 | } |
| 52 | return new Uint8Array(bytes); |
| 53 | } |
| 54 | |
| 55 | function buildRawGitObject(type: GitObjectType, payload: Uint8Array): Uint8Array { |
| 56 | const header = encodeGitObjectHeader(type, payload.byteLength); |
| 57 | const raw = new Uint8Array(header.byteLength + payload.byteLength); |
| 58 | raw.set(header, 0); |
| 59 | raw.set(payload, header.byteLength); |
| 60 | return raw; |
| 61 | } |
| 62 | |
| 63 | function encodeGitObjectHeader(type: GitObjectType, payloadLength: number): Uint8Array { |
| 64 | return textEncoder.encode(`${type} ${payloadLength}\0`); |
| 65 | } |
| 66 | |
| 67 | /** |
| 68 | * Compute the raw 20-byte SHA-1 digest for a Git object payload. |
| 69 | * This avoids a hex roundtrip on hot paths that already store OIDs in binary. |
| 70 | */ |
| 71 | export async function computeOidBytes( |
| 72 | type: GitObjectType, |
| 73 | payload: Uint8Array |
| 74 | ): Promise<Uint8Array> { |
| 75 | const digestStream = createDigestStream("SHA-1"); |
| 76 | const digestWriter = digestStream.getWriter(); |
| 77 | await digestWriter.write(encodeGitObjectHeader(type, payload.byteLength)); |
| 78 | await digestWriter.write(payload); |
| 79 | await digestWriter.close(); |
| 80 | return new Uint8Array(await digestStream.digest); |
| 81 | } |
| 82 | |
| 83 | async function computeOidBytesFromRaw(raw: Uint8Array): Promise<Uint8Array> { |
| 84 | return new Uint8Array(await crypto.subtle.digest("SHA-1", asBufferSource(raw))); |
| 85 | } |
| 86 | |
| 87 | /** |
| 88 | * Create a Git object with header and compute its OID |
| 89 | * @param type - Git object type |
| 90 | * @param payload - Object content |
| 91 | * @returns Object ID and raw bytes |
| 92 | */ |
| 93 | export async function createGitObject( |
| 94 | type: GitObjectType, |
| 95 | payload: Uint8Array |
| 96 | ): Promise<{ oid: string; raw: Uint8Array }> { |
| 97 | const raw = buildRawGitObject(type, payload); |
| 98 | const oid = bytesToHex(await computeOidBytesFromRaw(raw)); |
| 99 | return { oid, raw }; |
| 100 | } |
| 101 | |
| 102 | /** |
| 103 | * Create and compress a Git object |
| 104 | * @param type - Git object type |
| 105 | * @param payload - Object content |
| 106 | * @returns Object ID, raw bytes, and compressed bytes |
| 107 | */ |
| 108 | export async function encodeGitObject( |
| 109 | type: GitObjectType, |
| 110 | payload: Uint8Array |
| 111 | ): Promise<{ oid: string; raw: Uint8Array; zdata: Uint8Array }> { |
| 112 | const { oid, raw } = await createGitObject(type, payload); |
| 113 | const zdata = await deflate(raw); |
| 114 | return { oid, raw, zdata }; |
| 115 | } |
| 116 | |
| 117 | /** |
| 118 | * Parse a Git object to extract type and payload |
| 119 | * @param raw - Raw Git object bytes (with header) |
| 120 | * @returns Object type (GitObjectType) and payload |
| 121 | */ |
| 122 | export function parseGitObject(raw: Uint8Array): { type: GitObjectType; payload: Uint8Array } { |
| 123 | // Find the null byte that separates header from content |
| 124 | let nullIndex = -1; |
| 125 | for (let i = 0; i < raw.length; i++) { |
| 126 | if (raw[i] === 0) { |
| 127 | nullIndex = i; |
| 128 | break; |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | if (nullIndex === -1) { |
| 133 | throw new Error("Invalid Git object: no null byte found"); |
| 134 | } |
| 135 | |
| 136 | const header = new TextDecoder().decode(raw.subarray(0, nullIndex)); |
| 137 | const [typeStr] = header.split(" "); |
| 138 | const payload = raw.subarray(nullIndex + 1); |
| 139 | |
| 140 | if (typeStr !== "commit" && typeStr !== "tree" && typeStr !== "blob" && typeStr !== "tag") { |
| 141 | throw new Error(`Invalid Git object type: ${typeStr}`); |
| 142 | } |
| 143 | const type = typeStr as GitObjectType; |
| 144 | |
| 145 | return { type, payload }; |
| 146 | } |
| 147 | |
| 148 | /** |
| 149 | * Compute the OID of a Git object from its type and payload |
| 150 | * @param type - Git object type |
| 151 | * @param payload - Object content |
| 152 | * @returns SHA-1 hash as hex string |
| 153 | */ |
| 154 | export async function computeOid(type: GitObjectType, payload: Uint8Array): Promise<string> { |
| 155 | return bytesToHex(await computeOidBytes(type, payload)); |
| 156 | } |