@diegopetrucci/pi-extensions 0.1.74 → 0.1.76

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