Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

58 changes: 58 additions & 0 deletions apps/viewer/__tests__/blob-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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<ArrayBuffer> => 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);
});
181 changes: 181 additions & 0 deletions apps/viewer/__tests__/diff-blob-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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 <rev>`는 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<string> =>
(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");
});
2 changes: 1 addition & 1 deletion apps/viewer/__tests__/diff-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-statusls-files가
// 비-ASCII·특수문자 경로를 큰따옴표+8진 이스케이프로 인용해서 낸다
// (예: 한글.txt → "\355\225\234\352\270\200.txt"). 그 인용 문자열을 그대로
// 경로로 쓰면 git show/readFileSync가 못 찾아 내용이 빈 채로 렌더된다.
Expand Down
2 changes: 1 addition & 1 deletion apps/viewer/__tests__/diff-gaps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
4 changes: 2 additions & 2 deletions apps/viewer/__tests__/diff-large-blob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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을 끝까지 읽는지를 지킨다.
*/
Expand Down
92 changes: 92 additions & 0 deletions apps/viewer/__tests__/diff-raw.test.ts
Original file line number Diff line number Diff line change
@@ -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 },
]);
});
Loading
Loading