pi-diff-widget 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.1.1] - 2026-09-25
8
+
9
+ ### Fixed
10
+
11
+ - A tracked file whose parent directory disappeared no longer reports as a
12
+ whole-file deletion in the edits widget. Such baselines are dropped, so
13
+ `/diff-widget reject` can no longer rebuild a directory that was moved or
14
+ deleted. Files the agent actually deleted still count, and `reject` still
15
+ recreates them.
16
+
17
+ ## [0.1.0] - 2026-09-25
18
+
19
+ First release.
20
+
21
+ ### Added
22
+
23
+ - `/diff` command: a scrollable overlay with uncommitted changes and per-turn
24
+ edits. `Tab` toggles sources, `[` and `]` step turns, `n` and `p` jump files.
25
+ - Uncommitted view from git, including repos with no commits, with handling for
26
+ binary, oversized, and unreadable files.
27
+ - Per-turn view reconstructed from session data. `edit` diffs come from
28
+ `details.diff`; `write` diffs are synthesized from before/after snapshots and
29
+ persisted so they survive a reload.
30
+ - Live edits widget above the editor showing net agent changes against a
31
+ per-file session baseline. A file leaves the panel once it is committed.
32
+ - `/diff-widget` command: `on`, `off`, `accept` (alias `keep`) to keep the
33
+ changes, and `reject` to restore files to their pre-agent content. Unknown
34
+ actions warn instead of silently toggling.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 omgzsa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,96 @@
1
- # Temporary Holding Version
1
+ # pi-diff-widget
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Interactive diff viewer and live edits widget for [pi](https://pi.dev).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:pi-diff-widget
9
+ # straight from the repo, no npm required
10
+ pi install git:github.com/omgzsa/pi-diff-widget
11
+ # or from a local checkout
12
+ pi install /absolute/path/to/pi-diff
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```
18
+ /diff
19
+ ```
20
+
21
+ Opens a scrollable overlay with two sources:
22
+
23
+ - **Uncommitted**: tracked modifications, staged changes, deletions, and
24
+ untracked files from `git diff HEAD`.
25
+ - **Turns**: the files the agent edited, grouped by the prompt that caused them.
26
+ Read straight from the session, so it needs no git and works anywhere.
27
+
28
+ Diffs render with pi's own renderer, so colors and intra-line highlighting match
29
+ the inline edit diffs.
30
+
31
+ Keys:
32
+
33
+ - `Tab`: switch between uncommitted and turns
34
+ - `[` / `]`: previous / next turn (turns view)
35
+ - `up` / `down`: scroll one line
36
+ - `pageUp` / `pageDown`: scroll one page
37
+ - `n` / `p`: next / previous file or section
38
+ - `g` / `G`: jump to top / bottom
39
+ - `q` / `Esc`: close
40
+
41
+ ## Live edits widget
42
+
43
+ Above the editor, pi-diff shows the net effect of the agent's edits this
44
+ session, refreshed at each turn boundary:
45
+
46
+ ```
47
+ edits · 3 files · +3 -0
48
+ src/git.ts +1 -0
49
+ src/index.ts +1 -0
50
+ src/ui.ts +1 -0
51
+ ```
52
+
53
+ For each file the agent touches, pi-diff records the content before the first
54
+ touch and diffs it against the current content. Reverted files drop out and the
55
+ counts are net, so it shows what the agent has actually changed, not cumulative
56
+ churn. Committing a file drops it from the panel automatically, so what remains
57
+ is the agent's work that is not yet committed. Paths are shown relative to the
58
+ working directory. Baselines live in the session, capped at 256 KB per file.
59
+
60
+ It hides itself when nothing differs. `/diff-widget` toggles it. `/diff-widget
61
+ accept` (alias `keep`) makes the current state the new baseline, the equivalent of
62
+ Zed's Keep All. `/diff-widget reject` restores every tracked file to its
63
+ pre-agent content, deleting files the agent created and recreating ones it
64
+ deleted. It asks for confirmation first, and cannot be undone. Unknown actions
65
+ report a warning rather than toggling the widget.
66
+
67
+ ## Status
68
+
69
+ - [x] Uncommitted changes (`git diff HEAD` plus untracked files)
70
+ - [x] Per-turn diffs grouped by prompt, reconstructed from session edit results
71
+ - [x] `write`-tool before/after snapshots, persisted per turn
72
+ - [x] Live net edits widget above the editor, with `/diff-widget on|off|accept|reject`
73
+
74
+ See [ROADMAP.md](./ROADMAP.md) for planned work and known gaps.
75
+
76
+ ## How it works
77
+
78
+ - Uncommitted diffs shell out to git, then feed old and new content into pi's
79
+ `generateDiffString` and `renderDiff`. No third-party diff library.
80
+ - Per-turn diffs read pi's session data. The `edit` tool persists `details.diff`
81
+ per call, so reconstruction needs no git and works outside a repo. The `write`
82
+ tool stores no diff, so a `tool_call` handler snapshots the old content,
83
+ synthesizes one on `tool_result`, and appends it to the session at `turn_end`.
84
+ Write diffs therefore survive reload, same as edit diffs.
85
+
86
+ ## Development
87
+
88
+ ```bash
89
+ npm install
90
+ npm run typecheck
91
+ npm test
92
+ pi -e ./src/index.ts # then run /diff
93
+ ```
94
+
95
+ Tests use the built-in `node:test` runner with no extra dependencies. Git tests
96
+ build throwaway repos under the system temp directory.
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
- "name": "pi-diff-widget",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "pi-diff-widget",
3
+ "version": "0.1.1",
4
+ "description": "Interactive diff viewer for pi: uncommitted changes and per-turn diffs",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "diff",
9
+ "git"
10
+ ],
11
+ "license": "MIT",
12
+ "author": "omgzsa",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/omgzsa/pi-diff-widget.git"
16
+ },
17
+ "homepage": "https://github.com/omgzsa/pi-diff-widget#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/omgzsa/pi-diff-widget/issues"
20
+ },
21
+ "type": "module",
22
+ "files": [
23
+ "src",
24
+ "README.md",
25
+ "CHANGELOG.md",
26
+ "LICENSE"
27
+ ],
28
+ "pi": {
29
+ "extensions": [
30
+ "./src/index.ts"
31
+ ]
32
+ },
33
+ "scripts": {
34
+ "typecheck": "tsc --noEmit",
35
+ "test": "node --experimental-strip-types --test test/*.test.ts",
36
+ "prepublishOnly": "npm run typecheck && npm test"
37
+ },
38
+ "engines": {
39
+ "node": ">=22.19.0"
40
+ },
41
+ "peerDependencies": {
42
+ "@earendil-works/pi-coding-agent": "*",
43
+ "@earendil-works/pi-tui": "*"
44
+ },
45
+ "devDependencies": {
46
+ "@earendil-works/pi-coding-agent": "^0.85.1",
47
+ "@earendil-works/pi-tui": "^0.85.1",
48
+ "@types/node": "^22.0.0",
49
+ "typescript": "^5.6.0"
50
+ }
51
+ }
@@ -0,0 +1,180 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { isToolCallEventType } from '@earendil-works/pi-coding-agent';
4
+ import type {
5
+ ExtensionAPI,
6
+ SessionEntry,
7
+ } from '@earendil-works/pi-coding-agent';
8
+
9
+ export const BASELINE_ENTRY = 'pi-diff:baselines';
10
+ const MAX_BASELINE_BYTES = 256 * 1024;
11
+
12
+ export interface BaselineRecord {
13
+ path: string;
14
+ content: string;
15
+ /** True when the file did not exist when the baseline was captured. */
16
+ missing?: boolean;
17
+ }
18
+
19
+ export interface BaselineRead {
20
+ content: string;
21
+ existed: boolean;
22
+ }
23
+
24
+ /**
25
+ * Read a file for baselining. `existed` distinguishes a missing file (empty
26
+ * baseline) from a genuinely empty file, which reject needs in order to delete
27
+ * versus empty. Returns undefined when the file is too large or unreadable.
28
+ */
29
+ export async function readBaseline(
30
+ path: string,
31
+ maxBytes = MAX_BASELINE_BYTES,
32
+ ): Promise<BaselineRead | undefined> {
33
+ const info = await stat(path).catch(() => undefined);
34
+ if (!info || !info.isFile()) return { content: '', existed: false };
35
+ if (info.size > maxBytes) return undefined;
36
+ const content = await readFile(path, 'utf8').catch(() => undefined);
37
+ if (content === undefined) return undefined;
38
+ return { content, existed: true };
39
+ }
40
+
41
+ /** Content only, for callers that do not care whether the file existed. */
42
+ export async function readFileCapped(
43
+ path: string,
44
+ maxBytes = MAX_BASELINE_BYTES,
45
+ ): Promise<string | undefined> {
46
+ return (await readBaseline(path, maxBytes))?.content;
47
+ }
48
+
49
+ /**
50
+ * True when the path's parent directory is gone. A tracked path whose location
51
+ * no longer exists; the checkout moved or was removed, so its baseline must
52
+ * not read as a deletion.
53
+ */
54
+ export async function locationGone(path: string): Promise<boolean> {
55
+ const info = await stat(dirname(path)).catch(() => undefined);
56
+ return info?.isDirectory() !== true;
57
+ }
58
+
59
+ /**
60
+ * Records each file's content the first time the agent touches it, so net agent
61
+ * changes can be diffed against the pre-agent state. Independent of git.
62
+ */
63
+ export class BaselineTracker {
64
+ private readonly baselines = new Map<string, string>();
65
+ private readonly missing = new Set<string>();
66
+ private readonly unflushed = new Set<string>();
67
+
68
+ get(path: string): string | undefined {
69
+ return this.baselines.get(path);
70
+ }
71
+
72
+ paths(): string[] {
73
+ return [...this.baselines.keys()];
74
+ }
75
+
76
+ /** True when the path did not exist before the agent first touched it. */
77
+ wasMissing(path: string): boolean {
78
+ return this.missing.has(path);
79
+ }
80
+
81
+ async capture(cwd: string, path: string): Promise<void> {
82
+ const absolute = resolve(cwd, path);
83
+ if (this.baselines.has(absolute)) return;
84
+ const read = await readBaseline(absolute);
85
+ if (read === undefined) return;
86
+ this.store(absolute, read);
87
+ this.unflushed.add(absolute);
88
+ }
89
+
90
+ /** Accept current content as the new baseline, so net changes drop to zero. */
91
+ async rebaseline(pi: ExtensionAPI): Promise<void> {
92
+ for (const path of [...this.baselines.keys()]) {
93
+ const read = await readBaseline(path);
94
+ if (read === undefined) continue;
95
+ this.store(path, read);
96
+ this.unflushed.add(path);
97
+ }
98
+ this.flush(pi);
99
+ }
100
+
101
+ /** Drop baselines whose absolute path fails the predicate. */
102
+ retain(keep: (path: string) => boolean): void {
103
+ for (const path of [...this.baselines.keys()]) {
104
+ if (!keep(path)) {
105
+ this.baselines.delete(path);
106
+ this.missing.delete(path);
107
+ }
108
+ }
109
+ }
110
+
111
+ private store(path: string, read: BaselineRead): void {
112
+ this.baselines.set(path, read.content);
113
+ if (read.existed) this.missing.delete(path);
114
+ else this.missing.add(path);
115
+ }
116
+
117
+ flush(pi: ExtensionAPI): void {
118
+ if (this.unflushed.size === 0) return;
119
+ const records: BaselineRecord[] = [];
120
+ for (const path of this.unflushed) {
121
+ const content = this.baselines.get(path);
122
+ if (content === undefined) continue;
123
+ records.push({
124
+ path,
125
+ content,
126
+ missing: this.missing.has(path) ? true : undefined,
127
+ });
128
+ }
129
+ this.unflushed.clear();
130
+ if (records.length > 0) {
131
+ pi.appendEntry(BASELINE_ENTRY, { baselines: records });
132
+ }
133
+ }
134
+
135
+ load(branch: SessionEntry[]): void {
136
+ for (const entry of branch) {
137
+ if (
138
+ entry.type !== 'custom' ||
139
+ entry.customType !== BASELINE_ENTRY
140
+ ) {
141
+ continue;
142
+ }
143
+ const records = (
144
+ entry.data as { baselines?: BaselineRecord[] } | undefined
145
+ )?.baselines;
146
+ if (!Array.isArray(records)) continue;
147
+ for (const record of records) {
148
+ // Later entries win, so accepting can re-baseline a path.
149
+ this.store(record.path, {
150
+ content: record.content,
151
+ existed: record.missing !== true,
152
+ });
153
+ }
154
+ }
155
+ }
156
+ }
157
+
158
+ export function createBaselineTracker(pi: ExtensionAPI): BaselineTracker {
159
+ const tracker = new BaselineTracker();
160
+
161
+ pi.on('session_start', (_event, ctx) => {
162
+ tracker.load(ctx.sessionManager.getBranch());
163
+ });
164
+
165
+ pi.on('tool_call', async (event, ctx) => {
166
+ if (isToolCallEventType('edit', event)) {
167
+ await tracker.capture(ctx.cwd, event.input.path);
168
+ return;
169
+ }
170
+ if (isToolCallEventType('write', event)) {
171
+ await tracker.capture(ctx.cwd, event.input.path);
172
+ }
173
+ });
174
+
175
+ // Flush at turn boundaries, where it is safe to append a session entry.
176
+ pi.on('turn_end', () => tracker.flush(pi));
177
+ pi.on('agent_settled', () => tracker.flush(pi));
178
+
179
+ return tracker;
180
+ }
@@ -0,0 +1,50 @@
1
+ import { renderDiff } from '@earendil-works/pi-coding-agent';
2
+ import type { Theme } from '@earendil-works/pi-coding-agent';
3
+ import type { DiffSection } from './view.ts';
4
+
5
+ /** The theme methods `buildDocument` uses. */
6
+ export type DocumentTheme = Pick<Theme, 'fg' | 'bold'>;
7
+
8
+ /** Turns one raw display diff into styled, newline-joined lines. */
9
+ export type DiffTextRenderer = (diff: string) => string;
10
+
11
+ export interface DiffDocument {
12
+ lines: string[];
13
+ sectionStarts: number[];
14
+ }
15
+
16
+ /**
17
+ * Flatten sections into one scrollable document. Pure and TUI-free. The body
18
+ * renderer is injected so tests avoid pi's global theme; defaults to renderDiff.
19
+ */
20
+ export function buildDocument(
21
+ sections: DiffSection[],
22
+ theme: DocumentTheme,
23
+ renderDiffText: DiffTextRenderer = renderDiff,
24
+ ): DiffDocument {
25
+ const lines: string[] = [];
26
+ const sectionStarts: number[] = [];
27
+
28
+ if (sections.length === 0) {
29
+ lines.push(theme.fg('dim', 'No changes to show.'));
30
+ return { lines, sectionStarts };
31
+ }
32
+
33
+ for (const section of sections) {
34
+ sectionStarts.push(lines.length);
35
+ const detail = section.detail
36
+ ? ` ${theme.fg('dim', section.detail)}`
37
+ : '';
38
+ lines.push(`${theme.fg('accent', theme.bold(section.title))}${detail}`);
39
+ if (section.diff) {
40
+ for (const line of renderDiffText(section.diff).split('\n')) {
41
+ lines.push(line);
42
+ }
43
+ } else {
44
+ lines.push(theme.fg('dim', section.note ?? '(no textual diff)'));
45
+ }
46
+ lines.push('');
47
+ }
48
+
49
+ return { lines, sectionStarts };
50
+ }
package/src/exec.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type { ExecOptions, ExecResult } from '@earendil-works/pi-coding-agent';
2
+
3
+ /** The subset of `ExtensionAPI` used to shell out. */
4
+ export type ExecFn = (
5
+ command: string,
6
+ args: string[],
7
+ options?: ExecOptions,
8
+ ) => Promise<ExecResult>;
package/src/git.ts ADDED
@@ -0,0 +1,198 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import { generateDiffString } from '@earendil-works/pi-coding-agent';
4
+ import type { ExecResult } from '@earendil-works/pi-coding-agent';
5
+ import type { ExecFn } from './exec.ts';
6
+
7
+ export type FileStatus =
8
+ | 'added'
9
+ | 'modified'
10
+ | 'deleted'
11
+ | 'binary'
12
+ | 'too-large'
13
+ | 'unreadable';
14
+
15
+ export interface DiffFile {
16
+ path: string;
17
+ status: FileStatus;
18
+ /** Raw display diff in pi's line-numbered format (`+123 text`). Render with `renderDiff`. */
19
+ diff: string;
20
+ }
21
+
22
+ const MAX_TEXT_BYTES = 512 * 1024;
23
+ const MAX_DIFF_LINES = 4000;
24
+
25
+ function splitNul(value: string): string[] {
26
+ return value.split('\0').filter((part) => part.length > 0);
27
+ }
28
+
29
+ function looksBinary(text: string): boolean {
30
+ return text.includes('\0');
31
+ }
32
+
33
+ function capLines(text: string): string {
34
+ const lines = text.split('\n');
35
+ if (lines.length <= MAX_DIFF_LINES) return text;
36
+ const kept = lines.slice(0, MAX_DIFF_LINES);
37
+ kept.push(`... ${lines.length - MAX_DIFF_LINES} more lines`);
38
+ return kept.join('\n');
39
+ }
40
+
41
+ async function runGit(
42
+ exec: ExecFn,
43
+ cwd: string,
44
+ args: string[],
45
+ ): Promise<ExecResult> {
46
+ return exec('git', args, { cwd });
47
+ }
48
+
49
+ async function readHead(
50
+ exec: ExecFn,
51
+ cwd: string,
52
+ path: string,
53
+ ): Promise<string | undefined> {
54
+ const result = await runGit(exec, cwd, ['show', `HEAD:${path}`]);
55
+ return result.code === 0 ? result.stdout : undefined;
56
+ }
57
+
58
+ type WorktreeRead =
59
+ | { kind: 'text'; text: string }
60
+ | { kind: 'missing' }
61
+ | { kind: 'binary' }
62
+ | { kind: 'too-large' }
63
+ | { kind: 'unreadable' };
64
+
65
+ async function readWorktree(absolutePath: string): Promise<WorktreeRead> {
66
+ try {
67
+ const buffer = await readFile(absolutePath);
68
+ if (buffer.byteLength > MAX_TEXT_BYTES) return { kind: 'too-large' };
69
+ if (buffer.includes(0)) return { kind: 'binary' };
70
+ return { kind: 'text', text: buffer.toString('utf8') };
71
+ } catch (error) {
72
+ // Only ENOENT means the file is gone. Anything else is a read failure.
73
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
74
+ return { kind: 'missing' };
75
+ }
76
+ return { kind: 'unreadable' };
77
+ }
78
+ }
79
+
80
+ interface ChangedPaths {
81
+ hasHead: boolean;
82
+ paths: Set<string>;
83
+ }
84
+
85
+ /** Changed paths relative to `cwd`, or undefined when not in a git repository. */
86
+ async function collectChangedPaths(
87
+ exec: ExecFn,
88
+ cwd: string,
89
+ ): Promise<ChangedPaths | undefined> {
90
+ const insideRepo = await runGit(exec, cwd, [
91
+ 'rev-parse',
92
+ '--is-inside-work-tree',
93
+ ]);
94
+ if (insideRepo.code !== 0 || insideRepo.stdout.trim() !== 'true') {
95
+ return undefined;
96
+ }
97
+
98
+ const hasHead =
99
+ (await runGit(exec, cwd, ['rev-parse', '--verify', 'HEAD'])).code === 0;
100
+
101
+ const paths = new Set<string>();
102
+ if (hasHead) {
103
+ const tracked = await runGit(exec, cwd, [
104
+ 'diff',
105
+ '--name-only',
106
+ '-z',
107
+ '--no-renames',
108
+ 'HEAD',
109
+ ]);
110
+ if (tracked.code !== 0) {
111
+ throw new Error(tracked.stderr.trim() || 'git diff failed');
112
+ }
113
+ for (const path of splitNul(tracked.stdout)) paths.add(path);
114
+ const untracked = await runGit(exec, cwd, [
115
+ 'ls-files',
116
+ '--others',
117
+ '--exclude-standard',
118
+ '-z',
119
+ ]);
120
+ for (const path of splitNul(untracked.stdout)) paths.add(path);
121
+ } else {
122
+ // No commits yet: every staged or untracked file is new.
123
+ const all = await runGit(exec, cwd, [
124
+ 'ls-files',
125
+ '--cached',
126
+ '--others',
127
+ '--exclude-standard',
128
+ '-z',
129
+ ]);
130
+ for (const path of splitNul(all.stdout)) paths.add(path);
131
+ }
132
+
133
+ return { hasHead, paths };
134
+ }
135
+
136
+ /**
137
+ * Relative paths with uncommitted changes, or undefined when `cwd` is not a git
138
+ * repository. Cheaper than `collectUncommitted`: it reads no file contents.
139
+ */
140
+ export async function listDirtyPaths(
141
+ exec: ExecFn,
142
+ cwd: string,
143
+ ): Promise<Set<string> | undefined> {
144
+ return (await collectChangedPaths(exec, cwd))?.paths;
145
+ }
146
+
147
+ /**
148
+ * Read every uncommitted change in `cwd`: staged and unstaged tracked edits,
149
+ * plus untracked files. Uses `--no-renames` so renames arrive as a delete plus
150
+ * an add, which keeps content diffing simple.
151
+ */
152
+ export async function collectUncommitted(
153
+ exec: ExecFn,
154
+ cwd: string,
155
+ ): Promise<DiffFile[]> {
156
+ const changed = await collectChangedPaths(exec, cwd);
157
+ if (!changed) {
158
+ throw new Error('not a git repository');
159
+ }
160
+ const { hasHead, paths } = changed;
161
+
162
+ const files: DiffFile[] = [];
163
+ for (const path of [...paths].sort()) {
164
+ const head = hasHead ? await readHead(exec, cwd, path) : undefined;
165
+ const work = await readWorktree(resolve(cwd, path));
166
+
167
+ if (work.kind === 'unreadable') {
168
+ files.push({ path, status: 'unreadable', diff: '' });
169
+ continue;
170
+ }
171
+ if (
172
+ work.kind === 'binary' ||
173
+ (head !== undefined && looksBinary(head))
174
+ ) {
175
+ files.push({ path, status: 'binary', diff: '' });
176
+ continue;
177
+ }
178
+ if (work.kind === 'too-large') {
179
+ files.push({ path, status: 'too-large', diff: '' });
180
+ continue;
181
+ }
182
+
183
+ const before = head ?? '';
184
+ const after = work.kind === 'text' ? work.text : '';
185
+ const status: FileStatus =
186
+ head === undefined
187
+ ? 'added'
188
+ : work.kind === 'missing'
189
+ ? 'deleted'
190
+ : 'modified';
191
+
192
+ const { diff } = generateDiffString(before, after);
193
+ if (!diff && status === 'modified') continue; // mode-only change, nothing to show
194
+ files.push({ path, status, diff: capLines(diff) });
195
+ }
196
+
197
+ return files;
198
+ }