Skip to content

feat: 툴바의 2지선다 select를 검색 가능한 기준 피커로 바꾼다 - #68

Merged
say8425 merged 13 commits into
mainfrom
feat/ref-worktree-picker
Aug 21, 2026
Merged

feat: 툴바의 2지선다 select를 검색 가능한 기준 피커로 바꾼다#68
say8425 merged 13 commits into
mainfrom
feat/ref-worktree-picker

Conversation

@say8425

@say8425 say8425 commented Aug 21, 2026

Copy link
Copy Markdown
Owner

무엇을

툴바의 <select id="diff-mode">는 옵션이 딱 둘이었습니다 — Working tree와 자동 해석된 vs <base>. 그래서 다른 브랜치와는 견줄 수 없었습니다. 그 자리에 검색 가능한 피커를 둡니다.

Screenshots

Before — 옵션 두 개짜리 select

before-toolbar.png

After — Working tree + 로컬/원격 브랜치, 필터, HEAD·default 태그

ref-picker.png

HEAD 태그는 이 워크트리가 체크아웃한 브랜치입니다(자기 자신과 견주면 언제나 비어 보이므로, 막지 않고 왜 그런지 읽히게 했습니다). default 태그는 리포의 기본 브랜치입니다.

어떻게

git이 이미 워크트리와 브랜치를 하나로 모델링합니다 — for-each-ref%(worktreepath)가 브랜치마다 소유 워크트리를 알려주고, git 자신도 남이 쥔 브랜치의 체크아웃을 거부합니다. 목록은 그 사실 위에 서 있고 git 호출 두 번이면 만들어집니다(이 리포에서 콜드 11~13ms).

커밋은 슬라이스로 나눠 두었습니다. 앞의 셋은 피커 없이도 단독으로 유효합니다.

커밋 내용
3550228 비ASCII 브랜치명 500 수정 — 기능과 무관한 live 버그
3db5086 선택 파싱 단일화 + flight 키를 전함수로 (잠복 버그)
572b04a /api/refs — 워크트리·참조 열거
4e208a7 base wire 파라미터 + verifyBaseRef 보안 경계
1f29826 피커 목록·프리퍼런스 순수 로직
91d2dad 피커 UI
1791c0f 문서 7곳 + CLAUDE.md + 스크린샷

짚어둘 것 셋

① 살아 있던 500 버그. HTTP 헤더 값은 latin1이라 한글·일본어·중국어 브랜치명이 x-diff-base에 실리면 응답 생성이 throw해 응답 전체가 500이 됐습니다. 클라는 그걸 terminal로 보고 재시도 없이 "Failed to load diff."를 띄웁니다. 이 리포는 이미 korean-filename.e2e.ts를 갖고 있어 비ASCII git 식별자는 범위 안인데, 어떤 테스트도 이걸 덮지 않았습니다.

② 보안 경계 (verifyBaseRef). Bun의 $는 셸을 이스케이프하지 git의 옵션 파싱을 막아주지 않습니다. 첫 글자가 -인 참조가 git diff에 닿으면 --output=<path>로 데몬이 쓸 수 있는 아무 경로나 만들거나 비웁니다. 목록 밖 base는 조용히 auto로 흘리지 않고 400입니다 — 고르지도 않은 기준의 diff가 에러보다 나쁩니다. 회귀망으로 옵션 꼴 base가 400을 받고 파일이 생기지 않는지 단언합니다.

③ 죽은 워크트리. 디렉토리가 삭제돼도 등록은 prunable로 남고 branch 줄까지 달고 나오며, for-each-ref도 그 경로를 그대로 실어 보냅니다(둘 다 실측). 교차 확인 없이 목록에 올리면 존재하지 않는 경로를 광고하고, 그걸 고르면 CLAUDE.md의 "cwd 삭제"와 같은 막다른 화면이 됩니다.

호환성

  • mode=working|base는 계속 읽습니다. mode=base는 "서버가 골라라"와 같은 말이라 @auto로 옮겨져 기존 프리퍼런스와 statusline 링크가 이어집니다.
  • merge-base(HEAD, HEAD) === HEADbase=HEAD가 별도 분기 없이 옛 워킹트리 뷰와 같은 파일 목록을 냅니다(테스트로 고정).
  • 새 키 diffdeck:compare-base:<repo>만 리포로 네임스페이스합니다. 기존 여섯 cc-statusline: 키는 마이그레이션 코드가 없어 이름을 바꾸면 사용자 설정이 조용히 초기화되므로 건드리지 않았습니다.
  • CLI 플래그를 추가하지 않았습니다docs-flags-parity 게이트는 발화하지 않습니다. 그래서 문서 7곳을 손으로 챙겼습니다.

Test plan

  • bun test686 pass / 0 fail (기존 621 + 신규 65)
  • bun run test:coverage100% 유지. 새 파일 전부 게이트 안: server/{selection,refs}.ts, browser/{headerValue,refPicker/model}.ts
  • bun run typecheck / lint / format:check — 클린
  • bunx playwright test82 pass / 0 fail
    • 신규 ref-picker.e2e.ts 4종: [hidden] 실효성(happy-dom이 원리적으로 못 보는 계약), 필터, 선택 반영 + 포커스 복귀, 오버플로 메뉴와 상호배제
    • empty-state.e2e.ts의 lastEtag 리셋 회귀 가드는 같은 의미(선택 변경)로 구동되도록 옮김

이번 범위 밖 (후속)

Way 1의 두 그룹 중 타깃 선택은 넣지 않았습니다. 이 PR은 "무엇과 견줄까"만 다룹니다.

  • 워크트리 전환location.assign 전체 이동으로 갈 예정입니다. in-place는 main.ts에서 ~10개 바인딩을 비워야 하는데 그 파일은 커버리지 게이트 밖이고, seenIds·접힘 Set 3종은 조용히 틀립니다.
  • 워크트리 없는 참조의 커밋 상태 보기buildFile에 새 쪽 소스를 관통시켜야 하고, 2-rev diff라 타깃 allowlist(보안 리뷰)가 필요합니다.
  • baseRelation 빈 상태 안내 — 공통 조상이 없거나 base가 이미 내 작업을 포함하면 지금도 조용한 빈 화면입니다. summary.ts가 이미 세는 값으로 git 호출 추가 0에 만들 수 있습니다.
  • /api/summary는 아직 선택을 안 받습니다 — 사용자가 고른 base와 다른 비교를 설명할 수 있습니다.

알려진 것

  • apps/viewer/tsconfig.jsonbrowser/**를 include하지 않아 새 refPicker 파일들은 bun run typecheck가 보지 않습니다. 넣어봤더니 9건이 나왔는데 8건은 CLAUDE.md가 이미 적어 둔 미해결 두 가지(*.css?inline ambient 선언, vendored 전역 augmentation)이고 1건은 이 PR 이전부터 있던 타입 에러라, 되돌리고 기록만 했습니다.
  • 개요 스크린샷 캡션에서 "an inline image diff"를 뺐습니다 — 새 샷에 이미지 카드가 없는데 캡션이 그걸 주장하면 안 됩니다.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ArwfmcvGjKnRWMiEFBfJzF

say8425 and others added 8 commits August 21, 2026 00:57
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ArwfmcvGjKnRWMiEFBfJzF
/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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ArwfmcvGjKnRWMiEFBfJzF
피커가 고를 수 있는 것들을 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/<remote>/HEAD는 짧게 쓰면 "origin"이라 헛 항목이 되므로
목록에서 빼되, 그 symref 대상이 기본 브랜치의 출처다.

정렬은 붙이지 않는다. %(committerdate) 정렬은 참조마다 커밋 객체를 읽게
만들어 브랜치가 많은 리포에서 비용을 지배한다.

refsCache는 baseCache와 달리 핸들러 스코프에 둔다 — 모듈 스코프는 flight
타임아웃 테스트 둘의 특수 사정이지 일반 규칙이 아니다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ArwfmcvGjKnRWMiEFBfJzF
`base=<ref>`가 서버의 자동 해석을 대신한다. `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=<path>`로 데몬이 쓸 수 있는 아무 경로나 만들거나 비울 수 있다.
rev-parse --verify가 옵션 꼴을 거부하긴 하지만 그 방어에만 기대지 않고
먼저 끊는다. 회귀망으로 옵션 꼴 base가 400을 받고 파일이 생기지 않는지
단언한다.

목록에 없는 base는 조용히 auto로 흘려보내지 않고 400으로 거절한다 —
고르지도 않은 기준의 diff를 보여주는 것이 에러보다 나쁘다.

base 해석은 resolveSelectionBase 한 곳에 둔다. /api/diff와 /api/blob이
서로 다른 기준을 고르면 텍스트 diff와 이미지 카드가 다른 비교를 보여준다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ArwfmcvGjKnRWMiEFBfJzF
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ArwfmcvGjKnRWMiEFBfJzF
`<select id="diff-mode">`가 옵션 두 개(Working tree / vs base)로 고정돼
있어서, 자동 해석된 base 말고 다른 브랜치와는 견줄 수 없었다. 그 자리에
검색 가능한 피커를 둔다 — Working tree가 맨 위, 그 아래 로컬 브랜치,
그 아래 원격 브랜치이고 기본 브랜치와 현재 체크아웃에는 태그가 붙는다.

크롬은 grab 팝오버가 아니라 오버플로 메뉴 계열을 따른다. 툴바 컨트롤에
앵커된 절대 위치 패널이라 좌표 계산이 필요 없고, 그래서 grab의
computePlacement와 POPOVER_SIZE 상수를 끌어오지 않는다.

레거시 `mode`는 부팅 시 한 번만 읽는다. `mode=base`는 "서버가 골라라"와
같은 말이므로 `@auto`로 옮겨, 기존 사용자의 프리퍼런스와 statusline
링크가 그대로 이어진다.

이미지 blob에도 같은 기준을 실어 보낸다. 기준이 갈리면 텍스트 diff는
브랜치를, 이미지 카드는 워킹트리를 보여준다.

목록을 받기 전에도 Working tree 행은 항상 그린다. 빈 배열로 시작하면
피커를 열 때마다 "No match"가 한 프레임 스치는데, 그건 목록이 없는 것처럼
읽힌다(그 레이스가 e2e 플레이크로도 드러났다).

피커는 자기 dismiss를 갖는다 — 오버플로 메뉴 리스너에 얹으면 한쪽을
고치다 다른 쪽이 조용히 깨진다. Escape에는 grab 팝오버와 같은 IME 조합
가드를 둔다(조합 중 Escape는 조합 취소이지 닫기가 아니다).

#ref-picker에 author display를 선언했으므로 [hidden] 짝을 같은 블록에
뒀다. 이 규칙은 happy-dom이 원리적으로 못 보므로 e2e가 회귀망이다 —
ref-picker.e2e.ts 4종이 [hidden] 실효성·필터·선택 반영·포커스 복귀·
오버플로와의 상호배제를 지킨다.

빈 상태 카드의 모드 전환 액션도 피커 경로로 옮겼다. empty-state.e2e.ts의
lastEtag 리셋 회귀 가드는 같은 의미(선택 변경)로 구동되도록 유지했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ArwfmcvGjKnRWMiEFBfJzF
툴바에서 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 <branch>`)는 그대로
둔다. 그 계약은 이번 변경에서 바뀌지 않았고, baseName이 이제 실제로
견주는 대상을 가리키므로 오히려 더 정확해졌다.

CLAUDE.md에는 다음 사람이 밟을 함정 위주로 적었다: base가 mode를 이기는
이유, base=HEAD가 옛 working 뷰와 같아지는 이유, NUL 구분자가 필요한
이유(git은 refname에 "|"를 허용한다), 후행 빈 항목을 하나만 벗겨야 하는
이유, 죽은 워크트리 제외가 계약인 이유, verifyBaseRef가 보안 경계인 이유,
캐시 키가 전함수여야 하는 이유. 그리고 Bun $ 템플릿의 비ASCII 리터럴이
뭉개지는 함정 — 이것 때문에 한글 브랜치 테스트가 조용히 통과했었다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ArwfmcvGjKnRWMiEFBfJzF
리뷰가 지적한 결함이 전부 경계면에 몰려 있었다 — 빈 리포, 레거시 링크,
summary 라우트, 사라진 브랜치.

**CI 빨간불.** `diff-refs.test.ts`의 comparator 없는 `sort()`가 oxlint
error였다. `bun run lint`가 exit 1이었는데, 내가 출력을 grep으로 걸러
보느라 exit code를 놓쳤다.

**커밋 없는 리포에서 첫 화면이 깨졌다.** `base=HEAD`를 참조로 취급하면
unborn HEAD에서 `rev-parse --verify HEAD`가 실패해 400이 되고, 400은
재시도 없는 terminal이라 새 프로젝트에서 diffdeck을 처음 켠 화면이 통째로
실패 카드가 됐다. `parseBase`가 `HEAD`를 `{kind:"head"}`로 정규화하도록
고쳤다. 부수 효과로 prewarm이 데운 슬롯과 브라우저 첫 요청의 캐시 키가
다시 같아진다 — 그 전엔 `head`와 `ref:HEAD`가 서로 다른 슬롯이라 기동
직후 데운 것을 아무도 조회하지 않았다. e2e 픽스처는 항상 base 커밋을
만들므로 이 상태를 원리적으로 못 만든다 — 유닛에 unborn 리포 2종을 뒀다.

**빈 상태 카드가 다른 비교를 설명했다.** `/api/summary`가 선택을 무시하고
자동 해석 base를 쓰고 있어서, develop을 골라 놓고도 "No changes vs main"
이라고 말했다. 이 리팩터가 막겠다던 갈라짐 그 자체였다. 이제 세 라우트가
모두 `resolveSelectionBase`를 지난다.

**레거시 링크가 저장된 선택에 졌다.** `resolveCompareBase`가 저장값을
먼저 돌려줘서, 피커를 한 번이라도 쓴 사용자에게는 statusline의
`?mode=base` 링크가 조용히 무시됐다. 레거시 mode를 URL 레이어에서 base
값으로 승격시켜 "URL이 저장된 선택을 이긴다"는 계약을 복원했다.

**사라진 브랜치가 매 실행을 막았다.** 고른 브랜치가 삭제되면(PR 머지 후
prune) 저장된 기준이 400을 부르고 이후 모든 실행이 실패 카드로 시작했다.
저장된 값에서 왔을 때만 한 번 지우고 워킹트리로 되돌린다. URL이 명시한
기준은 건드리지 않는다 — 사용자가 그 링크로 그 비교를 요구한 것이므로,
조용히 다른 것을 보여주는 편이 에러보다 나쁘다. 400 응답도 어떤 ref가
문제인지 말하게 했다.

**키보드 조작을 되돌려 놓았다.** 네이티브 select를 없애면서 방향키·Enter가
같이 사라졌다(죽어 있던 `[data-active]` CSS가 내가 의도했었다는 증거다).
↑/↓/Home/End/Enter + `aria-activedescendant`, Enter에는 IME 조합 가드.

그 밖에: `/api/refs`를 열 때마다 다시 받고(서버 TTL이 흡수한다 — 한 번
받고 영원히 쓰면 켜 둔 채 만든 브랜치가 리로드 전까지 안 보인다), 죽은
`pickerLoaded`를 제거하고, 거짓이 된 주석 셋을 고치고, 번역 4종에 새 절과
스크린샷을 추가했다(번역본은 이미지를 상대 경로로 참조해서 내 첫 조사가
놓쳤다).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ArwfmcvGjKnRWMiEFBfJzF
@say8425

say8425 commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

코드 리뷰 반영 (9321cdd)

리뷰에서 Critical 2건 + Important 4건이 나왔고 전부 고쳤습니다. 결함이 한 곳도 설계에 있지 않고 전부 경계면(빈 리포, 레거시 링크, summary 라우트, 사라진 브랜치)에 몰려 있었습니다.

# 무엇이 어떻게
C1 bun run lint가 exit 1 (CI 빨간불) comparator 없는 sort() 수정
C2 커밋 없는 리포에서 첫 화면이 실패 카드 parseBasebase=HEAD{kind:"head"}로 정규화
I3 빈 상태 카드가 다른 비교를 설명 /api/summaryresolveSelectionBase를 지남
I4 레거시 ?mode= 링크가 저장된 선택에 짐 레거시 mode를 URL 레이어로 승격
I5 prewarm이 아무도 안 읽는 슬롯을 데움 C2 정규화로 함께 해소
I6 사라진 브랜치가 매 실행을 막음 저장값 출처일 때만 1회 자가복구

C2가 가장 아팠습니다. git init 직후 커밋 전에 diffdeck을 켜면 — 즉 새 프로젝트에서 처음 켜는 경로rev-parse --verify HEAD가 실패해 400이 되고, 400은 재시도 없는 terminal이라 화면 전체가 실패 카드였습니다. e2e 픽스처는 항상 base 커밋을 만들어서 이 상태를 원리적으로 만들 수 없습니다. 유닛에 unborn 리포 2종을 뒀습니다.

추가로 키보드 조작을 되돌려 놨습니다. 네이티브 <select>를 없애면서 방향키·Enter가 함께 사라졌는데, 죽어 있던 [data-active] CSS가 제가 의도했었다는 증거였습니다. ↑/↓/Home/End/Enter + aria-activedescendant + IME 조합 가드.

번역 4종에도 새 절과 스크린샷을 추가했습니다 — 번역본이 이미지를 상대 경로로 참조해서 제 첫 조사가 놓쳤던 부분입니다.

검증 (전부 레포 루트에서)

  • bun test688 pass / 0 fail
  • bun run test:coverage100%
  • bun run typecheck / lint / format:check — 전부 exit 0
  • bunx playwright test — 84개 중 83 pass / 1 fail

남은 빨간불 하나 — 판단 유보

retokenize-cache.e2e.ts가 간헐적으로 실패합니다. 현재 HEAD에서 단독 5회 중 4 pass / 1 fail입니다.

제 변경 탓이라고 보기 어려운 정황:

  • 실패 지점이 이 스펙이 존재하는 이유인 프레임 측정이 아니라 첫 로드입니다 (#status가 15초간 Loading…).
  • C2 정규화 이후 기본 뷰의 서버 경로는 main과 동일합니다({kind:"head"} → mode workinggetDiffFiles(repo, {untracked})).
  • 측정 당시 load average가 8.79였습니다. CLAUDE.md가 기록한 조건(git 서브프로세스 버스트 + 외부 프로세스 경합 → Bun $ never-settle → Loading… 고착)과 모양이 정확히 맞습니다. 그 경로의 flight 타임아웃은 45초라 이 스펙의 15초 예산을 넘습니다.

다만 main 베이스라인을 세우지 못했습니다 — 임시 워크트리에 node_modules를 심링크했더니 워크스페이스 패키지가 해석되지 않아 빌드가 실패했습니다. 그래서 "기존 문제"라고 단정하지 않고 이렇게 남깁니다. CI(부하가 다른 환경)에서 어떻게 나오는지 보는 것이 가장 빠른 판별이라고 봅니다.

검증 리뷰가 잡은 Minor 넷.

**자가복구가 잘못된 400에도 돌았다.** 서버의 400은 두 종류인데 상태
코드가 같다 — "고른 base가 없다"와 "git 리포가 아니다". 상태 코드로만
갈랐더니 뷰어를 켜 둔 채 repo 디렉토리를 지우면 재요청도 같은 400이라
화면은 그대로 실패 카드인데 **사용자 프리퍼런스만 조용히 사라졌다.**
전자에만 `x-diff-error: unknown-base`를 얹고 클라가 그 표식으로만
발동한다. 본문 문자열 매칭은 취약해서 쓰지 않았다. 두 400이 실제로
구별되는지 테스트로 고정했다.

**`?base=`(빈 값)에서 출처 판정이 어긋났다.** `params.get("base")`가
빈 문자열을 돌려주는데 `urlBase === null`로만 판정해서, 값은 저장소에서
왔는데 자가복구는 꺼졌다. `resolveCompareBase`가 이미 쓰는 규칙(빈 값은
없는 것으로 친다)을 호출부와 공유하도록 실제로 이긴 값으로 판정한다.

**복구가 재시도 예산을 먹었다.** 루프가 `for (;; attempt++)`이라
`continue`가 카운터를 태워서, 복구 직후 재요청이 503(single-flight
타임아웃)을 만나면 1초 재시도 없이 포기했다 — CLAUDE.md "Loading…
자가치유" 3요소 중 클라이언트 몫이 그 조합에서만 사라진다.

**CLAUDE.md가 직전 커밋으로 거짓이 됐다.** 내가 "merge-base 동치라서
별도 분기 없이"라고 적어 놓고 정확히 그 분기를 추가했다. 분기가 필요한
진짜 이유 둘(unborn HEAD에서 참조 검증 실패, prewarm 캐시 슬롯 불일치)을
적고, 다음 사람이 이걸 불필요한 중복으로 읽고 지우지 않도록 못을 박았다.
flight 예산 서술도 같은 줄에서 정리했다 — 정규화 덕에 기본 경로는 45+45가
복원됐고, 이제 그 서술이 설명하지 못하는 곳은 사용자가 base를 고른 경로
하나(verifyBaseRef가 flight 밖)다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ArwfmcvGjKnRWMiEFBfJzF
@say8425

say8425 commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

검증 리뷰 반영 (bd1786c) — 리뷰 판정 Ready to merge

수정 커밋(9321cdd)만 좁게 재검증받았고 Minor 4건이 나와 전부 고쳤습니다.

가장 실질적이었던 것 — 서버의 400이 두 종류인데 상태 코드가 같습니다: "고른 base가 없다"와 "git 리포가 아니다". 상태 코드로만 갈랐더니 뷰어를 켜 둔 채 repo 디렉토리를 지우면 재요청도 같은 400이라 화면은 그대로 실패 카드인데 사용자 프리퍼런스만 조용히 사라졌습니다. 전자에만 x-diff-error: unknown-base를 얹고 그 표식으로만 발동하게 했으며, 두 400이 실제로 구별되는지 테스트로 고정했습니다.

나머지 셋: ?base=(빈 값)에서 출처 판정이 어긋나던 것, 복구가 재시도 예산을 한 칸 먹던 것(503과 겹치면 Loading… 자가치유의 클라이언트 몫이 사라짐), 그리고 CLAUDE.md 드리프트 — 제가 "merge-base 동치라서 별도 분기 없이"라고 적어 놓고 정확히 그 분기를 추가했습니다. 분기가 필요한 진짜 이유 둘을 적고 다음 사람이 중복으로 읽고 지우지 않도록 못을 박았습니다.

리뷰어가 실행으로 확인해 준 것들: base=HEAD 정규화가 캐시 키·지문·x-diff-base·grab baseName을 안 깼고, prewarm 슬롯이 실제로 다시 일치하며(/r\0false\0head\0 양쪽 동일), 제가 의심했던 포커스 구멍은 실제로는 없었습니다.

검증

bun test 690 pass / 0 fail · 커버리지 100% · typecheck·lint·format exit 0 · ref-picker + empty-state e2e 9/9

후속으로 남긴 것

원격 브랜치 default 태그, foo/HEAD 이름의 원격 브랜치, origin/ 접두를 벗기는 표시명 관례(라벨과 빈 상태 카드가 다른 이름을 말함 — resolveBaseRef부터 이어진 기존 관례).

retokenize-cache 간헐 실패 — 제안은 받되 이 PR에서는 안 건드렸습니다

리뷰어는 첫 단언 timeout을 15s → 30s로 올리자고 제안했습니다(그 스펙이 재는 계약은 첫 로드 속도가 아니라 재진입 프레임 상한이므로 계약이 약해지지 않는다는 논거). 타당하지만, 원인을 귀속하지 못한 상태에서 제 PR을 초록으로 만들려고 남의 perf 가드 스펙의 timeout을 넓히는 건 제가 결정할 일이 아니라고 봅니다. 별도 판단으로 남깁니다.

say8425 and others added 4 commits August 21, 2026 14:18
CI 3회가 전부 빨간불이었다(test·coverage·e2e). 로컬에서는 690개가 전부
통과했는데, 두 테스트가 내 머신에서만 참인 것에 기대고 있었다.

**기본 브랜치 이름을 고정하지 않았다.** `diff-refs.test.ts`의 픽스처가
`git init` 후 이름을 고정하지 않아 머신의 `init.defaultBranch`를 그대로
따랐다 — 내 로컬은 `main`, CI 러너는 `master`라 `["feat-a", "master"]`를
받고 깨졌다. `GIT_CONFIG_KEY_0=init.defaultBranch`로 CI 조건을 로컬에
재현해 확인했고, 픽스처가 `branch -M main`으로 고정하게 했다.

**e2e 단언이 무의미했다.** "브랜치를 고르면 비교가 넓어진다"를 파일
개수 변화로 단언했는데, 기본 픽스처의 워킹트리 편집 셋 중 하나가
`src/hello.ts`이고 featureBranchCommit이 커밋하는 것도 같은 파일이라
기준을 바꿔도 개수가 그대로다. 로컬에서 통과한 건 status가 아직
"Loading…"일 때 이전 값을 캡처하는 레이스 덕이었다 — 운으로 초록이던
단언이다.

브랜치에만 있는 파일을 하나 만들어 실제로 가른다. 커밋된 뒤 워킹트리에서는
깨끗하므로 HEAD 기준에는 안 보이고 main 기준(갈림점)에서만 보인다:
3 file(s) → 4 file(s). 이제 기능이 깨지면 반드시 빨간불이 된다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ArwfmcvGjKnRWMiEFBfJzF
두 종류가 같은 목록에 같은 모양으로 있어 구분이 안 됐다. Working tree는
HEAD 대비 미커밋만, 브랜치는 merge-base 대비 갈라진 뒤 전부라 물어야 알
만큼 다른데 화면은 같게 말하고 있었다.

구역 이름 둘로 가른다 — UNCOMMITTED / COMPARE WITH A BRANCH. 이름 자체가
"무엇과 견주는가"를 말하므로 별도 설명이 필요 없다.

그리고 각 자리에 얼마나 들어 있는지를 고르기 전에 보여준다. 비어 있으면
숫자 0 대신 "nothing yet"이라고 쓴다 — 0은 훑어볼 때 눈에 안 걸리는데,
이 행이 비어 있다는 사실이야말로 고르기 전에 알아야 하는 것이다. 커밋을
다 해둔 리포에서 첫 화면이 비어 보이는 것이 "고장인가?"로 읽히던 것이
이걸로 없어진다.

개수는 공짜인 자리에만 붙인다. /api/summary가 매 로드마다 이미 계산하는
workingFiles와 baseFiles라 git 호출이 늘지 않는다. 브랜치마다 붙이려면
브랜치당 호출이 하나씩 더 들기 때문에 나머지 행에는 숫자를 쓰지 않는다 —
모르는 것을 지어내지 않는다.

어느 행에 붙일지는 이름이 아니라 현재 선택값으로 맞춘다. summary는 지금
기준으로 계산되므로 그래야 잰 것과 보여주는 자리가 정확히 같다(표시명은
origin/ 접두가 벗겨져 로컬 동명 브랜치와 헷갈릴 수 있다).

main.ts는 커버리지 게이트 밖이라 구역 렌더는 e2e가 회귀망이다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ArwfmcvGjKnRWMiEFBfJzF
설정 토글들 아래에 구분선을 긋고 버전 줄을 둔다. 배치는 토글 행과 같다 —
이름 왼쪽, 값 오른쪽. 다만 조작이 아니라 정보이므로 muted로 눕고, hover에서만
밝아진다.

값은 하드코딩하지 않고 /api/ping의 x-diffdeck-version 헤더에서 읽는다.
그 라우트가 존재하는 이유가 정확히 이것이다 — 장수 데몬이 디스크의 패키지보다
오래 살 수 있어서, 클라이언트가 자기가 기대하는 버전과 대조하라고 pid와
version을 싣는다. 지금까지 브라우저는 이 라우트를 부른 적이 없었으므로 이
배선이 첫 소비자다. 못 읽어도 메뉴의 나머지는 그대로 동작한다(부가 정보다).

링크는 릴리스 태그가 아니라 레포 루트로 건다. 태그 형식은 diffdeck-v1.3.2라
버전만으로 만들 수 있지만, 아직 배포되지 않은 버전에서는 404가 된다.
새 탭으로 열므로 rel=noopener로 opener를 끊는다.

main.ts는 커버리지 게이트 밖이라 flags-sync.e2e.ts가 회귀망이다 — href와
rel, 그리고 서버가 실제로 보고한 버전 꼴인지까지 단언한다(하드코딩된
문자열이면 통과하지 못한다).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ArwfmcvGjKnRWMiEFBfJzF
델타 리뷰가 잡은 둘. 둘 다 직전 커밋에서 내가 만든 것이다.

**개수가 잘못된 행에 붙었다.** 어느 행의 숫자인지를 `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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ArwfmcvGjKnRWMiEFBfJzF
@say8425
say8425 merged commit 3b2837d into main Aug 21, 2026
9 of 10 checks passed
@say8425
say8425 deleted the feat/ref-worktree-picker branch August 21, 2026 12:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant