From 2949a76caa22d59f3921172b4b487b1365c43e66 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 17 Oct 2026 17:38:42 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(Branches):=20=E4=BF=AE=E5=A4=8D=20Delet?= =?UTF-8?q?e=20Remote=20Branch=20=E5=AF=B9=E9=99=88=E6=97=A7=E8=BF=9C?= =?UTF-8?q?=E7=A8=8B=E5=88=86=E6=94=AF=E8=AF=AF=E6=8A=A5=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E4=B8=94=E5=90=9E=E6=8E=89=E7=9C=9F=E5=AE=9E=E5=8E=9F=E5=9B=A0?= =?UTF-8?q?;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 服务端已无此 ref(PR 合并后远端自动删除、本地 tracking ref 未 prune)时, push --delete 被拒的「remote ref does not exist」不再误报失败——按目标态已 达成收尾,并把本地陈旧 tracking ref 纳入 branch -D -r 清理; - 真失败(保护分支拒绝/认证/网络等)经 stderr 优先提取原因,通知中逐分支附上 真实报错并联动 Show Output,替换原先 catch{} 吞错后的笼统「N failed」; - engine 纯逻辑新增 isRemoteRefMissingError / formatRemoteDeleteResult (零 vscode 依赖),新增 18 个单测覆盖分类与三桶汇总。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- CHANGELOG.md | 4 ++ docs/.agents/issue.md | 8 +++ src/adapter/remote-commands.ts | 47 +++++++++----- src/engine/ref/remote-ref.ts | 69 ++++++++++++++++++++ tests/unit/ref-remote.test.ts | 112 ++++++++++++++++++++++++++++++++- 5 files changed, 225 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 648b51b..2494337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ## [Unreleased] +### Fixed + +- **Branches 视图「Delete Remote Branch」对陈旧远程分支误报失败且无原因**:PR 合并后远端自动删除的分支,本地 remote-tracking ref 在下次 `fetch --prune` 前一直保留,视图仍列出;右键删除时 `git push --delete` 被服务端拒绝(`remote ref does not exist`),且 `catch {}` 吞掉真实报错,只回显笼统的「N failed」。现在「服务端已无此 ref」被识别为目标态已达成——按成功收尾并清理本地陈旧 tracking ref(通知如实标注 *already gone on remote*);真失败(保护分支拒绝 / 认证 / 网络)则经 stderr 优先提取,在通知中逐分支附上真实原因,并联动 Show Output 查看完整输出。engine 纯逻辑 `isRemoteRefMissingError` / `formatRemoteDeleteResult` 落于 `engine/ref/remote-ref.ts`,全量单测覆盖。 + ## [0.0.19] - 2026-10-17 — Branches 视图 PR 状态徽标 · 取数安全加固 · 开发依赖升级 ### Fixed diff --git a/docs/.agents/issue.md b/docs/.agents/issue.md index d1e3284..9ec450a 100644 --- a/docs/.agents/issue.md +++ b/docs/.agents/issue.md @@ -164,3 +164,11 @@ - **处理方式**:webview state v3 在 `byRepo` 分区新增 `draft`(message + 三勾选);输入/勾选变更即时 `saveState()` 落盘(消除 debounce 尾丢);回灌仅限「重建首帧/切换仓库」两时机且先于 `reconcileChecked`(避免 state 推送往返覆盖当前编辑);提交成功清空草稿。未采用 `retainContextWhenHidden`(钉死整个 webview 内存,且不跨窗口 reload)。 - **后续防范**:webview 内用户编辑态一律显式入 `setState`(含 debounce 之外的即时落盘点);回灌逻辑必须有时机守卫(一次性/切换触发),并注意「先回灌 DOM、后执行任何从 DOM 取数回写 state」的顺序。 - **同类问题影响**:所有自绘 WebviewView 承载编辑器的扩展;凡输入经防抖上报宿主的模式都存在尾丢窗口。 + +## #19 Branches 视图 Delete Remote Branch 对陈旧远程分支删除失败且无原因提示 + +- **表因**:右键删除 `origin/rsi/fixture-terminology` 等远程分支直接报 `Deleted 0 remote branches, 1 failed: …`,无任何失败原因,此类分支「经常」删不掉。 +- **根因**:复合缺陷——① PR 合并后 GitHub 自动删除远端分支,本地 `refs/remotes/origin/...` 在下一次 `fetch --prune` 前一直陈旧,Branches 视图(数据源 `repo.state.refs`)仍列出它;② 对陈旧 ref 执行 `git push origin --delete ` 被服务端拒绝(git 2.54 实测 stderr:`error: unable to delete 'x': remote ref does not exist`);③ `remote-commands.ts` 删除循环的 `catch {}` 把 execGit 已透传挂载的 stderr 整个吞掉,最终只回显笼统的「N failed」,不可诊断。即「目标态已达成却按失败收尾」+「真失败也无原因」两个 Bug 叠加。 +- **处理方式**:删除循环改三桶(成功 / 服务端已无此 ref / 真失败)——「服务端已无此 ref」经 engine 纯逻辑 `isRemoteRefMissingError`(stderr 优先,匹配 `remote ref does not exist` / `delete of non-existent ref` 变体)识别,按达成收尾并把本地陈旧 tracking ref 纳入既有 `branch -D -r` 清理;真失败经 stderr 优先的 errMsg 提取原因,由 `formatRemoteDeleteResult` 汇总为通知(失败走 `showGitError`,联动 Show Output 看完整 stderr)。确认弹窗/受保护主干硬阻断/上游软警示逻辑不动。 +- **后续防范**:① 批量操作的 `catch` 必须保留可行动的失败原因(execGit 拒绝值已挂 `.stderr`,消费即可),`grep -rn 'catch {' src/adapter/` 应逐个确认非吞错;② remote-tracking ref 与服务端存在时滞,任何「对远程分支的操作」都要考虑「本地有、服务端无」的陈旧态——删除/比较/PR 匹配等场景遇到 `does not exist` 类报错应先判陈旧再判失败;③ 视图列表的「幽灵分支」根治靠 `fetch --prune`(Prune Remotes 命令已有),删除路径的容错只是补救。 +- **同类问题影响**:所有列出 remote-tracking ref 并提供远程删除的 Git GUI;凡把服务端拒绝一概当失败、不区分「目标态已达成」的实现,以及任何丢弃 stderr 的批量 CLI 包装。 diff --git a/src/adapter/remote-commands.ts b/src/adapter/remote-commands.ts index 0c31fb7..ecb06c6 100644 --- a/src/adapter/remote-commands.ts +++ b/src/adapter/remote-commands.ts @@ -8,13 +8,28 @@ import type { LogFilterControl } from './webview/log-webview'; import { truncateNames } from '../engine/ref/cleanup'; import { formatRemoteDeleteConfirm, + formatRemoteDeleteResult, + isRemoteRefMissingError, partitionRemoteByProtected, resolveRemoteBranch, type RemoteBranchTarget, + type RemoteDeleteFailure, } from '../engine/ref/remote-ref'; import type { GitRepositoryService } from './git-repository-service'; -const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); +// 与 history-commands.ts 同款:CLI 通道失败的真实原因在透传的 .stderr,优先暴露(vscode.git GitError 同理)。 +const errMsg = (e: unknown): string => { + if (e && typeof e === 'object') { + const ge = e as { stderr?: unknown; message?: unknown }; + if (typeof ge.stderr === 'string' && ge.stderr.trim()) { + return ge.stderr.trim(); + } + if (typeof ge.message === 'string' && ge.message) { + return ge.message; + } + } + return String(e); +}; /** * 注册远程同步对话框(Phase 3):Push 对话框 / Update Project / Merge 对话框。 @@ -206,19 +221,26 @@ export function registerRemoteCommands( if (choice !== confirmLabel) { return; } - // 逐分支推删,收集失败(不调用 handleGitConflict——删除不产生合并冲突)。 - const failures: string[] = []; + // 逐分支推删,三桶收集(不调用 handleGitConflict——删除不产生合并冲突): + // 成功 / 服务端已无此 ref(陈旧 tracking ref 场景,目标态已达成按成功收尾)/ 真失败(保留原因)。 + const failures: RemoteDeleteFailure[] = []; const succeeded: RemoteBranchTarget[] = []; + const alreadyGone: RemoteBranchTarget[] = []; for (const t of deletable) { try { await service.execGit(['push', t.remote, '--delete', t.branch]); succeeded.push(t); - } catch { - failures.push(t.shortName); + } catch (e) { + if (isRemoteRefMissingError(e)) { + alreadyGone.push(t); + } else { + failures.push({ name: t.shortName, reason: errMsg(e) }); + } } } - // 仅对服务端删除成功项清理本地 remote-tracking ref(失败项保留,待重试或下次 fetch --prune)。 - for (const t of succeeded) { + // 对服务端已无此分支的全部目标(含 alreadyGone 的陈旧项)清理本地 remote-tracking ref; + // 真失败项保留,待重试或下次 fetch --prune。 + for (const t of [...succeeded, ...alreadyGone]) { try { await service.execGit(['branch', '-D', '-r', `${t.remote}/${t.branch}`]); } catch { @@ -227,14 +249,11 @@ export function registerRemoteCommands( } branchesTree.refresh(); logTree.refresh(); - if (failures.length === 0) { - void vscode.window.showInformationMessage( - succeeded.length === 1 ? `Deleted remote branch ${succeeded[0].shortName}` : `Deleted ${succeeded.length} remote branches`, - ); + const result = formatRemoteDeleteResult(succeeded, alreadyGone, failures); + if (result.level === 'info') { + void vscode.window.showInformationMessage(result.message); } else { - void vscode.window.showWarningMessage( - `Deleted ${succeeded.length} remote branches, ${failures.length} failed: ${truncateNames(failures)}`, - ); + void showGitError(result.message); } }), ); diff --git a/src/engine/ref/remote-ref.ts b/src/engine/ref/remote-ref.ts index 94974f9..e72bc95 100644 --- a/src/engine/ref/remote-ref.ts +++ b/src/engine/ref/remote-ref.ts @@ -8,6 +8,8 @@ * 朴素 `split('/')[0]` 会把 `myorg/repo/feature` 错切成 `myorg`。 * - {@link partitionRemoteByProtected}:复用 {@link isProtectedBranch},把 main/master 等主干硬排除。 * - {@link formatRemoteDeleteConfirm}:生成 modal 确认文案,诚实传达服务器端不可逆 + 协作者影响。 + * - {@link isRemoteRefMissingError}:识别「服务端已无此 ref」的 push 失败(本地 tracking ref 陈旧场景)。 + * - {@link formatRemoteDeleteResult}:把逐分支删除结果三桶汇总为通知文案与级别。 * * 与 cleanup.ts 的关系:复用其 `isProtectedBranch` / `truncateNames` 作为单一事实源(轻量指针,不复制定义)。 */ @@ -94,3 +96,70 @@ export function formatRemoteDeleteConfirm( : `Delete ${n} remote branches: ${truncateNames(deletable.map((t) => t.shortName))}.\n${irreversible}`; return { detail: `${head}${body}`, confirmLabel: 'Delete' }; } + +/** 单条远程删除失败:分支短名 + 从 stderr/message 提取的原因。 */ +export interface RemoteDeleteFailure { + /** 远程分支短名,如 origin/foo。 */ + readonly name: string; + /** 失败原因(stderr 优先),可能多行。 */ + readonly reason: string; +} + +/** 「服务端已无此 ref」的报错文案(大小写不敏感)。git 2.x 规范文案 + 旧服务端变体。 */ +const REMOTE_REF_MISSING_PATTERNS: readonly RegExp[] = [/remote ref does not exist/i, /delete of non-existent ref/i]; + +/** + * 判定 `git push --delete ` 的失败是否属「服务端已无此 ref」。 + * + * 典型场景:PR 合并后远端自动删除分支,本地 remote-tracking ref 在下一次 `fetch --prune` + * 前一直陈旧——视图仍列出它,push --delete 却被服务端拒绝 + * (`error: unable to delete 'x': remote ref does not exist`)。此分支的目标态(服务端无此分支) + * 已天然达成,应按成功收尾并清理本地陈旧 ref,而非误报失败。 + * + * @param e execGit 的拒绝值:stderr 已透传挂载(优先),无 stderr 时回退 message。 + */ +export function isRemoteRefMissingError(e: unknown): boolean { + let text = ''; + if (e && typeof e === 'object') { + const ge = e as { stderr?: unknown; message?: unknown }; + if (typeof ge.stderr === 'string') { + text = ge.stderr; + } else if (typeof ge.message === 'string') { + text = ge.message; + } + } + return text.length > 0 && REMOTE_REF_MISSING_PATTERNS.some((re) => re.test(text)); +} + +/** 失败原因折叠为单行(换行→'; '),并截断总长,避免通知气泡过长。 */ +function collapseReason(reason: string, max = 160): string { + const line = reason.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).join('; '); + return line.length > max ? `${line.slice(0, max - 1)}…` : line; +} + +/** 通知级别:仅成功/已不存在走 info;存在真失败走 error(联动 Show Output)。 */ +export type RemoteDeleteResultLevel = 'info' | 'error'; + +/** + * 把逐分支远程删除结果(成功 / 服务端已不存在 / 失败)三桶汇总为通知文案与级别(纯逻辑)。 + * + * 诚实计数:alreadyGone 单列(服务端本就无此分支,非本次删除),失败项附真实原因。 + * @param succeeded 服务端删除成功的目标 + * @param alreadyGone 服务端已无此分支、按达成处理的目标(本地陈旧 tracking ref 已一并清理) + * @param failures 真失败项(含原因),展示超 4 条截断标注 + */ +export function formatRemoteDeleteResult( + succeeded: readonly RemoteBranchTarget[], + alreadyGone: readonly RemoteBranchTarget[], + failures: readonly RemoteDeleteFailure[], +): { level: RemoteDeleteResultLevel; message: string } { + const deletedPart = succeeded.length === 1 ? `Deleted remote branch ${succeeded[0].shortName}` : `Deleted ${succeeded.length} remote branches`; + const gonePart = alreadyGone.length > 0 ? `; ${alreadyGone.length} already gone on remote (pruned local tracking ref${alreadyGone.length > 1 ? 's' : ''})` : ''; + if (failures.length === 0) { + return { level: 'info', message: `${deletedPart}${gonePart}` }; + } + const MAX_SHOWN = 4; + const shown = failures.slice(0, MAX_SHOWN).map((f) => `${f.name} (${collapseReason(f.reason)})`); + const more = failures.length > MAX_SHOWN ? ` … and ${failures.length - MAX_SHOWN} more` : ''; + return { level: 'error', message: `${deletedPart}${gonePart}; ${failures.length} failed: ${shown.join(', ')}${more}` }; +} diff --git a/tests/unit/ref-remote.test.ts b/tests/unit/ref-remote.test.ts index 5e66859..aebb770 100644 --- a/tests/unit/ref-remote.test.ts +++ b/tests/unit/ref-remote.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from 'vitest'; -import { resolveRemoteBranch, partitionRemoteByProtected, formatRemoteDeleteConfirm } from '../../src/engine/ref/remote-ref'; +import { + resolveRemoteBranch, + partitionRemoteByProtected, + formatRemoteDeleteConfirm, + isRemoteRefMissingError, + formatRemoteDeleteResult, +} from '../../src/engine/ref/remote-ref'; describe('resolveRemoteBranch', () => { it('普通 remote:origin/foo → {origin, foo}', () => { @@ -109,3 +115,107 @@ describe('formatRemoteDeleteConfirm', () => { expect(r.detail).toContain('upstream'); }); }); + +describe('isRemoteRefMissingError', () => { + // 本机 git 2.54 对「服务端已无此分支」push --delete 的实测 stderr。 + const REAL_STDERR = 'error: unable to delete \'rsi/fixture-terminology\': remote ref does not exist\nerror: failed to push some refs to \'github.com:ThreeFish-AI/rsi.git\''; + + it('实测 stderr 文案(remote ref does not exist)→ true', () => { + const e = Object.assign(new Error('Command failed: git push'), { stderr: REAL_STDERR }); + expect(isRemoteRefMissingError(e)).toBe(true); + }); + + it('旧服务端变体(delete of non-existent ref)→ true', () => { + expect(isRemoteRefMissingError(Object.assign(new Error('x'), { stderr: ' ! [remote rejected] foo (delete of non-existent ref)' }))).toBe(true); + }); + + it('大小写不敏感', () => { + expect(isRemoteRefMissingError(Object.assign(new Error('x'), { stderr: 'Remote Ref Does Not Exist' }))).toBe(true); + }); + + it('无 stderr 时回退 message', () => { + expect(isRemoteRefMissingError(new Error('unable to delete: remote ref does not exist'))).toBe(true); + }); + + it('保护分支拒绝 → false(真失败)', () => { + const e = Object.assign(new Error('Command failed: git push'), { + stderr: 'remote: error: refusing to delete the current branch: refs/heads/main\n ! [remote rejected] main (remote failure)', + }); + expect(isRemoteRefMissingError(e)).toBe(false); + }); + + it('网络/认证错误等无关报错 → false', () => { + expect(isRemoteRefMissingError(Object.assign(new Error('x'), { stderr: 'fatal: Could not read from remote repository.' }))).toBe(false); + expect(isRemoteRefMissingError(new Error('Command failed: git push origin --delete foo'))).toBe(false); + }); + + it('非对象抛出值 / 空对象 / 空文本 → false', () => { + expect(isRemoteRefMissingError('remote ref does not exist')).toBe(false); + expect(isRemoteRefMissingError(null)).toBe(false); + expect(isRemoteRefMissingError({})).toBe(false); + expect(isRemoteRefMissingError(Object.assign(new Error(''), { stderr: '' }))).toBe(false); + }); +}); + +describe('formatRemoteDeleteResult', () => { + const t = (shortName: string, remote = 'origin', branch = shortName.slice(remote.length + 1)) => ({ remote, branch, shortName }); + + it('仅单条成功 → 单数文案 + info', () => { + const r = formatRemoteDeleteResult([t('origin/foo')], [], []); + expect(r).toEqual({ level: 'info', message: 'Deleted remote branch origin/foo' }); + }); + + it('多条成功 → 复数计数 + info', () => { + const r = formatRemoteDeleteResult([t('origin/a'), t('origin/b')], [], []); + expect(r.level).toBe('info'); + expect(r.message).toBe('Deleted 2 remote branches'); + }); + + it('成功 + 服务端已不存在 → 诚实并列 already gone + info', () => { + const r = formatRemoteDeleteResult([t('origin/a')], [t('origin/rsi/fixture-terminology')], []); + expect(r.level).toBe('info'); + expect(r.message).toBe('Deleted remote branch origin/a; 1 already gone on remote (pruned local tracking ref)'); + }); + + it('仅 alreadyGone(本次报错场景:0 删除 1 陈旧)→ info 且不称 deleted N remote branches 误导', () => { + const r = formatRemoteDeleteResult([], [t('origin/x')], []); + expect(r.level).toBe('info'); + expect(r.message).toContain('1 already gone on remote (pruned local tracking ref)'); + }); + + it('alreadyGone 复数 → tracking refs', () => { + const r = formatRemoteDeleteResult([], [t('origin/x'), t('origin/y')], []); + expect(r.message).toContain('pruned local tracking refs'); + }); + + it('含失败 → error 级,附分支名与折叠后的单行原因', () => { + const r = formatRemoteDeleteResult([t('origin/a')], [], [ + { name: 'origin/main', reason: 'remote: error: refusing to delete the current branch\n ! [remote rejected] main (remote failure)' }, + ]); + expect(r.level).toBe('error'); + expect(r.message).toContain('1 failed: origin/main ('); + expect(r.message).toContain('refusing to delete the current branch; ! [remote rejected] main (remote failure))'); + expect(r.message).not.toContain('\n'); + }); + + it('失败原因超长 → 截断加省略号', () => { + const r = formatRemoteDeleteResult([], [], [{ name: 'origin/x', reason: `line\n${'e'.repeat(300)}` }]); + expect(r.message.length).toBeLessThan(300); + expect(r.message).toContain('…'); + }); + + it('失败超 4 条 → 截断标注 and N more', () => { + const failures = Array.from({ length: 6 }, (_, i) => ({ name: `origin/f${i}`, reason: 'boom' })); + const r = formatRemoteDeleteResult([], [], failures); + expect(r.level).toBe('error'); + expect(r.message).toContain('6 failed: '); + expect(r.message).toContain('… and 2 more'); + expect(r.message).not.toContain('origin/f5 ('); + }); + + it('三桶共存 → 一条消息完整覆盖', () => { + const r = formatRemoteDeleteResult([t('origin/a')], [t('origin/b')], [{ name: 'origin/c', reason: 'denied' }]); + expect(r.level).toBe('error'); + expect(r.message).toBe('Deleted remote branch origin/a; 1 already gone on remote (pruned local tracking ref); 1 failed: origin/c (denied)'); + }); +}); From 15f35332e91a746615aff672efee08c002911452 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 17 Oct 2026 18:28:45 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(Branches):=20Review=20=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E2=80=94=E2=80=94=E9=94=99=E8=AF=AF=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E6=8F=90=E5=8F=96=20SSOT=20=E5=8C=96=E4=B8=8E=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E9=80=9A=E7=9F=A5=E8=AF=9A=E5=AE=9E=E5=8C=96;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抽取 engine/git-state/git-error.ts 的 gitErrorText 作为「stderr 优先提取」 单一事实源,remote-commands 与 history-commands 两处内联 errMsg 改为引用, 消除本 PR 引入的同逻辑第三份拷贝与语义漂移面; - formatRemoteDeleteResult 零计数段省略(仅陈旧项时不再以「Deleted 0 remote branches」开头)并复用 truncateNames 截断(SSOT);isRemoteRefMissingError 空白 stderr 回退 message 与 errMsg 守卫语义对齐; - issue.md #19 同步 gitErrorText 术语;新增/强化 20 个单测(494 全绿)。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- docs/.agents/issue.md | 2 +- src/adapter/history-commands.ts | 51 ++++++++++++------------------- src/adapter/remote-commands.ts | 23 +++----------- src/engine/git-state/git-error.ts | 27 ++++++++++++++++ src/engine/ref/remote-ref.ts | 37 +++++++++++----------- tests/unit/ref-remote.test.ts | 14 ++++++--- 6 files changed, 81 insertions(+), 73 deletions(-) create mode 100644 src/engine/git-state/git-error.ts diff --git a/docs/.agents/issue.md b/docs/.agents/issue.md index 9ec450a..465848f 100644 --- a/docs/.agents/issue.md +++ b/docs/.agents/issue.md @@ -169,6 +169,6 @@ - **表因**:右键删除 `origin/rsi/fixture-terminology` 等远程分支直接报 `Deleted 0 remote branches, 1 failed: …`,无任何失败原因,此类分支「经常」删不掉。 - **根因**:复合缺陷——① PR 合并后 GitHub 自动删除远端分支,本地 `refs/remotes/origin/...` 在下一次 `fetch --prune` 前一直陈旧,Branches 视图(数据源 `repo.state.refs`)仍列出它;② 对陈旧 ref 执行 `git push origin --delete ` 被服务端拒绝(git 2.54 实测 stderr:`error: unable to delete 'x': remote ref does not exist`);③ `remote-commands.ts` 删除循环的 `catch {}` 把 execGit 已透传挂载的 stderr 整个吞掉,最终只回显笼统的「N failed」,不可诊断。即「目标态已达成却按失败收尾」+「真失败也无原因」两个 Bug 叠加。 -- **处理方式**:删除循环改三桶(成功 / 服务端已无此 ref / 真失败)——「服务端已无此 ref」经 engine 纯逻辑 `isRemoteRefMissingError`(stderr 优先,匹配 `remote ref does not exist` / `delete of non-existent ref` 变体)识别,按达成收尾并把本地陈旧 tracking ref 纳入既有 `branch -D -r` 清理;真失败经 stderr 优先的 errMsg 提取原因,由 `formatRemoteDeleteResult` 汇总为通知(失败走 `showGitError`,联动 Show Output 看完整 stderr)。确认弹窗/受保护主干硬阻断/上游软警示逻辑不动。 +- **处理方式**:删除循环改三桶(成功 / 服务端已无此 ref / 真失败)——「服务端已无此 ref」经 engine 纯逻辑 `isRemoteRefMissingError`(stderr 优先,匹配 `remote ref does not exist` / `delete of non-existent ref` 变体)识别,按达成收尾并把本地陈旧 tracking ref 纳入既有 `branch -D -r` 清理;真失败经 stderr 优先的 `gitErrorText`(`engine/git-state/git-error.ts` 单一事实源)提取原因,由 `formatRemoteDeleteResult` 汇总为通知(失败走 `showGitError`,联动 Show Output 看完整 stderr)。确认弹窗/受保护主干硬阻断/上游软警示逻辑不动。 - **后续防范**:① 批量操作的 `catch` 必须保留可行动的失败原因(execGit 拒绝值已挂 `.stderr`,消费即可),`grep -rn 'catch {' src/adapter/` 应逐个确认非吞错;② remote-tracking ref 与服务端存在时滞,任何「对远程分支的操作」都要考虑「本地有、服务端无」的陈旧态——删除/比较/PR 匹配等场景遇到 `does not exist` 类报错应先判陈旧再判失败;③ 视图列表的「幽灵分支」根治靠 `fetch --prune`(Prune Remotes 命令已有),删除路径的容错只是补救。 - **同类问题影响**:所有列出 remote-tracking ref 并提供远程删除的 Git GUI;凡把服务端拒绝一概当失败、不区分「目标态已达成」的实现,以及任何丢弃 stderr 的批量 CLI 包装。 diff --git a/src/adapter/history-commands.ts b/src/adapter/history-commands.ts index 0a9b9af..b696fe3 100644 --- a/src/adapter/history-commands.ts +++ b/src/adapter/history-commands.ts @@ -14,6 +14,7 @@ import { selectedBranchRefs } from './branch-selection'; import { diffPrunedRefs, formatBranchDeleteConfirm, partitionByMerged, truncateNames } from '../engine/ref/cleanup'; import { diffShapeFromCode } from '../engine/diff/change-side'; import { resolveDiffSides } from './diff-sides'; +import { gitErrorText } from '../engine/git-state/git-error'; /** 注册 Log/Branches/Blame/History/Tags 相关命令。 */ export function registerHistoryCommands( @@ -23,20 +24,6 @@ export function registerHistoryCommands( favorites: BranchFavorites, ): vscode.Disposable[] { const subs: vscode.Disposable[] = []; - // vscode.git 在 git 非零退出时抛出 GitError,其 .message 为通用串「Failed to execute git」, - // 真实原因落在 .stderr。优先暴露 stderr,使「无上游」「non-fast-forward」等失败可读。 - const errMsg = (e: unknown): string => { - if (e && typeof e === 'object') { - const ge = e as { stderr?: unknown; message?: unknown }; - if (typeof ge.stderr === 'string' && ge.stderr.trim()) { - return ge.stderr.trim(); - } - if (typeof ge.message === 'string' && ge.message) { - return ge.message; - } - } - return String(e); - }; subs.push(vscode.commands.registerCommand('hyperGit.refreshLog', () => logTree.refresh())); subs.push(vscode.commands.registerCommand('hyperGit.refreshBranches', () => branchesTree.refresh())); @@ -115,7 +102,7 @@ export function registerHistoryCommands( await repo.createBranch(name.trim(), true); branchesTree.refresh(); } catch (e) { - void showGitError(`Failed to create branch: ${errMsg(e)}`); + void showGitError(`Failed to create branch: ${gitErrorText(e)}`); } } }), @@ -131,7 +118,7 @@ export function registerHistoryCommands( await repo.checkout(node.ref.shortName); branchesTree.refresh(); } catch (e) { - void showGitError(`Failed to checkout: ${errMsg(e)}`); + void showGitError(`Failed to checkout: ${gitErrorText(e)}`); } }), ); @@ -202,7 +189,7 @@ export function registerHistoryCommands( branchesTree.refresh(); } catch (e) { if (!(await handleGitConflict(service, 'Merge'))) { - void showGitError(`Failed to merge: ${errMsg(e)}`); + void showGitError(`Failed to merge: ${gitErrorText(e)}`); } } }), @@ -224,7 +211,7 @@ export function registerHistoryCommands( branchesTree.refresh(); } catch (e) { if (!(await handleGitConflict(service, 'Rebase'))) { - void showGitError(`Failed to rebase: ${errMsg(e)}`); + void showGitError(`Failed to rebase: ${gitErrorText(e)}`); } } }), @@ -251,7 +238,7 @@ export function registerHistoryCommands( const doc = await vscode.workspace.openTextDocument({ content: blame, language: 'plaintext' }); await vscode.window.showTextDocument(doc, { preview: true }); } catch (e) { - void showGitError(`Failed to Blame: ${errMsg(e)}`); + void showGitError(`Failed to Blame: ${gitErrorText(e)}`); } }), ); @@ -267,7 +254,7 @@ export function registerHistoryCommands( branchesTree.refresh(); } catch (e) { if (!(await handleGitConflict(service, 'Pull'))) { - void showGitError(`Failed to Pull: ${errMsg(e)}`); + void showGitError(`Failed to Pull: ${gitErrorText(e)}`); } } }), @@ -309,7 +296,7 @@ export function registerHistoryCommands( branchesTree.refresh(); void vscode.window.showInformationMessage(`Pushed ${head.name}`); } catch (e) { - void showGitError(`Failed to Push: ${errMsg(e)}`); + void showGitError(`Failed to Push: ${gitErrorText(e)}`); } }), ); @@ -352,7 +339,7 @@ export function registerHistoryCommands( branchesTree.refresh(); void vscode.window.showInformationMessage(`Pushed ${name}`); } catch (e) { - void showGitError(`Failed to Push: ${errMsg(e)}`); + void showGitError(`Failed to Push: ${gitErrorText(e)}`); } }), ); @@ -375,7 +362,7 @@ export function registerHistoryCommands( branchesTree.refresh(); logTree.refresh(); } catch (e) { - void showGitError(`Failed to Fetch: ${errMsg(e)}`); + void showGitError(`Failed to Fetch: ${gitErrorText(e)}`); } }), ); @@ -451,7 +438,7 @@ export function registerHistoryCommands( await repo.createBranch(name.trim(), true, source); branchesTree.refresh(); } catch (e) { - void showGitError(`Failed to create branch: ${errMsg(e)}`); + void showGitError(`Failed to create branch: ${gitErrorText(e)}`); } }), ); @@ -468,7 +455,7 @@ export function registerHistoryCommands( const doc = await vscode.workspace.openTextDocument({ content: `$ git diff --stat HEAD...${selected}\n\n${out}`, language: 'plaintext' }); await vscode.window.showTextDocument(doc, { preview: true }); } catch (e) { - void showGitError(`Failed to compare: ${errMsg(e)}`); + void showGitError(`Failed to compare: ${gitErrorText(e)}`); } }), ); @@ -507,7 +494,7 @@ export function registerHistoryCommands( branchesTree.refresh(); void vscode.window.showInformationMessage(`Created tag ${name.trim()} @ ${pick.target === 'HEAD' ? 'HEAD' : pick.target.slice(0, 7)}`); } catch (e) { - void showGitError(`Failed to create tag: ${errMsg(e)}`); + void showGitError(`Failed to create tag: ${gitErrorText(e)}`); } }), ); @@ -558,7 +545,7 @@ export function registerHistoryCommands( await repo.checkout(name); branchesTree.refresh(); } catch (e) { - void showGitError(`Failed to checkout tag: ${errMsg(e)}`); + void showGitError(`Failed to checkout tag: ${gitErrorText(e)}`); } }), ); @@ -587,7 +574,7 @@ export function registerHistoryCommands( try { await vscode.commands.executeCommand('vscode.diff', left, right, `${filePath} · ${hash.slice(0, 7)} (commit diff)`, { preview: true }); } catch (e) { - void showGitError(`Failed to open diff: ${errMsg(e)}`); + void showGitError(`Failed to open diff: ${gitErrorText(e)}`); } }), ); @@ -621,7 +608,7 @@ export function registerHistoryCommands( void vscode.window.showInformationMessage(`Reset (--${pick.label} ${hash.slice(0, 7)}) complete`); } catch (e) { if (!(await handleGitConflict(service, 'Reset'))) { - void showGitError(`Failed to Reset: ${errMsg(e)}`); + void showGitError(`Failed to Reset: ${gitErrorText(e)}`); } } }), @@ -646,7 +633,7 @@ export function registerHistoryCommands( branchesTree.refresh(); void vscode.window.showInformationMessage(`Created and checked out ${name.trim()} @ ${node.commit.hash.slice(0, 7)}`); } catch (e) { - void showGitError(`Failed to create branch: ${errMsg(e)}`); + void showGitError(`Failed to create branch: ${gitErrorText(e)}`); } }), ); @@ -669,7 +656,7 @@ export function registerHistoryCommands( branchesTree.refresh(); void vscode.window.showInformationMessage(`Created tag ${name.trim()} @ ${node.commit.hash.slice(0, 7)}`); } catch (e) { - void showGitError(`Failed to create tag: ${errMsg(e)}`); + void showGitError(`Failed to create tag: ${gitErrorText(e)}`); } }), ); @@ -687,7 +674,7 @@ export function registerHistoryCommands( }); await vscode.window.showTextDocument(doc, { preview: true }); } catch (e) { - void showGitError(`Failed to query: ${errMsg(e)}`); + void showGitError(`Failed to query: ${gitErrorText(e)}`); } }), ); diff --git a/src/adapter/remote-commands.ts b/src/adapter/remote-commands.ts index ecb06c6..3f4c1f0 100644 --- a/src/adapter/remote-commands.ts +++ b/src/adapter/remote-commands.ts @@ -16,20 +16,7 @@ import { type RemoteDeleteFailure, } from '../engine/ref/remote-ref'; import type { GitRepositoryService } from './git-repository-service'; - -// 与 history-commands.ts 同款:CLI 通道失败的真实原因在透传的 .stderr,优先暴露(vscode.git GitError 同理)。 -const errMsg = (e: unknown): string => { - if (e && typeof e === 'object') { - const ge = e as { stderr?: unknown; message?: unknown }; - if (typeof ge.stderr === 'string' && ge.stderr.trim()) { - return ge.stderr.trim(); - } - if (typeof ge.message === 'string' && ge.message) { - return ge.message; - } - } - return String(e); -}; +import { gitErrorText } from '../engine/git-state/git-error'; /** * 注册远程同步对话框(Phase 3):Push 对话框 / Update Project / Merge 对话框。 @@ -98,7 +85,7 @@ export function registerRemoteCommands( void vscode.window.showInformationMessage(`Pushed to ${remote} ${previewCount}`.trim()); } catch (e) { if (!(await handleGitConflict(service, 'Push'))) { - void showGitError(`Push failed: ${errMsg(e)}`); + void showGitError(`Push failed: ${gitErrorText(e)}`); } } }), @@ -129,7 +116,7 @@ export function registerRemoteCommands( void vscode.window.showInformationMessage('Update Project complete'); } catch (e) { if (!(await handleGitConflict(service, 'Update'))) { - void showGitError(`Update failed: ${errMsg(e)}`); + void showGitError(`Update failed: ${gitErrorText(e)}`); } } }), @@ -171,7 +158,7 @@ export function registerRemoteCommands( void vscode.window.showInformationMessage(`Merged ${target}`); } catch (e) { if (!(await handleGitConflict(service, 'Merge'))) { - void showGitError(`Merge failed: ${errMsg(e)}`); + void showGitError(`Merge failed: ${gitErrorText(e)}`); } } }), @@ -234,7 +221,7 @@ export function registerRemoteCommands( if (isRemoteRefMissingError(e)) { alreadyGone.push(t); } else { - failures.push({ name: t.shortName, reason: errMsg(e) }); + failures.push({ name: t.shortName, reason: gitErrorText(e) }); } } } diff --git a/src/engine/git-state/git-error.ts b/src/engine/git-state/git-error.ts new file mode 100644 index 0000000..fefb36f --- /dev/null +++ b/src/engine/git-state/git-error.ts @@ -0,0 +1,27 @@ +/** + * Git 错误文本提取的单一事实源(纯逻辑,零 vscode 依赖)。 + * + * CLI 通道({@link GitRepositoryService.execGit} 的约定)失败时把真实原因透传挂载在错误对象的 + * `.stderr`,`err.message` 只是 execFile 的通用串;vscode.git API 通道的 GitError 同样以 `.stderr` + * 承载真实原因。本函数统一两条通道的提取语义:非空白 stderr 优先(trim 后返回),回退 message, + * 最终 `String(e)` 兜底。新增代码与 engine 层分类一律引用本函数;存量 adapter 中尚有约 13 处 + * 只读 message 的旧式内联 `errMsg`(预先存在),待逐步迁移,禁止再新增内联拷贝。 + */ + +/** + * 从 git 失败的拒绝值中提取最可读的错误文本。 + * @param e execGit / vscode.git API 抛出的错误(任意 unknown) + * @returns 非空白 stderr(trim 后)> message > String(e) + */ +export function gitErrorText(e: unknown): string { + if (e && typeof e === 'object') { + const ge = e as { stderr?: unknown; message?: unknown }; + if (typeof ge.stderr === 'string' && ge.stderr.trim()) { + return ge.stderr.trim(); + } + if (typeof ge.message === 'string' && ge.message) { + return ge.message; + } + } + return String(e); +} diff --git a/src/engine/ref/remote-ref.ts b/src/engine/ref/remote-ref.ts index e72bc95..cce4512 100644 --- a/src/engine/ref/remote-ref.ts +++ b/src/engine/ref/remote-ref.ts @@ -15,6 +15,7 @@ */ import { isProtectedBranch, truncateNames } from './cleanup'; +import { gitErrorText } from '../git-state/git-error'; /** 解析后的远程分支定位。 */ export interface RemoteBranchTarget { @@ -116,18 +117,10 @@ const REMOTE_REF_MISSING_PATTERNS: readonly RegExp[] = [/remote ref does not exi * (`error: unable to delete 'x': remote ref does not exist`)。此分支的目标态(服务端无此分支) * 已天然达成,应按成功收尾并清理本地陈旧 ref,而非误报失败。 * - * @param e execGit 的拒绝值:stderr 已透传挂载(优先),无 stderr 时回退 message。 + * @param e execGit 的拒绝值:文本提取统一走 {@link gitErrorText}(非空白 stderr 优先,回退 message)。 */ export function isRemoteRefMissingError(e: unknown): boolean { - let text = ''; - if (e && typeof e === 'object') { - const ge = e as { stderr?: unknown; message?: unknown }; - if (typeof ge.stderr === 'string') { - text = ge.stderr; - } else if (typeof ge.message === 'string') { - text = ge.message; - } - } + const text = gitErrorText(e); return text.length > 0 && REMOTE_REF_MISSING_PATTERNS.some((re) => re.test(text)); } @@ -143,7 +136,8 @@ export type RemoteDeleteResultLevel = 'info' | 'error'; /** * 把逐分支远程删除结果(成功 / 服务端已不存在 / 失败)三桶汇总为通知文案与级别(纯逻辑)。 * - * 诚实计数:alreadyGone 单列(服务端本就无此分支,非本次删除),失败项附真实原因。 + * 诚实计数:alreadyGone 单列(服务端本就无此分支,非本次删除),失败项附真实原因; + * 零计数段省略(如仅陈旧项时文案为「N already gone on remote …」,不以「Deleted 0 …」开头)。 * @param succeeded 服务端删除成功的目标 * @param alreadyGone 服务端已无此分支、按达成处理的目标(本地陈旧 tracking ref 已一并清理) * @param failures 真失败项(含原因),展示超 4 条截断标注 @@ -153,13 +147,20 @@ export function formatRemoteDeleteResult( alreadyGone: readonly RemoteBranchTarget[], failures: readonly RemoteDeleteFailure[], ): { level: RemoteDeleteResultLevel; message: string } { - const deletedPart = succeeded.length === 1 ? `Deleted remote branch ${succeeded[0].shortName}` : `Deleted ${succeeded.length} remote branches`; - const gonePart = alreadyGone.length > 0 ? `; ${alreadyGone.length} already gone on remote (pruned local tracking ref${alreadyGone.length > 1 ? 's' : ''})` : ''; + const parts: string[] = []; + if (succeeded.length === 1) { + parts.push(`Deleted remote branch ${succeeded[0].shortName}`); + } else if (succeeded.length > 1) { + parts.push(`Deleted ${succeeded.length} remote branches`); + } + if (alreadyGone.length > 0) { + parts.push(`${alreadyGone.length} already gone on remote (pruned local tracking ref${alreadyGone.length > 1 ? 's' : ''})`); + } if (failures.length === 0) { - return { level: 'info', message: `${deletedPart}${gonePart}` }; + return { level: 'info', message: parts.join('; ') }; } - const MAX_SHOWN = 4; - const shown = failures.slice(0, MAX_SHOWN).map((f) => `${f.name} (${collapseReason(f.reason)})`); - const more = failures.length > MAX_SHOWN ? ` … and ${failures.length - MAX_SHOWN} more` : ''; - return { level: 'error', message: `${deletedPart}${gonePart}; ${failures.length} failed: ${shown.join(', ')}${more}` }; + // 复用 truncateNames(本模块头注释声明的 SSOT):名称+原因条目信息密度高,收紧到 4 条。 + const entries = failures.map((f) => `${f.name} (${collapseReason(f.reason)})`); + parts.push(`${failures.length} failed: ${truncateNames(entries, 4)}`); + return { level: 'error', message: parts.join('; ') }; } diff --git a/tests/unit/ref-remote.test.ts b/tests/unit/ref-remote.test.ts index aebb770..9c7586b 100644 --- a/tests/unit/ref-remote.test.ts +++ b/tests/unit/ref-remote.test.ts @@ -149,11 +149,17 @@ describe('isRemoteRefMissingError', () => { expect(isRemoteRefMissingError(new Error('Command failed: git push origin --delete foo'))).toBe(false); }); - it('非对象抛出值 / 空对象 / 空文本 → false', () => { - expect(isRemoteRefMissingError('remote ref does not exist')).toBe(false); + it('非对象抛出值 / 空对象 / 空文本 → false(裸字符串走 String(e) 兜底,命中文案为 true)', () => { + expect(isRemoteRefMissingError('remote ref does not exist')).toBe(true); + expect(isRemoteRefMissingError('boom')).toBe(false); expect(isRemoteRefMissingError(null)).toBe(false); expect(isRemoteRefMissingError({})).toBe(false); expect(isRemoteRefMissingError(Object.assign(new Error(''), { stderr: '' }))).toBe(false); + expect(isRemoteRefMissingError(Object.assign(new Error(''), { stderr: ' ' }))).toBe(false); + }); + + it('stderr 空白但 message 命中 → 回退 message(与 errMsg 守卫语义一致)', () => { + expect(isRemoteRefMissingError(Object.assign(new Error('push: remote ref does not exist'), { stderr: ' ' }))).toBe(true); }); }); @@ -177,10 +183,10 @@ describe('formatRemoteDeleteResult', () => { expect(r.message).toBe('Deleted remote branch origin/a; 1 already gone on remote (pruned local tracking ref)'); }); - it('仅 alreadyGone(本次报错场景:0 删除 1 陈旧)→ info 且不称 deleted N remote branches 误导', () => { + it('仅 alreadyGone(本次报错场景:0 删除 1 陈旧)→ info 且不以 Deleted 0 开头', () => { const r = formatRemoteDeleteResult([], [t('origin/x')], []); expect(r.level).toBe('info'); - expect(r.message).toContain('1 already gone on remote (pruned local tracking ref)'); + expect(r.message).toBe('1 already gone on remote (pruned local tracking ref)'); }); it('alreadyGone 复数 → tracking refs', () => {