fix(ext/sw): model isomorphic-git PushResult correctly; test the real push-rejection throw

PushResult has a singular `error: string | null` and a `refs` map — there
is no top-level `errors: string[]`. Replace the fabricated type and the
`errors`-based rejection guard with a correct PushResultRuntime type and a
belt-and-suspenders refsOk check. The real rejection path is a thrown
GitPushError (isomorphic-git throws, never returns, when a hook declines);
replace the single fiction-testing test with (a) a throw-path test that mocks
push to reject with a GitPushError and (b) a defensive non-throwing test that
mocks push to resolve { ok: false, error: 'unpack failed' }.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKBbrAjmpVXMEK15pNi3Ha
This commit is contained in:
adlee-was-taken
2026-06-26 00:19:34 -04:00
parent 56dec78f44
commit d942650f9f
2 changed files with 32 additions and 14 deletions

View File

@@ -147,14 +147,32 @@ describe('commitSignedPush engine', () => {
expect(args.onAuth()).toEqual(AUTH); 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 // 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]) }]; const files: CommitFile[] = [{ path: 'vault/item.enc', content: new Uint8Array([1]) }];
await expect( await expect(
commitSignedPush({ gitUrl: GIT_URL, branch: BRANCH, auth: AUTH, files, message: 'test', signer: makeSigner() }), 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');
}); });
}); });

View File

@@ -23,10 +23,9 @@ import { createMemFs } from './mem-fs';
import { buildSshSig } from './sshsig'; import { buildSshSig } from './sshsig';
import type { CommitFile, CommitSigner } from './git-host'; import type { CommitFile, CommitSigner } from './git-host';
// Runtime push results from isomorphic-git include an `errors` array that is // isomorphic-git PushResult: { ok: boolean; error: string | null; refs: {[ref]: {ok, error}} }
// not reflected in the TypeScript PushResult type. Intersect it here so the // There is NO top-level `errors: string[]`; the type below reflects reality.
// rejection check compiles under strict mode. type PushResultRuntime = { ok: boolean; error?: string | null; refs?: Record<string, { ok: boolean; error?: string | null }> };
type PushResultRuntime = { ok: boolean; errors?: string[] };
/** /**
* Clone `gitUrl` at depth 1, write `files`, sign the commit with SSHSIG, and * 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 // Push over git-receive-pack.
// pre-receive hook declines — cast to PushResultRuntime to access it.
const res = await git.push({ const res = await git.push({
fs, http, dir, fs, http, dir,
url: opts.gitUrl, url: opts.gitUrl,
@@ -103,11 +101,13 @@ export async function commitSignedPush(opts: {
onAuth, onAuth,
}) as unknown as PushResultRuntime; }) as unknown as PushResultRuntime;
const ok = res?.ok && (!res.errors || res.errors.length === 0); // isomorphic-git THROWS GitPushError when the server/pre-receive hook declines
if (!ok) { // a ref (the normal org-write rejection path); that error propagates to the
throw new Error( // caller unchanged. This guard is defensive belt-and-suspenders for any
`org push rejected by server/hook: ${JSON.stringify(res?.errors ?? res)}`, // 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)}`);
} }
} }