@monotykamary/localterm-server 2.66.7 → 2.66.9
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/agent-git-status.d.ts +3 -0
- package/dist/agent-git-status.d.ts.map +1 -0
- package/dist/agent-git-status.js +46 -0
- package/dist/agent-git-status.js.map +1 -0
- package/dist/agent-log-utils.d.ts +18 -0
- package/dist/agent-log-utils.d.ts.map +1 -0
- package/dist/agent-log-utils.js +123 -0
- package/dist/agent-log-utils.js.map +1 -0
- package/dist/agent-models.d.ts +4 -0
- package/dist/agent-models.d.ts.map +1 -0
- package/dist/agent-models.js +100 -0
- package/dist/agent-models.js.map +1 -0
- package/dist/agent-runner.d.ts +3 -5
- package/dist/agent-runner.d.ts.map +1 -1
- package/dist/agent-runner.js +8 -522
- package/dist/agent-runner.js.map +1 -1
- package/dist/agent-session-reader.d.ts +3 -0
- package/dist/agent-session-reader.d.ts.map +1 -0
- package/dist/agent-session-reader.js +113 -0
- package/dist/agent-session-reader.js.map +1 -0
- package/dist/build-pty-environment.d.ts +8 -0
- package/dist/build-pty-environment.d.ts.map +1 -0
- package/dist/build-pty-environment.js +44 -0
- package/dist/build-pty-environment.js.map +1 -0
- package/dist/caffeinate-automatic-detector.d.ts +57 -0
- package/dist/caffeinate-automatic-detector.d.ts.map +1 -0
- package/dist/caffeinate-automatic-detector.js +275 -0
- package/dist/caffeinate-automatic-detector.js.map +1 -0
- package/dist/caffeinate-battery-guard.d.ts +35 -0
- package/dist/caffeinate-battery-guard.d.ts.map +1 -0
- package/dist/caffeinate-battery-guard.js +190 -0
- package/dist/caffeinate-battery-guard.js.map +1 -0
- package/dist/caffeinate-manager.d.ts +5 -31
- package/dist/caffeinate-manager.d.ts.map +1 -1
- package/dist/caffeinate-manager.js +55 -400
- package/dist/caffeinate-manager.js.map +1 -1
- package/dist/cdp/cdp-client.d.ts +4 -70
- package/dist/cdp/cdp-client.d.ts.map +1 -1
- package/dist/cdp/cdp-client.js +58 -418
- package/dist/cdp/cdp-client.js.map +1 -1
- package/dist/cdp/cdp-connection.d.ts +72 -0
- package/dist/cdp/cdp-connection.d.ts.map +1 -0
- package/dist/cdp/cdp-connection.js +272 -0
- package/dist/cdp/cdp-connection.js.map +1 -0
- package/dist/cdp/constants.d.ts +6 -0
- package/dist/cdp/constants.d.ts.map +1 -0
- package/dist/cdp/constants.js +9 -0
- package/dist/cdp/constants.js.map +1 -0
- package/dist/cdp/target-registry.d.ts +41 -0
- package/dist/cdp/target-registry.d.ts.map +1 -0
- package/dist/cdp/target-registry.js +118 -0
- package/dist/cdp/target-registry.js.map +1 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +2 -0
- package/dist/constants.js.map +1 -1
- package/dist/git-branch-metadata.d.ts +11 -0
- package/dist/git-branch-metadata.d.ts.map +1 -0
- package/dist/git-branch-metadata.js +85 -0
- package/dist/git-branch-metadata.js.map +1 -0
- package/dist/git-diff-cache.d.ts +13 -0
- package/dist/git-diff-cache.d.ts.map +1 -0
- package/dist/git-diff-cache.js +32 -0
- package/dist/git-diff-cache.js.map +1 -0
- package/dist/git-diff-parser.d.ts +15 -0
- package/dist/git-diff-parser.d.ts.map +1 -0
- package/dist/git-diff-parser.js +196 -0
- package/dist/git-diff-parser.js.map +1 -0
- package/dist/git-diff-service.d.ts +10 -0
- package/dist/git-diff-service.d.ts.map +1 -0
- package/dist/git-diff-service.js +395 -0
- package/dist/git-diff-service.js.map +1 -0
- package/dist/git-diff.d.ts +8 -41
- package/dist/git-diff.d.ts.map +1 -1
- package/dist/git-diff.js +5 -944
- package/dist/git-diff.js.map +1 -1
- package/dist/github-pr.d.ts +32 -0
- package/dist/github-pr.d.ts.map +1 -0
- package/dist/github-pr.js +266 -0
- package/dist/github-pr.js.map +1 -0
- package/dist/pi-binary-resolver.d.ts +6 -0
- package/dist/pi-binary-resolver.d.ts.map +1 -0
- package/dist/pi-binary-resolver.js +70 -0
- package/dist/pi-binary-resolver.js.map +1 -0
- package/dist/pi-rpc-client.d.ts +14 -0
- package/dist/pi-rpc-client.d.ts.map +1 -0
- package/dist/pi-rpc-client.js +87 -0
- package/dist/pi-rpc-client.js.map +1 -0
- package/dist/session-client-hub.d.ts +57 -0
- package/dist/session-client-hub.d.ts.map +1 -0
- package/dist/session-client-hub.js +435 -0
- package/dist/session-client-hub.js.map +1 -0
- package/dist/session-command-executor.d.ts +14 -0
- package/dist/session-command-executor.d.ts.map +1 -0
- package/dist/session-command-executor.js +207 -0
- package/dist/session-command-executor.js.map +1 -0
- package/dist/session-git-event-bridge.d.ts +16 -0
- package/dist/session-git-event-bridge.d.ts.map +1 -0
- package/dist/session-git-event-bridge.js +56 -0
- package/dist/session-git-event-bridge.js.map +1 -0
- package/dist/session-lifecycle-policy.d.ts +15 -0
- package/dist/session-lifecycle-policy.d.ts.map +1 -0
- package/dist/session-lifecycle-policy.js +105 -0
- package/dist/session-lifecycle-policy.js.map +1 -0
- package/dist/session-manager.d.ts +11 -42
- package/dist/session-manager.d.ts.map +1 -1
- package/dist/session-manager.js +72 -987
- package/dist/session-manager.js.map +1 -1
- package/dist/session-output-coordinator.d.ts +21 -0
- package/dist/session-output-coordinator.d.ts.map +1 -0
- package/dist/session-output-coordinator.js +134 -0
- package/dist/session-output-coordinator.js.map +1 -0
- package/dist/session-output-transport.d.ts +22 -0
- package/dist/session-output-transport.d.ts.map +1 -0
- package/dist/session-output-transport.js +160 -0
- package/dist/session-output-transport.js.map +1 -0
- package/dist/session.d.ts +0 -4
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +11 -258
- package/dist/session.js.map +1 -1
- package/dist/shell-hook-builder.d.ts +16 -0
- package/dist/shell-hook-builder.d.ts.map +1 -0
- package/dist/shell-hook-builder.js +230 -0
- package/dist/shell-hook-builder.js.map +1 -0
- package/package.json +1 -1
package/dist/git-diff.js
CHANGED
|
@@ -1,945 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import { resolveGithubToken } from "./utils/resolve-github-token.js";
|
|
7
|
-
import { memoBy } from "./utils/memo-by.js";
|
|
8
|
-
import { GIT_BINARY_SNIFF_BYTES, GIT_CACHE_TTL_MS, GIT_EMPTY_TREE_HASH, GIT_MAX_BRANCHES, GIT_MAX_PATCH_BYTES_PER_FILE, GIT_MAX_TOTAL_PATCH_BYTES, GIT_MAX_UNTRACKED_FILE_BYTES, GIT_MAX_UNTRACKED_FILES, GIT_PR_CACHE_TTL_MS, } from "./constants.js";
|
|
9
|
-
const WORKING_OPTIONS = { mode: "working" };
|
|
10
|
-
const EMPTY_SUMMARY = {
|
|
11
|
-
isRepo: false,
|
|
12
|
-
files: 0,
|
|
13
|
-
additions: 0,
|
|
14
|
-
deletions: 0,
|
|
15
|
-
binaries: 0,
|
|
16
|
-
branch: null,
|
|
17
|
-
};
|
|
18
|
-
// Nested so a cwd can hold more than one comparison (the working-tree summary
|
|
19
|
-
// is pushed on git-dirty while the viewer may be open in branch mode).
|
|
20
|
-
const diffCacheByCwd = new Map();
|
|
21
|
-
const comparisonKey = (mode, base) => `${mode}:${base ?? ""}`;
|
|
22
|
-
const readDiffCache = (cwd, mode, base) => {
|
|
23
|
-
const byComparison = diffCacheByCwd.get(cwd);
|
|
24
|
-
if (!byComparison)
|
|
25
|
-
return null;
|
|
26
|
-
const entry = byComparison.get(comparisonKey(mode, base));
|
|
27
|
-
if (!entry)
|
|
28
|
-
return null;
|
|
29
|
-
if (Date.now() - entry.builtAt > GIT_CACHE_TTL_MS) {
|
|
30
|
-
byComparison.delete(comparisonKey(mode, base));
|
|
31
|
-
if (byComparison.size === 0)
|
|
32
|
-
diffCacheByCwd.delete(cwd);
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
return entry;
|
|
36
|
-
};
|
|
37
|
-
const writeDiffCache = (cwd, mode, base, cache) => {
|
|
38
|
-
let byComparison = diffCacheByCwd.get(cwd);
|
|
39
|
-
if (!byComparison) {
|
|
40
|
-
byComparison = new Map();
|
|
41
|
-
diffCacheByCwd.set(cwd, byComparison);
|
|
42
|
-
}
|
|
43
|
-
byComparison.set(comparisonKey(mode, base), cache);
|
|
44
|
-
};
|
|
45
|
-
export const invalidateGitDiffCache = (cwd) => {
|
|
46
|
-
diffCacheByCwd.delete(cwd);
|
|
47
|
-
};
|
|
48
|
-
const prCacheByCwd = new Map();
|
|
49
|
-
// In-flight detectPr dedup, keyed by (cwd, branch), so the diff path's cold-cache
|
|
50
|
-
// resolution and the client's concurrent getGitBranchPr share a single GitHub
|
|
51
|
-
// round-trip instead of racing into two. Cleared on settle.
|
|
52
|
-
const inflightDetectPr = new Map();
|
|
53
|
-
const readPrCache = (cwd, branch) => {
|
|
54
|
-
const byBranch = prCacheByCwd.get(cwd);
|
|
55
|
-
if (!byBranch)
|
|
56
|
-
return undefined;
|
|
57
|
-
const entry = byBranch.get(branch);
|
|
58
|
-
if (!entry)
|
|
59
|
-
return undefined;
|
|
60
|
-
if (Date.now() - entry.builtAt > GIT_PR_CACHE_TTL_MS) {
|
|
61
|
-
byBranch.delete(branch);
|
|
62
|
-
if (byBranch.size === 0)
|
|
63
|
-
prCacheByCwd.delete(cwd);
|
|
64
|
-
return undefined;
|
|
65
|
-
}
|
|
66
|
-
return entry.pr;
|
|
67
|
-
};
|
|
68
|
-
const writePrCache = (cwd, branch, pr) => {
|
|
69
|
-
let byBranch = prCacheByCwd.get(cwd);
|
|
70
|
-
if (!byBranch) {
|
|
71
|
-
byBranch = new Map();
|
|
72
|
-
prCacheByCwd.set(cwd, byBranch);
|
|
73
|
-
}
|
|
74
|
-
byBranch.set(branch, { pr, builtAt: Date.now() });
|
|
75
|
-
};
|
|
76
|
-
const runGitText = async (cwd, args) => {
|
|
77
|
-
const result = await runGit(cwd, args);
|
|
78
|
-
return result.stdout.toString("utf8");
|
|
79
|
-
};
|
|
80
|
-
// `git diff` flags shared by every diff invocation so numstat, name-status and
|
|
81
|
-
// patch all walk the same diff queue (same rename detection, same file order).
|
|
82
|
-
const DIFF_RENAME_FLAG = "--find-renames";
|
|
83
|
-
const isGitRepo = async (cwd) => {
|
|
84
|
-
const result = await runGit(cwd, ["rev-parse", "--is-inside-work-tree"]);
|
|
85
|
-
return result.exitCode === 0;
|
|
86
|
-
};
|
|
87
|
-
const getCurrentBranch = async (cwd) => {
|
|
88
|
-
const result = await runGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
89
|
-
if (result.exitCode !== 0)
|
|
90
|
-
return null;
|
|
91
|
-
const name = result.stdout.toString("utf8").trim();
|
|
92
|
-
return name === "HEAD" ? null : name;
|
|
93
|
-
};
|
|
94
|
-
// working mode compares against HEAD (or the empty tree when the branch is
|
|
95
|
-
// unborn). branch mode compares against the merge-base of HEAD and the base
|
|
96
|
-
// ref, so only changes since the branches diverged are shown.
|
|
97
|
-
const resolveDiffBaseRef = async (cwd) => {
|
|
98
|
-
const result = await runGit(cwd, ["rev-parse", "--verify", "-q", "HEAD"]);
|
|
99
|
-
return result.exitCode === 0 ? "HEAD" : GIT_EMPTY_TREE_HASH;
|
|
100
|
-
};
|
|
101
|
-
const verifyRef = async (cwd, ref) => {
|
|
102
|
-
const result = await runGit(cwd, ["rev-parse", "--verify", "-q", ref]);
|
|
103
|
-
return result.exitCode === 0;
|
|
104
|
-
};
|
|
105
|
-
const resolveDefaultBase = async (cwd) => {
|
|
106
|
-
const currentBranch = await getCurrentBranch(cwd);
|
|
107
|
-
const symbolic = await runGit(cwd, ["symbolic-ref", "-q", "refs/remotes/origin/HEAD"]);
|
|
108
|
-
if (symbolic.exitCode === 0) {
|
|
109
|
-
const target = symbolic.stdout.toString("utf8").trim();
|
|
110
|
-
if (target.startsWith("refs/remotes/")) {
|
|
111
|
-
const shortName = target.slice("refs/remotes/".length);
|
|
112
|
-
if (shortName !== currentBranch && (await verifyRef(cwd, shortName))) {
|
|
113
|
-
return { ref: shortName, source: "remoteHead" };
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
for (const name of ["main", "master", "develop"]) {
|
|
118
|
-
if (name === currentBranch)
|
|
119
|
-
continue;
|
|
120
|
-
for (const candidate of [`origin/${name}`, name]) {
|
|
121
|
-
if (await verifyRef(cwd, candidate))
|
|
122
|
-
return { ref: candidate, source: "fallback" };
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
return null;
|
|
126
|
-
};
|
|
127
|
-
const resolveEffectiveBaseRef = async (cwd, options) => {
|
|
128
|
-
if (options.mode !== "branch")
|
|
129
|
-
return resolveDiffBaseRef(cwd);
|
|
130
|
-
let baseRef = options.base?.trim() || null;
|
|
131
|
-
if (baseRef && !(await verifyRef(cwd, baseRef)))
|
|
132
|
-
baseRef = null;
|
|
133
|
-
// No explicit user override: a fork PR compares against its upstream base
|
|
134
|
-
// (the PR's base repo's default branch), not the fork's own default. The PR is
|
|
135
|
-
// read from the per-(cwd, branch) cache populated by getGitBranchPr — no GitHub
|
|
136
|
-
// round-trip in the warm case, so the diff stays local and the cache's branch
|
|
137
|
-
// key means a branch switch naturally misses. A cold cache (the viewer opened
|
|
138
|
-
// branch mode before getGitBranchPr landed, or a refresh raced it) resolves the
|
|
139
|
-
// PR inline via detectPrDeduped — which shares any in-flight getGitBranchPr
|
|
140
|
-
// call — rather than silently falling back to the fork's origin, which would
|
|
141
|
-
// mismatch the PR's base. A cached null (known no-PR) skips; so does a PR whose
|
|
142
|
-
// base resolves no usable ref; both degrade to the repo default below.
|
|
143
|
-
if (!baseRef) {
|
|
144
|
-
const currentBranch = await getCurrentBranch(cwd);
|
|
145
|
-
if (currentBranch) {
|
|
146
|
-
let cachedPr = readPrCache(cwd, currentBranch);
|
|
147
|
-
if (cachedPr === undefined) {
|
|
148
|
-
await detectPrDeduped(cwd);
|
|
149
|
-
cachedPr = readPrCache(cwd, currentBranch);
|
|
150
|
-
}
|
|
151
|
-
// detectPr resolved (and fetched, if needed) the base ref alongside the
|
|
152
|
-
// GitHub call, so the diff path reads it warm — no remote resolution here.
|
|
153
|
-
if (cachedPr?.baseRef)
|
|
154
|
-
baseRef = cachedPr.baseRef;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
if (!baseRef)
|
|
158
|
-
baseRef = (await resolveDefaultBase(cwd))?.ref ?? null;
|
|
159
|
-
if (!baseRef)
|
|
160
|
-
return null;
|
|
161
|
-
const mergeBase = await runGit(cwd, ["merge-base", baseRef, "HEAD"]);
|
|
162
|
-
if (mergeBase.exitCode === 0)
|
|
163
|
-
return mergeBase.stdout.toString("utf8").trim();
|
|
164
|
-
if (!(await verifyRef(cwd, baseRef)))
|
|
165
|
-
return null;
|
|
166
|
-
return runGitText(cwd, ["rev-parse", "--verify", baseRef]);
|
|
167
|
-
};
|
|
168
|
-
const countLines = (text) => {
|
|
169
|
-
if (text.length === 0)
|
|
170
|
-
return 0;
|
|
171
|
-
let count = 0;
|
|
172
|
-
for (let index = 0; index < text.length; index += 1) {
|
|
173
|
-
if (text.charCodeAt(index) === 10)
|
|
174
|
-
count += 1;
|
|
175
|
-
}
|
|
176
|
-
if (!text.endsWith("\n"))
|
|
177
|
-
count += 1;
|
|
178
|
-
return count;
|
|
179
|
-
};
|
|
180
|
-
const readUntrackedFile = async (cwd, filePath) => {
|
|
181
|
-
const absolutePath = path.join(cwd, filePath);
|
|
182
|
-
try {
|
|
183
|
-
const stat = await fsPromises.stat(absolutePath);
|
|
184
|
-
if (!stat.isFile())
|
|
185
|
-
return null;
|
|
186
|
-
const bytesToRead = Math.min(stat.size, GIT_MAX_UNTRACKED_FILE_BYTES);
|
|
187
|
-
const buffer = Buffer.alloc(bytesToRead);
|
|
188
|
-
const handle = await fsPromises.open(absolutePath, "r");
|
|
189
|
-
try {
|
|
190
|
-
const { buffer: readBuffer } = await handle.read(buffer, 0, bytesToRead, 0);
|
|
191
|
-
const sniffEnd = Math.min(readBuffer.length, GIT_BINARY_SNIFF_BYTES);
|
|
192
|
-
const binary = readBuffer.subarray(0, sniffEnd).includes(0);
|
|
193
|
-
const truncated = stat.size > GIT_MAX_UNTRACKED_FILE_BYTES;
|
|
194
|
-
const content = binary ? null : truncated ? null : readBuffer.toString("utf8");
|
|
195
|
-
const lines = binary ? 0 : content ? countLines(content) : 0;
|
|
196
|
-
return { path: filePath, binary, lines, content, truncated };
|
|
197
|
-
}
|
|
198
|
-
finally {
|
|
199
|
-
await handle.close();
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
catch {
|
|
203
|
-
return null;
|
|
204
|
-
}
|
|
205
|
-
};
|
|
206
|
-
const collectUntrackedFiles = async (cwd) => {
|
|
207
|
-
const result = await runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z"]);
|
|
208
|
-
if (result.exitCode !== 0)
|
|
209
|
-
return [];
|
|
210
|
-
const paths = result.stdout.toString("utf8").split("\0").filter(Boolean);
|
|
211
|
-
const files = [];
|
|
212
|
-
for (const filePath of paths) {
|
|
213
|
-
if (files.length >= GIT_MAX_UNTRACKED_FILES)
|
|
214
|
-
break;
|
|
215
|
-
const file = await readUntrackedFile(cwd, filePath);
|
|
216
|
-
if (file)
|
|
217
|
-
files.push(file);
|
|
218
|
-
}
|
|
219
|
-
return files;
|
|
220
|
-
};
|
|
221
|
-
export const buildUntrackedPatch = (content) => {
|
|
222
|
-
if (content.length === 0)
|
|
223
|
-
return "";
|
|
224
|
-
const hasTrailingNewline = content.endsWith("\n");
|
|
225
|
-
const lines = content.split("\n");
|
|
226
|
-
if (hasTrailingNewline)
|
|
227
|
-
lines.pop();
|
|
228
|
-
const body = lines.map((line) => `+${line}`).join("\n");
|
|
229
|
-
const noNewlineMarker = hasTrailingNewline ? "" : "\n\";
|
|
230
|
-
return `@@ -0,0 +1,${lines.length} @@\n${body}${noNewlineMarker}\n`;
|
|
231
|
-
};
|
|
232
|
-
export const splitPatchByFile = (raw) => raw.split(/^(?=diff --git )/m).filter((chunk) => chunk.startsWith("diff --git "));
|
|
233
|
-
export const parseNumstatZ = (raw) => {
|
|
234
|
-
const tokens = raw.split("\0");
|
|
235
|
-
const entries = [];
|
|
236
|
-
for (let index = 0; index < tokens.length; index += 1) {
|
|
237
|
-
const token = tokens[index];
|
|
238
|
-
if (!token)
|
|
239
|
-
continue;
|
|
240
|
-
const match = /^(\d+|-)\t(\d+|-)\t(.*)$/s.exec(token);
|
|
241
|
-
if (!match)
|
|
242
|
-
continue;
|
|
243
|
-
const binary = match[1] === "-" || match[2] === "-";
|
|
244
|
-
const additions = binary ? 0 : Number.parseInt(match[1], 10);
|
|
245
|
-
const deletions = binary ? 0 : Number.parseInt(match[2], 10);
|
|
246
|
-
if (match[3]) {
|
|
247
|
-
entries.push({ path: match[3], oldPath: null, additions, deletions, binary });
|
|
248
|
-
continue;
|
|
249
|
-
}
|
|
250
|
-
const oldPath = tokens[index + 1];
|
|
251
|
-
const newPath = tokens[index + 2];
|
|
252
|
-
index += 2;
|
|
253
|
-
if (!oldPath || !newPath)
|
|
254
|
-
continue;
|
|
255
|
-
entries.push({ path: newPath, oldPath, additions, deletions, binary });
|
|
256
|
-
}
|
|
257
|
-
return entries;
|
|
258
|
-
};
|
|
259
|
-
export const parseNameStatusZ = (raw) => {
|
|
260
|
-
const tokens = raw.split("\0");
|
|
261
|
-
const statuses = new Map();
|
|
262
|
-
for (let index = 0; index < tokens.length; index += 1) {
|
|
263
|
-
const statusToken = tokens[index];
|
|
264
|
-
if (!statusToken)
|
|
265
|
-
continue;
|
|
266
|
-
const letter = statusToken[0];
|
|
267
|
-
if (letter === "R" || letter === "C") {
|
|
268
|
-
const newPath = tokens[index + 2];
|
|
269
|
-
index += 2;
|
|
270
|
-
if (!newPath)
|
|
271
|
-
continue;
|
|
272
|
-
statuses.set(newPath, letter === "R" ? "renamed" : "added");
|
|
273
|
-
continue;
|
|
274
|
-
}
|
|
275
|
-
const filePath = tokens[index + 1];
|
|
276
|
-
index += 1;
|
|
277
|
-
if (!filePath)
|
|
278
|
-
continue;
|
|
279
|
-
if (letter === "A")
|
|
280
|
-
statuses.set(filePath, "added");
|
|
281
|
-
else if (letter === "D")
|
|
282
|
-
statuses.set(filePath, "deleted");
|
|
283
|
-
else
|
|
284
|
-
statuses.set(filePath, "modified");
|
|
285
|
-
}
|
|
286
|
-
return statuses;
|
|
287
|
-
};
|
|
288
|
-
// Undo git's C-style path quoting (paths with spaces/special chars get wrapped
|
|
289
|
-
// in "..." with \-escapes; core.quotepath=false leaves only those, not non-ASCII).
|
|
290
|
-
const unquoteGitPath = (raw) => {
|
|
291
|
-
if (raw.length < 2 || raw[0] !== '"' || raw[raw.length - 1] !== '"')
|
|
292
|
-
return raw;
|
|
293
|
-
let out = "";
|
|
294
|
-
let index = 1;
|
|
295
|
-
const end = raw.length - 1;
|
|
296
|
-
while (index < end) {
|
|
297
|
-
const char = raw[index];
|
|
298
|
-
if (char === "\\" && index + 1 < end) {
|
|
299
|
-
const next = raw[index + 1];
|
|
300
|
-
if (next === "n") {
|
|
301
|
-
out += "\n";
|
|
302
|
-
index += 2;
|
|
303
|
-
}
|
|
304
|
-
else if (next === "t") {
|
|
305
|
-
out += "\t";
|
|
306
|
-
index += 2;
|
|
307
|
-
}
|
|
308
|
-
else if (next === '"') {
|
|
309
|
-
out += '"';
|
|
310
|
-
index += 2;
|
|
311
|
-
}
|
|
312
|
-
else if (next === "\\") {
|
|
313
|
-
out += "\\";
|
|
314
|
-
index += 2;
|
|
315
|
-
}
|
|
316
|
-
else if (next >= "0" && next <= "7" && index + 3 < end) {
|
|
317
|
-
out += String.fromCharCode(Number.parseInt(raw.slice(index + 1, index + 4), 8));
|
|
318
|
-
index += 4;
|
|
319
|
-
}
|
|
320
|
-
else {
|
|
321
|
-
out += char;
|
|
322
|
-
index += 1;
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
else {
|
|
326
|
-
out += char;
|
|
327
|
-
index += 1;
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
return out;
|
|
331
|
-
};
|
|
332
|
-
// Strip the `+++ ` / `--- ` prefix and the `b/` / `a/` namespace, unquoting if
|
|
333
|
-
// git wrapped the path. `/dev/null` (a deletion's +++ side) yields null so the
|
|
334
|
-
// caller can fall back to the --- side.
|
|
335
|
-
const pathFromLine = (line, prefix) => {
|
|
336
|
-
if (!line.startsWith(prefix))
|
|
337
|
-
return null;
|
|
338
|
-
let rest = line.slice(prefix.length);
|
|
339
|
-
if (rest === "/dev/null")
|
|
340
|
-
return null;
|
|
341
|
-
rest = unquoteGitPath(rest);
|
|
342
|
-
// git appends a literal tab after an unquoted path in ---/+++ lines when the
|
|
343
|
-
// path contains a space, to keep it from running into hunk content; the
|
|
344
|
-
// closing quote already disambiguates quoted paths, so this only affects
|
|
345
|
-
// the unquoted branch. Numstat/name-status never carry that tab, so strip it
|
|
346
|
-
// or the path keys won't line up.
|
|
347
|
-
if (rest.endsWith("\t"))
|
|
348
|
-
rest = rest.slice(0, -1);
|
|
349
|
-
if (rest.startsWith("b/"))
|
|
350
|
-
return rest.slice(2);
|
|
351
|
-
if (rest.startsWith("a/"))
|
|
352
|
-
return rest.slice(2);
|
|
353
|
-
return rest;
|
|
354
|
-
};
|
|
355
|
-
// Pull the new-side path out of one `diff --git` chunk: prefer `+++ b/<path>`
|
|
356
|
-
// (added/modified/rename target), fall back to `--- a/<path>` for deletions
|
|
357
|
-
// (whose +++ is /dev/null), then to `rename to <path>` for a pure rename with
|
|
358
|
-
// no content change (which has no ---/+++ lines at all).
|
|
359
|
-
const extractPatchPath = (chunk) => {
|
|
360
|
-
const lines = chunk.split("\n");
|
|
361
|
-
for (const line of lines) {
|
|
362
|
-
if (line.startsWith("+++ ")) {
|
|
363
|
-
const fromNew = pathFromLine(line, "+++ ");
|
|
364
|
-
if (fromNew !== null)
|
|
365
|
-
return fromNew;
|
|
366
|
-
for (const fallback of lines) {
|
|
367
|
-
if (fallback.startsWith("--- ")) {
|
|
368
|
-
const fromOld = pathFromLine(fallback, "--- ");
|
|
369
|
-
if (fromOld !== null)
|
|
370
|
-
return fromOld;
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
return null;
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
for (const line of lines) {
|
|
377
|
-
if (line.startsWith("rename to "))
|
|
378
|
-
return unquoteGitPath(line.slice("rename to ".length));
|
|
379
|
-
}
|
|
380
|
-
return null;
|
|
381
|
-
};
|
|
382
|
-
// Index `git diff --patch` output by path. A single path can map to several
|
|
383
|
-
// `diff --git` blocks (a symlink re-added as a regular file emits a deletion +
|
|
384
|
-
// an addition for one path), so those are concatenated back into one patch.
|
|
385
|
-
const indexPatchesByPath = (raw) => {
|
|
386
|
-
const chunksByPath = new Map();
|
|
387
|
-
for (const chunk of splitPatchByFile(raw)) {
|
|
388
|
-
const patchPath = extractPatchPath(chunk);
|
|
389
|
-
if (patchPath === null)
|
|
390
|
-
continue;
|
|
391
|
-
let chunks = chunksByPath.get(patchPath);
|
|
392
|
-
if (!chunks) {
|
|
393
|
-
chunks = [];
|
|
394
|
-
chunksByPath.set(patchPath, chunks);
|
|
395
|
-
}
|
|
396
|
-
chunks.push(chunk);
|
|
397
|
-
}
|
|
398
|
-
const result = new Map();
|
|
399
|
-
for (const [patchPath, chunks] of chunksByPath) {
|
|
400
|
-
result.set(patchPath, chunks.join(""));
|
|
401
|
-
}
|
|
402
|
-
return result;
|
|
403
|
-
};
|
|
404
|
-
// One full diff pass for `(cwd)` against `baseRef`. Three parallel `git diff`
|
|
405
|
-
// invocations (numstat for counts+binary, name-status for the status letter +
|
|
406
|
-
// rename old path, patch for the body) walk the same diff queue. numstat and
|
|
407
|
-
// name-status are NUL-delimited and unambiguous; they're the source of truth
|
|
408
|
-
// for the file list. The patch output is keyed by path rather than paired
|
|
409
|
-
// positionally, because a single numstat entry can span several `diff --git`
|
|
410
|
-
// blocks (a symlink is deleted as mode 120000 and re-added as a regular file:
|
|
411
|
-
// git emits that as a deletion + an addition sharing one path, so there's no
|
|
412
|
-
// 1:1 with numstat entries). Untracked files are folded in from `ls-files`
|
|
413
|
-
// with synthesized patches (git's own diff never lists untracked files).
|
|
414
|
-
const buildDiffCache = async (cwd, baseRef) => {
|
|
415
|
-
const [numstatRes, nameStatusRes, patchRes] = await Promise.all([
|
|
416
|
-
runGit(cwd, [
|
|
417
|
-
"-c",
|
|
418
|
-
"core.quotepath=false",
|
|
419
|
-
"diff",
|
|
420
|
-
DIFF_RENAME_FLAG,
|
|
421
|
-
"--numstat",
|
|
422
|
-
"-z",
|
|
423
|
-
baseRef,
|
|
424
|
-
]),
|
|
425
|
-
runGit(cwd, [
|
|
426
|
-
"-c",
|
|
427
|
-
"core.quotepath=false",
|
|
428
|
-
"diff",
|
|
429
|
-
DIFF_RENAME_FLAG,
|
|
430
|
-
"--name-status",
|
|
431
|
-
"-z",
|
|
432
|
-
baseRef,
|
|
433
|
-
]),
|
|
434
|
-
runGit(cwd, ["-c", "core.quotepath=false", "diff", DIFF_RENAME_FLAG, "--patch", baseRef]),
|
|
435
|
-
]);
|
|
436
|
-
if (numstatRes.exitCode !== 0 || patchRes.exitCode !== 0)
|
|
437
|
-
return null;
|
|
438
|
-
const numstat = parseNumstatZ(numstatRes.stdout.toString("utf8"));
|
|
439
|
-
const statuses = parseNameStatusZ(nameStatusRes.stdout.toString("utf8"));
|
|
440
|
-
const patchesByPath = indexPatchesByPath(patchRes.stdout.toString("utf8"));
|
|
441
|
-
const untracked = await collectUntrackedFiles(cwd);
|
|
442
|
-
const fileMeta = [];
|
|
443
|
-
const filePatchByPath = new Map();
|
|
444
|
-
const fileBinaryByPath = new Map();
|
|
445
|
-
const filePatchOmittedByPath = new Map();
|
|
446
|
-
let totalPatchBytes = 0;
|
|
447
|
-
let additions = 0;
|
|
448
|
-
let deletions = 0;
|
|
449
|
-
let binaries = 0;
|
|
450
|
-
for (let index = 0; index < numstat.length; index += 1) {
|
|
451
|
-
const entry = numstat[index];
|
|
452
|
-
const status = statuses.get(entry.path) ?? "modified";
|
|
453
|
-
const oldPath = status === "renamed" ? entry.oldPath : null;
|
|
454
|
-
let patchText = null;
|
|
455
|
-
let patchOmitted = false;
|
|
456
|
-
if (entry.binary) {
|
|
457
|
-
binaries += 1;
|
|
458
|
-
}
|
|
459
|
-
else {
|
|
460
|
-
const rawPatch = patchesByPath.get(entry.path) ?? null;
|
|
461
|
-
if (rawPatch === null ||
|
|
462
|
-
rawPatch.length > GIT_MAX_PATCH_BYTES_PER_FILE ||
|
|
463
|
-
totalPatchBytes + rawPatch.length > GIT_MAX_TOTAL_PATCH_BYTES) {
|
|
464
|
-
patchOmitted = true;
|
|
465
|
-
}
|
|
466
|
-
else {
|
|
467
|
-
patchText = rawPatch;
|
|
468
|
-
totalPatchBytes += rawPatch.length;
|
|
469
|
-
}
|
|
470
|
-
additions += entry.additions;
|
|
471
|
-
deletions += entry.deletions;
|
|
472
|
-
}
|
|
473
|
-
fileMeta.push({
|
|
474
|
-
path: entry.path,
|
|
475
|
-
oldPath,
|
|
476
|
-
status,
|
|
477
|
-
additions: entry.additions,
|
|
478
|
-
deletions: entry.deletions,
|
|
479
|
-
binary: entry.binary,
|
|
480
|
-
});
|
|
481
|
-
filePatchByPath.set(entry.path, patchText);
|
|
482
|
-
fileBinaryByPath.set(entry.path, entry.binary);
|
|
483
|
-
filePatchOmittedByPath.set(entry.path, patchOmitted);
|
|
484
|
-
}
|
|
485
|
-
for (const file of untracked) {
|
|
486
|
-
const patch = file.binary
|
|
487
|
-
? null
|
|
488
|
-
: file.truncated || file.content === null
|
|
489
|
-
? null
|
|
490
|
-
: buildUntrackedPatch(file.content);
|
|
491
|
-
let patchOmitted = file.truncated;
|
|
492
|
-
if (patch !== null) {
|
|
493
|
-
if (patch.length > GIT_MAX_PATCH_BYTES_PER_FILE ||
|
|
494
|
-
totalPatchBytes + patch.length > GIT_MAX_TOTAL_PATCH_BYTES) {
|
|
495
|
-
patchOmitted = true;
|
|
496
|
-
}
|
|
497
|
-
else {
|
|
498
|
-
totalPatchBytes += patch.length;
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
const fileAdditions = file.binary ? 0 : file.lines;
|
|
502
|
-
if (file.binary)
|
|
503
|
-
binaries += 1;
|
|
504
|
-
additions += fileAdditions;
|
|
505
|
-
fileMeta.push({
|
|
506
|
-
path: file.path,
|
|
507
|
-
oldPath: null,
|
|
508
|
-
status: "untracked",
|
|
509
|
-
additions: fileAdditions,
|
|
510
|
-
deletions: 0,
|
|
511
|
-
binary: file.binary,
|
|
512
|
-
});
|
|
513
|
-
filePatchByPath.set(file.path, patchOmitted ? null : patch);
|
|
514
|
-
fileBinaryByPath.set(file.path, file.binary);
|
|
515
|
-
filePatchOmittedByPath.set(file.path, patchOmitted);
|
|
516
|
-
}
|
|
517
|
-
const summary = {
|
|
518
|
-
isRepo: true,
|
|
519
|
-
files: fileMeta.length,
|
|
520
|
-
additions,
|
|
521
|
-
deletions,
|
|
522
|
-
binaries,
|
|
523
|
-
branch: await getCurrentBranch(cwd),
|
|
524
|
-
};
|
|
525
|
-
return {
|
|
526
|
-
summary,
|
|
527
|
-
fileMeta,
|
|
528
|
-
filePatchByPath,
|
|
529
|
-
fileBinaryByPath,
|
|
530
|
-
filePatchOmittedByPath,
|
|
531
|
-
builtAt: Date.now(),
|
|
532
|
-
};
|
|
533
|
-
};
|
|
534
|
-
const ensureDiffCache = async (cwd, options) => {
|
|
535
|
-
// Read the cache before resolving the base ref — that resolution does git work
|
|
536
|
-
// (rev-parse + merge-base) on every call, so for the per-file patch endpoint
|
|
537
|
-
// (where the cache is warm on nearly every request) checking first keeps it a
|
|
538
|
-
// pure map lookup with no subprocess.
|
|
539
|
-
const cached = readDiffCache(cwd, options.mode, options.base ?? null);
|
|
540
|
-
if (cached)
|
|
541
|
-
return cached;
|
|
542
|
-
const baseRef = await resolveEffectiveBaseRef(cwd, options);
|
|
543
|
-
if (baseRef === null)
|
|
544
|
-
return null;
|
|
545
|
-
const cache = await buildDiffCache(cwd, baseRef);
|
|
546
|
-
if (cache)
|
|
547
|
-
writeDiffCache(cwd, options.mode, options.base ?? null, cache);
|
|
548
|
-
return cache;
|
|
549
|
-
};
|
|
550
|
-
export const getGitDiffSummary = async (cwd, options = WORKING_OPTIONS) => {
|
|
551
|
-
const cached = readDiffCache(cwd, options.mode, options.base ?? null);
|
|
552
|
-
if (cached)
|
|
553
|
-
return cached.summary;
|
|
554
|
-
if (!(await isGitRepo(cwd)))
|
|
555
|
-
return EMPTY_SUMMARY;
|
|
556
|
-
try {
|
|
557
|
-
const baseRef = await resolveEffectiveBaseRef(cwd, options);
|
|
558
|
-
if (baseRef === null)
|
|
559
|
-
return { ...EMPTY_SUMMARY, isRepo: true };
|
|
560
|
-
const branch = await getCurrentBranch(cwd);
|
|
561
|
-
// Summary is pushed on every git-dirty signal, so it stays on the cheap
|
|
562
|
-
// numstat-only path (no patch) unless a cache is already warm.
|
|
563
|
-
const numstatRes = await runGit(cwd, [
|
|
564
|
-
"-c",
|
|
565
|
-
"core.quotepath=false",
|
|
566
|
-
"diff",
|
|
567
|
-
DIFF_RENAME_FLAG,
|
|
568
|
-
"--numstat",
|
|
569
|
-
"-z",
|
|
570
|
-
baseRef,
|
|
571
|
-
]);
|
|
572
|
-
if (numstatRes.exitCode !== 0)
|
|
573
|
-
return { ...EMPTY_SUMMARY, isRepo: true };
|
|
574
|
-
let additions = 0;
|
|
575
|
-
let deletions = 0;
|
|
576
|
-
let binaries = 0;
|
|
577
|
-
let fileCount = 0;
|
|
578
|
-
for (const entry of parseNumstatZ(numstatRes.stdout.toString("utf8"))) {
|
|
579
|
-
fileCount += 1;
|
|
580
|
-
if (entry.binary) {
|
|
581
|
-
binaries += 1;
|
|
582
|
-
continue;
|
|
583
|
-
}
|
|
584
|
-
additions += entry.additions;
|
|
585
|
-
deletions += entry.deletions;
|
|
586
|
-
}
|
|
587
|
-
const untracked = await collectUntrackedFiles(cwd);
|
|
588
|
-
for (const file of untracked) {
|
|
589
|
-
fileCount += 1;
|
|
590
|
-
if (file.binary) {
|
|
591
|
-
binaries += 1;
|
|
592
|
-
}
|
|
593
|
-
else {
|
|
594
|
-
additions += file.lines;
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
return { isRepo: true, files: fileCount, additions, deletions, binaries, branch };
|
|
598
|
-
}
|
|
599
|
-
catch {
|
|
600
|
-
return { ...EMPTY_SUMMARY, isRepo: true };
|
|
601
|
-
}
|
|
602
|
-
};
|
|
603
|
-
export const getGitDiff = async (cwd, options = WORKING_OPTIONS) => {
|
|
604
|
-
const cached = readDiffCache(cwd, options.mode, options.base ?? null);
|
|
605
|
-
if (cached) {
|
|
606
|
-
return { isRepo: true, files: mapCacheFiles(cached) };
|
|
607
|
-
}
|
|
608
|
-
if (!(await isGitRepo(cwd)))
|
|
609
|
-
return { isRepo: false, files: [] };
|
|
610
|
-
const cache = await ensureDiffCache(cwd, options);
|
|
611
|
-
if (!cache)
|
|
612
|
-
return { isRepo: true, files: [] };
|
|
613
|
-
return { isRepo: true, files: mapCacheFiles(cache) };
|
|
614
|
-
};
|
|
615
|
-
const mapCacheFiles = (cache) => cache.fileMeta.map((meta) => ({
|
|
616
|
-
path: meta.path,
|
|
617
|
-
oldPath: meta.oldPath,
|
|
618
|
-
status: meta.status,
|
|
619
|
-
additions: meta.additions,
|
|
620
|
-
deletions: meta.deletions,
|
|
621
|
-
binary: meta.binary,
|
|
622
|
-
patch: cache.filePatchByPath.get(meta.path) ?? null,
|
|
623
|
-
patchOmitted: cache.filePatchOmittedByPath.get(meta.path) ?? false,
|
|
624
|
-
}));
|
|
625
|
-
export const getGitDiffFiles = async (cwd, options = WORKING_OPTIONS) => {
|
|
626
|
-
const cached = readDiffCache(cwd, options.mode, options.base ?? null);
|
|
627
|
-
if (cached)
|
|
628
|
-
return { isRepo: true, files: cached.fileMeta };
|
|
629
|
-
if (!(await isGitRepo(cwd)))
|
|
630
|
-
return { isRepo: false, files: [] };
|
|
631
|
-
const cache = await ensureDiffCache(cwd, options);
|
|
632
|
-
if (!cache)
|
|
633
|
-
return { isRepo: true, files: [] };
|
|
634
|
-
return { isRepo: true, files: cache.fileMeta };
|
|
635
|
-
};
|
|
636
|
-
export const getGitDiffFilePatch = async (cwd, requestedPath, options = WORKING_OPTIONS) => {
|
|
637
|
-
const empty = { patch: null, patchOmitted: false, binary: false };
|
|
638
|
-
const cache = await ensureDiffCache(cwd, options);
|
|
639
|
-
if (!cache)
|
|
640
|
-
return empty;
|
|
641
|
-
// O(1) lookup: the full diff pass that the per-file patch needs was already
|
|
642
|
-
// computed once for this (cwd, mode, base) and cached. This was the O(N²)
|
|
643
|
-
// regression — each per-file request re-ran the whole-tree diff + a jsdiff
|
|
644
|
-
// for every file. Now it's a map lookup.
|
|
645
|
-
if (cache.filePatchByPath.has(requestedPath)) {
|
|
646
|
-
return {
|
|
647
|
-
patch: cache.filePatchByPath.get(requestedPath) ?? null,
|
|
648
|
-
patchOmitted: cache.filePatchOmittedByPath.get(requestedPath) ?? false,
|
|
649
|
-
binary: cache.fileBinaryByPath.get(requestedPath) ?? false,
|
|
650
|
-
};
|
|
651
|
-
}
|
|
652
|
-
// An untracked path the cache didn't cover (created between the cache build
|
|
653
|
-
// and this request) falls back to synthesizing from the working tree.
|
|
654
|
-
return getGitDiffFilePatchFromWorkingTree(cwd, requestedPath);
|
|
655
|
-
};
|
|
656
|
-
const getGitDiffFilePatchFromWorkingTree = async (cwd, requestedPath) => {
|
|
657
|
-
const empty = { patch: null, patchOmitted: false, binary: false };
|
|
658
|
-
const absolutePath = path.join(cwd, requestedPath);
|
|
659
|
-
try {
|
|
660
|
-
const stat = fs.statSync(absolutePath);
|
|
661
|
-
if (!stat.isFile())
|
|
662
|
-
return empty;
|
|
663
|
-
const bytesToRead = Math.min(stat.size, GIT_MAX_UNTRACKED_FILE_BYTES);
|
|
664
|
-
const buffer = Buffer.alloc(bytesToRead);
|
|
665
|
-
const handle = fs.openSync(absolutePath, "r");
|
|
666
|
-
fs.readSync(handle, buffer, 0, bytesToRead, 0);
|
|
667
|
-
fs.closeSync(handle);
|
|
668
|
-
const sniffEnd = Math.min(buffer.length, GIT_BINARY_SNIFF_BYTES);
|
|
669
|
-
if (buffer.subarray(0, sniffEnd).includes(0)) {
|
|
670
|
-
return { patch: null, patchOmitted: false, binary: true };
|
|
671
|
-
}
|
|
672
|
-
const truncated = stat.size > GIT_MAX_UNTRACKED_FILE_BYTES;
|
|
673
|
-
const content = truncated ? null : buffer.toString("utf8");
|
|
674
|
-
const patch = truncated || content === null ? null : buildUntrackedPatch(content);
|
|
675
|
-
if (patch !== null && patch.length > GIT_MAX_PATCH_BYTES_PER_FILE) {
|
|
676
|
-
return { patch: null, patchOmitted: true, binary: false };
|
|
677
|
-
}
|
|
678
|
-
return { patch, patchOmitted: truncated, binary: false };
|
|
679
|
-
}
|
|
680
|
-
catch {
|
|
681
|
-
return empty;
|
|
682
|
-
}
|
|
683
|
-
};
|
|
684
|
-
const normalizeGraphqlPrState = (state) => {
|
|
685
|
-
if (state === "MERGED")
|
|
686
|
-
return "merged";
|
|
687
|
-
if (state === "CLOSED")
|
|
688
|
-
return "closed";
|
|
689
|
-
return "open";
|
|
690
|
-
};
|
|
691
|
-
const resolveGraphqlMergeable = (mergeable) => {
|
|
692
|
-
if (mergeable === "MERGEABLE")
|
|
693
|
-
return "mergeable";
|
|
694
|
-
if (mergeable === "CONFLICTING")
|
|
695
|
-
return "conflicting";
|
|
696
|
-
return "unknown";
|
|
697
|
-
};
|
|
698
|
-
// GraphQL `pullRequests(headRefName:)` filters by branch name across same-repo
|
|
699
|
-
// and fork PRs — the same semantics `gh pr list --head` uses. The REST
|
|
700
|
-
// `pulls.list` `head` param can't: `owner:branch` only matches fork PRs (0 for
|
|
701
|
-
// a same-repo PR), and a bare branch is silently ignored (returns all PRs). So
|
|
702
|
-
// a same-repo PR — the common case where you push to origin directly — was
|
|
703
|
-
// never detected. GraphQL also returns `mergeable` inline, so there is no
|
|
704
|
-
// per-PR detail round-trip.
|
|
705
|
-
const PR_LIST_QUERY = `
|
|
706
|
-
query($owner: String!, $repo: String!, $branch: String!, $perPage: Int!) {
|
|
707
|
-
repository(owner: $owner, name: $repo) {
|
|
708
|
-
pullRequests(
|
|
709
|
-
headRefName: $branch
|
|
710
|
-
states: [OPEN, CLOSED, MERGED]
|
|
711
|
-
first: $perPage
|
|
712
|
-
orderBy: { field: CREATED_AT, direction: DESC }
|
|
713
|
-
) {
|
|
714
|
-
nodes {
|
|
715
|
-
number
|
|
716
|
-
title
|
|
717
|
-
url
|
|
718
|
-
isDraft
|
|
719
|
-
mergedAt
|
|
720
|
-
mergeable
|
|
721
|
-
state
|
|
722
|
-
baseRefName
|
|
723
|
-
headRepositoryOwner { login }
|
|
724
|
-
baseRepository { nameWithOwner }
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
`;
|
|
730
|
-
const defaultPrFetcher = {
|
|
731
|
-
list: async (slug, branch, _state, perPage) => {
|
|
732
|
-
const token = await resolveGithubToken();
|
|
733
|
-
if (!token)
|
|
734
|
-
return [];
|
|
735
|
-
try {
|
|
736
|
-
const [owner, repo] = slug.split("/");
|
|
737
|
-
const octokit = new Octokit({ auth: token, request: { timeout: 8_000 } });
|
|
738
|
-
const data = await octokit.graphql(PR_LIST_QUERY, { owner, repo, branch, perPage });
|
|
739
|
-
const nodes = data?.repository?.pullRequests?.nodes ?? [];
|
|
740
|
-
return nodes.map((pr) => {
|
|
741
|
-
const state = normalizeGraphqlPrState(pr.state);
|
|
742
|
-
return {
|
|
743
|
-
number: pr.number,
|
|
744
|
-
title: pr.title ?? "",
|
|
745
|
-
baseRefName: pr.baseRefName ?? "",
|
|
746
|
-
url: pr.url ?? null,
|
|
747
|
-
state,
|
|
748
|
-
isDraft: pr.isDraft ?? false,
|
|
749
|
-
mergeable: state === "open" ? resolveGraphqlMergeable(pr.mergeable) : "unknown",
|
|
750
|
-
headOwner: pr.headRepositoryOwner?.login ?? null,
|
|
751
|
-
baseRepoFullName: pr.baseRepository?.nameWithOwner ?? null,
|
|
752
|
-
mergedAt: pr.mergedAt ?? null,
|
|
753
|
-
};
|
|
754
|
-
});
|
|
755
|
-
}
|
|
756
|
-
catch {
|
|
757
|
-
return [];
|
|
758
|
-
}
|
|
759
|
-
},
|
|
760
|
-
};
|
|
761
|
-
let activePrFetcher = defaultPrFetcher;
|
|
762
|
-
export const setPrFetcher = (fetcher) => {
|
|
763
|
-
activePrFetcher = fetcher;
|
|
764
|
-
};
|
|
765
|
-
const parseGithubRemotes = async (cwd) => {
|
|
766
|
-
const result = await runGit(cwd, ["remote", "-v"]);
|
|
767
|
-
if (result.exitCode !== 0)
|
|
768
|
-
return [];
|
|
769
|
-
const raw = [];
|
|
770
|
-
const seen = new Set();
|
|
771
|
-
for (const line of result.stdout.toString("utf8").split("\n")) {
|
|
772
|
-
// Partial/blobless clones annotate the fetch line with the filter spec,
|
|
773
|
-
// e.g. `origin\t<url> (fetch) [blob:none]`; a bare `(fetch)$` anchor misses
|
|
774
|
-
// it, so PR detection silently no-ops on partial clones.
|
|
775
|
-
const match = /^(\S+)\t(.+?)\s+\(fetch\)(?:\s+\[[^\]]*\])?$/.exec(line);
|
|
776
|
-
if (!match)
|
|
777
|
-
continue;
|
|
778
|
-
const [, name, url] = match;
|
|
779
|
-
if (seen.has(name))
|
|
780
|
-
continue;
|
|
781
|
-
seen.add(name);
|
|
782
|
-
const urlMatch = /github\.com[/:]([^\s/]+)\/([^\s]+?)(?:\.git)?$/i.exec(url);
|
|
783
|
-
if (!urlMatch)
|
|
784
|
-
continue;
|
|
785
|
-
const [, owner, repoName] = urlMatch;
|
|
786
|
-
raw.push({ name, slug: `${owner}/${repoName}`, owner });
|
|
787
|
-
}
|
|
788
|
-
return memoBy(raw, (remote) => `${remote.name} ${remote.slug}`);
|
|
789
|
-
};
|
|
790
|
-
// The wire type strips the internal-only fields (headOwner, baseRepoFullName)
|
|
791
|
-
// so they never reach the client.
|
|
792
|
-
const toWirePr = (pr) => ({
|
|
793
|
-
number: pr.number,
|
|
794
|
-
title: pr.title,
|
|
795
|
-
baseRefName: pr.baseRefName,
|
|
796
|
-
baseRef: pr.baseRef,
|
|
797
|
-
url: pr.url,
|
|
798
|
-
state: pr.state,
|
|
799
|
-
isDraft: pr.isDraft,
|
|
800
|
-
mergeable: pr.mergeable,
|
|
801
|
-
mergedAt: pr.mergedAt,
|
|
802
|
-
});
|
|
803
|
-
// detectPr returns the full ParsedPr (headOwner + baseRepoFullName retained)
|
|
804
|
-
// and caches it per (cwd, branch) so the diff path can resolve a fork PR's
|
|
805
|
-
// upstream base without a second GitHub round-trip. getGitBranchPr maps to the
|
|
806
|
-
// wire type; resolveEffectiveBaseRef reads the cache directly.
|
|
807
|
-
const detectPr = async (cwd) => {
|
|
808
|
-
const currentBranch = await getCurrentBranch(cwd);
|
|
809
|
-
if (!currentBranch)
|
|
810
|
-
return null;
|
|
811
|
-
const remotes = await parseGithubRemotes(cwd);
|
|
812
|
-
if (remotes.length === 0)
|
|
813
|
-
return null;
|
|
814
|
-
const ownRemote = remotes.find((remote) => remote.name === "origin") ?? remotes[0];
|
|
815
|
-
const ownOwner = ownRemote.owner.toLowerCase();
|
|
816
|
-
const slugs = memoBy(remotes, (remote) => remote.slug).map((remote) => remote.slug);
|
|
817
|
-
const results = await Promise.all(slugs.map((slug) => activePrFetcher.list(slug, currentBranch, "all", 30)));
|
|
818
|
-
const candidates = memoBy(results.flat().filter((pr) => pr.headOwner && pr.headOwner.toLowerCase() === ownOwner), (pr) => pr.url ?? `#${pr.number}`);
|
|
819
|
-
const openPr = candidates.find((pr) => pr.state === "open");
|
|
820
|
-
const chosenApi = openPr ?? candidates[0] ?? null;
|
|
821
|
-
// Resolve the comparison ref once here (mapping the PR's base repo to a local
|
|
822
|
-
// remote, fetching the upstream branch when it isn't local yet) so the diff
|
|
823
|
-
// path and the UI picker both read cachedPr.baseRef without re-resolving. A
|
|
824
|
-
// fork PR resolves to <upstream>/<baseRefName>; a same-repo PR (base repo is
|
|
825
|
-
// origin) to <origin>/<baseRefName> — automatic from the remote-slug match.
|
|
826
|
-
const chosen = chosenApi
|
|
827
|
-
? {
|
|
828
|
-
number: chosenApi.number,
|
|
829
|
-
title: chosenApi.title,
|
|
830
|
-
baseRefName: chosenApi.baseRefName,
|
|
831
|
-
url: chosenApi.url,
|
|
832
|
-
state: chosenApi.state,
|
|
833
|
-
isDraft: chosenApi.isDraft,
|
|
834
|
-
mergeable: chosenApi.mergeable,
|
|
835
|
-
baseRef: (await resolvePrBaseRef(cwd, chosenApi, remotes))?.ref ?? null,
|
|
836
|
-
headOwner: chosenApi.headOwner,
|
|
837
|
-
baseRepoFullName: chosenApi.baseRepoFullName,
|
|
838
|
-
mergedAt: chosenApi.mergedAt,
|
|
839
|
-
}
|
|
840
|
-
: null;
|
|
841
|
-
writePrCache(cwd, currentBranch, chosen);
|
|
842
|
-
return chosen;
|
|
843
|
-
};
|
|
844
|
-
// detectPr with per-(cwd, branch) in-flight dedup. Used by both getGitBranchPr
|
|
845
|
-
// (the client lease) and the diff path's cold-cache fallback so they never
|
|
846
|
-
// double-hit the GitHub API when they race.
|
|
847
|
-
const detectPrDeduped = async (cwd) => {
|
|
848
|
-
const currentBranch = await getCurrentBranch(cwd);
|
|
849
|
-
if (!currentBranch)
|
|
850
|
-
return null;
|
|
851
|
-
const key = `${cwd}\0${currentBranch}`;
|
|
852
|
-
const inflight = inflightDetectPr.get(key);
|
|
853
|
-
if (inflight)
|
|
854
|
-
return inflight;
|
|
855
|
-
const pending = detectPr(cwd).finally(() => {
|
|
856
|
-
inflightDetectPr.delete(key);
|
|
857
|
-
});
|
|
858
|
-
inflightDetectPr.set(key, pending);
|
|
859
|
-
return pending;
|
|
860
|
-
};
|
|
861
|
-
const resolvePrBaseRef = async (cwd, pr, remotes) => {
|
|
862
|
-
if (!pr.baseRepoFullName || !pr.baseRefName)
|
|
863
|
-
return null;
|
|
864
|
-
const baseRepo = pr.baseRepoFullName.toLowerCase();
|
|
865
|
-
const remote = remotes.find((candidate) => candidate.slug.toLowerCase() === baseRepo);
|
|
866
|
-
if (!remote)
|
|
867
|
-
return null;
|
|
868
|
-
const candidate = `${remote.name}/${pr.baseRefName}`;
|
|
869
|
-
if (await verifyRef(cwd, candidate))
|
|
870
|
-
return { ref: candidate, source: "pr" };
|
|
871
|
-
// Remote configured but its tracking ref isn't local — the common fork state:
|
|
872
|
-
// `upstream` added but never fetched, since the server only reads existing
|
|
873
|
-
// refs and never fetches on its own. Fetch just that one branch so the fork PR
|
|
874
|
-
// can diff against the upstream base instead of falling back to the fork's own
|
|
875
|
-
// (possibly drifted) default. Bounded by GIT_SPAWN_TIMEOUT_MS and
|
|
876
|
-
// GIT_TERMINAL_PROMPT=0, so a dead/slow/unauthenticated upstream degrades to the
|
|
877
|
-
// repo default below. Explicit refspec so the tracking ref is created/updated
|
|
878
|
-
// regardless of the remote's fetch refspec config (a bare `git fetch <remote>
|
|
879
|
-
// <branch>` only reliably writes FETCH_HEAD on older git).
|
|
880
|
-
await runGit(cwd, [
|
|
881
|
-
"fetch",
|
|
882
|
-
"--no-tags",
|
|
883
|
-
"--no-recurse-submodules",
|
|
884
|
-
"--",
|
|
885
|
-
remote.name,
|
|
886
|
-
`${pr.baseRefName}:refs/remotes/${remote.name}/${pr.baseRefName}`,
|
|
887
|
-
]);
|
|
888
|
-
return (await verifyRef(cwd, candidate)) ? { ref: candidate, source: "pr" } : null;
|
|
889
|
-
};
|
|
890
|
-
const listBranchesByRecency = async (cwd) => {
|
|
891
|
-
const result = await runGit(cwd, [
|
|
892
|
-
"for-each-ref",
|
|
893
|
-
"--format=%(refname:short)",
|
|
894
|
-
"--sort=-committerdate",
|
|
895
|
-
"refs/heads",
|
|
896
|
-
"refs/remotes",
|
|
897
|
-
]);
|
|
898
|
-
if (result.exitCode !== 0)
|
|
899
|
-
return [];
|
|
900
|
-
const names = [];
|
|
901
|
-
for (const name of result.stdout.toString("utf8").split("\n")) {
|
|
902
|
-
if (!name || name.endsWith("/HEAD"))
|
|
903
|
-
continue;
|
|
904
|
-
names.push(name);
|
|
905
|
-
if (names.length >= GIT_MAX_BRANCHES)
|
|
906
|
-
break;
|
|
907
|
-
}
|
|
908
|
-
return names;
|
|
909
|
-
};
|
|
910
|
-
export const listGithubRemoteSlugs = async (cwd) => {
|
|
911
|
-
const remotes = await parseGithubRemotes(cwd);
|
|
912
|
-
return memoBy(remotes, (remote) => remote.slug).map((remote) => remote.slug);
|
|
913
|
-
};
|
|
914
|
-
export const getGitBranchInfo = async (cwd) => {
|
|
915
|
-
if (!(await isGitRepo(cwd))) {
|
|
916
|
-
return {
|
|
917
|
-
isRepo: false,
|
|
918
|
-
currentBranch: null,
|
|
919
|
-
defaultBase: null,
|
|
920
|
-
defaultBaseSource: null,
|
|
921
|
-
branches: [],
|
|
922
|
-
pr: null,
|
|
923
|
-
};
|
|
924
|
-
}
|
|
925
|
-
const [currentBranch, defaultBase, branches] = await Promise.all([
|
|
926
|
-
getCurrentBranch(cwd),
|
|
927
|
-
resolveDefaultBase(cwd),
|
|
928
|
-
listBranchesByRecency(cwd),
|
|
929
|
-
]);
|
|
930
|
-
return {
|
|
931
|
-
isRepo: true,
|
|
932
|
-
currentBranch,
|
|
933
|
-
defaultBase: defaultBase?.ref ?? null,
|
|
934
|
-
defaultBaseSource: defaultBase?.source ?? null,
|
|
935
|
-
branches,
|
|
936
|
-
pr: null,
|
|
937
|
-
};
|
|
938
|
-
};
|
|
939
|
-
export const getGitBranchPr = async (cwd) => {
|
|
940
|
-
if (!(await isGitRepo(cwd)))
|
|
941
|
-
return null;
|
|
942
|
-
const detected = await detectPrDeduped(cwd);
|
|
943
|
-
return detected ? toWirePr(detected) : null;
|
|
944
|
-
};
|
|
1
|
+
export { getGitBranchInfo } from "./git-branch-metadata.js";
|
|
2
|
+
export { invalidateGitDiffCache } from "./git-diff-cache.js";
|
|
3
|
+
export { buildUntrackedPatch, parseNameStatusZ, parseNumstatZ, splitPatchByFile, } from "./git-diff-parser.js";
|
|
4
|
+
export { getGitDiff, getGitDiffFilePatch, getGitDiffFiles, getGitDiffSummary, } from "./git-diff-service.js";
|
|
5
|
+
export { getGitBranchPr, listGithubRemoteSlugs, setPrFetcher } from "./github-pr.js";
|
|
945
6
|
//# sourceMappingURL=git-diff.js.map
|