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 // full commit message
228 message?: string;
229}
230
231export const toBranchSummary = (branch: BranchEntry): BranchSummary => ({
232 name: branch.reference.name,
233 hash: branch.reference.hash,
234 when: branch.commit?.Committer?.When ?? branch.commit?.Author?.When,
235 isDefault: branch.is_default === true,
236 message: branch.commit?.Message
237});
238
239export interface TagSummary {
240 name: string;
241 hash: string;
242 // an annotated tag has its own hash, this is the commit it points at
243 commitHash: string;
244 when?: string;
245 message?: string;
246 // annotated tags carry their tagger
247 taggerName?: string;
248}
249
250export const hexFromBytes = (bytes: number[]): string =>
251 bytes.map((byte) => byte.toString(16).padStart(2, "0")).join("");
252
253export const toTagSummary = (tag: TagEntry): TagSummary => {
254 const target = tag.tag?.Target;
255 return {
256 name: tag.name,
257 hash: tag.hash,
258 commitHash: target?.length ? hexFromBytes(target) : tag.hash,
259 when: tag.tag?.Tagger?.When,
260 message: tag.message ?? tag.tag?.Message,
261 taggerName: tag.tag?.Tagger?.Name
262 };
263};
264
265export type TreeEntryKind = "file" | "directory" | "symlink" | "submodule";
266
267// modes come back octal and zero padded
268export const treeEntryKind = (mode: string): TreeEntryKind => {
269 switch (mode.replace(/^0+/, "").padStart(6, "0")) {
270 case "040000":
271 return "directory";
272 case "120000":
273 return "symlink";
274 case "160000":
275 return "submodule";
276 default:
277 return "file";
278 }
279};
280
281export interface TreeEntrySummary {
282 name: string;
283 kind: TreeEntryKind;
284 size: number;
285 lastCommitHash?: string;
286 lastCommitWhen?: string;
287 lastCommitMessage?: string;
288}
289
290export const toTreeEntrySummary = (entry: Tree.TreeEntry): TreeEntrySummary => ({
291 name: entry.name,
292 kind: treeEntryKind(entry.mode),
293 size: entry.size,
294 lastCommitHash: entry.last_commit?.hash,
295 lastCommitWhen: entry.last_commit?.when,
296 lastCommitMessage: entry.last_commit?.message
297});
298
299export const tagsByCommitHash = (
300 commits: CommitSummary[],
301 tags: TagSummary[]
302): Record<string, string[]> => {
303 const shown = new Set(commits.map((commit) => commit.hash));
304 return tags.reduce<Record<string, string[]>>((acc, tag) => {
305 if (shown.has(tag.commitHash)) (acc[tag.commitHash] ??= []).push(tag.name);
306 return acc;
307 }, {});
308};
309
310export const sortTreeEntries = (entries: TreeEntrySummary[]): TreeEntrySummary[] =>
311 [...entries].sort((a, b) => {
312 const aDir = a.kind === "directory" || a.kind === "submodule";
313 const bDir = b.kind === "directory" || b.kind === "submodule";
314 if (aDir !== bDir) return aDir ? -1 : 1;
315 return a.name.localeCompare(b.name);
316 });
317
318// the log cursor is a numeric offset encoded as a string
319export const logFor = (
320 ctx: BobbinContext,
321 repo: string,
322 ref: string,
323 limit: number,
324 cursor?: string,
325 init?: XrpcRequestInit
326) => knotLog<LogResponse>(ctx, { repo, ref, limit, cursor }, init);
327
328export const branchesFor = (
329 ctx: BobbinContext,
330 repo: string,
331 limit: number,
332 cursor?: string,
333 init?: XrpcRequestInit
334) => knotBranches<BranchesResponse>(ctx, { repo, limit, cursor }, init);
335
336export const tagsFor = (
337 ctx: BobbinContext,
338 repo: string,
339 limit: number,
340 cursor?: string,
341 init?: XrpcRequestInit
342) => knotTags<TagsResponse>(ctx, { repo, limit, cursor }, init);
343
344export const tagFor = (ctx: BobbinContext, repo: string, tag: string, init?: XrpcRequestInit) =>
345 knotTag<RepoTagResponse>(ctx, { repo, tag }, init);