forks/sorcery

Mirror of https://git.t4t.associates/char/sorcery

git clone https://git.gae.moe/forks/sorcery

Charlotte Somshow renames and stat overview in the commit diff view768100d

grace-patches
11.9 KiB308 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/library.rs`, "fn one() {}\nfn two() { todo!() }\nfn three() {}\nfn four() {}\n");
87  await Deno.remove(`${dir}/src/lib.rs`);
88  await Deno.writeTextFile(`${dir}/NOTES`, "# fixture\n");
89  await sh(dir, "git", "rm", "-q", "README.md");
90  await sh(dir, "git", "add", "-A");
91  await sh(dir, "git", "commit", "-q", "-m", "second commit");
92  return { dir, repo: new GitRepo(fileFetcher(`${dir}/.git`)) };
93}
94
95async function assertHistory(repo: GitRepo) {
96  const info = await repo.info();
97  assertEquals(info.head, "main");
98  const main = info.refs.find(r => r.name === "main")!;
99
100  const commits = [];
101  for await (const commit of log(repo, [main.oid], 10)) commits.push(commit);
102  assertEquals(commits.map(c => c.message.trim()), ["second commit", "initial commit"]);
103  assertEquals(commits[0].parents, [commits[1].oid]);
104  assertEquals(commits[0].author.name, "t");
105
106  const changes = await treeDiff(repo, commits[1].tree, commits[0].tree);
107  assertEquals(
108    changes.map(c => `${c.status} ${c.from ?? "-"} ${c.path}`),
109    ["renamed README.md NOTES", "renamed src/lib.rs src/library.rs"],
110  );
111
112  const modified = changes.find(c => c.path === "src/library.rs")!;
113  const oldData = await repo.blob(modified.oldOid!);
114  const newData = await repo.blob(modified.newOid!);
115  const oldText = new TextDecoder().decode(oldData);
116  const newText = new TextDecoder().decode(newData);
117  const hunks = diffLines(oldText, newText);
118  assertEquals(hunks.length, 1);
119  assertEquals(
120    hunks[0].lines.map(l => l.sign + l.text),
121    [
122      " fn one() {}",
123      "-fn two() {}",
124      "+fn two() { todo!() }",
125      " fn three() {}",
126      "+fn four() {}",
127    ],
128  );
129}
130
131Deno.test("loose objects", async () => {
132  const { repo } = await makeFixture();
133  await assertHistory(repo);
134});
135
136Deno.test("object queries seed the repository cache", async () => {
137  const { dir } = await makeFixture();
138  const oid = await sh(dir, "git", "rev-parse", "HEAD");
139  const treeOid = await sh(dir, "git", "show", "-s", "--format=%T", "HEAD");
140  const commit = await new Deno.Command("git", { args: ["cat-file", "commit", oid], cwd: dir }).output();
141  const tree = await new Deno.Command("git", { args: ["cat-file", "tree", treeOid], cwd: dir }).output();
142  assert(commit.success && tree.success);
143
144  const bundle = objectBundle([[oid, 1, commit.stdout], [treeOid, 2, tree.stdout]]);
145
146  let rawReads = 0;
147  const queries: unknown[] = [];
148  const files = fileFetcher(`${dir}/.git`);
149  const fetch: Fetcher = (path, range, query) => {
150    if (path === "obj" && query) {
151      queries.push(JSON.parse(new TextDecoder().decode(query)));
152      return Promise.resolve({ bytes: bundle, total: bundle.length });
153    }
154    if (path.startsWith("obj/") || path.startsWith(".git/objects/")) rawReads++;
155    return files(path, range);
156  };
157  const name = `sorcery-test-${crypto.randomUUID()}`;
158  try {
159    const repo = new GitRepo(fetch, name);
160    await repo.prefetch([oid], { depth: 2 });
161    await repo.prefetch([oid], { smart: "tree" });
162    const loaded = await repo.commit(oid);
163    await repo.tree(loaded.tree);
164
165    const warm = new GitRepo(fetch, name);
166    await warm.prefetch([oid], { smart: "tree" });
167    await warm.tree((await warm.commit(oid)).tree);
168    assertEquals(queries, [
169      { oids: [oid], depth: 2 },
170      { oids: [oid], smart: "tree" },
171    ]);
172    assertEquals(rawReads, 0);
173  } finally {
174    await caches.delete(name);
175  }
176});
177
178Deno.test("commit logs prefetch one page at a time", async () => {
179  const { dir } = await makeFixture();
180  for (let i = 3; i <= 12; i++) {
181    await Deno.writeTextFile(`${dir}/n`, `${i}\n`);
182    await sh(dir, "git", "add", "n");
183    await sh(dir, "git", "commit", "-q", "-m", `commit ${i}`);
184  }
185  const oids = (await sh(dir, "git", "rev-list", "HEAD")).split("\n");
186  const commits = new Map<string, Uint8Array>();
187  for (const oid of oids) {
188    const result = await new Deno.Command("git", { args: ["cat-file", "commit", oid], cwd: dir }).output();
189    assert(result.success);
190    commits.set(oid, result.stdout);
191  }
192
193  const queries: unknown[] = [];
194  let rawReads = 0;
195  const files = fileFetcher(`${dir}/.git`);
196  const fetch: Fetcher = (path, range, query) => {
197    if (path === "obj" && query) {
198      const body = JSON.parse(new TextDecoder().decode(query)) as {
199        oids: string[];
200        smart: string;
201        limit: number;
202      };
203      queries.push(body);
204      const start = oids.indexOf(body.oids[0]);
205      const specs = oids.slice(start, start + body.limit).map(oid => [oid, 1, commits.get(oid)!] as [string, number, Uint8Array]);
206      const bundle = objectBundle(specs);
207      return Promise.resolve({ bytes: bundle, total: bundle.length });
208    }
209    if (path.startsWith(".git/objects/")) rawReads++;
210    return files(path, range);
211  };
212  const name = `sorcery-test-${crypto.randomUUID()}`;
213  try {
214    const loaded = [];
215    for await (const commit of log(new GitRepo(fetch, name), oids, 10)) loaded.push(commit.oid);
216    assertEquals(loaded, oids);
217    assertEquals(queries, [
218      { oids: oids.slice(0, 10), smart: "commit-pagination", limit: 10 },
219      { oids: oids.slice(10), smart: "commit-pagination", limit: 10 },
220    ]);
221    assertEquals(rawReads, 0);
222  } finally {
223    await caches.delete(name);
224  }
225});
226
227Deno.test("packed objects (with deltas)", async () => {
228  const { dir } = await makeFixture();
229  // window/depth defaults keep our similar blobs as deltas; prune loose ones
230  await sh(dir, "git", "-c", "gc.pruneExpire=now", "gc", "-q", "--aggressive", "--prune=now");
231  const repo = new GitRepo(fileFetcher(`${dir}/.git`));
232  const info = await repo.info();
233  assert(info.packs.length > 0, "expected a pack after gc");
234  await assertHistory(repo);
235});
236
237Deno.test("SHA-256 loose and packed objects", async () => {
238  const { dir, repo } = await makeFixture("sha256");
239  const info = await repo.info();
240  assert(info.refs.every(ref => ref.oid.length === 64));
241  await assertHistory(repo);
242
243  await sh(dir, "git", "-c", "gc.pruneExpire=now", "gc", "-q", "--aggressive", "--prune=now");
244  const packed = new GitRepo(fileFetcher(`${dir}/.git`));
245  const packs = (await packed.info()).packs;
246  assert(packs.length > 0 && packs.every(stem => stem.length === 69));
247  await assertHistory(packed);
248});
249
250Deno.test("pack ranges persist in the cache; stale packs are pruned", async () => {
251  const { dir } = await makeFixture();
252  await sh(dir, "git", "-c", "gc.pruneExpire=now", "gc", "-q", "--aggressive", "--prune=now");
253  const files = fileFetcher(`${dir}/.git`);
254  let packFetches = 0;
255  const counting: Fetcher = (path, range) => {
256    if (path.includes("/pack/")) packFetches++;
257    return files(path, range);
258  };
259  const name = `sorcery-test-${crypto.randomUUID()}`;
260  try {
261    const stale = "https://sorcery-pack-cache.invalid/x/pack-dead.idx?range=all";
262    await (await caches.open(name)).put(stale, new Response("junk"));
263
264    await assertHistory(new GitRepo(counting, name));
265    assert(packFetches > 0, "expected cold-cache pack fetches");
266
267    packFetches = 0;
268    await assertHistory(new GitRepo(counting, name));
269    assertEquals(packFetches, 0, "warm cache should serve all pack ranges");
270
271    // pruning is fire-and-forget off the gitinfo fetch, so poll briefly
272    const cache = await caches.open(name);
273    for (let i = 0; i < 20 && (await cache.match(stale)); i++) {
274      await new Promise(resolve => setTimeout(resolve, 50));
275    }
276    assertEquals(await cache.match(stale), undefined, "stale pack entry should be pruned");
277  } finally {
278    await caches.delete(name);
279  }
280});
281
282Deno.test("failed fetches are retried, not cached", async () => {
283  const { dir } = await makeFixture();
284  await sh(dir, "git", "-c", "gc.pruneExpire=now", "gc", "-q", "--aggressive", "--prune=now");
285  const files = fileFetcher(`${dir}/.git`);
286  let failures = 1;
287  const fetch: Fetcher = (path, range) => {
288    if (path.endsWith(".pack") && failures-- > 0) return Promise.reject(new Error("aborted"));
289    return files(path, range);
290  };
291  const repo = new GitRepo(fetch);
292  const info = await repo.info();
293  const main = info.refs.find(r => r.name === "main")!;
294  await assertRejects(() => repo.commit(main.oid), Error, "aborted");
295  assertEquals((await repo.commit(main.oid)).message.trim(), "second commit");
296});
297
298Deno.test("myers diff edge cases", () => {
299  assertEquals(diffLines("", ""), []);
300  assertEquals(diffLines("a\n", "a\n"), []);
301  const addOnly = diffLines("", "a\nb\n");
302  assertEquals(addOnly[0].lines.map(l => l.sign + l.text), ["+a", "+b"]);
303  const delOnly = diffLines("a\nb\n", "");
304  assertEquals(delOnly[0].lines.map(l => l.sign + l.text), ["-a", "-b"]);
305  // no trailing newline handling
306  const noEol = diffLines("a", "b");
307  assertEquals(noEol[0].lines.map(l => l.sign + l.text), ["-a", "+b"]);
308});