@monotykamary/localterm-server 2.0.5 → 2.1.1

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 +420 -475
  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,216 @@ 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
- });
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 });
287
204
  }
288
- return result;
205
+ return entries;
289
206
  };
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 };
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;
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");
313
233
  }
234
+ return statuses;
314
235
  };
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;
236
+ // Undo git's C-style path quoting (paths with spaces/special chars get wrapped
237
+ // in "..." with \-escapes; core.quotepath=false leaves only those, not non-ASCII).
238
+ const unquoteGitPath = (raw) => {
239
+ if (raw.length < 2 || raw[0] !== '"' || raw[raw.length - 1] !== '"')
240
+ return raw;
241
+ let out = "";
242
+ let index = 1;
243
+ const end = raw.length - 1;
244
+ while (index < end) {
245
+ const char = raw[index];
246
+ if (char === "\\" && index + 1 < end) {
247
+ const next = raw[index + 1];
248
+ if (next === "n") {
249
+ out += "\n";
250
+ index += 2;
251
+ }
252
+ else if (next === "t") {
253
+ out += "\t";
254
+ index += 2;
255
+ }
256
+ else if (next === '"') {
257
+ out += '"';
258
+ index += 2;
259
+ }
260
+ else if (next === "\\") {
261
+ out += "\\";
262
+ index += 2;
263
+ }
264
+ else if (next >= "0" && next <= "7" && index + 3 < end) {
265
+ out += String.fromCharCode(Number.parseInt(raw.slice(index + 1, index + 4), 8));
266
+ index += 4;
267
+ }
268
+ else {
269
+ out += char;
270
+ index += 1;
327
271
  }
328
- const commit = r.repo.getCommit(baseOid);
329
- return commit.tree();
330
272
  }
331
- catch {
332
- return null;
273
+ else {
274
+ out += char;
275
+ index += 1;
333
276
  }
334
277
  }
278
+ return out;
335
279
  };
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)
280
+ // Strip the `+++ ` / `--- ` prefix and the `b/` / `a/` namespace, unquoting if
281
+ // git wrapped the path. `/dev/null` (a deletion's +++ side) yields null so the
282
+ // caller can fall back to the --- side.
283
+ const pathFromLine = (line, prefix) => {
284
+ if (!line.startsWith(prefix))
345
285
  return null;
346
- let diff;
347
- try {
348
- diff = r.repo.diffTreeToWorkdirWithIndex(baseTree);
349
- }
350
- catch {
286
+ let rest = line.slice(prefix.length);
287
+ if (rest === "/dev/null")
351
288
  return null;
289
+ rest = unquoteGitPath(rest);
290
+ // git appends a literal tab after an unquoted path in ---/+++ lines when the
291
+ // path contains a space, to keep it from running into hunk content; the
292
+ // closing quote already disambiguates quoted paths, so this only affects
293
+ // the unquoted branch. Numstat/name-status never carry that tab, so strip it
294
+ // or the path keys won't line up.
295
+ if (rest.endsWith("\t"))
296
+ rest = rest.slice(0, -1);
297
+ if (rest.startsWith("b/"))
298
+ return rest.slice(2);
299
+ if (rest.startsWith("a/"))
300
+ return rest.slice(2);
301
+ return rest;
302
+ };
303
+ // Pull the new-side path out of one `diff --git` chunk: prefer `+++ b/<path>`
304
+ // (added/modified/rename target), fall back to `--- a/<path>` for deletions
305
+ // (whose +++ is /dev/null), then to `rename to <path>` for a pure rename with
306
+ // no content change (which has no ---/+++ lines at all).
307
+ const extractPatchPath = (chunk) => {
308
+ const lines = chunk.split("\n");
309
+ for (const line of lines) {
310
+ if (line.startsWith("+++ ")) {
311
+ const fromNew = pathFromLine(line, "+++ ");
312
+ if (fromNew !== null)
313
+ return fromNew;
314
+ for (const fallback of lines) {
315
+ if (fallback.startsWith("--- ")) {
316
+ const fromOld = pathFromLine(fallback, "--- ");
317
+ if (fromOld !== null)
318
+ return fromOld;
319
+ }
320
+ }
321
+ return null;
322
+ }
352
323
  }
353
- try {
354
- diff.findSimilar({ renames: true });
324
+ for (const line of lines) {
325
+ if (line.startsWith("rename to "))
326
+ return unquoteGitPath(line.slice("rename to ".length));
355
327
  }
356
- catch {
357
- // Rename detection failed
328
+ return null;
329
+ };
330
+ // Index `git diff --patch` output by path. A single path can map to several
331
+ // `diff --git` blocks (a symlink re-added as a regular file emits a deletion +
332
+ // an addition for one path), so those are concatenated back into one patch.
333
+ const indexPatchesByPath = (raw) => {
334
+ const chunksByPath = new Map();
335
+ for (const chunk of splitPatchByFile(raw)) {
336
+ const patchPath = extractPatchPath(chunk);
337
+ if (patchPath === null)
338
+ continue;
339
+ let chunks = chunksByPath.get(patchPath);
340
+ if (!chunks) {
341
+ chunks = [];
342
+ chunksByPath.set(patchPath, chunks);
343
+ }
344
+ chunks.push(chunk);
345
+ }
346
+ const result = new Map();
347
+ for (const [patchPath, chunks] of chunksByPath) {
348
+ result.set(patchPath, chunks.join(""));
358
349
  }
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);
350
+ return result;
351
+ };
352
+ // One full diff pass for `(cwd)` against `baseRef`. Three parallel `git diff`
353
+ // invocations (numstat for counts+binary, name-status for the status letter +
354
+ // rename old path, patch for the body) walk the same diff queue. numstat and
355
+ // name-status are NUL-delimited and unambiguous; they're the source of truth
356
+ // for the file list. The patch output is keyed by path rather than paired
357
+ // positionally, because a single numstat entry can span several `diff --git`
358
+ // blocks (a symlink is deleted as mode 120000 and re-added as a regular file:
359
+ // git emits that as a deletion + an addition sharing one path, so there's no
360
+ // 1:1 with numstat entries). Untracked files are folded in from `ls-files`
361
+ // with synthesized patches (git's own diff never lists untracked files).
362
+ const buildDiffCache = async (cwd, baseRef) => {
363
+ const [numstatRes, nameStatusRes, patchRes] = await Promise.all([
364
+ runGit(cwd, [
365
+ "-c",
366
+ "core.quotepath=false",
367
+ "diff",
368
+ DIFF_RENAME_FLAG,
369
+ "--numstat",
370
+ "-z",
371
+ baseRef,
372
+ ]),
373
+ runGit(cwd, [
374
+ "-c",
375
+ "core.quotepath=false",
376
+ "diff",
377
+ DIFF_RENAME_FLAG,
378
+ "--name-status",
379
+ "-z",
380
+ baseRef,
381
+ ]),
382
+ runGit(cwd, ["-c", "core.quotepath=false", "diff", DIFF_RENAME_FLAG, "--patch", baseRef]),
383
+ ]);
384
+ if (numstatRes.exitCode !== 0 || patchRes.exitCode !== 0)
385
+ return null;
386
+ const numstat = parseNumstatZ(numstatRes.stdout.toString("utf8"));
387
+ const statuses = parseNameStatusZ(nameStatusRes.stdout.toString("utf8"));
388
+ const patchesByPath = indexPatchesByPath(patchRes.stdout.toString("utf8"));
389
+ const untracked = await collectUntrackedFiles(cwd);
366
390
  const fileMeta = [];
367
391
  const filePatchByPath = new Map();
368
392
  const fileBinaryByPath = new Map();
@@ -371,52 +395,40 @@ const buildDiffCache = async (r, baseRef) => {
371
395
  let additions = 0;
372
396
  let deletions = 0;
373
397
  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];
398
+ for (let index = 0; index < numstat.length; index += 1) {
399
+ const entry = numstat[index];
400
+ const status = statuses.get(entry.path) ?? "modified";
401
+ const oldPath = status === "renamed" ? entry.oldPath : null;
379
402
  let patchText = null;
380
403
  let patchOmitted = false;
381
- let fileAdditions;
382
- let fileDeletions;
383
- if (delta.binary) {
384
- fileAdditions = 0;
385
- fileDeletions = 0;
404
+ if (entry.binary) {
386
405
  binaries += 1;
387
406
  }
388
407
  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
- }
408
+ const rawPatch = patchesByPath.get(entry.path) ?? null;
409
+ if (rawPatch === null ||
410
+ rawPatch.length > GIT_MAX_PATCH_BYTES_PER_FILE ||
411
+ totalPatchBytes + rawPatch.length > GIT_MAX_TOTAL_PATCH_BYTES) {
412
+ patchOmitted = true;
402
413
  }
403
414
  else {
404
- patchOmitted = true;
415
+ patchText = rawPatch;
416
+ totalPatchBytes += rawPatch.length;
405
417
  }
418
+ additions += entry.additions;
419
+ deletions += entry.deletions;
406
420
  }
407
- additions += fileAdditions;
408
- deletions += fileDeletions;
409
421
  fileMeta.push({
410
- path: delta.path,
411
- oldPath: delta.oldPath,
412
- status: delta.status,
413
- additions: fileAdditions,
414
- deletions: fileDeletions,
415
- binary: delta.binary,
422
+ path: entry.path,
423
+ oldPath,
424
+ status,
425
+ additions: entry.additions,
426
+ deletions: entry.deletions,
427
+ binary: entry.binary,
416
428
  });
417
- filePatchByPath.set(delta.path, patchText);
418
- fileBinaryByPath.set(delta.path, delta.binary);
419
- filePatchOmittedByPath.set(delta.path, patchOmitted);
429
+ filePatchByPath.set(entry.path, patchText);
430
+ fileBinaryByPath.set(entry.path, entry.binary);
431
+ filePatchOmittedByPath.set(entry.path, patchOmitted);
420
432
  }
421
433
  for (const file of untracked) {
422
434
  const patch = file.binary
@@ -456,7 +468,7 @@ const buildDiffCache = async (r, baseRef) => {
456
468
  additions,
457
469
  deletions,
458
470
  binaries,
459
- branch: getCurrentBranch(r),
471
+ branch: await getCurrentBranch(cwd),
460
472
  };
461
473
  return {
462
474
  summary,
@@ -467,129 +479,64 @@ const buildDiffCache = async (r, baseRef) => {
467
479
  builtAt: Date.now(),
468
480
  };
469
481
  };
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);
482
+ const ensureDiffCache = async (cwd, options) => {
483
+ // Read the cache before resolving the base ref — that resolution does git work
484
+ // (rev-parse + merge-base) on every call, so for the per-file patch endpoint
485
+ // (where the cache is warm on nearly every request) checking first keeps it a
486
+ // pure map lookup with no subprocess.
487
+ const cached = readDiffCache(cwd, options.mode, options.base ?? null);
479
488
  if (cached)
480
489
  return cached;
481
- const baseRef = resolveEffectiveBaseRef(r, options);
490
+ const baseRef = await resolveEffectiveBaseRef(cwd, options);
482
491
  if (baseRef === null)
483
492
  return null;
484
- const cache = await buildDiffCache(r, baseRef);
493
+ const cache = await buildDiffCache(cwd, baseRef);
485
494
  if (cache)
486
- writeDiffCache(r.cwd, options.mode, options.base ?? null, cache);
495
+ writeDiffCache(cwd, options.mode, options.base ?? null, cache);
487
496
  return cache;
488
497
  };
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
498
  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);
499
+ const cached = readDiffCache(cwd, options.mode, options.base ?? null);
554
500
  if (cached)
555
501
  return cached.summary;
502
+ if (!(await isGitRepo(cwd)))
503
+ return EMPTY_SUMMARY;
556
504
  try {
557
- const baseRef = resolveEffectiveBaseRef(r, options);
505
+ const baseRef = await resolveEffectiveBaseRef(cwd, options);
558
506
  if (baseRef === null)
559
507
  return { ...EMPTY_SUMMARY, isRepo: true };
560
- const branch = getCurrentBranch(r);
561
- const baseTree = buildBaseTree(r, baseRef);
562
- if (!baseTree)
508
+ const branch = await getCurrentBranch(cwd);
509
+ // Summary is pushed on every git-dirty signal, so it stays on the cheap
510
+ // numstat-only path (no patch) unless a cache is already warm.
511
+ const numstatRes = await runGit(cwd, [
512
+ "-c",
513
+ "core.quotepath=false",
514
+ "diff",
515
+ DIFF_RENAME_FLAG,
516
+ "--numstat",
517
+ "-z",
518
+ baseRef,
519
+ ]);
520
+ if (numstatRes.exitCode !== 0)
563
521
  return { ...EMPTY_SUMMARY, isRepo: true };
564
- let diff;
565
- try {
566
- diff = r.repo.diffTreeToWorkdirWithIndex(baseTree);
567
- }
568
- catch {
569
- 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);
522
+ let additions = 0;
523
+ let deletions = 0;
580
524
  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++;
525
+ let fileCount = 0;
526
+ for (const entry of parseNumstatZ(numstatRes.stdout.toString("utf8"))) {
527
+ fileCount += 1;
528
+ if (entry.binary) {
529
+ binaries += 1;
530
+ continue;
586
531
  }
532
+ additions += entry.additions;
533
+ deletions += entry.deletions;
587
534
  }
588
- const untracked = await collectUntrackedFiles(r);
535
+ const untracked = await collectUntrackedFiles(cwd);
589
536
  for (const file of untracked) {
590
- fileCount++;
537
+ fileCount += 1;
591
538
  if (file.binary) {
592
- binaries++;
539
+ binaries += 1;
593
540
  }
594
541
  else {
595
542
  additions += file.lines;
@@ -602,39 +549,41 @@ export const getGitDiffSummary = async (cwd, options = WORKING_OPTIONS) => {
602
549
  }
603
550
  };
604
551
  export const getGitDiff = async (cwd, options = WORKING_OPTIONS) => {
605
- const r = await openRepo(cwd);
606
- if (!r)
552
+ const cached = readDiffCache(cwd, options.mode, options.base ?? null);
553
+ if (cached) {
554
+ return { isRepo: true, files: mapCacheFiles(cached) };
555
+ }
556
+ if (!(await isGitRepo(cwd)))
607
557
  return { isRepo: false, files: [] };
608
- const cache = await ensureDiffCache(r, options);
558
+ const cache = await ensureDiffCache(cwd, options);
609
559
  if (!cache)
610
560
  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
- };
561
+ return { isRepo: true, files: mapCacheFiles(cache) };
562
+ };
563
+ const mapCacheFiles = (cache) => cache.fileMeta.map((meta) => ({
564
+ path: meta.path,
565
+ oldPath: meta.oldPath,
566
+ status: meta.status,
567
+ additions: meta.additions,
568
+ deletions: meta.deletions,
569
+ binary: meta.binary,
570
+ patch: cache.filePatchByPath.get(meta.path) ?? null,
571
+ patchOmitted: cache.filePatchOmittedByPath.get(meta.path) ?? false,
572
+ }));
623
573
  export const getGitDiffFiles = async (cwd, options = WORKING_OPTIONS) => {
624
- const r = await openRepo(cwd);
625
- if (!r)
574
+ const cached = readDiffCache(cwd, options.mode, options.base ?? null);
575
+ if (cached)
576
+ return { isRepo: true, files: cached.fileMeta };
577
+ if (!(await isGitRepo(cwd)))
626
578
  return { isRepo: false, files: [] };
627
- const cache = await ensureDiffCache(r, options);
579
+ const cache = await ensureDiffCache(cwd, options);
628
580
  if (!cache)
629
581
  return { isRepo: true, files: [] };
630
582
  return { isRepo: true, files: cache.fileMeta };
631
583
  };
632
584
  export const getGitDiffFilePatch = async (cwd, requestedPath, options = WORKING_OPTIONS) => {
633
585
  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);
586
+ const cache = await ensureDiffCache(cwd, options);
638
587
  if (!cache)
639
588
  return empty;
640
589
  // O(1) lookup: the full diff pass that the per-file patch needs was already
@@ -650,11 +599,11 @@ export const getGitDiffFilePatch = async (cwd, requestedPath, options = WORKING_
650
599
  }
651
600
  // An untracked path the cache didn't cover (created between the cache build
652
601
  // and this request) falls back to synthesizing from the working tree.
653
- return getGitDiffFilePatchFromWorkingTree(r, requestedPath);
602
+ return getGitDiffFilePatchFromWorkingTree(cwd, requestedPath);
654
603
  };
655
- const getGitDiffFilePatchFromWorkingTree = async (r, requestedPath) => {
604
+ const getGitDiffFilePatchFromWorkingTree = async (cwd, requestedPath) => {
656
605
  const empty = { patch: null, patchOmitted: false, binary: false };
657
- const absolutePath = path.join(r.cwd, requestedPath);
606
+ const absolutePath = path.join(cwd, requestedPath);
658
607
  try {
659
608
  const stat = fs.statSync(absolutePath);
660
609
  if (!stat.isFile())
@@ -720,34 +669,33 @@ let activePrFetcher = defaultPrFetcher;
720
669
  export const setPrFetcher = (fetcher) => {
721
670
  activePrFetcher = fetcher;
722
671
  };
723
- const parseGithubRemotes = (r) => {
672
+ const parseGithubRemotes = async (cwd) => {
673
+ const result = await runGit(cwd, ["remote", "-v"]);
674
+ if (result.exitCode !== 0)
675
+ return [];
724
676
  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
677
+ const seen = new Set();
678
+ for (const line of result.stdout.toString("utf8").split("\n")) {
679
+ const match = /^(\S+)\t(.+?)\s+\(fetch\)$/.exec(line);
680
+ if (!match)
681
+ continue;
682
+ const [, name, url] = match;
683
+ if (seen.has(name))
684
+ continue;
685
+ seen.add(name);
686
+ const urlMatch = /github\.com[/:]([^\s/]+)\/([^\s]+?)(?:\.git)?$/i.exec(url);
687
+ if (!urlMatch)
688
+ continue;
689
+ const [, owner, repoName] = urlMatch;
690
+ raw.push({ name, slug: `${owner}/${repoName}`, owner });
743
691
  }
744
692
  return memoBy(raw, (remote) => `${remote.name} ${remote.slug}`);
745
693
  };
746
- const detectPr = async (r) => {
747
- const currentBranch = getCurrentBranch(r);
694
+ const detectPr = async (cwd) => {
695
+ const currentBranch = await getCurrentBranch(cwd);
748
696
  if (!currentBranch)
749
697
  return null;
750
- const remotes = parseGithubRemotes(r);
698
+ const remotes = await parseGithubRemotes(cwd);
751
699
  if (remotes.length === 0)
752
700
  return null;
753
701
  const ownRemote = remotes.find((remote) => remote.name === "origin") ?? remotes[0];
@@ -776,16 +724,32 @@ const detectPr = async (r) => {
776
724
  }
777
725
  : null;
778
726
  };
779
- export const listGithubRemoteSlugs = async (cwd) => {
780
- const r = await openRepo(cwd);
781
- if (!r)
727
+ const listBranchesByRecency = async (cwd) => {
728
+ const result = await runGit(cwd, [
729
+ "for-each-ref",
730
+ "--format=%(refname:short)",
731
+ "--sort=-committerdate",
732
+ "refs/heads",
733
+ "refs/remotes",
734
+ ]);
735
+ if (result.exitCode !== 0)
782
736
  return [];
783
- const remotes = parseGithubRemotes(r);
737
+ const names = [];
738
+ for (const name of result.stdout.toString("utf8").split("\n")) {
739
+ if (!name || name.endsWith("/HEAD"))
740
+ continue;
741
+ names.push(name);
742
+ if (names.length >= GIT_MAX_BRANCHES)
743
+ break;
744
+ }
745
+ return names;
746
+ };
747
+ export const listGithubRemoteSlugs = async (cwd) => {
748
+ const remotes = await parseGithubRemotes(cwd);
784
749
  return memoBy(remotes, (remote) => remote.slug).map((remote) => remote.slug);
785
750
  };
786
751
  export const getGitBranchInfo = async (cwd) => {
787
- const r = await openRepo(cwd);
788
- if (!r) {
752
+ if (!(await isGitRepo(cwd))) {
789
753
  return {
790
754
  isRepo: false,
791
755
  currentBranch: null,
@@ -795,42 +759,23 @@ export const getGitBranchInfo = async (cwd) => {
795
759
  pr: null,
796
760
  };
797
761
  }
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);
762
+ const [currentBranch, defaultBase, branches] = await Promise.all([
763
+ getCurrentBranch(cwd),
764
+ resolveDefaultBase(cwd),
765
+ listBranchesByRecency(cwd),
766
+ ]);
821
767
  return {
822
768
  isRepo: true,
823
769
  currentBranch,
824
770
  defaultBase: defaultBase?.ref ?? null,
825
771
  defaultBaseSource: defaultBase?.source ?? null,
826
- branches: branchData.map((b) => b.name),
772
+ branches,
827
773
  pr: null,
828
774
  };
829
775
  };
830
776
  export const getGitBranchPr = async (cwd) => {
831
- const r = await openRepo(cwd);
832
- if (!r)
777
+ if (!(await isGitRepo(cwd)))
833
778
  return null;
834
- return detectPr(r);
779
+ return detectPr(cwd);
835
780
  };
836
781
  //# sourceMappingURL=git-diff.js.map