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
11.8 KiB307 linesraw
1import { assert, assertEquals, assertRejects } from "jsr:@std/assert@1";
2import { type Fetcher, GitRepo } from "./repo.ts";
3import { diffLines } from "./diff.ts";
4import { log, treeDiff } from "./walk.ts";
5
6async function sh(cwd: string, ...args: string[]): Promise<string> {
7  const out = await new Deno.Command(args[0], {
8    args: args.slice(1),
9    cwd,
10    env: {
11      GIT_AUTHOR_NAME: "t",
12      GIT_AUTHOR_EMAIL: "t@t",
13      GIT_COMMITTER_NAME: "t",
14      GIT_COMMITTER_EMAIL: "t@t",
15    },
16  }).output();
17  if (!out.success) throw new Error(new TextDecoder().decode(out.stderr));
18  return new TextDecoder().decode(out.stdout).trim();
19}
20
21/** reads from a bare repo dir like nginx would serve `.git/`, incl. ranges */
22function fileFetcher(gitDir: string): Fetcher {
23  return async (path, range) => {
24    if (path === "gitinfo.json") {
25      // stand-in for the server-generated manifest
26      const packDir = `${gitDir}/objects/pack`;
27      const packs: string[] = [];
28      try {
29        for await (const f of Deno.readDir(packDir)) {
30          if (f.name.endsWith(".pack")) packs.push(f.name.slice(0, -5));
31        }
32      } catch { /* no packs yet */ }
33      const head = (await sh(gitDir, "git", "symbolic-ref", "--short", "HEAD")) || null;
34      const refs: unknown[] = [];
35      for (const line of (await sh(gitDir, "git", "show-ref")).split("\n").filter(Boolean)) {
36        const [oid, name] = line.split(" ");
37        if (name.startsWith("refs/heads/")) {
38          refs.push({ kind: "branch", name: name.slice(11), oid });
39        }
40      }
41      const json = JSON.stringify({ head, refs, packs: packs.sort() });
42      return { bytes: new TextEncoder().encode(json), total: json.length };
43    }
44
45    const filePath = `${gitDir}/${path.replace(/^\.git\//, "")}`;
46    let bytes: Uint8Array;
47    try {
48      bytes = await Deno.readFile(filePath);
49    } catch {
50      return null;
51    }
52    const total = bytes.length;
53    if (range) bytes = bytes.subarray(range[0], range[1] ?? undefined);
54    return { bytes, total };
55  };
56}
57
58function objectBundle(specs: Array<[string, number, Uint8Array]>): Uint8Array {
59  const bundle = new Uint8Array(6 + specs.reduce((size, [oid, , data]) => size + oid.length / 2 + 6 + data.length, 0));
60  bundle.set(new TextEncoder().encode("SOBJ"));
61  bundle[4] = 1;
62  let position = 6;
63  for (const [oid, type, data] of specs) {
64    const oidBytes = Uint8Array.from(oid.match(/../g)!, byte => parseInt(byte, 16));
65    bundle[position++] = oidBytes.length;
66    bundle.set(oidBytes, position);
67    position += oidBytes.length;
68    bundle[position++] = type;
69    new DataView(bundle.buffer).setUint32(position, data.length);
70    position += 4;
71    bundle.set(data, position);
72    position += data.length;
73  }
74  return bundle;
75}
76
77async function makeFixture(objectFormat: "sha1" | "sha256" = "sha1"): Promise<{ dir: string; repo: GitRepo }> {
78  const dir = await Deno.makeTempDir({ prefix: "sgw-git-test" });
79  const format = objectFormat === "sha256" ? ["--object-format=sha256"] : [];
80  await sh(dir, "git", "init", "-q", "-b", "main", ...format, ".");
81  await Deno.mkdir(`${dir}/src`);
82  await Deno.writeTextFile(`${dir}/README.md`, "# fixture\n");
83  await Deno.writeTextFile(`${dir}/src/lib.rs`, "fn one() {}\nfn two() {}\nfn three() {}\n");
84  await sh(dir, "git", "add", "-A");
85  await sh(dir, "git", "commit", "-q", "-m", "initial commit");
86  await Deno.writeTextFile(`${dir}/src/lib.rs`, "fn one() {}\nfn two() { todo!() }\nfn three() {}\nfn four() {}\n");
87  await Deno.writeTextFile(`${dir}/NOTES`, "hello\n");
88  await sh(dir, "git", "rm", "-q", "README.md");
89  await sh(dir, "git", "add", "-A");
90  await sh(dir, "git", "commit", "-q", "-m", "second commit");
91  return { dir, repo: new GitRepo(fileFetcher(`${dir}/.git`)) };
92}
93
94async function assertHistory(repo: GitRepo) {
95  const info = await repo.info();
96  assertEquals(info.head, "main");
97  const main = info.refs.find(r => r.name === "main")!;
98
99  const commits = [];
100  for await (const commit of log(repo, [main.oid], 10)) commits.push(commit);
101  assertEquals(commits.map(c => c.message.trim()), ["second commit", "initial commit"]);
102  assertEquals(commits[0].parents, [commits[1].oid]);
103  assertEquals(commits[0].author.name, "t");
104
105  const changes = await treeDiff(repo, commits[1].tree, commits[0].tree);
106  assertEquals(
107    changes.map(c => `${c.status} ${c.path}`),
108    ["added NOTES", "deleted README.md", "modified src/lib.rs"],
109  );
110
111  const modified = changes.find(c => c.path === "src/lib.rs")!;
112  const oldData = await repo.blob(modified.oldOid!);
113  const newData = await repo.blob(modified.newOid!);
114  const oldText = new TextDecoder().decode(oldData);
115  const newText = new TextDecoder().decode(newData);
116  const hunks = diffLines(oldText, newText);
117  assertEquals(hunks.length, 1);
118  assertEquals(
119    hunks[0].lines.map(l => l.sign + l.text),
120    [
121      " fn one() {}",
122      "-fn two() {}",
123      "+fn two() { todo!() }",
124      " fn three() {}",
125      "+fn four() {}",
126    ],
127  );
128}
129
130Deno.test("loose objects", async () => {
131  const { repo } = await makeFixture();
132  await assertHistory(repo);
133});
134
135Deno.test("object queries seed the repository cache", async () => {
136  const { dir } = await makeFixture();
137  const oid = await sh(dir, "git", "rev-parse", "HEAD");
138  const treeOid = await sh(dir, "git", "show", "-s", "--format=%T", "HEAD");
139  const commit = await new Deno.Command("git", { args: ["cat-file", "commit", oid], cwd: dir }).output();
140  const tree = await new Deno.Command("git", { args: ["cat-file", "tree", treeOid], cwd: dir }).output();
141  assert(commit.success && tree.success);
142
143  const bundle = objectBundle([[oid, 1, commit.stdout], [treeOid, 2, tree.stdout]]);
144
145  let rawReads = 0;
146  const queries: unknown[] = [];
147  const files = fileFetcher(`${dir}/.git`);
148  const fetch: Fetcher = (path, range, query) => {
149    if (path === "obj" && query) {
150      queries.push(JSON.parse(new TextDecoder().decode(query)));
151      return Promise.resolve({ bytes: bundle, total: bundle.length });
152    }
153    if (path.startsWith("obj/") || path.startsWith(".git/objects/")) rawReads++;
154    return files(path, range);
155  };
156  const name = `sorcery-test-${crypto.randomUUID()}`;
157  try {
158    const repo = new GitRepo(fetch, name);
159    await repo.prefetch([oid], { depth: 2 });
160    await repo.prefetch([oid], { smart: "tree" });
161    const loaded = await repo.commit(oid);
162    await repo.tree(loaded.tree);
163
164    const warm = new GitRepo(fetch, name);
165    await warm.prefetch([oid], { smart: "tree" });
166    await warm.tree((await warm.commit(oid)).tree);
167    assertEquals(queries, [
168      { oids: [oid], depth: 2 },
169      { oids: [oid], smart: "tree" },
170    ]);
171    assertEquals(rawReads, 0);
172  } finally {
173    await caches.delete(name);
174  }
175});
176
177Deno.test("commit logs prefetch one page at a time", async () => {
178  const { dir } = await makeFixture();
179  for (let i = 3; i <= 12; i++) {
180    await Deno.writeTextFile(`${dir}/n`, `${i}\n`);
181    await sh(dir, "git", "add", "n");
182    await sh(dir, "git", "commit", "-q", "-m", `commit ${i}`);
183  }
184  const oids = (await sh(dir, "git", "rev-list", "HEAD")).split("\n");
185  const commits = new Map<string, Uint8Array>();
186  for (const oid of oids) {
187    const result = await new Deno.Command("git", { args: ["cat-file", "commit", oid], cwd: dir }).output();
188    assert(result.success);
189    commits.set(oid, result.stdout);
190  }
191
192  const queries: unknown[] = [];
193  let rawReads = 0;
194  const files = fileFetcher(`${dir}/.git`);
195  const fetch: Fetcher = (path, range, query) => {
196    if (path === "obj" && query) {
197      const body = JSON.parse(new TextDecoder().decode(query)) as {
198        oids: string[];
199        smart: string;
200        limit: number;
201      };
202      queries.push(body);
203      const start = oids.indexOf(body.oids[0]);
204      const specs = oids.slice(start, start + body.limit).map(oid => [oid, 1, commits.get(oid)!] as [string, number, Uint8Array]);
205      const bundle = objectBundle(specs);
206      return Promise.resolve({ bytes: bundle, total: bundle.length });
207    }
208    if (path.startsWith(".git/objects/")) rawReads++;
209    return files(path, range);
210  };
211  const name = `sorcery-test-${crypto.randomUUID()}`;
212  try {
213    const loaded = [];
214    for await (const commit of log(new GitRepo(fetch, name), oids, 10)) loaded.push(commit.oid);
215    assertEquals(loaded, oids);
216    assertEquals(queries, [
217      { oids: oids.slice(0, 10), smart: "commit-pagination", limit: 10 },
218      { oids: oids.slice(10), smart: "commit-pagination", limit: 10 },
219    ]);
220    assertEquals(rawReads, 0);
221  } finally {
222    await caches.delete(name);
223  }
224});
225
226Deno.test("packed objects (with deltas)", async () => {
227  const { dir } = await makeFixture();
228  // window/depth defaults keep our similar blobs as deltas; prune loose ones
229  await sh(dir, "git", "-c", "gc.pruneExpire=now", "gc", "-q", "--aggressive", "--prune=now");
230  const repo = new GitRepo(fileFetcher(`${dir}/.git`));
231  const info = await repo.info();
232  assert(info.packs.length > 0, "expected a pack after gc");
233  await assertHistory(repo);
234});
235
236Deno.test("SHA-256 loose and packed objects", async () => {
237  const { dir, repo } = await makeFixture("sha256");
238  const info = await repo.info();
239  assert(info.refs.every(ref => ref.oid.length === 64));
240  await assertHistory(repo);
241
242  await sh(dir, "git", "-c", "gc.pruneExpire=now", "gc", "-q", "--aggressive", "--prune=now");
243  const packed = new GitRepo(fileFetcher(`${dir}/.git`));
244  const packs = (await packed.info()).packs;
245  assert(packs.length > 0 && packs.every(stem => stem.length === 69));
246  await assertHistory(packed);
247});
248
249Deno.test("pack ranges persist in the cache; stale packs are pruned", async () => {
250  const { dir } = await makeFixture();
251  await sh(dir, "git", "-c", "gc.pruneExpire=now", "gc", "-q", "--aggressive", "--prune=now");
252  const files = fileFetcher(`${dir}/.git`);
253  let packFetches = 0;
254  const counting: Fetcher = (path, range) => {
255    if (path.includes("/pack/")) packFetches++;
256    return files(path, range);
257  };
258  const name = `sorcery-test-${crypto.randomUUID()}`;
259  try {
260    const stale = "https://sorcery-pack-cache.invalid/x/pack-dead.idx?range=all";
261    await (await caches.open(name)).put(stale, new Response("junk"));
262
263    await assertHistory(new GitRepo(counting, name));
264    assert(packFetches > 0, "expected cold-cache pack fetches");
265
266    packFetches = 0;
267    await assertHistory(new GitRepo(counting, name));
268    assertEquals(packFetches, 0, "warm cache should serve all pack ranges");
269
270    // pruning is fire-and-forget off the gitinfo fetch, so poll briefly
271    const cache = await caches.open(name);
272    for (let i = 0; i < 20 && (await cache.match(stale)); i++) {
273      await new Promise(resolve => setTimeout(resolve, 50));
274    }
275    assertEquals(await cache.match(stale), undefined, "stale pack entry should be pruned");
276  } finally {
277    await caches.delete(name);
278  }
279});
280
281Deno.test("failed fetches are retried, not cached", async () => {
282  const { dir } = await makeFixture();
283  await sh(dir, "git", "-c", "gc.pruneExpire=now", "gc", "-q", "--aggressive", "--prune=now");
284  const files = fileFetcher(`${dir}/.git`);
285  let failures = 1;
286  const fetch: Fetcher = (path, range) => {
287    if (path.endsWith(".pack") && failures-- > 0) return Promise.reject(new Error("aborted"));
288    return files(path, range);
289  };
290  const repo = new GitRepo(fetch);
291  const info = await repo.info();
292  const main = info.refs.find(r => r.name === "main")!;
293  await assertRejects(() => repo.commit(main.oid), Error, "aborted");
294  assertEquals((await repo.commit(main.oid)).message.trim(), "second commit");
295});
296
297Deno.test("myers diff edge cases", () => {
298  assertEquals(diffLines("", ""), []);
299  assertEquals(diffLines("a\n", "a\n"), []);
300  const addOnly = diffLines("", "a\nb\n");
301  assertEquals(addOnly[0].lines.map(l => l.sign + l.text), ["+a", "+b"]);
302  const delOnly = diffLines("a\nb\n", "");
303  assertEquals(delOnly[0].lines.map(l => l.sign + l.text), ["-a", "-b"]);
304  // no trailing newline handling
305  const noEol = diffLines("a", "b");
306  assertEquals(noEol[0].lines.map(l => l.sign + l.text), ["-a", "+b"]);
307});