From 3a2c1199bc839ccdf1a63ba5488733fb333b973f Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Tue, 30 Jun 2026 17:58:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(Branches):=20=E8=BF=9C=E7=A8=8B=E5=88=86?= =?UTF-8?q?=E6=94=AF=E6=94=AF=E6=8C=81=E5=8F=B3=E9=94=AE=E5=88=A0=E9=99=A4?= =?UTF-8?q?=EF=BC=88git=20push=20--delete=EF=BC=89;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 hyperGit.deleteRemoteBranch 命令,挂载到远程分支右键菜单(与本地「删除分支」同槽位 1_branch@2,视觉对称),commandPalette 以 when:false 隐藏 - 经 git push --delete 删除服务端分支,并对成功项执行 branch -D -r 即时清理本地 remote-tracking ref,使视图立即同步 - 新增纯逻辑 src/engine/ref/remote-ref.ts: · resolveRemoteBranch 用已知 remotes 做最长前缀匹配拆分 {remote, branch},remote 名含斜杠(如 myorg/repo)亦安全,禁用朴素 split('/')[0] · partitionRemoteByProtected 复用 isProtectedBranch(SSOT),硬阻断 origin/main、origin/master · formatRemoteDeleteConfirm 诚实传达「服务器端不可逆 + 影响协作者」,支持单/多选与当前分支上游软警示 - 复用 selectedBranchRefs(多选归一化)/ execGit / 统一 modal 确认范式,零重定义;不调用 handleGitConflict(删除不产生合并冲突) - 新增 tests/unit/ref-remote.test.ts(16 个用例,覆盖斜杠安全/歧义/受保护分桶/确认文案) 🤖 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 --- package.json | 3 + src/adapter/remote-commands.ts | 89 +++++++++++++++++++++++++- src/engine/ref/remote-ref.ts | 96 ++++++++++++++++++++++++++++ tests/unit/ref-remote.test.ts | 111 +++++++++++++++++++++++++++++++++ 4 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 src/engine/ref/remote-ref.ts create mode 100644 tests/unit/ref-remote.test.ts diff --git a/package.json b/package.json index aa9968e..d0233d0 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,7 @@ { "command": "hyperGit.branchCreate", "title": "新建分支", "category": "Hyper Git", "icon": "$(add)" }, { "command": "hyperGit.branchCheckout", "title": "检出分支", "category": "Hyper Git" }, { "command": "hyperGit.branchDelete", "title": "删除分支", "category": "Hyper Git" }, + { "command": "hyperGit.deleteRemoteBranch", "title": "删除远程分支", "category": "Hyper Git" }, { "command": "hyperGit.mergeBranch", "title": "合并到当前分支", "category": "Hyper Git" }, { "command": "hyperGit.rebaseBranch", "title": "变基当前分支到…", "category": "Hyper Git" }, { "command": "hyperGit.showBlame", "title": "显示 Blame", "category": "Hyper Git" }, @@ -251,6 +252,7 @@ { "command": "hyperGit.discardChanges", "when": "view == hyperGit.changes && viewItem == hyperGit.fileChange", "group": "1_file@3" }, { "command": "hyperGit.branchCheckout", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/ && !listMultiSelection", "group": "1_branch@1" }, { "command": "hyperGit.branchDelete", "when": "view == hyperGit.branches && viewItem == hyperGit.branch", "group": "1_branch@2" }, + { "command": "hyperGit.deleteRemoteBranch", "when": "view == hyperGit.branches && viewItem == hyperGit.remoteBranch", "group": "1_branch@2" }, { "command": "hyperGit.mergeBranch", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/ && !listMultiSelection", "group": "1_branch@3" }, { "command": "hyperGit.rebaseBranch", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/ && !listMultiSelection", "group": "1_branch@4" }, { "command": "hyperGit.branchRename", "when": "view == hyperGit.branches && viewItem == hyperGit.branch && !listMultiSelection", "group": "1_branch@5" }, @@ -292,6 +294,7 @@ { "command": "hyperGit.showHistory", "when": "false" }, { "command": "hyperGit.branchCheckout", "when": "false" }, { "command": "hyperGit.branchDelete", "when": "false" }, + { "command": "hyperGit.deleteRemoteBranch", "when": "false" }, { "command": "hyperGit.mergeBranch", "when": "false" }, { "command": "hyperGit.rebaseBranch", "when": "false" }, { "command": "hyperGit.stashApply", "when": "false" }, diff --git a/src/adapter/remote-commands.ts b/src/adapter/remote-commands.ts index 4389b64..a8c8949 100644 --- a/src/adapter/remote-commands.ts +++ b/src/adapter/remote-commands.ts @@ -1,8 +1,16 @@ import * as vscode from 'vscode'; -import type { GitRepositoryService } from './git-repository-service'; -import type { BranchesTreeProvider } from './tree/branches-tree'; -import type { LogFilterControl } from './webview/log-webview'; +import { selectedBranchRefs } from './branch-selection'; import { handleGitConflict } from './conflict-ui'; +import type { BranchNode, BranchesTreeProvider } from './tree/branches-tree'; +import type { LogFilterControl } from './webview/log-webview'; +import { truncateNames } from '../engine/ref/cleanup'; +import { + formatRemoteDeleteConfirm, + partitionRemoteByProtected, + resolveRemoteBranch, + type RemoteBranchTarget, +} from '../engine/ref/remote-ref'; +import type { GitRepositoryService } from './git-repository-service'; const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); @@ -148,5 +156,80 @@ export function registerRemoteCommands( }), ); + subs.push( + vscode.commands.registerCommand('hyperGit.deleteRemoteBranch', async (node: BranchNode, nodes?: BranchNode[]) => { + const repo = service.repo; + if (!repo) { + return; + } + // 仅远程、非 tag(多选时自动过滤本地/标签节点)。 + const refs = selectedBranchRefs(node, nodes, (r) => r.isRemote && !r.isTag); + if (refs.length === 0) { + return; + } + const remotes = repo.state.remotes.map((r) => r.name); + if (remotes.length === 0) { + void vscode.window.showWarningMessage('未配置远程仓库(remote)'); + return; + } + // 用已知 remotes 做最长前缀匹配解析 {remote, branch};丢弃无法归属的脏数据。 + const targets = refs + .map((r) => resolveRemoteBranch(r.shortName, remotes)) + .filter((t): t is RemoteBranchTarget => t !== null); + if (targets.length === 0) { + void vscode.window.showWarningMessage('无法解析所选远程分支的归属 remote'); + return; + } + // 受保护主干(main/master)硬阻断——与本地删除硬阻断当前 HEAD 对称。 + const { deletable, protectedTargets } = partitionRemoteByProtected(targets); + if (deletable.length === 0) { + void vscode.window.showWarningMessage(`已跳过受保护分支:${truncateNames(protectedTargets.map((t) => t.shortName))}`); + return; + } + // 软警示:待删集合是否含当前分支上游(删之不致命,但令当前分支失远程追踪)。 + const headUpstream = repo.state.HEAD?.upstream?.name; + const hasUpstreamOfHead = !!headUpstream && deletable.some((t) => t.shortName === headUpstream); + const { detail, confirmLabel } = formatRemoteDeleteConfirm(deletable, { hasUpstreamOfHead }); + // 受保护跳过项透明并入文案。 + const fullDetail = + protectedTargets.length > 0 + ? `${detail}\n\n已自动跳过受保护分支:${truncateNames(protectedTargets.map((t) => t.shortName))}` + : detail; + const choice = await vscode.window.showWarningMessage(fullDetail, { modal: true }, confirmLabel); + if (choice !== confirmLabel) { + return; + } + // 逐分支推删,收集失败(不调用 handleGitConflict——删除不产生合并冲突)。 + const failures: string[] = []; + const succeeded: RemoteBranchTarget[] = []; + for (const t of deletable) { + try { + await service.execGit(['push', t.remote, '--delete', t.branch]); + succeeded.push(t); + } catch { + failures.push(t.shortName); + } + } + // 仅对服务端删除成功项清理本地 remote-tracking ref(失败项保留,待重试或下次 fetch --prune)。 + for (const t of succeeded) { + try { + await service.execGit(['branch', '-D', '-r', `${t.remote}/${t.branch}`]); + } catch { + /* 非关键:服务端已删为权威态,本地清理失败不影响正确性 */ + } + } + branchesTree.refresh(); + if (failures.length === 0) { + void vscode.window.showInformationMessage( + succeeded.length === 1 ? `已删除远程分支 ${succeeded[0].shortName}` : `已删除 ${succeeded.length} 个远程分支`, + ); + } else { + void vscode.window.showWarningMessage( + `已删除 ${succeeded.length} 个远程分支,${failures.length} 个失败:${truncateNames(failures)}`, + ); + } + }), + ); + return subs; } diff --git a/src/engine/ref/remote-ref.ts b/src/engine/ref/remote-ref.ts new file mode 100644 index 0000000..8195baa --- /dev/null +++ b/src/engine/ref/remote-ref.ts @@ -0,0 +1,96 @@ +/** + * 远程分支删除的纯逻辑(零 vscode 依赖)。 + * + * 远程分支删除是 `git push --delete `,与本地 `git branch -d/-D`(见 cleanup.ts) + * 正交:作用于远程服务器、影响协作者、不可撤销(无本地 reflog 兜底)。本模块提供其特有的: + * - {@link resolveRemoteBranch}:把 `origin/feature/foo` 这类远程短名拆成 {remote, branch}。 + * 必须用「已知 remotes 列表」做最长前缀匹配——remote 名本身可含 `/`(如 fork 场景 `myorg/repo`), + * 朴素 `split('/')[0]` 会把 `myorg/repo/feature` 错切成 `myorg`。 + * - {@link partitionRemoteByProtected}:复用 {@link isProtectedBranch},把 main/master 等主干硬排除。 + * - {@link formatRemoteDeleteConfirm}:生成 modal 确认文案,诚实传达服务器端不可逆 + 协作者影响。 + * + * 与 cleanup.ts 的关系:复用其 `isProtectedBranch` / `truncateNames` 作为单一事实源(轻量指针,不复制定义)。 + */ + +import { isProtectedBranch, truncateNames } from './cleanup'; + +/** 解析后的远程分支定位。 */ +export interface RemoteBranchTarget { + /** 远程名,如 origin / upstream / myorg/repo。 */ + readonly remote: string; + /** 远程上的分支名(不含 remote 前缀),如 feature/foo。 */ + readonly branch: string; + /** 规范化短名 remote/branch,用于回显。 */ + readonly shortName: string; +} + +/** + * 用「已知 remotes 列表」做最长前缀匹配,把远程短名拆成 {remote, branch}。 + * + * 为何不用 `shortName.split('/')[0]`:remote 名可含 `/`(GitHub fork/子组场景 `myorg/repo`), + * 其分支 `myorg/repo/feature` 的正确切分是 `remote='myorg/repo'`、`branch='feature'`; + * 朴素首段切分会得到 `'myorg'` → 推到不存在的 remote 失败,甚至误删。 + * + * @param shortName 远程分支短名,如 `origin/feature/foo` + * @param remotes 仓库已配置的 remote 名列表(权威事实源,来自 `repo.state.remotes`) + * @returns 匹配成功返回定位;shortName 是纯 remote 名 / 不属任何已知 remote / 分支段为空 时返回 null + */ +export function resolveRemoteBranch(shortName: string, remotes: readonly string[]): RemoteBranchTarget | null { + // 按长度降序保证最长前缀优先(`myorg/repo` 优先于 `myorg`)。 + for (const remote of [...remotes].sort((a, b) => b.length - a.length)) { + if (shortName === remote) { + // 纯 remote 名本身,无分支段。 + return null; + } + const prefix = `${remote}/`; + // 必须带 '/' 分隔符,避免 `origin` 误匹配 `originx/...`。 + if (shortName.startsWith(prefix)) { + const branch = shortName.slice(prefix.length); + if (branch) { + return { remote, branch, shortName }; + } + } + } + return null; +} + +/** + * 把选中的远程分支按「可删 / 受保护」分桶。 + * + * 复用 {@link isProtectedBranch}(SSOT):main/master 等主干一旦在远程删除会摧毁团队共享主干 + * 并阻断所有协作者的下一次 push,属比「删未合并本地分支」严重一个数量级的灾难,故硬排除。 + */ +export function partitionRemoteByProtected( + targets: readonly RemoteBranchTarget[], +): { deletable: RemoteBranchTarget[]; protectedTargets: RemoteBranchTarget[] } { + const deletable: RemoteBranchTarget[] = []; + const protectedTargets: RemoteBranchTarget[] = []; + for (const t of targets) { + (isProtectedBranch(t.branch) ? protectedTargets : deletable).push(t); + } + return { deletable, protectedTargets }; +} + +/** + * 生成远程删除的 modal 确认文案与确认按钮文本(纯逻辑)。 + * + * 远程删除的风险语义与本地本质不同:作用于服务器、影响协作者、不可撤销(无本地 reflog 兜底), + * 且 `git push --delete` 无 -d/-D 区分(远端无条件删除)。文案须诚实传达这三点; + * 按钮统一为「删除」(区别本地「删除/强制删除/全部删除」三态)。 + * + * @param deletable 经 {@link partitionRemoteByProtected} 过滤后的可删目标 + * @param opts.hasUpstreamOfHead 待删集合是否包含当前分支的上游(软警示,不硬阻断——本地分支与提交仍在,仅失远程追踪) + */ +export function formatRemoteDeleteConfirm( + deletable: readonly RemoteBranchTarget[], + opts?: { hasUpstreamOfHead?: boolean }, +): { detail: string; confirmLabel: string } { + const head = opts?.hasUpstreamOfHead ? '⚠ 其中包含当前分支的上游,删除后当前分支将失去远程追踪。\n' : ''; + const irreversible = '此操作作用于远程仓库,不可撤销,并可能影响其他协作者。'; + const n = deletable.length; + const body = + n === 1 + ? `将删除远程分支「${deletable[0].shortName}」(位于远程 ${deletable[0].remote})。\n${irreversible}` + : `将删除 ${n} 个远程分支:${truncateNames(deletable.map((t) => t.shortName))}。\n${irreversible}`; + return { detail: `${head}${body}`, confirmLabel: '删除' }; +} diff --git a/tests/unit/ref-remote.test.ts b/tests/unit/ref-remote.test.ts new file mode 100644 index 0000000..b2b60e9 --- /dev/null +++ b/tests/unit/ref-remote.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest'; +import { resolveRemoteBranch, partitionRemoteByProtected, formatRemoteDeleteConfirm } from '../../src/engine/ref/remote-ref'; + +describe('resolveRemoteBranch', () => { + it('普通 remote:origin/foo → {origin, foo}', () => { + expect(resolveRemoteBranch('origin/foo', ['origin'])).toEqual({ + remote: 'origin', + branch: 'foo', + shortName: 'origin/foo', + }); + }); + + it('含斜杠 remote:myorg/repo/feature → {myorg/repo, feature}(最长前缀)', () => { + expect(resolveRemoteBranch('myorg/repo/feature', ['origin', 'myorg/repo'])).toEqual({ + remote: 'myorg/repo', + branch: 'feature', + shortName: 'myorg/repo/feature', + }); + }); + + it('歧义优先最长前缀(同时存在 myorg 与 myorg/repo)', () => { + expect(resolveRemoteBranch('myorg/repo/feat', ['myorg', 'myorg/repo'])?.remote).toBe('myorg/repo'); + }); + + it('前缀带分隔符防误配(origin 不应匹配 originx/foo)', () => { + expect(resolveRemoteBranch('originx/foo', ['origin'])).toBeNull(); + }); + + it('分支名自身含斜杠:origin/feat/x → {origin, feat/x}', () => { + expect(resolveRemoteBranch('origin/feat/x', ['origin'])).toEqual({ + remote: 'origin', + branch: 'feat/x', + shortName: 'origin/feat/x', + }); + }); + + it('不属于任何已知 remote → null', () => { + expect(resolveRemoteBranch('upstream/foo', ['origin'])).toBeNull(); + }); + + it('纯 remote 名(无分支段)→ null', () => { + expect(resolveRemoteBranch('origin', ['origin'])).toBeNull(); + }); + + it('空 remotes → null', () => { + expect(resolveRemoteBranch('origin/foo', [])).toBeNull(); + }); +}); + +describe('partitionRemoteByProtected', () => { + const t = (shortName: string, remote: string, branch: string) => ({ remote, branch, shortName }); + + it('main/master 归 protected,其余 deletable', () => { + const { deletable, protectedTargets } = partitionRemoteByProtected([ + t('origin/main', 'origin', 'main'), + t('origin/master', 'origin', 'master'), + t('origin/feat', 'origin', 'feat'), + ]); + expect(deletable.map((x) => x.branch)).toEqual(['feat']); + expect(protectedTargets.map((x) => x.branch).sort()).toEqual(['main', 'master']); + }); + + it('空输入 → 两桶皆空', () => { + const r = partitionRemoteByProtected([]); + expect(r.deletable).toEqual([]); + expect(r.protectedTargets).toEqual([]); + }); + + it('分支名含斜杠的受保护判定基于末段(feat/main 不受保护)', () => { + const { deletable } = partitionRemoteByProtected([t('origin/feat/main', 'origin', 'feat/main')]); + expect(deletable.map((x) => x.branch)).toEqual(['feat/main']); + }); +}); + +describe('formatRemoteDeleteConfirm', () => { + const t = (shortName: string, remote: string, branch: string) => ({ remote, branch, shortName }); + + it('单条 → 删除 + 不可撤销 + 协作者', () => { + const r = formatRemoteDeleteConfirm([t('origin/foo', 'origin', 'foo')]); + expect(r.confirmLabel).toBe('删除'); + expect(r.detail).toContain('origin/foo'); + expect(r.detail).toContain('不可撤销'); + expect(r.detail).toContain('协作者'); + }); + + it('多条 → 含数量与截断名', () => { + const r = formatRemoteDeleteConfirm([ + t('origin/b0', 'origin', 'b0'), + t('origin/b1', 'origin', 'b1'), + t('origin/b2', 'origin', 'b2'), + ]); + expect(r.confirmLabel).toBe('删除'); + expect(r.detail).toContain('3 个'); + }); + + it('多条超上限 → truncateNames 截断(…还有)', () => { + const ts = Array.from({ length: 12 }, (_, i) => t(`origin/b${i}`, 'origin', `b${i}`)); + expect(formatRemoteDeleteConfirm(ts).detail).toContain('…还有'); + }); + + it('含当前 HEAD 上游 → ⚠ 软警示', () => { + const r = formatRemoteDeleteConfirm([t('origin/foo', 'origin', 'foo')], { hasUpstreamOfHead: true }); + expect(r.detail).toContain('上游'); + expect(r.detail).toContain('⚠'); + }); + + it('非 origin remote 也回显 remote 名', () => { + const r = formatRemoteDeleteConfirm([t('upstream/bar', 'upstream', 'bar')]); + expect(r.detail).toContain('upstream'); + }); +});