@diegopetrucci/pi-extensions 0.1.42 → 0.1.44

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.
Files changed (52) hide show
  1. package/README.md +4 -1
  2. package/extensions/annotate-git-diff/.pi-fleet-tested-version +1 -0
  3. package/extensions/annotate-git-diff/README.md +43 -0
  4. package/extensions/annotate-git-diff/clipboard.ts +143 -0
  5. package/extensions/annotate-git-diff/git.ts +943 -0
  6. package/extensions/annotate-git-diff/glimpseui.d.ts +89 -0
  7. package/extensions/annotate-git-diff/index.ts +412 -0
  8. package/extensions/annotate-git-diff/package.json +50 -0
  9. package/extensions/annotate-git-diff/prompt.ts +65 -0
  10. package/extensions/annotate-git-diff/quiet-glimpse.ts +156 -0
  11. package/extensions/annotate-git-diff/types.ts +202 -0
  12. package/extensions/annotate-git-diff/ui.ts +71 -0
  13. package/extensions/annotate-git-diff/watch.ts +104 -0
  14. package/extensions/annotate-git-diff/web/app.js +2381 -0
  15. package/extensions/annotate-git-diff/web/index.html +913 -0
  16. package/extensions/annotate-last-message/.pi-fleet-tested-version +1 -0
  17. package/extensions/annotate-last-message/README.md +40 -0
  18. package/extensions/annotate-last-message/glimpseui.d.ts +89 -0
  19. package/extensions/annotate-last-message/index.ts +165 -0
  20. package/extensions/annotate-last-message/package.json +45 -0
  21. package/extensions/annotate-last-message/prompt.ts +93 -0
  22. package/extensions/annotate-last-message/quiet-glimpse.ts +156 -0
  23. package/extensions/annotate-last-message/session.ts +112 -0
  24. package/extensions/annotate-last-message/types.ts +46 -0
  25. package/extensions/annotate-last-message/ui.ts +23 -0
  26. package/extensions/annotate-last-message/web/app.js +229 -0
  27. package/extensions/annotate-last-message/web/index.html +322 -0
  28. package/extensions/illustrations-to-explain-things/.pi-fleet-tested-version +1 -0
  29. package/extensions/illustrations-to-explain-things/README.md +52 -0
  30. package/extensions/illustrations-to-explain-things/package.json +31 -0
  31. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/SKILL.md +112 -0
  32. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/agents/openai.yaml +6 -0
  33. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/01-two-breakpoints.png +0 -0
  34. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/02-minimum-loop.png +0 -0
  35. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/03-sort-by-purpose.png +0 -0
  36. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/04-one-fish-many-uses.png +0 -0
  37. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/05-handoff-path.png +0 -0
  38. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/06-three-sources.png +0 -0
  39. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/07-three-content-jobs.png +0 -0
  40. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/08-handoff-copy-toolbox.png +0 -0
  41. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/09-common-pits-no-title.png +0 -0
  42. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/10-information-well.png +0 -0
  43. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/11-idea-press.png +0 -0
  44. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/12-content-fermentation.png +0 -0
  45. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/13-system-bearing.png +0 -0
  46. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/14-trust-bridge.png +0 -0
  47. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/references/composition-patterns.md +91 -0
  48. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/references/prompt-template.md +51 -0
  49. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/references/qa-checklist.md +48 -0
  50. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/references/style-dna.md +49 -0
  51. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/references/xiaohei-ip.md +53 -0
  52. package/package.json +18 -8
@@ -0,0 +1,943 @@
1
+ import { lstatSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { extname, relative, resolve } from "node:path";
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import type {
6
+ ChangeStatus,
7
+ ReviewCommitInfo,
8
+ ReviewFile,
9
+ ReviewFileComparison,
10
+ ReviewFileContents,
11
+ ReviewFileKind,
12
+ ReviewScope,
13
+ } from "./types.js";
14
+
15
+ interface ChangedPath {
16
+ status: ChangeStatus;
17
+ oldPath: string | null;
18
+ newPath: string | null;
19
+ }
20
+
21
+ interface ReviewBaseInfo {
22
+ mergeBase: string;
23
+ baseRef: string;
24
+ }
25
+
26
+ interface WorkingTreeStatusInfo {
27
+ hasChanges: boolean;
28
+ hasReviewableChanges: boolean;
29
+ hasUntracked: boolean;
30
+ hasTrackedDeletions: boolean;
31
+ hasRenames: boolean;
32
+ untrackedPaths: string[];
33
+ }
34
+
35
+ const WORKING_TREE_COMMIT_SHA = "__tlh_working_tree__";
36
+ const WORKING_TREE_COMMIT_SHORT_SHA = "WT";
37
+ const WORKING_TREE_COMMIT_SUBJECT = "Uncommitted changes";
38
+
39
+ export function isWorkingTreeCommitSha(sha: string): boolean {
40
+ return sha === WORKING_TREE_COMMIT_SHA;
41
+ }
42
+
43
+ function createWorkingTreeCommitInfo(): ReviewCommitInfo {
44
+ return {
45
+ sha: WORKING_TREE_COMMIT_SHA,
46
+ shortSha: WORKING_TREE_COMMIT_SHORT_SHA,
47
+ subject: WORKING_TREE_COMMIT_SUBJECT,
48
+ authorName: "",
49
+ authorDate: "",
50
+ kind: "working-tree",
51
+ };
52
+ }
53
+
54
+ async function runGitAllowFailure(pi: ExtensionAPI, repoRoot: string, args: string[]): Promise<string> {
55
+ const result = await pi.exec("git", args, { cwd: repoRoot });
56
+ if (result.code !== 0) {
57
+ return "";
58
+ }
59
+ return result.stdout;
60
+ }
61
+
62
+ async function runBashAllowFailure(pi: ExtensionAPI, repoRoot: string, script: string): Promise<string> {
63
+ const result = await pi.exec("bash", ["-lc", script], { cwd: repoRoot });
64
+ if (result.code !== 0) {
65
+ return "";
66
+ }
67
+ return result.stdout;
68
+ }
69
+
70
+ export async function getRepoRoot(pi: ExtensionAPI, cwd: string): Promise<string> {
71
+ const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd });
72
+ if (result.code !== 0) {
73
+ throw new Error("Not inside a git repository.");
74
+ }
75
+ return result.stdout.trim();
76
+ }
77
+
78
+ async function hasHead(pi: ExtensionAPI, repoRoot: string): Promise<boolean> {
79
+ const result = await pi.exec("git", ["rev-parse", "--verify", "HEAD"], { cwd: repoRoot });
80
+ return result.code === 0;
81
+ }
82
+
83
+ async function currentBranch(pi: ExtensionAPI, repoRoot: string): Promise<string> {
84
+ const result = await pi.exec("git", ["branch", "--show-current"], { cwd: repoRoot });
85
+ return result.code === 0 ? result.stdout.trim() || "HEAD" : "HEAD";
86
+ }
87
+
88
+ async function getUpstreamRef(pi: ExtensionAPI, repoRoot: string): Promise<string | null> {
89
+ const output = await runGitAllowFailure(pi, repoRoot, [
90
+ "rev-parse",
91
+ "--abbrev-ref",
92
+ "--symbolic-full-name",
93
+ "@{upstream}",
94
+ ]);
95
+ const value = output.trim();
96
+ return value.length > 0 ? value : null;
97
+ }
98
+
99
+ async function getOriginHeadRef(pi: ExtensionAPI, repoRoot: string): Promise<string | null> {
100
+ const output = await runGitAllowFailure(pi, repoRoot, ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"]);
101
+ const value = output.trim();
102
+ return value.length > 0 ? value : null;
103
+ }
104
+
105
+ function isSameBranchRef(ref: string, branch: string): boolean {
106
+ if (!branch || branch === "HEAD") return false;
107
+ return ref === branch || ref.endsWith(`/${branch}`);
108
+ }
109
+
110
+ async function findReviewBase(pi: ExtensionAPI, repoRoot: string): Promise<ReviewBaseInfo | null> {
111
+ const branch = await currentBranch(pi, repoRoot);
112
+ const candidates: string[] = [];
113
+ const upstreamRef = await getUpstreamRef(pi, repoRoot);
114
+ if (upstreamRef && !isSameBranchRef(upstreamRef, branch)) {
115
+ candidates.push(upstreamRef);
116
+ }
117
+
118
+ const originHeadRef = await getOriginHeadRef(pi, repoRoot);
119
+ if (originHeadRef) {
120
+ candidates.push(originHeadRef);
121
+ }
122
+
123
+ candidates.push("origin/main", "origin/master", "origin/develop", "main", "master", "develop");
124
+
125
+ const seen = new Set<string>();
126
+ for (const candidate of candidates) {
127
+ if (!candidate || seen.has(candidate)) continue;
128
+ seen.add(candidate);
129
+ const mergeBase = (await runGitAllowFailure(pi, repoRoot, ["merge-base", "HEAD", candidate])).trim();
130
+ if (mergeBase.length > 0) {
131
+ return { mergeBase, baseRef: candidate };
132
+ }
133
+ }
134
+
135
+ return null;
136
+ }
137
+
138
+ function parseNameStatusLine(parts: string[]): ChangedPath | null {
139
+ const code = (parts[0] ?? "")[0];
140
+
141
+ if (code === "R") {
142
+ const oldPath = parts[1] ?? null;
143
+ const newPath = parts[2] ?? null;
144
+ if (oldPath == null || newPath == null) return null;
145
+ return { status: "renamed", oldPath, newPath };
146
+ }
147
+
148
+ const path = parts[1] ?? null;
149
+ if (path == null) return null;
150
+
151
+ if (code === "M") return { status: "modified", oldPath: path, newPath: path };
152
+ if (code === "A") return { status: "added", oldPath: null, newPath: path };
153
+ if (code === "D") return { status: "deleted", oldPath: path, newPath: null };
154
+ return null;
155
+ }
156
+
157
+ function parseNameStatus(output: string): ChangedPath[] {
158
+ const lines = output
159
+ .split(/\r?\n/)
160
+ .map((line) => line.trim())
161
+ .filter((line) => line.length > 0);
162
+
163
+ const changes: ChangedPath[] = [];
164
+ for (const line of lines) {
165
+ const change = parseNameStatusLine(line.split("\t"));
166
+ if (change != null) changes.push(change);
167
+ }
168
+ return changes;
169
+ }
170
+
171
+ function parseStatusPorcelainZ(output: string): WorkingTreeStatusInfo {
172
+ const info: WorkingTreeStatusInfo = {
173
+ hasChanges: false,
174
+ hasReviewableChanges: false,
175
+ hasUntracked: false,
176
+ hasTrackedDeletions: false,
177
+ hasRenames: false,
178
+ untrackedPaths: [],
179
+ };
180
+ const tokens = output.split("\0");
181
+
182
+ for (let index = 0; index < tokens.length; ) {
183
+ const token = tokens[index] ?? "";
184
+ if (token.length === 0) {
185
+ index += 1;
186
+ continue;
187
+ }
188
+
189
+ const code = token.slice(0, 2);
190
+ const path = token.slice(3);
191
+ const isRenameOrCopy = code.includes("R") || code.includes("C");
192
+ const isReviewablePath = code !== "!!" && path.length > 0 && isIncludedReviewPath(path);
193
+ if (code !== "!!") {
194
+ info.hasChanges = true;
195
+ }
196
+ if (isReviewablePath) {
197
+ info.hasReviewableChanges = true;
198
+ }
199
+ if (code === "??") {
200
+ if (isReviewablePath) {
201
+ info.hasUntracked = true;
202
+ info.untrackedPaths.push(path);
203
+ }
204
+ } else if (isReviewablePath) {
205
+ if (code.includes("D")) info.hasTrackedDeletions = true;
206
+ if (isRenameOrCopy) info.hasRenames = true;
207
+ }
208
+
209
+ index += isRenameOrCopy ? 2 : 1;
210
+ }
211
+
212
+ return info;
213
+ }
214
+
215
+ async function getWorkingTreeStatusInfo(pi: ExtensionAPI, repoRoot: string): Promise<WorkingTreeStatusInfo> {
216
+ const output = await runGitAllowFailure(pi, repoRoot, ["status", "--porcelain=1", "--untracked-files=all", "-z"]);
217
+ return parseStatusPorcelainZ(output);
218
+ }
219
+
220
+ function toDisplayPath(change: ChangedPath): string {
221
+ if (change.status === "renamed") {
222
+ return `${change.oldPath ?? ""} -> ${change.newPath ?? ""}`;
223
+ }
224
+ return change.newPath ?? change.oldPath ?? "(unknown)";
225
+ }
226
+
227
+ function toComparison(change: ChangedPath): ReviewFileComparison {
228
+ return {
229
+ status: change.status,
230
+ oldPath: change.oldPath,
231
+ newPath: change.newPath,
232
+ displayPath: toDisplayPath(change),
233
+ hasOriginal: change.oldPath != null,
234
+ hasModified: change.newPath != null,
235
+ };
236
+ }
237
+
238
+ function buildBranchFileId(path: string, hasWorkingTreeFile: boolean, gitDiff: ReviewFileComparison): string {
239
+ return ["branch", path, hasWorkingTreeFile ? "working" : "gone", gitDiff.displayPath].join("::");
240
+ }
241
+
242
+ function buildSnapshotFileId(path: string, hasWorkingTreeFile: boolean): string {
243
+ return ["snapshot", path, hasWorkingTreeFile ? "working" : "head"].join("::");
244
+ }
245
+
246
+ function buildCommitFileId(sha: string, comparison: ReviewFileComparison): string {
247
+ return ["commit", sha, comparison.displayPath].join("::");
248
+ }
249
+
250
+ async function getRevisionContent(pi: ExtensionAPI, repoRoot: string, revision: string, path: string): Promise<string> {
251
+ const result = await pi.exec("git", ["show", `${revision}:${path}`], { cwd: repoRoot });
252
+ if (result.code !== 0) {
253
+ return "";
254
+ }
255
+ return result.stdout;
256
+ }
257
+
258
+ const WORKING_TREE_SYMLINK_CONTENT = "[working tree symlink omitted for safety]\n";
259
+
260
+ type WorkingTreeEntry =
261
+ | { kind: "file"; absolutePath: string }
262
+ | { kind: "symlink" }
263
+ | { kind: "missing" | "other" | "unsafe" };
264
+
265
+ function inspectWorkingTreePath(repoRoot: string, path: string): WorkingTreeEntry {
266
+ const repoBase = resolve(repoRoot);
267
+ const candidate = resolve(repoBase, path);
268
+ const relativePath = relative(repoBase, candidate);
269
+ if (relativePath.length === 0 || /^(\.\.(?:[/\\]|$))/.test(relativePath)) {
270
+ return { kind: "unsafe" };
271
+ }
272
+
273
+ const parts = relativePath.split(/[\\/]+/).filter(Boolean);
274
+ let currentPath = repoBase;
275
+ for (let index = 0; index < parts.length; index += 1) {
276
+ currentPath = resolve(currentPath, parts[index]);
277
+ let stat;
278
+ try {
279
+ stat = lstatSync(currentPath);
280
+ } catch {
281
+ return { kind: "missing" };
282
+ }
283
+ const isFinal = index === parts.length - 1;
284
+ if (stat.isSymbolicLink()) {
285
+ return isFinal ? { kind: "symlink" } : { kind: "unsafe" };
286
+ }
287
+ if (!isFinal) {
288
+ if (!stat.isDirectory()) return { kind: "unsafe" };
289
+ continue;
290
+ }
291
+ if (stat.isFile()) return { kind: "file", absolutePath: currentPath };
292
+ return { kind: "other" };
293
+ }
294
+
295
+ return { kind: "unsafe" };
296
+ }
297
+
298
+ async function getWorkingTreeContent(repoRoot: string, path: string): Promise<string> {
299
+ const entry = inspectWorkingTreePath(repoRoot, path);
300
+ if (entry.kind === "symlink") return WORKING_TREE_SYMLINK_CONTENT;
301
+ if (entry.kind !== "file") return "";
302
+ try {
303
+ return await readFile(entry.absolutePath, "utf8");
304
+ } catch {
305
+ return "";
306
+ }
307
+ }
308
+
309
+ async function getWorkingTreeBytes(repoRoot: string, path: string): Promise<Buffer | null> {
310
+ const entry = inspectWorkingTreePath(repoRoot, path);
311
+ if (entry.kind !== "file") return null;
312
+ try {
313
+ return await readFile(entry.absolutePath);
314
+ } catch {
315
+ return null;
316
+ }
317
+ }
318
+
319
+ function shellQuote(value: string): string {
320
+ return `'${value.replace(/'/g, `'\\''`)}'`;
321
+ }
322
+
323
+ async function getRevisionBytes(
324
+ pi: ExtensionAPI,
325
+ repoRoot: string,
326
+ revision: string,
327
+ path: string,
328
+ ): Promise<Buffer | null> {
329
+ const spec = shellQuote(`${revision}:${path}`);
330
+ const result = await pi.exec("bash", ["-lc", `git show ${spec} | base64 | tr -d '\\n'`], { cwd: repoRoot });
331
+ if (result.code !== 0) return null;
332
+ const encoded = (result.stdout ?? "").trim();
333
+ try {
334
+ return Buffer.from(encoded, "base64");
335
+ } catch {
336
+ return null;
337
+ }
338
+ }
339
+
340
+ const imageMimeTypes = new Map<string, string>([
341
+ [".avif", "image/avif"],
342
+ [".bmp", "image/bmp"],
343
+ [".gif", "image/gif"],
344
+ [".ico", "image/x-icon"],
345
+ [".jpeg", "image/jpeg"],
346
+ [".jpg", "image/jpeg"],
347
+ [".png", "image/png"],
348
+ [".webp", "image/webp"],
349
+ ]);
350
+
351
+ const binaryExtensions = new Set([
352
+ ".7z",
353
+ ".a",
354
+ ".avi",
355
+ ".avif",
356
+ ".bin",
357
+ ".bmp",
358
+ ".class",
359
+ ".dll",
360
+ ".dylib",
361
+ ".eot",
362
+ ".exe",
363
+ ".gif",
364
+ ".gz",
365
+ ".ico",
366
+ ".jar",
367
+ ".jpeg",
368
+ ".jpg",
369
+ ".lockb",
370
+ ".map",
371
+ ".mov",
372
+ ".mp3",
373
+ ".mp4",
374
+ ".o",
375
+ ".otf",
376
+ ".pdf",
377
+ ".png",
378
+ ".pyc",
379
+ ".so",
380
+ ".svgz",
381
+ ".tar",
382
+ ".ttf",
383
+ ".wasm",
384
+ ".webm",
385
+ ".webp",
386
+ ".woff",
387
+ ".woff2",
388
+ ".zip",
389
+ ]);
390
+
391
+ function classifyFilePath(path: string): { kind: ReviewFileKind; mimeType: string | null } {
392
+ const extension = extname(path.toLowerCase());
393
+ const mimeType = imageMimeTypes.get(extension) ?? null;
394
+ if (mimeType != null) return { kind: "image", mimeType };
395
+ if (binaryExtensions.has(extension)) return { kind: "binary", mimeType: null };
396
+ return { kind: "text", mimeType: null };
397
+ }
398
+
399
+ function isIncludedReviewPath(path: string): boolean {
400
+ const lowerPath = path.toLowerCase();
401
+ const fileName = lowerPath.split("/").pop() ?? lowerPath;
402
+ return fileName.length > 0;
403
+ }
404
+
405
+ function bufferToDataUrl(buffer: Buffer, mimeType: string): string {
406
+ return `data:${mimeType};base64,${buffer.toString("base64")}`;
407
+ }
408
+
409
+ function toReviewFile(
410
+ change: ChangedPath,
411
+ options: { id: string; worktreeStatus: ChangeStatus | null; hasWorkingTreeFile: boolean },
412
+ ): ReviewFile {
413
+ const comparison = toComparison(change);
414
+ const path = change.newPath ?? change.oldPath ?? comparison.displayPath;
415
+ const meta = classifyFilePath(path);
416
+ return {
417
+ id: options.id,
418
+ path,
419
+ worktreeStatus: options.worktreeStatus,
420
+ hasWorkingTreeFile: options.hasWorkingTreeFile,
421
+ inGitDiff: true,
422
+ gitDiff: comparison,
423
+ kind: meta.kind,
424
+ mimeType: meta.mimeType,
425
+ };
426
+ }
427
+
428
+ async function loadBinarySideFromWorkingTree(
429
+ repoRoot: string,
430
+ path: string | null,
431
+ mimeType: string | null,
432
+ ): Promise<{ exists: boolean; previewUrl: string | null }> {
433
+ if (path == null) return { exists: false, previewUrl: null };
434
+ const entry = inspectWorkingTreePath(repoRoot, path);
435
+ if (entry.kind === "symlink") return { exists: true, previewUrl: null };
436
+ if (entry.kind !== "file") return { exists: false, previewUrl: null };
437
+ const bytes = await getWorkingTreeBytes(repoRoot, path);
438
+ if (bytes == null) return { exists: false, previewUrl: null };
439
+ return { exists: true, previewUrl: mimeType ? bufferToDataUrl(bytes, mimeType) : null };
440
+ }
441
+
442
+ async function loadBinarySideFromRevision(
443
+ pi: ExtensionAPI,
444
+ repoRoot: string,
445
+ revision: string,
446
+ path: string | null,
447
+ mimeType: string | null,
448
+ ): Promise<{ exists: boolean; previewUrl: string | null }> {
449
+ if (path == null) return { exists: false, previewUrl: null };
450
+ const bytes = await getRevisionBytes(pi, repoRoot, revision, path);
451
+ if (bytes == null) return { exists: false, previewUrl: null };
452
+ return { exists: true, previewUrl: mimeType ? bufferToDataUrl(bytes, mimeType) : null };
453
+ }
454
+
455
+ function mergeChangedPaths(...groups: ChangedPath[][]): ChangedPath[] {
456
+ const merged = new Map<string, ChangedPath>();
457
+ for (const group of groups) {
458
+ for (const change of group) {
459
+ const key = change.newPath ?? change.oldPath ?? "";
460
+ if (key.length === 0) continue;
461
+ merged.set(key, change);
462
+ }
463
+ }
464
+ return [...merged.values()];
465
+ }
466
+
467
+ function toUntrackedChangedPaths(paths: string[]): ChangedPath[] {
468
+ return paths.map((path) => ({ status: "added", oldPath: null, newPath: path }) satisfies ChangedPath);
469
+ }
470
+
471
+ function shouldNormalizeBranchChanges(
472
+ trackedChanges: ChangedPath[],
473
+ workingTreeStatus: WorkingTreeStatusInfo,
474
+ ): boolean {
475
+ if (workingTreeStatus.hasRenames) return true;
476
+ if (!workingTreeStatus.hasUntracked) return false;
477
+ return trackedChanges.some((change) => change.status === "deleted");
478
+ }
479
+
480
+ async function getTrackedBranchReviewChanges(
481
+ pi: ExtensionAPI,
482
+ repoRoot: string,
483
+ branchComparisonBase: string,
484
+ ): Promise<ChangedPath[]> {
485
+ return parseNameStatus(
486
+ await runGitAllowFailure(pi, repoRoot, [
487
+ "diff",
488
+ "--find-renames",
489
+ "-M",
490
+ "--name-status",
491
+ branchComparisonBase,
492
+ "--",
493
+ ]),
494
+ );
495
+ }
496
+
497
+ async function getWorkingTreeSnapshotChanges(
498
+ pi: ExtensionAPI,
499
+ repoRoot: string,
500
+ baseRevision: string | null,
501
+ ): Promise<ChangedPath[]> {
502
+ const scriptLines = [
503
+ "set -euo pipefail",
504
+ 'tmp_index=$(mktemp "/tmp/tlh-annotate-git-diff-index.XXXXXX")',
505
+ "trap 'rm -f \"$tmp_index\"' EXIT",
506
+ 'export GIT_INDEX_FILE="$tmp_index"',
507
+ ];
508
+ if (baseRevision != null) {
509
+ scriptLines.push(`git read-tree ${shellQuote(baseRevision)}`);
510
+ } else {
511
+ scriptLines.push('rm -f "$tmp_index"');
512
+ }
513
+ scriptLines.push("git add -A -- .");
514
+ scriptLines.push(
515
+ baseRevision != null
516
+ ? `git diff --cached --find-renames -M --name-status ${shellQuote(baseRevision)} --`
517
+ : "git diff --cached --find-renames -M --name-status --root --",
518
+ );
519
+ const output = await runBashAllowFailure(pi, repoRoot, scriptLines.join("\n"));
520
+ return parseNameStatus(output);
521
+ }
522
+
523
+ async function getBranchReviewChanges(
524
+ pi: ExtensionAPI,
525
+ repoRoot: string,
526
+ branchComparisonBase: string | null,
527
+ workingTreeStatus: WorkingTreeStatusInfo,
528
+ ): Promise<ChangedPath[]> {
529
+ if (!branchComparisonBase) return [];
530
+ const trackedChanges = await getTrackedBranchReviewChanges(pi, repoRoot, branchComparisonBase);
531
+ if (shouldNormalizeBranchChanges(trackedChanges, workingTreeStatus)) {
532
+ return getWorkingTreeSnapshotChanges(pi, repoRoot, branchComparisonBase);
533
+ }
534
+ return mergeChangedPaths(trackedChanges, toUntrackedChangedPaths(workingTreeStatus.untrackedPaths));
535
+ }
536
+
537
+ async function getWorkingTreeReviewChanges(
538
+ pi: ExtensionAPI,
539
+ repoRoot: string,
540
+ repositoryHasHead: boolean,
541
+ ): Promise<ChangedPath[]> {
542
+ return getWorkingTreeSnapshotChanges(pi, repoRoot, repositoryHasHead ? "HEAD" : null);
543
+ }
544
+
545
+ async function listReviewableRepositoryPaths(pi: ExtensionAPI, repoRoot: string): Promise<string[]> {
546
+ const output = await runGitAllowFailure(pi, repoRoot, ["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--"]);
547
+ const seen = new Set<string>();
548
+ const paths: string[] = [];
549
+ for (const path of output.split("\0")) {
550
+ if (path.length === 0 || seen.has(path) || !isIncludedReviewPath(path)) continue;
551
+ seen.add(path);
552
+ paths.push(path);
553
+ }
554
+ return paths;
555
+ }
556
+
557
+ function hasWorkingTreeFile(repoRoot: string, path: string): boolean {
558
+ const entry = inspectWorkingTreePath(repoRoot, path);
559
+ return entry.kind === "file" || entry.kind === "symlink";
560
+ }
561
+
562
+ function toSnapshotReviewFile(repoRoot: string, path: string): ReviewFile {
563
+ const meta = classifyFilePath(path);
564
+ const workingTreeFile = hasWorkingTreeFile(repoRoot, path);
565
+ return {
566
+ id: buildSnapshotFileId(path, workingTreeFile),
567
+ path,
568
+ worktreeStatus: null,
569
+ hasWorkingTreeFile: workingTreeFile,
570
+ inGitDiff: false,
571
+ gitDiff: null,
572
+ kind: meta.kind,
573
+ mimeType: meta.mimeType,
574
+ };
575
+ }
576
+
577
+ async function getAllReviewFiles(
578
+ pi: ExtensionAPI,
579
+ repoRoot: string,
580
+ branchChanges: ChangedPath[],
581
+ ): Promise<{ allFiles: ReviewFile[]; branchFiles: ReviewFile[] }> {
582
+ const branchFiles = branchChanges.map(toBranchReviewFile);
583
+ const branchFilesByPath = new Map(branchFiles.map((file) => [file.path, file]));
584
+ const seenPaths = new Set<string>();
585
+ const allFiles: ReviewFile[] = [];
586
+
587
+ for (const path of await listReviewableRepositoryPaths(pi, repoRoot)) {
588
+ seenPaths.add(path);
589
+ allFiles.push(branchFilesByPath.get(path) ?? toSnapshotReviewFile(repoRoot, path));
590
+ }
591
+
592
+ for (const file of branchFiles) {
593
+ if (seenPaths.has(file.path)) continue;
594
+ allFiles.push(file);
595
+ }
596
+
597
+ allFiles.sort(compareReviewFiles);
598
+ branchFiles.sort(compareReviewFiles);
599
+ return { allFiles, branchFiles };
600
+ }
601
+
602
+ function compareReviewFiles(a: ReviewFile, b: ReviewFile): number {
603
+ return a.path.localeCompare(b.path);
604
+ }
605
+
606
+ function toBranchReviewFile(change: ChangedPath): ReviewFile {
607
+ const comparison = toComparison(change);
608
+ const path = change.newPath ?? change.oldPath ?? comparison.displayPath;
609
+ return toReviewFile(change, {
610
+ id: buildBranchFileId(path, change.newPath != null, comparison),
611
+ worktreeStatus: change.status,
612
+ hasWorkingTreeFile: change.newPath != null,
613
+ });
614
+ }
615
+
616
+ export async function getReviewWindowData(
617
+ pi: ExtensionAPI,
618
+ cwd: string,
619
+ ): Promise<{
620
+ repoRoot: string;
621
+ files: ReviewFile[];
622
+ commits: ReviewCommitInfo[];
623
+ branchBaseRef: string | null;
624
+ branchMergeBaseSha: string | null;
625
+ repositoryHasHead: boolean;
626
+ }> {
627
+ const repoRoot = await getRepoRoot(pi, cwd);
628
+ const repositoryHasHead = await hasHead(pi, repoRoot);
629
+ const reviewBase = repositoryHasHead ? await findReviewBase(pi, repoRoot) : null;
630
+ const branchComparisonBase = reviewBase?.mergeBase ?? (repositoryHasHead ? "HEAD" : null);
631
+ const workingTreeStatus = await getWorkingTreeStatusInfo(pi, repoRoot);
632
+ const branchChanges = repositoryHasHead
633
+ ? await getBranchReviewChanges(pi, repoRoot, branchComparisonBase, workingTreeStatus)
634
+ : await getWorkingTreeReviewChanges(pi, repoRoot, false);
635
+ const reviewableBranchChanges = branchChanges.filter((change) =>
636
+ isIncludedReviewPath(change.newPath ?? change.oldPath ?? ""),
637
+ );
638
+ const { allFiles: files, branchFiles } = await getAllReviewFiles(pi, repoRoot, reviewableBranchChanges);
639
+ const commits = reviewBase ? await listRangeCommits(pi, repoRoot, `${reviewBase.mergeBase}..HEAD`, 100) : [];
640
+ const workingTreeCommit = workingTreeStatus.hasReviewableChanges ? [createWorkingTreeCommitInfo()] : [];
641
+ const fallbackCommits =
642
+ repositoryHasHead && branchFiles.length === 0 && commits.length === 0 && !workingTreeStatus.hasReviewableChanges
643
+ ? await listRangeCommits(pi, repoRoot, "HEAD", 20)
644
+ : commits;
645
+
646
+ return {
647
+ repoRoot,
648
+ files,
649
+ commits: [...workingTreeCommit, ...fallbackCommits],
650
+ branchBaseRef: reviewBase?.baseRef ?? null,
651
+ branchMergeBaseSha: branchComparisonBase,
652
+ repositoryHasHead,
653
+ };
654
+ }
655
+
656
+ export async function listRangeCommits(
657
+ pi: ExtensionAPI,
658
+ repoRoot: string,
659
+ range: string,
660
+ limit: number,
661
+ ): Promise<ReviewCommitInfo[]> {
662
+ const sep = "\x1f";
663
+ const format = ["%H", "%h", "%s", "%an", "%aI"].join(sep);
664
+ const output = await runGitAllowFailure(pi, repoRoot, ["log", `-${limit}`, `--format=${format}`, range]);
665
+ return output
666
+ .split(/\r?\n/)
667
+ .map((line) => line.trimEnd())
668
+ .filter((line) => line.length > 0)
669
+ .map((line) => {
670
+ const [sha, shortSha, subject, authorName, authorDate] = line.split(sep);
671
+ return {
672
+ sha: sha ?? "",
673
+ shortSha: shortSha ?? (sha ?? "").slice(0, 7),
674
+ subject: subject ?? "",
675
+ authorName: authorName ?? "",
676
+ authorDate: authorDate ?? "",
677
+ kind: "commit",
678
+ } satisfies ReviewCommitInfo;
679
+ })
680
+ .filter((commit) => commit.sha.length > 0);
681
+ }
682
+
683
+ export async function getCommitFiles(pi: ExtensionAPI, repoRoot: string, sha: string): Promise<ReviewFile[]> {
684
+ if (isWorkingTreeCommitSha(sha)) {
685
+ const repositoryHasHead = await hasHead(pi, repoRoot);
686
+ const changes = (await getWorkingTreeReviewChanges(pi, repoRoot, repositoryHasHead)).filter((change) =>
687
+ isIncludedReviewPath(change.newPath ?? change.oldPath ?? ""),
688
+ );
689
+ return changes
690
+ .map((change): ReviewFile => {
691
+ const comparison = toComparison(change);
692
+ return toReviewFile(change, {
693
+ id: buildCommitFileId(sha, comparison),
694
+ worktreeStatus: change.status,
695
+ hasWorkingTreeFile: change.newPath != null,
696
+ });
697
+ })
698
+ .sort(compareReviewFiles);
699
+ }
700
+
701
+ const output = await runGitAllowFailure(pi, repoRoot, [
702
+ "diff-tree",
703
+ "--root",
704
+ "--find-renames",
705
+ "-M",
706
+ "--name-status",
707
+ "--no-commit-id",
708
+ "-r",
709
+ sha,
710
+ ]);
711
+ const changes = parseNameStatus(output).filter((change) =>
712
+ isIncludedReviewPath(change.newPath ?? change.oldPath ?? ""),
713
+ );
714
+ return changes
715
+ .map((change): ReviewFile => {
716
+ const comparison = toComparison(change);
717
+ return toReviewFile(change, {
718
+ id: buildCommitFileId(sha, comparison),
719
+ worktreeStatus: null,
720
+ hasWorkingTreeFile: false,
721
+ });
722
+ })
723
+ .sort(compareReviewFiles);
724
+ }
725
+
726
+ export async function loadReviewFileContents(
727
+ pi: ExtensionAPI,
728
+ repoRoot: string,
729
+ file: ReviewFile,
730
+ scope: ReviewScope,
731
+ commitSha: string | null = null,
732
+ branchMergeBaseSha: string | null = null,
733
+ ): Promise<ReviewFileContents> {
734
+ const emptyBinaryContents: ReviewFileContents = {
735
+ originalContent: "",
736
+ modifiedContent: "",
737
+ kind: file.kind,
738
+ mimeType: file.mimeType,
739
+ originalExists: false,
740
+ modifiedExists: false,
741
+ originalPreviewUrl: null,
742
+ modifiedPreviewUrl: null,
743
+ };
744
+
745
+ if (file.kind !== "text") {
746
+ if (scope === "all") {
747
+ const path = file.path;
748
+ const modifiedSide = file.hasWorkingTreeFile
749
+ ? await loadBinarySideFromWorkingTree(repoRoot, path, file.mimeType)
750
+ : await loadBinarySideFromRevision(pi, repoRoot, "HEAD", path, file.mimeType);
751
+ return {
752
+ ...emptyBinaryContents,
753
+ modifiedExists: modifiedSide.exists,
754
+ modifiedPreviewUrl: modifiedSide.previewUrl,
755
+ };
756
+ }
757
+
758
+ const comparison = file.gitDiff;
759
+ if (comparison == null) return emptyBinaryContents;
760
+
761
+ if (scope === "commits") {
762
+ if (!commitSha) return emptyBinaryContents;
763
+ if (isWorkingTreeCommitSha(commitSha)) {
764
+ const repositoryHasHead = await hasHead(pi, repoRoot);
765
+ const originalSide = repositoryHasHead
766
+ ? await loadBinarySideFromRevision(pi, repoRoot, "HEAD", comparison.oldPath, file.mimeType)
767
+ : { exists: false, previewUrl: null };
768
+ const modifiedSide = file.hasWorkingTreeFile
769
+ ? await loadBinarySideFromWorkingTree(repoRoot, comparison.newPath, file.mimeType)
770
+ : { exists: false, previewUrl: null };
771
+ return {
772
+ ...emptyBinaryContents,
773
+ originalExists: originalSide.exists,
774
+ modifiedExists: modifiedSide.exists,
775
+ originalPreviewUrl: originalSide.previewUrl,
776
+ modifiedPreviewUrl: modifiedSide.previewUrl,
777
+ };
778
+ }
779
+ const originalSide = await loadBinarySideFromRevision(
780
+ pi,
781
+ repoRoot,
782
+ `${commitSha}^`,
783
+ comparison.oldPath,
784
+ file.mimeType,
785
+ );
786
+ const modifiedSide = await loadBinarySideFromRevision(pi, repoRoot, commitSha, comparison.newPath, file.mimeType);
787
+ return {
788
+ ...emptyBinaryContents,
789
+ originalExists: originalSide.exists,
790
+ modifiedExists: modifiedSide.exists,
791
+ originalPreviewUrl: originalSide.previewUrl,
792
+ modifiedPreviewUrl: modifiedSide.previewUrl,
793
+ };
794
+ }
795
+
796
+ if (!branchMergeBaseSha) return emptyBinaryContents;
797
+ const originalSide = await loadBinarySideFromRevision(
798
+ pi,
799
+ repoRoot,
800
+ branchMergeBaseSha,
801
+ comparison.oldPath,
802
+ file.mimeType,
803
+ );
804
+ const modifiedSide = file.hasWorkingTreeFile
805
+ ? await loadBinarySideFromWorkingTree(repoRoot, comparison.newPath, file.mimeType)
806
+ : await loadBinarySideFromRevision(pi, repoRoot, "HEAD", comparison.newPath, file.mimeType);
807
+ return {
808
+ ...emptyBinaryContents,
809
+ originalExists: originalSide.exists,
810
+ modifiedExists: modifiedSide.exists,
811
+ originalPreviewUrl: originalSide.previewUrl,
812
+ modifiedPreviewUrl: modifiedSide.previewUrl,
813
+ };
814
+ }
815
+
816
+ if (scope === "all") {
817
+ const path = file.path;
818
+ const workingTreeExists = file.hasWorkingTreeFile ? hasWorkingTreeFile(repoRoot, path) : false;
819
+ const content = file.hasWorkingTreeFile
820
+ ? await getWorkingTreeContent(repoRoot, path)
821
+ : await getRevisionContent(pi, repoRoot, "HEAD", path);
822
+ const exists = file.hasWorkingTreeFile ? workingTreeExists : content.length > 0 || path.length > 0;
823
+ return {
824
+ originalContent: content,
825
+ modifiedContent: content,
826
+ kind: file.kind,
827
+ mimeType: file.mimeType,
828
+ originalExists: exists,
829
+ modifiedExists: exists,
830
+ originalPreviewUrl: null,
831
+ modifiedPreviewUrl: null,
832
+ };
833
+ }
834
+
835
+ const comparison = file.gitDiff;
836
+ if (comparison == null) {
837
+ return {
838
+ originalContent: "",
839
+ modifiedContent: "",
840
+ kind: file.kind,
841
+ mimeType: file.mimeType,
842
+ originalExists: false,
843
+ modifiedExists: false,
844
+ originalPreviewUrl: null,
845
+ modifiedPreviewUrl: null,
846
+ };
847
+ }
848
+
849
+ if (scope === "commits") {
850
+ if (!commitSha) {
851
+ return {
852
+ originalContent: "",
853
+ modifiedContent: "",
854
+ kind: file.kind,
855
+ mimeType: file.mimeType,
856
+ originalExists: false,
857
+ modifiedExists: false,
858
+ originalPreviewUrl: null,
859
+ modifiedPreviewUrl: null,
860
+ };
861
+ }
862
+ if (isWorkingTreeCommitSha(commitSha)) {
863
+ const repositoryHasHead = await hasHead(pi, repoRoot);
864
+ const originalContent =
865
+ repositoryHasHead && comparison.oldPath != null
866
+ ? await getRevisionContent(pi, repoRoot, "HEAD", comparison.oldPath)
867
+ : "";
868
+ const modifiedExists =
869
+ comparison.newPath != null && file.hasWorkingTreeFile
870
+ ? hasWorkingTreeFile(repoRoot, comparison.newPath)
871
+ : false;
872
+ const modifiedContent =
873
+ comparison.newPath == null
874
+ ? ""
875
+ : file.hasWorkingTreeFile
876
+ ? await getWorkingTreeContent(repoRoot, comparison.newPath)
877
+ : "";
878
+ return {
879
+ originalContent,
880
+ modifiedContent,
881
+ kind: file.kind,
882
+ mimeType: file.mimeType,
883
+ originalExists: repositoryHasHead && comparison.oldPath != null,
884
+ modifiedExists,
885
+ originalPreviewUrl: null,
886
+ modifiedPreviewUrl: null,
887
+ };
888
+ }
889
+ const originalContent =
890
+ comparison.oldPath == null ? "" : await getRevisionContent(pi, repoRoot, `${commitSha}^`, comparison.oldPath);
891
+ const modifiedContent =
892
+ comparison.newPath == null ? "" : await getRevisionContent(pi, repoRoot, commitSha, comparison.newPath);
893
+ return {
894
+ originalContent,
895
+ modifiedContent,
896
+ kind: file.kind,
897
+ mimeType: file.mimeType,
898
+ originalExists: comparison.oldPath != null,
899
+ modifiedExists: comparison.newPath != null,
900
+ originalPreviewUrl: null,
901
+ modifiedPreviewUrl: null,
902
+ };
903
+ }
904
+
905
+ if (!branchMergeBaseSha) {
906
+ return {
907
+ originalContent: "",
908
+ modifiedContent: "",
909
+ kind: file.kind,
910
+ mimeType: file.mimeType,
911
+ originalExists: false,
912
+ modifiedExists: false,
913
+ originalPreviewUrl: null,
914
+ modifiedPreviewUrl: null,
915
+ };
916
+ }
917
+
918
+ const originalContent =
919
+ comparison.oldPath == null ? "" : await getRevisionContent(pi, repoRoot, branchMergeBaseSha, comparison.oldPath);
920
+ const modifiedExists =
921
+ comparison.newPath != null && file.hasWorkingTreeFile ? hasWorkingTreeFile(repoRoot, comparison.newPath) : false;
922
+ const modifiedContent =
923
+ comparison.newPath == null
924
+ ? ""
925
+ : file.hasWorkingTreeFile
926
+ ? await getWorkingTreeContent(repoRoot, comparison.newPath)
927
+ : await getRevisionContent(pi, repoRoot, "HEAD", comparison.newPath);
928
+ return {
929
+ originalContent,
930
+ modifiedContent,
931
+ kind: file.kind,
932
+ mimeType: file.mimeType,
933
+ originalExists: comparison.oldPath != null,
934
+ modifiedExists,
935
+ originalPreviewUrl: null,
936
+ modifiedPreviewUrl: null,
937
+ };
938
+ }
939
+
940
+ export const __testing = {
941
+ parseStatusPorcelainZ,
942
+ shouldNormalizeBranchChanges,
943
+ };