@monotykamary/localterm-server 1.26.0 → 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.d.ts +3 -13
- package/dist/git-diff.d.ts.map +1 -1
- package/dist/git-diff.js +528 -520
- package/dist/git-diff.js.map +1 -1
- package/package.json +2 -1
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 {
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
87
|
-
|
|
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
|
-
|
|
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
|
|
115
|
-
const
|
|
116
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
const
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
"
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
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
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
|
122
|
+
return resolveDiffBaseRef(r);
|
|
252
123
|
let baseRef = options.base?.trim() || null;
|
|
253
124
|
if (baseRef) {
|
|
254
|
-
|
|
125
|
+
try {
|
|
126
|
+
r.repo.revparseSingle(baseRef);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
255
129
|
baseRef = null;
|
|
130
|
+
}
|
|
256
131
|
}
|
|
257
132
|
if (!baseRef) {
|
|
258
|
-
const
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
373
|
+
const r = await openRepo(cwd);
|
|
374
|
+
if (!r)
|
|
424
375
|
return EMPTY_SUMMARY;
|
|
425
376
|
try {
|
|
426
|
-
const
|
|
427
|
-
if (
|
|
377
|
+
const baseRef = resolveEffectiveBaseRef(r, options);
|
|
378
|
+
if (baseRef === null)
|
|
428
379
|
return { ...EMPTY_SUMMARY, isRepo: true };
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
if (
|
|
442
|
-
binaries
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
451
|
+
const r = await openRepo(cwd);
|
|
452
|
+
if (!r)
|
|
531
453
|
return { isRepo: false, files: [] };
|
|
532
|
-
const
|
|
533
|
-
if (
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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 +=
|
|
533
|
+
totalPatchBytes += entry.patch.length;
|
|
617
534
|
}
|
|
618
535
|
}
|
|
619
|
-
files.push(
|
|
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
|
-
|
|
541
|
+
const r = await openRepo(cwd);
|
|
542
|
+
if (!r)
|
|
629
543
|
return { isRepo: false, files: [] };
|
|
630
|
-
const
|
|
631
|
-
if (
|
|
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 =
|
|
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:
|
|
551
|
+
status: entry.status,
|
|
645
552
|
additions: entry.additions,
|
|
646
553
|
deletions: entry.deletions,
|
|
647
554
|
binary: entry.binary,
|
|
648
555
|
}));
|
|
649
|
-
|
|
650
|
-
|
|
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:
|
|
559
|
+
path: file.path,
|
|
662
560
|
oldPath: null,
|
|
663
561
|
status: "untracked",
|
|
664
|
-
additions:
|
|
562
|
+
additions: file.binary ? 0 : file.lines,
|
|
665
563
|
deletions: 0,
|
|
666
|
-
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
|
-
|
|
571
|
+
const r = await openRepo(cwd);
|
|
572
|
+
if (!r)
|
|
678
573
|
return empty;
|
|
679
|
-
const
|
|
680
|
-
if (
|
|
574
|
+
const baseRef = resolveEffectiveBaseRef(r, options);
|
|
575
|
+
if (baseRef === null)
|
|
681
576
|
return empty;
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
715
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
if (!
|
|
735
|
-
return
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
]
|
|
790
|
-
const
|
|
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
|
};
|