diff --git a/e2e/harness/api.ts b/e2e/harness/api.ts index c28fd8fdf..f66db6142 100644 --- a/e2e/harness/api.ts +++ b/e2e/harness/api.ts @@ -124,7 +124,7 @@ export const importStatus = async (repo: string, who?: Account) => who ? { token: await tokenFor(who, repo, IMPORT_STATUS) } : {} ); -// gitmirror forwards the token to the knot's receive-pack, so it's minted for that +// knotmirror forwards the token to the knot's receive-pack, so it's minted for that export const mirrorMerge = async ( who: Account, repo: string, diff --git a/e2e/harness/config.ts b/e2e/harness/config.ts index 0c9f8940e..4ce326a6f 100644 --- a/e2e/harness/config.ts +++ b/e2e/harness/config.ts @@ -34,8 +34,8 @@ export const config = { hydrantUrl: process.env.E2E_HYDRANT_URL ?? host("hydrant"), bobbinUrl: process.env.E2E_BOBBIN_URL ?? host("bobbin"), spindleUrl: process.env.E2E_SPINDLE_URL ?? host("spindle"), - // the service is named mirror here, not gitmirror - gitmirrorUrl: process.env.E2E_KNOTMIRROR_URL ?? host("mirror"), + // the service is named mirror here, not knotmirror + knotmirrorUrl: process.env.E2E_KNOTMIRROR_URL ?? host("mirror"), sitesUrl: process.env.E2E_SITES_URL ?? host("sites"), // deliberi follows the bobbin url, so a run pointed at another bobbin gets its own deliberi deliberiUrl: diff --git a/e2e/harness/flow.ts b/e2e/harness/flow.ts index 875697dde..5fb7cc3ef 100644 --- a/e2e/harness/flow.ts +++ b/e2e/harness/flow.ts @@ -23,7 +23,7 @@ import { } from "./vocabulary"; export * from "./vocabulary"; -export { bobbin, deliberi, git, gitmirror, hydrant, knot, knotCopy, pds } from "./services"; +export { bobbin, deliberi, git, hydrant, knot, knotCopy, knotmirror, pds } from "./services"; export { note } from "./record"; interface Place { diff --git a/e2e/harness/services.ts b/e2e/harness/services.ts index 9eb74b3fa..4396e0af5 100644 --- a/e2e/harness/services.ts +++ b/e2e/harness/services.ts @@ -605,13 +605,13 @@ export const deliberi: Hop = { }; // the opener is the editor of the first version, read from the knot's git -export const gitmirror = { +export const knotmirror = { // the mirror lags the knot a moment, so this polls openerOf: (uri: string) => - until(`gitmirror should list who opened ${uri}`, 30_000, async () => { + until(`knotmirror should list who opened ${uri}`, 30_000, async () => { const { repo, collection, rkey } = partsOf(uri); const { status, body } = await get( - xrpc(config.gitmirrorUrl, "org.tangled.temp.repo.listRecordOrigins", { + xrpc(config.knotmirrorUrl, "org.tangled.temp.repo.listRecordOrigins", { repo, collection, limit: "1000" diff --git a/e2e/harness/shared.ts b/e2e/harness/shared.ts index ed6dc2db6..9d26aecc9 100644 --- a/e2e/harness/shared.ts +++ b/e2e/harness/shared.ts @@ -7,7 +7,7 @@ import { deleteBranch, lsRemote, pushAnnotatedTag } from "./git"; import { get, ok, selfUri, until, xrpc, type Reply, type Written } from "./net"; import { bobbin, - gitmirror, + knotmirror, hydrant, knot, openers, @@ -553,9 +553,9 @@ export const bobbinNamesOpener = }); }; -export const gitmirrorNamesOpener = +export const knotmirrorNamesOpener = (slot: Slot, opener: (ctx: Ctx) => Account) => async (ctx: Ctx) => - expect(await gitmirror.openerOf(ctx.get(slot).uri)).toBe(opener(ctx).did); + expect(await knotmirror.openerOf(ctx.get(slot).uri)).toBe(opener(ctx).did); export const theFan = (ctx: Ctx) => recall(ctx, "fan"); export const theOwner = (ctx: Ctx) => recall(ctx, "owner"); diff --git a/e2e/specs/issues.spec.ts b/e2e/specs/issues.spec.ts index 4f65570c3..4ea0b60f4 100644 --- a/e2e/specs/issues.spec.ts +++ b/e2e/specs/issues.spec.ts @@ -34,7 +34,7 @@ import { fanOpened, deletesComment, deletesIssue, - gitmirrorNamesOpener, + knotmirrorNamesOpener, issue, note, onBranches, @@ -401,11 +401,11 @@ fanOpened("an issue the owner edited still belongs to whoever opened it") .sees(within(testid("ticketTitle"), text(fixedTitle))) .sees(within(component("Ticket"), authorLink(theFan))); -fanOpened("gitmirror lists the fan as the opener of an issue the owner closed") +fanOpened("knotmirror lists the fan as the opener of an issue the owner closed") .step("the owner closes it through the api", async (ctx) => { await tickets.setState(ctx.actor, ctx.get(ticketId), "closed"); }) .learn(ticketId) .follow(ticketId, through(knot, hydrant, bobbin)) .knotStored(ticketId, "closed") - .step("gitmirror names the fan", gitmirrorNamesOpener(ticketId, theFan)); + .step("knotmirror names the fan", knotmirrorNamesOpener(ticketId, theFan)); diff --git a/web/src/fixtures/pull.ts b/web/src/fixtures/pull.ts index d008e6a3b..31f917a1b 100644 --- a/web/src/fixtures/pull.ts +++ b/web/src/fixtures/pull.ts @@ -1,6 +1,6 @@ import type { CommentView } from "#lib/components/comment/comments.ts"; import type { CommitSummary } from "#lib/api/repo.ts"; -import type { MirrorFileDiff } from "#lib/api/gitmirror.ts"; +import type { MirrorFileDiff } from "#lib/api/knotmirror.ts"; import type { Revspec } from "#lib/api/pullRoute.ts"; export const bobbinUrl = "https://bobbin.example.test"; @@ -356,7 +356,7 @@ export const mockPullFetch = (options: PullFetchOptions = {}): typeof fetch => case "sh.tangled.git.temp2.getDiff": case "sh.tangled.git.temp2.getInterdiff": { if (options.diff === "pending") return new Promise(() => {}); - if (options.diff === "error") return failed("gitmirror is unreachable"); + if (options.diff === "error") return failed("knotmirror is unreachable"); if (options.diff === "empty") return json({ diffs: [] }); // an interdiff's spans don't map onto one commit range, so it // answers with the whole set diff --git a/web/src/lib/api/blame.test.ts b/web/src/lib/api/blame.test.ts index 8b3f71131..deffa15ab 100644 --- a/web/src/lib/api/blame.test.ts +++ b/web/src/lib/api/blame.test.ts @@ -29,7 +29,7 @@ const blameCall = (ctx: ReturnType) => blameFor(ctx, REPO_DID, "main", "src/main.rs"); describe("blameFor", () => { - it("requests gitmirror getBlame and maps its one-based runs", async () => { + it("requests knotmirror getBlame and maps its one-based runs", async () => { const fetchMock = vi.fn().mockResolvedValue( mirrorResponse({ commits: [ diff --git a/web/src/lib/api/blame.ts b/web/src/lib/api/blame.ts index bbe83b506..6115cf477 100644 --- a/web/src/lib/api/blame.ts +++ b/web/src/lib/api/blame.ts @@ -1,7 +1,7 @@ import { isDid, type Did } from "@atcute/lexicons/syntax"; import type { BobbinContext, XrpcRequestInit } from "#lib/api/client.ts"; import { resolveEmailToDid } from "#lib/api/emailDid.ts"; -import { getBlame } from "#lib/api/gitmirror.ts"; +import { getBlame } from "#lib/api/knotmirror.ts"; import { resolveMiniDoc } from "#lib/api/identity.ts"; import { didFromSignature, splitMessage } from "#lib/api/repo.ts"; import type * as GetBlame from "#lib/api/lexicons/types/sh/tangled/git/temp2/getBlame.ts"; diff --git a/web/src/lib/api/gitclient.ts b/web/src/lib/api/gitclient.ts index 4b1b76621..aba41ba96 100644 --- a/web/src/lib/api/gitclient.ts +++ b/web/src/lib/api/gitclient.ts @@ -7,9 +7,9 @@ import { languages as knotMirrorLanguages, getRef as knotMirrorGetRef, tags as knotMirrorTags, - tree as knotMirrorTree + tree as knotMirrorTree, + listCommitsByRef } from "#lib/api/knotmirror.ts"; -import { listCommitsByRef } from "#lib/api/gitmirror.ts"; import type { Did } from "@atcute/lexicons"; import type { GitInfo } from "#lib/api/records.ts"; diff --git a/web/src/lib/api/gitmirror.test.ts b/web/src/lib/api/gitmirror.test.ts deleted file mode 100644 index fc98d4c8d..000000000 --- a/web/src/lib/api/gitmirror.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { createBobbinClient } from "#lib/api/client.ts"; -import { - commitAtRef, - diffFileKind, - diffFileName, - diffFileStat, - listCommits, - toMirrorCommitDetail, - toMirrorCommitSummary, - type MirrorCommit, - type MirrorFileDiff -} from "#lib/api/gitmirror.ts"; - -const signature = (when: string) => ({ name: "alice", email: "alice@example.com", when }); - -const commit = (oid: string, extra: Partial = {}): MirrorCommit => ({ - oid, - parents: [], - tree: "tree", - author: signature("2025-09-22T10:40:35+09:00"), - committer: signature("2025-09-22T10:44:00+09:00"), - message: "subject", - extraHeaders: [], - ...extra -}); - -describe("gitmirror.listCommits", () => { - it("queries the listCommits nsid with the repo dids", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ commits: [commit("abc")] }), { - status: 200, - headers: { "content-type": "application/json" } - }) - ); - const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); - - const page = await listCommits(ctx, { - repos: ["did:plc:fork", "did:plc:repo"] as never, - ranges: ["base..head"], - limit: 2 - }); - - const url = new URL(String(fetchMock.mock.calls[0][0])); - expect(url.pathname).toBe("/xrpc/org.tangled.temp.git.listCommits"); - // array params repeat rather than joining - expect(url.searchParams.getAll("repos")).toEqual(["did:plc:fork", "did:plc:repo"]); - expect(url.searchParams.getAll("ranges")).toEqual(["base..head"]); - expect(url.searchParams.get("limit")).toBe("2"); - expect(page.commits).toHaveLength(1); - }); -}); - -describe("gitmirror.commitAtRef", () => { - it("recovers the exact hash when commit-time ordering returns an ancestor first", async () => { - const wanted = "9a925efef6a0e6dfcd2d4317b4a1eee8752928b8"; - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response(JSON.stringify({ commits: [commit("1".repeat(40))] }), { - headers: { "content-type": "application/json" } - }) - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ commits: [commit(wanted)] }), { - headers: { "content-type": "application/json" } - }) - ); - const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); - - await expect(commitAtRef(ctx, "did:plc:repo" as never, wanted)).resolves.toMatchObject({ - oid: wanted - }); - - const retry = new URL(String(fetchMock.mock.calls[1][0])); - expect(retry.pathname).toBe("/xrpc/org.tangled.temp.git.listCommitsByRef"); - expect(retry.searchParams.getAll("ranges")).toEqual([`${wanted}^..${wanted}`]); - expect(retry.searchParams.get("limit")).toBe("100"); - }); -}); - -describe("gitmirror.toMirrorCommitSummary", () => { - it("splits the message and picks up the change id", () => { - const summary = toMirrorCommitSummary( - commit("9a925efef6a0e6dfcd2d4317b4a1eee8752928b8", { - message: "pulls: log the range\n\nthe lexicon has no range param", - extraHeaders: [ - { key: "gpgsig", value: "..." }, - { key: "change-id", value: "kmzwvxqouwxnvzsnvtwyprxlpuoymqrt" } - ] - }) - ); - - expect(summary).toEqual({ - hash: "9a925efef6a0e6dfcd2d4317b4a1eee8752928b8", - shortHash: "9a925efe", - subject: "pulls: log the range", - body: "the lexicon has no range param", - authorName: "alice", - authorEmail: "alice@example.com", - // the committer date wins, like the other commit converters - when: "2025-09-22T10:44:00+09:00", - changeId: "kmzwvxqouwxnvzsnvtwyprxlpuoymqrt" - }); - }); -}); - -describe("gitmirror.toMirrorCommitDetail", () => { - it("maps signatures and commit trailers for the commit header", () => { - const detail = toMirrorCommitDetail( - commit("9a925efef6a0e6dfcd2d4317b4a1eee8752928b8", { - author: { - name: "alice", - email: "did:plc:alice", - when: "2025-09-22T10:40:35+09:00" - }, - message: "subject\n\nbody\n\nCo-authored-by: Bob " - }) - ); - - expect(detail).toMatchObject({ - hash: "9a925efef6a0e6dfcd2d4317b4a1eee8752928b8", - subject: "subject", - body: "body\n\nCo-authored-by: Bob ", - authorDid: "did:plc:alice", - authorWhen: "2025-09-22T10:40:35+09:00", - committerName: "alice", - committerWhen: "2025-09-22T10:44:00+09:00", - coAuthors: [{ name: "Bob", email: "bob@example.com" }] - }); - }); -}); - -const src = (path: string, extra: Partial = {}) => ({ - path, - oid: "oid", - size: 10, - isBinary: false, - isSubmodule: false, - ...extra -}); - -const fileDiff = ( - lhs: string, - rhs: string, - hunks: MirrorFileDiff["hunks"] = [], - extra: Partial = {} -): MirrorFileDiff => ({ - lhsSrc: src(lhs), - rhsSrc: src(rhs), - hunks, - hasSyntacticChanges: false, - ...extra -}); - -describe("gitmirror diff mappers", () => { - it("names a file by its surviving side", () => { - expect(diffFileName(fileDiff("a.ts", "a.ts"))).toBe("a.ts"); - expect(diffFileName(fileDiff("gone.ts", ""))).toBe("gone.ts"); - expect(diffFileName(fileDiff("", "added.ts"))).toBe("added.ts"); - }); - - it("reads the change kind off the two sides", () => { - expect(diffFileKind(fileDiff("a.ts", "a.ts"))).toBe("changed"); - expect(diffFileKind(fileDiff("", "a.ts"))).toBe("new"); - expect(diffFileKind(fileDiff("a.ts", ""))).toBe("deleted"); - expect(diffFileKind(fileDiff("a.ts", "b.ts"))).toBe("renamed"); - - // what gitmirror actually sends: both sides named, the absent one a null oid - const absent = src("a.ts", { oid: "0".repeat(40), size: 0 }); - expect(diffFileKind({ ...fileDiff("a.ts", "a.ts"), lhsSrc: absent })).toBe("new"); - expect(diffFileKind({ ...fileDiff("a.ts", "a.ts"), rhsSrc: absent })).toBe("deleted"); - }); - - it("counts line pairs: one side is pure, both sides is a replacement", () => { - const hunks = [ - { - novelLhs: [3], - novelRhs: [3, 4], - // line 3 replaced, line 4 added - lines: [{ lhs: 3, rhs: 3 }, { rhs: 4 }] - }, - { - novelLhs: [9], - novelRhs: [], - // line 9 deleted - lines: [{ lhs: 9 }] - } - ]; - expect(diffFileStat(fileDiff("a.ts", "a.ts", hunks))).toEqual({ - insertions: 2, - deletions: 2 - }); - expect(diffFileStat(fileDiff("a.ts", "a.ts"))).toEqual({ insertions: 0, deletions: 0 }); - }); -}); diff --git a/web/src/lib/api/gitmirror.ts b/web/src/lib/api/gitmirror.ts deleted file mode 100644 index 27aa635c7..000000000 --- a/web/src/lib/api/gitmirror.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { jsonGet } from "#lib/api/_request.ts"; -import { - coAuthorsFrom, - didFromSignature, - FULL_HASH_RE, - splitMessage, - type CommitDetail, - type CommitSummary -} from "#lib/api/repo.ts"; -import type { Did } from "@atcute/lexicons/syntax"; -import type { BobbinContext, XrpcRequestInit } from "#lib/api/client.ts"; -import type { Revspec } from "#lib/api/pullRoute.ts"; -import type * as ShTangledGitDefs from "#lib/api/lexicons/types/sh/tangled/git/defs.ts"; -import type * as GetBlame from "#lib/api/lexicons/types/sh/tangled/git/temp2/getBlame.ts"; -import type * as GetDiff from "#lib/api/lexicons/types/sh/tangled/git/temp2/getDiff.ts"; -import type * as GetInterdiff from "#lib/api/lexicons/types/sh/tangled/git/temp2/getInterdiff.ts"; -import type * as ListCommits from "#lib/api/lexicons/types/org/tangled/temp/git/listCommits.ts"; -import type * as MergeCheck from "#lib/api/lexicons/types/sh/tangled/git/temp2/mergeCheck.ts"; - -// gitmirror's own xrpc surface, proxied through bobbin — its grpc service is -// internal and streaming, so the read paths the frontend needs live here -const LIST_COMMITS_NSID = "org.tangled.temp.git.listCommits"; -const LIST_COMMITS_BY_REF_NSID = "org.tangled.temp.git.listCommitsByRef"; -const GET_DIFF_NSID = "sh.tangled.git.temp2.getDiff"; -const GET_INTERDIFF_NSID = "sh.tangled.git.temp2.getInterdiff"; -const GET_BLAME_NSID = "sh.tangled.git.temp2.getBlame"; -const MERGE_CHECK_NSID = "sh.tangled.git.temp2.mergeCheck"; - -export const getBlame = (ctx: BobbinContext, params: GetBlame.$params, init?: XrpcRequestInit) => - jsonGet(ctx, GET_BLAME_NSID, params, init); - -export type MirrorCommit = ListCommits.Commit; - -export const listCommits = ( - ctx: BobbinContext, - params: ListCommits.$params, - init?: XrpcRequestInit -) => jsonGet(ctx, LIST_COMMITS_NSID, params, init); - -export const listCommitsByRef = ( - ctx: BobbinContext, - params: Omit & { repo: Did }, - init?: XrpcRequestInit -) => jsonGet(ctx, LIST_COMMITS_BY_REF_NSID, params, init); - -export const commitAtRef = async ( - ctx: BobbinContext, - repo: Did, - ref: string, - init?: XrpcRequestInit -): Promise => { - const page = await listCommitsByRef(ctx, { repo, ranges: [ref], limit: 1 }, init); - const first = page.commits[0]; - if (!first || !FULL_HASH_RE.test(ref) || first.oid.toLowerCase() === ref.toLowerCase()) { - return first; - } - const exact = await listCommitsByRef( - ctx, - { repo, ranges: [`${ref}^..${ref}`], limit: 100 }, - init - ); - return exact.commits.find((commit) => commit.oid.toLowerCase() === ref.toLowerCase()); -}; - -const LOG_LIMIT = 100; - -// every commit a pull version brought, newest first -export const listLog = async ( - ctx: BobbinContext, - repo: Did, - range: Revspec, - includeStats = false, - init?: XrpcRequestInit -): Promise => { - const page = await listCommits( - ctx, - { repos: [repo], ranges: [`${range.base}..${range.head}`], limit: LOG_LIMIT, includeStats }, - init - ); - return page.commits.map(toMirrorCommitSummary); -}; - -export type MirrorFileDiff = ShTangledGitDefs.FileDiff; - -export const getDiff = (ctx: BobbinContext, params: GetDiff.$params, init?: XrpcRequestInit) => - jsonGet(ctx, GET_DIFF_NSID, params, init); - -// `base*` is the older version's span, `head*` the newer one's: gitmirror -// rebases base1..base2 onto head1 and diffs the result against head2 -export const getInterdiff = ( - ctx: BobbinContext, - params: GetInterdiff.$params, - init?: XrpcRequestInit -) => jsonGet(ctx, GET_INTERDIFF_NSID, params, init); - -export type MirrorConflict = MergeCheck.Conflict; - -export const mergeCheck = ( - ctx: BobbinContext, - params: MergeCheck.$params, - init?: XrpcRequestInit -) => jsonGet(ctx, MERGE_CHECK_NSID, params, init); - -export type MirrorFileKind = "new" | "deleted" | "renamed" | "changed"; - -// an add or delete carries the same path on both sides — gix names both resources -// after the change's location — so the side that doesn't exist is the one whose oid -// is null, not the one whose path is empty -export const diffSideExists = (src: ShTangledGitDefs.DiffSrc): boolean => - src.path !== "" && !/^0+$/.test(src.oid); - -const EMPTY_TREE_SHA1 = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; -const EMPTY_TREE_SHA256 = "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321"; - -export const emptyTreeOid = (like: string): string => - like.length === 64 ? EMPTY_TREE_SHA256 : EMPTY_TREE_SHA1; - -export const diffFileName = (file: MirrorFileDiff): string => file.rhsSrc.path || file.lhsSrc.path; - -export const diffFileKind = (file: MirrorFileDiff): MirrorFileKind => { - if (!diffSideExists(file.lhsSrc)) return "new"; - if (!diffSideExists(file.rhsSrc)) return "deleted"; - return file.lhsSrc.path === file.rhsSrc.path ? "changed" : "renamed"; -}; - -// the payload has no line text, only aligned line numbers: a pair with one side -// missing is a pure insertion or deletion, a pair with both is a replaced line -export const diffFileStat = (file: MirrorFileDiff): { insertions: number; deletions: number } => { - let insertions = 0; - let deletions = 0; - for (const hunk of file.hunks) { - for (const line of hunk.lines) { - if (line.rhs !== undefined) insertions++; - if (line.lhs !== undefined) deletions++; - } - } - return { insertions, deletions }; -}; - -export const mirrorChangeId = (commit: MirrorCommit): string | undefined => - commit.extraHeaders.find((header) => header.key === "change-id")?.value; - -export const toMirrorCommitSummary = (commit: MirrorCommit): CommitSummary => { - const [subject, body] = splitMessage(commit.message); - return { - hash: commit.oid, - shortHash: commit.oid.slice(0, 8), - subject, - body, - authorName: commit.author.name, - authorEmail: commit.author.email, - authorDid: didFromSignature(commit.author.email), - when: commit.committer.when ?? commit.author.when, - // the pull page links a commit row as `firstParent..commit` - parent: commit.parents[0], - changeId: mirrorChangeId(commit), - stat: commit.stat && { - insertions: commit.stat.insertions, - deletions: commit.stat.deletions, - files_changed: commit.stat.filesChanged - } - }; -}; - -export const toMirrorCommitDetail = (commit: MirrorCommit): CommitDetail => { - const [subject, body] = splitMessage(commit.message); - const committerWhen = commit.committer.when ?? ""; - return { - hash: commit.oid, - shortHash: commit.oid.slice(0, 8), - subject, - body, - authorName: commit.author.name, - authorEmail: commit.author.email, - authorDid: didFromSignature(commit.author.email), - authorWhen: commit.author.when ?? "", - committerName: commit.committer.name, - committerEmail: commit.committer.email, - committerWhen, - coAuthors: coAuthorsFrom(commit.message, committerWhen).map(({ Name, Email }) => ({ - name: Name, - email: Email - })) - }; -}; diff --git a/web/src/lib/api/knotmirror.test.ts b/web/src/lib/api/knotmirror.test.ts index d67e3bd67..2ea5f3145 100644 --- a/web/src/lib/api/knotmirror.test.ts +++ b/web/src/lib/api/knotmirror.test.ts @@ -1,6 +1,17 @@ import { describe, expect, it, vi } from "vitest"; import { createBobbinClient } from "#lib/api/client.ts"; import * as knotmirror from "#lib/api/knotmirror.ts"; +import { + commitAtRef, + diffFileKind, + diffFileName, + diffFileStat, + listCommits, + toMirrorCommitDetail, + toMirrorCommitSummary, + type MirrorCommit, + type MirrorFileDiff +} from "#lib/api/knotmirror.ts"; const jsonResponse = (body: unknown): Response => new Response(JSON.stringify(body), { @@ -192,3 +203,186 @@ describe("knotmirror.formatPatchUrl", () => { }); }); }); + +const signature = (when: string) => ({ name: "alice", email: "alice@example.com", when }); + +const commit = (oid: string, extra: Partial = {}): MirrorCommit => ({ + oid, + parents: [], + tree: "tree", + author: signature("2025-09-22T10:40:35+09:00"), + committer: signature("2025-09-22T10:44:00+09:00"), + message: "subject", + extraHeaders: [], + ...extra +}); + +describe("knotmirror.listCommits", () => { + it("queries the listCommits nsid with the repo dids", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ commits: [commit("abc")] }), { + status: 200, + headers: { "content-type": "application/json" } + }) + ); + const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); + + const page = await listCommits(ctx, { + repos: ["did:plc:fork", "did:plc:repo"] as never, + ranges: ["base..head"], + limit: 2 + }); + + const url = new URL(String(fetchMock.mock.calls[0][0])); + expect(url.pathname).toBe("/xrpc/org.tangled.temp.git.listCommits"); + // array params repeat rather than joining + expect(url.searchParams.getAll("repos")).toEqual(["did:plc:fork", "did:plc:repo"]); + expect(url.searchParams.getAll("ranges")).toEqual(["base..head"]); + expect(url.searchParams.get("limit")).toBe("2"); + expect(page.commits).toHaveLength(1); + }); +}); + +describe("knotmirror.commitAtRef", () => { + it("recovers the exact hash when commit-time ordering returns an ancestor first", async () => { + const wanted = "9a925efef6a0e6dfcd2d4317b4a1eee8752928b8"; + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ commits: [commit("1".repeat(40))] }), { + headers: { "content-type": "application/json" } + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ commits: [commit(wanted)] }), { + headers: { "content-type": "application/json" } + }) + ); + const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); + + await expect(commitAtRef(ctx, "did:plc:repo" as never, wanted)).resolves.toMatchObject({ + oid: wanted + }); + + const retry = new URL(String(fetchMock.mock.calls[1][0])); + expect(retry.pathname).toBe("/xrpc/org.tangled.temp.git.listCommitsByRef"); + expect(retry.searchParams.getAll("ranges")).toEqual([`${wanted}^..${wanted}`]); + expect(retry.searchParams.get("limit")).toBe("100"); + }); +}); + +describe("knotmirror.toMirrorCommitSummary", () => { + it("splits the message and picks up the change id", () => { + const summary = toMirrorCommitSummary( + commit("9a925efef6a0e6dfcd2d4317b4a1eee8752928b8", { + message: "pulls: log the range\n\nthe lexicon has no range param", + extraHeaders: [ + { key: "gpgsig", value: "..." }, + { key: "change-id", value: "kmzwvxqouwxnvzsnvtwyprxlpuoymqrt" } + ] + }) + ); + + expect(summary).toEqual({ + hash: "9a925efef6a0e6dfcd2d4317b4a1eee8752928b8", + shortHash: "9a925efe", + subject: "pulls: log the range", + body: "the lexicon has no range param", + authorName: "alice", + authorEmail: "alice@example.com", + // the committer date wins, like the other commit converters + when: "2025-09-22T10:44:00+09:00", + changeId: "kmzwvxqouwxnvzsnvtwyprxlpuoymqrt" + }); + }); +}); + +describe("knotmirror.toMirrorCommitDetail", () => { + it("maps signatures and commit trailers for the commit header", () => { + const detail = toMirrorCommitDetail( + commit("9a925efef6a0e6dfcd2d4317b4a1eee8752928b8", { + author: { + name: "alice", + email: "did:plc:alice", + when: "2025-09-22T10:40:35+09:00" + }, + message: "subject\n\nbody\n\nCo-authored-by: Bob " + }) + ); + + expect(detail).toMatchObject({ + hash: "9a925efef6a0e6dfcd2d4317b4a1eee8752928b8", + subject: "subject", + body: "body\n\nCo-authored-by: Bob ", + authorDid: "did:plc:alice", + authorWhen: "2025-09-22T10:40:35+09:00", + committerName: "alice", + committerWhen: "2025-09-22T10:44:00+09:00", + coAuthors: [{ name: "Bob", email: "bob@example.com" }] + }); + }); +}); + +const src = (path: string, extra: Partial = {}) => ({ + path, + oid: "oid", + size: 10, + isBinary: false, + isSubmodule: false, + ...extra +}); + +const fileDiff = ( + lhs: string, + rhs: string, + hunks: MirrorFileDiff["hunks"] = [], + extra: Partial = {} +): MirrorFileDiff => ({ + lhsSrc: src(lhs), + rhsSrc: src(rhs), + hunks, + hasSyntacticChanges: false, + ...extra +}); + +describe("knotmirror diff mappers", () => { + it("names a file by its surviving side", () => { + expect(diffFileName(fileDiff("a.ts", "a.ts"))).toBe("a.ts"); + expect(diffFileName(fileDiff("gone.ts", ""))).toBe("gone.ts"); + expect(diffFileName(fileDiff("", "added.ts"))).toBe("added.ts"); + }); + + it("reads the change kind off the two sides", () => { + expect(diffFileKind(fileDiff("a.ts", "a.ts"))).toBe("changed"); + expect(diffFileKind(fileDiff("", "a.ts"))).toBe("new"); + expect(diffFileKind(fileDiff("a.ts", ""))).toBe("deleted"); + expect(diffFileKind(fileDiff("a.ts", "b.ts"))).toBe("renamed"); + + // what knotmirror actually sends: both sides named, the absent one a null oid + const absent = src("a.ts", { oid: "0".repeat(40), size: 0 }); + expect(diffFileKind({ ...fileDiff("a.ts", "a.ts"), lhsSrc: absent })).toBe("new"); + expect(diffFileKind({ ...fileDiff("a.ts", "a.ts"), rhsSrc: absent })).toBe("deleted"); + }); + + it("counts line pairs: one side is pure, both sides is a replacement", () => { + const hunks = [ + { + novelLhs: [3], + novelRhs: [3, 4], + // line 3 replaced, line 4 added + lines: [{ lhs: 3, rhs: 3 }, { rhs: 4 }] + }, + { + novelLhs: [9], + novelRhs: [], + // line 9 deleted + lines: [{ lhs: 9 }] + } + ]; + expect(diffFileStat(fileDiff("a.ts", "a.ts", hunks))).toEqual({ + insertions: 2, + deletions: 2 + }); + expect(diffFileStat(fileDiff("a.ts", "a.ts"))).toEqual({ insertions: 0, deletions: 0 }); + }); +}); diff --git a/web/src/lib/api/knotmirror.ts b/web/src/lib/api/knotmirror.ts index fe024613a..3bbe72cf2 100644 --- a/web/src/lib/api/knotmirror.ts +++ b/web/src/lib/api/knotmirror.ts @@ -7,7 +7,23 @@ import type * as MirrorEntry from "#lib/api/lexicons/types/org/tangled/temp/git/ import type * as MirrorListBranches from "#lib/api/lexicons/types/org/tangled/temp/git/listBranches.ts"; import type * as MirrorListTags from "#lib/api/lexicons/types/org/tangled/temp/git/listTags.ts"; import type * as MirrorTree from "#lib/api/lexicons/types/org/tangled/temp/git/getTree.ts"; -import { treeEntryKind } from "#lib/api/repo.ts"; +import { + coAuthorsFrom, + didFromSignature, + FULL_HASH_RE, + splitMessage, + treeEntryKind, + type CommitDetail, + type CommitSummary +} from "#lib/api/repo.ts"; +import type { Did } from "@atcute/lexicons/syntax"; +import type { Revspec } from "#lib/api/pullRoute.ts"; +import type * as ShTangledGitDefs from "#lib/api/lexicons/types/sh/tangled/git/defs.ts"; +import type * as GetBlame from "#lib/api/lexicons/types/sh/tangled/git/temp2/getBlame.ts"; +import type * as GetDiff from "#lib/api/lexicons/types/sh/tangled/git/temp2/getDiff.ts"; +import type * as GetInterdiff from "#lib/api/lexicons/types/sh/tangled/git/temp2/getInterdiff.ts"; +import type * as ListCommits from "#lib/api/lexicons/types/org/tangled/temp/git/listCommits.ts"; +import type * as MergeCheck from "#lib/api/lexicons/types/sh/tangled/git/temp2/mergeCheck.ts"; const TREE_NSID = nsidOf("org.tangled.temp.git.getTree"); const ENTRY_NSID = nsidOf("org.tangled.temp.git.getEntry"); @@ -228,3 +244,170 @@ export const getMergeBase = ( params: { repos: string[]; base: string; head: string }, init?: XrpcRequestInit ) => jsonGet<{ commit: string }>(bobbin, MERGE_BASE_NSID, params, init); + +// knotmirror's own xrpc surface, proxied through bobbin — its grpc service is +// internal and streaming, so the read paths the frontend needs live here +const LIST_COMMITS_NSID = "org.tangled.temp.git.listCommits"; +const LIST_COMMITS_BY_REF_NSID = "org.tangled.temp.git.listCommitsByRef"; +const GET_DIFF_NSID = "sh.tangled.git.temp2.getDiff"; +const GET_INTERDIFF_NSID = "sh.tangled.git.temp2.getInterdiff"; +const GET_BLAME_NSID = "sh.tangled.git.temp2.getBlame"; +const MERGE_CHECK_NSID = "sh.tangled.git.temp2.mergeCheck"; + +export const getBlame = (ctx: BobbinContext, params: GetBlame.$params, init?: XrpcRequestInit) => + jsonGet(ctx, GET_BLAME_NSID, params, init); + +export type MirrorCommit = ListCommits.Commit; + +export const listCommits = ( + ctx: BobbinContext, + params: ListCommits.$params, + init?: XrpcRequestInit +) => jsonGet(ctx, LIST_COMMITS_NSID, params, init); + +export const listCommitsByRef = ( + ctx: BobbinContext, + params: Omit & { repo: Did }, + init?: XrpcRequestInit +) => jsonGet(ctx, LIST_COMMITS_BY_REF_NSID, params, init); + +export const commitAtRef = async ( + ctx: BobbinContext, + repo: Did, + ref: string, + init?: XrpcRequestInit +): Promise => { + const page = await listCommitsByRef(ctx, { repo, ranges: [ref], limit: 1 }, init); + const first = page.commits[0]; + if (!first || !FULL_HASH_RE.test(ref) || first.oid.toLowerCase() === ref.toLowerCase()) { + return first; + } + const exact = await listCommitsByRef( + ctx, + { repo, ranges: [`${ref}^..${ref}`], limit: 100 }, + init + ); + return exact.commits.find((commit) => commit.oid.toLowerCase() === ref.toLowerCase()); +}; + +const LOG_LIMIT = 100; + +// every commit a pull version brought, newest first +export const listLog = async ( + ctx: BobbinContext, + repo: Did, + range: Revspec, + includeStats = false, + init?: XrpcRequestInit +): Promise => { + const page = await listCommits( + ctx, + { repos: [repo], ranges: [`${range.base}..${range.head}`], limit: LOG_LIMIT, includeStats }, + init + ); + return page.commits.map(toMirrorCommitSummary); +}; + +export type MirrorFileDiff = ShTangledGitDefs.FileDiff; + +export const getDiff = (ctx: BobbinContext, params: GetDiff.$params, init?: XrpcRequestInit) => + jsonGet(ctx, GET_DIFF_NSID, params, init); + +// `base*` is the older version's span, `head*` the newer one's: knotmirror +// rebases base1..base2 onto head1 and diffs the result against head2 +export const getInterdiff = ( + ctx: BobbinContext, + params: GetInterdiff.$params, + init?: XrpcRequestInit +) => jsonGet(ctx, GET_INTERDIFF_NSID, params, init); + +export type MirrorConflict = MergeCheck.Conflict; + +export const mergeCheck = ( + ctx: BobbinContext, + params: MergeCheck.$params, + init?: XrpcRequestInit +) => jsonGet(ctx, MERGE_CHECK_NSID, params, init); + +export type MirrorFileKind = "new" | "deleted" | "renamed" | "changed"; + +// an add or delete carries the same path on both sides — gix names both resources +// after the change's location — so the side that doesn't exist is the one whose oid +// is null, not the one whose path is empty +export const diffSideExists = (src: ShTangledGitDefs.DiffSrc): boolean => + src.path !== "" && !/^0+$/.test(src.oid); + +const EMPTY_TREE_SHA1 = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; +const EMPTY_TREE_SHA256 = "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321"; + +export const emptyTreeOid = (like: string): string => + like.length === 64 ? EMPTY_TREE_SHA256 : EMPTY_TREE_SHA1; + +export const diffFileName = (file: MirrorFileDiff): string => file.rhsSrc.path || file.lhsSrc.path; + +export const diffFileKind = (file: MirrorFileDiff): MirrorFileKind => { + if (!diffSideExists(file.lhsSrc)) return "new"; + if (!diffSideExists(file.rhsSrc)) return "deleted"; + return file.lhsSrc.path === file.rhsSrc.path ? "changed" : "renamed"; +}; + +// the payload has no line text, only aligned line numbers: a pair with one side +// missing is a pure insertion or deletion, a pair with both is a replaced line +export const diffFileStat = (file: MirrorFileDiff): { insertions: number; deletions: number } => { + let insertions = 0; + let deletions = 0; + for (const hunk of file.hunks) { + for (const line of hunk.lines) { + if (line.rhs !== undefined) insertions++; + if (line.lhs !== undefined) deletions++; + } + } + return { insertions, deletions }; +}; + +export const mirrorChangeId = (commit: MirrorCommit): string | undefined => + commit.extraHeaders.find((header) => header.key === "change-id")?.value; + +export const toMirrorCommitSummary = (commit: MirrorCommit): CommitSummary => { + const [subject, body] = splitMessage(commit.message); + return { + hash: commit.oid, + shortHash: commit.oid.slice(0, 8), + subject, + body, + authorName: commit.author.name, + authorEmail: commit.author.email, + authorDid: didFromSignature(commit.author.email), + when: commit.committer.when ?? commit.author.when, + // the pull page links a commit row as `firstParent..commit` + parent: commit.parents[0], + changeId: mirrorChangeId(commit), + stat: commit.stat && { + insertions: commit.stat.insertions, + deletions: commit.stat.deletions, + files_changed: commit.stat.filesChanged + } + }; +}; + +export const toMirrorCommitDetail = (commit: MirrorCommit): CommitDetail => { + const [subject, body] = splitMessage(commit.message); + const committerWhen = commit.committer.when ?? ""; + return { + hash: commit.oid, + shortHash: commit.oid.slice(0, 8), + subject, + body, + authorName: commit.author.name, + authorEmail: commit.author.email, + authorDid: didFromSignature(commit.author.email), + authorWhen: commit.author.when ?? "", + committerName: commit.committer.name, + committerEmail: commit.committer.email, + committerWhen, + coAuthors: coAuthorsFrom(commit.message, committerWhen).map(({ Name, Email }) => ({ + name: Name, + email: Email + })) + }; +}; diff --git a/web/src/lib/api/pull.test.ts b/web/src/lib/api/pull.test.ts index e4f0fc11c..69f043068 100644 --- a/web/src/lib/api/pull.test.ts +++ b/web/src/lib/api/pull.test.ts @@ -38,7 +38,7 @@ afterEach(() => { }); describe("pull writes on the knot", () => { - it("merges through gitmirror with a receive-pack token for the knot", async () => { + it("merges through knotmirror with a receive-pack token for the knot", async () => { const { pull, minted, calls } = await load([]); const bobbin = "https://bobbin.example.com"; diff --git a/web/src/lib/api/pull.ts b/web/src/lib/api/pull.ts index 834a59036..df89fbd17 100644 --- a/web/src/lib/api/pull.ts +++ b/web/src/lib/api/pull.ts @@ -90,7 +90,7 @@ const mirrorPush = async ( ): Promise => { const token = await mintServiceAuth(agent, { aud: serviceDidForHost(knot), - // gitmirror forwards this token to the knot's receive-pack + // knotmirror forwards this token to the knot's receive-pack lxm: PUSH_NSID }); const response = await fetch(buildUrl(bobbin, nsid), { diff --git a/web/src/lib/api/pullCompose.ts b/web/src/lib/api/pullCompose.ts index 00c58f8a7..dd33d1441 100644 --- a/web/src/lib/api/pullCompose.ts +++ b/web/src/lib/api/pullCompose.ts @@ -3,8 +3,7 @@ import type { Did } from "@atcute/lexicons/syntax"; import type { GitRepoInfo } from "#lib/components/repo/types.ts"; import { createBobbinClient } from "#lib/api/client.ts"; import { branches as listBranches, gitTarget, type GitServiceConfig } from "#lib/api/gitclient.ts"; -import { listCommits, toMirrorCommitSummary } from "#lib/api/gitmirror.ts"; -import { getMergeBase } from "#lib/api/knotmirror.ts"; +import { getMergeBase, listCommits, toMirrorCommitSummary } from "#lib/api/knotmirror.ts"; import { listRepos } from "#lib/api/records.ts"; import { resolveRepoKnot, diff --git a/web/src/lib/api/repoDiff.test.ts b/web/src/lib/api/repoDiff.test.ts index d079318f3..82739b926 100644 --- a/web/src/lib/api/repoDiff.test.ts +++ b/web/src/lib/api/repoDiff.test.ts @@ -9,7 +9,7 @@ import { loadDiff, resolveChange } from "#lib/api/repoDiff.ts"; -import { emptyTreeOid, type MirrorFileDiff } from "#lib/api/gitmirror.ts"; +import { emptyTreeOid, type MirrorFileDiff } from "#lib/api/knotmirror.ts"; const src = (path: string, extra: Partial = {}) => ({ path, @@ -20,7 +20,7 @@ const src = (path: string, extra: Partial = {}) => ({ ...extra }); -// gitmirror names both sides of an add or delete after the same path and marks the +// knotmirror names both sides of an add or delete after the same path and marks the // side that doesn't exist with a null oid const absent = (path: string) => src(path, { oid: "0".repeat(40), size: 0 }); @@ -275,7 +275,7 @@ describe("fetchSides", () => { hunks: [{ novelLhs: [0], novelRhs: [0], lines: [{ lhs: 0, rhs: 0 }] }], hasSyntacticChanges: false }, - // the rebased tree only exists inside one gitmirror request, so a left + // the rebased tree only exists inside one knotmirror request, so a left // side with no inline text can never be recovered { lhsSrc: src("b.ts"), diff --git a/web/src/lib/api/repoDiff.ts b/web/src/lib/api/repoDiff.ts index 2ad729d9b..fbdd0ca7f 100644 --- a/web/src/lib/api/repoDiff.ts +++ b/web/src/lib/api/repoDiff.ts @@ -9,7 +9,7 @@ import { listLog, type MirrorFileDiff, type MirrorFileKind -} from "#lib/api/gitmirror.ts"; +} from "#lib/api/knotmirror.ts"; import type { FileContents } from "@pierre/diffs"; import type * as ShTangledGitDefs from "#lib/api/lexicons/types/sh/tangled/git/defs.ts"; import type { Did } from "@atcute/lexicons/syntax"; @@ -26,7 +26,7 @@ type DiffSrc = ShTangledGitDefs.DiffSrc; const HUNK_CHROME_LINES = 2; // what the page asks for: one version's diff, or the interdiff between two of -// them. `repo` is the source repo -- gitmirror unions both dids' object dirs +// them. `repo` is the source repo -- knotmirror unions both dids' object dirs // into one scratch repo, so a fork's head and its base share one did here export interface DiffSpec { kind: "diff"; @@ -65,7 +65,7 @@ export interface RepoDiffFile { sides?: { lhs: DiffSrc; rhs: DiffSrc }; } -// where the two sides' text comes from. an interdiff's lhs is a tree gitmirror +// where the two sides' text comes from. an interdiff's lhs is a tree knotmirror // rebased in memory for that one request, reachable by no ref, so its text can // only arrive inlined on the payload export type ContentsSource = @@ -142,7 +142,7 @@ const plainDiff = async (deps: RepoDiffDeps, repo: Did, version: Revspec): Promi headRepo: repo, headCommit: version.head }); - // the left side is read at `base`, while gitmirror diffs from + // the left side is read at `base`, while knotmirror diffs from // merge_base(base, head). identical when base is the merge base, which is // what a pull version stores return buildDiff(diffs, { oldRef: version.base, newRef: version.head }); diff --git a/web/src/lib/components/repo/CommitView.stories.svelte b/web/src/lib/components/repo/CommitView.stories.svelte index 3f9e31a39..466516419 100644 --- a/web/src/lib/components/repo/CommitView.stories.svelte +++ b/web/src/lib/components/repo/CommitView.stories.svelte @@ -1,7 +1,7 @@