@diegopetrucci/pi-extensions 0.1.74 → 0.1.78

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 (108) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +17 -12
  3. package/extensions/agent-workflow-audit/README.md +1 -1
  4. package/extensions/agent-workflow-audit/index.ts +1631 -1009
  5. package/extensions/agent-workflow-audit/package.json +1 -1
  6. package/extensions/annotate-git-diff/README.md +3 -1
  7. package/extensions/annotate-git-diff/clipboard.ts +109 -100
  8. package/extensions/annotate-git-diff/git.ts +846 -768
  9. package/extensions/annotate-git-diff/glimpseui.d.ts +87 -78
  10. package/extensions/annotate-git-diff/index.ts +712 -671
  11. package/extensions/annotate-git-diff/package.json +1 -1
  12. package/extensions/annotate-git-diff/prompt.ts +45 -45
  13. package/extensions/annotate-git-diff/quiet-glimpse.ts +161 -155
  14. package/extensions/annotate-git-diff/review-server.ts +337 -311
  15. package/extensions/annotate-git-diff/types.ts +132 -132
  16. package/extensions/annotate-git-diff/ui.ts +27 -27
  17. package/extensions/annotate-git-diff/watch.ts +74 -74
  18. package/extensions/annotate-git-diff/web/app.js +1947 -1884
  19. package/extensions/annotate-git-diff/web/index.html +5 -6
  20. package/extensions/annotate-last-message/README.md +1 -1
  21. package/extensions/annotate-last-message/glimpseui.d.ts +87 -78
  22. package/extensions/annotate-last-message/index.ts +210 -206
  23. package/extensions/annotate-last-message/package.json +1 -1
  24. package/extensions/annotate-last-message/prompt.ts +87 -66
  25. package/extensions/annotate-last-message/quiet-glimpse.ts +161 -155
  26. package/extensions/annotate-last-message/session.ts +94 -89
  27. package/extensions/annotate-last-message/theme.ts +199 -193
  28. package/extensions/annotate-last-message/types.ts +23 -23
  29. package/extensions/annotate-last-message/ui.ts +27 -25
  30. package/extensions/annotate-last-message/web/app.js +4 -7
  31. package/extensions/annotate-last-message/web/index.html +3 -3
  32. package/extensions/annotate-last-message/web/md-renderer.js +1 -1
  33. package/extensions/brrr/README.md +1 -1
  34. package/extensions/brrr/index.ts +213 -191
  35. package/extensions/brrr/package.json +1 -1
  36. package/extensions/claude-fast/README.md +5 -79
  37. package/extensions/claude-fast/index.ts +31 -289
  38. package/extensions/claude-fast/package.json +2 -3
  39. package/extensions/code-reviewer/README.md +6 -6
  40. package/extensions/code-reviewer/index.ts +1825 -1373
  41. package/extensions/code-reviewer/package.json +1 -1
  42. package/extensions/confirm-destructive/README.md +1 -1
  43. package/extensions/confirm-destructive/index.ts +50 -52
  44. package/extensions/confirm-destructive/package.json +1 -1
  45. package/extensions/context-cap/README.md +9 -9
  46. package/extensions/context-cap/index.ts +313 -305
  47. package/extensions/context-cap/package.json +2 -2
  48. package/extensions/context-inspector/index.ts +1211 -1064
  49. package/extensions/context-inspector/package.json +1 -1
  50. package/extensions/contrarian/README.md +4 -3
  51. package/extensions/contrarian/index.ts +1863 -1737
  52. package/extensions/contrarian/package.json +1 -1
  53. package/extensions/dirty-repo-guard/index.ts +37 -41
  54. package/extensions/dirty-repo-guard/package.json +1 -1
  55. package/extensions/dynamic-context-pruning/README.md +11 -43
  56. package/extensions/dynamic-context-pruning/index.ts +2131 -2105
  57. package/extensions/dynamic-context-pruning/package.json +1 -1
  58. package/extensions/fast/README.md +52 -14
  59. package/extensions/fast/index.ts +513 -317
  60. package/extensions/fast/package.json +1 -1
  61. package/extensions/git-footer/README.md +1 -1
  62. package/extensions/git-footer/index.ts +458 -411
  63. package/extensions/git-footer/package.json +2 -3
  64. package/extensions/gnosis/index.ts +180 -130
  65. package/extensions/gnosis/package.json +1 -1
  66. package/extensions/inline-bash/index.ts +87 -103
  67. package/extensions/inline-bash/package.json +1 -1
  68. package/extensions/librarian/README.md +1 -1
  69. package/extensions/librarian/index.ts +1877 -1419
  70. package/extensions/librarian/package.json +1 -1
  71. package/extensions/minimal-footer/README.md +5 -5
  72. package/extensions/minimal-footer/index.ts +987 -1012
  73. package/extensions/minimal-footer/minimal-footer.example.json +1 -1
  74. package/extensions/minimal-footer/openai-usage.ts +195 -190
  75. package/extensions/minimal-footer/package.json +1 -1
  76. package/extensions/notify/index.ts +222 -230
  77. package/extensions/notify/package.json +1 -1
  78. package/extensions/openai-fast/README.md +5 -73
  79. package/extensions/openai-fast/index.ts +32 -272
  80. package/extensions/openai-fast/package.json +2 -3
  81. package/extensions/oracle/README.md +4 -3
  82. package/extensions/oracle/index.ts +1740 -1627
  83. package/extensions/oracle/package.json +1 -1
  84. package/extensions/permission-gate/README.md +3 -0
  85. package/extensions/permission-gate/index.ts +785 -612
  86. package/extensions/permission-gate/package.json +1 -1
  87. package/extensions/permission-gate/powershell-safety.ts +840 -793
  88. package/extensions/project-mcp-json/.pi-fleet-tested-version +1 -0
  89. package/extensions/project-mcp-json/README.md +199 -0
  90. package/extensions/project-mcp-json/config.ts +564 -0
  91. package/extensions/project-mcp-json/index.ts +119 -0
  92. package/extensions/project-mcp-json/package.json +38 -0
  93. package/extensions/quiet-tools/.pi-fleet-tested-version +1 -1
  94. package/extensions/quiet-tools/README.md +30 -19
  95. package/extensions/quiet-tools/index.ts +376 -291
  96. package/extensions/quiet-tools/package.json +2 -2
  97. package/extensions/review/README.md +2 -2
  98. package/extensions/review/index.ts +1745 -1872
  99. package/extensions/review/package.json +1 -1
  100. package/extensions/todo/index.ts +280 -280
  101. package/extensions/todo/package.json +1 -1
  102. package/extensions/triage-comments/README.md +6 -4
  103. package/extensions/triage-comments/index.ts +2620 -1888
  104. package/extensions/triage-comments/package.json +1 -1
  105. package/package.json +9 -6
  106. package/assets/social-preview.png +0 -0
  107. package/extensions/claude-fast/claude-fast.example.json +0 -4
  108. 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,989 @@ 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
- };
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();
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
+ };
50
+ }
51
+
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;
62
+ }
63
+
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;
74
+ }
75
+
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];
151
+ const code = (parts[0] ?? "")[0];
140
152
 
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
- }
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
+ }
147
159
 
148
- const path = parts[1] ?? null;
149
- if (path == null) return null;
160
+ const path = parts[1] ?? null;
161
+ if (path == null) return null;
150
162
 
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;
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);
170
+ const lines = output
171
+ .split(/\r?\n/)
172
+ .map((line) => line.trim())
173
+ .filter((line) => line.length > 0);
162
174
 
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;
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
+ ".bin",
385
+ ".class",
386
+ ".dll",
387
+ ".dylib",
388
+ ".eot",
389
+ ".exe",
390
+ ".gz",
391
+ ".jar",
392
+ ".lockb",
393
+ ".map",
394
+ ".mov",
395
+ ".mp3",
396
+ ".mp4",
397
+ ".o",
398
+ ".otf",
399
+ ".pdf",
400
+ ".pyc",
401
+ ".so",
402
+ ".svgz",
403
+ ".tar",
404
+ ".ttf",
405
+ ".wasm",
406
+ ".webm",
407
+ ".woff",
408
+ ".woff2",
409
+ ".zip",
389
410
  ]);
390
411
 
391
412
  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 };
413
+ const extension = extname(path.toLowerCase());
414
+ const mimeType = imageMimeTypes.get(extension) ?? null;
415
+ if (mimeType != null) return { kind: "image", mimeType };
416
+ if (binaryExtensions.has(extension)) return { kind: "binary", mimeType: null };
417
+ return { kind: "text", mimeType: null };
397
418
  }
398
419
 
399
420
  function isIncludedReviewPath(path: string): boolean {
400
- const lowerPath = path.toLowerCase();
401
- const fileName = lowerPath.split("/").pop() ?? lowerPath;
402
- return fileName.length > 0;
421
+ const lowerPath = path.toLowerCase();
422
+ const fileName = lowerPath.split("/").pop() ?? lowerPath;
423
+ return fileName.length > 0;
403
424
  }
404
425
 
405
426
  function bufferToDataUrl(buffer: Buffer, mimeType: string): string {
406
- return `data:${mimeType};base64,${buffer.toString("base64")}`;
427
+ return `data:${mimeType};base64,${buffer.toString("base64")}`;
407
428
  }
408
429
 
409
430
  function toReviewFile(
410
- change: ChangedPath,
411
- options: { id: string; worktreeStatus: ChangeStatus | null; hasWorkingTreeFile: boolean },
431
+ change: ChangedPath,
432
+ options: { id: string; worktreeStatus: ChangeStatus | null; hasWorkingTreeFile: boolean },
412
433
  ): 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
- };
434
+ const comparison = toComparison(change);
435
+ const path = change.newPath ?? change.oldPath ?? comparison.displayPath;
436
+ const meta = classifyFilePath(path);
437
+ return {
438
+ id: options.id,
439
+ path,
440
+ worktreeStatus: options.worktreeStatus,
441
+ hasWorkingTreeFile: options.hasWorkingTreeFile,
442
+ inGitDiff: true,
443
+ gitDiff: comparison,
444
+ kind: meta.kind,
445
+ mimeType: meta.mimeType,
446
+ };
426
447
  }
427
448
 
428
449
  async function loadBinarySideFromWorkingTree(
429
- repoRoot: string,
430
- path: string | null,
431
- mimeType: string | null,
450
+ repoRoot: string,
451
+ path: string | null,
452
+ mimeType: string | null,
432
453
  ): 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 };
454
+ if (path == null) return { exists: false, previewUrl: null };
455
+ const entry = inspectWorkingTreePath(repoRoot, path);
456
+ if (entry.kind === "symlink") return { exists: true, previewUrl: null };
457
+ if (entry.kind !== "file") return { exists: false, previewUrl: null };
458
+ const bytes = await getWorkingTreeBytes(repoRoot, path);
459
+ if (bytes == null) return { exists: false, previewUrl: null };
460
+ return { exists: true, previewUrl: mimeType ? bufferToDataUrl(bytes, mimeType) : null };
440
461
  }
441
462
 
442
463
  async function loadBinarySideFromRevision(
443
- pi: ExtensionAPI,
444
- repoRoot: string,
445
- revision: string,
446
- path: string | null,
447
- mimeType: string | null,
464
+ pi: ExtensionAPI,
465
+ repoRoot: string,
466
+ revision: string,
467
+ path: string | null,
468
+ mimeType: string | null,
448
469
  ): 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 };
470
+ if (path == null) return { exists: false, previewUrl: null };
471
+ const bytes = await getRevisionBytes(pi, repoRoot, revision, path);
472
+ if (bytes == null) return { exists: false, previewUrl: null };
473
+ return { exists: true, previewUrl: mimeType ? bufferToDataUrl(bytes, mimeType) : null };
453
474
  }
454
475
 
455
476
  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()];
477
+ const merged = new Map<string, ChangedPath>();
478
+ for (const group of groups) {
479
+ for (const change of group) {
480
+ const key = change.newPath ?? change.oldPath ?? "";
481
+ if (key.length === 0) continue;
482
+ merged.set(key, change);
483
+ }
484
+ }
485
+ return [...merged.values()];
465
486
  }
466
487
 
467
488
  function toUntrackedChangedPaths(paths: string[]): ChangedPath[] {
468
- return paths.map((path) => ({ status: "added", oldPath: null, newPath: path }) satisfies ChangedPath);
489
+ return paths.map(
490
+ (path) => ({ status: "added", oldPath: null, newPath: path }) satisfies ChangedPath,
491
+ );
469
492
  }
470
493
 
471
494
  function shouldNormalizeBranchChanges(
472
- trackedChanges: ChangedPath[],
473
- workingTreeStatus: WorkingTreeStatusInfo,
495
+ trackedChanges: ChangedPath[],
496
+ workingTreeStatus: WorkingTreeStatusInfo,
474
497
  ): boolean {
475
- if (workingTreeStatus.hasRenames) return true;
476
- if (!workingTreeStatus.hasUntracked) return false;
477
- return trackedChanges.some((change) => change.status === "deleted");
498
+ if (workingTreeStatus.hasRenames) return true;
499
+ if (!workingTreeStatus.hasUntracked) return false;
500
+ return trackedChanges.some((change) => change.status === "deleted");
478
501
  }
479
502
 
480
503
  async function getTrackedBranchReviewChanges(
481
- pi: ExtensionAPI,
482
- repoRoot: string,
483
- branchComparisonBase: string,
504
+ pi: ExtensionAPI,
505
+ repoRoot: string,
506
+ branchComparisonBase: string,
484
507
  ): 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
- );
508
+ return parseNameStatus(
509
+ await runGitAllowFailure(pi, repoRoot, [
510
+ "diff",
511
+ "--find-renames",
512
+ "-M",
513
+ "--name-status",
514
+ branchComparisonBase,
515
+ "--",
516
+ ]),
517
+ );
495
518
  }
496
519
 
497
520
  async function getWorkingTreeSnapshotChanges(
498
- pi: ExtensionAPI,
499
- repoRoot: string,
500
- baseRevision: string | null,
521
+ pi: ExtensionAPI,
522
+ repoRoot: string,
523
+ baseRevision: string | null,
501
524
  ): 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);
525
+ const scriptLines = [
526
+ "set -euo pipefail",
527
+ 'tmp_index=$(mktemp "/tmp/tlh-annotate-git-diff-index.XXXXXX")',
528
+ "trap 'rm -f \"$tmp_index\"' EXIT",
529
+ 'export GIT_INDEX_FILE="$tmp_index"',
530
+ ];
531
+ if (baseRevision != null) {
532
+ scriptLines.push(`git read-tree ${shellQuote(baseRevision)}`);
533
+ } else {
534
+ scriptLines.push('rm -f "$tmp_index"');
535
+ }
536
+ scriptLines.push("git add -A -- .");
537
+ scriptLines.push(
538
+ baseRevision != null
539
+ ? `git diff --cached --find-renames -M --name-status ${shellQuote(baseRevision)} --`
540
+ : "git diff --cached --find-renames -M --name-status --root --",
541
+ );
542
+ const output = await runBashAllowFailure(pi, repoRoot, scriptLines.join("\n"));
543
+ return parseNameStatus(output);
521
544
  }
522
545
 
523
546
  async function getBranchReviewChanges(
524
- pi: ExtensionAPI,
525
- repoRoot: string,
526
- branchComparisonBase: string | null,
527
- workingTreeStatus: WorkingTreeStatusInfo,
547
+ pi: ExtensionAPI,
548
+ repoRoot: string,
549
+ branchComparisonBase: string | null,
550
+ workingTreeStatus: WorkingTreeStatusInfo,
528
551
  ): 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));
552
+ if (!branchComparisonBase) return [];
553
+ const trackedChanges = await getTrackedBranchReviewChanges(pi, repoRoot, branchComparisonBase);
554
+ if (shouldNormalizeBranchChanges(trackedChanges, workingTreeStatus)) {
555
+ return getWorkingTreeSnapshotChanges(pi, repoRoot, branchComparisonBase);
556
+ }
557
+ return mergeChangedPaths(
558
+ trackedChanges,
559
+ toUntrackedChangedPaths(workingTreeStatus.untrackedPaths),
560
+ );
535
561
  }
536
562
 
537
563
  async function getWorkingTreeReviewChanges(
538
- pi: ExtensionAPI,
539
- repoRoot: string,
540
- repositoryHasHead: boolean,
564
+ pi: ExtensionAPI,
565
+ repoRoot: string,
566
+ repositoryHasHead: boolean,
541
567
  ): 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;
568
+ return getWorkingTreeSnapshotChanges(pi, repoRoot, repositoryHasHead ? "HEAD" : null);
569
+ }
570
+
571
+ async function listReviewableRepositoryPaths(
572
+ pi: ExtensionAPI,
573
+ repoRoot: string,
574
+ ): Promise<string[]> {
575
+ const output = await runGitAllowFailure(pi, repoRoot, [
576
+ "ls-files",
577
+ "--cached",
578
+ "--others",
579
+ "--exclude-standard",
580
+ "-z",
581
+ "--",
582
+ ]);
583
+ const seen = new Set<string>();
584
+ const paths: string[] = [];
585
+ for (const path of output.split("\0")) {
586
+ if (path.length === 0 || seen.has(path) || !isIncludedReviewPath(path)) continue;
587
+ seen.add(path);
588
+ paths.push(path);
589
+ }
590
+ return paths;
555
591
  }
556
592
 
557
593
  function hasWorkingTreeFile(repoRoot: string, path: string): boolean {
558
- const entry = inspectWorkingTreePath(repoRoot, path);
559
- return entry.kind === "file" || entry.kind === "symlink";
594
+ const entry = inspectWorkingTreePath(repoRoot, path);
595
+ return entry.kind === "file" || entry.kind === "symlink";
560
596
  }
561
597
 
562
598
  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
- };
599
+ const meta = classifyFilePath(path);
600
+ const workingTreeFile = hasWorkingTreeFile(repoRoot, path);
601
+ return {
602
+ id: buildSnapshotFileId(path, workingTreeFile),
603
+ path,
604
+ worktreeStatus: null,
605
+ hasWorkingTreeFile: workingTreeFile,
606
+ inGitDiff: false,
607
+ gitDiff: null,
608
+ kind: meta.kind,
609
+ mimeType: meta.mimeType,
610
+ };
575
611
  }
576
612
 
577
613
  async function getAllReviewFiles(
578
- pi: ExtensionAPI,
579
- repoRoot: string,
580
- branchChanges: ChangedPath[],
614
+ pi: ExtensionAPI,
615
+ repoRoot: string,
616
+ branchChanges: ChangedPath[],
581
617
  ): 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[] = [];
618
+ const branchFiles = branchChanges.map(toBranchReviewFile);
619
+ const branchFilesByPath = new Map(branchFiles.map((file) => [file.path, file]));
620
+ const seenPaths = new Set<string>();
621
+ const allFiles: ReviewFile[] = [];
586
622
 
587
- for (const path of await listReviewableRepositoryPaths(pi, repoRoot)) {
588
- seenPaths.add(path);
589
- allFiles.push(branchFilesByPath.get(path) ?? toSnapshotReviewFile(repoRoot, path));
590
- }
623
+ for (const path of await listReviewableRepositoryPaths(pi, repoRoot)) {
624
+ seenPaths.add(path);
625
+ allFiles.push(branchFilesByPath.get(path) ?? toSnapshotReviewFile(repoRoot, path));
626
+ }
591
627
 
592
- for (const file of branchFiles) {
593
- if (seenPaths.has(file.path)) continue;
594
- allFiles.push(file);
595
- }
628
+ for (const file of branchFiles) {
629
+ if (seenPaths.has(file.path)) continue;
630
+ allFiles.push(file);
631
+ }
596
632
 
597
- allFiles.sort(compareReviewFiles);
598
- branchFiles.sort(compareReviewFiles);
599
- return { allFiles, branchFiles };
633
+ allFiles.sort(compareReviewFiles);
634
+ branchFiles.sort(compareReviewFiles);
635
+ return { allFiles, branchFiles };
600
636
  }
601
637
 
602
638
  function compareReviewFiles(a: ReviewFile, b: ReviewFile): number {
603
- return a.path.localeCompare(b.path);
639
+ return a.path.localeCompare(b.path);
604
640
  }
605
641
 
606
642
  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
- });
643
+ const comparison = toComparison(change);
644
+ const path = change.newPath ?? change.oldPath ?? comparison.displayPath;
645
+ return toReviewFile(change, {
646
+ id: buildBranchFileId(path, change.newPath != null, comparison),
647
+ worktreeStatus: change.status,
648
+ hasWorkingTreeFile: change.newPath != null,
649
+ });
614
650
  }
615
651
 
616
652
  export async function getReviewWindowData(
617
- pi: ExtensionAPI,
618
- cwd: string,
653
+ pi: ExtensionAPI,
654
+ cwd: string,
619
655
  ): Promise<{
620
- repoRoot: string;
621
- files: ReviewFile[];
622
- commits: ReviewCommitInfo[];
623
- branchBaseRef: string | null;
624
- branchMergeBaseSha: string | null;
625
- repositoryHasHead: boolean;
656
+ repoRoot: string;
657
+ files: ReviewFile[];
658
+ commits: ReviewCommitInfo[];
659
+ branchBaseRef: string | null;
660
+ branchMergeBaseSha: string | null;
661
+ repositoryHasHead: boolean;
626
662
  }> {
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,
663
+ const repoRoot = await getRepoRoot(pi, cwd);
664
+ const repositoryHasHead = await hasHead(pi, repoRoot);
665
+ const reviewBase = repositoryHasHead ? await findReviewBase(pi, repoRoot) : null;
666
+ const branchComparisonBase = reviewBase?.mergeBase ?? (repositoryHasHead ? "HEAD" : null);
667
+ const workingTreeStatus = await getWorkingTreeStatusInfo(pi, repoRoot);
668
+ const branchChanges = repositoryHasHead
669
+ ? await getBranchReviewChanges(pi, repoRoot, branchComparisonBase, workingTreeStatus)
670
+ : await getWorkingTreeReviewChanges(pi, repoRoot, false);
671
+ const reviewableBranchChanges = branchChanges.filter((change) =>
672
+ isIncludedReviewPath(change.newPath ?? change.oldPath ?? ""),
673
+ );
674
+ const { allFiles: files, branchFiles } = await getAllReviewFiles(
675
+ pi,
676
+ repoRoot,
677
+ reviewableBranchChanges,
678
+ );
679
+ const commits = reviewBase
680
+ ? await listRangeCommits(pi, repoRoot, `${reviewBase.mergeBase}..HEAD`, 100)
681
+ : [];
682
+ const workingTreeCommit = workingTreeStatus.hasReviewableChanges
683
+ ? [createWorkingTreeCommitInfo()]
684
+ : [];
685
+ const fallbackCommits =
686
+ repositoryHasHead &&
687
+ branchFiles.length === 0 &&
688
+ commits.length === 0 &&
689
+ !workingTreeStatus.hasReviewableChanges
690
+ ? await listRangeCommits(pi, repoRoot, "HEAD", 20)
691
+ : commits;
692
+
693
+ return {
694
+ repoRoot,
695
+ files,
696
+ commits: [...workingTreeCommit, ...fallbackCommits],
697
+ branchBaseRef: reviewBase?.baseRef ?? null,
698
+ branchMergeBaseSha: branchComparisonBase,
699
+ repositoryHasHead,
700
+ };
701
+ }
702
+
703
+ async function listRangeCommits(
704
+ pi: ExtensionAPI,
705
+ repoRoot: string,
706
+ range: string,
707
+ limit: number,
661
708
  ): 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);
709
+ const sep = "\x1f";
710
+ const format = ["%H", "%h", "%s", "%an", "%aI"].join(sep);
711
+ const output = await runGitAllowFailure(pi, repoRoot, [
712
+ "log",
713
+ `-${limit}`,
714
+ `--format=${format}`,
715
+ range,
716
+ ]);
717
+ return output
718
+ .split(/\r?\n/)
719
+ .map((line) => line.trimEnd())
720
+ .filter((line) => line.length > 0)
721
+ .map((line) => {
722
+ const [sha, shortSha, subject, authorName, authorDate] = line.split(sep);
723
+ return {
724
+ sha: sha ?? "",
725
+ shortSha: shortSha ?? (sha ?? "").slice(0, 7),
726
+ subject: subject ?? "",
727
+ authorName: authorName ?? "",
728
+ authorDate: authorDate ?? "",
729
+ kind: "commit",
730
+ } satisfies ReviewCommitInfo;
731
+ })
732
+ .filter((commit) => commit.sha.length > 0);
733
+ }
734
+
735
+ export async function getCommitFiles(
736
+ pi: ExtensionAPI,
737
+ repoRoot: string,
738
+ sha: string,
739
+ ): Promise<ReviewFile[]> {
740
+ if (isWorkingTreeCommitSha(sha)) {
741
+ const repositoryHasHead = await hasHead(pi, repoRoot);
742
+ const changes = (await getWorkingTreeReviewChanges(pi, repoRoot, repositoryHasHead)).filter(
743
+ (change) => isIncludedReviewPath(change.newPath ?? change.oldPath ?? ""),
744
+ );
745
+ return changes
746
+ .map((change): ReviewFile => {
747
+ const comparison = toComparison(change);
748
+ return toReviewFile(change, {
749
+ id: buildCommitFileId(sha, comparison),
750
+ worktreeStatus: change.status,
751
+ hasWorkingTreeFile: change.newPath != null,
752
+ });
753
+ })
754
+ .sort(compareReviewFiles);
755
+ }
756
+
757
+ const output = await runGitAllowFailure(pi, repoRoot, [
758
+ "diff-tree",
759
+ "--root",
760
+ "--find-renames",
761
+ "-M",
762
+ "--name-status",
763
+ "--no-commit-id",
764
+ "-r",
765
+ "--end-of-options",
766
+ sha,
767
+ ]);
768
+ const changes = parseNameStatus(output).filter((change) =>
769
+ isIncludedReviewPath(change.newPath ?? change.oldPath ?? ""),
770
+ );
771
+ return changes
772
+ .map((change): ReviewFile => {
773
+ const comparison = toComparison(change);
774
+ return toReviewFile(change, {
775
+ id: buildCommitFileId(sha, comparison),
776
+ worktreeStatus: null,
777
+ hasWorkingTreeFile: false,
778
+ });
779
+ })
780
+ .sort(compareReviewFiles);
725
781
  }
726
782
 
727
783
  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,
784
+ pi: ExtensionAPI,
785
+ repoRoot: string,
786
+ file: ReviewFile,
787
+ scope: ReviewScope,
788
+ commitSha: string | null = null,
789
+ branchMergeBaseSha: string | null = null,
734
790
  ): 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
- };
791
+ const emptyBinaryContents: ReviewFileContents = {
792
+ originalContent: "",
793
+ modifiedContent: "",
794
+ kind: file.kind,
795
+ mimeType: file.mimeType,
796
+ originalExists: false,
797
+ modifiedExists: false,
798
+ originalPreviewUrl: null,
799
+ modifiedPreviewUrl: null,
800
+ };
801
+
802
+ if (file.kind !== "text") {
803
+ if (scope === "all") {
804
+ const path = file.path;
805
+ const modifiedSide = file.hasWorkingTreeFile
806
+ ? await loadBinarySideFromWorkingTree(repoRoot, path, file.mimeType)
807
+ : await loadBinarySideFromRevision(pi, repoRoot, "HEAD", path, file.mimeType);
808
+ return {
809
+ ...emptyBinaryContents,
810
+ modifiedExists: modifiedSide.exists,
811
+ modifiedPreviewUrl: modifiedSide.previewUrl,
812
+ };
813
+ }
814
+
815
+ const comparison = file.gitDiff;
816
+ if (comparison == null) return emptyBinaryContents;
817
+
818
+ if (scope === "commits") {
819
+ if (!commitSha) return emptyBinaryContents;
820
+ if (isWorkingTreeCommitSha(commitSha)) {
821
+ const repositoryHasHead = await hasHead(pi, repoRoot);
822
+ const originalSide = repositoryHasHead
823
+ ? await loadBinarySideFromRevision(
824
+ pi,
825
+ repoRoot,
826
+ "HEAD",
827
+ comparison.oldPath,
828
+ file.mimeType,
829
+ )
830
+ : { exists: false, previewUrl: null };
831
+ const modifiedSide = file.hasWorkingTreeFile
832
+ ? await loadBinarySideFromWorkingTree(repoRoot, comparison.newPath, file.mimeType)
833
+ : { exists: false, previewUrl: null };
834
+ return {
835
+ ...emptyBinaryContents,
836
+ originalExists: originalSide.exists,
837
+ modifiedExists: modifiedSide.exists,
838
+ originalPreviewUrl: originalSide.previewUrl,
839
+ modifiedPreviewUrl: modifiedSide.previewUrl,
840
+ };
841
+ }
842
+ const originalSide = await loadBinarySideFromRevision(
843
+ pi,
844
+ repoRoot,
845
+ `${commitSha}^`,
846
+ comparison.oldPath,
847
+ file.mimeType,
848
+ );
849
+ const modifiedSide = await loadBinarySideFromRevision(
850
+ pi,
851
+ repoRoot,
852
+ commitSha,
853
+ comparison.newPath,
854
+ file.mimeType,
855
+ );
856
+ return {
857
+ ...emptyBinaryContents,
858
+ originalExists: originalSide.exists,
859
+ modifiedExists: modifiedSide.exists,
860
+ originalPreviewUrl: originalSide.previewUrl,
861
+ modifiedPreviewUrl: modifiedSide.previewUrl,
862
+ };
863
+ }
864
+
865
+ if (!branchMergeBaseSha) return emptyBinaryContents;
866
+ const originalSide = await loadBinarySideFromRevision(
867
+ pi,
868
+ repoRoot,
869
+ branchMergeBaseSha,
870
+ comparison.oldPath,
871
+ file.mimeType,
872
+ );
873
+ const modifiedSide = file.hasWorkingTreeFile
874
+ ? await loadBinarySideFromWorkingTree(repoRoot, comparison.newPath, file.mimeType)
875
+ : await loadBinarySideFromRevision(pi, repoRoot, "HEAD", comparison.newPath, file.mimeType);
876
+ return {
877
+ ...emptyBinaryContents,
878
+ originalExists: originalSide.exists,
879
+ modifiedExists: modifiedSide.exists,
880
+ originalPreviewUrl: originalSide.previewUrl,
881
+ modifiedPreviewUrl: modifiedSide.previewUrl,
882
+ };
883
+ }
884
+
885
+ if (scope === "all") {
886
+ const path = file.path;
887
+ const workingTreeExists = file.hasWorkingTreeFile ? hasWorkingTreeFile(repoRoot, path) : false;
888
+ const content = file.hasWorkingTreeFile
889
+ ? await getWorkingTreeContent(repoRoot, path)
890
+ : await getRevisionContent(pi, repoRoot, "HEAD", path);
891
+ const exists = file.hasWorkingTreeFile
892
+ ? workingTreeExists
893
+ : content.length > 0 || path.length > 0;
894
+ return {
895
+ originalContent: content,
896
+ modifiedContent: content,
897
+ kind: file.kind,
898
+ mimeType: file.mimeType,
899
+ originalExists: exists,
900
+ modifiedExists: exists,
901
+ originalPreviewUrl: null,
902
+ modifiedPreviewUrl: null,
903
+ };
904
+ }
905
+
906
+ const comparison = file.gitDiff;
907
+ if (comparison == null) {
908
+ return {
909
+ originalContent: "",
910
+ modifiedContent: "",
911
+ kind: file.kind,
912
+ mimeType: file.mimeType,
913
+ originalExists: false,
914
+ modifiedExists: false,
915
+ originalPreviewUrl: null,
916
+ modifiedPreviewUrl: null,
917
+ };
918
+ }
919
+
920
+ if (scope === "commits") {
921
+ if (!commitSha) {
922
+ return {
923
+ originalContent: "",
924
+ modifiedContent: "",
925
+ kind: file.kind,
926
+ mimeType: file.mimeType,
927
+ originalExists: false,
928
+ modifiedExists: false,
929
+ originalPreviewUrl: null,
930
+ modifiedPreviewUrl: null,
931
+ };
932
+ }
933
+ if (isWorkingTreeCommitSha(commitSha)) {
934
+ const repositoryHasHead = await hasHead(pi, repoRoot);
935
+ const originalContent =
936
+ repositoryHasHead && comparison.oldPath != null
937
+ ? await getRevisionContent(pi, repoRoot, "HEAD", comparison.oldPath)
938
+ : "";
939
+ const modifiedExists =
940
+ comparison.newPath != null && file.hasWorkingTreeFile
941
+ ? hasWorkingTreeFile(repoRoot, comparison.newPath)
942
+ : false;
943
+ const modifiedContent =
944
+ comparison.newPath == null
945
+ ? ""
946
+ : file.hasWorkingTreeFile
947
+ ? await getWorkingTreeContent(repoRoot, comparison.newPath)
948
+ : "";
949
+ return {
950
+ originalContent,
951
+ modifiedContent,
952
+ kind: file.kind,
953
+ mimeType: file.mimeType,
954
+ originalExists: repositoryHasHead && comparison.oldPath != null,
955
+ modifiedExists,
956
+ originalPreviewUrl: null,
957
+ modifiedPreviewUrl: null,
958
+ };
959
+ }
960
+ const originalContent =
961
+ comparison.oldPath == null
962
+ ? ""
963
+ : await getRevisionContent(pi, repoRoot, `${commitSha}^`, comparison.oldPath);
964
+ const modifiedContent =
965
+ comparison.newPath == null
966
+ ? ""
967
+ : await getRevisionContent(pi, repoRoot, commitSha, comparison.newPath);
968
+ return {
969
+ originalContent,
970
+ modifiedContent,
971
+ kind: file.kind,
972
+ mimeType: file.mimeType,
973
+ originalExists: comparison.oldPath != null,
974
+ modifiedExists: comparison.newPath != null,
975
+ originalPreviewUrl: null,
976
+ modifiedPreviewUrl: null,
977
+ };
978
+ }
979
+
980
+ if (!branchMergeBaseSha) {
981
+ return {
982
+ originalContent: "",
983
+ modifiedContent: "",
984
+ kind: file.kind,
985
+ mimeType: file.mimeType,
986
+ originalExists: false,
987
+ modifiedExists: false,
988
+ originalPreviewUrl: null,
989
+ modifiedPreviewUrl: null,
990
+ };
991
+ }
992
+
993
+ const originalContent =
994
+ comparison.oldPath == null
995
+ ? ""
996
+ : await getRevisionContent(pi, repoRoot, branchMergeBaseSha, comparison.oldPath);
997
+ const modifiedExists =
998
+ comparison.newPath != null && file.hasWorkingTreeFile
999
+ ? hasWorkingTreeFile(repoRoot, comparison.newPath)
1000
+ : false;
1001
+ const modifiedContent =
1002
+ comparison.newPath == null
1003
+ ? ""
1004
+ : file.hasWorkingTreeFile
1005
+ ? await getWorkingTreeContent(repoRoot, comparison.newPath)
1006
+ : await getRevisionContent(pi, repoRoot, "HEAD", comparison.newPath);
1007
+ return {
1008
+ originalContent,
1009
+ modifiedContent,
1010
+ kind: file.kind,
1011
+ mimeType: file.mimeType,
1012
+ originalExists: comparison.oldPath != null,
1013
+ modifiedExists,
1014
+ originalPreviewUrl: null,
1015
+ modifiedPreviewUrl: null,
1016
+ };
939
1017
  }
940
1018
 
941
1019
  export const __testing = {
942
- parseNameStatus,
943
- parseStatusPorcelainZ,
944
- shouldNormalizeBranchChanges,
1020
+ parseNameStatus,
1021
+ parseStatusPorcelainZ,
1022
+ shouldNormalizeBranchChanges,
945
1023
  };