fix(review): export the patch as bytes, not a JSON string (#1077) - #1141
Conversation
GET /api/v2/review/patch answered 500 for any repository with one non-UTF-8 byte in a tracked file. GitPython decodes with surrogateescape — lossless in Python, but a lone surrogate cannot be serialised into JSON, so the response raised. The Export Patch button on /review was dead for those repos, with a 500 indistinguishable from the server being down. A patch is a file that gets fed back to `git apply`, so it has to be byte-faithful end to end. Fixing only the 500 would have left a patch that reached the browser and still failed to apply — the issue is explicit that both halves need doing together. Backend: - New git.get_patch_bytes() using GitPython's stdout_as_string=False, so there is no decode to undo. GitPython strips the trailing newline that `git diff` emits and `git apply` wants, so it is restored — my first version was byte-identical except for that one byte, caught only by diffing against git rather than checking the 0xe9 byte survived. - The endpoint returns application/octet-stream with the filename in Content-Disposition, where a file download already expects it. Frontend: - reviewApi.getPatch fetches arraybuffer and returns both the bytes and a decoded string; the string is for display and clipboard only. - ExportPatchModal builds its Blob from the bytes. It previously re-encoded the string as UTF-8, so even a successful response would not round-trip. jest.setup.js gains TextEncoder/TextDecoder from Node's util: jsdom ships neither, and every browser has them. Polyfilling the environment beats contorting production code around a test-environment gap — the file already does this for scrollIntoView. Two existing tests broke, and the second matters: - test_patch_filename_is_derived_from_the_branch read `filename` from the JSON body; it now reads Content-Disposition. - test_a_raising_core_call_is_a_500 patched `git.get_patch`, which this endpoint no longer calls — so the "unexpected core failure becomes a 500" guard had silently stopped covering this route while still passing. Now points at get_patch_bytes. Backend: 6431 passed, 49 skipped. Web-UI: 1278 passed, lint and build clean.
|
Claude finished @frankbria's task in 5m 41s —— View job GLM precision review — doneScoped to concrete defects only (logic, crashes, security, data loss, contract breaks). Style, architecture, naming, and test coverage are covered by the general review. 1 finding (minor):
Verified clean (no defect):
|
ReviewThe core fix is sound: Two things worth a look before merge: 1. headers={"Content-Disposition": f'attachment; filename="{filename}"'},
2. if not repo.head.is_valid():
return b""
args = ["--patch", "--full-index"]
if staged:
args.insert(0, "--cached")This check runs before branching on # get_diff, for comparison
if staged:
if repo.head.is_valid():
return repo.git.diff("--cached")
return ""
else:
return repo.git.diff() # <-- no HEAD checkSo in a workspace with no commits yet but staged + further-modified files (plausible right after Minor: Nothing else stood out — error handling, rate limiting, and the auth wiring on this router are all unchanged and fine. The jest polyfill and contract-test updates look correct and match the stated goal (environment matching reality rather than working around it in prod code). |
) Review finding, and a regression this PR introduced. With responseType: 'arraybuffer', the ERROR body arrives as an ArrayBuffer too, so the interceptor's `data.detail` was undefined and the backend's reason collapsed into axios's generic "Request failed with status code 500". The Export Patch toast used to read "Failed to get patch: <git reason>" — I fixed a 500 while degrading the message for every other failure on the route. Fixed in the shared interceptor, which is the only place it can be: by the time a per-call catch runs the buffer is gone and the rejection carries only the generic message. The check is Object.prototype.toString, not `instanceof ArrayBuffer`. The instanceof failed in jest because jsdom's ArrayBuffer and Node's are different realms — a test artifact, but it points at real fragility: a buffer from an iframe would fail the same check in a browser. Two tests: the git reason survives a binary error body, and a non-JSON binary body (an HTML gateway-timeout page) falls back to axios's message instead of throwing. Verified non-tautological by reverting the decode. Web-UI: 1280 passed, lint and build clean. Backend: 6431 passed, 49 skipped.
|
Claude finished @frankbria's task in 3m 6s —— View job GLM precision review — doneRe-scoped to the new commit ✅ No new defects introduced by
Still open (not re-posted — already on the PR from the prior
Both live in files this commit didn't modify, so they're unaffected by |
ReviewThe core byte-fidelity fix is solid: The second commit (fb5ca4d) correctly fixes the regression flagged by both prior reviews (the GLM bot and my own): decoding The other two findings from my previous review on this PR are still open — I re-checked the current diff and neither has been addressed: 1. filename = f"{branch.replace('/', '-')}.patch"
...
headers={"Content-Disposition": f'attachment; filename="{filename}"'},Still unchanged. Two issues stand:
None of the new tests exercise a non-ASCII or quote-containing branch name, so this gap isn't caught by the suite either. 2. if not repo.head.is_valid():
return b""
args = ["--patch", "--full-index"]
if staged:
args.insert(0, "--cached")The HEAD check runs unconditionally, before branching on Minor, still applies: Everything else lines up with what's described in the PR body: Given #1 and #2 are still outstanding from the prior round and are real correctness/security-adjacent gaps (header injection potential, silent data loss for a plausible pre-first-commit workflow), I'd hold off on merging until at least the filename sanitization is addressed — the HEAD-check inconsistency is lower urgency but should get a tracking note if not fixed here. |
Closes #1077.
Both halves, because either alone is useless
The 500 and the browser-side re-encode are the same bug wearing two hats: a
patch is a file that gets fed back to
git apply. Fixing only the crashwould have produced a patch that arrives intact and still fails to apply.
Backend — new
git.get_patch_bytes()using GitPython'sstdout_as_string=False, so there is no surrogateescape decode to undo. Theendpoint returns
application/octet-stream, with the filename moved toContent-Dispositionwhere a file download already expects it.Frontend —
reviewApi.getPatchfetchesarraybufferand returns the bytesalongside a decoded string used only for the textarea and clipboard.
ExportPatchModalbuilds itsBlobfrom the bytes.Caught by verifying rather than assuming
GitPython strips the trailing newline. My first version was byte-identical
to
git diffexcept for one missing\n— whichgit applywants. I onlyfound it because I compared against
git diffdirectly instead of checking thatthe
0xe9byte survived. Restored, and asserted.Evidence
The test that matters applies the exported patch to a clean clone and compares
bytes:
GET /review/patchon a Latin-1 diffgit diff --patch --full-indexgit applyround-trip[0x2b, 0x63, 0x61, 0x66, 0xe9, 0x0a]—0xe9intact, not0xc3 0xa9The Blob test is verified non-tautological: restoring the old
new Blob([patchContent])fails it.Two existing tests broke, and the second is the interesting one
test_patch_filename_is_derived_from_the_branchreadfilenamefrom the JSONbody; it now reads
Content-Disposition.test_a_raising_core_call_is_a_500patchedgit.get_patch— a functionthis endpoint no longer calls. So the "unexpected core failure becomes a 500"
guard returned 200 and had silently stopped covering this route. Repointed
at
get_patch_bytes.That is the second time in two issues that redirecting an endpoint's call path
quietly detached a safety test from the thing it guards (the first was two
path-containment tests in #1066). Worth watching for.
jest.setup.jsGains
TextEncoder/TextDecoderfrom Node'sutil. jsdom ships neither andevery browser has them, so this makes the test environment match reality rather
than working around the gap in production code — the file already polyfills
scrollIntoViewin the same style.Acceptance criteria
git diff --patch --full-index, asserted viagit applyExportPatchModalbuilds its Blob from bytesBackend: 6431 passed, 49 skipped. Web-UI: 1278 passed, lint and build clean.
Breaking change
GET /api/v2/review/patchno longer returns JSON. Any consumer readingresponse.patchneeds the arraybuffer treatment — the in-repo client is updatedhere, and that reshaping is the fix, not a side effect.