diff --git a/mods/diff/hooks/entry-kinds-of/entry-kinds-of.ts b/mods/diff/hooks/entry-kinds-of/entry-kinds-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..3daad05b8d9158d95f76d53e93e465a9fa428d5a --- /dev/null +++ b/mods/diff/hooks/entry-kinds-of/entry-kinds-of.ts @@ -0,0 +1,17 @@ +import type Git from '../git' +import type { Host } from '../host' + +/** + * A directory's entries by name with their own kinds, through the host's + * listing (no link followed); null when it cannot be listed. + * + * @param engine the bound host + * @returns the probe (GitDeps `entryKindsOf`) + */ +export const entryKindsOf = + (engine: Host) => + (dir: string): Promise | null> => + engine.listDir(dir).then( + entries => new Map(entries.map(entry => [entry.name, entry.kind])), + () => null, + ) diff --git a/mods/diff/hooks/git/argv/diff-leading-args.ts b/mods/diff/hooks/git/argv/diff-leading-args.ts new file mode 100644 index 0000000000000000000000000000000000000000..fe77659e21759f67c3a03a6cf346083f01ae5cf2 --- /dev/null +++ b/mods/diff/hooks/git/argv/diff-leading-args.ts @@ -0,0 +1,20 @@ +/** + * The leading argv of every `git diff` the plugin runs: the config the + * engine's own diff panel pins, `core.quotePath=false`, and raw-blob flags. + * + * Root-relative paths whatever `diff.relative` says, non-ASCII paths as + * their own bytes, no optional locks, no child `git status` in a dirty + * submodule, and no external diff or textconv driver even on a stats pass. + */ +export const DIFF_LEADING_ARGS = [ + '--no-optional-locks', + '-c', + 'diff.relative=false', + '-c', + 'core.quotePath=false', + 'diff', + '--no-ext-diff', + '--no-textconv', + '--ignore-submodules=dirty', + '--submodule=short', +] as const diff --git a/mods/diff/hooks/git/argv/git-child-env/git-child-env.ts b/mods/diff/hooks/git/argv/git-child-env/git-child-env.ts new file mode 100644 index 0000000000000000000000000000000000000000..f362db6b1c221b17faf902b692a39fc59d3781a7 --- /dev/null +++ b/mods/diff/hooks/git/argv/git-child-env/git-child-env.ts @@ -0,0 +1,8 @@ +/** + * The environment laid over every git child the plugin spawns: the C + * locale, so `--shortstat` and git's messages parse the same everywhere. + * + * Nothing else is pinned, as for the built-in panel's own diff children: + * a partial clone lazy-fetches as it would for `git diff` at the prompt. + */ +export const GIT_CHILD_ENV = { LC_ALL: 'C', LANGUAGE: '' } as const diff --git a/mods/diff/hooks/git/argv/git-child-env/index.ts b/mods/diff/hooks/git/argv/git-child-env/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..07d69588b7e3c36079d2163d880f700196c7829b --- /dev/null +++ b/mods/diff/hooks/git/argv/git-child-env/index.ts @@ -0,0 +1,3 @@ +export * from './git-child-env.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/argv/index.ts b/mods/diff/hooks/git/argv/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..42515bf3dd186a83cd205ff18b1bd9533c54383f --- /dev/null +++ b/mods/diff/hooks/git/argv/index.ts @@ -0,0 +1,6 @@ +export * from './diff-leading-args.js' +export * from './git-child-env' +export * from './no-optional-locks.js' +export * from './pinned-lead-of' + +export * as default from '.' diff --git a/mods/diff/hooks/git/argv/no-optional-locks.ts b/mods/diff/hooks/git/argv/no-optional-locks.ts new file mode 100644 index 0000000000000000000000000000000000000000..7cc47104a65f24dcf2259cc208ba0b50bdfe1a64 --- /dev/null +++ b/mods/diff/hooks/git/argv/no-optional-locks.ts @@ -0,0 +1,5 @@ +/** + * The flag every probe carries so a read never takes the index lock from a + * git the person is running. + */ +export const NO_OPTIONAL_LOCKS = '--no-optional-locks' diff --git a/mods/diff/hooks/git/argv/pinned-lead-of/index.ts b/mods/diff/hooks/git/argv/pinned-lead-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..440afd54e4ab7baee4b18ff7ea979c999e4a943b --- /dev/null +++ b/mods/diff/hooks/git/argv/pinned-lead-of/index.ts @@ -0,0 +1,3 @@ +export * from './pinned-lead-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/argv/pinned-lead-of/pinned-lead-of.ts b/mods/diff/hooks/git/argv/pinned-lead-of/pinned-lead-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..c887e733e7015602de07429c2829da372b7ac6b5 --- /dev/null +++ b/mods/diff/hooks/git/argv/pinned-lead-of/pinned-lead-of.ts @@ -0,0 +1,18 @@ +import type Types from '../../types' + +/** + * The argv that leads every git child once the repository is pinned: its + * git directory and working tree named, as the built-in panel pins them. + * + * With `--git-dir` given git discovers nothing from its cwd, so a nested + * repository the shell later moves into is never the one read. + * + * @param repository the session's pinned repository + * @returns the leading argv + */ +export const pinnedLeadOf = ( + repository: Types.Repository, +): readonly string[] => [ + `--git-dir=${repository.gitDir}`, + `--work-tree=${repository.toplevel}`, +] diff --git a/mods/diff/hooks/git/cut-hunks/cut-hunks.ts b/mods/diff/hooks/git/cut-hunks/cut-hunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..83545faa0a70a83afa7ab9183658f871b9842846 --- /dev/null +++ b/mods/diff/hooks/git/cut-hunks/cut-hunks.ts @@ -0,0 +1,30 @@ +import Limits from '../../limits' +import type Types from '../types' + +/** + * Hunks with their lines cut at MAX_LINES_PER_FILE in all, in order, and + * whether any line was dropped. + * + * The one bound on every body the pane draws or attaches, whether git + * printed it or the transcript carried it. + * + * @param hunks the hunks as parsed or reported + * @returns the cut hunks and whether the cut dropped lines + */ +export function cutHunks( + hunks: readonly Types.Hunk[], +): Pick { + let budget = Limits.MAX_LINES_PER_FILE + let total = 0 + + return { + hunks: hunks.map(hunk => { + const lines = hunk.lines.slice(0, budget) + budget -= lines.length + total += hunk.lines.length + + return { ...hunk, lines } + }), + isTruncated: total > Limits.MAX_LINES_PER_FILE, + } +} diff --git a/mods/diff/hooks/git/cut-hunks/index.ts b/mods/diff/hooks/git/cut-hunks/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..25e0de49af66b8ce43d339bd882b52d3ef5b9869 --- /dev/null +++ b/mods/diff/hooks/git/cut-hunks/index.ts @@ -0,0 +1,3 @@ +export * from './cut-hunks.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/display-path-of/display-path-of.ts b/mods/diff/hooks/git/display-path-of/display-path-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a8cd95bc7f2ceda4f57305021b226ac3b56e6b8 --- /dev/null +++ b/mods/diff/hooks/git/display-path-of/display-path-of.ts @@ -0,0 +1,17 @@ +import { renameDisplayOf } from '../rename-display-of' +import type Types from '../types' + +/** + * A row's name as the built-in lists it: the path, or for a rename the one + * line `git diff --stat` prints for it (renameDisplayOf). + * + * @param file the row + * @returns the name to draw and to sort by + */ +export function displayPathOf( + file: Pick, +): string { + const { renamedFrom, path } = file + + return renamedFrom === null ? path : renameDisplayOf(renamedFrom, path) +} diff --git a/mods/diff/hooks/git/display-path-of/index.ts b/mods/diff/hooks/git/display-path-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc631ae8a664e2eed3a3b7674e494efe36054924 --- /dev/null +++ b/mods/diff/hooks/git/display-path-of/index.ts @@ -0,0 +1,3 @@ +export * from './display-path-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/empty-file-hunks/empty-file-hunks.ts b/mods/diff/hooks/git/empty-file-hunks/empty-file-hunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..e368d272f32306eaad3857727d96af3ff89f4a0c --- /dev/null +++ b/mods/diff/hooks/git/empty-file-hunks/empty-file-hunks.ts @@ -0,0 +1,11 @@ +import type Types from '../types' + +/** + * A body with nothing to draw: an untracked or binary row's, or a staged + * file's whose index body went stale on an unborn HEAD. + */ +export const EMPTY_FILE_HUNKS: Types.FileHunks = Object.freeze({ + hunks: [], + isTruncated: false, + isLarge: false, +}) diff --git a/mods/diff/hooks/git/empty-file-hunks/index.ts b/mods/diff/hooks/git/empty-file-hunks/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f702b35429a839b3d8dfe010df38d4858bc023ce --- /dev/null +++ b/mods/diff/hooks/git/empty-file-hunks/index.ts @@ -0,0 +1,3 @@ +export * from './empty-file-hunks.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/failed-run/failed-run.ts b/mods/diff/hooks/git/failed-run/failed-run.ts new file mode 100644 index 0000000000000000000000000000000000000000..33998eada7894ed7aed07fc6af9fa7539e77f5bf --- /dev/null +++ b/mods/diff/hooks/git/failed-run/failed-run.ts @@ -0,0 +1,11 @@ +import type Types from '../types' + +/** + * What a child that never ran, was killed, or timed out reads as: exit code + * -1 and no output, so a runner never rejects (GitRun). + */ +export const FAILED_RUN: Types.RunResult = Object.freeze({ + exitCode: -1, + stdout: '', + stderr: '', +}) diff --git a/mods/diff/hooks/git/failed-run/index.ts b/mods/diff/hooks/git/failed-run/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1a9f26785bf2bd553788ab7cbe3c736a702a0443 --- /dev/null +++ b/mods/diff/hooks/git/failed-run/index.ts @@ -0,0 +1,3 @@ +export * from './failed-run.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/fetch-diff/fetch-diff.ts b/mods/diff/hooks/git/fetch-diff/fetch-diff.ts new file mode 100644 index 0000000000000000000000000000000000000000..6bed75a61b2bb33d21252546cfad0050ecc9dfe5 --- /dev/null +++ b/mods/diff/hooks/git/fetch-diff/fetch-diff.ts @@ -0,0 +1,33 @@ +import Probes from '../probes' +import Tiers from '../tiers' +import type Types from '../types' + +/** + * One fetch of the session's pinned repository, as the built-in panel + * fetches; every git child names it so paths stay root-relative. + * + * Mid-merge, mid-rebase, mid-cherry-pick or mid-revert, or when git cannot + * answer, it is unavailable and the caller keeps what it had. + * + * @param deps the host: pinned git, the repository, the file probes + * @param mode what to compare against + * @returns the outcome + */ +export async function fetchDiff( + deps: Types.GitDeps, + mode: Types.BaseMode, +): Promise { + const isBusy = await Probes.isTransient( + deps.entryKindsOf, + deps.repository.gitDir, + ) + + if (isBusy) { + return { kind: 'unavailable' } + } + + const context = Tiers.fetchContextOf(deps, mode) + const isBranch = mode === 'branch' + + return isBranch ? Tiers.branchTier(context) : Tiers.workingTreeTier(context) +} diff --git a/mods/diff/hooks/git/fetch-diff/index.ts b/mods/diff/hooks/git/fetch-diff/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..18c8b1307f94737a66d4de968169d892e3bad43e --- /dev/null +++ b/mods/diff/hooks/git/fetch-diff/index.ts @@ -0,0 +1,3 @@ +export * from './fetch-diff.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/fetch-file-hunks/fetch-file-hunks.ts b/mods/diff/hooks/git/fetch-file-hunks/fetch-file-hunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..200dbfd98a7f2bd4e2e014619e92924fe98341cd --- /dev/null +++ b/mods/diff/hooks/git/fetch-file-hunks/fetch-file-hunks.ts @@ -0,0 +1,51 @@ +import Argv from '../argv' +import { EMPTY_FILE_HUNKS } from '../empty-file-hunks' +import Parse from '../parse' +import type Types from '../types' +import { isLastOfDiff } from './is-last-of-diff' +import { withClosingLine } from './with-closing-line' + +/** + * One file's hunks against the base its row was read against, so body and + * counts agree, by literal pathspec, as the built-in panel reads hunks. + * + * An untracked, binary or renamed row (the built-in shows a rename's + * counts, never its body), or on an unborn HEAD a file edited after + * staging, answers no hunks; each file but the last, a closing empty row. + * + * @param run runs git against the pinned repository + * @param data the fetch the row belongs to + * @param file the row + * @returns the parsed body, or null when git failed + */ +export async function fetchFileHunks( + run: Types.GitRun, + data: Types.DiffData, + file: Types.FileStat, +): Promise { + const hasNoBody = + file.isUntracked || + file.isBinary || + file.renamedFrom !== null || + data.stalePaths.includes(file.path) + + if (hasNoBody) { + return EMPTY_FILE_HUNKS + } + + const { exitCode, stdout } = await run([ + '--literal-pathspecs', + ...Argv.DIFF_LEADING_ARGS, + data.baseRef, + '--', + file.path, + ]) + + if (exitCode !== 0) { + return null + } + + const body = Parse.parseFileDiff(stdout) + + return isLastOfDiff(data, file) ? body : withClosingLine(body) +} diff --git a/mods/diff/hooks/git/fetch-file-hunks/index.ts b/mods/diff/hooks/git/fetch-file-hunks/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e07f872d9971ba7fc5dae2f678816ebc1af4a427 --- /dev/null +++ b/mods/diff/hooks/git/fetch-file-hunks/index.ts @@ -0,0 +1,5 @@ +export * from './fetch-file-hunks.js' +export * from './is-last-of-diff' +export * from './with-closing-line' + +export * as default from '.' diff --git a/mods/diff/hooks/git/fetch-file-hunks/is-last-of-diff/index.ts b/mods/diff/hooks/git/fetch-file-hunks/is-last-of-diff/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..127f870459f919ecbfb8855598c7f854eb033190 --- /dev/null +++ b/mods/diff/hooks/git/fetch-file-hunks/is-last-of-diff/index.ts @@ -0,0 +1,3 @@ +export * from './is-last-of-diff.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/fetch-file-hunks/is-last-of-diff/is-last-of-diff.ts b/mods/diff/hooks/git/fetch-file-hunks/is-last-of-diff/is-last-of-diff.ts new file mode 100644 index 0000000000000000000000000000000000000000..0681af90d1ab52e6ddf381a8863875643813732f --- /dev/null +++ b/mods/diff/hooks/git/fetch-file-hunks/is-last-of-diff/is-last-of-diff.ts @@ -0,0 +1,18 @@ +import type Types from '../../types' + +/** + * Whether a row is the last file of the whole diff git printed for its + * fetch: the last tracked row, the untracked ones being listed apart. + * + * The built-in reads that diff in one piece and trims its end, so the last + * file alone has no closing empty line to draw (withClosingLine). + * + * @param data the fetch the row belongs to + * @param file the row + * @returns whether git printed this file last + */ +export const isLastOfDiff = ( + data: Types.DiffData, + file: Types.FileStat, +): boolean => + data.files.filter(row => !row.isUntracked).at(-1)?.path === file.path diff --git a/mods/diff/hooks/git/fetch-file-hunks/with-closing-line/index.ts b/mods/diff/hooks/git/fetch-file-hunks/with-closing-line/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c6bf8e1cb7e75b54ea58c2161b955e114e2e5e98 --- /dev/null +++ b/mods/diff/hooks/git/fetch-file-hunks/with-closing-line/index.ts @@ -0,0 +1,3 @@ +export * from './with-closing-line.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/fetch-file-hunks/with-closing-line/with-closing-line.ts b/mods/diff/hooks/git/fetch-file-hunks/with-closing-line/with-closing-line.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c81e001b5ad06e743e93c907f735b5fa6465976 --- /dev/null +++ b/mods/diff/hooks/git/fetch-file-hunks/with-closing-line/with-closing-line.ts @@ -0,0 +1,27 @@ +import type Types from '../../types' + +/** + * A parsed body with one empty context row after its last hunk's lines, as + * the built-in panel draws the empty line that closes git's output. + * + * The built-in reads every file's diff in one piece: the newline before the + * next file's header parses as a context line of the file before it. A + * body with no hunk, or one already cut, gains nothing. + * + * @param body the parsed body + * @returns the body, its last hunk one row longer + */ +export function withClosingLine(body: Types.FileHunks): Types.FileHunks { + const last = body.hunks.at(-1) + const isClosed = last !== undefined && !body.isTruncated + + return isClosed + ? { + ...body, + hunks: [ + ...body.hunks.slice(0, -1), + { ...last, lines: [...last.lines, ' '] }, + ], + } + : body +} diff --git a/mods/diff/hooks/git/git-backend-of.ts b/mods/diff/hooks/git/git-backend-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..fb4a65bad3f9db57a00f3771feab6fd3660ce69c --- /dev/null +++ b/mods/diff/hooks/git/git-backend-of.ts @@ -0,0 +1,79 @@ +import type Backend from '../backend' +import Limits from '../limits' +import Argv from './argv' +import { FAILED_RUN } from './failed-run' +import { fetchDiff } from './fetch-diff' +import { fetchFileHunks } from './fetch-file-hunks' +import { GIT_BASE_MODES } from './git-base-modes' +import { GIT_WORDS } from './git-words' +import Probes from './probes' +import type Types from './types' + +/** + * The git backend over the session's directory: the repository read with + * one `rev-parse` in that directory, then every child pinned to it. + * + * Each child runs with `--git-dir`, `--work-tree` and cwd the top, under + * the C locale and the fetch's timeout (the built-in's execPinnedGit); the + * first fetch reads the paths dirty then, once (Probes.dirtyPathsOf). + * + * @param host the bound host's runner and probes + * @returns the backend, or null outside a git working tree + */ +export async function gitBackendOf( + host: Backend.BackendHost, +): Promise { + const runOf = + (pinned: Types.Repository | null): Types.GitRun => + async argv => { + try { + return await host.run( + ['git', ...(pinned ? Argv.pinnedLeadOf(pinned) : []), ...argv], + { + timeoutMs: Limits.GIT_TIMEOUT_MS, + env: Argv.GIT_CHILD_ENV, + ...(pinned && { cwd: pinned.toplevel }), + }, + ) + } catch { + return FAILED_RUN + } + } + + const repository = await Probes.repositoryOf(runOf(null)) + + if (!repository) { + return null + } + + const run = runOf(repository) + + let baseline: Promise | null> | null = null + + const depsOf = (dirty: ReadonlySet | null): Types.GitDeps => ({ + run, + repository, + mtimeOf: host.mtimeOf, + entryKindsOf: host.entryKindsOf, + sessionStartMs: host.sessionStartMsOf(), + baseline: dirty, + onBranchBase: host.onBranchBase, + }) + + return { + repository, + baseModes: GIT_BASE_MODES, + words: GIT_WORDS, + fetchDiff: async mode => { + baseline ??= Probes.dirtyPathsOf(run) + + return fetchDiff(depsOf(await baseline), mode) + }, + fetchFileHunks: (data, file) => fetchFileHunks(run, data, file), + headKeyOf: () => + Probes.headKeyOf( + { ...depsOf(null), readFile: host.readFile }, + repository, + ), + } +} diff --git a/mods/diff/hooks/git/git-base-modes/git-base-modes.ts b/mods/diff/hooks/git/git-base-modes/git-base-modes.ts new file mode 100644 index 0000000000000000000000000000000000000000..7211d985d17e22594672318b933cd5c12a460606 --- /dev/null +++ b/mods/diff/hooks/git/git-base-modes/git-base-modes.ts @@ -0,0 +1,10 @@ +import type Types from '../types' + +/** + * The comparisons git offers, in picker order: all three. + */ +export const GIT_BASE_MODES: readonly Types.BaseMode[] = Object.freeze([ + 'session', + 'uncommitted', + 'branch', +]) diff --git a/mods/diff/hooks/git/git-base-modes/index.ts b/mods/diff/hooks/git/git-base-modes/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..506b35bae7e039b9256226dd94a639f16d423d47 --- /dev/null +++ b/mods/diff/hooks/git/git-base-modes/index.ts @@ -0,0 +1,3 @@ +export * from './git-base-modes.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/git-words/git-words.ts b/mods/diff/hooks/git/git-words/git-words.ts new file mode 100644 index 0000000000000000000000000000000000000000..b6683b687bd921a340ef1227b1fe65e3f269b36b --- /dev/null +++ b/mods/diff/hooks/git/git-words/git-words.ts @@ -0,0 +1,19 @@ +import type Backend from '../../backend' + +/** + * The pane's git words, unquoted as DiffDetailView words them. + * + * Compared with HEAD, read by `git diff`, untracked files listed by git and + * staged with `git add :/…`. + */ +export const GIT_WORDS: Backend.BackendWords = Object.freeze({ + base: 'HEAD', + diffCommand: 'git diff', + lister: 'git', + untrackedNoteOf: (path: string, isPasteable: boolean) => [ + 'New file not yet staged.', + isPasteable + ? `Run \`git add :/${path}\` to see line counts.` + : 'Stage it with git add to see line counts.', + ], +}) diff --git a/mods/diff/hooks/git/git-words/index.ts b/mods/diff/hooks/git/git-words/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0b1c24d828ca79f511c65b9415fce75c7dc9442 --- /dev/null +++ b/mods/diff/hooks/git/git-words/index.ts @@ -0,0 +1,3 @@ +export * from './git-words.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/hunk-after.ts b/mods/diff/hooks/git/hunk-after.ts new file mode 100644 index 0000000000000000000000000000000000000000..577d3219cae3c6d475f7e694920cc42f6b45676b --- /dev/null +++ b/mods/diff/hooks/git/hunk-after.ts @@ -0,0 +1,20 @@ +import { countOf } from '../count-of' +import type { Hunk } from './types' + +/** + * The hunk less its first so many lines, its starts moved past them so + * the numbers of the lines kept stay right; whole at 0. + * + * @param hunk the hunk, its lines body lines only + * @param count how many leading lines to drop + * @returns the rest of the hunk + */ +export function hunkAfter(hunk: Hunk, count: number): Hunk { + const dropped = hunk.lines.slice(0, count) + + return { + oldStart: hunk.oldStart + countOf(dropped, line => !line.startsWith('+')), + newStart: hunk.newStart + countOf(dropped, line => !line.startsWith('-')), + lines: hunk.lines.slice(count), + } +} diff --git a/mods/diff/hooks/git/hunk-of/hunk-of.ts b/mods/diff/hooks/git/hunk-of/hunk-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..4aaed04f2be3945ff8cb22edea7f32d156747a12 --- /dev/null +++ b/mods/diff/hooks/git/hunk-of/hunk-of.ts @@ -0,0 +1,17 @@ +import type Types from '../types' + +/** + * One hunk from its header's captured starts and its body lines. + * + * @param starts the `old` and `new` groups of HUNK_HEADER, when it matched + * @param lines the body lines after the header + * @returns the hunk + */ +export const hunkOf = ( + starts: Record | undefined, + lines: readonly string[], +): Types.Hunk => ({ + oldStart: Number(starts?.old), + newStart: Number(starts?.new), + lines, +}) diff --git a/mods/diff/hooks/git/hunk-of/index.ts b/mods/diff/hooks/git/hunk-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9dc9d6d5cde9b5e2b326a512e7f3a61e644f82a8 --- /dev/null +++ b/mods/diff/hooks/git/hunk-of/index.ts @@ -0,0 +1,3 @@ +export * from './hunk-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/index.ts b/mods/diff/hooks/git/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4583dea198d66770744b5b81e1b40ab2c24b0852 --- /dev/null +++ b/mods/diff/hooks/git/index.ts @@ -0,0 +1,20 @@ +export * from './argv' +export * from './cut-hunks' +export * from './display-path-of' +export * from './empty-file-hunks' +export * from './failed-run' +export * from './fetch-diff' +export * from './fetch-file-hunks' +export * from './git-backend-of.js' +export * from './git-base-modes' +export * from './git-words' +export * from './hunk-after.js' +export * from './hunk-of' +export * from './large-file-hunks' +export * from './parse' +export * from './probes' +export * from './rename-display-of' +export * from './tiers' +export * from './types' + +export * as default from '.' diff --git a/mods/diff/hooks/git/large-file-hunks/index.ts b/mods/diff/hooks/git/large-file-hunks/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a8706bae015729a2cd50bc4742ba07b1c617bd0 --- /dev/null +++ b/mods/diff/hooks/git/large-file-hunks/index.ts @@ -0,0 +1,3 @@ +export * from './large-file-hunks.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/large-file-hunks/large-file-hunks.ts b/mods/diff/hooks/git/large-file-hunks/large-file-hunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5bca63b573497e8218183094bb3de4a1c8b17bf --- /dev/null +++ b/mods/diff/hooks/git/large-file-hunks/large-file-hunks.ts @@ -0,0 +1,11 @@ +import type Types from '../types' + +/** + * The body of a file whose diff text passed MAX_DIFF_BYTES: no hunks, and + * the pane says the file is too large. + */ +export const LARGE_FILE_HUNKS: Types.FileHunks = Object.freeze({ + hunks: [], + isTruncated: false, + isLarge: true, +}) diff --git a/mods/diff/hooks/git/parse/hunk-header/hunk-header.ts b/mods/diff/hooks/git/parse/hunk-header/hunk-header.ts new file mode 100644 index 0000000000000000000000000000000000000000..ecc79898189aa40a576ed09a5804e320328de7db --- /dev/null +++ b/mods/diff/hooks/git/parse/hunk-header/hunk-header.ts @@ -0,0 +1,5 @@ +/** + * A unified diff's hunk header, `@@ -old[,n] +new[,n] @@`, its two starts + * captured as `old` and `new`. + */ +export const HUNK_HEADER = /^@@ -(?\d+)(?:,\d+)? \+(?\d+)(?:,\d+)? @@/ diff --git a/mods/diff/hooks/git/parse/hunk-header/index.ts b/mods/diff/hooks/git/parse/hunk-header/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..25a979e270dc3607934393ba7c1437364c94fc62 --- /dev/null +++ b/mods/diff/hooks/git/parse/hunk-header/index.ts @@ -0,0 +1,3 @@ +export * from './hunk-header.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/parse/index.ts b/mods/diff/hooks/git/parse/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b3f08909292aee9e45619642490e4dc88d42074e --- /dev/null +++ b/mods/diff/hooks/git/parse/index.ts @@ -0,0 +1,11 @@ +export * from './hunk-header' +export * from './is-body-line' +export * from './is-whole-answer' +export * from './is-whole-listing' +export * from './parse-file-diff.js' +export * from './parse-numstat.js' +export * from './parse-shortstat.js' +export * from './rename-records' +export * from './shortstat-pattern' + +export * as default from '.' diff --git a/mods/diff/hooks/git/parse/is-body-line/index.ts b/mods/diff/hooks/git/parse/is-body-line/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..deb69448dc472acefd639af274184e200096407e --- /dev/null +++ b/mods/diff/hooks/git/parse/is-body-line/index.ts @@ -0,0 +1,3 @@ +export * from './is-body-line.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/parse/is-body-line/is-body-line.ts b/mods/diff/hooks/git/parse/is-body-line/is-body-line.ts new file mode 100644 index 0000000000000000000000000000000000000000..a0bc81fd275903361712fafc1f13f08b944820ad --- /dev/null +++ b/mods/diff/hooks/git/parse/is-body-line/is-body-line.ts @@ -0,0 +1,9 @@ +/** + * Whether a line after a hunk header is a body line (added, removed or + * context) rather than a note such as `\ No newline at end of file`. + * + * @param line one line of `git diff` output + * @returns whether it starts with `+`, `-` or a space + */ +export const isBodyLine = (line: string) => + line.startsWith('+') || line.startsWith('-') || line.startsWith(' ') diff --git a/mods/diff/hooks/git/parse/is-whole-answer/index.ts b/mods/diff/hooks/git/parse/is-whole-answer/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a80f3e1caef16daab0f4ef17d912274022a655d --- /dev/null +++ b/mods/diff/hooks/git/parse/is-whole-answer/index.ts @@ -0,0 +1,3 @@ +export * from './is-whole-answer.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/parse/is-whole-answer/is-whole-answer.ts b/mods/diff/hooks/git/parse/is-whole-answer/is-whole-answer.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c910cfa0807953b829e64d2cd29a9a68982f054 --- /dev/null +++ b/mods/diff/hooks/git/parse/is-whole-answer/is-whole-answer.ts @@ -0,0 +1,12 @@ +import type Types from '../../types' +import { isWholeListing } from '../is-whole-listing' + +/** + * Whether a `-z` git child both succeeded and answered a whole listing, + * so its stdout may be parsed (isWholeListing). + * + * @param result what the child left + * @returns whether to parse it + */ +export const isWholeAnswer = (result: Types.RunResult) => + result.exitCode === 0 && isWholeListing(result.stdout) diff --git a/mods/diff/hooks/git/parse/is-whole-listing/index.ts b/mods/diff/hooks/git/parse/is-whole-listing/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b4c9874a897a954f6e94dd3e8b22dd11e4f43abd --- /dev/null +++ b/mods/diff/hooks/git/parse/is-whole-listing/index.ts @@ -0,0 +1,5 @@ +export * from './is-under-utf8-ceiling' +export * from './is-whole-listing.js' +export * from './most-utf8-bytes' + +export * as default from '.' diff --git a/mods/diff/hooks/git/parse/is-whole-listing/is-under-utf8-ceiling/index.ts b/mods/diff/hooks/git/parse/is-whole-listing/is-under-utf8-ceiling/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..5c3a0a070d79056a7b544b524c927a1e7669c6da --- /dev/null +++ b/mods/diff/hooks/git/parse/is-whole-listing/is-under-utf8-ceiling/index.ts @@ -0,0 +1,3 @@ +export * from './is-under-utf8-ceiling.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/parse/is-whole-listing/is-under-utf8-ceiling/is-under-utf8-ceiling.ts b/mods/diff/hooks/git/parse/is-whole-listing/is-under-utf8-ceiling/is-under-utf8-ceiling.ts new file mode 100644 index 0000000000000000000000000000000000000000..c69d2712e1555ca114a2dd290bb8abf94a9ba236 --- /dev/null +++ b/mods/diff/hooks/git/parse/is-whole-listing/is-under-utf8-ceiling/is-under-utf8-ceiling.ts @@ -0,0 +1,13 @@ +import { MOST_UTF8_BYTES } from '../most-utf8-bytes' + +/** + * Whether the text's UTF-8 encoding stays under a byte count, encoding it + * only when its length alone cannot already say so. + * + * @param text the text + * @param ceiling the byte count it must stay under + * @returns whether it is under + */ +export const isUnderUtf8Ceiling = (text: string, ceiling: number) => + text.length * MOST_UTF8_BYTES < ceiling || + new TextEncoder().encode(text).length < ceiling diff --git a/mods/diff/hooks/git/parse/is-whole-listing/is-whole-listing.ts b/mods/diff/hooks/git/parse/is-whole-listing/is-whole-listing.ts new file mode 100644 index 0000000000000000000000000000000000000000..a97fe9d094679ead557c1bae460d97a7ee79529c --- /dev/null +++ b/mods/diff/hooks/git/parse/is-whole-listing/is-whole-listing.ts @@ -0,0 +1,18 @@ +import Limits from '../../../limits' +import { isUnderUtf8Ceiling } from './is-under-utf8-ceiling' +import { MOST_UTF8_BYTES } from './most-utf8-bytes' + +/** + * Whether a listing arrived whole, so no record was cut or lost. + * + * Every record is terminated (NUL for `-z` listings, a newline for line + * records) and the text is short of the host's output cap by MOST_UTF8_BYTES, + * what a cut inside a multi-byte character can cost the decoded text. + * + * @param stdout the listing as `$.process.run` returned it + * @param terminator what ends every record; NUL when absent + * @returns whether it can be parsed as the whole answer + */ +export const isWholeListing = (stdout: string, terminator = '\0') => + (stdout === '' || stdout.endsWith(terminator)) && + isUnderUtf8Ceiling(stdout, Limits.HOST_OUTPUT_CAP_BYTES - MOST_UTF8_BYTES) diff --git a/mods/diff/hooks/git/parse/is-whole-listing/most-utf8-bytes/index.ts b/mods/diff/hooks/git/parse/is-whole-listing/most-utf8-bytes/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6cc4be6405750a7a1d5c9bae30a9f17a42bb2902 --- /dev/null +++ b/mods/diff/hooks/git/parse/is-whole-listing/most-utf8-bytes/index.ts @@ -0,0 +1,3 @@ +export * from './most-utf8-bytes.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/parse/is-whole-listing/most-utf8-bytes/most-utf8-bytes.ts b/mods/diff/hooks/git/parse/is-whole-listing/most-utf8-bytes/most-utf8-bytes.ts new file mode 100644 index 0000000000000000000000000000000000000000..9928917873495c360620d1b25fb152e218de99b8 --- /dev/null +++ b/mods/diff/hooks/git/parse/is-whole-listing/most-utf8-bytes/most-utf8-bytes.ts @@ -0,0 +1,5 @@ +/** + * The most bytes one UTF-16 code unit takes in UTF-8, and so both the + * bound on a text's encoded length and the most a cut character drops. + */ +export const MOST_UTF8_BYTES = 3 diff --git a/mods/diff/hooks/git/parse/parse-file-diff.ts b/mods/diff/hooks/git/parse/parse-file-diff.ts new file mode 100644 index 0000000000000000000000000000000000000000..60c31d66b2e3302da7a3daa8317a75c44281e1ae --- /dev/null +++ b/mods/diff/hooks/git/parse/parse-file-diff.ts @@ -0,0 +1,41 @@ +import Limits from '../../limits' +import { cutHunks } from '../cut-hunks' +import { hunkOf } from '../hunk-of' +import { LARGE_FILE_HUNKS } from '../large-file-hunks' +import type Types from '../types' +import { HUNK_HEADER } from './hunk-header' +import { isBodyLine } from './is-body-line' + +/** + * One file's unified `git diff` output as hunks, the body cut at + * MAX_LINES_PER_FILE lines in all, as the built-in panel parses one file. + * + * Past MAX_DIFF_BYTES there are no hunks and `isLarge` is set; everything + * before the first hunk header is the file's header. + * + * @param stdout the command's standard output for that one path + * @returns the parsed body + */ +export function parseFileDiff(stdout: string): Types.FileHunks { + if (stdout.length > Limits.MAX_DIFF_BYTES) { + return LARGE_FILE_HUNKS + } + + const rows = stdout.split('\n') + + const headerRows = rows.flatMap((row, at) => + HUNK_HEADER.test(row) ? [at] : [], + ) + + return { + ...cutHunks( + headerRows.map((at, ordinal) => + hunkOf( + HUNK_HEADER.exec(rows[at] ?? '')?.groups, + rows.slice(at + 1, headerRows[ordinal + 1]).filter(isBodyLine), + ), + ), + ), + isLarge: false, + } +} diff --git a/mods/diff/hooks/git/parse/parse-numstat.ts b/mods/diff/hooks/git/parse/parse-numstat.ts new file mode 100644 index 0000000000000000000000000000000000000000..62244f585e4da6429f148949b6a520e640554f3f --- /dev/null +++ b/mods/diff/hooks/git/parse/parse-numstat.ts @@ -0,0 +1,66 @@ +import type Types from '../types' +import { RENAME_RECORDS } from './rename-records' + +/** + * `git diff --numstat -z` output as totals over every row and the first + * rows up to the cap; a binary file (`-` counts) is a 0/0 row marked binary. + * + * A record is `\t\t` ended by NUL, the path raw; a + * rename is `\t\t` then the old and the new path as the + * next two records, kept as one row under the new path. + * + * @param stdout the command's standard output + * @param maxFiles how many rows to keep + * @returns the totals and the kept rows + */ +export function parseNumstat( + stdout: string, + maxFiles: number, +): Types.NumstatResult { + const files: Types.FileStat[] = [] + const stats = { filesCount: 0, linesAdded: 0, linesRemoved: 0 } + const records = stdout.split('\0') + + let at = 0 + + while (at < records.length) { + const [addedField, removedField, ...pathFields] = (records[at] ?? '').split( + '\t', + ) + + const inline = pathFields.join('\t') + const isRename = pathFields.length === 1 && inline === '' + const renamedFrom = isRename ? (records[at + 1] ?? null) : null + const path = isRename ? (records[at + 2] ?? '') : inline + at += isRename ? RENAME_RECORDS : 1 + + const isRow = + addedField !== undefined && removedField !== undefined && path !== '' + + if (!isRow) { + continue + } + + const isBinary = addedField === '-' || removedField === '-' + const added = isBinary ? 0 : Number(addedField) || 0 + const removed = isBinary ? 0 : Number(removedField) || 0 + + stats.filesCount += 1 + stats.linesAdded += added + stats.linesRemoved += removed + + if (files.length < maxFiles) { + files.push({ + path, + renamedFrom, + added, + removed, + isBinary, + isUntracked: false, + isPreSession: false, + }) + } + } + + return { stats, files } +} diff --git a/mods/diff/hooks/git/parse/parse-shortstat.ts b/mods/diff/hooks/git/parse/parse-shortstat.ts new file mode 100644 index 0000000000000000000000000000000000000000..ece5cae837d3d330017d9cb0afb7a9e68da063be --- /dev/null +++ b/mods/diff/hooks/git/parse/parse-shortstat.ts @@ -0,0 +1,23 @@ +import type Types from '../types' +import { SHORTSTAT_PATTERN } from './shortstat-pattern' + +/** + * `git diff --shortstat` output as totals, or null when the line is not + * git's summary line (empty output for an empty diff included). + * + * @param stdout the command's standard output + * @returns the totals, or null + */ +export function parseShortstat(stdout: string): Types.DiffStats | null { + const counts = SHORTSTAT_PATTERN.exec(stdout)?.groups + + if (!counts) { + return null + } + + return { + filesCount: Number(counts.files), + linesAdded: Number(counts.added ?? 0), + linesRemoved: Number(counts.removed ?? 0), + } +} diff --git a/mods/diff/hooks/git/parse/rename-records/index.ts b/mods/diff/hooks/git/parse/rename-records/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f29fdeba1345fa133c9e59d87da9409f3276e93a --- /dev/null +++ b/mods/diff/hooks/git/parse/rename-records/index.ts @@ -0,0 +1,3 @@ +export * from './rename-records.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/parse/rename-records/rename-records.ts b/mods/diff/hooks/git/parse/rename-records/rename-records.ts new file mode 100644 index 0000000000000000000000000000000000000000..0322deab2e913a084aab814819b699d5b5613702 --- /dev/null +++ b/mods/diff/hooks/git/parse/rename-records/rename-records.ts @@ -0,0 +1,5 @@ +/** + * How many NUL records a rename takes in `--numstat -z` output: the counts + * with an empty path, then the old path, then the new. + */ +export const RENAME_RECORDS = 3 diff --git a/mods/diff/hooks/git/parse/shortstat-pattern/index.ts b/mods/diff/hooks/git/parse/shortstat-pattern/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce789a7a00066232a19b982dff658a22406a0ea6 --- /dev/null +++ b/mods/diff/hooks/git/parse/shortstat-pattern/index.ts @@ -0,0 +1,3 @@ +export * from './shortstat-pattern.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/parse/shortstat-pattern/shortstat-pattern.ts b/mods/diff/hooks/git/parse/shortstat-pattern/shortstat-pattern.ts new file mode 100644 index 0000000000000000000000000000000000000000..ccd3fe3ea41f4caa906c9c82bf64de3fc563fdaf --- /dev/null +++ b/mods/diff/hooks/git/parse/shortstat-pattern/shortstat-pattern.ts @@ -0,0 +1,11 @@ +/** + * `--shortstat`'s one line, ` 3 files changed, 10 insertions(+), 2 + * deletions(-)`, either tail optional; the counts captured by name. + */ +export const SHORTSTAT_PATTERN = new RegExp( + [ + String.raw`(?\d+) files? changed`, + String.raw`(?:, (?\d+) insertions?\(\+\))?`, + String.raw`(?:, (?\d+) deletions?\(-\))?`, + ].join(''), +) diff --git a/mods/diff/hooks/git/probes/index.ts b/mods/diff/hooks/git/probes/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..5df9909ec6dea93acce52a8323c7b4f5e08f1bd3 --- /dev/null +++ b/mods/diff/hooks/git/probes/index.ts @@ -0,0 +1,30 @@ +export * from './branch-base-of' +export * from './conventional-default-branches' +export * from './current-branch-of' +export * from './datings-of' +export * from './default-branch-of' +export * from './dirty-paths-of' +export * from './head-key-of' +export * from './is-absolute-path' +export * from './is-safe-ref-name' +export * from './is-sha' +export * from './is-transient.js' +export * from './is-unborn-head.js' +export * from './kind-walk-of' +export * from './merge-base-candidates-of' +export * from './missing-base-of' +export * from './overlay-unstaged.js' +export * from './repository-lines' +export * from './repository-of' +export * from './stamp-if-file' +export * from './stamp-probe-of' +export * from './stats-of.js' +export * from './tag-pre-session.js' +export * from './transient-state-files' +export * from './unstaged-numstat.js' +export * from './untracked-files' +export * from './untracked-place' +export * from './untracked-rows-of' +export * from './was-dirty-at-start' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/is-transient.ts b/mods/diff/hooks/git/probes/is-transient.ts new file mode 100644 index 0000000000000000000000000000000000000000..4fc7b4372e893d7a7524162ea9cdf9effdcfb443 --- /dev/null +++ b/mods/diff/hooks/git/probes/is-transient.ts @@ -0,0 +1,22 @@ +import type Types from '../types' +import { TRANSIENT_STATE_FILES } from './transient-state-files' + +/** + * Whether the repository is mid-merge, mid-rebase, mid-cherry-pick or + * mid-revert, from one listing of the git directory. + * + * A state file counts only as a real file; a symbolic link by that name is + * never touched. + * + * @param entryKindsOf the host's directory listing + * @param gitDir the repository's own git directory, absolute + * @returns whether any of TRANSIENT_STATE_FILES is present as a file + */ +export async function isTransient( + entryKindsOf: Types.GitDeps['entryKindsOf'], + gitDir: string, +): Promise { + const entries = await entryKindsOf(gitDir) + + return TRANSIENT_STATE_FILES.some(file => entries?.get(file) === 'file') +} diff --git a/mods/diff/hooks/git/probes/is-unborn-head.ts b/mods/diff/hooks/git/probes/is-unborn-head.ts new file mode 100644 index 0000000000000000000000000000000000000000..098ce5fc0684323a97f119117a07b78ac36f6516 --- /dev/null +++ b/mods/diff/hooks/git/probes/is-unborn-head.ts @@ -0,0 +1,24 @@ +import Argv from '../argv' +import type Types from '../types' + +/** + * Whether HEAD is unborn (a repository before its first commit): only a + * `rev-parse --verify HEAD` that ran and exited exactly 1 says so. + * + * A fatal repository error (128) or a killed probe proves nothing, as for + * the built-in panel's no-commits probe. + * + * @param run runs git + * @returns whether the probe exited 1 + */ +export async function isUnbornHead(run: Types.GitRun): Promise { + const { exitCode } = await run([ + Argv.NO_OPTIONAL_LOCKS, + 'rev-parse', + '--verify', + '--quiet', + 'HEAD', + ]) + + return exitCode === 1 +} diff --git a/mods/diff/hooks/git/probes/overlay-unstaged.ts b/mods/diff/hooks/git/probes/overlay-unstaged.ts new file mode 100644 index 0000000000000000000000000000000000000000..3417d2b2a2fb0f07c5b337808a28c01b3ae9cb84 --- /dev/null +++ b/mods/diff/hooks/git/probes/overlay-unstaged.ts @@ -0,0 +1,45 @@ +import type Types from '../types' + +/** + * The staged rows of an unborn HEAD with the working tree's unstaged edits + * folded in, as the built-in panel overlays them. + * + * Against the empty tree every surviving line is an addition: a path in + * both diffs shows added = staged + unstaged added − unstaged removed + * (never below 0), removed stays 0, and the added total moves with it. + * + * @param staged `git diff --cached --numstat`, capped + * @param unstaged plain `git diff --numstat`, uncapped + * @returns the staged result with corrected rows and totals + */ +export function overlayUnstaged( + staged: Types.NumstatResult, + unstaged: Types.NumstatResult, +): Types.NumstatResult { + const edits = new Map(unstaged.files.map(file => [file.path, file])) + + const files = staged.files.map(file => { + const edit = edits.get(file.path) + + if (!edit) { + return file + } + + const isBinary = file.isBinary || edit.isBinary + + const added = isBinary + ? 0 + : Math.max(0, file.added + edit.added - edit.removed) + + return { ...file, added, removed: 0, isBinary } + }) + + const linesAdded = + staged.stats.linesAdded + + files.reduce((sum, file) => sum + file.added, 0) - + staged.files.reduce((sum, file) => sum + file.added, 0) + + const stats = { ...staged.stats, linesAdded } + + return { stats, files } +} diff --git a/mods/diff/hooks/git/probes/stats-of.ts b/mods/diff/hooks/git/probes/stats-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..0662d8c660298c392d81d105c32bc1995b4a680b --- /dev/null +++ b/mods/diff/hooks/git/probes/stats-of.ts @@ -0,0 +1,45 @@ +import Limits from '../../limits' +import Argv from '../argv' +import Parse from '../parse' +import type Types from '../types' + +/** + * The rows and totals of `git diff `, or null when git could not + * say (as the built-in panel's stats fetch) or its listing arrived cut. + * + * A `--shortstat` probe runs first, so a diff past MAX_FILES_FOR_DETAILS + * answers its totals with no rows and no `--numstat` buffering. + * + * @param context runs git + * @param base the ref (or `--cached`) the working tree is compared with + * @returns the parsed result, or null on a failed numstat + */ +export async function statsOf( + context: Types.FetchContext, + base: string, +): Promise { + const run = context.run + + const shortstat = await run([...Argv.DIFF_LEADING_ARGS, base, '--shortstat']) + + const totals = + shortstat.exitCode === 0 ? Parse.parseShortstat(shortstat.stdout) : null + + const isPastDetails = + totals !== null && totals.filesCount > Limits.MAX_FILES_FOR_DETAILS + + if (isPastDetails) { + return { stats: totals, files: [] } + } + + const numstat = await run([ + ...Argv.DIFF_LEADING_ARGS, + base, + '--numstat', + '-z', + ]) + + const isRead = Parse.isWholeAnswer(numstat) + + return isRead ? Parse.parseNumstat(numstat.stdout, Limits.MAX_FILES) : null +} diff --git a/mods/diff/hooks/git/probes/tag-pre-session.ts b/mods/diff/hooks/git/probes/tag-pre-session.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e80a34ede678f46a175de24536abfe53464403d --- /dev/null +++ b/mods/diff/hooks/git/probes/tag-pre-session.ts @@ -0,0 +1,32 @@ +import type Types from '../types' +import { datingsOf } from './datings-of' +import { wasDirtyAtStart } from './was-dirty-at-start' + +/** + * The tracked rows, `isPreSession` set on each whose file was modified + * before the session began and was already dirty then (the built-in's tag). + * + * A file that cannot be dated (deleted this session, a symbolic link, past + * the listing budget), or that turned up since the start (wasDirtyAtStart), + * stays session work, so nothing real is hidden. + * + * @param context the fetch: its stamp probe, the session's start, its baseline + * @param files the tracked rows + * @returns the rows, tagged + */ +export async function tagPreSession( + context: Types.DatingContext, + files: readonly Types.FileStat[], +): Promise { + const datings = await datingsOf( + context, + files.map(file => file.path), + ) + + return files.map(file => ({ + ...file, + isPreSession: + datings.get(file.path) === 'pre-session' && + wasDirtyAtStart(context, file.path), + })) +} diff --git a/mods/diff/hooks/git/probes/unstaged-numstat.ts b/mods/diff/hooks/git/probes/unstaged-numstat.ts new file mode 100644 index 0000000000000000000000000000000000000000..93144701ee6d3ed621a5971f81a96d8e2e7c405f --- /dev/null +++ b/mods/diff/hooks/git/probes/unstaged-numstat.ts @@ -0,0 +1,23 @@ +import Argv from '../argv' +import Parse from '../parse' +import type Types from '../types' + +/** + * Plain `git diff --numstat` (index against working tree), the one diff + * beside `--cached` that runs on an unborn HEAD; null on failure or a cut. + * + * Parsed uncapped, since its rows correct rows already kept. + * + * @param run runs git + * @returns every row, or null when git failed + */ +export async function unstagedNumstat( + run: Types.GitRun, +): Promise { + const numstat = await run([...Argv.DIFF_LEADING_ARGS, '--numstat', '-z']) + const isRead = Parse.isWholeAnswer(numstat) + + return isRead + ? Parse.parseNumstat(numstat.stdout, Number.POSITIVE_INFINITY) + : null +} diff --git a/mods/diff/hooks/git/rename-display-of/index.ts b/mods/diff/hooks/git/rename-display-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..fba9eb84682833155f31c8f86bf4045ceb9cdd15 --- /dev/null +++ b/mods/diff/hooks/git/rename-display-of/index.ts @@ -0,0 +1,3 @@ +export * from './rename-display-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/rename-display-of/rename-display-of.ts b/mods/diff/hooks/git/rename-display-of/rename-display-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..c1896eb85f3c45e21d9c0992fb623a402881f8da --- /dev/null +++ b/mods/diff/hooks/git/rename-display-of/rename-display-of.ts @@ -0,0 +1,54 @@ +/** + * A rename as `git diff --stat` prints it: the shared leading and trailing + * directories once, the differing middles in braces, `old => new` between. + * + * `docs/{notes => renamed}`, `{src => lib}/main`, or with nothing shared + * `old => new`; a prefix or suffix only counts up to a slash, as git counts + * it (pprint_rename). + * + * @param oldPath the path before the rename + * @param newPath the path after it + * @returns the one-line rename + */ +export function renameDisplayOf(oldPath: string, newPath: string): string { + let prefix = 0 + let suffix = 0 + + for (let at = 0; at < oldPath.length && at < newPath.length; at++) { + if (oldPath[at] !== newPath[at]) { + break + } + + if (oldPath[at] === '/') { + prefix = at + 1 + } + } + + const floor = prefix - (prefix > 0 ? 1 : 0) + + for ( + let back = 1; + oldPath.length - back >= floor && newPath.length - back >= floor; + back++ + ) { + const character = oldPath[oldPath.length - back] + + if (character !== newPath[newPath.length - back]) { + break + } + + if (character === '/') { + suffix = back + } + } + + const middleOf = (path: string) => + path.slice(prefix, Math.max(prefix, path.length - suffix)) + + const middle = `${middleOf(oldPath)} => ${middleOf(newPath)}` + const isBraced = prefix + suffix > 0 + const head = oldPath.slice(0, prefix) + const tail = oldPath.slice(oldPath.length - suffix) + + return isBraced ? `${head}{${middle}}${tail}` : middle +} diff --git a/mods/diff/hooks/git/tiers/branch-source-of/branch-source-of.ts b/mods/diff/hooks/git/tiers/branch-source-of/branch-source-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..a02de14aaf2ccc91ce42f85cf8b3cfcad900a365 --- /dev/null +++ b/mods/diff/hooks/git/tiers/branch-source-of/branch-source-of.ts @@ -0,0 +1,25 @@ +import type Types from '../../types' + +/** + * What branch mode's diff compares, given its resolved base: the merge + * base, HEAD under the default branch's label, or the plain working tree. + * + * @param base the resolved base + * @returns the source the rows are labelled with + */ +export function branchSourceOf( + base: Types.ResolvedBranchBase, +): Types.DiffSource { + switch (base.kind) { + case 'merge-base': + return { + kind: 'branch', + baseBranch: base.baseBranch, + baseRef: base.mergeBase, + } + case 'head-is-base': + return { kind: 'branch', baseBranch: base.baseBranch, baseRef: 'HEAD' } + case 'none': + return { kind: 'working-tree', base: 'HEAD' } + } +} diff --git a/mods/diff/hooks/git/tiers/branch-source-of/index.ts b/mods/diff/hooks/git/tiers/branch-source-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e05a93f5f1d1c34a77ba41af65522ba64b3c368 --- /dev/null +++ b/mods/diff/hooks/git/tiers/branch-source-of/index.ts @@ -0,0 +1,3 @@ +export * from './branch-source-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/tiers/branch-tier.ts b/mods/diff/hooks/git/tiers/branch-tier.ts new file mode 100644 index 0000000000000000000000000000000000000000..fed4adab1857330fd00aa3fb91c67543366c1ade --- /dev/null +++ b/mods/diff/hooks/git/tiers/branch-tier.ts @@ -0,0 +1,56 @@ +import Probes from '../probes' +import type Types from '../types' +import { branchSourceOf } from './branch-source-of' +import { diffDataOf } from './diff-data-of' +import { unbornTier } from './unborn-tier' +import { withUntracked } from './with-untracked' + +/** + * Branch mode: everything that differs from the default branch, the merge + * base against the working tree, pinned, as the built-in panel diffs it. + * + * With HEAD at the base the diff is HEAD's under the vs- label; + * with no base it is labelled a plain working-tree diff; a base that may + * resolve next time answers unavailable so the last branch view stays. + * + * @param context the fetch + * @returns the outcome + */ +export async function branchTier( + context: Types.FetchContext, +): Promise { + const { deps, run } = context + const base = await Probes.branchBaseOf(run) + deps.onBranchBase(base) + + if (base.kind === 'error') { + return unbornTier(context) + } + + const source = branchSourceOf(base) + const baseRef = source.kind === 'branch' ? source.baseRef : 'HEAD' + const diff = await Probes.statsOf(context, baseRef) + const isTransientFailure = diff === null && base.kind === 'merge-base' + + if (isTransientFailure) { + return { kind: 'unavailable' } + } + + if (!diff) { + return unbornTier(context) + } + + return { + kind: 'data', + data: diffDataOf( + context, + await withUntracked(context, diff, 'session-only'), + { + source, + baseRef, + isUnborn: false, + stalePaths: [], + }, + ), + } +} diff --git a/mods/diff/hooks/git/tiers/diff-data-of/diff-basis/diff-basis.ts b/mods/diff/hooks/git/tiers/diff-data-of/diff-basis/diff-basis.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3284f3a5c7910f2ef2d63359e2ed5c397cc99c2 --- /dev/null +++ b/mods/diff/hooks/git/tiers/diff-data-of/diff-basis/diff-basis.ts @@ -0,0 +1,12 @@ +import type Types from '../../../types' + +/** + * What a tier decided about its diff besides the rows: what it compares, + * the ref the hunks read against, whether HEAD is unborn, stale bodies. + */ +export type DiffBasis = { + source: Types.DiffSource + baseRef: string + isUnborn: boolean + stalePaths: readonly string[] +} diff --git a/mods/diff/hooks/git/tiers/diff-data-of/diff-basis/index.ts b/mods/diff/hooks/git/tiers/diff-data-of/diff-basis/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..5311782e64a4450b56bddff0ef5499d46568bcfd --- /dev/null +++ b/mods/diff/hooks/git/tiers/diff-data-of/diff-basis/index.ts @@ -0,0 +1,3 @@ +export type * from './diff-basis.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/tiers/diff-data-of/diff-data-of.ts b/mods/diff/hooks/git/tiers/diff-data-of/diff-data-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..57f42d24c1178e3cd0a15dd23b4e925884fd7e19 --- /dev/null +++ b/mods/diff/hooks/git/tiers/diff-data-of/diff-data-of.ts @@ -0,0 +1,27 @@ +import type Types from '../../types' +import type { DiffBasis } from './diff-basis' + +/** + * A settled fetch: the tier's merged rows and totals over the repository + * and mode of the fetch, with the tier's basis. + * + * @param context the fetch + * @param result the rows and totals, untracked files merged or withheld + * @param basis what they compare and read against + * @returns the data an outcome carries + */ +export const diffDataOf = ( + context: Types.FetchContext, + result: Types.MergedResult, + basis: DiffBasis, +): Types.DiffData => ({ + repository: context.repository, + mode: context.mode, + stats: result.stats, + files: result.files, + source: basis.source, + baseRef: basis.baseRef, + isUnborn: basis.isUnborn, + stalePaths: basis.stalePaths, + isUntrackedWithheld: result.isUntrackedWithheld, +}) diff --git a/mods/diff/hooks/git/tiers/diff-data-of/index.ts b/mods/diff/hooks/git/tiers/diff-data-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..98736de9014a79a72afa138e095818e2feadd5e5 --- /dev/null +++ b/mods/diff/hooks/git/tiers/diff-data-of/index.ts @@ -0,0 +1,4 @@ +export * from './diff-basis' +export * from './diff-data-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/tiers/fetch-context-of/fetch-context-of.ts b/mods/diff/hooks/git/tiers/fetch-context-of/fetch-context-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..74a85744a6902cb62fb3d0c8eb8e9e49fd79468b --- /dev/null +++ b/mods/diff/hooks/git/tiers/fetch-context-of/fetch-context-of.ts @@ -0,0 +1,26 @@ +import Limits from '../../../limits' +import Probes from '../../probes' +import type Types from '../../types' + +/** + * The context every tier of a fetch shares: the host's pinned git, the + * repository, the mode, and one stamp probe over the working tree's top. + * + * @param deps the host + * @param mode the comparison asked for + * @returns the context + */ +export const fetchContextOf = ( + deps: Types.GitDeps, + mode: Types.BaseMode, +): Types.FetchContext => ({ + deps, + run: deps.run, + repository: deps.repository, + mode, + stamps: Probes.stampProbeOf( + deps, + deps.repository.toplevel, + Limits.MAX_LISTED_DIRECTORIES, + ), +}) diff --git a/mods/diff/hooks/git/tiers/fetch-context-of/index.ts b/mods/diff/hooks/git/tiers/fetch-context-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..868c2c298021359eb604756fdf6ed4c2cb1f2ddb --- /dev/null +++ b/mods/diff/hooks/git/tiers/fetch-context-of/index.ts @@ -0,0 +1,3 @@ +export * from './fetch-context-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/tiers/index.ts b/mods/diff/hooks/git/tiers/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..34d059b485c265bb6134bb4300855425c907993b --- /dev/null +++ b/mods/diff/hooks/git/tiers/index.ts @@ -0,0 +1,11 @@ +export * from './branch-source-of' +export * from './branch-tier.js' +export * from './diff-data-of' +export * from './fetch-context-of' +export * from './is-past-details' +export * from './unborn-tier' +export * from './with-untracked' +export * from './working-tree-basis' +export * from './working-tree-tier.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/tiers/is-past-details/index.ts b/mods/diff/hooks/git/tiers/is-past-details/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..61d9811a8b7350f7a61e68a214c1ea48938307dd --- /dev/null +++ b/mods/diff/hooks/git/tiers/is-past-details/index.ts @@ -0,0 +1,3 @@ +export * from './is-past-details.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/tiers/is-past-details/is-past-details.ts b/mods/diff/hooks/git/tiers/is-past-details/is-past-details.ts new file mode 100644 index 0000000000000000000000000000000000000000..7181ab4929e80bc7a9dacd2ebca8a1d70e9cb285 --- /dev/null +++ b/mods/diff/hooks/git/tiers/is-past-details/is-past-details.ts @@ -0,0 +1,12 @@ +import Limits from '../../../limits' +import type Types from '../../types' + +/** + * Whether a diff has more files than get per-file rows: past + * MAX_FILES_FOR_DETAILS only its totals are kept. + * + * @param result the parsed diff + * @returns whether rows, hunks and the untracked probe are skipped + */ +export const isPastDetails = (result: Types.NumstatResult) => + result.stats.filesCount > Limits.MAX_FILES_FOR_DETAILS diff --git a/mods/diff/hooks/git/tiers/unborn-tier/empty-numstat/empty-numstat.ts b/mods/diff/hooks/git/tiers/unborn-tier/empty-numstat/empty-numstat.ts new file mode 100644 index 0000000000000000000000000000000000000000..c31260e11c21efde3d986c74656463241b46797f --- /dev/null +++ b/mods/diff/hooks/git/tiers/unborn-tier/empty-numstat/empty-numstat.ts @@ -0,0 +1,10 @@ +import type Types from '../../../types' + +/** + * No rows and zero totals: what an unborn HEAD shows when even + * `git diff --cached` could not be read. + */ +export const EMPTY_NUMSTAT: Types.NumstatResult = Object.freeze({ + stats: Object.freeze({ filesCount: 0, linesAdded: 0, linesRemoved: 0 }), + files: Object.freeze([]), +}) diff --git a/mods/diff/hooks/git/tiers/unborn-tier/empty-numstat/index.ts b/mods/diff/hooks/git/tiers/unborn-tier/empty-numstat/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b14b1ccafc562b3469fbfbf14e6fd37b79c0d3c6 --- /dev/null +++ b/mods/diff/hooks/git/tiers/unborn-tier/empty-numstat/index.ts @@ -0,0 +1,3 @@ +export * from './empty-numstat.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/tiers/unborn-tier/index.ts b/mods/diff/hooks/git/tiers/unborn-tier/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6b3a769a03433cd5a1dbc8902ce7d31e3d955872 --- /dev/null +++ b/mods/diff/hooks/git/tiers/unborn-tier/index.ts @@ -0,0 +1,4 @@ +export * from './empty-numstat' +export * from './unborn-tier.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/tiers/unborn-tier/unborn-tier.ts b/mods/diff/hooks/git/tiers/unborn-tier/unborn-tier.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c42d3297d7aca543bd337d6a92a46b56018d5c4 --- /dev/null +++ b/mods/diff/hooks/git/tiers/unborn-tier/unborn-tier.ts @@ -0,0 +1,58 @@ +import Probes from '../../probes' +import type Types from '../../types' +import { diffDataOf } from '../diff-data-of' +import { withUntracked } from '../with-untracked' +import { EMPTY_NUMSTAT } from './empty-numstat' + +/** + * The one permanent cause of a failed vs-HEAD diff, told apart from the + * passing ones: an unborn HEAD, as the built-in panel tells it apart. + * + * Its rows are the staged files with unstaged edits folded in, then the + * session's untracked files; hunks read against `--cached`, a file edited + * after staging has no honest body; a withheld `--numstat`: no data. + * + * @param context the fetch + * @returns data marked unborn, or unavailable + */ +export async function unbornTier( + context: Types.FetchContext, +): Promise { + const { run } = context + + if (!(await Probes.isUnbornHead(run))) { + return { kind: 'unavailable' } + } + + const staged = await Probes.statsOf(context, '--cached') + + if (!staged) { + return { kind: 'unavailable' } + } + + const isOverlaid = staged.files.length > 0 + + const unstaged = isOverlaid + ? await Probes.unstagedNumstat(run) + : EMPTY_NUMSTAT + + if (!unstaged) { + return { kind: 'unavailable' } + } + + const merged = await withUntracked( + context, + Probes.overlayUnstaged(staged, unstaged), + 'session-only', + ) + + return { + kind: 'data', + data: diffDataOf(context, merged, { + source: { kind: 'working-tree', base: 'HEAD' }, + baseRef: '--cached', + isUnborn: true, + stalePaths: unstaged.files.map(file => file.path), + }), + } +} diff --git a/mods/diff/hooks/git/tiers/with-untracked/index.ts b/mods/diff/hooks/git/tiers/with-untracked/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f5791caa89801b88fd2373b8022901cba1bf33f --- /dev/null +++ b/mods/diff/hooks/git/tiers/with-untracked/index.ts @@ -0,0 +1,5 @@ +export * from './merged-result-of' +export * from './merged-with-untracked' +export * from './with-untracked.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/tiers/with-untracked/merged-result-of/index.ts b/mods/diff/hooks/git/tiers/with-untracked/merged-result-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e719f73b2a7331dbe242f3c4c9d23c1221d181a9 --- /dev/null +++ b/mods/diff/hooks/git/tiers/with-untracked/merged-result-of/index.ts @@ -0,0 +1,3 @@ +export * from './merged-result-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/tiers/with-untracked/merged-result-of/merged-result-of.ts b/mods/diff/hooks/git/tiers/with-untracked/merged-result-of/merged-result-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..7224db9fd00a224fb3a980792c58fb57acd98018 --- /dev/null +++ b/mods/diff/hooks/git/tiers/with-untracked/merged-result-of/merged-result-of.ts @@ -0,0 +1,13 @@ +import type Types from '../../../types' + +/** + * A tier's rows stamped with whether the untracked listing was withheld. + * + * @param result the rows and totals, untracked files merged in or not + * @param mark whether git did not give the listing whole + * @returns the merged result + */ +export const mergedResultOf = ( + result: Types.NumstatResult, + mark: Pick, +): Types.MergedResult => ({ ...result, ...mark }) diff --git a/mods/diff/hooks/git/tiers/with-untracked/merged-with-untracked/index.ts b/mods/diff/hooks/git/tiers/with-untracked/merged-with-untracked/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..429b1cf67b35caf8e0d90b937056e10a6936708f --- /dev/null +++ b/mods/diff/hooks/git/tiers/with-untracked/merged-with-untracked/index.ts @@ -0,0 +1,3 @@ +export * from './merged-with-untracked.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/tiers/with-untracked/merged-with-untracked/merged-with-untracked.ts b/mods/diff/hooks/git/tiers/with-untracked/merged-with-untracked/merged-with-untracked.ts new file mode 100644 index 0000000000000000000000000000000000000000..b5ca147e4dd2bcd25c0f8811bcad6f21008f00c0 --- /dev/null +++ b/mods/diff/hooks/git/tiers/with-untracked/merged-with-untracked/merged-with-untracked.ts @@ -0,0 +1,32 @@ +import type Types from '../../../types' +import { mergedResultOf } from '../merged-result-of' + +/** + * A tier's tracked rows and totals with a listed set of untracked rows + * after them, or flagged withheld when the lister gave no whole listing. + * + * As the built-in's mergeSessionUntrackedFiles merges them, a path a + * tracked row already holds keeps that row and counts once. + * + * @param result the tier's tracked rows and totals + * @param untracked the untracked rows, or null when their listing was withheld + * @returns the merged rows and totals, and whether the listing was withheld + */ +export function mergedWithUntracked( + result: Types.NumstatResult, + untracked: readonly Types.FileStat[] | null, +): Types.MergedResult { + if (!untracked) { + return mergedResultOf(result, { isUntrackedWithheld: true }) + } + + const tracked = new Set(result.files.map(file => file.path)) + const fresh = untracked.filter(file => !tracked.has(file.path)) + const filesCount = result.stats.filesCount + fresh.length + const stats = { ...result.stats, filesCount } + + return mergedResultOf( + { stats, files: [...result.files, ...fresh] }, + { isUntrackedWithheld: false }, + ) +} diff --git a/mods/diff/hooks/git/tiers/with-untracked/with-untracked.ts b/mods/diff/hooks/git/tiers/with-untracked/with-untracked.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c004215d51d334e5ab9afbd0e3d7545272a605d --- /dev/null +++ b/mods/diff/hooks/git/tiers/with-untracked/with-untracked.ts @@ -0,0 +1,34 @@ +import Limits from '../../../limits' +import Probes from '../../probes' +import type Types from '../../types' +import { isPastDetails } from '../is-past-details' +import { mergedResultOf } from './merged-result-of' +import { mergedWithUntracked } from './merged-with-untracked' + +/** + * A tier's rows with the untracked files merged after them + * (mergedWithUntracked). + * + * Past MAX_FILES_FOR_DETAILS nothing is listed; a listing git withheld + * leaves the tracked rows as they are and says so, as the built-in degrades. + * + * @param context the fetch + * @param result the tier's tracked rows and totals + * @param scope which untracked files count + * @returns the merged rows and totals, and whether the listing was withheld + */ +export async function withUntracked( + context: Types.FetchContext, + result: Types.NumstatResult, + scope: Types.UntrackedScope, +): Promise { + return isPastDetails(result) + ? mergedResultOf(result, { isUntrackedWithheld: false }) + : mergedWithUntracked( + result, + await Probes.untrackedFiles(context, { + slots: Limits.MAX_FILES - result.files.length, + scope, + }), + ) +} diff --git a/mods/diff/hooks/git/tiers/working-tree-basis/index.ts b/mods/diff/hooks/git/tiers/working-tree-basis/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..dffa72a5651aad62a7292eac304e228f22f5d89f --- /dev/null +++ b/mods/diff/hooks/git/tiers/working-tree-basis/index.ts @@ -0,0 +1,3 @@ +export * from './working-tree-basis.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/tiers/working-tree-basis/working-tree-basis.ts b/mods/diff/hooks/git/tiers/working-tree-basis/working-tree-basis.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9b747854a577a5f1ad5b7657f07c85e41d9f474 --- /dev/null +++ b/mods/diff/hooks/git/tiers/working-tree-basis/working-tree-basis.ts @@ -0,0 +1,12 @@ +import type DiffDataOf from '../diff-data-of' + +/** + * The basis of a plain working-tree diff: against HEAD, commits present, + * every body current. + */ +export const WORKING_TREE_BASIS: DiffDataOf.DiffBasis = Object.freeze({ + source: Object.freeze({ kind: 'working-tree', base: 'HEAD' }), + baseRef: 'HEAD', + isUnborn: false, + stalePaths: Object.freeze([]), +}) diff --git a/mods/diff/hooks/git/tiers/working-tree-tier.ts b/mods/diff/hooks/git/tiers/working-tree-tier.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf9441a8d2c842bfd0cfc4f46391ab496d6a7f76 --- /dev/null +++ b/mods/diff/hooks/git/tiers/working-tree-tier.ts @@ -0,0 +1,45 @@ +import Probes from '../probes' +import type Types from '../types' +import { diffDataOf } from './diff-data-of' +import { unbornTier } from './unborn-tier' +import { withUntracked } from './with-untracked' +import { WORKING_TREE_BASIS } from './working-tree-basis' + +/** + * Session and uncommitted mode: the working tree against HEAD, the built-in + * panel's working-tree tier. + * + * Session mode tags the rows that predate the session and keeps + * pre-session untracked files as tagged rows; uncommitted keeps the + * session's untracked files and answers even when empty. + * + * @param context the fetch + * @returns the outcome + */ +export async function workingTreeTier( + context: Types.FetchContext, +): Promise { + const workingTree = await Probes.statsOf(context, 'HEAD') + + if (!workingTree) { + return unbornTier(context) + } + + const isSession = context.mode === 'session' + + const files = isSession + ? await Probes.tagPreSession(context, workingTree.files) + : workingTree.files + + const data = diffDataOf( + context, + await withUntracked( + context, + { stats: workingTree.stats, files }, + isSession ? 'with-pre-session' : 'session-only', + ), + WORKING_TREE_BASIS, + ) + + return { kind: 'data', data } +} diff --git a/mods/diff/hooks/git/types/branch-base/branch-base.ts b/mods/diff/hooks/git/types/branch-base/branch-base.ts new file mode 100644 index 0000000000000000000000000000000000000000..21ce582a729050f5a74de02442799f675d5262f0 --- /dev/null +++ b/mods/diff/hooks/git/types/branch-base/branch-base.ts @@ -0,0 +1,9 @@ +import type { ResolvedBranchBase } from '../resolved-branch-base' + +/** + * What branch mode diffs against, as the built-in panel names it: a + * resolved base, or a failure that may pass on the next fetch. + */ +export type BranchBase = + | ResolvedBranchBase + | { kind: 'error'; reason: 'merge_base_failed' | 'head_rev_parse_failed' } diff --git a/mods/diff/hooks/git/types/branch-base/index.ts b/mods/diff/hooks/git/types/branch-base/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b33834346a29e12a141df2fa846593711676e18 --- /dev/null +++ b/mods/diff/hooks/git/types/branch-base/index.ts @@ -0,0 +1,3 @@ +export type * from './branch-base.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/types/dating-context/dating-context.ts b/mods/diff/hooks/git/types/dating-context/dating-context.ts new file mode 100644 index 0000000000000000000000000000000000000000..76746bf9bc39a1f6e466b9ef218a1e9a4cd57181 --- /dev/null +++ b/mods/diff/hooks/git/types/dating-context/dating-context.ts @@ -0,0 +1,11 @@ +import type { GitDeps } from '../git-deps' +import type { StampProbe } from '../stamp-probe' + +/** + * What dating a path against the session takes: the session's start, one + * stamp probe over the tree's top, and when read, the paths dirty then. + */ +export type DatingContext = { + deps: Pick + stamps: StampProbe +} diff --git a/mods/diff/hooks/git/types/dating-context/index.ts b/mods/diff/hooks/git/types/dating-context/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2bb07592681f6a034ec740572e4ae1da19911130 --- /dev/null +++ b/mods/diff/hooks/git/types/dating-context/index.ts @@ -0,0 +1,3 @@ +export type * from './dating-context.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/types/diff-data/diff-data.ts b/mods/diff/hooks/git/types/diff-data/diff-data.ts new file mode 100644 index 0000000000000000000000000000000000000000..44743c95c82339612edef9de763a296bf0bc2dbe --- /dev/null +++ b/mods/diff/hooks/git/types/diff-data/diff-data.ts @@ -0,0 +1,25 @@ +import type { BaseMode } from '../base-mode' +import type { DiffSource } from '../diff-source' +import type { DiffStats } from '../diff-stats' +import type { FileStat } from '../file-stat' +import type { Repository } from '../repository' + +/** + * One settled fetch: the rows, the totals, what they compare, and what a + * file's hunks are read against so both agree, over the working tree's top. + * + * `isUnborn`: a repository before its first commit (rows are the staged + * and new files, `baseRef` is `--cached`); `stalePaths`: files whose staged + * body is behind the working tree; `isUntrackedWithheld`: MergedResult's. + */ +export type DiffData = { + repository: Pick + mode: BaseMode + stats: DiffStats + files: readonly FileStat[] + source: DiffSource + isUnborn: boolean + baseRef: string + stalePaths: readonly string[] + isUntrackedWithheld: boolean +} diff --git a/mods/diff/hooks/git/types/diff-data/index.ts b/mods/diff/hooks/git/types/diff-data/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9933d5291a9b5a55a4317be70df5d30b6e9b4717 --- /dev/null +++ b/mods/diff/hooks/git/types/diff-data/index.ts @@ -0,0 +1,3 @@ +export type * from './diff-data.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/types/diff-source/diff-source.ts b/mods/diff/hooks/git/types/diff-source/diff-source.ts new file mode 100644 index 0000000000000000000000000000000000000000..973005aa6fad8841b74b1471f4e4a330fb951683 --- /dev/null +++ b/mods/diff/hooks/git/types/diff-source/diff-source.ts @@ -0,0 +1,10 @@ +/** + * What a fetched diff compares: the working tree against its checked-out + * commit, or against the merge-base with the default branch. + * + * `base` names the commit as the pane should (`HEAD`, or a short sha where + * the backend has no such word); `baseRef` is the ref git was handed. + */ +export type DiffSource = + | { kind: 'working-tree'; base: string } + | { kind: 'branch'; baseBranch: string; baseRef: string } diff --git a/mods/diff/hooks/git/types/diff-source/index.ts b/mods/diff/hooks/git/types/diff-source/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e2bcaba69225d64b032b576cad69f37ce5b75095 --- /dev/null +++ b/mods/diff/hooks/git/types/diff-source/index.ts @@ -0,0 +1,3 @@ +export type * from './diff-source.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/types/diff-stats/index.ts b/mods/diff/hooks/git/types/diff-stats/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..47582ad2e946ad7499934bf40061d584e04b8140 --- /dev/null +++ b/mods/diff/hooks/git/types/diff-stats/index.ts @@ -0,0 +1,3 @@ +export type * from './diff-stats.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/types/fetch-context.ts b/mods/diff/hooks/git/types/fetch-context.ts new file mode 100644 index 0000000000000000000000000000000000000000..2af21da77e8e4e6cfd12785add9d808e759395ae --- /dev/null +++ b/mods/diff/hooks/git/types/fetch-context.ts @@ -0,0 +1,20 @@ +import type { BaseMode } from './base-mode' +import type { GitDeps } from './git-deps' +import type { GitRun } from './git-run' +import type { Repository } from './repository' +import type { StampProbe } from './stamp-probe' + +/** + * What every tier of one fetch shares: the host, its pinned git, the + * repository, the mode asked for, and one stamp probe over the tree. + * + * One probe, so tracked and untracked dating draw on the same listings + * and one budget. + */ +export type FetchContext = { + deps: GitDeps + run: GitRun + repository: Repository + mode: BaseMode + stamps: StampProbe +} diff --git a/mods/diff/hooks/git/types/fetch-outcome.ts b/mods/diff/hooks/git/types/fetch-outcome.ts new file mode 100644 index 0000000000000000000000000000000000000000..b06d48ee1b6bf4ee24c8c95ebc8c9d83ff3811aa --- /dev/null +++ b/mods/diff/hooks/git/types/fetch-outcome.ts @@ -0,0 +1,10 @@ +import type { DiffData } from './diff-data' + +/** + * How one fetch ended: outside any repository, unreadable for now (a lock, a + * timeout, a rebase under way: the last good diff stays), or data. + */ +export type FetchOutcome = + | { kind: 'no-repository' } + | { kind: 'unavailable' } + | { kind: 'data'; data: DiffData } diff --git a/mods/diff/hooks/git/types/file-hunks.ts b/mods/diff/hooks/git/types/file-hunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..3ec069590202e350cd89233a91af4f2c57d05b24 --- /dev/null +++ b/mods/diff/hooks/git/types/file-hunks.ts @@ -0,0 +1,11 @@ +import type { Hunk } from './hunk' + +/** + * One file's parsed diff body: its hunks, cut at MAX_LINES_PER_FILE + * (`isTruncated`), or none at all past MAX_DIFF_BYTES (`isLarge`). + */ +export type FileHunks = { + hunks: readonly Hunk[] + isTruncated: boolean + isLarge: boolean +} diff --git a/mods/diff/hooks/git/types/file-stat/file-stat.ts b/mods/diff/hooks/git/types/file-stat/file-stat.ts new file mode 100644 index 0000000000000000000000000000000000000000..dbeeabb15ccfb6e4a2939defcbd0636b62da392f --- /dev/null +++ b/mods/diff/hooks/git/types/file-stat/file-stat.ts @@ -0,0 +1,17 @@ +/** + * One changed file's row: its repository-relative path, its line counts, + * and what kind of change it is. + * + * `isPreSession` is set in session mode alone, when the file's timestamp + * predates the session's start (an untracked file too); `renamedFrom` is + * the old path of a rename git detected, else null. + */ +export type FileStat = { + path: string + renamedFrom: string | null + added: number + removed: number + isBinary: boolean + isUntracked: boolean + isPreSession: boolean +} diff --git a/mods/diff/hooks/git/types/file-stat/index.ts b/mods/diff/hooks/git/types/file-stat/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..15a8b3a33317d11dd9dd7ffb7eed30a5c7f79aff --- /dev/null +++ b/mods/diff/hooks/git/types/file-stat/index.ts @@ -0,0 +1,3 @@ +export type * from './file-stat.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/types/git-run/git-run.ts b/mods/diff/hooks/git/types/git-run/git-run.ts new file mode 100644 index 0000000000000000000000000000000000000000..8cca9e860b038c6cc441d1cd22a03ffc254db409 --- /dev/null +++ b/mods/diff/hooks/git/types/git-run/git-run.ts @@ -0,0 +1,7 @@ +import type { RunResult } from '../run-result' + +/** + * Runs git with `argv` (no `git` in it) against the session's pinned + * repository and never rejects: a failure is a RunResult of exit code -1. + */ +export type GitRun = (argv: readonly string[]) => Promise diff --git a/mods/diff/hooks/git/types/git-run/index.ts b/mods/diff/hooks/git/types/git-run/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..50d96509e3bf1d75f241ee6105e631d8bf0cab2b --- /dev/null +++ b/mods/diff/hooks/git/types/git-run/index.ts @@ -0,0 +1,3 @@ +export type * from './git-run.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/types/index.ts b/mods/diff/hooks/git/types/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d022ab1eede837556e71fd307099ef2c8ab0c0a --- /dev/null +++ b/mods/diff/hooks/git/types/index.ts @@ -0,0 +1,27 @@ +export * from './base-mode' +export * from './branch-base' +export * from './dating' +export * from './dating-context' +export * from './diff-data' +export * from './diff-source' +export * from './diff-stats' +export * from './entry-kind' +export type * from './fetch-context.js' +export type * from './fetch-outcome.js' +export type * from './file-hunks.js' +export * from './file-stat' +export * from './git-deps' +export * from './git-run' +export * from './head-key-deps' +export * from './hunk' +export * from './kind-walk' +export * from './merged-result' +export * from './repository' +export * from './resolved-branch-base' +export * from './run-result' +export * from './stamp' +export * from './stamp-probe' +export type * from './untracked-scope.js' +export * from './walk-kind' + +export * as default from '.' diff --git a/mods/diff/hooks/git/types/kind-walk/index.ts b/mods/diff/hooks/git/types/kind-walk/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb3fe9f077c20ccff7767df1ea9218110f4cfc46 --- /dev/null +++ b/mods/diff/hooks/git/types/kind-walk/index.ts @@ -0,0 +1,3 @@ +export type * from './kind-walk.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/types/kind-walk/kind-walk.ts b/mods/diff/hooks/git/types/kind-walk/kind-walk.ts new file mode 100644 index 0000000000000000000000000000000000000000..a809d446639892a4fe950257bd60d5abfe6abf0f --- /dev/null +++ b/mods/diff/hooks/git/types/kind-walk/kind-walk.ts @@ -0,0 +1,13 @@ +import type { WalkKind } from '../walk-kind' + +/** + * Entry kinds under one root, every ancestor checked: a path's kind is + * answered only when each directory above it is itself a real directory. + */ +export type KindWalk = { + /** + * Answers the entry's kind at a root-relative `/` path (`''`: the root); + * see WalkKind for the two ways it cannot say. + */ + kindOf: (path: string) => Promise +} diff --git a/mods/diff/hooks/git/types/repository/index.ts b/mods/diff/hooks/git/types/repository/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b45030b97943c5c6a6dc5924dfedda78cc391d6 --- /dev/null +++ b/mods/diff/hooks/git/types/repository/index.ts @@ -0,0 +1,3 @@ +export type * from './repository.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/types/repository/repository.ts b/mods/diff/hooks/git/types/repository/repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..a30bc8906ac2b59bd09370df6fc30ae1e2d5a882 --- /dev/null +++ b/mods/diff/hooks/git/types/repository/repository.ts @@ -0,0 +1,9 @@ +/** + * Where the session's repository lives: its working tree's top, its own git + * directory (a linked worktree's), and the common directory holding refs. + */ +export type Repository = { + toplevel: string + gitDir: string + commonDir: string +} diff --git a/mods/diff/hooks/git/types/resolved-branch-base/index.ts b/mods/diff/hooks/git/types/resolved-branch-base/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..529169e983ad963bc19445967f755922766c8f5a --- /dev/null +++ b/mods/diff/hooks/git/types/resolved-branch-base/index.ts @@ -0,0 +1,3 @@ +export type * from './resolved-branch-base.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/types/resolved-branch-base/resolved-branch-base.ts b/mods/diff/hooks/git/types/resolved-branch-base/resolved-branch-base.ts new file mode 100644 index 0000000000000000000000000000000000000000..21e30f5980e9567e05d29e174f95606c0c582b5f --- /dev/null +++ b/mods/diff/hooks/git/types/resolved-branch-base/resolved-branch-base.ts @@ -0,0 +1,8 @@ +/** + * What branch mode diffs against once it resolved: the merge-base with the + * default branch, HEAD itself when it is that base, or nothing defensible. + */ +export type ResolvedBranchBase = + | { kind: 'merge-base'; mergeBase: string; baseBranch: string } + | { kind: 'head-is-base'; baseBranch: string } + | { kind: 'none' } diff --git a/mods/diff/hooks/git/types/run-result/index.ts b/mods/diff/hooks/git/types/run-result/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b003dbca8c21620b8a66664f8e43578a465984c7 --- /dev/null +++ b/mods/diff/hooks/git/types/run-result/index.ts @@ -0,0 +1,3 @@ +export type * from './run-result.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/types/run-result/run-result.ts b/mods/diff/hooks/git/types/run-result/run-result.ts new file mode 100644 index 0000000000000000000000000000000000000000..b5889925c9fbfa297d535cd270471c66e7f525c9 --- /dev/null +++ b/mods/diff/hooks/git/types/run-result/run-result.ts @@ -0,0 +1,9 @@ +/** + * What one git child left: its exit code and both output streams; a child + * that never ran or was killed reads as exit code -1 with no output. + */ +export type RunResult = { + exitCode: number + stdout: string + stderr: string +} diff --git a/mods/diff/hooks/git/types/untracked-scope.ts b/mods/diff/hooks/git/types/untracked-scope.ts new file mode 100644 index 0000000000000000000000000000000000000000..795ebd2f8dc65eb600982ae2f33ca1bfb1da7547 --- /dev/null +++ b/mods/diff/hooks/git/types/untracked-scope.ts @@ -0,0 +1,5 @@ +/** + * Which untracked files a fetch keeps: the session's own, or those and the + * ones that predate it, tagged (session mode). + */ +export type UntrackedScope = 'session-only' | 'with-pre-session' diff --git a/mods/diff/hooks/kept-of/kept-of.ts b/mods/diff/hooks/kept-of/kept-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5743b5ea58377d064c217e8f8ba4f49e8bd78f1 --- /dev/null +++ b/mods/diff/hooks/kept-of/kept-of.ts @@ -0,0 +1,9 @@ +/** + * The items that are there: nulls dropped, so a part may answer null for + * "nothing here" and the whole lists what is left. + * + * @param items values or nulls + * @returns the values, in order + */ +export const keptOf = (items: readonly (T | null)[]): T[] => + items.filter((item): item is T => item !== null) diff --git a/mods/diff/hooks/message-of/index.ts b/mods/diff/hooks/message-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1de1dae45430780667e5133563c26b3dc1d700b9 --- /dev/null +++ b/mods/diff/hooks/message-of/index.ts @@ -0,0 +1,3 @@ +export * from './message-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/message-of/message-of.ts b/mods/diff/hooks/message-of/message-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..1967d034859efb3983c5e5f05f095b4042246098 --- /dev/null +++ b/mods/diff/hooks/message-of/message-of.ts @@ -0,0 +1,9 @@ +/** + * A thrown or rejected value as the words it carries: an Error's message, + * anything else as a string. + * + * @param error what was caught + * @returns the message + */ +export const messageOf = (error: unknown) => + error instanceof Error ? error.message : String(error) diff --git a/mods/diff/hooks/pane-toggle/index.ts b/mods/diff/hooks/pane-toggle/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3730c79311521a865948aab3c16a04e7dc8f0c8c --- /dev/null +++ b/mods/diff/hooks/pane-toggle/index.ts @@ -0,0 +1,4 @@ +export * from './pane-belief' +export * from './pane-toggle-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/pane-toggle/pane-toggle-of.ts b/mods/diff/hooks/pane-toggle/pane-toggle-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..0960ff104bc719ca23754bb56f7be35c29578616 --- /dev/null +++ b/mods/diff/hooks/pane-toggle/pane-toggle-of.ts @@ -0,0 +1,21 @@ +import Limits from '../limits' +import type { PaneBelief } from './pane-belief' + +/** + * What `/diff` does, the one place its toggle is decided: close the open + * pane; otherwise open it, unless the terminal is too narrow. + * + * Narrower than the built-in panel shows on, it answers the resize line + * (`too-narrow`); a width not drawn yet opens. + * + * @param pane whether the pane is open, and the width + * @returns `close`, `open` or `too-narrow` + */ +export function paneToggleOf( + pane: PaneBelief, +): 'open' | 'close' | 'too-narrow' { + const isTooNarrow = + pane.columns !== null && pane.columns < Limits.OPEN_MIN_COLUMNS + + return pane.isOpen ? 'close' : isTooNarrow ? 'too-narrow' : 'open' +} diff --git a/mods/diff/hooks/todos/todo-progress-of.ts b/mods/diff/hooks/todos/todo-progress-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..79ab84f7db44185c8ee102166f0902d25b312eb6 --- /dev/null +++ b/mods/diff/hooks/todos/todo-progress-of.ts @@ -0,0 +1,33 @@ +import type { SessionMessage } from 'claude-code' + +import { arrayOf } from '../array-of' +import { countOf } from '../count-of' +import { isRecord } from '../is-record' +import Tools from '../tools' +import type { TodoProgress } from './todo-progress' + +/** + * The todo list as the latest TodoWrite call left it, counted: the pane's + * progress bar (ReplDiffSidebar's, read from the transcript instead). + * + * No call yet reads as an empty list. + * + * @param messages the transcript rows, oldest first + * @returns completed over total + */ +export function todoProgressOf( + messages: readonly SessionMessage[], +): TodoProgress { + const latest = messages + .flatMap(message => message.toolUses) + .findLast(use => use.tool === Tools.TODO_TOOL) + + const statuses = arrayOf(latest?.input.todos).map(todo => + isRecord(todo) ? todo.status : null, + ) + + return { + done: countOf(statuses, status => status === 'completed'), + total: statuses.length, + } +} diff --git a/mods/diff/hooks/turns/index.ts b/mods/diff/hooks/turns/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c2fb45095335656ae567ab9c0e933fb157da75a6 --- /dev/null +++ b/mods/diff/hooks/turns/index.ts @@ -0,0 +1,5 @@ +export * from './turn-diffs-of.js' +export * from './turn-of' +export * from './types' + +export * as default from '.' diff --git a/mods/diff/hooks/turns/turn-diffs-of.ts b/mods/diff/hooks/turns/turn-diffs-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb93e120f58bed7bae2465fa748506db8f5b5a41 --- /dev/null +++ b/mods/diff/hooks/turns/turn-diffs-of.ts @@ -0,0 +1,29 @@ +import type { SessionMessage } from 'claude-code' + +import TurnOf from './turn-of' +import type Types from './types' + +/** + * The transcript's turns that edited files, newest first, each numbered by + * its place among all prompts, read over `$.session.messages()`. + * + * Recomputed whole from the rows given, so a rewind that shrinks the + * transcript simply yields fewer turns. + * + * @param messages the transcript rows, oldest first + * @returns the turns with edits, newest first + */ +export function turnDiffsOf( + messages: readonly SessionMessage[], +): Types.TurnDiff[] { + const starts = messages.flatMap((message, at) => + TurnOf.isPromptRow(message) ? [at] : [], + ) + + return starts + .map((start, ordinal) => + TurnOf.turnOf(messages.slice(start, starts[ordinal + 1]), ordinal + 1), + ) + .filter(turn => turn.files.length > 0) + .toReversed() +} diff --git a/mods/diff/hooks/views/index.ts b/mods/diff/hooks/views/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2727d089e549b83f70300c08c054b0322a581ff6 --- /dev/null +++ b/mods/diff/hooks/views/index.ts @@ -0,0 +1,15 @@ +export * from './body' +export * from './detail' +export * from './dialog-pane' +export * from './entries' +export * from './inset-of' +export * from './kit' +export * from './layout' +export * from './pane-actions' +export * from './pane-seat' +export * from './pane-view.jsx' +export * from './prelude-of' +export * from './sections' +export * from './sidebar-pane' + +export * as default from '.' diff --git a/mods/diff/hooks/views/pane-view.tsx b/mods/diff/hooks/views/pane-view.tsx new file mode 100644 index 0000000000000000000000000000000000000000..96cac2536fec826c5f6ee021c35d8a81afea33ec --- /dev/null +++ b/mods/diff/hooks/views/pane-view.tsx @@ -0,0 +1,55 @@ +/* @jsxRuntime classic */ +/* @jsx h */ +/* @jsxFrag Fragment */ +import type { RenderElement } from 'claude-code' + +import Limits from '../limits' +import Names from '../names' +import type PaneState from '../pane-state' +import { dialogPane } from './dialog-pane' +import { insetOf } from './inset-of' +import type { Kit } from './kit' +import type { PaneSeat } from './pane-seat' +import Sections from './sections' +import { sidebarPane } from './sidebar-pane' + +/** + * The diff pane's body for one `ui.render`: docked, the built-in panel's + * layout (sidebarPane); inline, its dialog's, or its wider-terminal line. + * + * The line only where the session is fullscreen (or not known not to be) + * and the terminal too narrow to dock, as the built-in shows no panel + * there; without the fullscreen layout the dialog draws at any width. + * + * @param kit the elements, the handlers, the width + * @param model the pane's state + * @param seat where the surface seated the pane, and the terminal's width + * @returns the tree + */ +export function paneView( + kit: Kit, + model: PaneState.PaneModel, + seat: PaneSeat, +): RenderElement { + const { Box } = kit.ui + const isDocked = seat.placement === 'dock' + + const isNarrow = + model.isFullscreen !== false && + seat.terminalColumns !== null && + seat.terminalColumns < Limits.OPEN_MIN_COLUMNS + + return isDocked ? ( + + {sidebarPane(insetOf(kit), model)} + + ) : isNarrow ? ( + {Sections.dimNote(kit, Names.RESIZE_TERMINAL_TEXT)} + ) : ( + dialogPane(kit, model) + ) +}