This repository has no description
1import { ClientResponseError, type BobbinContext, type XrpcRequestInit } from "./client";
2import type { NiceCommit } from "./diff";
3import { getRepoByName, type RecordView, type RepoRecord } from "./records";
4import { branches as knotBranches, log as knotLog, tag as knotTag, tags as knotTags } from "./knot";
5import { httpStatusFor } from "./load";
6import { rkeyFromUri } from "./uri";
7import type * as Tree from "./lexicons/types/sh/tangled/repo/tree";
8
9// log, branches and tags are `*/*` in the lexicons, so these shapes are copied
10// from core/types by hand. anything go-git touches keeps its go field names and
11// writes hashes as byte arrays, so the hex comes from a sibling field
12
13export interface GitSignature {
14 Name: string;
15 Email: string;
16 When: string;
17}
18
19// go-git's IsHash accepts uppercase hex too (hex.DecodeString), both sha1
20// and sha256 match case-insensitively
21export const FULL_HASH_RE = /^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/;
22
23export const parseRawCommit = (spec: string): { ref: string; format: "patch" | "diff" } | null => {
24 const dot = spec.lastIndexOf(".");
25 if (dot === -1) return null;
26 const format = spec.slice(dot + 1);
27 if (format !== "patch" && format !== "diff") return null;
28 const ref = spec.slice(0, dot);
29 return FULL_HASH_RE.test(ref) ? { ref, format } : null;
30};
31
32export interface GitCommit {
33 Author?: GitSignature;
34 Committer?: GitSignature;
35 Message?: string;
36}
37
38// `this` and `parent` are the hex hashes, `hash` is the byte array
39export interface LogCommit {
40 this?: string;
41 parent?: string;
42 author?: GitSignature;
43 committer?: GitSignature;
44 message?: string;
45 tree?: string;
46 change_id?: string;
47}
48
49export interface LogResponse {
50 commits?: LogCommit[];
51 ref?: string;
52 total?: number;
53 page?: number;
54}
55
56export interface GitReference {
57 name: string;
58 hash: string;
59}
60
61export interface BranchEntry {
62 reference: GitReference;
63 commit?: GitCommit;
64 is_default?: boolean;
65}
66
67export interface BranchesResponse {
68 branches?: BranchEntry[];
69 total?: number;
70}
71
72export interface TagEntry {
73 name: string;
74 hash: string;
75 message?: string;
76 tag?: {
77 Tagger?: GitSignature;
78 Message?: string;
79 // the commit an annotated tag points at, bytes like every go-git hash
80 Target?: number[];
81 };
82}
83
84export interface TagsResponse {
85 tags?: TagEntry[];
86 total?: number;
87}
88
89export interface RepoTagResponse {
90 tag?: TagEntry;
91}
92
93// newer repos get tid rkeys and keep their display name in the record
94export const repoNameOf = (view: RecordView<RepoRecord>): string =>
95 view.value.name ?? rkeyFromUri(view.uri);
96
97// a repo bobbin has never indexed is a miss, not an error
98export const resolveRepoByName = async (
99 ctx: BobbinContext,
100 ownerDid: string,
101 name: string,
102 init?: XrpcRequestInit
103): Promise<RecordView<RepoRecord> | null> => {
104 try {
105 return await getRepoByName(ctx, ownerDid, name, init);
106 } catch (cause) {
107 if (cause instanceof ClientResponseError && httpStatusFor(cause) === 404) return null;
108 throw cause;
109 }
110};
111
112export interface CommitSummary {
113 hash: string;
114 shortHash: string;
115 subject: string;
116 body: string;
117 authorName: string;
118 authorEmail: string;
119 when: string;
120 changeId?: string;
121}
122
123export const splitMessage = (message: string): [string, string] => {
124 const separator = message.indexOf("\n\n");
125 if (separator === -1) return [message.trim(), ""];
126 return [message.slice(0, separator).trim(), message.slice(separator + 2).trim()];
127};
128
129const coAuthorPattern = /^Co-authored-by:\s*(.+?)\s*<([^>]+)>/gim;
130
131// trailers carry no date of their own, like types/commit.go every
132// co-author gets stamped with the committer's When
133export const coAuthorsFrom = (message: string, when: string): GitSignature[] => {
134 const seen = new Set<string>();
135 const coAuthors: GitSignature[] = [];
136 for (const match of message.matchAll(coAuthorPattern)) {
137 const name = match[1].trim();
138 const email = match[2].trim();
139 if (seen.has(email)) continue;
140 seen.add(email);
141 coAuthors.push({ Name: name, Email: email, When: when });
142 }
143 return coAuthors;
144};
145
146export const toCommitSummary = (commit: LogCommit): CommitSummary => {
147 const [subject, body] = splitMessage(commit.message ?? "");
148 const hash = commit.this ?? "";
149 return {
150 hash,
151 shortHash: hash.slice(0, 8),
152 subject,
153 body,
154 authorName: commit.author?.Name ?? "",
155 authorEmail: commit.author?.Email ?? "",
156 when: commit.committer?.When ?? commit.author?.When ?? "",
157 changeId: commit.change_id
158 };
159};
160
161export interface CommitDetail {
162 hash: string;
163 shortHash: string;
164 subject: string;
165 body: string;
166 authorName: string;
167 authorEmail: string;
168 authorWhen: string;
169 committerName: string;
170 committerEmail: string;
171 committerWhen: string;
172 coAuthors: { name: string; email: string }[];
173}
174
175// the diff endpoint's commit carries both signatures, unlike the log's
176// summary shape
177export const toCommitDetail = (commit: NiceCommit): CommitDetail => {
178 const [subject, body] = splitMessage(commit.message ?? "");
179 const hash = commit.this ?? "";
180 const committerWhen = commit.committer?.When ?? "";
181 return {
182 hash,
183 shortHash: hash.slice(0, 8),
184 subject,
185 body,
186 authorName: commit.author?.Name ?? "",
187 authorEmail: commit.author?.Email ?? "",
188 authorWhen: commit.author?.When ?? "",
189 committerName: commit.committer?.Name ?? "",
190 committerEmail: commit.committer?.Email ?? "",
191 committerWhen,
192 coAuthors: coAuthorsFrom(commit.message ?? "", committerWhen).map(({ Name, Email }) => ({
193 name: Name,
194 email: Email
195 }))
196 };
197};
198
199// the tree endpoint uses lexicon casing where the log ones pass through go field
200// names, so this cannot share `toCommitSummary`
201export const toTreeCommitSummary = (commit: {
202 hash: string;
203 message?: string;
204 when?: string;
205 author?: { name?: string; email?: string; when?: string };
206}): CommitSummary => {
207 const [subject, body] = splitMessage(commit.message ?? "");
208 return {
209 hash: commit.hash,
210 shortHash: commit.hash.slice(0, 8),
211 subject,
212 body,
213 authorName: commit.author?.name ?? "",
214 authorEmail: commit.author?.email ?? "",
215 when: commit.when ?? commit.author?.when ?? ""
216 };
217};
218
219export interface BranchSummary {
220 name: string;
221 hash: string;
222 when?: string;
223 isDefault: boolean;
224 // full commit message
225 message?: string;
226}
227
228export const toBranchSummary = (branch: BranchEntry): BranchSummary => ({
229 name: branch.reference.name,
230 hash: branch.reference.hash,
231 when: branch.commit?.Committer?.When ?? branch.commit?.Author?.When,
232 isDefault: branch.is_default === true,
233 message: branch.commit?.Message
234});
235
236export interface TagSummary {
237 name: string;
238 hash: string;
239 // an annotated tag has its own hash, this is the commit it points at
240 commitHash: string;
241 when?: string;
242 message?: string;
243 // annotated tags carry their tagger
244 taggerName?: string;
245}
246
247export const hexFromBytes = (bytes: number[]): string =>
248 bytes.map((byte) => byte.toString(16).padStart(2, "0")).join("");
249
250export const toTagSummary = (tag: TagEntry): TagSummary => {
251 const target = tag.tag?.Target;
252 return {
253 name: tag.name,
254 hash: tag.hash,
255 commitHash: target?.length ? hexFromBytes(target) : tag.hash,
256 when: tag.tag?.Tagger?.When,
257 message: tag.message ?? tag.tag?.Message,
258 taggerName: tag.tag?.Tagger?.Name
259 };
260};
261
262export type TreeEntryKind = "file" | "directory" | "symlink" | "submodule";
263
264// modes come back octal and zero padded
265export const treeEntryKind = (mode: string): TreeEntryKind => {
266 switch (mode.replace(/^0+/, "").padStart(6, "0")) {
267 case "040000":
268 return "directory";
269 case "120000":
270 return "symlink";
271 case "160000":
272 return "submodule";
273 default:
274 return "file";
275 }
276};
277
278export interface TreeEntrySummary {
279 name: string;
280 kind: TreeEntryKind;
281 size: number;
282 lastCommitHash?: string;
283 lastCommitWhen?: string;
284 lastCommitMessage?: string;
285}
286
287export const toTreeEntrySummary = (entry: Tree.TreeEntry): TreeEntrySummary => ({
288 name: entry.name,
289 kind: treeEntryKind(entry.mode),
290 size: entry.size,
291 lastCommitHash: entry.last_commit?.hash,
292 lastCommitWhen: entry.last_commit?.when,
293 lastCommitMessage: entry.last_commit?.message
294});
295
296export const tagsByCommitHash = (
297 commits: CommitSummary[],
298 tags: TagSummary[]
299): Record<string, string[]> => {
300 const shown = new Set(commits.map((commit) => commit.hash));
301 return tags.reduce<Record<string, string[]>>((acc, tag) => {
302 if (shown.has(tag.commitHash)) (acc[tag.commitHash] ??= []).push(tag.name);
303 return acc;
304 }, {});
305};
306
307export const sortTreeEntries = (entries: TreeEntrySummary[]): TreeEntrySummary[] =>
308 [...entries].sort((a, b) => {
309 const aDir = a.kind === "directory" || a.kind === "submodule";
310 const bDir = b.kind === "directory" || b.kind === "submodule";
311 if (aDir !== bDir) return aDir ? -1 : 1;
312 return a.name.localeCompare(b.name);
313 });
314
315// the log cursor is a numeric offset encoded as a string
316export const logFor = (
317 ctx: BobbinContext,
318 repo: string,
319 ref: string,
320 limit: number,
321 cursor?: string,
322 init?: XrpcRequestInit
323) => knotLog<LogResponse>(ctx, { repo, ref, limit, cursor }, init);
324
325export const branchesFor = (
326 ctx: BobbinContext,
327 repo: string,
328 limit: number,
329 cursor?: string,
330 init?: XrpcRequestInit
331) => knotBranches<BranchesResponse>(ctx, { repo, limit, cursor }, init);
332
333export const tagsFor = (
334 ctx: BobbinContext,
335 repo: string,
336 limit: number,
337 cursor?: string,
338 init?: XrpcRequestInit
339) => knotTags<TagsResponse>(ctx, { repo, limit, cursor }, init);
340
341export const tagFor = (ctx: BobbinContext, repo: string, tag: string, init?: XrpcRequestInit) =>
342 knotTag<RepoTagResponse>(ctx, { repo, tag }, init);