spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

mods: the diff, sec-default and telemetry tests move next to the mods (#93951)Browse files
Squash merge of 4 commits
Alice T'Poteat authored on Sep 14, 2026 · GitHub committed on Sep 14, 2026 parent b593276 · commit 18be13be8153
205 changed files+3653 -51compared with b593276
Mmods/diff/hooks/ask/fitted-ask-text-of.ts
Mmods/diff/hooks/git/parse/parse-numstat.ts
Mmods/diff/hooks/git/probes/kind-walk-of/kind-walk-of.ts
Mmods/diff/hooks/register.ts
Mmods/diff/hooks/views/detail/code-blocks-of/code-blocks-of.ts
Mmods/diff/hooks/views/detail/sub-hunks-of/sub-hunks-of.ts
Mmods/diff/hooks/views/layout/truncate-start/truncate-start.ts
Mmods/diff/hooks/views/layout/wrapped-lines/wrapped-lines.ts
Amods/diff/tests/ask/fitted-ask-text-of.test.ts
Amods/diff/tests/backend/backend-of.test.ts
Amods/diff/tests/classify/is-noise-file.test.ts
Amods/diff/tests/fixtures/ask/ask-lines.ts
Amods/diff/tests/fixtures/ask/ask-text.ts
Amods/diff/tests/fixtures/ask/index.ts
Amods/diff/tests/fixtures/ask/room-for-one-line.ts
Amods/diff/tests/fixtures/ask/room-for-two-lines.ts
Amods/diff/tests/fixtures/backend-of/backend-host-of.ts
Amods/diff/tests/fixtures/backend-of/git-host-of.ts
Amods/diff/tests/fixtures/backend-of/git-worktree.ts
Amods/diff/tests/fixtures/backend-of/index.ts
Amods/diff/tests/fixtures/backend-of/recording-backend-host-of.ts
Amods/diff/tests/fixtures/backend-of/recording-backend-host.ts
Amods/diff/tests/fixtures/backend-of/recording-probe-of.ts
Amods/diff/tests/fixtures/backend-of/recording-probe.ts
Amods/diff/tests/fixtures/backend-of/stub-backend-of.ts
Amods/diff/tests/fixtures/base-label-of/at-sha.ts
Amods/diff/tests/fixtures/base-label-of/index.ts
Amods/diff/tests/fixtures/base-label-of/one-file.ts
Amods/diff/tests/fixtures/base-label-of/working-tree.ts
Amods/diff/tests/fixtures/big-lines.ts
Amods/diff/tests/fixtures/branch-base-of/feature-head.ts
Amods/diff/tests/fixtures/branch-base-of/index.ts
Amods/diff/tests/fixtures/branch-base-of/local-base.ts
Amods/diff/tests/fixtures/branch-base-of/origin-base.ts
Amods/diff/tests/fixtures/checkout.ts
Amods/diff/tests/fixtures/code-blocks-of/are-contiguous.ts
Amods/diff/tests/fixtures/code-blocks-of/hunks-of.ts
Amods/diff/tests/fixtures/code-blocks-of/index.ts
Amods/diff/tests/fixtures/code-blocks-of/is-within-code-cap.ts
Amods/diff/tests/fixtures/code-blocks-of/mathematical-bold-a.ts
Amods/diff/tests/fixtures/code-blocks-of/parsed-hunk.ts
Amods/diff/tests/fixtures/code-blocks-of/parsed-hunks-of.ts
Amods/diff/tests/fixtures/code-blocks-of/wide-hunk.ts
Amods/diff/tests/fixtures/code-blocks-of/wide-line-letters.ts
Amods/diff/tests/fixtures/datings-of/index.ts
Amods/diff/tests/fixtures/datings-of/root-only-budget.ts
Amods/diff/tests/fixtures/datings-of/tracked-row.ts
Amods/diff/tests/fixtures/datings-of/two-listings-budget.ts
Amods/diff/tests/fixtures/edit-patch-of.ts
Amods/diff/tests/fixtures/fetch-diff/before-session-ms.ts
Amods/diff/tests/fixtures/fetch-diff/deep-directories.ts
Amods/diff/tests/fixtures/fetch-diff/during-session-ms.ts
Amods/diff/tests/fixtures/fetch-diff/fetch-repository.ts
Amods/diff/tests/fixtures/fetch-diff/files-listing.ts
Amods/diff/tests/fixtures/fetch-diff/git-session-start-ms.ts
Amods/diff/tests/fixtures/fetch-diff/index.ts
Amods/diff/tests/fixtures/fetch-diff/merge-head-linked.ts
Amods/diff/tests/fixtures/fetch-diff/merging.ts
Amods/diff/tests/fixtures/fetch-diff/one-staged.ts
Amods/diff/tests/fixtures/fetch-diff/vs-head.ts
Mmods/diff/tests/fixtures/git-in.ts
Amods/diff/tests/fixtures/head-key-of/head-sha.ts
Amods/diff/tests/fixtures/head-key-of/index.ts
Amods/diff/tests/fixtures/head-key-of/stamp.ts
Amods/diff/tests/fixtures/header-totals-of/index.ts
Amods/diff/tests/fixtures/header-totals-of/past-cap-count.ts
Amods/diff/tests/fixtures/header-totals-of/pre-session-row.ts
Amods/diff/tests/fixtures/header-totals-of/reported-count.ts
Amods/diff/tests/fixtures/header-totals-of/session-row.ts
Amods/diff/tests/fixtures/header-totals-of/stored-count.ts
Amods/diff/tests/fixtures/header-totals-of/test-row.ts
Amods/diff/tests/fixtures/hint-at.ts
Mmods/diff/tests/fixtures/in-repository.ts
Mmods/diff/tests/fixtures/index.ts
Amods/diff/tests/fixtures/kind-walk-of/index.ts
Amods/diff/tests/fixtures/kind-walk-of/walk-budget.ts
Amods/diff/tests/fixtures/kind-walk-of/walk-depth.ts
Amods/diff/tests/fixtures/no-stats.ts
Amods/diff/tests/fixtures/pane-view/body-rows.ts
Amods/diff/tests/fixtures/pane-view/builtin-headline-row.ts
Amods/diff/tests/fixtures/pane-view/columns.ts
Amods/diff/tests/fixtures/pane-view/config-detail-of.ts
Amods/diff/tests/fixtures/pane-view/cramped-rows.ts
Amods/diff/tests/fixtures/pane-view/docked-pane.ts
Amods/diff/tests/fixtures/pane-view/docks-pane.ts
Amods/diff/tests/fixtures/pane-view/draws-pane.ts
Amods/diff/tests/fixtures/pane-view/index.ts
Amods/diff/tests/fixtures/pane-view/long-line-chars.ts
Amods/diff/tests/fixtures/pane-view/long-path-segments.ts
Amods/diff/tests/fixtures/pane-view/noop-actions-of.ts
Amods/diff/tests/fixtures/pane-view/noop.ts
Amods/diff/tests/fixtures/pane-view/row-of.ts
Amods/diff/tests/fixtures/pane-view/row-shape-of.ts
Amods/diff/tests/fixtures/pane-view/row-shapes-of.ts
Amods/diff/tests/fixtures/pane-view/small-body.ts
Amods/diff/tests/fixtures/pane-view/totals.ts
Amods/diff/tests/fixtures/pane-view/tracked.ts
Amods/diff/tests/fixtures/pane-view/turn-two.ts
Amods/diff/tests/fixtures/pane-view/view-pane.ts
Amods/diff/tests/fixtures/pane-view/vs-spoofed.ts
Amods/diff/tests/fixtures/parse-file-diff/file-diff-header.ts
Amods/diff/tests/fixtures/parse-file-diff/index.ts
Amods/diff/tests/fixtures/parse-file-diff/past-the-cap.ts
Amods/diff/tests/fixtures/repository-of/echoed-flag.ts
Amods/diff/tests/fixtures/repository-of/index.ts
Amods/diff/tests/fixtures/repository-of/relative.ts
Amods/diff/tests/fixtures/repository-of/resolved.ts
Amods/diff/tests/fixtures/repository-of/windows.ts
Amods/diff/tests/fixtures/scripted/git-timed-out.ts
Amods/diff/tests/fixtures/scripted/index.ts
Amods/diff/tests/fixtures/scripted/ok.ts
Amods/diff/tests/fixtures/scripted/probe-missed.ts
Amods/diff/tests/fixtures/scripted/probed-repository-of.ts
Amods/diff/tests/fixtures/scripted/scripted-git-of.ts
Amods/diff/tests/fixtures/scripted/scripted-git.ts
Amods/diff/tests/fixtures/scripted/unscripted.ts
Dmods/diff/tests/fixtures/text-of.ts
Amods/diff/tests/fixtures/todo-write-of.ts
Amods/diff/tests/fixtures/tree/children-of.ts
Amods/diff/tests/fixtures/tree/codes-in.ts
Amods/diff/tests/fixtures/tree/element-in.ts
Amods/diff/tests/fixtures/tree/element-query.ts
Amods/diff/tests/fixtures/tree/index.ts
Amods/diff/tests/fixtures/tree/is-drawn.ts
Amods/diff/tests/fixtures/tree/json-of.ts
Amods/diff/tests/fixtures/tree/strings-of.ts
Amods/diff/tests/fixtures/tree/text-of.ts
Amods/diff/tests/fixtures/truncate-start/combining-acute.ts
Amods/diff/tests/fixtures/truncate-start/index.ts
Amods/diff/tests/fixtures/truncate-start/marks.ts
Amods/diff/tests/fixtures/truncate-start/narrow-room.ts
Amods/diff/tests/fixtures/truncate-start/room.ts
Amods/diff/tests/fixtures/turn-diffs-of/edits-of.ts
Amods/diff/tests/fixtures/turn-diffs-of/index.ts
Amods/diff/tests/fixtures/turn-diffs-of/prompt-of.ts
Amods/diff/tests/fixtures/turn-diffs-of/tool-results.ts
Amods/diff/tests/fixtures/typed-prompt-of.ts
Amods/diff/tests/fixtures/vs-main.ts
Amods/diff/tests/fixtures/worktree/elsewhere-lines.ts
Amods/diff/tests/fixtures/worktree/failed-start.ts
Amods/diff/tests/fixtures/worktree/index.ts
Amods/diff/tests/fixtures/worktree/one-secret.ts
Amods/diff/tests/fixtures/worktree/pinned-lead.ts
Amods/diff/tests/fixtures/worktree/secret-diff.ts
Amods/diff/tests/fixtures/worktree/worktree-lines.ts
Amods/diff/tests/fixtures/worktree/worktree-session.ts
Amods/diff/tests/fixtures/wrapped-lines/builtin-wraps.ts
Amods/diff/tests/fixtures/wrapped-lines/index.ts
Amods/diff/tests/git/fetch-diff.test.ts
Amods/diff/tests/git/parse/parse-file-diff.test.ts
Amods/diff/tests/git/parse/parse-numstat.test.ts
Amods/diff/tests/git/parse/parse-shortstat.test.ts
Amods/diff/tests/git/probes/branch-base-of.test.ts
Amods/diff/tests/git/probes/datings-of.test.ts
Amods/diff/tests/git/probes/head-key-of.test.ts
Amods/diff/tests/git/probes/is-safe-ref-name.test.ts
Amods/diff/tests/git/probes/kind-walk-of.test.ts
Amods/diff/tests/git/probes/repository-of.test.ts
Mmods/diff/tests/git.test.ts
Amods/diff/tests/pane-state/base-label-of.test.ts
Amods/diff/tests/pane-state/empty-state-of.test.ts
Amods/diff/tests/pane-state/header-totals-of.test.ts
Amods/diff/tests/pane-toggle/pane-toggle-of.test.ts
Mmods/diff/tests/register.test.ts
Amods/diff/tests/todos/todo-progress-of.test.ts
Amods/diff/tests/turns/turn-diffs-of.test.ts
Amods/diff/tests/views/detail/code-blocks-of.test.ts
Amods/diff/tests/views/detail/detail-view.test.ts
Amods/diff/tests/views/layout/truncate-start.test.ts
Amods/diff/tests/views/layout/wrapped-lines.test.ts
Amods/diff/tests/views/pane-view.test.ts
Amods/sec-default/tests/fixtures/agent-offered.ts
Amods/sec-default/tests/fixtures/agent-spawned.ts
Amods/sec-default/tests/fixtures/command-described.ts
Amods/sec-default/tests/fixtures/denying.ts
Mmods/sec-default/tests/fixtures/index.ts
Amods/sec-default/tests/fixtures/managed-policy.ts
Amods/sec-default/tests/fixtures/marking.ts
Amods/sec-default/tests/fixtures/odd-providers.ts
Amods/sec-default/tests/fixtures/org-providers.ts
Amods/sec-default/tests/fixtures/policy-command.ts
Amods/sec-default/tests/fixtures/policy-reads.ts
Amods/sec-default/tests/fixtures/reading.ts
Amods/sec-default/tests/fixtures/server-policy.ts
Amods/sec-default/tests/fixtures/stripping.ts
Amods/sec-default/tests/fixtures/subjects-echoed.ts
Amods/sec-default/tests/fixtures/tool-described.ts
Amods/sec-default/tests/fixtures/user-reachable-providers.ts
Amods/sec-default/tests/policy/create-policy-memo.test.ts
Mmods/sec-default/tests/register.test.ts
Amods/telemetry/tests/fixtures/analytics-off-environments.ts
Amods/telemetry/tests/fixtures/base64-alphabet.ts
Amods/telemetry/tests/fixtures/base64-decoded.ts
Amods/telemetry/tests/fixtures/base64-digit-bits.ts
Mmods/telemetry/tests/fixtures/batch-of.ts
Mmods/telemetry/tests/fixtures/first-party-session.ts
Mmods/telemetry/tests/fixtures/index.ts
Amods/telemetry/tests/fixtures/ingest-batch.ts
Amods/telemetry/tests/fixtures/ingest-event.ts
Amods/telemetry/tests/fixtures/mark.ts
Amods/telemetry/tests/fixtures/marking.ts
Amods/telemetry/tests/fixtures/refused.ts
Amods/telemetry/tests/fixtures/row-of.ts
Mmods/telemetry/tests/fixtures/survey-answer.ts
Mmods/telemetry/tests/register.test.ts
@@ -17,6 +17,7 @@
1717 }
1818
1919 const kept: string[] = []
20+
2021 let used = ASK_CUT_NOTE.length
2122
2223 for (const line of text.split('\n')) {
@@ -20,6 +20,7 @@
2020 const files: Types.FileStat[] = []
2121 const stats = { filesCount: 0, linesAdded: 0, linesRemoved: 0 }
2222 const records = stdout.split('\0')
23+
2324 let at = 0
2425
2526 while (at < records.length) {
@@ -41,6 +41,7 @@
4141
4242 async function kindOf(path: string): Promise<Types.WalkKind> {
4343 const names = path === '' ? [] : path.split('/')
44+
4445 let directory = ''
4546
4647 for (const [depth, name] of names.entries()) {
@@ -43,8 +43,11 @@
4343 let isRefreshQueued = false
4444 let generation = 0
4545 let bodyKey: string | null = null
46+
4647 const polled = { toplevel: '', headKey: '' }
48+
4749 let model: PaneState.PaneModel = PaneState.INITIAL_MODEL
50+
4851 const timers = new Map<'refresh' | 'redraw' | 'poll', Timer>()
4952 const loggedBaseKinds = new Set<'ok' | 'sad'>()
5053
@@ -23,6 +23,7 @@
2323 */
2424 export function codeBlocksOf(hunks: readonly Git.Hunk[]): Types.CodeBody {
2525 const blocks: Types.CodeBlock[] = []
26+
2627 let chars = 0
2728 let nodes = 0
2829 let isTruncated = false
@@ -19,6 +19,7 @@
1919 maxChars: number,
2020 ): Pick<Git.FileHunks, 'hunks' | 'isTruncated'> {
2121 const hunks: Git.Hunk[] = []
22+
2223 let rest = hunk
2324 let isTruncated = false
2425
@@ -18,6 +18,7 @@
1818 }
1919
2020 const characters = [...text]
21+
2122 let kept = ''
2223
2324 for (let at = characters.length - 1; at >= 0; at--) {
@@ -18,6 +18,7 @@
1818
1919 for (const [index, word] of words.entries()) {
2020 const wordWidth = cellWidth(word)
21+
2122 let rowWidth = cellWidth(rows[rows.length - 1] ?? '')
2223
2324 if (index !== 0) {
@@ -39,6 +40,7 @@
3940 }
4041
4142 const characters = [...word]
43+
4244 let visible = cellWidth(rows[rows.length - 1] ?? '')
4345
4446 for (const [at, character] of characters.entries()) {
@@ -1,0 +1,33 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Ask from '../../hooks/ask'
4+import Fixtures from '../fixtures'
5+
6+tier('builtin')
7+
8+describe('fitted-ask-text-of', () => {
9+ test('an ask that fits rides whole', () => {
10+ expect(
11+ Ask.fittedAskTextOf(Fixtures.ASK_TEXT, Fixtures.ASK_TEXT.length),
12+ ).toBe(Fixtures.ASK_TEXT)
13+ })
14+
15+ test('an ask past the room keeps its first lines and the cut note', () => {
16+ const fitted = Ask.fittedAskTextOf(
17+ Fixtures.ASK_TEXT,
18+ Fixtures.ROOM_FOR_TWO_LINES,
19+ )
20+
21+ expect(fitted).toBe(
22+ `${Fixtures.ASK_LINES.slice(0, 2).join('\n')}\n${Ask.ASK_CUT_NOTE}`,
23+ )
24+
25+ expect(fitted?.length).toBeLessThanOrEqual(Fixtures.ROOM_FOR_TWO_LINES)
26+ })
27+
28+ test('an ask whose first hunk line does not fit is not attached', () => {
29+ expect(
30+ Ask.fittedAskTextOf(Fixtures.ASK_TEXT, Fixtures.ROOM_FOR_ONE_LINE),
31+ ).toBeUndefined()
32+ })
33+})
@@ -1,0 +1,62 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Backend from '../../hooks/backend'
4+import Fixtures from '../fixtures'
5+
6+tier('builtin')
7+
8+describe('backend-of', () => {
9+ test('as published: no probes, so git or nothing', async () => {
10+ const inGit = Fixtures.gitHostOf()
11+ const outside = Fixtures.recordingBackendHostOf({})
12+
13+ expect((await Backend.backendOf(inGit.host, []))?.words.lister).toBe('git')
14+ expect(await Backend.backendOf(outside.host, [])).toBeNull()
15+ expect([inGit.programs, outside.programs]).toEqual([['git'], ['git']])
16+ })
17+
18+ test('a declining probe leaves a git working tree to git', async () => {
19+ const declining = Fixtures.recordingProbeOf(null)
20+ const inGit = Fixtures.gitHostOf()
21+ const backend = await Backend.backendOf(inGit.host, [declining.probe])
22+
23+ expect(backend?.words.lister).toBe('git')
24+ expect(backend?.baseModes).toEqual(['session', 'uncommitted', 'branch'])
25+ expect(declining.asked).toEqual([inGit.host])
26+ expect(inGit.programs).toEqual(['git'])
27+ })
28+
29+ test('a probe that answers pins before git is asked', async () => {
30+ const inGit = Fixtures.gitHostOf()
31+
32+ const backend = await Backend.backendOf(inGit.host, [
33+ Fixtures.recordingProbeOf('/home/me/src').probe,
34+ ])
35+
36+ expect(backend?.repository).toEqual({ toplevel: '/home/me/src' })
37+ expect(inGit.programs).toEqual([])
38+ })
39+
40+ test('the probes are asked in order and the first answer pins', async () => {
41+ const declining = Fixtures.recordingProbeOf(null)
42+ const first = Fixtures.recordingProbeOf('/first')
43+ const second = Fixtures.recordingProbeOf('/second')
44+ const { host } = Fixtures.recordingBackendHostOf({})
45+
46+ const backend = await Backend.backendOf(host, [
47+ declining.probe,
48+ first.probe,
49+ second.probe,
50+ ])
51+
52+ expect(backend?.repository).toEqual({ toplevel: '/first' })
53+
54+ expect([
55+ declining.asked.length,
56+ first.asked.length,
57+ second.asked.length,
58+ ]).toEqual([1, 1, 0])
59+
60+ expect(declining.asked[0]).toBe(host)
61+ })
62+})
@@ -1,0 +1,40 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Classify from '../../hooks/classify'
4+
5+tier('builtin')
6+
7+describe('is-noise-file', () => {
8+ test('tests, fixtures and snapshots are noise', () => {
9+ for (const path of [
10+ 'test/a.ts',
11+ 'src/x/__tests__/y.ts',
12+ 'lib/foo.test.tsx',
13+ 'pkg/bar_test.go',
14+ 'web/fixtures/data.json',
15+ 'ui/__snapshots__/a.snap',
16+ ]) {
17+ expect(Classify.isNoiseFile(path), path).toBe(true)
18+ }
19+ })
20+
21+ test('lockfiles, builds, minified and generated files are noise', () => {
22+ for (const path of [
23+ 'bun.lock',
24+ 'package-lock.json',
25+ 'dist/index.js',
26+ 'assets/app.min.js',
27+ 'types/api.d.ts',
28+ 'src/constants/prompts.generated/x.ts',
29+ 'proto/service.pb.go',
30+ ]) {
31+ expect(Classify.isNoiseFile(path), path).toBe(true)
32+ }
33+ })
34+
35+ test('source is not, even with test inside a longer word', () => {
36+ for (const path of ['src/contest/entry.ts', 'src/latest.ts', 'README.md']) {
37+ expect(Classify.isNoiseFile(path), path).toBe(false)
38+ }
39+ })
40+})
@@ -1,0 +1,12 @@
1+/**
2+ * The lines of an armed ask: where it came from, then one hunk long enough
3+ * that two lines and the cut note take less room than the whole.
4+ */
5+export const ASK_LINES: readonly string[] = [
6+ 'The user attached the diff of a.txt from the diff pane to this prompt:',
7+ '@@ -1,3 +1,3 @@',
8+ '-the first old line of a.txt, long enough to take up some room',
9+ '-the second old line of a.txt, long enough to take up some room',
10+ '+the first new line of a.txt, long enough to take up some room',
11+ '+the second new line of a.txt, long enough to take up some room',
12+]
@@ -1,0 +1,6 @@
1+import { ASK_LINES } from './ask-lines.js'
2+
3+/**
4+ * ASK_LINES as the one text an armed ask carries.
5+ */
6+export const ASK_TEXT = ASK_LINES.join('\n')
@@ -1,0 +1,6 @@
1+export * from './ask-lines.js'
2+export * from './ask-text.js'
3+export * from './room-for-one-line.js'
4+export * from './room-for-two-lines.js'
5+
6+export * as default from '.'
@@ -1,0 +1,8 @@
1+import Ask from '../../../hooks/ask'
2+import { ASK_LINES } from './ask-lines.js'
3+
4+/**
5+ * Room for ASK_LINES' heading line and the cut note only, so no hunk line.
6+ */
7+export const ROOM_FOR_ONE_LINE =
8+ (ASK_LINES[0]?.length ?? 0) + 1 + Ask.ASK_CUT_NOTE.length
@@ -1,0 +1,11 @@
1+import Ask from '../../../hooks/ask'
2+import { ASK_LINES } from './ask-lines.js'
3+
4+/**
5+ * Room for ASK_LINES' first two lines and the cut note, each line with its
6+ * newline, and not a character more.
7+ */
8+export const ROOM_FOR_TWO_LINES = ASK_LINES.slice(0, 2).reduce(
9+ (sum, line) => sum + line.length + 1,
10+ Ask.ASK_CUT_NOTE.length,
11+)
@@ -1,0 +1,20 @@
1+import type Backend from '../../../hooks/backend'
2+
3+/**
4+ * A backend host around one runner: every other member answers nothing (no
5+ * file, no mtime, no listing, time zero, a base callback that ignores).
6+ *
7+ * @param run the host's runner
8+ * @returns the host
9+ */
10+export const backendHostOf = (
11+ run: Backend.BackendHost['run'],
12+): Backend.BackendHost => ({
13+ run,
14+ readFile: () => Promise.reject(new Error('ENOENT')),
15+ mtimeOf: () => Promise.resolve(null),
16+ entryKindsOf: () => Promise.resolve(null),
17+ nowMs: () => Promise.resolve(0),
18+ sessionStartMsOf: () => 0,
19+ onBranchBase: () => undefined,
20+})
@@ -1,0 +1,13 @@
1+import Scripted from '../scripted'
2+import { GIT_WORKTREE } from './git-worktree.js'
3+import { recordingBackendHostOf } from './recording-backend-host-of.js'
4+import type { RecordingBackendHost } from './recording-backend-host.js'
5+
6+/**
7+ * A recording backend host inside a git working tree: `rev-parse` answers
8+ * the three lines, nothing else is scripted.
9+ *
10+ * @returns the host and the programs it ran
11+ */
12+export const gitHostOf = (): RecordingBackendHost =>
13+ recordingBackendHostOf({ 'rev-parse': Scripted.ok(GIT_WORKTREE) })
@@ -1,0 +1,5 @@
1+/**
2+ * `git rev-parse`'s three lines inside a working tree at /r: the toplevel,
3+ * the git dir, the common dir.
4+ */
5+export const GIT_WORKTREE = '/r\n/r/.git\n/r/.git\n'
@@ -1,0 +1,10 @@
1+export * from './backend-host-of.js'
2+export * from './git-host-of.js'
3+export * from './git-worktree.js'
4+export type * from './recording-backend-host.js'
5+export * from './recording-backend-host-of.js'
6+export type * from './recording-probe.js'
7+export * from './recording-probe-of.js'
8+export * from './stub-backend-of.js'
9+
10+export * as default from '.'
@@ -1,0 +1,27 @@
1+import type { ProcessRunResult } from 'claude-code'
2+
3+import Scripted from '../scripted'
4+import { backendHostOf } from './backend-host-of.js'
5+import type { RecordingBackendHost } from './recording-backend-host.js'
6+
7+/**
8+ * A backend host whose runner answers from a script and notes each program
9+ * it was asked to run.
10+ *
11+ * @param script git's answers by subcommand
12+ * @returns the host and the programs it ran, in order
13+ */
14+export function recordingBackendHostOf(
15+ script: Readonly<Record<string, ProcessRunResult>>,
16+): RecordingBackendHost {
17+ const programs: (string | undefined)[] = []
18+ const scripted = Scripted.scriptedGitOf(script)
19+
20+ const host = backendHostOf(([program, ...rest]) => {
21+ programs.push(program)
22+
23+ return scripted.run(rest)
24+ })
25+
26+ return { host, programs }
27+}
@@ -1,0 +1,10 @@
1+import type Backend from '../../../hooks/backend'
2+
3+/**
4+ * A backend host under test with the programs its runner was asked to run,
5+ * in order.
6+ */
7+export type RecordingBackendHost = {
8+ host: Backend.BackendHost
9+ programs: readonly (string | undefined)[]
10+}
@@ -1,0 +1,24 @@
1+import type Backend from '../../../hooks/backend'
2+import type { RecordingProbe } from './recording-probe.js'
3+import { stubBackendOf } from './stub-backend-of.js'
4+
5+/**
6+ * A backend probe that notes each host it is asked about and answers a stub
7+ * backend at the toplevel, or declines (null) when given none.
8+ *
9+ * @param toplevel the working copy's root, or null to decline
10+ * @returns the probe and the hosts it was asked about, in order
11+ */
12+export function recordingProbeOf(toplevel: string | null): RecordingProbe {
13+ const asked: Backend.BackendHost[] = []
14+ const answer = toplevel === null ? null : stubBackendOf(toplevel)
15+
16+ return {
17+ asked,
18+ probe: host => {
19+ asked.push(host)
20+
21+ return Promise.resolve(answer)
22+ },
23+ }
24+}
@@ -1,0 +1,9 @@
1+import type Backend from '../../../hooks/backend'
2+
3+/**
4+ * A backend probe under test with the hosts it was asked about, in order.
5+ */
6+export type RecordingProbe = {
7+ probe: Backend.BackendProbe
8+ asked: readonly Backend.BackendHost[]
9+}
@@ -1,0 +1,18 @@
1+import type { Backend } from '../../../hooks/backend'
2+import Git from '../../../hooks/git'
3+
4+/**
5+ * A backend that holds a working copy at a toplevel and fetches nothing,
6+ * its lister word the toplevel so a test can tell which probe pinned.
7+ *
8+ * @param toplevel the working copy's root
9+ * @returns the backend
10+ */
11+export const stubBackendOf = (toplevel: string): Backend => ({
12+ repository: { toplevel },
13+ baseModes: ['uncommitted'],
14+ words: { ...Git.GIT_WORDS, lister: toplevel },
15+ fetchDiff: () => Promise.resolve({ kind: 'unavailable' }),
16+ fetchFileHunks: () => Promise.resolve(null),
17+ headKeyOf: () => Promise.resolve(''),
18+})
@@ -1,0 +1,9 @@
1+import type Git from '../../../hooks/git'
2+
3+/**
4+ * A working-tree source whose backend names its own base, a short sha.
5+ */
6+export const AT_SHA: Git.DiffSource = {
7+ kind: 'working-tree',
8+ base: '8634408015c1',
9+}
@@ -1,0 +1,5 @@
1+export * from './at-sha.js'
2+export * from './one-file.js'
3+export * from './working-tree.js'
4+
5+export * as default from '.'
@@ -1,0 +1,10 @@
1+import type Git from '../../../hooks/git'
2+
3+/**
4+ * The totals of a diff with one file of one added line.
5+ */
6+export const ONE_FILE: Git.DiffStats = {
7+ filesCount: 1,
8+ linesAdded: 1,
9+ linesRemoved: 0,
10+}
@@ -1,0 +1,9 @@
1+import type Git from '../../../hooks/git'
2+
3+/**
4+ * A diff source that compares against the working tree, its base HEAD.
5+ */
6+export const WORKING_TREE: Git.DiffSource = {
7+ kind: 'working-tree',
8+ base: 'HEAD',
9+}
@@ -1,0 +1,4 @@
1+/**
2+ * A hunk length past what one turn's diff keeps whole.
3+ */
4+export const BIG_LINES = 1000
@@ -1,0 +1,4 @@
1+/**
2+ * What `rev-parse HEAD` answers on the feature branch.
3+ */
4+export const FEATURE_HEAD = 'cccccccccccccccccccccccccccccccccccccccc'
@@ -1,0 +1,5 @@
1+export * from './feature-head.js'
2+export * from './local-base.js'
3+export * from './origin-base.js'
4+
5+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * The merge-base of HEAD and the local default branch, when it differs
3+ * from the remote's.
4+ */
5+export const LOCAL_BASE = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
@@ -1,0 +1,4 @@
1+/**
2+ * The merge-base of HEAD and the remote's default branch.
3+ */
4+export const ORIGIN_BASE = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
@@ -1,0 +1,11 @@
1+import type Git from '../../hooks/git'
2+
3+/**
4+ * Where every model and view fixture sits: a plain checkout at `/r` whose
5+ * git dir is its common dir.
6+ */
7+export const CHECKOUT: Git.Repository = {
8+ toplevel: '/r',
9+ gitDir: '/r/.git',
10+ commonDir: '/r/.git',
11+}
@@ -1,0 +1,19 @@
1+import type { ParsedHunk } from './parsed-hunk.js'
2+
3+/**
4+ * Whether each hunk starts where the one before it ended, on both sides:
5+ * the sub-hunks of one hunk split leave no line out and repeat none.
6+ *
7+ * @param parts the hunks in order
8+ * @returns true when they abut
9+ */
10+export const areContiguous = (parts: readonly ParsedHunk[]): boolean =>
11+ parts.slice(1).every((part, at) => {
12+ const before = parts[at]
13+
14+ return (
15+ before !== undefined &&
16+ part.oldStart === before.oldStart + before.oldLines &&
17+ part.newStart === before.newStart + before.newLines
18+ )
19+ })
@@ -1,0 +1,36 @@
1+import type { ParsedHunk } from './parsed-hunk.js'
2+
3+/**
4+ * The hunks a unified-diff `Code` source holds, read as a surface reads
5+ * them.
6+ *
7+ * Each `@@ -a,b +c,d @@` header opens one and the lines under it are its
8+ * body; a source that opens with no header holds none.
9+ *
10+ * @param source the `Code` element's source
11+ * @returns the hunks in order
12+ */
13+export function hunksOf(source: string): ParsedHunk[] {
14+ const hunks: ParsedHunk[] = []
15+
16+ for (const line of source.split('\n')) {
17+ const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line)
18+ const last = hunks.at(-1)
19+
20+ if (header) {
21+ hunks.push({
22+ oldStart: Number(header[1]),
23+ oldLines: Number(header[2] ?? 1),
24+ newStart: Number(header[3]),
25+ newLines: Number(header[4] ?? 1),
26+ lines: [],
27+ })
28+ } else if (last) {
29+ hunks[hunks.length - 1] = { ...last, lines: [...last.lines, line] }
30+ } else {
31+ return []
32+ }
33+ }
34+
35+ return hunks
36+}
@@ -1,0 +1,10 @@
1+export * from './are-contiguous.js'
2+export * from './hunks-of.js'
3+export * from './is-within-code-cap.js'
4+export * from './mathematical-bold-a.js'
5+export type * from './parsed-hunk.js'
6+export * from './parsed-hunks-of.js'
7+export * from './wide-hunk.js'
8+export * from './wide-line-letters.js'
9+
10+export * as default from '.'
@@ -1,0 +1,11 @@
1+import Views from '../../../hooks/views'
2+
3+/**
4+ * Whether a block's source is one a `Code` element may carry: at most
5+ * MAX_CODE_CHARS, counted in UTF-16 units as the engine counts.
6+ *
7+ * @param block the block
8+ * @returns true when the source fits
9+ */
10+export const isWithinCodeCap = (block: Views.CodeBlock): boolean =>
11+ block.source.length <= Views.MAX_CODE_CHARS
@@ -1,0 +1,7 @@
1+/**
2+ * U+1D400, a letter beyond the Basic Multilingual Plane: one character on
3+ * screen, two UTF-16 units in a string.
4+ *
5+ * A `Code` source's length is charged in those units.
6+ */
7+export const MATHEMATICAL_BOLD_A = '\u{1D400}'
@@ -1,0 +1,11 @@
1+/**
2+ * One hunk read back out of a `Code` source: its header's four numbers and
3+ * the marked lines under it.
4+ */
5+export type ParsedHunk = {
6+ oldStart: number
7+ oldLines: number
8+ newStart: number
9+ newLines: number
10+ lines: readonly string[]
11+}
@@ -1,0 +1,12 @@
1+import type Views from '../../../hooks/views'
2+import { hunksOf } from './hunks-of.js'
3+import type { ParsedHunk } from './parsed-hunk.js'
4+
5+/**
6+ * Every hunk a body's `Code` blocks hold, in drawing order.
7+ *
8+ * @param body the file's body as `codeBlocksOf` cut it
9+ * @returns the hunks read back out of each block's source
10+ */
11+export const parsedHunksOf = (body: Views.CodeBody): ParsedHunk[] =>
12+ body.blocks.flatMap(block => hunksOf(block.source))
@@ -1,0 +1,25 @@
1+import type Git from '../../../hooks/git'
2+import { WIDE_LINE_LETTERS } from './wide-line-letters.js'
3+
4+/**
5+ * A hunk of wide lines cycling context, removed and added: at its defaults
6+ * two share a `Code` and three do not.
7+ *
8+ * @param start the first line's number on both sides
9+ * @param count how many lines
10+ * @param letter the character each line repeats WIDE_LINE_LETTERS times
11+ * @returns the hunk
12+ */
13+export function wideHunk(start = 1, count = 40, letter = 'x'): Git.Hunk {
14+ const markers = [' ', '-', '+']
15+
16+ return {
17+ oldStart: start,
18+ newStart: start,
19+ lines: Array.from(
20+ { length: count },
21+ (_, row) =>
22+ `${markers[row % markers.length]}${letter.repeat(WIDE_LINE_LETTERS)}`,
23+ ),
24+ }
25+}
@@ -1,0 +1,4 @@
1+/**
2+ * How many letters follow each marker in a wide hunk's line.
3+ */
4+export const WIDE_LINE_LETTERS = 99
@@ -1,0 +1,5 @@
1+export * from './root-only-budget.js'
2+export * from './tracked-row.js'
3+export * from './two-listings-budget.js'
4+
5+export * as default from '.'
@@ -1,0 +1,4 @@
1+/**
2+ * A kind-walk budget that lists the repository root and nothing under it.
3+ */
4+export const ROOT_ONLY_BUDGET = 1
@@ -1,0 +1,14 @@
1+import type Git from '../../../hooks/git'
2+
3+/**
4+ * One row under git, a directory deep: past what a root-only walk lists.
5+ */
6+export const TRACKED_ROW: Git.FileStat = {
7+ path: 'deep/x.ts',
8+ renamedFrom: null,
9+ added: 1,
10+ removed: 0,
11+ isBinary: false,
12+ isUntracked: false,
13+ isPreSession: false,
14+}
@@ -1,0 +1,4 @@
1+/**
2+ * A kind-walk budget spent after the root and one directory under it.
3+ */
4+export const TWO_LISTINGS_BUDGET = 2
@@ -1,0 +1,12 @@
1+/**
2+ * An Edit tool's result record holding one hunk of one file.
3+ *
4+ * @param lines the hunk's lines, `-`/`+`/space prefixed
5+ * @returns the record as the transcript stores it
6+ */
7+export const editPatchOf = (lines: string[]) => ({
8+ filePath: '/r/a.ts',
9+ structuredPatch: [
10+ { oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines },
11+ ],
12+})
@@ -1,0 +1,5 @@
1+/**
2+ * An mtime that predates GIT_SESSION_START_MS: a file last touched before
3+ * the session.
4+ */
5+export const BEFORE_SESSION_MS = 500
@@ -1,0 +1,5 @@
1+/**
2+ * How many top-level directories, each with a nested one, the wide layout
3+ * case lists and dates.
4+ */
5+export const DEEP_DIRECTORIES = 100
@@ -1,0 +1,4 @@
1+/**
2+ * An mtime past GIT_SESSION_START_MS: a file touched this session.
3+ */
4+export const DURING_SESSION_MS = 2_000
@@ -1,0 +1,10 @@
1+import type Git from '../../../hooks/git'
2+
3+/**
4+ * The repository the fetch cases run in: a plain checkout at `/repo`.
5+ */
6+export const FETCH_REPOSITORY: Git.Repository = {
7+ toplevel: '/repo',
8+ gitDir: '/repo/.git',
9+ commonDir: '/repo/.git',
10+}
@@ -1,0 +1,18 @@
1+import type Git from '../../../hooks/git'
2+
3+/**
4+ * What the working tree lists as: the files the cases name are plain
5+ * files; the link, the leak and the submodule are something else.
6+ */
7+export const FILES_LISTING: readonly (readonly [string, Git.EntryKind])[] = [
8+ ['old.ts', 'file'],
9+ ['new.ts', 'file'],
10+ ['scratch.txt', 'file'],
11+ ['stale.txt', 'file'],
12+ ['fresh.txt', 'file'],
13+ ['a.ts', 'file'],
14+ ['staged.ts', 'file'],
15+ ['link.ts', 'other'],
16+ ['leak', 'other'],
17+ ['sub', 'other'],
18+]
@@ -1,0 +1,5 @@
1+/**
2+ * When the session began, for the git cases that date files against it by
3+ * hand-picked mtimes.
4+ */
5+export const GIT_SESSION_START_MS = 1_000
@@ -1,0 +1,12 @@
1+export * from './before-session-ms.js'
2+export * from './deep-directories.js'
3+export * from './during-session-ms.js'
4+export * from './fetch-repository.js'
5+export * from './files-listing.js'
6+export * from './git-session-start-ms.js'
7+export * from './merge-head-linked.js'
8+export * from './merging.js'
9+export * from './one-staged.js'
10+export * from './vs-head.js'
11+
12+export * as default from '.'
@@ -1,0 +1,8 @@
1+import type Git from '../../../hooks/git'
2+
3+/**
4+ * A git dir listing whose MERGE_HEAD is a symbolic link, so no merge is in
5+ * progress as far as the fetch is concerned.
6+ */
7+export const MERGE_HEAD_LINKED: readonly (readonly [string, Git.EntryKind])[] =
8+ [['MERGE_HEAD', 'other']]
@@ -1,0 +1,8 @@
1+import type Git from '../../../hooks/git'
2+
3+/**
4+ * A git dir listing mid-merge: MERGE_HEAD is a plain file.
5+ */
6+export const MERGING: readonly (readonly [string, Git.EntryKind])[] = [
7+ ['MERGE_HEAD', 'file'],
8+]
@@ -1,0 +1,4 @@
1+/**
2+ * A shortstat line for one staged file of three insertions.
3+ */
4+export const ONE_STAGED = ' 1 file changed, 3 insertions(+)'
@@ -1,0 +1,5 @@
1+/**
2+ * The argv fragment of the diff against HEAD, to script it as failing.
3+ */
4+export const VS_HEAD =
5+ 'diff.relative=false diff --ignore-submodules=dirty --submodule=short HEAD'
@@ -4,15 +4,22 @@
44 import { REPOSITORY } from './repository.js'
55
66 /**
7- * What git answers in /work (REPOSITORY); an invocation it does not know
8- * fails as git does outside a repository.
7+ * What git answers from a script of outputs by command-line key, REPOSITORY
8+ * when none is given.
99 *
10+ * An invocation the script does not know fails as git does outside a
11+ * repository.
12+ *
1013 * @param argv the invocation, program first
14+ * @param script git's output for each invocation whose line holds the key
1115 * @returns git's exit code and output
1216 */
13-export function gitIn(argv: readonly string[]): ProcessRunResult {
17+export function gitIn(
18+ argv: readonly string[],
19+ script: Readonly<Record<string, string>> = REPOSITORY,
20+): ProcessRunResult {
1421 const line = argv.join(' ')
15- const found = Object.entries(REPOSITORY).find(([key]) => line.includes(key))
22+ const found = Object.entries(script).find(([key]) => line.includes(key))
1623
1724 return found
1825 ? { exitCode: 0, stdout: found[1], stderr: '' }
@@ -1,0 +1,4 @@
1+/**
2+ * What the scripted `rev-parse` answers when the key falls back to git.
3+ */
4+export const HEAD_SHA = '0123456789abcdef0123456789abcdef01234567'
@@ -1,0 +1,4 @@
1+export * from './head-sha.js'
2+export * from './stamp.js'
3+
4+export * as default from '.'
@@ -1,0 +1,4 @@
1+/**
2+ * The mtime every stat in the HEAD-key cases answers.
3+ */
4+export const STAMP = 42
@@ -1,0 +1,8 @@
1+export * from './past-cap-count.js'
2+export * from './pre-session-row.js'
3+export * from './reported-count.js'
4+export * from './session-row.js'
5+export * from './stored-count.js'
6+export * from './test-row.js'
7+
8+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * A reported file count past the row cap: ten files git counted but the
3+ * model never stored.
4+ */
5+export const PAST_CAP_COUNT = 60
@@ -1,0 +1,14 @@
1+import type Git from '../../../hooks/git'
2+
3+/**
4+ * A file last changed before the session: its lines leave the header.
5+ */
6+export const PRE_SESSION_ROW: Git.FileStat = {
7+ path: 'old.ts',
8+ renamedFrom: null,
9+ added: 5,
10+ removed: 1,
11+ isBinary: false,
12+ isUntracked: false,
13+ isPreSession: true,
14+}
@@ -1,0 +1,4 @@
1+/**
2+ * The file count git reported, two more than the rows stored.
3+ */
4+export const REPORTED_COUNT = 4
@@ -1,0 +1,14 @@
1+import type Git from '../../../hooks/git'
2+
3+/**
4+ * A source file changed this session: three added, one removed.
5+ */
6+export const SESSION_ROW: Git.FileStat = {
7+ path: 'src/a.ts',
8+ renamedFrom: null,
9+ added: 3,
10+ removed: 1,
11+ isBinary: false,
12+ isUntracked: false,
13+ isPreSession: false,
14+}
@@ -1,0 +1,4 @@
1+/**
2+ * How many rows the model stored in the past-the-cap case.
3+ */
4+export const STORED_COUNT = 50
@@ -1,0 +1,14 @@
1+import type Git from '../../../hooks/git'
2+
3+/**
4+ * A test file changed this session, which the partition counts as noise.
5+ */
6+export const TEST_ROW: Git.FileStat = {
7+ path: 'test/a.test.ts',
8+ renamedFrom: null,
9+ added: 3,
10+ removed: 1,
11+ isBinary: false,
12+ isUntracked: false,
13+ isPreSession: false,
14+}
@@ -1,0 +1,15 @@
1+import type { RenderInput } from 'claude-code'
2+
3+import { HINT } from './hint.js'
4+
5+/**
6+ * The prompt's hint on a terminal so many columns wide: drawing it is how
7+ * the plugin learns the width.
8+ *
9+ * @param columns the terminal's width
10+ * @returns the `ui.render` input
11+ */
12+export const hintAt = (columns: number): RenderInput<'PromptHint'> => ({
13+ ...HINT,
14+ viewport: { columns, rows: HINT.viewport?.rows ?? columns },
15+})
@@ -4,19 +4,29 @@
44 import { gitIn } from './git-in.js'
55 import { HINT_DRAWN } from './hint-drawn.js'
66 import { keeping } from './keeping.js'
7+import { REPOSITORY } from './repository.js'
78 import { startsSession } from './starts-session.js'
89
910 /**
10- * A session in /work (REPOSITORY), keeping each git run and each pane
11- * opened or closed.
11+ * A session in a repository git answers for from a script (REPOSITORY, in
12+ * /work, when none is given), keeping what the plugin does there.
1213 *
13- * The store starts empty, the clock at 0, and the engine draws the hint.
14+ * Kept: each git run, pane opened or closed, and status line. The clock
15+ * starts at 0 and the engine draws the hint. A test that rewrites the
16+ * script between calls changes what git answers next.
1417 *
1518 * @param on the test's `on`
16- * @returns the runs, the panes opened and closed, and the session's clock
19+ * @param script git's output for each invocation whose line holds the key
20+ * @param stored what the plugin's store holds at the start
21+ * @returns the runs, the panes opened and closed, the statuses, the clock
1722 */
18-export function inRepository(on: On) {
23+export function inRepository(
24+ on: On,
25+ script: Readonly<Record<string, string>> = REPOSITORY,
26+ stored: Readonly<Record<string, unknown>> = {},
27+) {
1928 const runs: Args<'process.run'>[] = []
29+ const statuses: (string | undefined)[] = []
2030 const opened = keeping<Args<'ui.open'>>()
2131 const closed = keeping<Args<'ui.close'>>()
2232 const clock = startsSession(on)
@@ -24,15 +34,21 @@
2434 on('process.run', ($, e) => {
2535 runs.push(e)
2636
27- return { value: gitIn(e.argv) }
37+ return { value: gitIn(e.argv, script) }
2838 })
2939
40+ on('ui.status', ($, e) => {
41+ statuses.push(e.text)
42+
43+ return { value: undefined }
44+ })
45+
3046 on('ui.open', opened.hook)
3147 on('ui.close', closed.hook)
3248 on('ui.invalidate', () => ({ value: undefined }))
3349 on('ui.render', { component: 'PromptHint' }, () => HINT_DRAWN)
3450 on('session.messages', () => ({ value: [] }))
35- mock.store(on)
51+ mock.store(on, stored)
3652
37- return { runs, opened: opened.kept, closed: closed.kept, clock }
53+ return { runs, opened: opened.kept, closed: closed.kept, statuses, clock }
3854 }
@@ -1,18 +1,44 @@
1+export * from './ask'
2+export * from './backend-of'
3+export * from './base-label-of'
4+export * from './big-lines.js'
5+export * from './branch-base-of'
16 export * from './builtin-holds.js'
7+export * from './checkout.js'
28 export * from './clear.js'
9+export * from './code-blocks-of'
10+export * from './datings-of'
311 export * from './diff.js'
12+export * from './edit-patch-of.js'
13+export * from './fetch-diff'
414 export * from './git-hung.js'
515 export * from './git-in.js'
16+export * from './head-key-of'
17+export * from './header-totals-of'
618 export * from './hint.js'
19+export * from './hint-at.js'
720 export * from './hint-drawn.js'
821 export * from './in-repository.js'
922 export * from './keeping.js'
23+export * from './kind-walk-of'
24+export * from './no-stats.js'
1025 export * from './not-a-repository.js'
1126 export * from './pane.js'
27+export * from './pane-view'
28+export * from './parse-file-diff'
1229 export * from './repository.js'
30+export * from './repository-of'
31+export * from './scripted'
1332 export * from './session.js'
1433 export * from './settle-ms.js'
1534 export * from './starts-session.js'
16-export * from './text-of.js'
35+export * from './todo-write-of.js'
36+export * from './tree'
37+export * from './truncate-start'
38+export * from './turn-diffs-of'
39+export * from './typed-prompt-of.js'
40+export * from './vs-main.js'
41+export * from './worktree'
42+export * from './wrapped-lines'
1743
1844 export * as default from '.'
@@ -1,0 +1,4 @@
1+export * from './walk-budget.js'
2+export * from './walk-depth.js'
3+
4+export * as default from '.'
@@ -1,0 +1,4 @@
1+/**
2+ * How many directory listings the kind walk may spend.
3+ */
4+export const WALK_BUDGET = 8
@@ -1,0 +1,4 @@
1+/**
2+ * The depth of the path that outruns any listing budget.
3+ */
4+export const WALK_DEPTH = 50_000
@@ -1,0 +1,10 @@
1+import type Git from '../../hooks/git'
2+
3+/**
4+ * The totals of a diff with nothing in it.
5+ */
6+export const NO_STATS: Git.DiffStats = {
7+ filesCount: 0,
8+ linesAdded: 0,
9+ linesRemoved: 0,
10+}
@@ -1,0 +1,4 @@
1+/**
2+ * The pane's body rows in the ordinary cases: docked in a 40-row terminal.
3+ */
4+export const BODY_ROWS = 35
@@ -1,0 +1,8 @@
1+/**
2+ * The panel row the built-in draws its empty headline on, docked in a
3+ * 40-row terminal over one pre-session file.
4+ *
5+ * Its header's top pad, the close, the header's bottom pad, then half the
6+ * spare rows.
7+ */
8+export const BUILTIN_HEADLINE_ROW = 17
@@ -1,0 +1,4 @@
1+/**
2+ * The pane's body width in the ordinary cases.
3+ */
4+export const COLUMNS = 60
@@ -1,0 +1,20 @@
1+import type Views from '../../../hooks/views'
2+import { SMALL_BODY } from './small-body.js'
3+
4+/**
5+ * A selected config file as the detail view takes it: untracked with no
6+ * body read, or tracked with a small body ready.
7+ *
8+ * @param isUntracked whether the file is untracked
9+ * @returns the detail model
10+ */
11+export const configDetailOf = (isUntracked: boolean): Views.DetailModel => ({
12+ words: { untrackedNoteOf: () => ['New file not yet staged.'] },
13+ path: 'src/app/config.ts',
14+ renamedFrom: null,
15+ isUntracked,
16+ isBinary: false,
17+ body: isUntracked ? null : SMALL_BODY,
18+ bodyState: isUntracked ? 'idle' : 'ready',
19+ isArmed: false,
20+})
@@ -1,0 +1,4 @@
1+/**
2+ * Body rows too few to center an empty state in, so it stacks from the top.
3+ */
4+export const CRAMPED_ROWS = 4
@@ -1,0 +1,20 @@
1+import type { On, RenderElement } from 'claude-code'
2+import type { Engine } from 'claude-code/testing'
3+
4+import type PaneState from '../../../hooks/pane-state'
5+import { docksPane } from './docks-pane.js'
6+
7+/**
8+ * The pane docked beneath the plugin over one model, drawn once through the
9+ * engine: the tree the engine took.
10+ *
11+ * @param $ the test's `$`
12+ * @param on the test's `on`
13+ * @param model the pane's model
14+ * @returns the tree
15+ */
16+export const dockedPane = (
17+ $: Engine,
18+ on: On,
19+ model: PaneState.PaneModel,
20+): Promise<RenderElement> => docksPane($, on)(model)
@@ -1,0 +1,35 @@
1+import type { On, RenderElement } from 'claude-code'
2+import type { Engine } from 'claude-code/testing'
3+
4+import PaneState from '../../../hooks/pane-state'
5+import Views from '../../../hooks/views'
6+import { drawsPane } from './draws-pane.js'
7+import { VIEW_PANE } from './view-pane.js'
8+
9+/**
10+ * The pane docked beneath the plugin over whichever model the test hands
11+ * next.
12+ *
13+ * Each call draws VIEW_PANE through the engine with `paneView` over that
14+ * model and answers the tree the engine took.
15+ *
16+ * @param $ the test's `$`
17+ * @param on the test's `on`
18+ * @param overrides kit members that differ from the ordinary ones
19+ * @returns draws the pane over a model
20+ */
21+export function docksPane(
22+ $: Engine,
23+ on: On,
24+ overrides: Partial<Omit<Views.Kit, 'ui'>> = {},
25+): (model: PaneState.PaneModel) => Promise<RenderElement> {
26+ let model = PaneState.INITIAL_MODEL
27+
28+ drawsPane(on, kit => Views.paneView(kit, model, 'dock'), overrides)
29+
30+ return next => {
31+ model = next
32+
33+ return $.ui.render(VIEW_PANE)
34+ }
35+}
@@ -1,0 +1,39 @@
1+import type { On, RenderElement } from 'claude-code'
2+
3+import { isOnPaneSurface } from '../../../hooks/is-on-pane-surface'
4+import type Views from '../../../hooks/views'
5+import { BODY_ROWS } from './body-rows.js'
6+import { COLUMNS } from './columns.js'
7+import { noopActionsOf } from './noop-actions-of.js'
8+
9+/**
10+ * Draws a pane beneath the plugin with a view of the test's, handed the kit
11+ * every view takes.
12+ *
13+ * The hook resolves the surface's elements; the body size is the view
14+ * fixtures' and the actions do nothing, unless overridden.
15+ *
16+ * @param on the test's `on`
17+ * @param view draws the tree from the kit
18+ * @param overrides kit members that differ from the ordinary ones
19+ */
20+export const drawsPane = (
21+ on: On,
22+ view: (kit: Views.Kit) => RenderElement,
23+ overrides: Partial<Omit<Views.Kit, 'ui'>> = {},
24+) =>
25+ on('ui.render', { component: 'Pane' }, async ($, e, next) => {
26+ if (!isOnPaneSurface(e)) {
27+ return next(e)
28+ }
29+
30+ const { Box, Text, Button, Select, Code } = await $.ui.resolve(e)
31+
32+ return view({
33+ ui: { Box, Text, Button, Select, Code },
34+ actions: noopActionsOf(),
35+ columns: COLUMNS,
36+ rows: BODY_ROWS,
37+ ...overrides,
38+ })
39+ })
@@ -1,0 +1,23 @@
1+export * from './body-rows.js'
2+export * from './builtin-headline-row.js'
3+export * from './columns.js'
4+export * from './config-detail-of.js'
5+export * from './cramped-rows.js'
6+export * from './docked-pane.js'
7+export * from './docks-pane.js'
8+export * from './draws-pane.js'
9+export * from './long-line-chars.js'
10+export * from './long-path-segments.js'
11+export * from './noop.js'
12+export * from './noop-actions-of.js'
13+export * from './row-of.js'
14+export * from './row-shape-of.js'
15+export * from './row-shapes-of.js'
16+export * from './small-body.js'
17+export * from './totals.js'
18+export * from './tracked.js'
19+export * from './turn-two.js'
20+export * from './view-pane.js'
21+export * from './vs-spoofed.js'
22+
23+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * How long each line of the budget case runs: four hundred of them outgrow
3+ * the body's character budget.
4+ */
5+export const LONG_LINE_CHARS = 300
@@ -1,0 +1,5 @@
1+/**
2+ * How many directory segments make a selected path longer than the engine
3+ * allows one string to be.
4+ */
5+export const LONG_PATH_SEGMENTS = 2_500
@@ -1,0 +1,17 @@
1+import type Views from '../../../hooks/views'
2+import { noop } from './noop.js'
3+
4+/**
5+ * Pane actions that each do nothing, for a tree drawn only to be read.
6+ *
7+ * @returns the actions
8+ */
9+export const noopActionsOf = (): Views.PaneActions => ({
10+ selectFile: noop,
11+ toggleNoise: noop,
12+ togglePreSession: noop,
13+ chooseBase: noop,
14+ chooseSource: noop,
15+ toggleAsk: noop,
16+ close: noop,
17+})
@@ -1,0 +1,7 @@
1+/**
2+ * An action that does nothing, for a view whose actions a case never
3+ * presses.
4+ *
5+ * @returns undefined
6+ */
7+export const noop = () => undefined
@@ -1,0 +1,22 @@
1+import type Git from '../../../hooks/git'
2+import { TRACKED } from './tracked.js'
3+
4+/**
5+ * A file row at a path: two added, one removed, tracked and in session,
6+ * unless the overrides say otherwise.
7+ *
8+ * @param path the row's path
9+ * @param overrides fields that differ from the ordinary row
10+ * @returns the row
11+ */
12+export const rowOf = (
13+ path: string,
14+ overrides: Partial<Git.FileStat> = {},
15+): Git.FileStat => ({
16+ path,
17+ renamedFrom: null,
18+ added: 2,
19+ removed: 1,
20+ ...TRACKED,
21+ ...overrides,
22+})
@@ -1,0 +1,18 @@
1+import Tree from '../tree'
2+
3+/**
4+ * A pane row as the layout tests read it: `blank` for a bare one-row Box,
5+ * `rule` for a line of rule characters, else its first label or its strings.
6+ *
7+ * @param node a row of the pane's body
8+ * @returns the row's shape
9+ */
10+export function rowShapeOf(node: unknown) {
11+ const text = Tree.jsonOf(node)
12+ const strings = Tree.stringsOf(node).join('')
13+ const label = /"label":"([^"]*)"/.exec(text)?.[1]
14+ const isBlank = text === '{"type":"Box","props":{"height":1}}'
15+ const isRule = /^─+$/.test(strings)
16+
17+ return isBlank ? 'blank' : isRule ? 'rule' : (label ?? strings)
18+}
@@ -1,0 +1,12 @@
1+import Tree from '../tree'
2+import { rowShapeOf } from './row-shape-of.js'
3+
4+/**
5+ * The rows of a pane's body, top to bottom, each as the layout tests read
6+ * it (rowShapeOf).
7+ *
8+ * @param tree the pane's tree, its body the first child
9+ * @returns each row's shape
10+ */
11+export const rowShapesOf = (tree: unknown) =>
12+ Tree.childrenOf(Tree.childrenOf(tree)[0]).map(rowShapeOf)
@@ -1,0 +1,11 @@
1+import type Git from '../../../hooks/git'
2+
3+/**
4+ * A one-hunk body with a context line, a removal and an addition: enough
5+ * to draw the word highlight.
6+ */
7+export const SMALL_BODY: Git.FileHunks = {
8+ hunks: [{ oldStart: 1, newStart: 1, lines: [' ctx', '-old', '+new'] }],
9+ isTruncated: false,
10+ isLarge: false,
11+}
@@ -1,0 +1,11 @@
1+import type Git from '../../../hooks/git'
2+
3+/**
4+ * What every pane-view diff sums to; the file count is replaced by the
5+ * number of rows a case gives.
6+ */
7+export const TOTALS: Git.DiffStats = {
8+ filesCount: 1,
9+ linesAdded: 9,
10+ linesRemoved: 3,
11+}
@@ -1,0 +1,8 @@
1+/**
2+ * The flags of an ordinary row: text, tracked, changed this session.
3+ */
4+export const TRACKED = {
5+ isBinary: false,
6+ isUntracked: false,
7+ isPreSession: false,
8+} as const
@@ -1,0 +1,20 @@
1+import type Turns from '../../../hooks/turns'
2+
3+/**
4+ * The second turn of a session as the turns model keeps it: one created
5+ * file, its hunk cut at the line cap.
6+ */
7+export const TURN_TWO: Turns.TurnDiff = {
8+ index: 2,
9+ preview: 'fix it',
10+ files: [
11+ {
12+ path: '/r/a.ts',
13+ hunks: [{ oldStart: 1, newStart: 1, lines: ['+x'] }],
14+ added: 1,
15+ removed: 0,
16+ isNewFile: true,
17+ isTruncated: true,
18+ },
19+ ],
20+}
@@ -1,0 +1,25 @@
1+import type { RenderInput } from 'claude-code'
2+
3+import { BODY_ROWS } from './body-rows.js'
4+import { COLUMNS } from './columns.js'
5+
6+/**
7+ * A pane the plugin does not own, docked on a 160-column terminal at the
8+ * view fixtures' body size.
9+ *
10+ * Its render passes beneath the plugin to the hook a test draws a view in.
11+ */
12+export const VIEW_PANE: RenderInput<'Pane'> = {
13+ component: 'Pane',
14+ surface: 'terminal',
15+ requestId: 'view',
16+ viewport: { columns: 160, rows: 40 },
17+ props: {
18+ title: 'View',
19+ isFocused: false,
20+ bodyColumns: COLUMNS,
21+ placement: 'dock',
22+ scroll: { offset: 0, bodyRows: BODY_ROWS },
23+ view: {},
24+ },
25+}
@@ -1,0 +1,10 @@
1+import type Git from '../../../hooks/git'
2+
3+/**
4+ * A branch comparison whose base branch name hides a right-to-left
5+ * override, to prove the view strips format characters.
6+ */
7+export const VS_SPOOFED: Pick<Git.DiffData, 'mode' | 'source'> = {
8+ mode: 'branch',
9+ source: { kind: 'branch', baseBranch: 'ma\u202Eniam', baseRef: 'abc' },
10+}
@@ -1,0 +1,5 @@
1+/**
2+ * The four header lines git prints before a file's first hunk.
3+ */
4+export const FILE_DIFF_HEADER =
5+ 'diff --git a/a.txt b/a.txt\nindex 1..2 100644\n--- a/a.txt\n+++ b/a.txt\n'
@@ -1,0 +1,4 @@
1+export * from './file-diff-header.js'
2+export * from './past-the-cap.js'
3+
4+export * as default from '.'
@@ -1,0 +1,4 @@
1+/**
2+ * How many lines past the per-file cap the truncation case feeds.
3+ */
4+export const PAST_THE_CAP = 5
@@ -1,0 +1,5 @@
1+/**
2+ * What a git too old for `--path-format` prints: the flag echoed back, then
3+ * relative paths.
4+ */
5+export const ECHOED_FLAG = '--path-format=absolute\n/r\n.git\n.git\n'
@@ -1,0 +1,6 @@
1+export * from './echoed-flag.js'
2+export * from './relative.js'
3+export * from './resolved.js'
4+export * from './windows.js'
5+
6+export * as default from '.'
@@ -1,0 +1,4 @@
1+/**
2+ * A `rev-parse` answer whose git dir is relative: not a pin.
3+ */
4+export const RELATIVE = '/r\n.git\n/r/.git\n'
@@ -1,0 +1,5 @@
1+/**
2+ * A `rev-parse` answer of three absolute lines for a linked worktree of
3+ * `/r`.
4+ */
5+export const RESOLVED = '/r\n/r/.git/worktrees/w\n/r/.git\n'
@@ -1,0 +1,5 @@
1+/**
2+ * A `rev-parse` answer with drive-letter paths, absolute as git prints them
3+ * on Windows.
4+ */
5+export const WINDOWS = 'C:/r\nC:/r/.git\nC:/r/.git\n'
@@ -1,0 +1,11 @@
1+import type { ProcessRunResult } from 'claude-code'
2+
3+/**
4+ * What the plugin's git runner answers when the host rejected the spawn:
5+ * the timeout, a cancelled dispatch. No exit code git itself uses.
6+ */
7+export const GIT_TIMED_OUT: ProcessRunResult = {
8+ exitCode: -1,
9+ stdout: '',
10+ stderr: '',
11+}
@@ -1,0 +1,9 @@
1+export * from './git-timed-out.js'
2+export * from './ok.js'
3+export * from './probe-missed.js'
4+export * from './probed-repository-of.js'
5+export type * from './scripted-git.js'
6+export * from './scripted-git-of.js'
7+export * from './unscripted.js'
8+
9+export * as default from '.'
@@ -1,0 +1,14 @@
1+import type { ProcessRunResult } from 'claude-code'
2+
3+/**
4+ * A scripted git answer that exited 0 with the given output, nothing on
5+ * stderr.
6+ *
7+ * @param stdout what the command printed; nothing by default
8+ * @returns the run result
9+ */
10+export const ok = (stdout = ''): ProcessRunResult => ({
11+ exitCode: 0,
12+ stdout,
13+ stderr: '',
14+})
@@ -1,0 +1,11 @@
1+import type { ProcessRunResult } from 'claude-code'
2+
3+/**
4+ * A probe that ran and found nothing: `rev-parse --verify` on an unborn
5+ * HEAD, `merge-base` with no common ancestor, `--is-ancestor` when not.
6+ */
7+export const PROBE_MISSED: ProcessRunResult = {
8+ exitCode: 1,
9+ stdout: '',
10+ stderr: '',
11+}
@@ -1,0 +1,12 @@
1+import Git from '../../../hooks/git'
2+import { ok } from './ok.js'
3+import { scriptedGitOf } from './scripted-git-of.js'
4+
5+/**
6+ * What the repository probe makes of a scripted `rev-parse` answer.
7+ *
8+ * @param stdout the scripted rev-parse output
9+ * @returns the probed repository, or null
10+ */
11+export const probedRepositoryOf = (stdout: string) =>
12+ Git.repositoryOf(scriptedGitOf({ 'rev-parse': ok(stdout) }).run)
@@ -1,0 +1,32 @@
1+import type { ProcessRunResult } from 'claude-code'
2+
3+import type Git from '../../../hooks/git'
4+import type { ScriptedGit } from './scripted-git.js'
5+import { UNSCRIPTED } from './unscripted.js'
6+
7+/**
8+ * A git answering from a script: the first entry whose pattern the joined
9+ * argv contains, UNSCRIPTED otherwise; every argv is kept in order.
10+ *
11+ * @param script pattern to result
12+ * @returns the runner and the argvs it saw
13+ */
14+export function scriptedGitOf(
15+ script: Readonly<Record<string, ProcessRunResult>>,
16+): ScriptedGit {
17+ const argvs: (readonly string[])[] = []
18+
19+ const run: Git.GitRun = argv => {
20+ argvs.push(argv)
21+
22+ const line = argv.join(' ')
23+
24+ const scripted = Object.entries(script).find(([pattern]) =>
25+ line.includes(pattern),
26+ )
27+
28+ return Promise.resolve(scripted?.[1] ?? UNSCRIPTED)
29+ }
30+
31+ return { run, argvs }
32+}
@@ -1,0 +1,10 @@
1+import type Git from '../../../hooks/git'
2+
3+/**
4+ * A git answering from a script: the runner, and every argv it has been
5+ * handed so far.
6+ */
7+export type ScriptedGit = {
8+ run: Git.GitRun
9+ argvs: readonly (readonly string[])[]
10+}
@@ -1,0 +1,11 @@
1+import type { ProcessRunResult } from 'claude-code'
2+
3+/**
4+ * What a scripted git answers for a command the script does not name: the
5+ * exit code git uses for a fatal error, and no output.
6+ */
7+export const UNSCRIPTED: ProcessRunResult = {
8+ exitCode: 128,
9+ stdout: '',
10+ stderr: '',
11+}
Dmods/diff/tests/fixtures/text-of.ts+0 -28
@@ -1,28 +1,0 @@
1-/**
2- * A rendered tree's text as it reads: its strings and its labels, in order.
3- *
4- * @param tree what `$.ui.render` resolved to, or a part of it
5- * @returns the text
6- */
7-export function textOf(tree: unknown): string {
8- if (typeof tree === 'string' || typeof tree === 'number') {
9- return String(tree)
10- }
11-
12- if (Array.isArray(tree)) {
13- return tree.map(textOf).join('')
14- }
15-
16- if (typeof tree !== 'object' || !tree) {
17- return ''
18- }
19-
20- const props: unknown = Reflect.get(tree, 'props')
21-
22- const label: unknown =
23- typeof props === 'object' && props ? Reflect.get(props, 'label') : undefined
24-
25- const lead = typeof label === 'string' ? label : ''
26-
27- return `${lead}${textOf(Reflect.get(tree, 'children') ?? [])}`
28-}
@@ -1,0 +1,20 @@
1+import type { SessionMessage } from 'claude-code'
2+
3+/**
4+ * An assistant message holding one TodoWrite whose items have the given
5+ * statuses.
6+ *
7+ * @param statuses each item's status
8+ * @returns the message
9+ */
10+export const todoWriteOf = (statuses: readonly string[]): SessionMessage => ({
11+ role: 'assistant',
12+ text: '',
13+ toolUses: [
14+ {
15+ tool_use_id: 'w',
16+ tool: 'TodoWrite',
17+ input: { todos: statuses.map(status => ({ content: 'x', status })) },
18+ },
19+ ],
20+})
@@ -1,0 +1,13 @@
1+/**
2+ * A rendered tree node's children as the element table built them, or
3+ * none for a string, a null, or a childless element.
4+ *
5+ * @param node an element, a string child, or anything else
6+ * @returns the children array, possibly empty
7+ */
8+export function childrenOf(node: unknown): readonly unknown[] {
9+ const isElement = typeof node === 'object' && node !== null
10+ const held: unknown = isElement ? Reflect.get(node, 'children') : null
11+
12+ return Array.isArray(held) ? held : []
13+}
@@ -1,0 +1,20 @@
1+import type { CodeProps, RenderNode } from 'claude-code'
2+
3+/**
4+ * The props of every `Code` under a rendered tree node, depth-first in
5+ * drawing order.
6+ *
7+ * @param node an element or a string
8+ * @returns the Code props
9+ */
10+export function codesIn(node: RenderNode): CodeProps[] {
11+ const isText = typeof node === 'string'
12+ const isCode = !isText && node.type === 'Code'
13+ const hasChildren = !isText && 'children' in node
14+
15+ return isCode
16+ ? [node.props]
17+ : hasChildren
18+ ? (node.children ?? []).flatMap(codesIn)
19+ : []
20+}
@@ -1,0 +1,39 @@
1+import type { ElementQuery } from './element-query.js'
2+
3+/**
4+ * The first element in a rendered tree of that type whose `props.key` or
5+ * `props.label` is the name, depth-first.
6+ *
7+ * @param tree what `$.ui.render` resolved to
8+ * @param wanted the element's type and its key or label
9+ * @returns the element as a plain record, or undefined
10+ */
11+export function elementIn(
12+ tree: unknown,
13+ wanted: ElementQuery,
14+): Record<string, unknown> | undefined {
15+ if (typeof tree !== 'object' || !tree) {
16+ return undefined
17+ }
18+
19+ const node: Record<string, unknown> = Object.fromEntries(Object.entries(tree))
20+ const isPropped = typeof node.props === 'object' && node.props !== null
21+
22+ const named: Record<string, unknown> = isPropped
23+ ? Object.fromEntries(Object.entries(node.props ?? {}))
24+ : {}
25+
26+ const isMatch =
27+ node.type === wanted.type &&
28+ (named.key === wanted.name || named.label === wanted.name)
29+
30+ const children: readonly unknown[] = Array.isArray(node.children)
31+ ? node.children
32+ : []
33+
34+ return isMatch
35+ ? node
36+ : children
37+ .map(child => elementIn(child, wanted))
38+ .find(found => found !== undefined)
39+}
@@ -1,0 +1,7 @@
1+/**
2+ * Which element elementIn looks for: its type, and its key or label.
3+ */
4+export type ElementQuery = {
5+ type: string
6+ name: string
7+}
@@ -1,0 +1,10 @@
1+export * from './children-of.js'
2+export * from './codes-in.js'
3+export * from './element-in.js'
4+export type * from './element-query.js'
5+export * from './is-drawn.js'
6+export * from './json-of.js'
7+export * from './strings-of.js'
8+export * from './text-of.js'
9+
10+export * as default from '.'
@@ -1,0 +1,13 @@
1+/**
2+ * Whether `$.ui.render` answered the tree a hook drew, not the engine's own.
3+ *
4+ * A tree the engine refused (an element it lacks, a text or node budget
5+ * outrun) comes back as the engine's own drawing instead, an `engine` node.
6+ *
7+ * @param tree what `$.ui.render` resolved to
8+ * @returns true for a hook's tree, false for the engine's own
9+ */
10+export const isDrawn = (tree: unknown): boolean =>
11+ typeof tree === 'object' &&
12+ tree !== null &&
13+ Reflect.get(tree, 'type') !== 'engine'
@@ -1,0 +1,8 @@
1+/**
2+ * A render tree flattened to one searchable string: its JSON, so a test
3+ * finds words, theme keys and element names alike.
4+ *
5+ * @param tree what `$.ui.render` resolved to
6+ * @returns the JSON text
7+ */
8+export const jsonOf = (tree: unknown) => JSON.stringify(tree)
@@ -1,0 +1,11 @@
1+import { childrenOf } from './children-of.js'
2+
3+/**
4+ * Every string child under a rendered tree node, depth-first in drawing
5+ * order: what the engine counts against its text caps.
6+ *
7+ * @param node an element or a string
8+ * @returns the strings
9+ */
10+export const stringsOf = (node: unknown): string[] =>
11+ typeof node === 'string' ? [node] : childrenOf(node).flatMap(stringsOf)
@@ -1,0 +1,28 @@
1+/**
2+ * A rendered tree's text as it reads: its strings and its labels, in order.
3+ *
4+ * @param tree what `$.ui.render` resolved to, or a part of it
5+ * @returns the text
6+ */
7+export function textOf(tree: unknown): string {
8+ if (typeof tree === 'string' || typeof tree === 'number') {
9+ return String(tree)
10+ }
11+
12+ if (Array.isArray(tree)) {
13+ return tree.map(textOf).join('')
14+ }
15+
16+ if (typeof tree !== 'object' || !tree) {
17+ return ''
18+ }
19+
20+ const props: unknown = Reflect.get(tree, 'props')
21+
22+ const label: unknown =
23+ typeof props === 'object' && props ? Reflect.get(props, 'label') : undefined
24+
25+ const lead = typeof label === 'string' ? label : ''
26+
27+ return `${lead}${textOf(Reflect.get(tree, 'children') ?? [])}`
28+}
@@ -1,0 +1,5 @@
1+/**
2+ * U+0301, a combining mark: zero cells wide on screen, one code point in a
3+ * string, so the layout charges it a cell.
4+ */
5+export const COMBINING_ACUTE = '\u0301'
@@ -1,0 +1,6 @@
1+export * from './combining-acute.js'
2+export * from './marks.js'
3+export * from './narrow-room.js'
4+export * from './room.js'
5+
6+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * How many combining marks pad the path: far more than the room if each
3+ * is charged a cell.
4+ */
5+export const MARKS = 2_000
@@ -1,0 +1,4 @@
1+/**
2+ * A room too narrow for the leading directories, so only the tail is kept.
3+ */
4+export const NARROW_ROOM = 12
@@ -1,0 +1,4 @@
1+/**
2+ * The cells a cut path may fill in the ordinary case.
3+ */
4+export const ROOM = 20
@@ -1,0 +1,19 @@
1+import type { SessionMessage } from 'claude-code'
2+
3+/**
4+ * An assistant message whose Edit calls came back with the given results,
5+ * in order.
6+ *
7+ * @param results each call's structured result
8+ * @returns the message
9+ */
10+export const editsOf = (...results: unknown[]): SessionMessage => ({
11+ role: 'assistant',
12+ text: '',
13+ toolUses: results.map((result, at) => ({
14+ tool_use_id: `t${at}`,
15+ tool: 'Edit',
16+ input: {},
17+ result,
18+ })),
19+})
@@ -1,0 +1,5 @@
1+export * from './edits-of.js'
2+export * from './prompt-of.js'
3+export * from './tool-results.js'
4+
5+export * as default from '.'
@@ -1,0 +1,13 @@
1+import type { SessionMessage } from 'claude-code'
2+
3+/**
4+ * A prompt the person typed, as the transcript holds it.
5+ *
6+ * @param text the prompt
7+ * @returns the message
8+ */
9+export const promptOf = (text: string): SessionMessage => ({
10+ role: 'user',
11+ text,
12+ toolUses: [],
13+})
@@ -1,0 +1,13 @@
1+import type { SessionMessage } from 'claude-code'
2+
3+/**
4+ * The message that carries a turn's tool results back, no text of its own.
5+ *
6+ * @returns the message
7+ */
8+export const toolResults = (): SessionMessage => ({
9+ role: 'user',
10+ text: '',
11+ toolUses: [],
12+ toolResults: [{ tool_use_id: 't', text: 'ok', isError: false }],
13+})
@@ -1,0 +1,19 @@
1+import type { PromptSubmitInput } from 'claude-code'
2+
3+/**
4+ * A prompt as the person submits it from the composer with a plain Enter,
5+ * carrying the context given, if any.
6+ *
7+ * @param text what the person typed
8+ * @param context what already rides beside the prompt
9+ * @returns the `prompt.submit` input
10+ */
11+export const typedPromptOf = (
12+ text: string,
13+ context?: readonly string[],
14+): PromptSubmitInput => ({
15+ text,
16+ ...(context && { context }),
17+ wait: false,
18+ origin: { kind: 'composer' },
19+})
@@ -1,0 +1,11 @@
1+import type Git from '../../hooks/git'
2+
3+/**
4+ * A branch comparison that resolved: main as the base, at a placeholder
5+ * merge-base ref.
6+ */
7+export const VS_MAIN: Git.DiffSource = {
8+ kind: 'branch',
9+ baseBranch: 'main',
10+ baseRef: 'x',
11+}
@@ -1,0 +1,4 @@
1+/**
2+ * `git rev-parse` for a repository other than the pinned one.
3+ */
4+export const ELSEWHERE_LINES = '/else\n/else/.git\n/else/.git\n'
@@ -1,0 +1,5 @@
1+/**
2+ * How `$.process.run` refuses a git child that could not start.
3+ */
4+export const FAILED_START =
5+ 'process.run(git) failed to start: ENOENT: no such file or directory'
@@ -1,0 +1,9 @@
1+export * from './elsewhere-lines.js'
2+export * from './failed-start.js'
3+export * from './one-secret.js'
4+export * from './pinned-lead.js'
5+export * from './secret-diff.js'
6+export * from './worktree-lines.js'
7+export * from './worktree-session.js'
8+
9+export * as default from '.'
@@ -1,0 +1,16 @@
1+import { SECRET_DIFF } from './secret-diff.js'
2+import { WORKTREE_LINES } from './worktree-lines.js'
3+
4+/**
5+ * Git's output in the linked worktree /main/wt, whose one change is a
6+ * secrets file, for each invocation whose command line holds the key.
7+ *
8+ * @returns the answers, fresh each call so a test may rewrite them
9+ */
10+export const oneSecret = (): Record<string, string> => ({
11+ 'rev-parse --path-format=absolute': WORKTREE_LINES,
12+ 'HEAD --shortstat': ' 1 file changed, 1 insertion(+)',
13+ 'HEAD --numstat': '1\t0\t.env\0',
14+ 'ls-files': '',
15+ '-- .env': SECRET_DIFF,
16+})
@@ -1,0 +1,9 @@
1+/**
2+ * What leads every git child's argv once the linked worktree is pinned:
3+ * its own git dir and its working tree.
4+ */
5+export const PINNED_LEAD: readonly string[] = [
6+ 'git',
7+ '--git-dir=/main/.git/worktrees/wt',
8+ '--work-tree=/main/wt',
9+]
@@ -1,0 +1,4 @@
1+/**
2+ * A one-line diff of a secrets file.
3+ */
4+export const SECRET_DIFF = '@@ -1 +1 @@\n-KEY=old\n+KEY=new\n'
@@ -1,0 +1,4 @@
1+/**
2+ * `git rev-parse` for a linked worktree: toplevel, git dir, common dir.
3+ */
4+export const WORKTREE_LINES = '/main/wt\n/main/.git/worktrees/wt\n/main/.git\n'
@@ -1,0 +1,10 @@
1+import type { SessionStartInput } from 'claude-code'
2+
3+/**
4+ * An interactive terminal session in the linked worktree /main/wt.
5+ */
6+export const WORKTREE_SESSION: SessionStartInput = {
7+ surface: 'terminal',
8+ isInteractive: true,
9+ cwd: '/main/wt',
10+}
@@ -1,0 +1,124 @@
1+/**
2+ * The built-in panel's empty-state lines as Ink wraps them (trim off,
3+ * hard) at each width the layout case tries, keyed by line then width.
4+ */
5+export const BUILTIN_WRAPS: Readonly<
6+ Record<string, Readonly<Record<number, readonly string[]>>>
7+> = {
8+ 'No changes this session': {
9+ 8: ['No ', 'changes ', 'this ', 'session'],
10+ 23: ['No changes this session'],
11+ 24: ['No changes this session'],
12+ 48: ['No changes this session'],
13+ 70: ['No changes this session'],
14+ },
15+ "Couldn't read the git diff — it will retry on the next change": {
16+ 8: [
17+ "Couldn't",
18+ ' read ',
19+ 'the git ',
20+ 'diff — ',
21+ 'it will ',
22+ 'retry on',
23+ ' the ',
24+ 'next ',
25+ 'change',
26+ ],
27+ 23: [
28+ "Couldn't read the git ",
29+ 'diff — it will retry on',
30+ ' the next change',
31+ ],
32+ 24: [
33+ "Couldn't read the git ",
34+ 'diff — it will retry on ',
35+ 'the next change',
36+ ],
37+ 48: ["Couldn't read the git diff — it will retry on ", 'the next change'],
38+ 70: ["Couldn't read the git diff — it will retry on the next change"],
39+ },
40+ 'No base branch to compare against — showing changes vs HEAD': {
41+ 8: [
42+ 'No base ',
43+ 'branch ',
44+ 'to ',
45+ 'compare ',
46+ 'against ',
47+ '— ',
48+ 'showing ',
49+ 'changes ',
50+ 'vs HEAD',
51+ ],
52+ 23: ['No base branch to ', 'compare against — ', 'showing changes vs HEAD'],
53+ 24: ['No base branch to ', 'compare against — ', 'showing changes vs HEAD'],
54+ 48: ['No base branch to compare against — showing ', 'changes vs HEAD'],
55+ 70: ['No base branch to compare against — showing changes vs HEAD'],
56+ },
57+ 'Tests and generated files are hidden · click "show" above to view them': {
58+ 8: [
59+ 'Tests ',
60+ 'and gene',
61+ 'rated ',
62+ 'files ',
63+ 'are ',
64+ 'hidden ·',
65+ ' click ',
66+ '"show" ',
67+ 'above to',
68+ ' view ',
69+ 'them',
70+ ],
71+ 23: [
72+ 'Tests and generated ',
73+ 'files are hidden · ',
74+ 'click "show" above to ',
75+ 'view them',
76+ ],
77+ 24: [
78+ 'Tests and generated ',
79+ 'files are hidden · click',
80+ ' "show" above to view ',
81+ 'them',
82+ ],
83+ 48: [
84+ 'Tests and generated files are hidden · click ',
85+ '"show" above to view them',
86+ ],
87+ 70: [
88+ 'Tests and generated files are hidden · click "show" above to view them',
89+ ],
90+ },
91+ 'No changes vs a-release-branch-with-a-name-longer-than-a-row-of-the-pane': {
92+ 8: [
93+ 'No ',
94+ 'changes ',
95+ 'vs a-rel',
96+ 'ease-bra',
97+ 'nch-with',
98+ '-a-name-',
99+ 'longer-t',
100+ 'han-a-ro',
101+ 'w-of-the',
102+ '-pane',
103+ ],
104+ 23: [
105+ 'No changes vs ',
106+ 'a-release-branch-with-a',
107+ '-name-longer-than-a-row',
108+ '-of-the-pane',
109+ ],
110+ 24: [
111+ 'No changes vs a-release-',
112+ 'branch-with-a-name-longe',
113+ 'r-than-a-row-of-the-pane',
114+ ],
115+ 48: [
116+ 'No changes vs a-release-branch-with-a-name-longe',
117+ 'r-than-a-row-of-the-pane',
118+ ],
119+ 70: [
120+ 'No changes vs ',
121+ 'a-release-branch-with-a-name-longer-than-a-row-of-the-pane',
122+ ],
123+ },
124+}
@@ -1,0 +1,3 @@
1+export * from './builtin-wraps.js'
2+
3+export * as default from '.'
@@ -1,0 +1,544 @@
1+import type { ProcessRunResult } from 'claude-code'
2+import { describe, expect, test, tier } from 'claude-code/testing'
3+
4+import Git from '../../hooks/git'
5+import Limits from '../../hooks/limits'
6+import Fixtures from '../fixtures'
7+
8+tier('builtin')
9+
10+describe('fetch-diff', () => {
11+ const HALF_CAP_RECORDS = Limits.HOST_OUTPUT_CAP_BYTES / 2
12+ const NEAR_CAP_RECORDS = HALF_CAP_RECORDS - 2
13+
14+ type Script = Record<string, ProcessRunResult>
15+ type Listing = readonly (readonly [string, Git.EntryKind])[]
16+ type ScriptedDeps = Git.GitDeps & Pick<Fixtures.ScriptedGit, 'argvs'>
17+
18+ const listingOf = (entries: Listing): ReadonlyMap<string, Git.EntryKind> =>
19+ new Map(entries)
20+
21+ function depsOf(
22+ script: Script,
23+ overrides: Partial<Git.GitDeps> = {},
24+ ): ScriptedDeps {
25+ const scripted = Fixtures.scriptedGitOf(script)
26+
27+ return {
28+ argvs: scripted.argvs,
29+ run: scripted.run,
30+ repository: Fixtures.FETCH_REPOSITORY,
31+ mtimeOf: () => Promise.resolve(Fixtures.DURING_SESSION_MS),
32+ entryKindsOf: () => Promise.resolve(listingOf(Fixtures.FILES_LISTING)),
33+ sessionStartMs: Fixtures.GIT_SESSION_START_MS,
34+ onBranchBase: () => undefined,
35+ ...overrides,
36+ }
37+ }
38+
39+ const unbornScriptOf = (): Script => ({
40+ [Fixtures.VS_HEAD]: Fixtures.UNSCRIPTED,
41+ 'rev-parse --verify --quiet HEAD': Fixtures.PROBE_MISSED,
42+ '--cached --shortstat': Fixtures.ok(Fixtures.ONE_STAGED),
43+ 'ls-files': Fixtures.ok(),
44+ })
45+
46+ const datedBefore =
47+ (suffix: string): Git.GitDeps['mtimeOf'] =>
48+ path =>
49+ Promise.resolve(
50+ path.endsWith(suffix)
51+ ? Fixtures.BEFORE_SESSION_MS
52+ : Fixtures.DURING_SESSION_MS,
53+ )
54+
55+ const trackedNoUntrackedOf = (): Script => ({
56+ 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
57+ 'HEAD --numstat': Fixtures.ok('1\t0\ta.ts\0'),
58+ 'ls-files': Fixtures.GIT_TIMED_OUT,
59+ })
60+
61+ const onFeatureNoUntrackedOf = (): Script => ({
62+ 'rev-parse --abbrev-ref HEAD': Fixtures.ok('feature\n'),
63+ 'symbolic-ref --short refs/remotes/origin/HEAD':
64+ Fixtures.ok('origin/main\n'),
65+ 'show-ref --verify --quiet refs/remotes/origin/main': Fixtures.ok(),
66+ 'merge-base HEAD origin/main': Fixtures.ok(`${Fixtures.ORIGIN_BASE}\n`),
67+ 'rev-parse HEAD': Fixtures.ok(`${Fixtures.FEATURE_HEAD}\n`),
68+ [`${Fixtures.ORIGIN_BASE} --shortstat`]: Fixtures.ok(Fixtures.ONE_STAGED),
69+ [`${Fixtures.ORIGIN_BASE} --numstat`]: Fixtures.ok('3\t0\ta.ts\0'),
70+ 'ls-files': Fixtures.GIT_TIMED_OUT,
71+ })
72+
73+ const unbornNoUntrackedOf = (): Script => ({
74+ ...unbornScriptOf(),
75+ '--cached --numstat': Fixtures.ok('3\t0\tstaged.ts\0'),
76+ '--submodule=short --numstat': Fixtures.ok('2\t1\tstaged.ts\0'),
77+ 'ls-files': Fixtures.GIT_TIMED_OUT,
78+ })
79+
80+ test('session mode: rows tagged by mtime, untracked merged', async () => {
81+ const outcome = await Git.fetchDiff(
82+ depsOf(
83+ {
84+ 'HEAD --shortstat': Fixtures.ok(
85+ ' 2 files changed, 3 insertions(+), 1 deletion(-)',
86+ ),
87+ 'HEAD --numstat': Fixtures.ok('2\t1\told.ts\x001\t0\tnew.ts\0'),
88+ 'ls-files': Fixtures.ok('scratch.txt\0'),
89+ },
90+ { mtimeOf: datedBefore('old.ts') },
91+ ),
92+ 'session',
93+ )
94+
95+ const data = outcome.kind === 'data' ? outcome.data : null
96+
97+ expect(data?.repository).toEqual(Fixtures.FETCH_REPOSITORY)
98+ expect(data?.baseRef).toBe('HEAD')
99+ expect(data?.isUnborn).toBe(false)
100+
101+ expect(data?.stats).toEqual({
102+ filesCount: 3,
103+ linesAdded: 3,
104+ linesRemoved: 1,
105+ })
106+
107+ expect(
108+ data?.files.map(file => [file.path, file.isPreSession, file.isUntracked]),
109+ ).toEqual([
110+ ['old.ts', true, false],
111+ ['new.ts', false, false],
112+ ['scratch.txt', false, true],
113+ ])
114+ })
115+
116+ test('every diff spawn carries the pinned leading argv', async () => {
117+ const deps = depsOf({
118+ 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
119+ 'HEAD --numstat': Fixtures.ok('1\t0\ta.ts\0'),
120+ 'ls-files': Fixtures.ok(),
121+ })
122+
123+ await Git.fetchDiff(deps, 'uncommitted')
124+
125+ const diffs = deps.argvs.filter(argv => argv.includes('diff'))
126+
127+ expect(deps.argvs.every(argv => argv[0] === '--no-optional-locks')).toBe(
128+ true,
129+ )
130+
131+ expect(diffs).toHaveLength(2)
132+
133+ expect(diffs.every(argv => argv.includes('core.quotePath=false'))).toBe(
134+ true,
135+ )
136+
137+ expect(diffs.every(argv => argv.includes('--no-ext-diff'))).toBe(true)
138+ expect(diffs.every(argv => argv.includes('--no-textconv'))).toBe(true)
139+ expect(diffs[1]).toContain('-z')
140+ expect(deps.argvs.find(argv => argv.includes('ls-files'))).toContain('-z')
141+
142+ expect(
143+ diffs.every(
144+ argv =>
145+ argv.slice(0, Git.DIFF_LEADING_ARGS.length).join(' ') ===
146+ Git.DIFF_LEADING_ARGS.join(' '),
147+ ),
148+ ).toBe(true)
149+ })
150+
151+ test('uncommitted mode drops pre-session untracked files', async () => {
152+ const outcome = await Git.fetchDiff(
153+ depsOf(
154+ {
155+ 'HEAD --shortstat': Fixtures.ok(),
156+ 'HEAD --numstat': Fixtures.ok(),
157+ 'ls-files': Fixtures.ok('stale.txt\0fresh.txt\0'),
158+ },
159+ { mtimeOf: datedBefore('stale.txt') },
160+ ),
161+ 'uncommitted',
162+ )
163+
164+ expect(
165+ outcome.kind === 'data' && outcome.data.files.map(file => file.path),
166+ ).toEqual(['fresh.txt'])
167+ })
168+
169+ test('past 500 files: totals only, no numstat, no untracked', async () => {
170+ const deps = depsOf({
171+ 'HEAD --shortstat': Fixtures.ok(' 501 files changed, 9 insertions(+)'),
172+ })
173+
174+ const outcome = await Git.fetchDiff(deps, 'session')
175+
176+ expect(outcome.kind === 'data' && outcome.data.stats.filesCount).toBe(501)
177+ expect(outcome.kind === 'data' && outcome.data.files).toEqual([])
178+ expect(deps.argvs.some(argv => argv.includes('--numstat'))).toBe(false)
179+ expect(deps.argvs.some(argv => argv.includes('ls-files'))).toBe(false)
180+ })
181+
182+ test('a transient state file: unavailable, and no diff runs', async () => {
183+ const deps = depsOf(
184+ {},
185+ {
186+ entryKindsOf: dir =>
187+ Promise.resolve(
188+ listingOf(
189+ dir === '/repo/.git' ? Fixtures.MERGING : Fixtures.FILES_LISTING,
190+ ),
191+ ),
192+ },
193+ )
194+
195+ expect(await Git.fetchDiff(deps, 'session')).toEqual({
196+ kind: 'unavailable',
197+ })
198+
199+ expect(deps.argvs.some(argv => argv.includes('diff'))).toBe(false)
200+ })
201+
202+ test('unborn HEAD: staged rows with unstaged edits folded in', async () => {
203+ const outcome = await Git.fetchDiff(
204+ depsOf({
205+ [Fixtures.VS_HEAD]: Fixtures.UNSCRIPTED,
206+ 'rev-parse --verify --quiet HEAD': Fixtures.PROBE_MISSED,
207+ '--cached --shortstat': Fixtures.ok(' 1 file changed, 3 insertions(+)'),
208+ '--cached --numstat': Fixtures.ok('3\t0\tstaged.ts\0'),
209+ '--submodule=short --numstat': Fixtures.ok('2\t1\tstaged.ts\0'),
210+ 'ls-files': Fixtures.ok(),
211+ }),
212+ 'session',
213+ )
214+
215+ const data = outcome.kind === 'data' ? outcome.data : null
216+
217+ expect(data?.isUnborn).toBe(true)
218+ expect(data?.baseRef).toBe('--cached')
219+
220+ expect(
221+ data?.files.map(file => [file.path, file.added, file.removed]),
222+ ).toEqual([['staged.ts', 4, 0]])
223+
224+ expect(data?.stats.linesAdded).toBe(4)
225+ expect(data?.stalePaths).toEqual(['staged.ts'])
226+ })
227+
228+ test('unborn HEAD: a staged or unstaged listing git withheld', async () => {
229+ const noStaged = await Git.fetchDiff(depsOf(unbornScriptOf()), 'session')
230+
231+ const noUnstaged = await Git.fetchDiff(
232+ depsOf({
233+ ...unbornScriptOf(),
234+ '--cached --numstat': Fixtures.ok('3\t0\tstaged.ts\0'),
235+ '--submodule=short --numstat': Fixtures.ok('2\t1\tstaged.ts'),
236+ }),
237+ 'session',
238+ )
239+
240+ expect(noStaged).toEqual({ kind: 'unavailable' })
241+ expect(noUnstaged).toEqual({ kind: 'unavailable' })
242+ })
243+
244+ test('a withheld untracked listing: tracked rows, flagged', async () => {
245+ const outcomes = await Promise.all([
246+ Git.fetchDiff(depsOf(trackedNoUntrackedOf()), 'session'),
247+ Git.fetchDiff(depsOf(trackedNoUntrackedOf()), 'uncommitted'),
248+ Git.fetchDiff(depsOf(onFeatureNoUntrackedOf()), 'branch'),
249+ Git.fetchDiff(depsOf(unbornNoUntrackedOf()), 'session'),
250+ ])
251+
252+ const listed = await Git.fetchDiff(
253+ depsOf({
254+ ...trackedNoUntrackedOf(),
255+ 'ls-files': Fixtures.ok('new.ts\0'),
256+ }),
257+ 'uncommitted',
258+ )
259+
260+ expect(
261+ outcomes.map(
262+ outcome =>
263+ outcome.kind === 'data' && [
264+ outcome.data.files.map(file => file.path),
265+ outcome.data.stats.filesCount,
266+ outcome.data.isUntrackedWithheld,
267+ ],
268+ ),
269+ ).toEqual([
270+ [['a.ts'], 1, true],
271+ [['a.ts'], 1, true],
272+ [['a.ts'], 1, true],
273+ [['staged.ts'], 1, true],
274+ ])
275+
276+ expect(listed.kind === 'data' && listed.data.isUntrackedWithheld).toBe(
277+ false,
278+ )
279+ })
280+
281+ test('a failed vs-HEAD diff with commits present: unavailable', async () => {
282+ expect(
283+ await Git.fetchDiff(
284+ depsOf({
285+ 'rev-parse --verify --quiet HEAD': Fixtures.ok('abc\n'),
286+ }),
287+ 'session',
288+ ),
289+ ).toEqual({ kind: 'unavailable' })
290+ })
291+
292+ test('a tracked or untracked symlink is never dated: no stat', async () => {
293+ const probed: string[] = []
294+
295+ const outcome = await Git.fetchDiff(
296+ depsOf(
297+ {
298+ 'HEAD --shortstat': Fixtures.ok(' 2 files changed, 2 insertions(+)'),
299+ 'HEAD --numstat': Fixtures.ok('1\t0\told.ts\x001\t0\tlink.ts\0'),
300+ 'ls-files': Fixtures.ok('leak\0scratch.txt\0'),
301+ },
302+ {
303+ mtimeOf: path => {
304+ probed.push(path)
305+
306+ return Promise.resolve(Fixtures.BEFORE_SESSION_MS)
307+ },
308+ },
309+ ),
310+ 'session',
311+ )
312+
313+ const data = outcome.kind === 'data' ? outcome.data : null
314+
315+ expect(probed.toSorted()).toEqual(['/repo/old.ts', '/repo/scratch.txt'])
316+
317+ expect(data?.files.map(file => [file.path, file.isPreSession])).toEqual([
318+ ['old.ts', true],
319+ ['link.ts', false],
320+ ['leak', false],
321+ ['scratch.txt', true],
322+ ])
323+ })
324+
325+ test('an unlistable directory dates nothing and stats nothing', async () => {
326+ const probed: string[] = []
327+
328+ await Git.fetchDiff(
329+ depsOf(
330+ {
331+ 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
332+ 'HEAD --numstat': Fixtures.ok('1\t0\told.ts\0'),
333+ 'ls-files': Fixtures.ok('scratch.txt\0'),
334+ },
335+ {
336+ entryKindsOf: () => Promise.resolve(null),
337+ mtimeOf: path => {
338+ probed.push(path)
339+
340+ return Promise.resolve(Fixtures.BEFORE_SESSION_MS)
341+ },
342+ },
343+ ),
344+ 'session',
345+ )
346+
347+ expect(probed).toEqual([])
348+ })
349+
350+ test('a MERGE_HEAD that is a symbolic link is no merge', async () => {
351+ const outcome = await Git.fetchDiff(
352+ depsOf(
353+ {
354+ 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
355+ 'HEAD --numstat': Fixtures.ok('1\t0\ta.ts\0'),
356+ 'ls-files': Fixtures.ok(),
357+ },
358+ {
359+ entryKindsOf: dir => {
360+ const isGitDir = dir === '/repo/.git'
361+
362+ return Promise.resolve(
363+ listingOf(
364+ isGitDir ? Fixtures.MERGE_HEAD_LINKED : Fixtures.FILES_LISTING,
365+ ),
366+ )
367+ },
368+ },
369+ ),
370+ 'session',
371+ )
372+
373+ expect(outcome).toMatchObject({ kind: 'data' })
374+ expect(outcome.kind === 'data' && outcome.data.files).toHaveLength(1)
375+ })
376+
377+ test('a path under a symlinked directory: unlisted, undated', async () => {
378+ const listed: string[] = []
379+ const probed: string[] = []
380+
381+ const outcome = await Git.fetchDiff(
382+ depsOf(
383+ {
384+ 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
385+ 'HEAD --numstat': Fixtures.ok('1\t0\tsub/inner.ts\0'),
386+ 'ls-files': Fixtures.ok(),
387+ },
388+ {
389+ entryKindsOf: dir => {
390+ listed.push(dir)
391+
392+ return Promise.resolve(listingOf(Fixtures.FILES_LISTING))
393+ },
394+ mtimeOf: path => {
395+ probed.push(path)
396+
397+ return Promise.resolve(Fixtures.BEFORE_SESSION_MS)
398+ },
399+ },
400+ ),
401+ 'session',
402+ )
403+
404+ expect(listed).not.toContain('/repo/sub')
405+ expect(probed).toEqual([])
406+
407+ expect(
408+ outcome.kind === 'data' && outcome.data.files.map(f => f.isPreSession),
409+ ).toEqual([false])
410+ })
411+
412+ test('a listing the host cut short is never parsed', async () => {
413+ const cutNumstat = await Git.fetchDiff(
414+ depsOf({
415+ 'HEAD --shortstat': Fixtures.ok(' 2 files changed, 2 insertions(+)'),
416+ 'HEAD --numstat': Fixtures.ok('1\t0\ta.ts\x001\t0\told'),
417+ 'rev-parse --verify --quiet HEAD': Fixtures.ok('abc\n'),
418+ }),
419+ 'uncommitted',
420+ )
421+
422+ const cutUntracked = await Git.fetchDiff(
423+ depsOf({
424+ 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
425+ 'HEAD --numstat': Fixtures.ok('1\t0\ta.ts\0'),
426+ 'ls-files': Fixtures.ok('scratch.txt\0fresh.tx'),
427+ }),
428+ 'uncommitted',
429+ )
430+
431+ expect(cutNumstat).toEqual({ kind: 'unavailable' })
432+
433+ expect(
434+ cutUntracked.kind === 'data' && [
435+ cutUntracked.data.files.map(f => f.path),
436+ cutUntracked.data.isUntrackedWithheld,
437+ ],
438+ ).toEqual([['a.ts'], true])
439+
440+ expect(Git.isWholeListing('a\0'.repeat(HALF_CAP_RECORDS))).toBe(false)
441+ expect(Git.isWholeListing('a\0'.repeat(NEAR_CAP_RECORDS))).toBe(true)
442+ })
443+
444+ test('an untracked directory git prints with a slash is dated', async () => {
445+ const probed: string[] = []
446+
447+ const outcome = await Git.fetchDiff(
448+ depsOf(
449+ {
450+ 'HEAD --shortstat': Fixtures.ok(),
451+ 'HEAD --numstat': Fixtures.ok(),
452+ 'ls-files': Fixtures.ok('nested/\0'),
453+ },
454+ {
455+ entryKindsOf: () => Promise.resolve(listingOf([['nested', 'dir']])),
456+ mtimeOf: path => {
457+ probed.push(path)
458+
459+ return Promise.resolve(Fixtures.BEFORE_SESSION_MS)
460+ },
461+ },
462+ ),
463+ 'session',
464+ )
465+
466+ expect(probed).toEqual(['/repo/nested'])
467+
468+ expect(
469+ outcome.kind === 'data' &&
470+ outcome.data.files.map(file => [file.path, file.isPreSession]),
471+ ).toEqual([['nested/', true]])
472+ })
473+
474+ test('a hundred directories deep and wide still date', async () => {
475+ const listed: string[] = []
476+
477+ const paths = Array.from(
478+ { length: Fixtures.DEEP_DIRECTORIES },
479+ (_, at) => `d${at}/nested/file${at}.txt`,
480+ )
481+
482+ const outcome = await Git.fetchDiff(
483+ depsOf(
484+ {
485+ 'HEAD --shortstat': Fixtures.ok(),
486+ 'HEAD --numstat': Fixtures.ok(),
487+ 'ls-files': Fixtures.ok(paths.map(path => `${path}\0`).join('')),
488+ },
489+ {
490+ entryKindsOf: dir => {
491+ listed.push(dir)
492+
493+ const isLeaf = dir.endsWith('/nested')
494+
495+ const entries: Listing = isLeaf
496+ ? paths.map(path => [path.split('/').at(-1) ?? '', 'file'])
497+ : [
498+ ['nested', 'dir'],
499+ ...paths.map(
500+ path => [path.slice(0, path.indexOf('/')), 'dir'] as const,
501+ ),
502+ ]
503+
504+ return Promise.resolve(listingOf(entries))
505+ },
506+ mtimeOf: () => Promise.resolve(Fixtures.BEFORE_SESSION_MS),
507+ },
508+ ),
509+ 'session',
510+ )
511+
512+ const rows = outcome.kind === 'data' ? outcome.data.files : []
513+
514+ expect(
515+ new Set(listed.filter(dir => dir !== Fixtures.FETCH_REPOSITORY.gitDir))
516+ .size,
517+ ).toBe(1 + 2 * Fixtures.DEEP_DIRECTORIES)
518+
519+ expect(rows).toHaveLength(Limits.MAX_FILES)
520+ expect(rows.every(row => row.isPreSession)).toBe(true)
521+ })
522+
523+ test('a rename is one row under its new name, diffed as a pair', async () => {
524+ const deps = depsOf({
525+ 'HEAD --shortstat': Fixtures.ok(' 1 file changed, 1 insertion(+)'),
526+ 'HEAD --numstat': Fixtures.ok('1\t1\t\0old.ts\0new.ts\0'),
527+ 'ls-files': Fixtures.ok(),
528+ '-- old.ts new.ts': Fixtures.ok('@@ -1 +1 @@\n-a\n+b\n'),
529+ })
530+
531+ const outcome = await Git.fetchDiff(deps, 'uncommitted')
532+ const data = outcome.kind === 'data' ? outcome.data : null
533+ const [row] = data?.files ?? []
534+
535+ const body =
536+ data && row ? await Git.fetchFileHunks(deps.run, data, row) : null
537+
538+ expect(data?.files.map(f => [f.path, f.renamedFrom])).toEqual([
539+ ['new.ts', 'old.ts'],
540+ ])
541+
542+ expect(body?.hunks[0]?.lines).toEqual(['-a', '+b'])
543+ })
544+})
@@ -1,0 +1,61 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Parse from '../../../hooks/git/parse'
4+import Limits from '../../../hooks/limits'
5+import Fixtures from '../../fixtures'
6+
7+tier('builtin')
8+
9+describe('parse-file-diff', () => {
10+ test('hunk starts and marked lines; header and notes skipped', () => {
11+ const parsed = Parse.parseFileDiff(
12+ `${Fixtures.FILE_DIFF_HEADER}@@ -1,3 +1,3 @@\n one\n-two\n+two ` +
13+ `changed\n three\n\\ No newline at end of file\n@@ -10 +10,2 @@ fn\n ` +
14+ `ctx\n+added\n`,
15+ )
16+
17+ expect(parsed.isLarge).toBe(false)
18+ expect(parsed.isTruncated).toBe(false)
19+
20+ expect(parsed.hunks).toEqual([
21+ {
22+ oldStart: 1,
23+ newStart: 1,
24+ lines: [' one', '-two', '+two changed', ' three'],
25+ },
26+ { oldStart: 10, newStart: 10, lines: [' ctx', '+added'] },
27+ ])
28+ })
29+
30+ test('a removed line that starts with dashes is body, not a header', () => {
31+ expect(
32+ Parse.parseFileDiff(
33+ `${Fixtures.FILE_DIFF_HEADER}@@ -1 +1 @@\n---- old rule\n+--- new ` +
34+ `rule\n`,
35+ ).hunks[0]?.lines,
36+ ).toEqual(['---- old rule', '+--- new rule'])
37+ })
38+
39+ test('the body is cut at the line cap and marked truncated', () => {
40+ const body = Array.from(
41+ { length: Limits.MAX_LINES_PER_FILE + Fixtures.PAST_THE_CAP },
42+ (_, at) => `+${at}`,
43+ ).join('\n')
44+
45+ const parsed = Parse.parseFileDiff(
46+ `${Fixtures.FILE_DIFF_HEADER}@@ -0,0 +1,405 @@\n${body}\n`,
47+ )
48+
49+ expect(parsed.hunks[0]?.lines).toHaveLength(Limits.MAX_LINES_PER_FILE)
50+ expect(parsed.isTruncated).toBe(true)
51+ })
52+
53+ test('a diff past the byte cap is large and has no hunks', () => {
54+ expect(
55+ Parse.parseFileDiff(
56+ `${Fixtures.FILE_DIFF_HEADER}@@ -1 +1 ` +
57+ `@@\n+${'x'.repeat(Limits.MAX_DIFF_BYTES)}\n`,
58+ ),
59+ ).toEqual({ hunks: [], isTruncated: false, isLarge: true })
60+ })
61+})
@@ -1,0 +1,67 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Parse from '../../../hooks/git/parse'
4+import Limits from '../../../hooks/limits'
5+
6+tier('builtin')
7+
8+describe('parse-numstat', () => {
9+ test('NUL records: totals, binaries at 0/0, tabs in a path kept', () => {
10+ const parsed = Parse.parseNumstat(
11+ '3\t1\tsrc/a.ts\0-\t-\timg.png\x002\t0\ta\tb.ts\0',
12+ Limits.MAX_FILES,
13+ )
14+
15+ expect(parsed.stats).toEqual({
16+ filesCount: 3,
17+ linesAdded: 5,
18+ linesRemoved: 1,
19+ })
20+
21+ expect(parsed.files.map(file => [file.path, file.isBinary])).toEqual([
22+ ['src/a.ts', false],
23+ ['img.png', true],
24+ ['a\tb.ts', false],
25+ ])
26+
27+ expect(
28+ parsed.files.every(file => !file.isUntracked && !file.isPreSession),
29+ ).toBe(true)
30+ })
31+
32+ test('the cap keeps the first rows but counts every row', () => {
33+ const parsed = Parse.parseNumstat('1\t0\ta\x001\t0\tb\x001\t0\tc\0', 2)
34+
35+ expect(parsed.files.map(file => file.path)).toEqual(['a', 'b'])
36+ expect(parsed.stats.filesCount).toBe(3)
37+ })
38+
39+ test('a line with fewer than three fields is no row', () => {
40+ expect(
41+ Parse.parseNumstat('\0warning: x\0', Limits.MAX_FILES).stats.filesCount,
42+ ).toBe(0)
43+ })
44+
45+ test('a non-ASCII path arrives as its own characters', () => {
46+ expect(
47+ Parse.parseNumstat('1\t1\tdocs/caf\u00e9.md\0', Limits.MAX_FILES).files[0]
48+ ?.path,
49+ ).toBe('docs/caf\u00e9.md')
50+ })
51+
52+ test('a rename record keeps the new path and remembers the old', () => {
53+ const parsed = Parse.parseNumstat(
54+ '2\t1\t\0src/old.ts\0src/new.ts\x003\t0\tkept.ts\0',
55+ Limits.MAX_FILES,
56+ )
57+
58+ expect(
59+ parsed.files.map(file => [file.path, file.renamedFrom, file.added]),
60+ ).toEqual([
61+ ['src/new.ts', 'src/old.ts', 2],
62+ ['kept.ts', null, 3],
63+ ])
64+
65+ expect(parsed.stats.filesCount).toBe(2)
66+ })
67+})
@@ -1,0 +1,31 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Parse from '../../../hooks/git/parse'
4+
5+tier('builtin')
6+
7+describe('parse-shortstat', () => {
8+ test('files, insertions and deletions; either tail optional', () => {
9+ expect(
10+ Parse.parseShortstat(
11+ ' 3 files changed, 10 insertions(+), 2 deletions(-)\n',
12+ ),
13+ ).toEqual({ filesCount: 3, linesAdded: 10, linesRemoved: 2 })
14+
15+ expect(Parse.parseShortstat(' 1 file changed, 1 insertion(+)\n')).toEqual({
16+ filesCount: 1,
17+ linesAdded: 1,
18+ linesRemoved: 0,
19+ })
20+
21+ expect(Parse.parseShortstat(' 1 file changed, 4 deletions(-)\n')).toEqual({
22+ filesCount: 1,
23+ linesAdded: 0,
24+ linesRemoved: 4,
25+ })
26+ })
27+
28+ test('an empty diff prints nothing and parses to null', () => {
29+ expect(Parse.parseShortstat('')).toBeNull()
30+ })
31+})
@@ -1,0 +1,119 @@
1+import type { ProcessRunResult } from 'claude-code'
2+import { describe, expect, test, tier } from 'claude-code/testing'
3+
4+import Git from '../../../hooks/git'
5+import Fixtures from '../../fixtures'
6+
7+tier('builtin')
8+
9+describe('branch-base-of', () => {
10+ type Script = Record<string, ProcessRunResult>
11+
12+ const runOf = (script: Script) => Fixtures.scriptedGitOf(script).run
13+
14+ const onFeature = (): Script => ({
15+ 'rev-parse --abbrev-ref HEAD': Fixtures.ok('feature\n'),
16+ 'symbolic-ref --short refs/remotes/origin/HEAD':
17+ Fixtures.ok('origin/main\n'),
18+ 'show-ref --verify --quiet refs/remotes/origin/main': Fixtures.ok(),
19+ })
20+
21+ test("origin's merge-base wins unless the local descends", async () => {
22+ expect(
23+ await Git.branchBaseOf(
24+ runOf({
25+ ...onFeature(),
26+ 'merge-base HEAD origin/main': Fixtures.ok(
27+ `${Fixtures.ORIGIN_BASE}\n`,
28+ ),
29+ 'merge-base HEAD main': Fixtures.ok(`${Fixtures.LOCAL_BASE}\n`),
30+ 'rev-parse HEAD': Fixtures.ok(`${Fixtures.FEATURE_HEAD}\n`),
31+ }),
32+ ),
33+ ).toEqual({
34+ kind: 'merge-base',
35+ mergeBase: Fixtures.ORIGIN_BASE,
36+ baseBranch: 'main',
37+ })
38+
39+ expect(
40+ await Git.branchBaseOf(
41+ runOf({
42+ ...onFeature(),
43+ 'merge-base HEAD origin/main': Fixtures.ok(
44+ `${Fixtures.ORIGIN_BASE}\n`,
45+ ),
46+ 'merge-base HEAD main': Fixtures.ok(`${Fixtures.LOCAL_BASE}\n`),
47+ [`merge-base --is-ancestor ${Fixtures.ORIGIN_BASE} ` +
48+ Fixtures.LOCAL_BASE]: Fixtures.ok(),
49+ 'rev-parse HEAD': Fixtures.ok(`${Fixtures.FEATURE_HEAD}\n`),
50+ }),
51+ ),
52+ ).toEqual({
53+ kind: 'merge-base',
54+ mergeBase: Fixtures.LOCAL_BASE,
55+ baseBranch: 'main',
56+ })
57+ })
58+
59+ test('the default branch, or HEAD at the base: head-is-base', async () => {
60+ expect(
61+ await Git.branchBaseOf(
62+ runOf({
63+ ...onFeature(),
64+ 'rev-parse --abbrev-ref HEAD': Fixtures.ok('main\n'),
65+ }),
66+ ),
67+ ).toEqual({ kind: 'head-is-base', baseBranch: 'main' })
68+
69+ expect(
70+ await Git.branchBaseOf(
71+ runOf({
72+ ...onFeature(),
73+ 'merge-base HEAD origin/main': Fixtures.ok(
74+ `${Fixtures.FEATURE_HEAD}\n`,
75+ ),
76+ 'rev-parse HEAD': Fixtures.ok(`${Fixtures.FEATURE_HEAD}\n`),
77+ }),
78+ ),
79+ ).toEqual({ kind: 'head-is-base', baseBranch: 'main' })
80+ })
81+
82+ test('a detached HEAD, or no common ancestor, has no base', async () => {
83+ expect(
84+ await Git.branchBaseOf(
85+ runOf({
86+ ...onFeature(),
87+ 'rev-parse --abbrev-ref HEAD': Fixtures.ok('HEAD\n'),
88+ }),
89+ ),
90+ ).toEqual({ kind: 'none' })
91+
92+ expect(
93+ await Git.branchBaseOf(
94+ runOf({
95+ ...onFeature(),
96+ 'merge-base HEAD origin/main': Fixtures.PROBE_MISSED,
97+ 'merge-base HEAD main': Fixtures.PROBE_MISSED,
98+ }),
99+ ),
100+ ).toEqual({ kind: 'none' })
101+ })
102+
103+ test('a failed merge-base while the ref exists: error', async () => {
104+ expect(await Git.branchBaseOf(runOf(onFeature()))).toEqual({
105+ kind: 'error',
106+ reason: 'merge_base_failed',
107+ })
108+ })
109+
110+ test('no default ref anywhere: none', async () => {
111+ expect(
112+ await Git.branchBaseOf(
113+ runOf({
114+ 'rev-parse --abbrev-ref HEAD': Fixtures.ok('feature\n'),
115+ }),
116+ ),
117+ ).toEqual({ kind: 'none' })
118+ })
119+})
@@ -1,0 +1,119 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Git from '../../../hooks/git'
4+import Fixtures from '../../fixtures'
5+
6+tier('builtin')
7+
8+describe('datings-of', () => {
9+ type Listing = ReadonlyMap<string, Git.EntryKind>
10+
11+ const LISTINGS: ReadonlyMap<string, Listing> = new Map<string, Listing>([
12+ [
13+ '/repo',
14+ new Map([
15+ ['a', 'dir'],
16+ ['b', 'dir'],
17+ ['c', 'dir'],
18+ ['deep', 'dir'],
19+ ]),
20+ ],
21+ ['/repo/a', new Map([['x', 'file']])],
22+ ['/repo/b', new Map([['y', 'file']])],
23+ ['/repo/c', new Map([['z', 'file']])],
24+ [
25+ '/repo/deep',
26+ new Map([
27+ ['x.ts', 'file'],
28+ ['y.txt', 'file'],
29+ ]),
30+ ],
31+ ])
32+
33+ function probeOf(budget: number) {
34+ const listed: string[] = []
35+ const statted: string[] = []
36+
37+ const probe = Git.stampProbeOf(
38+ {
39+ entryKindsOf: dir => {
40+ listed.push(dir)
41+
42+ return Promise.resolve(LISTINGS.get(dir) ?? null)
43+ },
44+ mtimeOf: path => {
45+ statted.push(path)
46+
47+ return Promise.resolve(Fixtures.BEFORE_SESSION_MS)
48+ },
49+ },
50+ '/repo',
51+ budget,
52+ )
53+
54+ return { probe, listed, statted }
55+ }
56+
57+ const depsOf = (run: Git.GitRun, probe: Git.StampProbe): Git.GitDeps => ({
58+ run,
59+ repository: Fixtures.CHECKOUT,
60+ mtimeOf: probe.mtimeOf,
61+ entryKindsOf: () => Promise.resolve(null),
62+ sessionStartMs: Fixtures.GIT_SESSION_START_MS,
63+ onBranchBase: () => undefined,
64+ })
65+
66+ function contextOf(probe: Git.StampProbe, listing: string) {
67+ const { run } = Fixtures.scriptedGitOf({ 'ls-files': Fixtures.ok(listing) })
68+
69+ return { run, deps: depsOf(run, probe), stamps: probe }
70+ }
71+
72+ test('a spent budget answers unlisted, and stats nothing there', async () => {
73+ const recorded = probeOf(Fixtures.TWO_LISTINGS_BUDGET)
74+
75+ expect([
76+ ...(await Git.datingsOf(
77+ {
78+ stamps: recorded.probe,
79+ deps: { sessionStartMs: Fixtures.GIT_SESSION_START_MS },
80+ },
81+ ['a/x', 'b/y', 'c/z', 'missing'],
82+ )),
83+ ]).toEqual([
84+ ['a/x', 'pre-session'],
85+ ['b/y', 'unlisted'],
86+ ['c/z', 'unlisted'],
87+ ['missing', 'session'],
88+ ])
89+
90+ expect(recorded.listed).toEqual(['/repo', '/repo/a'])
91+ expect(recorded.statted).toEqual(['/repo/a/x'])
92+ })
93+
94+ test('an unlisted tracked row stays session work', async () => {
95+ const context = contextOf(probeOf(Fixtures.ROOT_ONLY_BUDGET).probe, '')
96+ const rows = await Git.tagPreSession(context, [Fixtures.TRACKED_ROW])
97+
98+ expect(context.stamps.base).toBe('/repo')
99+ expect(rows.map(row => row.isPreSession)).toEqual([false])
100+ })
101+
102+ test('an unlisted untracked file reads as pre-session', async () => {
103+ const context = contextOf(
104+ probeOf(Fixtures.ROOT_ONLY_BUDGET).probe,
105+ 'deep/y.txt\0',
106+ )
107+
108+ const rows = await Git.untrackedFiles(context, {
109+ slots: 1,
110+ scope: 'with-pre-session',
111+ })
112+
113+ expect(context.stamps.base).toBe('/repo')
114+
115+ expect(rows?.map(row => [row.path, row.isPreSession])).toEqual([
116+ ['deep/y.txt', true],
117+ ])
118+ })
119+})
@@ -1,0 +1,118 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Git from '../../../hooks/git'
4+import Fixtures from '../../fixtures'
5+
6+tier('builtin')
7+
8+describe('head-key-of', () => {
9+ type Recorded = {
10+ deps: Git.HeadKeyDeps
11+ statted: readonly string[]
12+ read: readonly string[]
13+ ran: readonly (readonly string[])[]
14+ }
15+
16+ function recorded(
17+ listings: Readonly<Record<string, ReadonlyMap<string, Git.EntryKind>>>,
18+ head: string,
19+ ): Recorded {
20+ const statted: string[] = []
21+ const read: string[] = []
22+
23+ const git = Fixtures.scriptedGitOf({
24+ 'rev-parse': Fixtures.ok(`${Fixtures.HEAD_SHA}\n`),
25+ })
26+
27+ const depsOf = (): Git.HeadKeyDeps => ({
28+ run: git.run,
29+ entryKindsOf: dir => Promise.resolve(listings[dir] ?? null),
30+ mtimeOf: path => {
31+ statted.push(path)
32+
33+ return Promise.resolve(Fixtures.STAMP)
34+ },
35+ readFile: path => {
36+ read.push(path)
37+
38+ return Promise.resolve(head)
39+ },
40+ })
41+
42+ return { deps: depsOf(), statted, read, ran: git.argvs }
43+ }
44+
45+ const plainGitDir: ReadonlyMap<string, Git.EntryKind> = new Map([
46+ ['HEAD', 'file'],
47+ ['packed-refs', 'file'],
48+ ['refs', 'dir'],
49+ ])
50+
51+ const refs: ReadonlyMap<string, Git.EntryKind> = new Map([['heads', 'dir']])
52+ const heads: ReadonlyMap<string, Git.EntryKind> = new Map([['main', 'file']])
53+
54+ const healthy = {
55+ '/r/.git': plainGitDir,
56+ '/r/.git/refs': refs,
57+ '/r/.git/refs/heads': heads,
58+ }
59+
60+ test('a plain checkout reads HEAD, its ref file, packed-refs', async () => {
61+ const probe = recorded(healthy, 'ref: refs/heads/main\n')
62+ const key = await Git.headKeyOf(probe.deps, Fixtures.CHECKOUT)
63+
64+ expect(probe.statted).toEqual([
65+ '/r/.git/HEAD',
66+ '/r/.git/refs/heads/main',
67+ '/r/.git/packed-refs',
68+ ])
69+
70+ expect(key).toContain('refs/heads/main')
71+ expect(probe.ran).toEqual([])
72+ })
73+
74+ test('a climbing ref: is never stat-ed; git answers instead', async () => {
75+ const probe = recorded(healthy, 'ref: ../../x\n')
76+ const key = await Git.headKeyOf(probe.deps, Fixtures.CHECKOUT)
77+
78+ expect(probe.statted).toEqual([])
79+ expect(key).toBe(`rev:${Fixtures.HEAD_SHA}`)
80+ })
81+
82+ test('a HEAD that is a symbolic link is never read or stat-ed', async () => {
83+ const probe = recorded(
84+ {
85+ ...healthy,
86+ '/r/.git': new Map<string, Git.EntryKind>([
87+ ['HEAD', 'other'],
88+ ['packed-refs', 'file'],
89+ ['refs', 'dir'],
90+ ]),
91+ },
92+ 'ref: refs/heads/main\n',
93+ )
94+
95+ const key = await Git.headKeyOf(probe.deps, Fixtures.CHECKOUT)
96+
97+ expect(probe.read).toEqual([])
98+ expect(probe.statted).toEqual([])
99+ expect(key).toBe(`rev:${Fixtures.HEAD_SHA}`)
100+ })
101+
102+ test('a symlinked refs directory hides the ref file', async () => {
103+ const probe = recorded(
104+ {
105+ ...healthy,
106+ '/r/.git': new Map<string, Git.EntryKind>([
107+ ['HEAD', 'file'],
108+ ['refs', 'other'],
109+ ]),
110+ },
111+ 'ref: refs/heads/main\n',
112+ )
113+
114+ await Git.headKeyOf(probe.deps, Fixtures.CHECKOUT)
115+
116+ expect(probe.statted).toEqual(['/r/.git/HEAD'])
117+ })
118+})
@@ -1,0 +1,29 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Git from '../../../hooks/git'
4+
5+tier('builtin')
6+
7+describe('is-safe-ref-name', () => {
8+ test('branch refs under refs/ pass', () => {
9+ for (const ref of ['refs/heads/main', 'refs/heads/me/fh-diff_mod.2']) {
10+ expect(Git.isSafeRefName(ref), ref).toBe(true)
11+ }
12+ })
13+
14+ test('climbing, absolute, backslashed, control or lock names fail', () => {
15+ for (const ref of [
16+ '',
17+ 'refs/../../etc/passwd',
18+ '/refs/heads/main',
19+ 'refs\\heads\\main',
20+ 'refs/heads/ma\u001bin',
21+ 'refs/heads/main.lock',
22+ 'refs/heads/.hidden',
23+ 'heads/main',
24+ 'refs//main',
25+ ]) {
26+ expect(Git.isSafeRefName(ref), ref).toBe(false)
27+ }
28+ })
29+})
@@ -1,0 +1,43 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Git from '../../../hooks/git'
4+import Fixtures from '../../fixtures'
5+
6+tier('builtin')
7+
8+describe('kind-walk-of', () => {
9+ const DEEP_PATH = Array.from({ length: Fixtures.WALK_DEPTH }, () => 'a').join(
10+ '/',
11+ )
12+
13+ function walkOf(budget: number) {
14+ const listed: string[] = []
15+
16+ const walk = Git.kindWalkOf(
17+ directory => {
18+ listed.push(directory)
19+
20+ return Promise.resolve(new Map([['a', 'dir' as const]]))
21+ },
22+ '/repo',
23+ budget,
24+ )
25+
26+ return { walk, listed }
27+ }
28+
29+ test('a fifty-thousand-deep path reads over-budget in budget', async () => {
30+ const { walk, listed } = walkOf(Fixtures.WALK_BUDGET)
31+
32+ expect(await walk.kindOf(DEEP_PATH)).toBe('over-budget')
33+ expect(listed.length).toBe(Fixtures.WALK_BUDGET)
34+ })
35+
36+ test('a path within the budget reads its kind from its parent', async () => {
37+ const { walk, listed } = walkOf(Fixtures.WALK_BUDGET)
38+
39+ expect(await walk.kindOf('a/a/a')).toBe('dir')
40+ expect(await walk.kindOf('a/a/b')).toBe(null)
41+ expect(listed).toEqual(['/repo', '/repo/a', '/repo/a/a'])
42+ })
43+})
@@ -1,0 +1,27 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Fixtures from '../../fixtures'
4+
5+tier('builtin')
6+
7+describe('repository-of', () => {
8+ test('three absolute lines are the repository', async () => {
9+ expect(await Fixtures.probedRepositoryOf(Fixtures.RESOLVED)).toEqual({
10+ toplevel: '/r',
11+ gitDir: '/r/.git/worktrees/w',
12+ commonDir: '/r/.git',
13+ })
14+
15+ expect(await Fixtures.probedRepositoryOf(Fixtures.WINDOWS)).toEqual({
16+ toplevel: 'C:/r',
17+ gitDir: 'C:/r/.git',
18+ commonDir: 'C:/r/.git',
19+ })
20+ })
21+
22+ test('a git that echoes the flag, or a relative path: nothing', async () => {
23+ expect(await Fixtures.probedRepositoryOf(Fixtures.ECHOED_FLAG)).toBeNull()
24+ expect(await Fixtures.probedRepositoryOf(Fixtures.RELATIVE)).toBeNull()
25+ expect(await Fixtures.probedRepositoryOf('')).toBeNull()
26+ })
27+})
@@ -1,31 +1,40 @@
11 import { describe, expect, test, tier } from 'claude-code/testing'
22
3+import Git from '../hooks/git'
34 import Fixtures from './fixtures'
45
56 tier('builtin')
67
78 describe('git', () => {
8- test('each git child runs pinned to /work, in C locale', async ($, on) => {
9- const world = Fixtures.inRepository(on)
9+ test('each git child names the pin, in the C locale', async ($, on) => {
10+ const world = Fixtures.inRepository(on, Fixtures.oneSecret())
1011
11- await $.session.start(Fixtures.SESSION)
12+ await $.session.start(Fixtures.WORKTREE_SESSION)
1213 await $.command.run(Fixtures.DIFF)
1314 await world.clock.advance(Fixtures.SETTLE_MS)
15+ await $.ui.render(Fixtures.PANE)
16+ await $.ui.press({ plugin: 'diff', key: 'ask' })
17+ await world.clock.settle()
1418
1519 const [discovery, ...pinned] = world.runs
20+ const diffs = pinned.filter(run => run.argv.includes('diff'))
1621
1722 expect(discovery?.argv).toContain('--show-toplevel')
1823 expect(discovery?.init?.cwd).toBeUndefined()
19- expect(pinned.length).toBeGreaterThan(0)
24+ expect(diffs.length).toBeGreaterThanOrEqual(3)
2025
26+ for (const run of world.runs) {
27+ expect(run.init?.env).toEqual(Git.GIT_CHILD_ENV)
28+ }
29+
2130 for (const run of pinned) {
22- expect(run.argv.slice(0, 3)).toEqual([
23- 'git',
24- '--git-dir=/work/.git',
25- '--work-tree=/work',
26- ])
31+ expect(run.argv.slice(0, Fixtures.PINNED_LEAD.length)).toEqual(
32+ Fixtures.PINNED_LEAD,
33+ )
2734
28- expect(run.init).toMatchObject({ cwd: '/work', env: { LC_ALL: 'C' } })
35+ expect(run.init).toMatchObject({ cwd: '/main/wt' })
2936 }
37+
38+ expect(world.runs.some(run => run.argv.includes('config'))).toBe(false)
3039 })
3140 })
@@ -1,0 +1,72 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Git from '../../hooks/git'
4+import PaneState from '../../hooks/pane-state'
5+import Fixtures from '../fixtures'
6+
7+tier('builtin')
8+
9+describe('base-label-of', () => {
10+ const dataIn = (
11+ mode: Git.BaseMode,
12+ source: Git.DiffSource,
13+ ): Git.DiffData => ({
14+ repository: Fixtures.CHECKOUT,
15+ mode,
16+ stats: Fixtures.ONE_FILE,
17+ files: [],
18+ source,
19+ isUnborn: false,
20+ baseRef: 'HEAD',
21+ stalePaths: [],
22+ isUntrackedWithheld: false,
23+ })
24+
25+ const labelOf = (
26+ mode: Git.BaseMode,
27+ data: Git.DiffData | null,
28+ filesCount: number,
29+ ) =>
30+ PaneState.baseLabelOf(
31+ { requestedMode: mode, data, words: Git.GIT_WORDS },
32+ filesCount,
33+ )
34+
35+ test('settled session mode and no data show no line', () => {
36+ expect(
37+ labelOf('session', dataIn('session', Fixtures.WORKING_TREE), 1),
38+ ).toBeNull()
39+
40+ expect(labelOf('branch', null, 0)).toBeNull()
41+ })
42+
43+ test('settled pinned modes name their base', () => {
44+ expect(
45+ labelOf('uncommitted', dataIn('uncommitted', Fixtures.WORKING_TREE), 1),
46+ ).toBe('uncommitted (vs HEAD)')
47+
48+ expect(labelOf('branch', dataIn('branch', Fixtures.VS_MAIN), 1)).toBe(
49+ 'branch vs main',
50+ )
51+
52+ expect(labelOf('branch', dataIn('branch', Fixtures.WORKING_TREE), 1)).toBe(
53+ 'vs HEAD (no base branch)',
54+ )
55+ })
56+
57+ test('a working tree that names its own base shows that name', () => {
58+ expect(
59+ labelOf('uncommitted', dataIn('uncommitted', Fixtures.AT_SHA), 1),
60+ ).toBe('uncommitted (vs 8634408015c1)')
61+ })
62+
63+ test('a pending mode shows with an ellipsis, session too', () => {
64+ expect(labelOf('branch', dataIn('session', Fixtures.WORKING_TREE), 1)).toBe(
65+ 'branch diff…',
66+ )
67+
68+ expect(labelOf('session', dataIn('branch', Fixtures.VS_MAIN), 1)).toBe(
69+ 'this session…',
70+ )
71+ })
72+})
@@ -1,0 +1,93 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Git from '../../hooks/git'
4+import PaneState from '../../hooks/pane-state'
5+import Fixtures from '../fixtures'
6+
7+tier('builtin')
8+
9+describe('empty-state-of', () => {
10+ const dataIn = (
11+ mode: Git.BaseMode,
12+ overrides: Partial<Git.DiffData> = {},
13+ ): Git.DiffData => ({
14+ repository: Fixtures.CHECKOUT,
15+ mode,
16+ stats: Fixtures.NO_STATS,
17+ files: [],
18+ source: { kind: 'working-tree', base: 'HEAD' },
19+ isUnborn: false,
20+ baseRef: 'HEAD',
21+ stalePaths: [],
22+ isUntrackedWithheld: false,
23+ ...overrides,
24+ })
25+
26+ test("the built-in's headlines, keyed on the fetched mode", () => {
27+ expect(PaneState.emptyStateOf(null, 0, Git.GIT_WORDS)?.headline).toBe(
28+ 'Diff unavailable',
29+ )
30+
31+ expect(PaneState.emptyStateOf(dataIn('session'), 0, Git.GIT_WORDS)).toEqual(
32+ {
33+ headline: 'No changes this session',
34+ hint: null,
35+ },
36+ )
37+
38+ expect(
39+ PaneState.emptyStateOf(dataIn('uncommitted'), 0, Git.GIT_WORDS)?.headline,
40+ ).toBe('No uncommitted changes')
41+
42+ expect(
43+ PaneState.emptyStateOf(
44+ dataIn('branch', { source: Fixtures.VS_MAIN }),
45+ 0,
46+ Git.GIT_WORDS,
47+ )?.headline,
48+ ).toBe('No changes vs main')
49+
50+ expect(PaneState.emptyStateOf(dataIn('branch'), 0, Git.GIT_WORDS)).toEqual({
51+ headline: 'No changes vs HEAD',
52+ hint: 'No base branch to compare against — showing changes vs HEAD',
53+ })
54+
55+ expect(
56+ PaneState.emptyStateOf(
57+ dataIn('session', { isUnborn: true }),
58+ 0,
59+ Git.GIT_WORDS,
60+ ),
61+ ).toEqual({
62+ headline: 'No commits yet',
63+ hint: "Nothing to diff against until the repo's first commit",
64+ })
65+ })
66+
67+ test('with files to count there is no empty state', () => {
68+ expect(
69+ PaneState.emptyStateOf(dataIn('session'), 2, Git.GIT_WORDS),
70+ ).toBeNull()
71+ })
72+
73+ test('no tracked rows, untracked withheld: claims no more', () => {
74+ const withheld = { isUntrackedWithheld: true }
75+
76+ expect(
77+ PaneState.emptyStateOf(dataIn('session', withheld), 0, Git.GIT_WORDS)
78+ ?.headline,
79+ ).toBe('No tracked changes')
80+
81+ expect(
82+ PaneState.emptyStateOf(
83+ dataIn('session', { ...withheld, isUnborn: true }),
84+ 0,
85+ Git.GIT_WORDS,
86+ )?.headline,
87+ ).toBe('No tracked changes')
88+
89+ expect(
90+ PaneState.emptyStateOf(dataIn('uncommitted', withheld), 1, Git.GIT_WORDS),
91+ ).toBeNull()
92+ })
93+})
@@ -1,0 +1,74 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import type Git from '../../hooks/git'
4+import PaneState from '../../hooks/pane-state'
5+import Fixtures from '../fixtures'
6+
7+tier('builtin')
8+
9+describe('header-totals-of', () => {
10+ const dataOf = (
11+ files: readonly Git.FileStat[],
12+ filesCount: number,
13+ ): Git.DiffData => ({
14+ repository: Fixtures.CHECKOUT,
15+ mode: 'session',
16+ stats: {
17+ filesCount,
18+ linesAdded: files.reduce((sum, each) => sum + each.added, 0),
19+ linesRemoved: files.reduce((sum, each) => sum + each.removed, 0),
20+ },
21+ files,
22+ source: { kind: 'working-tree', base: 'HEAD' },
23+ isUnborn: false,
24+ baseRef: 'HEAD',
25+ stalePaths: [],
26+ isUntrackedWithheld: false,
27+ })
28+
29+ test('pre-session rows leave the header; the rest is not shown', () => {
30+ const data = dataOf(
31+ [Fixtures.SESSION_ROW, Fixtures.PRE_SESSION_ROW],
32+ Fixtures.REPORTED_COUNT,
33+ )
34+
35+ expect(
36+ PaneState.headerTotalsOf(
37+ data,
38+ PaneState.partitionOf(data.files, 'hidden'),
39+ ),
40+ ).toEqual({ filesCount: 3, linesAdded: 3, linesRemoved: 1, notShown: 2 })
41+ })
42+
43+ test('every stored row pre-session: files past the cap still count', () => {
44+ const stored = Array.from({ length: Fixtures.STORED_COUNT }, (_, at) => ({
45+ ...Fixtures.PRE_SESSION_ROW,
46+ path: `old${at}.ts`,
47+ }))
48+
49+ const pastCap = Fixtures.PAST_CAP_COUNT - Fixtures.STORED_COUNT
50+
51+ expect(
52+ PaneState.headerTotalsOf(
53+ dataOf(stored, Fixtures.PAST_CAP_COUNT),
54+ PaneState.partitionOf(stored, 'hidden'),
55+ ),
56+ ).toEqual({
57+ filesCount: pastCap,
58+ linesAdded: 0,
59+ linesRemoved: 0,
60+ notShown: pastCap,
61+ })
62+ })
63+
64+ test('tests count as noise and leave the shown rows', () => {
65+ const partition = PaneState.partitionOf(
66+ [Fixtures.SESSION_ROW, Fixtures.TEST_ROW],
67+ 'hidden',
68+ )
69+
70+ expect(partition.shown.map(each => each.path)).toEqual(['src/a.ts'])
71+ expect(partition.noiseCount).toBe(1)
72+ expect(PaneState.partitionOf(partition.shown, 'shown').noiseCount).toBe(0)
73+ })
74+})
@@ -1,0 +1,54 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Limits from '../../hooks/limits'
4+import PaneToggle from '../../hooks/pane-toggle'
5+
6+tier('builtin')
7+
8+describe('pane-toggle-of', () => {
9+ test('a pane believed open that still draws closes, however narrow', () => {
10+ expect(
11+ PaneToggle.paneToggleOf({
12+ isBelievedOpen: true,
13+ wasDrawnWhenProbed: true,
14+ columns: Limits.OPEN_MIN_COLUMNS - 1,
15+ }),
16+ ).toBe('close')
17+ })
18+
19+ test('below the panel width it is too narrow, from it it opens', () => {
20+ expect(
21+ PaneToggle.paneToggleOf({
22+ isBelievedOpen: false,
23+ wasDrawnWhenProbed: false,
24+ columns: Limits.OPEN_MIN_COLUMNS - 1,
25+ }),
26+ ).toBe('too-narrow')
27+
28+ expect(
29+ PaneToggle.paneToggleOf({
30+ isBelievedOpen: false,
31+ wasDrawnWhenProbed: false,
32+ columns: Limits.OPEN_MIN_COLUMNS,
33+ }),
34+ ).toBe('open')
35+
36+ expect(
37+ PaneToggle.paneToggleOf({
38+ isBelievedOpen: false,
39+ wasDrawnWhenProbed: false,
40+ columns: null,
41+ }),
42+ ).toBe('open')
43+ })
44+
45+ test('a pane the person closed reopens only where it fits', () => {
46+ expect(
47+ PaneToggle.paneToggleOf({
48+ isBelievedOpen: true,
49+ wasDrawnWhenProbed: false,
50+ columns: Limits.OPEN_MIN_COLUMNS - 1,
51+ }),
52+ ).toBe('too-narrow')
53+ })
54+})
@@ -1,6 +1,8 @@
1+import type { Args } from 'claude-code'
12 import { describe, expect, mock, test, tier } from 'claude-code/testing'
23
34 import Limits from '../hooks/limits'
5+import Names from '../hooks/names'
46 import Fixtures from './fixtures'
57
68 tier('builtin')
@@ -186,4 +188,200 @@
186188
187189 expect(world.closed.map(pane => pane.id)).toEqual(['diff'])
188190 })
191+
192+ test('ask attaches the hunks on screen and calls no tool', async ($, on) => {
193+ const world = Fixtures.inRepository(on, Fixtures.oneSecret())
194+ const called: Args<'tool.call'>[] = []
195+
196+ on('tool.call', ($, e) => {
197+ called.push(e)
198+
199+ return { result: 'called' }
200+ })
201+
202+ on('prompt.submit', ($, e) => ({ text: e.text, context: e.context }))
203+
204+ await $.session.start(Fixtures.WORKTREE_SESSION)
205+ await $.command.run(Fixtures.DIFF)
206+ await world.clock.advance(Fixtures.SETTLE_MS)
207+ await $.ui.render(Fixtures.PANE)
208+ await $.ui.press({ plugin: 'diff', key: 'ask' })
209+ await world.clock.settle()
210+
211+ const armed = Fixtures.jsonOf(await $.ui.render(Fixtures.PANE))
212+
213+ const submitted = await $.prompt.submit(Fixtures.typedPromptOf('why?'))
214+
215+ expect(armed).toContain('asked ✓')
216+ expect(called).toEqual([])
217+
218+ expect(world.statuses.at(-2)).toBe(
219+ '.env rides your next prompt (press asked ✓ to drop it)',
220+ )
221+
222+ expect(submitted).toMatchObject({
223+ context: [
224+ 'The user attached the diff of .env from the diff pane to this ' +
225+ 'prompt:\n@@ -1 +1 @@\n-KEY=old\n+KEY=new',
226+ ],
227+ })
228+ })
229+
230+ test('an ask with no room in the context is dropped', async ($, on) => {
231+ const world = Fixtures.inRepository(on, Fixtures.oneSecret())
232+ const full = 'x'.repeat(Limits.PROMPT_CONTEXT_MAX_CHARS)
233+ const reached: (readonly string[] | undefined)[] = []
234+
235+ on('prompt.submit', ($, e) => {
236+ reached.push(e.context)
237+
238+ return { text: e.text }
239+ })
240+
241+ await $.session.start(Fixtures.WORKTREE_SESSION)
242+ await $.command.run(Fixtures.DIFF)
243+ await world.clock.advance(Fixtures.SETTLE_MS)
244+ await $.ui.render(Fixtures.PANE)
245+ await $.ui.press({ plugin: 'diff', key: 'ask' })
246+ await world.clock.settle()
247+
248+ await $.prompt.submit(Fixtures.typedPromptOf('why?', [full]))
249+ await $.prompt.submit(Fixtures.typedPromptOf('and now?'))
250+
251+ expect(reached).toEqual([[full], undefined])
252+
253+ expect(world.statuses.at(-1)).toBe(
254+ ".env's diff did not fit in the prompt and was dropped",
255+ )
256+ })
257+
258+ test('a worktree opens on the base it kept', async ($, on) => {
259+ const world = Fixtures.inRepository(on, Fixtures.oneSecret(), {
260+ 'base:/main/wt': 'uncommitted',
261+ })
262+
263+ await $.session.start(Fixtures.WORKTREE_SESSION)
264+ await $.command.run(Fixtures.DIFF)
265+ await world.clock.advance(Fixtures.SETTLE_MS)
266+
267+ const base = Fixtures.elementIn(await $.ui.render(Fixtures.PANE), {
268+ type: 'Select',
269+ name: 'base',
270+ })
271+
272+ expect(base?.props).toMatchObject({ value: 'uncommitted' })
273+ })
274+
275+ test('a repository made later is pinned once, on /diff', async ($, on) => {
276+ const { 'rev-parse --path-format=absolute': worktree = '', ...notYet } =
277+ Fixtures.oneSecret()
278+
279+ const script: Record<string, string> = notYet
280+ const world = Fixtures.inRepository(on, script)
281+
282+ await $.session.start(Fixtures.WORKTREE_SESSION)
283+
284+ const before = await $.command.run(Fixtures.DIFF)
285+
286+ script['rev-parse --path-format=absolute'] = worktree
287+
288+ const after = await $.command.run(Fixtures.DIFF)
289+
290+ await world.clock.advance(Fixtures.SETTLE_MS)
291+ await $.ui.render(Fixtures.PANE)
292+
293+ script['rev-parse --path-format=absolute'] = Fixtures.ELSEWHERE_LINES
294+
295+ const third = $.command.run(Fixtures.DIFF)
296+
297+ await world.clock.advance(Fixtures.SETTLE_MS)
298+ await third
299+
300+ const probes = world.runs.filter(run =>
301+ run.argv.includes('--show-toplevel'),
302+ )
303+
304+ expect(before.text).toContain("isn't in a git repository")
305+ expect(after).toEqual({})
306+ expect(world.opened[0]?.id).toBe('diff')
307+ expect(probes).toHaveLength(3)
308+
309+ expect(
310+ world.runs.some(run => run.argv.includes('--git-dir=/else/.git')),
311+ ).toBe(false)
312+
313+ expect(
314+ world.runs
315+ .filter(run => run.argv.includes('--numstat'))
316+ .every(run => run.argv[1] === Fixtures.PINNED_LEAD[1]),
317+ ).toBe(true)
318+ })
319+
320+ test('a repository that appears later opens on first edit', async ($, on) => {
321+ const { 'rev-parse --path-format=absolute': worktree = '', ...notYet } =
322+ Fixtures.oneSecret()
323+
324+ const script: Record<string, string> = notYet
325+ const world = Fixtures.inRepository(on, script)
326+
327+ on('tool.call', () => ({ result: 'edited' }))
328+
329+ await $.session.start(Fixtures.WORKTREE_SESSION)
330+ await $.ui.render(Fixtures.HINT)
331+
332+ script['rev-parse --path-format=absolute'] = worktree
333+
334+ await $.tool.call({
335+ tool: 'Edit',
336+ file_path: '/main/wt/.env',
337+ old_string: 'old',
338+ new_string: 'new',
339+ })
340+
341+ await world.clock.advance(Fixtures.SETTLE_MS)
342+
343+ expect(world.opened.map(pane => pane.id)).toEqual(['diff'])
344+ })
345+
346+ test('a git that cannot start is an answer, not a timeout', async ($, on) => {
347+ const runs: Args<'process.run'>[] = []
348+
349+ Fixtures.startsSession(on)
350+
351+ on('process.run', ($, e) => {
352+ runs.push(e)
353+
354+ return e.argv.includes('--show-toplevel')
355+ ? { deny: Fixtures.FAILED_START }
356+ : { value: Fixtures.gitIn(e.argv, Fixtures.oneSecret()) }
357+ })
358+
359+ await $.session.start(Fixtures.WORKTREE_SESSION)
360+
361+ const { text } = await $.command.run(Fixtures.DIFF)
362+ const probes = runs.filter(run => run.argv.includes('--show-toplevel'))
363+
364+ expect(text).toContain("isn't in a git repository")
365+ expect(probes, "the boot's, then this /diff's; no retry").toHaveLength(2)
366+ })
367+
368+ test('/diff on a narrow terminal: the resize line', async ($, on) => {
369+ const world = Fixtures.inRepository(on, Fixtures.oneSecret())
370+
371+ await $.session.start(Fixtures.WORKTREE_SESSION)
372+ await $.ui.render(Fixtures.hintAt(Limits.OPEN_MIN_COLUMNS - 1))
373+
374+ const narrow = await $.command.run(Fixtures.DIFF)
375+ const openedNarrow = [...world.opened]
376+
377+ await $.ui.render(Fixtures.hintAt(Limits.OPEN_MIN_COLUMNS))
378+ await $.command.run(Fixtures.DIFF)
379+
380+ expect(narrow.text).toContain(
381+ 'Resize your terminal to at least 110 columns to show the diff panel',
382+ )
383+
384+ expect(openedNarrow).toEqual([])
385+ expect(world.opened.map(pane => pane.id)).toEqual([Names.PANE_ID])
386+ })
189387 })
@@ -1,0 +1,26 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Todos from '../../hooks/todos'
4+import Fixtures from '../fixtures'
5+
6+tier('builtin')
7+
8+describe('todo-progress-of', () => {
9+ test('the latest TodoWrite decides: completed over total', () => {
10+ expect(
11+ Todos.todoProgressOf([
12+ Fixtures.todoWriteOf(['pending', 'pending']),
13+ Fixtures.todoWriteOf(['completed', 'in_progress', 'pending']),
14+ ]),
15+ ).toEqual({ done: 1, total: 3 })
16+ })
17+
18+ test('no TodoWrite yet reads as an empty list', () => {
19+ expect(
20+ Todos.todoProgressOf([{ role: 'user', text: 'hi', toolUses: [] }]),
21+ ).toEqual({
22+ done: 0,
23+ total: 0,
24+ })
25+ })
26+})
@@ -1,0 +1,94 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Limits from '../../hooks/limits'
4+import Turns from '../../hooks/turns'
5+import Fixtures from '../fixtures'
6+
7+tier('builtin')
8+
9+describe('turn-diffs-of', () => {
10+ test('turns with edits, newest first, numbered by prompt', () => {
11+ const turns = Turns.turnDiffsOf([
12+ Fixtures.promptOf('first'),
13+ Fixtures.editsOf(Fixtures.editPatchOf(['-a', '+b'])),
14+ Fixtures.toolResults(),
15+ Fixtures.promptOf('second, nothing edited'),
16+ Fixtures.promptOf('third turn with a long prompt that gets cut'),
17+ Fixtures.editsOf(Fixtures.editPatchOf(['+c']), {
18+ filePath: '/r/new.ts',
19+ type: 'create',
20+ content: 'x\ny',
21+ }),
22+ Fixtures.toolResults(),
23+ ])
24+
25+ expect(turns.map(turn => turn.index)).toEqual([3, 1])
26+ expect(turns[0]?.preview).toBe('third turn with a long prompt…')
27+
28+ expect(
29+ turns[0]?.files.map(file => [
30+ file.path,
31+ file.added,
32+ file.removed,
33+ file.isNewFile,
34+ ]),
35+ ).toEqual([
36+ ['/r/a.ts', 1, 0, false],
37+ ['/r/new.ts', 2, 0, true],
38+ ])
39+
40+ expect(turns[0]?.files[1]?.hunks).toEqual([
41+ { oldStart: 0, newStart: 1, lines: ['+x', '+y'] },
42+ ])
43+
44+ expect(turns[1]?.files.map(file => [file.added, file.removed])).toEqual([
45+ [1, 1],
46+ ])
47+ })
48+
49+ test('a file edited twice in a turn: both hunks, summed', () => {
50+ const [turn] = Turns.turnDiffsOf([
51+ Fixtures.promptOf('go'),
52+ Fixtures.editsOf(
53+ Fixtures.editPatchOf(['+a']),
54+ Fixtures.editPatchOf(['-b', '+c']),
55+ ),
56+ ])
57+
58+ expect(turn?.files).toHaveLength(1)
59+ expect(turn?.files[0]?.hunks).toHaveLength(2)
60+ expect([turn?.files[0]?.added, turn?.files[0]?.removed]).toEqual([2, 1])
61+ })
62+
63+ test('results that are not file edits are ignored', () => {
64+ expect(
65+ Turns.turnDiffsOf([
66+ Fixtures.promptOf('go'),
67+ Fixtures.editsOf({ stdout: 'x' }, 'text', null),
68+ ]),
69+ ).toEqual([])
70+ })
71+
72+ test('a big created file is cut at the line cap and says so', () => {
73+ const [turn] = Turns.turnDiffsOf([
74+ Fixtures.promptOf('write it'),
75+ Fixtures.editsOf({
76+ filePath: '/r/big.ts',
77+ type: 'create',
78+ content: Array.from(
79+ { length: Fixtures.BIG_LINES },
80+ (_, at) => `l${at}`,
81+ ).join('\n'),
82+ }),
83+ ])
84+
85+ const file = turn?.files[0]
86+
87+ expect(file?.hunks.flatMap(hunk => hunk.lines)).toHaveLength(
88+ Limits.MAX_LINES_PER_FILE,
89+ )
90+
91+ expect(file?.isTruncated).toBe(true)
92+ expect(file?.added).toBe(Fixtures.BIG_LINES)
93+ })
94+})
@@ -1,0 +1,105 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Limits from '../../../hooks/limits'
4+import Views from '../../../hooks/views'
5+import Fixtures from '../../fixtures'
6+
7+tier('builtin')
8+
9+describe('code-blocks-of', () => {
10+ test('hunks share a block while they fit, then one opens', () => {
11+ const { blocks, isTruncated } = Views.codeBlocksOf([
12+ Fixtures.wideHunk(),
13+ Fixtures.wideHunk(Fixtures.BIG_LINES),
14+ Fixtures.wideHunk(2 * Fixtures.BIG_LINES),
15+ ])
16+
17+ expect(isTruncated).toBe(false)
18+ expect(blocks.map(block => block.hasDivider)).toEqual([false, true])
19+ expect(blocks.every(Fixtures.isWithinCodeCap)).toBe(true)
20+
21+ expect(blocks.map(block => Fixtures.hunksOf(block.source).length)).toEqual([
22+ 2, 1,
23+ ])
24+
25+ expect(Fixtures.hunksOf(blocks[1]?.source ?? '')[0]?.oldStart).toBe(
26+ 2 * Fixtures.BIG_LINES,
27+ )
28+ })
29+
30+ test('an oversize hunk stacks as sub-hunks that add up', () => {
31+ const hunk = Fixtures.wideHunk(
32+ Fixtures.BIG_LINES,
33+ Limits.MAX_LINES_PER_FILE,
34+ )
35+
36+ const body = Views.codeBlocksOf([hunk])
37+ const parts = Fixtures.parsedHunksOf(body)
38+
39+ expect(body.isTruncated).toBe(false)
40+ expect(body.blocks.length).toBeGreaterThan(2)
41+ expect(body.blocks.some(block => block.hasDivider)).toBe(false)
42+ expect(body.blocks.every(Fixtures.isWithinCodeCap)).toBe(true)
43+ expect(parts).toHaveLength(body.blocks.length)
44+ expect(parts.flatMap(part => part.lines)).toEqual([...hunk.lines])
45+
46+ expect(parts.reduce((sum, part) => sum + part.oldLines, 0)).toBe(
47+ hunk.lines.filter(line => !line.startsWith('+')).length,
48+ )
49+
50+ expect(parts.reduce((sum, part) => sum + part.newLines, 0)).toBe(
51+ hunk.lines.filter(line => !line.startsWith('-')).length,
52+ )
53+
54+ expect(parts[0]?.oldStart).toBe(Fixtures.BIG_LINES)
55+ expect(Fixtures.areContiguous(parts)).toBe(true)
56+ })
57+
58+ test('letters beyond the BMP are capped in UTF-16 units', () => {
59+ const hunk = Fixtures.wideHunk(
60+ Fixtures.BIG_LINES,
61+ Limits.MAX_LINES_PER_FILE / 2,
62+ Fixtures.MATHEMATICAL_BOLD_A,
63+ )
64+
65+ const body = Views.codeBlocksOf([hunk])
66+ const parts = Fixtures.parsedHunksOf(body)
67+
68+ expect(Fixtures.MATHEMATICAL_BOLD_A).toHaveLength(2)
69+ expect(body.isTruncated).toBe(false)
70+ expect(body.blocks.length).toBeGreaterThan(2)
71+ expect(body.blocks.every(Fixtures.isWithinCodeCap)).toBe(true)
72+ expect(parts.flatMap(part => part.lines)).toEqual([...hunk.lines])
73+ expect(Fixtures.areContiguous(parts)).toBe(true)
74+ })
75+
76+ test('a line longer than any source holds is cut, reported', () => {
77+ const body = Views.codeBlocksOf([
78+ {
79+ oldStart: 1,
80+ newStart: 1,
81+ lines: [`+${'z'.repeat(2 * Views.MAX_CODE_CHARS)}`, '+b'],
82+ },
83+ ])
84+
85+ const lines = Fixtures.parsedHunksOf(body).flatMap(part => part.lines)
86+
87+ expect(body.isTruncated).toBe(true)
88+ expect(body.blocks.every(Fixtures.isWithinCodeCap)).toBe(true)
89+ expect(lines).toEqual([expect.stringMatching(/^\+z+$/), '+b'])
90+ })
91+
92+ test("a line's controls and format characters go, tabs stay", () => {
93+ const { blocks } = Views.codeBlocksOf([
94+ {
95+ oldStart: 1,
96+ newStart: 1,
97+ lines: ['-\tesc\u001b[31m', '+\tbidi\u202Eok\r', '\\ No newline'],
98+ },
99+ ])
100+
101+ expect(blocks.map(block => block.source)).toEqual([
102+ '@@ -1,1 +1,1 @@\n-\tesc[31m\n+\tbidiok',
103+ ])
104+ })
105+})
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.