import { describe, expect, it, vi } from "vitest"; import { coAuthorsFrom, logFor, repoNameOf, resolveRepoByName, sortTreeEntries, toBranchSummary, toCommitDetail, toCommitSummary, toTagSummary, toTreeEntrySummary, treeEntryKind, type BranchEntry, type LogCommit, type TagEntry, type TreeEntrySummary } from "./repo"; import { ClientResponseError, createBobbinClient, type BobbinContext } from "./client"; import type { NiceCommit } from "./diff"; import type { RecordView, RepoRecord } from "./records"; const jsonResponse = (body: unknown): Response => new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); const makeCtx = (fetchMock: typeof globalThis.fetch): BobbinContext => createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); describe("repoNameOf", () => { const view = (uri: string, name?: string): RecordView => ({ uri: uri as RecordView["uri"], value: { $type: "sh.tangled.repo", createdAt: "", knot: "knot.test", name } }); it("prefers the record's cosmetic name over a tid rkey", () => { expect(repoNameOf(view("at://did:plc:o/sh.tangled.repo/3lzg6enurmo22", "infra"))).toBe("infra"); }); it("falls back to the rkey for unnamed records", () => { expect(repoNameOf(view("at://did:plc:o/sh.tangled.repo/infra"))).toBe("infra"); }); }); describe("treeEntryKind", () => { it("maps git file modes to entry kinds", () => { expect(treeEntryKind("0040000")).toBe("directory"); expect(treeEntryKind("040000")).toBe("directory"); expect(treeEntryKind("0100644")).toBe("file"); expect(treeEntryKind("0100755")).toBe("file"); expect(treeEntryKind("0120000")).toBe("symlink"); expect(treeEntryKind("0160000")).toBe("submodule"); }); }); describe("sortTreeEntries", () => { it("puts directories and submodules before files, then sorts by name", () => { const entry = (name: string, kind: TreeEntrySummary["kind"]): TreeEntrySummary => ({ name, kind, size: 0 }); const sorted = sortTreeEntries([ entry("readme.md", "file"), entry("src", "directory"), entry(".gitignore", "file"), entry("vendor", "submodule") ]); expect(sorted.map((item) => item.name)).toEqual(["src", "vendor", ".gitignore", "readme.md"]); }); }); describe("toCommitSummary", () => { // `this` is where the hex hash lives, `hash` is a byte array on the wire const commit: LogCommit = { this: "0c4d0e9b07940033721395a434b5873f0fb9e6c8", author: { Name: "Ada", Email: "ada@example.com", When: "2026-07-01T10:00:00Z" }, committer: { Name: "Ada", Email: "ada@example.com", When: "2026-07-02T10:00:00Z" }, message: "web: add repo index\n\nWith a longer body.\n", change_id: "abc123" }; it("splits the subject from the body and shortens the hash", () => { const summary = toCommitSummary(commit); expect(summary).toMatchObject({ hash: "0c4d0e9b07940033721395a434b5873f0fb9e6c8", shortHash: "0c4d0e9b", subject: "web: add repo index", body: "With a longer body.", authorName: "Ada", when: "2026-07-02T10:00:00Z", changeId: "abc123" }); }); it("leaves the body empty for single-line messages", () => { const summary = toCommitSummary({ ...commit, message: "one liner\n" }); expect(summary.body).toBe(""); expect(summary.subject).toBe("one liner"); }); }); describe("toCommitDetail", () => { const commit: NiceCommit = { this: "0c4d0e9b07940033721395a434b5873f0fb9e6c8", author: { Name: "Ada", Email: "ada@example.com", When: "2026-07-01T10:00:00Z" }, committer: { Name: "Grace", Email: "grace@example.com", When: "2026-07-02T10:00:00Z" }, message: "web: add commit page\n\nA longer body.\n\nCo-authored-by: Alan Turing \n" }; it("carries both signatures and stamps co-authors with the committer's When", () => { expect(toCommitDetail(commit)).toEqual({ hash: "0c4d0e9b07940033721395a434b5873f0fb9e6c8", shortHash: "0c4d0e9b", subject: "web: add commit page", body: "A longer body.\n\nCo-authored-by: Alan Turing ", authorName: "Ada", authorEmail: "ada@example.com", authorWhen: "2026-07-01T10:00:00Z", committerName: "Grace", committerEmail: "grace@example.com", committerWhen: "2026-07-02T10:00:00Z", coAuthors: [{ name: "Alan Turing", email: "alan@example.com" }] }); }); it("fills empty strings for a bare commit", () => { expect(toCommitDetail({})).toEqual({ hash: "", shortHash: "", subject: "", body: "", authorName: "", authorEmail: "", authorWhen: "", committerName: "", committerEmail: "", committerWhen: "", coAuthors: [] }); }); }); describe("coAuthorsFrom", () => { it("stamps every co-author with the passed When, like go's Commit.CoAuthors", () => { const message = "subject\n\nCo-authored-by: Ada Lovelace \nCo-authored-by: Alan Turing \n"; expect(coAuthorsFrom(message, "2026-07-02T10:00:00Z")).toEqual([ { Name: "Ada Lovelace", Email: "ada@example.com", When: "2026-07-02T10:00:00Z" }, { Name: "Alan Turing", Email: "alan@example.com", When: "2026-07-02T10:00:00Z" } ]); }); it("dedupes by email and matches the trailer case-insensitively", () => { const message = "subject\n\nco-authored-by: Ada \nCo-Authored-By: Ada Again \n"; expect(coAuthorsFrom(message, "w")).toEqual([ { Name: "Ada", Email: "ada@example.com", When: "w" } ]); }); }); describe("logFor", () => { it("passes the cursor through to the knot", async () => { const fetchMock = vi .fn() .mockResolvedValue(jsonResponse({ commits: [], total: 42 })); await logFor(makeCtx(fetchMock), "at://did:plc:o/sh.tangled.repo/abc", "master", 20, "40"); const url = new URL(String(fetchMock.mock.calls[0][0])); expect(url.pathname).toBe("/xrpc/sh.tangled.repo.log"); expect(url.searchParams.get("ref")).toBe("master"); expect(url.searchParams.get("limit")).toBe("20"); expect(url.searchParams.get("cursor")).toBe("40"); }); }); describe("toBranchSummary", () => { it("reads the nested reference and the go-git commit fields", () => { const branch: BranchEntry = { reference: { name: "master", hash: "ff3a3678" }, commit: { Committer: { Name: "Ada", Email: "a@b.c", When: "2026-07-02T10:00:00Z" }, Message: "the tip commit" }, is_default: true }; expect(toBranchSummary(branch)).toEqual({ name: "master", hash: "ff3a3678", when: "2026-07-02T10:00:00Z", isDefault: true, message: "the tip commit" }); }); it("treats a missing is_default as not default", () => { expect(toBranchSummary({ reference: { name: "topic", hash: "abc" } }).isDefault).toBe(false); }); }); describe("toTagSummary", () => { // an annotated tag's own hash is the tag object, the commit is in Target it("reads the inlined reference and follows an annotated tag to its commit", () => { const tag: TagEntry = { name: "v1.0.0", hash: "63fa1d4b", message: "release", tag: { Tagger: { Name: "Ada", Email: "a@b.c", When: "2026-07-01T10:00:00Z" }, Target: [75, 78, 254, 37] } }; expect(toTagSummary(tag)).toEqual({ name: "v1.0.0", hash: "63fa1d4b", commitHash: "4b4efe25", when: "2026-07-01T10:00:00Z", message: "release", taggerName: "Ada" }); }); it("a lightweight tag is its own commit", () => { const summary = toTagSummary({ name: "v1.0.0", hash: "eebb477b" }); expect(summary.commitHash).toBe("eebb477b"); }); }); describe("toTreeEntrySummary", () => { it("carries the last commit over from snake_case", () => { expect( toTreeEntrySummary({ name: "flake.nix", mode: "0100644", size: 213, last_commit: { hash: "f0b11b85", message: "init", when: "2026-07-01T10:00:00Z" } }) ).toEqual({ name: "flake.nix", kind: "file", size: 213, lastCommitHash: "f0b11b85", lastCommitWhen: "2026-07-01T10:00:00Z", lastCommitMessage: "init" }); }); }); describe("resolveRepoByName", () => { const owner = "did:plc:owner"; it("asks bobbin for the owner and name", async () => { const fetchMock = vi.fn().mockResolvedValue( jsonResponse({ uri: `at://${owner}/sh.tangled.repo/3lzg6enurmo22`, value: { $type: "sh.tangled.repo", knot: "knot.test", name: "infra" } }) ); const view = await resolveRepoByName(makeCtx(fetchMock), owner, "infra"); expect(view?.uri).toBe(`at://${owner}/sh.tangled.repo/3lzg6enurmo22`); const url = new URL(String(fetchMock.mock.calls[0][0])); expect(url.pathname).toBe("/xrpc/sh.tangled.repo.getRepoByName"); expect(url.searchParams.get("owner")).toBe(owner); expect(url.searchParams.get("name")).toBe("infra"); }); it("returns null when the owner has no such repo", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ error: "RecordNotFound" }), { status: 404, headers: { "content-type": "application/json" } }) ); expect(await resolveRepoByName(makeCtx(fetchMock), owner, "missing")).toBeNull(); }); it("propagates anything that is not a miss", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ error: "UpstreamFailed" }), { status: 502, headers: { "content-type": "application/json" } }) ); await expect(resolveRepoByName(makeCtx(fetchMock), owner, "infra")).rejects.toBeInstanceOf( ClientResponseError ); }); });