import { describe, expect, it, vi } from "vitest";
import {
coAuthorsFrom,
importState,
refNamesByCommitHash,
resolveForkRepoLabels,
resolveRepoByName,
resolveRepoByRepoDid,
resolveRepoKnot,
sortTreeEntries,
toBranchSummary,
toTagSummary,
toTreeEntrySummary,
treeEntryKind,
type TreeEntrySummary
} from "#lib/api/repo.ts";
import { ClientResponseError, createBobbinClient, type BobbinContext } from "#lib/api/client.ts";
import { didOf } from "#lib/api/syntax.ts";
import type { Fetch } from "#lib/fetch.ts";
const jsonResponse = (body: unknown): Response =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" }
});
const makeCtx = (fetchMock: Fetch): BobbinContext =>
createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock });
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("coAuthorsFrom", () => {
it("stamps every co-author with the passed When, like go's Commit.CoAuthors", () => {
const message =
"subject\n\nCo-authored-by: Ada Lovelace <ada@example.com>\nCo-authored-by: Alan Turing <alan@example.com>\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 <ada@example.com>\nCo-Authored-By: Ada Again <ada@example.com>\n";
expect(coAuthorsFrom(message, "w")).toEqual([
{ Name: "Ada", Email: "ada@example.com", When: "w" }
]);
});
});
describe("importState", () => {
const repo = { knot: "knot.nel.pet", repoDid: "did:plc:repo" };
const statusFetch = (body: Record<string, string>) =>
vi.fn<Fetch>().mockResolvedValue(jsonResponse(body));
it("asks the repo's knot directly, not bobbin", async () => {
const fetchMock = statusFetch({});
await importState(repo, { fetch: fetchMock });
const url = new URL(String(fetchMock.mock.calls[0][0]));
expect(url.origin).toBe("https://knot.nel.pet");
expect(url.pathname).toBe("/xrpc/org.tangled.repo.getImportStatus");
expect(url.searchParams.get("repo")).toBe(repo.repoDid);
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers);
expect(headers.has("authorization")).toBe(false);
});
it("keeps in-flight states and drops the knot's reason", async () => {
const state = (status: string) =>
importState(repo, { fetch: statusFetch({ status, reason: "clone died" }) });
expect(await state("fetching")).toEqual({ content: "fetching" });
expect(await state("partial")).toEqual({ content: "partial" });
expect(await state("failed")).toEqual({ content: "failed" });
});
it("reports nothing for a finished import, no import, or a record with no repoDid", async () => {
expect(await importState(repo, { fetch: statusFetch({ status: "present" }) })).toBeNull();
expect(await importState(repo, { fetch: statusFetch({}) })).toBeNull();
const fetchMock = statusFetch({ status: "failed" });
expect(await importState({ knot: repo.knot }, { fetch: fetchMock })).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it("rejects on a knot error so the caller can drop the banner", async () => {
const fetchMock = vi
.fn<Fetch>()
.mockResolvedValue(
new Response(JSON.stringify({ error: "RepoNotFound" }), { status: 404 })
);
await expect(importState(repo, { fetch: fetchMock })).rejects.toBeInstanceOf(
ClientResponseError
);
});
});
describe("toBranchSummary", () => {
it("reads the tip commit and marks the default branch", () => {
const branch = {
name: "master",
oid: "ff3a3678",
message: "the tip commit",
author: { name: "Ada", email: "a@b.c", when: "2026-07-01T10:00:00Z" as const },
committer: { name: "Ada", email: "a@b.c", when: "2026-07-02T10:00:00Z" as const }
};
expect(toBranchSummary(branch, "master")).toEqual({
name: "master",
hash: "ff3a3678",
when: "2026-07-02T10:00:00Z",
isDefault: true,
message: "the tip commit"
});
});
it("treats a missing defaultBranch as not default", () => {
const branch = {
name: "topic",
oid: "abc",
message: "",
author: { name: "Ada", email: "a@b.c", when: "2026-07-02T10:00:00Z" as const },
committer: { name: "Ada", email: "a@b.c", when: "2026-07-02T10:00:00Z" as const }
};
expect(toBranchSummary(branch).isDefault).toBe(false);
});
});
describe("toTagSummary", () => {
const author = { name: "Ada", email: "a@b.c", when: "2026-06-30T10:00:00Z" as const };
it("reads the tagger and message from the annotation", () => {
expect(
toTagSummary({
name: "v1.0.0",
oid: "4b4efe25",
message: "tip commit",
author,
annotation: {
message: "release",
tagger: { name: "Ada", email: "a@b.c", when: "2026-07-01T10:00:00Z" }
}
})
).toEqual({
name: "v1.0.0",
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", oid: "eebb477b", message: "", author });
expect(summary.commitHash).toBe("eebb477b");
});
});
describe("refNamesByCommitHash", () => {
it("lists tags then branches pointing at each shown commit, once per name", () => {
const tags = [
{ name: "v1.0.0", commitHash: "aaa" },
{ name: "gone", commitHash: "zzz" }
];
const branches = [
{ name: "main", hash: "aaa", isDefault: true },
{ name: "v1.0.0", hash: "aaa", isDefault: false },
{ name: "feat", hash: "bbb", isDefault: false }
];
expect(refNamesByCommitHash([{ hash: "aaa" }, { hash: "bbb" }], tags, branches)).toEqual({
aaa: ["v1.0.0", "main"],
bbb: ["feat"]
});
});
});
describe("toTreeEntrySummary", () => {
it("carries the last commit over", () => {
expect(
toTreeEntrySummary({
name: "flake.nix",
mode: "0100644",
size: 213,
lastCommit: { 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<Fetch>().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/org.tangled.temp.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<Fetch>().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<Fetch>().mockResolvedValue(
new Response(JSON.stringify({ error: "UpstreamFailed" }), {
status: 502,
headers: { "content-type": "application/json" }
})
);
await expect(resolveRepoByName(makeCtx(fetchMock), owner, "infra")).rejects.toBeInstanceOf(
ClientResponseError
);
});
});
describe("resolveForkRepoLabels", () => {
const enrichReply = jsonResponse({
output: {
items: [
{
uri: "at://did:plc:forkrepo/org.tangled.repo.manifest/self",
value: {
$type: "org.tangled.repo.manifest",
declaration: { owner: "did:plc:forkowner", name: "core" }
}
}
]
},
data: {
"did:plc:forkowner": {
"org.tangled.repo.manifest:.repo": {
"blue.microcosm.identity.resolveMiniDoc": { handle: "fork.example" }
}
}
}
});
it("labels each fork did as owner/name from the enrich sidecar", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(enrichReply);
const labels = await resolveForkRepoLabels(makeCtx(fetchMock), ["did:plc:forkrepo"]);
expect(labels).toEqual({ "did:plc:forkrepo": "fork.example/core" });
const [url, request] = fetchMock.mock.calls[0];
expect(String(url)).toContain("/xrpc/org.tangled.query.enrichResponse");
expect(JSON.parse(String(request?.body))).toMatchObject({
xrpc: "org.tangled.repo.getReposByRepoDids",
params: { dids: ["did:plc:forkrepo"] }
});
});
it("skips dids bobbin has no repo for", async () => {
const fetchMock = vi
.fn<Fetch>()
.mockResolvedValue(jsonResponse({ output: { items: [] }, data: {} }));
expect(await resolveForkRepoLabels(makeCtx(fetchMock), ["did:plc:gone"])).toEqual({});
});
it("does not ask when there is nothing to label", async () => {
const fetchMock = vi.fn<Fetch>();
expect(await resolveForkRepoLabels(makeCtx(fetchMock), [])).toEqual({});
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe("resolveRepoByRepoDid", () => {
const enrichReply = jsonResponse({
output: {
uri: "at://did:plc:j5hmlfdrwkvtxm7cjmu7j2is/org.tangled.repo.manifest/self",
value: {
$type: "org.tangled.repo.manifest",
declaration: { owner: "did:plc:owner", name: "core" }
}
},
data: {
"did:plc:owner": {
"org.tangled.repo.manifest:.repo": {
"blue.microcosm.identity.resolveMiniDoc": {
did: "did:plc:owner",
handle: "tangled.org"
}
}
}
}
});
it("resolves owner handle and repo name from repo did", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(enrichReply);
const result = await resolveRepoByRepoDid(
makeCtx(fetchMock),
"did:plc:j5hmlfdrwkvtxm7cjmu7j2is"
);
expect(result).toEqual({ owner: "tangled.org", name: "core" });
const [url, request] = fetchMock.mock.calls[0];
expect(String(url)).toContain("/xrpc/org.tangled.query.enrichResponse");
expect(JSON.parse(String(request?.body))).toMatchObject({
xrpc: "org.tangled.repo.getRepoByRepoDid",
params: { repoDid: "did:plc:j5hmlfdrwkvtxm7cjmu7j2is" }
});
});
it("falls back to owner did when handle is missing or .invalid", async () => {
const enrichReplyNoHandle = jsonResponse({
output: {
uri: "at://did:plc:j5hmlfdrwkvtxm7cjmu7j2is/org.tangled.repo.manifest/self",
value: {
$type: "org.tangled.repo.manifest",
declaration: { owner: "did:plc:owner", name: "core" }
}
},
data: {
"did:plc:owner": {
"org.tangled.repo.manifest:.repo": {
"blue.microcosm.identity.resolveMiniDoc": {
did: "did:plc:owner",
handle: "handle.invalid"
}
}
}
}
});
const fetchMock = vi.fn<Fetch>().mockResolvedValue(enrichReplyNoHandle);
const result = await resolveRepoByRepoDid(
makeCtx(fetchMock),
"did:plc:j5hmlfdrwkvtxm7cjmu7j2is"
);
expect(result).toEqual({ owner: "did:plc:owner", name: "core" });
});
it("returns null when bobbin has no such repo", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(
new Response(JSON.stringify({ error: "RecordNotFound" }), {
status: 404,
headers: { "content-type": "application/json" }
})
);
expect(await resolveRepoByRepoDid(makeCtx(fetchMock), "did:plc:missing")).toBeNull();
});
});
describe("resolveRepoKnot", () => {
it("takes the knot bobbin puts on the repo view", async () => {
const fetchMock = vi.fn(async () =>
jsonResponse({
uri: "at://did:plc:kelp/org.tangled.repo.manifest/self",
value: {},
knot: "knot.test"
})
);
await expect(resolveRepoKnot(makeCtx(fetchMock), didOf("did:plc:kelp"))).resolves.toBe(
"knot.test"
);
});
it("leaves a repo bobbin can't find without a knot", async () => {
const fetchMock = vi.fn(async () => new Response(null, { status: 404 }));
await expect(
resolveRepoKnot(makeCtx(fetchMock), didOf("did:plc:urchin"))
).resolves.toBeUndefined();
});
});