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

diff: the pane reads every file's hunks with one git process, where it started one per fileBrowse files
poteat authored 23 days ago parent 732e167 · commit b70d7dc65bab
55 changed files+1213 -200compared with 732e167
Mmods/diff/hooks/backend/types/backend/backend.ts
Amods/diff/hooks/git/argv/hunks-args/hunks-args.ts
Amods/diff/hooks/git/argv/hunks-args/index.ts
Mmods/diff/hooks/git/argv/index.ts
Dmods/diff/hooks/git/fetch-file-hunks/fetch-file-hunks.ts
Dmods/diff/hooks/git/fetch-file-hunks/index.ts
Dmods/diff/hooks/git/fetch-file-hunks/is-last-of-diff/index.ts
Dmods/diff/hooks/git/fetch-file-hunks/is-last-of-diff/is-last-of-diff.ts
Dmods/diff/hooks/git/fetch-file-hunks/with-closing-line/index.ts
Dmods/diff/hooks/git/fetch-file-hunks/with-closing-line/with-closing-line.ts
Amods/diff/hooks/git/fetch-hunks/fetch-hunks.ts
Amods/diff/hooks/git/fetch-hunks/index.ts
Amods/diff/hooks/git/fetch-hunks/is-last-of-diff/index.ts
Amods/diff/hooks/git/fetch-hunks/is-last-of-diff/is-last-of-diff.ts
Amods/diff/hooks/git/fetch-hunks/with-closing-line/index.ts
Amods/diff/hooks/git/fetch-hunks/with-closing-line/with-closing-line.ts
Mmods/diff/hooks/git/git-backend-of.ts
Mmods/diff/hooks/git/index.ts
Amods/diff/hooks/git/parse/file-header-start/file-header-start.ts
Amods/diff/hooks/git/parse/file-header-start/index.ts
Mmods/diff/hooks/git/parse/index.ts
Amods/diff/hooks/git/parse/parse-file-diffs/index.ts
Amods/diff/hooks/git/parse/parse-file-diffs/parse-file-diffs.ts
Amods/diff/hooks/git/parse/quoted-header-start/index.ts
Amods/diff/hooks/git/parse/quoted-header-start/quoted-header-start.ts
Amods/diff/hooks/git/parse/unmerged-status/index.ts
Amods/diff/hooks/git/parse/unmerged-status/unmerged-status.ts
Mmods/diff/hooks/index.ts
Mmods/diff/hooks/limits/sizes/index.ts
Amods/diff/hooks/limits/sizes/max-pathspec-chars/index.ts
Amods/diff/hooks/limits/sizes/max-pathspec-chars/max-pathspec-chars.ts
Dmods/diff/hooks/limits/sizes/pane/body-fetch-concurrency.ts
Mmods/diff/hooks/limits/sizes/pane/index.ts
Dmods/diff/hooks/map-limited/index.ts
Dmods/diff/hooks/map-limited/map-limited.ts
Mmods/diff/hooks/register.ts
Mmods/diff/tests/fixtures/backend-of/stub-backend-of.ts
Mmods/diff/tests/fixtures/beneath/beneath.ts
Mmods/diff/tests/fixtures/git-in.ts
Mmods/diff/tests/fixtures/in-repository.ts
Amods/diff/tests/fixtures/scripted/files-changed.ts
Amods/diff/tests/fixtures/scripted/hunks-answer-of.ts
Amods/diff/tests/fixtures/scripted/hunks-read-of.ts
Mmods/diff/tests/fixtures/scripted/index.ts
Amods/diff/tests/fixtures/scripted/long-line-of.ts
Amods/diff/tests/fixtures/scripted/one-added-of.ts
Amods/diff/tests/fixtures/scripted/printed-file.ts
Amods/diff/tests/fixtures/scripted/raw-record-of.ts
Amods/diff/tests/fixtures/scripted/unusual-names.ts
Mmods/diff/tests/git/fetch-diff.test.ts
Amods/diff/tests/git/fetch-hunks.test.ts
Amods/diff/tests/git/parse/parse-file-diffs.test.ts
Mmods/diff/tests/git.test.ts
Amods/diff/tests/record.test.ts
Mmods/diff/tests/views.test.ts
@@ -30,12 +30,13 @@
3030 fetchDiff: (mode: Git.BaseMode) => Promise<Git.FetchOutcome>
3131
3232 /**
33- * One row's hunks against the base its fetch read (Git.fetchFileHunks).
33+ * The rows' hunks by path against the base their fetch read, null where
34+ * they could not be read (Git.fetchHunks).
3435 */
35- fetchFileHunks: (
36+ fetchHunks: (
3637 data: Git.DiffData,
37- file: Git.FileStat,
38- ) => Promise<Git.FileHunks | null>
38+ files: readonly Git.FileStat[],
39+ ) => Promise<ReadonlyMap<string, Git.FileHunks | null>>
3940
4041 /**
4142 * A string that changes when the checked-out commit moves
@@ -1,0 +1,17 @@
1+/**
2+ * What the hunks read adds to the shared diff argv: the listed names and
3+ * every file's patch from one child, each file as it reads when asked alone.
4+ *
5+ * `--raw -z` heads the answer with the names as git holds them, NUL-ended
6+ * and never quoted; no rename is paired among the paths asked together, as
7+ * none can be for one path; the header's `a/` and `b/` hold whatever a
8+ * person's `diff.noprefix`, `diff.mnemonicPrefix` or prefixes are set to.
9+ */
10+export const HUNKS_ARGS = [
11+ '--no-renames',
12+ '--src-prefix=a/',
13+ '--dst-prefix=b/',
14+ '--raw',
15+ '-z',
16+ '-p',
17+] as const
@@ -1,0 +1,3 @@
1+export * from './hunks-args.js'
2+
3+export * as default from '.'
@@ -1,5 +1,6 @@
11 export * from './diff-leading-args.js'
22 export * from './git-child-env'
3+export * from './hunks-args'
34 export * from './no-optional-locks.js'
45 export * from './pinned-lead-of'
56
Dmods/diff/hooks/git/fetch-file-hunks/fetch-file-hunks.ts+0 -51
@@ -1,51 +1,0 @@
1-import Argv from '../argv'
2-import { EMPTY_FILE_HUNKS } from '../empty-file-hunks'
3-import Parse from '../parse'
4-import type Types from '../types'
5-import { isLastOfDiff } from './is-last-of-diff'
6-import { withClosingLine } from './with-closing-line'
7-
8-/**
9- * One file's hunks against the base its row was read against, so body and
10- * counts agree, by literal pathspec, as the built-in panel reads hunks.
11- *
12- * An untracked, binary or renamed row (the built-in shows a rename's
13- * counts, never its body), or on an unborn HEAD a file edited after
14- * staging, answers no hunks; each file but the last, a closing empty row.
15- *
16- * @param run runs git against the pinned repository
17- * @param data the fetch the row belongs to
18- * @param file the row
19- * @returns the parsed body, or null when git failed
20- */
21-export async function fetchFileHunks(
22- run: Types.GitRun,
23- data: Types.DiffData,
24- file: Types.FileStat,
25-): Promise<Types.FileHunks | null> {
26- const hasNoBody =
27- file.isUntracked ||
28- file.isBinary ||
29- file.renamedFrom !== null ||
30- data.stalePaths.includes(file.path)
31-
32- if (hasNoBody) {
33- return EMPTY_FILE_HUNKS
34- }
35-
36- const { exitCode, stdout } = await run([
37- '--literal-pathspecs',
38- ...Argv.DIFF_LEADING_ARGS,
39- data.baseRef,
40- '--',
41- file.path,
42- ])
43-
44- if (exitCode !== 0) {
45- return null
46- }
47-
48- const body = Parse.parseFileDiff(stdout)
49-
50- return isLastOfDiff(data, file) ? body : withClosingLine(body)
51-}
Dmods/diff/hooks/git/fetch-file-hunks/index.ts+0 -5
@@ -1,5 +1,0 @@
1-export * from './fetch-file-hunks.js'
2-export * from './is-last-of-diff'
3-export * from './with-closing-line'
4-
5-export * as default from '.'
Dmods/diff/hooks/git/fetch-file-hunks/is-last-of-diff/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './is-last-of-diff.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/git/fetch-file-hunks/is-last-of-diff/is-last-of-diff.ts+0 -18
@@ -1,18 +1,0 @@
1-import type Types from '../../types'
2-
3-/**
4- * Whether a row is the last file of the whole diff git printed for its
5- * fetch: the last tracked row, the untracked ones being listed apart.
6- *
7- * The built-in reads that diff in one piece and trims its end, so the last
8- * file alone has no closing empty line to draw (withClosingLine).
9- *
10- * @param data the fetch the row belongs to
11- * @param file the row
12- * @returns whether git printed this file last
13- */
14-export const isLastOfDiff = (
15- data: Types.DiffData,
16- file: Types.FileStat,
17-): boolean =>
18- data.files.filter(row => !row.isUntracked).at(-1)?.path === file.path
Dmods/diff/hooks/git/fetch-file-hunks/with-closing-line/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './with-closing-line.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/git/fetch-file-hunks/with-closing-line/with-closing-line.ts+0 -27
@@ -1,27 +1,0 @@
1-import type Types from '../../types'
2-
3-/**
4- * A parsed body with one empty context row after its last hunk's lines, as
5- * the built-in panel draws the empty line that closes git's output.
6- *
7- * The built-in reads every file's diff in one piece: the newline before the
8- * next file's header parses as a context line of the file before it. A
9- * body with no hunk, or one already cut, gains nothing.
10- *
11- * @param body the parsed body
12- * @returns the body, its last hunk one row longer
13- */
14-export function withClosingLine(body: Types.FileHunks): Types.FileHunks {
15- const last = body.hunks.at(-1)
16- const isClosed = last !== undefined && !body.isTruncated
17-
18- return isClosed
19- ? {
20- ...body,
21- hunks: [
22- ...body.hunks.slice(0, -1),
23- { ...last, lines: [...last.lines, ' '] },
24- ],
25- }
26- : body
27-}
@@ -1,0 +1,87 @@
1+import Limits from '../../limits'
2+import Argv from '../argv'
3+import { EMPTY_FILE_HUNKS } from '../empty-file-hunks'
4+import Parse from '../parse'
5+import type Types from '../types'
6+import { isLastOfDiff } from './is-last-of-diff'
7+import { withClosingLine } from './with-closing-line'
8+
9+/**
10+ * The rows' hunks against the base their counts were read against, from one
11+ * git child over their literal paths; each but the last, a closing empty row.
12+ *
13+ * A row with no body (untracked, binary, renamed, or staged then edited on
14+ * an unborn HEAD) is never named to git. Paths past MAX_PATHSPEC_CHARS and
15+ * files a cut answer left unread go to a further child.
16+ *
17+ * @param run runs git against the pinned repository
18+ * @param data the fetch the rows belong to
19+ * @param files the rows
20+ * @returns each row's parsed body by path, null where git failed
21+ */
22+export async function fetchHunks(
23+ run: Types.GitRun,
24+ data: Types.DiffData,
25+ files: readonly Types.FileStat[],
26+): Promise<ReadonlyMap<string, Types.FileHunks | null>> {
27+ const bodied = files.filter(
28+ file =>
29+ !file.isUntracked &&
30+ !file.isBinary &&
31+ file.renamedFrom === null &&
32+ !data.stalePaths.includes(file.path),
33+ )
34+
35+ const bodiless = files
36+ .filter(file => !bodied.includes(file))
37+ .map(file => [file.path, EMPTY_FILE_HUNKS] as const)
38+
39+ if (bodied.length === 0) {
40+ return new Map(bodiless)
41+ }
42+
43+ let spent = 0
44+
45+ const asked = bodied
46+ .map(file => file.path)
47+ .filter((path, at) => {
48+ spent += path.length
49+
50+ return at === 0 || spent <= Limits.MAX_PATHSPEC_CHARS
51+ })
52+
53+ const { exitCode, stdout } = await run([
54+ '--literal-pathspecs',
55+ ...Argv.DIFF_LEADING_ARGS,
56+ ...Argv.HUNKS_ARGS,
57+ data.baseRef,
58+ '--',
59+ ...asked,
60+ ])
61+
62+ const hasAnswered = exitCode === 0
63+
64+ const settled = hasAnswered
65+ ? Parse.parseFileDiffs(stdout, asked)
66+ : new Map<string, Types.FileHunks>()
67+
68+ const waiting = bodied.filter(file => !settled.has(file.path))
69+ const isStuck = settled.size === 0
70+
71+ const rest = isStuck
72+ ? waiting.map(file => [file.path, null] as const)
73+ : await fetchHunks(run, data, waiting)
74+
75+ return new Map<string, Types.FileHunks | null>([
76+ ...bodiless,
77+ ...bodied.flatMap(file => {
78+ const body = settled.get(file.path)
79+ const isLast = isLastOfDiff(data, file)
80+
81+ return body
82+ ? [[file.path, isLast ? body : withClosingLine(body)] as const]
83+ : []
84+ }),
85+ ...rest,
86+ ])
87+}
@@ -1,0 +1,5 @@
1+export * from './fetch-hunks.js'
2+export * from './is-last-of-diff'
3+export * from './with-closing-line'
4+
5+export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './is-last-of-diff.js'
2+
3+export * as default from '.'
@@ -1,0 +1,18 @@
1+import type Types from '../../types'
2+
3+/**
4+ * Whether a row is the last file of the whole diff git printed for its
5+ * fetch: the last tracked row, the untracked ones being listed apart.
6+ *
7+ * The built-in reads that diff in one piece and trims its end, so the last
8+ * file alone has no closing empty line to draw (withClosingLine).
9+ *
10+ * @param data the fetch the row belongs to
11+ * @param file the row
12+ * @returns whether git printed this file last
13+ */
14+export const isLastOfDiff = (
15+ data: Types.DiffData,
16+ file: Types.FileStat,
17+): boolean =>
18+ data.files.filter(row => !row.isUntracked).at(-1)?.path === file.path
@@ -1,0 +1,3 @@
1+export * from './with-closing-line.js'
2+
3+export * as default from '.'
@@ -1,0 +1,27 @@
1+import type Types from '../../types'
2+
3+/**
4+ * A parsed body with one empty context row after its last hunk's lines, as
5+ * the built-in panel draws the empty line that closes git's output.
6+ *
7+ * The built-in reads every file's diff in one piece: the newline before the
8+ * next file's header parses as a context line of the file before it. A
9+ * body with no hunk, or one already cut, gains nothing.
10+ *
11+ * @param body the parsed body
12+ * @returns the body, its last hunk one row longer
13+ */
14+export function withClosingLine(body: Types.FileHunks): Types.FileHunks {
15+ const last = body.hunks.at(-1)
16+ const isClosed = last !== undefined && !body.isTruncated
17+
18+ return isClosed
19+ ? {
20+ ...body,
21+ hunks: [
22+ ...body.hunks.slice(0, -1),
23+ { ...last, lines: [...last.lines, ' '] },
24+ ],
25+ }
26+ : body
27+}
@@ -3,7 +3,7 @@
33 import Argv from './argv'
44 import { FAILED_RUN } from './failed-run'
55 import { fetchDiff } from './fetch-diff'
6-import { fetchFileHunks } from './fetch-file-hunks'
6+import { fetchHunks } from './fetch-hunks'
77 import { GIT_BASE_MODES } from './git-base-modes'
88 import { GIT_WORDS } from './git-words'
99 import Probes from './probes'
@@ -69,7 +69,7 @@
6969
7070 return fetchDiff(depsOf(await baseline), mode)
7171 },
72- fetchFileHunks: (data, file) => fetchFileHunks(run, data, file),
72+ fetchHunks: (data, files) => fetchHunks(run, data, files),
7373 headKeyOf: () =>
7474 Probes.headKeyOf(
7575 { ...depsOf(null), readFile: host.readFile },
@@ -4,7 +4,7 @@
44 export * from './empty-file-hunks'
55 export * from './failed-run'
66 export * from './fetch-diff'
7-export * from './fetch-file-hunks'
7+export * from './fetch-hunks'
88 export * from './git-backend-of.js'
99 export * from './git-base-modes'
1010 export * from './git-words'
@@ -1,0 +1,5 @@
1+/**
2+ * How the line that opens one file's patch starts. No line inside a patch
3+ * starts so: a hunk's lines start with a space, `+`, `-` or a backslash.
4+ */
5+export const FILE_HEADER_START = 'diff --git '
@@ -1,0 +1,3 @@
1+export * from './file-header-start.js'
2+
3+export * as default from '.'
@@ -1,11 +1,15 @@
1+export * from './file-header-start'
12 export * from './hunk-header'
23 export * from './is-body-line'
34 export * from './is-whole-answer'
45 export * from './is-whole-listing'
56 export * from './parse-file-diff.js'
7+export * from './parse-file-diffs'
68 export * from './parse-numstat.js'
79 export * from './parse-shortstat.js'
10+export * from './quoted-header-start'
811 export * from './rename-records'
912 export * from './shortstat-pattern'
13+export * from './unmerged-status'
1014
1115 export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './parse-file-diffs.js'
2+
3+export * as default from '.'
@@ -1,0 +1,93 @@
1+import Limits from '../../../limits'
2+import { EMPTY_FILE_HUNKS } from '../../empty-file-hunks'
3+import type Types from '../../types'
4+import { FILE_HEADER_START } from '../file-header-start'
5+import { isWholeListing } from '../is-whole-listing'
6+import { parseFileDiff } from '../parse-file-diff.js'
7+import { QUOTED_HEADER_START } from '../quoted-header-start'
8+import { UNMERGED_STATUS } from '../unmerged-status'
9+
10+/**
11+ * The bodies one `git diff --raw -z -p` answer settles, by path: each file's
12+ * patch parsed as one file's, under the name git listed for it.
13+ *
14+ * The n-th patch is the n-th NUL-ended name's, unmerged names having none and
15+ * neighbours under one opening line being one file; an unquoted opening line
16+ * must equal its name's. Where the host cut, a file waits unless large.
17+ *
18+ * @param stdout the child's standard output
19+ * @param asked the paths the child was asked for
20+ * @returns the settled bodies; none where names and patches disagree
21+ */
22+export function parseFileDiffs(
23+ stdout: string,
24+ asked: readonly string[],
25+): ReadonlyMap<string, Types.FileHunks> {
26+ const fields = stdout.split('\0')
27+
28+ const patchAt = fields.findIndex(
29+ (field, at) => at % 2 === 0 && !field.startsWith(':'),
30+ )
31+
32+ const listed = fields
33+ .slice(0, patchAt)
34+ .filter(
35+ (_, at) => at % 2 === 1 && !fields[at - 1]?.endsWith(UNMERGED_STATUS),
36+ )
37+
38+ const isParted = fields[patchAt] === ''
39+
40+ const rows = fields
41+ .slice(patchAt + (isParted ? 1 : 0))
42+ .join('\0')
43+ .split('\n')
44+
45+ const opened = rows.flatMap((row, at) =>
46+ row.startsWith(FILE_HEADER_START) ? [at] : [],
47+ )
48+
49+ const starts = opened.filter(
50+ (at, nth) => rows[at] !== rows[opened[nth - 1] ?? -1],
51+ )
52+
53+ const isWhole = isWholeListing(stdout, '\n')
54+
55+ const arrived = starts
56+ .map((start, nth) => {
57+ const end = starts[nth + 1]
58+ const path = listed[nth] ?? ''
59+ const isLast = end === undefined
60+
61+ return {
62+ path,
63+ isNamed:
64+ rows[start]?.startsWith(QUOTED_HEADER_START) === true ||
65+ rows[start] === `${FILE_HEADER_START}a/${path} b/${path}`,
66+ isCut: isLast && !isWhole,
67+ text: rows.slice(start, end).join('\n') + (isLast ? '' : '\n'),
68+ }
69+ })
70+ .filter(patch => !patch.isCut || patch.text.length > Limits.MAX_DIFF_BYTES)
71+
72+ const isPaired =
73+ patchAt !== -1 &&
74+ starts.length <= listed.length &&
75+ (starts.length === listed.length || starts.length === 0 || !isWhole) &&
76+ arrived.every(patch => patch.isNamed)
77+
78+ if (!isPaired) {
79+ return new Map()
80+ }
81+
82+ const read = new Map(
83+ arrived.map(patch => [patch.path, parseFileDiff(patch.text)]),
84+ )
85+
86+ const waiting = isWhole ? [] : listed.slice(arrived.length)
87+
88+ return new Map(
89+ asked
90+ .filter(path => !waiting.includes(path))
91+ .map(path => [path, read.get(path) ?? EMPTY_FILE_HUNKS]),
92+ )
93+}
@@ -1,0 +1,3 @@
1+export * from './quoted-header-start.js'
2+
3+export * as default from '.'
@@ -1,0 +1,7 @@
1+import { FILE_HEADER_START } from '../file-header-start'
2+
3+/**
4+ * How a patch's opening line starts when git quoted the name in it: a name
5+ * holding a double quote, a backslash or a control character.
6+ */
7+export const QUOTED_HEADER_START = `${FILE_HEADER_START}"`
@@ -1,0 +1,3 @@
1+export * from './unmerged-status.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * How the raw record of an unmerged path ends: its status, after the modes
3+ * and the object names. Git prints no patch for such a path.
4+ */
5+export const UNMERGED_STATUS = ' U'
@@ -13,7 +13,6 @@
1313 export * from './is-record'
1414 export * from './kept-of'
1515 export * from './limits'
16-export * from './map-limited'
1716 export * from './message-of'
1817 export * from './mtime-of'
1918 export * from './names'
@@ -3,6 +3,7 @@
33 export * from './max-files-for-details.js'
44 export * from './max-lines-per-file.js'
55 export * from './max-listed-directories.js'
6+export * from './max-pathspec-chars'
67 export * from './max-untracked-probes.js'
78 export * from './max-visible-files.js'
89 export * from './pane'
@@ -1,0 +1,3 @@
1+export * from './max-pathspec-chars.js'
2+
3+export * as default from '.'
@@ -1,0 +1,9 @@
1+/**
2+ * How many characters of paths one git child is handed on its command
3+ * line; the rest are asked of a further child.
4+ *
5+ * Windows holds a whole command line to 32,767 characters and doubles a
6+ * quote or a backslash it has to escape: twice this, the quotes around each
7+ * path and the leading arguments stay under it.
8+ */
9+export const MAX_PATHSPEC_CHARS = 12_000
Dmods/diff/hooks/limits/sizes/pane/body-fetch-concurrency.ts+0 -5
@@ -1,5 +1,0 @@
1-/**
2- * How many files' hunks are read from the backend at once while the pane
3- * loads its bodies.
4- */
5-export const BODY_FETCH_CONCURRENCY = 6
@@ -1,4 +1,3 @@
1-export * from './body-fetch-concurrency.js'
21 export * from './gutter-chrome.js'
32 export * from './max-summary-rows.js'
43 export * from './pane-top-pad-rows.js'
Dmods/diff/hooks/map-limited/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './map-limited.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/map-limited/map-limited.ts+0 -30
@@ -1,30 +1,0 @@
1-/**
2- * Maps every item through an async function, a bounded number of them in
3- * flight at once, and resolves to the results in the items' order.
4- *
5- * @param items the inputs
6- * @param limit how many may run at once, at least one
7- * @param fn the work for one item
8- * @returns the results, in order
9- */
10-export async function mapLimited<T, R>(
11- items: readonly T[],
12- limit: number,
13- fn: (item: T) => Promise<R>,
14-): Promise<R[]> {
15- const results: R[] = []
16-
17- let next = 0
18-
19- async function worker(): Promise<void> {
20- for (let at = next++; at < items.length; at = next++) {
21- results[at] = await fn(items[at] as T)
22- }
23- }
24-
25- await Promise.all(
26- Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker),
27- )
28-
29- return results
30-}
@@ -18,7 +18,6 @@
1818 import { isOnPaneSurface } from './is-on-pane-surface'
1919 import { isRecord } from './is-record'
2020 import Limits from './limits'
21-import { mapLimited } from './map-limited'
2221 import { messageOf } from './message-of'
2322 import { mtimeOf } from './mtime-of'
2423 import Names from './names'
@@ -33,12 +32,9 @@
3332 * Registers the diff pane: `/diff` once the built-in stands down, the
3433 * pane's drawing and refresh, its opening on Claude's first edit, the ask.
3534 *
36- * Git runs when the built-in's would: `session.start` binds the host and
37- * registers `/diff`, and off its dispatch reads the transcript, so a resumed
38- * session whose turns already edited opens as its first edit would; `/diff`
39- * or the main loop's first checkpointed edit with room pins the backend,
40- * until `/clear`, which reads afresh under a pane it leaves open; a docked
41- * pane fetches, then opens.
35+ * Git runs when the built-in's would: none at the start; `/diff` or the main
36+ * loop's first checkpointed edit with room pins the backend, until `/clear`,
37+ * which reads afresh under a pane it leaves open.
4238 *
4339 * @param on the engine's registrar
4440 */
@@ -62,7 +58,7 @@
6258 let bodyStamp: string | null = null
6359 let bodyBase: string | null = null
6460
65- const bodyLoads = new Map<string, Promise<Git.FileHunks | null>>()
61+ const bodyLoads = new Set<string>()
6662
6763 const polled = { toplevel: '', headKey: '' }
6864 const pin = { cwd: '', isEmpty: false, epoch: 0 }
@@ -255,31 +251,21 @@
255251 ): Promise<boolean> {
256252 const stamp = bodyStampOf(data)
257253
258- function loadOf(file: Git.FileStat): Promise<Git.FileHunks | null> {
259- const load = pinned.fetchFileHunks(data, file)
260- bodyLoads.set(file.path, load)
254+ const files = drawnFilesOf(model).filter(file => !bodyLoads.has(file.path))
261255
262- return load.then(body => {
263- if (bodyStamp === stamp) {
264- model = {
265- ...model,
266- bodies: new Map(model.bodies).set(file.path, body),
267- }
256+ for (const file of files) {
257+ bodyLoads.add(file.path)
258+ }
268259
269- redraw(engine)
270- }
260+ const read = await pinned.fetchHunks(data, files)
261+ const isCurrent = bodyStamp === stamp && read.size > 0
271262
272- return body
273- })
263+ if (isCurrent) {
264+ model = { ...model, bodies: new Map([...model.bodies, ...read]) }
265+ redraw(engine)
274266 }
275267
276- return (
277- await mapLimited(
278- drawnFilesOf(model).filter(file => !bodyLoads.has(file.path)),
279- Limits.BODY_FETCH_CONCURRENCY,
280- loadOf,
281- )
282- ).includes(null)
268+ return [...read.values()].includes(null)
283269 }
284270
285271 function startPoll(engine: Host, pinned: Backend.Backend) {
@@ -506,10 +492,11 @@
506492 }
507493
508494 async function openOnRestore(engine: Host): Promise<void> {
509- const messages = await engine.messages().catch((): SessionMessage[] => [])
495+ hasRestoredEdits =
496+ Turns.turnDiffsOf(
497+ await engine.messages().catch((): SessionMessage[] => []),
498+ ).length > 0
510499
511- hasRestoredEdits = Turns.turnDiffsOf(messages).length > 0
512-
513500 if (hasRestoredEdits) {
514501 await openOnFirstEdit(engine)
515502 }
@@ -687,8 +674,7 @@
687674
688675 on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
689676 if (isOnPaneSurface(e)) {
690- const viewport: { columns?: number; isFullscreen?: boolean } | undefined =
691- e.viewport
677+ const { viewport } = e
692678
693679 const isFirstMeasure = columns === null && viewport?.columns !== undefined
694680
@@ -13,6 +13,6 @@
1313 baseModes: ['uncommitted'],
1414 words: { ...Git.GIT_WORDS, lister: toplevel },
1515 fetchDiff: () => Promise.resolve({ kind: 'unavailable' }),
16- fetchFileHunks: () => Promise.resolve(null),
16+ fetchHunks: () => Promise.resolve(new Map()),
1717 headKeyOf: () => Promise.resolve(''),
1818 })
@@ -2,8 +2,10 @@
22
33 /**
44 * What the world beneath a repository session holds and answers besides
5- * git: the store, the settings, the environment, the transcript, whether a
6- * pane is seated.
5+ * git.
6+ *
7+ * The store, the settings, the environment, the transcript, whether a pane
8+ * is seated, whether a hunks read is refused.
79 */
810 export type Beneath = {
911 /**
@@ -32,4 +34,10 @@
3234 * engine leaves an unasked open on a narrow terminal; placed when not given.
3335 */
3436 isLeftWaiting?: () => boolean
37+
38+ /**
39+ * Why a hunks read asked now is refused, as the host refuses a git that
40+ * outran its timeout; answered from the script when null or not given.
41+ */
42+ hunksRefusal?: () => string | null
3543 }
@@ -2,13 +2,15 @@
22
33 import { NOT_A_REPOSITORY } from './not-a-repository.js'
44 import { REPOSITORY } from './repository.js'
5+import Scripted from './scripted'
56
67 /**
78 * What git answers from a script of outputs by command-line key, REPOSITORY
89 * when none is given.
910 *
10- * An invocation the script does not know fails as git does outside a
11- * repository.
11+ * A hunks read (`--raw`) is answered for the paths it names from the bodies
12+ * the script holds under `-- <path>`; an invocation the script does not
13+ * know fails as git does outside a repository.
1214 *
1315 * @param argv the invocation, program first
1416 * @param script git's output for each invocation whose line holds the key
@@ -20,8 +22,18 @@
2022 ): ProcessRunResult {
2123 const line = argv.join(' ')
2224 const found = Object.entries(script).find(([key]) => line.includes(key))
25+ const isHunksRead = argv.includes('--raw')
2326
24- return found
25- ? { exitCode: 0, stdout: found[1], stderr: '' }
26- : NOT_A_REPOSITORY
27+ const stdout = isHunksRead
28+ ? Scripted.hunksAnswerOf(
29+ argv
30+ .slice(argv.indexOf('--') + 1)
31+ .filter(path => Object.hasOwn(script, `-- ${path}`))
32+ .map(path => [path, script[`-- ${path}`] ?? '']),
33+ )
34+ : found?.[1]
35+
36+ const isKnown = stdout !== undefined
37+
38+ return isKnown ? { exitCode: 0, stdout, stderr: '' } : NOT_A_REPOSITORY
2739 }
@@ -19,7 +19,7 @@
1919 * @param on the test's `on`
2020 * @param script git's output for each invocation whose line holds the key
2121 * @param beneath the store, settings, environment, transcript, whether an
22- * open is seated
22+ * open is seated, whether a hunks read is refused
2323 * @returns the runs, the ring's moves, the panes opened, left waiting and
2424 * closed, the statuses, the clock
2525 */
@@ -39,7 +39,10 @@
3939 on('process.run', ($, e) => {
4040 runs.push(e)
4141
42- return { value: gitIn(e.argv, script) }
42+ const refusal = e.argv.includes('--raw') ? beneath.hunksRefusal?.() : null
43+ const isRefused = typeof refusal === 'string'
44+
45+ return isRefused ? { deny: refusal } : { value: gitIn(e.argv, script) }
4346 })
4447
4548 on('ui.focus', (_engine, e) => {
@@ -1,0 +1,18 @@
1+import { answersOf } from '../answers-of.js'
2+
3+/**
4+ * Git's output in /work where that many files changed a line each.
5+ *
6+ * @param count how many files changed
7+ * @returns the answers by key
8+ */
9+export const filesChanged = (count: number) =>
10+ answersOf(
11+ Array.from({ length: count }, (_, at) => `1\t1\tfile${at}.ts\0`).join(''),
12+ Object.fromEntries(
13+ Array.from({ length: count }, (_, at) => [
14+ `file${at}.ts`,
15+ `@@ -1 +1 @@\n-const v = ${at}\n+const v = ${at + 1}\n`,
16+ ]),
17+ ),
18+ )
@@ -1,0 +1,22 @@
1+import type { PrintedFile } from './printed-file.js'
2+import { rawRecordOf } from './raw-record-of.js'
3+
4+/**
5+ * What git prints for one hunks read: every file's raw record, an empty
6+ * field, then every file's patch under its opening line, in the order given.
7+ *
8+ * @param printed the changed files
9+ * @returns the child's standard output, empty when nothing changed
10+ */
11+export function hunksAnswerOf(printed: readonly PrintedFile[]) {
12+ const records = printed.map(([path]) => rawRecordOf(path))
13+
14+ const patches = printed.map(
15+ ([path, body, opening = `diff --git a/${path} b/${path}`]) =>
16+ `${opening}\n${body}`,
17+ )
18+
19+ const isEmpty = printed.length === 0
20+
21+ return isEmpty ? '' : `${records.join('')}\0${patches.join('')}`
22+}
@@ -1,0 +1,13 @@
1+import { hunksAnswerOf } from './hunks-answer-of.js'
2+import { ok } from './ok.js'
3+import { oneAddedOf } from './one-added-of.js'
4+
5+/**
6+ * A hunks read git answered for the given paths, in their order: in each
7+ * file one added line that names it.
8+ *
9+ * @param paths the changed files
10+ * @returns the run result
11+ */
12+export const hunksReadOf = (paths: readonly string[]) =>
13+ ok(hunksAnswerOf(paths.map(path => [path, oneAddedOf(`in ${path}`)])))
@@ -1,9 +1,17 @@
1+export * from './files-changed.js'
12 export * from './git-timed-out.js'
3+export * from './hunks-answer-of.js'
4+export * from './hunks-read-of.js'
5+export * from './long-line-of.js'
26 export * from './ok.js'
7+export * from './one-added-of.js'
8+export type * from './printed-file.js'
39 export * from './probe-missed.js'
410 export * from './probed-repository-of.js'
11+export * from './raw-record-of.js'
512 export type * from './scripted-git.js'
613 export * from './scripted-git-of.js'
714 export * from './unscripted.js'
15+export * from './unusual-names.js'
816
917 export * as default from '.'
@@ -1,0 +1,9 @@
1+/**
2+ * A file's unified diff after its opening line: one hunk of one added line
3+ * that many characters long.
4+ *
5+ * @param length how long the added line is
6+ * @returns the body
7+ */
8+export const longLineOf = (length: number) =>
9+ `@@ -0,0 +1 @@\n+${'x'.repeat(length)}\n`
@@ -1,0 +1,8 @@
1+/**
2+ * A file's unified diff after its opening line: one hunk, a line of context
3+ * and one added line.
4+ *
5+ * @param text the added line, without its marker
6+ * @returns the body
7+ */
8+export const oneAddedOf = (text: string) => `@@ -1 +1,2 @@\n one\n+${text}\n`
@@ -1,0 +1,9 @@
1+/**
2+ * One file of a scripted hunks answer: its path, its unified diff after the
3+ * opening line, and that line where git would quote the name in it.
4+ */
5+export type PrintedFile = readonly [
6+ path: string,
7+ body: string,
8+ opening?: string,
9+]
@@ -1,0 +1,9 @@
1+/**
2+ * One changed file's record as `git diff --raw -z` prints it: the modes,
3+ * the object names and the status, then the path, each NUL-ended.
4+ *
5+ * @param path the path as git holds it
6+ * @returns the record
7+ */
8+export const rawRecordOf = (path: string) =>
9+ `:100644 100644 5626abf 0000000 M\0${path}\0`
@@ -1,0 +1,24 @@
1+import { oneAddedOf } from './one-added-of.js'
2+import type { PrintedFile } from './printed-file.js'
3+
4+/**
5+ * Files whose names a parse of git's lines could mistake, in git's order,
6+ * each opening as git prints it: quoted around a newline or a quote.
7+ */
8+export const UNUSUAL_NAMES: readonly PrintedFile[] = [
9+ ['a b/x b', oneAddedOf('in a folder named a b')],
10+ ['diff --git a b', oneAddedOf('named as an opening line')],
11+ ['höher.txt', oneAddedOf('outside ASCII')],
12+ [
13+ 'new\nline.txt',
14+ oneAddedOf('a newline in the name'),
15+ 'diff --git "a/new\\nline.txt" "b/new\\nline.txt"',
16+ ],
17+ [
18+ 'quo"te.txt',
19+ oneAddedOf('a quote in the name'),
20+ 'diff --git "a/quo\\"te.txt" "b/quo\\"te.txt"',
21+ ],
22+ ['with space.txt', oneAddedOf('a space in the name')],
23+ ['x b/x.txt', oneAddedOf('b/ inside the name')],
24+]
@@ -532,9 +532,12 @@
532532 const data = outcome.kind === 'data' ? outcome.data : null
533533 const [row] = data?.files ?? []
534534
535- const body =
536- data && row ? await Git.fetchFileHunks(deps.run, data, row) : null
535+ const isListed = data !== null && row !== undefined
537536
537+ const body = isListed
538+ ? (await Git.fetchHunks(deps.run, data, [row])).get(row.path)
539+ : null
540+
538541 expect(data?.files.map(f => [f.path, f.renamedFrom])).toEqual([
539542 ['new.ts', 'old.ts'],
540543 ])
@@ -1,0 +1,281 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Git from '../../hooks/git'
4+import Limits from '../../hooks/limits'
5+import Fixtures from '../fixtures'
6+
7+tier('builtin')
8+
9+describe('fetch-hunks', () => {
10+ type Answer = (asked: readonly string[], nth: number) => Git.RunResult
11+
12+ const rowOf = (path: string, kind: Partial<Git.FileStat> = {}) => ({
13+ path,
14+ renamedFrom: null,
15+ added: 1,
16+ removed: 0,
17+ isBinary: false,
18+ isUntracked: false,
19+ isPreSession: false,
20+ ...kind,
21+ })
22+
23+ const dataOf = (
24+ files: readonly Git.FileStat[],
25+ basis: Partial<Git.DiffData> = {},
26+ ): Git.DiffData => ({
27+ repository: Fixtures.FETCH_REPOSITORY,
28+ mode: 'uncommitted',
29+ stats: { filesCount: files.length, linesAdded: 0, linesRemoved: 0 },
30+ files,
31+ source: { kind: 'working-tree', base: 'HEAD' },
32+ isUnborn: false,
33+ baseRef: 'HEAD',
34+ stalePaths: [],
35+ isUntrackedWithheld: false,
36+ ...basis,
37+ })
38+
39+ function gitOf(answer: Answer = Fixtures.hunksReadOf) {
40+ const asks: (readonly string[])[] = []
41+ const argvs: (readonly string[])[] = []
42+
43+ const run: Git.GitRun = argv => {
44+ const asked = argv.slice(argv.indexOf('--') + 1)
45+
46+ argvs.push(argv)
47+ asks.push(asked)
48+
49+ return Promise.resolve(answer(asked, asks.length))
50+ }
51+
52+ return { run, asks, argvs }
53+ }
54+
55+ const linesOf = (body: Git.FileHunks | null | undefined) =>
56+ body?.hunks.flatMap(hunk => hunk.lines)
57+
58+ const FIFTY = Array.from({ length: Limits.MAX_FILES }, (_, at) =>
59+ rowOf(`src/file${at + Limits.MAX_FILES}.ts`),
60+ )
61+
62+ const LONG = ['a', 'b', 'c'].map(name =>
63+ rowOf(name.repeat(Limits.MAX_PATHSPEC_CHARS / 2)),
64+ )
65+
66+ test('fifty files are read by one child', async () => {
67+ const git = gitOf()
68+ const read = await Git.fetchHunks(git.run, dataOf(FIFTY), FIFTY)
69+
70+ expect(git.asks).toEqual([FIFTY.map(file => file.path)])
71+ expect(read.size).toBe(Limits.MAX_FILES)
72+
73+ expect(
74+ FIFTY.every(
75+ file => linesOf(read.get(file.path))?.[1] === `+in ${file.path}`,
76+ ),
77+ ).toBe(true)
78+ })
79+
80+ test('the child keeps every pin of the read of one file', async () => {
81+ const git = gitOf()
82+
83+ await Git.fetchHunks(git.run, dataOf(FIFTY), FIFTY.slice(0, 1))
84+
85+ expect(git.argvs[0]?.slice(0, -1)).toEqual([
86+ '--literal-pathspecs',
87+ '--no-optional-locks',
88+ '-c',
89+ 'diff.relative=false',
90+ '-c',
91+ 'core.quotePath=false',
92+ 'diff',
93+ '--no-ext-diff',
94+ '--no-textconv',
95+ '--ignore-submodules=dirty',
96+ '--submodule=short',
97+ '--no-renames',
98+ '--src-prefix=a/',
99+ '--dst-prefix=b/',
100+ '--raw',
101+ '-z',
102+ '-p',
103+ 'HEAD',
104+ '--',
105+ ])
106+ })
107+
108+ test('on an unborn HEAD the child reads what is staged', async () => {
109+ const git = gitOf()
110+ const files = [rowOf('edited-since.ts'), rowOf('staged.ts')]
111+
112+ const read = await Git.fetchHunks(
113+ git.run,
114+ dataOf(files, {
115+ isUnborn: true,
116+ baseRef: '--cached',
117+ stalePaths: ['edited-since.ts'],
118+ }),
119+ files,
120+ )
121+
122+ expect(git.argvs[0]?.slice(-3)).toEqual(['--cached', '--', 'staged.ts'])
123+ expect(read.get('edited-since.ts')).toEqual(Git.EMPTY_FILE_HUNKS)
124+ expect(linesOf(read.get('staged.ts'))).toEqual([' one', '+in staged.ts'])
125+ })
126+
127+ test('rows with no body are never named to git', async () => {
128+ const git = gitOf()
129+
130+ const files = [
131+ rowOf('app.ts'),
132+ rowOf('logo.png', { isBinary: true }),
133+ rowOf('moved.ts', { renamedFrom: 'old.ts' }),
134+ rowOf('new.ts', { isUntracked: true }),
135+ ]
136+
137+ const read = await Git.fetchHunks(git.run, dataOf(files), files)
138+
139+ expect(git.asks).toEqual([['app.ts']])
140+
141+ expect(
142+ ['logo.png', 'moved.ts', 'new.ts'].map(path => read.get(path)),
143+ ).toEqual([
144+ Git.EMPTY_FILE_HUNKS,
145+ Git.EMPTY_FILE_HUNKS,
146+ Git.EMPTY_FILE_HUNKS,
147+ ])
148+ })
149+
150+ test('rows with no body alone start no child', async () => {
151+ const git = gitOf()
152+ const files = [rowOf('new.ts', { isUntracked: true })]
153+
154+ await Git.fetchHunks(git.run, dataOf(files), files)
155+
156+ expect(git.asks).toEqual([])
157+ })
158+
159+ test('every file but the last gains the closing line', async () => {
160+ const git = gitOf()
161+ const files = [rowOf('a.ts'), rowOf('b.ts')]
162+ const read = await Git.fetchHunks(git.run, dataOf(files), files)
163+
164+ expect(linesOf(read.get('a.ts'))).toEqual([' one', '+in a.ts', ' '])
165+ expect(linesOf(read.get('b.ts'))).toEqual([' one', '+in b.ts'])
166+ })
167+
168+ test('paths past the budget are asked of a second child', async () => {
169+ const git = gitOf()
170+ const read = await Git.fetchHunks(git.run, dataOf(LONG), LONG)
171+ const paths = LONG.map(file => file.path)
172+
173+ expect(git.asks).toEqual([paths.slice(0, 2), paths.slice(2)])
174+
175+ expect(paths.map(path => linesOf(read.get(path))?.[1])).toEqual(
176+ paths.map(path => `+in ${path}`),
177+ )
178+ })
179+
180+ test('one path past the budget is still asked, alone', async () => {
181+ const git = gitOf()
182+ const files = [rowOf('d'.repeat(Limits.MAX_PATHSPEC_CHARS + 1)), rowOf('e')]
183+
184+ await Git.fetchHunks(git.run, dataOf(files), files)
185+
186+ expect(git.asks.map(asked => asked.length)).toEqual([1, 1])
187+ })
188+
189+ test('past the output cap the rest is asked again', async () => {
190+ const files = [rowOf('a.ts'), rowOf('big.ts'), rowOf('c.ts'), rowOf('d.ts')]
191+
192+ const git = gitOf((asked, nth) => {
193+ const isFirst = nth === 1
194+
195+ return isFirst
196+ ? Fixtures.ok(
197+ Fixtures.hunksAnswerOf([
198+ ['a.ts', Fixtures.oneAddedOf('in a.ts')],
199+ ['big.ts', Fixtures.longLineOf(Limits.HOST_OUTPUT_CAP_BYTES)],
200+ ['c.ts', Fixtures.oneAddedOf('never arrived')],
201+ ['d.ts', Fixtures.oneAddedOf('never arrived')],
202+ ]).slice(0, Limits.HOST_OUTPUT_CAP_BYTES),
203+ )
204+ : Fixtures.hunksReadOf(asked)
205+ })
206+
207+ const read = await Git.fetchHunks(git.run, dataOf(files), files)
208+
209+ expect(git.asks, 'the second list starts where the first stopped').toEqual([
210+ ['a.ts', 'big.ts', 'c.ts', 'd.ts'],
211+ ['c.ts', 'd.ts'],
212+ ])
213+
214+ expect(linesOf(read.get('a.ts'))).toEqual([' one', '+in a.ts', ' '])
215+ expect(read.get('big.ts')).toEqual(Git.LARGE_FILE_HUNKS)
216+ expect(linesOf(read.get('c.ts'))).toEqual([' one', '+in c.ts', ' '])
217+ expect(linesOf(read.get('d.ts'))).toEqual([' one', '+in d.ts'])
218+ })
219+
220+ test('a child that fails leaves every file unread', async () => {
221+ const git = gitOf(() => Fixtures.GIT_TIMED_OUT)
222+ const read = await Git.fetchHunks(git.run, dataOf(FIFTY), FIFTY)
223+
224+ expect(git.asks).toHaveLength(1)
225+ expect([...read.values()].every(body => body === null)).toBe(true)
226+ expect(read.size).toBe(Limits.MAX_FILES)
227+ })
228+
229+ test('a second child that fails keeps what the first read', async () => {
230+ const git = gitOf((asked, nth) => {
231+ const isFirst = nth === 1
232+
233+ return isFirst ? Fixtures.hunksReadOf(asked) : Fixtures.UNSCRIPTED
234+ })
235+
236+ const read = await Git.fetchHunks(git.run, dataOf(LONG), LONG)
237+ const [first, second, third] = LONG.map(file => read.get(file.path))
238+
239+ expect(git.asks).toHaveLength(2)
240+ expect(linesOf(first)?.[0]).toBe(' one')
241+ expect(linesOf(second)?.[0]).toBe(' one')
242+ expect(third).toBeNull()
243+ })
244+
245+ test('a second child is paired by its own list', async () => {
246+ const git = gitOf((asked, nth) => {
247+ const isFirst = nth === 1
248+
249+ return Fixtures.hunksReadOf(isFirst ? asked : asked.toReversed())
250+ })
251+
252+ const files = [...LONG, rowOf('z.ts')]
253+ const read = await Git.fetchHunks(git.run, dataOf(files), files)
254+ const [, , third] = LONG.map(file => file.path)
255+
256+ expect(git.asks[1]).toEqual([third, 'z.ts'])
257+ expect(linesOf(read.get(third ?? ''))?.[1]).toBe(`+in ${third}`)
258+ expect(linesOf(read.get('z.ts'))).toEqual([' one', '+in z.ts'])
259+ })
260+
261+ test('an answer whose names and patches disagree ends the read', async () => {
262+ const git = gitOf(() =>
263+ Fixtures.ok(
264+ Fixtures.hunksAnswerOf([
265+ ['a.ts', Fixtures.oneAddedOf('a'), 'diff --git a/o.ts b/o.ts'],
266+ ['b.ts', Fixtures.oneAddedOf('b')],
267+ ]),
268+ ),
269+ )
270+
271+ const files = [rowOf('a.ts'), rowOf('b.ts')]
272+ const read = await Git.fetchHunks(git.run, dataOf(files), files)
273+
274+ expect(git.asks, 'asked once, never again').toHaveLength(1)
275+
276+ expect([...read]).toEqual([
277+ ['a.ts', null],
278+ ['b.ts', null],
279+ ])
280+ })
281+})
@@ -1,0 +1,315 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Git from '../../../hooks/git'
4+import Parse from '../../../hooks/git/parse'
5+import Limits from '../../../hooks/limits'
6+import Fixtures from '../../fixtures'
7+
8+tier('builtin')
9+
10+describe('parse-file-diffs', () => {
11+ const linesOf = (body: Git.FileHunks | undefined) =>
12+ body?.hunks.flatMap(hunk => hunk.lines)
13+
14+ const cutAtCap = (printed: readonly Fixtures.PrintedFile[]) =>
15+ Fixtures.hunksAnswerOf(printed).slice(0, Limits.HOST_OUTPUT_CAP_BYTES)
16+
17+ test('an empty answer: every file asked reads no hunks', () => {
18+ expect([...Parse.parseFileDiffs('', ['a.ts', 'b.ts'])]).toEqual([
19+ ['a.ts', Git.EMPTY_FILE_HUNKS],
20+ ['b.ts', Git.EMPTY_FILE_HUNKS],
21+ ])
22+ })
23+
24+ test('each unusual name holds its own patch', () => {
25+ expect(
26+ [
27+ ...Parse.parseFileDiffs(
28+ Fixtures.hunksAnswerOf(Fixtures.UNUSUAL_NAMES),
29+ Fixtures.UNUSUAL_NAMES.map(([path]) => path),
30+ ),
31+ ].map(([path, body]) => [path, linesOf(body)]),
32+ ).toEqual(
33+ Fixtures.UNUSUAL_NAMES.map(([path, body]) => [
34+ path,
35+ body.split('\n').slice(1, -1),
36+ ]),
37+ )
38+ })
39+
40+ test('the order asked in does not move a patch', () => {
41+ const read = Parse.parseFileDiffs(
42+ Fixtures.hunksAnswerOf([
43+ ['a.ts', Fixtures.oneAddedOf('first')],
44+ ['b.ts', Fixtures.oneAddedOf('next')],
45+ ]),
46+ ['b.ts', 'a.ts'],
47+ )
48+
49+ expect(linesOf(read.get('a.ts'))).toEqual([' one', '+first'])
50+ expect(linesOf(read.get('b.ts'))).toEqual([' one', '+next'])
51+ })
52+
53+ test('content that looks like an opening line stays content', () => {
54+ const read = Parse.parseFileDiffs(
55+ Fixtures.hunksAnswerOf([
56+ [
57+ 'a.ts',
58+ '@@ -1,2 +1,2 @@\n diff --git a/b.ts b/b.ts\n' +
59+ '-diff --git a/x b/x\n+diff --git a/y b/y\n',
60+ ],
61+ ['b.ts', Fixtures.oneAddedOf('its own')],
62+ ]),
63+ ['a.ts', 'b.ts'],
64+ )
65+
66+ expect(linesOf(read.get('a.ts'))).toEqual([
67+ ' diff --git a/b.ts b/b.ts',
68+ '-diff --git a/x b/x',
69+ '+diff --git a/y b/y',
70+ ])
71+
72+ expect(linesOf(read.get('b.ts'))).toEqual([' one', '+its own'])
73+ })
74+
75+ test('a file turned link: its two patches are one file', () => {
76+ const read = Parse.parseFileDiffs(
77+ Fixtures.hunksAnswerOf([
78+ ['a.ts', Fixtures.oneAddedOf('before')],
79+ [
80+ 'link',
81+ 'deleted file mode 100644\n@@ -1 +0,0 @@\n-one\n' +
82+ 'diff --git a/link b/link\nnew file mode 120000\n' +
83+ '@@ -0,0 +1 @@\n+a.ts\n',
84+ ],
85+ ['z.ts', Fixtures.oneAddedOf('after')],
86+ ]),
87+ ['a.ts', 'link', 'z.ts'],
88+ )
89+
90+ expect(linesOf(read.get('a.ts'))).toEqual([' one', '+before'])
91+ expect(linesOf(read.get('link'))).toEqual(['-one', '+a.ts'])
92+ expect(linesOf(read.get('z.ts'))).toEqual([' one', '+after'])
93+ })
94+
95+ test('a conflicted file read against HEAD is one more file', () => {
96+ const read = Parse.parseFileDiffs(
97+ Fixtures.hunksAnswerOf([
98+ ['a.ts', Fixtures.oneAddedOf('before')],
99+ [
100+ 'both changed.ts',
101+ '@@ -1 +1,5 @@\n+<<<<<<< ours\n one\n+=======\n+theirs\n+>>>>>>> ' +
102+ 'theirs\n',
103+ ],
104+ ['gone on our side.ts', '@@ -0,0 +1 @@\n+theirs\n'],
105+ ['z.ts', Fixtures.oneAddedOf('after')],
106+ ]).replace(
107+ Fixtures.rawRecordOf('gone on our side.ts'),
108+ Fixtures.rawRecordOf('gone on our side.ts').replace(' M\0', ' A\0'),
109+ ),
110+ ['a.ts', 'both changed.ts', 'gone on our side.ts', 'z.ts'],
111+ )
112+
113+ expect(linesOf(read.get('a.ts'))).toEqual([' one', '+before'])
114+
115+ expect(linesOf(read.get('both changed.ts'))).toEqual([
116+ '+<<<<<<< ours',
117+ ' one',
118+ '+=======',
119+ '+theirs',
120+ '+>>>>>>> theirs',
121+ ])
122+
123+ expect(linesOf(read.get('gone on our side.ts'))).toEqual(['+theirs'])
124+ expect(linesOf(read.get('z.ts'))).toEqual([' one', '+after'])
125+ })
126+
127+ test('an unmerged path has no patch and reads no hunks', () => {
128+ const read = Parse.parseFileDiffs(
129+ Fixtures.hunksAnswerOf([
130+ ['a.ts', `${Fixtures.oneAddedOf('before')}* Unmerged path c.ts\n`],
131+ ['z.ts', Fixtures.oneAddedOf('after')],
132+ ]).replace(
133+ Fixtures.rawRecordOf('z.ts'),
134+ Fixtures.rawRecordOf('c.ts').replace(' M\0', ' U\0') +
135+ Fixtures.rawRecordOf('z.ts'),
136+ ),
137+ ['a.ts', 'c.ts', 'z.ts'],
138+ )
139+
140+ expect(linesOf(read.get('a.ts'))).toEqual([' one', '+before'])
141+ expect(read.get('c.ts')).toEqual(Git.EMPTY_FILE_HUNKS)
142+ expect(linesOf(read.get('z.ts'))).toEqual([' one', '+after'])
143+ })
144+
145+ test('a file git no longer lists reads no hunks', () => {
146+ const read = Parse.parseFileDiffs(
147+ Fixtures.hunksAnswerOf([['kept.ts', Fixtures.oneAddedOf('changed')]]),
148+ ['gone.ts', 'kept.ts'],
149+ )
150+
151+ expect(read.get('gone.ts')).toEqual(Git.EMPTY_FILE_HUNKS)
152+ expect(linesOf(read.get('kept.ts'))).toEqual([' one', '+changed'])
153+ })
154+
155+ test('a listed file nobody asked for is left out', () => {
156+ const read = Parse.parseFileDiffs(
157+ Fixtures.hunksAnswerOf([
158+ ['made', '@@ -1 +0,0 @@\n-a file\n'],
159+ ['made/inside.ts', '@@ -0,0 +1 @@\n+now a folder\n'],
160+ ]),
161+ ['made'],
162+ )
163+
164+ expect([...read.keys()]).toEqual(['made'])
165+ expect(linesOf(read.get('made'))).toEqual(['-a file'])
166+ })
167+
168+ test('with no empty field before the patches it reads the same', () => {
169+ expect(
170+ linesOf(
171+ Parse.parseFileDiffs(
172+ Fixtures.hunksAnswerOf([
173+ ['a.ts', Fixtures.oneAddedOf('unparted')],
174+ ]).replace('\0\0', '\0'),
175+ ['a.ts'],
176+ ).get('a.ts'),
177+ ),
178+ ).toEqual([' one', '+unparted'])
179+ })
180+
181+ test('fewer patches than names settle nothing', () => {
182+ expect(
183+ Parse.parseFileDiffs(
184+ Fixtures.rawRecordOf('a.ts') +
185+ Fixtures.hunksAnswerOf([['b.ts', Fixtures.oneAddedOf('whose')]]),
186+ ['a.ts', 'b.ts'],
187+ ).size,
188+ ).toBe(0)
189+ })
190+
191+ test('a patch opened under another name settles nothing', () => {
192+ expect(
193+ Parse.parseFileDiffs(
194+ Fixtures.hunksAnswerOf([
195+ ['a.ts', Fixtures.oneAddedOf('here'), 'diff --git a/b.ts b/b.ts'],
196+ ['b.ts', Fixtures.oneAddedOf('there'), 'diff --git a/a.ts b/a.ts'],
197+ ]),
198+ ['a.ts', 'b.ts'],
199+ ).size,
200+ ).toBe(0)
201+ })
202+
203+ test('patches git coloured: no hunks, as one file read alone', () => {
204+ expect([
205+ ...Parse.parseFileDiffs(
206+ Fixtures.hunksAnswerOf([
207+ [
208+ 'a.ts',
209+ '\u001b[36m@@ -1 +1,2 @@\u001b[m\n one\n\u001b[32m+two\u001b[m\n',
210+ '\u001b[1mdiff --git a/a.ts b/a.ts\u001b[m',
211+ ],
212+ ]),
213+ ['a.ts'],
214+ ),
215+ ]).toEqual([['a.ts', Git.EMPTY_FILE_HUNKS]])
216+ })
217+
218+ test('the line cap and the size cap hold file by file', () => {
219+ const long = Array.from(
220+ { length: Limits.MAX_LINES_PER_FILE + Fixtures.PAST_THE_CAP },
221+ (_, at) => `+${at}`,
222+ ).join('\n')
223+
224+ const read = Parse.parseFileDiffs(
225+ Fixtures.hunksAnswerOf([
226+ ['large.ts', Fixtures.longLineOf(Limits.MAX_DIFF_BYTES)],
227+ ['long.ts', `@@ -0,0 +1,405 @@\n${long}\n`],
228+ ['small.ts', Fixtures.oneAddedOf('whole')],
229+ ]),
230+ ['large.ts', 'long.ts', 'small.ts'],
231+ )
232+
233+ expect(read.get('large.ts')).toEqual(Git.LARGE_FILE_HUNKS)
234+ expect(read.get('long.ts')?.isTruncated).toBe(true)
235+
236+ expect(linesOf(read.get('long.ts'))).toHaveLength(Limits.MAX_LINES_PER_FILE)
237+
238+ expect(read.get('small.ts')?.isTruncated).toBe(false)
239+ expect(linesOf(read.get('small.ts'))).toEqual([' one', '+whole'])
240+ })
241+
242+ test('a file at the size cap to the character is not large', () => {
243+ const room =
244+ Limits.MAX_DIFF_BYTES -
245+ Fixtures.hunksAnswerOf([['edge.ts', Fixtures.longLineOf(0)]]).replace(
246+ /^.*\0/s,
247+ '',
248+ ).length
249+
250+ const largePast = (extra: number) =>
251+ Parse.parseFileDiffs(
252+ Fixtures.hunksAnswerOf([
253+ ['edge.ts', Fixtures.longLineOf(room + extra)],
254+ ['next.ts', Fixtures.oneAddedOf('after it')],
255+ ]),
256+ ['edge.ts', 'next.ts'],
257+ ).get('edge.ts')?.isLarge
258+
259+ expect(largePast(0)).toBe(false)
260+ expect(largePast(1)).toBe(true)
261+ })
262+
263+ test('cut by the host: whole files settle, the rest wait', () => {
264+ const read = Parse.parseFileDiffs(
265+ cutAtCap([
266+ ['a.ts', Fixtures.oneAddedOf('whole')],
267+ ['big.ts', Fixtures.longLineOf(Limits.MAX_DIFF_BYTES)],
268+ ['cut.ts', Fixtures.longLineOf(Limits.HOST_OUTPUT_CAP_BYTES)],
269+ ['unseen.ts', Fixtures.oneAddedOf('never arrived')],
270+ ]),
271+ ['a.ts', 'big.ts', 'cut.ts', 'unseen.ts'],
272+ )
273+
274+ expect([...read.keys()]).toEqual(['a.ts', 'big.ts', 'cut.ts'])
275+ expect(linesOf(read.get('a.ts'))).toEqual([' one', '+whole'])
276+ expect(read.get('big.ts')).toEqual(Git.LARGE_FILE_HUNKS)
277+
278+ expect(read.get('cut.ts'), 'what arrived of it is already large').toEqual(
279+ Git.LARGE_FILE_HUNKS,
280+ )
281+ })
282+
283+ test('cut by the host inside a small file: that file waits', () => {
284+ expect([
285+ ...Parse.parseFileDiffs(
286+ cutAtCap([
287+ [
288+ 'big.ts',
289+ Fixtures.longLineOf(
290+ Limits.HOST_OUTPUT_CAP_BYTES - Limits.MAX_DIFF_BYTES / 2,
291+ ),
292+ ],
293+ ['cut.ts', Fixtures.longLineOf(Limits.MAX_DIFF_BYTES)],
294+ ['unseen.ts', Fixtures.oneAddedOf('never arrived')],
295+ ]),
296+ ['big.ts', 'cut.ts', 'unseen.ts'],
297+ ),
298+ ]).toEqual([['big.ts', Git.LARGE_FILE_HUNKS]])
299+ })
300+
301+ test('cut by the host: a file git did not list reads no hunks', () => {
302+ expect([
303+ ...Parse.parseFileDiffs(
304+ cutAtCap([
305+ ['cut.ts', Fixtures.longLineOf(Limits.HOST_OUTPUT_CAP_BYTES)],
306+ ['unseen.ts', Fixtures.oneAddedOf('never arrived')],
307+ ]),
308+ ['cut.ts', 'gone.ts', 'unseen.ts'],
309+ ),
310+ ]).toEqual([
311+ ['cut.ts', Git.LARGE_FILE_HUNKS],
312+ ['gone.ts', Git.EMPTY_FILE_HUNKS],
313+ ])
314+ })
315+})
@@ -1,6 +1,7 @@
11 import { describe, expect, mock, test, tier } from 'claude-code/testing'
22
33 import Git from '../hooks/git'
4+import Limits from '../hooks/limits'
45 import Fixtures from './fixtures'
56
67 tier('builtin')
@@ -71,6 +72,46 @@
7172 expect(walks()[0]?.init?.cwd, 'pinned like every child').toBe('/work')
7273 })
7374
75+ test('fifty drawn files: one hunks child a read', async ($, on) => {
76+ const world = Fixtures.inRepository(
77+ on,
78+ Fixtures.filesChanged(Limits.MAX_FILES),
79+ )
80+
81+ const hunkReads = () => world.runs.filter(run => run.argv.includes('--raw'))
82+
83+ on('tool.call', () => ({ result: 'ran' }))
84+
85+ await $.session.start(Fixtures.SESSION)
86+ await $.command.run(Fixtures.DIFF)
87+ await world.clock.advance(Fixtures.SETTLE_MS)
88+
89+ expect(hunkReads(), 'the open read every body at once').toHaveLength(1)
90+
91+ expect(
92+ hunkReads()[0]?.argv.filter(word => /^file\d+\.ts$/.test(word)),
93+ 'every drawn file named to the one child',
94+ ).toHaveLength(Limits.MAX_FILES)
95+
96+ const read = world.runs.length
97+
98+ await $.tool.call({ tool: 'Bash', command: 'make' })
99+ await world.clock.advance(Fixtures.SETTLE_MS)
100+
101+ expect(
102+ world.runs
103+ .slice(read)
104+ .filter(run => Fixtures.gitWordOf(run.argv) !== Fixtures.POLL_WORD)
105+ .map(run => run.argv.find(word => /^--(\w*stat|raw)$/.test(word))),
106+ 'a command: the counts, then one child for fifty bodies',
107+ ).toEqual(['--shortstat', '--numstat', '--raw'])
108+
109+ expect(
110+ Fixtures.textOf(await $.ui.render(Fixtures.PANE)),
111+ 'drawn from that one answer',
112+ ).toContain('const v = 1')
113+ })
114+
74115 test('a file moved in since the start is session work', async ($, on) => {
75116 const clock = Fixtures.startsSession(on, Fixtures.SETTLE_MS)
76117
@@ -95,7 +136,7 @@
95136 expect(drawn).toContain('+1 file edited before this session (show)')
96137 })
97138
98- test("the session began when the engine says: a resumed one's first start, again at /clear", async ($, on) => {
139+ test('the session began when the engine says', async ($, on) => {
99140 let startedAt = 0
100141
101142 const clock = Fixtures.startsSession(on, Fixtures.SETTLE_MS)
@@ -1,0 +1,39 @@
1+import type { Args } from 'claude-code'
2+import { describe, expect, test, tier } from 'claude-code/testing'
3+
4+import Record from '../hooks/record'
5+import Fixtures from './fixtures'
6+
7+tier('builtin')
8+
9+describe('record', () => {
10+ test('a read marks how its hunks went, by the same names', async ($, on) => {
11+ let refusal: string | null = null
12+
13+ const marked = Fixtures.keeping<Args<'telemetry.mark'>>()
14+
15+ const world = Fixtures.inRepository(on, Fixtures.TWO_FILES, {
16+ hunksRefusal: () => refusal,
17+ })
18+
19+ on('telemetry.mark', marked.hook)
20+ on('tool.call', () => ({ result: 'ran' }))
21+
22+ await $.session.start(Fixtures.SESSION)
23+ await $.command.run(Fixtures.DIFF)
24+ await world.clock.advance(Fixtures.SETTLE_MS)
25+
26+ refusal = Fixtures.GIT_HUNG
27+
28+ await $.tool.call({ tool: 'Bash', command: 'make' })
29+ await world.clock.advance(Fixtures.SETTLE_MS)
30+
31+ expect(
32+ marked.kept.filter(mark => mark.feature === Record.FEATURES.read),
33+ 'one row a read: the counts came both times, the bodies once',
34+ ).toEqual([
35+ { feature: 'repl_diff_read', kind: 'ok' },
36+ { feature: 'repl_diff_read', kind: 'sad', reason: 'git_hunks_failed' },
37+ ])
38+ })
39+})
@@ -24,6 +24,54 @@
2424 expect(drawn).not.toContain('❯')
2525 })
2626
27+ test('a failed hunks read blanks every body, not the list', async ($, on) => {
28+ const world = Fixtures.inRepository(on, Fixtures.TWO_FILES, {
29+ hunksRefusal: () => Fixtures.GIT_HUNG,
30+ })
31+
32+ await $.session.start(Fixtures.SESSION)
33+ await $.command.run(Fixtures.DIFF)
34+ await world.clock.advance(Fixtures.SETTLE_MS)
35+
36+ const drawn = Fixtures.textOf(await $.ui.render(Fixtures.PANE))
37+
38+ expect(drawn).toContain('2 files changed +3 -1')
39+ expect(drawn).toContain('app.ts')
40+ expect(drawn).toContain('lib.ts')
41+ expect(drawn.match(/Diff unavailable/g), 'once a file').toHaveLength(2)
42+ expect(drawn).not.toContain('const a')
43+ })
44+
45+ test('the read after a failed one draws the hunks again', async ($, on) => {
46+ let refusal: string | null = Fixtures.GIT_HUNG
47+
48+ const world = Fixtures.inRepository(on, Fixtures.TWO_FILES, {
49+ hunksRefusal: () => refusal,
50+ })
51+
52+ on('tool.call', () => ({ result: 'ran' }))
53+
54+ await $.session.start(Fixtures.SESSION)
55+ await $.command.run(Fixtures.DIFF)
56+ await world.clock.advance(Fixtures.SETTLE_MS)
57+
58+ expect(
59+ Fixtures.textOf(await $.ui.render(Fixtures.PANE)),
60+ 'the failed read blanked the hunks first',
61+ ).toContain('Diff unavailable')
62+
63+ refusal = null
64+
65+ await $.tool.call({ tool: 'Bash', command: 'make' })
66+ await world.clock.advance(Fixtures.SETTLE_MS)
67+
68+ const drawn = Fixtures.textOf(await $.ui.render(Fixtures.PANE))
69+
70+ expect(drawn).not.toContain('Diff unavailable')
71+ expect(drawn).toContain('+const a = 2')
72+ expect(drawn).toContain('+export const c = 2')
73+ })
74+
2775 test('inline, a row opens that file alone', async ($, on) => {
2876 const world = Fixtures.inRepository(on, Fixtures.TWO_FILES)
2977