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
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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" },
Expand Down
89 changes: 86 additions & 3 deletions src/adapter/remote-commands.ts
Original file line number Diff line number Diff line change
@@ -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));

Expand Down Expand Up @@ -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;
}
96 changes: 96 additions & 0 deletions src/engine/ref/remote-ref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* 远程分支删除的纯逻辑(零 vscode 依赖)。
*
* 远程分支删除是 `git push <remote> --delete <branch>`,与本地 `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: '删除' };
}
111 changes: 111 additions & 0 deletions tests/unit/ref-remote.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading