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
129/** the subject is the first paragraph, not the first line */
130export const subjectOf = (message: string): string => splitMessage(message)[0];
131
132const coAuthorPattern = /^Co-authored-by:\s*(.+?)\s*<([^>]+)>/gim;
133
134// trailers carry no date of their own, like types/commit.go every
135// co-author gets stamped with the committer's When
136export const coAuthorsFrom = (message: string, when: string): GitSignature[] => {
137 const seen = new Set<string>();
138 const coAuthors: GitSignature[] = [];
139 for (const match of message.matchAll(coAuthorPattern)) {
140 const name = match[1].trim();
141 const email = match[2].trim();
142 if (seen.has(email)) continue;
143 seen.add(email);
144 coAuthors.push({ Name: name, Email: email, When: when });
145 }
146 return coAuthors;
147};
148
149export const toCommitSummary = (commit: LogCommit): CommitSummary => {
150 const [subject, body] = splitMessage(commit.message ?? "");
151 const hash = commit.this ?? "";
152 return {
153 hash,
154 shortHash: hash.slice(0, 8),
155 subject,
156 body,
157 authorName: commit.author?.Name ?? "",
158 authorEmail: commit.author?.Email ?? "",
159 when: commit.committer?.When ?? commit.author?.When ?? "",
160 changeId: commit.change_id
161 };
162};
163
164export interface CommitDetail {
165 hash: string;
166 shortHash: string;
167 subject: string;
168 body: string;
169 authorName: string;
170 authorEmail: string;
171 authorWhen: string;
172 committerName: string;
173 committerEmail: string;
174 committerWhen: string;
175 coAuthors: { name: string; email: string }[];
176}
177
178// the diff endpoint's commit carries both signatures, unlike the log's
179// summary shape
180export const toCommitDetail = (commit: NiceCommit): CommitDetail => {
181 const [subject, body] = splitMessage(commit.message ?? "");
182 const hash = commit.this ?? "";
183 const committerWhen = commit.committer?.When ?? "";
184 return {
185 hash,
186 shortHash: hash.slice(0, 8),
187 subject,
188 body,
189 authorName: commit.author?.Name ?? "",
190 authorEmail: commit.author?.Email ?? "",
191 authorWhen: commit.author?.When ?? "",
192 committerName: commit.committer?.Name ?? "",
193 committerEmail: commit.committer?.Email ?? "",
194 committerWhen,
195 coAuthors: coAuthorsFrom(commit.message ?? "", committerWhen).map(({ Name, Email }) => ({
196 name: Name,
197 email: Email
198 }))
199 };
200};
201
202// the tree endpoint uses lexicon casing where the log ones pass through go field
203// names, so this cannot share `toCommitSummary`
204export const toTreeCommitSummary = (commit: {
205 hash: string;
206 message?: string;
207 when?: string;
208 author?: { name?: string; email?: string; when?: string };
209}): CommitSummary => {
210 const [subject, body] = splitMessage(commit.message ?? "");
211 return {
212 hash: commit.hash,
213 shortHash: commit.hash.slice(0, 8),
214 subject,
215 body,
216 authorName: commit.author?.name ?? "",
217 authorEmail: commit.author?.email ?? "",
218 when: commit.when ?? commit.author?.when ?? ""
219 };
220};
221
222export interface BranchSummary {
223 name: string;
224 hash: string;
225 when?: string;
226 isDefault: boolean;
227}
228
229export const toBranchSummary = (branch: BranchEntry): BranchSummary => ({
230 name: branch.reference.name,
231 hash: branch.reference.hash,
232 when: branch.commit?.Committer?.When ?? branch.commit?.Author?.When,
233 isDefault: branch.is_default === true
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}
244
245export const hexFromBytes = (bytes: number[]): string =>
246 bytes.map((byte) => byte.toString(16).padStart(2, "0")).join("");
247
248export const toTagSummary = (tag: TagEntry): TagSummary => {
249 const target = tag.tag?.Target;
250 return {
251 name: tag.name,
252 hash: tag.hash,
253 commitHash: target?.length ? hexFromBytes(target) : tag.hash,
254 when: tag.tag?.Tagger?.When,
255 message: tag.message ?? tag.tag?.Message
256 };
257};
258
259export type TreeEntryKind = "file" | "directory" | "symlink" | "submodule";
260
261// modes come back octal and zero padded
262export const treeEntryKind = (mode: string): TreeEntryKind => {
263 switch (mode.replace(/^0+/, "").padStart(6, "0")) {
264 case "040000":
265 return "directory";
266 case "120000":
267 return "symlink";
268 case "160000":
269 return "submodule";
270 default:
271 return "file";
272 }
273};
274
275export interface TreeEntrySummary {
276 name: string;
277 kind: TreeEntryKind;
278 size: number;
279 lastCommitHash?: string;
280 lastCommitWhen?: string;
281 lastCommitMessage?: string;
282}
283
284export const toTreeEntrySummary = (entry: Tree.TreeEntry): TreeEntrySummary => ({
285 name: entry.name,
286 kind: treeEntryKind(entry.mode),
287 size: entry.size,
288 lastCommitHash: entry.last_commit?.hash,
289 lastCommitWhen: entry.last_commit?.when,
290 lastCommitMessage: entry.last_commit?.message
291});
292
293export const tagsByCommitHash = (
294 commits: CommitSummary[],
295 tags: TagSummary[]
296): Record<string, string[]> => {
297 const shown = new Set(commits.map((commit) => commit.hash));
298 return tags.reduce<Record<string, string[]>>((acc, tag) => {
299 if (shown.has(tag.commitHash)) (acc[tag.commitHash] ??= []).push(tag.name);
300 return acc;
301 }, {});
302};
303
304export const sortTreeEntries = (entries: TreeEntrySummary[]): TreeEntrySummary[] =>
305 [...entries].sort((a, b) => {
306 const aDir = a.kind === "directory" || a.kind === "submodule";
307 const bDir = b.kind === "directory" || b.kind === "submodule";
308 if (aDir !== bDir) return aDir ? -1 : 1;
309 return a.name.localeCompare(b.name);
310 });
311
312// the log cursor is a numeric offset encoded as a string
313export const logFor = (
314 ctx: BobbinContext,
315 repo: string,
316 ref: string,
317 limit: number,
318 cursor?: string,
319 init?: XrpcRequestInit
320) => knotLog<LogResponse>(ctx, { repo, ref, limit, cursor }, init);
321
322export const branchesFor = (
323 ctx: BobbinContext,
324 repo: string,
325 limit: number,
326 cursor?: string,
327 init?: XrpcRequestInit
328) => knotBranches<BranchesResponse>(ctx, { repo, limit, cursor }, init);
329
330export const tagsFor = (
331 ctx: BobbinContext,
332 repo: string,
333 limit: number,
334 cursor?: string,
335 init?: XrpcRequestInit
336) => knotTags<TagsResponse>(ctx, { repo, limit, cursor }, init);
337
338export const tagFor = (ctx: BobbinContext, repo: string, tag: string, init?: XrpcRequestInit) =>
339 knotTag<RepoTagResponse>(ctx, { repo, tag }, init);