diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9075b91..843fdeb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,8 +16,8 @@ jobs: name: Lint & Build runs-on: ubuntu-latest steps: - - uses: pnpm/action-setup@v6 - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: node-version: 22 @@ -37,8 +37,8 @@ jobs: os: ${{ (github.event_name == 'pull_request') && fromJson('["ubuntu-latest"]') || fromJson('["ubuntu-latest", "macos-latest", "windows-latest"]') }} runs-on: ${{ matrix.os }} steps: - - uses: pnpm/action-setup@v6 - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: node-version: 22 @@ -60,8 +60,8 @@ jobs: needs: test runs-on: ubuntu-latest steps: - - uses: pnpm/action-setup@v6 - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: node-version: 22 @@ -105,8 +105,8 @@ jobs: runs-on: ubuntu-latest environment: production steps: - - uses: pnpm/action-setup@v6 - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: node-version: 22 diff --git a/package.json b/package.json index a5007ba..ec2604b 100644 --- a/package.json +++ b/package.json @@ -314,6 +314,12 @@ "category": "Hyper Git", "icon": "$(repo-fetch)" }, + { + "command": "hyperGit.pruneRemotes", + "title": "Prune Deleted Remote Branches", + "category": "Hyper Git", + "icon": "$(clear-all)" + }, { "command": "hyperGit.cherryPick", "title": "Cherry-Pick", @@ -767,6 +773,11 @@ "when": "view == hyperGit.branches", "group": "1_sync@1" }, + { + "command": "hyperGit.pruneRemotes", + "when": "view == hyperGit.branches", + "group": "1_sync@1.5" + }, { "command": "hyperGit.updateProject", "when": "view == hyperGit.branches", diff --git a/src/adapter/history-commands.ts b/src/adapter/history-commands.ts index d849fec..902bdea 100644 --- a/src/adapter/history-commands.ts +++ b/src/adapter/history-commands.ts @@ -8,7 +8,7 @@ import type { LogFilterControl, LogNode } from './webview/log-webview'; import { handleGitConflict } from './conflict-ui'; import type { MergeMode } from '../engine/log/log-filter'; import { selectedBranchRefs } from './branch-selection'; -import { formatBranchDeleteConfirm, partitionByMerged, truncateNames } from '../engine/ref/cleanup'; +import { diffPrunedRefs, formatBranchDeleteConfirm, partitionByMerged, truncateNames } from '../engine/ref/cleanup'; /** 注册 Log/Branches/Blame/History/Tags 相关命令。 */ export function registerHistoryCommands( @@ -321,12 +321,52 @@ export function registerHistoryCommands( try { await repo.fetch(pick.includes('Prune') ? { prune: true } : undefined); branchesTree.refresh(); + logTree.refresh(); } catch (e) { void vscode.window.showErrorMessage(`Failed to Fetch: ${errMsg(e)}`); } }), ); + subs.push( + vscode.commands.registerCommand('hyperGit.pruneRemotes', async () => { + const repo = service.repo; + if (!repo) { + return; + } + const remotes = repo.state.remotes.map((r) => r.name); + if (remotes.length === 0) { + void vscode.window.showWarningMessage('No remote repository configured'); + return; + } + // prune 前后对 refs/remotes 各快照一次,差集即为被清理的陈旧跟踪引用。 + // `--prune` 的 [deleted] 明细走 stderr,execGit 仅回传 stdout,故用快照差集做循证反馈。 + const before = await listRemoteTrackingRefs(service); + const failed: string[] = []; + // 逐 remote 执行 fetch --prune:API 的 FetchOptions 仅接受单 remote,遍历以覆盖多远程场景。 + for (const remote of remotes) { + try { + await repo.fetch({ remote, prune: true }); + } catch { + failed.push(remote); + } + } + const after = await listRemoteTrackingRefs(service); + const pruned = diffPrunedRefs(before, after); + branchesTree.refresh(); + logTree.refresh(); + if (pruned.length === 0) { + void vscode.window.showInformationMessage( + failed.length > 0 ? `Prune failed for: ${truncateNames(failed)}` : 'No stale remote branches to prune', + ); + } else { + void vscode.window.showInformationMessage( + `Pruned ${pruned.length} stale remote branch(es): ${truncateNames(pruned)}`, + ); + } + }), + ); + // —— Branches 高级操作(Phase 1)—— subs.push( @@ -632,3 +672,20 @@ export function registerHistoryCommands( return subs; } + +/** + * 列出当前 `refs/remotes/*` 短名(如 `origin/master`),供 prune 前后做差集。 + * 解析 `git for-each-ref --format=%(refname:short) refs/remotes`:逐行去空白、去空行。 + * 失败返回空数组(非关键路径,调用方据此跳过差集)。 + */ +async function listRemoteTrackingRefs(service: GitRepositoryService): Promise { + try { + const out = await service.execGit(['for-each-ref', '--format=%(refname:short)', 'refs/remotes']); + return out + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + } catch { + return []; + } +} diff --git a/src/adapter/remote-commands.ts b/src/adapter/remote-commands.ts index 62341b8..8a7318f 100644 --- a/src/adapter/remote-commands.ts +++ b/src/adapter/remote-commands.ts @@ -219,6 +219,7 @@ 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`, diff --git a/src/engine/ref/cleanup.ts b/src/engine/ref/cleanup.ts index 8e0ccc0..0c72ef2 100644 --- a/src/engine/ref/cleanup.ts +++ b/src/engine/ref/cleanup.ts @@ -61,6 +61,20 @@ export function partitionByMerged( return { merged, unmerged }; } +/** + * 计算被 `git fetch --prune` 清理掉的远程跟踪引用:before 有、after 无的差集(保 before 顺序)。 + * + * 用途:`--prune` 的 `[deleted]` 逐行输出走 stderr,{@link GitRepositoryService.execGit} 仅回传 stdout, + * 故无法直接解析清理明细;改为 prune 前后对 `refs/remotes` 各快照一次(`for-each-ref`),以此差集 + * 给出循证反馈。纯逻辑、零 vscode 依赖,便于单测。 + * @param before prune 前 `refs/remotes/*` 短名列表(如 `['origin/feat-a', 'origin/master']`) + * @param after prune 后 `refs/remotes/*` 短名列表 + */ +export function diffPrunedRefs(before: readonly string[], after: readonly string[]): string[] { + const remaining = new Set(after); + return before.filter((name) => !remaining.has(name)); +} + /** 名称列表内联展示:超过 max 个则截断并标注剩余数量,避免确认弹窗过长。 */ export function truncateNames(names: readonly string[], max = 8): string { if (names.length <= max) { diff --git a/tests/unit/ref-cleanup.test.ts b/tests/unit/ref-cleanup.test.ts index 89d3a76..a20d092 100644 --- a/tests/unit/ref-cleanup.test.ts +++ b/tests/unit/ref-cleanup.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { + diffPrunedRefs, filterMergeable, formatBranchDeleteConfirm, isProtectedBranch, @@ -75,6 +76,34 @@ describe('truncateNames', () => { }); }); +describe('diffPrunedRefs', () => { + it('返回 before 有、after 无的引用(保 before 顺序)', () => { + const before = ['origin/feat-a', 'origin/master', 'origin/feat-b']; + const after = ['origin/master']; + // feat-a / feat-b 被清理,master 保留;顺序跟随 before + expect(diffPrunedRefs(before, after)).toEqual(['origin/feat-a', 'origin/feat-b']); + }); + + it('无可清理时返回空数组', () => { + const refs = ['origin/main', 'origin/dev']; + expect(diffPrunedRefs(refs, refs)).toEqual([]); + }); + + it('after 新增引用不影响差集(仅关心被移除项)', () => { + const before = ['origin/a']; + const after = ['origin/a', 'origin/b']; + expect(diffPrunedRefs(before, after)).toEqual([]); + }); + + it('全部被清理', () => { + expect(diffPrunedRefs(['origin/x', 'origin/y'], [])).toEqual(['origin/x', 'origin/y']); + }); + + it('空 before 永远为空', () => { + expect(diffPrunedRefs([], ['origin/a'])).toEqual([]); + }); +}); + describe('formatBranchDeleteConfirm', () => { it('单个已合并 → 安全删除文案', () => { expect(formatBranchDeleteConfirm(['feat'], [])).toEqual({ detail: '分支「feat」已合并,可安全删除。', confirmLabel: '删除' });