char/sorcery

static-files based git repo viewer

git clone https://git.t4t.associates/char/sorcery

Charlotte Somexperiment: support sha-256 oids in git repos42f80d8

main
7.9 KiB255 linesraw
1import { inflate, oidBytes, toHex } from "./codec.ts";
2import type { Fetcher, GitRepo } from "./repo.ts";
3import type { GitObject, ObjectType } from "./types.ts";
4
5/** pack entry types (`OBJ_*` in git) */
6const TYPES: Record<number, ObjectType> = { 1: "commit", 2: "tree", 3: "blob", 4: "tag" };
7const OFS_DELTA = 6;
8const REF_DELTA = 7;
9
10export interface PackIndex {
11  lookup(oid: string): Promise<number | null>;
12  entryEnd(offset: number): number | null;
13}
14
15const PACK_INDEX_HEADER_BYTES = 8 + 256 * 4;
16
17interface PackIndexHeader {
18  count: number;
19  fanout: number[];
20}
21
22function parseIndexHeader(buf: Uint8Array): PackIndexHeader {
23  const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
24  if (buf.length < PACK_INDEX_HEADER_BYTES || view.getUint32(0) !== 0xff744f63 || view.getUint32(4) !== 2) {
25    throw new Error("unsupported pack index format");
26  }
27  const fanout = Array.from({ length: 256 }, (_, i) => view.getUint32(8 + i * 4));
28  return { count: fanout[255], fanout };
29}
30
31/** Eager path for small version-2 indexes. */
32export function parseIndex(buf: Uint8Array, hashBytes: number): PackIndex {
33  const { count, fanout } = parseIndexHeader(buf);
34  const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
35  const names = PACK_INDEX_HEADER_BYTES;
36  const offsets = names + count * hashBytes + count * 4; // skip crc table
37  const largeOffsets = offsets + count * 4;
38
39  const oidAt = (i: number) => buf.subarray(names + i * hashBytes, names + (i + 1) * hashBytes);
40  const offsetAt = (i: number): number => {
41    const raw = view.getUint32(offsets + i * 4);
42    if (raw & 0x80000000) {
43      return Number(view.getBigUint64(largeOffsets + (raw & 0x7fffffff) * 8));
44    }
45    return raw;
46  };
47
48  const sortedOffsets = Array.from({ length: count }, (_, i) => offsetAt(i)).sort((a, b) => a - b);
49
50  return {
51    async lookup(oid: string): Promise<number | null> {
52      const target = oidBytes(oid);
53      if (target.length !== hashBytes) throw new Error(`object id ${oid} has the wrong hash format`);
54      let lo = target[0] === 0 ? 0 : fanout[target[0] - 1];
55      let hi = fanout[target[0]];
56      while (lo < hi) {
57        const mid = (lo + hi) >> 1;
58        const cmp = compare(oidAt(mid), target);
59        if (cmp === 0) return offsetAt(mid);
60        if (cmp < 0) lo = mid + 1;
61        else hi = mid;
62      }
63      return null;
64    },
65    entryEnd(offset: number): number | null {
66      const i = sortedOffsets.findIndex(candidate => candidate > offset);
67      return i === -1 ? null : sortedOffsets[i];
68    },
69  };
70}
71
72export function compare(a: Uint8Array, b: Uint8Array): number {
73  for (let i = 0; i < a.length; i++) {
74    if (a[i] !== b[i]) return a[i] - b[i];
75  }
76  return 0;
77}
78
79export interface PackEntry {
80  type: number;
81  /** inflated payload: object content, or delta instructions */
82  data: Uint8Array;
83  /** for OFS_DELTA: absolute offset of the base entry */
84  baseOffset?: number;
85  /** for REF_DELTA: oid of the base object */
86  baseOid?: string;
87}
88
89export interface PackEntryHeader {
90  type: number;
91  size: number;
92  bytes: number;
93  baseOffset?: number;
94  baseOid?: string;
95}
96
97export function parseEntryHeader(slice: Uint8Array, hashBytes: number, entryOffset = 0): PackEntryHeader {
98  let pos = 0;
99  let byte = slice[pos++];
100  const type = (byte >> 4) & 7;
101  let size = byte & 0x0f;
102  let shift = 4;
103  while (byte & 0x80) {
104    if (pos >= slice.length) throw new Error("truncated pack entry header");
105    byte = slice[pos++];
106    size += (byte & 0x7f) * 2 ** shift;
107    shift += 7;
108  }
109
110  let baseOffset: number | undefined;
111  let baseOid: string | undefined;
112  if (type === OFS_DELTA) {
113    byte = slice[pos++];
114    let relative = byte & 0x7f;
115    while (byte & 0x80) {
116      byte = slice[pos++];
117      relative = ((relative + 1) << 7) | (byte & 0x7f);
118    }
119    baseOffset = entryOffset - relative;
120  } else if (type === REF_DELTA) {
121    baseOid = toHex(slice.subarray(pos, pos + hashBytes));
122    pos += hashBytes;
123  }
124
125  return { type, size, bytes: pos, baseOffset, baseOid };
126}
127
128/** decode one entry from an exact pack slice starting at its header */
129export async function parseEntry(slice: Uint8Array, hashBytes: number, entryOffset: number): Promise<PackEntry> {
130  const header = parseEntryHeader(slice, hashBytes, entryOffset);
131  return {
132    type: header.type,
133    data: await inflate(slice.subarray(header.bytes)),
134    baseOffset: header.baseOffset,
135    baseOid: header.baseOid,
136  };
137}
138
139export function entryType(type: number): ObjectType {
140  const known = TYPES[type];
141  if (!known) throw new Error(`unexpected pack entry type ${type}`);
142  return known;
143}
144
145export function isDelta(type: number): boolean {
146  return type === OFS_DELTA || type === REF_DELTA;
147}
148
149export function deltaResultSize(delta: Uint8Array): number {
150  let pos = 0;
151  const varint = () => {
152    let value = 0;
153    let shift = 0;
154    let byte;
155    do {
156      byte = delta[pos++];
157      value += (byte & 0x7f) * 2 ** shift;
158      shift += 7;
159    } while (byte & 0x80);
160    return value;
161  };
162  varint();
163  return varint();
164}
165
166/** apply copy/insert delta instructions to a base object */
167export function applyDelta(base: Uint8Array, delta: Uint8Array): Uint8Array {
168  let pos = 0;
169  const varint = () => {
170    let value = 0;
171    let shift = 0;
172    let byte;
173    do {
174      byte = delta[pos++];
175      value |= (byte & 0x7f) << shift;
176      shift += 7;
177    } while (byte & 0x80);
178    return value >>> 0;
179  };
180
181  const srcSize = varint();
182  if (srcSize !== base.length) throw new Error("delta base size mismatch");
183  const dstSize = varint();
184  const out = new Uint8Array(dstSize);
185  let outPos = 0;
186
187  while (pos < delta.length) {
188    const op = delta[pos++];
189    if (op & 0x80) {
190      // copy from base: bitmask selects which offset/size bytes follow
191      let offset = 0;
192      let size = 0;
193      for (let i = 0; i < 4; i++) if (op & (1 << i)) offset |= delta[pos++] << (i * 8);
194      for (let i = 0; i < 3; i++) if (op & (0x10 << i)) size |= delta[pos++] << (i * 8);
195      if (size === 0) size = 0x10000;
196      out.set(base.subarray(offset, offset + size), outPos);
197      outPos += size;
198    } else if (op !== 0) {
199      out.set(delta.subarray(pos, pos + op), outPos);
200      pos += op;
201      outPos += op;
202    } else {
203      throw new Error("invalid delta opcode 0");
204    }
205  }
206  if (outPos !== dstSize) throw new Error("delta output size mismatch");
207  return out;
208}
209
210export class Pack {
211  #index?: Promise<PackIndex>;
212  readonly #hashBytes: number;
213
214  constructor(
215    readonly fetch: Fetcher,
216    readonly stem: string,
217  ) {
218    const hash = stem.match(/^pack-([0-9a-f]{40}|[0-9a-f]{64})$/)?.[1];
219    if (!hash) throw new Error(`invalid pack name ${stem}`);
220    this.#hashBytes = hash.length / 2;
221  }
222
223  index(): Promise<PackIndex> {
224    if (this.#index) return this.#index;
225    this.#index = this.fetch(`.git/objects/pack/${this.stem}.idx`).then(result => {
226      if (!result) throw new Error(`missing pack index ${this.stem}`);
227      return parseIndex(result.bytes, this.#hashBytes);
228    });
229    this.#index.catch(() => (this.#index = undefined));
230    return this.#index;
231  }
232
233  /** read + resolve (possibly delta-chained) object content at an offset */
234  async readAt(offset: number, repo: GitRepo): Promise<GitObject> {
235    const entry = await this.#readPackedEntry(offset);
236    if (!isDelta(entry.type)) {
237      return { type: entryType(entry.type), data: entry.data };
238    }
239    const base = entry.baseOffset !== undefined
240      ? await this.readAt(entry.baseOffset, repo)
241      : await repo.object(entry.baseOid!);
242    return { type: base.type, data: applyDelta(base.data, entry.data) };
243  }
244
245  async #readPackedEntry(offset: number): Promise<PackEntry> {
246    const index = await this.index();
247    const path = `.git/objects/pack/${this.stem}.pack`;
248    const end = index.entryEnd(offset);
249    const res = await this.fetch(path, [offset, end]);
250    if (!res) throw new Error(`missing pack ${this.stem}`);
251    let bytes = res.bytes;
252    if (end === null) bytes = bytes.subarray(0, res.total - this.#hashBytes - offset);
253    return parseEntry(bytes, this.#hashBytes, offset);
254  }
255}