@monotykamary/localterm-server 2.0.4 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/constants.d.ts +2 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +11 -0
  4. package/dist/constants.js.map +1 -1
  5. package/dist/git-diff.d.ts +2 -1
  6. package/dist/git-diff.d.ts.map +1 -1
  7. package/dist/git-diff.js +353 -551
  8. package/dist/git-diff.js.map +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +5 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/utils/run-git.d.ts +7 -0
  13. package/dist/utils/run-git.d.ts.map +1 -0
  14. package/dist/utils/run-git.js +44 -0
  15. package/dist/utils/run-git.js.map +1 -0
  16. package/package.json +1 -3
  17. package/dist/cdp/open-background-tab.d.ts +0 -33
  18. package/dist/cdp/open-background-tab.d.ts.map +0 -1
  19. package/dist/cdp/open-background-tab.js +0 -98
  20. package/dist/cdp/open-background-tab.js.map +0 -1
  21. package/dist/finding-store.d.ts +0 -21
  22. package/dist/finding-store.d.ts.map +0 -1
  23. package/dist/finding-store.js +0 -155
  24. package/dist/finding-store.js.map +0 -1
  25. package/dist/utils/compute-patch.d.ts +0 -7
  26. package/dist/utils/compute-patch.d.ts.map +0 -1
  27. package/dist/utils/compute-patch.js +0 -74
  28. package/dist/utils/compute-patch.js.map +0 -1
  29. package/dist/utils/parse-osc-finding.d.ts +0 -7
  30. package/dist/utils/parse-osc-finding.d.ts.map +0 -1
  31. package/dist/utils/parse-osc-finding.js +0 -55
  32. package/dist/utils/parse-osc-finding.js.map +0 -1
  33. package/dist/utils/reconcile-file-stats.d.ts +0 -7
  34. package/dist/utils/reconcile-file-stats.d.ts.map +0 -1
  35. package/dist/utils/reconcile-file-stats.js +0 -45
  36. package/dist/utils/reconcile-file-stats.js.map +0 -1
  37. package/dist/utils/resolve-cwd-for-pid.d.ts +0 -2
  38. package/dist/utils/resolve-cwd-for-pid.d.ts.map +0 -1
  39. package/dist/utils/resolve-cwd-for-pid.js +0 -29
  40. package/dist/utils/resolve-cwd-for-pid.js.map +0 -1
  41. package/dist/utils/rewrite-kitty-file-transmission.d.ts +0 -2
  42. package/dist/utils/rewrite-kitty-file-transmission.d.ts.map +0 -1
  43. package/dist/utils/rewrite-kitty-file-transmission.js +0 -147
  44. package/dist/utils/rewrite-kitty-file-transmission.js.map +0 -1
package/dist/git-diff.js CHANGED
@@ -2,12 +2,10 @@ import fs from "node:fs";
2
2
  import fsPromises from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { Octokit } from "@octokit/rest";
5
- import { openRepository } from "es-git";
6
- import { computePatchFromContents } from "./utils/compute-patch.js";
7
- import { memoBy } from "./utils/memo-by.js";
8
- import { reconcileFileStats } from "./utils/reconcile-file-stats.js";
5
+ import { runGit } from "./utils/run-git.js";
9
6
  import { resolveGithubToken } from "./utils/resolve-github-token.js";
10
- import { GIT_BINARY_SNIFF_BYTES, GIT_MAX_BRANCHES, GIT_MAX_PATCH_BYTES_PER_FILE, GIT_MAX_TOTAL_PATCH_BYTES, GIT_MAX_UNTRACKED_FILE_BYTES, GIT_MAX_UNTRACKED_FILES, } from "./constants.js";
7
+ import { memoBy } from "./utils/memo-by.js";
8
+ import { GIT_BINARY_SNIFF_BYTES, GIT_CACHE_TTL_MS, GIT_EMPTY_TREE_HASH, GIT_MAX_BRANCHES, GIT_MAX_PATCH_BYTES_PER_FILE, GIT_MAX_TOTAL_PATCH_BYTES, GIT_MAX_UNTRACKED_FILE_BYTES, GIT_MAX_UNTRACKED_FILES, } from "./constants.js";
11
9
  const WORKING_OPTIONS = { mode: "working" };
12
10
  const EMPTY_SUMMARY = {
13
11
  isRepo: false,
@@ -17,130 +15,103 @@ const EMPTY_SUMMARY = {
17
15
  binaries: 0,
18
16
  branch: null,
19
17
  };
20
- const GIT_EMPTY_TREE_HASH = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
21
- const ZERO_OID = "0000000000000000000000000000000000000000";
22
- const collectIterator = (iterable) => {
23
- const result = [];
24
- for (const item of iterable)
25
- result.push(item);
26
- return result;
27
- };
28
- const deltaTypeToStatus = (delta) => {
29
- switch (delta) {
30
- case "Added":
31
- return "added";
32
- case "Deleted":
33
- return "deleted";
34
- case "Renamed":
35
- return "renamed";
36
- case "Copied":
37
- return "added";
38
- case "Untracked":
39
- return "untracked";
40
- case "Modified":
41
- case "Typechange":
42
- default:
43
- return "modified";
44
- }
45
- };
46
- const openRepo = async (cwd) => {
47
- try {
48
- const repo = await openRepository(cwd);
49
- return { cwd, repo };
50
- }
51
- catch {
18
+ // Nested so a cwd can hold more than one comparison (the working-tree summary
19
+ // is pushed on git-dirty while the viewer may be open in branch mode).
20
+ const diffCacheByCwd = new Map();
21
+ const comparisonKey = (mode, base) => `${mode}:${base ?? ""}`;
22
+ const readDiffCache = (cwd, mode, base) => {
23
+ const byComparison = diffCacheByCwd.get(cwd);
24
+ if (!byComparison)
25
+ return null;
26
+ const entry = byComparison.get(comparisonKey(mode, base));
27
+ if (!entry)
28
+ return null;
29
+ if (Date.now() - entry.builtAt > GIT_CACHE_TTL_MS) {
30
+ byComparison.delete(comparisonKey(mode, base));
31
+ if (byComparison.size === 0)
32
+ diffCacheByCwd.delete(cwd);
52
33
  return null;
53
34
  }
35
+ return entry;
54
36
  };
55
- const getCurrentBranch = (r) => {
56
- try {
57
- const head = r.repo.head();
58
- const shorthand = head.shorthand();
59
- return shorthand && shorthand !== "HEAD" ? shorthand : null;
37
+ const writeDiffCache = (cwd, mode, base, cache) => {
38
+ let byComparison = diffCacheByCwd.get(cwd);
39
+ if (!byComparison) {
40
+ byComparison = new Map();
41
+ diffCacheByCwd.set(cwd, byComparison);
60
42
  }
61
- catch {
43
+ byComparison.set(comparisonKey(mode, base), cache);
44
+ };
45
+ export const invalidateGitDiffCache = (cwd) => {
46
+ diffCacheByCwd.delete(cwd);
47
+ };
48
+ const runGitText = async (cwd, args) => {
49
+ const result = await runGit(cwd, args);
50
+ return result.stdout.toString("utf8");
51
+ };
52
+ // `git diff` flags shared by every diff invocation so numstat, name-status and
53
+ // patch all walk the same diff queue (same rename detection, same file order).
54
+ const DIFF_RENAME_FLAG = "--find-renames";
55
+ const isGitRepo = async (cwd) => {
56
+ const result = await runGit(cwd, ["rev-parse", "--is-inside-work-tree"]);
57
+ return result.exitCode === 0;
58
+ };
59
+ const getCurrentBranch = async (cwd) => {
60
+ const result = await runGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
61
+ if (result.exitCode !== 0)
62
62
  return null;
63
- }
63
+ const name = result.stdout.toString("utf8").trim();
64
+ return name === "HEAD" ? null : name;
64
65
  };
65
- const resolveDiffBaseRef = (r) => {
66
- try {
67
- r.repo.head();
68
- return "HEAD";
69
- }
70
- catch {
71
- return GIT_EMPTY_TREE_HASH;
72
- }
66
+ // working mode compares against HEAD (or the empty tree when the branch is
67
+ // unborn). branch mode compares against the merge-base of HEAD and the base
68
+ // ref, so only changes since the branches diverged are shown.
69
+ const resolveDiffBaseRef = async (cwd) => {
70
+ const result = await runGit(cwd, ["rev-parse", "--verify", "-q", "HEAD"]);
71
+ return result.exitCode === 0 ? "HEAD" : GIT_EMPTY_TREE_HASH;
73
72
  };
74
- const resolveDefaultBase = (r) => {
75
- const currentBranch = getCurrentBranch(r);
76
- try {
77
- const remoteHead = r.repo.getReference("refs/remotes/origin/HEAD");
78
- const symTarget = remoteHead.symbolicTarget();
79
- if (symTarget) {
80
- const shortName = symTarget.replace("refs/remotes/", "");
81
- if (shortName !== currentBranch) {
82
- try {
83
- r.repo.revparseSingle(shortName);
84
- return { ref: shortName, source: "remoteHead" };
85
- }
86
- catch {
87
- // stale origin/HEAD
88
- }
73
+ const verifyRef = async (cwd, ref) => {
74
+ const result = await runGit(cwd, ["rev-parse", "--verify", "-q", ref]);
75
+ return result.exitCode === 0;
76
+ };
77
+ const resolveDefaultBase = async (cwd) => {
78
+ const currentBranch = await getCurrentBranch(cwd);
79
+ const symbolic = await runGit(cwd, ["symbolic-ref", "-q", "refs/remotes/origin/HEAD"]);
80
+ if (symbolic.exitCode === 0) {
81
+ const target = symbolic.stdout.toString("utf8").trim();
82
+ if (target.startsWith("refs/remotes/")) {
83
+ const shortName = target.slice("refs/remotes/".length);
84
+ if (shortName !== currentBranch && (await verifyRef(cwd, shortName))) {
85
+ return { ref: shortName, source: "remoteHead" };
89
86
  }
90
87
  }
91
88
  }
92
- catch {
93
- // No origin/HEAD configured
94
- }
95
89
  for (const name of ["main", "master", "develop"]) {
96
90
  if (name === currentBranch)
97
91
  continue;
98
92
  for (const candidate of [`origin/${name}`, name]) {
99
- try {
100
- r.repo.revparseSingle(candidate);
93
+ if (await verifyRef(cwd, candidate))
101
94
  return { ref: candidate, source: "fallback" };
102
- }
103
- catch {
104
- continue;
105
- }
106
95
  }
107
96
  }
108
97
  return null;
109
98
  };
110
- const resolveEffectiveBaseRef = (r, options) => {
99
+ const resolveEffectiveBaseRef = async (cwd, options) => {
111
100
  if (options.mode !== "branch")
112
- return resolveDiffBaseRef(r);
101
+ return resolveDiffBaseRef(cwd);
113
102
  let baseRef = options.base?.trim() || null;
114
- if (baseRef) {
115
- try {
116
- r.repo.revparseSingle(baseRef);
117
- }
118
- catch {
119
- baseRef = null;
120
- }
121
- }
122
- if (!baseRef) {
123
- const resolved = resolveDefaultBase(r);
124
- baseRef = resolved?.ref ?? null;
125
- }
103
+ if (baseRef && !(await verifyRef(cwd, baseRef)))
104
+ baseRef = null;
105
+ if (!baseRef)
106
+ baseRef = (await resolveDefaultBase(cwd))?.ref ?? null;
126
107
  if (!baseRef)
127
108
  return null;
128
- try {
129
- const baseOid = r.repo.revparseSingle(baseRef);
130
- const headOid = r.repo.revparseSingle("HEAD");
131
- const mergeBase = r.repo.getMergeBase(baseOid, headOid);
132
- if (mergeBase)
133
- return mergeBase;
134
- }
135
- catch {
136
- // Unrelated histories
137
- }
138
- try {
139
- return r.repo.revparseSingle(baseRef);
140
- }
141
- catch {
109
+ const mergeBase = await runGit(cwd, ["merge-base", baseRef, "HEAD"]);
110
+ if (mergeBase.exitCode === 0)
111
+ return mergeBase.stdout.toString("utf8").trim();
112
+ if (!(await verifyRef(cwd, baseRef)))
142
113
  return null;
143
- }
114
+ return runGitText(cwd, ["rev-parse", "--verify", baseRef]);
144
115
  };
145
116
  const countLines = (text) => {
146
117
  if (text.length === 0)
@@ -154,42 +125,45 @@ const countLines = (text) => {
154
125
  count += 1;
155
126
  return count;
156
127
  };
157
- const collectUntrackedFiles = async (r) => {
158
- const files = [];
159
- const statuses = r.repo.statuses();
160
- const entries = collectIterator(statuses.iter());
161
- for (const entry of entries) {
162
- const s = entry.status();
163
- if (!s.wtNew || s.ignored)
164
- continue;
165
- if (files.length >= GIT_MAX_UNTRACKED_FILES)
166
- break;
167
- const filePath = entry.path();
168
- const absolutePath = path.join(r.cwd, filePath);
128
+ const readUntrackedFile = async (cwd, filePath) => {
129
+ const absolutePath = path.join(cwd, filePath);
130
+ try {
131
+ const stat = await fsPromises.stat(absolutePath);
132
+ if (!stat.isFile())
133
+ return null;
134
+ const bytesToRead = Math.min(stat.size, GIT_MAX_UNTRACKED_FILE_BYTES);
135
+ const buffer = Buffer.alloc(bytesToRead);
136
+ const handle = await fsPromises.open(absolutePath, "r");
169
137
  try {
170
- const stat = await fsPromises.stat(absolutePath);
171
- if (!stat.isFile())
172
- continue;
173
- const bytesToRead = Math.min(stat.size, GIT_MAX_UNTRACKED_FILE_BYTES);
174
- const buffer = Buffer.alloc(bytesToRead);
175
- const handle = await fsPromises.open(absolutePath, "r");
176
- try {
177
- const { buffer: readBuffer } = await handle.read(buffer, 0, bytesToRead, 0);
178
- const sniffEnd = Math.min(readBuffer.length, GIT_BINARY_SNIFF_BYTES);
179
- const binary = readBuffer.subarray(0, sniffEnd).includes(0);
180
- const truncated = stat.size > GIT_MAX_UNTRACKED_FILE_BYTES;
181
- const content = binary ? null : truncated ? null : readBuffer.toString("utf8");
182
- const lines = binary ? 0 : content ? countLines(content) : 0;
183
- files.push({ path: filePath, binary, lines, content, truncated });
184
- }
185
- finally {
186
- await handle.close();
187
- }
138
+ const { buffer: readBuffer } = await handle.read(buffer, 0, bytesToRead, 0);
139
+ const sniffEnd = Math.min(readBuffer.length, GIT_BINARY_SNIFF_BYTES);
140
+ const binary = readBuffer.subarray(0, sniffEnd).includes(0);
141
+ const truncated = stat.size > GIT_MAX_UNTRACKED_FILE_BYTES;
142
+ const content = binary ? null : truncated ? null : readBuffer.toString("utf8");
143
+ const lines = binary ? 0 : content ? countLines(content) : 0;
144
+ return { path: filePath, binary, lines, content, truncated };
188
145
  }
189
- catch {
190
- continue;
146
+ finally {
147
+ await handle.close();
191
148
  }
192
149
  }
150
+ catch {
151
+ return null;
152
+ }
153
+ };
154
+ const collectUntrackedFiles = async (cwd) => {
155
+ const result = await runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z"]);
156
+ if (result.exitCode !== 0)
157
+ return [];
158
+ const paths = result.stdout.toString("utf8").split("\0").filter(Boolean);
159
+ const files = [];
160
+ for (const filePath of paths) {
161
+ if (files.length >= GIT_MAX_UNTRACKED_FILES)
162
+ break;
163
+ const file = await readUntrackedFile(cwd, filePath);
164
+ if (file)
165
+ files.push(file);
166
+ }
193
167
  return files;
194
168
  };
195
169
  export const buildUntrackedPatch = (content) => {
@@ -203,161 +177,7 @@ export const buildUntrackedPatch = (content) => {
203
177
  const noNewlineMarker = hasTrailingNewline ? "" : "\n\";
204
178
  return `@@ -0,0 +1,${lines.length} @@\n${body}${noNewlineMarker}\n`;
205
179
  };
206
- const readBlobContent = (r, oid) => {
207
- if (oid === ZERO_OID)
208
- return null;
209
- try {
210
- const obj = r.repo.findObject(oid);
211
- if (!obj)
212
- return null;
213
- const blob = obj.peelToBlob();
214
- return Buffer.from(blob.content()).toString("utf8");
215
- }
216
- catch {
217
- return null;
218
- }
219
- };
220
- const readWorkingTreeFile = (r, filePath, maxBytes = GIT_MAX_UNTRACKED_FILE_BYTES) => {
221
- const absolutePath = path.join(r.cwd, filePath);
222
- try {
223
- const stat = fs.statSync(absolutePath);
224
- if (!stat.isFile())
225
- return null;
226
- const bytesToRead = Math.min(stat.size, maxBytes);
227
- const buffer = Buffer.alloc(bytesToRead);
228
- const handle = fs.openSync(absolutePath, "r");
229
- fs.readSync(handle, buffer, 0, bytesToRead, 0);
230
- fs.closeSync(handle);
231
- const sniffEnd = Math.min(buffer.length, GIT_BINARY_SNIFF_BYTES);
232
- if (buffer.subarray(0, sniffEnd).includes(0))
233
- return null;
234
- if (stat.size > maxBytes)
235
- return null;
236
- return buffer.toString("utf8");
237
- }
238
- catch {
239
- return null;
240
- }
241
- };
242
- const collectDeltaMeta = (r, diff, isWorkingTree) => {
243
- const stats = diff.stats();
244
- const totalInsertions = Number(stats.insertions);
245
- const totalDeletions = Number(stats.deletions);
246
- const deltas = collectIterator(diff.deltas());
247
- const result = [];
248
- for (const delta of deltas) {
249
- const status = deltaTypeToStatus(delta.status());
250
- if (status === "untracked")
251
- continue;
252
- const newFile = delta.newFile();
253
- const oldFile = delta.oldFile();
254
- const isBinary = newFile.isBinary();
255
- result.push({
256
- path: newFile.path(),
257
- oldPath: status === "renamed" ? oldFile.path() : null,
258
- status,
259
- additions: 0,
260
- deletions: 0,
261
- binary: isBinary,
262
- oldId: oldFile.id(),
263
- newId: newFile.id(),
264
- isWorkingTree,
265
- });
266
- }
267
- // Compute per-file additions/deletions from patch text.
268
- // es-git's DiffStats only gives aggregate totals; we generate
269
- // patches per-delta and count +/- lines from the structured diff.
270
- const nonBinary = result.filter((d) => !d.binary);
271
- if (nonBinary.length === 0)
272
- return result;
273
- if (nonBinary.length === 1) {
274
- nonBinary[0].additions = totalInsertions;
275
- nonBinary[0].deletions = totalDeletions;
276
- return result;
277
- }
278
- // Compute per-file stats from patch text. Each delta gets its own
279
- // patch generated from old vs new content, giving exact counts.
280
- for (const d of result) {
281
- if (d.binary)
282
- continue;
283
- try {
284
- const oldContent = d.oldId === ZERO_OID || d.oldId === ""
285
- ? d.status === "added"
286
- ? null
287
- : readBlobContent(r, d.oldId)
288
- : readBlobContent(r, d.oldId);
289
- const newContent = d.isWorkingTree
290
- ? readWorkingTreeFile(r, d.path, GIT_MAX_TOTAL_PATCH_BYTES)
291
- : d.newId === ZERO_OID || d.newId === ""
292
- ? d.status === "deleted"
293
- ? null
294
- : readBlobContent(r, d.newId)
295
- : readBlobContent(r, d.newId);
296
- const aPath = d.oldPath ?? d.path;
297
- const patchResult = computePatchFromContents(oldContent, newContent, aPath, d.path, null, null, d.oldId === ZERO_OID ? null : d.oldId, d.newId === ZERO_OID ? null : d.newId, d.status === "renamed");
298
- d.additions = patchResult.additions;
299
- d.deletions = patchResult.deletions;
300
- }
301
- catch {
302
- // Fallback: proportional split of aggregate totals
303
- const perFileAdd = Math.floor(totalInsertions / nonBinary.length);
304
- d.additions = perFileAdd;
305
- d.deletions = Math.floor(totalDeletions / nonBinary.length);
306
- }
307
- }
308
- // Verify totals match and redistribute remainder if needed
309
- reconcileFileStats(result, totalInsertions, totalDeletions);
310
- return result;
311
- };
312
- const buildBaseTree = (r, baseRef) => {
313
- try {
314
- return r.repo.getTree(baseRef);
315
- }
316
- catch {
317
- try {
318
- const baseOid = r.repo.revparseSingle(baseRef);
319
- const obj = r.repo.findObject(baseOid);
320
- if (!obj)
321
- return null;
322
- if (obj.type() === "Tree") {
323
- return obj;
324
- }
325
- const commit = r.repo.getCommit(baseOid);
326
- return commit.tree();
327
- }
328
- catch {
329
- return null;
330
- }
331
- }
332
- };
333
180
  export const splitPatchByFile = (raw) => raw.split(/^(?=diff --git )/m).filter((chunk) => chunk.startsWith("diff --git "));
334
- const computeTrackedDeltas = async (r, baseRef) => {
335
- const baseTree = buildBaseTree(r, baseRef);
336
- if (!baseTree)
337
- return [];
338
- let diff;
339
- try {
340
- diff = r.repo.diffTreeToWorkdirWithIndex(baseTree);
341
- }
342
- catch {
343
- return [];
344
- }
345
- try {
346
- diff.findSimilar({ renames: true });
347
- }
348
- catch {
349
- // Rename detection failed
350
- }
351
- const deltaMeta = collectDeltaMeta(r, diff, true);
352
- return deltaMeta.map((d) => ({
353
- path: d.path,
354
- oldPath: d.oldPath,
355
- status: d.status,
356
- additions: d.additions,
357
- deletions: d.deletions,
358
- binary: d.binary,
359
- }));
360
- };
361
181
  export const parseNumstatZ = (raw) => {
362
182
  const tokens = raw.split("\0");
363
183
  const entries = [];
@@ -413,242 +233,236 @@ export const parseNameStatusZ = (raw) => {
413
233
  }
414
234
  return statuses;
415
235
  };
416
- export const getGitDiffSummary = async (cwd, options = WORKING_OPTIONS) => {
417
- const r = await openRepo(cwd);
418
- if (!r)
419
- return EMPTY_SUMMARY;
420
- try {
421
- const baseRef = resolveEffectiveBaseRef(r, options);
422
- if (baseRef === null)
423
- return { ...EMPTY_SUMMARY, isRepo: true };
424
- const branch = getCurrentBranch(r);
425
- const baseTree = buildBaseTree(r, baseRef);
426
- if (!baseTree)
427
- return { ...EMPTY_SUMMARY, isRepo: true };
428
- let diff;
429
- try {
430
- diff = r.repo.diffTreeToWorkdirWithIndex(baseTree);
431
- }
432
- catch {
433
- return { ...EMPTY_SUMMARY, isRepo: true };
434
- }
435
- try {
436
- diff.findSimilar({ renames: true });
437
- }
438
- catch {
439
- // Rename detection failed
440
- }
441
- const stats = diff.stats();
442
- let additions = Number(stats.insertions);
443
- let deletions = Number(stats.deletions);
444
- let binaries = 0;
445
- let fileCount = Number(stats.filesChanged);
446
- const diffDeltas = collectIterator(diff.deltas());
447
- for (const delta of diffDeltas) {
448
- if (deltaTypeToStatus(delta.status()) !== "untracked" && delta.newFile().isBinary()) {
449
- binaries++;
450
- }
451
- }
452
- const untracked = await collectUntrackedFiles(r);
453
- for (const file of untracked) {
454
- fileCount++;
455
- if (file.binary) {
456
- binaries++;
457
- }
458
- else {
459
- additions += file.lines;
460
- }
461
- }
462
- return { isRepo: true, files: fileCount, additions, deletions, binaries, branch };
463
- }
464
- catch {
465
- return { ...EMPTY_SUMMARY, isRepo: true };
466
- }
467
- };
468
- const buildFilePatch = (r, delta) => {
469
- if (delta.binary)
236
+ // One full diff pass for `(cwd)` against `baseRef`. Three parallel `git diff`
237
+ // invocations (numstat for counts+binary, name-status for the status letter +
238
+ // rename old path, patch for the body) walk the same sorted diff queue, so
239
+ // numstat[i]/name-status.key(patch-path)/patch[i] all describe the same file
240
+ // positionally. Untracked files are folded in from `ls-files` with synthesized
241
+ // patches (git's own diff never lists untracked files).
242
+ const buildDiffCache = async (cwd, baseRef) => {
243
+ const [numstatRes, nameStatusRes, patchRes] = await Promise.all([
244
+ runGit(cwd, ["diff", DIFF_RENAME_FLAG, "--numstat", "-z", baseRef]),
245
+ runGit(cwd, ["diff", DIFF_RENAME_FLAG, "--name-status", "-z", baseRef]),
246
+ runGit(cwd, ["diff", DIFF_RENAME_FLAG, "--patch", baseRef]),
247
+ ]);
248
+ if (numstatRes.exitCode !== 0 || patchRes.exitCode !== 0)
470
249
  return null;
471
- const oldContent = delta.status === "added"
472
- ? null
473
- : delta.isWorkingTree
474
- ? readBlobContent(r, delta.oldId)
475
- : readBlobContent(r, delta.oldId);
476
- const newContent = delta.status === "deleted"
477
- ? null
478
- : delta.isWorkingTree
479
- ? readWorkingTreeFile(r, delta.path, GIT_MAX_TOTAL_PATCH_BYTES)
480
- : delta.newId === ZERO_OID
481
- ? readWorkingTreeFile(r, delta.path, GIT_MAX_TOTAL_PATCH_BYTES)
482
- : readBlobContent(r, delta.newId);
483
- const aPath = delta.oldPath ?? delta.path;
484
- const result = computePatchFromContents(oldContent, newContent, aPath, delta.path, null, null, delta.oldId === ZERO_OID ? null : delta.oldId, delta.newId === ZERO_OID ? null : delta.newId, delta.status === "renamed");
485
- return result.patchText || null;
486
- };
487
- export const getGitDiff = async (cwd, options = WORKING_OPTIONS) => {
488
- const r = await openRepo(cwd);
489
- if (!r)
490
- return { isRepo: false, files: [] };
491
- const baseRef = resolveEffectiveBaseRef(r, options);
492
- if (baseRef === null)
493
- return { isRepo: true, files: [] };
494
- const baseTree = buildBaseTree(r, baseRef);
495
- if (!baseTree)
496
- return { isRepo: true, files: [] };
497
- let diff;
498
- try {
499
- diff = r.repo.diffTreeToWorkdirWithIndex(baseTree);
500
- }
501
- catch {
502
- return { isRepo: true, files: [] };
503
- }
504
- try {
505
- diff.findSimilar({ renames: true });
506
- }
507
- catch {
508
- // Rename detection failed
509
- }
510
- const deltaMeta = collectDeltaMeta(r, diff, true);
250
+ const numstat = parseNumstatZ(numstatRes.stdout.toString("utf8"));
251
+ const statuses = parseNameStatusZ(nameStatusRes.stdout.toString("utf8"));
252
+ const patches = splitPatchByFile(patchRes.stdout.toString("utf8"));
253
+ // numstat and --patch share the diff queue's order; if they ever disagree
254
+ // (a git version quirk on some edge diff) we can't trust positional pairing,
255
+ // so degrade: stats stay correct, patches read as omitted.
256
+ const patchesAligned = patches.length === numstat.length;
257
+ const untracked = await collectUntrackedFiles(cwd);
258
+ const fileMeta = [];
259
+ const filePatchByPath = new Map();
260
+ const fileBinaryByPath = new Map();
261
+ const filePatchOmittedByPath = new Map();
511
262
  let totalPatchBytes = 0;
512
- const files = deltaMeta.map((entry) => {
513
- let patch = null;
263
+ let additions = 0;
264
+ let deletions = 0;
265
+ let binaries = 0;
266
+ for (let index = 0; index < numstat.length; index += 1) {
267
+ const entry = numstat[index];
268
+ const status = statuses.get(entry.path) ?? "modified";
269
+ const oldPath = status === "renamed" ? entry.oldPath : null;
270
+ let patchText = null;
514
271
  let patchOmitted = false;
515
272
  if (entry.binary) {
516
- patch = null;
273
+ binaries += 1;
517
274
  }
518
275
  else {
519
- try {
520
- patch = buildFilePatch(r, entry);
521
- }
522
- catch {
276
+ const rawPatch = patchesAligned ? (patches[index] ?? null) : null;
277
+ if (rawPatch === null ||
278
+ rawPatch.length > GIT_MAX_PATCH_BYTES_PER_FILE ||
279
+ totalPatchBytes + rawPatch.length > GIT_MAX_TOTAL_PATCH_BYTES) {
523
280
  patchOmitted = true;
524
281
  }
525
- if (patch !== null) {
526
- if (patch.length > GIT_MAX_PATCH_BYTES_PER_FILE ||
527
- totalPatchBytes + patch.length > GIT_MAX_TOTAL_PATCH_BYTES) {
528
- patch = null;
529
- patchOmitted = true;
530
- }
531
- else {
532
- totalPatchBytes += patch.length;
533
- }
534
- }
535
- else if (!patchOmitted) {
536
- patchOmitted = true;
282
+ else {
283
+ patchText = rawPatch;
284
+ totalPatchBytes += rawPatch.length;
537
285
  }
286
+ additions += entry.additions;
287
+ deletions += entry.deletions;
538
288
  }
539
- return {
289
+ fileMeta.push({
540
290
  path: entry.path,
541
- oldPath: entry.oldPath,
542
- status: entry.status,
291
+ oldPath,
292
+ status,
543
293
  additions: entry.additions,
544
294
  deletions: entry.deletions,
545
295
  binary: entry.binary,
546
- patch,
547
- patchOmitted,
548
- };
549
- });
550
- const untracked = await collectUntrackedFiles(r);
296
+ });
297
+ filePatchByPath.set(entry.path, patchText);
298
+ fileBinaryByPath.set(entry.path, entry.binary);
299
+ filePatchOmittedByPath.set(entry.path, patchOmitted);
300
+ }
551
301
  for (const file of untracked) {
552
302
  const patch = file.binary
553
303
  ? null
554
304
  : file.truncated || file.content === null
555
305
  ? null
556
306
  : buildUntrackedPatch(file.content);
557
- const entry = {
558
- path: file.path,
559
- oldPath: null,
560
- status: "untracked",
561
- additions: file.binary ? 0 : file.lines,
562
- deletions: 0,
563
- binary: file.binary,
564
- patch,
565
- patchOmitted: file.truncated,
566
- };
567
- if (entry.patch !== null) {
568
- if (entry.patch.length > GIT_MAX_PATCH_BYTES_PER_FILE ||
569
- totalPatchBytes + entry.patch.length > GIT_MAX_TOTAL_PATCH_BYTES) {
570
- entry.patch = null;
571
- entry.patchOmitted = true;
307
+ let patchOmitted = file.truncated;
308
+ if (patch !== null) {
309
+ if (patch.length > GIT_MAX_PATCH_BYTES_PER_FILE ||
310
+ totalPatchBytes + patch.length > GIT_MAX_TOTAL_PATCH_BYTES) {
311
+ patchOmitted = true;
572
312
  }
573
313
  else {
574
- totalPatchBytes += entry.patch.length;
314
+ totalPatchBytes += patch.length;
575
315
  }
576
316
  }
577
- files.push(entry);
578
- }
579
- return { isRepo: true, files };
580
- };
581
- export const getGitDiffFiles = async (cwd, options = WORKING_OPTIONS) => {
582
- const r = await openRepo(cwd);
583
- if (!r)
584
- return { isRepo: false, files: [] };
585
- const baseRef = resolveEffectiveBaseRef(r, options);
586
- if (baseRef === null)
587
- return { isRepo: true, files: [] };
588
- const trackedDeltas = await computeTrackedDeltas(r, baseRef);
589
- const files = trackedDeltas.map((entry) => ({
590
- path: entry.path,
591
- oldPath: entry.oldPath,
592
- status: entry.status,
593
- additions: entry.additions,
594
- deletions: entry.deletions,
595
- binary: entry.binary,
596
- }));
597
- const untracked = await collectUntrackedFiles(r);
598
- for (const file of untracked) {
599
- files.push({
317
+ const fileAdditions = file.binary ? 0 : file.lines;
318
+ if (file.binary)
319
+ binaries += 1;
320
+ additions += fileAdditions;
321
+ fileMeta.push({
600
322
  path: file.path,
601
323
  oldPath: null,
602
324
  status: "untracked",
603
- additions: file.binary ? 0 : file.lines,
325
+ additions: fileAdditions,
604
326
  deletions: 0,
605
327
  binary: file.binary,
606
328
  });
329
+ filePatchByPath.set(file.path, patchOmitted ? null : patch);
330
+ fileBinaryByPath.set(file.path, file.binary);
331
+ filePatchOmittedByPath.set(file.path, patchOmitted);
607
332
  }
608
- return { isRepo: true, files };
333
+ const summary = {
334
+ isRepo: true,
335
+ files: fileMeta.length,
336
+ additions,
337
+ deletions,
338
+ binaries,
339
+ branch: await getCurrentBranch(cwd),
340
+ };
341
+ return {
342
+ summary,
343
+ fileMeta,
344
+ filePatchByPath,
345
+ fileBinaryByPath,
346
+ filePatchOmittedByPath,
347
+ builtAt: Date.now(),
348
+ };
609
349
  };
610
- export const getGitDiffFilePatch = async (cwd, requestedPath, options = WORKING_OPTIONS) => {
611
- const empty = { patch: null, patchOmitted: false, binary: false };
612
- const r = await openRepo(cwd);
613
- if (!r)
614
- return empty;
615
- const baseRef = resolveEffectiveBaseRef(r, options);
350
+ const ensureDiffCache = async (cwd, options) => {
351
+ // Read the cache before resolving the base ref that resolution does git work
352
+ // (rev-parse + merge-base) on every call, so for the per-file patch endpoint
353
+ // (where the cache is warm on nearly every request) checking first keeps it a
354
+ // pure map lookup with no subprocess.
355
+ const cached = readDiffCache(cwd, options.mode, options.base ?? null);
356
+ if (cached)
357
+ return cached;
358
+ const baseRef = await resolveEffectiveBaseRef(cwd, options);
616
359
  if (baseRef === null)
617
- return empty;
618
- const baseTree = buildBaseTree(r, baseRef);
619
- if (!baseTree)
620
- return empty;
621
- let diff;
360
+ return null;
361
+ const cache = await buildDiffCache(cwd, baseRef);
362
+ if (cache)
363
+ writeDiffCache(cwd, options.mode, options.base ?? null, cache);
364
+ return cache;
365
+ };
366
+ export const getGitDiffSummary = async (cwd, options = WORKING_OPTIONS) => {
367
+ const cached = readDiffCache(cwd, options.mode, options.base ?? null);
368
+ if (cached)
369
+ return cached.summary;
370
+ if (!(await isGitRepo(cwd)))
371
+ return EMPTY_SUMMARY;
622
372
  try {
623
- diff = r.repo.diffTreeToWorkdirWithIndex(baseTree);
373
+ const baseRef = await resolveEffectiveBaseRef(cwd, options);
374
+ if (baseRef === null)
375
+ return { ...EMPTY_SUMMARY, isRepo: true };
376
+ const branch = await getCurrentBranch(cwd);
377
+ // Summary is pushed on every git-dirty signal, so it stays on the cheap
378
+ // numstat-only path (no patch) unless a cache is already warm.
379
+ const numstatRes = await runGit(cwd, ["diff", DIFF_RENAME_FLAG, "--numstat", "-z", baseRef]);
380
+ if (numstatRes.exitCode !== 0)
381
+ return { ...EMPTY_SUMMARY, isRepo: true };
382
+ let additions = 0;
383
+ let deletions = 0;
384
+ let binaries = 0;
385
+ let fileCount = 0;
386
+ for (const entry of parseNumstatZ(numstatRes.stdout.toString("utf8"))) {
387
+ fileCount += 1;
388
+ if (entry.binary) {
389
+ binaries += 1;
390
+ continue;
391
+ }
392
+ additions += entry.additions;
393
+ deletions += entry.deletions;
394
+ }
395
+ const untracked = await collectUntrackedFiles(cwd);
396
+ for (const file of untracked) {
397
+ fileCount += 1;
398
+ if (file.binary) {
399
+ binaries += 1;
400
+ }
401
+ else {
402
+ additions += file.lines;
403
+ }
404
+ }
405
+ return { isRepo: true, files: fileCount, additions, deletions, binaries, branch };
624
406
  }
625
407
  catch {
626
- return empty;
627
- }
628
- try {
629
- diff.findSimilar({ renames: true });
408
+ return { ...EMPTY_SUMMARY, isRepo: true };
630
409
  }
631
- catch {
632
- // Rename detection failed
410
+ };
411
+ export const getGitDiff = async (cwd, options = WORKING_OPTIONS) => {
412
+ const cached = readDiffCache(cwd, options.mode, options.base ?? null);
413
+ if (cached) {
414
+ return { isRepo: true, files: mapCacheFiles(cached) };
633
415
  }
634
- const deltaMeta = collectDeltaMeta(r, diff, true);
635
- const entry = deltaMeta.find((d) => d.path === requestedPath);
636
- if (entry) {
637
- if (entry.binary)
638
- return { patch: null, patchOmitted: false, binary: true };
639
- try {
640
- const patch = buildFilePatch(r, entry);
641
- if (patch === null)
642
- return empty;
643
- if (patch.length > GIT_MAX_PATCH_BYTES_PER_FILE) {
644
- return { patch: null, patchOmitted: true, binary: false };
645
- }
646
- return { patch, patchOmitted: false, binary: false };
647
- }
648
- catch {
649
- return { patch: null, patchOmitted: true, binary: false };
650
- }
416
+ if (!(await isGitRepo(cwd)))
417
+ return { isRepo: false, files: [] };
418
+ const cache = await ensureDiffCache(cwd, options);
419
+ if (!cache)
420
+ return { isRepo: true, files: [] };
421
+ return { isRepo: true, files: mapCacheFiles(cache) };
422
+ };
423
+ const mapCacheFiles = (cache) => cache.fileMeta.map((meta) => ({
424
+ path: meta.path,
425
+ oldPath: meta.oldPath,
426
+ status: meta.status,
427
+ additions: meta.additions,
428
+ deletions: meta.deletions,
429
+ binary: meta.binary,
430
+ patch: cache.filePatchByPath.get(meta.path) ?? null,
431
+ patchOmitted: cache.filePatchOmittedByPath.get(meta.path) ?? false,
432
+ }));
433
+ export const getGitDiffFiles = async (cwd, options = WORKING_OPTIONS) => {
434
+ const cached = readDiffCache(cwd, options.mode, options.base ?? null);
435
+ if (cached)
436
+ return { isRepo: true, files: cached.fileMeta };
437
+ if (!(await isGitRepo(cwd)))
438
+ return { isRepo: false, files: [] };
439
+ const cache = await ensureDiffCache(cwd, options);
440
+ if (!cache)
441
+ return { isRepo: true, files: [] };
442
+ return { isRepo: true, files: cache.fileMeta };
443
+ };
444
+ export const getGitDiffFilePatch = async (cwd, requestedPath, options = WORKING_OPTIONS) => {
445
+ const empty = { patch: null, patchOmitted: false, binary: false };
446
+ const cache = await ensureDiffCache(cwd, options);
447
+ if (!cache)
448
+ return empty;
449
+ // O(1) lookup: the full diff pass that the per-file patch needs was already
450
+ // computed once for this (cwd, mode, base) and cached. This was the O(N²)
451
+ // regression — each per-file request re-ran the whole-tree diff + a jsdiff
452
+ // for every file. Now it's a map lookup.
453
+ if (cache.filePatchByPath.has(requestedPath)) {
454
+ return {
455
+ patch: cache.filePatchByPath.get(requestedPath) ?? null,
456
+ patchOmitted: cache.filePatchOmittedByPath.get(requestedPath) ?? false,
457
+ binary: cache.fileBinaryByPath.get(requestedPath) ?? false,
458
+ };
651
459
  }
460
+ // An untracked path the cache didn't cover (created between the cache build
461
+ // and this request) falls back to synthesizing from the working tree.
462
+ return getGitDiffFilePatchFromWorkingTree(cwd, requestedPath);
463
+ };
464
+ const getGitDiffFilePatchFromWorkingTree = async (cwd, requestedPath) => {
465
+ const empty = { patch: null, patchOmitted: false, binary: false };
652
466
  const absolutePath = path.join(cwd, requestedPath);
653
467
  try {
654
468
  const stat = fs.statSync(absolutePath);
@@ -715,34 +529,33 @@ let activePrFetcher = defaultPrFetcher;
715
529
  export const setPrFetcher = (fetcher) => {
716
530
  activePrFetcher = fetcher;
717
531
  };
718
- const parseGithubRemotes = (r) => {
532
+ const parseGithubRemotes = async (cwd) => {
533
+ const result = await runGit(cwd, ["remote", "-v"]);
534
+ if (result.exitCode !== 0)
535
+ return [];
719
536
  const raw = [];
720
- try {
721
- for (const remoteName of r.repo.remoteNames()) {
722
- try {
723
- const remote = r.repo.getRemote(remoteName);
724
- const url = remote.url();
725
- const match = /github\.com[/:]([^\s/]+)\/([^\s]+?)(?:\.git)?$/i.exec(url);
726
- if (!match)
727
- continue;
728
- const [, owner, repoName] = match;
729
- raw.push({ name: remoteName, slug: `${owner}/${repoName}`, owner });
730
- }
731
- catch {
732
- continue;
733
- }
734
- }
735
- }
736
- catch {
737
- // No remotes
537
+ const seen = new Set();
538
+ for (const line of result.stdout.toString("utf8").split("\n")) {
539
+ const match = /^(\S+)\t(.+?)\s+\(fetch\)$/.exec(line);
540
+ if (!match)
541
+ continue;
542
+ const [, name, url] = match;
543
+ if (seen.has(name))
544
+ continue;
545
+ seen.add(name);
546
+ const urlMatch = /github\.com[/:]([^\s/]+)\/([^\s]+?)(?:\.git)?$/i.exec(url);
547
+ if (!urlMatch)
548
+ continue;
549
+ const [, owner, repoName] = urlMatch;
550
+ raw.push({ name, slug: `${owner}/${repoName}`, owner });
738
551
  }
739
552
  return memoBy(raw, (remote) => `${remote.name} ${remote.slug}`);
740
553
  };
741
- const detectPr = async (r) => {
742
- const currentBranch = getCurrentBranch(r);
554
+ const detectPr = async (cwd) => {
555
+ const currentBranch = await getCurrentBranch(cwd);
743
556
  if (!currentBranch)
744
557
  return null;
745
- const remotes = parseGithubRemotes(r);
558
+ const remotes = await parseGithubRemotes(cwd);
746
559
  if (remotes.length === 0)
747
560
  return null;
748
561
  const ownRemote = remotes.find((remote) => remote.name === "origin") ?? remotes[0];
@@ -771,16 +584,32 @@ const detectPr = async (r) => {
771
584
  }
772
585
  : null;
773
586
  };
774
- export const listGithubRemoteSlugs = async (cwd) => {
775
- const r = await openRepo(cwd);
776
- if (!r)
587
+ const listBranchesByRecency = async (cwd) => {
588
+ const result = await runGit(cwd, [
589
+ "for-each-ref",
590
+ "--format=%(refname:short)",
591
+ "--sort=-committerdate",
592
+ "refs/heads",
593
+ "refs/remotes",
594
+ ]);
595
+ if (result.exitCode !== 0)
777
596
  return [];
778
- const remotes = parseGithubRemotes(r);
597
+ const names = [];
598
+ for (const name of result.stdout.toString("utf8").split("\n")) {
599
+ if (!name || name.endsWith("/HEAD"))
600
+ continue;
601
+ names.push(name);
602
+ if (names.length >= GIT_MAX_BRANCHES)
603
+ break;
604
+ }
605
+ return names;
606
+ };
607
+ export const listGithubRemoteSlugs = async (cwd) => {
608
+ const remotes = await parseGithubRemotes(cwd);
779
609
  return memoBy(remotes, (remote) => remote.slug).map((remote) => remote.slug);
780
610
  };
781
611
  export const getGitBranchInfo = async (cwd) => {
782
- const r = await openRepo(cwd);
783
- if (!r) {
612
+ if (!(await isGitRepo(cwd))) {
784
613
  return {
785
614
  isRepo: false,
786
615
  currentBranch: null,
@@ -790,50 +619,23 @@ export const getGitBranchInfo = async (cwd) => {
790
619
  pr: null,
791
620
  };
792
621
  }
793
- // Pure-local: branch refs + default base only. The PR lease is resolved
794
- // separately by getGitBranchPr so the toolbar paints without waiting on the
795
- // GitHub REST API.
796
- const currentBranch = getCurrentBranch(r);
797
- const defaultBase = resolveDefaultBase(r);
798
- const branchEntries = collectIterator(r.repo.branches());
799
- const branchData = [];
800
- for (const b of branchEntries) {
801
- if (b.name.endsWith("/HEAD"))
802
- continue;
803
- try {
804
- const refName = b.type === "Remote" ? `refs/remotes/${b.name}` : `refs/heads/${b.name}`;
805
- const ref = r.repo.getReference(refName);
806
- const target = ref.target();
807
- if (!target)
808
- continue;
809
- const commit = r.repo.getCommit(target);
810
- branchData.push({ name: b.name, time: commit.time().getTime() });
811
- }
812
- catch {
813
- branchData.push({ name: b.name, time: 0 });
814
- }
815
- if (branchData.length >= GIT_MAX_BRANCHES)
816
- break;
817
- }
818
- branchData.sort((a, b) => b.time - a.time);
622
+ const [currentBranch, defaultBase, branches] = await Promise.all([
623
+ getCurrentBranch(cwd),
624
+ resolveDefaultBase(cwd),
625
+ listBranchesByRecency(cwd),
626
+ ]);
819
627
  return {
820
628
  isRepo: true,
821
629
  currentBranch,
822
630
  defaultBase: defaultBase?.ref ?? null,
823
631
  defaultBaseSource: defaultBase?.source ?? null,
824
- branches: branchData.map((b) => b.name),
632
+ branches,
825
633
  pr: null,
826
634
  };
827
635
  };
828
- // Resolves the PR the current branch maps to via the GitHub REST API. Bounded by
829
- // Octokit's own timeout and a try/catch in the default fetcher (returns []), so
830
- // it degrades to null on a missing token, network failure, or no PR. Served from
831
- // /api/git/branches/pr so the fast branch lease (/api/git/branches) never waits
832
- // on the network.
833
636
  export const getGitBranchPr = async (cwd) => {
834
- const r = await openRepo(cwd);
835
- if (!r)
637
+ if (!(await isGitRepo(cwd)))
836
638
  return null;
837
- return detectPr(r);
639
+ return detectPr(cwd);
838
640
  };
839
641
  //# sourceMappingURL=git-diff.js.map