import { ClientResponseError, type BobbinContext, type XrpcRequestInit } from "./client"; import type { NiceCommit } from "./diff"; import { getRepoByName, type RecordView, type RepoRecord } from "./records"; import { branches as knotBranches, log as knotLog, tag as knotTag, tags as knotTags } from "./knot"; import { httpStatusFor } from "./load"; import { rkeyFromUri } from "./uri"; import type * as Tree from "./lexicons/types/sh/tangled/repo/tree"; // log, branches and tags are `*/*` in the lexicons, so these shapes are copied // from core/types by hand. anything go-git touches keeps its go field names and // writes hashes as byte arrays, so the hex comes from a sibling field export interface GitSignature { Name: string; Email: string; When: string; } // go-git's IsHash accepts uppercase hex too (hex.DecodeString), both sha1 // and sha256 match case-insensitively export const FULL_HASH_RE = /^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/; export const parseRawCommit = (spec: string): { ref: string; format: "patch" | "diff" } | null => { const dot = spec.lastIndexOf("."); if (dot === -1) return null; const format = spec.slice(dot + 1); if (format !== "patch" && format !== "diff") return null; const ref = spec.slice(0, dot); return FULL_HASH_RE.test(ref) ? { ref, format } : null; }; export interface GitCommit { Author?: GitSignature; Committer?: GitSignature; Message?: string; } // `this` and `parent` are the hex hashes, `hash` is the byte array export interface LogCommit { this?: string; parent?: string; author?: GitSignature; committer?: GitSignature; message?: string; tree?: string; change_id?: string; } export interface LogResponse { commits?: LogCommit[]; ref?: string; total?: number; page?: number; } export interface GitReference { name: string; hash: string; } export interface BranchEntry { reference: GitReference; commit?: GitCommit; is_default?: boolean; } export interface BranchesResponse { branches?: BranchEntry[]; total?: number; } export interface TagEntry { name: string; hash: string; message?: string; tag?: { Tagger?: GitSignature; Message?: string; // the commit an annotated tag points at, bytes like every go-git hash Target?: number[]; }; } export interface TagsResponse { tags?: TagEntry[]; total?: number; } export interface RepoTagResponse { tag?: TagEntry; } // newer repos get tid rkeys and keep their display name in the record export const repoNameOf = (view: RecordView): string => view.value.name ?? rkeyFromUri(view.uri); // a repo bobbin has never indexed is a miss, not an error export const resolveRepoByName = async ( ctx: BobbinContext, ownerDid: string, name: string, init?: XrpcRequestInit ): Promise | null> => { try { return await getRepoByName(ctx, ownerDid, name, init); } catch (cause) { if (cause instanceof ClientResponseError && httpStatusFor(cause) === 404) return null; throw cause; } }; export interface CommitSummary { hash: string; shortHash: string; subject: string; body: string; authorName: string; authorEmail: string; when: string; changeId?: string; } export const splitMessage = (message: string): [string, string] => { const separator = message.indexOf("\n\n"); if (separator === -1) return [message.trim(), ""]; return [message.slice(0, separator).trim(), message.slice(separator + 2).trim()]; }; const coAuthorPattern = /^Co-authored-by:\s*(.+?)\s*<([^>]+)>/gim; // trailers carry no date of their own, like types/commit.go every // co-author gets stamped with the committer's When export const coAuthorsFrom = (message: string, when: string): GitSignature[] => { const seen = new Set(); const coAuthors: GitSignature[] = []; for (const match of message.matchAll(coAuthorPattern)) { const name = match[1].trim(); const email = match[2].trim(); if (seen.has(email)) continue; seen.add(email); coAuthors.push({ Name: name, Email: email, When: when }); } return coAuthors; }; export const toCommitSummary = (commit: LogCommit): CommitSummary => { const [subject, body] = splitMessage(commit.message ?? ""); const hash = commit.this ?? ""; return { hash, shortHash: hash.slice(0, 8), subject, body, authorName: commit.author?.Name ?? "", authorEmail: commit.author?.Email ?? "", when: commit.committer?.When ?? commit.author?.When ?? "", changeId: commit.change_id }; }; export interface CommitDetail { hash: string; shortHash: string; subject: string; body: string; authorName: string; authorEmail: string; authorWhen: string; committerName: string; committerEmail: string; committerWhen: string; coAuthors: { name: string; email: string }[]; } // the diff endpoint's commit carries both signatures, unlike the log's // summary shape export const toCommitDetail = (commit: NiceCommit): CommitDetail => { const [subject, body] = splitMessage(commit.message ?? ""); const hash = commit.this ?? ""; const committerWhen = commit.committer?.When ?? ""; return { hash, shortHash: hash.slice(0, 8), subject, body, authorName: commit.author?.Name ?? "", authorEmail: 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 })) }; }; // the tree endpoint uses lexicon casing where the log ones pass through go field // names, so this cannot share `toCommitSummary` export const toTreeCommitSummary = (commit: { hash: string; message?: string; when?: string; author?: { name?: string; email?: string; when?: string }; }): CommitSummary => { const [subject, body] = splitMessage(commit.message ?? ""); return { hash: commit.hash, shortHash: commit.hash.slice(0, 8), subject, body, authorName: commit.author?.name ?? "", authorEmail: commit.author?.email ?? "", when: commit.when ?? commit.author?.when ?? "" }; }; export interface BranchSummary { name: string; hash: string; when?: string; isDefault: boolean; // full commit message message?: string; } export const toBranchSummary = (branch: BranchEntry): BranchSummary => ({ name: branch.reference.name, hash: branch.reference.hash, when: branch.commit?.Committer?.When ?? branch.commit?.Author?.When, isDefault: branch.is_default === true, message: branch.commit?.Message }); export interface TagSummary { name: string; hash: string; // an annotated tag has its own hash, this is the commit it points at commitHash: string; when?: string; message?: string; // annotated tags carry their tagger taggerName?: string; } export const hexFromBytes = (bytes: number[]): string => bytes.map((byte) => byte.toString(16).padStart(2, "0")).join(""); export const toTagSummary = (tag: TagEntry): TagSummary => { const target = tag.tag?.Target; return { name: tag.name, hash: tag.hash, commitHash: target?.length ? hexFromBytes(target) : tag.hash, when: tag.tag?.Tagger?.When, message: tag.message ?? tag.tag?.Message, taggerName: tag.tag?.Tagger?.Name }; }; export type TreeEntryKind = "file" | "directory" | "symlink" | "submodule"; // modes come back octal and zero padded export const treeEntryKind = (mode: string): TreeEntryKind => { switch (mode.replace(/^0+/, "").padStart(6, "0")) { case "040000": return "directory"; case "120000": return "symlink"; case "160000": return "submodule"; default: return "file"; } }; export interface TreeEntrySummary { name: string; kind: TreeEntryKind; size: number; lastCommitHash?: string; lastCommitWhen?: string; lastCommitMessage?: string; } export const toTreeEntrySummary = (entry: Tree.TreeEntry): TreeEntrySummary => ({ name: entry.name, kind: treeEntryKind(entry.mode), size: entry.size, lastCommitHash: entry.last_commit?.hash, lastCommitWhen: entry.last_commit?.when, lastCommitMessage: entry.last_commit?.message }); export const tagsByCommitHash = ( commits: CommitSummary[], tags: TagSummary[] ): Record => { const shown = new Set(commits.map((commit) => commit.hash)); return tags.reduce>((acc, tag) => { if (shown.has(tag.commitHash)) (acc[tag.commitHash] ??= []).push(tag.name); return acc; }, {}); }; export const sortTreeEntries = (entries: TreeEntrySummary[]): TreeEntrySummary[] => [...entries].sort((a, b) => { const aDir = a.kind === "directory" || a.kind === "submodule"; const bDir = b.kind === "directory" || b.kind === "submodule"; if (aDir !== bDir) return aDir ? -1 : 1; return a.name.localeCompare(b.name); }); // the log cursor is a numeric offset encoded as a string export const logFor = ( ctx: BobbinContext, repo: string, ref: string, limit: number, cursor?: string, init?: XrpcRequestInit ) => knotLog(ctx, { repo, ref, limit, cursor }, init); export const branchesFor = ( ctx: BobbinContext, repo: string, limit: number, cursor?: string, init?: XrpcRequestInit ) => knotBranches(ctx, { repo, limit, cursor }, init); export const tagsFor = ( ctx: BobbinContext, repo: string, limit: number, cursor?: string, init?: XrpcRequestInit ) => knotTags(ctx, { repo, limit, cursor }, init); export const tagFor = (ctx: BobbinContext, repo: string, tag: string, init?: XrpcRequestInit) => knotTag(ctx, { repo, tag }, init);