pim-agent 0.9.0 → 0.11.0
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/bin/pim.ts +1 -1
- package/package.json +2 -3
- package/packages/core/src/extensions/CoreExtensions.ts +19 -16
- package/packages/core/src/extensions/apply-patch/render.ts +16 -55
- package/packages/core/src/extensions/bash/index.ts +2 -1
- package/packages/core/src/extensions/bash/run.ts +8 -2
- package/packages/core/src/extensions/system-prompt/index.ts +45 -22
- package/packages/core/src/extensions/system-prompt/prompt.ts +6 -3
- package/packages/core/src/picker/PickerService.ts +23 -1
- package/packages/core/src/picker/token.ts +12 -5
- package/packages/core/src/session/EventLog.ts +22 -79
- package/packages/core/src/session/SearchIndex.ts +752 -0
- package/packages/core/src/session/SearchTokens.ts +97 -0
- package/packages/core/src/session/SessionDigest.ts +161 -0
- package/packages/core/src/session/SessionHost.ts +52 -10
- package/packages/core/src/session/SessionMeta.ts +267 -0
- package/packages/core/src/session/SessionName.ts +18 -0
- package/packages/core/src/session/SessionRegistry.ts +111 -15
- package/packages/core/src/session/SessionUi.ts +127 -0
- package/packages/core/src/session/WriteMark.ts +15 -1
- package/packages/core/src/shared/DiffLines.ts +1 -1
- package/packages/core/src/shared/DiffPatch.ts +70 -0
- package/packages/core/src/shared/DiffRenderer.ts +1 -1
- package/packages/core/src/shared/Directories.ts +39 -5
- package/packages/core/src/shared/ExtensionToggles.ts +8 -0
- package/packages/core/src/shared/FileWatch.ts +15 -1
- package/packages/core/src/shared/Git.ts +483 -42
- package/packages/core/src/shared/GitMonitor.ts +221 -0
- package/packages/core/src/shared/Levenshtein.ts +54 -1
- package/packages/core/src/shared/PiExtensions.ts +228 -0
- package/packages/core/src/shared/Proc.ts +21 -1
- package/packages/core/src/shared/RepoDiff.ts +741 -0
- package/packages/core/src/shared/Surface.ts +2 -0
- package/packages/core/src/shared/Updater.ts +21 -5
- package/packages/core/src/view/DiffExpand.ts +214 -0
- package/packages/core/src/view/DiffLayout.ts +7 -7
- package/packages/core/src/view/DiffPairs.ts +47 -0
- package/packages/core/src/view/MovePath.ts +70 -0
- package/packages/daemon/src/WebSurface.ts +1 -2
- package/packages/protocol/src/Command.ts +151 -3
- package/packages/protocol/src/Diff.ts +15 -0
- package/packages/protocol/src/ServerEvent.ts +191 -2
- package/packages/server/src/ClientConnection.ts +15 -3
- package/packages/server/src/ProbeClient.ts +111 -28
- package/packages/server/src/SessionCatalogue.ts +292 -38
- package/packages/server/src/SessionStream.ts +297 -45
- package/packages/server/src/StaticClient.ts +95 -12
- package/packages/server/src/WsGateway.ts +417 -45
- package/packages/server/src/probe.ts +77 -7
- package/packages/telegram/src/Session.ts +1 -0
- package/packages/tui/src/extensions/command-picker/index.ts +64 -32
- package/packages/tui/src/extensions/footer/index.ts +19 -43
- package/packages/tui/src/extensions/session-lease/index.ts +8 -3
- package/packages/web/dist/client/assets/CommitMonoV143-VF-ytizKI8U.woff2 +0 -0
- package/packages/web/dist/client/assets/{core-CGdQx9la.js → core-D3tyj0PP.js} +1 -1
- package/packages/web/dist/client/assets/index-CTrlTM53.js +45 -0
- package/packages/web/dist/client/assets/index-JnduS4Cd.css +1 -0
- package/packages/web/dist/client/index.html +2 -2
- package/packages/protocol/src/Protocol.ts +0 -6
- package/packages/web/dist/client/assets/commit-mono-latin-300-italic-B48RJMeK.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-300-italic-DBq5bJCE.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-italic-BXinMwCi.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-italic-DjSHLl2N.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-normal-s0S3qwFW.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-normal-wzhe4RuD.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-600-normal-BuQDXT0M.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-600-normal-SwgSWHaV.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-700-normal-1-GU0IUE.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-700-normal-DU8mrtj2.woff2 +0 -0
- package/packages/web/dist/client/assets/index-BMNwleWO.js +0 -40
- package/packages/web/dist/client/assets/index-CqjB2RPr.css +0 -1
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { DiffLines, type ToolDiffHunk, type ToolDiffLine } from "./DiffLines";
|
|
4
|
+
import { DiffPatch } from "./DiffPatch";
|
|
5
|
+
import { Git } from "./Git";
|
|
6
|
+
import type { GitMonitor } from "./GitMonitor";
|
|
7
|
+
import { Lines } from "./Lines";
|
|
8
|
+
import { Proc, type ProcResult } from "./Proc";
|
|
9
|
+
|
|
10
|
+
export type DiffBase =
|
|
11
|
+
| { readonly kind: "worktree" }
|
|
12
|
+
| { readonly kind: "unstaged" }
|
|
13
|
+
| { readonly kind: "staged" }
|
|
14
|
+
| { readonly kind: "commit"; readonly ref: string }
|
|
15
|
+
| { readonly kind: "branch"; readonly ref: string };
|
|
16
|
+
|
|
17
|
+
export type ChangeStatus =
|
|
18
|
+
| "added"
|
|
19
|
+
| "modified"
|
|
20
|
+
| "deleted"
|
|
21
|
+
| "renamed"
|
|
22
|
+
| "untracked";
|
|
23
|
+
|
|
24
|
+
/** One row of the change list; carries no hunks. */
|
|
25
|
+
export type ChangeSummary = {
|
|
26
|
+
readonly path: string;
|
|
27
|
+
/** Present only for a rename. */
|
|
28
|
+
readonly oldPath?: string;
|
|
29
|
+
readonly status: ChangeStatus;
|
|
30
|
+
readonly added: number;
|
|
31
|
+
readonly removed: number;
|
|
32
|
+
readonly binary?: boolean;
|
|
33
|
+
/** Blob SHAs the diff was computed against; anchors an outdated review comment. */
|
|
34
|
+
readonly baseSha?: string;
|
|
35
|
+
readonly headSha?: string;
|
|
36
|
+
/** Changes whenever the file's content does, so a `seen` mark keyed to it clears itself. */
|
|
37
|
+
readonly fingerprint: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type ChangeList = {
|
|
41
|
+
readonly base: DiffBase;
|
|
42
|
+
readonly files: readonly ChangeSummary[];
|
|
43
|
+
readonly added: number;
|
|
44
|
+
readonly removed: number;
|
|
45
|
+
/** More files exist than were returned. */
|
|
46
|
+
readonly truncated?: boolean;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type FileDiff = {
|
|
50
|
+
readonly path: string;
|
|
51
|
+
readonly hunks: readonly ToolDiffHunk[];
|
|
52
|
+
readonly truncated?: boolean;
|
|
53
|
+
readonly binary?: boolean;
|
|
54
|
+
/** Byte size of each side of a binary file, when git or the working tree can say. */
|
|
55
|
+
readonly oldBytes?: number;
|
|
56
|
+
readonly newBytes?: number;
|
|
57
|
+
/** Lines in the side context is read from; absent on a clipped diff, whose end is not the file's. */
|
|
58
|
+
readonly total?: number;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** A stretch of one file, 1-based and inclusive, numbered on the side `readLines` reads. */
|
|
62
|
+
export type LineSpan = {
|
|
63
|
+
readonly start: number;
|
|
64
|
+
readonly end: number;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type LineRun = {
|
|
68
|
+
readonly start: number;
|
|
69
|
+
readonly lines: readonly string[];
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type FileLines = {
|
|
73
|
+
readonly path: string;
|
|
74
|
+
readonly runs: readonly LineRun[];
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const DEFAULT_CONTEXT = 3;
|
|
78
|
+
|
|
79
|
+
const FILE_LIMIT = 2000;
|
|
80
|
+
|
|
81
|
+
/** How much of one file a reader is handed at once; past either limit the rest is dropped. */
|
|
82
|
+
const CHANGED_LINE_LIMIT = 2000;
|
|
83
|
+
const PATCH_TEXT_LIMIT = 1_000_000;
|
|
84
|
+
|
|
85
|
+
/** Past this an untracked file is reported as binary rather than read. */
|
|
86
|
+
const UNTRACKED_BYTE_LIMIT = 1_000_000;
|
|
87
|
+
|
|
88
|
+
/** Past this a file is diffed but never read whole, so no gap of it can be opened. */
|
|
89
|
+
const CONTEXT_BYTE_LIMIT = 1_000_000;
|
|
90
|
+
|
|
91
|
+
/** Most lines one `readLines` hands back, however many spans asked. */
|
|
92
|
+
const CONTEXT_LINE_LIMIT = 2000;
|
|
93
|
+
|
|
94
|
+
const EMPTY_SHA = /^0+$/;
|
|
95
|
+
|
|
96
|
+
const BINARY_PATCH = /^(?:Binary files |GIT binary patch)/m;
|
|
97
|
+
|
|
98
|
+
/** One `git` read; `--no-optional-locks` or it takes `index.lock` and fails an agent's write beside it. */
|
|
99
|
+
function git(cwd: string, args: readonly string[]): Promise<ProcResult> {
|
|
100
|
+
return Proc.run(["git", "--no-optional-locks", ...args], { cwd });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function read(
|
|
104
|
+
cwd: string,
|
|
105
|
+
args: readonly string[],
|
|
106
|
+
fallback: string
|
|
107
|
+
): Promise<string> {
|
|
108
|
+
const result = await git(cwd, args);
|
|
109
|
+
if (result.code !== 0) {
|
|
110
|
+
throw new Error(Git.failure(result, fallback));
|
|
111
|
+
}
|
|
112
|
+
return result.stdout;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether the branch checked out has a commit on it. A repository whose first
|
|
117
|
+
* commit is still unwritten has an unborn `HEAD`: it names nothing, and every
|
|
118
|
+
* `git diff HEAD` of it fails outright instead of calling the tree new.
|
|
119
|
+
*/
|
|
120
|
+
async function born(cwd: string): Promise<boolean> {
|
|
121
|
+
const head = await git(cwd, ["rev-parse", "--quiet", "--verify", "HEAD"]);
|
|
122
|
+
return head.code === 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The base an unborn `HEAD` is diffed against, so that every file reads as
|
|
127
|
+
* added. `hash-object` names git's empty tree without writing it, and names it
|
|
128
|
+
* right in a sha256 repository too, where the familiar `4b825dc…` is not it.
|
|
129
|
+
*/
|
|
130
|
+
async function emptyTree(cwd: string): Promise<string> {
|
|
131
|
+
const sha = await read(
|
|
132
|
+
cwd,
|
|
133
|
+
["hash-object", "-t", "tree", "/dev/null"],
|
|
134
|
+
"could not name the empty tree"
|
|
135
|
+
);
|
|
136
|
+
return sha.trim();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** What every `git diff` of this base is asked about; `branch` resolves to the merge base first. */
|
|
140
|
+
async function baseArgs(
|
|
141
|
+
cwd: string,
|
|
142
|
+
base: DiffBase
|
|
143
|
+
): Promise<readonly string[]> {
|
|
144
|
+
switch (base.kind) {
|
|
145
|
+
case "worktree":
|
|
146
|
+
return (await born(cwd)) ? ["HEAD"] : [await emptyTree(cwd)];
|
|
147
|
+
case "unstaged":
|
|
148
|
+
return [];
|
|
149
|
+
case "staged":
|
|
150
|
+
return ["--cached"];
|
|
151
|
+
case "commit":
|
|
152
|
+
return [base.ref];
|
|
153
|
+
case "branch": {
|
|
154
|
+
// Nothing is shared with a branch when this one has no commits at all.
|
|
155
|
+
if (!(await born(cwd))) {
|
|
156
|
+
return [await emptyTree(cwd)];
|
|
157
|
+
}
|
|
158
|
+
const merged = await read(
|
|
159
|
+
cwd,
|
|
160
|
+
["merge-base", "HEAD", base.ref],
|
|
161
|
+
`no commit is shared by HEAD and ${base.ref}`
|
|
162
|
+
);
|
|
163
|
+
return [merged.trim()];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
type Entry = {
|
|
169
|
+
readonly path: string;
|
|
170
|
+
readonly oldPath?: string;
|
|
171
|
+
readonly status: ChangeStatus;
|
|
172
|
+
readonly baseSha?: string;
|
|
173
|
+
readonly headSha?: string;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
type Counts = {
|
|
177
|
+
readonly added: number;
|
|
178
|
+
readonly removed: number;
|
|
179
|
+
readonly binary: boolean;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
function statusOf(code: string): ChangeStatus {
|
|
183
|
+
const letter = code[0];
|
|
184
|
+
if (letter === "A" || letter === "C") {
|
|
185
|
+
return "added";
|
|
186
|
+
}
|
|
187
|
+
if (letter === "D") {
|
|
188
|
+
return "deleted";
|
|
189
|
+
}
|
|
190
|
+
if (letter === "R") {
|
|
191
|
+
return "renamed";
|
|
192
|
+
}
|
|
193
|
+
return "modified";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function shaOf(value: string | undefined): string | undefined {
|
|
197
|
+
return value === undefined || value === "" || EMPTY_SHA.test(value)
|
|
198
|
+
? undefined
|
|
199
|
+
: value;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** `:<oldmode> <newmode> <oldsha> <newsha> <status>\0<path>\0`, a second path when the status is a rename or copy. */
|
|
203
|
+
function parseRaw(text: string): readonly Entry[] {
|
|
204
|
+
const fields = text.split("\0");
|
|
205
|
+
const entries: Entry[] = [];
|
|
206
|
+
let index = 0;
|
|
207
|
+
|
|
208
|
+
while (index < fields.length) {
|
|
209
|
+
const header = fields[index];
|
|
210
|
+
index += 1;
|
|
211
|
+
if (header === undefined || !header.startsWith(":")) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const [, , baseSha, headSha, code] = header.slice(1).split(" ");
|
|
215
|
+
const first = fields[index];
|
|
216
|
+
index += 1;
|
|
217
|
+
if (code === undefined || first === undefined || first === "") {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const moved = code.startsWith("R") || code.startsWith("C");
|
|
221
|
+
const second = moved ? fields[index] : undefined;
|
|
222
|
+
if (moved) {
|
|
223
|
+
index += 1;
|
|
224
|
+
}
|
|
225
|
+
const path = moved ? second : first;
|
|
226
|
+
if (path === undefined || path === "") {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const from = shaOf(baseSha);
|
|
230
|
+
const to = shaOf(headSha);
|
|
231
|
+
entries.push({
|
|
232
|
+
path,
|
|
233
|
+
...(moved ? { oldPath: first } : {}),
|
|
234
|
+
status: statusOf(code),
|
|
235
|
+
...(from === undefined ? {} : { baseSha: from }),
|
|
236
|
+
...(to === undefined ? {} : { headSha: to }),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return entries;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** `<added>\t<removed>\t<path>\0`; a rename leaves the path empty and follows with the old and new ones. */
|
|
244
|
+
function parseNumstat(text: string): ReadonlyMap<string, Counts> {
|
|
245
|
+
const fields = text.split("\0");
|
|
246
|
+
const counts = new Map<string, Counts>();
|
|
247
|
+
let index = 0;
|
|
248
|
+
|
|
249
|
+
while (index < fields.length) {
|
|
250
|
+
const field = fields[index];
|
|
251
|
+
index += 1;
|
|
252
|
+
if (field === undefined || field === "") {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const firstTab = field.indexOf("\t");
|
|
256
|
+
const secondTab = field.indexOf("\t", firstTab + 1);
|
|
257
|
+
if (firstTab < 0 || secondTab < 0) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const added = field.slice(0, firstTab);
|
|
261
|
+
const removed = field.slice(firstTab + 1, secondTab);
|
|
262
|
+
let path = field.slice(secondTab + 1);
|
|
263
|
+
if (path === "") {
|
|
264
|
+
index += 1;
|
|
265
|
+
path = fields[index] ?? "";
|
|
266
|
+
index += 1;
|
|
267
|
+
}
|
|
268
|
+
if (path === "") {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
counts.set(path, {
|
|
272
|
+
added: Number(added) || 0,
|
|
273
|
+
removed: Number(removed) || 0,
|
|
274
|
+
binary: added === "-",
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return counts;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Only the `?` entries of porcelain v2; a rename entry carries a second field that is a path, not a record. */
|
|
282
|
+
function parseUntracked(text: string): readonly string[] {
|
|
283
|
+
const fields = text.split("\0");
|
|
284
|
+
const paths: string[] = [];
|
|
285
|
+
let index = 0;
|
|
286
|
+
|
|
287
|
+
while (index < fields.length) {
|
|
288
|
+
const field = fields[index];
|
|
289
|
+
index += 1;
|
|
290
|
+
if (field === undefined || field === "") {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (field.startsWith("2 ")) {
|
|
294
|
+
index += 1;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (field.startsWith("? ")) {
|
|
298
|
+
paths.push(field.slice(2));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return paths;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function untrackedPaths(cwd: string): Promise<readonly string[]> {
|
|
306
|
+
return read(
|
|
307
|
+
cwd,
|
|
308
|
+
["status", "--porcelain=v2", "-z", "--untracked-files=all"],
|
|
309
|
+
"could not read the working tree"
|
|
310
|
+
).then(parseUntracked);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
type Untracked = {
|
|
314
|
+
readonly binary: boolean;
|
|
315
|
+
readonly text: string;
|
|
316
|
+
readonly lines: number;
|
|
317
|
+
readonly mtimeMs: number;
|
|
318
|
+
readonly bytes: number;
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
async function readUntracked(cwd: string, path: string): Promise<Untracked> {
|
|
322
|
+
const file = Bun.file(join(cwd, path));
|
|
323
|
+
const mtimeMs = file.lastModified;
|
|
324
|
+
const opaque: Untracked = {
|
|
325
|
+
binary: true,
|
|
326
|
+
text: "",
|
|
327
|
+
lines: 0,
|
|
328
|
+
mtimeMs,
|
|
329
|
+
bytes: file.size,
|
|
330
|
+
};
|
|
331
|
+
if (file.size > UNTRACKED_BYTE_LIMIT) {
|
|
332
|
+
return opaque;
|
|
333
|
+
}
|
|
334
|
+
const bytes = await file.bytes().catch(() => undefined);
|
|
335
|
+
if (bytes === undefined) {
|
|
336
|
+
return opaque;
|
|
337
|
+
}
|
|
338
|
+
if (Lines.isBinaryBytes(bytes)) {
|
|
339
|
+
return opaque;
|
|
340
|
+
}
|
|
341
|
+
const text = new TextDecoder().decode(bytes);
|
|
342
|
+
return {
|
|
343
|
+
binary: false,
|
|
344
|
+
text,
|
|
345
|
+
lines: DiffLines.fromText(text).lines.length,
|
|
346
|
+
mtimeMs,
|
|
347
|
+
bytes: bytes.length,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function fingerprintOf(
|
|
352
|
+
cwd: string,
|
|
353
|
+
entry: Entry,
|
|
354
|
+
added: number,
|
|
355
|
+
removed: number
|
|
356
|
+
): string {
|
|
357
|
+
if (entry.status === "deleted") {
|
|
358
|
+
return `deleted:${entry.baseSha ?? ""}`;
|
|
359
|
+
}
|
|
360
|
+
if (entry.headSha !== undefined) {
|
|
361
|
+
return entry.headSha;
|
|
362
|
+
}
|
|
363
|
+
return `${added}:${removed}:${Bun.file(join(cwd, entry.path)).lastModified}`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function summaryOf(
|
|
367
|
+
cwd: string,
|
|
368
|
+
entry: Entry,
|
|
369
|
+
counts: Counts | undefined
|
|
370
|
+
): ChangeSummary {
|
|
371
|
+
const added = counts?.added ?? 0;
|
|
372
|
+
const removed = counts?.removed ?? 0;
|
|
373
|
+
return {
|
|
374
|
+
path: entry.path,
|
|
375
|
+
...(entry.oldPath === undefined ? {} : { oldPath: entry.oldPath }),
|
|
376
|
+
status: entry.status,
|
|
377
|
+
added,
|
|
378
|
+
removed,
|
|
379
|
+
...(counts?.binary === true ? { binary: true } : {}),
|
|
380
|
+
...(entry.baseSha === undefined ? {} : { baseSha: entry.baseSha }),
|
|
381
|
+
...(entry.headSha === undefined ? {} : { headSha: entry.headSha }),
|
|
382
|
+
fingerprint: fingerprintOf(cwd, entry, added, removed),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function untrackedSummary(
|
|
387
|
+
cwd: string,
|
|
388
|
+
path: string
|
|
389
|
+
): Promise<ChangeSummary> {
|
|
390
|
+
const file = await readUntracked(cwd, path);
|
|
391
|
+
const added = file.binary ? 0 : file.lines;
|
|
392
|
+
return {
|
|
393
|
+
path,
|
|
394
|
+
status: "untracked",
|
|
395
|
+
added,
|
|
396
|
+
removed: 0,
|
|
397
|
+
...(file.binary ? { binary: true } : {}),
|
|
398
|
+
fingerprint: `${added}:0:${file.mtimeMs}`,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function collect(base: DiffBase, files: readonly ChangeSummary[]): ChangeList {
|
|
403
|
+
const kept = files.slice(0, FILE_LIMIT);
|
|
404
|
+
return {
|
|
405
|
+
base,
|
|
406
|
+
files: kept,
|
|
407
|
+
added: kept.reduce((total, file) => total + file.added, 0),
|
|
408
|
+
removed: kept.reduce((total, file) => total + file.removed, 0),
|
|
409
|
+
...(files.length > kept.length ? { truncated: true } : {}),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function rawEntries(
|
|
414
|
+
cwd: string,
|
|
415
|
+
args: readonly string[]
|
|
416
|
+
): Promise<readonly Entry[]> {
|
|
417
|
+
return parseRaw(
|
|
418
|
+
await read(
|
|
419
|
+
cwd,
|
|
420
|
+
["diff", "--raw", "-z", "--no-abbrev", "--find-renames", ...args],
|
|
421
|
+
"could not read the change list"
|
|
422
|
+
)
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function untrackedDiff(
|
|
427
|
+
cwd: string,
|
|
428
|
+
path: string,
|
|
429
|
+
context: number
|
|
430
|
+
): Promise<FileDiff> {
|
|
431
|
+
const file = await readUntracked(cwd, path);
|
|
432
|
+
if (file.binary) {
|
|
433
|
+
return { path, hunks: [], binary: true, newBytes: file.bytes };
|
|
434
|
+
}
|
|
435
|
+
const diff = DiffLines.buildToolDiff(
|
|
436
|
+
path,
|
|
437
|
+
DiffLines.emptySide,
|
|
438
|
+
DiffLines.fromText(file.text),
|
|
439
|
+
context
|
|
440
|
+
);
|
|
441
|
+
return clipped(path, diff?.hunks ?? [], false, file.lines);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
type Clip = {
|
|
445
|
+
readonly hunks: readonly ToolDiffHunk[];
|
|
446
|
+
readonly truncated: boolean;
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
/** The longest prefix of a patch that is still whole hunks, once it is too much text to parse. */
|
|
450
|
+
function clipPatch(patch: string): {
|
|
451
|
+
readonly text: string;
|
|
452
|
+
readonly cut: boolean;
|
|
453
|
+
} {
|
|
454
|
+
if (patch.length <= PATCH_TEXT_LIMIT) {
|
|
455
|
+
return { text: patch, cut: false };
|
|
456
|
+
}
|
|
457
|
+
const boundary = patch.lastIndexOf("\n@@ ", PATCH_TEXT_LIMIT);
|
|
458
|
+
return { text: boundary < 0 ? "" : patch.slice(0, boundary + 1), cut: true };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function changedOf(lines: readonly ToolDiffLine[]): number {
|
|
462
|
+
return lines.filter((line) => line.kind !== "context").length;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** A hunk cut short still has to describe itself: its spans count the lines left in it. */
|
|
466
|
+
function reflow(
|
|
467
|
+
hunk: ToolDiffHunk,
|
|
468
|
+
lines: readonly ToolDiffLine[]
|
|
469
|
+
): ToolDiffHunk {
|
|
470
|
+
return {
|
|
471
|
+
...hunk,
|
|
472
|
+
oldLines: lines.filter((line) => line.kind !== "added").length,
|
|
473
|
+
newLines: lines.filter((line) => line.kind !== "removed").length,
|
|
474
|
+
lines,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function headOf(
|
|
479
|
+
lines: readonly ToolDiffLine[],
|
|
480
|
+
budget: number
|
|
481
|
+
): readonly ToolDiffLine[] {
|
|
482
|
+
const kept: ToolDiffLine[] = [];
|
|
483
|
+
let left = budget;
|
|
484
|
+
for (const line of lines) {
|
|
485
|
+
if (line.kind !== "context") {
|
|
486
|
+
if (left === 0) {
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
left -= 1;
|
|
490
|
+
}
|
|
491
|
+
kept.push(line);
|
|
492
|
+
}
|
|
493
|
+
return kept;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Hunks while the changed-line budget lasts, the one that overruns it cut short. */
|
|
497
|
+
function clipHunks(hunks: readonly ToolDiffHunk[]): Clip {
|
|
498
|
+
let budget = CHANGED_LINE_LIMIT;
|
|
499
|
+
const kept: ToolDiffHunk[] = [];
|
|
500
|
+
|
|
501
|
+
for (const hunk of hunks) {
|
|
502
|
+
const changed = changedOf(hunk.lines);
|
|
503
|
+
if (changed <= budget) {
|
|
504
|
+
kept.push(hunk);
|
|
505
|
+
budget -= changed;
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
const lines = headOf(hunk.lines, budget);
|
|
509
|
+
if (changedOf(lines) > 0) {
|
|
510
|
+
kept.push(reflow(hunk, lines));
|
|
511
|
+
}
|
|
512
|
+
return { hunks: kept, truncated: true };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return { hunks: kept, truncated: false };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function clipped(
|
|
519
|
+
path: string,
|
|
520
|
+
hunks: readonly ToolDiffHunk[],
|
|
521
|
+
cut: boolean,
|
|
522
|
+
total: number | undefined
|
|
523
|
+
): FileDiff {
|
|
524
|
+
const clip = clipHunks(hunks);
|
|
525
|
+
const whole = !clip.truncated && !cut;
|
|
526
|
+
return {
|
|
527
|
+
path,
|
|
528
|
+
hunks: clip.hunks,
|
|
529
|
+
...(whole ? {} : { truncated: true }),
|
|
530
|
+
...(total === undefined || !whole ? {} : { total }),
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* The text of the side a gap is read from: the new one, which is the index
|
|
536
|
+
* blob where the base has staged it and the working tree everywhere else. A
|
|
537
|
+
* file too big to hold, or gone from the side asked for, has no text at all.
|
|
538
|
+
*/
|
|
539
|
+
async function sideText(
|
|
540
|
+
cwd: string,
|
|
541
|
+
path: string,
|
|
542
|
+
entry: Entry | undefined
|
|
543
|
+
): Promise<string | undefined> {
|
|
544
|
+
if (entry?.headSha !== undefined) {
|
|
545
|
+
const result = await git(cwd, ["cat-file", "blob", entry.headSha]);
|
|
546
|
+
return result.code === 0 && result.stdout.length <= CONTEXT_BYTE_LIMIT
|
|
547
|
+
? result.stdout
|
|
548
|
+
: undefined;
|
|
549
|
+
}
|
|
550
|
+
const file = Bun.file(join(cwd, path));
|
|
551
|
+
if (file.size > CONTEXT_BYTE_LIMIT || !(await file.exists())) {
|
|
552
|
+
return undefined;
|
|
553
|
+
}
|
|
554
|
+
return await file.text().catch(() => undefined);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function totalOf(text: string | undefined): number | undefined {
|
|
558
|
+
return text === undefined ? undefined : DiffLines.fromText(text).lines.length;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async function blobSize(
|
|
562
|
+
cwd: string,
|
|
563
|
+
sha: string | undefined
|
|
564
|
+
): Promise<number | undefined> {
|
|
565
|
+
if (sha === undefined) {
|
|
566
|
+
return undefined;
|
|
567
|
+
}
|
|
568
|
+
const result = await git(cwd, ["cat-file", "-s", sha]);
|
|
569
|
+
const size = Number(result.stdout.trim());
|
|
570
|
+
return result.code === 0 && Number.isFinite(size) ? size : undefined;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function worktreeSize(
|
|
574
|
+
cwd: string,
|
|
575
|
+
path: string
|
|
576
|
+
): Promise<number | undefined> {
|
|
577
|
+
const file = Bun.file(join(cwd, path));
|
|
578
|
+
return (await file.exists()) ? file.size : undefined;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** A binary file is a row, not a diff: all a reader is told is how big each side is. */
|
|
582
|
+
async function binaryDiff(
|
|
583
|
+
cwd: string,
|
|
584
|
+
path: string,
|
|
585
|
+
entry: Entry | undefined
|
|
586
|
+
): Promise<FileDiff> {
|
|
587
|
+
const [oldBytes, newBytes] = await Promise.all([
|
|
588
|
+
blobSize(cwd, entry?.baseSha),
|
|
589
|
+
entry?.headSha === undefined
|
|
590
|
+
? worktreeSize(cwd, path)
|
|
591
|
+
: blobSize(cwd, entry.headSha),
|
|
592
|
+
]);
|
|
593
|
+
return {
|
|
594
|
+
path,
|
|
595
|
+
hunks: [],
|
|
596
|
+
binary: true,
|
|
597
|
+
...(oldBytes === undefined ? {} : { oldBytes }),
|
|
598
|
+
...(newBytes === undefined ? {} : { newBytes }),
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Every changed file of one base, in git's own order and without a hunk of any of them. */
|
|
603
|
+
async function listChanges(
|
|
604
|
+
cwd: string,
|
|
605
|
+
base: DiffBase,
|
|
606
|
+
monitor: GitMonitor
|
|
607
|
+
): Promise<ChangeList> {
|
|
608
|
+
return await serialise(monitor, cwd, async () => {
|
|
609
|
+
const args = await baseArgs(cwd, base);
|
|
610
|
+
const [entries, numstat] = await Promise.all([
|
|
611
|
+
rawEntries(cwd, args),
|
|
612
|
+
read(
|
|
613
|
+
cwd,
|
|
614
|
+
["diff", "--numstat", "-z", "--find-renames", ...args],
|
|
615
|
+
"could not count the changes"
|
|
616
|
+
),
|
|
617
|
+
]);
|
|
618
|
+
const counts = parseNumstat(numstat);
|
|
619
|
+
const files = entries.map((entry) =>
|
|
620
|
+
summaryOf(cwd, entry, counts.get(entry.path))
|
|
621
|
+
);
|
|
622
|
+
if (base.kind !== "worktree") {
|
|
623
|
+
return collect(base, files);
|
|
624
|
+
}
|
|
625
|
+
const untracked = await Promise.all(
|
|
626
|
+
(await untrackedPaths(cwd)).map((path) => untrackedSummary(cwd, path))
|
|
627
|
+
);
|
|
628
|
+
return collect(base, [...files, ...untracked]);
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** One file's hunks; a renamed file is diffed against the path it came from, an untracked one against nothing. */
|
|
633
|
+
async function fileDiff(
|
|
634
|
+
cwd: string,
|
|
635
|
+
base: DiffBase,
|
|
636
|
+
path: string,
|
|
637
|
+
monitor: GitMonitor,
|
|
638
|
+
context: number = DEFAULT_CONTEXT
|
|
639
|
+
): Promise<FileDiff> {
|
|
640
|
+
return await serialise(monitor, cwd, async () => {
|
|
641
|
+
const args = await baseArgs(cwd, base);
|
|
642
|
+
const entry = (await rawEntries(cwd, args)).find(
|
|
643
|
+
(candidate) => candidate.path === path
|
|
644
|
+
);
|
|
645
|
+
if (
|
|
646
|
+
entry === undefined &&
|
|
647
|
+
base.kind === "worktree" &&
|
|
648
|
+
(await untrackedPaths(cwd)).includes(path)
|
|
649
|
+
) {
|
|
650
|
+
return await untrackedDiff(cwd, path, context);
|
|
651
|
+
}
|
|
652
|
+
const paths = entry?.oldPath === undefined ? [path] : [entry.oldPath, path];
|
|
653
|
+
const [patch, text] = await Promise.all([
|
|
654
|
+
read(
|
|
655
|
+
cwd,
|
|
656
|
+
[
|
|
657
|
+
"diff",
|
|
658
|
+
"--no-color",
|
|
659
|
+
`-U${String(context)}`,
|
|
660
|
+
"--find-renames",
|
|
661
|
+
...args,
|
|
662
|
+
"--",
|
|
663
|
+
...paths,
|
|
664
|
+
],
|
|
665
|
+
`could not diff ${path}`
|
|
666
|
+
),
|
|
667
|
+
sideText(cwd, path, entry),
|
|
668
|
+
]);
|
|
669
|
+
if (BINARY_PATCH.test(patch)) {
|
|
670
|
+
return await binaryDiff(cwd, path, entry);
|
|
671
|
+
}
|
|
672
|
+
const clip = clipPatch(patch);
|
|
673
|
+
return clipped(
|
|
674
|
+
path,
|
|
675
|
+
DiffPatch.fromUnified(path, clip.text)?.hunks ?? [],
|
|
676
|
+
clip.cut,
|
|
677
|
+
totalOf(text)
|
|
678
|
+
);
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** The lines a reader asked to see of a file they are already reading a diff of, as they are. */
|
|
683
|
+
async function readLines(
|
|
684
|
+
cwd: string,
|
|
685
|
+
base: DiffBase,
|
|
686
|
+
path: string,
|
|
687
|
+
spans: readonly LineSpan[],
|
|
688
|
+
monitor: GitMonitor
|
|
689
|
+
): Promise<FileLines> {
|
|
690
|
+
return await serialise(monitor, cwd, async () => {
|
|
691
|
+
const args = await baseArgs(cwd, base);
|
|
692
|
+
const entry = (await rawEntries(cwd, args)).find(
|
|
693
|
+
(candidate) => candidate.path === path
|
|
694
|
+
);
|
|
695
|
+
const text = await sideText(cwd, path, entry);
|
|
696
|
+
if (text === undefined) {
|
|
697
|
+
throw new Error(`could not read ${path}`);
|
|
698
|
+
}
|
|
699
|
+
const lines = DiffLines.fromText(text).lines;
|
|
700
|
+
const runs: LineRun[] = [];
|
|
701
|
+
let budget = CONTEXT_LINE_LIMIT;
|
|
702
|
+
|
|
703
|
+
for (const span of spans) {
|
|
704
|
+
const start = Math.max(1, Math.trunc(span.start));
|
|
705
|
+
const end = Math.min(
|
|
706
|
+
lines.length,
|
|
707
|
+
Math.trunc(span.end),
|
|
708
|
+
start + budget - 1
|
|
709
|
+
);
|
|
710
|
+
if (end < start) {
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
runs.push({ start, lines: lines.slice(start - 1, end) });
|
|
714
|
+
budget -= end - start + 1;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return { path, runs };
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Reading is serialised against the checkouts and pulls that move the tree
|
|
723
|
+
* under it; a read that arrives while one runs is refused rather than answered
|
|
724
|
+
* from half a working tree.
|
|
725
|
+
*/
|
|
726
|
+
async function serialise<T>(
|
|
727
|
+
monitor: GitMonitor,
|
|
728
|
+
cwd: string,
|
|
729
|
+
work: () => Promise<T>
|
|
730
|
+
): Promise<T> {
|
|
731
|
+
const outcome = await monitor.run<T>(cwd, async () => ({
|
|
732
|
+
ok: true,
|
|
733
|
+
value: await work(),
|
|
734
|
+
}));
|
|
735
|
+
if (outcome.value === undefined) {
|
|
736
|
+
throw new Error(outcome.ok ? `could not read ${cwd}` : outcome.error);
|
|
737
|
+
}
|
|
738
|
+
return outcome.value;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
export const RepoDiff = { listChanges, fileDiff, readLines };
|