From 9c80696900d32233caee2bbfbda636428a768bc2 Mon Sep 17 00:00:00 2001 From: Penguin Date: Mon, 21 Sep 2026 18:46:39 +0900 Subject: [PATCH 1/7] =?UTF-8?q?perf:=20blob=20OID=EB=A5=BC=20=ED=82=A4?= =?UTF-8?q?=EB=A1=9C=20=ED=95=98=EB=8A=94=20=EB=B0=94=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EC=83=81=ED=95=9C=20LRU=EB=A5=BC=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경 폴의 재빌드가 바뀌지 않은 blob을 다시 `git show`하지 않게 할 캐시. 키가 내용의 해시라 무효화가 없고, 상한(기본 64MB)을 넘으면 오래된 것부터 버린다. 상한보다 큰 항목은 저장하지 않는다. 아직 아무도 쓰지 않는다 — 다음 커밋들이 `getDiffFiles`와 서버에 배선한다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK --- apps/viewer/__tests__/blob-cache.test.ts | 58 ++++++++++++++++++++ apps/viewer/server/blobCache.ts | 68 ++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 apps/viewer/__tests__/blob-cache.test.ts create mode 100644 apps/viewer/server/blobCache.ts 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/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 }), + }; +}; From 839e3f1161b8235e8045d4f58faa29dc3eaf7eb8 Mon Sep 17 00:00:00 2001 From: Penguin Date: Mon, 21 Sep 2026 18:47:05 +0900 Subject: [PATCH 2/7] =?UTF-8?q?refactor:=20git=20=ED=97=AC=ED=8D=BC?= =?UTF-8?q?=EA=B0=80=20=EC=A2=85=EB=A3=8C=20=EC=BD=94=EB=93=9C=EB=8F=84=20?= =?UTF-8?q?=EB=8F=8C=EB=A0=A4=EC=A3=BC=EB=8A=94=20gitRun=EC=9D=84=20?= =?UTF-8?q?=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit blob 캐시가 실패한 `git show`(빈 출력)를 저장하지 않으려면 빈 파일과 실패를 갈라야 한다. `gitRun`이 stdout과 종료 코드를 함께 주고, `gitBytes`는 그 위에서 지금과 같은 동작(종료 코드 무시)을 유지한다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK --- apps/viewer/__tests__/git-output.test.ts | 24 +++++++++++++++++++++++- apps/viewer/server/gitOutput.ts | 21 +++++++++++++++++---- 2 files changed, 40 insertions(+), 5 deletions(-) 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/server/gitOutput.ts b/apps/viewer/server/gitOutput.ts index 500a918..71cf32c 100644 --- a/apps/viewer/server/gitOutput.ts +++ b/apps/viewer/server/gitOutput.ts @@ -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)); From 235b21fcae80dfec4d49b753b38b6b42468f12f6 Mon Sep 17 00:00:00 2001 From: Penguin Date: Mon, 21 Sep 2026 18:48:18 +0900 Subject: [PATCH 3/7] =?UTF-8?q?refactor:=20diff=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=9D=84=20--raw=20--no-abbrev=EB=A1=9C=20=EB=BD=91=EC=95=84?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=EB=A7=88=EB=8B=A4=20blob=20OID=EB=A5=BC?= =?UTF-8?q?=20=EC=96=BB=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `git diff --name-status -z`를 `git diff --raw -z --no-abbrev`로 바꾼다. 같은 한 번의 호출에 파일마다 old/new blob OID가 실려 온다 — 다음 커밋의 blob 캐시가 이것을 키로 쓴다. 상태 매핑·경로·끝의 `--`는 그대로라 동작은 같다. `--no-abbrev`가 계약이다: `--full-index`로는 7자 약어가 나온다(실측). 없는 쪽 OID(0)는 null로 둔다. `buildFile`은 필드가 늘어 spec 객체를 받는다. 파서 테스트의 입력은 git 2.55의 실제 출력(A·D·M·R·T, SHA-256)을 옮긴 것이다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK --- apps/viewer/__tests__/diff-raw.test.ts | 84 ++++++++++++++++++++++++++ apps/viewer/server/diff.ts | 81 ++++++++++++++++--------- apps/viewer/server/summary.ts | 2 +- 3 files changed, 138 insertions(+), 29 deletions(-) create mode 100644 apps/viewer/__tests__/diff-raw.test.ts diff --git a/apps/viewer/__tests__/diff-raw.test.ts b/apps/viewer/__tests__/diff-raw.test.ts new file mode 100644 index 0000000..86b745f --- /dev/null +++ b/apps/viewer/__tests__/diff-raw.test.ts @@ -0,0 +1,84 @@ +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([]); +}); diff --git a/apps/viewer/server/diff.ts b/apps/viewer/server/diff.ts index b2dace1..92c41f8 100644 --- a/apps/viewer/server/diff.ts +++ b/apps/viewer/server/diff.ts @@ -158,12 +158,11 @@ const readWorkingBytes = ( const buildFile = async ( repo: string, base: string, - status: DiffFileStatus, - name: string, - oldName?: string, + spec: FileSpec, /** new 쪽 리비전. 없으면 워킹트리(디스크의 지금 파일)를 읽는다. */ head?: string, ): Promise => { + const { status, name, oldName } = spec; const oldBytes = status === "added" || status === "untracked" ? new Uint8Array() @@ -189,30 +188,50 @@ 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; + +// `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 [, , oldRaw = "", newRaw = "", code = ""] = meta.slice(1).split(" "); + const oldOid = oidOrNull(oldRaw); + const newOid = oidOrNull(newRaw); if (/^[RC]/.test(code)) { - // C(copy)는 이 호출이 -C/--find-copies 없이 도는 한(현재 미사용) git이 - // 내지 않아 실제로는 미도달 — 나중에 copy 감지를 켜면 이 분기가 살아난다. - const oldName = tokens[i]; + // C(copy)는 이 호출이 -C/--find-copies 없이 도는 한 git이 내지 않아 + // 실제로는 미도달 — 나중에 copy 감지를 켜면 이 분기가 살아난다. + 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 +240,7 @@ const parseNameStatusZ = ( : code.startsWith("D") ? "deleted" : "modified"; - specs.push({ status, name }); + specs.push({ status, name, oldOid, newOid }); } } return specs; @@ -306,22 +325,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), )), ); } @@ -340,7 +360,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/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; From 073dacc8d291147d9ff2136b7b34169b40b8c05b Mon Sep 17 00:00:00 2001 From: Penguin Date: Mon, 21 Sep 2026 18:50:29 +0900 Subject: [PATCH 4/7] =?UTF-8?q?perf:=20=EB=B3=80=EA=B2=BD=20=ED=8F=B4?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B0=94=EB=80=8C=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EC=9D=80=20blob=EC=9D=98=20git=20show=EB=A5=BC=20blob=20OID=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=EB=A1=9C=20=EA=B1=B4=EB=84=88=EB=9B=B4?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getDiffFiles(repo, opts, blobs)`가 선택 인자로 blob 캐시를 받는다. old 쪽과 head 모드의 new 쪽은 `--raw`가 준 OID로 캐시를 먼저 보고, 없으면 지금과 같은 `git show`로 읽는다. 워킹트리 new 쪽은 캐시하지 않는다(디스크가 진실). - 키는 전체 OID — 이름으로 잡으면 커밋 직후 옛 내용을 낸다. - `git show`의 종료 코드가 0일 때만 저장한다. 워킹트리 비교에선 old blob이 없으면 `git diff --raw`가 먼저 죽으므로(실측), 이 가드가 실제로 지키는 곳은 목록이 blob을 읽지 않는 head 모드다 — 테스트도 그 모드에서 재현한다. - OID는 캐시 키로만 쓴다. old 쪽을 읽을지는 지금처럼 상태가 정한다. 회귀망 `diff-blob-cache.test.ts` 4종. 이름 키 뮤테이션과 실패 저장 뮤테이션을 각각 정확히 한 테스트가 잡는다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK --- apps/viewer/__tests__/diff-blob-cache.test.ts | 109 ++++++++++++++++++ apps/viewer/server/diff.ts | 43 ++++++- 2 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 apps/viewer/__tests__/diff-blob-cache.test.ts 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..2f8dbce --- /dev/null +++ b/apps/viewer/__tests__/diff-blob-cache.test.ts @@ -0,0 +1,109 @@ +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 { createBlobCache } from "../server/blobCache.ts"; +import { getDiffFiles } from "../server/diff.ts"; + +/** + * `getDiffFiles`가 blob OID 캐시를 거칠 때의 정확성 계약. 각 테스트가 잡는 깨짐: + * ① 캐시를 안 거치거나(hit 0) 워킹트리 new 쪽까지 캐시해 편집이 안 보이는 구현, + * ② 이름(`HEAD`, 경로)으로 키를 잡아 커밋 직후 옛 내용을 내는 구현, + * ③ 실패한 `git show`(빈 출력)를 저장해 빈 old 쪽이 눌러앉는 구현(head 모드에서만 + * 재현된다 — 해당 테스트 주석 참고), + * ④ 캐시 경로가 다른 바이트를 내거나 head 모드 new 쪽을 캐시하지 않는 구현. + */ + +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 `는 old blob이 없으면 목록 단계에서 먼저 죽는다 + // (`fatal: unable to read …`, 실측). 커밋끼리 비교하는 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 }); +}); diff --git a/apps/viewer/server/diff.ts b/apps/viewer/server/diff.ts index 92c41f8..7a25d93 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 서브프로세스가 뜨므로 무제한이면 @@ -144,6 +145,34 @@ const showBytes = ( ): Promise> => gitBytes(["-C", repo, "show", `${rev}:${path}`]); +// blob 하나를 읽되, OID와 캐시가 있으면 캐시를 먼저 본다. miss 경로는 +// `showBytes`와 같은 `git show :`라 캐시가 비어 있을 때 출력이 +// 지금과 바이트 단위로 같다. **종료 코드가 0일 때만 저장한다** — 잠깐 못 읽은 +// 결과(빈 바이트, 예: 부분 클론의 지연 페치 실패)를 저장하면 그 빈 내용이 +// 영구히 눌러앉는다(지금은 그 빌드에만 비고 다음 재빌드에서 회복된다). 워킹트리 +// 비교에선 old blob이 없으면 목록(`git diff --raw`)이 먼저 죽으므로, 이 가드가 +// 실제로 지키는 곳은 목록이 blob을 읽지 않는 head 모드(커밋 대 커밋)다(실측). +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", + `${rev}:${path}`, + ]); + if (oid && blobs && exitCode === 0) blobs.set(oid, stdout); + return stdout; +}; + const readWorkingBytes = ( repo: string, path: string, @@ -161,17 +190,21 @@ const buildFile = async ( 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(); @@ -307,6 +340,8 @@ export const getDiffFiles = async ( /** new 쪽 리비전. 없으면 워킹트리를 본다. */ head?: string; } = {}, + /** 변경 폴에서 바뀌지 않은 blob의 `git show`를 건너뛴다. 없으면 지금과 같다. */ + blobs?: BlobCache, ): Promise => { const base = await resolveDiffBaseRev(repo, opts); const files: DiffFile[] = []; @@ -341,7 +376,7 @@ export const getDiffFiles = async ( const specs = parseRawZ(raw); files.push( ...(await mapWithLimit(specs, BUILD_CONCURRENCY, (spec) => - buildFile(repo, base, spec, opts.head), + buildFile(repo, base, spec, opts.head, blobs), )), ); } From f301c47f194f45d5b7289a933f17f9f617e1bd93 Mon Sep 17 00:00:00 2001 From: Penguin Date: Mon, 21 Sep 2026 18:51:44 +0900 Subject: [PATCH 5/7] =?UTF-8?q?perf:=20=EC=84=9C=EB=B2=84=20=ED=95=B8?= =?UTF-8?q?=EB=93=A4=EB=9F=AC=EB=A7=88=EB=8B=A4=20blob=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=EB=A5=BC=20=ED=95=98=EB=82=98=20=EB=91=90=EA=B3=A0=20?= =?UTF-8?q?diff=20=EC=9E=AC=EB=B9=8C=EB=93=9C=EC=97=90=20=EB=84=98?= =?UTF-8?q?=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createHandler`가 `diffCache` 옆에 blob 캐시를 하나 만들어 `/api/diff`의 두 `getDiffFiles` 호출(워킹트리 모드·base 모드)에 넘긴다. prewarm과 `/api/diff`가 공유한다. `startDiffServer`에 테스트용 `blobCache` 이음새를 둔다 (`flightTimeoutMs`·`cwdDeps`와 같은 종류). 배선 테스트는 두 모드를 따로 찌른다 — 한 모드만 보면 다른 호출에서 캐시를 빼도 초록이다. 호출을 하나씩 빼는 뮤테이션으로 각각 한 테스트만 죽는 것을 확인했다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK --- apps/viewer/__tests__/diff-server.test.ts | 44 +++++++++++++++++++++++ apps/viewer/server/server.ts | 23 ++++++++---- 2 files changed, 60 insertions(+), 7 deletions(-) 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/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", From 4fc35dff46b27d7db30ceda515e3c4b25f63da08 Mon Sep 17 00:00:00 2001 From: Penguin Date: Mon, 21 Sep 2026 19:00:21 +0900 Subject: [PATCH 6/7] =?UTF-8?q?docs:=20blob=20OID=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=EC=9D=98=20=EA=B3=84=EC=95=BD=EC=9D=84=20CLAUDE.md=EC=97=90=20?= =?UTF-8?q?=EC=A0=81=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 키는 전체 OID(`--no-abbrev`, 이름 금지), 종료 코드 0만 저장(가드가 실제로 지키는 곳은 목록이 blob을 읽지 않는 head 모드 — 워킹트리 비교는 old blob이 없으면 목록이 먼저 죽는다), OID는 키로만, 워킹트리 new 쪽은 캐시 안 함. 회귀망과 각 테스트가 잡는 뮤테이션도 함께 적는다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index bc9772e..e5035a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,6 +133,7 @@ cd scripts/parity && python3 -m http.server 8099 # http://127.0.0.1:8099/index.h - **평범한 데이터 갱신에도 픽셀 `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`. +- **변경 폴의 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**다 — 이름(`HEAD`·브랜치)으로 잡으면 커밋 직후 같은 이름이 다른 내용을 가리켜 옛 내용이 나오고, `--full-index`는 여기서 7자 약어를 준다(실측). 키가 내용의 해시라 무효화 로직이 없다(`git show :`의 출력은 OID만의 함수다 — textconv·`eol`·필터가 걸린 경로에서도 실측 동일). ② **`git show`의 종료 코드가 0일 때만 저장한다**(`gitRun`) — 잠깐 못 읽은 빈 결과를 저장하면 영구히 눌러앉는다. 이 가드가 실제로 지키는 곳은 목록이 blob을 읽지 않는 **head 모드**다: 워킹트리와 비교하는 `git diff `는 old blob이 없으면 목록 단계에서 먼저 `fatal: unable to read`로 죽는다(실측 — 그래서 회귀 테스트도 head 모드에서 재현한다). ③ **OID는 캐시 키로만** 쓴다 — old 쪽을 읽을지는 지금처럼 상태가 정한다. ④ 워킹트리 모드의 new 쪽은 캐시하지 않는다(디스크가 진실이고 전부 읽어도 4.3ms). 캐시는 `createHandler`마다 하나(prewarm과 `/api/diff`가 공유), 상한 64MB LRU(556파일 diff의 양쪽 합이 44.5MB였다). payload 캐시·지문·etag는 그대로다. 회귀망: `diff-blob-cache.test.ts` 4종(이름 키·실패 저장 뮤테이션을 각각 정확히 한 테스트가 잡는다) + `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개가 그 상태였다). 새 분기를 넣을 때 커버리지 초록을 증거로 받지 말고, **일부러 그 분기로 들어가는 테스트**를 따로 둘 것. From c1871d76793d25c896b75715c10b40a150881cd4 Mon Sep 17 00:00:00 2001 From: Penguin Date: Mon, 21 Sep 2026 19:10:38 +0900 Subject: [PATCH 7/7] =?UTF-8?q?experiment:=20[=EB=A8=B8=EC=A7=80=20?= =?UTF-8?q?=EA=B8=88=EC=A7=80]=20#78=EC=9D=98=20=EB=8B=A4=EC=84=AF=20git?= =?UTF-8?q?=20=ED=98=B8=EC=B6=9C=EC=9D=84=20$=EB=A1=9C=20=EB=90=98?= =?UTF-8?q?=EB=8F=8C=EB=A6=B0=20e2e=20A/B=20=EB=8C=80=EC=A1=B0=EA=B5=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/viewer/server/diff.ts | 32 ++++++++++++------------------- apps/viewer/server/fingerprint.ts | 5 +++-- apps/viewer/server/refs.ts | 16 ++++++---------- 3 files changed, 21 insertions(+), 32 deletions(-) diff --git a/apps/viewer/server/diff.ts b/apps/viewer/server/diff.ts index 7a25d93..55e6371 100644 --- a/apps/viewer/server/diff.ts +++ b/apps/viewer/server/diff.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; import { $ } from "bun"; import type { BlobCache } from "./blobCache.ts"; -import { gitBytes, gitRun, gitText } from "./gitOutput.ts"; +import { gitBytes, gitRun } from "./gitOutput.ts"; import { mapWithLimit } from "./mapLimit.ts"; // buildFile 병렬 실행 상한 — 파일당 git 서브프로세스가 뜨므로 무제한이면 @@ -360,17 +360,13 @@ export const getDiffFiles = async ( // `rev-parse`·`show :`는 rev만 받아 영향이 없다(실측). // // `$`가 아니라 `gitText`다 — 큰 diff에서 출력이 64KB를 넘는다. - const raw = await gitText([ - "-C", - repo, - "diff", - "--raw", - "-z", - "--no-abbrev", - base, - ...(opts.head ? [opts.head] : []), - "--", - ]); + const raw = opts.head + ? await $`git -C ${repo} diff --raw -z --no-abbrev ${base} ${opts.head} -- 2>/dev/null` + .nothrow() + .text() + : await $`git -C ${repo} diff --raw -z --no-abbrev ${base} -- 2>/dev/null` + .nothrow() + .text(); // 파일별 git show/워킹트리 읽기는 서로 독립이라 병렬화하되, 대형 diff에서 // git 서브프로세스가 무제한으로 뜨지 않도록 동시성을 제한한다 (순서 유지). const specs = parseRawZ(raw); @@ -384,14 +380,10 @@ export const getDiffFiles = async ( // 없으므로 건너뛴다(디스크를 훑어 봐야 그건 워킹트리의 사실이지 이 뷰의 // 사실이 아니다). if (opts.untracked && !opts.head) { - const listed = await gitText([ - "-C", - repo, - "ls-files", - "--others", - "--exclude-standard", - "-z", - ]); + const listed = + await $`git -C ${repo} ls-files --others --exclude-standard -z 2>/dev/null` + .nothrow() + .text(); const paths = listed.split("\0").filter((s) => s !== ""); files.push( ...(await mapWithLimit(paths, BUILD_CONCURRENCY, (path) => diff --git a/apps/viewer/server/fingerprint.ts b/apps/viewer/server/fingerprint.ts index a9e122c..650ef99 100644 --- a/apps/viewer/server/fingerprint.ts +++ b/apps/viewer/server/fingerprint.ts @@ -14,7 +14,6 @@ import { statSync } from "node:fs"; import { join } from "node:path"; import { $ } from "bun"; -import { gitText } from "./gitOutput.ts"; export const repoFingerprint = async ( repo: string, @@ -30,7 +29,9 @@ export const repoFingerprint = async ( const [status, head, baseRev, headRev] = await Promise.all([ // `$`가 아니라 `gitText` — untracked를 켜면 출력이 64KB를 쉽게 넘고, watch가 // 2초마다 부르는 자리라 Bun 1.3.x `$`의 never-settle에 가장 오래 노출된다. - gitText(["-C", repo, "status", "--porcelain", "-z", untrackedFlag]), + $`git -C ${repo} status --porcelain -z ${untrackedFlag} 2>/dev/null` + .nothrow() + .text(), $`git -C ${repo} rev-parse HEAD 2>/dev/null`.nothrow().text(), opts.mode === "base" && opts.ref ? $`git -C ${repo} rev-parse ${opts.ref} 2>/dev/null`.nothrow().text() diff --git a/apps/viewer/server/refs.ts b/apps/viewer/server/refs.ts index 7bf1ca6..44bb239 100644 --- a/apps/viewer/server/refs.ts +++ b/apps/viewer/server/refs.ts @@ -5,7 +5,7 @@ * 개념이 아니라 git이 이미 갖고 있는 관계다: `%(worktreepath)`가 브랜치마다 * 그것을 물고 있는 워크트리를 알려준다. */ -import { gitText } from "./gitOutput.ts"; +import { $ } from "bun"; export interface WorktreeRecord { path: string; @@ -161,15 +161,11 @@ export const getRefs = async (repo: string): Promise => { // 둘 다 `$`가 아니라 `gitText` — 출력이 참조 수·등록된 워크트리 수에 // 비례해 64KB를 넘을 수 있다(워크트리는 디렉토리가 지워져도 prunable로 // 등록이 남으므로 약 300개면 넘는다). - gitText(["-C", repo, "worktree", "list", "--porcelain", "-z"]), - gitText([ - "-C", - repo, - "for-each-ref", - REF_FORMAT, - "refs/heads", - "refs/remotes", - ]), + $`git -C ${repo} worktree list --porcelain -z`.nothrow().quiet().text(), + $`git -C ${repo} for-each-ref ${REF_FORMAT} refs/heads refs/remotes` + .nothrow() + .quiet() + .text(), ]); const worktrees = parseWorktreeList(wtRaw); const live = new Set(worktrees.map((w) => w.path));