diff --git a/extension/src/service-worker/__tests__/git-push.test.ts b/extension/src/service-worker/__tests__/git-push.test.ts index d03ebe1..28ab6f2 100644 --- a/extension/src/service-worker/__tests__/git-push.test.ts +++ b/extension/src/service-worker/__tests__/git-push.test.ts @@ -147,14 +147,32 @@ describe('commitSignedPush engine', () => { expect(args.onAuth()).toEqual(AUTH); }); - test('5 — push rejection throws with server detail', async () => { + test('5a — push rejection throw path (real): GitPushError thrown by isomorphic-git propagates', async () => { + // isomorphic-git THROWS GitPushError (never returns) when a pre-receive hook declines. + // This is the real rejection path; commitSignedPush must not swallow it. + const hookError = Object.assign( + new Error('One or more branches were not updated: pre-receive hook declined'), + { code: 'GitPushError' }, + ); // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.mocked(git.push).mockResolvedValueOnce({ ok: false, errors: ['pre-receive hook declined'] } as any); + vi.mocked(git.push).mockRejectedValueOnce(hookError); const files: CommitFile[] = [{ path: 'vault/item.enc', content: new Uint8Array([1]) }]; await expect( commitSignedPush({ gitUrl: GIT_URL, branch: BRANCH, auth: AUTH, files, message: 'test', signer: makeSigner() }), - ).rejects.toThrow('pre-receive hook declined'); + ).rejects.toThrow('not updated'); + }); + + test('5b — push rejection defensive path: non-throwing ok:false triggers guard', async () => { + // Belt-and-suspenders: if push() somehow resolves with ok:false (non-standard), + // the guard must still throw with the error detail from the result. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(git.push).mockResolvedValueOnce({ ok: false, error: 'unpack failed', refs: {} } as any); + const files: CommitFile[] = [{ path: 'vault/item.enc', content: new Uint8Array([1]) }]; + + await expect( + commitSignedPush({ gitUrl: GIT_URL, branch: BRANCH, auth: AUTH, files, message: 'test', signer: makeSigner() }), + ).rejects.toThrow('unpack failed'); }); }); diff --git a/extension/src/service-worker/git-push.ts b/extension/src/service-worker/git-push.ts index cfbbe67..44eed7f 100644 --- a/extension/src/service-worker/git-push.ts +++ b/extension/src/service-worker/git-push.ts @@ -23,10 +23,9 @@ import { createMemFs } from './mem-fs'; import { buildSshSig } from './sshsig'; import type { CommitFile, CommitSigner } from './git-host'; -// Runtime push results from isomorphic-git include an `errors` array that is -// not reflected in the TypeScript PushResult type. Intersect it here so the -// rejection check compiles under strict mode. -type PushResultRuntime = { ok: boolean; errors?: string[] }; +// isomorphic-git PushResult: { ok: boolean; error: string | null; refs: {[ref]: {ok, error}} } +// There is NO top-level `errors: string[]`; the type below reflects reality. +type PushResultRuntime = { ok: boolean; error?: string | null; refs?: Record }; /** * Clone `gitUrl` at depth 1, write `files`, sign the commit with SSHSIG, and @@ -93,8 +92,7 @@ export async function commitSignedPush(opts: { }), }); - // Push over git-receive-pack. The runtime result includes `errors[]` when a - // pre-receive hook declines — cast to PushResultRuntime to access it. + // Push over git-receive-pack. const res = await git.push({ fs, http, dir, url: opts.gitUrl, @@ -103,11 +101,13 @@ export async function commitSignedPush(opts: { onAuth, }) as unknown as PushResultRuntime; - const ok = res?.ok && (!res.errors || res.errors.length === 0); - if (!ok) { - throw new Error( - `org push rejected by server/hook: ${JSON.stringify(res?.errors ?? res)}`, - ); + // isomorphic-git THROWS GitPushError when the server/pre-receive hook declines + // a ref (the normal org-write rejection path); that error propagates to the + // caller unchanged. This guard is defensive belt-and-suspenders for any + // non-throwing failure: push() only returns when ok && every ref ok. + const refsOk = !res?.refs || Object.values(res.refs).every((r) => r.ok); + if (!res || res.ok !== true || !refsOk) { + throw new Error(`org push rejected by server/hook: ${res?.error ?? JSON.stringify(res)}`); } }