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: { Committer: { Name: "Ada", Email: "a@b.c", When: "2026-07-02T10:00:00Z" } },
185 is_default: true
186 };
187 expect(toBranchSummary(branch)).toEqual({
188 name: "master",
189 hash: "ff3a3678",
190 when: "2026-07-02T10:00:00Z",
191 isDefault: true
192 });
193 });
194
195 it("treats a missing is_default as not default", () => {
196 expect(toBranchSummary({ reference: { name: "topic", hash: "abc" } }).isDefault).toBe(false);
197 });
198});
199
200describe("toTagSummary", () => {
201 // an annotated tag's own hash is the tag object, the commit is in Target
202 it("reads the inlined reference and follows an annotated tag to its commit", () => {
203 const tag: TagEntry = {
204 name: "v1.0.0",
205 hash: "63fa1d4b",
206 message: "release",
207 tag: {
208 Tagger: { Name: "Ada", Email: "a@b.c", When: "2026-07-01T10:00:00Z" },
209 Target: [75, 78, 254, 37]
210 }
211 };
212 expect(toTagSummary(tag)).toEqual({
213 name: "v1.0.0",
214 hash: "63fa1d4b",
215 commitHash: "4b4efe25",
216 when: "2026-07-01T10:00:00Z",
217 message: "release"
218 });
219 });
220
221 it("a lightweight tag is its own commit", () => {
222 const summary = toTagSummary({ name: "v1.0.0", hash: "eebb477b" });
223 expect(summary.commitHash).toBe("eebb477b");
224 });
225});
226
227describe("toTreeEntrySummary", () => {
228 it("carries the last commit over from snake_case", () => {
229 expect(
230 toTreeEntrySummary({
231 name: "flake.nix",
232 mode: "0100644",
233 size: 213,
234 last_commit: { hash: "f0b11b85", message: "init", when: "2026-07-01T10:00:00Z" }
235 })
236 ).toEqual({
237 name: "flake.nix",
238 kind: "file",
239 size: 213,
240 lastCommitHash: "f0b11b85",
241 lastCommitWhen: "2026-07-01T10:00:00Z",
242 lastCommitMessage: "init"
243 });
244 });
245});
246
247describe("resolveRepoByName", () => {
248 const owner = "did:plc:owner";
249
250 it("asks bobbin for the owner and name", async () => {
251 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
252 jsonResponse({
253 uri: `at://${owner}/sh.tangled.repo/3lzg6enurmo22`,
254 value: { $type: "sh.tangled.repo", knot: "knot.test", name: "infra" }
255 })
256 );
257 const view = await resolveRepoByName(makeCtx(fetchMock), owner, "infra");
258 expect(view?.uri).toBe(`at://${owner}/sh.tangled.repo/3lzg6enurmo22`);
259 const url = new URL(String(fetchMock.mock.calls[0][0]));
260 expect(url.pathname).toBe("/xrpc/sh.tangled.repo.getRepoByName");
261 expect(url.searchParams.get("owner")).toBe(owner);
262 expect(url.searchParams.get("name")).toBe("infra");
263 });
264
265 it("returns null when the owner has no such repo", async () => {
266 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
267 new Response(JSON.stringify({ error: "RecordNotFound" }), {
268 status: 404,
269 headers: { "content-type": "application/json" }
270 })
271 );
272 expect(await resolveRepoByName(makeCtx(fetchMock), owner, "missing")).toBeNull();
273 });
274
275 it("propagates anything that is not a miss", async () => {
276 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
277 new Response(JSON.stringify({ error: "UpstreamFailed" }), {
278 status: 502,
279 headers: { "content-type": "application/json" }
280 })
281 );
282 await expect(resolveRepoByName(makeCtx(fetchMock), owner, "infra")).rejects.toBeInstanceOf(
283 ClientResponseError
284 );
285 });
286});