dsh-github-flow 0.4.0 → 0.6.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/README.en.md +91 -12
- package/README.md +91 -12
- package/docs/UPGRADE-NOTES.md +9 -12
- package/docs/architecture.md +63 -29
- package/docs/images/1.png +0 -0
- package/lib/api/routes-actions.js +268 -5
- package/lib/api/routes-overview.js +147 -29
- package/lib/cache.d.ts +77 -4
- package/lib/cache.js +114 -7
- package/lib/client.d.ts +9 -4
- package/lib/client.js +5293 -942
- package/lib/commands/gh.d.ts +6 -1
- package/lib/commands/gh.js +144 -71
- package/lib/executor/error-classifier.d.ts +55 -0
- package/lib/executor/error-classifier.js +199 -0
- package/lib/executor/git-parser.d.ts +157 -1
- package/lib/executor/git-parser.js +513 -4
- package/lib/executor.d.ts +147 -4
- package/lib/executor.js +866 -77
- package/lib/main.js +28 -4
- package/lib/renderers/agent-task.d.ts +16 -0
- package/lib/renderers/agent-task.js +107 -0
- package/lib/renderers/codespace.d.ts +16 -0
- package/lib/renderers/codespace.js +103 -0
- package/lib/renderers/discussion.d.ts +16 -0
- package/lib/renderers/discussion.js +101 -0
- package/lib/renderers/markdown.d.ts +4 -0
- package/lib/renderers/markdown.js +4 -0
- package/lib/renderers/project.d.ts +20 -0
- package/lib/renderers/project.js +128 -0
- package/lib/renderers/skill.d.ts +30 -0
- package/lib/renderers/skill.js +180 -0
- package/lib/renderers/status.d.ts +5 -0
- package/lib/renderers/status.js +52 -0
- package/lib/telemetry.d.ts +70 -0
- package/lib/telemetry.js +161 -0
- package/lib/tools/agent-task.d.ts +54 -0
- package/lib/tools/agent-task.js +135 -0
- package/lib/tools/codespace.d.ts +51 -0
- package/lib/tools/codespace.js +154 -0
- package/lib/tools/discussion.d.ts +70 -0
- package/lib/tools/discussion.js +195 -0
- package/lib/tools/help.js +129 -14
- package/lib/tools/image-utils.d.ts +27 -0
- package/lib/tools/image-utils.js +73 -3
- package/lib/tools/project.d.ts +80 -0
- package/lib/tools/project.js +241 -0
- package/lib/tools/repo.js +13 -1
- package/lib/tools/skill.d.ts +24 -1
- package/lib/tools/skill.js +115 -5
- package/lib/tools/status.d.ts +3 -2
- package/lib/tools/status.js +9 -4
- package/lib/types.d.ts +336 -0
- package/lib/ui/commit-modal.js +144 -14
- package/lib/ui/help-modal.d.ts +21 -0
- package/lib/ui/help-modal.js +300 -0
- package/lib/ui/issue-modal.d.ts +14 -0
- package/lib/ui/issue-modal.js +366 -0
- package/lib/ui/markdown-lite.d.ts +66 -0
- package/lib/ui/markdown-lite.js +312 -0
- package/lib/ui/network-banner.d.ts +12 -0
- package/lib/ui/network-banner.js +35 -0
- package/lib/ui/pr-modal.d.ts +17 -0
- package/lib/ui/pr-modal.js +516 -0
- package/lib/ui/repo-badges.d.ts +43 -0
- package/lib/ui/repo-badges.js +82 -0
- package/lib/ui/repo-selector.d.ts +14 -0
- package/lib/ui/repo-selector.js +112 -0
- package/lib/ui/storage.d.ts +56 -0
- package/lib/ui/storage.js +163 -0
- package/lib/ui/styles.d.ts +1 -1
- package/lib/ui/styles.js +1219 -0
- package/lib/ui/sync-settings-modal.js +1 -1
- package/lib/ui/upgrade-card.js +39 -14
- package/lib/ui/utils.d.ts +35 -0
- package/lib/ui/utils.js +172 -0
- package/lib/ui/workspace-panel.js +198 -31
- package/lib/upgrade-manager.d.ts +5 -1
- package/lib/upgrade-manager.js +24 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +2 -2
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* `## master` / `## HEAD (no branch)` 无 upstream(ahead/behind 未知=-1)
|
|
9
9
|
* 后续每行 `<XY> <path>` 为 porcelain 变更项(XY 两字符状态码,?? = untracked)。
|
|
10
10
|
*/
|
|
11
|
-
export function parseGitStatusOutput(output) {
|
|
11
|
+
export function parseGitStatusOutput(output, maxFiles = 20) {
|
|
12
12
|
const lines = (output || '').split('\n').filter((l) => l.length > 0);
|
|
13
13
|
if (lines.length === 0 || !lines[0].startsWith('## '))
|
|
14
14
|
return null;
|
|
@@ -34,16 +34,20 @@ export function parseGitStatusOutput(output) {
|
|
|
34
34
|
// 仅 ahead 无 behind 时 behind=0(反之亦然)——git 输出只列非零侧
|
|
35
35
|
}
|
|
36
36
|
const dirtyFiles = [];
|
|
37
|
-
|
|
37
|
+
const limit = maxFiles > 0 ? maxFiles : Number.MAX_SAFE_INTEGER;
|
|
38
|
+
for (let i = 1; i < lines.length && dirtyFiles.length < limit; i++) {
|
|
38
39
|
const line = lines[i];
|
|
39
40
|
if (line.length < 4)
|
|
40
41
|
continue;
|
|
41
42
|
const status = line.slice(0, 2).trim() || 'M';
|
|
42
43
|
// 路径可能含空格(git 不加引号场景);rename 形态 `R old -> new` 取 new
|
|
43
|
-
let p = line.slice(3);
|
|
44
|
+
let p = line.slice(3).trim();
|
|
44
45
|
const renameArrow = p.indexOf(' -> ');
|
|
45
46
|
if (renameArrow >= 0)
|
|
46
|
-
p = p.slice(renameArrow + 4);
|
|
47
|
+
p = p.slice(renameArrow + 4).trim();
|
|
48
|
+
if (p.startsWith('"') && p.endsWith('"') && p.length >= 2) {
|
|
49
|
+
p = p.slice(1, -1);
|
|
50
|
+
}
|
|
47
51
|
dirtyFiles.push({ status, path: p });
|
|
48
52
|
}
|
|
49
53
|
return {
|
|
@@ -141,3 +145,508 @@ export function parseGitBranchLines(output) {
|
|
|
141
145
|
}
|
|
142
146
|
return { current, branches };
|
|
143
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* 解析 unified diff 输出,统计增删行数与区块数量。
|
|
150
|
+
* 忽略 diff 文件头(--- 和 +++),仅统计真正的改动行(+ 与 -)。
|
|
151
|
+
*/
|
|
152
|
+
export function parseUncommittedDiff(rawDiff) {
|
|
153
|
+
let additions = 0;
|
|
154
|
+
let deletions = 0;
|
|
155
|
+
let hunksCount = 0;
|
|
156
|
+
if (!rawDiff || typeof rawDiff !== 'string') {
|
|
157
|
+
return { additions, deletions, hunksCount };
|
|
158
|
+
}
|
|
159
|
+
const lines = rawDiff.split('\n');
|
|
160
|
+
for (const line of lines) {
|
|
161
|
+
if (line.startsWith('@@')) {
|
|
162
|
+
hunksCount++;
|
|
163
|
+
}
|
|
164
|
+
else if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
165
|
+
additions++;
|
|
166
|
+
}
|
|
167
|
+
else if (line.startsWith('-') && !line.startsWith('---')) {
|
|
168
|
+
deletions++;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { additions, deletions, hunksCount };
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* 解析 `git show --stat` 或 `git log --stat` 的统计文本为结构化列表与增删数字。
|
|
175
|
+
* 能够从 `19 ++++++++++++++++++-` 精准推导 additions=18, deletions=1,
|
|
176
|
+
* 提取文件路径与 `X files changed, Y insertions(+), Z deletions(-)` 汇总。
|
|
177
|
+
*/
|
|
178
|
+
export function parseGitStatText(statText) {
|
|
179
|
+
if (!statText || typeof statText !== 'string')
|
|
180
|
+
return null;
|
|
181
|
+
const lines = statText.split('\n');
|
|
182
|
+
const files = [];
|
|
183
|
+
let summary = null;
|
|
184
|
+
for (const line of lines) {
|
|
185
|
+
const trimmed = line.trim();
|
|
186
|
+
if (!trimmed)
|
|
187
|
+
continue;
|
|
188
|
+
// 检查汇总行(如 6 files changed, 62 insertions(+), 6 deletions(-))
|
|
189
|
+
const summaryMatch = trimmed.match(/(\d+)\s+files?\s+changed(?:,\s+(\d+)\s+insertions?\(\+\))?(?:,\s+(\d+)\s+deletions?\(-\))?/i);
|
|
190
|
+
if (summaryMatch) {
|
|
191
|
+
summary = {
|
|
192
|
+
filesChanged: parseInt(summaryMatch[1], 10),
|
|
193
|
+
insertions: parseInt(summaryMatch[2] || '0', 10),
|
|
194
|
+
deletions: parseInt(summaryMatch[3] || '0', 10),
|
|
195
|
+
};
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
// 检查文件变更行(包含 | 分隔符)
|
|
199
|
+
const pipeIdx = trimmed.indexOf('|');
|
|
200
|
+
if (pipeIdx > 0) {
|
|
201
|
+
let left = trimmed.slice(0, pipeIdx).trim();
|
|
202
|
+
const right = trimmed.slice(pipeIdx + 1).trim();
|
|
203
|
+
// 去除文件名两端可能的双引号
|
|
204
|
+
if (left.startsWith('"') && left.endsWith('"') && left.length >= 2) {
|
|
205
|
+
left = left.slice(1, -1);
|
|
206
|
+
}
|
|
207
|
+
if (/^Bin\b/i.test(right)) {
|
|
208
|
+
files.push({ path: left, total: 0, additions: 0, deletions: 0, isBinary: true, rawRight: right });
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
const numMatch = right.match(/^(\d+)(.*)$/);
|
|
212
|
+
if (numMatch) {
|
|
213
|
+
const total = parseInt(numMatch[1], 10);
|
|
214
|
+
const graph = numMatch[2].trim();
|
|
215
|
+
let plusCount = 0;
|
|
216
|
+
let minusCount = 0;
|
|
217
|
+
for (let i = 0; i < graph.length; i++) {
|
|
218
|
+
if (graph[i] === '+')
|
|
219
|
+
plusCount++;
|
|
220
|
+
else if (graph[i] === '-')
|
|
221
|
+
minusCount++;
|
|
222
|
+
}
|
|
223
|
+
let additions = 0;
|
|
224
|
+
let deletions = 0;
|
|
225
|
+
if (plusCount === 0 && minusCount === 0) {
|
|
226
|
+
additions = total;
|
|
227
|
+
}
|
|
228
|
+
else if (minusCount === 0) {
|
|
229
|
+
additions = total;
|
|
230
|
+
deletions = 0;
|
|
231
|
+
}
|
|
232
|
+
else if (plusCount === 0) {
|
|
233
|
+
additions = 0;
|
|
234
|
+
deletions = total;
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
if (plusCount + minusCount === total) {
|
|
238
|
+
additions = plusCount;
|
|
239
|
+
deletions = minusCount;
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
const addExact = Math.round((total * plusCount) / (plusCount + minusCount));
|
|
243
|
+
additions = addExact;
|
|
244
|
+
deletions = Math.max(0, total - addExact);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
files.push({ path: left, total, additions, deletions, isBinary: false });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (files.length === 0 && !summary)
|
|
253
|
+
return null;
|
|
254
|
+
return { files, summary };
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* 提取 `.git/config` 中所有 `[remote "..."]` 段内的 url。
|
|
258
|
+
*
|
|
259
|
+
* 为什么必须按段扫描而不是全文抓 `url = `:`[submodule "..."]` 段同样带 url
|
|
260
|
+
* (实测 `[submodule "lib"] url = https://github.com/x/y.git`)。全文正则会把
|
|
261
|
+
* 子模块的远程算进指纹,导致一次 `git submodule add` 就触发整个面板缓存的
|
|
262
|
+
* 无谓作废。子模块的 url 与本工作区关联哪个 GitHub 仓库毫无关系。
|
|
263
|
+
*
|
|
264
|
+
* 输出语义:保持文件中的出现顺序、已去重、不含空值。
|
|
265
|
+
* 排序交给调用方(指纹需要稳定顺序,但那是调用方的语义,纯函数不该替它决定)。
|
|
266
|
+
*/
|
|
267
|
+
export function parseRemoteUrls(configText) {
|
|
268
|
+
return parseGitConfig(configText).allUrls;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* 规范化 git 配置值:处理引号包裹与行尾注释。
|
|
272
|
+
*
|
|
273
|
+
* git 的实际规则(全部实测):
|
|
274
|
+
* - 未加引号:`;` 或 `#` 之后一律视为注释,**即使前面没有空格**
|
|
275
|
+
* `url = https://github.com/a/b.git ; note` → https://github.com/a/b.git
|
|
276
|
+
* `url = https://github.com/a/b.git;note` → https://github.com/a/b.git
|
|
277
|
+
* - 加引号:引号内的 `;` / `#` 是字面值,注释只能出现在闭合引号之后
|
|
278
|
+
* `url = "https://user:p#ss@github.com/a/b.git"` → 保留 #
|
|
279
|
+
* `url = "https://github.com/a/b.git" ; note` → 剥掉引号与注释
|
|
280
|
+
* - 引号内支持反斜杠转义
|
|
281
|
+
*
|
|
282
|
+
* 为什么必须处理:不剥注释会让 `url = https://github.com/a/b.git ; note`
|
|
283
|
+
* 解析失败(URL 尾部带注释文本),用户手写的 config 里这很常见。
|
|
284
|
+
*/
|
|
285
|
+
function unquoteValue(raw) {
|
|
286
|
+
const v = raw.trim();
|
|
287
|
+
if (!v)
|
|
288
|
+
return '';
|
|
289
|
+
// 引号形态:取引号内内容(支持 \" 转义),忽略闭合引号之后的一切
|
|
290
|
+
const q = v[0];
|
|
291
|
+
if (q === '"' || q === "'") {
|
|
292
|
+
let out = '';
|
|
293
|
+
for (let i = 1; i < v.length; i++) {
|
|
294
|
+
const ch = v[i];
|
|
295
|
+
if (ch === '\\' && i + 1 < v.length) {
|
|
296
|
+
out += v[i + 1];
|
|
297
|
+
i++;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (ch === q)
|
|
301
|
+
return out.trim();
|
|
302
|
+
out += ch;
|
|
303
|
+
}
|
|
304
|
+
// 引号未闭合:按未加引号处理(畸形配置,尽力而非报错)
|
|
305
|
+
return out.trim();
|
|
306
|
+
}
|
|
307
|
+
// 未加引号:首个 ; 或 # 起为注释
|
|
308
|
+
const cut = v.search(/[;#]/);
|
|
309
|
+
return (cut >= 0 ? v.slice(0, cut) : v).trim();
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* 解析 `.git/config` 与 `HEAD`,产出与 git 语义一致的远程视图。
|
|
313
|
+
*
|
|
314
|
+
* 为什么把 HEAD 一起解析:`branch.<name>.remote` 的 `<name>` 是**当前分支**,
|
|
315
|
+
* 不知道当前分支就无法按 git 的规则选出 remote,只能退化成「取第一个」——
|
|
316
|
+
* 那正是本次要修的错误。
|
|
317
|
+
*
|
|
318
|
+
* @param configText `.git/config` 全文
|
|
319
|
+
* @param headText HEAD 文件全文。worktree 场景必须传**局部 gitdir** 下的那个,
|
|
320
|
+
* 传共享目录的会拿到主工作树分支名(实测两者不同)。
|
|
321
|
+
*/
|
|
322
|
+
export function parseGitConfig(configText, headText) {
|
|
323
|
+
const remotes = [];
|
|
324
|
+
const allUrls = [];
|
|
325
|
+
const branchRemoteByName = new Map();
|
|
326
|
+
if (typeof configText === 'string' && configText) {
|
|
327
|
+
// 当前所处 section:'remote' / 'branch' / null(其他段)
|
|
328
|
+
let section = null;
|
|
329
|
+
let sectionName = '';
|
|
330
|
+
for (const rawLine of configText.split(/\r?\n/)) {
|
|
331
|
+
const line = rawLine.trim();
|
|
332
|
+
if (!line || line.startsWith('#') || line.startsWith(';'))
|
|
333
|
+
continue;
|
|
334
|
+
if (line.startsWith('[')) {
|
|
335
|
+
// `[remote "origin"]` / `[remote.origin]` 两种段头都认。
|
|
336
|
+
// 前瞻断言排除 `[remote-tracking]` 这类同前缀但语义无关的段
|
|
337
|
+
// (`\b` 做不到——`-` 是非单词字符,会被判为词边界)。
|
|
338
|
+
const quoted = /^\[\s*(remote|branch)\s+"([^"]*)"\s*\]/i.exec(line);
|
|
339
|
+
const dotted = /^\[\s*(remote|branch)\.([^\s\]]+)\s*\]/i.exec(line);
|
|
340
|
+
const m = quoted || dotted;
|
|
341
|
+
if (m) {
|
|
342
|
+
section = m[1].toLowerCase();
|
|
343
|
+
sectionName = m[2];
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
section = null;
|
|
347
|
+
sectionName = '';
|
|
348
|
+
}
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (!section)
|
|
352
|
+
continue;
|
|
353
|
+
if (section === 'remote') {
|
|
354
|
+
const mu = /^url\s*=\s*(.+)$/i.exec(line);
|
|
355
|
+
if (mu) {
|
|
356
|
+
const url = unquoteValue(mu[1]);
|
|
357
|
+
if (url) {
|
|
358
|
+
// 指纹收集全部 url 行(含同 remote 的第二行)
|
|
359
|
+
if (!allUrls.includes(url))
|
|
360
|
+
allUrls.push(url);
|
|
361
|
+
// 身份映射只保留首个:git 对同名 remote 的多 url 也只认第一个
|
|
362
|
+
if (!remotes.some((r) => r.name === sectionName)) {
|
|
363
|
+
remotes.push({ name: sectionName, url });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
// upstream 段写在前面时也要能读到——这里不做任何顺序假设
|
|
369
|
+
}
|
|
370
|
+
else if (section === 'branch') {
|
|
371
|
+
const mr = /^remote\s*=\s*(.+)$/i.exec(line);
|
|
372
|
+
if (mr) {
|
|
373
|
+
const value = unquoteValue(mr[1]);
|
|
374
|
+
if (value)
|
|
375
|
+
branchRemoteByName.set(sectionName, value);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// HEAD 形态:`ref: refs/heads/main` → main;40 位 sha(detached)→ null
|
|
381
|
+
let currentBranch = null;
|
|
382
|
+
if (typeof headText === 'string' && headText) {
|
|
383
|
+
const mh = /^\s*ref:\s*refs\/heads\/(.+?)\s*$/m.exec(headText);
|
|
384
|
+
if (mh)
|
|
385
|
+
currentBranch = mh[1];
|
|
386
|
+
}
|
|
387
|
+
const branchRemote = currentBranch ? branchRemoteByName.get(currentBranch) ?? null : null;
|
|
388
|
+
return { remotes, allUrls, currentBranch, branchRemote };
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* 按 git 的规则选出「这个工作区关联的 remote」。
|
|
392
|
+
*
|
|
393
|
+
* 优先级与 git 一致:
|
|
394
|
+
* 1. `branch.<当前分支>.remote` —— 当前分支显式跟踪的 remote
|
|
395
|
+
* 2. `origin` —— git 的默认远程
|
|
396
|
+
* 3. 第一个 remote —— 兜底(保持既有行为)
|
|
397
|
+
*
|
|
398
|
+
* 特殊值 `.` 表示跟踪**本地分支**而非远程(实测可设置),跳过第 1 级——
|
|
399
|
+
* 拿 "." 去查 `[remote "."]` 必然查不到,且语义上它根本不是远程。
|
|
400
|
+
*/
|
|
401
|
+
export function resolveRemoteTarget(cfg) {
|
|
402
|
+
const remotes = Array.isArray(cfg?.remotes) ? cfg.remotes : [];
|
|
403
|
+
if (remotes.length === 0)
|
|
404
|
+
return null;
|
|
405
|
+
const branchRemote = cfg?.branchRemote;
|
|
406
|
+
if (branchRemote && branchRemote !== '.') {
|
|
407
|
+
const hit = remotes.find((r) => r.name === branchRemote);
|
|
408
|
+
if (hit)
|
|
409
|
+
return hit;
|
|
410
|
+
}
|
|
411
|
+
const origin = remotes.find((r) => r.name === 'origin');
|
|
412
|
+
if (origin)
|
|
413
|
+
return origin;
|
|
414
|
+
return remotes[0];
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* 从 remote url 中提取 GitHub 的 owner/repo。
|
|
418
|
+
*
|
|
419
|
+
* 覆盖形态(均实测于真实 clone / hand-written config):
|
|
420
|
+
* https://github.com/o/r.git
|
|
421
|
+
* git@github.com:o/r.git
|
|
422
|
+
* ssh://git@github.com/o/r.git
|
|
423
|
+
* https://user:token@github.com/o/r.git
|
|
424
|
+
* 带尾斜杠 / 不带 .git
|
|
425
|
+
*
|
|
426
|
+
* 非 github.com 的 host 一律返回 null(GHE 不在支持范围)。
|
|
427
|
+
* 为什么绝不"猜一个":错误身份比没有身份更有害——面板会显示一个
|
|
428
|
+
* 用户根本不认识的仓库,且所有后续判断都建立在错误前提上。
|
|
429
|
+
* 正则两端锚定,杜绝 `github.com.evil.com` 这类子串误匹配。
|
|
430
|
+
*/
|
|
431
|
+
export function parseGithubUrl(url) {
|
|
432
|
+
if (typeof url !== 'string' || !url)
|
|
433
|
+
return null;
|
|
434
|
+
const m = /^(?:[a-z][a-z0-9+.-]*:\/\/)?(?:[^@/\s]+@)?github\.com[:/]+([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/i.exec(url.trim());
|
|
435
|
+
if (!m)
|
|
436
|
+
return null;
|
|
437
|
+
const owner = m[1];
|
|
438
|
+
const repo = m[2];
|
|
439
|
+
// 排除 `.` / `..` 这类目录穿越形态被当成仓库名
|
|
440
|
+
if (!owner || !repo || repo === '.' || repo === '..')
|
|
441
|
+
return null;
|
|
442
|
+
return { owner, repo };
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* 从 remote url 中提取 host(小写)。
|
|
446
|
+
*
|
|
447
|
+
* 覆盖:https://host/o/r、git@host:o/r、ssh://git@host:2222/o/r、带 token 的 https。
|
|
448
|
+
* 无法解析返回 null。
|
|
449
|
+
*
|
|
450
|
+
* 为什么不直接复用 parseGithubUrl:本函数要处理**任意 host**(GHE / GitLab),
|
|
451
|
+
* 用于判断「这个 remote 是不是 GitHub」,而不是「这个 remote 属于哪个 GitHub 仓库」。
|
|
452
|
+
*/
|
|
453
|
+
export function parseRemoteHost(url) {
|
|
454
|
+
if (typeof url !== 'string' || !url)
|
|
455
|
+
return null;
|
|
456
|
+
const raw = url.trim();
|
|
457
|
+
// 本地路径形态不是"远程 host",必须显式排除:
|
|
458
|
+
// file:///c/Users/me/repo → 否则会解析出 host "file"
|
|
459
|
+
// C:\local\repo → 否则会解析出 host "c"(盘符)
|
|
460
|
+
// 这两类的误判虽不影响 classifyRepoLayout(它只比对 'github.com'),
|
|
461
|
+
// 但违反本函数「无法解析返回 null」的契约,会误导后续调用方。
|
|
462
|
+
if (/^file:/i.test(raw))
|
|
463
|
+
return null;
|
|
464
|
+
if (/^[a-zA-Z]:[\\/]/.test(raw))
|
|
465
|
+
return null;
|
|
466
|
+
const m = /^(?:[a-z][a-z0-9+.-]*:\/\/)?(?:[^@/\s]+@)?([^/:\s]+)[:/]/i.exec(raw);
|
|
467
|
+
if (!m)
|
|
468
|
+
return null;
|
|
469
|
+
const host = m[1].toLowerCase();
|
|
470
|
+
// 单字母多半是盘符残留;含空格不可能出现在 host 位置
|
|
471
|
+
if (host.length < 2 || host === '.' || host.includes(' '))
|
|
472
|
+
return null;
|
|
473
|
+
return host;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* 判断工作区处于哪种远程布局。
|
|
477
|
+
*
|
|
478
|
+
* 为什么需要分类而不是顺序盲扫:S2 的兜底逻辑按 `.git/config` 的书写顺序
|
|
479
|
+
* 挑第一个能解析出 GitHub 身份的 remote,而用户执行 `git remote rename` /
|
|
480
|
+
* `git remote add` 的先后会改变这个顺序——同一个工作区在两次配置操作后
|
|
481
|
+
* 可能解析出不同仓库。分类后每种布局的处置规则固定,与顺序无关。
|
|
482
|
+
*
|
|
483
|
+
* 分类只看 host 与 git 选中的 remote,不看 URL 的仓库名——后者是身份层的事。
|
|
484
|
+
*/
|
|
485
|
+
export function classifyRepoLayout(cfg) {
|
|
486
|
+
const remotes = Array.isArray(cfg?.remotes) ? cfg.remotes : [];
|
|
487
|
+
if (remotes.length === 0)
|
|
488
|
+
return 'no-remote';
|
|
489
|
+
const isGithub = (u) => parseRemoteHost(u) === 'github.com';
|
|
490
|
+
const githubRemotes = remotes.filter((r) => isGithub(r.url));
|
|
491
|
+
if (githubRemotes.length === 0)
|
|
492
|
+
return 'no-github';
|
|
493
|
+
if (githubRemotes.length === remotes.length)
|
|
494
|
+
return 'github-only';
|
|
495
|
+
return 'github-mixed';
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* 在混合布局中确定性地选出一个 GitHub remote。
|
|
499
|
+
*
|
|
500
|
+
* 规则(与书写顺序无关):
|
|
501
|
+
* 1. 名字为 `origin` 的优先
|
|
502
|
+
* 2. 否则按 remote 名字字典序最小的
|
|
503
|
+
*
|
|
504
|
+
* 为什么用字典序而不是「第一个出现的」:后者让同一个工作区的解析结果
|
|
505
|
+
* 依赖用户执行 git remote 命令的先后,属于不可复现的行为。
|
|
506
|
+
* 字典序是可复现、可断言的确定性规则。
|
|
507
|
+
*/
|
|
508
|
+
export function pickGithubRemote(cfg) {
|
|
509
|
+
const remotes = Array.isArray(cfg?.remotes) ? cfg.remotes : [];
|
|
510
|
+
const github = remotes.filter((r) => parseRemoteHost(r.url) === 'github.com');
|
|
511
|
+
if (github.length === 0)
|
|
512
|
+
return null;
|
|
513
|
+
const origin = github.find((r) => r.name === 'origin');
|
|
514
|
+
if (origin)
|
|
515
|
+
return origin;
|
|
516
|
+
return [...github].sort((a, b) => a.name.localeCompare(b.name))[0];
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* 解码 git 的 C 风格路径转义(`core.quotePath=true` 时的输出形态)。
|
|
520
|
+
*
|
|
521
|
+
* 为什么必须解码:git 的 `core.quotePath` **默认就是 true**,此时非 ASCII 路径
|
|
522
|
+
* (中文/日文/emoji)会被输出成 `"\344\270\255\346\226\207.txt"` 这种八进制转义。
|
|
523
|
+
* 而 `gh pr view --json files` 返回的是**真实 UTF-8 路径**——两者不一致会导致
|
|
524
|
+
* 点击文件时按真实路径查不到 diff,界面显示空白,看起来像"这个文件没改动"。
|
|
525
|
+
*
|
|
526
|
+
* 只在后端运行(git-parser 仅被 executor 引用),可安全使用 Buffer 做字节重组。
|
|
527
|
+
*/
|
|
528
|
+
function unquoteGitPath(raw) {
|
|
529
|
+
const quoted = raw.trim();
|
|
530
|
+
if (!(quoted.startsWith('"') && quoted.endsWith('"')) || quoted.length < 2)
|
|
531
|
+
return quoted;
|
|
532
|
+
const inner = quoted.slice(1, -1);
|
|
533
|
+
const bytes = [];
|
|
534
|
+
const simpleEscapes = {
|
|
535
|
+
n: 10, t: 9, r: 13, '"': 34, '\\': 92, a: 7, b: 8, f: 12, v: 11,
|
|
536
|
+
};
|
|
537
|
+
for (let i = 0; i < inner.length; i++) {
|
|
538
|
+
const ch = inner[i];
|
|
539
|
+
if (ch === '\\' && i + 1 < inner.length) {
|
|
540
|
+
const next = inner[i + 1];
|
|
541
|
+
// 八进制转义:\NNN(git 对非 ASCII 字节统一用 3 位八进制)
|
|
542
|
+
if (next >= '0' && next <= '7') {
|
|
543
|
+
const oct = inner.slice(i + 1, i + 4);
|
|
544
|
+
if (/^[0-7]{3}$/.test(oct)) {
|
|
545
|
+
bytes.push(parseInt(oct, 8));
|
|
546
|
+
i += 3;
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (next in simpleEscapes) {
|
|
551
|
+
bytes.push(simpleEscapes[next]);
|
|
552
|
+
i += 1;
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
bytes.push(ch.charCodeAt(0));
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
// 普通字符:按其 UTF-8 字节序列入栈(保证与八进制字节混排后可整体还原)
|
|
559
|
+
for (const b of Buffer.from(ch, 'utf8'))
|
|
560
|
+
bytes.push(b);
|
|
561
|
+
}
|
|
562
|
+
return Buffer.from(bytes).toString('utf8');
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* 从 `diff --git <src> <dst>` 行中解析出源路径与目标路径。
|
|
566
|
+
*
|
|
567
|
+
* 两种形态都要支持:
|
|
568
|
+
* 1. 未加引号:`diff --git a/src/app.ts b/src/app.ts`
|
|
569
|
+
* 2. 加引号:`diff --git "a/\344\270\255.txt" "b/\344\270\255.txt"`
|
|
570
|
+
* —— core.quotePath=true(git 默认值)下的非 ASCII 路径形态,需转义还原。
|
|
571
|
+
*
|
|
572
|
+
* 未加引号形态的分割点判定为什么不能用「第一个」或「最后一个 ` b/`」:
|
|
573
|
+
* 路径本身就可能含 ` b/` 子串(如 `diff --git a/x b/y.ts b/x b/y.ts`)。
|
|
574
|
+
* 非贪婪 → 在第一个 ` b/` 截断,得到 src=`x` / dst=`y.ts b/x b/y.ts`(错)
|
|
575
|
+
* 贪婪 → 在最后一个 ` b/` 截断,得到 src=`x b/y.ts b/x` / dst=`y.ts`(也错)
|
|
576
|
+
* 正确做法:**枚举所有 ` b/` 分割点,优先选让两侧路径相等的那个**——
|
|
577
|
+
* 因为除重命名外 git 的 src 与 dst 本就同路径。重命名场景退化为取首个分割点。
|
|
578
|
+
*/
|
|
579
|
+
function parseDiffGitHeader(line) {
|
|
580
|
+
const body = line.replace(/^diff --git\s+/, '').trim();
|
|
581
|
+
if (!body)
|
|
582
|
+
return null;
|
|
583
|
+
// 形态 2:两侧均为带引号字符串
|
|
584
|
+
if (body.startsWith('"')) {
|
|
585
|
+
const m = /^("(?:[^"\\]|\\.)*")\s+("(?:[^"\\]|\\.)*")$/.exec(body);
|
|
586
|
+
if (!m)
|
|
587
|
+
return null;
|
|
588
|
+
return { src: unquoteGitPath(m[1]), dst: unquoteGitPath(m[2]) };
|
|
589
|
+
}
|
|
590
|
+
// 形态 1:逐分割点尝试,优先两侧等值
|
|
591
|
+
if (!body.startsWith('a/'))
|
|
592
|
+
return null;
|
|
593
|
+
const rest = body.slice(2);
|
|
594
|
+
const positions = [];
|
|
595
|
+
let idx = rest.indexOf(' b/');
|
|
596
|
+
while (idx >= 0) {
|
|
597
|
+
positions.push(idx);
|
|
598
|
+
idx = rest.indexOf(' b/', idx + 1);
|
|
599
|
+
}
|
|
600
|
+
if (positions.length === 0)
|
|
601
|
+
return null;
|
|
602
|
+
for (const p of positions) {
|
|
603
|
+
const srcPart = rest.slice(0, p);
|
|
604
|
+
const dstPart = rest.slice(p + 1); // 跳过空格,保留 "b/..." 前缀
|
|
605
|
+
if (dstPart.startsWith('b/') && dstPart.slice(2) === srcPart) {
|
|
606
|
+
return { src: `a/${srcPart}`, dst: dstPart };
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
// 重命名等两侧路径确实不同的场景:取首个分割点
|
|
610
|
+
const p = positions[0];
|
|
611
|
+
return { src: `a/${rest.slice(0, p)}`, dst: rest.slice(p + 1) };
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* 将整段 Git Unified Diff 纯文本按变动文件切分为字典映射。
|
|
615
|
+
*
|
|
616
|
+
* 为什么需要按文件切片:PR 可能会修改数十个文件,前端需要支持「点击某个文件即刻查看该文件改动」。
|
|
617
|
+
* 在内存做切片索引后,切换文件无需发起网络请求(0ms 响应),体验极致流畅。
|
|
618
|
+
*
|
|
619
|
+
* 键必须与 `gh pr view --json files` 返回的 `files[].path` **逐字一致**,
|
|
620
|
+
* 否则点击文件查不到 diff。因此需要处理 core.quotePath 的八进制转义。
|
|
621
|
+
*
|
|
622
|
+
* @param rawDiff gh pr diff 或 git diff 输出的原始文本
|
|
623
|
+
* @returns 映射字典:{ [filePath: string]: string (该文件的完整 diff 文本段) }
|
|
624
|
+
*/
|
|
625
|
+
export function splitUnifiedDiff(rawDiff) {
|
|
626
|
+
const fileDiffs = {};
|
|
627
|
+
if (typeof rawDiff !== 'string' || !rawDiff)
|
|
628
|
+
return fileDiffs;
|
|
629
|
+
// 使用正向前瞻匹配每一个 `diff --git ` 开头的分界线
|
|
630
|
+
const chunks = rawDiff.split(/(?=^diff --git )/m);
|
|
631
|
+
for (const chunk of chunks) {
|
|
632
|
+
const trimmed = chunk.trim();
|
|
633
|
+
if (!trimmed.startsWith('diff --git'))
|
|
634
|
+
continue;
|
|
635
|
+
const firstLine = trimmed.split(/\r?\n/, 1)[0] || '';
|
|
636
|
+
const parsed = parseDiffGitHeader(firstLine);
|
|
637
|
+
if (!parsed)
|
|
638
|
+
continue;
|
|
639
|
+
const { src, dst } = parsed;
|
|
640
|
+
// 统一剥掉 a/ 与 b/ 前缀,得到仓库内的相对路径
|
|
641
|
+
const srcPath = src.replace(/^[ab]\//, '');
|
|
642
|
+
const dstPath = dst.replace(/^[ab]\//, '');
|
|
643
|
+
// 删除文件时目标侧是 /dev/null,必须回退用源路径作为键,
|
|
644
|
+
// 否则该文件的 diff 会被挂到一个不存在的 "/dev/null" 键上,永远查不到。
|
|
645
|
+
const isDeletion = dstPath === 'dev/null' || dstPath === '/dev/null';
|
|
646
|
+
const targetPath = isDeletion ? srcPath : dstPath;
|
|
647
|
+
if (targetPath) {
|
|
648
|
+
fileDiffs[targetPath] = trimmed;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return fileDiffs;
|
|
652
|
+
}
|