1/**
2 * Plain-fetch client of the git-service REST API (`/api/v1`).
3 *
4 * The types mirror `alchemy/Git`'s API schemas (`src/Git/Api/Schema.ts`)
5 * by hand — the SPA deliberately ships no Effect runtime; it is an example
6 * of consuming the service from any plain JS frontend.
7 */
8
9// ── configuration ───────────────────────────────────────────────────────────
10
11const builtinUrl: string | undefined = import.meta.env.VITE_GIT_URL;
12
13/**
14 * Where the service lives. Same origin as the SPA in the single-origin
15 * deployment, so the Better Auth session cookie rides along with every
16 * request (`credentials: "include"`).
17 */
18export interface Connection {
19 readonly url: string;
20}
21
22export const getConnection = (): Connection => ({
23 url: (builtinUrl ?? location.origin).replace(/\/+$/, ""),
24});
25
26// ── auth (Better Auth, mounted at /api/auth) ────────────────────────────────
27
28export interface User {
29 readonly id: string;
30 readonly name: string;
31 readonly email: string;
32}
33
34const authRequest = async <T>(
35 c: Connection,
36 method: string,
37 path: string,
38 body?: unknown,
39): Promise<T> => {
40 const res = await fetch(`${c.url}/api/auth${path}`, {
41 method,
42 credentials: "include",
43 headers: body !== undefined ? { "Content-Type": "application/json" } : {},
44 body: body !== undefined ? JSON.stringify(body) : undefined,
45 });
46 if (!res.ok) {
47 const text = await res.text();
48 let message = text;
49 try {
50 message = (JSON.parse(text) as { message?: string }).message ?? text;
51 } catch {
52 /* not JSON */
53 }
54 throw new ApiError(res.status, "AuthError", message || res.statusText);
55 }
56 const text = await res.text();
57 return (text.length === 0 ? null : JSON.parse(text)) as T;
58};
59
60export const getSession = (c: Connection): Promise<User | null> =>
61 authRequest<{ user: User } | null>(c, "GET", "/get-session").then(
62 (session) => session?.user ?? null,
63 );
64
65export const signUp = (
66 c: Connection,
67 input: { name: string; email: string; password: string },
68): Promise<User> =>
69 authRequest<{ user: User }>(c, "POST", "/sign-up/email", input).then(
70 (r) => r.user,
71 );
72
73export const signIn = (
74 c: Connection,
75 input: { email: string; password: string },
76): Promise<User> =>
77 authRequest<{ user: User }>(c, "POST", "/sign-in/email", input).then(
78 (r) => r.user,
79 );
80
81export const signOut = (c: Connection): Promise<void> =>
82 authRequest<unknown>(c, "POST", "/sign-out", {}).then(() => undefined);
83
84/** An API key: the password a `git` remote carries. Values are shown once. */
85export interface ApiKey {
86 readonly id: string;
87 readonly name: string | null;
88 /** The first characters of the key, for recognition. */
89 readonly start: string | null;
90 readonly createdAt: string;
91 readonly expiresAt: string | null;
92}
93
94export const listApiKeys = (c: Connection): Promise<ApiKey[]> =>
95 authRequest<ApiKey[]>(c, "GET", "/api-key/list");
96
97export const createApiKey = (
98 c: Connection,
99 name: string,
100): Promise<ApiKey & { key: string }> =>
101 authRequest(c, "POST", "/api-key/create", { name });
102
103export const deleteApiKey = (c: Connection, keyId: string): Promise<void> =>
104 authRequest<unknown>(c, "POST", "/api-key/delete", { keyId }).then(
105 () => undefined,
106 );
107
108export type RepoStatus = "ready" | "importing" | "forking" | "deleting";
109
110export interface ObjectStats {
111 loose: number;
112 packed: number;
113 r2: number;
114 bytes: number;
115}
116
117export interface PushStats {
118 objects: number;
119 bytes: number;
120 ingestMs: number;
121 stageMs: number;
122 connectivityMs: number;
123 finalizeMs: number;
124 totalMs: number;
125}
126
127export interface Repo {
128 owner: string;
129 name: string;
130 repoId: string;
131 defaultBranch: string;
132 description: string | null;
133 readOnly: boolean;
134 /** Readable (REST + clone) without signing in. */
135 public: boolean;
136 forkOf: string | null;
137 status: RepoStatus;
138 createdAt: number;
139 objects: ObjectStats;
140 lastPush: PushStats | null;
141}
142
143export interface Ref {
144 name: string;
145 oid: string;
146 peeled?: string;
147}
148
149export interface Signature {
150 name: string;
151 email: string;
152 /** Unix timestamp (seconds). */
153 date: number;
154 /** Timezone offset as written, e.g. `+0200`. */
155 tz: string;
156}
157
158export interface CommitInfo {
159 oid: string;
160 tree: string;
161 parents: string[];
162 author: Signature;
163 committer: Signature;
164 message: string;
165}
166
167export interface TreeEntry {
168 mode: string;
169 name: string;
170 oid: string;
171 type: "blob" | "tree" | "commit";
172}
173
174export type FileStatus = "added" | "removed" | "modified";
175
176/**
177 * One changed file in a commit diff or comparison. Content is NOT
178 * included — clients fetch old/new blobs by oid (`getBlob`) and diff
179 * locally. `oldSize`/`newSize` gate binary/oversize files without a
180 * round trip. Gitlinks (mode `160000`) carry commit oids — render
181 * "Subproject commit …", never fetch them as blobs. No rename detection
182 * in v1 (a rename is `removed` + `added`); a mode-only change is
183 * `modified` with `oldOid === newOid`.
184 */
185export interface DiffEntry {
186 path: string;
187 status: FileStatus;
188 oldOid?: string | null;
189 newOid?: string | null;
190 oldMode?: string | null;
191 newMode?: string | null;
192 oldSize?: number | null;
193 newSize?: number | null;
194}
195
196/** Changed files of one commit vs its FIRST parent (null for a root commit). */
197export interface CommitDiff {
198 oid: string;
199 parent: string | null;
200 files: DiffEntry[];
201 /** `true` when the list was cut at the server cap (1000 files). */
202 truncated: boolean;
203}
204
205/** GitHub-style three-dot comparison of two revisions. */
206export interface Comparison {
207 base: string;
208 head: string;
209 mergeBase: string;
210 aheadBy: number;
211 behindBy: number;
212 /** Head-side commits, committer-time descending, capped at 250. */
213 commits: CommitInfo[];
214 commitsTruncated: boolean;
215 /** File diff of mergeBase..head (three-dot). */
216 files: DiffEntry[];
217 filesTruncated: boolean;
218}
219
220export interface RepoCreated {
221 repo: Repo;
222 remote: string;
223}
224
225export interface Page<T> {
226 items: T[];
227 nextCursor: string | null;
228 hasMore: boolean;
229}
230
231// ── errors ──────────────────────────────────────────────────────────────────
232
233export class ApiError extends Error {
234 constructor(
235 readonly status: number,
236 readonly tag: string,
237 override readonly message: string,
238 ) {
239 super(message);
240 }
241}
242
243const parseError = async (res: Response): Promise<ApiError> => {
244 let tag = `HTTP ${res.status}`;
245 let message = res.statusText;
246 try {
247 const body: unknown = await res.clone().json();
248 if (typeof body === "object" && body !== null) {
249 const record = body as Record<string, unknown>;
250 if (typeof record._tag === "string") tag = record._tag;
251 if (typeof record.message === "string") message = record.message;
252 else if (typeof record.reason === "string") message = record.reason;
253 }
254 } catch {
255 try {
256 message = (await res.text()) || message;
257 } catch {
258 /* keep statusText */
259 }
260 }
261 return new ApiError(res.status, tag, message || tag);
262};
263
264// ── transport ───────────────────────────────────────────────────────────────
265
266const request = async <T>(
267 connection: Connection,
268 method: string,
269 path: string,
270 body?: unknown,
271): Promise<T> => {
272 const res = await fetch(`${connection.url}/api/v1${path}`, {
273 method,
274 // The Better Auth session cookie is the credential; anonymous
275 // requests carry none and the policy confines them to public reads.
276 credentials: "include",
277 headers: body !== undefined ? { "Content-Type": "application/json" } : {},
278 body: body !== undefined ? JSON.stringify(body) : undefined,
279 });
280 if (!res.ok) throw await parseError(res);
281 if (res.status === 204) return undefined as T;
282 const text = await res.text();
283 return (text.length === 0 ? undefined : JSON.parse(text)) as T;
284};
285
286const seg = encodeURIComponent;
287
288// ── repos ───────────────────────────────────────────────────────────────────
289
290export const listRepos = (
291 c: Connection,
292 query?: { owner?: string; cursor?: string; limit?: number },
293): Promise<Page<Repo>> => {
294 const params = new URLSearchParams();
295 if (query?.owner) params.set("owner", query.owner);
296 if (query?.cursor) params.set("cursor", query.cursor);
297 if (query?.limit) params.set("limit", String(query.limit));
298 const qs = params.size > 0 ? `?${params}` : "";
299 return request(c, "GET", `/repos${qs}`);
300};
301
302export const getRepo = (c: Connection, owner: string, repo: string) =>
303 request<Repo>(c, "GET", `/repos/${seg(owner)}/${seg(repo)}`);
304
305export const createRepo = (
306 c: Connection,
307 payload: {
308 owner: string;
309 name: string;
310 description?: string;
311 public?: boolean;
312 },
313) => request<RepoCreated>(c, "POST", `/repos`, payload);
314
315export const updateRepo = (
316 c: Connection,
317 owner: string,
318 repo: string,
319 payload: {
320 description?: string | null;
321 defaultBranch?: string;
322 readOnly?: boolean;
323 public?: boolean;
324 },
325) => request<Repo>(c, "PATCH", `/repos/${seg(owner)}/${seg(repo)}`, payload);
326
327export const deleteRepo = (c: Connection, owner: string, repo: string) =>
328 request<unknown>(c, "DELETE", `/repos/${seg(owner)}/${seg(repo)}`);
329
330export const compactRepo = (c: Connection, owner: string, repo: string) =>
331 request<unknown>(c, "POST", `/repos/${seg(owner)}/${seg(repo)}/compact`);
332
333// ── refs ────────────────────────────────────────────────────────────────────
334
335export const listRefs = (c: Connection, owner: string, repo: string) =>
336 request<{ head: string | null; refs: Ref[] }>(
337 c,
338 "GET",
339 `/repos/${seg(owner)}/${seg(repo)}/refs`,
340 );
341
342// ── objects ─────────────────────────────────────────────────────────────────
343
344export const getCommit = (
345 c: Connection,
346 owner: string,
347 repo: string,
348 oid: string,
349) =>
350 request<CommitInfo>(
351 c,
352 "GET",
353 `/repos/${seg(owner)}/${seg(repo)}/commits/${seg(oid)}`,
354 );
355
356export const getLog = (
357 c: Connection,
358 owner: string,
359 repo: string,
360 query?: { ref?: string; cursor?: string; limit?: number },
361): Promise<Page<CommitInfo>> => {
362 const params = new URLSearchParams();
363 if (query?.ref) params.set("ref", query.ref);
364 if (query?.cursor) params.set("cursor", query.cursor);
365 if (query?.limit) params.set("limit", String(query.limit));
366 const qs = params.size > 0 ? `?${params}` : "";
367 return request(c, "GET", `/repos/${seg(owner)}/${seg(repo)}/log${qs}`);
368};
369
370export const getTree = (
371 c: Connection,
372 owner: string,
373 repo: string,
374 oid: string,
375) =>
376 request<{ oid: string; entries: TreeEntry[] }>(
377 c,
378 "GET",
379 `/repos/${seg(owner)}/${seg(repo)}/trees/${seg(oid)}`,
380 );
381
382/** Raw file bytes at `ref` + `path` (the streaming non-JSON route). */
383export const getFile = async (
384 c: Connection,
385 owner: string,
386 repo: string,
387 options: { ref?: string; path: string },
388): Promise<Uint8Array> => {
389 const params = new URLSearchParams({ path: options.path });
390 if (options.ref) params.set("ref", options.ref);
391 const res = await fetch(
392 `${c.url}/api/v1/repos/${seg(owner)}/${seg(repo)}/file?${params}`,
393 {
394 credentials: "include",
395 },
396 );
397 if (!res.ok) throw await parseError(res);
398 return new Uint8Array(await res.arrayBuffer());
399};
400
401/** Changed files of a commit vs its first parent (empty tree for a root). */
402export const getCommitDiff = (
403 c: Connection,
404 owner: string,
405 repo: string,
406 oid: string,
407) =>
408 request<CommitDiff>(
409 c,
410 "GET",
411 `/repos/${seg(owner)}/${seg(repo)}/commits/${seg(oid)}/diff`,
412 );
413
414/**
415 * Three-dot comparison of two revisions (short/full refname or 40-hex
416 * oid; annotated tags peeled): merge base, ahead/behind, head-side
417 * commits, and the mergeBase..head file diff.
418 */
419export const compareCommits = (
420 c: Connection,
421 owner: string,
422 repo: string,
423 query: { base: string; head: string },
424) => {
425 const params = new URLSearchParams({ base: query.base, head: query.head });
426 return request<Comparison>(
427 c,
428 "GET",
429 `/repos/${seg(owner)}/${seg(repo)}/compare?${params}`,
430 );
431};
432
433/** JSON blob endpoint serves ≤ 1 MiB only (422 beyond; use /raw). */
434const MAX_JSON_BLOB = 1024 * 1024;
435
436/** Raw blob bytes by oid (the streaming non-JSON route, any size). */
437const getBlobRaw = async (
438 c: Connection,
439 owner: string,
440 repo: string,
441 oid: string,
442): Promise<Uint8Array> => {
443 const res = await fetch(
444 `${c.url}/api/v1/repos/${seg(owner)}/${seg(repo)}/blobs/${seg(oid)}/raw`,
445 { credentials: "include" },
446 );
447 if (!res.ok) throw await parseError(res);
448 return new Uint8Array(await res.arrayBuffer());
449};
450
451/**
452 * Blob bytes by oid. Uses the JSON base64 route for blobs ≤ 1 MiB and
453 * falls back to `/raw` for bigger ones — pass `size` (known from a
454 * {@link DiffEntry}) to skip the doomed JSON attempt entirely.
455 */
456export const getBlob = async (
457 c: Connection,
458 owner: string,
459 repo: string,
460 oid: string,
461 options?: { size?: number },
462): Promise<Uint8Array> => {
463 if (options?.size !== undefined && options.size > MAX_JSON_BLOB) {
464 return getBlobRaw(c, owner, repo, oid);
465 }
466 try {
467 const json = await request<{
468 oid: string;
469 size: number;
470 encoding: "base64";
471 content: string;
472 }>(c, "GET", `/repos/${seg(owner)}/${seg(repo)}/blobs/${seg(oid)}`);
473 const binary = atob(json.content);
474 const bytes = new Uint8Array(binary.length);
475 for (let index = 0; index < binary.length; index++) {
476 bytes[index] = binary.charCodeAt(index);
477 }
478 return bytes;
479 } catch (cause) {
480 // ObjectTooLarge — the size hint was absent or stale; stream it raw.
481 if (cause instanceof ApiError && cause.tag === "ObjectTooLarge") {
482 return getBlobRaw(c, owner, repo, oid);
483 }
484 throw cause;
485 }
486};
487
488// ── pull requests ───────────────────────────────────────────────────────────
489
490export type PullState = "open" | "closed" | "merged";
491
492/** Why `mergeable` is what it is (see {@link PullDetail}). */
493export type MergeableReason =
494 | "ff"
495 | "merge-commit"
496 | "conflict"
497 | "up-to-date"
498 | "unknown";
499
500/**
501 * A pull request. PRs track **live** branches by ref name — the record
502 * stores intent + lifecycle, never a diff snapshot; diff and mergeability
503 * are recomputed from current ref tips on every read.
504 */
505export interface Pull {
506 /** Per-repo monotonic PR number (1-based, never reused). */
507 number: number;
508 title: string;
509 body: string | null;
510 /** Full base ref name, e.g. `refs/heads/main`. */
511 baseRef: string;
512 /** Full head ref name, e.g. `refs/heads/feature`. */
513 headRef: string;
514 state: PullState;
515 /** Epoch milliseconds. */
516 createdAt: number;
517 updatedAt: number;
518 /** Epoch milliseconds; `null` unless `state` is `merged`. */
519 mergedAt: number | null;
520 /** FF: the head tip; the merge commit otherwise. Set iff merged. */
521 mergeCommit: string | null;
522}
523
524/**
525 * PR detail = the row + live computed compare fields. Live fields are
526 * `null` when uncomputable: a missing base/head branch, a saturated
527 * ancestor walk, or a merged PR (its record is `mergeCommit`).
528 */
529export interface PullDetail extends Pull {
530 /** Current tip of `baseRef`; `null` if the branch is gone. */
531 baseOid: string | null;
532 /** Current tip of `headRef`; `null` if the branch is gone. */
533 headOid: string | null;
534 mergeBase: string | null;
535 /** Commits on head not on base (`null` when the walk saturates). */
536 aheadBy: number | null;
537 /** Commits on base not on head. */
538 behindBy: number | null;
539 /**
540 * `true` = FF-able or trivially merge-able; `false` = conflicting
541 * paths or up-to-date; `null` = uncomputable.
542 */
543 mergeable: boolean | null;
544 mergeableReason: MergeableReason | null;
545}
546
547/** Response of a successful PR merge. */
548export interface MergeResult {
549 method: "ff" | "merge-commit";
550 /** The oid the base ref now points at. */
551 oid: string;
552 /** The PR after the merge (`state: "merged"`). */
553 pull: Pull;
554}
555
556/** Lists PRs, newest first. `state` defaults to `open` server-side. */
557export const listPulls = (
558 c: Connection,
559 owner: string,
560 repo: string,
561 query?: {
562 state?: PullState | "all";
563 cursor?: string;
564 limit?: number;
565 },
566): Promise<Page<Pull>> => {
567 const params = new URLSearchParams();
568 if (query?.state) params.set("state", query.state);
569 if (query?.cursor) params.set("cursor", query.cursor);
570 if (query?.limit) params.set("limit", String(query.limit));
571 const qs = params.size > 0 ? `?${params}` : "";
572 return request(c, "GET", `/repos/${seg(owner)}/${seg(repo)}/pulls${qs}`);
573};
574
575/** Reads one PR with live compare fields (ahead/behind/mergeable). */
576export const getPull = (
577 c: Connection,
578 owner: string,
579 repo: string,
580 number: number,
581) =>
582 request<PullDetail>(
583 c,
584 "GET",
585 `/repos/${seg(owner)}/${seg(repo)}/pulls/${number}`,
586 );
587
588/** Opens a PR. `base`/`head` accept short (`main`) or full branch names. */
589export const createPull = (
590 c: Connection,
591 owner: string,
592 repo: string,
593 payload: { title: string; body?: string; base: string; head: string },
594) =>
595 request<Pull>(c, "POST", `/repos/${seg(owner)}/${seg(repo)}/pulls`, payload);
596
597/** Patches title/body, or closes/reopens via `state`. */
598export const updatePull = (
599 c: Connection,
600 owner: string,
601 repo: string,
602 number: number,
603 payload: {
604 title?: string;
605 body?: string | null;
606 state?: "open" | "closed";
607 },
608) =>
609 request<Pull>(
610 c,
611 "PATCH",
612 `/repos/${seg(owner)}/${seg(repo)}/pulls/${number}`,
613 payload,
614 );
615
616/**
617 * Merges an open PR: fast-forward when possible, else a merge commit iff
618 * the three-way tree merge is trivial. `expectedHeadOid` guards against
619 * a force-push racing the merge (409 RefConflict when stale).
620 */
621export const mergePull = (
622 c: Connection,
623 owner: string,
624 repo: string,
625 number: number,
626 payload?: { message?: string; expectedHeadOid?: string },
627) =>
628 request<MergeResult>(
629 c,
630 "POST",
631 `/repos/${seg(owner)}/${seg(repo)}/pulls/${number}/merge`,
632 payload ?? {},
633 );