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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
59 changes: 58 additions & 1 deletion src/adapter/history-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<string[]> {
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 [];
}
}
1 change: 1 addition & 0 deletions src/adapter/remote-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
14 changes: 14 additions & 0 deletions src/engine/ref/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
29 changes: 29 additions & 0 deletions tests/unit/ref-cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import {
diffPrunedRefs,
filterMergeable,
formatBranchDeleteConfirm,
isProtectedBranch,
Expand Down Expand Up @@ -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: '删除' });
Expand Down