Skip to content

feat: 툴바에 지금 보고 있는 워크트리와 브랜치를 표시한다 - #72

Merged
say8425 merged 16 commits into
mainfrom
feat/toolbar-repo-branch
Sep 4, 2026
Merged

feat: 툴바에 지금 보고 있는 워크트리와 브랜치를 표시한다#72
say8425 merged 16 commits into
mainfrom
feat/toolbar-repo-branch

Conversation

@say8425

@say8425 say8425 commented Aug 29, 2026

Copy link
Copy Markdown
Owner

뷰어는 repo를 URL 파라미터로만 받아서, 화면 어디에도 **"지금 어느 리포의 어느 워크트리에서 무엇을 보고 있는가"**가 없었다.

실제로 밟은 사고: 워크트리를 4개 쓰는 리포에서 엉뚱한 워크트리(main, origin보다 1044 커밋 뒤처짐)를 열어 놓고 "changed files가 1개밖에 안 나온다"고 판단했다. 실제 작업 브랜치는 별도 워크트리에 있었고 거기선 176 files가 정상적으로 나왔다. 화면에 단서가 없으니 오진을 가려낼 수 없었다.

원인을 파다 보니 툴바 피커 자체가 틀린 축을 건드리고 있었다. 목록의 브랜치 이름은 "그 브랜치를 보여줘"로 읽히는데 실제로는 *견줄 기준(base)*을 골랐다 — 그래서 메인 워크트리에서 남의 브랜치를 골라도 1 file만 나왔다(같은 브랜치를 워크트리로 열면 176). 게다가 뒤처진 브랜치에서는 merge-base가 HEAD라 어떤 base를 골라도 결과가 같아 보인다 — "피커를 바꿔도 화면이 안 변한다"가 고장으로 읽힌다.

이 PR은 그 한 갈래를 끝까지 따라가 툴바를 다시 세운다: 표식을 만들고 → 피커를 head 축으로 돌리고 → 중복을 걷어내고 → 조작과 정보를 자리로 갈랐다.

Screenshots

Before (origin/main) — 툴바가 어느 리포/워크트리/브랜치인지 말하지 않는다. 탭 제목도 diffdeck뿐.

before.png

After ① 메인 워크트리 — 리포 접두를 생략해 diffdeck · feat/toolbar-repo-branch. 개수 오른쪽에 전체 변경량.

toolbar-main-worktree.png

After ② 링크된 워크트리diffdeck / feat+SHOT-1-toolbar · shot/linked-preview. 세 조각이 서로 다른 축이다.

toolbar-linked-worktree.png

After ③ 브랜치를 head로 — 워크트리 조각이 통째로 빠지고 head가 주인공 자리를 가진다. 커밋된 rev의 diff는 어느 워크트리에서 보든 같으므로(실측) 워크트리 이름을 남기면 거짓 인상을 준다.

toolbar-branch-head.png

피커 — 워크트리 구역(각 행이 물고 있는 브랜치를 오른쪽에)과 브랜치 구역(default가 맨 위). 워크트리를 고르면 그 URL로 이동하고, 브랜치를 고르면 head만 바꾼다.

head-picker-open.png

마지막 커밋 — 조작을 오른쪽으로. 왼쪽 그룹은 한 문장으로 읽혀야 하는데(무엇을 · 무엇과 견줘 · 몇 파일 · 몇 줄) Unified/Split과 새로고침이 첫 마디와 둘째 마디 사이에 앉아 그 문장을 두 동강 냈다.

Before toolbar-before-controls-in-the-middle.png
After toolbar-after-controls-on-the-right.png

무엇을

1. 표식 — 트리거가 곧 표시다

세 조각이 서로 다른 축이다. 워크트리 이름만 보이면 어느 리포인지 모르고, 리포 이름만 보이면 어느 워크트리인지 모른다.

한때 피커 트리거와 개수 왼쪽 라벨이 따로였다. 피커가 base를 고르던 시절엔 서로 다른 축이라 중복이 아니었지만, 피커가 head를 고르게 된 순간 둘이 같은 말을 하게 됐다. 그래서 고르는 컨트롤이 곧 표시다 — 고르는 자리와 지금 무엇을 보는지 말하는 자리가 하나면 어긋날 수 없고, 남은 자리(#base-label)는 트리거가 결코 말하지 않는 축(base)만 가진다.

색은 구별되는 조각 하나만 밝힌다(#picker-name = --vd-fg, 나머지 muted). 리포 이름은 그 리포의 워크트리마다 같아서 화면을 가르지 못하기 때문이고, 같은 이유로 탭 제목에는 리포 접두를 넣지 않는다(탭은 좁고 오른쪽부터 잘린다).

git 호출은 늘지 않는다. /api/refs가 이미 주는 worktrees[]에 리포 루트 하나만 더했고, parseRepoRootworktree list --porcelain 원본의 첫 레코드를 읽는다.

parseWorktreeList의 결과를 대신 쓰면 안 된다 — 그쪽은 bare·prunable을 걸러내는데, bare 리포는 메인 항목이 바로 그 bare라 첫 항목이 링크된 워크트리로 밀린다. 그러면 라벨이 남의 워크트리를 리포 이름이라 말한다(실측).

  • browser/repoLabel.ts — DOM에 닿는 문자열 전량을 만드는 순수 모듈. main.ts는 커버리지 게이트 밖이라 거기 조립을 두면 게이트가 100% 초록인 채로 버그가 산다(isLargeFile 사건과 같은 구조).
  • findWorktree는 세그먼트 경계 최장 접두 일치. repo는 기동 시점의 process.cwd()라 리포 루트라는 보장이 없고, 중첩 워크트리에서 바깥이 이기면 라벨이 엉뚱한 워크트리를 말한다. 피커의 HEAD 태그 판정도 같은 함수를 쓴다.
  • 갱신 시점 셋: load(), 피커 열림, watch의 poll(). poll()이 빠지면 watch 중에 diff만 갈리고 라벨은 옛 브랜치에 굳는다 — 같은 화면의 빈 상태 카드는 살아 있는 브랜치를 말하므로 한 화면이 두 브랜치를 주장하게 된다.

2. 피커가 base가 아니라 head를 고른다

wire는 head=<ref>, 없으면 워킹트리. base=HEAD와 달리 head=HEAD는 정규화하지 않는다base=HEAD는 "커밋 안 한 것만"이라 워킹트리 뷰와 결과가 같지만 head=HEAD커밋된 HEAD를 보는 것이라 미커밋 변경이 빠진다.

  • 갈림점은 head 기준이어야 한다. merge-base(ref, HEAD)로 재면 지금 워크트리의 HEAD와 갈림점을 잡는데 그 둘은 아무 관계도 없고, base 브랜치가 그 사이 만든 파일이 "head에서 삭제됨"으로 끼어든다(뮤테이션 확인).
  • 커밋된 rev에는 untracked가 없다 — 토글이 켜져 있어도 건너뛴다.
  • head도 verifyBaseRef의 보안 경계를 탄다. 그 값이 이제 git diff의 두 번째 인자로도 가므로 -로 시작하는 참조가 닿으면 --output=<path>가 된다. 표식은 x-diff-error: unknown-head로 base와 가른다(자가복구가 다르다).
  • /api/summaryworkingFiles·untrackedFilesnumber | null이 됐다 — 재지 않은 것을 0으로 적으면 주장이 되어, 실제로는 미커밋 변경이 있는데도 카드가 "Nothing to show in any mode"라고 말한다.
  • head는 저장하지 않는다 — URL이 진실이라야 새로고침·링크가 재현되고, 저장하면 다음에 그 리포를 열 때 남의 브랜치 뷰에 갇힌 채 시작한다.
  • 캐시 키와 지문에 head가 들어간다selectionCacheKey는 "flight 클로저가 읽는 모든 입력의 전함수"라는 기존 계약이다.

e2e 함정 기록: 픽스처에 git remote add를 쓰면 base 해석의 첫 단계인 gh pr view가 그것을 GitHub 리포로 풀어 보려 하고, 그때부터 스펙 하나가 수십 초씩 걸린다(실측: ref-picker.e2e.ts 전체가 16초 → 15.5분). update-ref + symbolic-ref로 symref만 직접 세운다.

3. 전체 변경량 · 자동 base 전환

  • #change-totals — 파일 수만으로는 규모를 못 읽는다(176 file(s)가 한 줄씩인지 통째로 새로 쓴 것인지). 세는 값의 출처가 계약이다: hunk숫자 필드이지 FileDiffMetadata의 동명 string[] 필드가 아니다(그 착각이 대형 파일 자동 접힘을 오작동시킨 적이 있다). e2e가 스펙 안에서 git diff --numstat을 돌려 매번 대조한다.
  • 빈 워킹트리에서 자동으로 base 뷰를 연다 — 워크트리 워크플로에서는 작업이 브랜치에 커밋돼 있어 기본 뷰가 구조적으로 비어 있다. 불변식 셋: ① 사용자가 고른 적 있으면 안 덮는다, ② 토글로 감춰졌을 뿐 이 뷰에도 볼 것이 있으면(untracked) 안 데려간다, ③ 저장하지 않는다(추론이지 선택이 아니다 — 저장하면 ①이 이후 모든 자동 전환을 영구히 막는다). 셋 다 뮤테이션으로 판별력 확인.

4. 조작과 정보를 자리로 가른다 (마지막 커밋)

옮겨도 폭은 손해 보지 않는다(실측): 총 폭이 그대로라 트리거가 줄어들 몫도 그대로여서, 560px 창에서 트리거 160px · 넘침 54px로 옮기기 전과 완전히 같은 값이다.

오른쪽 순서 찾기 · 새로고침 │ Unified/Split · 트리 │ ⋯ 의 근거:

근거 종류
찾기가 맨 앞 — find 바가 그 자리에서 340px로 펼쳐지고 그룹은 오른쪽 끝에 고정이라, 바가 자라면 왼쪽 이웃만 밀린다. 찾기 우선이면 이동량 0px, 세그먼트를 앞에 두면 −285px 점프 실측
가운데 셋은 순간 동작 / 상태 토글로 갈랐다 — 118px 세그먼트가 두 무리 사이에 놓여 축의 경계와 시각적 경계가 겹친다 설계
트리 토글이 — 메뉴의 Hide file tree같은 토글이고, 메뉴 여섯 중 넷이 레이아웃 설계
가 마지막 — 메뉴는 툴바 아래로 떨어져 이웃을 가리지 않으므로 기계적 제약이 아니다 관례

CSS 사슬 한 마디가 새로 필요했다: .tb-picker { display: flex }. 블록이면 안쪽 inline-flex 버튼이 flex item이 아니라 줄어드는 몫을 못 받고 자연폭 그대로 넘쳐, 트리거가 아무리 좁아져도 라벨이 한 번도 잘리지 않는다(실측: 1280px 창에서 161자 브랜치인데 scrollWidth == clientWidth).

max-width는 두지 않는다 — 좁은 창에서 두 안의 수치가 완전히 같고(700/560/460px에서 221/81/0px) 161자 브랜치에서도 넘치지 않았다. 즉 상한은 오버플로 방어에 아무것도 안 보태면서 공간이 남는 넓은 창에서 세 조각을 잘라 이 표식의 존재 이유를 깎는다.

테스트 플랜

bun test              # 775 pass
bun run test:coverage # 100% 게이트 통과
bun run typecheck     # 통과
bun run lint          # 신규 에러 없음
bun run format:check  # 통과
bunx playwright test  # 107 passed (apps/viewer에서)

유닛 — 커버리지 게이트는 branch를 세지 않으므로 폴백 분기마다 따로 찔렀다.

파일 무엇을
repo-label.test.ts 35 후행 슬래시·루트·빈 경로, 하위 디렉토리 기동, 중첩 워크트리 최장 일치, detached, bare .git 접미, head 뷰의 워크트리 제거
ref-picker-model.test.ts 19 두 구역의 정렬(default → 지금 보는 것 → 받은 순서), 워크트리 구역 생략 조건, 필터
change-totals.test.ts 9 합산, 한쪽 0, 센 줄 없음
diff-refs.test.ts (parseRepoRoot) 4 bare가 핵심 — 필터 전 원본을 읽어야 한다는 계약
diff-selection / diff-command / diff-server / summary / empty-state +22 head 파싱·갈림점 기준·HTTP 400(unknown-head, 옵션 꼴 차단)·null 요약·shouldAutoViewBase

e2e — 배선은 커버리지 밖이라 여기가 유일한 그물이다.

파일 핵심
toolbar-repo.e2e.ts 8 링크된 워크트리 두 축, --watch 무-focus 수렴, 브랜치 head가 워크트리 이름을 버림, 넓은 창·좁은 창 레이아웃
ref-picker.e2e.ts 9 브랜치를 고르면 커밋된 작업을 본다, 워크트리를 고르면 이동, default 최상단, 키보드 조작
toolbar-totals.e2e.ts 3 git과 대조(하드코딩 없이 스펙 안에서 --numstat 합산)
toolbar-order.e2e.ts 3 두 그룹의 소속·순서, 바를 열어도 아무것도 안 움직임, 좁은 창에서 오른쪽 그룹 유지
empty-state.e2e.ts 5 자동 전환 + 무-저장, URL base=는 안 덮음

뮤테이션으로 판별력 확인한 것들

지운 것 죽는 것
poll()의 라벨 갱신 toolbar-repo ⑥만
.tb-picker { display: flex } toolbar-repo ⑧의 넓은 창·좁은 창 clipped 둘 다 (rightEdge는 통과 — 상자만 줄고 버튼이 그 위로 넘칠 뿐)
.tb-leftmin-width:0 / flex:none toolbar-repo ⑧의 좁은 창만
.tb-rightmargin-left: auto toolbar-order ②만 (①은 통과 — 순서는 그대로다. 즉 ②는 ①의 중복이 아니라 그 순서의 이유를 지킨다)
renderPatch의 합산 배선 toolbar-totals ①②
갈림점을 HEAD 기준으로 되돌림 diff-command 2종
자동 전환을 persist: true empty-state ④만

문서 의무 없음 — CLI 플래그를 추가하지 않으므로 docs-flags-parity.test.ts의 일곱 문서는 그대로다. CLAUDE.md에는 이 UI 계약들을 전부 기록했다.

🤖 Generated with Claude Code

https://claude.ai/code/session_013LwoJdwzqo3Ln6y8g5ivMM

뷰어는 repo를 URL 파라미터로만 받아서, 화면 어디에도 "지금 어느 워크트리의
무엇을 보고 있는가"가 없었다. 워크트리를 여럿 쓰는 리포에서 엉뚱한 워크트리를
열어도 알아챌 단서가 없고, 게다가 뒤처진 브랜치에서는 merge-base가 HEAD라
어떤 base를 골라도 결과가 같아 보여서 "피커를 바꿔도 화면이 안 변한다"를
고장으로 오인하게 된다. 정체성을 상시로 말해 두면 그 오진이 생기지 않는다.

서버 변경은 없다. /api/refs가 이미 워크트리별 {path, branch, head}를 주고
있어서 그대로 재사용한다.

- browser/repoLabel.ts (신규): DOM에 닿는 문자열 전량을 만드는 순수 모듈.
  main.ts는 커버리지 게이트 밖이라 거기 조립을 두면 유닛·커버리지·타입체크가
  전부 초록인 채로 버그가 산다(isLargeFile 사건과 같은 구조).
- findWorktree는 정확 일치가 아니라 세그먼트 경계 최장 접두 일치다. repo는
  기동 시점의 process.cwd()라 리포 루트라는 보장이 없고, 중첩 워크트리에서
  바깥이 이기면 라벨이 엉뚱한 워크트리를 말한다. 피커의 HEAD 태그 판정도
  같은 함수로 옮겨 "내가 어느 워크트리에 있는가"의 답을 하나로 만든다.
- detached는 emptyState.ts와 같은 어휘(detached @ <short>)를 쓴다.
- 갱신은 load()(부트스트랩·focus·refresh·토글)와 피커 열림에서만 한다.
  watch의 poll()은 fetchDiff만 부르고 load()를 거치지 않으므로 2초 폴링이
  git 서브프로세스를 상시로 늘리지 않는다. 신선도는 피커와 같다(5초 TTL).
- CSS는 새 값을 만들지 않는다: max-width 260px은 #ref-picker-btn과 같은 값,
  ellipsis 3속성은 #ref-picker-label과 같은 세트, 색은 --vd-fg/--vd-fg-muted
  2톤. 상자(배경·테두리·radius)는 주지 않는다 — 조작이 아니라 정보다.

회귀망: repo-label.test.ts 20종(경로·중첩·detached·비ASCII 폴백을 분기마다
따로 찌른다 — 커버리지 게이트는 branch를 세지 않는다) + toolbar-repo.e2e.ts
5종(배선, 공백 텍스트 노드, 런타임 브랜치 전환, 긴 이름의 레이아웃 상한 —
happy-dom에는 레이아웃이 없어 마지막 둘은 유닛이 원리적으로 못 잡는다).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LwoJdwzqo3Ln6y8g5ivMM
@say8425
say8425 force-pushed the feat/toolbar-repo-branch branch from cb9de56 to a40eb1f Compare August 29, 2026 17:11
다차원 리뷰(5관점 + 지적별 적대적 검증)에서 확인된 것들.

**중대: watch 모드에서 라벨이 무한정 낡는다.** 갱신을 load()와 피커 열림에만
달아 뒀는데, watch는 창을 안 보고 있을 때 쓰는 기능이라 focus가 발화하지
않는다. 그래서 diff만 2초마다 새 브랜치 것으로 갈리고 툴바·탭 제목은 옛
브랜치에 굳었다. 게다가 같은 화면의 빈 상태 카드는 /api/summary(캐시 없음)로
살아 있는 브랜치를 말하므로 한 화면이 두 브랜치를 동시에 주장했다 — 이 기능이
없애려던 오진을 새로 만드는 셈이다. poll()에서도 갱신한다. 비용은 /api/refs의
5초 TTL이 흡수한다(폴이 2초여도 git 호출은 5초에 두 번이 상한). "내용이 바뀐
폴에서만" 같은 조건은 못 쓴다 — 워킹트리가 깨끗한 채로 브랜치만 갈아타면
지문이 그대로라 304로 흘러 한 번도 안 돈다(그게 고쳐야 할 경우다).

**회귀: 좁은 창에서 툴바가 화면을 벗어난다.** flex item의 기본
min-width:auto가 라벨 폭만큼 툴바의 하드 최소폭을 늘려, 라벨 없이는 460px에서도
멀쩡하던 툴바가 560px에서 .tb-right를 화면 밖으로 밀어냈다(실측). 게다가
#status가 두 줄로 접혀 툴바 높이가 700px에서 43 → 49px로 뛰었다. `.tb-left {
min-width: 0 }` + `.tb-left > * { flex: none }`으로 줄어드는 몫을 라벨이
전담하게 한다 — 조작과 개수는 정보 라벨보다 우선한다.

**사실이 틀린 주석 둘.** ① 모듈 헤더가 typecheck를 분리 근거로 들었는데
`apps/viewer/tsconfig.json`의 include에 `browser/**`가 없어 새 모듈도 typecheck
밖이다(분리 근거는 커버리지 게이트뿐이다). ② `SHORT_OID_LENGTH = 7`이
`rev-parse --short`의 기본과 같다고 적었는데 그쪽은 core.abbrev(auto)를 따라
길어진다(실측: 3.5k 객체 7자, 37k 객체 8자). 카드와 한 글자 갈릴 수 있다는
사실과, 그럼에도 카드를 자르지 않는 이유(auto는 모호하지 않은 길이다)를 적었다.

**무력했던 테스트 계약.** toHaveText는 공백을 정규화해서 `name  · main`도
통과한다 — 공백 텍스트 노드 금지 계약은 textContent를 그대로 봐야 잡힌다.

회귀망 2종 추가 + 뮤테이션으로 판별력 확인: poll()의 갱신을 지우면 ⑤만,
min-width:0 또는 flex:none을 지우면 ⑥만 죽는다(나머지는 전부 통과).
반증된 지적도 있다 — findWorktree의 최장 일치는 기존 유닛이 이미 잡고(뮤테이션
확인), 좁은 창에서 ellipsis는 실제로 발동한다.

CLAUDE.md에 이 UI 계약을 기록한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LwoJdwzqo3Ln6y8g5ivMM
@say8425

say8425 commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

리뷰 라운드 결과 (fe22571)

5개 관점(정확성·배선·CSS/레이아웃·테스트 판별력·레포 관례)으로 리뷰하고 지적마다 반증을 시도해 검증했습니다. 확인된 것만 고쳤고, 반증된 것은 고치지 않았습니다.

고친 것

① 중대 — --watch에서 라벨이 무한정 낡는다
갱신을 load()와 피커 열림에만 달아 뒀는데, watch는 창을 안 보고 있을 때 쓰는 기능이라 focus가 발화하지 않습니다. diff만 2초마다 새 브랜치 것으로 갈리고 툴바·탭 제목은 옛 브랜치에 굳었습니다. 더 나쁜 건 같은 화면의 빈 상태 카드가 /api/summary(캐시 없음)로 살아 있는 브랜치를 말한다는 점 — 한 화면이 두 브랜치를 동시에 주장했고, 이 기능이 없애려던 오진을 새로 만드는 셈이었습니다. poll()에서도 갱신합니다. 비용은 /api/refs의 5초 TTL이 흡수합니다(폴이 2초여도 git 호출은 5초에 두 번이 상한). "내용이 바뀐 폴에서만" 같은 조건은 쓸 수 없습니다 — 워킹트리가 깨끗한 채로 브랜치만 갈아타면 지문이 그대로라 304로 흘러 한 번도 안 돕니다(그게 정확히 고쳐야 할 경우입니다).

② 회귀 — 좁은 창에서 툴바가 화면을 벗어난다

창 폭 base(라벨 없음) 수정 전 수정 후
900px ✓ h43 ✓ h43 ✓ h43
700px ✓ h43 h49 ✓ h43
560px ✓ h43 화면 밖 ✓ h43
460px ❌ 화면 밖 ✓ h43

flex item의 기본 min-width:auto가 라벨 폭만큼 툴바의 하드 최소폭을 늘려 .tb-right를 밀어냈고, #status가 두 줄로 접혀 높이도 뛰었습니다. .tb-left { min-width: 0 } + .tb-left > * { flex: none }으로 줄어드는 몫을 라벨이 전담하게 했습니다 — 조작과 개수는 정보 라벨보다 우선합니다.

③ 사실이 틀린 주석 둘

  • 모듈 헤더가 typecheck를 분리 근거로 들었는데, apps/viewer/tsconfig.json의 include에 browser/**가 없어 새 모듈도 typecheck 밖입니다(확인함). 분리 근거는 커버리지 게이트뿐입니다.
  • SHORT_OID_LENGTH = 7rev-parse --short의 기본과 같다고 적었는데 그쪽은 core.abbrev(auto)를 따라 길어집니다(실측: 3.5k 객체 리포 7자, 37k 객체 리포 8자). 카드와 한 글자 갈릴 수 있다는 사실과, 그럼에도 카드를 자르지 않는 이유(auto는 git이 고른 모호하지 않은 길이다)를 적었습니다.

④ 무력했던 테스트 계약
toHaveText는 공백을 정규화해서 name · main도 통과합니다 — "조각 사이 공백 텍스트 노드 금지"는 textContent를 그대로 봐야 잡힙니다.

반증한 것 (고치지 않음)

  • "findWorktree의 최장 일치에 판별력 있는 테스트가 없다" — 최초 일치로 퇴화시키면 기존 유닛이 죽습니다(뮤테이션 확인).
  • "창 폭 축소로는 ellipsis가 절대 발동하지 않는다" — 700px에서 실제로 발동합니다(clipped=true 실측).

판별력 검증

새 계약이 실제로 지켜지는지 뮤테이션으로 확인했습니다. 각 뮤테이션이 해당 스펙만 죽입니다:

뮤테이션 죽는 테스트
poll()refreshRepoLabel() 제거 ⑤ watch 무-focus 수렴 only
.tb-left { min-width: 0 } 제거 ⑥ 좁은 창 레이아웃 only
.tb-left > * { flex: none } 제거 ⑥ 좁은 창 레이아웃 only

검증

bun test 723 pass · coverage 100%(신규 모듈 100.00/100.00) · typecheck · lint · format · toolbar-repo.e2e.ts 6/6.

로컬 전체 e2e에서 worker-highlight가 빨간불인데 이 PR과 무관합니다 — 실패 지점이 #status"Loading…" 대기라 CLAUDE.md에 기록된 Bun $ never-settle 재현이고(그 스펙은 해당 3종 중 하나로 이미 문서화돼 있습니다), base 코드에서도 나옵니다. 부하가 큰 로컬 맥의 알려진 조건이라 retries로 가리지 않고 CI를 심판으로 둡니다.

CLAUDE.md에 이 UI 계약(갱신 시점 셋·CSS 짝 규칙·OID 길이 차이·findWorktree 규칙)을 기록했습니다.

say8425 and others added 2 commits August 30, 2026 16:26
라벨이 워크트리 디렉토리 이름 하나를 "리포 이름"인 양 말하고 있었다. 그래서
중첩 워크트리에서는 `feat+PAY-412 · feat/PAY-412-idempotency`처럼 떠서 어느
리포인지 알 수 없었고, 반대로 리포 이름만 말했다면 어느 워크트리인지 알 수
없었을 것이다. 둘은 서로 다른 축이라 둘 다 말한다:

  메인 워크트리   payments-api · main
  링크된 워크트리 payments-api / feat+PAY-412 · feat/PAY-412-idempotency

색은 **구별되는 조각 하나만** 밝힌다(#repo-name). 리포 이름은 그 리포의
워크트리마다 같아서 화면을 가르지 못하기 때문이고, 같은 이유로 탭 제목에는
리포 접두를 넣지 않는다(탭은 좁고 오른쪽부터 잘린다).

자리는 개수(#status) 바로 왼쪽이다 — 왼쪽 그룹은 조작(피커·뷰 모드·새로고침)이
먼저 오고 그 결과를 말하는 정보가 뒤에 온다.

**git 호출은 늘지 않는다.** parseRepoRoot가 `worktree list --porcelain` 원본의
첫 레코드를 읽는다. git은 메인 워크트리를 항상 먼저 내며 링크·중첩 워크트리에서
실행해도 그렇다(실측). parseWorktreeList의 결과를 대신 쓰면 안 된다 — 그쪽은
bare·prunable을 걸러내는데 bare 리포는 메인 항목이 바로 그 bare라 첫 항목이
링크된 워크트리로 밀리고, 그러면 라벨이 남의 워크트리를 리포 이름이라 말한다
(실측). bare 루트의 관례적 `.git` 접미는 벗긴다.

**max-width: 260px를 제거한다.** 실측해 보니 좁은 창에서 상한 유무의 수치가
완전히 같았고(700/560/460px에서 각각 221/81/0px) 161자 브랜치에서도 툴바가
넘치지 않았다 — 폭을 붙드는 기제는 flex shrink 하나뿐이고 상한은 오버플로
방어에 아무것도 보태지 않으면서, 세 조각의 자연폭 350px을 공간이 남는데도
260px로 잘라 "내가 어디 있는지 말한다"는 이 라벨의 존재 이유를 깎고 있었다.
e2e의 픽셀 상한 단언도 의미 있는 계약(잘림 + text-overflow 계산값)으로 바꿨다.

회귀망: repo-label.test.ts 26종 + diff-refs.test.ts의 parseRepoRoot 4종(bare가
핵심) + toolbar-repo.e2e.ts 7종(⑤ 링크된 워크트리 두 축과 배치 순서 추가).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
워크트리 워크플로에서는 작업이 브랜치에 **커밋**돼 있어서 기본 뷰(미커밋
변경)가 구조적으로 비어 있다. 즉 볼 게 가장 많은 순간에 빈 화면이 뜨고,
프리퍼런스 키가 리포 경로별이라 새 워크트리를 팔 때마다 카드의 버튼을 한
번씩 눌러 줘야 했다(실측: 미커밋 0건인데 main 대비 176 files인 워크트리).

판정을 새로 세우지 않고 **카드 자신의 액션을 읽는다** — 카드가 전환을 권할
상황이 곧 전환할 가치가 있는 상황이라 조건이 두 곳으로 갈라지지 않는다.

불변식 셋이 계약이다:

① 사용자가 고른 적 있으면 덮지 않는다(URL base= 또는 저장된 프리퍼런스).
   resolveCompareBase가 null을 주는 것이 "고른 적 없음"의 정의다. 모듈
   초기화 때 캐시하면 피커로 고른 뒤에도 옛 값이 남아 선택을 덮으므로
   호출 시점에 다시 읽는다.
② 토글로 감춰졌을 뿐 이 뷰에도 볼 것이 있으면(untracked) 데려가지 않는다.
   카드가 두 선택지를 나란히 보여주는 편이 낫다. 이 경계선이 없으면 기존
   카드 스펙이 죽는다(실측으로 확인).
③ 저장하지 않는다 — 추론이지 사용자의 선택이 아니다. 저장하면 고른 적 없는
   프리퍼런스가 생겨 불변식 ①이 이후 모든 자동 전환을 영구히 막는다.

그래서 applySelection(사용자 경로, 저장함)과 selectBase(next, {persist})가
갈라졌다. applySelection이 패널 닫기·포커스 복귀를 조기 반환보다 앞에 두는
순서는 그대로다 — 이미 고른 행을 눌러도 피커는 닫혀야 한다.

회귀망: shouldAutoViewBase 유닛 5종 + empty-state.e2e.ts 2종. 뮤테이션으로
판별력 확인 — untracked 경계선을 지우면 기존 카드 스펙만, persist:true로
바꾸면 새 스펙만 죽는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@say8425

say8425 commented Aug 30, 2026

Copy link
Copy Markdown
Owner Author

빈 워킹트리에서의 자동 base 전환 (8bbe009)

로컬 테스트 중 발견한 문제를 함께 고쳤습니다. 워크트리를 열었더니 Working tree clean만 뜨는 현상인데, 조사해 보니 데이터 버그가 아니라 기본값 문제였습니다.

git status --porcelain   → 0줄      (미커밋 변경 없음)
git diff main...HEAD     → 176 files (커밋된 작업)

기본 뷰가 "미커밋 변경"이라 정말로 비어 있습니다. 그런데 워크트리 워크플로에서는 작업이 브랜치에 커밋돼 있으니 이 상태가 구조적입니다 — 볼 게 가장 많은 순간에 빈 화면이 뜨고, 프리퍼런스 키가 리포 경로별이라 새 워크트리를 팔 때마다 카드의 버튼을 한 번씩 눌러야 했습니다.

이제 고른 적이 없으면 볼 것이 있는 쪽을 엽니다. 판정을 새로 세우지 않고 카드 자신의 액션을 읽습니다(model.actionsswitch-mode 유무) — 카드가 전환을 권할 상황이 곧 전환할 가치가 있는 상황이라 조건이 두 곳으로 갈라지지 않습니다.

불변식 셋이 계약

① 사용자의 선택은 절대 덮지 않는다. URL base=든 저장된 프리퍼런스든. resolveCompareBasenull을 주는 것이 "고른 적 없음"의 정의입니다. 모듈 초기화 때 캐시하면 피커로 고른 뒤에도 옛 값이 남아 선택을 덮으므로 호출 시점에 다시 읽습니다.

② 토글로 감춰졌을 뿐 이 뷰에도 볼 것이 있으면(untracked) 데려가지 않습니다. 카드가 두 선택지를 나란히 보여주는 편이 낫고, 이 경계선이 없으면 기존 카드 스펙이 죽습니다.

③ 저장하지 않는다. 추론이지 사용자의 선택이 아닙니다. 저장하면 고른 적 없는 프리퍼런스가 생겨 불변식 ①이 이후 모든 자동 전환을 영구히 막습니다.

그래서 applySelection(사용자 경로, 저장함)과 selectBase(next, {persist})(공통 코어)가 갈라졌습니다. applySelection이 패널 닫기·포커스 복귀를 조기 반환보다 앞에 두는 순서는 그대로 보존했습니다 — 이미 고른 행을 눌러도 피커는 닫혀야 하니까요.

판별력 (뮤테이션)

뮤테이션 죽는 스펙
untracked 경계선(②) 제거 기존 카드 스펙 only
persist: falsetrue (③ 위반) 새 자동 전환 스펙 only

검증

bun test 738 pass · coverage 100%(emptyState.ts 포함) · typecheck · lint · format · empty-state.e2e.ts 5/5(기존 3 + 신규 2).

기존 3종이 그대로 통과하는 게 핵심입니다 — 불변식 ②가 없었으면 첫 번째 스펙이 깨졌을 겁니다.

실측 리포의 브랜치가 679개라, 목록에서 "나는 어디 있나"를 확인하려면 훑거나
검색어를 쳐야 했다. 맨 위 자리를 그 답에 준다.

올리는 근거는 태그가 아니라 **위치**다. 자기 브랜치가 기본 브랜치이기도 하면
toRow의 태그는 default로 남지만(그 삼항의 우선순위는 그대로) 지금 있는 곳이라는
사실은 변하지 않으므로 여전히 올린다.

올림은 로컬 안에서만 한다 — 체크아웃된 브랜치는 언제나 로컬이고(원격 레코드
이름은 origin/main 꼴이라 짧은 현재 브랜치명과 애초에 안 맞는다) 로컬이
원격보다 앞이므로 그것으로 구역 맨 위가 된다.

**순서는 유닛이 정하지만 배선은 못 잡는다.** main.ts가 현재 브랜치를 넘기지
않으면 유닛 전부가 통과한 채 화면만 조용히 옛 순서로 돌아간다. 그래서 e2e를
짝으로 뒀고, 뮤테이션으로 확인했다 — current를 null로 바꾸면 그 e2e 하나만
죽고 나머지 7종은 통과한다.

회귀망: ref-picker-model.test.ts 4종(올림·기본브랜치 겸용·로컬우선 유지·
detached 무변화) + ref-picker.e2e.ts 1종.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@say8425

say8425 commented Aug 30, 2026

Copy link
Copy Markdown
Owner Author

피커 목록 맨 위를 지금 체크아웃한 브랜치에 (1a16899)

실측 리포의 브랜치가 679개라, 목록에서 "나는 어디 있나"를 확인하려면 훑거나 검색어를 쳐야 했습니다. 맨 위 자리를 그 답에 줍니다.

picker.png

UNCOMMITTED
  Working tree                       nothing yet
COMPARE WITH A BRANCH
  feature/FE-25807-common-comment    HEAD     ← 올라옴
  FE-20527-review-i18n
  chore/MEC-294-revive-ui-contents-storybook
  …

설계 결정 셋

올리는 근거는 태그가 아니라 위치다. 자기 브랜치가 기본 브랜치이기도 하면 toRow의 태그는 default로 남지만(그 삼항의 우선순위는 그대로 뒀습니다) 지금 있는 곳이라는 사실은 변하지 않으므로 여전히 올립니다.

올림은 로컬 안에서만. 체크아웃된 브랜치는 언제나 로컬이고(원격 레코드 이름은 origin/main 꼴이라 짧은 현재 브랜치명과 애초에 안 맞습니다), 로컬이 원격보다 앞이므로 그것으로 구역 맨 위가 됩니다.

순서는 유닛이 정하지만 배선은 못 잡는다. main.ts가 현재 브랜치를 넘기지 않으면 유닛 전부가 통과한 채 화면만 조용히 옛 순서로 돌아갑니다. 그래서 e2e를 짝으로 뒀고 뮤테이션으로 확인했습니다 — currentnull로 바꾸면 그 e2e 하나만 죽고 나머지 7종은 통과합니다.

회귀망

ref-picker-model.test.ts 4종(올림 · 기본브랜치 겸용 · 로컬우선 유지 · detached 무변화) + ref-picker.e2e.ts 1종.

기존 순서 의존 스펙 2곳은 그대로 통과합니다 — 필터 후 단일 행을 보거나(filters the list as you type), allTextContents()로 위치를 동적으로 찾기(moves with the arrow keys) 때문입니다.

검증

bun test 742 pass · coverage 100%(refPicker/model.ts 포함) · typecheck · lint · format · ref-picker.e2e.ts 8/8.

say8425 and others added 11 commits August 31, 2026 11:17
파일 수만으로는 규모를 못 읽는다 — 176 file(s)가 한 줄씩 고친 것인지 통째로
새로 쓴 것인지 열기 전에는 갈리지 않는다.

**세는 값의 출처가 계약이다.** hunk의 **숫자** 필드이고, largeFile.ts의
countChangedLines와 같은 곳이다. FileDiffMetadata에도 동명 필드가 있지만
그건 string[]이고 파일 전량으로 파싱한 diff에서는 새/옛 파일의 전체 내용이라,
세면 변경량이 아니라 파일 길이가 나온다(그 착각이 실제로 대형 파일 자동
접힘을 오작동시킨 적이 있다).

합산은 renderPatch가 이미 전량 파싱해 둔 items에서 하므로 git 호출도 파싱도
늘지 않는다.

**git과 수치가 같다.** 실측: 176파일 워크트리에서 `git diff --numstat
<merge-base>` 합이 +17105 -435이고 화면도 같다(바이너리 8개는 양쪽 다 줄
수에서 빠진다). e2e가 그 대조를 매번 한다 — 기대값을 하드코딩하지 않고
스펙 안에서 git을 돌려 합산해 비교하므로, 클라이언트 재-diff가 git과 갈리면
바로 빨간불이 난다.

한쪽이 0이어도 둘 다 말한다(git·GitHub과 같다 — 자리가 사라지면 남은 숫자가
추가인지 삭제인지 색에만 의존하게 된다). 센 줄이 하나도 없으면(이미지만
바뀐 diff) 자리를 통째로 비운다.

갱신은 renderPatch 한 곳뿐이다 — 로딩·실패 중에는 손대지 않는데, 그때 화면에는
직전 diff가 그대로 남아 있으므로 숫자도 그것이 맞기 때문이다.

색은 앱에 이미 있는 add·del 쌍을 쓴다(--vd-success / #f85149). 엔진의
--diffs-addition-base는 shadow root 안에만 살아서 툴바에서는 해석되지 않는다.

회귀망: change-totals.test.ts 9종 + toolbar-totals.e2e.ts 3종. 뮤테이션으로
판별력 확인 — renderPatch의 합산 배선을 지우면 앞의 둘이 죽는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
지금까지 diff의 오른쪽(new)은 언제나 워킹트리였다. 피커가 head를 고르게
하려면 그 축이 열려야 한다. base와 **독립**인 축으로 더한다.

wire는 `head=<ref>`이고 없거나 비면 워킹트리다. **base와 달리 head=HEAD는
정규화하지 않는다** — base=HEAD는 "커밋 안 한 것만"이라 워킹트리 뷰와 결과가
같지만, head=HEAD는 커밋된 HEAD를 보는 것이라 미커밋 변경이 빠진다. 둘을
합치면 그 구분이 사라진다.

**갈림점은 head 기준이어야 한다.** merge-base(ref, HEAD)로 재면 브랜치를
head로 볼 때 지금 워크트리의 HEAD와 갈림점을 잡는데, 그 둘은 아무 관계도
없다 — 남의 브랜치를 보면서 내 위치를 기준 삼는 셈이다. 그러면 base 브랜치가
그 사이 만든 파일이 "head에서 삭제됨"으로 끼어든다. 뮤테이션으로 확인했다:
HEAD 기준으로 되돌리면 diff-command의 새 테스트 2종이 죽는다.

**커밋된 rev에는 untracked가 없다** — 토글이 켜져 있어도 건너뛴다. 디스크를
훑어 봐야 그건 워킹트리의 사실이지 이 뷰의 사실이 아니다.

**head도 base와 같은 보안 경계를 탄다.** verifyBaseRef가 첫 글자 `-`를 먼저
끊는 이유(옵션 주입)는 어느 축이든 유효하고, 이제 그 값이 git diff의 두 번째
인자로도 간다. 표식은 x-diff-error: unknown-head로 base와 가른다 —
클라이언트의 자가복구가 다르기 때문이다(base는 저장된 프리퍼런스를 지우면
되지만 head는 URL의 축을 되돌려야 한다).

캐시 키와 지문에도 head가 들어간다. 키는 "flight 클로저가 읽는 모든 입력의
전함수"라는 기존 계약이고, 빠지면 워킹트리 뷰와 브랜치 뷰가 같은 슬롯에
합류해 한쪽이 남의 diff를 받는다. 지문에는 head rev를 넣어 브랜치가 움직이면
캐시가 깨지게 한다.

이미지 blob도 같은 축을 탄다 — 예전에 라우트마다 기준이 갈려 이미지 카드가
텍스트와 다른 비교를 보여준 적이 있다.

회귀망: diff-selection.test.ts 7종 + diff-command.test.ts 4종(갈림점 기준이
핵심) + diff-server.test.ts 3종(HTTP 200 경로 · unknown-head 400 · 옵션 꼴
head가 git에 닿지 않음).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
브랜치를 head로 보고 있으면 카드가 워크트리를 설명하면 안 된다.

**재지 않은 것을 0으로 적지 않는다.** workingFiles·untrackedFiles가
`number | null`이 되고, head가 커밋된 리비전이면 null이다. 0으로 적으면
"아무것도 없다"는 **주장**이 되어, 실제로는 미커밋 변경이 있는데도 카드가
"Nothing to show in any mode"라고 말한다. 이 파일이 이미 세운 "측정한 것만
주장한다" 원칙을 그대로 따른다.

branch는 보고 있는 그 브랜치를 말한다 — 워크트리의 브랜치를 말하면 보고
있지도 않은 곳을 가리킨다. baseFiles·aheadCommits도 diff와 같은 축(head
기준 merge-base)으로 센다.

/api/summary도 head를 받는다. 카드와 화면이 다른 비교를 설명하는 것은
parseSelection을 한 곳으로 모은 이유 그 자체다.

회귀망: summary.test.ts 3종(워킹트리 무주장 · 보고 있는 브랜치 이름 ·
head 기준 개수).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
목록에 뜬 브랜치 이름이 "그 브랜치를 보여줘"로 읽히는데 실제로는 반대 축을
건드리고 있었다. 메인 워크트리에서 feature 브랜치를 골라도 1 file만 나오던
것이 그 결과다 — 같은 브랜치를 워크트리로 열면 176이었다.

**두 구역은 고르면 일어나는 일이 다르다.** 워크트리는 다른 리포 경로라 그
URL로 이동하고(repo가 바뀌므로 diff·트리·라벨·프리퍼런스 키가 전부 갈린다),
브랜치는 같은 워크트리 안의 축이라 head만 바꾼다. 옮기면서 head를 들고 가지
않는 이유는 그것이 워크트리에 매인 값이 아니어서다 — 그대로 두면 새
워크트리에서 남의 브랜치를 보게 된다.

**head는 저장하지 않는다.** URL이 진실이라야 새로고침·링크가 재현되고,
저장하면 다음에 그 리포를 열 때 남의 브랜치 뷰에 갇힌 채 시작한다.

순서는 두 구역 모두 default → 지금 보고 있는 것 → 받은 순서다. 올리는 근거는
태그가 아니라 위치다.

**워크트리 구역은 고를 것이 없으면 사라지되, head가 브랜치면 남긴다** —
제목만 남기고 비우면 "뭔가 있어야 하는데 없다"로 읽히지만, 워크트리가
하나뿐인 리포에서 무조건 숨기면 브랜치 뷰에 갇혀 돌아올 길이 없어진다.

행의 파일 개수는 뺐다. 그 수치는 base 축의 것이라 head를 고르는 지금은 행의
뜻과 맞지 않고, getRepoSummary가 의도적으로 single-flight 밖이라 목록이 그
속도에 묶여 있었다.

빈 상태 스펙 넷을 새 계약으로 옮겼다 — 피커 라벨은 이제 head를 말하므로
base의 증거가 아니고, 관찰 가능한 신호(카드 유무·개수·컨텍스트 줄)로 바꿨다.
untracked 재계산 회귀 가드는 head를 바꾸기 **전에** 확인하도록 순서를
조정했다: 커밋된 rev를 보고 있으면 untracked는 재지 않은 값이라 그 안내가
원리적으로 없다.

**e2e 함정 기록**: 픽스처에 `git remote add`를 쓰면 base 해석의 첫 단계인
`gh pr view`가 그것을 GitHub 리포로 풀어 보려 해서 스펙 하나가 수십 초씩
걸린다(실측: 이 파일 전체가 16초 → 15.5분). default 브랜치가 필요하면
update-ref + symbolic-ref로 symref만 직접 세운다.

회귀망: ref-picker-model.test.ts 19종 + ref-picker.e2e.ts 9종.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
브랜치를 head로 골랐을 때 라벨이 **워크트리의** 브랜치를 말하고 있었다.
화면에는 A의 diff가 떠 있는데 라벨과 탭 제목은 B라고 했다 — 오늘 내내 고쳐
온 "화면이 자기가 뭘 보고 있는지 잘못 말하는" 문제가 새 형태로 되살아난
것이다. 실측으로 확인했다:

  라벨 : engagement-frontend / docs+MEC-230-… · docs/MEC-230-…   ← 틀림
  화면 : feature/FE-25807-common-comment 의 176 files

셋을 함께 고친다.

① **브랜치 뷰에서는 워크트리 이름을 뺀다.** 브랜치를 head로 보면 워크트리는
   결과에 영향을 주지 않는다 — 어느 워크트리에서 보든 같은 diff다(실측:
   워크트리 셋에서 전부 176). 이름을 두면 "이 워크트리의 무언가를 보고 있다"는
   잘못된 인상을 준다. 주인공 자리는 보고 있는 브랜치가 갖는다.

② **base를 표시한다.** `· vs main`. 견줄 기준이 워킹트리(HEAD)면 말하지
   않는다 — 그건 "커밋 안 한 변경"이지 무엇과 견준 결과가 아니다.

③ **브랜치를 고르면 base를 자동 해석으로 올린다.** 커밋된 rev에는 미커밋
   변경이 없어 워킹트리 기준과의 조합은 언제나 빈 diff다. 자동 base 전환이
   결국 구제하지만 그 전에 빈 화면이 한 번 깜박였다. URL에는 싣지 않는다 —
   추론이지 사용자의 선택이 아니고, 새로고침하면 그 전환이 같은 상태를 만든다.

라벨은 `syncPickerLabel`에서 함께 다시 그린다. head·base가 바뀌는 지점이 곧
그 함수를 부르는 지점이라 둘이 갈라져 한쪽만 낡는 일이 생기지 않는다.

  워크트리 뷰 : engagement-frontend / docs+MEC-230-… · docs/MEC-230-… · vs main
  브랜치 뷰   : engagement-frontend · feature/FE-25807-common-comment · vs main

회귀망: repo-label.test.ts 7종 추가(33종) + toolbar-repo.e2e.ts 1종 추가(8종).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
피커가 base가 아니라 head를 고르게 된 순간, 트리거가 되풀이하던 선택
항목과 개수 왼쪽 라벨이 같은 말을 하게 됐다. 고르는 자리와 지금 무엇을
보는지 말하는 자리를 하나로 합쳐 둘이 어긋날 수 없게 하고, 남은 자리는
트리거가 결코 말하지 않는 축(견줄 기준)만 가진다.

- 트리거가 리포·워크트리·브랜치를 통째로 말한다
  (#ref-picker-label = #picker-scope + #picker-name + #picker-branch)
- 개수 왼쪽은 #base-label — "vs main" 하나뿐이다
- 브랜치를 head로 보면 워크트리 조각이 빠지고 head가 주인공 자리를
  가진다. 커밋된 rev의 diff는 어느 워크트리에서 보든 같으므로 워크트리
  이름은 거짓 인상을 주고, 그 워크트리의 브랜치는 보고 있지도 않은 곳을
  가리킨다.

폭을 붙드는 사슬에 `.tb-picker { display: flex }`가 새로 필요하다 —
블록이면 안쪽 inline-flex 버튼이 flex item이 아니라 줄어드는 몫을 못
받고 자연폭 그대로 넘쳐, 트리거가 아무리 좁아져도 라벨이 한 번도 잘리지
않는다(실측: 1280px 창에서 161자 브랜치인데 scrollWidth == clientWidth).
뮤테이션으로 확인했다 — 지우면 e2e ⑧의 넓은 창·좁은 창 clipped 단언이
둘 다 죽는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LwoJdwzqo3Ln6y8g5ivMM
왼쪽 그룹은 한 문장으로 읽혀야 한다 — 무엇을 보는가 · 무엇과 견주는가 ·
몇 파일인가 · 몇 줄인가. 그런데 Unified/Split과 새로고침이 첫 마디와
둘째 마디 사이에 앉아 그 문장을 두 동강 냈다. 오른쪽 그룹은 이미 순수
조작이었으므로, 조작이 두 곳에 흩어지고 정보가 그 사이에 끼는 배치였다.

옮겨도 폭은 손해 보지 않는다(실측): 총 폭이 그대로라 트리거가 줄어들
몫도 그대로여서, 560px 창에서 트리거 160px · 넘침 54px로 옮기기 전과
완전히 같은 값이다.

오른쪽 순서 `찾기 · 새로고침 │ Unified/Split · 트리 │ ⋯`의 근거:

- 찾기가 맨 앞인 것은 실측이다. find 바는 돋보기 자리에서 340px로
  펼쳐지는데 이 그룹은 오른쪽 끝에 고정돼 있어 바가 자라면 그 왼쪽
  이웃만 밀린다. 찾기 우선이면 이동량 0px, 세그먼트를 앞에 두면 −285px.
- 가운데 셋은 순간 동작(찾기·새로고침) / 상태 토글(렌더 방식·트리)로
  갈랐다. 118px 세그먼트가 두 무리 사이에 놓여 축의 경계와 시각적
  경계가 겹친다.
- 트리 토글이 ⋯ 옆인 것은 메뉴의 Hide file tree가 같은 토글이고, 메뉴
  여섯 중 넷이 레이아웃이기 때문이다.

회귀망 toolbar-order.e2e.ts 3종. ②(바를 열어도 아무것도 안 움직인다)가
①(순서)의 중복이 아님을 뮤테이션으로 확인했다 — margin-left:auto를
고정 마진으로 바꾸면 ①은 통과하고 ②만 죽는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LwoJdwzqo3Ln6y8g5ivMM
renderPatch의 빈 분기가 "No changes."를 동기로 그려 놓고, 요약이 도착하면
정보형 카드로 덮었다. 그 사이가 실측 60~80ms라 사용자에게는 없다고 한 번
말한 뒤 말을 바꾸는 것으로 보인다 — 첫 로드 673ms "No changes." → 753ms
"No tracked changes …", head를 고르면 282ms → 340ms.

자동 base 전환이 걸리는 경우가 가장 나쁘다: 볼 것이 있는데도 없다고 말한
뒤 diff가 뜬다.

이제 자리만 잡고(LOADING_MARKUP — 첫 로드 표시와 같은 마크업이라 로딩에서
로딩으로 넘어가는 자리가 깜박이지 않는다) 문구는 enrichEmptyState가 한 번만
쓴다. "No changes."는 폴백이 되어 요약 fetch가 실패했을 때만 나오고, 그때
data-loading을 함께 걷어낸다.

회귀망 empty-state.e2e.ts 3종. 이 종류는 최종 상태로는 원리적으로 안
보이므로(끝나면 옳은 카드가 떠 있다) addInitScript로 첫 페인트 전에
MutationObserver를 걸어 #empty가 거쳐 간 문구를 전부 기록한다. 뮤테이션으로
판별력 확인 — 옛 동기 문구를 되돌리면 ⑥⑦만, 폴백 write를 지우면 ⑧만 죽는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LwoJdwzqo3Ln6y8g5ivMM
**Critical — `git diff`에 `--`가 없었다.** 참조 이름이 트래킹된 경로와
겹치면(`docs`·`src`·`test`처럼 흔하다) git이 rev인지 path인지 못 정해
`ambiguous argument`로 죽는데, `2>/dev/null` + `.nothrow()`가 그 실패를 빈
문자열로 삼켜 **에러 없는 "변경 없음" 화면**이 된다(실측). 피커가
`%(refname:short)`를 그대로 넘기므로 목록에서 고르기만 해도 도달한다.
head 축이 사용자 ref 이름을 `git diff`의 위치 인자로 처음 통과시키며 연
노출이다 — 예전엔 언제나 HEAD 아니면 merge-base OID였다. 다른 git
명령(merge-base·rev-list·rev-parse·show)은 rev만 받아 영향 없음을 실측 확인.

**부트스트랩 TDZ.** applyRepoLabel([], null)이 compareBase·currentHead
선언보다 앞에서 돌았다. 살아 있던 이유는 번들러뿐이다 — bun build가 최상위
let을 var로 낮춰 TDZ가 아니라 undefined가 됐다(실측: 같은 코드를 ESM 그대로
평가하면 ReferenceError로 모듈이 죽는다). 게다가 undefined는 null이 아니라
head 분기를 통과해 #base-label에 "vs undefined"를 썼다. 뒤의 호출이 덮어써서
페인트만 안 됐다. 그 호출 하나로 합쳤다.

**재지 않은 것에 대한 주장.** emptyState가 untrackedFiles를 `?? 0`으로
접어, head가 커밋된 rev라 워킹트리를 재지도 않은 화면이 "Working tree
clean"이라고 그 워킹트리에 대해 단언했다 — null을 도입한 이유 그 자체다.

**grab 참조가 head를 말하지 않았다.** 브랜치 head 뷰에서 잡은 줄은 그
브랜치의 커밋된 내용인데, 붙여넣기를 받은 에이전트는 자기 워킹트리의 같은
경로를 연다. `on <head>`를 싣는다.

**CSS 계약 두 가지를 사실에 맞춘다.** width 사슬은 넷이 아니라 다섯이고
(#ref-picker-btn의 min-width:0이 display:flex와 한 짝), .tb-picker는
특이도가 아니라 소스 순서로만 이기고 있었다(주석은 "id 규칙이 이긴다"고
적었다) — #toolbar로 스코프해 순서 의존을 없앴다.

테스트 공백도 메웠다: 지문의 head rev, /api/blob·/api/summary의
unknown-head 400, blob의 head 리비전 읽기. 넷 다 라인 커버리지로는 초록인
채 한 번도 안 나가던 분기다(게이트는 branch를 안 센다).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LwoJdwzqo3Ln6y8g5ivMM
**vacuous 테스트 둘을 실제 가드로 바꿨다.**
- toolbar-totals ③: clean 리포로 띄우면 첫 렌더가 곧 빈 분기라 두 span이
  마크업 상태 그대로여서, 빈 분기의 applyChangeTotals([]) 배선을 통째로
  지워도 초록이었다. 숫자가 쓰인 뒤 사라지는 **전이**를 본다(뮤테이션으로
  확인 — 클리어를 지우면 죽는다).
- ref-picker ⑦: 워킹트리 뷰에서 시작하니 URL에 head가 애초에 없어서,
  워크트리 이동이 head를 실어 날라도 통과했다. 먼저 head를 세운 뒤 고른다
  (뮤테이션 확인 — delete("head")를 지우면 라벨이 옛 브랜치를 말하며 죽는다).
- head를 저장하지 않는다는 계약에도 단언이 없었다 — localStorage 검사 추가.

**피커 첫 오픈이 거짓말을 했다.** head 모델에는 목록 없이 세울 수 있는 행이
하나도 없어(예전 base 모델의 "Working tree 행은 항상 있다"는 전제가 깨졌다)
첫 오픈이 정말로 빈 배열인데 "No match"를 썼다 — "이 리포엔 고를 게 없다"로
읽힌다. pickerLoaded로 가른다.

**피커 필터가 note를 안 봤다.** 워크트리 행의 브랜치 이름이 거기 사는데
label만 봐서, 브랜치명을 타이핑하면 그걸 물고 있는 워크트리가 안 잡혔다.

**listbox에 role 없는 자식이 섞였다.** 구역 제목·구분선·빈 문구가 직접
자식이라 보조기술이 옵션 수를 잘못 세거나 제목을 옵션처럼 읽는다.

**enrichEmptyState 스냅샷에 head가 빠졌다.** base가 이미 @auto면 head만
갈아타도 mode가 안 바뀌어, marker 가드 이전 창에서 옛 head의 카드가 그려질
수 있었다.

aria-label "View"는 동사로 읽혀 "What to view"로. CLAUDE.md의 회귀망 개수도
실제 값으로 맞췄다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LwoJdwzqo3Ln6y8g5ivMM
**`?head=<branch>`가 그냥 빈 화면이었다.** base=HEAD(= mode=working, = base
미지정)는 "아직 커밋 안 한 것만"이라는 뜻인데 커밋된 rev에는 그런 게 없어서
`git diff <rev> <rev>`가 되어 에러 없이 0건이 나온다. 가장 자연스러운 수동·
공유 URL이 아무것도 안 보여줬고, 카드는 "No changes on main"이라 말하는데
실제로는 그 브랜치에 31파일이 있었다(실측). 자동 base 전환도 구제하지 못한다
— mode가 working이면 getRepoSummary가 base 개수를 아예 재지 않아 카드에
switch-mode 액션이 안 생기기 때문이다.

이 규칙은 원래 브라우저 한 줄(applyPick의 "HEAD" → "@auto")에만 살아서 URL을
손으로 만들면 우회됐다. parseSelection으로 올려 한 곳으로 만들었고, 화면도
같은 규칙을 써야 하므로 baseDisplay()가 head가 있을 때 HEAD를 auto처럼
표시한다 — 안 그러면 서버는 main과 견주는데 툴바는 아무 말도 안 한다.
사용자가 고른 진짜 base(base=develop)는 그대로 둔다.

**사라진 head가 막다른 화면이었다.** 머지 후 삭제된 브랜치를 가리키는 링크는
"Failed to load diff."만 남기고 새로고침해도 같아서 스스로 못 빠져나온다.
base처럼 조용히 되돌리지는 않는다 — base의 스테일 값은 사용자가 볼 수 없는
localStorage에 살지만 head는 링크가 요청한 것이라, 말없이 다른 화면을
보여주면 사용자가 속는다. 무엇이 없는지 말하고 나갈 길 하나를 준다.

회귀망: diff-selection 3종(세 URL 꼴·진짜 base 보존·워킹트리 뷰 불변) +
ref-picker.e2e ⑩(카드 문구·URL 불변·버튼이 head를 떨군다).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LwoJdwzqo3Ln6y8g5ivMM
@say8425
say8425 merged commit 1dfa736 into main Sep 4, 2026
5 checks passed
@say8425
say8425 deleted the feat/toolbar-repo-branch branch September 4, 2026 05:23
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