This repository has no description
1import { describe, expect, it, vi } from "vitest";
2import {
3 coAuthorsFrom,
4 logFor,
5 repoNameOf,
6 resolveRepoByName,
7 sortTreeEntries,
8 toBranchSummary,
9 toCommitDetail,
10 toCommitSummary,
11 toTagSummary,
12 toTreeEntrySummary,
13 treeEntryKind,
14 type BranchEntry,
15 type LogCommit,
16 type TagEntry,
17 type TreeEntrySummary
18} from "./repo";
19import { ClientResponseError, createBobbinClient, type BobbinContext } from "./client";
20import type { NiceCommit } from "./diff";
21import type { RecordView, RepoRecord } from "./records";
22
23const jsonResponse = (body: unknown): Response =>
24 new Response(JSON.stringify(body), {
25 status: 200,
26 headers: { "content-type": "application/json" }
27 });
28
29const makeCtx = (fetchMock: typeof globalThis.fetch): BobbinContext =>
30 createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock });
31
32describe("repoNameOf", () => {
33 const view = (uri: string, name?: string): RecordView<RepoRecord> => ({
34 uri: uri as RecordView<RepoRecord>["uri"],
35 value: { $type: "sh.tangled.repo", createdAt: "", knot: "knot.test", name }
36 });
37
38 it("prefers the record's cosmetic name over a tid rkey", () => {
39 expect(repoNameOf(view("at://did:plc:o/sh.tangled.repo/3lzg6enurmo22", "infra"))).toBe("infra");
40 });
41
42 it("falls back to the rkey for unnamed records", () => {
43 expect(repoNameOf(view("at://did:plc:o/sh.tangled.repo/infra"))).toBe("infra");
44 });
45});
46
47describe("treeEntryKind", () => {
48 it("maps git file modes to entry kinds", () => {
49 expect(treeEntryKind("0040000")).toBe("directory");
50 expect(treeEntryKind("040000")).toBe("directory");
51 expect(treeEntryKind("0100644")).toBe("file");
52 expect(treeEntryKind("0100755")).toBe("file");
53 expect(treeEntryKind("0120000")).toBe("symlink");
54 expect(treeEntryKind("0160000")).toBe("submodule");
55 });
56});
57
58describe("sortTreeEntries", () => {
59 it("puts directories and submodules before files, then sorts by name", () => {
60 const entry = (name: string, kind: TreeEntrySummary["kind"]): TreeEntrySummary => ({
61 name,
62 kind,
63 size: 0
64 });
65 const sorted = sortTreeEntries([
66 entry("readme.md", "file"),
67 entry("src", "directory"),
68 entry(".gitignore", "file"),
69 entry("vendor", "submodule")
70 ]);
71 expect(sorted.map((item) => item.name)).toEqual(["src", "vendor", ".gitignore", "readme.md"]);
72 });
73});
74
75describe("toCommitSummary", () => {
76 // `this` is where the hex hash lives, `hash` is a byte array on the wire
77 const commit: LogCommit = {
78 this: "0c4d0e9b07940033721395a434b5873f0fb9e6c8",
79 author: { Name: "Ada", Email: "ada@example.com", When: "2026-07-01T10:00:00Z" },
80 committer: { Name: "Ada", Email: "ada@example.com", When: "2026-07-02T10:00:00Z" },
81 message: "web: add repo index\n\nWith a longer body.\n",
82 change_id: "abc123"
83 };
84
85 it("splits the subject from the body and shortens the hash", () => {
86 const summary = toCommitSummary(commit);
87 expect(summary).toMatchObject({
88 hash: "0c4d0e9b07940033721395a434b5873f0fb9e6c8",
89 shortHash: "0c4d0e9b",
90 subject: "web: add repo index",
91 body: "With a longer body.",
92 authorName: "Ada",
93 when: "2026-07-02T10:00:00Z",
94 changeId: "abc123"
95 });
96 });
97
98 it("leaves the body empty for single-line messages", () => {
99 const summary = toCommitSummary({ ...commit, message: "one liner\n" });
100 expect(summary.body).toBe("");
101 expect(summary.subject).toBe("one liner");
102 });
103});
104
105describe("toCommitDetail", () => {
106 const commit: NiceCommit = {
107 this: "0c4d0e9b07940033721395a434b5873f0fb9e6c8",
108 author: { Name: "Ada", Email: "ada@example.com", When: "2026-07-01T10:00:00Z" },
109 committer: { Name: "Grace", Email: "grace@example.com", When: "2026-07-02T10:00:00Z" },
110 message:
111 "web: add commit page\n\nA longer body.\n\nCo-authored-by: Alan Turing <alan@example.com>\n"
112 };
113
114 it("carries both signatures and stamps co-authors with the committer's When", () => {
115 expect(toCommitDetail(commit)).toEqual({
116 hash: "0c4d0e9b07940033721395a434b5873f0fb9e6c8",
117 shortHash: "0c4d0e9b",
118 subject: "web: add commit page",
119 body: "A longer body.\n\nCo-authored-by: Alan Turing <alan@example.com>",
120 authorName: "Ada",
121 authorEmail: "ada@example.com",
122 authorWhen: "2026-07-01T10:00:00Z",
123 committerName: "Grace",
124 committerEmail: "grace@example.com",
125 committerWhen: "2026-07-02T10:00:00Z",
126 coAuthors: [{ name: "Alan Turing", email: "alan@example.com" }]
127 });
128 });
129
130 it("fills empty strings for a bare commit", () => {
131 expect(toCommitDetail({})).toEqual({
132 hash: "",
133 shortHash: "",
134 subject: "",
135 body: "",
136 authorName: "",
137 authorEmail: "",
138 authorWhen: "",
139 committerName: "",
140 committerEmail: "",
141 committerWhen: "",
142 coAuthors: []
143 });
144 });
145});
146
147describe("coAuthorsFrom", () => {
148 it("stamps every co-author with the passed When, like go's Commit.CoAuthors", () => {
149 const message =
150 "subject\n\nCo-authored-by: Ada Lovelace <ada@example.com>\nCo-authored-by: Alan Turing <alan@example.com>\n";
151 expect(coAuthorsFrom(message, "2026-07-02T10:00:00Z")).toEqual([
152 { Name: "Ada Lovelace", Email: "ada@example.com", When: "2026-07-02T10:00:00Z" },
153 { Name: "Alan Turing", Email: "alan@example.com", When: "2026-07-02T10:00:00Z" }
154 ]);
155 });
156
157 it("dedupes by email and matches the trailer case-insensitively", () => {
158 const message =
159 "subject\n\nco-authored-by: Ada <ada@example.com>\nCo-Authored-By: Ada Again <ada@example.com>\n";
160 expect(coAuthorsFrom(message, "w")).toEqual([
161 { Name: "Ada", Email: "ada@example.com", When: "w" }
162 ]);
163 });
164});
165
166describe("logFor", () => {
167 it("passes the cursor through to the knot", async () => {
168 const fetchMock = vi
169 .fn<typeof globalThis.fetch>()
170 .mockResolvedValue(jsonResponse({ commits: [], total: 42 }));
171 await logFor(makeCtx(fetchMock), "at://did:plc:o/sh.tangled.repo/abc", "master", 20, "40");
172 const url = new URL(String(fetchMock.mock.calls[0][0]));
173 expect(url.pathname).toBe("/xrpc/sh.tangled.repo.log");
174 expect(url.searchParams.get("ref")).toBe("master");
175 expect(url.searchParams.get("limit")).toBe("20");
176 expect(url.searchParams.get("cursor")).toBe("40");
177 });
178});
179
180describe("toBranchSummary", () => {
181 it("reads the nested reference and the go-git commit fields", () => {
182 const branch: BranchEntry = {
183 reference: { name: "master", hash: "ff3a3678" },
184 commit: {
185 Committer: { Name: "Ada", Email: "a@b.c", When: "2026-07-02T10:00:00Z" },
186 Message: "the tip commit"
187 },
188 is_default: true
189 };
190 expect(toBranchSummary(branch)).toEqual({
191 name: "master",
192 hash: "ff3a3678",
193 when: "2026-07-02T10:00:00Z",
194 isDefault: true,
195 message: "the tip commit"
196 });
197 });
198
199 it("treats a missing is_default as not default", () => {
200 expect(toBranchSummary({ reference: { name: "topic", hash: "abc" } }).isDefault).toBe(false);
201 });
202});
203
204describe("toTagSummary", () => {
205 // an annotated tag's own hash is the tag object, the commit is in Target
206 it("reads the inlined reference and follows an annotated tag to its commit", () => {
207 const tag: TagEntry = {
208 name: "v1.0.0",
209 hash: "63fa1d4b",
210 message: "release",
211 tag: {
212 Tagger: { Name: "Ada", Email: "a@b.c", When: "2026-07-01T10:00:00Z" },
213 Target: [75, 78, 254, 37]
214 }
215 };
216 expect(toTagSummary(tag)).toEqual({
217 name: "v1.0.0",
218 hash: "63fa1d4b",
219 commitHash: "4b4efe25",
220 when: "2026-07-01T10:00:00Z",
221 message: "release",
222 taggerName: "Ada"
223 });
224 });
225
226 it("a lightweight tag is its own commit", () => {
227 const summary = toTagSummary({ name: "v1.0.0", hash: "eebb477b" });
228 expect(summary.commitHash).toBe("eebb477b");
229 });
230});
231
232describe("toTreeEntrySummary", () => {
233 it("carries the last commit over from snake_case", () => {
234 expect(
235 toTreeEntrySummary({
236 name: "flake.nix",
237 mode: "0100644",
238 size: 213,
239 last_commit: { hash: "f0b11b85", message: "init", when: "2026-07-01T10:00:00Z" }
240 })
241 ).toEqual({
242 name: "flake.nix",
243 kind: "file",
244 size: 213,
245 lastCommitHash: "f0b11b85",
246 lastCommitWhen: "2026-07-01T10:00:00Z",
247 lastCommitMessage: "init"
248 });
249 });
250});
251
252describe("resolveRepoByName", () => {
253 const owner = "did:plc:owner";
254
255 it("asks bobbin for the owner and name", async () => {
256 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
257 jsonResponse({
258 uri: `at://${owner}/sh.tangled.repo/3lzg6enurmo22`,
259 value: { $type: "sh.tangled.repo", knot: "knot.test", name: "infra" }
260 })
261 );
262 const view = await resolveRepoByName(makeCtx(fetchMock), owner, "infra");
263 expect(view?.uri).toBe(`at://${owner}/sh.tangled.repo/3lzg6enurmo22`);
264 const url = new URL(String(fetchMock.mock.calls[0][0]));
265 expect(url.pathname).toBe("/xrpc/sh.tangled.repo.getRepoByName");
266 expect(url.searchParams.get("owner")).toBe(owner);
267 expect(url.searchParams.get("name")).toBe("infra");
268 });
269
270 it("returns null when the owner has no such repo", async () => {
271 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
272 new Response(JSON.stringify({ error: "RecordNotFound" }), {
273 status: 404,
274 headers: { "content-type": "application/json" }
275 })
276 );
277 expect(await resolveRepoByName(makeCtx(fetchMock), owner, "missing")).toBeNull();
278 });
279
280 it("propagates anything that is not a miss", async () => {
281 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
282 new Response(JSON.stringify({ error: "UpstreamFailed" }), {
283 status: 502,
284 headers: { "content-type": "application/json" }
285 })
286 );
287 await expect(resolveRepoByName(makeCtx(fetchMock), owner, "infra")).rejects.toBeInstanceOf(
288 ClientResponseError
289 );
290 });
291});