dsh-github-flow 0.5.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 +16 -4
- package/README.md +16 -4
- package/docs/UPGRADE-NOTES.md +8 -8
- package/docs/architecture.md +33 -17
- package/docs/images/1.png +0 -0
- package/lib/api/routes-actions.js +139 -8
- 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 +7 -3
- package/lib/client.js +3309 -314
- package/lib/executor/error-classifier.d.ts +55 -0
- package/lib/executor/error-classifier.js +199 -0
- package/lib/executor/git-parser.d.ts +124 -0
- package/lib/executor/git-parser.js +397 -0
- package/lib/executor.d.ts +122 -4
- package/lib/executor.js +685 -80
- package/lib/tools/help.js +22 -7
- package/lib/tools/image-utils.d.ts +27 -0
- package/lib/tools/image-utils.js +73 -3
- package/lib/types.d.ts +331 -0
- package/lib/ui/help-modal.d.ts +6 -0
- package/lib/ui/help-modal.js +113 -15
- 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 +47 -0
- package/lib/ui/storage.js +139 -0
- package/lib/ui/styles.d.ts +1 -1
- package/lib/ui/styles.js +960 -0
- package/lib/ui/utils.js +13 -1
- package/lib/ui/workspace-panel.js +169 -27
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +1 -1
|
@@ -253,3 +253,400 @@ export function parseGitStatText(statText) {
|
|
|
253
253
|
return null;
|
|
254
254
|
return { files, summary };
|
|
255
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
|
+
}
|
package/lib/executor.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import type { GhExecutionOptions, GhResult, AuthStatus, RepoMetadata, PluginConfig, GitLocalStatus, GitCommitLog, GitCommitEntry, GitBranchesResult } from './types.js';
|
|
1
|
+
import type { GhExecutionOptions, GhResult, AuthStatus, RepoMetadata, PluginConfig, GitLocalStatus, GitCommitLog, GitCommitEntry, GitBranchesResult, AssociatedRepoItem, PrDetailData, IssueDetailData } from './types.js';
|
|
2
2
|
import type { ProxyManager } from './proxy.js';
|
|
3
3
|
import { parseGhVersion, compareGhVersions } from './executor/version-utils.js';
|
|
4
4
|
import { spawnWithStdin, safeSlice, cutByUtf8Bytes, truncateOutput } from './executor/process-utils.js';
|
|
5
|
-
import { parseGitStatusOutput, parseGitLogLines, parseGitBranchLines, parseUncommittedDiff, parseGitStatText } from './executor/git-parser.js';
|
|
5
|
+
import { parseGitStatusOutput, parseGitLogLines, parseGitBranchLines, parseUncommittedDiff, parseGitStatText, parseRemoteUrls, parseGitConfig, resolveRemoteTarget, parseGithubUrl, parseRemoteHost, classifyRepoLayout, pickGithubRemote, splitUnifiedDiff } from './executor/git-parser.js';
|
|
6
|
+
import type { ParsedGitConfig, ParsedRemote } from './executor/git-parser.js';
|
|
6
7
|
import { getPlatformInstallGuide, getGhUpgradeHint } from './executor/platform-guide.js';
|
|
7
|
-
|
|
8
|
+
import { classifyGhError, describeDeterministicError, isRetryableError, permissionRemedy, isPermissionError, type GhErrorKind } from './executor/error-classifier.js';
|
|
9
|
+
export { parseGhVersion, compareGhVersions, spawnWithStdin, safeSlice, cutByUtf8Bytes, truncateOutput, parseGitStatusOutput, parseGitLogLines, parseGitBranchLines, parseUncommittedDiff, parseGitStatText, parseRemoteUrls, parseGitConfig, resolveRemoteTarget, parseGithubUrl, parseRemoteHost, classifyRepoLayout, pickGithubRemote, splitUnifiedDiff, getPlatformInstallGuide, getGhUpgradeHint, classifyGhError, describeDeterministicError, isRetryableError, permissionRemedy, isPermissionError, };
|
|
10
|
+
export type { GhErrorKind, ParsedGitConfig, ParsedRemote };
|
|
8
11
|
export declare class GhExecutor {
|
|
9
12
|
ghPath: string;
|
|
10
13
|
defaultTimeoutMs: number;
|
|
@@ -182,6 +185,57 @@ export declare class GhExecutor {
|
|
|
182
185
|
currentBranch?: string;
|
|
183
186
|
error?: string;
|
|
184
187
|
}>;
|
|
188
|
+
/**
|
|
189
|
+
* 推导当前工作区可供用户切换的全部关联 GitHub 仓库列表。
|
|
190
|
+
*
|
|
191
|
+
* 数据来源(纯本地+拓扑合成,零新增网络开销):
|
|
192
|
+
* 1. 扫描本地 .git/config 的所有 GitHub remote(如 origin, upstream 等);
|
|
193
|
+
* 2. 若当前仓库为 Fork 且存在 parent(上游),合并上游仓库(支持本地未配 upstream 的情形);
|
|
194
|
+
* 3. 标识当前基准 (isCurrent) 及角色 (fork, upstream, origin, parent)。
|
|
195
|
+
*/
|
|
196
|
+
getAssociatedRepos(cwd?: string, currentRepo?: RepoMetadata | null, currentUser?: string | null): AssociatedRepoItem[];
|
|
197
|
+
/**
|
|
198
|
+
* 设置当前工作区的默认基准 GitHub 仓库(调用官方 `gh repo set-default` 并在本地 git config 持久化)。
|
|
199
|
+
*
|
|
200
|
+
* 为什么调用 gh repo set-default:
|
|
201
|
+
* 1. 它会在 .git/config 写入 `remote.<name>.gh-resolved = base`,这是官方的持久化事实源;
|
|
202
|
+
* 2. 执行后,不仅本插件的 Web 驾驶舱,外部系统终端中运行的 gh pr, gh issue, gh run 也将 100% 对齐;
|
|
203
|
+
* 3. 严格做参数格式校验,仅允许合法的 owner/repo 或合法 remote 别名,杜绝命令注入。
|
|
204
|
+
*/
|
|
205
|
+
setDefaultRepo(cwd: string | undefined, targetRepoOrRemote: string): Promise<{
|
|
206
|
+
ok: boolean;
|
|
207
|
+
target?: string;
|
|
208
|
+
error?: string;
|
|
209
|
+
}>;
|
|
210
|
+
/**
|
|
211
|
+
* 获取 Pull Request 完整详情与变更 Diff 数据(用于 PR 在线审阅与代码比对)。
|
|
212
|
+
*
|
|
213
|
+
* 架构设计:
|
|
214
|
+
* 1. 严格参数白名单校验(PR 编号必须为正整数,repo 必须为合法 owner/repo 格式);
|
|
215
|
+
* 2. 并行拉取元数据 (gh pr view) 与 Unified Diff (gh pr diff);
|
|
216
|
+
* 3. 在内存通过 splitUnifiedDiff 对 Diff 原文按修改文件切片索引,
|
|
217
|
+
* 前端点击任一文件即可 0ms 呈现代码行级增删对比,绝无二次网络往返。
|
|
218
|
+
*/
|
|
219
|
+
getPrDetail(cwd?: string, prNumber?: number | string, repo?: string): Promise<{
|
|
220
|
+
ok: boolean;
|
|
221
|
+
data?: PrDetailData;
|
|
222
|
+
error?: string;
|
|
223
|
+
}>;
|
|
224
|
+
/**
|
|
225
|
+
* 获取 Issue 完整详情(供 IssueDetailModal 在线查看消费)。
|
|
226
|
+
*
|
|
227
|
+
* 架构设计:
|
|
228
|
+
* 1. 严格参数白名单(编号必须为正整数,repo 必须为 owner/repo 形态)——与
|
|
229
|
+
* getPrDetail 同一套规则,杜绝任何形式的命令/路径注入;
|
|
230
|
+
* 2. 单次 `gh issue view --json` 往返即可拿到正文、评论时间线、里程碑与关联 PR,
|
|
231
|
+
* 不拆成多路并发(Issue 没有 diff 这类重载荷,多开进程反而增加失败面);
|
|
232
|
+
* 3. 老版本 gh 遇未知字段会整体失败,因此保留 BASE 字段集降级链。
|
|
233
|
+
*/
|
|
234
|
+
getIssueDetail(cwd?: string, issueNumber?: number | string, repo?: string): Promise<{
|
|
235
|
+
ok: boolean;
|
|
236
|
+
data?: IssueDetailData;
|
|
237
|
+
error?: string;
|
|
238
|
+
}>;
|
|
185
239
|
/** 生成当前操作系统平台的推荐安装引导与命令 */
|
|
186
240
|
private getPlatformInstallGuide;
|
|
187
241
|
/**
|
|
@@ -201,13 +255,77 @@ export declare class GhExecutor {
|
|
|
201
255
|
*/
|
|
202
256
|
private probeAuth;
|
|
203
257
|
/**
|
|
204
|
-
*
|
|
258
|
+
* 从 `refs/remotes/<remote>/HEAD` 符号引用中读取仓库默认分支。
|
|
259
|
+
*
|
|
260
|
+
* 为什么只认这一个来源(其余全部实测排除):
|
|
261
|
+
* - `packed-refs` 只存具体 sha,不含 symbolic ref —— 读不到;
|
|
262
|
+
* - 当前 HEAD 指向的是**用户 checkout 的分支**,不是仓库默认分支,语义错误;
|
|
263
|
+
* - 猜 main/master —— 无依据,宁可回落固定值也不猜。
|
|
264
|
+
*
|
|
265
|
+
* @returns 分支名;文件不存在(未 fetch 过)或内容不是符号引用时返回 null,
|
|
266
|
+
* 由调用方回落到既有默认值(保持原有行为,不引入新错误)
|
|
267
|
+
*/
|
|
268
|
+
readDefaultBranchFromRef(remoteName: string, cwd?: string): string | null;
|
|
269
|
+
/**
|
|
270
|
+
* 解析工作区真实的 git 目录的**两个**路径:局部目录与共享仓库目录。
|
|
271
|
+
*
|
|
272
|
+
* 为什么必须分开:worktree 的 `HEAD` 在局部目录(`.git/worktrees/<name>/HEAD`),
|
|
273
|
+
* 而 `config` 在共享目录(`commondir` 指向处)。只解析其中一个必然读错:
|
|
274
|
+
* - 只读局部 → 找不到 config(remote 配置在共享目录)
|
|
275
|
+
* - 只读共享 → 拿到**主工作树**的分支名,进而按错误分支查 `branch.*.remote`
|
|
276
|
+
*
|
|
277
|
+
* 实测:主工作树 HEAD 是 `ref: refs/heads/master`,worktree 的是 `ref: refs/heads/feat`。
|
|
278
|
+
*
|
|
279
|
+
* 常规仓库两者指向同一目录。
|
|
280
|
+
*
|
|
281
|
+
* @returns 两个绝对路径;非 Git 目录/指针不可解析时返回 null(绝不抛错)
|
|
282
|
+
*/
|
|
283
|
+
resolveGitDirInfo(cwd?: string): {
|
|
284
|
+
localDir: string;
|
|
285
|
+
commonDir: string;
|
|
286
|
+
} | null;
|
|
287
|
+
/**
|
|
288
|
+
* 解析工作区真实的 git 目录(共享仓库目录)。
|
|
289
|
+
* 薄封装自 resolveGitDirInfo:读 `config` 的场景(远程指纹)只需要共享目录。
|
|
290
|
+
*/
|
|
291
|
+
resolveGitDirPath(cwd?: string): string | null;
|
|
292
|
+
/**
|
|
293
|
+
* remote 配置指纹:全部 remote url 去重、排序、拼接后取 SHA-256 前 12 位。
|
|
294
|
+
*
|
|
295
|
+
* 为什么不用身份解析做缓存失效判定:身份解析要回答「变成了什么」,
|
|
296
|
+
* 一旦解析器对某种 config 布局有偏差(如双 remote 时取错 remote 段),
|
|
297
|
+
* 比对就永远失配——缓存再也不命中,每次请求都重拉全部 gh 子进程。
|
|
298
|
+
* 指纹只回答「变了没有」,不依赖任何语义理解,因此对该类偏差免疫。
|
|
299
|
+
*
|
|
300
|
+
* 反例(实测):`git remote rename origin upstream` 后 `git remote add origin <fork>`,
|
|
301
|
+
* config 里 upstream 段排在 origin 段前,取「第一个 github.com 匹配」会拿到上游,
|
|
302
|
+
* 而 git 权威解析(branch.<name>.remote → origin)拿到的是 fork。
|
|
303
|
+
*
|
|
304
|
+
* @returns 12 位十六进制指纹;仅当「非 Git 目录 / config 不可读」时返回 null
|
|
305
|
+
* (调用方据此保守判定为「未变更」,绝不因探测失败误清空缓存)。
|
|
306
|
+
* 注意:**config 可读但没有任何 remote 时返回的是空集合的哈希,不是 null**——
|
|
307
|
+
* 「仓库没有远程」是一个确定事实,它与「读不到配置」必须区分开:
|
|
308
|
+
* 否则用户删光 remote 后指纹恒为 null,旧快照再也无法失效。
|
|
309
|
+
*/
|
|
310
|
+
getRemoteFingerprint(cwd?: string): string | null;
|
|
311
|
+
/**
|
|
312
|
+
* 读取并解析工作区的 git 远程视图(`.git/config` + `HEAD`)。
|
|
313
|
+
* 内部方法:把「两个路径各读哪个文件」的细节收敛在一处,
|
|
314
|
+
* 避免各调用点自己拼路径读错 worktree 的文件。
|
|
315
|
+
*/
|
|
316
|
+
private readParsedGitConfig;
|
|
317
|
+
/**
|
|
318
|
+
* 仅从本地 `.git/config` 解析仓库身份,绝不发起网络请求。
|
|
205
319
|
*
|
|
206
320
|
* 为什么需要它:全局驾驶舱要为每个工作区认识别仓库,而 getRepoMetadata 会调用
|
|
207
321
|
* `gh repo view` 走网络。实测 5 个工作区时该端点冷启动 20.5 秒,其中绝大部分耗在这 5 次
|
|
208
322
|
* 网络往返上——而有界并发根本救不了它,因为瓶颈是网络延迟而不是进程排队。
|
|
209
323
|
* 工作区矩阵只需要「这个目录属于哪个仓库」这一身份信息,本地 .git/config 已经完整具备,
|
|
210
324
|
* 描述/star 数等富字段是当前仓库详情才需要的。这一步把每个工作区从 1 次网络往返降为 0。
|
|
325
|
+
*
|
|
326
|
+
* 解析口径与 git 一致(三级回落),不再「取 config 里第一个 github.com 匹配」——
|
|
327
|
+
* 后者在 `git remote rename origin upstream` + `add origin <fork>` 的布局下会取到上游,
|
|
328
|
+
* 而 git 认的是 origin(实测)。
|
|
211
329
|
*/
|
|
212
330
|
getRepoIdentityFromLocal(cwd?: string): RepoMetadata | null;
|
|
213
331
|
/**
|