spacehub

surshar / claude-code

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

Built by Anthropic · github.com/anthropics

Merge pull request #93215 from anthropics/poteat/modsBrowse files
Add mods: sec-default, diff and telemetry
Alice T'Poteat authored on Sep 9, 2026 · GitHub committed on Sep 9, 2026 parents 9cdc2a4 + d9c456d · commit e62465d553ec
300 changed files+3638 -0compared with 9cdc2a4
Amods/README.md
Amods/diff/.claude-plugin/plugin.json
Amods/diff/README.md
Amods/diff/hooks/array-of/array-of.ts
Amods/diff/hooks/array-of/index.ts
Amods/diff/hooks/ask/armed-ask/armed-ask.ts
Amods/diff/hooks/ask/armed-ask/index.ts
Amods/diff/hooks/ask/armed-ask-of.ts
Amods/diff/hooks/ask/index.ts
Amods/diff/hooks/classify/base-name-of/base-name-of.ts
Amods/diff/hooks/classify/base-name-of/index.ts
Amods/diff/hooks/classify/index.ts
Amods/diff/hooks/classify/is-generated-file/generated-directories/generated-directories.ts
Amods/diff/hooks/classify/is-generated-file/generated-directories/index.ts
Amods/diff/hooks/classify/is-generated-file/generated-filename-patterns/generated-filename-patterns.ts
Amods/diff/hooks/classify/is-generated-file/generated-filename-patterns/index.ts
Amods/diff/hooks/classify/is-generated-file/generated-filenames/generated-filenames.ts
Amods/diff/hooks/classify/is-generated-file/generated-filenames/index.ts
Amods/diff/hooks/classify/is-generated-file/generated-suffixes/generated-suffixes.ts
Amods/diff/hooks/classify/is-generated-file/generated-suffixes/index.ts
Amods/diff/hooks/classify/is-generated-file/index.ts
Amods/diff/hooks/classify/is-generated-file/is-generated-file.ts
Amods/diff/hooks/classify/is-noise-file.ts
Amods/diff/hooks/classify/is-test-file/index.ts
Amods/diff/hooks/classify/is-test-file/is-test-file.ts
Amods/diff/hooks/classify/is-test-file/test-directories/index.ts
Amods/diff/hooks/classify/is-test-file/test-directories/test-directories.ts
Amods/diff/hooks/classify/is-test-file/test-filename-patterns/index.ts
Amods/diff/hooks/classify/is-test-file/test-filename-patterns/test-filename-patterns.ts
Amods/diff/hooks/classify/rooted-path-of/index.ts
Amods/diff/hooks/classify/rooted-path-of/rooted-path-of.ts
Amods/diff/hooks/command-spec/command-spec.ts
Amods/diff/hooks/command-spec/index.ts
Amods/diff/hooks/count-of/count-of.ts
Amods/diff/hooks/count-of/index.ts
Amods/diff/hooks/entry-kinds-of/entry-kinds-of.ts
Amods/diff/hooks/entry-kinds-of/index.ts
Amods/diff/hooks/git/argv/diff-leading-args.ts
Amods/diff/hooks/git/argv/git-child-env/git-child-env.ts
Amods/diff/hooks/git/argv/git-child-env/index.ts
Amods/diff/hooks/git/argv/index.ts
Amods/diff/hooks/git/argv/no-optional-locks.ts
Amods/diff/hooks/git/argv/pinned-lead-of/index.ts
Amods/diff/hooks/git/argv/pinned-lead-of/pinned-lead-of.ts
Amods/diff/hooks/git/cut-hunks/cut-hunks.ts
Amods/diff/hooks/git/cut-hunks/index.ts
Amods/diff/hooks/git/empty-file-hunks/empty-file-hunks.ts
Amods/diff/hooks/git/empty-file-hunks/index.ts
Amods/diff/hooks/git/fetch-diff.ts
Amods/diff/hooks/git/fetch-file-hunks.ts
Amods/diff/hooks/git/hunk-of/hunk-of.ts
Amods/diff/hooks/git/hunk-of/index.ts
Amods/diff/hooks/git/index.ts
Amods/diff/hooks/git/large-file-hunks/index.ts
Amods/diff/hooks/git/large-file-hunks/large-file-hunks.ts
Amods/diff/hooks/git/parse/hunk-header/hunk-header.ts
Amods/diff/hooks/git/parse/hunk-header/index.ts
Amods/diff/hooks/git/parse/index.ts
Amods/diff/hooks/git/parse/is-body-line/index.ts
Amods/diff/hooks/git/parse/is-body-line/is-body-line.ts
Amods/diff/hooks/git/parse/is-whole-answer/index.ts
Amods/diff/hooks/git/parse/is-whole-answer/is-whole-answer.ts
Amods/diff/hooks/git/parse/is-whole-listing/index.ts
Amods/diff/hooks/git/parse/is-whole-listing/is-under-utf8-ceiling/index.ts
Amods/diff/hooks/git/parse/is-whole-listing/is-under-utf8-ceiling/is-under-utf8-ceiling.ts
Amods/diff/hooks/git/parse/is-whole-listing/is-whole-listing.ts
Amods/diff/hooks/git/parse/is-whole-listing/most-utf8-bytes/index.ts
Amods/diff/hooks/git/parse/is-whole-listing/most-utf8-bytes/most-utf8-bytes.ts
Amods/diff/hooks/git/parse/parse-file-diff.ts
Amods/diff/hooks/git/parse/parse-numstat.ts
Amods/diff/hooks/git/parse/parse-shortstat.ts
Amods/diff/hooks/git/parse/rename-records/index.ts
Amods/diff/hooks/git/parse/rename-records/rename-records.ts
Amods/diff/hooks/git/parse/shortstat-pattern/index.ts
Amods/diff/hooks/git/parse/shortstat-pattern/shortstat-pattern.ts
Amods/diff/hooks/git/probes/branch-base-of/branch-base-of.ts
Amods/diff/hooks/git/probes/branch-base-of/index.ts
Amods/diff/hooks/git/probes/conventional-default-branches/conventional-default-branches.ts
Amods/diff/hooks/git/probes/conventional-default-branches/index.ts
Amods/diff/hooks/git/probes/current-branch-of/current-branch-of.ts
Amods/diff/hooks/git/probes/current-branch-of/index.ts
Amods/diff/hooks/git/probes/datings-of/datings-of.ts
Amods/diff/hooks/git/probes/datings-of/index.ts
Amods/diff/hooks/git/probes/default-branch-of/default-branch-of.ts
Amods/diff/hooks/git/probes/default-branch-of/index.ts
Amods/diff/hooks/git/probes/head-key-of/head-key-of.ts
Amods/diff/hooks/git/probes/head-key-of/index.ts
Amods/diff/hooks/git/probes/index.ts
Amods/diff/hooks/git/probes/is-absolute-path/index.ts
Amods/diff/hooks/git/probes/is-absolute-path/is-absolute-path.ts
Amods/diff/hooks/git/probes/is-safe-ref-name/index.ts
Amods/diff/hooks/git/probes/is-safe-ref-name/is-safe-ref-name.ts
Amods/diff/hooks/git/probes/is-safe-ref-name/safe-ref-pattern/index.ts
Amods/diff/hooks/git/probes/is-safe-ref-name/safe-ref-pattern/safe-ref-pattern.ts
Amods/diff/hooks/git/probes/is-sha/index.ts
Amods/diff/hooks/git/probes/is-sha/is-sha.ts
Amods/diff/hooks/git/probes/is-transient.ts
Amods/diff/hooks/git/probes/is-unborn-head.ts
Amods/diff/hooks/git/probes/kind-walk-of/index.ts
Amods/diff/hooks/git/probes/kind-walk-of/kind-walk-of.ts
Amods/diff/hooks/git/probes/merge-base-candidates-of/index.ts
Amods/diff/hooks/git/probes/merge-base-candidates-of/merge-base-candidates/index.ts
Amods/diff/hooks/git/probes/merge-base-candidates-of/merge-base-candidates/merge-base-candidates.ts
Amods/diff/hooks/git/probes/merge-base-candidates-of/merge-base-candidates-of.ts
Amods/diff/hooks/git/probes/missing-base-of/index.ts
Amods/diff/hooks/git/probes/missing-base-of/missing-base-of.ts
Amods/diff/hooks/git/probes/overlay-unstaged.ts
Amods/diff/hooks/git/probes/repository-lines/index.ts
Amods/diff/hooks/git/probes/repository-lines/repository-lines.ts
Amods/diff/hooks/git/probes/repository-of/index.ts
Amods/diff/hooks/git/probes/repository-of/repository-of.ts
Amods/diff/hooks/git/probes/stamp-if-file/index.ts
Amods/diff/hooks/git/probes/stamp-if-file/stamp-if-file.ts
Amods/diff/hooks/git/probes/stamp-probe-of/index.ts
Amods/diff/hooks/git/probes/stamp-probe-of/stamp-probe-of.ts
Amods/diff/hooks/git/probes/stats-of.ts
Amods/diff/hooks/git/probes/tag-pre-session.ts
Amods/diff/hooks/git/probes/transient-state-files/index.ts
Amods/diff/hooks/git/probes/transient-state-files/transient-state-files.ts
Amods/diff/hooks/git/probes/unstaged-numstat.ts
Amods/diff/hooks/git/probes/untracked-files/index.ts
Amods/diff/hooks/git/probes/untracked-files/untracked-files.ts
Amods/diff/hooks/git/probes/untracked-place/index.ts
Amods/diff/hooks/git/probes/untracked-place/untracked-place.ts
Amods/diff/hooks/git/tiers/branch-source-of/branch-source-of.ts
Amods/diff/hooks/git/tiers/branch-source-of/index.ts
Amods/diff/hooks/git/tiers/branch-tier.ts
Amods/diff/hooks/git/tiers/diff-data-of/diff-basis/diff-basis.ts
Amods/diff/hooks/git/tiers/diff-data-of/diff-basis/index.ts
Amods/diff/hooks/git/tiers/diff-data-of/diff-data-of.ts
Amods/diff/hooks/git/tiers/diff-data-of/index.ts
Amods/diff/hooks/git/tiers/fetch-context-of/fetch-context-of.ts
Amods/diff/hooks/git/tiers/fetch-context-of/index.ts
Amods/diff/hooks/git/tiers/index.ts
Amods/diff/hooks/git/tiers/is-past-details/index.ts
Amods/diff/hooks/git/tiers/is-past-details/is-past-details.ts
Amods/diff/hooks/git/tiers/unborn-tier/empty-numstat/empty-numstat.ts
Amods/diff/hooks/git/tiers/unborn-tier/empty-numstat/index.ts
Amods/diff/hooks/git/tiers/unborn-tier/index.ts
Amods/diff/hooks/git/tiers/unborn-tier/unborn-tier.ts
Amods/diff/hooks/git/tiers/with-untracked/index.ts
Amods/diff/hooks/git/tiers/with-untracked/merged-result-of/index.ts
Amods/diff/hooks/git/tiers/with-untracked/merged-result-of/merged-result-of.ts
Amods/diff/hooks/git/tiers/with-untracked/with-untracked.ts
Amods/diff/hooks/git/tiers/working-tree-basis/index.ts
Amods/diff/hooks/git/tiers/working-tree-basis/working-tree-basis.ts
Amods/diff/hooks/git/tiers/working-tree-tier.ts
Amods/diff/hooks/git/types/base-mode/base-mode.ts
Amods/diff/hooks/git/types/base-mode/index.ts
Amods/diff/hooks/git/types/branch-base/branch-base.ts
Amods/diff/hooks/git/types/branch-base/index.ts
Amods/diff/hooks/git/types/dating/dating.ts
Amods/diff/hooks/git/types/dating/index.ts
Amods/diff/hooks/git/types/diff-data/diff-data.ts
Amods/diff/hooks/git/types/diff-data/index.ts
Amods/diff/hooks/git/types/diff-source/diff-source.ts
Amods/diff/hooks/git/types/diff-source/index.ts
Amods/diff/hooks/git/types/diff-stats/diff-stats.ts
Amods/diff/hooks/git/types/diff-stats/index.ts
Amods/diff/hooks/git/types/entry-kind/entry-kind.ts
Amods/diff/hooks/git/types/entry-kind/index.ts
Amods/diff/hooks/git/types/fetch-context.ts
Amods/diff/hooks/git/types/fetch-outcome.ts
Amods/diff/hooks/git/types/file-hunks.ts
Amods/diff/hooks/git/types/file-stat/file-stat.ts
Amods/diff/hooks/git/types/file-stat/index.ts
Amods/diff/hooks/git/types/git-deps/git-deps.ts
Amods/diff/hooks/git/types/git-deps/index.ts
Amods/diff/hooks/git/types/git-run/git-run.ts
Amods/diff/hooks/git/types/git-run/index.ts
Amods/diff/hooks/git/types/head-key-deps/head-key-deps.ts
Amods/diff/hooks/git/types/head-key-deps/index.ts
Amods/diff/hooks/git/types/hunk/hunk.ts
Amods/diff/hooks/git/types/hunk/index.ts
Amods/diff/hooks/git/types/index.ts
Amods/diff/hooks/git/types/kind-walk/index.ts
Amods/diff/hooks/git/types/kind-walk/kind-walk.ts
Amods/diff/hooks/git/types/merged-result/index.ts
Amods/diff/hooks/git/types/merged-result/merged-result.ts
Amods/diff/hooks/git/types/merged-result/numstat-result/index.ts
Amods/diff/hooks/git/types/merged-result/numstat-result/numstat-result.ts
Amods/diff/hooks/git/types/repository/index.ts
Amods/diff/hooks/git/types/repository/repository.ts
Amods/diff/hooks/git/types/resolved-branch-base/index.ts
Amods/diff/hooks/git/types/resolved-branch-base/resolved-branch-base.ts
Amods/diff/hooks/git/types/run-result/index.ts
Amods/diff/hooks/git/types/run-result/run-result.ts
Amods/diff/hooks/git/types/stamp/index.ts
Amods/diff/hooks/git/types/stamp/stamp.ts
Amods/diff/hooks/git/types/stamp-probe/index.ts
Amods/diff/hooks/git/types/stamp-probe/stamp-probe.ts
Amods/diff/hooks/git/types/untracked-scope.ts
Amods/diff/hooks/git/types/walk-kind/index.ts
Amods/diff/hooks/git/types/walk-kind/walk-kind.ts
Amods/diff/hooks/hooks.json
Amods/diff/hooks/host/host.ts
Amods/diff/hooks/host/index.ts
Amods/diff/hooks/index.ts
Amods/diff/hooks/is-on-pane-surface/index.ts
Amods/diff/hooks/is-on-pane-surface/is-on-pane-surface.ts
Amods/diff/hooks/is-record/index.ts
Amods/diff/hooks/is-record/is-record.ts
Amods/diff/hooks/limits/columns/auto-open-min-columns.ts
Amods/diff/hooks/limits/columns/index.ts
Amods/diff/hooks/limits/columns/open-min-columns.ts
Amods/diff/hooks/limits/columns/widest-bucket-columns.ts
Amods/diff/hooks/limits/index.ts
Amods/diff/hooks/limits/sizes/host-output-cap-bytes.ts
Amods/diff/hooks/limits/sizes/index.ts
Amods/diff/hooks/limits/sizes/max-diff-bytes.ts
Amods/diff/hooks/limits/sizes/max-files-for-details.ts
Amods/diff/hooks/limits/sizes/max-files.ts
Amods/diff/hooks/limits/sizes/max-lines-per-file.ts
Amods/diff/hooks/limits/sizes/max-listed-directories.ts
Amods/diff/hooks/limits/sizes/max-untracked-probes.ts
Amods/diff/hooks/limits/timing/git-timeout-ms.ts
Amods/diff/hooks/limits/timing/head-poll-ms.ts
Amods/diff/hooks/limits/timing/index.ts
Amods/diff/hooks/limits/timing/open-probe-ms.ts
Amods/diff/hooks/limits/timing/redraw-coalesce-ms.ts
Amods/diff/hooks/limits/timing/refresh-debounce-ms.ts
Amods/diff/hooks/message-of/index.ts
Amods/diff/hooks/message-of/message-of.ts
Amods/diff/hooks/mtime-of/index.ts
Amods/diff/hooks/mtime-of/mtime-of.ts
Amods/diff/hooks/names/command-description.ts
Amods/diff/hooks/names/command-name.ts
Amods/diff/hooks/names/focused-pane/focused-pane.ts
Amods/diff/hooks/names/focused-pane/index.ts
Amods/diff/hooks/names/index.ts
Amods/diff/hooks/names/keys/base-store-key-of.ts
Amods/diff/hooks/names/keys/index.ts
Amods/diff/hooks/names/keys/store-open-key.ts
Amods/diff/hooks/names/pane-id.ts
Amods/diff/hooks/names/pane-title.ts
Amods/diff/hooks/names/texts/builtin-holds-pattern.ts
Amods/diff/hooks/names/texts/index.ts
Amods/diff/hooks/names/texts/not-in-repository-text.ts
Amods/diff/hooks/names/texts/register-failed-text-of.ts
Amods/diff/hooks/names/texts/untracked-withheld-text.ts
Amods/diff/hooks/pane-state/after-fetch/after-fetch.ts
Amods/diff/hooks/pane-state/after-fetch/fetched/fetched.ts
Amods/diff/hooks/pane-state/after-fetch/fetched/index.ts
Amods/diff/hooks/pane-state/after-fetch/index.ts
Amods/diff/hooks/pane-state/after-new-session/after-new-session.ts
Amods/diff/hooks/pane-state/after-new-session/index.ts
Amods/diff/hooks/pane-state/after-new-session/new-session-fields/index.ts
Amods/diff/hooks/pane-state/after-new-session/new-session-fields/new-session-fields.ts
Amods/diff/hooks/pane-state/base-label-of.ts
Amods/diff/hooks/pane-state/base-mode-of/base-mode-of.ts
Amods/diff/hooks/pane-state/base-mode-of/index.ts
Amods/diff/hooks/pane-state/body-state/body-state.ts
Amods/diff/hooks/pane-state/body-state/index.ts
Amods/diff/hooks/pane-state/empty-state/empty-state.ts
Amods/diff/hooks/pane-state/empty-state/index.ts
Amods/diff/hooks/pane-state/empty-state-of.ts
Amods/diff/hooks/pane-state/header-totals/header-totals.ts
Amods/diff/hooks/pane-state/header-totals/index.ts
Amods/diff/hooks/pane-state/header-totals-of.ts
Amods/diff/hooks/pane-state/index.ts
Amods/diff/hooks/pane-state/initial-model/index.ts
Amods/diff/hooks/pane-state/initial-model/initial-model.ts
Amods/diff/hooks/pane-state/listed-of.ts
Amods/diff/hooks/pane-state/mode-label-of/index.ts
Amods/diff/hooks/pane-state/mode-label-of/mode-label-of.ts
Amods/diff/hooks/pane-state/pane-model/index.ts
Amods/diff/hooks/pane-state/pane-model/pane-model.ts
Amods/diff/hooks/pane-state/partition/index.ts
Amods/diff/hooks/pane-state/partition/partition.ts
Amods/diff/hooks/pane-state/partition-of.ts
Amods/diff/hooks/pane-state/picked-turn-of/index.ts
Amods/diff/hooks/pane-state/picked-turn-of/picked-turn-of.ts
Amods/diff/hooks/pane-state/selection-of/index.ts
Amods/diff/hooks/pane-state/selection-of/selection-of.ts
Amods/diff/hooks/pane-state/source/index.ts
Amods/diff/hooks/pane-state/source/source.ts
Amods/diff/hooks/pane-state/turn-totals-of/index.ts
Amods/diff/hooks/pane-state/turn-totals-of/turn-totals-of.ts
Amods/diff/hooks/pane-state/zero-totals/index.ts
Amods/diff/hooks/pane-state/zero-totals/zero-totals.ts
Amods/diff/hooks/pane-toggle/index.ts
Amods/diff/hooks/pane-toggle/pane-belief/index.ts
Amods/diff/hooks/pane-toggle/pane-belief/pane-belief.ts
Amods/diff/hooks/pane-toggle/pane-toggle-of.ts
Amods/diff/hooks/record/features/features.ts
Amods/diff/hooks/record/features/index.ts
Amods/diff/hooks/record/index.ts
Amods/diff/hooks/record/mark-outcome/index.ts
Amods/diff/hooks/record/mark-outcome/mark-outcome.ts
Amods/diff/hooks/record/recorder/index.ts
Amods/diff/hooks/record/recorder/recorder.ts
Amods/diff/hooks/record/recorder-of.ts
Amods/diff/hooks/record/safely/index.ts
Amods/diff/hooks/record/safely/safely.ts
Amods/diff/hooks/record/shown-triggers/index.ts
Amods/diff/hooks/record/shown-triggers/shown-triggers.ts
Amods/diff/hooks/record/width-bucket-of/index.ts
Amods/diff/hooks/record/width-bucket-of/width-bucket-of.ts
Amods/diff/hooks/record/width-buckets/index.ts
Amods/diff/hooks/record/width-buckets/width-buckets.ts

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