diff --git a/extension/src/service-worker/__tests__/git-host-extensions.test.ts b/extension/src/service-worker/__tests__/git-host-extensions.test.ts new file mode 100644 index 0000000..12267a1 --- /dev/null +++ b/extension/src/service-worker/__tests__/git-host-extensions.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GiteaHost } from '../gitea'; +import { GitHubHost } from '../github'; + +describe('lastCommit (Gitea)', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + + it('returns commit metadata when API succeeds', async () => { + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify([ + { sha: 'abc1234567', commit: { author: { name: 'Alice', date: '2026-04-20T12:00:00Z' } } }, + ]), { status: 200 })); + const host = new GiteaHost('https://git.example.com', 'user/vault', 'tok'); + const result = await host.lastCommit('manifest.enc'); + expect(result).toEqual({ sha: 'abc1234', author: 'Alice', date: '2026-04-20T12:00:00Z' }); + }); + + it('returns null on 404', async () => { + fetchSpy.mockResolvedValueOnce(new Response('', { status: 404 })); + const host = new GiteaHost('https://git.example.com', 'user/vault', 'tok'); + expect(await host.lastCommit('manifest.enc')).toBeNull(); + }); + + it('returns null on network error', async () => { + fetchSpy.mockRejectedValueOnce(new Error('network')); + const host = new GiteaHost('https://git.example.com', 'user/vault', 'tok'); + expect(await host.lastCommit('manifest.enc')).toBeNull(); + }); +}); + +describe('lastCommit (GitHub)', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + + it('returns commit metadata when API succeeds', async () => { + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify([ + { sha: 'def4567890', commit: { author: { name: 'Bob', date: '2026-04-22T15:00:00Z' } } }, + ]), { status: 200 })); + const host = new GitHubHost('user/vault', 'tok'); + const result = await host.lastCommit('manifest.enc'); + expect(result).toEqual({ sha: 'def4567', author: 'Bob', date: '2026-04-22T15:00:00Z' }); + }); + + it('returns null when commits list is empty', async () => { + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })); + const host = new GitHubHost('user/vault', 'tok'); + expect(await host.lastCommit('manifest.enc')).toBeNull(); + }); +}); diff --git a/extension/src/service-worker/git-host.ts b/extension/src/service-worker/git-host.ts index cdb1e78..ef4e071 100644 --- a/extension/src/service-worker/git-host.ts +++ b/extension/src/service-worker/git-host.ts @@ -17,6 +17,11 @@ export interface GitHost { /// List file names in a directory (non-recursive). listDir(path: string): Promise; + /// Best-effort: returns metadata for the most recent commit touching `path`. + /// Returns null if the path has no commits, the API fails, or the host + /// doesn't support the lookup. Callers must tolerate null. + lastCommit(path: string): Promise<{ sha: string; author: string; date: string } | null>; + /// Write an opaque binary blob to the repo. Optimized for large /// attachments — implementations switch from Contents API to Git /// Data API when content exceeds BLOB_THRESHOLD_BYTES. diff --git a/extension/src/service-worker/gitea.ts b/extension/src/service-worker/gitea.ts index ddceb00..c4b7f77 100644 --- a/extension/src/service-worker/gitea.ts +++ b/extension/src/service-worker/gitea.ts @@ -16,14 +16,17 @@ import { uint8ArrayToBase64, base64ToUint8Array, BLOB_THRESHOLD_BYTES } from './ export class GiteaHost implements GitHost { private baseUrl: string; private gitApiBase: string; + private commitsUrl: string; private branch: string = 'main'; private headers: Record; constructor(hostUrl: string, repoPath: string, apiToken: string) { // Remove trailing slash from hostUrl const host = hostUrl.replace(/\/+$/, ''); - this.baseUrl = `${host}/api/v1/repos/${repoPath}/contents`; - this.gitApiBase = `${host}/api/v1/repos/${repoPath}/git`; + const apiUrl = `${host}/api/v1`; + this.baseUrl = `${apiUrl}/repos/${repoPath}/contents`; + this.gitApiBase = `${apiUrl}/repos/${repoPath}/git`; + this.commitsUrl = `${apiUrl}/repos/${repoPath}/commits`; this.headers = { 'Authorization': `token ${apiToken}`, 'Content-Type': 'application/json', @@ -115,6 +118,24 @@ export class GiteaHost implements GitHost { return json.map((item: { name: string }) => item.name); } + async lastCommit(path: string): Promise<{ sha: string; author: string; date: string } | null> { + try { + const url = `${this.commitsUrl}?path=${encodeURIComponent(path)}&limit=1`; + const resp = await fetch(url, { headers: this.headers }); + if (!resp.ok) return null; + const json = await resp.json(); + if (!Array.isArray(json) || json.length === 0) return null; + const c = json[0]; + return { + sha: String(c.sha).slice(0, 7), + author: c.commit?.author?.name ?? 'unknown', + date: c.commit?.author?.date ?? '', + }; + } catch { + return null; + } + } + async putBlob(path: string, content: Uint8Array, message: string): Promise { if (content.length <= BLOB_THRESHOLD_BYTES) { await this.writeFile(path, content, message); diff --git a/extension/src/service-worker/github.ts b/extension/src/service-worker/github.ts index 0ee250c..709472d 100644 --- a/extension/src/service-worker/github.ts +++ b/extension/src/service-worker/github.ts @@ -14,12 +14,14 @@ import { uint8ArrayToBase64, base64ToUint8Array, BLOB_THRESHOLD_BYTES } from './ export class GitHubHost implements GitHost { private baseUrl: string; private gitApiBase: string; + private commitsUrl: string; private branch: string = 'main'; private headers: Record; constructor(repoPath: string, apiToken: string) { this.baseUrl = `https://api.github.com/repos/${repoPath}/contents`; this.gitApiBase = `https://api.github.com/repos/${repoPath}/git`; + this.commitsUrl = `https://api.github.com/repos/${repoPath}/commits`; this.headers = { 'Authorization': `Bearer ${apiToken}`, 'Content-Type': 'application/json', @@ -109,6 +111,24 @@ export class GitHubHost implements GitHost { return json.map((item: { name: string }) => item.name); } + async lastCommit(path: string): Promise<{ sha: string; author: string; date: string } | null> { + try { + const url = `${this.commitsUrl}?path=${encodeURIComponent(path)}&per_page=1`; + const resp = await fetch(url, { headers: this.headers }); + if (!resp.ok) return null; + const json = await resp.json(); + if (!Array.isArray(json) || json.length === 0) return null; + const c = json[0]; + return { + sha: String(c.sha).slice(0, 7), + author: c.commit?.author?.name ?? 'unknown', + date: c.commit?.author?.date ?? '', + }; + } catch { + return null; + } + } + async putBlob(path: string, content: Uint8Array, message: string): Promise { if (content.length <= BLOB_THRESHOLD_BYTES) { // Contents API path — same as writeFile