// Minimal GitHub REST client (Git Data API) used for sync. import type { GitHubSettings } from '$lib/state/settings.svelte'; export class GitHubError extends Error { constructor( message: string, readonly status: number ) { super(message); } } export interface TreeEntry { path: string; type: 'blob' | 'tree' | 'commit'; sha: string; mode: string; } /** Git's blob sha for an empty file (used for folder `.gitkeep`s). */ export const EMPTY_BLOB_SHA = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'; export class GitHub { constructor(private s: GitHubSettings) {} private async req(method: string, path: string, body?: unknown): Promise { const url = path.startsWith('https://') ? path : `https://api.github.com/repos/${this.s.owner}/${this.s.repo}${path}`; const res = await fetch(url, { method, headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${this.s.token}`, 'X-GitHub-Api-Version': '2022-11-28', ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined, cache: 'no-store' }); if (!res.ok) { let msg = res.statusText; try { msg = (await res.json()).message ?? msg; } catch { /* not json */ } throw new GitHubError(`GitHub ${method} ${path}: ${res.status} ${msg}`, res.status); } return res.status === 204 ? (undefined as T) : res.json(); } async checkAccess() { const repo = await this.req<{ permissions?: { push?: boolean }; default_branch: string }>('GET', ''); if (repo.permissions && !repo.permissions.push) throw new Error('The token has no push access to this repository.'); return repo; } /** Head commit sha of the branch, or null if the branch (or repo) is empty. */ async head(branch = this.s.branch): Promise { try { const ref = await this.req<{ object: { sha: string } }>('GET', `/git/ref/heads/${enc(branch)}`); return ref.object.sha; } catch (e) { if (e instanceof GitHubError && (e.status === 404 || e.status === 409)) return null; throw e; } } async commit(sha: string) { return this.req<{ sha: string; tree: { sha: string } }>('GET', `/git/commits/${sha}`); } async tree(sha: string): Promise { const t = await this.req<{ tree: TreeEntry[]; truncated: boolean }>('GET', `/git/trees/${sha}?recursive=1`); if (t.truncated) console.warn('GitHub tree listing truncated; some files may be missing from sync'); return t.tree; } async blob(sha: string): Promise { const b = await this.req<{ content: string; encoding: string }>('GET', `/git/blobs/${sha}`); return fromBase64(b.content.replace(/\n/g, '')); } async createBlob(bytes: Uint8Array): Promise { const r = await this.req<{ sha: string }>('POST', '/git/blobs', { content: toBase64(bytes), encoding: 'base64' }); return r.sha; } async createTree(base: string | null, entries: { path: string; sha: string | null }[]): Promise { const r = await this.req<{ sha: string }>('POST', '/git/trees', { ...(base ? { base_tree: base } : {}), tree: entries.map((e) => ({ path: e.path, mode: '100644', type: 'blob', sha: e.sha })) }); return r.sha; } async createCommit(message: string, tree: string, parent: string | null): Promise { const r = await this.req<{ sha: string }>('POST', '/git/commits', { message, tree, parents: parent ? [parent] : [] }); return r.sha; } /** Move the branch to `sha`. Throws a 422 GitHubError if not a fast-forward. */ async updateRef(sha: string, create: boolean) { if (create) await this.req('POST', '/git/refs', { ref: `refs/heads/${this.s.branch}`, sha }); else await this.req('PATCH', `/git/refs/heads/${enc(this.s.branch)}`, { sha, force: false }); } /** The Git Data API refuses to work on a repo with no commits at all. */ async initEmptyRepo() { await this.req('PUT', `/contents/.papure`, { message: 'Initialise Papure vault', content: toBase64(new TextEncoder().encode('Papure vault\n')) }); } } const enc = (s: string) => s.split('/').map(encodeURIComponent).join('/'); export function toBase64(bytes: Uint8Array): string { let bin = ''; const CHUNK = 0x8000; for (let i = 0; i < bytes.length; i += CHUNK) { bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); } return btoa(bin); } export function fromBase64(b64: string): Uint8Array { const bin = atob(b64); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; }