Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/.agents/issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch>` 被服务端拒绝(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 包装。
51 changes: 19 additions & 32 deletions src/adapter/history-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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()));
Expand Down Expand Up @@ -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)}`);
}
}
}),
Expand All @@ -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)}`);
}
}),
);
Expand Down Expand Up @@ -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)}`);
}
}
}),
Expand All @@ -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)}`);
}
}
}),
Expand All @@ -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)}`);
}
}),
);
Expand All @@ -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)}`);
}
}
}),
Expand Down Expand Up @@ -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)}`);
}
}),
);
Expand Down Expand Up @@ -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)}`);
}
}),
);
Expand All @@ -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)}`);
}
}),
);
Expand Down Expand Up @@ -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)}`);
}
}),
);
Expand All @@ -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)}`);
}
}),
);
Expand Down Expand Up @@ -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)}`);
}
}),
);
Expand Down Expand Up @@ -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)}`);
}
}),
);
Expand Down Expand Up @@ -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)}`);
}
}),
);
Expand Down Expand Up @@ -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)}`);
}
}
}),
Expand All @@ -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)}`);
}
}),
);
Expand All @@ -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)}`);
}
}),
);
Expand All @@ -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)}`);
}
}),
);
Expand Down
42 changes: 24 additions & 18 deletions src/adapter/remote-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 对话框。
Expand Down Expand Up @@ -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)}`);
}
}
}),
Expand Down Expand Up @@ -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)}`);
}
}
}),
Expand Down Expand Up @@ -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)}`);
}
}
}),
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
}
}),
);
Expand Down
27 changes: 27 additions & 0 deletions src/engine/git-state/git-error.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading