Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
a40eb1f
feat: 툴바에 지금 보고 있는 워크트리와 브랜치를 표시한다
say8425 Aug 29, 2026
fe22571
fix: 리뷰 지적을 반영한다 — watch 갱신 누락과 좁은 창 레이아웃 회귀
say8425 Aug 29, 2026
9e878e8
feat: 툴바 표식에 워크트리 축을 더하고 개수 왼쪽으로 옮긴다
say8425 Aug 30, 2026
8bbe009
feat: 워킹트리가 텅 비었으면 볼 것이 있는 base 뷰를 연다
say8425 Aug 30, 2026
1a16899
feat: 피커 목록 맨 위를 지금 체크아웃한 브랜치에 준다
say8425 Aug 30, 2026
f6ea0f5
feat: 개수 오른쪽에 전체 변경량을 더한다
say8425 Aug 31, 2026
2a60d94
feat: 서버에 head 축을 연다 — 리비전 대 리비전 diff
say8425 Aug 31, 2026
f6bbd1b
feat: 빈 상태 카드가 head 축을 안다
say8425 Aug 31, 2026
6f44250
feat: 피커가 base가 아니라 head를 고른다
say8425 Aug 31, 2026
1db9957
fix: 라벨이 보고 있는 것을 말하게 하고 base를 표시한다
say8425 Aug 31, 2026
7941820
refactor: 트리거가 곧 표식이 되게 하고 중복 라벨을 걷어낸다
say8425 Aug 31, 2026
fb8b377
refactor: 조작을 오른쪽으로 모아 정보 사슬을 잇는다
say8425 Aug 31, 2026
ec4e6bd
fix: 빈 상태가 "없다"고 말한 뒤 말을 바꾸지 않게 한다
say8425 Sep 1, 2026
1f04278
fix: 코드 리뷰 지적을 반영한다 — 모호한 ref, TDZ, 재지 않은 것에 대한 주장
say8425 Sep 1, 2026
ed184b6
fix: 리뷰 2차 — 공허한 단언을 판별력 있게 바꾸고 첫 오픈의 거짓말을 없앤다
say8425 Sep 1, 2026
80ef43c
fix: 의미 없는 base·head 조합과 사라진 head의 막다른 화면
say8425 Sep 1, 2026
File filter

Filter by extension

Filter by extension

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

Large diffs are not rendered by default.

83 changes: 83 additions & 0 deletions apps/viewer/__tests__/change-totals.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, test } from "bun:test";
import { changeTotalsView, sumChangeTotals } from "../browser/changeTotals.ts";

const file = (additionLines: number, deletionLines: number) => ({
hunks: [{ additionLines, deletionLines }],
});

describe("sumChangeTotals", () => {
test("한 파일의 hunk를 합한다", () => {
expect(
sumChangeTotals([
{
hunks: [
{ additionLines: 3, deletionLines: 1 },
{ additionLines: 4, deletionLines: 0 },
],
},
]),
).toEqual({ additions: 7, deletions: 1 });
});

test("여러 파일을 가로질러 합한다", () => {
expect(sumChangeTotals([file(10, 2), file(5, 3), file(1, 0)])).toEqual({
additions: 16,
deletions: 5,
});
});

// 이미지·바이너리는 hunk가 없다. 0을 보태고 조용히 지나가야 한다 —
// 여기서 터지면 이미지가 하나 섞인 diff마다 툴바가 죽는다.
test("hunk가 없는 파일은 0을 보탠다", () => {
expect(sumChangeTotals([file(4, 1), { hunks: [] }])).toEqual({
additions: 4,
deletions: 1,
});
});

test("파일이 없으면 0", () => {
expect(sumChangeTotals([])).toEqual({ additions: 0, deletions: 0 });
});
});

describe("changeTotalsView", () => {
test("추가와 삭제를 각자의 자리에 넣는다", () => {
const v = changeTotalsView([file(17022, 435)]);
expect(v.additions).toBe("+17022");
// 구분자는 삭제 쪽이 품고 온다 — 조각 사이에 공백 텍스트 노드를
// 만들지 않으려는 것으로, #picker-branch와 같은 관례다.
expect(v.deletions).toBe(" -435");
});

// git·GitHub이 그렇듯 한쪽이 0이어도 둘 다 말한다 — "삭제가 없었다"는
// 것도 정보이고, 자리가 사라지면 옆 숫자가 무엇인지 흔들린다.
test("한쪽이 0이어도 둘 다 말한다", () => {
expect(changeTotalsView([file(5, 0)])).toEqual({
additions: "+5",
deletions: " -0",
});
expect(changeTotalsView([file(0, 9)])).toEqual({
additions: "+0",
deletions: " -9",
});
});

// 이미지만 바뀐 diff. 파일은 있는데 센 줄이 없으므로 `+0 -0`을 쓰면
// 아무 말도 아닌 숫자가 툴바를 차지한다.
test("센 줄이 하나도 없으면 아무 말도 하지 않는다", () => {
expect(changeTotalsView([{ hunks: [] }])).toEqual({
additions: "",
deletions: "",
});
});

test("파일 목록이 비면 아무 말도 하지 않는다", () => {
expect(changeTotalsView([])).toEqual({ additions: "", deletions: "" });
});

// 자릿수 구분자를 넣지 않는다 — #status의 "176 file(s)"도, 엔진이 파일
// 헤더에 그리는 배지도 생 숫자다. 한 화면에서 표기가 갈리면 안 된다.
test("천 단위 구분자를 넣지 않는다", () => {
expect(changeTotalsView([file(1234567, 0)]).additions).toBe("+1234567");
});
});
101 changes: 100 additions & 1 deletion apps/viewer/__tests__/diff-command.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { $ } from "bun";
Expand Down Expand Up @@ -299,3 +299,102 @@ describe("getDiffFiles base mode", () => {
expect(baseB?.newContents).toContain("BETA_working");
});
});

describe("head selection (rev → rev)", () => {
// init -- A (main)
// \-- B (feat)
// 브랜치를 head로 보면 "그 브랜치가 갈라진 뒤 한 일"만 보여야 한다.
const branchOffAndAdvanceMain = async (): Promise<void> => {
await $`git -C ${repo} branch -M main`;
await $`git -C ${repo} checkout -qb feat`;
writeFileSync(join(repo, "a.txt"), "one\ntwo\n");
await $`git -C ${repo} commit -qam feat`;
await $`git -C ${repo} checkout -q main`;
writeFileSync(join(repo, "b.txt"), "main moved on\n");
await $`git -C ${repo} add b.txt`;
await $`git -C ${repo} commit -qm "main moves"`;
};

test("shows the branch's committed work, not the working tree", async () => {
await branchOffAndAdvanceMain();
// 워킹트리를 더럽힌다 — head가 커밋된 rev면 이건 보이면 안 된다.
writeFileSync(join(repo, "a.txt"), "one\nuncommitted\n");

const files = await getDiffFiles(repo, {
mode: "base",
ref: "main",
head: "feat",
});
expect(files.map((f) => f.name)).toEqual(["a.txt"]);
expect(files[0]?.newContents).toBe("one\ntwo\n");
});

// **갈림점은 head 기준이어야 한다.** `merge-base(ref, HEAD)`로 재면 지금
// 워크트리의 HEAD(main)와 갈림점을 잡게 되는데, 그 둘은 아무 관계도 없다
// — 남의 브랜치를 보면서 내 위치를 기준 삼는 셈이다. 그러면 main이 그
// 사이 만든 b.txt가 "feat에서 삭제됨"으로 끼어든다.
test("measures the merge base against the head, not the current HEAD", async () => {
await branchOffAndAdvanceMain();
const files = await getDiffFiles(repo, {
mode: "base",
ref: "main",
head: "feat",
});
expect(files.map((f) => f.name)).not.toContain("b.txt");
expect(files).toHaveLength(1);
});

test("a committed head carries no untracked files", async () => {
await branchOffAndAdvanceMain();
writeFileSync(join(repo, "scratch.txt"), "not in any commit\n");

const files = await getDiffFiles(repo, {
untracked: true,
mode: "base",
ref: "main",
head: "feat",
});
expect(files.some((f) => f.status === "untracked")).toBe(false);
});

// **참조 이름이 트래킹된 경로와 같을 때.** `docs`·`src`·`test` 같은 이름은
// 흔한데, `git diff <base> <ref>`에 `--`가 없으면 git이 rev인지 path인지
// 못 정해 `ambiguous argument`로 죽는다. 그 실패는 `2>/dev/null` +
// `.nothrow()`가 빈 문자열로 삼켜 **에러 없는 "변경 없음"** 이 된다 —
// 사용자는 피커에서 그 브랜치를 고르기만 해도 이 상태에 들어간다.
// 기존 픽스처의 `feat`/`main`은 원리적으로 이 결함을 못 잡는다.
test("a branch named like a tracked directory still diffs", async () => {
await $`git -C ${repo} branch -M main`;
mkdirSync(join(repo, "docs"));
writeFileSync(join(repo, "docs", "a.md"), "on main\n");
await $`git -C ${repo} add docs`;
await $`git -C ${repo} commit -qm docs`;
await $`git -C ${repo} checkout -qb docs`;
writeFileSync(join(repo, "docs", "b.md"), "on the branch\n");
await $`git -C ${repo} add docs`;
await $`git -C ${repo} commit -qm "more docs"`;
await $`git -C ${repo} checkout -q main`;

const files = await getDiffFiles(repo, {
mode: "base",
ref: "main",
head: "docs",
});
expect(files.map((f) => f.name)).toEqual(["docs/b.md"]);
});

// 이미지 카드가 텍스트 diff와 다른 축을 보면 안 된다.
test("getFileBytes reads the new side from the head revision", async () => {
await branchOffAndAdvanceMain();
writeFileSync(join(repo, "a.txt"), "one\nuncommitted\n");

const bytes = await getFileBytes(repo, "a.txt", "new", {
mode: "base",
ref: "main",
head: "feat",
});
expect(new TextDecoder().decode(bytes ?? new Uint8Array())).toBe(
"one\ntwo\n",
);
});
});
19 changes: 19 additions & 0 deletions apps/viewer/__tests__/diff-fingerprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,25 @@ afterEach(() => {
});

describe("repoFingerprint", () => {
// head가 브랜치면 그 브랜치가 움직였을 때 캐시가 깨져야 한다 — 이 필드가
// 막으려는 유일한 실패다. 다른 head 테스트들이 지나가며 라인 커버리지로는
// 초록이지만(게이트는 branch를 안 센다) 이 시나리오를 찌르는 것은 없었다.
test("a moving head branch changes the fingerprint", async () => {
await $`git -C ${repo} checkout -qb feat`;
writeFileSync(join(repo, "a.txt"), "one\ntwo\n");
await $`git -C ${repo} commit -qam feat`;
await $`git -C ${repo} checkout -q -`;

const before = await repoFingerprint(repo, { head: "feat" });

await $`git -C ${repo} checkout -q feat`;
writeFileSync(join(repo, "a.txt"), "one\ntwo\nthree\n");
await $`git -C ${repo} commit -qam more`;
await $`git -C ${repo} checkout -q -`;

expect(await repoFingerprint(repo, { head: "feat" })).not.toBe(before);
});

test("is non-empty and stable while nothing changes", async () => {
const fp1 = await repoFingerprint(repo);
const fp2 = await repoFingerprint(repo);
Expand Down
54 changes: 53 additions & 1 deletion apps/viewer/__tests__/diff-refs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ 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";
import {
getRefs,
parseRefList,
parseRepoRoot,
parseWorktreeList,
} from "../server/refs.ts";

// `git worktree list --porcelain -z`의 실측 형식(git 2.54.0): 속성 한 줄마다
// NUL이 붙고, 레코드 사이는 빈 항목이다.
Expand All @@ -15,6 +20,53 @@ const wt = (...records: string[][]): string =>
const refs = (...records: string[][]): string =>
records.map((fields) => `${fields.join("\0")}\0`).join("\n");

describe("parseRepoRoot", () => {
// git은 메인 워크트리를 **항상 먼저** 낸다 — 링크된 워크트리나 중첩
// 워크트리에서 명령을 실행해도 그렇다(실측: 평범·bare·중첩 셋 다).
// 그래서 리포 루트는 git 호출을 늘리지 않고 첫 레코드에서 얻는다.
test("takes the first record — the main worktree", () => {
const raw = wt(
["worktree /repo", "HEAD abc", "branch refs/heads/main"],
[
"worktree /repo/.claude/worktrees/feat",
"HEAD abc",
"branch refs/heads/feat",
],
);
expect(parseRepoRoot(raw)).toBe("/repo");
});

// **bare가 정확히 이 함수가 필요한 이유다.** parseWorktreeList는 bare를
// 걸러내므로(워킹트리가 없어 고를 수 없다) 그 결과의 첫 항목은 메인이
// 아니라 링크된 워크트리다 — 실측으로 확인했다. 필터 전 원본을 읽어야
// 리포 이름을 옳게 말한다.
test("keeps the bare main worktree that parseWorktreeList drops", () => {
const raw = wt(
["worktree /srv/myproj.git", "bare"],
["worktree /srv/wt-feat", "HEAD abc", "branch refs/heads/feat"],
);
expect(parseRepoRoot(raw)).toBe("/srv/myproj.git");
expect(parseWorktreeList(raw).map((w) => w.path)).toEqual(["/srv/wt-feat"]);
});

test("keeps a prunable main worktree too", () => {
const raw = wt(
[
"worktree /repo",
"HEAD abc",
"prunable gitdir file points to non-existent location",
],
["worktree /wt", "HEAD abc", "branch refs/heads/feat"],
);
expect(parseRepoRoot(raw)).toBe("/repo");
});

test("returns null when git said nothing", () => {
expect(parseRepoRoot("")).toBeNull();
expect(parseRepoRoot("\0")).toBeNull();
});
});

describe("parseWorktreeList", () => {
test("reads path, branch and head from an attached worktree", () => {
const out = parseWorktreeList(
Expand Down
92 changes: 92 additions & 0 deletions apps/viewer/__tests__/diff-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,95 @@
);
});
});

describe("parseSelection with an explicit head", () => {
test("head is the working tree unless asked otherwise", () => {
expect(parseSelection(new URLSearchParams("repo=/r")).head).toEqual({
kind: "worktree",
});
});

test("an empty head falls back to the working tree", () => {
expect(parseSelection(new URLSearchParams("repo=/r&head=")).head).toEqual({
kind: "worktree",
});
});

// 브랜치를 head로 보면 워킹트리를 거치지 않으므로 커밋된 것만 보인다.
test("head names a ref to view instead of the working tree", () => {
expect(
parseSelection(new URLSearchParams("repo=/r&head=feature/x")).head,
).toEqual({ kind: "ref", ref: "feature/x" });
});

// base와 달리 HEAD를 정규화하지 않는다. base=HEAD는 "커밋 안 한 것만"이라
// 워킹트리 뷰와 같지만, head=HEAD는 **커밋된 HEAD**를 보는 것이라 워킹트리
// 뷰와 다르다(미커밋 변경이 빠진다). 둘을 합치면 그 구분이 사라진다.
test("head=HEAD stays a ref — it is not the working tree", () => {
expect(
parseSelection(new URLSearchParams("repo=/r&head=HEAD")).head,
).toEqual({ kind: "ref", ref: "HEAD" });
});

test("head and base are independent axes", () => {
const sel = parseSelection(
new URLSearchParams("repo=/r&base=main&head=dev"),
);
expect(sel.base).toEqual({ kind: "ref", ref: "main" });
expect(sel.head).toEqual({ kind: "ref", ref: "dev" });
});
});

describe("selectionCacheKey with an explicit head", () => {
const sel = (query: string) => parseSelection(new URLSearchParams(query));

// 계약: 키는 flight 클로저가 읽는 모든 입력의 전함수여야 한다. head가
// 빠지면 워킹트리 뷰와 브랜치 뷰가 같은 슬롯에 합류해 한쪽이 남의 diff를
// 받는다 — 예전에 해석된 base ref가 빠져 있어 실제로 겪은 그 버그다.
test("separates a worktree head from a ref head", () => {
expect(selectionCacheKey(sel("repo=/r"), null)).not.toBe(
selectionCacheKey(sel("repo=/r&head=dev"), null),
);
});

test("separates two different ref heads", () => {
expect(selectionCacheKey(sel("repo=/r&head=dev"), null)).not.toBe(
selectionCacheKey(sel("repo=/r&head=main"), null),
);
});

test("the same head lands on the same slot", () => {
expect(selectionCacheKey(sel("repo=/r&head=dev"), null)).toBe(
selectionCacheKey(sel("repo=/r&head=dev"), null),
);
});

// 커밋된 rev에는 "아직 커밋 안 한 것"이 없다. 곧이곧대로 답하면
// `git diff <rev> <rev>`가 되어 에러 없이 빈 화면이 되므로, 의미 없는
// 조합을 유일하게 말이 되는 해석으로 푼다.
test("a rev head with a working-tree base resolves to auto", () => {
for (const q of [
"head=feat",
"head=feat&base=HEAD",
"head=feat&mode=working",
]) {
const sel = parseSelection(new URLSearchParams(q));

Check warning on line 221 in apps/viewer/__tests__/diff-selection.test.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(no-shadow)

'sel' is already declared in the upper scope.
expect(sel.base).toEqual({ kind: "auto" });
expect(sel.head).toEqual({ kind: "ref", ref: "feat" });
}
});

// 사용자가 고른 진짜 base는 그대로 둔다 — 정규화는 "빈 화면 조합" 하나만
// 건드린다.
test("an explicit base ref survives alongside a head", () => {
const sel = parseSelection(new URLSearchParams("head=feat&base=develop"));

Check warning on line 230 in apps/viewer/__tests__/diff-selection.test.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(no-shadow)

'sel' is already declared in the upper scope.
expect(sel.base).toEqual({ kind: "ref", ref: "develop" });
});

// 워킹트리를 보는 기본 뷰는 정규화 대상이 아니다.
test("a worktree head keeps the working-tree base", () => {
expect(parseSelection(new URLSearchParams("")).base).toEqual({
kind: "head",
});
});
});
Loading
Loading