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..465848f 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 优先的 `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 0c31fb7..3f4c1f0 100644 --- a/src/adapter/remote-commands.ts +++ b/src/adapter/remote-commands.ts @@ -8,13 +8,15 @@ 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)); +import { gitErrorText } from '../engine/git-state/git-error'; /** * 注册远程同步对话框(Phase 3):Push 对话框 / Update Project / Merge 对话框。 @@ -83,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)}`); } } }), @@ -114,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)}`); } } }), @@ -156,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)}`); } } }), @@ -206,19 +208,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: gitErrorText(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 +236,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/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 94974f9..cce4512 100644 --- a/src/engine/ref/remote-ref.ts +++ b/src/engine/ref/remote-ref.ts @@ -8,11 +8,14 @@ * 朴素 `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` 作为单一事实源(轻量指针,不复制定义)。 */ import { isProtectedBranch, truncateNames } from './cleanup'; +import { gitErrorText } from '../git-state/git-error'; /** 解析后的远程分支定位。 */ export interface RemoteBranchTarget { @@ -94,3 +97,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 的拒绝值:文本提取统一走 {@link gitErrorText}(非空白 stderr 优先,回退 message)。 + */ +export function isRemoteRefMissingError(e: unknown): boolean { + const text = gitErrorText(e); + 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 单列(服务端本就无此分支,非本次删除),失败项附真实原因; + * 零计数段省略(如仅陈旧项时文案为「N already gone on remote …」,不以「Deleted 0 …」开头)。 + * @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 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: parts.join('; ') }; + } + // 复用 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 5e66859..9c7586b 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,113 @@ 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(裸字符串走 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); + }); +}); + +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 0 开头', () => { + const r = formatRemoteDeleteResult([], [t('origin/x')], []); + expect(r.level).toBe('info'); + expect(r.message).toBe('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)'); + }); +});