pi-diff-widget 0.0.0-stage → 0.1.2

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,42 @@
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.2] - 2026-10-12
8
+
9
+ ### Changed
10
+
11
+ - README: document the `/diff-widget` actions (`on`, `off`, `accept`, `reject`)
12
+ as a table, link them from the `/diff` usage section, and correct the
13
+ local-checkout path in the install block.
14
+
15
+ ## [0.1.1] - 2026-09-25
16
+
17
+ ### Fixed
18
+
19
+ - A tracked file whose parent directory disappeared no longer reports as a
20
+ whole-file deletion in the edits widget. Such baselines are dropped, so
21
+ `/diff-widget reject` can no longer rebuild a directory that was moved or
22
+ deleted. Files the agent actually deleted still count, and `reject` still
23
+ recreates them.
24
+
25
+ ## [0.1.0] - 2026-09-25
26
+
27
+ First release.
28
+
29
+ ### Added
30
+
31
+ - `/diff` command: a scrollable overlay with uncommitted changes and per-turn
32
+ edits. `Tab` toggles sources, `[` and `]` step turns, `n` and `p` jump files.
33
+ - Uncommitted view from git, including repos with no commits, with handling for
34
+ binary, oversized, and unreadable files.
35
+ - Per-turn view reconstructed from session data. `edit` diffs come from
36
+ `details.diff`; `write` diffs are synthesized from before/after snapshots and
37
+ persisted so they survive a reload.
38
+ - Live edits widget above the editor showing net agent changes against a
39
+ per-file session baseline. A file leaves the panel once it is committed.
40
+ - `/diff-widget` command: `on`, `off`, `accept` (alias `keep`) to keep the
41
+ changes, and `reject` to restore files to their pre-agent content. Unknown
42
+ 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,119 @@
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-widget
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
+ See [Live edits widget](#live-edits-widget) for the `/diff-widget` actions
42
+ (`on`, `off`, `accept`, `reject`).
43
+
44
+ ## Live edits widget
45
+
46
+ Above the editor, pi-diff shows the net effect of the agent's edits this
47
+ session, refreshed at each turn boundary:
48
+
49
+ ```
50
+ edits · 3 files · +3 -0
51
+ src/git.ts +1 -0
52
+ src/index.ts +1 -0
53
+ src/ui.ts +1 -0
54
+ ```
55
+
56
+ For each file the agent touches, pi-diff records the content before the first
57
+ touch and diffs it against the current content. Reverted files drop out and the
58
+ counts are net, so it shows what the agent has actually changed, not cumulative
59
+ churn. Committing a file drops it from the panel automatically, so what remains
60
+ is the agent's work that is not yet committed. Paths are shown relative to the
61
+ working directory. Baselines live in the session, capped at 256 KB per file. A
62
+ baseline whose parent directory no longer exists is dropped, so a checkout you
63
+ move or delete stops counting as a whole-file deletion.
64
+
65
+ It hides itself when nothing differs.
66
+
67
+ ### `/diff-widget` actions
68
+
69
+ | Command | Effect |
70
+ |---|---|
71
+ | `/diff-widget` | Toggle the panel. With no argument it shows a hidden panel and hides a visible one |
72
+ | `/diff-widget on` | Show it |
73
+ | `/diff-widget off` | Hide it |
74
+ | `/diff-widget accept` | Keep the agent's edits: the current content becomes the baseline and the panel clears. Alias: `keep` |
75
+ | `/diff-widget reject` | Undo the agent's edits: restore every tracked file to its pre-agent content, delete the files the agent created, and recreate the ones it deleted |
76
+
77
+ `accept` is Zed's Keep All. `reject` asks for confirmation first, naming up to
78
+ five affected paths, and cannot be undone. Whole files only: there is no
79
+ hunk-level accept or reject. With nothing to reject it reports `Nothing to
80
+ reject`, and any unrecognized argument warns instead of toggling the panel, so a
81
+ typo never hides the widget by accident.
82
+
83
+ ```
84
+ /diff-widget
85
+ /diff-widget on
86
+ /diff-widget accept
87
+ /diff-widget reject
88
+ ```
89
+
90
+ ## Status
91
+
92
+ - [x] Uncommitted changes (`git diff HEAD` plus untracked files)
93
+ - [x] Per-turn diffs grouped by prompt, reconstructed from session edit results
94
+ - [x] `write`-tool before/after snapshots, persisted per turn
95
+ - [x] Live net edits widget above the editor, with `/diff-widget on|off|accept|reject`
96
+
97
+ See [ROADMAP.md](./ROADMAP.md) for planned work and known gaps.
98
+
99
+ ## How it works
100
+
101
+ - Uncommitted diffs shell out to git, then feed old and new content into pi's
102
+ `generateDiffString` and `renderDiff`. No third-party diff library.
103
+ - Per-turn diffs read pi's session data. The `edit` tool persists `details.diff`
104
+ per call, so reconstruction needs no git and works outside a repo. The `write`
105
+ tool stores no diff, so a `tool_call` handler snapshots the old content,
106
+ synthesizes one on `tool_result`, and appends it to the session at `turn_end`.
107
+ Write diffs therefore survive reload, same as edit diffs.
108
+
109
+ ## Development
110
+
111
+ ```bash
112
+ npm install
113
+ npm run typecheck
114
+ npm test
115
+ pi -e ./src/index.ts # then run /diff
116
+ ```
117
+
118
+ Tests use the built-in `node:test` runner with no extra dependencies. Git tests
119
+ 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.2",
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
+ }