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