This repository has no description
1import { describe, expect, it, vi } from "vitest";
2import {
3 repoNameOf,
4 resolveRepoByName,
5 sortTreeEntries,
6 toBranchSummary,
7 toCommitSummary,
8 toTagSummary,
9 toTreeEntrySummary,
10 treeEntryKind,
11 type BranchEntry,
12 type LogCommit,
13 type TagEntry,
14 type TreeEntrySummary
15} from "./repo";
16import { ClientResponseError, createBobbinClient, type BobbinContext } from "./client";
17import type { RecordView, RepoRecord } from "./records";
18
19const jsonResponse = (body: unknown): Response =>
20 new Response(JSON.stringify(body), {
21 status: 200,
22 headers: { "content-type": "application/json" }
23 });
24
25const makeCtx = (fetchMock: typeof globalThis.fetch): BobbinContext =>
26 createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock });
27
28describe("repoNameOf", () => {
29 const view = (uri: string, name?: string): RecordView<RepoRecord> => ({
30 uri: uri as RecordView<RepoRecord>["uri"],
31 value: { $type: "sh.tangled.repo", createdAt: "", knot: "knot.test", name }
32 });
33
34 it("prefers the record's cosmetic name over a tid rkey", () => {
35 expect(repoNameOf(view("at://did:plc:o/sh.tangled.repo/3lzg6enurmo22", "infra"))).toBe("infra");
36 });
37
38 it("falls back to the rkey for unnamed records", () => {
39 expect(repoNameOf(view("at://did:plc:o/sh.tangled.repo/infra"))).toBe("infra");
40 });
41});
42
43describe("treeEntryKind", () => {
44 it("maps git file modes to entry kinds", () => {
45 expect(treeEntryKind("0040000")).toBe("directory");
46 expect(treeEntryKind("040000")).toBe("directory");
47 expect(treeEntryKind("0100644")).toBe("file");
48 expect(treeEntryKind("0100755")).toBe("file");
49 expect(treeEntryKind("0120000")).toBe("symlink");
50 expect(treeEntryKind("0160000")).toBe("submodule");
51 });
52});
53
54describe("sortTreeEntries", () => {
55 it("puts directories and submodules before files, then sorts by name", () => {
56 const entry = (name: string, kind: TreeEntrySummary["kind"]): TreeEntrySummary => ({
57 name,
58 kind,
59 size: 0
60 });
61 const sorted = sortTreeEntries([
62 entry("readme.md", "file"),
63 entry("src", "directory"),
64 entry(".gitignore", "file"),
65 entry("vendor", "submodule")
66 ]);
67 expect(sorted.map((item) => item.name)).toEqual(["src", "vendor", ".gitignore", "readme.md"]);
68 });
69});
70
71describe("toCommitSummary", () => {
72 // `this` is where the hex hash lives, `hash` is a byte array on the wire
73 const commit: LogCommit = {
74 this: "0c4d0e9b07940033721395a434b5873f0fb9e6c8",
75 author: { Name: "Ada", Email: "ada@example.com", When: "2026-07-01T10:00:00Z" },
76 committer: { Name: "Ada", Email: "ada@example.com", When: "2026-07-02T10:00:00Z" },
77 message: "web: add repo index\n\nWith a longer body.\n",
78 change_id: "abc123"
79 };
80
81 it("splits the subject from the body and shortens the hash", () => {
82 const summary = toCommitSummary(commit);
83 expect(summary).toMatchObject({
84 hash: "0c4d0e9b07940033721395a434b5873f0fb9e6c8",
85 shortHash: "0c4d0e9b",
86 subject: "web: add repo index",
87 body: "With a longer body.",
88 authorName: "Ada",
89 when: "2026-07-02T10:00:00Z",
90 changeId: "abc123"
91 });
92 });
93
94 it("leaves the body empty for single-line messages", () => {
95 const summary = toCommitSummary({ ...commit, message: "one liner\n" });
96 expect(summary.body).toBe("");
97 expect(summary.subject).toBe("one liner");
98 });
99});
100
101describe("toBranchSummary", () => {
102 it("reads the nested reference and the go-git commit fields", () => {
103 const branch: BranchEntry = {
104 reference: { name: "master", hash: "ff3a3678" },
105 commit: { Committer: { Name: "Ada", Email: "a@b.c", When: "2026-07-02T10:00:00Z" } },
106 is_default: true
107 };
108 expect(toBranchSummary(branch)).toEqual({
109 name: "master",
110 hash: "ff3a3678",
111 when: "2026-07-02T10:00:00Z",
112 isDefault: true
113 });
114 });
115
116 it("treats a missing is_default as not default", () => {
117 expect(toBranchSummary({ reference: { name: "topic", hash: "abc" } }).isDefault).toBe(false);
118 });
119});
120
121describe("toTagSummary", () => {
122 // an annotated tag's own hash is the tag object, the commit is in Target
123 it("reads the inlined reference and follows an annotated tag to its commit", () => {
124 const tag: TagEntry = {
125 name: "v1.0.0",
126 hash: "63fa1d4b",
127 message: "release",
128 tag: {
129 Tagger: { Name: "Ada", Email: "a@b.c", When: "2026-07-01T10:00:00Z" },
130 Target: [75, 78, 254, 37]
131 }
132 };
133 expect(toTagSummary(tag)).toEqual({
134 name: "v1.0.0",
135 hash: "63fa1d4b",
136 commitHash: "4b4efe25",
137 when: "2026-07-01T10:00:00Z",
138 message: "release"
139 });
140 });
141
142 it("a lightweight tag is its own commit", () => {
143 const summary = toTagSummary({ name: "v1.0.0", hash: "eebb477b" });
144 expect(summary.commitHash).toBe("eebb477b");
145 });
146});
147
148describe("toTreeEntrySummary", () => {
149 it("carries the last commit over from snake_case", () => {
150 expect(
151 toTreeEntrySummary({
152 name: "flake.nix",
153 mode: "0100644",
154 size: 213,
155 last_commit: { hash: "f0b11b85", message: "init", when: "2026-07-01T10:00:00Z" }
156 })
157 ).toEqual({
158 name: "flake.nix",
159 kind: "file",
160 size: 213,
161 lastCommitHash: "f0b11b85",
162 lastCommitWhen: "2026-07-01T10:00:00Z",
163 lastCommitMessage: "init"
164 });
165 });
166});
167
168describe("resolveRepoByName", () => {
169 const owner = "did:plc:owner";
170
171 it("asks bobbin for the owner and name", async () => {
172 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
173 jsonResponse({
174 uri: `at://${owner}/sh.tangled.repo/3lzg6enurmo22`,
175 value: { $type: "sh.tangled.repo", knot: "knot.test", name: "infra" }
176 })
177 );
178 const view = await resolveRepoByName(makeCtx(fetchMock), owner, "infra");
179 expect(view?.uri).toBe(`at://${owner}/sh.tangled.repo/3lzg6enurmo22`);
180 const url = new URL(String(fetchMock.mock.calls[0][0]));
181 expect(url.pathname).toBe("/xrpc/sh.tangled.repo.getRepoByName");
182 expect(url.searchParams.get("owner")).toBe(owner);
183 expect(url.searchParams.get("name")).toBe("infra");
184 });
185
186 it("returns null when the owner has no such repo", async () => {
187 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
188 new Response(JSON.stringify({ error: "RecordNotFound" }), {
189 status: 404,
190 headers: { "content-type": "application/json" }
191 })
192 );
193 expect(await resolveRepoByName(makeCtx(fetchMock), owner, "missing")).toBeNull();
194 });
195
196 it("propagates anything that is not a miss", async () => {
197 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
198 new Response(JSON.stringify({ error: "UpstreamFailed" }), {
199 status: 502,
200 headers: { "content-type": "application/json" }
201 })
202 );
203 await expect(resolveRepoByName(makeCtx(fetchMock), owner, "infra")).rejects.toBeInstanceOf(
204 ClientResponseError
205 );
206 });
207});