surshar / claude-code
Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.
Built by Anthropic · github.com/anthropics
Add mods: sec-default, diff and telemetry
Showing the first 300 changed files.
| @@ -1,0 +1,24 @@ | ||
| 1 | +# Mods | |
| 2 | + | |
| 3 | +A mod is a Claude Code plugin whose behaviour lives in a hooks module: one | |
| 4 | +`register(on, options)` entry that hooks the engine's events as functions | |
| 5 | +`($, e, next)`. These three ship inside Claude Code; this folder is their | |
| 6 | +source, published as it is built into the binary. | |
| 7 | + | |
| 8 | +| Mod | What it does | Seated | | |
| 9 | +| --- | --- | --- | | |
| 10 | +| [`sec-default`](sec-default) | Keeps an organization's classic hooks, prompt content, managed settings and tool policy out of reach of the plugins a person installs; adds no policy of its own. | Outermost, on a machine with managed settings or for a Team or Enterprise organization, unless managed `prependPlugins` says otherwise | | |
| 11 | +| [`diff`](diff) | `/diff`: the session's uncommitted changes in a pane beside the transcript, file by file with their hunks, refreshed as Claude edits files and runs commands. | Built in | | |
| 12 | +| [`telemetry`](telemetry) | Adds `$.telemetry` (`log`, `mark`) in the `engine.create` fold so a plugin can record an event as a first-party analytics row; sends nothing wherever Claude Code's analytics are off. | Built in | | |
| 13 | + | |
| 14 | +Each folder is a complete plugin: `.claude-plugin/plugin.json`, a | |
| 15 | +`hooks/hooks.json` naming the module, and TypeScript under `hooks/` typed | |
| 16 | +against the declarations `/plugin-types` writes (`import type … from | |
| 17 | +'claude-code'`). To read one running from source: | |
| 18 | + | |
| 19 | + claude --plugin-dir mods/diff | |
| 20 | + | |
| 21 | +Early access: hooks modules load only where function hooks are enabled, and | |
| 22 | +the API these mods are written against may change between releases without | |
| 23 | +notice. They are not listed in this repository's marketplace; the copies that | |
| 24 | +matter are the ones already in your Claude Code. |
| @@ -1,0 +1,8 @@ | ||
| 1 | +{ | |
| 2 | + "name": "diff", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "description": "The diff pane as a plugin: /diff opens the session's uncommitted changes beside the transcript, file by file with their hunks, refreshed as Claude edits and runs commands; the first edit opens it where the terminal is wide enough, and a file's ask button rides its hunks on the next prompt.", | |
| 5 | + "author": { | |
| 6 | + "name": "Anthropic" | |
| 7 | + } | |
| 8 | +} |
| @@ -1,0 +1,54 @@ | ||
| 1 | +# diff | |
| 2 | + | |
| 3 | +The diff pane as a plugin: `/diff` opens the session's uncommitted changes | |
| 4 | +beside the transcript, one row per changed file and the selected file's | |
| 5 | +hunks beneath, and closes it again. The pane refreshes as Claude edits, | |
| 6 | +runs shell commands and finishes turns, and while it is open it polls the | |
| 7 | +repository's HEAD so a commit or checkout made elsewhere shows too. The | |
| 8 | +first successful edit of a session opens the pane by itself where the | |
| 9 | +terminal is wide enough (144 columns when the person never chose, 110 when | |
| 10 | +they kept it open before; a person who closed it is left alone). A file's | |
| 11 | +ask button arms that file: its hunks ride the next prompt as context, once. | |
| 12 | + | |
| 13 | +The pane compares the working tree against HEAD, split at the session's | |
| 14 | +start (the default), against HEAD plainly, or against the merge-base with | |
| 15 | +the default branch; the choice is kept per repository in the plugin's | |
| 16 | +store. A second picker shows one earlier turn's edits instead of the | |
| 17 | +working tree, read from the session's messages. Files that changed before | |
| 18 | +the session started, and noise (lockfiles, generated and test files), are | |
| 19 | +listed apart and folded until asked for. Outside a git repository `/diff` | |
| 20 | +says so and does nothing else. | |
| 21 | + | |
| 22 | +`hooks/register.ts` is the module; everything under `hooks/` is its parts. | |
| 23 | + | |
| 24 | +## What it hooks | |
| 25 | + | |
| 26 | +| event | what the hook does | | |
| 27 | +| --- | --- | | |
| 28 | +| `session.start` | Binds the engine once, registers `/diff` (a session where another `/diff` is listed leaves the plugin idle), and pins the repository. | | |
| 29 | +| `ui.render` of `PromptHint` | Reads the terminal's width, which decides whether the first edit opens the pane. | | |
| 30 | +| `ui.render` of `Pane` | Draws the pane: header, the file list, the toggles, the base and source pickers, and the selected file's hunks. | | |
| 31 | +| `command.run` of `diff` | Opens or closes the pane and remembers the choice. | | |
| 32 | +| `command.run` of `clear`, `resume` | Closes the pane and forgets the session's state. | | |
| 33 | +| `tool.call` of `Edit`, `Write`, `NotebookEdit` | After the edit, refreshes an open pane; the session's first successful edit opens it. | | |
| 34 | +| `tool.call` of `Bash`, `PowerShell` | After the command, refreshes an open pane. | | |
| 35 | +| `turn.complete` | Refreshes an open pane. | | |
| 36 | +| `prompt.submit` | Adds the armed file's hunks to the prompt's context and disarms. | | |
| 37 | + | |
| 38 | +## What it calls on `$` | |
| 39 | + | |
| 40 | +`clock.after`, `clock.every`, `clock.now`, `clock.sleep`, `command.register`, | |
| 41 | +`fs.list`, `fs.read`, `fs.stat`, `process.run` (git, read-only), `session.messages`, | |
| 42 | +`store.get`, `store.set`, `telemetry.log`, `telemetry.mark`, `ui.close`, | |
| 43 | +`ui.invalidate`, `ui.log`, `ui.open`, `ui.resolve`, `ui.status`. | |
| 44 | + | |
| 45 | +`$.telemetry` is the telemetry plugin's noun; where it is absent the rows | |
| 46 | +are dropped and nothing else changes. | |
| 47 | + | |
| 48 | +## Try it | |
| 49 | + | |
| 50 | +```sh | |
| 51 | +claude --plugin-dir /path/to/diff | |
| 52 | +``` | |
| 53 | + | |
| 54 | +then `/diff` inside a git repository with a modified file. |
| @@ -1,0 +1,9 @@ | ||
| 1 | +/** | |
| 2 | + * A value a tool stored as the array it is, or an empty one when it is | |
| 3 | + * anything else. | |
| 4 | + * | |
| 5 | + * @param value a field of a tool's input or result | |
| 6 | + * @returns the array's items, unknown each | |
| 7 | + */ | |
| 8 | +export const arrayOf = (value: unknown): readonly unknown[] => | |
| 9 | + Array.isArray(value) ? value : [] |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './array-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,9 @@ | ||
| 1 | +/** | |
| 2 | + * The file the person asked about from the pane: its path and its diff as | |
| 3 | + * text, held until the next prompt carries it as context. | |
| 4 | + */ | |
| 5 | +export type ArmedAsk = { | |
| 6 | + path: string | |
| 7 | + text: string | |
| 8 | + lines: number | |
| 9 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './armed-ask.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,31 @@ | ||
| 1 | +import type Git from '../git' | |
| 2 | +import Limits from '../limits' | |
| 3 | +import type { ArmedAsk } from './armed-ask' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * A file's diff as the context block the next prompt carries when the | |
| 7 | + * person presses ask. | |
| 8 | + * | |
| 9 | + * A line saying where it came from, then the hunks in unified form, cut at | |
| 10 | + * MAX_LINES_PER_FILE lines. | |
| 11 | + * | |
| 12 | + * @param path the file's repository-relative path | |
| 13 | + * @param hunks its hunks as the pane shows them | |
| 14 | + * @returns the armed ask | |
| 15 | + */ | |
| 16 | +export function armedAskOf(path: string, hunks: readonly Git.Hunk[]): ArmedAsk { | |
| 17 | + const body = hunks | |
| 18 | + .flatMap(hunk => [ | |
| 19 | + `@@ -${hunk.oldStart} +${hunk.newStart} @@`, | |
| 20 | + ...hunk.lines, | |
| 21 | + ]) | |
| 22 | + .slice(0, Limits.MAX_LINES_PER_FILE) | |
| 23 | + | |
| 24 | + return { | |
| 25 | + path, | |
| 26 | + text: | |
| 27 | + `The user attached the diff of ${path} from the diff pane to this ` + | |
| 28 | + `prompt:\n${body.join('\n')}`, | |
| 29 | + lines: body.length, | |
| 30 | + } | |
| 31 | +} |
| @@ -1,0 +1,4 @@ | ||
| 1 | +export * from './armed-ask' | |
| 2 | +export * from './armed-ask-of.js' | |
| 3 | + | |
| 4 | +export * as default from '.' |
| @@ -1,0 +1,7 @@ | ||
| 1 | +/** | |
| 2 | + * A `/`-separated path's last segment: the file's own name. | |
| 3 | + * | |
| 4 | + * @param path the repository-relative path | |
| 5 | + * @returns the base name | |
| 6 | + */ | |
| 7 | +export const baseNameOf = (path: string) => path.split('/').at(-1) ?? path |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './base-name-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,7 @@ | ||
| 1 | +export * from './base-name-of' | |
| 2 | +export * from './is-generated-file' | |
| 3 | +export * from './is-noise-file.js' | |
| 4 | +export * from './is-test-file' | |
| 5 | +export * from './rooted-path-of' | |
| 6 | + | |
| 7 | +export * as default from '.' |
| @@ -1,0 +1,28 @@ | ||
| 1 | +/** | |
| 2 | + * Path segments of build output, vendored code, caches and snapshots, the | |
| 3 | + * engine's excluded directories, matched against `/<path>`. | |
| 4 | + */ | |
| 5 | +export const GENERATED_DIRECTORIES = [ | |
| 6 | + '/dist/', | |
| 7 | + '/build/', | |
| 8 | + '/out/', | |
| 9 | + '/output/', | |
| 10 | + '/node_modules/', | |
| 11 | + '/vendor/', | |
| 12 | + '/vendored/', | |
| 13 | + '/third_party/', | |
| 14 | + '/third-party/', | |
| 15 | + '/external/', | |
| 16 | + '/.next/', | |
| 17 | + '/.nuxt/', | |
| 18 | + '/.svelte-kit/', | |
| 19 | + '/coverage/', | |
| 20 | + '/__pycache__/', | |
| 21 | + '/.tox/', | |
| 22 | + '/venv/', | |
| 23 | + '/.venv/', | |
| 24 | + '/target/release/', | |
| 25 | + '/target/debug/', | |
| 26 | + '.generated/', | |
| 27 | + '/__snapshots__/', | |
| 28 | +] as const |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './generated-directories.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,21 @@ | ||
| 1 | +/** | |
| 2 | + * File-name shapes of generated code, the engine's excluded file-name | |
| 3 | + * patterns: minified, bundled, stubs, specs, snapshots. | |
| 4 | + */ | |
| 5 | +export const GENERATED_FILENAME_PATTERNS: readonly RegExp[] = Object.freeze([ | |
| 6 | + /\.min\.[a-z]+$/i, | |
| 7 | + /-min\.[a-z]+$/i, | |
| 8 | + /\.bundle\.[a-z]+$/i, | |
| 9 | + /\.generated\.[a-z]+$/i, | |
| 10 | + /\.gen\.[a-z]+$/i, | |
| 11 | + /\.auto\.[a-z]+$/i, | |
| 12 | + /_generated\.[a-z]+$/i, | |
| 13 | + /_gen\.[a-z]+$/i, | |
| 14 | + /\.pb\.(?:go|js|ts|py|rb)$/i, | |
| 15 | + /_pb2?\.py$/i, | |
| 16 | + /\.pb\.h$/i, | |
| 17 | + /\.grpc\.[a-z]+$/i, | |
| 18 | + /\.swagger\.[a-z]+$/i, | |
| 19 | + /\.openapi\.[a-z]+$/i, | |
| 20 | + /\.snap$/i, | |
| 21 | +]) |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './generated-filename-patterns.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,18 @@ | ||
| 1 | +/** | |
| 2 | + * File names (lowercase) that are lockfiles or shrinkwraps, never hand | |
| 3 | + * written, so their churn is noise in a diff. | |
| 4 | + */ | |
| 5 | +export const GENERATED_FILENAMES: ReadonlySet<string> = new Set([ | |
| 6 | + 'package-lock.json', | |
| 7 | + 'yarn.lock', | |
| 8 | + 'pnpm-lock.yaml', | |
| 9 | + 'bun.lockb', | |
| 10 | + 'bun.lock', | |
| 11 | + 'composer.lock', | |
| 12 | + 'gemfile.lock', | |
| 13 | + 'cargo.lock', | |
| 14 | + 'poetry.lock', | |
| 15 | + 'pipfile.lock', | |
| 16 | + 'shrinkwrap.json', | |
| 17 | + 'npm-shrinkwrap.json', | |
| 18 | +]) |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './generated-filenames.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,15 @@ | ||
| 1 | +/** | |
| 2 | + * Extensions, simple and compound (lowercase), of minified, bundled or | |
| 3 | + * generated output, the engine's excluded extensions. | |
| 4 | + */ | |
| 5 | +export const GENERATED_SUFFIXES = [ | |
| 6 | + '.lock', | |
| 7 | + '.min.js', | |
| 8 | + '.min.css', | |
| 9 | + '.min.html', | |
| 10 | + '.bundle.js', | |
| 11 | + '.bundle.css', | |
| 12 | + '.generated.ts', | |
| 13 | + '.generated.js', | |
| 14 | + '.d.ts', | |
| 15 | +] as const |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './generated-suffixes.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,7 @@ | ||
| 1 | +export * from './generated-directories' | |
| 2 | +export * from './generated-filename-patterns' | |
| 3 | +export * from './generated-filenames' | |
| 4 | +export * from './generated-suffixes' | |
| 5 | +export * from './is-generated-file.js' | |
| 6 | + | |
| 7 | +export * as default from '.' |
| @@ -1,0 +1,28 @@ | ||
| 1 | +import { baseNameOf } from '../base-name-of' | |
| 2 | +import { rootedPathOf } from '../rooted-path-of' | |
| 3 | +import { GENERATED_DIRECTORIES } from './generated-directories' | |
| 4 | +import { GENERATED_FILENAME_PATTERNS } from './generated-filename-patterns' | |
| 5 | +import { GENERATED_FILENAMES } from './generated-filenames' | |
| 6 | +import { GENERATED_SUFFIXES } from './generated-suffixes' | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Whether a repository-relative path names generated or vendored content, | |
| 10 | + * by the Linguist-style rules the engine's own diff panel applies. | |
| 11 | + * | |
| 12 | + * @param path the file's path from the repository root, `/`-separated | |
| 13 | + * @returns whether the diff pane counts it among the tests and generated files | |
| 14 | + */ | |
| 15 | +export function isGeneratedFile(path: string) { | |
| 16 | + const name = baseNameOf(path).toLowerCase() | |
| 17 | + const rooted = rootedPathOf(path) | |
| 18 | + const hasGeneratedSuffix = GENERATED_SUFFIXES.some(suffix => | |
| 19 | + name.endsWith(suffix), | |
| 20 | + ) | |
| 21 | + | |
| 22 | + return ( | |
| 23 | + GENERATED_FILENAMES.has(name) || | |
| 24 | + hasGeneratedSuffix || | |
| 25 | + GENERATED_DIRECTORIES.some(directory => rooted.includes(directory)) || | |
| 26 | + GENERATED_FILENAME_PATTERNS.some(pattern => pattern.test(name)) | |
| 27 | + ) | |
| 28 | +} |
| @@ -1,0 +1,12 @@ | ||
| 1 | +import { isGeneratedFile } from './is-generated-file' | |
| 2 | +import { isTestFile } from './is-test-file' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Whether a changed file is hidden behind the pane's tests-and-generated | |
| 6 | + * line until the person asks to see it. | |
| 7 | + * | |
| 8 | + * @param path the file's path from the repository root | |
| 9 | + * @returns whether it is a test or a generated file | |
| 10 | + */ | |
| 11 | +export const isNoiseFile = (path: string) => | |
| 12 | + isTestFile(path) || isGeneratedFile(path) |
| @@ -1,0 +1,5 @@ | ||
| 1 | +export * from './is-test-file.js' | |
| 2 | +export * from './test-directories' | |
| 3 | +export * from './test-filename-patterns' | |
| 4 | + | |
| 5 | +export * as default from '.' |
| @@ -1,0 +1,21 @@ | ||
| 1 | +import { baseNameOf } from '../base-name-of' | |
| 2 | +import { rootedPathOf } from '../rooted-path-of' | |
| 3 | +import { TEST_DIRECTORIES } from './test-directories' | |
| 4 | +import { TEST_FILENAME_PATTERNS } from './test-filename-patterns' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Whether a repository-relative path is a test, spec, fixture or snapshot, | |
| 8 | + * by the same rules the engine's own diff panel applies. | |
| 9 | + * | |
| 10 | + * @param path the file's path from the repository root, `/`-separated | |
| 11 | + * @returns whether the diff pane counts it among the tests and generated files | |
| 12 | + */ | |
| 13 | +export function isTestFile(path: string) { | |
| 14 | + const name = baseNameOf(path) | |
| 15 | + const rooted = rootedPathOf(path) | |
| 16 | + | |
| 17 | + return ( | |
| 18 | + TEST_DIRECTORIES.some(directory => rooted.includes(directory)) || | |
| 19 | + TEST_FILENAME_PATTERNS.some(pattern => pattern.test(name)) | |
| 20 | + ) | |
| 21 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './test-directories.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,16 @@ | ||
| 1 | +/** | |
| 2 | + * Path segments of tests, specs, mocks and fixtures, the engine's test | |
| 3 | + * directories, matched against `/<path>`. | |
| 4 | + */ | |
| 5 | +export const TEST_DIRECTORIES = [ | |
| 6 | + '/test/', | |
| 7 | + '/tests/', | |
| 8 | + '/spec/', | |
| 9 | + '/specs/', | |
| 10 | + '/__tests__/', | |
| 11 | + '/__mocks__/', | |
| 12 | + '/__snapshots__/', | |
| 13 | + '/__fixtures__/', | |
| 14 | + '/fixtures/', | |
| 15 | + '/testdata/', | |
| 16 | +] as const |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './test-filename-patterns.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,11 @@ | ||
| 1 | +/** | |
| 2 | + * File-name shapes of test files and snapshots, the engine's test file-name | |
| 3 | + * patterns. | |
| 4 | + */ | |
| 5 | +export const TEST_FILENAME_PATTERNS: readonly RegExp[] = Object.freeze([ | |
| 6 | + /\.test\.[a-z]+$/i, | |
| 7 | + /\.spec\.[a-z]+$/i, | |
| 8 | + /_test\.[a-z]+$/i, | |
| 9 | + /_spec\.[a-z]+$/i, | |
| 10 | + /\.snap$/i, | |
| 11 | +]) |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './rooted-path-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,8 @@ | ||
| 1 | +/** | |
| 2 | + * A path with exactly one leading `/`, so a directory pattern like `/test/` | |
| 3 | + * matches at the start of a relative path too. | |
| 4 | + * | |
| 5 | + * @param path the repository-relative path | |
| 6 | + * @returns the rooted path | |
| 7 | + */ | |
| 8 | +export const rootedPathOf = (path: string) => `/${path.replace(/^\/+/, '')}` |
| @@ -1,0 +1,10 @@ | ||
| 1 | +import Names from '../names' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * What `session.start` hands `$.command.register`: the one place the | |
| 5 | + * `/diff` spec is built, where a later CommandSpec field joins. | |
| 6 | + */ | |
| 7 | +export const COMMAND_SPEC = { | |
| 8 | + name: Names.COMMAND_NAME, | |
| 9 | + description: Names.COMMAND_DESCRIPTION, | |
| 10 | +} as const |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './command-spec.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,10 @@ | ||
| 1 | +/** | |
| 2 | + * How many items of a list pass a test, without the intermediate array a | |
| 3 | + * filter would build. | |
| 4 | + * | |
| 5 | + * @param items the list | |
| 6 | + * @param passes the test | |
| 7 | + * @returns the number of passing items | |
| 8 | + */ | |
| 9 | +export const countOf = <T>(items: readonly T[], passes: (item: T) => boolean) => | |
| 10 | + items.reduce((total, item) => total + (passes(item) ? 1 : 0), 0) |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './count-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,17 @@ | ||
| 1 | +import type Git from '../git' | |
| 2 | +import type { Host } from '../host' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * A directory's entries by name with their own kinds, through the host's | |
| 6 | + * listing (no link followed); null when it cannot be listed. | |
| 7 | + * | |
| 8 | + * @param engine the bound host | |
| 9 | + * @returns the probe (GitDeps `entryKindsOf`) | |
| 10 | + */ | |
| 11 | +export const entryKindsOf = | |
| 12 | + (engine: Host) => | |
| 13 | + (dir: string): Promise<ReadonlyMap<string, Git.EntryKind> | null> => | |
| 14 | + engine.listDir(dir).then( | |
| 15 | + entries => new Map(entries.map(entry => [entry.name, entry.kind])), | |
| 16 | + () => null, | |
| 17 | + ) |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './entry-kinds-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,20 @@ | ||
| 1 | +/** | |
| 2 | + * The leading argv of every `git diff` the plugin runs: the config the | |
| 3 | + * engine's own diff panel pins, `core.quotePath=false`, and raw-blob flags. | |
| 4 | + * | |
| 5 | + * Root-relative paths whatever `diff.relative` says, non-ASCII paths as | |
| 6 | + * their own bytes, no optional locks, no child `git status` in a dirty | |
| 7 | + * submodule, and no external diff or textconv driver even on a stats pass. | |
| 8 | + */ | |
| 9 | +export const DIFF_LEADING_ARGS = [ | |
| 10 | + '--no-optional-locks', | |
| 11 | + '-c', | |
| 12 | + 'diff.relative=false', | |
| 13 | + '-c', | |
| 14 | + 'core.quotePath=false', | |
| 15 | + 'diff', | |
| 16 | + '--no-ext-diff', | |
| 17 | + '--no-textconv', | |
| 18 | + '--ignore-submodules=dirty', | |
| 19 | + '--submodule=short', | |
| 20 | +] as const |
| @@ -1,0 +1,8 @@ | ||
| 1 | +/** | |
| 2 | + * The environment laid over every git child the plugin spawns: the C | |
| 3 | + * locale, so `--shortstat` and git's messages parse the same everywhere. | |
| 4 | + * | |
| 5 | + * Nothing else is pinned, as for the built-in panel's own diff children: | |
| 6 | + * a partial clone lazy-fetches as it would for `git diff` at the prompt. | |
| 7 | + */ | |
| 8 | +export const GIT_CHILD_ENV = { LC_ALL: 'C', LANGUAGE: '' } as const |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './git-child-env.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,6 @@ | ||
| 1 | +export * from './diff-leading-args.js' | |
| 2 | +export * from './git-child-env' | |
| 3 | +export * from './no-optional-locks.js' | |
| 4 | +export * from './pinned-lead-of' | |
| 5 | + | |
| 6 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The flag every probe carries so a read never takes the index lock from a | |
| 3 | + * git the person is running. | |
| 4 | + */ | |
| 5 | +export const NO_OPTIONAL_LOCKS = '--no-optional-locks' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './pinned-lead-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,18 @@ | ||
| 1 | +import type Types from '../../types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The argv that leads every git child once the repository is pinned: its | |
| 5 | + * git directory and working tree named, as the built-in panel pins them. | |
| 6 | + * | |
| 7 | + * With `--git-dir` given git discovers nothing from its cwd, so a nested | |
| 8 | + * repository the shell later moves into is never the one read. | |
| 9 | + * | |
| 10 | + * @param repository the session's pinned repository | |
| 11 | + * @returns the leading argv | |
| 12 | + */ | |
| 13 | +export const pinnedLeadOf = ( | |
| 14 | + repository: Types.Repository, | |
| 15 | +): readonly string[] => [ | |
| 16 | + `--git-dir=${repository.gitDir}`, | |
| 17 | + `--work-tree=${repository.toplevel}`, | |
| 18 | +] |
| @@ -1,0 +1,30 @@ | ||
| 1 | +import Limits from '../../limits' | |
| 2 | +import type Types from '../types' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Hunks with their lines cut at MAX_LINES_PER_FILE in all, in order, and | |
| 6 | + * whether any line was dropped. | |
| 7 | + * | |
| 8 | + * The one bound on every body the pane draws or attaches, whether git | |
| 9 | + * printed it or the transcript carried it. | |
| 10 | + * | |
| 11 | + * @param hunks the hunks as parsed or reported | |
| 12 | + * @returns the cut hunks and whether the cut dropped lines | |
| 13 | + */ | |
| 14 | +export function cutHunks( | |
| 15 | + hunks: readonly Types.Hunk[], | |
| 16 | +): Pick<Types.FileHunks, 'hunks' | 'isTruncated'> { | |
| 17 | + let budget = Limits.MAX_LINES_PER_FILE | |
| 18 | + let total = 0 | |
| 19 | + | |
| 20 | + return { | |
| 21 | + hunks: hunks.map(hunk => { | |
| 22 | + const lines = hunk.lines.slice(0, budget) | |
| 23 | + budget -= lines.length | |
| 24 | + total += hunk.lines.length | |
| 25 | + | |
| 26 | + return { ...hunk, lines } | |
| 27 | + }), | |
| 28 | + isTruncated: total > Limits.MAX_LINES_PER_FILE, | |
| 29 | + } | |
| 30 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './cut-hunks.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,11 @@ | ||
| 1 | +import type Types from '../types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A body with nothing to draw: an untracked or binary row's, or a staged | |
| 5 | + * file's whose index body went stale on an unborn HEAD. | |
| 6 | + */ | |
| 7 | +export const EMPTY_FILE_HUNKS: Types.FileHunks = Object.freeze({ | |
| 8 | + hunks: [], | |
| 9 | + isTruncated: false, | |
| 10 | + isLarge: false, | |
| 11 | +}) |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './empty-file-hunks.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,33 @@ | ||
| 1 | +import Probes from './probes' | |
| 2 | +import Tiers from './tiers' | |
| 3 | +import type Types from './types' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * One fetch of the session's pinned repository, as the built-in panel | |
| 7 | + * fetches; every git child names it so paths stay root-relative. | |
| 8 | + * | |
| 9 | + * Mid-merge, mid-rebase, mid-cherry-pick or mid-revert, or when git cannot | |
| 10 | + * answer, it is unavailable and the caller keeps what it had. | |
| 11 | + * | |
| 12 | + * @param deps the host: pinned git, the repository, the file probes | |
| 13 | + * @param mode what to compare against | |
| 14 | + * @returns the outcome | |
| 15 | + */ | |
| 16 | +export async function fetchDiff( | |
| 17 | + deps: Types.GitDeps, | |
| 18 | + mode: Types.BaseMode, | |
| 19 | +): Promise<Types.FetchOutcome> { | |
| 20 | + const isBusy = await Probes.isTransient( | |
| 21 | + deps.entryKindsOf, | |
| 22 | + deps.repository.gitDir, | |
| 23 | + ) | |
| 24 | + | |
| 25 | + if (isBusy) { | |
| 26 | + return { kind: 'unavailable' } | |
| 27 | + } | |
| 28 | + | |
| 29 | + const context = Tiers.fetchContextOf(deps, mode) | |
| 30 | + const isBranch = mode === 'branch' | |
| 31 | + | |
| 32 | + return isBranch ? Tiers.branchTier(context) : Tiers.workingTreeTier(context) | |
| 33 | +} |
| @@ -1,0 +1,41 @@ | ||
| 1 | +import Argv from './argv' | |
| 2 | +import { EMPTY_FILE_HUNKS } from './empty-file-hunks' | |
| 3 | +import GitParse from './parse' | |
| 4 | +import type Types from './types' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * One file's hunks against the base its row was read against, so body and | |
| 8 | + * counts agree, by literal pathspec, as the built-in panel reads hunks. | |
| 9 | + * | |
| 10 | + * A rename passes both its paths so git pairs them; an untracked or binary | |
| 11 | + * row, or on an unborn HEAD a file edited after staging, has no honest | |
| 12 | + * body and answers no hunks. | |
| 13 | + * | |
| 14 | + * @param run runs git against the pinned repository | |
| 15 | + * @param data the fetch the row belongs to | |
| 16 | + * @param file the row | |
| 17 | + * @returns the parsed body, or null when git failed | |
| 18 | + */ | |
| 19 | +export async function fetchFileHunks( | |
| 20 | + run: Types.GitRun, | |
| 21 | + data: Types.DiffData, | |
| 22 | + file: Types.FileStat, | |
| 23 | +): Promise<Types.FileHunks | null> { | |
| 24 | + const hasNoBody = | |
| 25 | + file.isUntracked || file.isBinary || data.stalePaths.includes(file.path) | |
| 26 | + | |
| 27 | + if (hasNoBody) { | |
| 28 | + return EMPTY_FILE_HUNKS | |
| 29 | + } | |
| 30 | + | |
| 31 | + const { exitCode, stdout } = await run([ | |
| 32 | + '--literal-pathspecs', | |
| 33 | + ...Argv.DIFF_LEADING_ARGS, | |
| 34 | + data.baseRef, | |
| 35 | + '--', | |
| 36 | + ...(file.renamedFrom === null ? [] : [file.renamedFrom]), | |
| 37 | + file.path, | |
| 38 | + ]) | |
| 39 | + | |
| 40 | + return exitCode === 0 ? GitParse.parseFileDiff(stdout) : null | |
| 41 | +} |
| @@ -1,0 +1,17 @@ | ||
| 1 | +import type Types from '../types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * One hunk from its header's captured starts and its body lines. | |
| 5 | + * | |
| 6 | + * @param starts the `old` and `new` groups of HUNK_HEADER, when it matched | |
| 7 | + * @param lines the body lines after the header | |
| 8 | + * @returns the hunk | |
| 9 | + */ | |
| 10 | +export const hunkOf = ( | |
| 11 | + starts: Record<string, string> | undefined, | |
| 12 | + lines: readonly string[], | |
| 13 | +): Types.Hunk => ({ | |
| 14 | + oldStart: Number(starts?.old), | |
| 15 | + newStart: Number(starts?.new), | |
| 16 | + lines, | |
| 17 | +}) |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './hunk-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,13 @@ | ||
| 1 | +export * from './argv' | |
| 2 | +export * from './cut-hunks' | |
| 3 | +export * from './empty-file-hunks' | |
| 4 | +export * from './fetch-diff.js' | |
| 5 | +export * from './fetch-file-hunks.js' | |
| 6 | +export * from './hunk-of' | |
| 7 | +export * from './large-file-hunks' | |
| 8 | +export * from './parse' | |
| 9 | +export * from './probes' | |
| 10 | +export * from './tiers' | |
| 11 | +export * from './types' | |
| 12 | + | |
| 13 | +export * as default from '.' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './large-file-hunks.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,11 @@ | ||
| 1 | +import type Types from '../types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The body of a file whose diff text passed MAX_DIFF_BYTES: no hunks, and | |
| 5 | + * the pane says the file is too large. | |
| 6 | + */ | |
| 7 | +export const LARGE_FILE_HUNKS: Types.FileHunks = Object.freeze({ | |
| 8 | + hunks: [], | |
| 9 | + isTruncated: false, | |
| 10 | + isLarge: true, | |
| 11 | +}) |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * A unified diff's hunk header, `@@ -old[,n] +new[,n] @@`, its two starts | |
| 3 | + * captured as `old` and `new`. | |
| 4 | + */ | |
| 5 | +export const HUNK_HEADER = /^@@ -(?<old>\d+)(?:,\d+)? \+(?<new>\d+)(?:,\d+)? @@/ |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './hunk-header.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,11 @@ | ||
| 1 | +export * from './hunk-header' | |
| 2 | +export * from './is-body-line' | |
| 3 | +export * from './is-whole-answer' | |
| 4 | +export * from './is-whole-listing' | |
| 5 | +export * from './parse-file-diff.js' | |
| 6 | +export * from './parse-numstat.js' | |
| 7 | +export * from './parse-shortstat.js' | |
| 8 | +export * from './rename-records' | |
| 9 | +export * from './shortstat-pattern' | |
| 10 | + | |
| 11 | +export * as default from '.' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './is-body-line.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,9 @@ | ||
| 1 | +/** | |
| 2 | + * Whether a line after a hunk header is a body line (added, removed or | |
| 3 | + * context) rather than a note such as `\ No newline at end of file`. | |
| 4 | + * | |
| 5 | + * @param line one line of `git diff` output | |
| 6 | + * @returns whether it starts with `+`, `-` or a space | |
| 7 | + */ | |
| 8 | +export const isBodyLine = (line: string) => | |
| 9 | + line.startsWith('+') || line.startsWith('-') || line.startsWith(' ') |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './is-whole-answer.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,12 @@ | ||
| 1 | +import type Types from '../../types' | |
| 2 | +import { isWholeListing } from '../is-whole-listing' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Whether a `-z` git child both succeeded and answered a whole listing, | |
| 6 | + * so its stdout may be parsed (isWholeListing). | |
| 7 | + * | |
| 8 | + * @param result what the child left | |
| 9 | + * @returns whether to parse it | |
| 10 | + */ | |
| 11 | +export const isWholeAnswer = (result: Types.RunResult) => | |
| 12 | + result.exitCode === 0 && isWholeListing(result.stdout) |
| @@ -1,0 +1,5 @@ | ||
| 1 | +export * from './is-under-utf8-ceiling' | |
| 2 | +export * from './is-whole-listing.js' | |
| 3 | +export * from './most-utf8-bytes' | |
| 4 | + | |
| 5 | +export * as default from '.' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './is-under-utf8-ceiling.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,13 @@ | ||
| 1 | +import { MOST_UTF8_BYTES } from '../most-utf8-bytes' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Whether the text's UTF-8 encoding stays under a byte count, encoding it | |
| 5 | + * only when its length alone cannot already say so. | |
| 6 | + * | |
| 7 | + * @param text the text | |
| 8 | + * @param ceiling the byte count it must stay under | |
| 9 | + * @returns whether it is under | |
| 10 | + */ | |
| 11 | +export const isUnderUtf8Ceiling = (text: string, ceiling: number) => | |
| 12 | + text.length * MOST_UTF8_BYTES < ceiling || | |
| 13 | + new TextEncoder().encode(text).length < ceiling |
| @@ -1,0 +1,17 @@ | ||
| 1 | +import Limits from '../../../limits' | |
| 2 | +import { isUnderUtf8Ceiling } from './is-under-utf8-ceiling' | |
| 3 | +import { MOST_UTF8_BYTES } from './most-utf8-bytes' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Whether a `-z` listing arrived whole: every record NUL-terminated and | |
| 7 | + * the text short of the host's output cap, so no record was cut or lost. | |
| 8 | + * | |
| 9 | + * A cut inside a multi-byte character costs the decoded text up to | |
| 10 | + * MOST_UTF8_BYTES, hence the margin under the cap. | |
| 11 | + * | |
| 12 | + * @param stdout the listing as `$.process.run` returned it | |
| 13 | + * @returns whether it can be parsed as the whole answer | |
| 14 | + */ | |
| 15 | +export const isWholeListing = (stdout: string) => | |
| 16 | + (stdout === '' || stdout.endsWith('\0')) && | |
| 17 | + isUnderUtf8Ceiling(stdout, Limits.HOST_OUTPUT_CAP_BYTES - MOST_UTF8_BYTES) |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './most-utf8-bytes.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The most bytes one UTF-16 code unit takes in UTF-8, and so both the | |
| 3 | + * bound on a text's encoded length and the most a cut character drops. | |
| 4 | + */ | |
| 5 | +export const MOST_UTF8_BYTES = 3 |
| @@ -1,0 +1,40 @@ | ||
| 1 | +import Limits from '../../limits' | |
| 2 | +import { cutHunks } from '../cut-hunks' | |
| 3 | +import { hunkOf } from '../hunk-of' | |
| 4 | +import { LARGE_FILE_HUNKS } from '../large-file-hunks' | |
| 5 | +import type Types from '../types' | |
| 6 | +import { HUNK_HEADER } from './hunk-header' | |
| 7 | +import { isBodyLine } from './is-body-line' | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * One file's unified `git diff` output as hunks, the body cut at | |
| 11 | + * MAX_LINES_PER_FILE lines in all, as the built-in panel parses one file. | |
| 12 | + * | |
| 13 | + * Past MAX_DIFF_BYTES there are no hunks and `isLarge` is set; everything | |
| 14 | + * before the first hunk header is the file's header. | |
| 15 | + * | |
| 16 | + * @param stdout the command's standard output for that one path | |
| 17 | + * @returns the parsed body | |
| 18 | + */ | |
| 19 | +export function parseFileDiff(stdout: string): Types.FileHunks { | |
| 20 | + if (stdout.length > Limits.MAX_DIFF_BYTES) { | |
| 21 | + return LARGE_FILE_HUNKS | |
| 22 | + } | |
| 23 | + | |
| 24 | + const rows = stdout.split('\n') | |
| 25 | + const headerRows = rows.flatMap((row, at) => | |
| 26 | + HUNK_HEADER.test(row) ? [at] : [], | |
| 27 | + ) | |
| 28 | + | |
| 29 | + return { | |
| 30 | + ...cutHunks( | |
| 31 | + headerRows.map((at, ordinal) => | |
| 32 | + hunkOf( | |
| 33 | + HUNK_HEADER.exec(rows[at] ?? '')?.groups, | |
| 34 | + rows.slice(at + 1, headerRows[ordinal + 1]).filter(isBodyLine), | |
| 35 | + ), | |
| 36 | + ), | |
| 37 | + ), | |
| 38 | + isLarge: false, | |
| 39 | + } | |
| 40 | +} |
| @@ -1,0 +1,62 @@ | ||
| 1 | +import type Types from '../types' | |
| 2 | +import { RENAME_RECORDS } from './rename-records' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * `git diff --numstat -z` output as totals over every row and the first | |
| 6 | + * rows up to the cap; a binary file (`-` counts) is a 0/0 row marked binary. | |
| 7 | + * | |
| 8 | + * A record is `<added>\t<removed>\t<path>` ended by NUL, the path raw; a | |
| 9 | + * rename is `<added>\t<removed>\t` then the old and the new path as the | |
| 10 | + * next two records, kept as one row under the new path. | |
| 11 | + * | |
| 12 | + * @param stdout the command's standard output | |
| 13 | + * @param maxFiles how many rows to keep | |
| 14 | + * @returns the totals and the kept rows | |
| 15 | + */ | |
| 16 | +export function parseNumstat( | |
| 17 | + stdout: string, | |
| 18 | + maxFiles: number, | |
| 19 | +): Types.NumstatResult { | |
| 20 | + const files: Types.FileStat[] = [] | |
| 21 | + const stats = { filesCount: 0, linesAdded: 0, linesRemoved: 0 } | |
| 22 | + const records = stdout.split('\0') | |
| 23 | + let at = 0 | |
| 24 | + | |
| 25 | + while (at < records.length) { | |
| 26 | + const [addedField, removedField, ...pathFields] = (records[at] ?? '').split( | |
| 27 | + '\t', | |
| 28 | + ) | |
| 29 | + const inline = pathFields.join('\t') | |
| 30 | + const isRename = pathFields.length === 1 && inline === '' | |
| 31 | + const renamedFrom = isRename ? (records[at + 1] ?? null) : null | |
| 32 | + const path = isRename ? (records[at + 2] ?? '') : inline | |
| 33 | + at += isRename ? RENAME_RECORDS : 1 | |
| 34 | + const isRow = | |
| 35 | + addedField !== undefined && removedField !== undefined && path !== '' | |
| 36 | + | |
| 37 | + if (!isRow) { | |
| 38 | + continue | |
| 39 | + } | |
| 40 | + | |
| 41 | + const isBinary = addedField === '-' || removedField === '-' | |
| 42 | + const added = isBinary ? 0 : Number(addedField) || 0 | |
| 43 | + const removed = isBinary ? 0 : Number(removedField) || 0 | |
| 44 | + stats.filesCount += 1 | |
| 45 | + stats.linesAdded += added | |
| 46 | + stats.linesRemoved += removed | |
| 47 | + | |
| 48 | + if (files.length < maxFiles) { | |
| 49 | + files.push({ | |
| 50 | + path, | |
| 51 | + renamedFrom, | |
| 52 | + added, | |
| 53 | + removed, | |
| 54 | + isBinary, | |
| 55 | + isUntracked: false, | |
| 56 | + isPreSession: false, | |
| 57 | + }) | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + return { stats, files } | |
| 62 | +} |
| @@ -1,0 +1,23 @@ | ||
| 1 | +import type Types from '../types' | |
| 2 | +import { SHORTSTAT_PATTERN } from './shortstat-pattern' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * `git diff --shortstat` output as totals, or null when the line is not | |
| 6 | + * git's summary line (empty output for an empty diff included). | |
| 7 | + * | |
| 8 | + * @param stdout the command's standard output | |
| 9 | + * @returns the totals, or null | |
| 10 | + */ | |
| 11 | +export function parseShortstat(stdout: string): Types.DiffStats | null { | |
| 12 | + const counts = SHORTSTAT_PATTERN.exec(stdout)?.groups | |
| 13 | + | |
| 14 | + if (!counts) { | |
| 15 | + return null | |
| 16 | + } | |
| 17 | + | |
| 18 | + return { | |
| 19 | + filesCount: Number(counts.files), | |
| 20 | + linesAdded: Number(counts.added ?? 0), | |
| 21 | + linesRemoved: Number(counts.removed ?? 0), | |
| 22 | + } | |
| 23 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './rename-records.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * How many NUL records a rename takes in `--numstat -z` output: the counts | |
| 3 | + * with an empty path, then the old path, then the new. | |
| 4 | + */ | |
| 5 | +export const RENAME_RECORDS = 3 |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './shortstat-pattern.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,11 @@ | ||
| 1 | +/** | |
| 2 | + * `--shortstat`'s one line, ` 3 files changed, 10 insertions(+), 2 | |
| 3 | + * deletions(-)`, either tail optional; the counts captured by name. | |
| 4 | + */ | |
| 5 | +export const SHORTSTAT_PATTERN = new RegExp( | |
| 6 | + [ | |
| 7 | + String.raw`(?<files>\d+) files? changed`, | |
| 8 | + String.raw`(?:, (?<added>\d+) insertions?\(\+\))?`, | |
| 9 | + String.raw`(?:, (?<removed>\d+) deletions?\(-\))?`, | |
| 10 | + ].join(''), | |
| 11 | +) |
| @@ -1,0 +1,72 @@ | ||
| 1 | +import Argv from '../../argv' | |
| 2 | +import type Types from '../../types' | |
| 3 | +import { currentBranchOf } from '../current-branch-of' | |
| 4 | +import { defaultBranchOf } from '../default-branch-of' | |
| 5 | +import { mergeBaseCandidatesOf } from '../merge-base-candidates-of' | |
| 6 | +import { missingBaseOf } from '../missing-base-of' | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * What branch mode compares against, resolved as the built-in panel does: | |
| 10 | + * the merge-base of HEAD and the default branch, HEAD itself, or none. | |
| 11 | + * | |
| 12 | + * `origin/<default>` wins unless the local ref's base descends from it; a | |
| 13 | + * detached HEAD, a `-`-led default name or no common ancestor is `none`; | |
| 14 | + * a failure that may pass (a ref still there, a failed rev-parse) `error`. | |
| 15 | + * | |
| 16 | + * @param run runs git | |
| 17 | + * @returns the base | |
| 18 | + */ | |
| 19 | +export async function branchBaseOf( | |
| 20 | + run: Types.GitRun, | |
| 21 | +): Promise<Types.BranchBase> { | |
| 22 | + const [branch, defaultBranch] = await Promise.all([ | |
| 23 | + currentBranchOf(run), | |
| 24 | + defaultBranchOf(run), | |
| 25 | + ]) | |
| 26 | + const isUnrelated = branch === 'HEAD' || defaultBranch.startsWith('-') | |
| 27 | + | |
| 28 | + if (isUnrelated) { | |
| 29 | + return { kind: 'none' } | |
| 30 | + } | |
| 31 | + | |
| 32 | + if (branch === defaultBranch) { | |
| 33 | + return { kind: 'head-is-base', baseBranch: defaultBranch } | |
| 34 | + } | |
| 35 | + | |
| 36 | + const { candidates, hasNoCommonAncestor } = await mergeBaseCandidatesOf( | |
| 37 | + run, | |
| 38 | + defaultBranch, | |
| 39 | + ) | |
| 40 | + const [first, second] = candidates | |
| 41 | + | |
| 42 | + if (first === undefined) { | |
| 43 | + return hasNoCommonAncestor | |
| 44 | + ? { kind: 'none' } | |
| 45 | + : missingBaseOf(run, defaultBranch) | |
| 46 | + } | |
| 47 | + | |
| 48 | + const isLocalBaseNewer = | |
| 49 | + second !== undefined && | |
| 50 | + second !== first && | |
| 51 | + ( | |
| 52 | + await run([ | |
| 53 | + Argv.NO_OPTIONAL_LOCKS, | |
| 54 | + 'merge-base', | |
| 55 | + '--is-ancestor', | |
| 56 | + first, | |
| 57 | + second, | |
| 58 | + ]) | |
| 59 | + ).exitCode === 0 | |
| 60 | + const mergeBase = isLocalBaseNewer ? second : first | |
| 61 | + const head = await run([Argv.NO_OPTIONAL_LOCKS, 'rev-parse', 'HEAD']) | |
| 62 | + | |
| 63 | + if (head.exitCode !== 0) { | |
| 64 | + return { kind: 'error', reason: 'head_rev_parse_failed' } | |
| 65 | + } | |
| 66 | + | |
| 67 | + const isHeadTheBase = head.stdout.trim() === mergeBase | |
| 68 | + | |
| 69 | + return isHeadTheBase | |
| 70 | + ? { kind: 'head-is-base', baseBranch: defaultBranch } | |
| 71 | + : { kind: 'merge-base', mergeBase, baseBranch: defaultBranch } | |
| 72 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './branch-base-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * Tried in order as the default branch when `refs/remotes/origin/HEAD` names | |
| 3 | + * none that resolves; past them the guess is `main`, as the engine's is. | |
| 4 | + */ | |
| 5 | +export const CONVENTIONAL_DEFAULT_BRANCHES = ['main', 'master'] as const |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './conventional-default-branches.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,20 @@ | ||
| 1 | +import Argv from '../../argv' | |
| 2 | +import type Types from '../../types' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * The checked-out branch's name, or `HEAD` when detached or unreadable. | |
| 6 | + * | |
| 7 | + * @param run runs git | |
| 8 | + * @returns the branch name | |
| 9 | + */ | |
| 10 | +export async function currentBranchOf(run: Types.GitRun): Promise<string> { | |
| 11 | + const { exitCode, stdout } = await run([ | |
| 12 | + Argv.NO_OPTIONAL_LOCKS, | |
| 13 | + 'rev-parse', | |
| 14 | + '--abbrev-ref', | |
| 15 | + 'HEAD', | |
| 16 | + ]) | |
| 17 | + const name = stdout.trim() | |
| 18 | + | |
| 19 | + return exitCode === 0 && name !== '' ? name : 'HEAD' | |
| 20 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './current-branch-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,37 @@ | ||
| 1 | +import type Types from '../../types' | |
| 2 | +import { stampIfFile } from '../stamp-if-file' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * When each path was last modified against the session's start, dating a | |
| 6 | + * path only when it is a real file under real directories at every level. | |
| 7 | + * | |
| 8 | + * Listings report an entry's own kind, so a symbolic link anywhere on the | |
| 9 | + * path is never listed, descended or stat'ed: session work (the engine | |
| 10 | + * lstats); a path the listing budget never reached is the caller's to place. | |
| 11 | + * | |
| 12 | + * @param probe the fetch's stamp probe over the working tree's top | |
| 13 | + * @param sessionStartMs when the session began | |
| 14 | + * @param paths repository-relative paths, `/`-separated as git prints them | |
| 15 | + * @returns each path's Dating | |
| 16 | + */ | |
| 17 | +export async function datingsOf( | |
| 18 | + probe: Types.StampProbe, | |
| 19 | + sessionStartMs: number, | |
| 20 | + paths: readonly string[], | |
| 21 | +): Promise<ReadonlyMap<string, Types.Dating>> { | |
| 22 | + const stamps: readonly Types.Stamp[] = await Promise.all( | |
| 23 | + paths.map(path => stampIfFile(probe, path)), | |
| 24 | + ) | |
| 25 | + | |
| 26 | + function datingOf(stamp: Types.Stamp): Types.Dating { | |
| 27 | + if (stamp === 'over-budget') { | |
| 28 | + return 'unlisted' | |
| 29 | + } | |
| 30 | + | |
| 31 | + const isEarlier = (stamp ?? Number.POSITIVE_INFINITY) < sessionStartMs | |
| 32 | + | |
| 33 | + return isEarlier ? 'pre-session' : 'session' | |
| 34 | + } | |
| 35 | + | |
| 36 | + return new Map(paths.map((path, at) => [path, datingOf(stamps[at] ?? null)])) | |
| 37 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './datings-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,45 @@ | ||
| 1 | +import Argv from '../../argv' | |
| 2 | +import type Types from '../../types' | |
| 3 | +import { CONVENTIONAL_DEFAULT_BRANCHES } from '../conventional-default-branches' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * The remote's default branch: what `origin/HEAD` names while that ref | |
| 7 | + * resolves, else the first conventional name with a remote ref, else main. | |
| 8 | + * | |
| 9 | + * The engine's own default-branch confidence ladder, over `$.process.run`. | |
| 10 | + * | |
| 11 | + * @param run runs git | |
| 12 | + * @returns the branch name, never empty | |
| 13 | + */ | |
| 14 | +export async function defaultBranchOf(run: Types.GitRun): Promise<string> { | |
| 15 | + const hasRemoteRef = async (branch: string): Promise<boolean> => | |
| 16 | + ( | |
| 17 | + await run([ | |
| 18 | + Argv.NO_OPTIONAL_LOCKS, | |
| 19 | + 'show-ref', | |
| 20 | + '--verify', | |
| 21 | + '--quiet', | |
| 22 | + `refs/remotes/origin/${branch}`, | |
| 23 | + ]) | |
| 24 | + ).exitCode === 0 | |
| 25 | + const symref = await run([ | |
| 26 | + Argv.NO_OPTIONAL_LOCKS, | |
| 27 | + 'symbolic-ref', | |
| 28 | + '--short', | |
| 29 | + 'refs/remotes/origin/HEAD', | |
| 30 | + ]) | |
| 31 | + const named = | |
| 32 | + symref.exitCode === 0 ? symref.stdout.trim().replace(/^origin\//, '') : '' | |
| 33 | + | |
| 34 | + if (named !== '' && (await hasRemoteRef(named))) { | |
| 35 | + return named | |
| 36 | + } | |
| 37 | + | |
| 38 | + for (const candidate of CONVENTIONAL_DEFAULT_BRANCHES) { | |
| 39 | + if (await hasRemoteRef(candidate)) { | |
| 40 | + return candidate | |
| 41 | + } | |
| 42 | + } | |
| 43 | + | |
| 44 | + return 'main' | |
| 45 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './default-branch-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,64 @@ | ||
| 1 | +import Limits from '../../../limits' | |
| 2 | +import Argv from '../../argv' | |
| 3 | +import type Types from '../../types' | |
| 4 | +import { isSafeRefName } from '../is-safe-ref-name' | |
| 5 | +import { stampIfFile } from '../stamp-if-file' | |
| 6 | +import { stampProbeOf } from '../stamp-probe-of' | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * A string that changes when HEAD moves: HEAD's text and the timestamps | |
| 10 | + * of HEAD, its ref file and packed-refs, each a listed real file. | |
| 11 | + * | |
| 12 | + * A HEAD that is not a plain file, or a `ref:` outside the safe refname | |
| 13 | + * grammar, is read through `git rev-parse` instead and nothing is stat'ed; | |
| 14 | + * every directory above a stat'ed file was listed as a real directory. | |
| 15 | + * | |
| 16 | + * @param deps git, listings, timestamps, a file read | |
| 17 | + * @param repository the repository's directories | |
| 18 | + * @returns the key for this tick | |
| 19 | + */ | |
| 20 | +export async function headKeyOf( | |
| 21 | + deps: Types.HeadKeyDeps, | |
| 22 | + repository: Types.Repository, | |
| 23 | +): Promise<string> { | |
| 24 | + const own = stampProbeOf( | |
| 25 | + deps, | |
| 26 | + repository.gitDir, | |
| 27 | + Limits.MAX_LISTED_DIRECTORIES, | |
| 28 | + ) | |
| 29 | + const isShared = repository.commonDir === repository.gitDir | |
| 30 | + const common = isShared | |
| 31 | + ? own | |
| 32 | + : stampProbeOf(deps, repository.commonDir, Limits.MAX_LISTED_DIRECTORIES) | |
| 33 | + | |
| 34 | + async function viaGit() { | |
| 35 | + const parsed = await deps.run([ | |
| 36 | + Argv.NO_OPTIONAL_LOCKS, | |
| 37 | + 'rev-parse', | |
| 38 | + '--verify', | |
| 39 | + '--quiet', | |
| 40 | + 'HEAD', | |
| 41 | + ]) | |
| 42 | + | |
| 43 | + return `rev:${parsed.exitCode === 0 ? parsed.stdout.trim() : ''}` | |
| 44 | + } | |
| 45 | + | |
| 46 | + if ((await own.walk.kindOf('HEAD')) !== 'file') { | |
| 47 | + return viaGit() | |
| 48 | + } | |
| 49 | + | |
| 50 | + const head = await deps.readFile(`${repository.gitDir}/HEAD`).catch(() => '') | |
| 51 | + const ref = head.startsWith('ref: ') ? head.slice('ref: '.length).trim() : '' | |
| 52 | + const isSymbolic = ref !== '' | |
| 53 | + | |
| 54 | + const isUnsafeRef = isSymbolic && !isSafeRefName(ref) | |
| 55 | + | |
| 56 | + return isUnsafeRef | |
| 57 | + ? viaGit() | |
| 58 | + : [ | |
| 59 | + head, | |
| 60 | + await stampIfFile(own, 'HEAD'), | |
| 61 | + isSymbolic ? await stampIfFile(common, ref) : null, | |
| 62 | + await stampIfFile(common, 'packed-refs'), | |
| 63 | + ].join('|') | |
| 64 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './head-key-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,27 @@ | ||
| 1 | +export * from './branch-base-of' | |
| 2 | +export * from './conventional-default-branches' | |
| 3 | +export * from './current-branch-of' | |
| 4 | +export * from './datings-of' | |
| 5 | +export * from './default-branch-of' | |
| 6 | +export * from './head-key-of' | |
| 7 | +export * from './is-absolute-path' | |
| 8 | +export * from './is-safe-ref-name' | |
| 9 | +export * from './is-sha' | |
| 10 | +export * from './is-transient.js' | |
| 11 | +export * from './is-unborn-head.js' | |
| 12 | +export * from './kind-walk-of' | |
| 13 | +export * from './merge-base-candidates-of' | |
| 14 | +export * from './missing-base-of' | |
| 15 | +export * from './overlay-unstaged.js' | |
| 16 | +export * from './repository-lines' | |
| 17 | +export * from './repository-of' | |
| 18 | +export * from './stamp-if-file' | |
| 19 | +export * from './stamp-probe-of' | |
| 20 | +export * from './stats-of.js' | |
| 21 | +export * from './tag-pre-session.js' | |
| 22 | +export * from './transient-state-files' | |
| 23 | +export * from './unstaged-numstat.js' | |
| 24 | +export * from './untracked-files' | |
| 25 | +export * from './untracked-place' | |
| 26 | + | |
| 27 | +export * as default from '.' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './is-absolute-path.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,9 @@ | ||
| 1 | +/** | |
| 2 | + * Whether a line `rev-parse --path-format=absolute` printed is an absolute | |
| 3 | + * path (POSIX or drive-letter), and so neither relative nor an echoed flag. | |
| 4 | + * | |
| 5 | + * @param line one line of rev-parse output | |
| 6 | + * @returns whether it names an absolute path | |
| 7 | + */ | |
| 8 | +export const isAbsolutePath = (line: string) => | |
| 9 | + line.startsWith('/') || /^[A-Za-z]:[\\/]/.test(line) |
| @@ -1,0 +1,4 @@ | ||
| 1 | +export * from './is-safe-ref-name.js' | |
| 2 | +export * from './safe-ref-pattern' | |
| 3 | + | |
| 4 | +export * as default from '.' |
| @@ -1,0 +1,15 @@ | ||
| 1 | +import { SAFE_REF_PATTERN } from './safe-ref-pattern' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Whether HEAD's `ref:` text names a ref the poll may stat: under `refs/`, | |
| 5 | + * plain characters, no `..`, no component ending `.lock`. | |
| 6 | + * | |
| 7 | + * Git's refname rules, narrowed; anything else is read through git. | |
| 8 | + * | |
| 9 | + * @param ref the text after `ref: ` | |
| 10 | + * @returns whether the ref is safe to join under the common directory | |
| 11 | + */ | |
| 12 | +export const isSafeRefName = (ref: string) => | |
| 13 | + SAFE_REF_PATTERN.test(ref) && | |
| 14 | + !ref.includes('..') && | |
| 15 | + !ref.split('/').some(part => part.endsWith('.lock')) |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './safe-ref-pattern.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The refnames the HEAD poll will touch on disk: under `refs/`, of plain | |
| 3 | + * name characters only, `/` between components. | |
| 4 | + */ | |
| 5 | +export const SAFE_REF_PATTERN = /^refs(?:\/[A-Za-z0-9_@+-][A-Za-z0-9._@+-]*)+$/ |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './is-sha.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,9 @@ | ||
| 1 | +/** | |
| 2 | + * Whether git printed an object name: 40 or 64 lowercase hex digits, the | |
| 3 | + * only shape a merge-base may take before it reaches a `git diff` argv. | |
| 4 | + * | |
| 5 | + * @param text one trimmed line of git output | |
| 6 | + * @returns whether it is a full object name | |
| 7 | + */ | |
| 8 | +export const isSha = (text: string) => | |
| 9 | + /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(text) |
| @@ -1,0 +1,22 @@ | ||
| 1 | +import type Types from '../types' | |
| 2 | +import { TRANSIENT_STATE_FILES } from './transient-state-files' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Whether the repository is mid-merge, mid-rebase, mid-cherry-pick or | |
| 6 | + * mid-revert, from one listing of the git directory. | |
| 7 | + * | |
| 8 | + * A state file counts only as a real file; a symbolic link by that name is | |
| 9 | + * never touched. | |
| 10 | + * | |
| 11 | + * @param entryKindsOf the host's directory listing | |
| 12 | + * @param gitDir the repository's own git directory, absolute | |
| 13 | + * @returns whether any of TRANSIENT_STATE_FILES is present as a file | |
| 14 | + */ | |
| 15 | +export async function isTransient( | |
| 16 | + entryKindsOf: Types.GitDeps['entryKindsOf'], | |
| 17 | + gitDir: string, | |
| 18 | +): Promise<boolean> { | |
| 19 | + const entries = await entryKindsOf(gitDir) | |
| 20 | + | |
| 21 | + return TRANSIENT_STATE_FILES.some(file => entries?.get(file) === 'file') | |
| 22 | +} |
| @@ -1,0 +1,24 @@ | ||
| 1 | +import Argv from '../argv' | |
| 2 | +import type Types from '../types' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Whether HEAD is unborn (a repository before its first commit): only a | |
| 6 | + * `rev-parse --verify HEAD` that ran and exited exactly 1 says so. | |
| 7 | + * | |
| 8 | + * A fatal repository error (128) or a killed probe proves nothing, as for | |
| 9 | + * the built-in panel's no-commits probe. | |
| 10 | + * | |
| 11 | + * @param run runs git | |
| 12 | + * @returns whether the probe exited 1 | |
| 13 | + */ | |
| 14 | +export async function isUnbornHead(run: Types.GitRun): Promise<boolean> { | |
| 15 | + const { exitCode } = await run([ | |
| 16 | + Argv.NO_OPTIONAL_LOCKS, | |
| 17 | + 'rev-parse', | |
| 18 | + '--verify', | |
| 19 | + '--quiet', | |
| 20 | + 'HEAD', | |
| 21 | + ]) | |
| 22 | + | |
| 23 | + return exitCode === 1 | |
| 24 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './kind-walk-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,70 @@ | ||
| 1 | +import type Types from '../../types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A KindWalk: each directory is listed once, only after its own entry | |
| 5 | + * read as `dir` in its parent, and only while listings remain. | |
| 6 | + * | |
| 7 | + * Read from the root down, one listing a component: the depth is the | |
| 8 | + * budget's, never the stack's. A symbolic link is never listed or descended | |
| 9 | + * and what lies under it reads as null; the unlisted reads `over-budget`. | |
| 10 | + * | |
| 11 | + * @param entryKindsOf the host's directory listing | |
| 12 | + * @param root the absolute directory the walk's paths are relative to | |
| 13 | + * @param budget how many directories this walk may list | |
| 14 | + * @returns the walk | |
| 15 | + */ | |
| 16 | +export function kindWalkOf( | |
| 17 | + entryKindsOf: Types.GitDeps['entryKindsOf'], | |
| 18 | + root: string, | |
| 19 | + budget: number, | |
| 20 | +): Types.KindWalk { | |
| 21 | + const listings = new Map< | |
| 22 | + string, | |
| 23 | + Promise<ReadonlyMap<string, Types.EntryKind> | null> | |
| 24 | + >() | |
| 25 | + | |
| 26 | + function listingOf(directory: string) { | |
| 27 | + const held = listings.get(directory) | |
| 28 | + const isOverBudget = held === undefined && listings.size >= budget | |
| 29 | + | |
| 30 | + if (isOverBudget) { | |
| 31 | + return 'over-budget' | |
| 32 | + } | |
| 33 | + | |
| 34 | + const listed = | |
| 35 | + held ?? entryKindsOf(directory === '' ? root : `${root}/${directory}`) | |
| 36 | + listings.set(directory, listed) | |
| 37 | + | |
| 38 | + return listed | |
| 39 | + } | |
| 40 | + | |
| 41 | + async function kindOf(path: string): Promise<Types.WalkKind> { | |
| 42 | + const names = path === '' ? [] : path.split('/') | |
| 43 | + let directory = '' | |
| 44 | + | |
| 45 | + for (const [depth, name] of names.entries()) { | |
| 46 | + const listing = listingOf(directory) | |
| 47 | + | |
| 48 | + if (listing === 'over-budget') { | |
| 49 | + return 'over-budget' | |
| 50 | + } | |
| 51 | + | |
| 52 | + const kind = (await listing)?.get(name) ?? null | |
| 53 | + const isLeaf = depth === names.length - 1 | |
| 54 | + | |
| 55 | + if (isLeaf) { | |
| 56 | + return kind | |
| 57 | + } | |
| 58 | + | |
| 59 | + if (kind !== 'dir') { | |
| 60 | + return null | |
| 61 | + } | |
| 62 | + | |
| 63 | + directory = directory === '' ? name : `${directory}/${name}` | |
| 64 | + } | |
| 65 | + | |
| 66 | + return 'dir' | |
| 67 | + } | |
| 68 | + | |
| 69 | + return { kindOf } | |
| 70 | +} |
| @@ -1,0 +1,4 @@ | ||
| 1 | +export * from './merge-base-candidates' | |
| 2 | +export * from './merge-base-candidates-of.js' | |
| 3 | + | |
| 4 | +export * as default from '.' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './merge-base-candidates.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,8 @@ | ||
| 1 | +/** | |
| 2 | + * What the two merge-base calls yielded: the object names printed, in | |
| 3 | + * call order, and whether either ran to a no-common-ancestor exit. | |
| 4 | + */ | |
| 5 | +export type MergeBaseCandidates = { | |
| 6 | + candidates: readonly string[] | |
| 7 | + hasNoCommonAncestor: boolean | |
| 8 | +} |
| @@ -1,0 +1,38 @@ | ||
| 1 | +import Argv from '../../argv' | |
| 2 | +import type Types from '../../types' | |
| 3 | +import { isSha } from '../is-sha' | |
| 4 | +import type { MergeBaseCandidates } from './merge-base-candidates' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * The merge-bases of HEAD with `origin/<default>` and with `<default>`, in | |
| 8 | + * that order, each present only when git printed an object name. | |
| 9 | + * | |
| 10 | + * Also whether any call ran and exited 1: no common ancestor, a stable | |
| 11 | + * state rather than a failure. | |
| 12 | + * | |
| 13 | + * @param run runs git | |
| 14 | + * @param defaultBranch the default branch's name | |
| 15 | + * @returns the candidate object names and the no-ancestor flag | |
| 16 | + */ | |
| 17 | +export async function mergeBaseCandidatesOf( | |
| 18 | + run: Types.GitRun, | |
| 19 | + defaultBranch: string, | |
| 20 | +): Promise<MergeBaseCandidates> { | |
| 21 | + const results = [ | |
| 22 | + await run([ | |
| 23 | + Argv.NO_OPTIONAL_LOCKS, | |
| 24 | + 'merge-base', | |
| 25 | + 'HEAD', | |
| 26 | + `origin/${defaultBranch}`, | |
| 27 | + ]), | |
| 28 | + await run([Argv.NO_OPTIONAL_LOCKS, 'merge-base', 'HEAD', defaultBranch]), | |
| 29 | + ] | |
| 30 | + | |
| 31 | + return { | |
| 32 | + candidates: results | |
| 33 | + .filter(result => result.exitCode === 0) | |
| 34 | + .map(result => result.stdout.trim()) | |
| 35 | + .filter(isSha), | |
| 36 | + hasNoCommonAncestor: results.some(result => result.exitCode === 1), | |
| 37 | + } | |
| 38 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './missing-base-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,38 @@ | ||
| 1 | +import Argv from '../../argv' | |
| 2 | +import type Types from '../../types' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * The verdict for a merge-base that produced nothing: `none` when neither | |
| 6 | + * ref of the default branch exists, else `error`. | |
| 7 | + * | |
| 8 | + * Only a probe that ran and exited non-zero is an authoritative miss; one | |
| 9 | + * that answered 0 or never ran means the merge-base failure may pass. | |
| 10 | + * | |
| 11 | + * @param run runs git | |
| 12 | + * @param defaultBranch the default branch's name | |
| 13 | + * @returns `none` or `error` | |
| 14 | + */ | |
| 15 | +export async function missingBaseOf( | |
| 16 | + run: Types.GitRun, | |
| 17 | + defaultBranch: string, | |
| 18 | +): Promise<Types.BranchBase> { | |
| 19 | + for (const ref of [ | |
| 20 | + `refs/remotes/origin/${defaultBranch}`, | |
| 21 | + `refs/heads/${defaultBranch}`, | |
| 22 | + ]) { | |
| 23 | + const { exitCode } = await run([ | |
| 24 | + Argv.NO_OPTIONAL_LOCKS, | |
| 25 | + 'show-ref', | |
| 26 | + '--verify', | |
| 27 | + '--quiet', | |
| 28 | + ref, | |
| 29 | + ]) | |
| 30 | + const isPresentOrUnknown = exitCode === 0 || exitCode === -1 | |
| 31 | + | |
| 32 | + if (isPresentOrUnknown) { | |
| 33 | + return { kind: 'error', reason: 'merge_base_failed' } | |
| 34 | + } | |
| 35 | + } | |
| 36 | + | |
| 37 | + return { kind: 'none' } | |
| 38 | +} |
| @@ -1,0 +1,41 @@ | ||
| 1 | +import type Types from '../types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The staged rows of an unborn HEAD with the working tree's unstaged edits | |
| 5 | + * folded in, as the built-in panel overlays them. | |
| 6 | + * | |
| 7 | + * Against the empty tree every surviving line is an addition: a path in | |
| 8 | + * both diffs shows added = staged + unstaged added − unstaged removed | |
| 9 | + * (never below 0), removed stays 0, and the added total moves with it. | |
| 10 | + * | |
| 11 | + * @param staged `git diff --cached --numstat`, capped | |
| 12 | + * @param unstaged plain `git diff --numstat`, uncapped | |
| 13 | + * @returns the staged result with corrected rows and totals | |
| 14 | + */ | |
| 15 | +export function overlayUnstaged( | |
| 16 | + staged: Types.NumstatResult, | |
| 17 | + unstaged: Types.NumstatResult, | |
| 18 | +): Types.NumstatResult { | |
| 19 | + const edits = new Map(unstaged.files.map(file => [file.path, file])) | |
| 20 | + const files = staged.files.map(file => { | |
| 21 | + const edit = edits.get(file.path) | |
| 22 | + | |
| 23 | + if (!edit) { | |
| 24 | + return file | |
| 25 | + } | |
| 26 | + | |
| 27 | + const isBinary = file.isBinary || edit.isBinary | |
| 28 | + const added = isBinary | |
| 29 | + ? 0 | |
| 30 | + : Math.max(0, file.added + edit.added - edit.removed) | |
| 31 | + | |
| 32 | + return { ...file, added, removed: 0, isBinary } | |
| 33 | + }) | |
| 34 | + const linesAdded = | |
| 35 | + staged.stats.linesAdded + | |
| 36 | + files.reduce((sum, file) => sum + file.added, 0) - | |
| 37 | + staged.files.reduce((sum, file) => sum + file.added, 0) | |
| 38 | + const stats = { ...staged.stats, linesAdded } | |
| 39 | + | |
| 40 | + return { stats, files } | |
| 41 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './repository-lines.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * How many lines the repository probe's `rev-parse` answers when git | |
| 3 | + * understood every option: top level, git directory, common directory. | |
| 4 | + */ | |
| 5 | +export const REPOSITORY_LINES = 3 |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './repository-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,36 @@ | ||
| 1 | +import Argv from '../../argv' | |
| 2 | +import type Types from '../../types' | |
| 3 | +import { isAbsolutePath } from '../is-absolute-path' | |
| 4 | +import { REPOSITORY_LINES } from '../repository-lines' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * The repository the session's directory is in, read with one `rev-parse`: | |
| 8 | + * working tree top, git directory, common directory, all absolute. | |
| 9 | + * | |
| 10 | + * Exactly three absolute lines or nothing: a git too old for | |
| 11 | + * `--path-format` echoes the flag as a line of its own, and such an answer | |
| 12 | + * pins no repository rather than a wrong one. | |
| 13 | + * | |
| 14 | + * @param run runs git in the session's directory, nothing pinned yet | |
| 15 | + * @returns the three absolute paths, or null outside a working tree | |
| 16 | + */ | |
| 17 | +export async function repositoryOf( | |
| 18 | + run: Types.GitRun, | |
| 19 | +): Promise<Types.Repository | null> { | |
| 20 | + const { exitCode, stdout } = await run([ | |
| 21 | + Argv.NO_OPTIONAL_LOCKS, | |
| 22 | + 'rev-parse', | |
| 23 | + '--path-format=absolute', | |
| 24 | + '--show-toplevel', | |
| 25 | + '--git-dir', | |
| 26 | + '--git-common-dir', | |
| 27 | + ]) | |
| 28 | + const lines = stdout.split('\n').filter(line => line !== '') | |
| 29 | + const [toplevel = '', gitDir = '', commonDir = ''] = lines | |
| 30 | + const isResolved = | |
| 31 | + exitCode === 0 && | |
| 32 | + lines.length === REPOSITORY_LINES && | |
| 33 | + lines.every(isAbsolutePath) | |
| 34 | + | |
| 35 | + return isResolved ? { toplevel, gitDir, commonDir } : null | |
| 36 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './stamp-if-file.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,29 @@ | ||
| 1 | +import type Types from '../../types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A path's modification time when the walk shows it as a real entry of the | |
| 5 | + * wanted kind under real directories; otherwise nothing is stat'ed. | |
| 6 | + * | |
| 7 | + * A path git printed with a trailing `/` (an untracked directory, a nested | |
| 8 | + * repository) names a directory and is dated as one. | |
| 9 | + * | |
| 10 | + * @param probe the walk over the base directory and the timestamp probe | |
| 11 | + * @param path the path relative to the walk's root | |
| 12 | + * @returns the timestamp, `over-budget`, or null | |
| 13 | + */ | |
| 14 | +export async function stampIfFile( | |
| 15 | + probe: Types.StampProbe, | |
| 16 | + path: string, | |
| 17 | +): Promise<Types.Stamp> { | |
| 18 | + const isDirectory = path.endsWith('/') | |
| 19 | + const entry = isDirectory ? path.slice(0, -1) : path | |
| 20 | + const kind = await probe.walk.kindOf(entry) | |
| 21 | + const isUnlisted = kind === 'over-budget' | |
| 22 | + const isWanted = kind === (isDirectory ? 'dir' : 'file') | |
| 23 | + | |
| 24 | + return isUnlisted | |
| 25 | + ? 'over-budget' | |
| 26 | + : isWanted | |
| 27 | + ? probe.mtimeOf(`${probe.base}/${entry}`) | |
| 28 | + : null | |
| 29 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './stamp-probe-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,21 @@ | ||
| 1 | +import type Types from '../../types' | |
| 2 | +import { kindWalkOf } from '../kind-walk-of' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * A StampProbe rooted at a directory: a fresh, bounded kind walk over it | |
| 6 | + * and the host's timestamp probe. | |
| 7 | + * | |
| 8 | + * @param deps lists a directory, reads a timestamp | |
| 9 | + * @param base the absolute directory the probe's paths are under | |
| 10 | + * @param budget how many directories the walk may list | |
| 11 | + * @returns the probe | |
| 12 | + */ | |
| 13 | +export const stampProbeOf = ( | |
| 14 | + deps: Pick<Types.GitDeps, 'entryKindsOf' | 'mtimeOf'>, | |
| 15 | + base: string, | |
| 16 | + budget: number, | |
| 17 | +): Types.StampProbe => ({ | |
| 18 | + walk: kindWalkOf(deps.entryKindsOf, base, budget), | |
| 19 | + base, | |
| 20 | + mtimeOf: deps.mtimeOf, | |
| 21 | +}) |
| @@ -1,0 +1,42 @@ | ||
| 1 | +import Limits from '../../limits' | |
| 2 | +import Argv from '../argv' | |
| 3 | +import GitParse from '../parse' | |
| 4 | +import type Types from '../types' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * The rows and totals of `git diff <base>`, or null when git could not | |
| 8 | + * say (as the built-in panel's stats fetch) or its listing arrived cut. | |
| 9 | + * | |
| 10 | + * A `--shortstat` probe runs first, so a diff past MAX_FILES_FOR_DETAILS | |
| 11 | + * answers its totals with no rows and no `--numstat` buffering. | |
| 12 | + * | |
| 13 | + * @param context runs git | |
| 14 | + * @param base the ref (or `--cached`) the working tree is compared with | |
| 15 | + * @returns the parsed result, or null on a failed numstat | |
| 16 | + */ | |
| 17 | +export async function statsOf( | |
| 18 | + context: Types.FetchContext, | |
| 19 | + base: string, | |
| 20 | +): Promise<Types.NumstatResult | null> { | |
| 21 | + const run = context.run | |
| 22 | + | |
| 23 | + const shortstat = await run([...Argv.DIFF_LEADING_ARGS, base, '--shortstat']) | |
| 24 | + const totals = | |
| 25 | + shortstat.exitCode === 0 ? GitParse.parseShortstat(shortstat.stdout) : null | |
| 26 | + const isPastDetails = | |
| 27 | + totals !== null && totals.filesCount > Limits.MAX_FILES_FOR_DETAILS | |
| 28 | + | |
| 29 | + if (isPastDetails) { | |
| 30 | + return { stats: totals, files: [] } | |
| 31 | + } | |
| 32 | + | |
| 33 | + const numstat = await run([ | |
| 34 | + ...Argv.DIFF_LEADING_ARGS, | |
| 35 | + base, | |
| 36 | + '--numstat', | |
| 37 | + '-z', | |
| 38 | + ]) | |
| 39 | + const isRead = GitParse.isWholeAnswer(numstat) | |
| 40 | + | |
| 41 | + return isRead ? GitParse.parseNumstat(numstat.stdout, Limits.MAX_FILES) : null | |
| 42 | +} |
| @@ -1,0 +1,29 @@ | ||
| 1 | +import type Types from '../types' | |
| 2 | +import { datingsOf } from './datings-of' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * The tracked rows, `isPreSession` set on each whose file was modified | |
| 6 | + * before the session began, by mtime alone as the built-in panel tags them. | |
| 7 | + * | |
| 8 | + * A file that cannot be dated (deleted this session, a symbolic link, past | |
| 9 | + * the listing budget) stays session work, so nothing real is hidden. | |
| 10 | + * | |
| 11 | + * @param context the fetch: its stamp probe and the session's start | |
| 12 | + * @param files the tracked rows | |
| 13 | + * @returns the rows, tagged | |
| 14 | + */ | |
| 15 | +export async function tagPreSession( | |
| 16 | + context: Pick<Types.FetchContext, 'deps' | 'stamps'>, | |
| 17 | + files: readonly Types.FileStat[], | |
| 18 | +): Promise<readonly Types.FileStat[]> { | |
| 19 | + const datings = await datingsOf( | |
| 20 | + context.stamps, | |
| 21 | + context.deps.sessionStartMs, | |
| 22 | + files.map(file => file.path), | |
| 23 | + ) | |
| 24 | + | |
| 25 | + return files.map(file => ({ | |
| 26 | + ...file, | |
| 27 | + isPreSession: datings.get(file.path) === 'pre-session', | |
| 28 | + })) | |
| 29 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './transient-state-files.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,10 @@ | ||
| 1 | +/** | |
| 2 | + * The git-directory files that mean a merge, rebase, cherry-pick or revert | |
| 3 | + * is under way: the working tree then holds incoming changes, unread. | |
| 4 | + */ | |
| 5 | +export const TRANSIENT_STATE_FILES = [ | |
| 6 | + 'MERGE_HEAD', | |
| 7 | + 'REBASE_HEAD', | |
| 8 | + 'CHERRY_PICK_HEAD', | |
| 9 | + 'REVERT_HEAD', | |
| 10 | +] as const |
| @@ -1,0 +1,23 @@ | ||
| 1 | +import Argv from '../argv' | |
| 2 | +import GitParse from '../parse' | |
| 3 | +import type Types from '../types' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Plain `git diff --numstat` (index against working tree), the one diff | |
| 7 | + * beside `--cached` that runs on an unborn HEAD; null on failure or a cut. | |
| 8 | + * | |
| 9 | + * Parsed uncapped, since its rows correct rows already kept. | |
| 10 | + * | |
| 11 | + * @param run runs git | |
| 12 | + * @returns every row, or null when git failed | |
| 13 | + */ | |
| 14 | +export async function unstagedNumstat( | |
| 15 | + run: Types.GitRun, | |
| 16 | +): Promise<Types.NumstatResult | null> { | |
| 17 | + const numstat = await run([...Argv.DIFF_LEADING_ARGS, '--numstat', '-z']) | |
| 18 | + const isRead = GitParse.isWholeAnswer(numstat) | |
| 19 | + | |
| 20 | + return isRead | |
| 21 | + ? GitParse.parseNumstat(numstat.stdout, Number.POSITIVE_INFINITY) | |
| 22 | + : null | |
| 23 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './untracked-files.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,73 @@ | ||
| 1 | +import Limits from '../../../limits' | |
| 2 | +import Argv from '../../argv' | |
| 3 | +import GitParse from '../../parse' | |
| 4 | +import type Types from '../../types' | |
| 5 | +import { datingsOf } from '../datings-of' | |
| 6 | +import type { UntrackedPlace } from '../untracked-place' | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * The untracked, unignored files as 0/0 rows: the session's own first, | |
| 10 | + * then, when the scope asks, the tagged pre-session ones, up to the slots. | |
| 11 | + * | |
| 12 | + * At most MAX_UNTRACKED_PROBES are dated; a file past that or past the | |
| 13 | + * listing budget reads as pre-session, an undatable one as the session's | |
| 14 | + * (the built-in panel's rule). A listing not given whole: no answer. | |
| 15 | + * | |
| 16 | + * @param context the fetch: its pinned git, stamp probe and session start | |
| 17 | + * @param place the free row slots and the scope | |
| 18 | + * @returns the rows to merge after the tracked ones, or null when the | |
| 19 | + * `-z` listing failed, timed out or was cut | |
| 20 | + */ | |
| 21 | +export async function untrackedFiles( | |
| 22 | + context: Pick<Types.FetchContext, 'run' | 'deps' | 'stamps'>, | |
| 23 | + place: UntrackedPlace, | |
| 24 | +): Promise<readonly Types.FileStat[] | null> { | |
| 25 | + if (place.slots <= 0) { | |
| 26 | + return [] | |
| 27 | + } | |
| 28 | + | |
| 29 | + const listing = await context.run([ | |
| 30 | + Argv.NO_OPTIONAL_LOCKS, | |
| 31 | + 'ls-files', | |
| 32 | + '-z', | |
| 33 | + '--others', | |
| 34 | + '--exclude-standard', | |
| 35 | + '--full-name', | |
| 36 | + ]) | |
| 37 | + | |
| 38 | + if (!GitParse.isWholeAnswer(listing)) { | |
| 39 | + return null | |
| 40 | + } | |
| 41 | + | |
| 42 | + const paths = listing.stdout.split('\0').filter(path => path !== '') | |
| 43 | + const probedPaths = paths.slice(0, Limits.MAX_UNTRACKED_PROBES) | |
| 44 | + const datings = await datingsOf( | |
| 45 | + context.stamps, | |
| 46 | + context.deps.sessionStartMs, | |
| 47 | + probedPaths, | |
| 48 | + ) | |
| 49 | + const probed = probedPaths.map(path => ({ | |
| 50 | + path, | |
| 51 | + isPreSession: datings.get(path) !== 'session', | |
| 52 | + })) | |
| 53 | + const isWithPreSession = place.scope === 'with-pre-session' | |
| 54 | + const earlier = isWithPreSession | |
| 55 | + ? [ | |
| 56 | + ...probed.filter(file => file.isPreSession), | |
| 57 | + ...paths | |
| 58 | + .slice(Limits.MAX_UNTRACKED_PROBES) | |
| 59 | + .map(path => ({ path, isPreSession: true })), | |
| 60 | + ] | |
| 61 | + : [] | |
| 62 | + const ordered = [...probed.filter(file => !file.isPreSession), ...earlier] | |
| 63 | + | |
| 64 | + return ordered.slice(0, place.slots).map(file => ({ | |
| 65 | + path: file.path, | |
| 66 | + renamedFrom: null, | |
| 67 | + added: 0, | |
| 68 | + removed: 0, | |
| 69 | + isBinary: false, | |
| 70 | + isUntracked: true, | |
| 71 | + isPreSession: file.isPreSession, | |
| 72 | + })) | |
| 73 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './untracked-place.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,10 @@ | ||
| 1 | +import type Types from '../../types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * How much untrackedFiles may keep: the row slots left under MAX_FILES, and | |
| 5 | + * which files count. | |
| 6 | + */ | |
| 7 | +export type UntrackedPlace = { | |
| 8 | + slots: number | |
| 9 | + scope: Types.UntrackedScope | |
| 10 | +} |
| @@ -1,0 +1,25 @@ | ||
| 1 | +import type Types from '../../types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * What branch mode's diff compares, given its resolved base: the merge | |
| 5 | + * base, HEAD under the default branch's label, or the plain working tree. | |
| 6 | + * | |
| 7 | + * @param base the resolved base | |
| 8 | + * @returns the source the rows are labelled with | |
| 9 | + */ | |
| 10 | +export function branchSourceOf( | |
| 11 | + base: Types.ResolvedBranchBase, | |
| 12 | +): Types.DiffSource { | |
| 13 | + switch (base.kind) { | |
| 14 | + case 'merge-base': | |
| 15 | + return { | |
| 16 | + kind: 'branch', | |
| 17 | + baseBranch: base.baseBranch, | |
| 18 | + baseRef: base.mergeBase, | |
| 19 | + } | |
| 20 | + case 'head-is-base': | |
| 21 | + return { kind: 'branch', baseBranch: base.baseBranch, baseRef: 'HEAD' } | |
| 22 | + case 'none': | |
| 23 | + return { kind: 'working-tree' } | |
| 24 | + } | |
| 25 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './branch-source-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,56 @@ | ||
| 1 | +import Probes from '../probes' | |
| 2 | +import type Types from '../types' | |
| 3 | +import { branchSourceOf } from './branch-source-of' | |
| 4 | +import { diffDataOf } from './diff-data-of' | |
| 5 | +import { unbornTier } from './unborn-tier' | |
| 6 | +import { withUntracked } from './with-untracked' | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Branch mode: everything that differs from the default branch, the merge | |
| 10 | + * base against the working tree, pinned, as the built-in panel diffs it. | |
| 11 | + * | |
| 12 | + * With HEAD at the base the diff is HEAD's under the vs-<default> label; | |
| 13 | + * with no base it is labelled a plain working-tree diff; a base that may | |
| 14 | + * resolve next time answers unavailable so the last branch view stays. | |
| 15 | + * | |
| 16 | + * @param context the fetch | |
| 17 | + * @returns the outcome | |
| 18 | + */ | |
| 19 | +export async function branchTier( | |
| 20 | + context: Types.FetchContext, | |
| 21 | +): Promise<Types.FetchOutcome> { | |
| 22 | + const { deps, run } = context | |
| 23 | + const base = await Probes.branchBaseOf(run) | |
| 24 | + deps.onBranchBase(base) | |
| 25 | + | |
| 26 | + if (base.kind === 'error') { | |
| 27 | + return unbornTier(context) | |
| 28 | + } | |
| 29 | + | |
| 30 | + const source = branchSourceOf(base) | |
| 31 | + const baseRef = source.kind === 'branch' ? source.baseRef : 'HEAD' | |
| 32 | + const diff = await Probes.statsOf(context, baseRef) | |
| 33 | + const isTransientFailure = diff === null && base.kind === 'merge-base' | |
| 34 | + | |
| 35 | + if (isTransientFailure) { | |
| 36 | + return { kind: 'unavailable' } | |
| 37 | + } | |
| 38 | + | |
| 39 | + if (!diff) { | |
| 40 | + return unbornTier(context) | |
| 41 | + } | |
| 42 | + | |
| 43 | + return { | |
| 44 | + kind: 'data', | |
| 45 | + data: diffDataOf( | |
| 46 | + context, | |
| 47 | + await withUntracked(context, diff, 'session-only'), | |
| 48 | + { | |
| 49 | + source, | |
| 50 | + baseRef, | |
| 51 | + isUnborn: false, | |
| 52 | + stalePaths: [], | |
| 53 | + }, | |
| 54 | + ), | |
| 55 | + } | |
| 56 | +} |
| @@ -1,0 +1,12 @@ | ||
| 1 | +import type Types from '../../../types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * What a tier decided about its diff besides the rows: what it compares, | |
| 5 | + * the ref the hunks read against, whether HEAD is unborn, stale bodies. | |
| 6 | + */ | |
| 7 | +export type DiffBasis = { | |
| 8 | + source: Types.DiffSource | |
| 9 | + baseRef: string | |
| 10 | + isUnborn: boolean | |
| 11 | + stalePaths: readonly string[] | |
| 12 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './diff-basis.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,27 @@ | ||
| 1 | +import type Types from '../../types' | |
| 2 | +import type { DiffBasis } from './diff-basis' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * A settled fetch: the tier's merged rows and totals over the repository | |
| 6 | + * and mode of the fetch, with the tier's basis. | |
| 7 | + * | |
| 8 | + * @param context the fetch | |
| 9 | + * @param result the rows and totals, untracked files merged or withheld | |
| 10 | + * @param basis what they compare and read against | |
| 11 | + * @returns the data an outcome carries | |
| 12 | + */ | |
| 13 | +export const diffDataOf = ( | |
| 14 | + context: Types.FetchContext, | |
| 15 | + result: Types.MergedResult, | |
| 16 | + basis: DiffBasis, | |
| 17 | +): Types.DiffData => ({ | |
| 18 | + repository: context.repository, | |
| 19 | + mode: context.mode, | |
| 20 | + stats: result.stats, | |
| 21 | + files: result.files, | |
| 22 | + source: basis.source, | |
| 23 | + baseRef: basis.baseRef, | |
| 24 | + isUnborn: basis.isUnborn, | |
| 25 | + stalePaths: basis.stalePaths, | |
| 26 | + isUntrackedWithheld: result.isUntrackedWithheld, | |
| 27 | +}) |
| @@ -1,0 +1,4 @@ | ||
| 1 | +export * from './diff-basis' | |
| 2 | +export * from './diff-data-of.js' | |
| 3 | + | |
| 4 | +export * as default from '.' |
| @@ -1,0 +1,26 @@ | ||
| 1 | +import Limits from '../../../limits' | |
| 2 | +import Probes from '../../probes' | |
| 3 | +import type Types from '../../types' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * The context every tier of a fetch shares: the host's pinned git, the | |
| 7 | + * repository, the mode, and one stamp probe over the working tree's top. | |
| 8 | + * | |
| 9 | + * @param deps the host | |
| 10 | + * @param mode the comparison asked for | |
| 11 | + * @returns the context | |
| 12 | + */ | |
| 13 | +export const fetchContextOf = ( | |
| 14 | + deps: Types.GitDeps, | |
| 15 | + mode: Types.BaseMode, | |
| 16 | +): Types.FetchContext => ({ | |
| 17 | + deps, | |
| 18 | + run: deps.run, | |
| 19 | + repository: deps.repository, | |
| 20 | + mode, | |
| 21 | + stamps: Probes.stampProbeOf( | |
| 22 | + deps, | |
| 23 | + deps.repository.toplevel, | |
| 24 | + Limits.MAX_LISTED_DIRECTORIES, | |
| 25 | + ), | |
| 26 | +}) |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './fetch-context-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,11 @@ | ||
| 1 | +export * from './branch-source-of' | |
| 2 | +export * from './branch-tier.js' | |
| 3 | +export * from './diff-data-of' | |
| 4 | +export * from './fetch-context-of' | |
| 5 | +export * from './is-past-details' | |
| 6 | +export * from './unborn-tier' | |
| 7 | +export * from './with-untracked' | |
| 8 | +export * from './working-tree-basis' | |
| 9 | +export * from './working-tree-tier.js' | |
| 10 | + | |
| 11 | +export * as default from '.' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './is-past-details.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,12 @@ | ||
| 1 | +import Limits from '../../../limits' | |
| 2 | +import type Types from '../../types' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Whether a diff has more files than get per-file rows: past | |
| 6 | + * MAX_FILES_FOR_DETAILS only its totals are kept. | |
| 7 | + * | |
| 8 | + * @param result the parsed diff | |
| 9 | + * @returns whether rows, hunks and the untracked probe are skipped | |
| 10 | + */ | |
| 11 | +export const isPastDetails = (result: Types.NumstatResult) => | |
| 12 | + result.stats.filesCount > Limits.MAX_FILES_FOR_DETAILS |
| @@ -1,0 +1,10 @@ | ||
| 1 | +import type Types from '../../../types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * No rows and zero totals: what an unborn HEAD shows when even | |
| 5 | + * `git diff --cached` could not be read. | |
| 6 | + */ | |
| 7 | +export const EMPTY_NUMSTAT: Types.NumstatResult = Object.freeze({ | |
| 8 | + stats: Object.freeze({ filesCount: 0, linesAdded: 0, linesRemoved: 0 }), | |
| 9 | + files: Object.freeze([]), | |
| 10 | +}) |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './empty-numstat.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,4 @@ | ||
| 1 | +export * from './empty-numstat' | |
| 2 | +export * from './unborn-tier.js' | |
| 3 | + | |
| 4 | +export * as default from '.' |
| @@ -1,0 +1,57 @@ | ||
| 1 | +import Probes from '../../probes' | |
| 2 | +import type Types from '../../types' | |
| 3 | +import { diffDataOf } from '../diff-data-of' | |
| 4 | +import { withUntracked } from '../with-untracked' | |
| 5 | +import { EMPTY_NUMSTAT } from './empty-numstat' | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * The one permanent cause of a failed vs-HEAD diff, told apart from the | |
| 9 | + * passing ones: an unborn HEAD, as the built-in panel tells it apart. | |
| 10 | + * | |
| 11 | + * Its rows are the staged files with unstaged edits folded in, then the | |
| 12 | + * session's untracked files; hunks read against `--cached`, a file edited | |
| 13 | + * after staging has no honest body; a withheld `--numstat`: no data. | |
| 14 | + * | |
| 15 | + * @param context the fetch | |
| 16 | + * @returns data marked unborn, or unavailable | |
| 17 | + */ | |
| 18 | +export async function unbornTier( | |
| 19 | + context: Types.FetchContext, | |
| 20 | +): Promise<Types.FetchOutcome> { | |
| 21 | + const { run } = context | |
| 22 | + | |
| 23 | + if (!(await Probes.isUnbornHead(run))) { | |
| 24 | + return { kind: 'unavailable' } | |
| 25 | + } | |
| 26 | + | |
| 27 | + const staged = await Probes.statsOf(context, '--cached') | |
| 28 | + | |
| 29 | + if (!staged) { | |
| 30 | + return { kind: 'unavailable' } | |
| 31 | + } | |
| 32 | + | |
| 33 | + const isOverlaid = staged.files.length > 0 | |
| 34 | + const unstaged = isOverlaid | |
| 35 | + ? await Probes.unstagedNumstat(run) | |
| 36 | + : EMPTY_NUMSTAT | |
| 37 | + | |
| 38 | + if (!unstaged) { | |
| 39 | + return { kind: 'unavailable' } | |
| 40 | + } | |
| 41 | + | |
| 42 | + const merged = await withUntracked( | |
| 43 | + context, | |
| 44 | + Probes.overlayUnstaged(staged, unstaged), | |
| 45 | + 'session-only', | |
| 46 | + ) | |
| 47 | + | |
| 48 | + return { | |
| 49 | + kind: 'data', | |
| 50 | + data: diffDataOf(context, merged, { | |
| 51 | + source: { kind: 'working-tree' }, | |
| 52 | + baseRef: '--cached', | |
| 53 | + isUnborn: true, | |
| 54 | + stalePaths: unstaged.files.map(file => file.path), | |
| 55 | + }), | |
| 56 | + } | |
| 57 | +} |
| @@ -1,0 +1,4 @@ | ||
| 1 | +export * from './merged-result-of' | |
| 2 | +export * from './with-untracked.js' | |
| 3 | + | |
| 4 | +export * as default from '.' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './merged-result-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,13 @@ | ||
| 1 | +import type Types from '../../../types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A tier's rows stamped with whether the untracked listing was withheld. | |
| 5 | + * | |
| 6 | + * @param result the rows and totals, untracked files merged in or not | |
| 7 | + * @param mark whether git did not give the listing whole | |
| 8 | + * @returns the merged result | |
| 9 | + */ | |
| 10 | +export const mergedResultOf = ( | |
| 11 | + result: Types.NumstatResult, | |
| 12 | + mark: Pick<Types.MergedResult, 'isUntrackedWithheld'>, | |
| 13 | +): Types.MergedResult => ({ ...result, ...mark }) |
| @@ -1,0 +1,47 @@ | ||
| 1 | +import Limits from '../../../limits' | |
| 2 | +import Probes from '../../probes' | |
| 3 | +import type Types from '../../types' | |
| 4 | +import { isPastDetails } from '../is-past-details' | |
| 5 | +import { mergedResultOf } from './merged-result-of' | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * A tier's rows with the untracked files merged after them, as the built-in | |
| 9 | + * panel merges the session's untracked files. | |
| 10 | + * | |
| 11 | + * A path a numstat row already holds keeps that row and counts once. Past | |
| 12 | + * MAX_FILES_FOR_DETAILS nothing is listed; a listing git withheld leaves | |
| 13 | + * the tracked rows as they are and says so, as the built-in degrades. | |
| 14 | + * | |
| 15 | + * @param context the fetch | |
| 16 | + * @param result the tier's tracked rows and totals | |
| 17 | + * @param scope which untracked files count | |
| 18 | + * @returns the merged rows and totals, and whether the listing was withheld | |
| 19 | + */ | |
| 20 | +export async function withUntracked( | |
| 21 | + context: Types.FetchContext, | |
| 22 | + result: Types.NumstatResult, | |
| 23 | + scope: Types.UntrackedScope, | |
| 24 | +): Promise<Types.MergedResult> { | |
| 25 | + if (isPastDetails(result)) { | |
| 26 | + return mergedResultOf(result, { isUntrackedWithheld: false }) | |
| 27 | + } | |
| 28 | + | |
| 29 | + const tracked = new Set(result.files.map(file => file.path)) | |
| 30 | + const untracked = await Probes.untrackedFiles(context, { | |
| 31 | + slots: Limits.MAX_FILES - result.files.length, | |
| 32 | + scope, | |
| 33 | + }) | |
| 34 | + | |
| 35 | + if (!untracked) { | |
| 36 | + return mergedResultOf(result, { isUntrackedWithheld: true }) | |
| 37 | + } | |
| 38 | + | |
| 39 | + const fresh = untracked.filter(file => !tracked.has(file.path)) | |
| 40 | + const filesCount = result.stats.filesCount + fresh.length | |
| 41 | + const stats = { ...result.stats, filesCount } | |
| 42 | + | |
| 43 | + return mergedResultOf( | |
| 44 | + { stats, files: [...result.files, ...fresh] }, | |
| 45 | + { isUntrackedWithheld: false }, | |
| 46 | + ) | |
| 47 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './working-tree-basis.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,12 @@ | ||
| 1 | +import type DiffDataOf from '../diff-data-of' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The basis of a plain working-tree diff: against HEAD, commits present, | |
| 5 | + * every body current. | |
| 6 | + */ | |
| 7 | +export const WORKING_TREE_BASIS: DiffDataOf.DiffBasis = Object.freeze({ | |
| 8 | + source: Object.freeze({ kind: 'working-tree' }), | |
| 9 | + baseRef: 'HEAD', | |
| 10 | + isUnborn: false, | |
| 11 | + stalePaths: Object.freeze([]), | |
| 12 | +}) |
| @@ -1,0 +1,43 @@ | ||
| 1 | +import Probes from '../probes' | |
| 2 | +import type Types from '../types' | |
| 3 | +import { diffDataOf } from './diff-data-of' | |
| 4 | +import { unbornTier } from './unborn-tier' | |
| 5 | +import { withUntracked } from './with-untracked' | |
| 6 | +import { WORKING_TREE_BASIS } from './working-tree-basis' | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Session and uncommitted mode: the working tree against HEAD, the built-in | |
| 10 | + * panel's working-tree tier. | |
| 11 | + * | |
| 12 | + * Session mode tags the rows that predate the session and keeps | |
| 13 | + * pre-session untracked files as tagged rows; uncommitted keeps the | |
| 14 | + * session's untracked files and answers even when empty. | |
| 15 | + * | |
| 16 | + * @param context the fetch | |
| 17 | + * @returns the outcome | |
| 18 | + */ | |
| 19 | +export async function workingTreeTier( | |
| 20 | + context: Types.FetchContext, | |
| 21 | +): Promise<Types.FetchOutcome> { | |
| 22 | + const workingTree = await Probes.statsOf(context, 'HEAD') | |
| 23 | + | |
| 24 | + if (!workingTree) { | |
| 25 | + return unbornTier(context) | |
| 26 | + } | |
| 27 | + | |
| 28 | + const isSession = context.mode === 'session' | |
| 29 | + const files = isSession | |
| 30 | + ? await Probes.tagPreSession(context, workingTree.files) | |
| 31 | + : workingTree.files | |
| 32 | + const data = diffDataOf( | |
| 33 | + context, | |
| 34 | + await withUntracked( | |
| 35 | + context, | |
| 36 | + { stats: workingTree.stats, files }, | |
| 37 | + isSession ? 'with-pre-session' : 'session-only', | |
| 38 | + ), | |
| 39 | + WORKING_TREE_BASIS, | |
| 40 | + ) | |
| 41 | + | |
| 42 | + return { kind: 'data', data } | |
| 43 | +} |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * What the diff compares the working tree against: HEAD partitioned by the | |
| 3 | + * session's start, HEAD plainly, or the merge-base with the default branch. | |
| 4 | + */ | |
| 5 | +export type BaseMode = 'session' | 'uncommitted' | 'branch' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './base-mode.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,9 @@ | ||
| 1 | +import type { ResolvedBranchBase } from '../resolved-branch-base' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * What branch mode diffs against, as the built-in panel names it: a | |
| 5 | + * resolved base, or a failure that may pass on the next fetch. | |
| 6 | + */ | |
| 7 | +export type BranchBase = | |
| 8 | + | ResolvedBranchBase | |
| 9 | + | { kind: 'error'; reason: 'merge_base_failed' | 'head_rev_parse_failed' } |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './branch-base.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,7 @@ | ||
| 1 | +/** | |
| 2 | + * When a path was last written, against the session: before it began, | |
| 3 | + * since, or never reached because the listing budget was spent. | |
| 4 | + * | |
| 5 | + * An undatable path (deleted, a symbolic link) reads as `session`. | |
| 6 | + */ | |
| 7 | +export type Dating = 'pre-session' | 'session' | 'unlisted' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './dating.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,25 @@ | ||
| 1 | +import type { BaseMode } from '../base-mode' | |
| 2 | +import type { DiffSource } from '../diff-source' | |
| 3 | +import type { DiffStats } from '../diff-stats' | |
| 4 | +import type { FileStat } from '../file-stat' | |
| 5 | +import type { Repository } from '../repository' | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * One settled fetch: the rows, the totals, what they compare, and what a | |
| 9 | + * file's hunks must be read against so both agree. | |
| 10 | + * | |
| 11 | + * `isUnborn`: a repository before its first commit (rows are the staged | |
| 12 | + * and new files, `baseRef` is `--cached`); `stalePaths`: files whose staged | |
| 13 | + * body is behind the working tree; `isUntrackedWithheld`: MergedResult's. | |
| 14 | + */ | |
| 15 | +export type DiffData = { | |
| 16 | + repository: Repository | |
| 17 | + mode: BaseMode | |
| 18 | + stats: DiffStats | |
| 19 | + files: readonly FileStat[] | |
| 20 | + source: DiffSource | |
| 21 | + isUnborn: boolean | |
| 22 | + baseRef: string | |
| 23 | + stalePaths: readonly string[] | |
| 24 | + isUntrackedWithheld: boolean | |
| 25 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './diff-data.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,7 @@ | ||
| 1 | +/** | |
| 2 | + * What a fetched diff compares: the working tree against HEAD, or against | |
| 3 | + * the merge-base with the default branch (`baseRef` the ref git was handed). | |
| 4 | + */ | |
| 5 | +export type DiffSource = | |
| 6 | + | { kind: 'working-tree' } | |
| 7 | + | { kind: 'branch'; baseBranch: string; baseRef: string } |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './diff-source.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,8 @@ | ||
| 1 | +/** | |
| 2 | + * A diff's totals as `--numstat` or `--shortstat` count them. | |
| 3 | + */ | |
| 4 | +export type DiffStats = { | |
| 5 | + filesCount: number | |
| 6 | + linesAdded: number | |
| 7 | + linesRemoved: number | |
| 8 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './diff-stats.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,7 @@ | ||
| 1 | +import type { FsEntry } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A directory entry's own kind as `$.fs.list` reports it, no link | |
| 5 | + * followed: `file`, `dir`, or `other` (a symbolic link is `other`). | |
| 6 | + */ | |
| 7 | +export type EntryKind = FsEntry['kind'] |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './entry-kind.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,20 @@ | ||
| 1 | +import type { BaseMode } from './base-mode' | |
| 2 | +import type { GitDeps } from './git-deps' | |
| 3 | +import type { GitRun } from './git-run' | |
| 4 | +import type { Repository } from './repository' | |
| 5 | +import type { StampProbe } from './stamp-probe' | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * What every tier of one fetch shares: the host, its pinned git, the | |
| 9 | + * repository, the mode asked for, and one stamp probe over the tree. | |
| 10 | + * | |
| 11 | + * One probe, so tracked and untracked dating draw on the same listings | |
| 12 | + * and one budget. | |
| 13 | + */ | |
| 14 | +export type FetchContext = { | |
| 15 | + deps: GitDeps | |
| 16 | + run: GitRun | |
| 17 | + repository: Repository | |
| 18 | + mode: BaseMode | |
| 19 | + stamps: StampProbe | |
| 20 | +} |
| @@ -1,0 +1,10 @@ | ||
| 1 | +import type { DiffData } from './diff-data' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * How one fetch ended: outside any repository, unreadable for now (a lock, a | |
| 5 | + * timeout, a rebase under way: the last good diff stays), or data. | |
| 6 | + */ | |
| 7 | +export type FetchOutcome = | |
| 8 | + | { kind: 'no-repository' } | |
| 9 | + | { kind: 'unavailable' } | |
| 10 | + | { kind: 'data'; data: DiffData } |
| @@ -1,0 +1,11 @@ | ||
| 1 | +import type { Hunk } from './hunk' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * One file's parsed diff body: its hunks, cut at MAX_LINES_PER_FILE | |
| 5 | + * (`isTruncated`), or none at all past MAX_DIFF_BYTES (`isLarge`). | |
| 6 | + */ | |
| 7 | +export type FileHunks = { | |
| 8 | + hunks: readonly Hunk[] | |
| 9 | + isTruncated: boolean | |
| 10 | + isLarge: boolean | |
| 11 | +} |
| @@ -1,0 +1,17 @@ | ||
| 1 | +/** | |
| 2 | + * One changed file's row: its repository-relative path, its line counts, | |
| 3 | + * and what kind of change it is. | |
| 4 | + * | |
| 5 | + * `isPreSession` is set in session mode alone, when the file's timestamp | |
| 6 | + * predates the session's start (an untracked file too); `renamedFrom` is | |
| 7 | + * the old path of a rename git detected, else null. | |
| 8 | + */ | |
| 9 | +export type FileStat = { | |
| 10 | + path: string | |
| 11 | + renamedFrom: string | null | |
| 12 | + added: number | |
| 13 | + removed: number | |
| 14 | + isBinary: boolean | |
| 15 | + isUntracked: boolean | |
| 16 | + isPreSession: boolean | |
| 17 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './file-stat.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,39 @@ | ||
| 1 | +import type { BranchBase } from '../branch-base' | |
| 2 | +import type { EntryKind } from '../entry-kind' | |
| 3 | +import type { GitRun } from '../git-run' | |
| 4 | +import type { Repository } from '../repository' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * What the fetch reaches the host through: pinned git, the repository, a | |
| 8 | + * directory listing, a timestamp probe, the start, a branch-base listener. | |
| 9 | + */ | |
| 10 | +export type GitDeps = { | |
| 11 | + /** | |
| 12 | + * Runs git against `repository`; never rejects (GitRun). | |
| 13 | + */ | |
| 14 | + run: GitRun | |
| 15 | + | |
| 16 | + /** | |
| 17 | + * Where the session started, resolved once and pinned on every child. | |
| 18 | + */ | |
| 19 | + repository: Repository | |
| 20 | + | |
| 21 | + /** | |
| 22 | + * A path's modification time in epoch milliseconds, or null when it | |
| 23 | + * cannot be read. Called only on a path a listing showed as a file. | |
| 24 | + */ | |
| 25 | + mtimeOf: (path: string) => Promise<number | null> | |
| 26 | + | |
| 27 | + /** | |
| 28 | + * A directory's entries by name with their own kinds (no link followed), | |
| 29 | + * or null when it cannot be listed. | |
| 30 | + */ | |
| 31 | + entryKindsOf: (dir: string) => Promise<ReadonlyMap<string, EntryKind> | null> | |
| 32 | + | |
| 33 | + sessionStartMs: number | |
| 34 | + | |
| 35 | + /** | |
| 36 | + * Told each time branch mode resolves its base, for telemetry. | |
| 37 | + */ | |
| 38 | + onBranchBase: (base: BranchBase) => void | |
| 39 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './git-deps.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,7 @@ | ||
| 1 | +import type { RunResult } from '../run-result' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Runs git with `argv` (no `git` in it) against the session's pinned | |
| 5 | + * repository and never rejects: a failure is a RunResult of exit code -1. | |
| 6 | + */ | |
| 7 | +export type GitRun = (argv: readonly string[]) => Promise<RunResult> |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './git-run.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,12 @@ | ||
| 1 | +import type { GitDeps } from '../git-deps' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * What reading the HEAD key takes: git for the fallback, listings and | |
| 5 | + * timestamps for the files, and a file read for HEAD's text. | |
| 6 | + */ | |
| 7 | +export type HeadKeyDeps = Pick<GitDeps, 'run' | 'mtimeOf' | 'entryKindsOf'> & { | |
| 8 | + /** | |
| 9 | + * A file's text; rejects when it cannot be read. | |
| 10 | + */ | |
| 11 | + readFile: (path: string) => Promise<string> | |
| 12 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './head-key-deps.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,9 @@ | ||
| 1 | +/** | |
| 2 | + * One hunk of a unified diff: where it starts on each side and its lines, | |
| 3 | + * each still carrying its `+`, `-` or space marker. | |
| 4 | + */ | |
| 5 | +export type Hunk = { | |
| 6 | + oldStart: number | |
| 7 | + newStart: number | |
| 8 | + lines: readonly string[] | |
| 9 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './hunk.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,26 @@ | ||
| 1 | +export * from './base-mode' | |
| 2 | +export * from './branch-base' | |
| 3 | +export * from './dating' | |
| 4 | +export * from './diff-data' | |
| 5 | +export * from './diff-source' | |
| 6 | +export * from './diff-stats' | |
| 7 | +export * from './entry-kind' | |
| 8 | +export type * from './fetch-context.js' | |
| 9 | +export type * from './fetch-outcome.js' | |
| 10 | +export type * from './file-hunks.js' | |
| 11 | +export * from './file-stat' | |
| 12 | +export * from './git-deps' | |
| 13 | +export * from './git-run' | |
| 14 | +export * from './head-key-deps' | |
| 15 | +export * from './hunk' | |
| 16 | +export * from './kind-walk' | |
| 17 | +export * from './merged-result' | |
| 18 | +export * from './repository' | |
| 19 | +export * from './resolved-branch-base' | |
| 20 | +export * from './run-result' | |
| 21 | +export * from './stamp' | |
| 22 | +export * from './stamp-probe' | |
| 23 | +export type * from './untracked-scope.js' | |
| 24 | +export * from './walk-kind' | |
| 25 | + | |
| 26 | +export * as default from '.' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './kind-walk.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,13 @@ | ||
| 1 | +import type { WalkKind } from '../walk-kind' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Entry kinds under one root, every ancestor checked: a path's kind is | |
| 5 | + * answered only when each directory above it is itself a real directory. | |
| 6 | + */ | |
| 7 | +export type KindWalk = { | |
| 8 | + /** | |
| 9 | + * Answers the entry's kind at a root-relative `/` path (`''`: the root); | |
| 10 | + * see WalkKind for the two ways it cannot say. | |
| 11 | + */ | |
| 12 | + kindOf: (path: string) => Promise<WalkKind> | |
| 13 | +} |
| @@ -1,0 +1,4 @@ | ||
| 1 | +export type * from './merged-result.js' | |
| 2 | +export * from './numstat-result' | |
| 3 | + | |
| 4 | +export * as default from '.' |
| @@ -1,0 +1,10 @@ | ||
| 1 | +import type { NumstatResult } from './numstat-result' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A tier's rows once the untracked files were merged in, and whether git | |
| 5 | + * withheld their listing (failed, timed out, cut). | |
| 6 | + * | |
| 7 | + * Withheld: the rows are the tracked ones alone, as the built-in degrades, | |
| 8 | + * and the pane says so. | |
| 9 | + */ | |
| 10 | +export type MergedResult = NumstatResult & { isUntrackedWithheld: boolean } |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './numstat-result.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,11 @@ | ||
| 1 | +import type { DiffStats } from '../../diff-stats' | |
| 2 | +import type { FileStat } from '../../file-stat' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * A parsed `--numstat`: the totals over every row, and the first MAX_FILES | |
| 6 | + * rows. | |
| 7 | + */ | |
| 8 | +export type NumstatResult = { | |
| 9 | + stats: DiffStats | |
| 10 | + files: readonly FileStat[] | |
| 11 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './repository.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,9 @@ | ||
| 1 | +/** | |
| 2 | + * Where the session's repository lives: its working tree's top, its own git | |
| 3 | + * directory (a linked worktree's), and the common directory holding refs. | |
| 4 | + */ | |
| 5 | +export type Repository = { | |
| 6 | + toplevel: string | |
| 7 | + gitDir: string | |
| 8 | + commonDir: string | |
| 9 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './resolved-branch-base.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,8 @@ | ||
| 1 | +/** | |
| 2 | + * What branch mode diffs against once it resolved: the merge-base with the | |
| 3 | + * default branch, HEAD itself when it is that base, or nothing defensible. | |
| 4 | + */ | |
| 5 | +export type ResolvedBranchBase = | |
| 6 | + | { kind: 'merge-base'; mergeBase: string; baseBranch: string } | |
| 7 | + | { kind: 'head-is-base'; baseBranch: string } | |
| 8 | + | { kind: 'none' } |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './run-result.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,9 @@ | ||
| 1 | +/** | |
| 2 | + * What one git child left: its exit code and both output streams; a child | |
| 3 | + * that never ran or was killed reads as exit code -1 with no output. | |
| 4 | + */ | |
| 5 | +export type RunResult = { | |
| 6 | + exitCode: number | |
| 7 | + stdout: string | |
| 8 | + stderr: string | |
| 9 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './stamp.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,7 @@ | ||
| 1 | +/** | |
| 2 | + * A file's date behind a kind walk: its modification time; null when it is | |
| 3 | + * no real file under real directories or cannot be read. | |
| 4 | + * | |
| 5 | + * `over-budget` when the walk's listings ran out before reaching it. | |
| 6 | + */ | |
| 7 | +export type Stamp = number | 'over-budget' | null |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './stamp-probe.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,12 @@ | ||
| 1 | +import type { GitDeps } from '../git-deps' | |
| 2 | +import type { KindWalk } from '../kind-walk' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * What stamping a file behind a kind walk takes: the walk, the absolute | |
| 6 | + * base its paths are under, and the timestamp probe. | |
| 7 | + */ | |
| 8 | +export type StampProbe = { | |
| 9 | + walk: KindWalk | |
| 10 | + base: string | |
| 11 | + mtimeOf: GitDeps['mtimeOf'] | |
| 12 | +} |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * Which untracked files a fetch keeps: the session's own, or those and the | |
| 3 | + * ones that predate it, tagged (session mode). | |
| 4 | + */ | |
| 5 | +export type UntrackedScope = 'session-only' | 'with-pre-session' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './walk-kind.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,9 @@ | ||
| 1 | +import type { EntryKind } from '../entry-kind' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * What a kind walk answers for a path: the entry's own kind; null when it | |
| 5 | + * is absent, an ancestor is no real directory, or a listing failed. | |
| 6 | + * | |
| 7 | + * `over-budget` when the walk had spent its listings before reaching it. | |
| 8 | + */ | |
| 9 | +export type WalkKind = EntryKind | 'over-budget' | null |
| @@ -1,0 +1,4 @@ | ||
| 1 | +{ | |
| 2 | + "description": "The diff pane: /diff and its Pane drawing, a refresh on Claude's edits, shell commands and finished turns, the pane's opening on the first edit, and the ask that rides a file's hunks on the next prompt", | |
| 3 | + "modules": ["./register.ts"] | |
| 4 | +} |
| @@ -1,0 +1,116 @@ | ||
| 1 | +import type { | |
| 2 | + CommandSpec, | |
| 3 | + EngineInterface, | |
| 4 | + FsEntry, | |
| 5 | + FsStat, | |
| 6 | + PaneCloseArgs, | |
| 7 | + PaneOpenArgs, | |
| 8 | + ProcessRunInit, | |
| 9 | + ProcessRunResult, | |
| 10 | + SessionMessage, | |
| 11 | + TimerCall, | |
| 12 | +} from 'claude-code' | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * The engine as `session.start` bound it from its `$`, each member spelled | |
| 16 | + * `$.noun.event(...)` there; used by every later hook, timer and press. | |
| 17 | + */ | |
| 18 | +export type Host = { | |
| 19 | + /** | |
| 20 | + * `$.clock.now`. | |
| 21 | + */ | |
| 22 | + now: () => number | |
| 23 | + | |
| 24 | + /** | |
| 25 | + * `$.clock.after`. | |
| 26 | + */ | |
| 27 | + after: TimerCall | |
| 28 | + | |
| 29 | + /** | |
| 30 | + * `$.clock.every`. | |
| 31 | + */ | |
| 32 | + every: TimerCall | |
| 33 | + | |
| 34 | + /** | |
| 35 | + * `$.clock.sleep`, no signal. | |
| 36 | + */ | |
| 37 | + sleep: (ms: number) => Promise<void> | |
| 38 | + | |
| 39 | + /** | |
| 40 | + * `$.process.run`. | |
| 41 | + */ | |
| 42 | + run: ( | |
| 43 | + argv: readonly string[], | |
| 44 | + init: ProcessRunInit, | |
| 45 | + ) => Promise<ProcessRunResult> | |
| 46 | + | |
| 47 | + /** | |
| 48 | + * `$.fs.stat`. | |
| 49 | + */ | |
| 50 | + stat: (path: string) => Promise<FsStat> | |
| 51 | + | |
| 52 | + /** | |
| 53 | + * `$.fs.list`: a directory's entries by kind, links never followed. | |
| 54 | + */ | |
| 55 | + listDir: (path: string) => Promise<FsEntry[]> | |
| 56 | + | |
| 57 | + /** | |
| 58 | + * `$.fs.read`. | |
| 59 | + */ | |
| 60 | + readFile: (path: string) => Promise<string> | |
| 61 | + | |
| 62 | + /** | |
| 63 | + * Reads the plugin's store (`$.store.get`). | |
| 64 | + */ | |
| 65 | + storeGet: (key: string) => Promise<unknown> | |
| 66 | + | |
| 67 | + /** | |
| 68 | + * Writes the plugin's store (`$.store.set`). | |
| 69 | + */ | |
| 70 | + storeSet: (key: string, value: unknown) => Promise<void> | |
| 71 | + | |
| 72 | + /** | |
| 73 | + * `$.session.messages`. | |
| 74 | + */ | |
| 75 | + messages: () => Promise<SessionMessage[]> | |
| 76 | + | |
| 77 | + /** | |
| 78 | + * `$.ui.invalidate("ui.render")`: every pane instance draws again. | |
| 79 | + */ | |
| 80 | + invalidate: () => void | |
| 81 | + | |
| 82 | + /** | |
| 83 | + * `$.ui.status`: the plugin's line under the prompt. | |
| 84 | + */ | |
| 85 | + status: (text: string | undefined) => void | |
| 86 | + | |
| 87 | + /** | |
| 88 | + * One debug line under the plugin's name (`$.ui.log`). | |
| 89 | + */ | |
| 90 | + uiLog: (text: string) => void | |
| 91 | + | |
| 92 | + /** | |
| 93 | + * `$.ui.open`. | |
| 94 | + */ | |
| 95 | + openPane: (pane: PaneOpenArgs) => Promise<void> | |
| 96 | + | |
| 97 | + /** | |
| 98 | + * `$.ui.close`. | |
| 99 | + */ | |
| 100 | + closePane: (pane: PaneCloseArgs) => Promise<void> | |
| 101 | + | |
| 102 | + /** | |
| 103 | + * `$.command.register`; rejects while another `/diff` is listed. | |
| 104 | + */ | |
| 105 | + registerCommand: (spec: CommandSpec) => Promise<unknown> | |
| 106 | + | |
| 107 | + /** | |
| 108 | + * `$.telemetry.mark`; rejects where the telemetry built-in is absent. | |
| 109 | + */ | |
| 110 | + mark: EngineInterface['telemetry']['mark'] | |
| 111 | + | |
| 112 | + /** | |
| 113 | + * `$.telemetry.log`; rejects where the telemetry built-in is absent. | |
| 114 | + */ | |
| 115 | + log: EngineInterface['telemetry']['log'] | |
| 116 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './host.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,25 @@ | ||
| 1 | +export * from './array-of' | |
| 2 | +export * from './ask' | |
| 3 | +export * from './classify' | |
| 4 | +export * from './command-spec' | |
| 5 | +export * from './count-of' | |
| 6 | +export * from './entry-kinds-of' | |
| 7 | +export * from './git' | |
| 8 | +export * from './host' | |
| 9 | +export * from './is-on-pane-surface' | |
| 10 | +export * from './is-record' | |
| 11 | +export * from './limits' | |
| 12 | +export * from './message-of' | |
| 13 | +export * from './mtime-of' | |
| 14 | +export * from './names' | |
| 15 | +export * from './pane-state' | |
| 16 | +export * from './pane-toggle' | |
| 17 | +export * from './record' | |
| 18 | +export * from './register.js' | |
| 19 | +export * from './todos' | |
| 20 | +export * from './tools' | |
| 21 | +export * from './turns' | |
| 22 | +export * from './views' | |
| 23 | +export * from './word-diff' | |
| 24 | + | |
| 25 | +export * as default from '.' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './is-on-pane-surface.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,12 @@ | ||
| 1 | +import type { RenderSurface } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Whether a render event comes from a surface that draws the pane: every | |
| 5 | + * one but the mobile app, which has no Select and is not terminal-wide. | |
| 6 | + * | |
| 7 | + * @param e the render event, or anything naming its surface | |
| 8 | + * @returns false on `mobile`, true otherwise, narrowing `e` to the rest | |
| 9 | + */ | |
| 10 | +export const isOnPaneSurface = <E extends Record<'surface', RenderSurface>>( | |
| 11 | + e: E, | |
| 12 | +): e is Exclude<E, Record<'surface', 'mobile'>> => e.surface !== 'mobile' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './is-record.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,8 @@ | ||
| 1 | +/** | |
| 2 | + * Whether a value is a plain object that can be read field by field. | |
| 3 | + * | |
| 4 | + * @param value anything a tool result may be | |
| 5 | + * @returns whether it is a non-null, non-array object | |
| 6 | + */ | |
| 7 | +export const isRecord = (value: unknown): value is Record<string, unknown> => | |
| 8 | + typeof value === 'object' && value !== null && !Array.isArray(value) |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The terminal width from which the first edit opens the pane when the person | |
| 3 | + * never said whether they want it (the built-in sidebar's own 144). | |
| 4 | + */ | |
| 5 | +export const AUTO_OPEN_MIN_COLUMNS = 144 |
| @@ -1,0 +1,5 @@ | ||
| 1 | +export * from './auto-open-min-columns.js' | |
| 2 | +export * from './open-min-columns.js' | |
| 3 | +export * from './widest-bucket-columns.js' | |
| 4 | + | |
| 5 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The terminal width from which the first edit opens the pane when the person | |
| 3 | + * kept it open before (the built-in sidebar's own minimum). | |
| 4 | + */ | |
| 5 | +export const OPEN_MIN_COLUMNS = 110 |
| @@ -1,0 +1,4 @@ | ||
| 1 | +/** | |
| 2 | + * The terminal width from which `diff_pane_shown` reports `200_plus`. | |
| 3 | + */ | |
| 4 | +export const WIDEST_BUCKET_COLUMNS = 200 |
| @@ -1,0 +1,5 @@ | ||
| 1 | +export * from './columns' | |
| 2 | +export * from './sizes' | |
| 3 | +export * from './timing' | |
| 4 | + | |
| 5 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The most of a child's stdout `$.process.run` hands back, in bytes | |
| 3 | + * (process-host OUTPUT_LIMIT); a listing that long was cut, silently. | |
| 4 | + */ | |
| 5 | +export const HOST_OUTPUT_CAP_BYTES = 4 * 1024 * 1024 |
| @@ -1,0 +1,9 @@ | ||
| 1 | +export * from './host-output-cap-bytes.js' | |
| 2 | +export * from './max-diff-bytes.js' | |
| 3 | +export * from './max-files.js' | |
| 4 | +export * from './max-files-for-details.js' | |
| 5 | +export * from './max-lines-per-file.js' | |
| 6 | +export * from './max-listed-directories.js' | |
| 7 | +export * from './max-untracked-probes.js' | |
| 8 | + | |
| 9 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * A file whose unified diff text is longer than this is a "large file": its | |
| 3 | + * hunks are not parsed (the built-in panel's size cap). | |
| 4 | + */ | |
| 5 | +export const MAX_DIFF_BYTES = 1_000_000 |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * Past this many changed files the shortstat totals alone are kept: no | |
| 3 | + * per-file rows, no hunks, no untracked probe (the built-in panel's cap). | |
| 4 | + */ | |
| 5 | +export const MAX_FILES_FOR_DETAILS = 500 |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * How many per-file rows a diff keeps (the built-in panel's cap); the rest | |
| 3 | + * are counted as "not shown". | |
| 4 | + */ | |
| 5 | +export const MAX_FILES = 50 |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * How many diff lines of one file are kept before the body is cut and the | |
| 3 | + * file reads as truncated (GitHub's auto-load limit, as the built-in's). | |
| 4 | + */ | |
| 5 | +export const MAX_LINES_PER_FILE = 400 |
| @@ -1,0 +1,9 @@ | ||
| 1 | +/** | |
| 2 | + * How many directories one fetch may list while dating its files; past it | |
| 3 | + * an untracked file reads as pre-session, a tracked one as session work. | |
| 4 | + * | |
| 5 | + * A walk lists only the distinct ancestors of the paths it dates (MAX_FILES | |
| 6 | + * tracked, MAX_UNTRACKED_PROBES untracked), so the budget in effect is the | |
| 7 | + * dated set's own directory count; this caps a pathological spread. | |
| 8 | + */ | |
| 9 | +export const MAX_LISTED_DIRECTORIES = 512 |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * How many untracked paths get a timestamp probe per fetch; the rest read as | |
| 3 | + * pre-session, since only MAX_FILES rows could ever show. | |
| 4 | + */ | |
| 5 | +export const MAX_UNTRACKED_PROBES = 500 |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * How long one git child may run before the fetch gives up on it and keeps | |
| 3 | + * the last good diff (the built-in panel's own timeout). | |
| 4 | + */ | |
| 5 | +export const GIT_TIMEOUT_MS = 5000 |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * How often `.git/HEAD` and the checked-out ref are probed for a checkout or | |
| 3 | + * commit made outside the session. | |
| 4 | + */ | |
| 5 | +export const HEAD_POLL_MS = 2000 |
| @@ -1,0 +1,7 @@ | ||
| 1 | +export * from './git-timeout-ms.js' | |
| 2 | +export * from './head-poll-ms.js' | |
| 3 | +export * from './open-probe-ms.js' | |
| 4 | +export * from './redraw-coalesce-ms.js' | |
| 5 | +export * from './refresh-debounce-ms.js' | |
| 6 | + | |
| 7 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * How long `/diff` waits for the pane to draw after an invalidate before it | |
| 3 | + * takes the pane as closed by the person and opens it again. | |
| 4 | + */ | |
| 5 | +export const OPEN_PROBE_MS = 120 |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * How long state changes gather before one `$.ui.invalidate`, keeping the | |
| 3 | + * pane under the engine's ten redraws a second. | |
| 4 | + */ | |
| 5 | +export const REDRAW_COALESCE_MS = 100 |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The trailing-edge wait between a trigger (an edit, a shell command, a turn | |
| 3 | + * step) and the refetch it causes, so a burst of edits fetches once. | |
| 4 | + */ | |
| 5 | +export const REFRESH_DEBOUNCE_MS = 150 |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './message-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,9 @@ | ||
| 1 | +/** | |
| 2 | + * A thrown or rejected value as the words it carries: an Error's message, | |
| 3 | + * anything else as a string. | |
| 4 | + * | |
| 5 | + * @param error what was caught | |
| 6 | + * @returns the message | |
| 7 | + */ | |
| 8 | +export const messageOf = (error: unknown) => | |
| 9 | + error instanceof Error ? error.message : String(error) |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './mtime-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,16 @@ | ||
| 1 | +import type { Host } from '../host' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A path's modification time through the host, or null when the path | |
| 5 | + * cannot be read (GitDeps `mtimeOf`). | |
| 6 | + * | |
| 7 | + * @param engine the bound host | |
| 8 | + * @returns the probe | |
| 9 | + */ | |
| 10 | +export const mtimeOf = | |
| 11 | + (engine: Host) => | |
| 12 | + (path: string): Promise<number | null> => | |
| 13 | + engine.stat(path).then( | |
| 14 | + stat => stat.mtimeMs, | |
| 15 | + () => null, | |
| 16 | + ) |
| @@ -1,0 +1,6 @@ | ||
| 1 | +/** | |
| 2 | + * What the typeahead and `/help` show for `/diff` (the built-in's fullscreen | |
| 3 | + * wording: the pane is the panel's successor). | |
| 4 | + */ | |
| 5 | +export const COMMAND_DESCRIPTION = | |
| 6 | + 'Toggle the diff panel showing uncommitted changes' |
| @@ -1,0 +1,4 @@ | ||
| 1 | +/** | |
| 2 | + * The slash command the plugin serves once the built-in `/diff` stands down. | |
| 3 | + */ | |
| 4 | +export const COMMAND_NAME = 'diff' |
| @@ -1,0 +1,9 @@ | ||
| 1 | +import type { PaneOpenArgs } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The part of `$.ui.open`'s argument that asks the surface for the | |
| 5 | + * person's keyboard: what `/diff` adds and the first-edit open leaves out. | |
| 6 | + */ | |
| 7 | +export const FOCUSED_PANE: Pick<PaneOpenArgs, 'focus'> = Object.freeze({ | |
| 8 | + focus: true, | |
| 9 | +}) |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './focused-pane.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,9 @@ | ||
| 1 | +export * from './command-description.js' | |
| 2 | +export * from './command-name.js' | |
| 3 | +export * from './focused-pane' | |
| 4 | +export * from './keys' | |
| 5 | +export * from './pane-id.js' | |
| 6 | +export * from './pane-title.js' | |
| 7 | +export * from './texts' | |
| 8 | + | |
| 9 | +export * as default from '.' |
| @@ -1,0 +1,8 @@ | ||
| 1 | +/** | |
| 2 | + * The `$.store` key of a working tree's chosen comparison base, one per | |
| 3 | + * `--show-toplevel` so a linked worktree keeps its own. | |
| 4 | + * | |
| 5 | + * @param toplevel the working tree's top-level path | |
| 6 | + * @returns the key | |
| 7 | + */ | |
| 8 | +export const baseStoreKeyOf = (toplevel: string) => `base:${toplevel}` |
| @@ -1,0 +1,4 @@ | ||
| 1 | +export * from './base-store-key-of.js' | |
| 2 | +export * from './store-open-key.js' | |
| 3 | + | |
| 4 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The `$.store` key holding whether the person keeps the pane open (`/diff` | |
| 3 | + * writes it; the first edit reads it): the built-in's `diffSidebarOpen`. | |
| 4 | + */ | |
| 5 | +export const STORE_OPEN_KEY = 'open' |
| @@ -1,0 +1,4 @@ | ||
| 1 | +/** | |
| 2 | + * The one pane the plugin opens; its `requestId` at `ui.render`. | |
| 3 | + */ | |
| 4 | +export const PANE_ID = 'diff' |
| @@ -1,0 +1,4 @@ | ||
| 1 | +/** | |
| 2 | + * The pane header's text. | |
| 3 | + */ | |
| 4 | +export const PANE_TITLE = 'Diff' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The one `$.command.register` refusal meaning the built-in `/diff` serves | |
| 3 | + * this session, as the engine words it: the plugin stands down silently. | |
| 4 | + */ | |
| 5 | +export const BUILTIN_HOLDS_PATTERN = /refused: it is the built-in \// |
| @@ -1,0 +1,6 @@ | ||
| 1 | +export * from './builtin-holds-pattern.js' | |
| 2 | +export * from './not-in-repository-text.js' | |
| 3 | +export * from './register-failed-text-of.js' | |
| 4 | +export * from './untracked-withheld-text.js' | |
| 5 | + | |
| 6 | +export * as default from '.' |
| @@ -1,0 +1,6 @@ | ||
| 1 | +/** | |
| 2 | + * `/diff`'s answer outside a git repository, worded as the built-in words it. | |
| 3 | + */ | |
| 4 | +export const NOT_IN_REPOSITORY_TEXT = | |
| 5 | + 'The diff panel shows git changes — the current directory isn’t in a ' + | |
| 6 | + 'git repository' |
| @@ -1,0 +1,10 @@ | ||
| 1 | +/** | |
| 2 | + * The transcript line when `/diff` could not be registered for a reason | |
| 3 | + * other than the built-in holding it: the person learns the pane is off. | |
| 4 | + * | |
| 5 | + * @param reason the refusal's message | |
| 6 | + * @returns the line | |
| 7 | + */ | |
| 8 | +export const registerFailedTextOf = (reason: string) => | |
| 9 | + `could not register /diff: ${reason}; the diff panel is unavailable this ` + | |
| 10 | + `session` |
| @@ -1,0 +1,6 @@ | ||
| 1 | +/** | |
| 2 | + * The pane's word for a fetch whose untracked listing git did not give | |
| 3 | + * whole: the tracked rows stand, the new files are not counted. | |
| 4 | + */ | |
| 5 | +export const UNTRACKED_WITHHELD_TEXT = | |
| 6 | + 'Untracked files unavailable (git could not list them); not counted' |
| @@ -1,0 +1,29 @@ | ||
| 1 | +import Todos from '../../todos' | |
| 2 | +import Turns from '../../turns' | |
| 3 | +import type { PaneModel } from '../pane-model' | |
| 4 | +import type { Fetched } from './fetched' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * The model once a refresh has settled: the outcome's data (kept as it was | |
| 8 | + * when the fetch was unavailable), the transcript's turns and todos. | |
| 9 | + * | |
| 10 | + * @param model the model before the refresh | |
| 11 | + * @param fetched what the refresh read | |
| 12 | + * @returns the settled model | |
| 13 | + */ | |
| 14 | +export function afterFetch(model: PaneModel, fetched: Fetched): PaneModel { | |
| 15 | + const { outcome, messages } = fetched | |
| 16 | + const isData = outcome.kind === 'data' | |
| 17 | + const isKept = outcome.kind === 'unavailable' | |
| 18 | + const data = isData ? outcome.data : isKept ? model.data : null | |
| 19 | + | |
| 20 | + return { | |
| 21 | + ...model, | |
| 22 | + isLoading: false, | |
| 23 | + hasSettled: true, | |
| 24 | + isOutsideRepository: outcome.kind === 'no-repository', | |
| 25 | + data, | |
| 26 | + turns: Turns.turnDiffsOf(messages), | |
| 27 | + todos: Todos.todoProgressOf(messages), | |
| 28 | + } | |
| 29 | +} |
| @@ -1,0 +1,11 @@ | ||
| 1 | +import type { SessionMessage } from 'claude-code' | |
| 2 | + | |
| 3 | +import type Git from '../../../git' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * What one refresh read: how the diff fetch ended and the transcript rows. | |
| 7 | + */ | |
| 8 | +export type Fetched = { | |
| 9 | + outcome: Git.FetchOutcome | |
| 10 | + messages: readonly SessionMessage[] | |
| 11 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './fetched.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,4 @@ | ||
| 1 | +export * from './after-fetch.js' | |
| 2 | +export * from './fetched' | |
| 3 | + | |
| 4 | +export * as default from '.' |
| @@ -1,0 +1,14 @@ | ||
| 1 | +import type { PaneModel } from '../pane-model' | |
| 2 | +import { NEW_SESSION_FIELDS } from './new-session-fields' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * The model after `/clear` or `/resume`: the fetch kept, every pick and | |
| 6 | + * the transcript's turns dropped. | |
| 7 | + * | |
| 8 | + * @param model the model before the switch | |
| 9 | + * @returns the reset model | |
| 10 | + */ | |
| 11 | +export const afterNewSession = (model: PaneModel): PaneModel => ({ | |
| 12 | + ...model, | |
| 13 | + ...NEW_SESSION_FIELDS, | |
| 14 | +}) |
| @@ -1,0 +1,4 @@ | ||
| 1 | +export * from './after-new-session.js' | |
| 2 | +export * from './new-session-fields' | |
| 3 | + | |
| 4 | +export * as default from '.' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './new-session-fields.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,11 @@ | ||
| 1 | +/** | |
| 2 | + * What `/clear` and `/resume` reset: the pick, the source, the turns and | |
| 3 | + * the body; the fetch itself stays. | |
| 4 | + */ | |
| 5 | +export const NEW_SESSION_FIELDS = Object.freeze({ | |
| 6 | + selectedPath: null, | |
| 7 | + source: Object.freeze({ kind: 'current' }), | |
| 8 | + turns: Object.freeze([]), | |
| 9 | + body: null, | |
| 10 | + bodyState: 'idle', | |
| 11 | +} as const) |
| @@ -1,0 +1,43 @@ | ||
| 1 | +import type Git from '../git' | |
| 2 | +import { modeLabelOf } from './mode-label-of' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * The dim line under the header naming what the diff compares against | |
| 6 | + * (ReplDiffSidebar diffBaseLabel), or null for none. | |
| 7 | + * | |
| 8 | + * Hidden in settled session mode and before any fetch; the requested mode | |
| 9 | + * with an ellipsis while its fetch is pending; on an unborn HEAD with | |
| 10 | + * rows, what the rows are instead. | |
| 11 | + * | |
| 12 | + * @param requested the mode the person picked | |
| 13 | + * @param data the last good fetch | |
| 14 | + * @param filesCount the header's session file count | |
| 15 | + * @returns the line, or null | |
| 16 | + */ | |
| 17 | +export function baseLabelOf( | |
| 18 | + requested: Git.BaseMode, | |
| 19 | + data: Git.DiffData | null, | |
| 20 | + filesCount: number, | |
| 21 | +): string | null { | |
| 22 | + if (!data) { | |
| 23 | + return null | |
| 24 | + } | |
| 25 | + | |
| 26 | + if (data.isUnborn) { | |
| 27 | + const hasRows = filesCount > 0 | |
| 28 | + | |
| 29 | + return hasRows ? 'no commits yet — showing staged and new files' : null | |
| 30 | + } | |
| 31 | + | |
| 32 | + const isPending = requested !== data.mode | |
| 33 | + const isSettledSession = !isPending && requested === 'session' | |
| 34 | + | |
| 35 | + if (isSettledSession) { | |
| 36 | + return null | |
| 37 | + } | |
| 38 | + | |
| 39 | + const phase = isPending ? 'pending' : 'settled' | |
| 40 | + const label = modeLabelOf(requested, data.source, phase) | |
| 41 | + | |
| 42 | + return isPending ? `${label}…` : label | |
| 43 | +} |
| @@ -1,0 +1,14 @@ | ||
| 1 | +import type Git from '../../git' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A stored or picked value as a base mode, or null when it names none. | |
| 5 | + * | |
| 6 | + * @param value what `$.store.get` returned or a Select carried | |
| 7 | + * @returns the mode, or null | |
| 8 | + */ | |
| 9 | +export function baseModeOf(value: unknown): Git.BaseMode | null { | |
| 10 | + const isMode = | |
| 11 | + value === 'session' || value === 'uncommitted' || value === 'branch' | |
| 12 | + | |
| 13 | + return isMode ? value : null | |
| 14 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './base-mode-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * Where the selected file's body stands: nothing selected, being read, git | |
| 3 | + * could not read it, or in hand. | |
| 4 | + */ | |
| 5 | +export type BodyState = 'idle' | 'loading' | 'failed' | 'ready' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './body-state.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,8 @@ | ||
| 1 | +/** | |
| 2 | + * What the header says instead of a count when there is nothing to list, | |
| 3 | + * and the body's hint under it when the hint adds something. | |
| 4 | + */ | |
| 5 | +export type EmptyState = { | |
| 6 | + headline: string | |
| 7 | + hint: string | null | |
| 8 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './empty-state.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,64 @@ | ||
| 1 | +import type Git from '../git' | |
| 2 | +import Names from '../names' | |
| 3 | +import type { EmptyState } from './empty-state' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * The headline that replaces the count when nothing is listed, worded as | |
| 7 | + * ReplDiffSidebar words it, keyed on the mode the data was FETCHED in. | |
| 8 | + * | |
| 9 | + * Null while there are files to count. No data after a settled fetch is | |
| 10 | + * "Diff unavailable"; no tracked rows while the untracked listing was | |
| 11 | + * withheld claims nothing about new files. | |
| 12 | + * | |
| 13 | + * @param data the last good fetch, or null when none ever settled | |
| 14 | + * @param filesCount the header's session file count | |
| 15 | + * @returns the empty state, or null | |
| 16 | + */ | |
| 17 | +export function emptyStateOf( | |
| 18 | + data: Git.DiffData | null, | |
| 19 | + filesCount: number, | |
| 20 | +): EmptyState | null { | |
| 21 | + if (!data) { | |
| 22 | + return { | |
| 23 | + headline: 'Diff unavailable', | |
| 24 | + hint: "Couldn't read the git diff — it will retry on the next change", | |
| 25 | + } | |
| 26 | + } | |
| 27 | + | |
| 28 | + if (filesCount > 0) { | |
| 29 | + return null | |
| 30 | + } | |
| 31 | + | |
| 32 | + if (data.isUntrackedWithheld) { | |
| 33 | + return { | |
| 34 | + headline: 'No tracked changes', | |
| 35 | + hint: Names.UNTRACKED_WITHHELD_TEXT, | |
| 36 | + } | |
| 37 | + } | |
| 38 | + | |
| 39 | + if (data.isUnborn) { | |
| 40 | + return { | |
| 41 | + headline: 'No commits yet', | |
| 42 | + hint: "Nothing to diff against until the repo's first commit", | |
| 43 | + } | |
| 44 | + } | |
| 45 | + | |
| 46 | + switch (data.mode) { | |
| 47 | + case 'uncommitted': | |
| 48 | + return { headline: 'No uncommitted changes', hint: null } | |
| 49 | + case 'branch': | |
| 50 | + if (data.source.kind === 'branch') { | |
| 51 | + return { | |
| 52 | + headline: `No changes vs ${data.source.baseBranch}`, | |
| 53 | + hint: null, | |
| 54 | + } | |
| 55 | + } | |
| 56 | + | |
| 57 | + return { | |
| 58 | + headline: 'No changes vs HEAD', | |
| 59 | + hint: 'No base branch to compare against — showing changes vs HEAD', | |
| 60 | + } | |
| 61 | + case 'session': | |
| 62 | + return { headline: 'No changes this session', hint: null } | |
| 63 | + } | |
| 64 | +} |
| @@ -1,0 +1,10 @@ | ||
| 1 | +/** | |
| 2 | + * The header's counts: the fetched totals less the pre-session rows, and | |
| 3 | + * how many session files fell past the row cap ("not shown"). | |
| 4 | + */ | |
| 5 | +export type HeaderTotals = { | |
| 6 | + filesCount: number | |
| 7 | + linesAdded: number | |
| 8 | + linesRemoved: number | |
| 9 | + notShown: number | |
| 10 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './header-totals.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,32 @@ | ||
| 1 | +import type Git from '../git' | |
| 2 | +import type { HeaderTotals } from './header-totals' | |
| 3 | +import type { Partition } from './partition' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * The header's counts (ReplDiffSidebar): the fetched totals less the | |
| 7 | + * pre-session rows, and how many session files fell past the row cap. | |
| 8 | + * | |
| 9 | + * Files past the cap were never dated, so they always count and always | |
| 10 | + * show as `notShown`: an edit that sorts after fifty older ones stays seen. | |
| 11 | + * | |
| 12 | + * @param data the fetch | |
| 13 | + * @param partition its rows, grouped | |
| 14 | + * @returns the counts and how many session files are past the cap | |
| 15 | + */ | |
| 16 | +export function headerTotalsOf( | |
| 17 | + data: Git.DiffData, | |
| 18 | + partition: Partition, | |
| 19 | +): HeaderTotals { | |
| 20 | + const { stats, files } = data | |
| 21 | + const before = partition.preSession | |
| 22 | + const filesCount = stats.filesCount - before.length | |
| 23 | + | |
| 24 | + return { | |
| 25 | + filesCount, | |
| 26 | + linesAdded: | |
| 27 | + stats.linesAdded - before.reduce((sum, file) => sum + file.added, 0), | |
| 28 | + linesRemoved: | |
| 29 | + stats.linesRemoved - before.reduce((sum, file) => sum + file.removed, 0), | |
| 30 | + notShown: Math.max(0, filesCount - (files.length - before.length)), | |
| 31 | + } | |
| 32 | +} |
| @@ -1,0 +1,22 @@ | ||
| 1 | +export * from './after-fetch' | |
| 2 | +export * from './after-new-session' | |
| 3 | +export * from './base-label-of.js' | |
| 4 | +export * from './base-mode-of' | |
| 5 | +export * from './body-state' | |
| 6 | +export * from './empty-state' | |
| 7 | +export * from './empty-state-of.js' | |
| 8 | +export * from './header-totals' | |
| 9 | +export * from './header-totals-of.js' | |
| 10 | +export * from './initial-model' | |
| 11 | +export * from './listed-of.js' | |
| 12 | +export * from './mode-label-of' | |
| 13 | +export * from './pane-model' | |
| 14 | +export * from './partition' | |
| 15 | +export * from './partition-of.js' | |
| 16 | +export * from './picked-turn-of' | |
| 17 | +export * from './selection-of' | |
| 18 | +export * from './source' | |
| 19 | +export * from './turn-totals-of' | |
| 20 | +export * from './zero-totals' | |
| 21 | + | |
| 22 | +export * as default from '.' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './initial-model.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,23 @@ | ||
| 1 | +import type { PaneModel } from '../pane-model' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The pane before anything happened: session mode, nothing fetched, | |
| 5 | + * nothing picked, the current source. | |
| 6 | + */ | |
| 7 | +export const INITIAL_MODEL: PaneModel = Object.freeze({ | |
| 8 | + isLoading: false, | |
| 9 | + hasSettled: false, | |
| 10 | + isOutsideRepository: false, | |
| 11 | + data: null, | |
| 12 | + requestedMode: 'session', | |
| 13 | + selectedPath: null, | |
| 14 | + isNoiseShown: false, | |
| 15 | + isPreSessionShown: false, | |
| 16 | + source: Object.freeze({ kind: 'current' }), | |
| 17 | + turns: Object.freeze([]), | |
| 18 | + body: null, | |
| 19 | + bodyState: 'idle', | |
| 20 | + todos: Object.freeze({ done: 0, total: 0 }), | |
| 21 | + armedPath: null, | |
| 22 | + isFocused: false, | |
| 23 | +}) |
| @@ -1,0 +1,21 @@ | ||
| 1 | +import type Git from '../git' | |
| 2 | +import type { Partition } from './partition' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * The rows a person can pick from right now: the session's shown rows, | |
| 6 | + * then the pre-session rows while that section is open. | |
| 7 | + * | |
| 8 | + * @param partition the fetch's rows, grouped | |
| 9 | + * @param preSession whether the pre-session section is open | |
| 10 | + * @returns the pickable rows in drawing order | |
| 11 | + */ | |
| 12 | +export function listedOf( | |
| 13 | + partition: Partition, | |
| 14 | + preSession: 'shown' | 'hidden', | |
| 15 | +): readonly Git.FileStat[] { | |
| 16 | + const isOpen = preSession === 'shown' | |
| 17 | + | |
| 18 | + return isOpen | |
| 19 | + ? [...partition.shown, ...partition.preSession] | |
| 20 | + : partition.shown | |
| 21 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './mode-label-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,29 @@ | ||
| 1 | +import type Git from '../../git' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A requested mode's name for the base line; branch mode names its base | |
| 5 | + * branch from the data, or says what stands in while none is known. | |
| 6 | + * | |
| 7 | + * @param requested the mode the person picked | |
| 8 | + * @param source what the data on screen compares | |
| 9 | + * @param phase `pending` while the requested mode's fetch has not landed | |
| 10 | + * @returns the label without its pending ellipsis | |
| 11 | + */ | |
| 12 | +export function modeLabelOf( | |
| 13 | + requested: Git.BaseMode, | |
| 14 | + source: Git.DiffSource, | |
| 15 | + phase: 'pending' | 'settled', | |
| 16 | +) { | |
| 17 | + switch (requested) { | |
| 18 | + case 'session': | |
| 19 | + return 'this session' | |
| 20 | + case 'uncommitted': | |
| 21 | + return 'uncommitted (vs HEAD)' | |
| 22 | + case 'branch': | |
| 23 | + if (source.kind === 'branch') { | |
| 24 | + return `branch vs ${source.baseBranch}` | |
| 25 | + } | |
| 26 | + | |
| 27 | + return phase === 'pending' ? 'branch diff' : 'vs HEAD (no base branch)' | |
| 28 | + } | |
| 29 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './pane-model.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,27 @@ | ||
| 1 | +import type Git from '../../git' | |
| 2 | +import type Todos from '../../todos' | |
| 3 | +import type Turns from '../../turns' | |
| 4 | +import type { BodyState } from '../body-state' | |
| 5 | +import type { Source } from '../source' | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Everything one drawing of the pane reads: the last good fetch, what the | |
| 9 | + * person picked, the selected file's body, the transcript's turns and todos. | |
| 10 | + */ | |
| 11 | +export type PaneModel = { | |
| 12 | + isLoading: boolean | |
| 13 | + hasSettled: boolean | |
| 14 | + isOutsideRepository: boolean | |
| 15 | + data: Git.DiffData | null | |
| 16 | + requestedMode: Git.BaseMode | |
| 17 | + selectedPath: string | null | |
| 18 | + isNoiseShown: boolean | |
| 19 | + isPreSessionShown: boolean | |
| 20 | + source: Source | |
| 21 | + turns: readonly Turns.TurnDiff[] | |
| 22 | + body: Git.FileHunks | null | |
| 23 | + bodyState: BodyState | |
| 24 | + todos: Todos.TodoProgress | |
| 25 | + armedPath: string | null | |
| 26 | + isFocused: boolean | |
| 27 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './partition.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,11 @@ | ||
| 1 | +import type Git from '../../git' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A fetch's rows as the pane groups them: the session's own that are | |
| 5 | + * listed, how many tests and generated files there are, the pre-session. | |
| 6 | + */ | |
| 7 | +export type Partition = { | |
| 8 | + shown: readonly Git.FileStat[] | |
| 9 | + preSession: readonly Git.FileStat[] | |
| 10 | + noiseCount: number | |
| 11 | +} |
| @@ -1,0 +1,28 @@ | ||
| 1 | +import Classify from '../classify' | |
| 2 | +import type Git from '../git' | |
| 3 | +import type { Partition } from './partition' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * A fetch's rows split the way ReplDiffSidebar splits them: pre-session | |
| 7 | + * rows apart, tests and generated files counted and hidden unless shown. | |
| 8 | + * | |
| 9 | + * No read-deny bucket: the pane feeds nothing to the model on its own. | |
| 10 | + * | |
| 11 | + * @param files the fetched rows | |
| 12 | + * @param noise `shown` keeps tests and generated files in the list | |
| 13 | + * @returns the groups | |
| 14 | + */ | |
| 15 | +export function partitionOf( | |
| 16 | + files: readonly Git.FileStat[], | |
| 17 | + noise: 'shown' | 'hidden', | |
| 18 | +): Partition { | |
| 19 | + const preSession = files.filter(file => file.isPreSession) | |
| 20 | + const session = files.filter(file => !file.isPreSession) | |
| 21 | + const quiet = session.filter(file => !Classify.isNoiseFile(file.path)) | |
| 22 | + | |
| 23 | + return { | |
| 24 | + shown: noise === 'shown' ? session : quiet, | |
| 25 | + preSession, | |
| 26 | + noiseCount: session.length - quiet.length, | |
| 27 | + } | |
| 28 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './picked-turn-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,16 @@ | ||
| 1 | +import type Turns from '../../turns' | |
| 2 | +import type { PaneModel } from '../pane-model' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * The turn the pane's source names while the transcript still holds it; | |
| 6 | + * undefined for the current source or a turn a rewind dropped. | |
| 7 | + * | |
| 8 | + * @param model the pane's state | |
| 9 | + * @returns the picked turn, or undefined | |
| 10 | + */ | |
| 11 | +export function pickedTurnOf(model: PaneModel): Turns.TurnDiff | undefined { | |
| 12 | + const { source } = model | |
| 13 | + const wanted = source.kind === 'turn' ? source.index : null | |
| 14 | + | |
| 15 | + return model.turns.find(candidate => candidate.index === wanted) | |
| 16 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './selection-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,16 @@ | ||
| 1 | +import type Git from '../../git' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The file whose body the pane shows: the one the person picked while it is | |
| 5 | + * still listed, else the first listed one, else none. | |
| 6 | + * | |
| 7 | + * @param listed the rows on screen, in order (session rows, then the | |
| 8 | + * pre-session rows while that section is open) | |
| 9 | + * @param selectedPath the person's pick, or null | |
| 10 | + * @returns the selected row, or null for an empty list | |
| 11 | + */ | |
| 12 | +export const selectionOf = ( | |
| 13 | + listed: readonly Git.FileStat[], | |
| 14 | + selectedPath: string | null, | |
| 15 | +): Git.FileStat | null => | |
| 16 | + listed.find(file => file.path === selectedPath) ?? listed[0] ?? null |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './source.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,4 @@ | ||
| 1 | +/** | |
| 2 | + * What the pane lists: the repository's diff now, or one past turn's edits. | |
| 3 | + */ | |
| 4 | +export type Source = { kind: 'current' } | { kind: 'turn'; index: number } |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './turn-totals-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,16 @@ | ||
| 1 | +import type Turns from '../../turns' | |
| 2 | +import type { HeaderTotals } from '../header-totals' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * A turn's header counts: its files and their summed lines; nothing falls | |
| 6 | + * past a cap. | |
| 7 | + * | |
| 8 | + * @param turn the turn | |
| 9 | + * @returns the counts | |
| 10 | + */ | |
| 11 | +export const turnTotalsOf = (turn: Turns.TurnDiff): HeaderTotals => ({ | |
| 12 | + filesCount: turn.files.length, | |
| 13 | + linesAdded: turn.files.reduce((sum, file) => sum + file.added, 0), | |
| 14 | + linesRemoved: turn.files.reduce((sum, file) => sum + file.removed, 0), | |
| 15 | + notShown: 0, | |
| 16 | +}) |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './zero-totals.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,12 @@ | ||
| 1 | +import type { HeaderTotals } from '../header-totals' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The header's counts when no fetch has settled into data: nothing | |
| 5 | + * changed, nothing past the cap. | |
| 6 | + */ | |
| 7 | +export const ZERO_TOTALS: HeaderTotals = Object.freeze({ | |
| 8 | + filesCount: 0, | |
| 9 | + linesAdded: 0, | |
| 10 | + linesRemoved: 0, | |
| 11 | + notShown: 0, | |
| 12 | +}) |
| @@ -1,0 +1,4 @@ | ||
| 1 | +export * from './pane-belief' | |
| 2 | +export * from './pane-toggle-of.js' | |
| 3 | + | |
| 4 | +export * as default from '.' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './pane-belief.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,8 @@ | ||
| 1 | +/** | |
| 2 | + * What the plugin knows of its pane when `/diff` runs: whether it opened | |
| 3 | + * it and never closed it, and whether it drew when a redraw was asked. | |
| 4 | + */ | |
| 5 | +export type PaneBelief = { | |
| 6 | + isBelievedOpen: boolean | |
| 7 | + wasDrawnWhenProbed: boolean | |
| 8 | +} |
| @@ -1,0 +1,14 @@ | ||
| 1 | +import type { PaneBelief } from './pane-belief' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * What `/diff` does, the one place its toggle is decided: close only when | |
| 5 | + * the plugin opened the pane and it still draws; otherwise open, focused. | |
| 6 | + * | |
| 7 | + * The person's `[x]` raises nothing a plugin sees, so a pane believed | |
| 8 | + * open is probed with a redraw first. | |
| 9 | + * | |
| 10 | + * @param pane the plugin's belief and the probe's answer | |
| 11 | + * @returns `close` or `open` | |
| 12 | + */ | |
| 13 | +export const paneToggleOf = (pane: PaneBelief): 'open' | 'close' => | |
| 14 | + pane.isBelievedOpen && pane.wasDrawnWhenProbed ? 'close' : 'open' |
| @@ -1,0 +1,13 @@ | ||
| 1 | +/** | |
| 2 | + * The feature names the plugin marks, kept as the built-in panel's so the | |
| 3 | + * feature surface keeps one series across the swap. | |
| 4 | + * | |
| 5 | + * `read` per fetch round, `tabSwitch` per open or close, `baseSwitch` per | |
| 6 | + * base pick, `baseResolve` once a session. | |
| 7 | + */ | |
| 8 | +export const FEATURES = { | |
| 9 | + read: 'repl_diff_read', | |
| 10 | + tabSwitch: 'repl_tab_switch', | |
| 11 | + baseSwitch: 'repl_diff_base_switch', | |
| 12 | + baseResolve: 'diff_base_resolve', | |
| 13 | +} as const |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './features.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,10 @@ | ||
| 1 | +export * from './features' | |
| 2 | +export * from './mark-outcome' | |
| 3 | +export * from './recorder' | |
| 4 | +export * from './recorder-of.js' | |
| 5 | +export * from './safely' | |
| 6 | +export * from './shown-triggers' | |
| 7 | +export * from './width-bucket-of' | |
| 8 | +export * from './width-buckets' | |
| 9 | + | |
| 10 | +export * as default from '.' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './mark-outcome.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * How a marked feature went: fine, or degraded for a reason (the pane never | |
| 3 | + * marks `bad`: a failed fetch keeps the last good diff on screen). | |
| 4 | + */ | |
| 5 | +export type MarkOutcome = { kind: 'ok' } | { kind: 'sad'; reason: string } |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './recorder.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,27 @@ | ||
| 1 | +import type { MarkOutcome } from '../mark-outcome' | |
| 2 | +import type { SHOWN_TRIGGERS } from '../shown-triggers' | |
| 3 | +import type { WIDTH_BUCKETS } from '../width-buckets' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * The plugin's telemetry as its hooks call it; every method swallows a | |
| 7 | + * missing `$.telemetry` and a refused row, so recording never disturbs. | |
| 8 | + */ | |
| 9 | +export type Recorder = { | |
| 10 | + /** | |
| 11 | + * One `tengu_feature_<kind>` row. | |
| 12 | + */ | |
| 13 | + mark: (feature: string, outcome: MarkOutcome) => void | |
| 14 | + | |
| 15 | + /** | |
| 16 | + * `tengu_plugin_diff_pane_shown`, once a session. | |
| 17 | + */ | |
| 18 | + shown: ( | |
| 19 | + trigger: (typeof SHOWN_TRIGGERS)[number], | |
| 20 | + bucket: (typeof WIDTH_BUCKETS)[number], | |
| 21 | + ) => void | |
| 22 | + | |
| 23 | + /** | |
| 24 | + * `tengu_plugin_diff_ask_attach`: a file's diff rode the next prompt. | |
| 25 | + */ | |
| 26 | + asked: (lines: number) => void | |
| 27 | +} |
| @@ -1,0 +1,28 @@ | ||
| 1 | +import type { Host } from '../host' | |
| 2 | +import type { Recorder } from './recorder' | |
| 3 | +import { safely } from './safely' | |
| 4 | +import { SHOWN_TRIGGERS } from './shown-triggers' | |
| 5 | +import { WIDTH_BUCKETS } from './width-buckets' | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * The plugin's Recorder over the host: each row goes through the telemetry | |
| 9 | + * built-in's `$.telemetry`; a missing noun or a refused row is dropped. | |
| 10 | + * | |
| 11 | + * @param host the engine as session.start bound it | |
| 12 | + * @returns the recorder | |
| 13 | + */ | |
| 14 | +export const recorderOf = (host: Host): Recorder => ({ | |
| 15 | + mark: (feature, outcome) => safely(() => host.mark({ feature, ...outcome })), | |
| 16 | + shown: (trigger, bucket) => | |
| 17 | + safely(() => | |
| 18 | + host.log({ | |
| 19 | + event: 'diff_pane_shown', | |
| 20 | + props: { | |
| 21 | + trigger: { value: trigger, of: SHOWN_TRIGGERS }, | |
| 22 | + width_bucket: { value: bucket, of: WIDTH_BUCKETS }, | |
| 23 | + }, | |
| 24 | + }), | |
| 25 | + ), | |
| 26 | + asked: lines => | |
| 27 | + safely(() => host.log({ event: 'diff_ask_attach', props: { lines } })), | |
| 28 | +}) |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './safely.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,11 @@ | ||
| 1 | +/** | |
| 2 | + * Sends one telemetry row and forgets it: a missing `$.telemetry` (no | |
| 3 | + * telemetry built-in) throws synchronously, a refused row rejects; both drop. | |
| 4 | + * | |
| 5 | + * @param send posts the row | |
| 6 | + */ | |
| 7 | +export function safely(send: () => Promise<void>) { | |
| 8 | + try { | |
| 9 | + void send().catch(() => undefined) | |
| 10 | + } catch {} | |
| 11 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './shown-triggers.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * What opened the pane the first time it showed this session: Claude's | |
| 3 | + * first edit, or the person's `/diff`. | |
| 4 | + */ | |
| 5 | +export const SHOWN_TRIGGERS = ['auto_open', 'manual'] as const |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './width-bucket-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,21 @@ | ||
| 1 | +import Limits from '../../limits' | |
| 2 | +import { WIDTH_BUCKETS } from '../width-buckets' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * The bucket a terminal width falls in, the built-in panel's own buckets. | |
| 6 | + * | |
| 7 | + * @param columns the terminal's width, or null when no render told it yet | |
| 8 | + * @returns one of WIDTH_BUCKETS | |
| 9 | + */ | |
| 10 | +export function widthBucketOf( | |
| 11 | + columns: number | null, | |
| 12 | +): (typeof WIDTH_BUCKETS)[number] { | |
| 13 | + const [narrow, medium, wide, widest] = WIDTH_BUCKETS | |
| 14 | + const width = columns ?? 0 | |
| 15 | + | |
| 16 | + const isNarrow = width < Limits.OPEN_MIN_COLUMNS | |
| 17 | + const isMedium = width < Limits.AUTO_OPEN_MIN_COLUMNS | |
| 18 | + const isWide = width < Limits.WIDEST_BUCKET_COLUMNS | |
| 19 | + | |
| 20 | + return isNarrow ? narrow : isMedium ? medium : isWide ? wide : widest | |
| 21 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './width-buckets.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,10 @@ | ||
| 1 | +/** | |
| 2 | + * The terminal-width buckets `diff_pane_shown` reports, the built-in's | |
| 3 | + * `tengu_repl_diff_panel_shown` buckets. | |
| 4 | + */ | |
| 5 | +export const WIDTH_BUCKETS = [ | |
| 6 | + 'under_110', | |
| 7 | + '110_to_143', | |
| 8 | + '144_to_199', | |
| 9 | + '200_plus', | |
| 10 | +] as const |