From 3550228c095790c35e572b849b8ff57965b2b8dc Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 21 Aug 2026 00:57:20 +0900 Subject: [PATCH 01/13] =?UTF-8?q?fix:=20=EB=B9=84ASCII=20=EB=B8=8C?= =?UTF-8?q?=EB=9E=9C=EC=B9=98=EB=AA=85=EC=9D=B4=20diff=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=9D=84=20500=EC=9C=BC=EB=A1=9C=20=EB=A7=8C=EB=93=9C?= =?UTF-8?q?=EB=8A=94=20=EA=B2=83=EC=9D=84=20=EA=B3=A0=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTP 헤더 값은 latin1이라 한글·일본어·중국어 브랜치명을 x-diff-base에 그대로 실으면 Response 생성이 throw하고 응답 전체가 500이 된다(실측: Bun 1.3.12 — `Header 'x-diff-base' has invalid value: '기능'`). 클라의 fetchDiffOnce는 비-503 non-ok를 terminal로 매핑해 재시도 없이 "Failed to load diff."를 띄우므로, 그런 리포는 diff가 아예 뜨지 않았다. 이 레포는 이미 korean-filename.e2e.ts를 갖고 있어 비ASCII git 식별자는 범위 안이다. 서버가 percent-encode하고 클라가 decodeHeaderValue로 되돌린다. ASCII 이름은 encodeURIComponent가 항등이라 기존 동작과 테스트가 그대로다. 인코딩하지 않는 옛 서버가 "%"를 품은 브랜치명을 보내는 경우를 위해 디코딩은 URIError를 삼키고 원문으로 폴백한다(커버리지 게이트가 branch를 안 세므로 그 갈래를 찌르는 테스트를 따로 뒀다). 계측 함정 기록: Bun 1.3.12의 $ 템플릿에 비ASCII를 **리터럴**로 적으면 "uAE30uB2A5" 같은 ASCII 텍스트로 뭉개진다(실측 코드포인트 75 41 45 33 30 …). 처음 쓴 테스트가 한글이 아닌 브랜치를 만들어 조용히 통과했다. refname은 반드시 ${보간}으로 넘길 것. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ArwfmcvGjKnRWMiEFBfJzF --- apps/viewer/__tests__/diff-server.test.ts | 43 ++++++++++++++++++++++ apps/viewer/__tests__/header-value.test.ts | 24 ++++++++++++ apps/viewer/browser/headerValue.ts | 19 ++++++++++ apps/viewer/browser/main.ts | 3 +- apps/viewer/server/server.ts | 4 +- 5 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 apps/viewer/__tests__/header-value.test.ts create mode 100644 apps/viewer/browser/headerValue.ts diff --git a/apps/viewer/__tests__/diff-server.test.ts b/apps/viewer/__tests__/diff-server.test.ts index 7ea027f..1667c80 100644 --- a/apps/viewer/__tests__/diff-server.test.ts +++ b/apps/viewer/__tests__/diff-server.test.ts @@ -579,3 +579,46 @@ describe("diff server flight timeout", () => { } }); }); + +// HTTP 헤더 값은 latin1이다. git은 refname에 비ASCII를 허용하므로 base 브랜치 +// 이름이 한글/일본어/중국어이면 x-diff-base를 실은 Response 생성이 throw하고 +// 응답 전체가 500이 된다(Bun 1.3.12 실측). 클라의 fetchDiffOnce는 비-503 +// non-ok를 terminal로 매핑해 재시도 없이 "Failed to load diff."를 띄우므로, +// 그런 리포는 diff가 아예 뜨지 않는다. 이 리포는 이미 korean-filename.e2e.ts를 +// 갖고 있어 비ASCII git 식별자는 범위 안이다. +describe("diff server non-latin1 base name", () => { + // 원격 없이 refs만 세워 hermetic하게 만든다: origin/HEAD -> origin/<한글>. + // resolveBaseRef의 defaultBranchName 갈래가 이걸 읽어 base를 낸다. + // + // refname을 반드시 ${보간}으로 넘길 것 — Bun 1.3.12의 $ 템플릿에 비ASCII를 + // 리터럴로 적으면 "uAE30uB2A5" 같은 ASCII 텍스트로 뭉개져(실측: 코드포인트 + // 75 41 45 33 30 …) 한글이 아닌 브랜치가 만들어지고, 테스트가 조용히 + // 아무것도 검증하지 않게 된다. + const KOREAN_BRANCH = "기능"; + const setUpKoreanBase = async (): Promise => { + const head = (await $`git -C ${repo} rev-parse HEAD`.text()).trim(); + const ref = `refs/remotes/origin/${KOREAN_BRANCH}`; + await $`git -C ${repo} update-ref ${ref} ${head}`; + await $`git -C ${repo} symbolic-ref refs/remotes/origin/HEAD ${ref}`; + }; + + test("serves the diff instead of 500 when the base branch is non-latin1", async () => { + await setUpKoreanBase(); + const token = readTokenSync({ XDG_CACHE_HOME: cacheHome }); + const res = await fetch( + `${base}/api/diff?repo=${encodeURIComponent(repo)}&token=${token}`, + ); + expect(res.status).toBe(200); + }); + + test("reports the non-latin1 base name so the client can render it", async () => { + await setUpKoreanBase(); + const token = readTokenSync({ XDG_CACHE_HOME: cacheHome }); + const res = await fetch( + `${base}/api/diff?repo=${encodeURIComponent(repo)}&token=${token}`, + ); + expect(decodeURIComponent(res.headers.get("x-diff-base") ?? "")).toBe( + KOREAN_BRANCH, + ); + }); +}); diff --git a/apps/viewer/__tests__/header-value.test.ts b/apps/viewer/__tests__/header-value.test.ts new file mode 100644 index 0000000..8eb0adf --- /dev/null +++ b/apps/viewer/__tests__/header-value.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { decodeHeaderValue } from "../browser/headerValue.ts"; + +describe("decodeHeaderValue", () => { + test("decodes a percent-encoded non-latin1 branch name", () => { + expect(decodeHeaderValue(encodeURIComponent("기능"))).toBe("기능"); + }); + + test("leaves an ascii branch name unchanged", () => { + expect(decodeHeaderValue("main")).toBe("main"); + }); + + test("treats a missing header as empty", () => { + expect(decodeHeaderValue(null)).toBe(""); + }); + + // 커버리지 게이트는 branch를 세지 않으므로 이 갈래를 일부러 찌른다. + // 인코딩하지 않는 옛 서버가 "%"를 품은 브랜치명을 그대로 보내면 + // decodeURIComponent가 URIError를 던진다 — 라벨 하나 때문에 diff 전체를 + // 잃지 않도록 원문으로 되돌린다. + test("falls back to the raw value when the encoding is malformed", () => { + expect(decodeHeaderValue("release-50%-done")).toBe("release-50%-done"); + }); +}); diff --git a/apps/viewer/browser/headerValue.ts b/apps/viewer/browser/headerValue.ts new file mode 100644 index 0000000..7f096e8 --- /dev/null +++ b/apps/viewer/browser/headerValue.ts @@ -0,0 +1,19 @@ +/** + * 응답 헤더로 실려 온 git 식별자(브랜치명 등)를 사람이 읽는 문자열로 되돌린다. + * + * HTTP 헤더 값은 latin1이라 비ASCII를 그대로 담을 수 없다 — 담으면 Bun의 + * Response 생성 자체가 throw해서 응답 전체가 500이 된다(실측: Bun 1.3.12, + * `Header 'x-diff-base' has invalid value: '기능'`). git은 refname에 비ASCII를 + * 허용하므로 서버가 percent-encode해 보내고, 여기서 되돌린다. + */ +export const decodeHeaderValue = (raw: string | null): string => { + if (raw == null) return ""; + try { + return decodeURIComponent(raw); + } catch { + // 인코딩하지 않는 옛 서버가 "%"를 품은 브랜치명을 그대로 보내면 + // decodeURIComponent가 URIError를 던진다. 라벨 하나 때문에 diff 전체를 + // 잃는 것보다 원문을 쓰는 편이 낫다. + return raw; + } +}; diff --git a/apps/viewer/browser/main.ts b/apps/viewer/browser/main.ts index 4dfa302..92c4fc5 100644 --- a/apps/viewer/browser/main.ts +++ b/apps/viewer/browser/main.ts @@ -41,6 +41,7 @@ import { } from "./grab/selectionAdapter.ts"; import { extractSnippet } from "./grab/snippet.ts"; import { resolveTextTarget, rowSide } from "./grab/textSelection.ts"; +import { decodeHeaderValue } from "./headerValue.ts"; import { ensureImageCard, IMAGE_CARD_CSS } from "./imageCard.ts"; import { blobUrl, type ImageEntry, imageEntries } from "./imageDiff.ts"; import { countChangedLines, isLargeFile } from "./largeFile.ts"; @@ -1072,7 +1073,7 @@ const fetchDiffOnce = async (): Promise => { const res = await fetch(`/api/diff?${query.toString()}`, { headers: lastEtag ? { "if-none-match": lastEtag } : {}, }); - const base = res.headers.get("x-diff-base") ?? ""; + const base = decodeHeaderValue(res.headers.get("x-diff-base")); if (res.status === 304) return { kind: "unchanged", base }; if (res.status === 503) return { kind: "retryable" }; if (!res.ok) return { kind: "terminal" }; diff --git a/apps/viewer/server/server.ts b/apps/viewer/server/server.ts index 4b4ccf6..5d83c57 100644 --- a/apps/viewer/server/server.ts +++ b/apps/viewer/server/server.ts @@ -227,14 +227,14 @@ const createHandler = (cfg: { if (req.headers.get("if-none-match") === etag) { return new Response(null, { status: 304, - headers: { etag, "x-diff-base": base ?? "" }, + headers: { etag, "x-diff-base": encodeURIComponent(base ?? "") }, }); } // NOTE: intentionally no Access-Control-Allow-Origin — cross-origin pages must not read this. return new Response(entry.body, { headers: { "content-type": "application/json; charset=utf-8", - "x-diff-base": base ?? "", + "x-diff-base": encodeURIComponent(base ?? ""), etag, }, }); From 3db508699a6747703e68e1cd3428ec82b1643b68 Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 21 Aug 2026 10:26:27 +0900 Subject: [PATCH 02/13] =?UTF-8?q?refactor:=20diff=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=ED=8C=8C=EC=8B=B1=EC=9D=84=20=ED=95=9C=20=EA=B3=B3=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=AA=A8=EC=9C=BC=EA=B3=A0=20flight=20=ED=82=A4?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=84=ED=95=A8=EC=88=98=EB=A1=9C=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/diff·/api/blob·/api/summary가 각자 repo 파싱과 `mode` 삼항을 복제해 갖고 있어 셋이 조용히 갈라질 수 있었다 — 텍스트 diff는 base를 보는데 이미지 blob은 워킹트리를 보는 식으로. server/selection.ts의 parseSelection 하나로 모은다. 같은 파일의 selectionCacheKey가 payload 캐시와 single-flight가 공유하는 키를 만든다. 예전 키(repo\0untracked\0mode)에는 해석된 base ref가 없는데 flight 클로저는 그 ref를 읽었다. createSingleFlight는 키가 같으면 fn을 다시 읽지 않고 기존 프라미스를 돌려주므로, base가 다르게 해석된 두 요청 중 한쪽이 남의 ref로 만든 diff를 받을 수 있었다(baseCache TTL 만료나 `gh pr view` 결과 변화로 도달한다). 키에 ref 이름을 넣어 클로저 캡처의 전함수로 만든다. 동시성이라 결정적 e2e를 쓰기 어려운 계약인데, 키 생성이 순수 함수가 되면서 유닛으로 직접 단언할 수 있게 됐다. ref는 이름으로 넣는다 — OID를 넣으면 커밋마다 새 슬롯이 생겨 8칸 LRU가 헛돈다(OID는 지문이 볼 몫이다). head 기준일 때는 ref를 키에서 뺀다. 넣으면 origin/HEAD가 움직일 때마다 워킹트리 뷰 캐시가 이유 없이 날아간다. 동작 변화는 키에 ref가 들어가는 것 하나뿐이고 나머지는 순수 리팩터다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ArwfmcvGjKnRWMiEFBfJzF --- apps/viewer/__tests__/diff-selection.test.ts | 83 ++++++++++++++++++++ apps/viewer/server/selection.ts | 54 +++++++++++++ apps/viewer/server/server.ts | 18 +++-- 3 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 apps/viewer/__tests__/diff-selection.test.ts create mode 100644 apps/viewer/server/selection.ts diff --git a/apps/viewer/__tests__/diff-selection.test.ts b/apps/viewer/__tests__/diff-selection.test.ts new file mode 100644 index 0000000..4ea9068 --- /dev/null +++ b/apps/viewer/__tests__/diff-selection.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { parseSelection, selectionCacheKey } from "../server/selection.ts"; + +const params = (query: string): URLSearchParams => new URLSearchParams(query); + +describe("parseSelection", () => { + test("defaults to the head base when mode is absent", () => { + expect(parseSelection(params("repo=/r")).base).toEqual({ kind: "head" }); + }); + + test("mode=base selects the auto-resolved base", () => { + expect(parseSelection(params("repo=/r&mode=base")).base).toEqual({ + kind: "auto", + }); + }); + + test("mode=working selects the head base", () => { + expect(parseSelection(params("repo=/r&mode=working")).base).toEqual({ + kind: "head", + }); + }); + + // 오늘의 삼항(=== "base" ? ... : "working")과 같은 관용: 알 수 없는 값은 + // 400이 아니라 조용히 working으로 떨어진다. 링크가 깨지지 않는 쪽이다. + test("an unknown mode falls back to the head base", () => { + expect(parseSelection(params("repo=/r&mode=nonsense")).base).toEqual({ + kind: "head", + }); + }); + + test("untracked is on only for the exact string 1", () => { + expect(parseSelection(params("repo=/r&untracked=1")).untracked).toBe(true); + expect(parseSelection(params("repo=/r&untracked=true")).untracked).toBe( + false, + ); + expect(parseSelection(params("repo=/r")).untracked).toBe(false); + }); + + test("a missing repo becomes the empty string, as the routes expect", () => { + expect(parseSelection(params("")).repo).toBe(""); + }); +}); + +describe("selectionCacheKey", () => { + // 이 스위트가 지키는 진짜 계약: 키는 flight 클로저가 읽는 모든 입력의 + // 전함수여야 한다. 오늘 diffFlight의 클로저는 repo·untracked·mode·ref를 + // 읽는데 키에는 ref가 없어서, 해석된 base가 바뀐 두 요청이 같은 키로 + // 합류하면 한쪽이 남의 ref로 만든 diff를 받는다. + test("distinguishes two auto selections whose base resolved differently", () => { + const sel = parseSelection(params("repo=/r&mode=base")); + expect(selectionCacheKey(sel, "origin/main")).not.toBe( + selectionCacheKey(sel, "origin/develop"), + ); + }); + + test("ignores the resolved ref when the base is head", () => { + const sel = parseSelection(params("repo=/r&mode=working")); + expect(selectionCacheKey(sel, "origin/main")).toBe( + selectionCacheKey(sel, "origin/develop"), + ); + }); + + test("separates repo, untracked and base kind", () => { + const keys = new Set([ + selectionCacheKey(parseSelection(params("repo=/a&mode=working")), null), + selectionCacheKey(parseSelection(params("repo=/b&mode=working")), null), + selectionCacheKey( + parseSelection(params("repo=/a&mode=working&untracked=1")), + null, + ), + selectionCacheKey(parseSelection(params("repo=/a&mode=base")), null), + ]); + expect(keys.size).toBe(4); + }); + + // 경로·refname에 구분자가 섞여도 서로 다른 선택이 같은 키로 뭉개지지 + // 않아야 한다 (NUL은 두 값 어디에도 들어갈 수 없다). + test("does not collide when a repo path contains the separator's neighbours", () => { + expect( + selectionCacheKey(parseSelection(params("repo=/a%00false")), null), + ).not.toBe(selectionCacheKey(parseSelection(params("repo=/a")), null)); + }); +}); diff --git a/apps/viewer/server/selection.ts b/apps/viewer/server/selection.ts new file mode 100644 index 0000000..b2d3a81 --- /dev/null +++ b/apps/viewer/server/selection.ts @@ -0,0 +1,54 @@ +/** + * diff 선택(무엇을 무엇과 견주는가)의 단일 파서. + * + * /api/diff·/api/blob·/api/summary가 각자 `mode` 삼항을 복제해 갖고 있으면 + * 셋이 조용히 갈라질 수 있다 — 텍스트 diff는 base를 보는데 이미지 blob은 + * 워킹트리를 보는 식으로. 그 삼항을 여기 한 곳으로 모은다. + */ + +/** 무엇을 기준으로 견줄 것인가. */ +export type BaseSelector = + /** HEAD 대비 — 아직 커밋하지 않은 변경만 (레거시 `mode=working`). */ + | { kind: "head" } + /** 서버가 해석한 base 브랜치 대비 (레거시 `mode=base`). */ + | { kind: "auto" }; + +export interface Selection { + repo: string; + untracked: boolean; + base: BaseSelector; +} + +export const parseSelection = (params: URLSearchParams): Selection => ({ + repo: params.get("repo") ?? "", + untracked: params.get("untracked") === "1", + // 알 수 없는 값은 400이 아니라 working으로 떨어진다 — 오늘의 관용이고, + // 밖에서 만들어진 오래된 링크가 깨지지 않는 쪽이다. + base: params.get("mode") === "base" ? { kind: "auto" } : { kind: "head" }, +}); + +/** + * payload 캐시와 single-flight가 공유하는 키. + * + * **계약: 이 키는 flight 클로저가 읽는 모든 입력의 전함수여야 한다.** + * 클로저는 repo·untracked·base 종류·해석된 base ref를 읽으므로 넷이 전부 + * 들어간다. 예전에는 해석된 ref가 키에 없었는데, createSingleFlight는 키가 + * 같으면 fn을 다시 읽지 않고 기존 프라미스를 돌려주므로(singleFlight.ts), + * base가 다르게 해석된 두 요청 중 한쪽이 남의 ref로 만든 diff를 받을 수 + * 있었다(baseCache TTL 만료나 `gh pr view` 결과 변화로 도달한다). + * + * ref는 **이름**으로 넣는다. 커밋 OID를 넣으면 커밋마다 새 슬롯이 생겨 + * 8칸짜리 LRU가 헛돈다 — OID는 지문(fingerprint)이 볼 몫이다. + */ +export const selectionCacheKey = ( + sel: Selection, + resolvedBaseRef: string | null, +): string => + [ + sel.repo, + String(sel.untracked), + sel.base.kind, + // head 기준은 해석된 ref를 쓰지 않는다. 넣으면 origin/HEAD가 움직일 + // 때마다 워킹트리 뷰의 캐시가 이유 없이 날아간다. + sel.base.kind === "auto" ? (resolvedBaseRef ?? "") : "", + ].join("\0"); diff --git a/apps/viewer/server/server.ts b/apps/viewer/server/server.ts index 5d83c57..2e6e268 100644 --- a/apps/viewer/server/server.ts +++ b/apps/viewer/server/server.ts @@ -16,6 +16,7 @@ import { type PayloadCacheEntry, payloadEtag, } from "./payloadCache.ts"; +import { parseSelection, selectionCacheKey } from "./selection.ts"; import { createSingleFlight, SingleFlightTimeoutError, @@ -180,12 +181,13 @@ const createHandler = (cfg: { if (url.searchParams.get("token") !== cfg.token) { return new Response("forbidden", { status: 403 }); } - const repo = url.searchParams.get("repo") ?? ""; + const sel = parseSelection(url.searchParams); + const repo = sel.repo; if (!repo || !(await isGitRepo(repo))) { return new Response("not a git repository", { status: 400 }); } - const untracked = url.searchParams.get("untracked") === "1"; - const mode = url.searchParams.get("mode") === "base" ? "base" : "working"; + const untracked = sel.untracked; + const mode = sel.base.kind === "auto" ? "base" : "working"; const baseResult = await awaitFlight(resolveBaseCached(repo)); if (baseResult instanceof Response) return baseResult; const { base, ref } = baseResult; @@ -193,7 +195,7 @@ const createHandler = (cfg: { // 판정한다. 지문은 파이프라인 "이전"에 뜨므로, 그 사이에 리포가 // 바뀌면 저장된 지문이 이미 낡은 값이 되어 다음 요청이 무조건 // 재계산한다 — 낡은 payload가 눌러앉는 방향의 레이스는 없다. - const cacheKey = `${repo}\0${untracked}\0${mode}`; + const cacheKey = selectionCacheKey(sel, ref); const entryResult = await awaitFlight( diffFlight(cacheKey, async () => { const fingerprint = await repoFingerprint(repo, { @@ -244,7 +246,8 @@ const createHandler = (cfg: { if (url.searchParams.get("token") !== cfg.token) { return new Response("forbidden", { status: 403 }); } - const repo = url.searchParams.get("repo") ?? ""; + const sel = parseSelection(url.searchParams); + const repo = sel.repo; if (!repo || !(await isGitRepo(repo))) { return new Response("not a git repository", { status: 400 }); } @@ -262,7 +265,8 @@ const createHandler = (cfg: { if (url.searchParams.get("token") !== cfg.token) { return new Response("forbidden", { status: 403 }); } - const repo = url.searchParams.get("repo") ?? ""; + const sel = parseSelection(url.searchParams); + const repo = sel.repo; if (!repo || !(await isGitRepo(repo))) { return new Response("not a git repository", { status: 400 }); } @@ -272,7 +276,7 @@ const createHandler = (cfg: { return new Response("not found", { status: 404 }); } const side = url.searchParams.get("side") === "old" ? "old" : "new"; - const mode = url.searchParams.get("mode") === "base" ? "base" : "working"; + const mode = sel.base.kind === "auto" ? "base" : "working"; let ref: string | null = null; if (mode === "base") { const baseResult = await awaitFlight(resolveBaseCached(repo)); From 572b04a90e46e163b377602fff9a8c66c135b34c Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 21 Aug 2026 10:32:18 +0900 Subject: [PATCH 03/13] =?UTF-8?q?feat:=20=EC=9B=8C=ED=81=AC=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=EC=99=80=20=EC=B0=B8=EC=A1=B0=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=9D=84=20=EB=82=B4=EB=A0=A4=EC=A3=BC=EB=8A=94=20/api/refs?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 피커가 고를 수 있는 것들을 git 호출 두 번으로 만든다: `worktree list --porcelain -z`와 `for-each-ref`. 브랜치와 워크트리를 잇는 것은 UI가 지어낸 개념이 아니라 git이 이미 갖고 있는 관계다 — %(worktreepath)가 브랜치마다 그것을 물고 있는 워크트리를 알려준다. 형식은 추측하지 않고 git 2.54.0에서 실측했다. - worktree list -z: 속성마다 NUL, 레코드 사이는 빈 항목. 속성은 `key value` 또는 홀로 선 불리언(`detached`, `bare`). - for-each-ref: 필드 구분자로 %00을 쓴다. git은 refname에 "|"를 허용하므로(실제로 `weird|pipe` 브랜치를 만들어 확인) 파이프 구분자는 안전하지 않다. 레코드 사이엔 git이 리터럴 개행을 하나 끼워 넣는데, refname엔 개행이 못 들어가므로 선행 개행 하나만 벗기면 된다. 죽은 워크트리를 걸러내는 것이 핵심이다. 디렉토리가 삭제돼도 등록은 `prunable`로 남고 branch 줄까지 그대로 달고 나오며, for-each-ref도 그 경로를 %(worktreepath)로 실어 보낸다(둘 다 실측). 교차 확인 없이 목록에 올리면 존재하지 않는 워크트리를 광고하게 되고, 그걸 고르면 빠져나올 수 없는 화면이 된다 — CLAUDE.md가 적어 둔 "삭제된 cwd" 사고와 같은 부류다. bare(워킹트리 없음)도 같은 이유로 뺀다. refs/remotes//HEAD는 짧게 쓰면 "origin"이라 헛 항목이 되므로 목록에서 빼되, 그 symref 대상이 기본 브랜치의 출처다. 정렬은 붙이지 않는다. %(committerdate) 정렬은 참조마다 커밋 객체를 읽게 만들어 브랜치가 많은 리포에서 비용을 지배한다. refsCache는 baseCache와 달리 핸들러 스코프에 둔다 — 모듈 스코프는 flight 타임아웃 테스트 둘의 특수 사정이지 일반 규칙이 아니다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ArwfmcvGjKnRWMiEFBfJzF --- apps/viewer/__tests__/diff-refs.test.ts | 191 ++++++++++++++++++++++ apps/viewer/__tests__/diff-server.test.ts | 34 ++++ apps/viewer/server/refs.ts | 146 +++++++++++++++++ apps/viewer/server/server.ts | 37 +++++ 4 files changed, 408 insertions(+) create mode 100644 apps/viewer/__tests__/diff-refs.test.ts create mode 100644 apps/viewer/server/refs.ts diff --git a/apps/viewer/__tests__/diff-refs.test.ts b/apps/viewer/__tests__/diff-refs.test.ts new file mode 100644 index 0000000..549ff2f --- /dev/null +++ b/apps/viewer/__tests__/diff-refs.test.ts @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { $ } from "bun"; +import { getRefs, parseRefList, parseWorktreeList } from "../server/refs.ts"; + +// `git worktree list --porcelain -z`의 실측 형식(git 2.54.0): 속성 한 줄마다 +// NUL이 붙고, 레코드 사이는 빈 항목이다. +const wt = (...records: string[][]): string => + `${records.map((lines) => lines.map((l) => `${l}\0`).join("")).join("\0")}\0`; + +// `for-each-ref --format=...%00...%00`의 실측 형식: 필드마다 NUL, 레코드 +// 사이에 리터럴 개행이 하나 들어간다. +const refs = (...records: string[][]): string => + records.map((fields) => `${fields.join("\0")}\0`).join("\n"); + +describe("parseWorktreeList", () => { + test("reads path, branch and head from an attached worktree", () => { + const out = parseWorktreeList( + wt(["worktree /repo", "HEAD abc123", "branch refs/heads/main"]), + ); + expect(out).toEqual([ + { path: "/repo", branch: "main", head: "abc123", detached: false }, + ]); + }); + + test("marks a detached worktree and leaves its branch null", () => { + const out = parseWorktreeList( + wt(["worktree /wt", "HEAD abc123", "detached"]), + ); + expect(out).toEqual([ + { path: "/wt", branch: null, head: "abc123", detached: true }, + ]); + }); + + // 이 레포에서 실제로 밟은 상태다. 디렉토리가 사라져도 등록은 남고, + // branch 줄까지 그대로 달고 나온다 — 고를 수 있게 두면 워크트리가 없는 + // 경로로 이동해 빠져나올 수 없는 화면이 된다. + test("drops a worktree whose directory is gone", () => { + const out = parseWorktreeList( + wt( + ["worktree /alive", "HEAD abc", "branch refs/heads/main"], + [ + "worktree /gone", + "HEAD abc", + "branch refs/heads/feat", + "prunable gitdir file points to non-existent location", + ], + ), + ); + expect(out.map((w) => w.path)).toEqual(["/alive"]); + }); + + test("drops a bare repository, which has no working tree at all", () => { + const out = parseWorktreeList( + wt( + ["worktree /bare.git", "bare"], + ["worktree /real", "HEAD abc", "branch refs/heads/main"], + ), + ); + expect(out.map((w) => w.path)).toEqual(["/real"]); + }); + + test("tolerates an empty listing", () => { + expect(parseWorktreeList("")).toEqual([]); + }); +}); + +describe("parseRefList", () => { + const live = new Set(["/repo", "/wt-a"]); + + test("splits local and remote refs by their prefix", () => { + const { refs: out } = parseRefList( + refs( + ["refs/heads/main", "main", "", ""], + ["refs/remotes/origin/main", "origin/main", "", ""], + ), + live, + ); + expect(out).toEqual([ + { name: "main", kind: "local", worktreePath: null }, + { name: "origin/main", kind: "remote", worktreePath: null }, + ]); + }); + + test("attributes a branch to the worktree holding it", () => { + const { refs: out } = parseRefList( + refs(["refs/heads/feat", "feat", "/wt-a", ""]), + live, + ); + expect(out[0]?.worktreePath).toBe("/wt-a"); + }); + + // for-each-ref는 죽은 워크트리 경로도 그대로 실어 보낸다(실측). 살아 있는 + // 워크트리 집합과 교차 확인하지 않으면 목록이 그 경로를 광고하게 된다. + test("ignores a worktree path that is no longer live", () => { + const { refs: out } = parseRefList( + refs(["refs/heads/feat", "feat", "/wt-gone", ""]), + live, + ); + expect(out[0]?.worktreePath).toBeNull(); + }); + + // git은 refname에 "|"를 허용한다 — 실제로 만들어 확인했다. 필드 구분자로 + // NUL을 쓰는 이유다. + test("keeps a refname containing a pipe intact", () => { + const { refs: out } = parseRefList( + refs(["refs/heads/weird|pipe", "weird|pipe", "", ""]), + live, + ); + expect(out[0]?.name).toBe("weird|pipe"); + }); + + test("reports the default branch from origin/HEAD and hides the pseudo-ref", () => { + const { refs: out, defaultBranch } = parseRefList( + refs( + ["refs/remotes/origin/HEAD", "origin", "", "refs/remotes/origin/main"], + ["refs/remotes/origin/main", "origin/main", "", ""], + ), + live, + ); + expect(defaultBranch).toBe("main"); + expect(out.map((r) => r.name)).toEqual(["origin/main"]); + }); + + test("reports no default branch when origin/HEAD is unset", () => { + const { defaultBranch } = parseRefList( + refs(["refs/heads/main", "main", "", ""]), + live, + ); + expect(defaultBranch).toBeNull(); + }); + + test("tolerates an empty listing", () => { + expect(parseRefList("", live)).toEqual({ refs: [], defaultBranch: null }); + }); +}); + +// 파서 둘이 진짜 git 출력 위에서 합쳐지는지 본다. 픽스처 문자열은 형식을 +// 내가 옳게 적었다는 것만 증명하지, git이 실제로 그렇게 내보낸다는 것은 +// 증명하지 않는다. +describe("getRefs against a real repository", () => { + let root: string; + let repo: string; + + beforeEach(async () => { + // macOS의 /var는 /private/var 심링크다. git은 워크트리 경로를 + // realpath로 돌려주므로 기대값도 realpath여야 한다. (프로덕션에는 + // 영향이 없다 — 교차 확인은 git 출력끼리 비교하므로 양쪽 다 + // realpath다.) + root = realpathSync(mkdtempSync(join(tmpdir(), "dd-refs-"))); + repo = join(root, "main-wt"); + await $`git init -q ${repo}`; + await $`git -C ${repo} config user.email t@t.co`; + await $`git -C ${repo} config user.name test`; + await $`git -C ${repo} commit -q --allow-empty -m init`; + await $`git -C ${repo} branch feat-a`; + await $`git -C ${repo} branch feat-gone`; + // 워크트리는 repo의 형제로 만든다 — 안에 만들면 git status에 잡힌다. + await $`git -C ${repo} worktree add -q ${join(root, "wt-a")} feat-a`; + await $`git -C ${repo} worktree add -q ${join(root, "wt-gone")} feat-gone`; + rmSync(join(root, "wt-gone"), { recursive: true, force: true }); + }); + + afterEach(() => rmSync(root, { recursive: true, force: true })); + + test("lists live worktrees and drops the one whose directory is gone", async () => { + const { worktrees } = await getRefs(repo); + expect(worktrees.map((w) => w.branch).sort()).toEqual(["feat-a", "main"]); + }); + + test("attributes a branch to its worktree but not to a dead one", async () => { + const { refs: out } = await getRefs(repo); + const byName = new Map(out.map((r) => [r.name, r])); + expect(byName.get("feat-a")?.worktreePath).toBe(join(root, "wt-a")); + // git은 죽은 워크트리 경로도 그대로 실어 보낸다 — 교차 확인이 그걸 막는다. + expect(byName.get("feat-gone")?.worktreePath).toBeNull(); + }); + + test("reads the default branch from origin/HEAD when it is set", async () => { + const head = (await $`git -C ${repo} rev-parse HEAD`.text()).trim(); + const ref = "refs/remotes/origin/main"; + await $`git -C ${repo} update-ref ${ref} ${head}`; + await $`git -C ${repo} symbolic-ref refs/remotes/origin/HEAD ${ref}`; + const { refs: out, defaultBranch } = await getRefs(repo); + expect(defaultBranch).toBe("main"); + // 짧게 쓰면 "origin"이 되는 유사 참조는 목록에 없어야 한다. + expect(out.map((r) => r.name)).not.toContain("origin"); + }); +}); diff --git a/apps/viewer/__tests__/diff-server.test.ts b/apps/viewer/__tests__/diff-server.test.ts index 1667c80..67f860b 100644 --- a/apps/viewer/__tests__/diff-server.test.ts +++ b/apps/viewer/__tests__/diff-server.test.ts @@ -622,3 +622,37 @@ describe("diff server non-latin1 base name", () => { ); }); }); + +describe("diff server refs route", () => { + test("rejects a request without the token", async () => { + const res = await fetch( + `${base}/api/refs?repo=${encodeURIComponent(repo)}`, + ); + expect(res.status).toBe(403); + }); + + test("rejects a path that is not a git repository", async () => { + const token = readTokenSync({ XDG_CACHE_HOME: cacheHome }); + const res = await fetch( + `${base}/api/refs?repo=${encodeURIComponent(viewerDir)}&token=${token}`, + ); + expect(res.status).toBe(400); + }); + + test("lists the current worktree and its branch", async () => { + await $`git -C ${repo} branch -M main`; + const token = readTokenSync({ XDG_CACHE_HOME: cacheHome }); + const res = await fetch( + `${base}/api/refs?repo=${encodeURIComponent(repo)}&token=${token}`, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + worktrees: Array<{ branch: string | null }>; + refs: Array<{ name: string; worktreePath: string | null }>; + defaultBranch: string | null; + }; + expect(body.worktrees.map((w) => w.branch)).toEqual(["main"]); + const main = body.refs.find((r) => r.name === "main"); + expect(main?.worktreePath).not.toBeNull(); + }); +}); diff --git a/apps/viewer/server/refs.ts b/apps/viewer/server/refs.ts new file mode 100644 index 0000000..fe470ba --- /dev/null +++ b/apps/viewer/server/refs.ts @@ -0,0 +1,146 @@ +/** + * 피커가 고를 수 있는 것들 — 살아 있는 워크트리와 참조 — 의 목록. + * + * git 호출 두 번이면 끝난다. 브랜치와 워크트리를 잇는 것은 UI가 지어낸 + * 개념이 아니라 git이 이미 갖고 있는 관계다: `%(worktreepath)`가 브랜치마다 + * 그것을 물고 있는 워크트리를 알려준다. + */ +import { $ } from "bun"; + +export interface WorktreeRecord { + path: string; + /** 짧은 브랜치명. detached면 null. */ + branch: string | null; + head: string | null; + detached: boolean; +} + +export interface RefRecord { + name: string; + kind: "local" | "remote"; + /** 이 참조를 물고 있는 **살아 있는** 워크트리의 경로. 없으면 null. */ + worktreePath: string | null; +} + +export interface RefsResult { + worktrees: WorktreeRecord[]; + refs: RefRecord[]; + defaultBranch: string | null; +} + +const REMOTES_PREFIX = "refs/remotes/"; +const HEADS_PREFIX = "refs/heads/"; + +/** + * `git worktree list --porcelain -z` 파싱. + * + * 실측 형식(git 2.54.0): 속성 한 줄마다 NUL이 붙고 레코드 사이는 빈 항목이다. + * 속성은 `key value` 또는 홀로 선 불리언 단어(`detached`, `bare`)다. + */ +export const parseWorktreeList = (raw: string): WorktreeRecord[] => { + const out: WorktreeRecord[] = []; + let path: string | null = null; + let branch: string | null = null; + let head: string | null = null; + let detached = false; + let usable = true; + + const flush = (): void => { + if (path && usable) out.push({ path, branch, head, detached }); + path = null; + branch = null; + head = null; + detached = false; + usable = true; + }; + + for (const token of raw.split("\0")) { + if (token === "") { + flush(); + continue; + } + if (token.startsWith("worktree ")) path = token.slice("worktree ".length); + else if (token.startsWith("HEAD ")) head = token.slice("HEAD ".length); + else if (token.startsWith(`branch ${HEADS_PREFIX}`)) + branch = token.slice(`branch ${HEADS_PREFIX}`.length); + else if (token === "detached") detached = true; + // bare에는 워킹트리가 없고, prunable은 디렉토리가 이미 사라진 등록이다. + // 둘 다 고를 수 있게 두면 존재하지 않는 경로로 데려간다. + else if (token === "bare" || token.startsWith("prunable")) usable = false; + } + flush(); + return out; +}; + +const REF_FIELDS = 4; + +/** + * `for-each-ref --format=%(refname)%00%(refname:short)%00%(worktreepath)%00%(symref)%00` 파싱. + * + * 필드 구분자가 NUL인 이유: git은 refname에 `|`를 허용한다(실측 — `weird|pipe` + * 브랜치를 만들어 확인했다). 레코드 사이에는 git이 리터럴 개행을 하나 끼워 + * 넣는데, refname에는 개행이 못 들어가므로 필드마다 선행 개행 하나만 벗기면 + * 안전하다. + * + * `liveWorktrees`와 교차 확인하는 것이 핵심이다: for-each-ref는 **이미 삭제된** + * 워크트리 경로도 그대로 실어 보낸다(실측). + */ +export const parseRefList = ( + raw: string, + liveWorktrees: ReadonlySet, +): { refs: RefRecord[]; defaultBranch: string | null } => { + const fields = raw + .split("\0") + .map((f) => (f.startsWith("\n") ? f.slice(1) : f)); + // 포맷이 %00으로 끝나므로 후행 빈 항목이 정확히 하나 생긴다. 전부 + // 벗기면 마지막 필드(symref)가 정당하게 비어 있는 레코드까지 먹어 + // 치워서 레코드가 통째로 사라진다. + if (fields.at(-1) === "") fields.pop(); + + const refs: RefRecord[] = []; + let defaultBranch: string | null = null; + + for (let i = 0; i + REF_FIELDS <= fields.length; i += REF_FIELDS) { + const [refname = "", short = "", worktreePath = "", symref = ""] = + fields.slice(i, i + REF_FIELDS); + // refs/remotes//HEAD는 브랜치가 아니라 기본 브랜치를 가리키는 + // 심볼릭 참조다. 짧게 쓰면 그냥 "origin"이라 목록에 두면 헛 항목이 된다. + if (refname.startsWith(REMOTES_PREFIX) && refname.endsWith("/HEAD")) { + if (symref.startsWith(REMOTES_PREFIX)) { + const withRemote = symref.slice(REMOTES_PREFIX.length); + const slash = withRemote.indexOf("/"); + if (slash !== -1) defaultBranch = withRemote.slice(slash + 1); + } + continue; + } + refs.push({ + name: short, + kind: refname.startsWith(HEADS_PREFIX) ? "local" : "remote", + worktreePath: + worktreePath !== "" && liveWorktrees.has(worktreePath) + ? worktreePath + : null, + }); + } + return { refs, defaultBranch }; +}; + +const REF_FORMAT = + "--format=%(refname)%00%(refname:short)%00%(worktreepath)%00%(symref)%00"; + +export const getRefs = async (repo: string): Promise => { + // 정렬을 붙이지 않는다. `%(committerdate)` 정렬은 참조마다 커밋 객체를 + // 읽게 만들어 브랜치가 많은 리포에서 비용을 지배한다 — 목록은 검색으로 + // 찾는 것이고, 기본(refname) 순서면 충분하다. + const [wtRaw, refRaw] = await Promise.all([ + $`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)); + const { refs, defaultBranch } = parseRefList(refRaw, live); + return { worktrees, refs, defaultBranch }; +}; diff --git a/apps/viewer/server/server.ts b/apps/viewer/server/server.ts index 2e6e268..a215cb5 100644 --- a/apps/viewer/server/server.ts +++ b/apps/viewer/server/server.ts @@ -16,6 +16,7 @@ import { type PayloadCacheEntry, payloadEtag, } from "./payloadCache.ts"; +import { getRefs, type RefsResult } from "./refs.ts"; import { parseSelection, selectionCacheKey } from "./selection.ts"; import { createSingleFlight, @@ -44,6 +45,9 @@ export interface DiffServerHandle { // 시작해 baseFlight가 miss로 되돌아가고 그 테스트는 조용히 baseFlight // 가드만 다시 증명하게 된다 — 첫 번째 테스트와 똑같은 것을, 티 나지 않게. const BASE_TTL_MS = 10_000; +// 피커 목록의 수명. 브랜치·워크트리는 diff 내용보다 훨씬 덜 움직이므로 +// 짧게 잡아도 팝오버를 열 때마다 git을 두 번 부르지 않는다. +const REFS_TTL_MS = 5_000; const baseCache = new Map< string, { value: { base: string | null; ref: string | null }; at: number } @@ -130,6 +134,21 @@ const createHandler = (cfg: { // 같은 (repo, untracked, mode)의 지문 계산+파이프라인을 동시에 한 번만 — // 콜드 상태에서 프리워밍과 첫 화면 요청이 겹쳐도 중복 실행되지 않는다. const diffFlight = createSingleFlight(cfg.flightTimeoutMs); + // 피커 목록. baseCache와 달리 **핸들러 스코프**에 둔다 — baseCache가 모듈 + // 스코프인 것은 flight 타임아웃 테스트 둘이 "따로 띄운 두 서버가 같은 warm + // 항목을 본다"에 의존하는 특수 사정 때문이고(CLAUDE.md), 여기엔 그런 요구가 + // 없다. 서버 인스턴스가 자기 캐시를 갖는 쪽이 격리에 낫다. + const refsFlight = createSingleFlight(cfg.flightTimeoutMs); + const refsCache = new Map(); + const getRefsCached = (repo: string): Promise => + refsFlight(repo, async () => { + const now = Date.now(); + const hit = refsCache.get(repo); + if (hit && now - hit.at < REFS_TTL_MS) return hit.value; + const value = await getRefs(repo); + refsCache.set(repo, { value, at: now }); + return value; + }); return async (req: Request): Promise => { const url = new URL(req.url); @@ -261,6 +280,24 @@ const createHandler = (cfg: { }); } + // 피커가 고를 수 있는 것들. /api/diff의 순차 flight 사슬에 끼우지 않고 + // 자기 라우트에서 자기 예산으로 돈다. + if (url.pathname === "/api/refs") { + if (url.searchParams.get("token") !== cfg.token) { + return new Response("forbidden", { status: 403 }); + } + const sel = parseSelection(url.searchParams); + const repo = sel.repo; + if (!repo || !(await isGitRepo(repo))) { + return new Response("not a git repository", { status: 400 }); + } + const result = await awaitFlight(getRefsCached(repo)); + if (result instanceof Response) return result; + return new Response(JSON.stringify(result), { + headers: { "content-type": "application/json; charset=utf-8" }, + }); + } + if (url.pathname === "/api/blob") { if (url.searchParams.get("token") !== cfg.token) { return new Response("forbidden", { status: 403 }); From 4e208a766af08989a7463a05966a17ef5b1d77f6 Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 21 Aug 2026 10:35:48 +0900 Subject: [PATCH 04/13] =?UTF-8?q?feat:=20=EA=B2=AC=EC=A4=84=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EC=9D=84=20=ED=98=B8=EC=B6=9C=EC=9E=90=EA=B0=80=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=BC=20=EC=88=98=20=EC=9E=88=EA=B2=8C=20base=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=EB=A5=BC=20=EB=B0=9B?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `base=`가 서버의 자동 해석을 대신한다. `base=@auto`는 자동 해석을 명시적으로 요구하고, base가 없으면 레거시 `mode`로 떨어진다. base가 있으면 mode는 무시된다 — 한 축을 두 파라미터가 인코딩하면 `mode=working&base=main` 같은 모순 상태가 생기고 우선순위 규칙이 필요해지는데, 새 파라미터가 이긴다는 규칙 하나로 그 상태 자체를 없앤다. `@auto` 표식을 쓰는 이유는 `auto`라는 이름의 브랜치와 구별하기 위해서다. `merge-base(HEAD, HEAD) === HEAD`라서 `base=HEAD`가 별도 분기 없이 오늘의 워킹트리 뷰와 같은 결과를 낸다 — 피커의 "Working tree" 항목이 그 값을 보낸다. 파일 목록이 실제로 동일함을 테스트로 고정했다. verifyBaseRef는 보안 경계다. Bun의 $는 셸을 이스케이프하지 git의 옵션 파싱을 막아주지 않으므로, 첫 글자가 "-"인 참조가 `git diff`에 도달하면 `--output=`로 데몬이 쓸 수 있는 아무 경로나 만들거나 비울 수 있다. rev-parse --verify가 옵션 꼴을 거부하긴 하지만 그 방어에만 기대지 않고 먼저 끊는다. 회귀망으로 옵션 꼴 base가 400을 받고 파일이 생기지 않는지 단언한다. 목록에 없는 base는 조용히 auto로 흘려보내지 않고 400으로 거절한다 — 고르지도 않은 기준의 diff를 보여주는 것이 에러보다 나쁘다. base 해석은 resolveSelectionBase 한 곳에 둔다. /api/diff와 /api/blob이 서로 다른 기준을 고르면 텍스트 diff와 이미지 카드가 다른 비교를 보여준다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ArwfmcvGjKnRWMiEFBfJzF --- apps/viewer/__tests__/diff-selection.test.ts | 64 ++++++++++++++++++++ apps/viewer/__tests__/diff-server.test.ts | 49 +++++++++++++++ apps/viewer/server/diff.ts | 26 ++++++++ apps/viewer/server/selection.ts | 44 +++++++++++--- apps/viewer/server/server.ts | 31 ++++++++-- 5 files changed, 201 insertions(+), 13 deletions(-) diff --git a/apps/viewer/__tests__/diff-selection.test.ts b/apps/viewer/__tests__/diff-selection.test.ts index 4ea9068..77dcdd0 100644 --- a/apps/viewer/__tests__/diff-selection.test.ts +++ b/apps/viewer/__tests__/diff-selection.test.ts @@ -81,3 +81,67 @@ describe("selectionCacheKey", () => { ).not.toBe(selectionCacheKey(parseSelection(params("repo=/a")), null)); }); }); + +describe("parseSelection with an explicit base", () => { + test("base names a ref to compare against", () => { + expect(parseSelection(params("repo=/r&base=develop")).base).toEqual({ + kind: "ref", + ref: "develop", + }); + }); + + test("the @auto sentinel asks the server to resolve the base itself", () => { + expect(parseSelection(params("repo=/r&base=@auto")).base).toEqual({ + kind: "auto", + }); + }); + + // base가 있으면 mode는 무시된다 — 한 축을 두 파라미터가 인코딩하면 + // 서로 모순되는 상태가 생기므로, 새 파라미터가 이긴다는 규칙 하나로 + // 그 상태를 없앤다. + test("an explicit base wins over the legacy mode", () => { + expect( + parseSelection(params("repo=/r&mode=working&base=develop")).base, + ).toEqual({ kind: "ref", ref: "develop" }); + }); + + test("an empty base falls back to the legacy mode", () => { + expect(parseSelection(params("repo=/r&base=&mode=base")).base).toEqual({ + kind: "auto", + }); + }); + + test("base=HEAD expresses uncommitted-only, the same view as mode=working", () => { + expect(parseSelection(params("repo=/r&base=HEAD")).base).toEqual({ + kind: "ref", + ref: "HEAD", + }); + }); +}); + +describe("selectionCacheKey with an explicit base", () => { + test("two different chosen refs never share a slot", () => { + expect( + selectionCacheKey(parseSelection(params("repo=/r&base=a")), null), + ).not.toBe( + selectionCacheKey(parseSelection(params("repo=/r&base=b")), null), + ); + }); + + // 사용자가 고른 ref는 서버가 해석할 필요가 없으므로 해석값과 무관해야 한다. + test("a chosen ref ignores the server-resolved base", () => { + const sel = parseSelection(params("repo=/r&base=develop")); + expect(selectionCacheKey(sel, "origin/main")).toBe( + selectionCacheKey(sel, "origin/other"), + ); + }); + + // 고른 ref "auto"와 자동 해석은 서로 다른 질문이다. + test("a ref literally named auto is not the auto selector", () => { + expect( + selectionCacheKey(parseSelection(params("repo=/r&base=auto")), null), + ).not.toBe( + selectionCacheKey(parseSelection(params("repo=/r&base=@auto")), null), + ); + }); +}); diff --git a/apps/viewer/__tests__/diff-server.test.ts b/apps/viewer/__tests__/diff-server.test.ts index 67f860b..f223245 100644 --- a/apps/viewer/__tests__/diff-server.test.ts +++ b/apps/viewer/__tests__/diff-server.test.ts @@ -656,3 +656,52 @@ describe("diff server refs route", () => { expect(main?.worktreePath).not.toBeNull(); }); }); + +describe("diff server caller-supplied base", () => { + const tok = (): string => + readTokenSync({ XDG_CACHE_HOME: cacheHome }) as string; + + const diff = (query: string): Promise => + fetch( + `${base}/api/diff?repo=${encodeURIComponent(repo)}&token=${tok()}&${query}`, + ); + + test("compares against the branch the caller names", async () => { + await $`git -C ${repo} branch -M main`; + await $`git -C ${repo} checkout -qb feature`; + writeFileSync(join(repo, "c.txt"), "on the branch\n"); + await $`git -C ${repo} add c.txt`; + await $`git -C ${repo} commit -qm branch-work`; + + const res = await diff("base=main"); + expect(res.status).toBe(200); + expect(decodeURIComponent(res.headers.get("x-diff-base") ?? "")).toBe( + "main", + ); + const files = (await res.json()) as Array<{ name: string }>; + expect(files.map((f) => f.name)).toContain("c.txt"); + }); + + // 조용히 auto로 흘려보내면 사용자가 고르지 않은 기준의 diff를 보여주게 된다. + test("refuses a base that does not exist instead of falling back", async () => { + const res = await diff("base=no-such-branch"); + expect(res.status).toBe(400); + }); + + // Bun의 $는 셸을 이스케이프하지 git의 옵션 파싱을 막지 않는다. 첫 글자가 + // "-"인 ref가 git diff에 도달하면 --output=로 임의의 파일을 만들거나 + // 비울 수 있다. refExists(rev-parse --verify)가 옵션 꼴을 거부하는 것이 + // 그 통로를 닫는다. + test("refuses an option-shaped base and writes nothing", async () => { + const victim = join(cacheHome, "pwned.txt"); + const res = await diff(`base=${encodeURIComponent(`--output=${victim}`)}`); + expect(res.status).toBe(400); + expect(existsSync(victim)).toBe(false); + }); + + test("base=HEAD shows the same files as the default working view", async () => { + const withHead = await diff("base=HEAD"); + const plain = await diff("untracked=0"); + expect(await withHead.json()).toEqual(await plain.json()); + }); +}); diff --git a/apps/viewer/server/diff.ts b/apps/viewer/server/diff.ts index a5822ea..f365b83 100644 --- a/apps/viewer/server/diff.ts +++ b/apps/viewer/server/diff.ts @@ -24,6 +24,32 @@ const refExists = async (repo: string, ref: string): Promise => { return r.exitCode === 0; }; +/** + * 호출자가 고른 base 참조를 검증하고 표시명을 만든다. + * + * **보안 경계다.** Bun의 `$`는 셸을 이스케이프하지 git의 옵션 파싱을 막아주지 + * 않는다 — 첫 글자가 `-`인 참조가 `git diff`에 도달하면 `--output=`로 + * 데몬이 쓸 수 있는 아무 경로나 만들거나 비울 수 있다. `refExists`가 쓰는 + * `rev-parse --verify --quiet`는 옵션 꼴 문자열을 거부하지만, 그 방어에만 + * 기대지 않고 여기서 먼저 끊는다. + * + * 알려진 한계: `rev-parse --verify`는 커밋이 아닌 리비전(`HEAD:a.txt` 같은 + * blob)도 통과시킨다. 그런 값은 `merge-base`가 실패해 빈 diff가 되는데, + * 보안 문제는 아니지만 조용한 빈 화면이라 목록 밖 값은 애초에 고를 수 없게 + * 하는 것이 옳다(피커는 /api/refs가 준 목록에서만 고른다). + */ +export const verifyBaseRef = async ( + repo: string, + ref: string, +): Promise<{ base: string; ref: string } | null> => { + if (ref === "" || ref.startsWith("-")) return null; + if (!(await refExists(repo, ref))) return null; + return { + base: ref.startsWith("origin/") ? ref.slice("origin/".length) : ref, + ref, + }; +}; + export const prBaseName = async (repo: string): Promise => { try { const out = await $`gh pr view --json baseRefName -q .baseRefName` diff --git a/apps/viewer/server/selection.ts b/apps/viewer/server/selection.ts index b2d3a81..3578fc8 100644 --- a/apps/viewer/server/selection.ts +++ b/apps/viewer/server/selection.ts @@ -10,8 +10,28 @@ export type BaseSelector = /** HEAD 대비 — 아직 커밋하지 않은 변경만 (레거시 `mode=working`). */ | { kind: "head" } - /** 서버가 해석한 base 브랜치 대비 (레거시 `mode=base`). */ - | { kind: "auto" }; + /** 서버가 해석한 base 브랜치 대비 (레거시 `mode=base`, 또는 `base=@auto`). */ + | { kind: "auto" } + /** 사용자가 고른 참조 대비. `base=HEAD`면 head와 같은 뷰가 된다. */ + | { kind: "ref"; ref: string }; + +/** + * `base`에 실린 "서버가 알아서 골라라" 표식. 그냥 `auto`로 하면 실제로 + * `auto`라는 이름의 브랜치와 구별되지 않는다. + */ +export const AUTO_BASE = "@auto"; + +const parseBase = (params: URLSearchParams): BaseSelector => { + const raw = params.get("base") ?? ""; + if (raw === AUTO_BASE) return { kind: "auto" }; + // base가 있으면 mode는 무시한다. 한 축을 두 파라미터가 인코딩하면 + // `mode=working&base=main` 같은 모순 상태가 생기고 우선순위 규칙이 + // 필요해진다 — 새 파라미터가 이긴다는 규칙 하나로 그 상태를 없앤다. + if (raw !== "") return { kind: "ref", ref: raw }; + // 알 수 없는 mode 값은 400이 아니라 working으로 떨어진다 — 오늘의 + // 관용이고, 밖에서 만들어진 오래된 링크가 깨지지 않는 쪽이다. + return params.get("mode") === "base" ? { kind: "auto" } : { kind: "head" }; +}; export interface Selection { repo: string; @@ -22,9 +42,7 @@ export interface Selection { export const parseSelection = (params: URLSearchParams): Selection => ({ repo: params.get("repo") ?? "", untracked: params.get("untracked") === "1", - // 알 수 없는 값은 400이 아니라 working으로 떨어진다 — 오늘의 관용이고, - // 밖에서 만들어진 오래된 링크가 깨지지 않는 쪽이다. - base: params.get("mode") === "base" ? { kind: "auto" } : { kind: "head" }, + base: parseBase(params), }); /** @@ -40,6 +58,15 @@ export const parseSelection = (params: URLSearchParams): Selection => ({ * ref는 **이름**으로 넣는다. 커밋 OID를 넣으면 커밋마다 새 슬롯이 생겨 * 8칸짜리 LRU가 헛돈다 — OID는 지문(fingerprint)이 볼 몫이다. */ +const baseIdentity = ( + base: BaseSelector, + resolvedBaseRef: string | null, +): string => { + if (base.kind === "auto") return resolvedBaseRef ?? ""; + if (base.kind === "ref") return base.ref; + return ""; +}; + export const selectionCacheKey = ( sel: Selection, resolvedBaseRef: string | null, @@ -48,7 +75,8 @@ export const selectionCacheKey = ( sel.repo, String(sel.untracked), sel.base.kind, - // head 기준은 해석된 ref를 쓰지 않는다. 넣으면 origin/HEAD가 움직일 - // 때마다 워킹트리 뷰의 캐시가 이유 없이 날아간다. - sel.base.kind === "auto" ? (resolvedBaseRef ?? "") : "", + // auto만 해석값에 의존한다. 사용자가 고른 ref는 서버가 해석할 것이 + // 없고, head 기준에 해석값을 넣으면 origin/HEAD가 움직일 때마다 + // 워킹트리 뷰의 캐시가 이유 없이 날아간다. + baseIdentity(sel.base, resolvedBaseRef), ].join("\0"); diff --git a/apps/viewer/server/server.ts b/apps/viewer/server/server.ts index a215cb5..113a869 100644 --- a/apps/viewer/server/server.ts +++ b/apps/viewer/server/server.ts @@ -8,6 +8,7 @@ import { getFileBytes, isGitRepo, resolveBaseRef, + verifyBaseRef, } from "./diff.ts"; import { repoFingerprint } from "./fingerprint.ts"; import { imageContentType, isImagePath } from "./imageTypes.ts"; @@ -17,7 +18,11 @@ import { payloadEtag, } from "./payloadCache.ts"; import { getRefs, type RefsResult } from "./refs.ts"; -import { parseSelection, selectionCacheKey } from "./selection.ts"; +import { + parseSelection, + type Selection, + selectionCacheKey, +} from "./selection.ts"; import { createSingleFlight, SingleFlightTimeoutError, @@ -140,6 +145,22 @@ const createHandler = (cfg: { // 없다. 서버 인스턴스가 자기 캐시를 갖는 쪽이 격리에 낫다. const refsFlight = createSingleFlight(cfg.flightTimeoutMs); const refsCache = new Map(); + // base 해석의 단일 지점. /api/diff와 /api/blob이 서로 다른 기준을 고르면 + // 텍스트 diff와 이미지 카드가 다른 비교를 보여주게 된다. + // + // 사용자가 고른 ref는 서버가 해석할 것이 없다. 목록 밖 값이면 조용히 + // auto로 흘려보내지 않고 거절한다 — 고르지도 않은 기준의 diff를 보여주는 + // 것이 에러보다 나쁘다. + const resolveSelectionBase = async ( + repo: string, + sel: Selection, + ): Promise<{ base: string | null; ref: string | null } | Response> => { + if (sel.base.kind === "ref") { + const verified = await verifyBaseRef(repo, sel.base.ref); + return verified ?? new Response("unknown base ref", { status: 400 }); + } + return awaitFlight(resolveBaseCached(repo)); + }; const getRefsCached = (repo: string): Promise => refsFlight(repo, async () => { const now = Date.now(); @@ -206,8 +227,8 @@ const createHandler = (cfg: { return new Response("not a git repository", { status: 400 }); } const untracked = sel.untracked; - const mode = sel.base.kind === "auto" ? "base" : "working"; - const baseResult = await awaitFlight(resolveBaseCached(repo)); + const mode = sel.base.kind === "head" ? "working" : "base"; + const baseResult = await resolveSelectionBase(repo, sel); if (baseResult instanceof Response) return baseResult; const { base, ref } = baseResult; // 파이프라인(파일당 git 서브프로세스) 전에 싼 지문으로 변경 여부를 @@ -313,10 +334,10 @@ const createHandler = (cfg: { return new Response("not found", { status: 404 }); } const side = url.searchParams.get("side") === "old" ? "old" : "new"; - const mode = sel.base.kind === "auto" ? "base" : "working"; + const mode = sel.base.kind === "head" ? "working" : "base"; let ref: string | null = null; if (mode === "base") { - const baseResult = await awaitFlight(resolveBaseCached(repo)); + const baseResult = await resolveSelectionBase(repo, sel); if (baseResult instanceof Response) return baseResult; ref = baseResult.ref; } From 1f29826e8ee420d54aa318e05930ae694795b88c Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 21 Aug 2026 10:38:39 +0900 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20=ED=94=BC=EC=BB=A4=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EA=B3=BC=20=EA=B8=B0=EC=A4=80=20=ED=94=84=EB=A6=AC?= =?UTF-8?q?=ED=8D=BC=EB=9F=B0=EC=8A=A4=EC=9D=98=20=EC=88=9C=EC=88=98=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildBaseRows가 "무엇과 견줄까" 목록을 만든다. 맨 위가 Working tree (base=HEAD — merge-base(HEAD, HEAD)가 HEAD라 오늘의 워킹트리 뷰와 같다), 그 아래 로컬 브랜치, 그 아래 원격 브랜치다. 태그 두 개가 조용한 빈 화면을 막는 장치다. 기본 브랜치에는 "default"를 붙여 흔한 선택을 찾기 쉽게 하고, 이 워크트리가 체크아웃한 브랜치에는 "HEAD"를 붙인다 — 자기 자신과 견주면 언제나 비어 보이는데, 막지 않고 왜 그런지 읽히게 한다. 둘 다 해당하면 default가 이긴다. filterBaseRows는 대소문자를 무시하고 이름 어디서나 부분 일치한다. "work"로 Working tree 행에도 닿는다. resolveCompareBase는 기존 resolver들과 같은 층위(URL → localStorage → 기본값)를 따르되, 저장 키를 리포 경로로 네임스페이스한다. 워크트리마다 견주는 기준이 다른데 기존 여섯 cc-statusline 키처럼 공유하면 한 워크트리의 선택이 다른 워크트리로 샌다. 그 여섯은 마이그레이션 코드가 없어 이름을 바꾸면 사용자 설정이 조용히 초기화되므로 건드리지 않는다. 아무것도 고르지 않으면 null이 되고 base 파라미터를 아예 보내지 않는다 — 서버가 자동 해석하므로 기존 사용자는 오늘과 같은 화면을 본다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ArwfmcvGjKnRWMiEFBfJzF --- .../viewer/__tests__/ref-picker-model.test.ts | 97 +++++++++++++++++++ apps/viewer/__tests__/viewer-prefs.test.ts | 52 +++++++++- apps/viewer/browser/prefs.ts | 21 ++++ apps/viewer/browser/refPicker/model.ts | 62 ++++++++++++ 4 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 apps/viewer/__tests__/ref-picker-model.test.ts create mode 100644 apps/viewer/browser/refPicker/model.ts diff --git a/apps/viewer/__tests__/ref-picker-model.test.ts b/apps/viewer/__tests__/ref-picker-model.test.ts new file mode 100644 index 0000000..21eae7a --- /dev/null +++ b/apps/viewer/__tests__/ref-picker-model.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from "bun:test"; +import { buildBaseRows, filterBaseRows } from "../browser/refPicker/model.ts"; +import type { RefRecord } from "../server/refs.ts"; + +const ref = (name: string, kind: "local" | "remote" = "local"): RefRecord => ({ + name, + kind, + worktreePath: null, +}); + +describe("buildBaseRows", () => { + test("offers the working tree first, before any branch", () => { + const rows = buildBaseRows([ref("main")], null, null); + expect(rows[0]).toEqual({ + value: "HEAD", + label: "Working tree", + kind: "working", + tag: null, + }); + }); + + // merge-base(HEAD, HEAD) === HEAD라서 이 값이 오늘의 워킹트리 뷰와 같다. + test("the working tree row carries HEAD as its wire value", () => { + expect(buildBaseRows([], null, null)[0]?.value).toBe("HEAD"); + }); + + test("keeps local branches ahead of remote ones", () => { + const rows = buildBaseRows( + [ref("origin/main", "remote"), ref("develop"), ref("main")], + null, + null, + ); + expect(rows.slice(1).map((r) => r.kind)).toEqual([ + "local", + "local", + "remote", + ]); + }); + + test("tags the default branch so the usual choice is findable", () => { + const rows = buildBaseRows([ref("main"), ref("develop")], "main", null); + expect(rows.find((r) => r.value === "main")?.tag).toBe("default"); + expect(rows.find((r) => r.value === "develop")?.tag).toBeNull(); + }); + + // 자기 자신과 견주면 언제나 비어 보인다. 막지는 않되 왜 그런지 읽히도록 + // 표시한다 — 조용한 빈 화면이 이 기능에서 가장 큰 위험이다. + test("marks the branch this worktree has checked out", () => { + const rows = buildBaseRows( + [ref("main"), ref("feature")], + "main", + "feature", + ); + expect(rows.find((r) => r.value === "feature")?.tag).toBe("HEAD"); + }); + + test("prefers the default tag when a branch is both default and checked out", () => { + const rows = buildBaseRows([ref("main")], "main", "main"); + expect(rows.find((r) => r.value === "main")?.tag).toBe("default"); + }); +}); + +describe("filterBaseRows", () => { + const rows = buildBaseRows( + [ref("main"), ref("feat/grab-popover"), ref("origin/main", "remote")], + "main", + null, + ); + + test("an empty query keeps every row", () => { + expect(filterBaseRows(rows, "")).toHaveLength(rows.length); + }); + + test("matches anywhere in the name, not just the start", () => { + expect(filterBaseRows(rows, "grab").map((r) => r.value)).toEqual([ + "feat/grab-popover", + ]); + }); + + test("ignores case so typing is cheap", () => { + expect(filterBaseRows(rows, "GRAB")).toHaveLength(1); + }); + + test("keeps the working tree row reachable by name", () => { + expect(filterBaseRows(rows, "work").map((r) => r.kind)).toEqual([ + "working", + ]); + }); + + test("returns nothing when the query matches nothing", () => { + expect(filterBaseRows(rows, "zzz")).toEqual([]); + }); + + test("trims surrounding whitespace before matching", () => { + expect(filterBaseRows(rows, " grab ")).toHaveLength(1); + }); +}); diff --git a/apps/viewer/__tests__/viewer-prefs.test.ts b/apps/viewer/__tests__/viewer-prefs.test.ts index b7add54..45ea649 100644 --- a/apps/viewer/__tests__/viewer-prefs.test.ts +++ b/apps/viewer/__tests__/viewer-prefs.test.ts @@ -1,13 +1,18 @@ import { describe, expect, test } from "bun:test"; import { - clampTreeWidth, + type Getter, DEFAULT_TREE_WIDTH, FLATTEN_KEY, MAX_TREE_WIDTH, MIN_TREE_WIDTH, + TREE_SIDE_KEY, + TREE_WIDTH_KEY, + clampTreeWidth, + compareBaseKey, readFlatten, readTreeSide, readTreeWidth, + resolveCompareBase, resolveDiffStyle, resolveFlatten, resolveFoldWithTree, @@ -15,8 +20,6 @@ import { resolveTreeSide, resolveUntracked, resolveWatch, - TREE_SIDE_KEY, - TREE_WIDTH_KEY, } from "../browser/prefs.ts"; const fake = @@ -150,3 +153,46 @@ describe("readTreeWidth", () => { ); }); }); + +describe("compare base preference", () => { + const store = + (entries: Record): Getter => + (k) => + entries[k] ?? null; + + test("the URL parameter wins over the stored choice", () => { + expect( + resolveCompareBase( + "develop", + store({ [compareBaseKey("/r")]: "main" }), + "/r", + ), + ).toBe("develop"); + }); + + test("falls back to the stored choice when the URL is silent", () => { + expect( + resolveCompareBase(null, store({ [compareBaseKey("/r")]: "main" }), "/r"), + ).toBe("main"); + }); + + // null이면 base 파라미터를 아예 보내지 않고 서버가 자동 해석한다 — + // 오늘의 기본 동작이 그대로 유지된다. + test("resolves to null when neither layer has a value", () => { + expect(resolveCompareBase(null, store({}), "/r")).toBeNull(); + }); + + test("an empty URL parameter behaves as absent", () => { + expect( + resolveCompareBase("", store({ [compareBaseKey("/r")]: "main" }), "/r"), + ).toBe("main"); + }); + + // 워크트리마다 견주는 기준이 다르다. 네임스페이스가 없으면 한 워크트리의 + // 선택이 다른 워크트리로 새어 나간다(기존 여섯 키가 그 상태다). + test("keeps each repository's choice separate", () => { + const get = store({ [compareBaseKey("/a")]: "main" }); + expect(resolveCompareBase(null, get, "/a")).toBe("main"); + expect(resolveCompareBase(null, get, "/b")).toBeNull(); + }); +}); diff --git a/apps/viewer/browser/prefs.ts b/apps/viewer/browser/prefs.ts index fa7ce8b..a16de29 100644 --- a/apps/viewer/browser/prefs.ts +++ b/apps/viewer/browser/prefs.ts @@ -71,3 +71,24 @@ export const readTreeWidth = (get: Getter): number => { const stored = get(TREE_WIDTH_KEY); return stored === null ? DEFAULT_TREE_WIDTH : clampTreeWidth(Number(stored)); }; + +/** + * 견줄 기준의 저장 키. 리포 경로로 네임스페이스한다 — 워크트리마다 견주는 + * 기준이 다른데, 위의 여섯 `cc-statusline:` 키처럼 공유해 버리면 한 워크트리의 + * 선택이 다른 워크트리로 새어 나간다. (그 여섯은 마이그레이션 코드가 없어 + * 이름을 바꾸면 모든 사용자의 설정이 조용히 초기화되므로 그대로 둔다.) + */ +export const compareBaseKey = (repo: string): string => + `diffdeck:compare-base:${repo}`; + +/** + * URL 파라미터 → localStorage → null. null이면 `base`를 아예 보내지 않고 + * 서버가 자동 해석하므로, 아무것도 고르지 않은 사용자는 오늘과 같은 화면을 + * 본다. + */ +export const resolveCompareBase = ( + urlParam: string | null, + get: Getter, + repo: string, +): string | null => + urlParam !== null && urlParam !== "" ? urlParam : get(compareBaseKey(repo)); diff --git a/apps/viewer/browser/refPicker/model.ts b/apps/viewer/browser/refPicker/model.ts new file mode 100644 index 0000000..fdeb520 --- /dev/null +++ b/apps/viewer/browser/refPicker/model.ts @@ -0,0 +1,62 @@ +/** + * 피커가 보여줄 "무엇과 견줄까" 목록의 순수 로직. + * + * DOM도 fetch도 모르므로 유닛으로 전부 덮인다 — 배선만 main.ts에 남는다. + */ +import type { RefRecord } from "../../server/refs.ts"; + +export interface BaseRow { + /** `base=` 쿼리에 실릴 값. */ + value: string; + /** 화면에 보이는 이름. */ + label: string; + kind: "working" | "local" | "remote"; + /** 오른쪽에 붙는 맥락 표시. */ + tag: "default" | "HEAD" | null; +} + +/** + * 아직 커밋하지 않은 변경만 보는 선택. `merge-base(HEAD, HEAD) === HEAD`라서 + * 서버에 특별한 분기 없이 오늘의 워킹트리 뷰와 같은 결과가 된다. + */ +const WORKING_ROW: BaseRow = { + value: "HEAD", + label: "Working tree", + kind: "working", + tag: null, +}; + +export const buildBaseRows = ( + refs: readonly RefRecord[], + defaultBranch: string | null, + currentBranch: string | null, +): BaseRow[] => { + const toRow = (r: RefRecord): BaseRow => ({ + value: r.name, + label: r.name, + kind: r.kind, + // 자기 자신과 견주면 언제나 비어 보인다. 막지는 않되 왜 그런지 + // 읽히도록 표시한다 — 조용한 빈 화면이 이 기능의 가장 큰 위험이다. + tag: + r.name === defaultBranch + ? "default" + : r.name === currentBranch + ? "HEAD" + : null, + }); + // 로컬을 먼저, 원격을 뒤로. 각 무리 안에서는 받은 순서를 그대로 둔다. + return [ + WORKING_ROW, + ...refs.filter((r) => r.kind === "local").map(toRow), + ...refs.filter((r) => r.kind === "remote").map(toRow), + ]; +}; + +export const filterBaseRows = ( + rows: readonly BaseRow[], + query: string, +): BaseRow[] => { + const needle = query.trim().toLowerCase(); + if (needle === "") return [...rows]; + return rows.filter((r) => r.label.toLowerCase().includes(needle)); +}; From 91d2dad6d1a0977240788da85848a7b1a22c5670 Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 21 Aug 2026 10:53:59 +0900 Subject: [PATCH 06/13] =?UTF-8?q?feat:=20=ED=88=B4=EB=B0=94=EC=9D=98=202?= =?UTF-8?q?=EC=A7=80=EC=84=A0=EB=8B=A4=20select=EB=A5=BC=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EA=B0=80=EB=8A=A5=ED=95=9C=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=ED=94=BC=EC=BB=A4=EB=A1=9C=20=EB=B0=94=EA=BE=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `를 검색 가능한 목록으로 바꾼 것. +// +// 여기서만 잡히는 계약들이다 — happy-dom에는 레이아웃도 CSS 캐스케이드도 +// 없어서 "[hidden]이 실제로 숨기는가", "패널이 정말 페인트되는가", +// "닫을 때 포커스가 트리거로 돌아오는가"를 유닛이 원리적으로 볼 수 없다. +import { expect, launchViewer, test } from "./fixtures/app.ts"; + +const OPTS = { featureBranchCommit: true, branches: ["develop"] }; + +test.describe("compare base picker", () => { + test("opens to a searchable list and closes on Escape", async ({ page }) => { + const { url, stop } = await launchViewer([], OPTS); + try { + await page.goto(url); + const panel = page.locator("#ref-picker"); + // author가 display를 선언한 노드라 [hidden] 짝이 없으면 처음부터 + // 열린 채로 보인다 — 이 단언이 그 규칙의 회귀망이다. + await expect(panel).toBeHidden(); + + await page.locator("#ref-picker-btn").click(); + await expect(panel).toBeVisible(); + await expect(page.locator("#ref-picker-btn")).toHaveAttribute( + "aria-expanded", + "true", + ); + const rows = page.locator("#ref-picker .ref-row"); + await expect(rows.filter({ hasText: "Working tree" })).toHaveCount(1); + await expect(rows.filter({ hasText: "develop" })).toHaveCount(1); + + await page.keyboard.press("Escape"); + await expect(panel).toBeHidden(); + } finally { + await stop(); + } + }); + + test("filters the list as you type", async ({ page }) => { + const { url, stop } = await launchViewer([], OPTS); + try { + await page.goto(url); + await page.locator("#ref-picker-btn").click(); + const rows = page.locator("#ref-picker .ref-row"); + // 목록은 /api/refs가 도착한 뒤에 채워진다. count()는 재시도하지 + // 않는 일회성 읽기라, 도착을 기다리는 단언을 먼저 둔다. + await expect(rows.filter({ hasText: "develop" })).toHaveCount(1); + expect(await rows.count()).toBeGreaterThan(1); + await page.locator("#ref-picker-search").fill("develop"); + await expect(rows).toHaveCount(1); + await expect(rows.first()).toHaveText(/develop/); + } finally { + await stop(); + } + }); + + // 기본값은 base=HEAD(미커밋만)이므로 브랜치에 커밋한 파일은 안 보인다. + // main을 기준으로 고르면 merge-base가 갈림점으로 내려가 그 커밋까지 들어온다. + test("choosing a branch widens the diff to the committed work", async ({ + page, + }) => { + const { url, stop } = await launchViewer([], OPTS); + try { + await page.goto(url); + await expect(page.locator("#ref-picker-label")).toHaveText( + "Working tree", + ); + const before = await page.locator("#status").textContent(); + + await page.locator("#ref-picker-btn").click(); + await page + .locator("#ref-picker .ref-row") + .filter({ hasText: /^main/ }) + .first() + .click(); + + await expect(page.locator("#ref-picker-label")).toHaveText("vs main"); + await expect(page.locator("#status")).not.toHaveText(before ?? ""); + // 닫힌 뒤 포커스가 트리거로 돌아와야 키보드 사용자가 길을 잃지 않는다. + await expect(page.locator("#ref-picker-btn")).toBeFocused(); + } finally { + await stop(); + } + }); + + test("never shares the screen with the overflow menu", async ({ page }) => { + const { url, stop } = await launchViewer([], OPTS); + try { + await page.goto(url); + await page.locator("#overflow-btn").click(); + await expect(page.locator("#overflow-menu")).toBeVisible(); + await page.locator("#ref-picker-btn").click(); + await expect(page.locator("#ref-picker")).toBeVisible(); + await expect(page.locator("#overflow-menu")).toBeHidden(); + } finally { + await stop(); + } + }); +}); diff --git a/apps/viewer/index.html b/apps/viewer/index.html index f325f37..31167ff 100644 --- a/apps/viewer/index.html +++ b/apps/viewer/index.html @@ -54,8 +54,7 @@ } /* One control height for every toolbar widget — without it, line-height: normal + differing paddings yield 25/26/26.5/27px mismatches. */ - #toolbar button, - #toolbar select { + #toolbar button { box-sizing: border-box; height: 26px; background: var(--vd-inset); @@ -70,18 +69,9 @@ display: inline-flex; align-items: center; } - #toolbar button:hover, - #toolbar select:hover { + #toolbar button:hover { background: var(--vd-hover); } - #toolbar select { - appearance: none; - -webkit-appearance: none; - padding-right: 26px; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='none' stroke='%2384848a' stroke-width='1.5' d='M3 4.5 6 7.5 9 4.5'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 8px center; - } /* Segmented control (Pierre-style): inset pill, active segment raised. fieldset 기본 스타일(margin/min-inline-size)을 리셋해 div 시절과 동일하게 유지. */ #toolbar .tb-seg { @@ -327,6 +317,96 @@ #overflow-menu input[type="checkbox"] { order: 1; } + /* 견줄 기준 피커. 오버플로 메뉴와 같은 종(種)이다 — 툴바 컨트롤에 + 앵커된 절대 위치 패널이라 grab 팝오버의 좌표 계산이 필요 없다. */ + .tb-picker { + position: relative; + } + #toolbar #ref-picker-btn { + gap: 8px; + max-width: 260px; + } + #ref-picker-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + #ref-picker { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 10; + display: flex; + flex-direction: column; + gap: 6px; + min-width: 260px; + max-width: min(420px, 90vw); + padding: 6px; + background: var(--vd-bg); + border: 1px solid var(--vd-border); + border-radius: var(--vd-radius); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45); + } + /* author가 display를 선언했으면 [hidden] 짝을 반드시 함께 둔다 — + 없으면 author origin이 UA 규칙을 이겨 패널이 영구히 열린 채 남는다. + happy-dom은 textContent만 보므로 유닛이 못 잡고 실브라우저에서만 + 드러난다(#grab-popover에서 이미 한 번, .grab-hint에서 두 번 밟았다). */ + #ref-picker[hidden] { + display: none; + } + #ref-picker-search { + box-sizing: border-box; + height: 26px; + padding: 0 8px; + background: var(--vd-inset); + border: 1px solid var(--vd-border); + border-radius: var(--vd-radius); + color: var(--vd-fg); + font: inherit; + outline: none; + } + #ref-picker-list { + display: flex; + flex-direction: column; + max-height: 260px; + overflow-y: auto; + } + #ref-picker .ref-row { + box-sizing: border-box; + position: relative; + min-height: 26px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 8px 0 26px; + border-radius: 4px; + color: var(--vd-fg); + cursor: pointer; + white-space: nowrap; + } + #ref-picker .ref-row:hover, + #ref-picker .ref-row[data-active="true"] { + background: var(--vd-hover); + } + #ref-picker .ref-row svg { + position: absolute; + left: 8px; + color: var(--vd-accent); + } + #ref-picker .ref-row-label { + overflow: hidden; + text-overflow: ellipsis; + } + #ref-picker .ref-row-tag { + margin-left: auto; + padding-left: 10px; + font-size: 11px; + color: var(--vd-fg-muted); + } + #ref-picker-empty { + padding: 4px 8px; + color: var(--vd-fg-muted); + } #tree { grid-row: 2; grid-column: 1; @@ -608,10 +688,49 @@
- +
+ + +
Date: Fri, 21 Aug 2026 10:58:12 +0900 Subject: [PATCH 07/13] =?UTF-8?q?docs:=20=EA=B8=B0=EC=A4=80=20=ED=94=BC?= =?UTF-8?q?=EC=BB=A4=EB=A5=BC=20=EB=AC=B8=EC=84=9C=20=EC=9D=BC=EA=B3=B1=20?= =?UTF-8?q?=EA=B3=B3=EA=B3=BC=20CLAUDE.md=EC=97=90=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=EC=8A=A4=ED=81=AC=EB=A6=B0=EC=83=B7?= =?UTF-8?q?=EC=9D=84=20=EB=8B=A4=EC=8B=9C=20=EC=B0=8D=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 툴바에서 select가 사라졌으므로 docs/screenshot.png가 낡았다. diffdeck 자신을 main 기준으로 열어 다시 찍었고(이번 작업의 실제 diff가 화면에 나온다), 피커가 열린 docs/ref-picker.png를 새로 추가했다. 개요 샷의 캡션에서 "an inline image diff"를 뺐다 — 새 샷에는 이미지 카드가 안 보이는데, 화면에 없는 것을 캡션이 주장하면 안 된다. 이미지 diff는 Features 목록이 계속 설명한다. 문서 일곱 곳이 같은 문장을 되풀이하고 있었다: 루트 README, npm 페이지가 되는 apps/viewer/README.md, 번역 4종, 그리고 에이전트가 diffdeck을 구동할 때 읽는 유일한 문서인 SKILL.md. 전부 옮겼다. CLI 플래그를 추가하지 않았으므로 docs-flags-parity 게이트는 발화하지 않는다 — 그래서 손으로 챙겼다(그 게이트가 감시하지 않는 표면이라는 뜻이기도 하다). SKILL.md의 grab 어휘(`working diff` / `base diff vs `)는 그대로 둔다. 그 계약은 이번 변경에서 바뀌지 않았고, baseName이 이제 실제로 견주는 대상을 가리키므로 오히려 더 정확해졌다. CLAUDE.md에는 다음 사람이 밟을 함정 위주로 적었다: base가 mode를 이기는 이유, base=HEAD가 옛 working 뷰와 같아지는 이유, NUL 구분자가 필요한 이유(git은 refname에 "|"를 허용한다), 후행 빈 항목을 하나만 벗겨야 하는 이유, 죽은 워크트리 제외가 계약인 이유, verifyBaseRef가 보안 경계인 이유, 캐시 키가 전함수여야 하는 이유. 그리고 Bun $ 템플릿의 비ASCII 리터럴이 뭉개지는 함정 — 이것 때문에 한글 브랜치 테스트가 조용히 통과했었다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ArwfmcvGjKnRWMiEFBfJzF --- CLAUDE.md | 4 ++++ README.md | 14 ++++++++++++-- apps/viewer/README.md | 6 +++--- docs/README.es.md | 2 +- docs/README.ja.md | 2 +- docs/README.ko.md | 2 +- docs/README.zh.md | 2 +- docs/ref-picker.png | Bin 0 -> 503890 bytes docs/screenshot.png | Bin 407084 -> 502653 bytes skills/diffdeck/SKILL.md | 2 +- 10 files changed, 24 insertions(+), 10 deletions(-) create mode 100644 docs/ref-picker.png diff --git a/CLAUDE.md b/CLAUDE.md index 59025f6..004cff7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,10 @@ cc-statusline에 포함됐던 로컬 diff 뷰어를 독립 제품으로 분리 + 뷰어 토글(untracked 포함·watch 자동갱신·flatten·파일트리 좌우·unified/split·파일트리 숨김·트리 접기 동기화)은 `--untracked`/`--watch`/`--no-flatten`/`--tree-right`/`--split`/`--hide-tree`/`--fold-with-tree` CLI 플래그로 구동 시점에 미리 설정할 수 있다(session-only — 저장된 localStorage 프리퍼런스는 건드리지 않음). 인앱 토글의 초기 표시 상태는 항상 실제 launch 값과 일치하도록 sync되며, 우선순위 계산(URL 파라미터 → localStorage → 기본값)은 `apps/viewer/browser/prefs.ts`의 순수 resolver 함수(`resolveUntracked`/`resolveWatch`/`resolveFlatten`/`resolveTreeSide`/`resolveDiffStyle`/`resolveTreeHidden`/`resolveFoldWithTree`)로 분리해 단위 테스트한다. unified↔split 전환은 읽던 스크롤 위치를 그대로 유지한다(엔진 앵커링 — 아래 "CodeView 인스턴스 수명" 항목 참고). 파일트리 숨김은 `localStorage` 폴백이 없는 session-only 토글(`resolveUntracked`와 동일 패턴)로, 툴바 아이콘 버튼(`#tree-toggle-btn`)과 오버플로 메뉴 체크박스(`#toggle-tree-hidden`) 둘 다에서 조작 가능하며 항상 서로 동기화된다. 사이드바에서 디렉토리를 접으면 diff 화면의 해당 파일들도 자동으로 접히는 "Fold with tree" 토글은 `flatten`/`treeSide`와 동일하게 `localStorage`(`cc-statusline:fold-with-tree`)에 영속화되며, 오버플로 메뉴 체크박스(`#toggle-fold-with-tree`)로만 조작한다(전용 툴바 버튼 없음). 트리에서 접힌 디렉토리 아래 파일을 diff 헤더 클릭으로 개별 펼치면 그 파일은 사용자가 다시 접기 전까지(토글 on/off와 무관하게) 계속 펼쳐진 채 유지된다. 파일 트리 사이드바 폭은 `#tree-resizer`를 드래그하거나(포커스 후 방향키로도 10px 단위 조정 가능) 180~600px 범위에서 조정할 수 있으며, 조정한 폭은 `localStorage`(session-only 토글들과 달리 지속)에 저장된다. +**견줄 기준 피커** — 툴바의 옛 `가 공짜로 주던 키보드 조작을 되돌려 놓는다. 클릭 전용으로 +// 두면 이 컨트롤만 마우스를 요구하게 된다. +pickerSearch?.addEventListener("keydown", (event) => { + // 조합 중의 Enter는 한글 확정이지 선택이 아니다 (grab 팝오버와 같은 가드). + if (event.isComposing || event.keyCode === 229) return; + const last = pickerVisible.length - 1; + if (last < 0) return; + const move = (next: number): void => { + event.preventDefault(); + pickerActive = next; + renderPickerRows(); + pickerList + ?.querySelector('[data-active="true"]') + ?.scrollIntoView({ block: "nearest" }); + }; + if (event.key === "ArrowDown") + return move(pickerActive >= last ? 0 : pickerActive + 1); + if (event.key === "ArrowUp") + return move(pickerActive <= 0 ? last : pickerActive - 1); + if (event.key === "Home") return move(0); + if (event.key === "End") return move(last); + if (event.key === "Enter") { + event.preventDefault(); + const row = pickerVisible[pickerActive]; + if (row) void applySelection(row.value); + } +}); // 피커는 자기 dismiss를 갖는다 — 오버플로 메뉴의 리스너에 얹으면 한쪽을 // 고치다 다른 쪽이 조용히 깨진다. @@ -1352,16 +1421,20 @@ document.addEventListener("keydown", (event) => { pickerBtn?.focus(); }); -// URL의 base가 저장된 선택을 이긴다. 레거시 `mode`는 여기서만 읽는다 — -// mode=base는 "서버가 골라라"와 같은 말이므로 @auto로 옮긴다. -const legacyMode = - params.get("mode") ?? localStorage.getItem("cc-statusline:diff-mode"); +// URL이 저장된 선택을 이긴다. 레거시 `mode`는 **URL 레이어에서** base 값으로 +// 승격시켜야 그 계약이 유지된다 — 저장된 선택 뒤로 내리면 한 번이라도 피커를 +// 쓴 사용자에게는 statusline의 `?mode=base` 링크가 조용히 무시된다 +// (link.ts는 지금도 mode를 발행한다). +const urlMode = params.get("mode"); +const urlBase = + params.get("base") ?? + (urlMode === "base" ? "@auto" : urlMode === "working" ? "HEAD" : null); +const storedLegacyMode = localStorage.getItem("cc-statusline:diff-mode"); +// URL이 기준을 명시했는지 — 저장된 값에서 왔을 때만 자가복구가 돈다. +const compareBaseFromStorage = urlBase === null; compareBase = - resolveCompareBase( - params.get("base"), - (k) => localStorage.getItem(k), - repo, - ) ?? (legacyMode === "base" ? "@auto" : "HEAD"); + resolveCompareBase(urlBase, (k) => localStorage.getItem(k), repo) ?? + (storedLegacyMode === "base" ? "@auto" : "HEAD"); syncPickerLabel(); // Apply persisted file-tree side and reflect stored prefs in the overflow menu. diff --git a/apps/viewer/browser/prefs.ts b/apps/viewer/browser/prefs.ts index a16de29..cc7c134 100644 --- a/apps/viewer/browser/prefs.ts +++ b/apps/viewer/browser/prefs.ts @@ -82,9 +82,11 @@ export const compareBaseKey = (repo: string): string => `diffdeck:compare-base:${repo}`; /** - * URL 파라미터 → localStorage → null. null이면 `base`를 아예 보내지 않고 - * 서버가 자동 해석하므로, 아무것도 고르지 않은 사용자는 오늘과 같은 화면을 - * 본다. + * URL 파라미터 → localStorage → null. + * + * null은 "고른 적 없음"이라는 뜻일 뿐이고, 그 경우 무엇을 보낼지는 호출부가 + * 정한다(main.ts는 워킹트리를 뜻하는 `HEAD`로 떨어진다). 이 함수 자체는 + * 어떤 wire 값도 가정하지 않는다. */ export const resolveCompareBase = ( urlParam: string | null, diff --git a/apps/viewer/e2e/ref-picker.e2e.ts b/apps/viewer/e2e/ref-picker.e2e.ts index d9e6a3b..2d9aabe 100644 --- a/apps/viewer/e2e/ref-picker.e2e.ts +++ b/apps/viewer/e2e/ref-picker.e2e.ts @@ -3,6 +3,7 @@ // 여기서만 잡히는 계약들이다 — happy-dom에는 레이아웃도 CSS 캐스케이드도 // 없어서 "[hidden]이 실제로 숨기는가", "패널이 정말 페인트되는가", // "닫을 때 포커스가 트리거로 돌아오는가"를 유닛이 원리적으로 볼 수 없다. +import { spawnSync } from "node:child_process"; import { expect, launchViewer, test } from "./fixtures/app.ts"; const OPTS = { featureBranchCommit: true, branches: ["develop"] }; @@ -94,4 +95,63 @@ test.describe("compare base picker", () => { await stop(); } }); + + // 네이티브 `(옵션 두 개)를 대체한다. wire는 `base=` \| `base=@auto`이고, 없으면 레거시 `mode=working|base`로 떨어진다(`server/selection.ts`의 `parseSelection` 한 곳에서만 읽는다 — 예전엔 `mode` 삼항이 `/api/diff`와 `/api/blob`에 복제돼 있어 텍스트 diff와 이미지 카드가 다른 기준을 볼 수 있었다). **`base`가 있으면 `mode`는 무시된다**: 한 축을 두 파라미터가 인코딩하면 `mode=working&base=main` 같은 모순 상태가 생기고 우선순위 규칙이 필요해지는데, "새 파라미터가 이긴다" 하나로 그 상태 자체를 없앤다. `@auto` 표식을 쓰는 이유는 실제로 `auto`라는 이름의 브랜치와 구별하기 위해서다. **`merge-base(HEAD, HEAD) === HEAD`라서 `base=HEAD`가 별도 분기 없이 옛 working 뷰와 같은 결과**를 내고, 피커의 "Working tree" 행이 그 값을 보낸다. 목록은 `/api/refs`가 git 호출 **두 번**으로 만든다(`server/refs.ts`): `for-each-ref --format=…%00…`과 `worktree list --porcelain -z`. **필드 구분자가 NUL인 건 취향이 아니다** — git은 refname에 `|`를 허용한다(실측: `weird|pipe` 브랜치를 만들어 확인). `for-each-ref`는 레코드 사이에 리터럴 개행을 하나 끼워 넣는데 refname엔 개행이 못 들어가므로 필드마다 선행 개행 하나만 벗기면 되고, **후행 빈 항목은 정확히 하나만 벗겨야 한다**(전부 벗기면 `symref`가 정당하게 빈 레코드가 통째로 사라진다 — 실제로 밟았다). **죽은 워크트리 제외가 핵심 계약이다**: 디렉토리가 삭제돼도 등록은 `prunable`로 남고 `branch` 줄까지 달고 나오며 `for-each-ref`도 그 경로를 `%(worktreepath)`로 실어 보낸다(둘 다 실측). 교차 확인 없이 목록에 올리면 존재하지 않는 워크트리를 광고하고, 그걸 고르면 위 "cwd 삭제" 항목과 같은 막다른 화면이 된다. `bare`(워킹트리 없음)도 뺀다. **`verifyBaseRef`(`diff.ts`)는 보안 경계다** — Bun의 `$`는 셸을 이스케이프하지 git의 **옵션 파싱**을 막아주지 않으므로, 첫 글자가 `-`인 참조가 `git diff`에 닿으면 `--output=`로 데몬이 쓸 수 있는 아무 경로나 만들거나 비운다(실측). `rev-parse --verify`가 옵션 꼴을 거부하긴 하지만 그 방어에만 기대지 말 것. 목록 밖 base는 조용히 auto로 흘리지 않고 **400**이다(고르지도 않은 기준의 diff가 에러보다 나쁘다). `selectionCacheKey`는 **flight 클로저가 읽는 모든 입력의 전함수여야 한다** — 예전 키(`repo\0untracked\0mode`)엔 해석된 ref가 없는데 클로저는 그걸 물어서, base가 다르게 해석된 두 요청이 같은 키로 합류하면 한쪽이 남의 ref로 만든 diff를 받았다(`baseCache` TTL 만료나 `gh pr view` 결과 변화로 도달). ref는 **이름**으로 넣는다(OID를 넣으면 커밋마다 새 슬롯이 생겨 8칸 LRU가 헛돈다 — OID는 지문의 몫). 새 프리퍼런스 키 `diffdeck:compare-base:`만 리포로 네임스페이스한다 — 기존 여섯 `cc-statusline:` 키는 마이그레이션 코드가 없어 이름을 바꾸면 사용자 설정이 조용히 초기화되므로 건드리지 않는다. `#ref-picker`는 `#overflow-menu` 계열(툴바 앵커 절대 위치)이라 grab의 `computePlacement`를 쓰지 않으며, author `display`를 선언했으므로 **`#ref-picker[hidden]` 짝이 필수**다. 피커는 자기 dismiss(바깥 mousedown·Escape+IME 가드)를 갖고 오버플로 메뉴 리스너에 얹지 않는다. 회귀망: `ref-picker.e2e.ts` 4종 + `diff-selection.test.ts`·`diff-refs.test.ts`·`ref-picker-model.test.ts`. +**견줄 기준 피커** — 툴바의 옛 ` Fold with tree + + diffdeck
From 85fb050cc269c80102c536c45730db5cd4204000 Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 21 Aug 2026 19:33:29 +0900 Subject: [PATCH 13/13] =?UTF-8?q?fix:=20=ED=94=BC=EC=BB=A4=20=EA=B0=9C?= =?UTF-8?q?=EC=88=98=EA=B0=80=20=EC=97=89=EB=9A=B1=ED=95=9C=20=ED=96=89?= =?UTF-8?q?=EC=97=90=20=EB=B6=99=EB=8D=98=20=EA=B2=83=EA=B3=BC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9D=B4=20summary=EB=A5=BC=20=EA=B8=B0=EB=8B=A4?= =?UTF-8?q?=EB=A6=AC=EB=8D=98=20=EA=B2=83=EC=9D=84=20=EA=B3=A0=EC=B9=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 델타 리뷰가 잡은 둘. 둘 다 직전 커밋에서 내가 만든 것이다. **개수가 잘못된 행에 붙었다.** 어느 행의 숫자인지를 `diffBase`(= x-diff-base)로 맞췄는데 그건 origin/ 접두가 벗겨진 **표시명**이라, origin/main으로 잰 숫자가 로컬 main 행에 붙었다. 로컬이 뒤처져 있으면 값이 실제로 갈린다 — 리뷰어가 그런 리포를 만들어 확인했다: 피커엔 "2 file(s)"인데 그 행을 누르면 4개가 나온다. 화면의 숫자가 클릭 결과와 다른 건 숫자를 안 보여주는 것보다 나쁘고, 같은 커밋에 적어둔 "모르는 것을 지어내지 않는다"와도 어긋난다. RepoSummary가 **실제로 잰 참조**를 함께 보고하게 했다(`ref`). base와 달리 접두가 살아 있어 origin/main은 원격 행에 정확히 붙는다. 클라의 measuredAgainst 특수 분기는 통째로 사라졌다 — 모든 선택 상태가 한 규칙으로 덮인다. **목록이 summary를 기다렸다.** /api/refs는 6~16ms인데 /api/summary는 62~70ms라 목록이 10배 늦게 떴다. 구조가 더 문제다 — getRepoSummary는 CLAUDE.md가 적었듯 의도적으로 single-flight 밖이라, 거기서 매달리면 목록이 Working tree 한 줄에 영구히 갇히고 catch도 안 탄다. 이제 refs가 오면 먼저 그리고 개수는 도착하면 얹는다. 직전 커밋 메시지의 "git 호출이 늘지 않는다"는 사실이 아니었다. fetchSummary는 지금까지 diff가 비었을 때만 불렸고, 이제 피커를 열 때마다 불린다. 값 자체가 공짜라는 뜻이었지 호출이 공짜라는 뜻은 아니었는데 그렇게 읽히게 썼다. 테스트 공백도 메웠다. 귀속 계산은 main.ts에 있어 커버리지 게이트 밖이고 유닛은 RowCounts를 주어진 것으로 놓으므로, 아무도 안 보던 자리였다 — e2e가 브랜치 행의 개수까지 단언한다. e2e 픽스처는 이제 옵션과 무관하게 `branch -M main`을 한다. 예전엔 세 옵션 중 하나가 있을 때만 고정해서, 기본 픽스처는 여전히 머신의 init.defaultBranch를 물려받았다 — 같은 함정을 다시 팔 자리였다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ArwfmcvGjKnRWMiEFBfJzF --- apps/viewer/__tests__/summary.test.ts | 14 +++++++++++ apps/viewer/browser/main.ts | 36 ++++++++++++++------------- apps/viewer/e2e/fixtures/repo.ts | 7 +++--- apps/viewer/e2e/ref-picker.e2e.ts | 12 +++++++++ apps/viewer/server/summary.ts | 8 ++++++ 5 files changed, 57 insertions(+), 20 deletions(-) diff --git a/apps/viewer/__tests__/summary.test.ts b/apps/viewer/__tests__/summary.test.ts index 8a295c0..dd94c3d 100644 --- a/apps/viewer/__tests__/summary.test.ts +++ b/apps/viewer/__tests__/summary.test.ts @@ -103,3 +103,17 @@ describe("getRepoSummary", () => { expect(s.aheadCommits).toBeNull(); }); }); + +// 피커가 "이 숫자는 어느 행의 것인가"를 가리려면 표시명이 아니라 잰 참조가 +// 필요하다. base는 origin/ 접두가 벗겨져 있어 로컬 동명 브랜치와 구별되지 +// 않는다. +test("reports the ref it measured against, prefix intact", async () => { + const head = (await $`git -C ${repo} rev-parse HEAD`.text()).trim(); + await $`git -C ${repo} update-ref refs/remotes/origin/main ${head}`; + const summary = await getRepoSummary(repo, { + base: "main", + ref: "origin/main", + }); + expect(summary.base).toBe("main"); + expect(summary.ref).toBe("origin/main"); +}); diff --git a/apps/viewer/browser/main.ts b/apps/viewer/browser/main.ts index 7797236..81b6461 100644 --- a/apps/viewer/browser/main.ts +++ b/apps/viewer/browser/main.ts @@ -1359,28 +1359,30 @@ const loadPickerRows = async (): Promise => { ); if (!res.ok) return; const body = (await res.json()) as RefsResult; - // 개수는 /api/summary가 매 로드마다 이미 계산하는 값이라 git 호출이 - // 늘지 않는다. 브랜치마다 붙이려면 브랜치당 호출이 하나씩 더 들기 - // 때문에, 잰 그 비교 하나에만 붙인다. + // %(HEAD)는 명령을 실행한 워크트리 기준이라 리포 전역 목록에서는 + // misleading하다. 이 워크트리가 무엇을 체크아웃했는지는 worktree + // 목록에서 자기 경로를 찾아 읽는다. + const current = body.worktrees.find((w) => w.path === repo)?.branch ?? null; + // 목록을 먼저 그린다. 개수를 기다리면 목록이 /api/refs(수 ms)가 아니라 + // /api/summary(수십 ms)의 속도로 뜨고, 더 나쁘게는 getRepoSummary가 + // 의도적으로 single-flight 밖이라(CLAUDE.md) 거기서 매달리면 목록이 + // Working tree 한 줄에 영구히 갇힌다. + pickerRows = buildBaseRows(body.refs, body.defaultBranch, current); + renderPickerRows(); + + // 개수는 부가 정보다 — 도착하면 얹고, 못 받으면 목록을 그대로 쓴다. const summary = await fetchSummary(); - // 이름이 아니라 **현재 선택값**으로 맞춘다 — summary는 지금 기준으로 - // 계산되므로 그래야 잰 것과 보여주는 자리가 정확히 같다. (표시명은 - // origin/ 접두가 벗겨져 로컬 동명 브랜치와 헷갈릴 수 있다.) - const measuredAgainst = - compareBase === "HEAD" || compareBase === "@auto" - ? diffBase - : compareBase; + if (!summary) return; const counts: RowCounts = { - working: summary?.workingFiles ?? null, + working: summary.workingFiles, + // 표시명(base)이 아니라 **실제로 잰 ref**로 맞춘다. base는 origin/ + // 접두가 벗겨져 있어, 그걸로 맞추면 origin/main으로 잰 숫자가 로컬 + // main 행에 붙는다 — 로컬이 뒤처져 있으면 값이 실제로 갈린다. base: - summary && summary.baseFiles !== null && measuredAgainst !== "" - ? { name: measuredAgainst, files: summary.baseFiles } + summary.ref !== null && summary.baseFiles !== null + ? { name: summary.ref, files: summary.baseFiles } : null, }; - // %(HEAD)는 명령을 실행한 워크트리 기준이라 리포 전역 목록에서는 - // misleading하다. 이 워크트리가 무엇을 체크아웃했는지는 worktree - // 목록에서 자기 경로를 찾아 읽는다. - const current = body.worktrees.find((w) => w.path === repo)?.branch ?? null; pickerRows = buildBaseRows(body.refs, body.defaultBranch, current, counts); renderPickerRows(); } catch { diff --git a/apps/viewer/e2e/fixtures/repo.ts b/apps/viewer/e2e/fixtures/repo.ts index 33c192f..5c9f485 100644 --- a/apps/viewer/e2e/fixtures/repo.ts +++ b/apps/viewer/e2e/fixtures/repo.ts @@ -214,9 +214,10 @@ export const makeFixtureRepo = ( git(dir, ["add", "-A"]); git(dir, ["commit", "-qm", "base"]); - if (options.clean || options.featureBranchCommit || options.branches) { - git(dir, ["branch", "-M", "main"]); - } + // 옵션과 무관하게 항상 고정한다. git init은 머신의 init.defaultBranch를 + // 따르므로(개발자 로컬 main, CI 러너 master) 고정하지 않으면 브랜치명을 + // 건드리는 스펙이 개발자 머신에서만 통과한다 — 실제로 CI에서 한 번 밟았다. + git(dir, ["branch", "-M", "main"]); for (const branch of options.branches ?? []) { git(dir, ["branch", branch]); } diff --git a/apps/viewer/e2e/ref-picker.e2e.ts b/apps/viewer/e2e/ref-picker.e2e.ts index 90793d8..0d63d45 100644 --- a/apps/viewer/e2e/ref-picker.e2e.ts +++ b/apps/viewer/e2e/ref-picker.e2e.ts @@ -207,6 +207,18 @@ test.describe("compare base picker", () => { .locator("#ref-picker .ref-row") .filter({ hasText: "Working tree" }); await expect(working.locator(".ref-row-tag")).toHaveText("3 file(s)"); + + // 개수가 **어느 행의 것인지**도 지킨다. 서버가 잰 참조로 맞추지 + // 않고 표시명으로 맞추면(base는 origin/ 접두가 벗겨진다) 남의 + // 숫자가 로컬 동명 브랜치에 붙는다 — 화면의 숫자와 눌렀을 때 + // 나오는 개수가 달라진다. + const mainRow = page + .locator("#ref-picker .ref-row") + .filter({ hasText: /^main/ }) + .first(); + // 이 픽스처엔 origin/HEAD가 없어 default 태그는 안 붙는다. + // 귀속이 깨지면 이 행에는 아무 텍스트도 없으므로 여전히 가른다. + await expect(mainRow.locator(".ref-row-tag")).toHaveText(/\d+ file\(s\)/); } finally { await stop(); } diff --git a/apps/viewer/server/summary.ts b/apps/viewer/server/summary.ts index b896785..422f5ef 100644 --- a/apps/viewer/server/summary.ts +++ b/apps/viewer/server/summary.ts @@ -9,7 +9,14 @@ import { resolveDiffBaseRev } from "./diff.ts"; export interface RepoSummary { branch: string | null; head: string; + /** 표시용 이름 — origin/ 접두가 벗겨져 있다. */ base: string | null; + /** + * baseFiles를 **실제로 잰** 참조. `base`와 달리 접두가 살아 있어 + * (`origin/main` vs `main`) 목록의 어느 행이 그 숫자의 주인인지 + * 가릴 수 있다. 표시명으로 맞추면 로컬 동명 브랜치에 남의 숫자가 붙는다. + */ + ref: string | null; workingFiles: number; baseFiles: number | null; untrackedFiles: number; @@ -64,6 +71,7 @@ export const getRepoSummary = async ( branch: branch || null, head, base: opts.base, + ref: opts.ref ?? null, workingFiles, baseFiles, untrackedFiles,