From 98d70f6c6ca35ea1cdcb6c1ca87d12a10733deb6 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Mon, 29 Jun 2026 21:16:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(Worktree):=20=E6=96=B0=E5=A2=9E=20Worktree?= =?UTF-8?q?=20=E8=A7=86=E5=9B=BE=E4=B8=8E=E5=AE=8C=E6=95=B4=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=A0=91=E6=93=8D=E4=BD=9C=E5=8A=9F=E8=83=BD;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 hyperGit.worktrees 视图:经 git worktree list --porcelain -z 枚举工作树,区分主工作树 / 普通 / detached / locked / prunable,当前打开的工作树高亮; - 新增 9 个命令:刷新、新建(新建分支·检出已有·Detached 三模式)、打开(新窗口)、删除(普通/强制 + 二次确认)、复制路径、锁定、解锁、移动、清理(prunable 预览 + 确认); - 新增 engine 纯逻辑解析器 parseWorktreeList(NUL 段累积状态机,适配 -z 真实格式)+ vitest 单测; - 主工作树与当前打开工作树的删除/移动经菜单 when 与命令内双重守护; - API 不可用降级复用 EmptyChangesProvider,避免「视图未提供器」报错。 🤖 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 | 42 +++- src/adapter/tree/worktree-tree.ts | 168 ++++++++++++++++ src/adapter/worktree-commands.ts | 288 +++++++++++++++++++++++++++ src/engine/worktree/worktree-list.ts | 113 +++++++++++ src/extension.ts | 14 +- tests/unit/worktree-list.test.ts | 114 +++++++++++ 6 files changed, 734 insertions(+), 5 deletions(-) create mode 100644 src/adapter/tree/worktree-tree.ts create mode 100644 src/adapter/worktree-commands.ts create mode 100644 src/engine/worktree/worktree-list.ts create mode 100644 tests/unit/worktree-list.test.ts diff --git a/package.json b/package.json index 8dc9f59..8eb392e 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,11 @@ "id": "hyperGit.shelf", "name": "Shelf", "visibility": "visible" + }, + { + "id": "hyperGit.worktrees", + "name": "Worktrees", + "visibility": "visible" } ] }, @@ -95,6 +100,10 @@ { "view": "hyperGit.stash", "contents": "暂无 Stash。\n[Stash 当前变更](command:hyperGit.stashCreate)" + }, + { + "view": "hyperGit.worktrees", + "contents": "暂无 Worktree。\n[新建 Worktree](command:hyperGit.worktreeAdd)" } ], "commands": [ @@ -181,7 +190,16 @@ { "command": "hyperGit.createPatch", "title": "导出 Patch…", "category": "Hyper Git", "icon": "$(diff)" }, { "command": "hyperGit.applyPatch", "title": "应用 Patch…", "category": "Hyper Git", "icon": "$(diff)" }, { "command": "hyperGit.showReflog", "title": "查看 Reflog", "category": "Hyper Git", "icon": "$(history)" }, - { "command": "hyperGit.toggleBlameAnnotation", "title": "切换 Blame 注解", "category": "Hyper Git", "icon": "$(eye)" } + { "command": "hyperGit.toggleBlameAnnotation", "title": "切换 Blame 注解", "category": "Hyper Git", "icon": "$(eye)" }, + { "command": "hyperGit.worktreeRefresh", "title": "刷新 Worktrees", "category": "Hyper Git", "icon": "$(refresh)" }, + { "command": "hyperGit.worktreeAdd", "title": "新建 Worktree", "category": "Hyper Git", "icon": "$(add)" }, + { "command": "hyperGit.worktreeOpen", "title": "打开 Worktree", "category": "Hyper Git", "icon": "$(folder-opened)" }, + { "command": "hyperGit.worktreeRemove", "title": "删除 Worktree", "category": "Hyper Git", "icon": "$(trash)" }, + { "command": "hyperGit.worktreeCopyPath", "title": "复制 Worktree 路径", "category": "Hyper Git" }, + { "command": "hyperGit.worktreeLock", "title": "锁定 Worktree", "category": "Hyper Git", "icon": "$(lock)" }, + { "command": "hyperGit.worktreeUnlock", "title": "解锁 Worktree", "category": "Hyper Git" }, + { "command": "hyperGit.worktreeMove", "title": "移动 Worktree…", "category": "Hyper Git" }, + { "command": "hyperGit.worktreePrune", "title": "清理失效 Worktree", "category": "Hyper Git", "icon": "$(clear-all)" } ], "menus": { "view/title": [ @@ -220,7 +238,10 @@ { "command": "hyperGit.stashCreate", "when": "view == hyperGit.stash", "group": "navigation" }, { "command": "hyperGit.stashKeepIndex", "when": "view == hyperGit.stash", "group": "navigation" }, { "command": "hyperGit.stashClear", "when": "view == hyperGit.stash" }, - { "command": "hyperGit.shelveChanges", "when": "view == hyperGit.shelf", "group": "navigation" } + { "command": "hyperGit.shelveChanges", "when": "view == hyperGit.shelf", "group": "navigation" }, + { "command": "hyperGit.worktreeRefresh", "when": "view == hyperGit.worktrees", "group": "navigation" }, + { "command": "hyperGit.worktreeAdd", "when": "view == hyperGit.worktrees", "group": "navigation" }, + { "command": "hyperGit.worktreePrune", "when": "view == hyperGit.worktrees", "group": "navigation" } ], "view/item/context": [ { "command": "hyperGit.setActiveChangelist", "when": "view == hyperGit.changes && viewItem == hyperGit.changelist", "group": "1_changelist@1" }, @@ -261,7 +282,13 @@ { "command": "hyperGit.stashBranch", "when": "view == hyperGit.stash && viewItem == hyperGit.stash", "group": "1_stash@4" }, { "command": "hyperGit.unshelveSilently", "when": "view == hyperGit.shelf && viewItem == hyperGit.shelf", "group": "1_shelf@1" }, { "command": "hyperGit.unshelveWithMerge", "when": "view == hyperGit.shelf && viewItem == hyperGit.shelf", "group": "1_shelf@2" }, - { "command": "hyperGit.deleteShelf", "when": "view == hyperGit.shelf && viewItem == hyperGit.shelf", "group": "1_shelf@3" } + { "command": "hyperGit.deleteShelf", "when": "view == hyperGit.shelf && viewItem == hyperGit.shelf", "group": "1_shelf@3" }, + { "command": "hyperGit.worktreeOpen", "when": "view == hyperGit.worktrees && viewItem =~ /hyperGit.worktree|hyperGit.worktreeMain/", "group": "1_wt@1" }, + { "command": "hyperGit.worktreeMove", "when": "view == hyperGit.worktrees && viewItem == hyperGit.worktree", "group": "1_wt@2" }, + { "command": "hyperGit.worktreeLock", "when": "view == hyperGit.worktrees && viewItem == hyperGit.worktree", "group": "1_wt@3" }, + { "command": "hyperGit.worktreeUnlock", "when": "view == hyperGit.worktrees && viewItem == hyperGit.worktree", "group": "1_wt@4" }, + { "command": "hyperGit.worktreeCopyPath", "when": "view == hyperGit.worktrees && viewItem =~ /hyperGit.worktree|hyperGit.worktreeMain/", "group": "1_wt@5" }, + { "command": "hyperGit.worktreeRemove", "when": "view == hyperGit.worktrees && viewItem == hyperGit.worktree", "group": "1_wt@6" } ], "editor/context": [ { "command": "hyperGit.toggleBlameAnnotation", "when": "resourceScheme == file", "group": "hyperGit@1" } @@ -302,7 +329,14 @@ { "command": "hyperGit.resetToHere", "when": "false" }, { "command": "hyperGit.createBranchFromCommit", "when": "false" }, { "command": "hyperGit.createTagFromCommit", "when": "false" }, - { "command": "hyperGit.showContainingBranches", "when": "false" } + { "command": "hyperGit.showContainingBranches", "when": "false" }, + { "command": "hyperGit.worktreeOpen", "when": "false" }, + { "command": "hyperGit.worktreeRemove", "when": "false" }, + { "command": "hyperGit.worktreeCopyPath", "when": "false" }, + { "command": "hyperGit.worktreeLock", "when": "false" }, + { "command": "hyperGit.worktreeUnlock", "when": "false" }, + { "command": "hyperGit.worktreeMove", "when": "false" }, + { "command": "hyperGit.worktreeRefresh", "when": "false" } ] }, "configuration": { diff --git a/src/adapter/tree/worktree-tree.ts b/src/adapter/tree/worktree-tree.ts new file mode 100644 index 0000000..0803d95 --- /dev/null +++ b/src/adapter/tree/worktree-tree.ts @@ -0,0 +1,168 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import type { GitRepositoryService } from '../git-repository-service'; +import { parseWorktreeList, type ParsedWorktree } from '../../engine/worktree/worktree-list'; + +/** Worktrees 视图节点:单个工作树(扁平,无分组——工作树数量通常 ≤ 个位数)。 */ +export interface WorktreeNode { + readonly kind: 'worktree'; + /** 绝对路径(跨平台原样存储,比较时归一)。 */ + readonly path: string; + /** 名称:main 为 'main',其余取分支名或路径 basename。 */ + readonly name: string; + /** HEAD 的可读标识:detached 为短 sha,否则分支名(缺失则短 sha)。 */ + readonly ref: string; + readonly isMain: boolean; + readonly detached: boolean; + readonly locked: boolean; + readonly prunable: boolean; +} + +/** + * Worktrees 视图 TreeDataProvider。 + * + * 数据源策略(对齐 StashTreeProvider):vscode.git 稳定 API 未暴露 worktree 创建/删除, + * `RepositoryState.worktrees` 只读且版本敏感。故改用受控 CLI 通道 `git worktree list --porcelain -z` + * 枚举真实工作树(含 main / detached / locked / prunable 标记)。 + * 构造函数自订阅 service.onDidChange 做即时刷新;带 in-flight 去重缓存避免并发重复 spawn。 + */ +export class WorktreeTreeProvider implements vscode.TreeDataProvider, vscode.Disposable { + private readonly _onDidChange = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChange.event; + private readonly disposables: vscode.Disposable[] = []; + private cache: WorktreeNode[] | undefined; + private inFlight: Promise | undefined; + + constructor(private readonly service: GitRepositoryService) { + this.disposables.push(service.onDidChange(() => this.refresh())); + } + + refresh(): void { + this.cache = undefined; + this._onDidChange.fire(undefined); + } + + async getChildren(): Promise { + const repo = this.service.repo; + if (!repo) { + return []; + } + if (this.cache) { + return this.cache; + } + if (this.inFlight) { + return this.inFlight; + } + this.inFlight = (async () => { + try { + const out = await this.service.execGit(['worktree', 'list', '--porcelain', '-z']); + const nodes = parseWorktreeList(out).map((p) => this.toNode(p)); + this.cache = nodes; + return nodes; + } catch { + return []; + } finally { + this.inFlight = undefined; + } + })(); + return this.inFlight; + } + + private toNode(p: ParsedWorktree): WorktreeNode { + const shortSha = p.commit.slice(0, 7); + return { + kind: 'worktree', + path: p.path, + name: p.isMain ? 'main' : (p.branch ?? path.basename(p.path)), + ref: p.detached ? shortSha : (p.branch ?? shortSha), + isMain: p.isMain, + detached: p.detached, + locked: p.locked, + prunable: p.prunable, + }; + } + + getTreeItem(node: WorktreeNode): vscode.TreeItem { + const isCurrent = this.isCurrent(node); + + const item = new vscode.TreeItem(node.name, vscode.TreeItemCollapsibleState.None); + item.id = `worktree:${node.path}`; + item.description = this.describe(node, isCurrent); + item.tooltip = this.tooltip(node, isCurrent); + item.contextValue = node.isMain ? 'hyperGit.worktreeMain' : 'hyperGit.worktree'; + + const icon = node.isMain ? 'home' : node.detached ? 'git-commit' : 'git-branch'; + // prunable 警示色优先(红色 > 高亮),其次当前打开高亮,对齐 branches-tree 的 ThemeIcon 高亮范式。 + const color = node.prunable + ? new vscode.ThemeColor('gitDecoration.deletedResourceForeground') + : isCurrent + ? new vscode.ThemeColor('gitDecoration.modifiedResourceForeground') + : undefined; + item.iconPath = new vscode.ThemeIcon(icon, color); + return item; + } + + private describe(node: WorktreeNode, isCurrent: boolean): string { + const parts: string[] = []; + if (node.isMain) { + parts.push('主'); + } + if (isCurrent) { + parts.push('当前'); + } + if (node.locked) { + parts.push('🔒'); + } + if (node.prunable) { + parts.push('⚠ 可清理'); + } + parts.push(node.ref); + return parts.join(' · '); + } + + private tooltip(node: WorktreeNode, isCurrent: boolean): string { + const lines: string[] = []; + lines.push(`${node.name}${node.isMain ? '(主工作树)' : ''}${isCurrent ? '(当前打开)' : ''}`); + lines.push(`路径:${node.path}`); + lines.push(`HEAD:${node.ref}${node.detached ? '(detached)' : ''}`); + if (node.locked) { + lines.push('已锁定(防止自动清理)'); + } + if (node.prunable) { + lines.push('⚠ 可清理(目录已失效)'); + } + return lines.join('\n'); + } + + /** 该节点是否当前打开的 worktree(供命令层删除/移动守护复用)。 */ + isCurrent(node: WorktreeNode): boolean { + return isSameWorktreePath(node.path, this.service.repo?.rootUri.fsPath); + } + + dispose(): void { + this.disposables.forEach((d) => d.dispose()); + this._onDidChange.dispose(); + } +} + +/** + * 工作树路径归一比较:`realpathSync` 规避 macOS `/tmp` ↔ `/private/tmp` 软链漏判; + * 路径不存在(如 prunable,目录已删除)时退回原值。`path.normalize` 后去尾部分隔符。 + */ +function isSameWorktreePath(a: string, b: string | undefined): boolean { + if (!b) { + return false; + } + return normalizeWorktreePath(a) === normalizeWorktreePath(b); +} + +function normalizeWorktreePath(p: string): string { + let resolved = p; + try { + resolved = fs.realpathSync(p); + } catch { + // 路径不存在(prunable)或不可读 → 用原值(不阻断比较)。 + } + return path.normalize(resolved).replace(/[\\/]+$/, ''); +} diff --git a/src/adapter/worktree-commands.ts b/src/adapter/worktree-commands.ts new file mode 100644 index 0000000..77f0ae0 --- /dev/null +++ b/src/adapter/worktree-commands.ts @@ -0,0 +1,288 @@ +import * as vscode from 'vscode'; +import type { GitRepositoryService } from './git-repository-service'; +import type { WorktreeNode, WorktreeTreeProvider } from './tree/worktree-tree'; +import { parseWorktreeList } from '../engine/worktree/worktree-list'; + +/** + * 注册 Worktree 相关命令。 + * + * 经受控 CLI 通道 `service.execGit` 执行 `git worktree` 全生命周期操作(vscode.git 稳定 API 未暴露)。 + * 范式对齐 stash-commands:命令参数类型守卫 + try/catch + showErrorMessage + 成功后 refresh; + * 危险/不可逆操作经 `showWarningMessage({ modal: true })` 二次确认。 + */ +export function registerWorktreeCommands(service: GitRepositoryService, worktreeTree: WorktreeTreeProvider): vscode.Disposable[] { + const subs: vscode.Disposable[] = []; + const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); + + subs.push( + vscode.commands.registerCommand('hyperGit.worktreeRefresh', () => { + worktreeTree.refresh(); + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.worktreeAdd', async () => { + const repo = service.repo; + if (!repo) { + return; + } + // ① 分支模式 + const modePick = await vscode.window.showQuickPick( + [ + { label: '新建分支', description: '创建新分支并检出', mode: 'new' as const }, + { label: '检出已有分支', description: '检出已存在的本地分支', mode: 'checkout' as const }, + { label: 'Detached HEAD', description: '在指定提交/分支上 detached 检出', mode: 'detached' as const }, + ], + { placeHolder: 'Worktree 分支模式' }, + ); + if (!modePick) { + return; + } + + // ② 收集分支名 / 源 ref + let branch: string | undefined; + let sourceRef: string | undefined; + if (modePick.mode === 'new') { + branch = await vscode.window.showInputBox({ prompt: '新分支名', placeHolder: 'feature/y' }); + if (!branch?.trim()) { + return; + } + const start = await vscode.window.showInputBox({ prompt: '基于(start-point,留空 = HEAD)', placeHolder: 'HEAD / main / abc1234' }); + if (start === undefined) { + return; // Esc 取消;空字符串 = HEAD(允许) + } + sourceRef = start.trim() || undefined; + } else { + // checkout / detached:枚举本地分支供选择 + let items: { label: string }[] = []; + try { + const list = await service.execGit(['for-each-ref', '--format=%(refname:short)', 'refs/heads']); + items = list + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0) + .map((l) => ({ label: l })); + } catch { + // 列表读取失败 → 退化为空列表(下方 pick 为空时取消) + } + const pick = await vscode.window.showQuickPick(items, { + placeHolder: modePick.mode === 'detached' ? '选择提交/分支(detached)' : '选择本地分支', + }); + if (!pick) { + return; + } + sourceRef = pick.label; + } + + // ③ Worktree 路径 + const safeName = (branch ?? sourceRef ?? 'worktree').replace(/[^A-Za-z0-9._-]+/g, '-'); + const wtPath = await vscode.window.showInputBox({ + prompt: 'Worktree 路径(相对仓库根 / 绝对)', + value: `../${safeName}-wt`, + placeHolder: '../feature-y-wt', + }); + if (!wtPath?.trim()) { + return; + } + + const args = ['worktree', 'add']; + if (modePick.mode === 'new') { + args.push('-b', branch!.trim()); + } else if (modePick.mode === 'detached') { + args.push('--detach'); + } + args.push(wtPath.trim()); + if (modePick.mode === 'new') { + if (sourceRef) { + args.push(sourceRef); // 可选 start-point(默认 HEAD) + } + } else { + args.push(sourceRef!); // checkout / detached 必须有源 ref + } + try { + await service.execGit(args); + worktreeTree.refresh(); + void vscode.window.showInformationMessage(`已创建 Worktree:${wtPath.trim()}`); + } catch (e) { + void vscode.window.showErrorMessage(`创建 Worktree 失败:${errMsg(e)}`); + } + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.worktreeOpen', async (node?: WorktreeNode) => { + if (!node || node.kind !== 'worktree') { + return; + } + // 默认新窗口打开:本项目单仓库选取模式(pickRepository 只取首个),多根同窗会破坏 + // SCM/分支/日志数据源;独立窗口符合 worktree 并行工作语义。 + await vscode.commands.executeCommand('vscode.openFolder', vscode.Uri.file(node.path), { forceNewWindow: true }); + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.worktreeRemove', async (node?: WorktreeNode) => { + const repo = service.repo; + if (!repo || !node || node.kind !== 'worktree') { + return; + } + if (node.isMain) { + void vscode.window.showWarningMessage('主工作树不可删除'); + return; + } + if (worktreeTree.isCurrent(node)) { + void vscode.window.showWarningMessage('当前打开的 Worktree 不可删除,请先切换到其他窗口'); + return; + } + const forcePick = await vscode.window.showQuickPick( + [ + { label: '删除', description: '含未提交改动将失败', f: false as const }, + { label: '强制删除', description: '忽略未提交改动(不可逆)', f: true as const }, + ], + { placeHolder: '删除 Worktree' }, + ); + if (!forcePick) { + return; + } + const ok = await vscode.window.showWarningMessage( + `删除 Worktree?\n${node.path}\n(${node.ref})`, + { modal: true }, + '删除', + ); + if (ok !== '删除') { + return; + } + try { + const args = ['worktree', 'remove']; + if (forcePick.f) { + args.push('--force'); + } + args.push(node.path); + await service.execGit(args); + worktreeTree.refresh(); + void vscode.window.showInformationMessage(`已删除 Worktree:${node.name}`); + } catch (e) { + void vscode.window.showErrorMessage(`删除 Worktree 失败:${errMsg(e)}`); + } + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.worktreeCopyPath', async (node?: WorktreeNode) => { + if (!node || node.kind !== 'worktree') { + return; + } + await vscode.env.clipboard.writeText(node.path); + void vscode.window.showInformationMessage('已复制 Worktree 路径'); + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.worktreeLock', async (node?: WorktreeNode) => { + const repo = service.repo; + if (!repo || !node || node.kind !== 'worktree') { + return; + } + const reason = await vscode.window.showInputBox({ prompt: '锁定原因(可空)', placeHolder: '正在备份' }); + if (reason === undefined) { + return; // Esc 取消;空字符串 = 无原因(允许) + } + try { + const args = ['worktree', 'lock']; + if (reason.trim()) { + args.push('--reason', reason.trim()); + } + args.push(node.path); + await service.execGit(args); + worktreeTree.refresh(); + void vscode.window.showInformationMessage(`已锁定 Worktree:${node.name}`); + } catch (e) { + void vscode.window.showErrorMessage(`锁定 Worktree 失败:${errMsg(e)}`); + } + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.worktreeUnlock', async (node?: WorktreeNode) => { + const repo = service.repo; + if (!repo || !node || node.kind !== 'worktree') { + return; + } + try { + await service.execGit(['worktree', 'unlock', node.path]); + worktreeTree.refresh(); + void vscode.window.showInformationMessage(`已解锁 Worktree:${node.name}`); + } catch (e) { + void vscode.window.showErrorMessage(`解锁 Worktree 失败:${errMsg(e)}`); + } + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.worktreeMove', async (node?: WorktreeNode) => { + const repo = service.repo; + if (!repo || !node || node.kind !== 'worktree') { + return; + } + if (node.isMain) { + void vscode.window.showWarningMessage('主工作树不可移动'); + return; + } + const dest = await vscode.window.showInputBox({ + prompt: '新路径(相对仓库根 / 绝对)', + placeHolder: '../new-location', + }); + if (!dest?.trim()) { + return; + } + try { + await service.execGit(['worktree', 'move', node.path, dest.trim()]); + worktreeTree.refresh(); + void vscode.window.showInformationMessage(`已移动 Worktree → ${dest.trim()}`); + } catch (e) { + void vscode.window.showErrorMessage(`移动 Worktree 失败:${errMsg(e)}`); + } + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.worktreePrune', async () => { + const repo = service.repo; + if (!repo) { + return; + } + // 不静默清理:先列出 prunable 项,无则提示;有则 modal 确认(防误删网络/移动盘合法 worktree)。 + let prunablePaths: string[] = []; + try { + const out = await service.execGit(['worktree', 'list', '--porcelain', '-z']); + prunablePaths = parseWorktreeList(out) + .filter((p) => p.prunable) + .map((p) => p.path); + } catch (e) { + void vscode.window.showErrorMessage(`读取 Worktree 列表失败:${errMsg(e)}`); + return; + } + if (prunablePaths.length === 0) { + void vscode.window.showInformationMessage('无可清理的失效 Worktree'); + return; + } + const ok = await vscode.window.showWarningMessage( + `清理 ${prunablePaths.length} 个失效 Worktree 元数据?\n${prunablePaths.join('\n')}`, + { modal: true }, + '清理', + ); + if (ok !== '清理') { + return; + } + try { + await service.execGit(['worktree', 'prune', '-v']); + worktreeTree.refresh(); + void vscode.window.showInformationMessage(`已清理 ${prunablePaths.length} 个失效 Worktree`); + } catch (e) { + void vscode.window.showErrorMessage(`清理 Worktree 失败:${errMsg(e)}`); + } + }), + ); + + return subs; +} diff --git a/src/engine/worktree/worktree-list.ts b/src/engine/worktree/worktree-list.ts new file mode 100644 index 0000000..f050408 --- /dev/null +++ b/src/engine/worktree/worktree-list.ts @@ -0,0 +1,113 @@ +/** + * `git worktree list --porcelain -z` 输出解析器(纯逻辑,零 vscode 依赖)。 + * + * 目的:为 Worktrees 视图提供可靠的工作树枚举底座。vscode.git 稳定 API 未暴露 worktree 的 + * 创建/删除操作(`Repository` 接口无 createWorktree/deleteWorktree),`RepositoryState.worktrees` + * 为只读且版本敏感。故改用受控 CLI 通道执行 `git worktree list`,一次拿全字段(含 main / detached / + * locked / prunable 标记),供 adapter 渲染与生命周期操作复用。 + * + * 配套 CLI(`-z` NUL 分隔,规避 locked/prunable 的 reason 含换行的歧义): + * git worktree list --porcelain -z + * + * `-z` 输出格式(实测 git 2.x,**无 =v1/=v2 版本控制**):每个「字段行」后跟一个 NUL, + * 记录之间以**空段**(连续双 NUL 之间的空串)分隔——**整体不含 `\n`**。字段行语义: + * - `worktree `:记录起始;`HEAD `;`branch refs/heads/`;`detached`; + * `bare`;`locked []`(reason 同字段,特殊字符时引号转义);`prunable []`。 + * - 第一条有效记录即 main worktree(list 顺序保证);bare 仓库首块仅有 `worktree`+`bare`。 + * + * 解析策略:按 NUL 切段,逐段累积到当前记录;遇空段(记录边界)或下一条 `worktree` 段时提交当前记录。 + */ + +/** 一条 worktree 的解析结果。 */ +export interface ParsedWorktree { + /** 绝对路径(跨平台原样存储,比较时归一)。 */ + readonly path: string; + /** 完整 sha(bare 块为空字符串)。 */ + readonly commit: string; + /** 短分支名(已去 `refs/heads/` 前缀);detached / bare 为 undefined。 */ + readonly branch: string | undefined; + /** 是否处于 detached HEAD。 */ + readonly detached: boolean; + /** 是否主工作树(首条记录 或 bare)。 */ + readonly isMain: boolean; + /** 是否已锁定(防止 prune 自动清理)。 */ + readonly locked: boolean; + /** 是否可清理(目录已失效,元数据待 prune)。 */ + readonly prunable: boolean; +} + +const WT_PREFIX = 'worktree '; +const HEAD_PREFIX = 'HEAD '; +const BRANCH_PREFIX = 'branch '; +const HEADS_PREFIX = 'refs/heads/'; + +/** + * 解析 `git worktree list --porcelain -z` 输出为 ParsedWorktree[]。 + * 容错:缺 `worktree` 段的孤立字段(如残块)不产生记录(hasPath 守护); + * locked/prunable 的 reason 同段(`locked `),仅取布尔标记(reason 不解析)。 + * 第一条成功提交的记录即 main worktree(list 顺序保证)。 + */ +export function parseWorktreeList(output: string): ParsedWorktree[] { + const results: ParsedWorktree[] = []; + let path = ''; + let commit = ''; + let branch: string | undefined; + let detached = false; + let bare = false; + let locked = false; + let prunable = false; + let hasPath = false; + + const flush = (): void => { + if (hasPath) { + results.push({ + path, + commit, + branch, + detached, + isMain: results.length === 0 || bare, + locked, + prunable, + }); + } + path = ''; + commit = ''; + branch = undefined; + detached = false; + bare = false; + locked = false; + prunable = false; + hasPath = false; + }; + + for (const seg of output.split('\x00')) { + if (seg.length === 0) { + // -z 记录边界为空段 → 提交当前记录。 + flush(); + continue; + } + if (seg.startsWith(WT_PREFIX)) { + // 防御性:遇下一条 worktree 段时先提交前一条(正常情况空段已 flush)。 + if (hasPath) { + flush(); + } + path = seg.slice(WT_PREFIX.length); + hasPath = true; + } else if (seg.startsWith(HEAD_PREFIX)) { + commit = seg.slice(HEAD_PREFIX.length); + } else if (seg.startsWith(BRANCH_PREFIX)) { + const ref = seg.slice(BRANCH_PREFIX.length); + branch = ref.startsWith(HEADS_PREFIX) ? ref.slice(HEADS_PREFIX.length) : ref; + } else if (seg === 'detached') { + detached = true; + } else if (seg === 'bare') { + bare = true; + } else if (seg === 'locked' || seg.startsWith('locked ')) { + locked = true; + } else if (seg === 'prunable' || seg.startsWith('prunable ')) { + prunable = true; + } + } + flush(); + return results; +} diff --git a/src/extension.ts b/src/extension.ts index a43992c..21aa43a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -18,6 +18,8 @@ import { registerPartialCommands } from './adapter/partial-commands'; import { registerAdvancedCommands } from './adapter/advanced-commands'; import { registerRemoteCommands } from './adapter/remote-commands'; import { StashTreeProvider } from './adapter/tree/stash-tree'; +import { WorktreeTreeProvider } from './adapter/tree/worktree-tree'; +import { registerWorktreeCommands } from './adapter/worktree-commands'; import { CommitWebviewProvider } from './adapter/webview/commit-webview'; import { GraphWebview } from './adapter/webview/graph-webview'; import { showGitConsole } from './infra/git-console'; @@ -50,7 +52,11 @@ export async function activate(context: vscode.ExtensionContext): Promise const api = await getGitApi(); if (!api) { logger.warn('vscode.git API 不可用,视图保持空状态'); - context.subscriptions.push(vscode.window.registerTreeDataProvider('hyperGit.changes', new EmptyChangesProvider())); + const empty = new EmptyChangesProvider(); + context.subscriptions.push( + vscode.window.registerTreeDataProvider('hyperGit.changes', empty), + vscode.window.registerTreeDataProvider('hyperGit.worktrees', empty), + ); return; } @@ -75,6 +81,7 @@ export async function activate(context: vscode.ExtensionContext): Promise const logTree = new LogTreeProvider(service); const branchesTree = new BranchesTreeProvider(service, favorites); const stashTree = new StashTreeProvider(service); + const worktreeTree = new WorktreeTreeProvider(service); const inlineLens = new InlineCommitCodeLensProvider(service); const blame = new BlameAnnotationController(service); const shelfService = new ShelfService(service, context.globalStorageUri.fsPath); @@ -91,6 +98,7 @@ export async function activate(context: vscode.ExtensionContext): Promise logTree, branchesTree, stashTree, + worktreeTree, shelfTree, blame, changesView, @@ -99,6 +107,7 @@ export async function activate(context: vscode.ExtensionContext): Promise vscode.window.registerTreeDataProvider('hyperGit.branches', branchesTree), vscode.window.registerTreeDataProvider('hyperGit.stash', stashTree), vscode.window.registerTreeDataProvider('hyperGit.shelf', shelfTree), + vscode.window.registerTreeDataProvider('hyperGit.worktrees', worktreeTree), ...registerChangesCommands(service, registry, tree), ...registerHistoryCommands(service, logTree, branchesTree, favorites), ...registerGitCliCommands(service, branchesTree, logTree), @@ -110,6 +119,7 @@ export async function activate(context: vscode.ExtensionContext): Promise vscode.commands.registerCommand('hyperGit.toggleBlameAnnotation', () => blame.toggle()), ...registerStashCommands(service, stashTree), ...registerShelfCommands(service, shelfService, shelfTree), + ...registerWorktreeCommands(service, worktreeTree), vscode.commands.registerCommand('hyperGit.commit', focusCommitView), vscode.commands.registerCommand('hyperGit.commitAndPush', focusCommitView), vscode.commands.registerCommand('hyperGit.showGraph', () => GraphWebview.open(service)), @@ -137,6 +147,7 @@ export async function activate(context: vscode.ExtensionContext): Promise logTree.refresh(); branchesTree.refresh(); stashTree.refresh(); + worktreeTree.refresh(); shelfTree.refresh(); inlineLens.refresh(); }, 150); @@ -153,6 +164,7 @@ export async function activate(context: vscode.ExtensionContext): Promise setTimeout(() => { branchesTree.refresh(); logTree.refresh(); + worktreeTree.refresh(); updateChangesBadge(); }, 500); } diff --git a/tests/unit/worktree-list.test.ts b/tests/unit/worktree-list.test.ts new file mode 100644 index 0000000..379d10e --- /dev/null +++ b/tests/unit/worktree-list.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from 'vitest'; +import { parseWorktreeList } from '../../src/engine/worktree/worktree-list'; + +const NUL = '\x00'; + +/** + * 构造一条 `git worktree list --porcelain -z` 记录。 + * -z 真实格式:每个字段后一个 NUL,记录末尾额外一个 NUL 形成空段(双 NUL)边界,整体不含 \n。 + */ +function record(path: string, fields: string[]): string { + return [`worktree ${path}`, ...fields].join(NUL) + NUL + NUL; +} + +const SHA_MAIN = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'; +const SHA_FEAT = '1111222233334444555566667777888899990000'; +const SHA_DET = 'aaaabbbbccccddddeeeeffff0000111122223333'; +const SHA_LOCK = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; +const SHA_PRUNE = 'cafecafecafecafecafecafecafecafecafecafe'; + +/** 真实多 worktree 仓库的完整样本(main + linked 分支 + detached + locked + prunable)。 */ +const SAMPLE = [ + record('/repo/main', [`HEAD ${SHA_MAIN}`, 'branch refs/heads/main']), + record('/repo/feature', [`HEAD ${SHA_FEAT}`, 'branch refs/heads/feature/x']), + record('/repo/det', [`HEAD ${SHA_DET}`, 'detached']), + record('/repo/locked', [`HEAD ${SHA_LOCK}`, 'branch refs/heads/locked-b', 'locked 正在备份']), + record('/repo/gone', [`HEAD ${SHA_PRUNE}`, 'branch refs/heads/gone-b', 'prunable gitdir 指向已删除目录']), +].join(''); + +describe('parseWorktreeList', () => { + it('解析全部 worktree 记录', () => { + const list = parseWorktreeList(SAMPLE); + expect(list).toHaveLength(5); + }); + + it('第一条记录标记为 main,其余非 main', () => { + const list = parseWorktreeList(SAMPLE); + expect(list[0].isMain).toBe(true); + expect(list.slice(1).every((w) => w.isMain === false)).toBe(true); + }); + + it('解析绝对路径与完整 sha', () => { + const list = parseWorktreeList(SAMPLE); + expect(list[0].path).toBe('/repo/main'); + expect(list[0].commit).toBe(SHA_MAIN); + expect(list[1].path).toBe('/repo/feature'); + }); + + it('分支名去 refs/heads/ 前缀', () => { + const list = parseWorktreeList(SAMPLE); + expect(list[0].branch).toBe('main'); + expect(list[1].branch).toBe('feature/x'); + }); + + it('detached 记录:无 branch 字段,detached=true,仍解析 HEAD', () => { + const list = parseWorktreeList(SAMPLE); + const det = list.find((w) => w.path === '/repo/det'); + expect(det?.detached).toBe(true); + expect(det?.branch).toBeUndefined(); + expect(det?.commit).toBe(SHA_DET); + }); + + it('locked 标记(reason 同段,仅取布尔)', () => { + const list = parseWorktreeList(SAMPLE); + const locked = list.find((w) => w.path === '/repo/locked'); + expect(locked?.locked).toBe(true); + expect(locked?.prunable).toBe(false); + }); + + it('prunable 标记(reason 同段,仅取布尔)', () => { + const list = parseWorktreeList(SAMPLE); + const gone = list.find((w) => w.path === '/repo/gone'); + expect(gone?.prunable).toBe(true); + expect(gone?.locked).toBe(false); + }); + + it('locked 字段无 reason 时也标记 locked=true', () => { + const out = [ + record('/r/main', [`HEAD ${SHA_MAIN}`, 'branch refs/heads/main']), + record('/r/l', [`HEAD ${SHA_LOCK}`, 'branch refs/heads/x', 'locked']), + ].join(''); + const list = parseWorktreeList(out); + expect(list[1].locked).toBe(true); + }); + + it('bare 仓库首块:仅 worktree + bare,isMain=true,branch=undefined,commit 为空', () => { + const out = [ + record('/bare/repo', ['bare']), + record('/bare/wt', [`HEAD ${SHA_MAIN}`, 'branch refs/heads/main']), + ].join(''); + const list = parseWorktreeList(out); + expect(list).toHaveLength(2); + expect(list[0].isMain).toBe(true); + expect(list[0].branch).toBeUndefined(); + expect(list[0].commit).toBe(''); + expect(list[1].isMain).toBe(false); + }); + + it('容错:忽略尾部空段(record 末尾双 NUL)', () => { + const out = record('/r/main', [`HEAD ${SHA_MAIN}`, 'branch refs/heads/main']); + expect(parseWorktreeList(out)).toHaveLength(1); + }); + + it('容错:无 worktree 字段的孤立段不产生记录', () => { + // 第二段只有 HEAD 字段(缺 worktree 起始),应被丢弃;仅保留第一记录 + const out = record('/r/main', [`HEAD ${SHA_MAIN}`, 'branch refs/heads/main']) + `HEAD orphan${SHA_DET}` + NUL; + const list = parseWorktreeList(out); + expect(list).toHaveLength(1); + expect(list[0].path).toBe('/r/main'); + }); + + it('空输入返回空数组', () => { + expect(parseWorktreeList('')).toHaveLength(0); + }); +});