diff --git a/CLAUDE.md b/CLAUDE.md index bc9772e..b64ee8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,7 +132,8 @@ cd scripts/parity && python3 -m http.server 8099 # http://127.0.0.1:8099/index.h - **CodeView 인스턴스 수명 — 재생성은 `!codeView`일 때만**: `renderPatch`가 CodeView를 새로 만드는 건 첫 렌더와 빈 상태 복귀뿐이다. unified↔split 전환은 살아 있는 인스턴스에 `setOptions(codeViewOptions())` → `setItems` → `render`로 태운다. 재생성하면 `diffMount.replaceChildren()`이 **스크롤 컨테이너 자신**(`#diff`가 곧 `diffMount`)을 비워 scrollHeight가 무너지고 브라우저가 scrollTop을 0으로 클램프해, 읽던 위치를 잃는다. 엔진은 `diffStyle`을 item-layout 옵션으로 취급하고(`hasItemLayoutOptionChanged`) `setOptions` 진입 즉시 `capturePendingLayoutAnchor()`로 앵커를 잡아 렌더 경로에서 `resolveAnchoredScrollTop()`으로 뷰포트를 붙든다(픽셀이 아니라 의미론적 앵커 — split은 scrollHeight가 대략 절반이라 픽셀 복원은 엉뚱한 파일에 착지한다). **호출 순서 자체가 계약이다**: `setOptions`가 `setItems`보다 먼저여야 앵커가 전환 전 레이아웃을 본다. `setOptions`가 건 인덱스 0 리셋이 뒤이은 `setItems`의 부분 리셋에 지워지지 않는 건 `markLayoutDirtyFromIndex()`가 기존 인덱스와 min을 취하기 때문이라, 순서를 뒤집으면 테스트가 못 잡는 채로 조용히 깨진다. `config.overscrollSize`를 생성 분기에서만 세팅해도 되는 이유도 여기 있다 — `setOptions`는 `config`를 건드리지 않아 이후 옵션 변경을 전부 살아남는다. 회귀망: `diffstyle-scroll.e2e.ts`(양방향 앵커 오프셋 + `data-diff-type`). - **평범한 데이터 갱신에도 픽셀 `scrollTo`를 얹지 말 것**: refresh·`--watch` 갱신 경로는 `setItems` → `render`만 부르고 스크롤 복원을 엔진에 맡긴다. `reconcileItems`가 `markLayoutDirtyFromIndex`를 세우면 렌더 경로가 `layoutDirtyIndex != null`을 보고 scroll correction을 무조건 재무장하고, 캐시된 앵커가 없으면 `getScrollAnchor`가 `renderState`에서 새로 만들어 보정한다 — 즉 이미 의미론적으로 보존된다. 여기에 `scrollTo({type:"position"})`를 얹으면 잉여가 아니라 **해롭다**: ① `position` 타깃은 애초에 항등 복원이 아니다 — `resolveScrollTargetTop`이 클램프되지 않은 값에서 `getStickyHeaderOffset()`(= `diffHeaderHeight` 44, 우리가 `stickyHeaders: true`를 주고 `disableFileHeader`를 안 주므로)을 빼므로, 모든 refresh·watch 폴링마다 뷰포트가 44px씩 위로 밀렸다, ② 뷰포트 *위쪽* 파일 길이가 변하면 아래 내용이 통째로 밀리는데 옛 픽셀로 되돌아가 읽던 줄이 어긋난다(실측: 60줄 증가에 1244px 드리프트), ③ `scrollTo`가 세우는 `pendingScrollTarget`을 프레임이 앵커보다 우선해 적용하므로, 스타일 전환의 렌더가 아직 큐에만 있는 1프레임 창에 갱신이 겹치면(그 창에선 `renderedDiffStyle`이 이미 갱신돼 이 갱신 분기로 들어온다) 전환 전 픽셀값이 앵커를 덮어써 split 기준 거의 바닥으로 착지한다. 보정이 항상 도는 건 아니고 그럴 필요도 없다: `setItems`엔 아무것도 dirty로 안 세우는 append 전용 경로가 있고(위쪽이 안 움직였으니 보정할 게 없다), 앵커 아이템이 사라지거나 목록이 렌더 윈도우보다 작아져 `renderState`가 리셋되면 앵커 해석이 비어 돌아온다 — 그 폴백은 클램프된 현재 위치라 옛 "값 −44"보다 낫다. 회귀망: `update-anchor.e2e.ts`(위쪽 파일 증가 + rAF 게이트로 결정화한 한 프레임 레이스). - **`#diff`에 innerHTML을 쓰기 전엔 살아 있는 CodeView가 없는지 확인**: 위와 같은 이유로 이 노드는 CodeView가 `setup()`에서 자기 컨테이너를 append한 스크롤 컨테이너라, 덮어쓰면 그 컨테이너가 문서에서 떨어져 나간다. `CodeView.setup()`은 이미 setup된 인스턴스의 재부착을 거부하므로(`already setup`) 인스턴스를 새로 만들기 전까지 패널이 영구히 빈 채로 남는다. 그래서 `load()`의 실패 카드는 `!codeView`로 가드한다 — `!lastFiles`가 아니다: 변경 없는 리포는 `lastFiles === []`(truthy)지만 `teardownViews()`로 이미 codeView가 비워진 뒤라 카드를 쓰는 게 안전하고, `!lastFiles`로 걸면 그 경우에 상태 라벨만 실패를 말하고 화면은 "No changes."를 계속 주장한다. 회귀망: `load-failure.e2e.ts`. -- **"Loading…" 자가 치유 — 세 부분이 함께여야 동작한다.** Bun 1.3.x의 `$`(ShellPromise)는 **64KB 파이프 버퍼를 넘는 stdout을 받는 호출에서** resolve도 reject도 없이 영구 pending이 될 수 있다 — 호출이 겹치면 거의 확정이고 완전 순차여도 결국 걸린다(자식은 사라지고 좀비도 없고 이벤트 루프도 정상인데 프라미스만 안 끝난다 — 실측; 1.3.12·1.3.14 재현, 업스트림은 1.4.0에서 수정). 예전엔 외부 프로세스 생성 경합이 필요하다고 봤지만 아니었다 — 200KB 파일 12개를 `BUILD_CONCURRENCY=8`로 읽으면 첫 호출에 죽고, 60KB는 멀쩡하다. 그래서 출력이 64KB를 넘을 수 있는 서버의 git 호출은 전부 `$`가 아니라 **`gitOutput.ts`의 `gitBytes`/`gitText`(`Bun.spawn`)**를 탄다 — `showBytes`(파일별 버스트), `summary.ts`, `getDiffFiles`의 `diff --name-status`·`ls-files --others`, 지문의 `status`, `refs.ts`의 `for-each-ref`·`worktree list`. 남은 `$`는 `rev-parse`·`merge-base`·`gh pr view`처럼 출력 크기가 리포 규모와 무관한 호출뿐이다. 크기는 필요조건일 뿐이다 — 같은 크기라도 호출에 따라 안 멈추기도 한다(`worktree list` 110KB, `for-each-ref` 606KB는 한 번도 안 멈췄다). **`$`로 되돌리지 말 것** — 회귀망 `diff-large-blob.test.ts`·`git-output.test.ts`·`git-large-output.test.ts`는 행업을 1.3.x에서만 잡으므로(최신 Bun에선 `$`도 통과한다) CI의 `test-bun13` 잡이 셋을 Bun 1.3.14로 고정해 돌린다. **다만 호출처마다 판별력이 다르다.** `git-large-output.test.ts`는 호출처마다 그 함수를 8번 동시에 불러 출력을 겹치게 만드는데, 지문·`name-status`·`ls-files`는 되돌리면 첫 라운드에 확실히 죽지만(각 3/3 — `ls-files` 케이스는 `name-status`도 거치므로 그걸 되돌려도 함께 죽는다) `for-each-ref`는 좁은 구간(참조 600~800개 ≈ 180~240KB)에서만 멈춰 8-way로는 20라운드를 돌려도 5번 중 3번만 잡혔고, 그 케이스만 16-way로 올려 11번 중 11번 잡는다(macOS). `worktree list`는 옮겼지만 멈춤을 재현하지 못해(죽은 워크트리 400개로 110KB, 8·16-way 10라운드씩 8번 무사) 테스트가 지키지 않는다. `summary.ts`는 순차 호출이라 아예 결정론적으로 재현되지 않아(437KB 출력에서 한 번은 18번째 호출에 걸리고 다른 한 번은 150회 무사했다) **그 파일이 `$`로 돌아가는 것은 테스트가 못 잡는다** — 헬퍼 자체의 행업만 `git-output.test.ts`가 잡는다(전부 실측). 큰 출력의 `$`는 이제 없지만 아래 세 부품은 그대로 둔다 — flight 타임아웃은 원인을 가리지 않는 안전망이고(예: `baseFlight` 안의 `gh pr view`는 네트워크를 기다린다), 남은 작은 `$`가 원리적으로 안전하다는 증명은 없다(60KB는 멀쩡했다는 관측뿐이다). **새 git 호출의 출력이 64KB를 넘을 수 있으면 `$`가 아니라 `gitBytes`/`gitText`로 쓴다.** `ShellPromise`엔 `.timeout()`/`.kill()`이 없어 `Promise.race`가 유일한 레버다. 예전엔 `singleFlight`가 키를 `.finally()`에서만 지워 그 키가 **영구 오염**되고 이후 모든 요청이 죽은 프라미스에 합류했다. 지금은 ① `singleFlight`가 flight를 타임아웃과 race해 키를 풀고(`SingleFlightTimeoutError`로 호출자가 타임아웃을 구분한다), ② `awaitFlight`가 **타임아웃만** 503+`Retry-After`로 흡수하며(그 외 에러는 다시 던져 기존 동작 보존), ③ `browser/main.ts`의 `fetchDiff`가 503·네트워크 실패를 1회 재시도한다(403·400은 terminal). **따로 넣으면 어느 쪽도 동작하지 않는다** — 키를 안 풀고 재시도하면 같은 죽은 프라미스에 다시 합류한다. **상수 제약은 per-flight가 아니라 합이다**: `/api/diff`가 `resolveBaseCached` → `diffFlight`를 순차로 두 번 기다리므로 45+45=90 < `idleTimeout` 120(슬랙 30초). 재시도가 1회인 이유도 `BUILD_CONCURRENCY=8`이 호출당이라(전역 세마포어 아님) 시도가 겹치면 동시 git 서브프로세스가 배로 늘어 재시도가 스스로를 느리게 만들기 때문이다. **덮지 않는 곳**: `isGitRepo`는 flight 앞에서, `getRepoSummary`·`getFileBytes`는 뒤에서 돌고 flight로 안 감싸였다 — 거기서 매달리면 예전 실패 모드 그대로 `idleTimeout`이 소켓을 닫으며, `/api/blob`은 클라이언트 재시도가 없어 이미지가 그냥 안 뜬다(단 둘의 큰 출력 읽기는 이제 `gitText`·`showBytes`라 출력 크기로는 더 걸리지 않는다 — `getRepoSummary`·`getFileBytes`에 남은 `$`는 둘 다 출력이 작은 `merge-base`(`resolveDiffBaseRev`)뿐이다). 회귀망: `diff-server.test.ts`의 실제 HTTP 503 2종 + `self-heal.e2e.ts`. +- **"Loading…" 자가 치유 — 세 부분이 함께여야 동작한다.** Bun 1.3.x의 `$`(ShellPromise)는 **64KB 파이프 버퍼를 넘는 stdout을 받는 호출에서** resolve도 reject도 없이 영구 pending이 될 수 있다 — 호출이 겹치면 거의 확정이고 완전 순차여도 결국 걸린다(자식은 사라지고 좀비도 없고 이벤트 루프도 정상인데 프라미스만 안 끝난다 — 실측; 1.3.12·1.3.14 재현, 업스트림은 1.4.0에서 수정). 예전엔 외부 프로세스 생성 경합이 필요하다고 봤지만 아니었다 — 200KB 파일 12개를 `BUILD_CONCURRENCY=8`로 읽으면 첫 호출에 죽고, 60KB는 멀쩡하다. 그래서 출력이 64KB를 넘을 수 있는 서버의 git 호출은 전부 `$`가 아니라 **`gitOutput.ts`의 `gitBytes`/`gitText`(`Bun.spawn`)**를 탄다 — `readBlob`(`getDiffFiles`의 파일별 버스트, `gitRun`)과 `showBytes`(`/api/blob`), `summary.ts`, `getDiffFiles`의 `diff --raw`·`ls-files --others`, 지문의 `status`, `refs.ts`의 `for-each-ref`·`worktree list`. 남은 `$`는 `rev-parse`·`merge-base`·`gh pr view`처럼 출력 크기가 리포 규모와 무관한 호출뿐이다. 크기는 필요조건일 뿐이다 — 같은 크기라도 호출에 따라 안 멈추기도 한다(`worktree list` 110KB, `for-each-ref` 606KB는 한 번도 안 멈췄다). **`$`로 되돌리지 말 것** — 회귀망 `diff-large-blob.test.ts`·`git-output.test.ts`·`git-large-output.test.ts`는 행업을 1.3.x에서만 잡으므로(최신 Bun에선 `$`도 통과한다) CI의 `test-bun13` 잡이 셋을 Bun 1.3.14로 고정해 돌린다. **다만 호출처마다 판별력이 다르다.** `git-large-output.test.ts`는 호출처마다 그 함수를 8번 동시에 불러 출력을 겹치게 만드는데, 지문·목록(`diff --raw`, 측정 당시엔 `--name-status`)·`ls-files`는 되돌리면 첫 라운드에 확실히 죽지만(각 3/3 — `ls-files` 케이스는 목록도 거치므로 그걸 되돌려도 함께 죽는다) `for-each-ref`는 좁은 구간(참조 600~800개 ≈ 180~240KB)에서만 멈춰 8-way로는 20라운드를 돌려도 5번 중 3번만 잡혔고, 그 케이스만 16-way로 올려 11번 중 11번 잡는다(macOS). `worktree list`는 옮겼지만 멈춤을 재현하지 못해(죽은 워크트리 400개로 110KB, 8·16-way 10라운드씩 8번 무사) 테스트가 지키지 않는다. `summary.ts`는 순차 호출이라 아예 결정론적으로 재현되지 않아(437KB 출력에서 한 번은 18번째 호출에 걸리고 다른 한 번은 150회 무사했다) **그 파일이 `$`로 돌아가는 것은 테스트가 못 잡는다** — 헬퍼 자체의 행업만 `git-output.test.ts`가 잡는다(전부 실측). 큰 출력의 `$`는 이제 없지만 아래 세 부품은 그대로 둔다 — flight 타임아웃은 원인을 가리지 않는 안전망이고(예: `baseFlight` 안의 `gh pr view`는 네트워크를 기다린다), 남은 작은 `$`가 원리적으로 안전하다는 증명은 없다(60KB는 멀쩡했다는 관측뿐이다). **새 git 호출의 출력이 64KB를 넘을 수 있으면 `$`가 아니라 `gitBytes`/`gitText`로 쓴다.** `ShellPromise`엔 `.timeout()`/`.kill()`이 없어 `Promise.race`가 유일한 레버다. 예전엔 `singleFlight`가 키를 `.finally()`에서만 지워 그 키가 **영구 오염**되고 이후 모든 요청이 죽은 프라미스에 합류했다. 지금은 ① `singleFlight`가 flight를 타임아웃과 race해 키를 풀고(`SingleFlightTimeoutError`로 호출자가 타임아웃을 구분한다), ② `awaitFlight`가 **타임아웃만** 503+`Retry-After`로 흡수하며(그 외 에러는 다시 던져 기존 동작 보존), ③ `browser/main.ts`의 `fetchDiff`가 503·네트워크 실패를 1회 재시도한다(403·400은 terminal). **따로 넣으면 어느 쪽도 동작하지 않는다** — 키를 안 풀고 재시도하면 같은 죽은 프라미스에 다시 합류한다. **상수 제약은 per-flight가 아니라 합이다**: `/api/diff`가 `resolveBaseCached` → `diffFlight`를 순차로 두 번 기다리므로 45+45=90 < `idleTimeout` 120(슬랙 30초). 재시도가 1회인 이유도 `BUILD_CONCURRENCY=8`이 호출당이라(전역 세마포어 아님) 시도가 겹치면 동시 git 서브프로세스가 배로 늘어 재시도가 스스로를 느리게 만들기 때문이다. **덮지 않는 곳**: `isGitRepo`는 flight 앞에서, `getRepoSummary`·`getFileBytes`는 뒤에서 돌고 flight로 안 감싸였다 — 거기서 매달리면 예전 실패 모드 그대로 `idleTimeout`이 소켓을 닫으며, `/api/blob`은 클라이언트 재시도가 없어 이미지가 그냥 안 뜬다(단 둘의 큰 출력 읽기는 이제 `gitText`·`showBytes`라 출력 크기로는 더 걸리지 않는다 — `getRepoSummary`·`getFileBytes`에 남은 `$`는 둘 다 출력이 작은 `merge-base`(`resolveDiffBaseRev`)뿐이다). 회귀망: `diff-server.test.ts`의 실제 HTTP 503 2종 + `self-heal.e2e.ts`. +- **변경 폴의 old 쪽은 blob OID 캐시가 든다(`server/blobCache.ts`).** watch에서 한 파일만 바뀌어도 지문이 바뀌면 `getDiffFiles`가 diff 전체를 다시 만드는데, 파일마다 old 쪽을 `git show`로 다시 읽던 것이 변경 폴 비용의 대부분이었다(176파일 픽스처에서 230ms 중 176ms — 실측). 그래서 목록을 `git diff --raw -z --no-abbrev`로 뽑아 파일마다 blob OID를 얻고(`parseRawZ`), 그 OID를 키로 old 쪽(과 head 모드의 new 쪽) 바이트를 재사용한다. **계약 넷**: ① 키는 **전체 OID**이고 **값도 그 OID로 읽는다**(`git show `). 이름(`HEAD`·브랜치)으로 키를 잡으면 커밋 직후 같은 이름이 다른 내용을 가리켜 옛 내용이 나오고, 키만 OID로 잡고 값을 이름(`HEAD:`)으로 읽어도 똑같이 틀린다 — 목록과 읽기 사이에 ref가 움직이면(watch 중의 커밋·체크아웃, head로 보는 브랜치의 전진) 새 내용이 옛 OID 아래 저장돼 **세션 내내** 남는다(캐시 전엔 다음 폴에 회복되던 경합이다 — 리뷰에서 결정론적으로 재현, 한때 이 계획의 결함이었다). `--full-index`는 여기서 7자 약어를 준다(실측). 키가 내용의 해시라 무효화 로직이 없다(`git show `·`git show :`·`git cat-file blob`의 출력은 textconv·`eol`·필터가 걸린 경로에서도 md5 동일 — 실측). 서브모듈(gitlink, 160000)의 OID는 커밋이라 키로 쓰지 않는다. ② **`git show`의 종료 코드가 0일 때만 저장한다**(`gitRun`) — 잠깐 못 읽은 빈 결과를 저장하면 영구히 눌러앉는다. 워킹트리와 비교하는 `git diff `는 stat이 바뀐 파일이면 old blob을 읽어서, 그게 없으면 목록 단계에서 먼저 `fatal: unable to read`로 죽는다 — 그래서 회귀 테스트는 목록이 트리 OID만 보는 **head 모드**에서 재현한다(stat이 깨끗한 항목은 워킹트리 모드에서도 목록에 나오므로 가드는 두 모드 모두에서 쓰인다 — 리뷰 실측). ③ **OID는 캐시 키로만** 쓴다 — old 쪽을 읽을지는 지금처럼 상태가 정한다. ④ 워킹트리 모드의 new 쪽은 캐시하지 않는다(디스크가 진실이고 전부 읽어도 4.3ms). 캐시는 `createHandler`마다 하나(prewarm과 `/api/diff`가 공유), 상한 64MB LRU(556파일 diff의 양쪽 합이 44.5MB였다). payload 캐시·지문·etag는 그대로다. 회귀망: `diff-blob-cache.test.ts` 7종(이름 키·이름 읽기·실패 저장·`--no-abbrev` 누락 뮤테이션을 각각 잡는다 — 이름 읽기 경합은 처음 miss에서 ref를 움직이는 래퍼 캐시로 결정론적으로 만든다) + `diff-raw.test.ts`(실제 git 출력 리터럴) + `blob-cache.test.ts` + `diff-server.test.ts`의 배선 2종(워킹트리·base 모드 호출을 따로 — 한 모드만 보면 다른 호출에서 캐시를 빼도 초록이다). - **`server.ts`의 `baseCache`는 반드시 모듈 스코프에 남아야 한다** (`diffCache`와 달리 `createHandler` 안으로 옮기지 마라). `diff-server.test.ts`의 "diffFlight 타임아웃" 테스트가 기본 타임아웃 서버로 캐시를 데운 뒤 **별도로 새로 띄운** `flightTimeoutMs:1` 서버가 그 warm 항목을 그대로 봐야만, `baseFlight`가 마이크로태스크로 1ms 레이스를 이기고 제어가 `diffFlight`까지 도달한다. 옮기면(구조적 격리라는 그럴듯한 이유로 그럴 수 있다) 두 번째 서버가 빈 캐시로 시작해 `baseFlight`가 miss로 되돌아가고, 그 테스트는 **조용히** 첫 번째 테스트와 똑같은 `baseFlight` 가드만 다시 증명한다 — 실패가 아니라 무증상 퇴화다. 두 테스트가 **정반대 조건**(하나는 캐시 미스, 하나는 히트)에 의존하므로 공용 픽스처로 합치지도 마라. - **빈 상태의 문구는 `/api/summary`가 와야 정해진다 — 먼저 그리면 말을 바꾼다.** `renderPatch`의 `files.length === 0` 분기는 한때 `
No changes.
`를 **동기로** 써 놓고, `enrichEmptyState()`가 요약을 받아 온 뒤 정보형 카드로 덮었다. 그 사이가 실측 60~80ms라 사용자에게는 "없다"고 한 번 말한 뒤 말을 바꾸는 것으로 보인다(실측: 첫 로드 673ms `No changes.` → 753ms `No tracked changes …`; head를 고르면 282ms → 340ms). **자동 base 전환이 걸리는 경우가 가장 나쁘다** — 볼 것이 있는데도 없다고 말한 뒤 diff가 뜬다. 그래서 지금은 자리만 잡고(`LOADING_MARKUP` — `load()`의 첫 로드 표시와 **같은 마크업이어야 한다**: 다른 것을 그리면 로딩에서 로딩으로 넘어가는 자리에서 한 번 더 깜박인다) 문구는 `enrichEmptyState`가 한 번만 쓴다. `No changes.`는 이제 **폴백**이고 요약 fetch가 실패했을 때만 나온다 — 그때 `data-loading`을 함께 걷어내지 않으면 화면이 영원히 로딩이다(노드 동일성은 유지해 marker 가드가 계속 맞게 둔다). **이 종류는 최종 상태로는 원리적으로 안 보인다**(끝나고 나면 옳은 카드가 떠 있다) — 회귀망은 `addInitScript`로 첫 페인트 전에 MutationObserver를 걸어 `#empty`가 거쳐 간 문구를 전부 기록한다(`empty-state.e2e.ts` ⑥⑦, 폴백은 ⑧이 `/api/summary`를 route abort로 막아 찌른다). 그 관찰자는 `document.documentElement`가 아니라 **`document`**를 봐야 한다 — document-start에는 `documentElement`가 아직 없어 기록이 통째로 빈다(실측). 뮤테이션으로 판별력 확인: 옛 동기 문구를 되돌리면 ⑥⑦만, 폴백 write를 지우면 ⑧만 죽는다. - **커버리지 100%가 무엇을 뜻하지 않는지 알 것.** `bunfig.toml`의 게이트는 line·function·statement만 세고 **branch를 세지 않는다.** 그래서 `if (x instanceof Response) return x;` 같은 한 줄 가드는 `if`가 실행되기만 하면 covered로 찍히고, 그 `return`이 한 번도 안 나가도 100%가 유지된다(실제로 503 반환 경로 4개가 그 상태였다). 새 분기를 넣을 때 커버리지 초록을 증거로 받지 말고, **일부러 그 분기로 들어가는 테스트**를 따로 둘 것. diff --git a/apps/viewer/__tests__/blob-cache.test.ts b/apps/viewer/__tests__/blob-cache.test.ts new file mode 100644 index 0000000..00019ee --- /dev/null +++ b/apps/viewer/__tests__/blob-cache.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from "bun:test"; +import { + DEFAULT_BLOB_CACHE_BYTES, + createBlobCache, +} from "../server/blobCache.ts"; + +const bytes = (n: number): Uint8Array => new Uint8Array(n); + +test("returns what was stored and counts hits and misses", () => { + const cache = createBlobCache({ maxBytes: 100 }); + cache.set("a", bytes(10)); + expect(cache.get("a")?.byteLength).toBe(10); + expect(cache.get("b")).toBeUndefined(); + expect(cache.stats()).toEqual({ hits: 1, misses: 1, bytes: 10, entries: 1 }); +}); + +test("stores an empty blob (a zero-length hit is still a hit)", () => { + const cache = createBlobCache({ maxBytes: 100 }); + cache.set("empty", bytes(0)); + expect(cache.get("empty")?.byteLength).toBe(0); + expect(cache.stats().hits).toBe(1); +}); + +test("evicts the least recently used entries once the byte total exceeds the cap", () => { + const cache = createBlobCache({ maxBytes: 100 }); + cache.set("a", bytes(40)); + cache.set("b", bytes(40)); + cache.get("a"); // a가 최근 — 다음 퇴출 대상은 b + cache.set("c", bytes(40)); // 120 > 100 → b를 버려 80 + expect(cache.get("b")).toBeUndefined(); + expect(cache.get("a")?.byteLength).toBe(40); + expect(cache.get("c")?.byteLength).toBe(40); + expect(cache.stats()).toMatchObject({ bytes: 80, entries: 2 }); +}); + +test("does not store an entry larger than the cap, and keeps what it had", () => { + const cache = createBlobCache({ maxBytes: 100 }); + cache.set("a", bytes(40)); + cache.set("huge", bytes(101)); + expect(cache.get("huge")).toBeUndefined(); + expect(cache.get("a")?.byteLength).toBe(40); + expect(cache.stats()).toMatchObject({ bytes: 40, entries: 1 }); +}); + +test("overwriting a key replaces its bytes instead of counting them twice", () => { + const cache = createBlobCache({ maxBytes: 100 }); + cache.set("a", bytes(40)); + cache.set("a", bytes(30)); + expect(cache.stats()).toMatchObject({ bytes: 30, entries: 1 }); +}); + +test("defaults to a 64MB cap", () => { + const cache = createBlobCache(); + cache.set("at-cap", bytes(DEFAULT_BLOB_CACHE_BYTES)); + cache.set("over-cap", bytes(DEFAULT_BLOB_CACHE_BYTES + 1)); + expect(cache.stats().entries).toBe(1); + expect(DEFAULT_BLOB_CACHE_BYTES).toBe(64 * 1024 * 1024); +}); diff --git a/apps/viewer/__tests__/diff-blob-cache.test.ts b/apps/viewer/__tests__/diff-blob-cache.test.ts new file mode 100644 index 0000000..f679915 --- /dev/null +++ b/apps/viewer/__tests__/diff-blob-cache.test.ts @@ -0,0 +1,181 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { $ } from "bun"; +import { type BlobCache, createBlobCache } from "../server/blobCache.ts"; +import { getDiffFiles } from "../server/diff.ts"; + +/** + * `getDiffFiles`가 blob OID 캐시를 거칠 때의 정확성 계약. 각 테스트가 잡는 깨짐: + * ① 캐시를 안 거치거나(hit 0) 워킹트리 new 쪽까지 캐시해 편집이 안 보이는 구현, + * ② 이름(`HEAD`, 경로)으로 키를 잡아 커밋 직후 옛 내용을 내는 구현, + * ③ 실패한 `git show`(빈 출력)를 저장해 빈 old 쪽이 눌러앉는 구현(head 모드에서 + * 재현한다 — 해당 테스트 주석 참고), + * ④ 캐시 경로가 다른 바이트를 내거나 head 모드 new 쪽을 캐시하지 않는 구현, + * ⑤ 키는 OID로 잡고 값은 이름(`HEAD:path`)으로 읽어, 목록과 읽기 사이에 ref가 + * 움직이면 새 내용을 옛 OID 아래 영구히 저장하는 구현, + * ⑥ 약어 OID를 키로 쓰는 구현(`--no-abbrev` 누락). + */ + +let repo: string; + +beforeEach(async () => { + repo = mkdtempSync(join(tmpdir(), "cc-blob-cache-")); + await $`git -C ${repo} init -q -b main`; + await $`git -C ${repo} config user.email t@t.co`; + await $`git -C ${repo} config user.name test`; + writeFileSync(join(repo, "a.txt"), "v1\n"); + writeFileSync(join(repo, "b.txt"), "b1\n"); + await $`git -C ${repo} add -A`; + await $`git -C ${repo} commit -qm init`; +}); + +afterEach(() => { + rmSync(repo, { recursive: true, force: true }); +}); + +test("reuses the cached old side and still reads the new side from disk", async () => { + const blobs = createBlobCache(); + writeFileSync(join(repo, "a.txt"), "v2\n"); + await getDiffFiles(repo, {}, blobs); + expect(blobs.stats()).toMatchObject({ hits: 0, entries: 1 }); + + writeFileSync(join(repo, "a.txt"), "v3\n"); + const [file] = await getDiffFiles(repo, {}, blobs); + expect(blobs.stats().hits).toBe(1); + expect(file?.oldContents).toBe("v1\n"); + expect(file?.newContents).toBe("v3\n"); +}); + +test("after a commit moves HEAD, the old side follows the new HEAD blob", async () => { + const blobs = createBlobCache(); + writeFileSync(join(repo, "a.txt"), "v2\n"); + await getDiffFiles(repo, {}, blobs); + + await $`git -C ${repo} commit -qam v2`; + writeFileSync(join(repo, "a.txt"), "v3\n"); + const [file] = await getDiffFiles(repo, {}, blobs); + expect(file?.oldContents).toBe("v2\n"); + expect(file?.newContents).toBe("v3\n"); +}); + +// feat 브랜치: a.txt를 고치고 c.txt를 더한다. main은 움직이지 않으므로 +// merge-base(main, feat) = main이고, head 모드 old 쪽 a.txt는 main의 "v1\n"이다. +const branchFeat = async (): Promise => { + await $`git -C ${repo} checkout -qb feat`; + writeFileSync(join(repo, "a.txt"), "feat\n"); + writeFileSync(join(repo, "c.txt"), "new on feat\n"); + await $`git -C ${repo} add -A`; + await $`git -C ${repo} commit -qm feat`; + await $`git -C ${repo} checkout -q main`; +}; +const HEAD_OPTS = { mode: "base" as const, ref: "main", head: "feat" }; + +test("a failed git show is not cached", async () => { + // `git show`만 실패시키려면 목록이 그 blob을 읽지 않아야 한다. 워킹트리와 + // 비교하는 `git diff `는 stat이 바뀐 파일이면 old blob을 읽어서, 그게 + // 없으면 목록 단계에서 먼저 죽는다(`fatal: unable to read …`, 실측 — stat이 + // 깨끗한 항목은 목록에 나온다). 커밋끼리 비교하는 head 모드는 트리의 OID만 + // 보므로 늘 목록이 나오고 `git show`만 실패한다. + await branchFeat(); + const oid = (await $`git -C ${repo} rev-parse main:a.txt`.text()).trim(); + const loose = join(repo, ".git", "objects", oid.slice(0, 2), oid.slice(2)); + const blobs = createBlobCache(); + + renameSync(loose, `${loose}.away`); + const broken = (await getDiffFiles(repo, HEAD_OPTS, blobs)).find( + (f) => f.name === "a.txt", + ); + expect(broken?.oldContents).toBe(""); // 지금과 같은 동작: 그 빌드에만 빈 old 쪽 + expect(broken?.newContents).toBe("feat\n"); + // 읽힌 두 blob(a.txt new, c.txt new)만 저장되고 실패한 old 쪽은 빠진다. + expect(blobs.stats().entries).toBe(2); + + renameSync(`${loose}.away`, loose); + const healed = (await getDiffFiles(repo, HEAD_OPTS, blobs)).find( + (f) => f.name === "a.txt", + ); + expect(healed?.oldContents).toBe("v1\n"); +}); + +test("head mode reads both sides through the cache and matches the uncached result", async () => { + await branchFeat(); + const uncached = await getDiffFiles(repo, HEAD_OPTS); + const blobs = createBlobCache(); + const first = await getDiffFiles(repo, HEAD_OPTS, blobs); + const second = await getDiffFiles(repo, HEAD_OPTS, blobs); + expect(first).toEqual(uncached); + expect(second).toEqual(uncached); + // a.txt: old·new 두 blob, c.txt: new 하나(추가라 old 없음) → 3개 저장, + // 두 번째 빌드에서 셋 다 hit. + expect(blobs.stats()).toMatchObject({ entries: 3, hits: 3 }); +}); + +// get()이 처음 miss할 때 한 번 `onMiss`를 부르는 캐시. `getDiffFiles`는 목록 +// (`git diff --raw`)을 뽑은 뒤 파일마다 캐시를 보고 miss면 읽으므로, 여기서 ref를 +// 움직이면 "목록과 읽기 사이에 ref가 움직인" 경합을 결정론적으로 만든다. +const racingCache = (onMiss: () => void): BlobCache => { + const inner = createBlobCache(); + let fired = false; + return { + get(oid) { + const hit = inner.get(oid); + if (hit === undefined && !fired) { + fired = true; + onMiss(); + } + return hit; + }, + set: (oid, bytes) => inner.set(oid, bytes), + stats: () => inner.stats(), + }; +}; + +const gitSync = (args: string[]): void => { + const r = Bun.spawnSync(["git", "-C", repo, ...args], { stderr: "pipe" }); + if (r.exitCode !== 0) + throw new Error(`git ${args.join(" ")}: ${r.stderr.toString()}`); +}; + +const revParse = async (spec: string): Promise => + (await $`git -C ${repo} rev-parse ${spec}`.text()).trim(); + +const text = (bytes: Uint8Array | undefined): string | undefined => + bytes === undefined ? undefined : new TextDecoder().decode(bytes); + +test("HEAD moving between listing and reading does not store the new content under the old id", async () => { + writeFileSync(join(repo, "a.txt"), "v2\n"); + const v1 = await revParse("HEAD:a.txt"); + // 목록은 HEAD=v1을 보고, 읽기 직전에 v2가 커밋된다. + const blobs = racingCache(() => gitSync(["commit", "-qam", "v2"])); + const [file] = await getDiffFiles(repo, {}, blobs); + expect(file?.oldContents).toBe("v1\n"); + expect(text(blobs.get(v1))).toBe("v1\n"); +}); + +test("a head branch moving between listing and reading does not poison the cache", async () => { + await branchFeat(); + const featA = await revParse("feat:a.txt"); + // 목록은 feat의 a.txt="feat"를 보고, 읽기 사이에 feat가 한 커밋 전진한다. + const blobs = racingCache(() => { + gitSync(["checkout", "-q", "feat"]); + writeFileSync(join(repo, "a.txt"), "feat moved on\n"); + gitSync(["commit", "-qam", "advance"]); + gitSync(["checkout", "-q", "main"]); + }); + const file = (await getDiffFiles(repo, HEAD_OPTS, blobs)).find( + (f) => f.name === "a.txt", + ); + expect(file?.newContents).toBe("feat\n"); + expect(text(blobs.get(featA))).toBe("feat\n"); +}); + +test("keys entries by the full object id", async () => { + writeFileSync(join(repo, "a.txt"), "v2\n"); + const blobs = createBlobCache(); + await getDiffFiles(repo, {}, blobs); + const full = await revParse("HEAD:a.txt"); + expect(full).toHaveLength(40); + expect(text(blobs.get(full))).toBe("v1\n"); +}); diff --git a/apps/viewer/__tests__/diff-command.test.ts b/apps/viewer/__tests__/diff-command.test.ts index 3c2b18b..0ba52df 100644 --- a/apps/viewer/__tests__/diff-command.test.ts +++ b/apps/viewer/__tests__/diff-command.test.ts @@ -127,7 +127,7 @@ describe("getDiffFiles", () => { }); describe("getDiffFiles non-ASCII and special filenames", () => { - // git의 기본값(core.quotePath=true)에서는 -z 없는 --name-status/ls-files가 + // git의 기본값(core.quotePath=true)에서는 -z 없는 diff 목록(--raw/--name-status)·ls-files가 // 비-ASCII·특수문자 경로를 큰따옴표+8진 이스케이프로 인용해서 낸다 // (예: 한글.txt → "\355\225\234\352\270\200.txt"). 그 인용 문자열을 그대로 // 경로로 쓰면 git show/readFileSync가 못 찾아 내용이 빈 채로 렌더된다. diff --git a/apps/viewer/__tests__/diff-gaps.test.ts b/apps/viewer/__tests__/diff-gaps.test.ts index 0b9baa8..0ed1e2e 100644 --- a/apps/viewer/__tests__/diff-gaps.test.ts +++ b/apps/viewer/__tests__/diff-gaps.test.ts @@ -53,7 +53,7 @@ describe("defaultBranchName", () => { }); }); -describe("getDiffFiles name-status parsing", () => { +describe("getDiffFiles --raw listing parsing", () => { test("classifies renamed/added/deleted/modified/untracked from a real fixture", async () => { const repo = mkRepo("dd-gaps-repo-"); await $`git -C ${repo} init -q`; diff --git a/apps/viewer/__tests__/diff-large-blob.test.ts b/apps/viewer/__tests__/diff-large-blob.test.ts index f2bc49c..de506ce 100644 --- a/apps/viewer/__tests__/diff-large-blob.test.ts +++ b/apps/viewer/__tests__/diff-large-blob.test.ts @@ -11,12 +11,12 @@ import { getDiffFiles } from "../server/diff.ts"; * Bun 1.3.x(1.3.12·1.3.14 실측)의 `$`는 64KB를 넘는 stdout을 받는 호출에서 * 자식이 이미 끝났는데도 promise가 영영 settle하지 않을 수 있다 — 겹치면 * 거의 확정이고, 완전 순차여도 결국 걸린다(200KB × 32를 하나씩 읽어 14라운드째). - * `getDiffFiles`의 8-way `showBytes` 버스트가 정확히 그 모양이라 큰 diff의 + * `getDiffFiles`의 8-way 파일별 읽기 버스트(`readBlob` → `gitRun`)가 정확히 그 모양이라 큰 diff의 * `/api/diff`가 45초 flight 타임아웃 → 503으로 떨어졌다(실측: 556파일 리포에서 * 매번). 200KB 파일 12개면 첫 호출에서 죽고, 60KB(버퍼 미만)는 멀쩡하다. * * **판별력은 Bun 버전에 달렸다**: 업스트림이 1.4.0에서 고쳐, 1.4 이상에서는 - * `showBytes`를 `$`로 되돌려도 이 테스트가 통과한다. 1.3.x에서는 되돌리면 + * `readBlob`의 읽기를 `$`로 되돌려도 이 테스트가 통과한다. 1.3.x에서는 되돌리면 * 타임아웃으로 죽는다 — 그래서 CI의 `test-bun13` 잡이 이 파일을 Bun 1.3.14로 * 고정해 돌린다. 내용 단언은 버전과 무관하게 큰 blob을 끝까지 읽는지를 지킨다. */ diff --git a/apps/viewer/__tests__/diff-raw.test.ts b/apps/viewer/__tests__/diff-raw.test.ts new file mode 100644 index 0000000..3556f6d --- /dev/null +++ b/apps/viewer/__tests__/diff-raw.test.ts @@ -0,0 +1,92 @@ +import { expect, test } from "bun:test"; +import { parseRawZ } from "../server/diff.ts"; + +const Z40 = "0".repeat(40); + +// git 2.55, `git diff --raw -z --no-abbrev HEAD --` 실측 출력(NUL을 \0로). +const SHA1_RAW = [ + `:000000 100644 ${Z40} da0f8ed91a8f2f0f067b3bdf26265d5ca48cf82c A`, + "a.txt", + `:100644 000000 6f1852975b9306ae5d8dfdf0d4cb1f5cb36ac229 ${Z40} D`, + "d.txt", + `:100644 100644 63a911f26fe84ea7fd8a863a636cfac908895ec9 ${Z40} M`, + "m.txt", + ":100644 100644 a38f4b510f1ec5b3e15c7fdc3d5096573687dfb2 a38f4b510f1ec5b3e15c7fdc3d5096573687dfb2 R100", + "r.txt", + "r2.txt", + `:100644 120000 795ea43143ebd1173b2ff6d1f24e7705306545dd ${Z40} T`, + "t.txt", + "", +].join("\0"); + +test("parses added, deleted, modified, renamed and type-changed records", () => { + expect(parseRawZ(SHA1_RAW)).toEqual([ + { + status: "added", + name: "a.txt", + oldOid: null, + newOid: "da0f8ed91a8f2f0f067b3bdf26265d5ca48cf82c", + }, + { + status: "deleted", + name: "d.txt", + oldOid: "6f1852975b9306ae5d8dfdf0d4cb1f5cb36ac229", + newOid: null, + }, + { + status: "modified", + name: "m.txt", + oldOid: "63a911f26fe84ea7fd8a863a636cfac908895ec9", + newOid: null, + }, + { + status: "renamed", + name: "r2.txt", + oldName: "r.txt", + oldOid: "a38f4b510f1ec5b3e15c7fdc3d5096573687dfb2", + newOid: "a38f4b510f1ec5b3e15c7fdc3d5096573687dfb2", + }, + { + status: "modified", + name: "t.txt", + oldOid: "795ea43143ebd1173b2ff6d1f24e7705306545dd", + newOid: null, + }, + ]); +}); + +test("keeps 64-character SHA-256 object ids whole", () => { + const oid = + "14f5162e2fe3d240d0d37aaab0f90e4af9a7cfa79639f3bab005b5bfb4174d9f"; + const raw = `:100644 100644 ${oid} ${"0".repeat(64)} M\0f\0`; + expect(parseRawZ(raw)).toEqual([ + { status: "modified", name: "f", oldOid: oid, newOid: null }, + ]); +}); + +test("an unmerged record has no object ids and still maps to modified", () => { + // `getDiffFiles`는 늘 base rev를 넘겨 충돌 중인 파일도 M으로 받지만(실측), + // U가 오더라도 필드를 밀리지 않고 캐시 키 없이 넘긴다. + const raw = `:000000 000000 ${Z40} ${Z40} U\0u.txt\0:100644 100644 ${"1".repeat(40)} ${Z40} M\0next.txt\0`; + expect(parseRawZ(raw)).toEqual([ + { status: "modified", name: "u.txt", oldOid: null, newOid: null }, + { + status: "modified", + name: "next.txt", + oldOid: "1".repeat(40), + newOid: null, + }, + ]); +}); + +test("returns nothing for empty output", () => { + expect(parseRawZ("")).toEqual([]); +}); + +test("a submodule (gitlink) record carries no cache key — its id names a commit, not a blob", () => { + const c1 = "a".repeat(40); + const c2 = "b".repeat(40); + expect(parseRawZ(`:160000 160000 ${c1} ${c2} M\0sub\0`)).toEqual([ + { status: "modified", name: "sub", oldOid: null, newOid: null }, + ]); +}); diff --git a/apps/viewer/__tests__/diff-server.test.ts b/apps/viewer/__tests__/diff-server.test.ts index 8928252..322f06b 100644 --- a/apps/viewer/__tests__/diff-server.test.ts +++ b/apps/viewer/__tests__/diff-server.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { $ } from "bun"; import packageJson from "../package.json"; +import { createBlobCache } from "../server/blobCache.ts"; import { startDiffServer } from "../server/server.ts"; import { generateToken, @@ -47,6 +48,49 @@ afterEach(() => { }); describe("diff server", () => { + test("rebuilds after an edit reuse cached blobs", async () => { + const blobCache = createBlobCache(); + const h = startDiffServer({ + port: 0, + viewerDir, + env: { XDG_CACHE_HOME: cacheHome }, + blobCache, + }); + try { + const url = `http://127.0.0.1:${h.server.port}/api/diff?repo=${encodeURIComponent(repo)}&token=${h.token}`; + expect((await fetch(url)).status).toBe(200); + // 크기가 바뀌어 지문이 달라진다 → 재빌드 + writeFileSync(join(repo, "a.txt"), "three\n"); + expect((await fetch(url)).status).toBe(200); + expect(blobCache.stats().hits).toBe(1); + } finally { + h.stop(); + } + }); + + test("base-mode rebuilds after an edit reuse cached blobs too", async () => { + // 위 테스트는 기본(워킹트리 모드) 호출만 지나간다 — base 모드는 서버에서 + // 별도의 getDiffFiles 호출이라 따로 찌른다. 목록에 있는 참조여야 400이 + // 아니므로 브랜치를 하나 세운다. + await $`git -C ${repo} branch basepoint`; + const blobCache = createBlobCache(); + const h = startDiffServer({ + port: 0, + viewerDir, + env: { XDG_CACHE_HOME: cacheHome }, + blobCache, + }); + try { + const url = `http://127.0.0.1:${h.server.port}/api/diff?repo=${encodeURIComponent(repo)}&token=${h.token}&base=basepoint`; + expect((await fetch(url)).status).toBe(200); + writeFileSync(join(repo, "a.txt"), "three\n"); + expect((await fetch(url)).status).toBe(200); + expect(blobCache.stats().hits).toBe(1); + } finally { + h.stop(); + } + }); + test("ping returns 204 with marker header", async () => { const res = await fetch(`${base}/api/ping`); expect(res.status).toBe(204); diff --git a/apps/viewer/__tests__/git-large-output.test.ts b/apps/viewer/__tests__/git-large-output.test.ts index 81d06a7..1ccd53d 100644 --- a/apps/viewer/__tests__/git-large-output.test.ts +++ b/apps/viewer/__tests__/git-large-output.test.ts @@ -14,9 +14,10 @@ import { getRefs } from "../server/refs.ts"; * flight 키로 겹친다. * * 한 케이스가 한 호출처를 지킨다: 지문의 `status -uall`, `getDiffFiles`의 - * `diff --name-status`와 `ls-files --others`, `getRefs`의 `for-each-ref`. 앞의 + * `diff --raw`와 `ls-files --others`, `getRefs`의 `for-each-ref`. 앞의 * 셋은 `$`로 돌아가면 첫 라운드에 확정적으로 죽는다(1.3.12, 각 3/3 실측 — - * `ls-files` 케이스는 `name-status`도 거치므로 그걸 되돌려도 함께 죽는다). + * `ls-files` 케이스는 `diff --raw`도 거치므로 그걸 되돌려도 함께 죽는다 — 측정은 + * 목록이 `--name-status`이던 시절에 했고, 지금의 `--raw` 출력은 더 크다). * * **`for-each-ref` 케이스만 16-way다.** 이 호출의 멈춤은 좁은 구간에서만 난다 * (8-way에서 참조 600~800개 ≈ 180~240KB — 400개 이하나 2000개에서는 30라운드 동안 @@ -34,7 +35,7 @@ import { getRefs } from "../server/refs.ts"; * 먼저 멈추면 무엇을 재는지 흐려진다. */ -const STAGED = 1000; // 이름 150자 × 1000 → name-status ~160KB +const STAGED = 1000; // 이름 150자 × 1000 → diff --raw ~250KB const LOOSE = 1000; // → ls-files --others ~157KB, status -uall은 둘을 합쳐 ~320KB const BRANCHES = 800; // → for-each-ref ~256KB const CALLS = 8; @@ -134,9 +135,9 @@ test( ); test( - "getDiffFiles settles under concurrent calls when `diff --name-status` exceeds 64KB", + "getDiffFiles settles under concurrent calls when `diff --raw` exceeds 64KB", async () => { - const results = await concurrently("getDiffFiles(name-status)", () => + const results = await concurrently("getDiffFiles(diff --raw)", () => getDiffFiles(repo), ); for (const files of results) { diff --git a/apps/viewer/__tests__/git-output.test.ts b/apps/viewer/__tests__/git-output.test.ts index f4c783f..bf68abc 100644 --- a/apps/viewer/__tests__/git-output.test.ts +++ b/apps/viewer/__tests__/git-output.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { $ } from "bun"; -import { gitText } from "../server/gitOutput.ts"; +import { gitRun, gitText } from "../server/gitOutput.ts"; import { mapWithLimit } from "../server/mapLimit.ts"; /** @@ -93,3 +93,25 @@ test("decodes stdout as UTF-8", async () => { test("ignores the exit code: a missing rev:path yields an empty string", async () => { expect(await gitText(["-C", repo, "show", "HEAD:nope.txt"])).toBe(""); }); + +test("gitRun reports exit code 0 with the bytes on success", async () => { + const { stdout, exitCode } = await gitRun([ + "-C", + repo, + "show", + "HEAD:한글.txt", + ]); + expect(exitCode).toBe(0); + expect(new TextDecoder().decode(stdout)).toBe("안녕 — 세계\n"); +}); + +test("gitRun reports a non-zero exit code when git fails", async () => { + const { stdout, exitCode } = await gitRun([ + "-C", + repo, + "show", + "HEAD:nope.txt", + ]); + expect(exitCode).not.toBe(0); + expect(stdout.byteLength).toBe(0); +}); diff --git a/apps/viewer/e2e/fixtures/repo.ts b/apps/viewer/e2e/fixtures/repo.ts index 5c9f485..6d494a8 100644 --- a/apps/viewer/e2e/fixtures/repo.ts +++ b/apps/viewer/e2e/fixtures/repo.ts @@ -60,8 +60,8 @@ export interface FixtureRepoOptions { /** * Opt-in: commit `src/한글파일.ts` and edit it in the working tree, so specs * can assert a non-ASCII filename actually renders diff content (regression - * guard for the `git diff --name-status`/`ls-files` C-quoting bug — see - * apps/viewer/server/diff.ts's `parseNameStatusZ`). + * guard for the `git diff --raw`/`ls-files` C-quoting bug — see + * apps/viewer/server/diff.ts's `parseRawZ`). */ koreanFilename?: boolean; /** diff --git a/apps/viewer/e2e/korean-filename.e2e.ts b/apps/viewer/e2e/korean-filename.e2e.ts index 510e4e2..9251896 100644 --- a/apps/viewer/e2e/korean-filename.e2e.ts +++ b/apps/viewer/e2e/korean-filename.e2e.ts @@ -4,7 +4,8 @@ // `git diff --name-status`/`git ls-files`가 비-ASCII 경로를 큰따옴표+8진 // 이스케이프로 인용해서 낸다. 그 인용 문자열을 그대로 파일 경로로 쓰면 // git show/readFileSync가 못 찾아 조용히 빈 diff가 렌더됐다 -// (apps/viewer/server/diff.ts의 `parseNameStatusZ` 도입 전 실제 재현). +// (apps/viewer/server/diff.ts의 `parseNameStatusZ` — 지금의 `parseRawZ` — 도입 전 +// 실제 재현). // 2) 클라이언트: 서버가 이미 올바른 이름을 내려줘도, vendored // parseDiffFromFile(@diffdeck/diffs)이 npm `diff`의 createTwoFilesPatch로 // 유니파이드 diff 텍스트를 만든 뒤 그 텍스트의 `--- `/`+++ ` 헤더 줄을 diff --git a/apps/viewer/server/blobCache.ts b/apps/viewer/server/blobCache.ts new file mode 100644 index 0000000..4d5893a --- /dev/null +++ b/apps/viewer/server/blobCache.ts @@ -0,0 +1,68 @@ +/** + * blob OID → 바이트 캐시. 키가 내용의 해시(전체 OID)라 같은 키는 영원히 같은 + * 바이트다 — 그래서 무효화가 없고 상한을 넘을 때 오래된 것부터 버리기만 한다. + * 리포·워크트리·경로가 달라도 같은 내용이면 공유된다(`git show :`의 + * 출력은 blob OID만의 함수다 — textconv·eol·필터가 걸린 경로에서도 실측 동일). + * + * 돌려주는 배열은 저장된 것 그대로다. 호출자가 고치면 캐시가 오염되므로 읽기만 + * 한다(`buildFile`은 해시·디코드·`includes(0)`만 한다). + */ + +export interface BlobCacheStats { + hits: number; + misses: number; + bytes: number; + entries: number; +} + +export interface BlobCache { + get(oid: string): Uint8Array | undefined; + set(oid: string, bytes: Uint8Array): void; + /** 서버 배선이 실제로 쓰이는지 테스트가 확인하는 용도. */ + stats(): BlobCacheStats; +} + +// 45초 503이 나던 556파일 diff에서 old 쪽이 10.0MB, 양쪽을 다 담아도 44.5MB였다 +// (실측). 넘치면 miss가 늘 뿐 결과는 같다. +export const DEFAULT_BLOB_CACHE_BYTES = 64 * 1024 * 1024; + +export const createBlobCache = ({ + maxBytes = DEFAULT_BLOB_CACHE_BYTES, +}: { maxBytes?: number } = {}): BlobCache => { + const entries = new Map>(); + let bytes = 0; + let hits = 0; + let misses = 0; + return { + get(oid) { + const hit = entries.get(oid); + if (hit === undefined) { + misses++; + return undefined; + } + hits++; + // Map 삽입 순서가 LRU 순서다 — 다시 넣어 맨 뒤(최근)로 옮긴다. + entries.delete(oid); + entries.set(oid, hit); + return hit; + }, + set(oid, value) { + // 한 항목 때문에 나머지를 다 비우지 않는다. + if (value.byteLength > maxBytes) return; + const prev = entries.get(oid); + if (prev !== undefined) { + bytes -= prev.byteLength; + entries.delete(oid); + } + entries.set(oid, value); + bytes += value.byteLength; + // 방금 넣은 것은 맨 뒤라 가장 늦게 버려지고, 상한 이하이므로 살아남는다. + for (const [key, old] of entries) { + if (bytes <= maxBytes) break; + entries.delete(key); + bytes -= old.byteLength; + } + }, + stats: () => ({ hits, misses, bytes, entries: entries.size }), + }; +}; diff --git a/apps/viewer/server/diff.ts b/apps/viewer/server/diff.ts index b2dace1..98fd484 100644 --- a/apps/viewer/server/diff.ts +++ b/apps/viewer/server/diff.ts @@ -1,7 +1,8 @@ import { readFileSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; import { $ } from "bun"; -import { gitBytes, gitText } from "./gitOutput.ts"; +import type { BlobCache } from "./blobCache.ts"; +import { gitBytes, gitRun, gitText } from "./gitOutput.ts"; import { mapWithLimit } from "./mapLimit.ts"; // buildFile 병렬 실행 상한 — 파일당 git 서브프로세스가 뜨므로 무제한이면 @@ -134,9 +135,8 @@ export interface DiffFile { // Uint8Array로 명시: fetch Response body(BodyInit)는 // SharedArrayBuffer 기반 뷰를 받지 않으므로 넓은 ArrayBufferLike면 안 된다. // -// `$`가 아니라 `gitBytes`(Bun.spawn)다 — 여기가 `getDiffFiles`의 8-way 버스트라 -// Bun 1.3.x `$`의 never-settle을 가장 확실하게 밟던 자리다(근거와 동작 계약은 -// gitOutput.ts). 회귀망: `diff-large-blob.test.ts`. +// `/api/blob`(이미지)의 읽기다. `getDiffFiles`의 파일별 버스트는 이제 `readBlob`이 +// 맡는다. 둘 다 `$`가 아니라 `Bun.spawn`이다(근거와 동작 계약은 gitOutput.ts). const showBytes = ( repo: string, rev: string, @@ -144,6 +144,42 @@ const showBytes = ( ): Promise> => gitBytes(["-C", repo, "show", `${rev}:${path}`]); +// blob 하나를 읽되, OID와 캐시가 있으면 캐시를 먼저 본다. 파일별 `git` 버스트는 +// 여기(`gitRun` — `$`가 아니라 `Bun.spawn`)를 탄다. +// +// **OID가 있으면 이름이 아니라 OID로 읽는다 — 이게 캐시 계약의 절반이다.** 키는 +// 목록(`git diff --raw`)이 준 OID인데 값을 `git show HEAD:`처럼 이름으로 읽으면, +// 목록과 읽기 사이에 ref가 움직일 때(watch 중의 커밋·체크아웃·리베이스, head로 보는 +// 브랜치의 전진) 새 커밋의 내용이 옛 OID 아래 저장되고 세션 내내 남는다 — 캐시 전엔 +// 다음 폴에 저절로 회복되던 경합이 영구 오염이 된다(리뷰에서 결정론적으로 재현). +// OID로 읽으면 목록이 본 바로 그 내용을 읽는다. 출력은 `git show :`와 +// 바이트 단위로 같다(textconv·`eol`·필터가 걸린 경로에서도 md5 동일 — 실측). OID는 +// `oidOrNull`이 16진수만 통과시키므로 옵션 꼴이 git에 닿을 수 없다. +// +// **종료 코드가 0일 때만 저장한다** — 잠깐 못 읽은 결과(빈 바이트, 예: 부분 +// 클론의 지연 페치 실패)를 저장하면 그 빈 내용이 영구히 눌러앉는다(캐시 전엔 그 +// 빌드에만 비고 다음 재빌드에서 회복됐다). +const readBlob = async ( + repo: string, + rev: string, + path: string, + oid: string | null, + blobs?: BlobCache, +): Promise> => { + if (oid && blobs) { + const hit = blobs.get(oid); + if (hit !== undefined) return hit; + } + const { stdout, exitCode } = await gitRun([ + "-C", + repo, + "show", + oid ?? `${rev}:${path}`, + ]); + if (oid && blobs && exitCode === 0) blobs.set(oid, stdout); + return stdout; +}; + const readWorkingBytes = ( repo: string, path: string, @@ -158,21 +194,24 @@ const readWorkingBytes = ( const buildFile = async ( repo: string, base: string, - status: DiffFileStatus, - name: string, - oldName?: string, + spec: FileSpec, /** new 쪽 리비전. 없으면 워킹트리(디스크의 지금 파일)를 읽는다. */ head?: string, + blobs?: BlobCache, ): Promise => { + const { status, name, oldName } = spec; + // old 쪽을 읽을지는 상태가 정한다 — OID는 캐시 키로만 쓴다. const oldBytes = status === "added" || status === "untracked" ? new Uint8Array() - : await showBytes(repo, base, oldName ?? name); + : await readBlob(repo, base, oldName ?? name, spec.oldOid, blobs); + // 워킹트리 new 쪽은 캐시하지 않는다 — 디스크가 진실이고, 전부 새로 읽어도 + // 176파일에 4.3ms다(실측). const newBytes = status === "deleted" ? new Uint8Array() : head - ? await showBytes(repo, head, name) + ? await readBlob(repo, head, name, spec.newOid, blobs) : readWorkingBytes(repo, name); const binary = oldBytes.includes(0) || newBytes.includes(0); const decoder = new TextDecoder(); @@ -189,30 +228,57 @@ const buildFile = async ( }; }; -// git의 기본값(core.quotePath=true)에서는 -z 없는 출력이 비-ASCII/특수문자 -// 경로를 큰따옴표+8진 이스케이프로 인용해서 낸다. 그 인용 문자열을 그대로 -// 경로로 쓰면 git show/readFileSync가 못 찾아 조용히 빈 내용이 된다. -z는 -// NUL로 레코드를 구분하고 경로를 인용 없이 그대로 낸다(fingerprint.ts와 동일 -// 전략). rename/copy(R/C, 유사도 점수 접미) 레코드만 경로 필드가 2개(old, new). -const parseNameStatusZ = ( - output: string, -): Array<{ status: DiffFileStatus; name: string; oldName?: string }> => { - const tokens = output.split("\0").filter((t) => t !== ""); - const specs: Array<{ - status: DiffFileStatus; - name: string; - oldName?: string; - }> = []; +export interface FileSpec { + status: DiffFileStatus; + name: string; + oldName?: string; + /** 0이 아닌 전체 blob OID. 없으면 null — 캐시를 거치지 않는다. */ + oldOid: string | null; + newOid: string | null; +} + +const oidOrNull = (s: string): string | null => + /^[0-9a-f]+$/.test(s) && !/^0+$/.test(s) ? s : null; + +// 서브모듈(gitlink)의 OID는 blob이 아니라 서브모듈 쪽 커밋이다 — `git show <커밋>`의 +// 출력은 git 설정(로그 형식)에 따라 달라지고 대개 로컬에 그 객체가 없다. 키로 쓰지 +// 않고 지금처럼 이름으로 읽는다. +const GITLINK_MODE = "160000"; + +// `git diff --raw -z --no-abbrev`의 레코드: `: +// \0\0`. rename/copy(R/C, 유사도 점수 접미)만 경로가 +// 둘(`\0\0`)이다. +// +// **`-z`가 계약이다.** git의 기본값(core.quotePath=true)에서는 -z 없는 출력이 +// 비-ASCII/특수문자 경로를 큰따옴표+8진 이스케이프로 인용해서 낸다. 그 인용 +// 문자열을 그대로 경로로 쓰면 git show/readFileSync가 못 찾아 조용히 빈 내용이 +// 된다. -z는 NUL로 레코드를 구분하고 경로를 인용 없이 그대로 낸다 +// (fingerprint.ts와 동일 전략). +// +// **`--no-abbrev`도 계약이다.** `--full-index`는 패치의 index 줄에만 작용해 여기선 +// 7자 약어가 나온다(실측) — 약어를 blob 캐시 키로 쓰면 큰 리포에서 충돌한다. +// 없는 쪽 OID(추가된 파일의 old, 삭제된 파일의 new, 워킹트리에서 stat이 바뀐 +// 파일의 new)는 전부 0이라 null로 둔다. 상태 매핑은 예전 `--name-status` 파서와 +// 같다. +export const parseRawZ = (output: string): FileSpec[] => { + const tokens = output.split("\0"); + const specs: FileSpec[] = []; for (let i = 0; i < tokens.length;) { - const code = tokens[i] ?? ""; + const meta = tokens[i] ?? ""; i++; + if (!meta.startsWith(":")) continue; + const [oldMode, newMode, oldRaw = "", newRaw = "", code = ""] = meta + .slice(1) + .split(" "); + const oldOid = oldMode === GITLINK_MODE ? null : oidOrNull(oldRaw); + const newOid = newMode === GITLINK_MODE ? null : oidOrNull(newRaw); if (/^[RC]/.test(code)) { - // C(copy)는 이 호출이 -C/--find-copies 없이 도는 한(현재 미사용) git이 - // 내지 않아 실제로는 미도달 — 나중에 copy 감지를 켜면 이 분기가 살아난다. - const oldName = tokens[i]; + // C(copy)는 기본 설정에선 안 나오지만 사용자가 `diff.renames=copies`를 + // 켜 두면 -C 없이도 나온다 — rename처럼 두 경로를 읽는다. + const oldName = tokens[i] ?? ""; const name = tokens[i + 1] ?? ""; i += 2; - specs.push({ status: "renamed", name, oldName }); + specs.push({ status: "renamed", name, oldName, oldOid, newOid }); } else { const name = tokens[i] ?? ""; i++; @@ -221,7 +287,7 @@ const parseNameStatusZ = ( : code.startsWith("D") ? "deleted" : "modified"; - specs.push({ status, name }); + specs.push({ status, name, oldOid, newOid }); } } return specs; @@ -288,6 +354,8 @@ export const getDiffFiles = async ( /** new 쪽 리비전. 없으면 워킹트리를 본다. */ head?: string; } = {}, + /** 변경 폴에서 바뀌지 않은 blob의 `git show`를 건너뛴다. 없으면 지금과 같다. */ + blobs?: BlobCache, ): Promise => { const base = await resolveDiffBaseRev(repo, opts); const files: DiffFile[] = []; @@ -306,22 +374,23 @@ export const getDiffFiles = async ( // `rev-parse`·`show :`는 rev만 받아 영향이 없다(실측). // // `$`가 아니라 `gitText`다 — 큰 diff에서 출력이 64KB를 넘는다. - const nameStatus = await gitText([ + const raw = await gitText([ "-C", repo, "diff", - "--name-status", + "--raw", "-z", + "--no-abbrev", base, ...(opts.head ? [opts.head] : []), "--", ]); // 파일별 git show/워킹트리 읽기는 서로 독립이라 병렬화하되, 대형 diff에서 // git 서브프로세스가 무제한으로 뜨지 않도록 동시성을 제한한다 (순서 유지). - const specs = parseNameStatusZ(nameStatus); + const specs = parseRawZ(raw); files.push( ...(await mapWithLimit(specs, BUILD_CONCURRENCY, (spec) => - buildFile(repo, base, spec.status, spec.name, spec.oldName, opts.head), + buildFile(repo, base, spec, opts.head, blobs), )), ); } @@ -340,7 +409,12 @@ export const getDiffFiles = async ( const paths = listed.split("\0").filter((s) => s !== ""); files.push( ...(await mapWithLimit(paths, BUILD_CONCURRENCY, (path) => - buildFile(repo, base, "untracked", path), + buildFile(repo, base, { + status: "untracked", + name: path, + oldOid: null, + newOid: null, + }), )), ); } diff --git a/apps/viewer/server/gitOutput.ts b/apps/viewer/server/gitOutput.ts index 500a918..58acf7c 100644 --- a/apps/viewer/server/gitOutput.ts +++ b/apps/viewer/server/gitOutput.ts @@ -8,7 +8,7 @@ * promise가 영영 settle하지 않을 수 있다 — 호출이 겹치면 거의 확정이고 완전 * 순차여도 결국 걸린다(1.3.12·1.3.14 실측, macOS·Linux 모두; 업스트림은 1.4.0에서 * 수정). 크기는 필요조건일 뿐이다 — 같은 크기라도 호출에 따라 안 멈추기도 한다 - * (`worktree list` 110KB는 한 번도 안 멈췄다). `getDiffFiles`의 8-way `showBytes` 버스트가 그 모양이라 큰 blob이 섞인 + * (`worktree list` 110KB는 한 번도 안 멈췄다). `getDiffFiles`의 8-way 파일별 읽기 버스트(지금의 `readBlob`)가 그 모양이라 큰 blob이 섞인 * diff가 통째로 45초 flight 타임아웃 → 503이 됐고, 같은 작업을 `Bun.spawn`으로는 * 수천 번 돌려도 걸리지 않았다. * @@ -21,17 +21,30 @@ * 호출자 몫이다 — `verifyBaseRef`). 회귀망: `git-output.test.ts`, * `diff-large-blob.test.ts`, `git-large-output.test.ts`(호출처별). */ -export const gitBytes = async ( +export interface GitRunResult { + stdout: Uint8Array; + exitCode: number; +} + +/** + * `gitBytes`와 같되 종료 코드를 함께 준다. 결과를 저장하는 호출자(blob 캐시)가 + * 실패한 읽기를 굳히지 않으려면 빈 출력이 "빈 파일"인지 "실패"인지 갈라야 한다. + */ +export const gitRun = async ( args: readonly string[], -): Promise> => { +): Promise => { const proc = Bun.spawn(["git", ...args], { stdout: "pipe", stderr: "ignore", }); const buf = await new Response(proc.stdout).arrayBuffer(); - await proc.exited; - return new Uint8Array(buf); + const exitCode = await proc.exited; + return { stdout: new Uint8Array(buf), exitCode }; }; +export const gitBytes = async ( + args: readonly string[], +): Promise> => (await gitRun(args)).stdout; + export const gitText = async (args: readonly string[]): Promise => new TextDecoder().decode(await gitBytes(args)); diff --git a/apps/viewer/server/server.ts b/apps/viewer/server/server.ts index e618dac..af03b4a 100644 --- a/apps/viewer/server/server.ts +++ b/apps/viewer/server/server.ts @@ -2,6 +2,7 @@ import { existsSync } from "node:fs"; import { resolve } from "node:path"; import type { Server } from "bun"; import packageJson from "../package.json"; +import { type BlobCache, createBlobCache } from "./blobCache.ts"; import { type CwdDeps, isCwdAlive } from "./cwd.ts"; import { getDiffFiles, @@ -114,9 +115,15 @@ const createHandler = (cfg: { // 테스트 전용 훅 — 프로덕션에서는 항상 undefined라 REAL_CWD_DEPS를 쓴다. // flightTimeoutMs와 같은 패턴이다. cwdDeps?: CwdDeps; + // 테스트 전용 훅 — 프로덕션에서는 항상 undefined라 핸들러가 자기 캐시를 + // 만든다. flightTimeoutMs와 같은 패턴이다. + blobCache?: BlobCache; }) => { const viewerRoot = resolve(cfg.viewerDir); const diffCache = createPayloadCache(); + // 변경 폴의 재빌드가 바뀌지 않은 blob을 다시 `git show`하지 않게 한다. + // diffCache와 같이 핸들러마다 하나 — prewarm과 /api/diff가 공유한다. + const blobs = cfg.blobCache ?? createBlobCache(); // 동시 콜드 요청(프리워밍 + 첫 화면 + 폴)이 gh pr view를 중복 실행하지 // 않게 single-flight로 합류시킨다. diffFlight와 마찬가지로 핸들러 // 인스턴스마다 새로 만든다 — flightTimeoutMs를 인스턴스별로 다르게 줄 @@ -283,13 +290,12 @@ const createHandler = (cfg: { if (cached) return cached; const files = mode === "base" - ? await getDiffFiles(repo, { - untracked, - mode: "base", - ref: ref ?? undefined, - head, - }) - : await getDiffFiles(repo, { untracked, head }); + ? await getDiffFiles( + repo, + { untracked, mode: "base", ref: ref ?? undefined, head }, + blobs, + ) + : await getDiffFiles(repo, { untracked, head }, blobs); const fresh = { fingerprint, etag: payloadEtag(files), @@ -434,6 +440,8 @@ export const startDiffServer = (opts: { // 테스트 전용 훅 — 프로덕션에서는 항상 undefined라 REAL_CWD_DEPS를 쓴다. // flightTimeoutMs와 같은 패턴이다. cwdDeps?: CwdDeps; + // 테스트 전용 훅 — createHandler의 같은 이름 필드로 그대로 흘러간다. + blobCache?: BlobCache; }): DiffServerHandle => { const env = opts.env ?? process.env; // Mint the token but don't write it yet — Bun.serve throws if the port is @@ -448,6 +456,7 @@ export const startDiffServer = (opts: { flightTimeoutMs: opts.flightTimeoutMs, repairCwd: opts.repairCwd, cwdDeps: opts.cwdDeps, + blobCache: opts.blobCache, }); const server = Bun.serve({ hostname: "127.0.0.1", diff --git a/apps/viewer/server/summary.ts b/apps/viewer/server/summary.ts index df6f57a..9df9dc4 100644 --- a/apps/viewer/server/summary.ts +++ b/apps/viewer/server/summary.ts @@ -4,7 +4,7 @@ import { gitText } from "./gitOutput.ts"; /** * 빈 diff 화면의 정보형 빈 상태 전용 경량 요약. diff가 0건일 때만 lazy하게 * 호출되므로 캐시 없음. 개수 파싱은 전부 -z + NUL 분할 (비-ASCII/개행 - * 파일명 안전 — parseNameStatusZ와 같은 이유). + * 파일명 안전 — parseRawZ와 같은 이유). */ export interface RepoSummary { branch: string | null;