@monotykamary/localterm-server 2.0.5 → 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 (40) hide show
  1. package/dist/constants.d.ts +1 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +4 -0
  4. package/dist/constants.js.map +1 -1
  5. package/dist/git-diff.d.ts.map +1 -1
  6. package/dist/git-diff.js +288 -483
  7. package/dist/git-diff.js.map +1 -1
  8. package/dist/utils/run-git.d.ts +7 -0
  9. package/dist/utils/run-git.d.ts.map +1 -0
  10. package/dist/utils/run-git.js +44 -0
  11. package/dist/utils/run-git.js.map +1 -0
  12. package/package.json +1 -3
  13. package/dist/cdp/open-background-tab.d.ts +0 -33
  14. package/dist/cdp/open-background-tab.d.ts.map +0 -1
  15. package/dist/cdp/open-background-tab.js +0 -98
  16. package/dist/cdp/open-background-tab.js.map +0 -1
  17. package/dist/finding-store.d.ts +0 -21
  18. package/dist/finding-store.d.ts.map +0 -1
  19. package/dist/finding-store.js +0 -155
  20. package/dist/finding-store.js.map +0 -1
  21. package/dist/utils/compute-patch.d.ts +0 -7
  22. package/dist/utils/compute-patch.d.ts.map +0 -1
  23. package/dist/utils/compute-patch.js +0 -74
  24. package/dist/utils/compute-patch.js.map +0 -1
  25. package/dist/utils/parse-osc-finding.d.ts +0 -7
  26. package/dist/utils/parse-osc-finding.d.ts.map +0 -1
  27. package/dist/utils/parse-osc-finding.js +0 -55
  28. package/dist/utils/parse-osc-finding.js.map +0 -1
  29. package/dist/utils/reconcile-file-stats.d.ts +0 -7
  30. package/dist/utils/reconcile-file-stats.d.ts.map +0 -1
  31. package/dist/utils/reconcile-file-stats.js +0 -45
  32. package/dist/utils/reconcile-file-stats.js.map +0 -1
  33. package/dist/utils/resolve-cwd-for-pid.d.ts +0 -2
  34. package/dist/utils/resolve-cwd-for-pid.d.ts.map +0 -1
  35. package/dist/utils/resolve-cwd-for-pid.js +0 -29
  36. package/dist/utils/resolve-cwd-for-pid.js.map +0 -1
  37. package/dist/utils/rewrite-kitty-file-transmission.d.ts +0 -2
  38. package/dist/utils/rewrite-kitty-file-transmission.d.ts.map +0 -1
  39. package/dist/utils/rewrite-kitty-file-transmission.js +0 -147
  40. package/dist/utils/rewrite-kitty-file-transmission.js.map +0 -1
package/dist/git-diff.js CHANGED
@@ -2,10 +2,9 @@ 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 { memoBy } from "./utils/memo-by.js";
7
- import { computePatchFromContents } from "./utils/compute-patch.js";
5
+ import { runGit } from "./utils/run-git.js";
8
6
  import { resolveGithubToken } from "./utils/resolve-github-token.js";
7
+ import { memoBy } from "./utils/memo-by.js";
9
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";
10
9
  const WORKING_OPTIONS = { mode: "working" };
11
10
  const EMPTY_SUMMARY = {
@@ -16,7 +15,6 @@ const EMPTY_SUMMARY = {
16
15
  binaries: 0,
17
16
  branch: null,
18
17
  };
19
- const ZERO_OID = "0000000000000000000000000000000000000000";
20
18
  // Nested so a cwd can hold more than one comparison (the working-tree summary
21
19
  // is pushed on git-dirty while the viewer may be open in branch mode).
22
20
  const diffCacheByCwd = new Map();
@@ -47,128 +45,73 @@ const writeDiffCache = (cwd, mode, base, cache) => {
47
45
  export const invalidateGitDiffCache = (cwd) => {
48
46
  diffCacheByCwd.delete(cwd);
49
47
  };
50
- const collectIterator = (iterable) => {
51
- const result = [];
52
- for (const item of iterable)
53
- result.push(item);
54
- return result;
55
- };
56
- const deltaTypeToStatus = (delta) => {
57
- switch (delta) {
58
- case "Added":
59
- return "added";
60
- case "Deleted":
61
- return "deleted";
62
- case "Renamed":
63
- return "renamed";
64
- case "Copied":
65
- return "added";
66
- case "Untracked":
67
- return "untracked";
68
- case "Modified":
69
- case "Typechange":
70
- default:
71
- return "modified";
72
- }
48
+ const runGitText = async (cwd, args) => {
49
+ const result = await runGit(cwd, args);
50
+ return result.stdout.toString("utf8");
73
51
  };
74
- const openRepo = async (cwd) => {
75
- try {
76
- const repo = await openRepository(cwd);
77
- return { cwd, repo };
78
- }
79
- catch {
80
- return null;
81
- }
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;
82
58
  };
83
- const getCurrentBranch = (r) => {
84
- try {
85
- const head = r.repo.head();
86
- const shorthand = head.shorthand();
87
- return shorthand && shorthand !== "HEAD" ? shorthand : null;
88
- }
89
- catch {
59
+ const getCurrentBranch = async (cwd) => {
60
+ const result = await runGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
61
+ if (result.exitCode !== 0)
90
62
  return null;
91
- }
92
- };
93
- const resolveDiffBaseRef = (r) => {
94
- try {
95
- r.repo.head();
96
- return "HEAD";
97
- }
98
- catch {
99
- return GIT_EMPTY_TREE_HASH;
100
- }
101
- };
102
- const resolveDefaultBase = (r) => {
103
- const currentBranch = getCurrentBranch(r);
104
- try {
105
- const remoteHead = r.repo.getReference("refs/remotes/origin/HEAD");
106
- const symTarget = remoteHead.symbolicTarget();
107
- if (symTarget) {
108
- const shortName = symTarget.replace("refs/remotes/", "");
109
- if (shortName !== currentBranch) {
110
- try {
111
- r.repo.revparseSingle(shortName);
112
- return { ref: shortName, source: "remoteHead" };
113
- }
114
- catch {
115
- // stale origin/HEAD
116
- }
63
+ const name = result.stdout.toString("utf8").trim();
64
+ return name === "HEAD" ? null : name;
65
+ };
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;
72
+ };
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" };
117
86
  }
118
87
  }
119
88
  }
120
- catch {
121
- // No origin/HEAD configured
122
- }
123
89
  for (const name of ["main", "master", "develop"]) {
124
90
  if (name === currentBranch)
125
91
  continue;
126
92
  for (const candidate of [`origin/${name}`, name]) {
127
- try {
128
- r.repo.revparseSingle(candidate);
93
+ if (await verifyRef(cwd, candidate))
129
94
  return { ref: candidate, source: "fallback" };
130
- }
131
- catch {
132
- continue;
133
- }
134
95
  }
135
96
  }
136
97
  return null;
137
98
  };
138
- const resolveEffectiveBaseRef = (r, options) => {
99
+ const resolveEffectiveBaseRef = async (cwd, options) => {
139
100
  if (options.mode !== "branch")
140
- return resolveDiffBaseRef(r);
101
+ return resolveDiffBaseRef(cwd);
141
102
  let baseRef = options.base?.trim() || null;
142
- if (baseRef) {
143
- try {
144
- r.repo.revparseSingle(baseRef);
145
- }
146
- catch {
147
- baseRef = null;
148
- }
149
- }
150
- if (!baseRef) {
151
- const resolved = resolveDefaultBase(r);
152
- baseRef = resolved?.ref ?? null;
153
- }
103
+ if (baseRef && !(await verifyRef(cwd, baseRef)))
104
+ baseRef = null;
105
+ if (!baseRef)
106
+ baseRef = (await resolveDefaultBase(cwd))?.ref ?? null;
154
107
  if (!baseRef)
155
108
  return null;
156
- try {
157
- const baseOid = r.repo.revparseSingle(baseRef);
158
- const headOid = r.repo.revparseSingle("HEAD");
159
- const mergeBase = r.repo.getMergeBase(baseOid, headOid);
160
- if (mergeBase)
161
- return mergeBase;
162
- }
163
- catch {
164
- // Unrelated histories
165
- }
166
- try {
167
- return r.repo.revparseSingle(baseRef);
168
- }
169
- 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)))
170
113
  return null;
171
- }
114
+ return runGitText(cwd, ["rev-parse", "--verify", baseRef]);
172
115
  };
173
116
  const countLines = (text) => {
174
117
  if (text.length === 0)
@@ -182,42 +125,45 @@ const countLines = (text) => {
182
125
  count += 1;
183
126
  return count;
184
127
  };
185
- const collectUntrackedFiles = async (r) => {
186
- const files = [];
187
- const statuses = r.repo.statuses();
188
- const entries = collectIterator(statuses.iter());
189
- for (const entry of entries) {
190
- const s = entry.status();
191
- if (!s.wtNew || s.ignored)
192
- continue;
193
- if (files.length >= GIT_MAX_UNTRACKED_FILES)
194
- break;
195
- const filePath = entry.path();
196
- 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");
197
137
  try {
198
- const stat = await fsPromises.stat(absolutePath);
199
- if (!stat.isFile())
200
- continue;
201
- const bytesToRead = Math.min(stat.size, GIT_MAX_UNTRACKED_FILE_BYTES);
202
- const buffer = Buffer.alloc(bytesToRead);
203
- const handle = await fsPromises.open(absolutePath, "r");
204
- try {
205
- const { buffer: readBuffer } = await handle.read(buffer, 0, bytesToRead, 0);
206
- const sniffEnd = Math.min(readBuffer.length, GIT_BINARY_SNIFF_BYTES);
207
- const binary = readBuffer.subarray(0, sniffEnd).includes(0);
208
- const truncated = stat.size > GIT_MAX_UNTRACKED_FILE_BYTES;
209
- const content = binary ? null : truncated ? null : readBuffer.toString("utf8");
210
- const lines = binary ? 0 : content ? countLines(content) : 0;
211
- files.push({ path: filePath, binary, lines, content, truncated });
212
- }
213
- finally {
214
- await handle.close();
215
- }
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 };
216
145
  }
217
- catch {
218
- continue;
146
+ finally {
147
+ await handle.close();
219
148
  }
220
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
+ }
221
167
  return files;
222
168
  };
223
169
  export const buildUntrackedPatch = (content) => {
@@ -231,138 +177,84 @@ export const buildUntrackedPatch = (content) => {
231
177
  const noNewlineMarker = hasTrailingNewline ? "" : "\n\";
232
178
  return `@@ -0,0 +1,${lines.length} @@\n${body}${noNewlineMarker}\n`;
233
179
  };
234
- const readBlobContent = (r, oid) => {
235
- if (oid === ZERO_OID)
236
- return null;
237
- try {
238
- const obj = r.repo.findObject(oid);
239
- if (!obj)
240
- return null;
241
- const blob = obj.peelToBlob();
242
- return Buffer.from(blob.content()).toString("utf8");
243
- }
244
- catch {
245
- return null;
246
- }
247
- };
248
- const readWorkingTreeFile = (r, filePath, maxBytes = GIT_MAX_UNTRACKED_FILE_BYTES) => {
249
- const absolutePath = path.join(r.cwd, filePath);
250
- try {
251
- const stat = fs.statSync(absolutePath);
252
- if (!stat.isFile())
253
- return null;
254
- const bytesToRead = Math.min(stat.size, maxBytes);
255
- const buffer = Buffer.alloc(bytesToRead);
256
- const handle = fs.openSync(absolutePath, "r");
257
- fs.readSync(handle, buffer, 0, bytesToRead, 0);
258
- fs.closeSync(handle);
259
- const sniffEnd = Math.min(buffer.length, GIT_BINARY_SNIFF_BYTES);
260
- if (buffer.subarray(0, sniffEnd).includes(0))
261
- return null;
262
- if (stat.size > maxBytes)
263
- return null;
264
- return buffer.toString("utf8");
265
- }
266
- catch {
267
- return null;
268
- }
269
- };
270
- const collectDeltaInfos = (diff) => {
271
- const deltas = collectIterator(diff.deltas());
272
- const result = [];
273
- for (const delta of deltas) {
274
- const status = deltaTypeToStatus(delta.status());
275
- if (status === "untracked")
180
+ export const splitPatchByFile = (raw) => raw.split(/^(?=diff --git )/m).filter((chunk) => chunk.startsWith("diff --git "));
181
+ export const parseNumstatZ = (raw) => {
182
+ const tokens = raw.split("\0");
183
+ const entries = [];
184
+ for (let index = 0; index < tokens.length; index += 1) {
185
+ const token = tokens[index];
186
+ if (!token)
276
187
  continue;
277
- const newFile = delta.newFile();
278
- const oldFile = delta.oldFile();
279
- result.push({
280
- path: newFile.path(),
281
- oldPath: status === "renamed" ? oldFile.path() : null,
282
- status,
283
- binary: newFile.isBinary(),
284
- oldId: oldFile.id(),
285
- newId: newFile.id(),
286
- });
287
- }
288
- return result;
289
- };
290
- const buildDeltaPatch = (r, delta) => {
291
- if (delta.binary)
292
- return { patchText: null, additions: 0, deletions: 0 };
293
- // All diff passes use diffTreeToWorkdirWithIndex, so the new side is the
294
- // working tree — read it straight from disk rather than the delta's blob id
295
- // (which points at the index/HEAD blob for unstaged edits and would read as
296
- // unchanged vs the old side → zero counts). The old side is the base tree's
297
- // blob.
298
- const oldContent = delta.status === "added" ? null : readBlobContent(r, delta.oldId);
299
- const newContent = delta.status === "deleted"
300
- ? null
301
- : readWorkingTreeFile(r, delta.path, GIT_MAX_TOTAL_PATCH_BYTES);
302
- const aPath = delta.oldPath ?? delta.path;
303
- try {
304
- const patchResult = 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");
305
- return {
306
- patchText: patchResult.patchText || null,
307
- additions: patchResult.additions,
308
- deletions: patchResult.deletions,
309
- };
310
- }
311
- catch {
312
- return { patchText: null, additions: 0, deletions: 0 };
188
+ const match = /^(\d+|-)\t(\d+|-)\t(.*)$/s.exec(token);
189
+ if (!match)
190
+ continue;
191
+ const binary = match[1] === "-" || match[2] === "-";
192
+ const additions = binary ? 0 : Number.parseInt(match[1], 10);
193
+ const deletions = binary ? 0 : Number.parseInt(match[2], 10);
194
+ if (match[3]) {
195
+ entries.push({ path: match[3], oldPath: null, additions, deletions, binary });
196
+ continue;
197
+ }
198
+ const oldPath = tokens[index + 1];
199
+ const newPath = tokens[index + 2];
200
+ index += 2;
201
+ if (!oldPath || !newPath)
202
+ continue;
203
+ entries.push({ path: newPath, oldPath, additions, deletions, binary });
313
204
  }
205
+ return entries;
314
206
  };
315
- const buildBaseTree = (r, baseRef) => {
316
- try {
317
- return r.repo.getTree(baseRef);
318
- }
319
- catch {
320
- try {
321
- const baseOid = r.repo.revparseSingle(baseRef);
322
- const obj = r.repo.findObject(baseOid);
323
- if (!obj)
324
- return null;
325
- if (obj.type() === "Tree") {
326
- return obj;
327
- }
328
- const commit = r.repo.getCommit(baseOid);
329
- return commit.tree();
330
- }
331
- catch {
332
- return null;
207
+ export const parseNameStatusZ = (raw) => {
208
+ const tokens = raw.split("\0");
209
+ const statuses = new Map();
210
+ for (let index = 0; index < tokens.length; index += 1) {
211
+ const statusToken = tokens[index];
212
+ if (!statusToken)
213
+ continue;
214
+ const letter = statusToken[0];
215
+ if (letter === "R" || letter === "C") {
216
+ const newPath = tokens[index + 2];
217
+ index += 2;
218
+ if (!newPath)
219
+ continue;
220
+ statuses.set(newPath, letter === "R" ? "renamed" : "added");
221
+ continue;
333
222
  }
223
+ const filePath = tokens[index + 1];
224
+ index += 1;
225
+ if (!filePath)
226
+ continue;
227
+ if (letter === "A")
228
+ statuses.set(filePath, "added");
229
+ else if (letter === "D")
230
+ statuses.set(filePath, "deleted");
231
+ else
232
+ statuses.set(filePath, "modified");
334
233
  }
234
+ return statuses;
335
235
  };
336
- // One full diff pass for `(cwd)` against `baseRef`: walks the tree diff, runs
337
- // rename detection, then builds per-file metadata + patch text in a single
338
- // loop (one jsdiff per file, used for both counts and patch the old code
339
- // ran jsdiff twice per file). Untracked files are folded in from the working
340
- // tree with synthesized patches. Yields to the event loop between batches so a
341
- // large branch diff never blocks the WS terminal during this one pass.
342
- const buildDiffCache = async (r, baseRef) => {
343
- const baseTree = buildBaseTree(r, baseRef);
344
- if (!baseTree)
345
- return null;
346
- let diff;
347
- try {
348
- diff = r.repo.diffTreeToWorkdirWithIndex(baseTree);
349
- }
350
- catch {
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)
351
249
  return null;
352
- }
353
- try {
354
- diff.findSimilar({ renames: true });
355
- }
356
- catch {
357
- // Rename detection failed
358
- }
359
- // Prime libgit2's lazy per-delta binary flag: DiffFile.isBinary() returns
360
- // false until the diff's stats have been materialized, which loads content
361
- // and sniffs for NUL bytes. Without this, binary files read as text and get
362
- // a junk patch synthesized from utf8-decoded blob bytes.
363
- void diff.stats();
364
- const deltaInfos = collectDeltaInfos(diff);
365
- const untracked = await collectUntrackedFiles(r);
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);
366
258
  const fileMeta = [];
367
259
  const filePatchByPath = new Map();
368
260
  const fileBinaryByPath = new Map();
@@ -371,52 +263,40 @@ const buildDiffCache = async (r, baseRef) => {
371
263
  let additions = 0;
372
264
  let deletions = 0;
373
265
  let binaries = 0;
374
- const YIELD_EVERY = 200;
375
- for (let index = 0; index < deltaInfos.length; index += 1) {
376
- if (index > 0 && index % YIELD_EVERY === 0)
377
- await yieldToEventLoop();
378
- const delta = deltaInfos[index];
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;
379
270
  let patchText = null;
380
271
  let patchOmitted = false;
381
- let fileAdditions;
382
- let fileDeletions;
383
- if (delta.binary) {
384
- fileAdditions = 0;
385
- fileDeletions = 0;
272
+ if (entry.binary) {
386
273
  binaries += 1;
387
274
  }
388
275
  else {
389
- const patchResult = buildDeltaPatch(r, delta);
390
- fileAdditions = patchResult.additions;
391
- fileDeletions = patchResult.deletions;
392
- patchText = patchResult.patchText;
393
- if (patchText !== null) {
394
- if (patchText.length > GIT_MAX_PATCH_BYTES_PER_FILE ||
395
- totalPatchBytes + patchText.length > GIT_MAX_TOTAL_PATCH_BYTES) {
396
- patchText = null;
397
- patchOmitted = true;
398
- }
399
- else {
400
- totalPatchBytes += patchText.length;
401
- }
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) {
280
+ patchOmitted = true;
402
281
  }
403
282
  else {
404
- patchOmitted = true;
283
+ patchText = rawPatch;
284
+ totalPatchBytes += rawPatch.length;
405
285
  }
286
+ additions += entry.additions;
287
+ deletions += entry.deletions;
406
288
  }
407
- additions += fileAdditions;
408
- deletions += fileDeletions;
409
289
  fileMeta.push({
410
- path: delta.path,
411
- oldPath: delta.oldPath,
412
- status: delta.status,
413
- additions: fileAdditions,
414
- deletions: fileDeletions,
415
- binary: delta.binary,
290
+ path: entry.path,
291
+ oldPath,
292
+ status,
293
+ additions: entry.additions,
294
+ deletions: entry.deletions,
295
+ binary: entry.binary,
416
296
  });
417
- filePatchByPath.set(delta.path, patchText);
418
- fileBinaryByPath.set(delta.path, delta.binary);
419
- filePatchOmittedByPath.set(delta.path, patchOmitted);
297
+ filePatchByPath.set(entry.path, patchText);
298
+ fileBinaryByPath.set(entry.path, entry.binary);
299
+ filePatchOmittedByPath.set(entry.path, patchOmitted);
420
300
  }
421
301
  for (const file of untracked) {
422
302
  const patch = file.binary
@@ -456,7 +336,7 @@ const buildDiffCache = async (r, baseRef) => {
456
336
  additions,
457
337
  deletions,
458
338
  binaries,
459
- branch: getCurrentBranch(r),
339
+ branch: await getCurrentBranch(cwd),
460
340
  };
461
341
  return {
462
342
  summary,
@@ -467,129 +347,56 @@ const buildDiffCache = async (r, baseRef) => {
467
347
  builtAt: Date.now(),
468
348
  };
469
349
  };
470
- const yieldToEventLoop = () => new Promise((resolve) => {
471
- setImmediate(resolve);
472
- });
473
- const ensureDiffCache = async (r, options) => {
474
- // Read the cache before resolving the base ref — that resolution does
475
- // libgit2 work (revparseSingle + getMergeBase) on every call, so for the
476
- // per-file patch endpoint (where the cache is warm on nearly every
477
- // request) checking first keeps it a pure map lookup.
478
- const cached = readDiffCache(r.cwd, options.mode, options.base ?? null);
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);
479
356
  if (cached)
480
357
  return cached;
481
- const baseRef = resolveEffectiveBaseRef(r, options);
358
+ const baseRef = await resolveEffectiveBaseRef(cwd, options);
482
359
  if (baseRef === null)
483
360
  return null;
484
- const cache = await buildDiffCache(r, baseRef);
361
+ const cache = await buildDiffCache(cwd, baseRef);
485
362
  if (cache)
486
- writeDiffCache(r.cwd, options.mode, options.base ?? null, cache);
363
+ writeDiffCache(cwd, options.mode, options.base ?? null, cache);
487
364
  return cache;
488
365
  };
489
- export const splitPatchByFile = (raw) => raw.split(/^(?=diff --git )/m).filter((chunk) => chunk.startsWith("diff --git "));
490
- export const parseNumstatZ = (raw) => {
491
- const tokens = raw.split("\0");
492
- const entries = [];
493
- for (let index = 0; index < tokens.length; index += 1) {
494
- const token = tokens[index];
495
- if (!token)
496
- continue;
497
- const match = /^(\d+|-)\t(\d+|-)\t(.*)$/s.exec(token);
498
- if (!match)
499
- continue;
500
- const binary = match[1] === "-" || match[2] === "-";
501
- const additions = binary ? 0 : Number.parseInt(match[1], 10);
502
- const deletions = binary ? 0 : Number.parseInt(match[2], 10);
503
- if (match[3]) {
504
- entries.push({ path: match[3], oldPath: null, additions, deletions, binary });
505
- continue;
506
- }
507
- const oldPath = tokens[index + 1];
508
- const newPath = tokens[index + 2];
509
- index += 2;
510
- if (!oldPath || !newPath)
511
- continue;
512
- entries.push({ path: newPath, oldPath, additions, deletions, binary });
513
- }
514
- return entries;
515
- };
516
- export const parseNameStatusZ = (raw) => {
517
- const tokens = raw.split("\0");
518
- const statuses = new Map();
519
- for (let index = 0; index < tokens.length; index += 1) {
520
- const statusToken = tokens[index];
521
- if (!statusToken)
522
- continue;
523
- const letter = statusToken[0];
524
- if (letter === "R" || letter === "C") {
525
- const newPath = tokens[index + 2];
526
- index += 2;
527
- if (!newPath)
528
- continue;
529
- statuses.set(newPath, letter === "R" ? "renamed" : "added");
530
- continue;
531
- }
532
- const filePath = tokens[index + 1];
533
- index += 1;
534
- if (!filePath)
535
- continue;
536
- if (letter === "A")
537
- statuses.set(filePath, "added");
538
- else if (letter === "D")
539
- statuses.set(filePath, "deleted");
540
- else
541
- statuses.set(filePath, "modified");
542
- }
543
- return statuses;
544
- };
545
366
  export const getGitDiffSummary = async (cwd, options = WORKING_OPTIONS) => {
546
- const r = await openRepo(cwd);
547
- if (!r)
548
- return EMPTY_SUMMARY;
549
- // Summary is pushed on every git-dirty signal (per-keystroke during edits),
550
- // so it must stay cheap even when the full diff cache is cold. Read only the
551
- // aggregate stats from the tree diff — no per-file jsdiff — unless a cache
552
- // is already warm, in which case reuse its per-file summary.
553
- const cached = readDiffCache(r.cwd, options.mode, options.base ?? null);
367
+ const cached = readDiffCache(cwd, options.mode, options.base ?? null);
554
368
  if (cached)
555
369
  return cached.summary;
370
+ if (!(await isGitRepo(cwd)))
371
+ return EMPTY_SUMMARY;
556
372
  try {
557
- const baseRef = resolveEffectiveBaseRef(r, options);
373
+ const baseRef = await resolveEffectiveBaseRef(cwd, options);
558
374
  if (baseRef === null)
559
375
  return { ...EMPTY_SUMMARY, isRepo: true };
560
- const branch = getCurrentBranch(r);
561
- const baseTree = buildBaseTree(r, baseRef);
562
- if (!baseTree)
563
- return { ...EMPTY_SUMMARY, isRepo: true };
564
- let diff;
565
- try {
566
- diff = r.repo.diffTreeToWorkdirWithIndex(baseTree);
567
- }
568
- catch {
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)
569
381
  return { ...EMPTY_SUMMARY, isRepo: true };
570
- }
571
- try {
572
- diff.findSimilar({ renames: true });
573
- }
574
- catch {
575
- // Rename detection failed
576
- }
577
- const stats = diff.stats();
578
- let additions = Number(stats.insertions);
579
- let deletions = Number(stats.deletions);
382
+ let additions = 0;
383
+ let deletions = 0;
580
384
  let binaries = 0;
581
- let fileCount = Number(stats.filesChanged);
582
- const diffDeltas = collectIterator(diff.deltas());
583
- for (const delta of diffDeltas) {
584
- if (deltaTypeToStatus(delta.status()) !== "untracked" && delta.newFile().isBinary()) {
585
- binaries++;
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;
586
391
  }
392
+ additions += entry.additions;
393
+ deletions += entry.deletions;
587
394
  }
588
- const untracked = await collectUntrackedFiles(r);
395
+ const untracked = await collectUntrackedFiles(cwd);
589
396
  for (const file of untracked) {
590
- fileCount++;
397
+ fileCount += 1;
591
398
  if (file.binary) {
592
- binaries++;
399
+ binaries += 1;
593
400
  }
594
401
  else {
595
402
  additions += file.lines;
@@ -602,39 +409,41 @@ export const getGitDiffSummary = async (cwd, options = WORKING_OPTIONS) => {
602
409
  }
603
410
  };
604
411
  export const getGitDiff = async (cwd, options = WORKING_OPTIONS) => {
605
- const r = await openRepo(cwd);
606
- if (!r)
412
+ const cached = readDiffCache(cwd, options.mode, options.base ?? null);
413
+ if (cached) {
414
+ return { isRepo: true, files: mapCacheFiles(cached) };
415
+ }
416
+ if (!(await isGitRepo(cwd)))
607
417
  return { isRepo: false, files: [] };
608
- const cache = await ensureDiffCache(r, options);
418
+ const cache = await ensureDiffCache(cwd, options);
609
419
  if (!cache)
610
420
  return { isRepo: true, files: [] };
611
- const files = cache.fileMeta.map((meta) => ({
612
- path: meta.path,
613
- oldPath: meta.oldPath,
614
- status: meta.status,
615
- additions: meta.additions,
616
- deletions: meta.deletions,
617
- binary: meta.binary,
618
- patch: cache.filePatchByPath.get(meta.path) ?? null,
619
- patchOmitted: cache.filePatchOmittedByPath.get(meta.path) ?? false,
620
- }));
621
- return { isRepo: true, files };
622
- };
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
+ }));
623
433
  export const getGitDiffFiles = async (cwd, options = WORKING_OPTIONS) => {
624
- const r = await openRepo(cwd);
625
- if (!r)
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)))
626
438
  return { isRepo: false, files: [] };
627
- const cache = await ensureDiffCache(r, options);
439
+ const cache = await ensureDiffCache(cwd, options);
628
440
  if (!cache)
629
441
  return { isRepo: true, files: [] };
630
442
  return { isRepo: true, files: cache.fileMeta };
631
443
  };
632
444
  export const getGitDiffFilePatch = async (cwd, requestedPath, options = WORKING_OPTIONS) => {
633
445
  const empty = { patch: null, patchOmitted: false, binary: false };
634
- const r = await openRepo(cwd);
635
- if (!r)
636
- return empty;
637
- const cache = await ensureDiffCache(r, options);
446
+ const cache = await ensureDiffCache(cwd, options);
638
447
  if (!cache)
639
448
  return empty;
640
449
  // O(1) lookup: the full diff pass that the per-file patch needs was already
@@ -650,11 +459,11 @@ export const getGitDiffFilePatch = async (cwd, requestedPath, options = WORKING_
650
459
  }
651
460
  // An untracked path the cache didn't cover (created between the cache build
652
461
  // and this request) falls back to synthesizing from the working tree.
653
- return getGitDiffFilePatchFromWorkingTree(r, requestedPath);
462
+ return getGitDiffFilePatchFromWorkingTree(cwd, requestedPath);
654
463
  };
655
- const getGitDiffFilePatchFromWorkingTree = async (r, requestedPath) => {
464
+ const getGitDiffFilePatchFromWorkingTree = async (cwd, requestedPath) => {
656
465
  const empty = { patch: null, patchOmitted: false, binary: false };
657
- const absolutePath = path.join(r.cwd, requestedPath);
466
+ const absolutePath = path.join(cwd, requestedPath);
658
467
  try {
659
468
  const stat = fs.statSync(absolutePath);
660
469
  if (!stat.isFile())
@@ -720,34 +529,33 @@ let activePrFetcher = defaultPrFetcher;
720
529
  export const setPrFetcher = (fetcher) => {
721
530
  activePrFetcher = fetcher;
722
531
  };
723
- const parseGithubRemotes = (r) => {
532
+ const parseGithubRemotes = async (cwd) => {
533
+ const result = await runGit(cwd, ["remote", "-v"]);
534
+ if (result.exitCode !== 0)
535
+ return [];
724
536
  const raw = [];
725
- try {
726
- for (const remoteName of r.repo.remoteNames()) {
727
- try {
728
- const remote = r.repo.getRemote(remoteName);
729
- const url = remote.url();
730
- const match = /github\.com[/:]([^\s/]+)\/([^\s]+?)(?:\.git)?$/i.exec(url);
731
- if (!match)
732
- continue;
733
- const [, owner, repoName] = match;
734
- raw.push({ name: remoteName, slug: `${owner}/${repoName}`, owner });
735
- }
736
- catch {
737
- continue;
738
- }
739
- }
740
- }
741
- catch {
742
- // 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 });
743
551
  }
744
552
  return memoBy(raw, (remote) => `${remote.name} ${remote.slug}`);
745
553
  };
746
- const detectPr = async (r) => {
747
- const currentBranch = getCurrentBranch(r);
554
+ const detectPr = async (cwd) => {
555
+ const currentBranch = await getCurrentBranch(cwd);
748
556
  if (!currentBranch)
749
557
  return null;
750
- const remotes = parseGithubRemotes(r);
558
+ const remotes = await parseGithubRemotes(cwd);
751
559
  if (remotes.length === 0)
752
560
  return null;
753
561
  const ownRemote = remotes.find((remote) => remote.name === "origin") ?? remotes[0];
@@ -776,16 +584,32 @@ const detectPr = async (r) => {
776
584
  }
777
585
  : null;
778
586
  };
779
- export const listGithubRemoteSlugs = async (cwd) => {
780
- const r = await openRepo(cwd);
781
- 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)
782
596
  return [];
783
- 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);
784
609
  return memoBy(remotes, (remote) => remote.slug).map((remote) => remote.slug);
785
610
  };
786
611
  export const getGitBranchInfo = async (cwd) => {
787
- const r = await openRepo(cwd);
788
- if (!r) {
612
+ if (!(await isGitRepo(cwd))) {
789
613
  return {
790
614
  isRepo: false,
791
615
  currentBranch: null,
@@ -795,42 +619,23 @@ export const getGitBranchInfo = async (cwd) => {
795
619
  pr: null,
796
620
  };
797
621
  }
798
- const currentBranch = getCurrentBranch(r);
799
- const defaultBase = resolveDefaultBase(r);
800
- const branchEntries = collectIterator(r.repo.branches());
801
- const branchData = [];
802
- for (const b of branchEntries) {
803
- if (b.name.endsWith("/HEAD"))
804
- continue;
805
- try {
806
- const refName = b.type === "Remote" ? `refs/remotes/${b.name}` : `refs/heads/${b.name}`;
807
- const ref = r.repo.getReference(refName);
808
- const target = ref.target();
809
- if (!target)
810
- continue;
811
- const commit = r.repo.getCommit(target);
812
- branchData.push({ name: b.name, time: commit.time().getTime() });
813
- }
814
- catch {
815
- branchData.push({ name: b.name, time: 0 });
816
- }
817
- if (branchData.length >= GIT_MAX_BRANCHES)
818
- break;
819
- }
820
- 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
+ ]);
821
627
  return {
822
628
  isRepo: true,
823
629
  currentBranch,
824
630
  defaultBase: defaultBase?.ref ?? null,
825
631
  defaultBaseSource: defaultBase?.source ?? null,
826
- branches: branchData.map((b) => b.name),
632
+ branches,
827
633
  pr: null,
828
634
  };
829
635
  };
830
636
  export const getGitBranchPr = async (cwd) => {
831
- const r = await openRepo(cwd);
832
- if (!r)
637
+ if (!(await isGitRepo(cwd)))
833
638
  return null;
834
- return detectPr(r);
639
+ return detectPr(cwd);
835
640
  };
836
641
  //# sourceMappingURL=git-diff.js.map