@monotykamary/localterm-server 1.26.1 → 1.27.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.
package/dist/git-diff.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { GH_COMMAND_TIMEOUT_MS, GIT_BINARY_SNIFF_BYTES, GIT_COMMAND_TIMEOUT_MS, GIT_EMPTY_TREE_HASH, GIT_MAX_BRANCHES, GIT_MAX_OUTPUT_BYTES, GIT_MAX_PATCH_BYTES_PER_FILE, GIT_MAX_TOTAL_PATCH_BYTES, GIT_MAX_UNTRACKED_FILES, GIT_MAX_UNTRACKED_FILE_BYTES, } from "./constants.js";
4
+ import { openRepository } from "es-git";
5
+ import { GH_COMMAND_TIMEOUT_MS, GIT_BINARY_SNIFF_BYTES, GIT_COMMAND_TIMEOUT_MS, GIT_MAX_BRANCHES, GIT_MAX_OUTPUT_BYTES, GIT_MAX_PATCH_BYTES_PER_FILE, GIT_MAX_TOTAL_PATCH_BYTES, GIT_MAX_UNTRACKED_FILE_BYTES, GIT_MAX_UNTRACKED_FILES, } from "./constants.js";
5
6
  const WORKING_OPTIONS = { mode: "working" };
6
7
  const EMPTY_SUMMARY = {
7
8
  isRepo: false,
@@ -11,6 +12,13 @@ const EMPTY_SUMMARY = {
11
12
  binaries: 0,
12
13
  branch: null,
13
14
  };
15
+ const GIT_EMPTY_TREE_HASH = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
16
+ const collectIterator = (iterable) => {
17
+ const result = [];
18
+ for (const item of iterable)
19
+ result.push(item);
20
+ return result;
21
+ };
14
22
  const runGit = (cwd, args) => new Promise((resolve, reject) => {
15
23
  execFile("git", ["--no-optional-locks", "-C", cwd, ...args], {
16
24
  timeout: GIT_COMMAND_TIMEOUT_MS,
@@ -24,269 +32,288 @@ const runGit = (cwd, args) => new Promise((resolve, reject) => {
24
32
  resolve(stdout);
25
33
  });
26
34
  });
27
- const isGitRepo = async (cwd) => {
28
- try {
29
- const stdout = await runGit(cwd, ["rev-parse", "--is-inside-work-tree"]);
30
- return stdout.trim() === "true";
31
- }
32
- catch {
33
- // Not a repo, git not installed, or the command timed out — all of these
34
- // degrade to "nothing to diff" rather than an error the client must handle.
35
- return false;
36
- }
37
- };
38
- // Diff base for the working tree. HEAD when at least one commit exists;
39
- // otherwise git's well-known empty tree, so staged files in a brand-new
40
- // repository still show up.
41
- const resolveDiffBase = async (cwd) => {
42
- try {
43
- await runGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD"]);
44
- return "HEAD";
45
- }
46
- catch {
47
- return GIT_EMPTY_TREE_HASH;
48
- }
49
- };
50
- // Run `gh` for PR discovery. Best-effort: gh may be absent, unauthenticated, or
51
- // slow on the network, so every failure (including a non-zero exit when the
52
- // branch has no PR) collapses to null and the caller falls back to local git.
53
35
  const runGh = (cwd, args) => new Promise((resolve) => {
54
36
  execFile("gh", args, { cwd, timeout: GH_COMMAND_TIMEOUT_MS, maxBuffer: GIT_MAX_OUTPUT_BYTES, encoding: "utf8" }, (error, stdout) => resolve(error ? null : stdout));
55
37
  });
56
- // True when `ref` names a commit in this repo. `^{commit}` forces peeling so a
57
- // tag or tree can't masquerade as a valid diff base.
58
- const refExists = async (cwd, ref) => {
59
- try {
60
- await runGit(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
61
- return true;
62
- }
63
- catch {
64
- return false;
38
+ const deltaTypeToStatus = (delta) => {
39
+ switch (delta) {
40
+ case "Added":
41
+ return "added";
42
+ case "Deleted":
43
+ return "deleted";
44
+ case "Renamed":
45
+ return "renamed";
46
+ case "Copied":
47
+ return "added";
48
+ case "Untracked":
49
+ return "untracked";
50
+ case "Modified":
51
+ case "Typechange":
52
+ default:
53
+ return "modified";
65
54
  }
66
55
  };
67
- // First existing ref among the candidates, tried in order. Used to map a bare
68
- // branch name (e.g. a PR's baseRefName "main") onto a ref that actually exists,
69
- // preferring the remote-tracking copy over a possibly-stale local branch.
70
- const firstExistingRef = async (cwd, candidates) => {
71
- for (const candidate of candidates) {
72
- if (candidate && (await refExists(cwd, candidate)))
73
- return candidate;
74
- }
75
- return null;
76
- };
77
- // Discover the PR for the current branch via gh. Returns null whenever gh is
78
- // unavailable or there is no PR — never throws.
79
- // gh reports PR state in upper case (OPEN/CLOSED/MERGED); map to our enum.
80
- const normalizePrState = (raw) => raw === "MERGED" ? "merged" : raw === "CLOSED" ? "closed" : "open";
81
- // Parse `gh pr list --json …` output (an array) into our PR shape.
82
- const parsePrList = (stdout) => {
83
- if (!stdout)
84
- return [];
56
+ const openRepo = async (cwd) => {
85
57
  try {
86
- const parsed = JSON.parse(stdout);
87
- if (!Array.isArray(parsed))
88
- return [];
89
- const prs = [];
90
- for (const item of parsed) {
91
- if (typeof item?.number !== "number" || !item.baseRefName)
92
- continue;
93
- prs.push({
94
- number: item.number,
95
- title: typeof item.title === "string" ? item.title : "",
96
- baseRefName: item.baseRefName,
97
- url: typeof item.url === "string" && item.url.length > 0 ? item.url : null,
98
- state: normalizePrState(item.state),
99
- headOwner: typeof item.headRepositoryOwner?.login === "string"
100
- ? item.headRepositoryOwner.login
101
- : null,
102
- });
103
- }
104
- return prs;
58
+ const repo = await openRepository(cwd);
59
+ return { cwd, repo };
105
60
  }
106
61
  catch {
107
- return [];
62
+ return null;
108
63
  }
109
64
  };
110
- // Parse the repo's GitHub remotes from `git remote -v` — preserving order
111
- // (origin first) and de-duplicating the fetch/push pair per remote.
112
- const parseGithubRemotes = async (cwd) => {
65
+ const getCurrentBranch = (r) => {
113
66
  try {
114
- const stdout = await runGit(cwd, ["remote", "-v"]);
115
- const seen = new Set();
116
- const remotes = [];
117
- for (const line of stdout.split("\n")) {
118
- // <name>\t<url> (fetch|push); url is SSH (git@github.com:owner/repo.git)
119
- // or HTTPS (https://github.com/owner/repo).
120
- const match = /^(\S+)\s+\S*github\.com[/:]([^\s/]+)\/([^\s]+?)(?:\.git)?(?:\s|$)/.exec(line);
121
- if (!match)
122
- continue;
123
- const [, name, owner, repo] = match;
124
- const slug = `${owner}/${repo}`;
125
- const key = `${name} ${slug}`;
126
- if (seen.has(key))
127
- continue;
128
- seen.add(key);
129
- remotes.push({ name, slug, owner });
130
- }
131
- return remotes;
67
+ const head = r.repo.head();
68
+ const shorthand = head.shorthand();
69
+ return shorthand && shorthand !== "HEAD" ? shorthand : null;
132
70
  }
133
71
  catch {
134
- return [];
72
+ return null;
135
73
  }
136
74
  };
137
- // GitHub "owner/repo" slugs parsed from the repo's remotes (origin first).
138
- export const listGithubRemoteSlugs = async (cwd) => {
139
- const slugs = [];
140
- for (const remote of await parseGithubRemotes(cwd)) {
141
- if (!slugs.includes(remote.slug))
142
- slugs.push(remote.slug);
75
+ const resolveDiffBaseRef = (r) => {
76
+ try {
77
+ r.repo.head();
78
+ return "HEAD";
143
79
  }
144
- return slugs;
145
- };
146
- const PR_LIST_FIELDS = "number,title,baseRefName,url,state,headRepositoryOwner";
147
- // Discover the PR for the current branch via gh. Returns null whenever gh is
148
- // unavailable or there is no PR — never throws.
149
- //
150
- // `gh pr list --head <branch>` matches by branch NAME across every fork, so on a
151
- // common name like "main" it returns strangers' PRs. We therefore keep only PRs
152
- // whose head repository is OUR repo (the `origin` remote's owner) — the branch
153
- // in our own fork. We query every GitHub remote (a fork's PR targets the
154
- // upstream) in parallel, using `--state all` so merged/closed PRs count too. An
155
- // open PR wins; otherwise the most recent merged/closed one.
156
- const detectPr = async (cwd) => {
157
- const currentBranch = await getCurrentBranch(cwd);
158
- if (!currentBranch)
159
- return null;
160
- const remotes = await parseGithubRemotes(cwd);
161
- if (remotes.length === 0)
162
- return null;
163
- // The head repo of our PR is the fork we push to — `origin` (or, lacking one,
164
- // the first remote). Only PRs whose head is owned by it are ours.
165
- const ownRemote = remotes.find((remote) => remote.name === "origin") ?? remotes[0];
166
- const ownOwner = ownRemote.owner.toLowerCase();
167
- const slugs = [...new Set(remotes.map((remote) => remote.slug))];
168
- // gh process spawn + network dominate, so query every candidate repo in
169
- // parallel — collapses N sequential round-trips into one.
170
- const results = await Promise.all(slugs.map((slug) => runGh(cwd, [
171
- "pr",
172
- "list",
173
- "--repo",
174
- slug,
175
- "--head",
176
- currentBranch,
177
- "--state",
178
- "all",
179
- "--json",
180
- PR_LIST_FIELDS,
181
- "--limit",
182
- "30",
183
- ]).then(parsePrList)));
184
- const seen = new Set();
185
- let fallback = null;
186
- for (const prs of results) {
187
- for (const pr of prs) {
188
- // Only PRs whose head is our own fork's copy of this branch.
189
- if (!pr.headOwner || pr.headOwner.toLowerCase() !== ownOwner)
190
- continue;
191
- const key = pr.url ?? `#${pr.number}`;
192
- if (seen.has(key))
193
- continue;
194
- seen.add(key);
195
- if (pr.state === "open")
196
- return toBranchPr(pr);
197
- fallback ??= pr;
198
- }
80
+ catch {
81
+ return GIT_EMPTY_TREE_HASH;
199
82
  }
200
- return fallback ? toBranchPr(fallback) : null;
201
83
  };
202
- // Drop the internal headOwner before returning the public PR shape.
203
- const toBranchPr = ({ number, title, baseRefName, url, state }) => ({
204
- number,
205
- title,
206
- baseRefName,
207
- url,
208
- state,
209
- });
210
- // Pick the base branch to compare against when the client hasn't chosen one,
211
- // using LOCAL git only (no gh) so the branch diff loads as fast as the working
212
- // diff. Order of preference: the repo's default branch (origin/HEAD), then a
213
- // conventional main/master/develop. The current branch is never its own base.
214
- // Returns a concrete, existing ref plus how it was found. PR detection is kept
215
- // off this path on purpose — the PR's base is almost always the default branch,
216
- // and a non-default PR base can be chosen from the picker.
217
- const resolveDefaultBase = async (cwd, currentBranch) => {
218
- // origin/HEAD -> refs/remotes/origin/main (the remote's default branch).
84
+ const resolveDefaultBase = (r) => {
85
+ const currentBranch = getCurrentBranch(r);
219
86
  try {
220
- const stdout = await runGit(cwd, [
221
- "symbolic-ref",
222
- "--quiet",
223
- "--short",
224
- "refs/remotes/origin/HEAD",
225
- ]);
226
- const ref = stdout.trim();
227
- if (ref && ref !== currentBranch && (await refExists(cwd, ref))) {
228
- return { ref, source: "remoteHead" };
87
+ const remoteHead = r.repo.getReference("refs/remotes/origin/HEAD");
88
+ const symTarget = remoteHead.symbolicTarget();
89
+ if (symTarget) {
90
+ const shortName = symTarget.replace("refs/remotes/", "");
91
+ if (shortName !== currentBranch) {
92
+ try {
93
+ r.repo.revparseSingle(shortName);
94
+ return { ref: shortName, source: "remoteHead" };
95
+ }
96
+ catch {
97
+ // stale origin/HEAD
98
+ }
99
+ }
229
100
  }
230
101
  }
231
102
  catch {
232
- // No origin/HEAD configured — fall through to the conventional names.
103
+ // No origin/HEAD configured
233
104
  }
234
105
  for (const name of ["main", "master", "develop"]) {
235
106
  if (name === currentBranch)
236
107
  continue;
237
- const ref = await firstExistingRef(cwd, [`origin/${name}`, name]);
238
- if (ref)
239
- return { ref, source: "fallback" };
108
+ for (const candidate of [`origin/${name}`, name]) {
109
+ try {
110
+ r.repo.revparseSingle(candidate);
111
+ return { ref: candidate, source: "fallback" };
112
+ }
113
+ catch {
114
+ continue;
115
+ }
116
+ }
240
117
  }
241
118
  return null;
242
119
  };
243
- // Resolve the actual `git diff` base for the requested mode. "working" diffs
244
- // against HEAD (or the empty tree). "branch" diffs the working tree against the
245
- // MERGE BASE of the chosen base ref and HEAD, so changes the base branch made
246
- // after we forked don't show up — the same set GitHub shows for a PR, plus any
247
- // uncommitted/untracked work on top. Returns null when a branch base can't be
248
- // resolved, signalling the caller to report an empty diff.
249
- const resolveEffectiveBase = async (cwd, options) => {
120
+ const resolveEffectiveBaseRef = (r, options) => {
250
121
  if (options.mode !== "branch")
251
- return resolveDiffBase(cwd);
122
+ return resolveDiffBaseRef(r);
252
123
  let baseRef = options.base?.trim() || null;
253
124
  if (baseRef) {
254
- if (!(await refExists(cwd, baseRef)))
125
+ try {
126
+ r.repo.revparseSingle(baseRef);
127
+ }
128
+ catch {
255
129
  baseRef = null;
130
+ }
256
131
  }
257
132
  if (!baseRef) {
258
- const currentBranch = await getCurrentBranch(cwd);
259
- const resolved = await resolveDefaultBase(cwd, currentBranch);
133
+ const resolved = resolveDefaultBase(r);
260
134
  baseRef = resolved?.ref ?? null;
261
135
  }
262
136
  if (!baseRef)
263
137
  return null;
264
138
  try {
265
- const mergeBase = (await runGit(cwd, ["merge-base", baseRef, "HEAD"])).trim();
139
+ const baseOid = r.repo.revparseSingle(baseRef);
140
+ const headOid = r.repo.revparseSingle("HEAD");
141
+ const mergeBase = r.repo.getMergeBase(baseOid, headOid);
266
142
  if (mergeBase)
267
143
  return mergeBase;
268
144
  }
269
145
  catch {
270
- // Unrelated histories or detached/no HEAD — diff against the ref directly.
146
+ // Unrelated histories
271
147
  }
272
- return baseRef;
273
- };
274
- // Short current-branch name, or null when detached (rev-parse returns "HEAD").
275
- const getCurrentBranch = async (cwd) => {
276
148
  try {
277
- const name = (await runGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
278
- return name && name !== "HEAD" ? name : null;
149
+ return r.repo.revparseSingle(baseRef);
279
150
  }
280
151
  catch {
281
152
  return null;
282
153
  }
283
154
  };
284
- /**
285
- * Parse `git diff --numstat -z` output. Entries are NUL-separated:
286
- * `<added>\t<deleted>\t<path>` for ordinary changes, and
287
- * `<added>\t<deleted>\t` followed by two extra NUL-separated tokens
288
- * (old path, new path) for renames/copies. Binary files report `-` counts.
289
- */
155
+ const countLines = (text) => {
156
+ if (text.length === 0)
157
+ return 0;
158
+ let count = 0;
159
+ for (let index = 0; index < text.length; index += 1) {
160
+ if (text.charCodeAt(index) === 10)
161
+ count += 1;
162
+ }
163
+ if (!text.endsWith("\n"))
164
+ count += 1;
165
+ return count;
166
+ };
167
+ const collectUntrackedFiles = (r) => {
168
+ const files = [];
169
+ const statuses = r.repo.statuses();
170
+ const entries = collectIterator(statuses.iter());
171
+ for (const entry of entries) {
172
+ const s = entry.status();
173
+ if (!s.wtNew || s.ignored)
174
+ continue;
175
+ if (files.length >= GIT_MAX_UNTRACKED_FILES)
176
+ break;
177
+ const filePath = entry.path();
178
+ const absolutePath = path.join(r.cwd, filePath);
179
+ try {
180
+ const stat = fs.statSync(absolutePath);
181
+ if (!stat.isFile())
182
+ continue;
183
+ const bytesToRead = Math.min(stat.size, GIT_MAX_UNTRACKED_FILE_BYTES);
184
+ const buffer = Buffer.alloc(bytesToRead);
185
+ const handle = fs.openSync(absolutePath, "r");
186
+ fs.readSync(handle, buffer, 0, bytesToRead, 0);
187
+ fs.closeSync(handle);
188
+ const sniffEnd = Math.min(buffer.length, GIT_BINARY_SNIFF_BYTES);
189
+ const binary = buffer.subarray(0, sniffEnd).includes(0);
190
+ const truncated = stat.size > GIT_MAX_UNTRACKED_FILE_BYTES;
191
+ const content = binary ? null : truncated ? null : buffer.toString("utf8");
192
+ const lines = binary ? 0 : content ? countLines(content) : 0;
193
+ files.push({ path: filePath, binary, lines, content, truncated });
194
+ }
195
+ catch {
196
+ continue;
197
+ }
198
+ }
199
+ return files;
200
+ };
201
+ export const splitPatchByFile = (raw) => raw.split(/^(?=diff --git )/m).filter((chunk) => chunk.startsWith("diff --git "));
202
+ export const buildUntrackedPatch = (content) => {
203
+ if (content.length === 0)
204
+ return "";
205
+ const hasTrailingNewline = content.endsWith("\n");
206
+ const lines = content.split("\n");
207
+ if (hasTrailingNewline)
208
+ lines.pop();
209
+ const body = lines.map((line) => `+${line}`).join("\n");
210
+ const noNewlineMarker = hasTrailingNewline ? "" : "\n\";
211
+ return `@@ -0,0 +1,${lines.length} @@\n${body}${noNewlineMarker}\n`;
212
+ };
213
+ const computeTrackedDeltas = async (r, baseRef) => {
214
+ let baseTree;
215
+ try {
216
+ baseTree = r.repo.getTree(baseRef);
217
+ }
218
+ catch {
219
+ try {
220
+ const baseOid = r.repo.revparseSingle(baseRef);
221
+ const obj = r.repo.findObject(baseOid);
222
+ if (!obj)
223
+ return [];
224
+ if (obj.type() === "Tree") {
225
+ baseTree = obj;
226
+ }
227
+ else {
228
+ const commit = r.repo.getCommit(baseOid);
229
+ baseTree = commit.tree();
230
+ }
231
+ }
232
+ catch {
233
+ return [];
234
+ }
235
+ }
236
+ let diff;
237
+ try {
238
+ diff = r.repo.diffTreeToWorkdirWithIndex(baseTree);
239
+ }
240
+ catch {
241
+ return [];
242
+ }
243
+ try {
244
+ diff.findSimilar({ renames: true });
245
+ }
246
+ catch {
247
+ // Rename detection failed
248
+ }
249
+ const stats = diff.stats();
250
+ const totalInsertions = Number(stats.insertions);
251
+ const totalDeletions = Number(stats.deletions);
252
+ const deltas = collectIterator(diff.deltas());
253
+ const trackedDeltas = [];
254
+ for (const delta of deltas) {
255
+ const status = deltaTypeToStatus(delta.status());
256
+ if (status === "untracked")
257
+ continue;
258
+ trackedDeltas.push({
259
+ path: delta.newFile().path(),
260
+ oldPath: status === "renamed" ? delta.oldFile().path() : null,
261
+ status,
262
+ additions: 0,
263
+ deletions: 0,
264
+ binary: delta.newFile().isBinary(),
265
+ });
266
+ }
267
+ // Redistribute the stats diff-wide totals across deltas using git diff --numstat.
268
+ // es-git's DiffStats gives aggregate insertions/deletions but not per-file.
269
+ // We fall back to `git diff --numstat -z` for per-file counts — one subprocess
270
+ // instead of the previous six, and only for the stat-heavy code paths.
271
+ if (trackedDeltas.length > 0) {
272
+ try {
273
+ const numstatRaw = await runGit(r.cwd, [
274
+ "diff",
275
+ baseRef,
276
+ "-M",
277
+ "--no-ext-diff",
278
+ "--no-textconv",
279
+ "--numstat",
280
+ "-z",
281
+ ]);
282
+ const entries = parseNumstatZ(numstatRaw);
283
+ const numstatByPath = new Map(entries.map((e) => [e.path, e]));
284
+ for (const d of trackedDeltas) {
285
+ const entry = numstatByPath.get(d.path);
286
+ if (entry && !d.binary) {
287
+ d.additions = entry.additions;
288
+ d.deletions = entry.deletions;
289
+ }
290
+ }
291
+ }
292
+ catch {
293
+ // Fallback: split aggregate stats across files
294
+ const nonBinary = trackedDeltas.filter((d) => !d.binary);
295
+ if (nonBinary.length === 1) {
296
+ nonBinary[0].additions = totalInsertions;
297
+ nonBinary[0].deletions = totalDeletions;
298
+ }
299
+ else if (nonBinary.length > 0) {
300
+ const perFileAdd = Math.floor(totalInsertions / nonBinary.length);
301
+ let extraAdd = totalInsertions - perFileAdd * nonBinary.length;
302
+ const perFileDel = Math.floor(totalDeletions / nonBinary.length);
303
+ let extraDel = totalDeletions - perFileDel * nonBinary.length;
304
+ for (const d of nonBinary) {
305
+ d.additions = perFileAdd + (extraAdd > 0 ? 1 : 0);
306
+ d.deletions = perFileDel + (extraDel > 0 ? 1 : 0);
307
+ if (extraAdd > 0)
308
+ extraAdd--;
309
+ if (extraDel > 0)
310
+ extraDel--;
311
+ }
312
+ }
313
+ }
314
+ }
315
+ return trackedDeltas;
316
+ };
290
317
  export const parseNumstatZ = (raw) => {
291
318
  const tokens = raw.split("\0");
292
319
  const entries = [];
@@ -313,10 +340,6 @@ export const parseNumstatZ = (raw) => {
313
340
  }
314
341
  return entries;
315
342
  };
316
- /**
317
- * Parse `git diff --name-status -z` output into a path -> status map.
318
- * Tokens alternate `<status>` then path (or old path, new path for R/C).
319
- */
320
343
  export const parseNameStatusZ = (raw) => {
321
344
  const tokens = raw.split("\0");
322
345
  const statuses = new Map();
@@ -346,179 +369,77 @@ export const parseNameStatusZ = (raw) => {
346
369
  }
347
370
  return statuses;
348
371
  };
349
- // Split `git diff --patch` output into one chunk per file. Chunk order matches
350
- // the numstat/name-status order for the same diff arguments.
351
- export const splitPatchByFile = (raw) => raw.split(/^(?=diff --git )/m).filter((chunk) => chunk.startsWith("diff --git "));
352
- const countLines = (text) => {
353
- if (text.length === 0)
354
- return 0;
355
- let count = 0;
356
- for (let index = 0; index < text.length; index += 1) {
357
- if (text.charCodeAt(index) === 10)
358
- count += 1;
359
- }
360
- if (!text.endsWith("\n"))
361
- count += 1;
362
- return count;
363
- };
364
- const readUntrackedFile = async (filePath) => {
365
- let handle = null;
366
- try {
367
- handle = await fs.promises.open(filePath, "r");
368
- const stat = await handle.stat();
369
- if (!stat.isFile())
370
- return null;
371
- const bytesToRead = Math.min(stat.size, GIT_MAX_UNTRACKED_FILE_BYTES);
372
- const buffer = Buffer.alloc(bytesToRead);
373
- await handle.read(buffer, 0, bytesToRead, 0);
374
- const sniffEnd = Math.min(buffer.length, GIT_BINARY_SNIFF_BYTES);
375
- if (buffer.subarray(0, sniffEnd).includes(0)) {
376
- return { binary: true, lines: 0, truncated: false, content: null };
377
- }
378
- const truncated = stat.size > GIT_MAX_UNTRACKED_FILE_BYTES;
379
- const text = buffer.toString("utf8");
380
- return { binary: false, lines: countLines(text), truncated, content: truncated ? null : text };
381
- }
382
- catch {
383
- // Vanished, unreadable, or special file — skip it.
384
- return null;
385
- }
386
- finally {
387
- await handle?.close();
388
- }
389
- };
390
- // Untracked-file line counts are recomputed on every summary poll; cache them
391
- // by size+mtime so steady-state polls only stat each file. Bounded to avoid
392
- // growing forever across many repos/sessions.
393
- const UNTRACKED_CACHE_MAX_ENTRIES = 4096;
394
- const untrackedStatsCache = new Map();
395
- const getUntrackedStats = async (filePath) => {
396
- let cacheKey = null;
397
- try {
398
- const stat = await fs.promises.lstat(filePath);
399
- if (!stat.isFile())
400
- return null;
401
- cacheKey = `${stat.size}:${stat.mtimeMs}`;
402
- const cached = untrackedStatsCache.get(filePath);
403
- if (cached && cached.key === cacheKey) {
404
- return { lines: cached.lines, binary: cached.binary };
405
- }
406
- }
407
- catch {
408
- return null;
409
- }
410
- const read = await readUntrackedFile(filePath);
411
- if (!read)
412
- return null;
413
- if (untrackedStatsCache.size >= UNTRACKED_CACHE_MAX_ENTRIES)
414
- untrackedStatsCache.clear();
415
- untrackedStatsCache.set(filePath, { key: cacheKey, lines: read.lines, binary: read.binary });
416
- return { lines: read.lines, binary: read.binary };
417
- };
418
- const listUntrackedPaths = async (cwd) => {
419
- const stdout = await runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z"]);
420
- return stdout.split("\0").filter((entry) => entry.length > 0);
421
- };
422
372
  export const getGitDiffSummary = async (cwd, options = WORKING_OPTIONS) => {
423
- if (!(await isGitRepo(cwd)))
373
+ const r = await openRepo(cwd);
374
+ if (!r)
424
375
  return EMPTY_SUMMARY;
425
376
  try {
426
- const base = await resolveEffectiveBase(cwd, options);
427
- if (base === null)
377
+ const baseRef = resolveEffectiveBaseRef(r, options);
378
+ if (baseRef === null)
428
379
  return { ...EMPTY_SUMMARY, isRepo: true };
429
- const [numstatRaw, untrackedPaths, branch] = await Promise.all([
430
- runGit(cwd, ["diff", base, "-M", "--no-ext-diff", "--no-textconv", "--numstat", "-z"]),
431
- listUntrackedPaths(cwd),
432
- getCurrentBranch(cwd),
433
- ]);
434
- const tracked = parseNumstatZ(numstatRaw);
435
- let additions = 0;
436
- let deletions = 0;
380
+ const branch = getCurrentBranch(r);
381
+ let baseTree;
382
+ try {
383
+ baseTree = r.repo.getTree(baseRef);
384
+ }
385
+ catch {
386
+ try {
387
+ const baseOid = r.repo.revparseSingle(baseRef);
388
+ const obj = r.repo.findObject(baseOid);
389
+ if (!obj)
390
+ return { ...EMPTY_SUMMARY, isRepo: true };
391
+ if (obj.type() === "Tree") {
392
+ baseTree = obj;
393
+ }
394
+ else {
395
+ const commit = r.repo.getCommit(baseOid);
396
+ baseTree = commit.tree();
397
+ }
398
+ }
399
+ catch {
400
+ return { ...EMPTY_SUMMARY, isRepo: true };
401
+ }
402
+ }
403
+ let diff;
404
+ try {
405
+ diff = r.repo.diffTreeToWorkdirWithIndex(baseTree);
406
+ }
407
+ catch {
408
+ return { ...EMPTY_SUMMARY, isRepo: true };
409
+ }
410
+ try {
411
+ diff.findSimilar({ renames: true });
412
+ }
413
+ catch {
414
+ // Rename detection failed
415
+ }
416
+ const stats = diff.stats();
417
+ let additions = Number(stats.insertions);
418
+ let deletions = Number(stats.deletions);
437
419
  let binaries = 0;
438
- for (const entry of tracked) {
439
- additions += entry.additions;
440
- deletions += entry.deletions;
441
- if (entry.binary)
442
- binaries += 1;
443
- }
444
- for (const relativePath of untrackedPaths.slice(0, GIT_MAX_UNTRACKED_FILES)) {
445
- const stats = await getUntrackedStats(path.join(cwd, relativePath));
446
- if (!stats)
447
- continue;
448
- if (stats.binary)
449
- binaries += 1;
450
- else
451
- additions += stats.lines;
420
+ let fileCount = Number(stats.filesChanged);
421
+ const diffDeltas = collectIterator(diff.deltas());
422
+ for (const delta of diffDeltas) {
423
+ if (deltaTypeToStatus(delta.status()) !== "untracked" && delta.newFile().isBinary()) {
424
+ binaries++;
425
+ }
452
426
  }
453
- return {
454
- isRepo: true,
455
- files: tracked.length + untrackedPaths.length,
456
- additions,
457
- deletions,
458
- binaries,
459
- branch,
460
- };
427
+ const untracked = collectUntrackedFiles(r);
428
+ for (const file of untracked) {
429
+ fileCount++;
430
+ if (file.binary) {
431
+ binaries++;
432
+ }
433
+ else {
434
+ additions += file.lines;
435
+ }
436
+ }
437
+ return { isRepo: true, files: fileCount, additions, deletions, binaries, branch };
461
438
  }
462
439
  catch {
463
- // Transient git failure (lock contention, timeout) — report a quiet repo
464
- // rather than erroring the poll; the next poll will catch up.
465
440
  return { ...EMPTY_SUMMARY, isRepo: true };
466
441
  }
467
442
  };
468
- // Synthesize a single-hunk unified diff for an untracked (new) file so the
469
- // client renders it exactly like a tracked added file.
470
- export const buildUntrackedPatch = (content) => {
471
- if (content.length === 0)
472
- return "";
473
- const hasTrailingNewline = content.endsWith("\n");
474
- const lines = content.split("\n");
475
- if (hasTrailingNewline)
476
- lines.pop();
477
- const body = lines.map((line) => `+${line}`).join("\n");
478
- const noNewlineMarker = hasTrailingNewline ? "" : "\n\";
479
- return `@@ -0,0 +1,${lines.length} @@\n${body}${noNewlineMarker}\n`;
480
- };
481
- const buildUntrackedDiffFile = async (cwd, relativePath) => {
482
- const read = await readUntrackedFile(path.join(cwd, relativePath));
483
- if (!read)
484
- return null;
485
- if (read.binary) {
486
- return {
487
- path: relativePath,
488
- oldPath: null,
489
- status: "untracked",
490
- additions: 0,
491
- deletions: 0,
492
- binary: true,
493
- patch: null,
494
- patchOmitted: false,
495
- };
496
- }
497
- return {
498
- path: relativePath,
499
- oldPath: null,
500
- status: "untracked",
501
- additions: read.lines,
502
- deletions: 0,
503
- binary: false,
504
- patch: read.truncated || read.content === null ? null : buildUntrackedPatch(read.content),
505
- patchOmitted: read.truncated,
506
- };
507
- };
508
- // Collect per-file metadata (counts, rename old paths, status letters) without
509
- // generating any patch text. Shared by the bulk diff, the file-list endpoint,
510
- // and the per-file patch fetch. Rejects propagate to the caller's try/catch.
511
- const collectTrackedMeta = async (cwd, base) => {
512
- const [numstatRaw, nameStatusRaw] = await Promise.all([
513
- runGit(cwd, ["diff", base, "-M", "--no-ext-diff", "--no-textconv", "--numstat", "-z"]),
514
- runGit(cwd, ["diff", base, "-M", "--no-ext-diff", "--no-textconv", "--name-status", "-z"]),
515
- ]);
516
- return { tracked: parseNumstatZ(numstatRaw), statuses: parseNameStatusZ(nameStatusRaw) };
517
- };
518
- const resolveFileStatus = (entry, statuses) => statuses.get(entry.path) ?? (entry.oldPath ? "renamed" : "modified");
519
- // Select the patch chunk whose new-side path matches the requested file. Our
520
- // per-file diff normally yields exactly one chunk; an odd worktree state can
521
- // split it, and we'd rather show nothing than attach the wrong file's patch.
522
443
  const pickPatchChunk = (chunks, newPath) => {
523
444
  if (chunks.length === 0)
524
445
  return null;
@@ -527,26 +448,19 @@ const pickPatchChunk = (chunks, newPath) => {
527
448
  return (chunks.find((chunk) => chunk.includes(`\n+++ b/${newPath}\n`) || chunk.includes(`\nrename to ${newPath}\n`)) ?? null);
528
449
  };
529
450
  export const getGitDiff = async (cwd, options = WORKING_OPTIONS) => {
530
- if (!(await isGitRepo(cwd)))
451
+ const r = await openRepo(cwd);
452
+ if (!r)
531
453
  return { isRepo: false, files: [] };
532
- const base = await resolveEffectiveBase(cwd, options);
533
- if (base === null)
534
- return { isRepo: true, files: [] };
535
- let tracked = [];
536
- let statuses = new Map();
537
- try {
538
- ({ tracked, statuses } = await collectTrackedMeta(cwd, base));
539
- }
540
- catch {
454
+ const baseRef = resolveEffectiveBaseRef(r, options);
455
+ if (baseRef === null)
541
456
  return { isRepo: true, files: [] };
542
- }
543
- // The patch run can exceed maxBuffer or time out on huge diffs; degrade to
544
- // stats-only entries instead of failing the whole response.
457
+ const trackedDeltas = await computeTrackedDeltas(r, baseRef);
458
+ // Patch text via subprocess es-git's print() doesn't emit +/- line markers.
545
459
  let patchChunks = null;
546
460
  try {
547
461
  const patchRaw = await runGit(cwd, [
548
462
  "diff",
549
- base,
463
+ baseRef,
550
464
  "-M",
551
465
  "--no-ext-diff",
552
466
  "--no-textconv",
@@ -554,17 +468,14 @@ export const getGitDiff = async (cwd, options = WORKING_OPTIONS) => {
554
468
  "--patch",
555
469
  ]);
556
470
  const chunks = splitPatchByFile(patchRaw);
557
- // Chunks pair with numstat entries by index; if the counts diverge
558
- // (unexpected git output) don't risk attaching the wrong patch to a file.
559
- if (chunks.length === tracked.length)
471
+ if (chunks.length === trackedDeltas.length)
560
472
  patchChunks = chunks;
561
473
  }
562
474
  catch {
563
475
  patchChunks = null;
564
476
  }
565
477
  let totalPatchBytes = 0;
566
- const files = tracked.map((entry, index) => {
567
- const status = resolveFileStatus(entry, statuses);
478
+ const files = trackedDeltas.map((entry, index) => {
568
479
  let patch = null;
569
480
  let patchOmitted = false;
570
481
  if (entry.binary) {
@@ -587,7 +498,7 @@ export const getGitDiff = async (cwd, options = WORKING_OPTIONS) => {
587
498
  return {
588
499
  path: entry.path,
589
500
  oldPath: entry.oldPath,
590
- status,
501
+ status: entry.status,
591
502
  additions: entry.additions,
592
503
  deletions: entry.deletions,
593
504
  binary: entry.binary,
@@ -595,109 +506,86 @@ export const getGitDiff = async (cwd, options = WORKING_OPTIONS) => {
595
506
  patchOmitted,
596
507
  };
597
508
  });
598
- let untrackedPaths = [];
599
- try {
600
- untrackedPaths = await listUntrackedPaths(cwd);
601
- }
602
- catch {
603
- untrackedPaths = [];
604
- }
605
- for (const relativePath of untrackedPaths.slice(0, GIT_MAX_UNTRACKED_FILES)) {
606
- const file = await buildUntrackedDiffFile(cwd, relativePath);
607
- if (!file)
608
- continue;
609
- if (file.patch !== null) {
610
- if (file.patch.length > GIT_MAX_PATCH_BYTES_PER_FILE ||
611
- totalPatchBytes + file.patch.length > GIT_MAX_TOTAL_PATCH_BYTES) {
612
- file.patch = null;
613
- file.patchOmitted = true;
509
+ const untracked = collectUntrackedFiles(r);
510
+ for (const file of untracked) {
511
+ const patch = file.binary
512
+ ? null
513
+ : file.truncated || file.content === null
514
+ ? null
515
+ : buildUntrackedPatch(file.content);
516
+ const entry = {
517
+ path: file.path,
518
+ oldPath: null,
519
+ status: "untracked",
520
+ additions: file.binary ? 0 : file.lines,
521
+ deletions: 0,
522
+ binary: file.binary,
523
+ patch,
524
+ patchOmitted: file.truncated,
525
+ };
526
+ if (entry.patch !== null) {
527
+ if (entry.patch.length > GIT_MAX_PATCH_BYTES_PER_FILE ||
528
+ totalPatchBytes + entry.patch.length > GIT_MAX_TOTAL_PATCH_BYTES) {
529
+ entry.patch = null;
530
+ entry.patchOmitted = true;
614
531
  }
615
532
  else {
616
- totalPatchBytes += file.patch.length;
533
+ totalPatchBytes += entry.patch.length;
617
534
  }
618
535
  }
619
- files.push(file);
536
+ files.push(entry);
620
537
  }
621
538
  return { isRepo: true, files };
622
539
  };
623
- // File list for the diff viewer: per-file metadata only, no patch bodies. Cheap
624
- // (numstat + name-status + stat-only untracked) and small, so the viewer can
625
- // render its sidebar and totals the instant it opens. Patches load on demand via
626
- // getGitDiffFilePatch.
627
540
  export const getGitDiffFiles = async (cwd, options = WORKING_OPTIONS) => {
628
- if (!(await isGitRepo(cwd)))
541
+ const r = await openRepo(cwd);
542
+ if (!r)
629
543
  return { isRepo: false, files: [] };
630
- const base = await resolveEffectiveBase(cwd, options);
631
- if (base === null)
632
- return { isRepo: true, files: [] };
633
- let tracked = [];
634
- let statuses = new Map();
635
- try {
636
- ({ tracked, statuses } = await collectTrackedMeta(cwd, base));
637
- }
638
- catch {
544
+ const baseRef = resolveEffectiveBaseRef(r, options);
545
+ if (baseRef === null)
639
546
  return { isRepo: true, files: [] };
640
- }
641
- const files = tracked.map((entry) => ({
547
+ const trackedDeltas = await computeTrackedDeltas(r, baseRef);
548
+ const files = trackedDeltas.map((entry) => ({
642
549
  path: entry.path,
643
550
  oldPath: entry.oldPath,
644
- status: resolveFileStatus(entry, statuses),
551
+ status: entry.status,
645
552
  additions: entry.additions,
646
553
  deletions: entry.deletions,
647
554
  binary: entry.binary,
648
555
  }));
649
- let untrackedPaths = [];
650
- try {
651
- untrackedPaths = await listUntrackedPaths(cwd);
652
- }
653
- catch {
654
- untrackedPaths = [];
655
- }
656
- for (const relativePath of untrackedPaths.slice(0, GIT_MAX_UNTRACKED_FILES)) {
657
- const stats = await getUntrackedStats(path.join(cwd, relativePath));
658
- if (!stats)
659
- continue;
556
+ const untracked = collectUntrackedFiles(r);
557
+ for (const file of untracked) {
660
558
  files.push({
661
- path: relativePath,
559
+ path: file.path,
662
560
  oldPath: null,
663
561
  status: "untracked",
664
- additions: stats.binary ? 0 : stats.lines,
562
+ additions: file.binary ? 0 : file.lines,
665
563
  deletions: 0,
666
- binary: stats.binary,
564
+ binary: file.binary,
667
565
  });
668
566
  }
669
567
  return { isRepo: true, files };
670
568
  };
671
- // Unified diff for a SINGLE file, fetched on demand. Unlike getGitDiff this is
672
- // NOT subject to the whole-response cap (GIT_MAX_TOTAL_PATCH_BYTES) — only the
673
- // per-file cap applies — so a file the bulk endpoint dropped because the response
674
- // total was exhausted still loads when opened individually.
675
569
  export const getGitDiffFilePatch = async (cwd, requestedPath, options = WORKING_OPTIONS) => {
676
570
  const empty = { patch: null, patchOmitted: false, binary: false };
677
- if (!(await isGitRepo(cwd)))
571
+ const r = await openRepo(cwd);
572
+ if (!r)
678
573
  return empty;
679
- const base = await resolveEffectiveBase(cwd, options);
680
- if (base === null)
574
+ const baseRef = resolveEffectiveBaseRef(r, options);
575
+ if (baseRef === null)
681
576
  return empty;
682
- let tracked = [];
683
- try {
684
- ({ tracked } = await collectTrackedMeta(cwd, base));
685
- }
686
- catch {
687
- return empty;
688
- }
689
- const entry = tracked.find((candidate) => candidate.path === requestedPath);
577
+ // Check if tracked
578
+ const trackedDeltas = await computeTrackedDeltas(r, baseRef);
579
+ const entry = trackedDeltas.find((d) => d.path === requestedPath);
690
580
  if (entry) {
691
581
  if (entry.binary)
692
582
  return { patch: null, patchOmitted: false, binary: true };
693
- // A rename must diff BOTH endpoints so `-M` pairs them into one chunk;
694
- // diffing only the new path would report it as an addition.
583
+ // A rename must diff BOTH endpoints so -M pairs them into one chunk.
695
584
  const pathspecs = entry.oldPath ? [entry.oldPath, entry.path] : [entry.path];
696
- let raw;
697
585
  try {
698
- raw = await runGit(cwd, [
586
+ const raw = await runGit(r.cwd, [
699
587
  "diff",
700
- base,
588
+ baseRef,
701
589
  "-M",
702
590
  "--no-ext-diff",
703
591
  "--no-textconv",
@@ -706,73 +594,175 @@ export const getGitDiffFilePatch = async (cwd, requestedPath, options = WORKING_
706
594
  "--",
707
595
  ...pathspecs,
708
596
  ]);
597
+ const chunk = pickPatchChunk(splitPatchByFile(raw), entry.path);
598
+ if (chunk === null)
599
+ return empty;
600
+ if (chunk.length > GIT_MAX_PATCH_BYTES_PER_FILE) {
601
+ return { patch: null, patchOmitted: true, binary: false };
602
+ }
603
+ return { patch: chunk, patchOmitted: false, binary: false };
709
604
  }
710
605
  catch {
711
- // Timed out or exceeded maxBuffer on a single pathological file.
712
606
  return { patch: null, patchOmitted: true, binary: false };
713
607
  }
714
- const chunk = pickPatchChunk(splitPatchByFile(raw), entry.path);
715
- if (chunk === null)
608
+ }
609
+ // Not tracked — check if it's an untracked file
610
+ const absolutePath = path.join(cwd, requestedPath);
611
+ try {
612
+ const stat = fs.statSync(absolutePath);
613
+ if (!stat.isFile())
716
614
  return empty;
717
- if (chunk.length > GIT_MAX_PATCH_BYTES_PER_FILE) {
615
+ const bytesToRead = Math.min(stat.size, GIT_MAX_UNTRACKED_FILE_BYTES);
616
+ const buffer = Buffer.alloc(bytesToRead);
617
+ const handle = fs.openSync(absolutePath, "r");
618
+ fs.readSync(handle, buffer, 0, bytesToRead, 0);
619
+ fs.closeSync(handle);
620
+ const sniffEnd = Math.min(buffer.length, GIT_BINARY_SNIFF_BYTES);
621
+ if (buffer.subarray(0, sniffEnd).includes(0)) {
622
+ return { patch: null, patchOmitted: false, binary: true };
623
+ }
624
+ const truncated = stat.size > GIT_MAX_UNTRACKED_FILE_BYTES;
625
+ const content = truncated ? null : buffer.toString("utf8");
626
+ const patch = truncated || content === null ? null : buildUntrackedPatch(content);
627
+ if (patch !== null && patch.length > GIT_MAX_PATCH_BYTES_PER_FILE) {
718
628
  return { patch: null, patchOmitted: true, binary: false };
719
629
  }
720
- return { patch: chunk, patchOmitted: false, binary: false };
721
- }
722
- // Not tracked: it may be an untracked file (synthesize a patch like getGitDiff
723
- // does), otherwise it was committed/reverted since the file list was fetched.
724
- let untrackedPaths = [];
725
- try {
726
- untrackedPaths = await listUntrackedPaths(cwd);
630
+ return { patch, patchOmitted: truncated, binary: false };
727
631
  }
728
632
  catch {
729
633
  return empty;
730
634
  }
731
- if (!untrackedPaths.includes(requestedPath))
732
- return empty;
733
- const file = await buildUntrackedDiffFile(cwd, requestedPath);
734
- if (!file)
735
- return empty;
736
- if (file.patch !== null && file.patch.length > GIT_MAX_PATCH_BYTES_PER_FILE) {
737
- return { patch: null, patchOmitted: true, binary: file.binary };
635
+ };
636
+ const normalizePrState = (raw) => raw === "MERGED" ? "merged" : raw === "CLOSED" ? "closed" : "open";
637
+ const parsePrList = (stdout) => {
638
+ if (!stdout)
639
+ return [];
640
+ try {
641
+ const parsed = JSON.parse(stdout);
642
+ if (!Array.isArray(parsed))
643
+ return [];
644
+ const prs = [];
645
+ for (const item of parsed) {
646
+ if (typeof item?.number !== "number" || !item.baseRefName)
647
+ continue;
648
+ prs.push({
649
+ number: item.number,
650
+ title: typeof item.title === "string" ? item.title : "",
651
+ baseRefName: item.baseRefName,
652
+ url: typeof item.url === "string" && item.url.length > 0 ? item.url : null,
653
+ state: normalizePrState(item.state),
654
+ headOwner: typeof item.headRepositoryOwner?.login === "string"
655
+ ? item.headRepositoryOwner.login
656
+ : null,
657
+ });
658
+ }
659
+ return prs;
660
+ }
661
+ catch {
662
+ return [];
738
663
  }
739
- return { patch: file.patch, patchOmitted: file.patchOmitted, binary: file.binary };
740
664
  };
741
- // Candidate base branches for the picker: local heads + remote-tracking
742
- // branches, newest-committed-first so the most relevant refs surface at the top.
743
- // The synthetic `*/HEAD` symref entries are dropped (they're aliases, not
744
- // branches). Bounded by GIT_MAX_BRANCHES.
745
- const listBranches = async (cwd) => {
665
+ const PR_LIST_FIELDS = "number,title,baseRefName,url,state,headRepositoryOwner";
666
+ const parseGithubRemotes = (r) => {
667
+ const seen = new Set();
668
+ const remotes = [];
746
669
  try {
747
- const stdout = await runGit(cwd, [
748
- "for-each-ref",
749
- "--sort=-committerdate",
750
- "--format=%(refname:short)",
751
- "refs/heads",
752
- "refs/remotes",
753
- ]);
754
- const seen = new Set();
755
- const branches = [];
756
- for (const line of stdout.split("\n")) {
757
- const ref = line.trim();
758
- if (!ref || ref.endsWith("/HEAD") || seen.has(ref))
670
+ for (const remoteName of r.repo.remoteNames()) {
671
+ try {
672
+ const remote = r.repo.getRemote(remoteName);
673
+ const url = remote.url();
674
+ const match = /github\.com[/:]([^\s/]+)\/([^\s]+?)(?:\.git)?$/i.exec(url);
675
+ if (!match)
676
+ continue;
677
+ const [, owner, repoName] = match;
678
+ const slug = `${owner}/${repoName}`;
679
+ const key = `${remoteName} ${slug}`;
680
+ if (seen.has(key))
681
+ continue;
682
+ seen.add(key);
683
+ remotes.push({ name: remoteName, slug, owner });
684
+ }
685
+ catch {
759
686
  continue;
760
- seen.add(ref);
761
- branches.push(ref);
762
- if (branches.length >= GIT_MAX_BRANCHES)
763
- break;
687
+ }
764
688
  }
765
- return branches;
766
689
  }
767
690
  catch {
691
+ // No remotes
692
+ }
693
+ return remotes;
694
+ };
695
+ const detectPr = async (cwd, r) => {
696
+ const currentBranch = getCurrentBranch(r);
697
+ if (!currentBranch)
698
+ return null;
699
+ const remotes = parseGithubRemotes(r);
700
+ if (remotes.length === 0)
701
+ return null;
702
+ const ownRemote = remotes.find((remote) => remote.name === "origin") ?? remotes[0];
703
+ const ownOwner = ownRemote.owner.toLowerCase();
704
+ const slugs = [...new Set(remotes.map((remote) => remote.slug))];
705
+ const results = await Promise.all(slugs.map((slug) => runGh(cwd, [
706
+ "pr",
707
+ "list",
708
+ "--repo",
709
+ slug,
710
+ "--head",
711
+ currentBranch,
712
+ "--state",
713
+ "all",
714
+ "--json",
715
+ PR_LIST_FIELDS,
716
+ "--limit",
717
+ "30",
718
+ ]).then(parsePrList)));
719
+ const seen = new Set();
720
+ let fallback = null;
721
+ for (const prs of results) {
722
+ for (const pr of prs) {
723
+ if (!pr.headOwner || pr.headOwner.toLowerCase() !== ownOwner)
724
+ continue;
725
+ const key = pr.url ?? `#${pr.number}`;
726
+ if (seen.has(key))
727
+ continue;
728
+ seen.add(key);
729
+ if (pr.state === "open") {
730
+ return {
731
+ number: pr.number,
732
+ title: pr.title,
733
+ baseRefName: pr.baseRefName,
734
+ url: pr.url,
735
+ state: pr.state,
736
+ };
737
+ }
738
+ fallback ??= pr;
739
+ }
740
+ }
741
+ return fallback
742
+ ? {
743
+ number: fallback.number,
744
+ title: fallback.title,
745
+ baseRefName: fallback.baseRefName,
746
+ url: fallback.url,
747
+ state: fallback.state,
748
+ }
749
+ : null;
750
+ };
751
+ export const listGithubRemoteSlugs = async (cwd) => {
752
+ const r = await openRepo(cwd);
753
+ if (!r)
768
754
  return [];
755
+ const remotes = parseGithubRemotes(r);
756
+ const slugs = [];
757
+ for (const remote of remotes) {
758
+ if (!slugs.includes(remote.slug))
759
+ slugs.push(remote.slug);
769
760
  }
761
+ return slugs;
770
762
  };
771
- // Everything the base-branch picker needs, gathered in one request so the client
772
- // never polls: the candidate refs, a preselected default (with provenance), the
773
- // current branch, and the PR the branch maps to (via gh, best-effort).
774
763
  export const getGitBranchInfo = async (cwd) => {
775
- if (!(await isGitRepo(cwd))) {
764
+ const r = await openRepo(cwd);
765
+ if (!r) {
776
766
  return {
777
767
  isRepo: false,
778
768
  currentBranch: null,
@@ -782,18 +772,36 @@ export const getGitBranchInfo = async (cwd) => {
782
772
  pr: null,
783
773
  };
784
774
  }
785
- const [currentBranch, branches, pr] = await Promise.all([
786
- getCurrentBranch(cwd),
787
- listBranches(cwd),
788
- detectPr(cwd),
789
- ]);
790
- const defaultBase = await resolveDefaultBase(cwd, currentBranch);
775
+ const currentBranch = getCurrentBranch(r);
776
+ const pr = await detectPr(cwd, r);
777
+ const defaultBase = resolveDefaultBase(r);
778
+ const branchEntries = collectIterator(r.repo.branches());
779
+ const branchData = [];
780
+ for (const b of branchEntries) {
781
+ if (b.name.endsWith("/HEAD"))
782
+ continue;
783
+ try {
784
+ const refName = b.type === "Remote" ? `refs/remotes/${b.name}` : `refs/heads/${b.name}`;
785
+ const ref = r.repo.getReference(refName);
786
+ const target = ref.target();
787
+ if (!target)
788
+ continue;
789
+ const commit = r.repo.getCommit(target);
790
+ branchData.push({ name: b.name, time: commit.time().getTime() });
791
+ }
792
+ catch {
793
+ branchData.push({ name: b.name, time: 0 });
794
+ }
795
+ if (branchData.length >= GIT_MAX_BRANCHES)
796
+ break;
797
+ }
798
+ branchData.sort((a, b) => b.time - a.time);
791
799
  return {
792
800
  isRepo: true,
793
801
  currentBranch,
794
802
  defaultBase: defaultBase?.ref ?? null,
795
803
  defaultBaseSource: defaultBase?.source ?? null,
796
- branches,
804
+ branches: branchData.map((b) => b.name),
797
805
  pr,
798
806
  };
799
807
  };