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
| @@ -30,12 +30,13 @@ | ||
| 30 | 30 | fetchDiff: (mode: Git.BaseMode) => Promise<Git.FetchOutcome> |
| 31 | 31 | |
| 32 | 32 | /** |
| 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). | |
| 34 | 35 | */ |
| 35 | - fetchFileHunks: ( | |
| 36 | + fetchHunks: ( | |
| 36 | 37 | data: Git.DiffData, |
| 37 | - file: Git.FileStat, | |
| 38 | - ) => Promise<Git.FileHunks | null> | |
| 38 | + files: readonly Git.FileStat[], | |
| 39 | + ) => Promise<ReadonlyMap<string, Git.FileHunks | null>> | |
| 39 | 40 | |
| 40 | 41 | /** |
| 41 | 42 | * 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 @@ | ||
| 1 | 1 | export * from './diff-leading-args.js' |
| 2 | 2 | export * from './git-child-env' |
| 3 | +export * from './hunks-args' | |
| 3 | 4 | export * from './no-optional-locks.js' |
| 4 | 5 | export * from './pinned-lead-of' |
| 5 | 6 |
| @@ -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 | -} |
| @@ -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 '.' |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './is-last-of-diff.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -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 |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './with-closing-line.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -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 @@ | ||
| 3 | 3 | import Argv from './argv' |
| 4 | 4 | import { FAILED_RUN } from './failed-run' |
| 5 | 5 | import { fetchDiff } from './fetch-diff' |
| 6 | -import { fetchFileHunks } from './fetch-file-hunks' | |
| 6 | +import { fetchHunks } from './fetch-hunks' | |
| 7 | 7 | import { GIT_BASE_MODES } from './git-base-modes' |
| 8 | 8 | import { GIT_WORDS } from './git-words' |
| 9 | 9 | import Probes from './probes' |
| @@ -69,7 +69,7 @@ | ||
| 69 | 69 | |
| 70 | 70 | return fetchDiff(depsOf(await baseline), mode) |
| 71 | 71 | }, |
| 72 | - fetchFileHunks: (data, file) => fetchFileHunks(run, data, file), | |
| 72 | + fetchHunks: (data, files) => fetchHunks(run, data, files), | |
| 73 | 73 | headKeyOf: () => |
| 74 | 74 | Probes.headKeyOf( |
| 75 | 75 | { ...depsOf(null), readFile: host.readFile }, |
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | export * from './empty-file-hunks' |
| 5 | 5 | export * from './failed-run' |
| 6 | 6 | export * from './fetch-diff' |
| 7 | -export * from './fetch-file-hunks' | |
| 7 | +export * from './fetch-hunks' | |
| 8 | 8 | export * from './git-backend-of.js' |
| 9 | 9 | export * from './git-base-modes' |
| 10 | 10 | 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' | |
| 1 | 2 | export * from './hunk-header' |
| 2 | 3 | export * from './is-body-line' |
| 3 | 4 | export * from './is-whole-answer' |
| 4 | 5 | export * from './is-whole-listing' |
| 5 | 6 | export * from './parse-file-diff.js' |
| 7 | +export * from './parse-file-diffs' | |
| 6 | 8 | export * from './parse-numstat.js' |
| 7 | 9 | export * from './parse-shortstat.js' |
| 10 | +export * from './quoted-header-start' | |
| 8 | 11 | export * from './rename-records' |
| 9 | 12 | export * from './shortstat-pattern' |
| 13 | +export * from './unmerged-status' | |
| 10 | 14 | |
| 11 | 15 | 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 @@ | ||
| 13 | 13 | export * from './is-record' |
| 14 | 14 | export * from './kept-of' |
| 15 | 15 | export * from './limits' |
| 16 | -export * from './map-limited' | |
| 17 | 16 | export * from './message-of' |
| 18 | 17 | export * from './mtime-of' |
| 19 | 18 | export * from './names' |
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | export * from './max-files-for-details.js' |
| 4 | 4 | export * from './max-lines-per-file.js' |
| 5 | 5 | export * from './max-listed-directories.js' |
| 6 | +export * from './max-pathspec-chars' | |
| 6 | 7 | export * from './max-untracked-probes.js' |
| 7 | 8 | export * from './max-visible-files.js' |
| 8 | 9 | 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 |
| @@ -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' | |
| 2 | 1 | export * from './gutter-chrome.js' |
| 3 | 2 | export * from './max-summary-rows.js' |
| 4 | 3 | export * from './pane-top-pad-rows.js' |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './map-limited.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -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 @@ | ||
| 18 | 18 | import { isOnPaneSurface } from './is-on-pane-surface' |
| 19 | 19 | import { isRecord } from './is-record' |
| 20 | 20 | import Limits from './limits' |
| 21 | -import { mapLimited } from './map-limited' | |
| 22 | 21 | import { messageOf } from './message-of' |
| 23 | 22 | import { mtimeOf } from './mtime-of' |
| 24 | 23 | import Names from './names' |
| @@ -33,12 +32,9 @@ | ||
| 33 | 32 | * Registers the diff pane: `/diff` once the built-in stands down, the |
| 34 | 33 | * pane's drawing and refresh, its opening on Claude's first edit, the ask. |
| 35 | 34 | * |
| 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. | |
| 42 | 38 | * |
| 43 | 39 | * @param on the engine's registrar |
| 44 | 40 | */ |
| @@ -62,7 +58,7 @@ | ||
| 62 | 58 | let bodyStamp: string | null = null |
| 63 | 59 | let bodyBase: string | null = null |
| 64 | 60 | |
| 65 | - const bodyLoads = new Map<string, Promise<Git.FileHunks | null>>() | |
| 61 | + const bodyLoads = new Set<string>() | |
| 66 | 62 | |
| 67 | 63 | const polled = { toplevel: '', headKey: '' } |
| 68 | 64 | const pin = { cwd: '', isEmpty: false, epoch: 0 } |
| @@ -255,31 +251,21 @@ | ||
| 255 | 251 | ): Promise<boolean> { |
| 256 | 252 | const stamp = bodyStampOf(data) |
| 257 | 253 | |
| 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)) | |
| 261 | 255 | |
| 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 | + } | |
| 268 | 259 | |
| 269 | - redraw(engine) | |
| 270 | - } | |
| 260 | + const read = await pinned.fetchHunks(data, files) | |
| 261 | + const isCurrent = bodyStamp === stamp && read.size > 0 | |
| 271 | 262 | |
| 272 | - return body | |
| 273 | - }) | |
| 263 | + if (isCurrent) { | |
| 264 | + model = { ...model, bodies: new Map([...model.bodies, ...read]) } | |
| 265 | + redraw(engine) | |
| 274 | 266 | } |
| 275 | 267 | |
| 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) | |
| 283 | 269 | } |
| 284 | 270 | |
| 285 | 271 | function startPoll(engine: Host, pinned: Backend.Backend) { |
| @@ -506,10 +492,11 @@ | ||
| 506 | 492 | } |
| 507 | 493 | |
| 508 | 494 | 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 | |
| 510 | 499 | |
| 511 | - hasRestoredEdits = Turns.turnDiffsOf(messages).length > 0 | |
| 512 | - | |
| 513 | 500 | if (hasRestoredEdits) { |
| 514 | 501 | await openOnFirstEdit(engine) |
| 515 | 502 | } |
| @@ -687,8 +674,7 @@ | ||
| 687 | 674 | |
| 688 | 675 | on('ui.render', { component: 'PromptHint' }, ($, e, next) => { |
| 689 | 676 | if (isOnPaneSurface(e)) { |
| 690 | - const viewport: { columns?: number; isFullscreen?: boolean } | undefined = | |
| 691 | - e.viewport | |
| 677 | + const { viewport } = e | |
| 692 | 678 | |
| 693 | 679 | const isFirstMeasure = columns === null && viewport?.columns !== undefined |
| 694 | 680 |
| @@ -13,6 +13,6 @@ | ||
| 13 | 13 | baseModes: ['uncommitted'], |
| 14 | 14 | words: { ...Git.GIT_WORDS, lister: toplevel }, |
| 15 | 15 | fetchDiff: () => Promise.resolve({ kind: 'unavailable' }), |
| 16 | - fetchFileHunks: () => Promise.resolve(null), | |
| 16 | + fetchHunks: () => Promise.resolve(new Map()), | |
| 17 | 17 | headKeyOf: () => Promise.resolve(''), |
| 18 | 18 | }) |
| @@ -2,8 +2,10 @@ | ||
| 2 | 2 | |
| 3 | 3 | /** |
| 4 | 4 | * 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. | |
| 7 | 9 | */ |
| 8 | 10 | export type Beneath = { |
| 9 | 11 | /** |
| @@ -32,4 +34,10 @@ | ||
| 32 | 34 | * engine leaves an unasked open on a narrow terminal; placed when not given. |
| 33 | 35 | */ |
| 34 | 36 | 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 | |
| 35 | 43 | } |
| @@ -2,13 +2,15 @@ | ||
| 2 | 2 | |
| 3 | 3 | import { NOT_A_REPOSITORY } from './not-a-repository.js' |
| 4 | 4 | import { REPOSITORY } from './repository.js' |
| 5 | +import Scripted from './scripted' | |
| 5 | 6 | |
| 6 | 7 | /** |
| 7 | 8 | * What git answers from a script of outputs by command-line key, REPOSITORY |
| 8 | 9 | * when none is given. |
| 9 | 10 | * |
| 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. | |
| 12 | 14 | * |
| 13 | 15 | * @param argv the invocation, program first |
| 14 | 16 | * @param script git's output for each invocation whose line holds the key |
| @@ -20,8 +22,18 @@ | ||
| 20 | 22 | ): ProcessRunResult { |
| 21 | 23 | const line = argv.join(' ') |
| 22 | 24 | const found = Object.entries(script).find(([key]) => line.includes(key)) |
| 25 | + const isHunksRead = argv.includes('--raw') | |
| 23 | 26 | |
| 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 | |
| 27 | 39 | } |
| @@ -19,7 +19,7 @@ | ||
| 19 | 19 | * @param on the test's `on` |
| 20 | 20 | * @param script git's output for each invocation whose line holds the key |
| 21 | 21 | * @param beneath the store, settings, environment, transcript, whether an |
| 22 | - * open is seated | |
| 22 | + * open is seated, whether a hunks read is refused | |
| 23 | 23 | * @returns the runs, the ring's moves, the panes opened, left waiting and |
| 24 | 24 | * closed, the statuses, the clock |
| 25 | 25 | */ |
| @@ -39,7 +39,10 @@ | ||
| 39 | 39 | on('process.run', ($, e) => { |
| 40 | 40 | runs.push(e) |
| 41 | 41 | |
| 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) } | |
| 43 | 46 | }) |
| 44 | 47 | |
| 45 | 48 | 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' | |
| 1 | 2 | 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' | |
| 2 | 6 | export * from './ok.js' |
| 7 | +export * from './one-added-of.js' | |
| 8 | +export type * from './printed-file.js' | |
| 3 | 9 | export * from './probe-missed.js' |
| 4 | 10 | export * from './probed-repository-of.js' |
| 11 | +export * from './raw-record-of.js' | |
| 5 | 12 | export type * from './scripted-git.js' |
| 6 | 13 | export * from './scripted-git-of.js' |
| 7 | 14 | export * from './unscripted.js' |
| 15 | +export * from './unusual-names.js' | |
| 8 | 16 | |
| 9 | 17 | 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 @@ | ||
| 532 | 532 | const data = outcome.kind === 'data' ? outcome.data : null |
| 533 | 533 | const [row] = data?.files ?? [] |
| 534 | 534 | |
| 535 | - const body = | |
| 536 | - data && row ? await Git.fetchFileHunks(deps.run, data, row) : null | |
| 535 | + const isListed = data !== null && row !== undefined | |
| 537 | 536 | |
| 537 | + const body = isListed | |
| 538 | + ? (await Git.fetchHunks(deps.run, data, [row])).get(row.path) | |
| 539 | + : null | |
| 540 | + | |
| 538 | 541 | expect(data?.files.map(f => [f.path, f.renamedFrom])).toEqual([ |
| 539 | 542 | ['new.ts', 'old.ts'], |
| 540 | 543 | ]) |
| @@ -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 @@ | ||
| 1 | 1 | import { describe, expect, mock, test, tier } from 'claude-code/testing' |
| 2 | 2 | |
| 3 | 3 | import Git from '../hooks/git' |
| 4 | +import Limits from '../hooks/limits' | |
| 4 | 5 | import Fixtures from './fixtures' |
| 5 | 6 | |
| 6 | 7 | tier('builtin') |
| @@ -71,6 +72,46 @@ | ||
| 71 | 72 | expect(walks()[0]?.init?.cwd, 'pinned like every child').toBe('/work') |
| 72 | 73 | }) |
| 73 | 74 | |
| 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 | + | |
| 74 | 115 | test('a file moved in since the start is session work', async ($, on) => { |
| 75 | 116 | const clock = Fixtures.startsSession(on, Fixtures.SETTLE_MS) |
| 76 | 117 | |
| @@ -95,7 +136,7 @@ | ||
| 95 | 136 | expect(drawn).toContain('+1 file edited before this session (show)') |
| 96 | 137 | }) |
| 97 | 138 | |
| 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) => { | |
| 99 | 140 | let startedAt = 0 |
| 100 | 141 | |
| 101 | 142 | 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 @@ | ||
| 24 | 24 | expect(drawn).not.toContain('❯') |
| 25 | 25 | }) |
| 26 | 26 | |
| 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 | + | |
| 27 | 75 | test('inline, a row opens that file alone', async ($, on) => { |
| 28 | 76 | const world = Fixtures.inRepository(on, Fixtures.TWO_FILES) |
| 29 | 77 |