diff --git a/CHANGELOG.md b/CHANGELOG.md index 594c56a..6484a26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ ## [Unreleased] +### Added — Parity Recovery(Batch 5-12,IDEA 全量对齐) + +> 基于「IDEA Git 功能复刻全量对齐」评审(用户截图反馈 Branches 空白/工具栏缺失),经 3 路代码审计确认「功能多数已实现但被视图 bug + 工具栏未浮现掩盖」,本批次先解除可见痛感再全量补齐。共新增 **13 个 engine 纯逻辑模块 + 21 个命令**,单测 64 → 166。 + +- **Branches 视图渲染修复(Batch5)**:`engine/ref/for-each-ref`(NUL 分隔 + upstream/HEAD/ahead-behind track)+ async getChildren + `repo.state.refs` 为空时 `git for-each-ref` CLI 兜底 + 首帧刷新(修复初始 `_onDidChange.fire` 早于订阅挂载而丢失的根因)。 +- **工具栏 Action 组补齐 + 命令 bug(Batch5)**:Changes 加 fetch/pull/push/commit;Branches 加 fetch;Log 加 cherry-pick/revert/reset;cherry-pick/revert 成功后刷新;resetHead 重写(修 `HEAD~0` 致 mixed/keep 失效);branchDelete 加 `--merged` 检查;merge/rebase 加确认。 +- **冲突兜底引导(Batch5)**:`engine/git-state/conflict-detector` + `adapter/conflict-ui`;merge/rebase/pull/cherry-pick/revert/stash-pop/unshelve 失败检测冲突弹「解决/中止」。 +- **Branches 对齐 IDEA(Batch6)**:Favorites/Local/Remote/Tags 四段分组 + ahead/behind/upstream 展示 + `engine/ref/favorites`/`cleanup`(提取复用)+ toggleFavorite/checkoutAsNew/compareWithCurrent/tagCreate/tagDelete/tagCheckout。 +- **交互式 Rebase webview 1:1 复刻(Batch7)**:补 reword/edit + 拖拽重排序;reword 经 `process.execPath` 跑 Node editor helper + state 文件非交互写入新 message(已功能性验证);`engine/rebase/todo`。 +- **Log 提交详情面板 + 高级过滤(Batch8)**:commit 展开显示变更文件(`engine/log/commit-files`)+ 单文件 diff(commit^ vs commit)+ `engine/log/log-filter`(合并模式/日期/正则)+ per-commit 操作(resetToHere/createBranchFromCommit/createTagFromCommit/showContainingBranches)。 +- **真实 SVG 提交拓扑图(Batch9)**:解析 `git log --graph` 字符粒度渲染(`*` 圆点可点击 / `|` 竖线 / `/ \` 斜线,多色 lane)+ 实时刷新 + 节点点击 QuickPick;`engine/log/graph-parser`。 +- **Push/Update/Merge 对话框(Batch10)**:force/force-with-lease/push tags、pull --rebase/--no-rebase、merge ff-only/no-ff/squash + message;fetch --prune;`engine/commit/trailer`(Co-authored-by)。 +- **自绘 3-way Merge Editor(Batch11)**:自实现 `engine/merge/diff3`(基于 LCS,双方同改动自动消解)+ WebviewPanel 三栏(OURS/RESULT 可编辑/THEIRS)+ Accept 按钮 + 写回 `git add`;handleGitConflict「解决冲突」直接调起。 +- **Phase 4/5 收尾(Batch12)**:Stash 高级(keep-index/clear/branch)、Patch create/apply、Reflog 视图、编辑器内 Blame 行内注解(`engine/blame/blame-parser` + `adapter/editor/blame-annotation`)。 + +### 架构与质量 +- 新增 engine 模块(零 vscode 依赖、可单测):`ref/{for-each-ref,favorites,cleanup}`、`rebase/todo`、`log/{log-filter,commit-files,graph-parser}`、`merge/diff3`、`commit/trailer`、`git-state/conflict-detector`、`blame/blame-parser`。 +- 命令 56 → 77;单测 64 → 166(+21 文件);集成 3/3;lint/类型/构建全程 GREEN。 +- 关键算法(reword helper / graph 解析 / diff3 / blame 解析)均做真实 git 数据功能性验证。 +- git 底座:稳定 API 能做的用 `Repository.*`;缺口用 `GitRepositoryService.execGit`(复用 `api.git.path` 同一二进制)。 + +### 待补(后续) +- Commit webview 的 Co-authored-by / author 覆盖(`--author`)/ undo-last-commit 按钮 UI 接线(engine trailer 已就绪)。 +- Partial 多文件选择 UX、行级 split chunks(IDEA "Include Selected Lines")。 +- 目录/folder diff(虚拟文档)、Submodules 管理。 +- M5 AI Agent(5 个接缝已预埋 Null 实现,本轮未启动)。 + + + ## [0.0.1-rc.2] - 2026-06-28 — 第二个预发布候选 包含 rc.1 后的全部 Parity Recovery(Batch 1-4 + Editor Inline Commit),大幅补齐 IDEA Git 功能复刻。 diff --git a/README.md b/README.md index f5cd02e..d9e2716 100644 --- a/README.md +++ b/README.md @@ -33,13 +33,14 @@ IntelliJ IDEA 的统一 Git 工具窗口(顶部 `Commit / Shelf / Stash` 标 ## 已知限制 -> 以下受 vscode.git 稳定 API 边界限制,暂不可用(详见 [实施状态 §3](./docs/milestones/implementation-status.md#3-api-限制汇总vscodegit-稳定-api-边界)): +> vscode.git 稳定 API 不含 cherry-pick / revert / reset / 分支重命名 / hunk 暂存 / stash list / graph topology / shelf / author 覆盖等;**这些均已通过 `GitRepositoryService.execGit`(复用 `api.git.path` 同一 git 二进制)的受控 CLI 通道实现**(Batch 5-12 全量对齐,详见 [CHANGELOG](./CHANGELOG.md) [Unreleased])。 -- cherry-pick / revert(commit-level)/ reset / 分支重命名:稳定 API 无对应方法。 -- 行级 / hunk 级 partial commit:`add` 仅整文件。 -- 多 stash 列表枚举:API 无 `git stash list`(当前 apply/pop/drop 作用于 stash@{0})。 -- Author 覆盖:`commit()` 的 `CommitOptions` 无 `author` 字段。 -- 完整 Log 拓扑图(SVG 连线):作为 M3.x 增强。 +当前仍待补: + +- Commit 窗口的 Co-authored-by / Author 覆盖(`--author`)/ undo-last-commit **按钮 UI 接线**(engine `trailer` 已就绪,仅缺 webview 交互)。 +- Partial 多文件选择 UX、行级 split chunks(IDEA "Include Selected Lines")。 +- 目录 / folder diff(虚拟文档)、Submodules 管理。 +- M5 AI Agent(5 个接缝已预埋 Null 实现,本轮未启动)。 详见[工程实施方案](./docs/architecture/engineering-plan.md)、[实施状态总览](./docs/milestones/implementation-status.md)与[知识索引](./.agents/knowledge-map.md)。 diff --git a/docs/milestones/implementation-status.md b/docs/milestones/implementation-status.md index 2c2a841..df0cdb1 100644 --- a/docs/milestones/implementation-status.md +++ b/docs/milestones/implementation-status.md @@ -2,7 +2,10 @@ > Hyper Git VS Code 扩展的里程碑实施记录、API 限制发现、功能达成矩阵与 M5 AI 设计(留存)。 > 本文档随里程碑推进即时更新;调研与原始方案见 [工程实施方案](../architecture/engineering-plan.md) 与 [调研报告](../research/README.md)。 -> 最后更新:2026-06-27(M0-M4 落地完成,准备 v0.0.1-rc.1)。 +> 最后更新:2026-06-29(Parity Recovery Batch 5-12 全量对齐 IDEA 完成)。 +> +> **⚠️ 重大更新(2026-06-29,Batch 5-12)**:经 IDEA Git 功能复刻评审,发现「功能多数已实现,但被 Branches 视图渲染 bug + 工具栏未浮现 + 命令 bug 掩盖」。本批 Recovery **先解除可见痛感,再全量补齐**:新增 13 个 engine 纯逻辑模块 + 21 个命令,单测 64 → 166、命令 56 → 77。**§3 所列 vscode.git 稳定 API 限制(cherry-pick/revert/reset/branch rename/hunk 暂存/stash list/graph topology/shelf/author 等)现均已通过 `GitRepositoryService.execGit`(复用 `api.git.path` 同一 git 二进制)受控 CLI 通道解决**。详见 [CHANGELOG](../../CHANGELOG.md) [Unreleased]。 +> AI M5 暂不启动(5 个接缝保留 Null 实现,本轮专注 IDE 对齐)。 --- @@ -23,7 +26,10 @@ | M2 Commit 窗口 | 0.3.0 | [#4](https://github.com/ThreeFish-AI/hyper-git/pull/4) | Commit WebviewView(勾选+多行编辑器+Amend/signoff/skipHooks+CC 实时校验)、CommitPipeline(Checkin hook 链)、5 AI 接缝 Null 注入 | 集成:真实 git 提交闭环 | | M0-M2 审查修复 | 0.3.1 | [#5](https://github.com/ThreeFish-AI/hyper-git/pull/5) | 11 类正确性修复(订阅泄漏/仓库选取/indexChanges/commit 语义/push 警告/linter 等)+ 16 项测试补齐 | 单测 45/45 + 集成 3/3 | | M3 Log/Branches/Blame | 0.4.0 | [#6](https://github.com/ThreeFish-AI/hyper-git/pull/6) | Log TreeView(过滤+copy hash+Show History)、Branches TreeView(create/checkout/delete/merge/rebase)、Blame | 命令注册集成 | -| M4 Stash/Shelf | 0.5.0 | [#7](https://github.com/ThreeFish-AI/hyper-git/pull/7) | Stash 视图(log refNames 枚举)+ create/apply/pop/drop;Shelf MVP(stash 近似) | 命令注册集成 | +| M4 Stash/Shelf | 0.5.0 | [#7](https://github.com/ThreeFish-AI/hyper-git/pull/7) | Stash 视图(log refnames 枚举)+ create/apply/pop/drop;Shelf MVP(stash 近似) | 命令注册集成 | +| **Parity Recovery Batch 5-7** | — | commit `00b5ba7`/`2105445`/`9699ee9` | Branches 渲染修复 + 工具栏 Action 组 + 冲突兜底;Branches 对齐 IDEA(Favorites/Tags/ahead-behind);Rebase webview reword/edit/拖拽 1:1 复刻 | 单测 166/166 + 集成 3/3 | +| **Parity Recovery Batch 8-9** | — | commit `efd888c`/`ed01883` | Log 提交详情面板 + 高级过滤 + per-commit 操作;真实 SVG 提交拓扑图(解析 git --graph 字符粒度渲染 + 可点击 + 实时刷新) | 同上 | +| **Parity Recovery Batch 10-12** | — | commit `2274d8a`/`fcb6ffa`/`acdf53c` | Push/Update/Merge 对话框 + fetch prune + Co-authored-by trailer;自绘 3-way Merge Editor(自实现 diff3);Stash 高级 + Patch + Reflog + Blame 行内注解 | 同上 | ## 2. P0/P1 功能达成矩阵(对照 [IDEA 功能矩阵](../requirements/idea-feature-matrix.md)) diff --git a/package.json b/package.json index 7570c49..4a3c30a 100644 --- a/package.json +++ b/package.json @@ -128,13 +128,28 @@ { "command": "hyperGit.discardChanges", "title": "丢弃改动", "category": "Hyper Git" }, { "command": "hyperGit.pull", "title": "Pull", "category": "Hyper Git", "icon": "$(arrow-down)" }, { "command": "hyperGit.push", "title": "Push", "category": "Hyper Git", "icon": "$(arrow-up)" }, - { "command": "hyperGit.fetch", "title": "Fetch", "category": "Hyper Git" }, - { "command": "hyperGit.cherryPick", "title": "Cherry-Pick", "category": "Hyper Git" }, - { "command": "hyperGit.revertCommit", "title": "Revert Commit", "category": "Hyper Git" }, - { "command": "hyperGit.resetHead", "title": "Reset HEAD…", "category": "Hyper Git" }, + { "command": "hyperGit.fetch", "title": "Fetch", "category": "Hyper Git", "icon": "$(repo-fetch)" }, + { "command": "hyperGit.cherryPick", "title": "Cherry-Pick", "category": "Hyper Git", "icon": "$(repo-pull)" }, + { "command": "hyperGit.revertCommit", "title": "Revert Commit", "category": "Hyper Git", "icon": "$(discard)" }, + { "command": "hyperGit.resetHead", "title": "Reset HEAD…", "category": "Hyper Git", "icon": "$(history)" }, { "command": "hyperGit.branchRename", "title": "重命名分支", "category": "Hyper Git" }, { "command": "hyperGit.ignorePath", "title": "添加到 .gitignore", "category": "Hyper Git" }, { "command": "hyperGit.compareBranches", "title": "比较分支", "category": "Hyper Git" }, + { "command": "hyperGit.toggleFavorite", "title": "收藏/取消收藏", "category": "Hyper Git", "icon": "$(star-full)" }, + { "command": "hyperGit.checkoutAsNew", "title": "从选中分支新建并检出", "category": "Hyper Git" }, + { "command": "hyperGit.compareWithCurrent", "title": "与当前分支比较", "category": "Hyper Git", "icon": "$(compare)" }, + { "command": "hyperGit.tagCreate", "title": "新建标签", "category": "Hyper Git", "icon": "$(tag)" }, + { "command": "hyperGit.tagDelete", "title": "删除标签", "category": "Hyper Git" }, + { "command": "hyperGit.tagCheckout", "title": "检出标签", "category": "Hyper Git" }, + { "command": "hyperGit.openCommitFileDiff", "title": "打开提交文件 Diff", "category": "Hyper Git" }, + { "command": "hyperGit.resetToHere", "title": "Reset 当前分支到此提交…", "category": "Hyper Git", "icon": "$(history)" }, + { "command": "hyperGit.createBranchFromCommit", "title": "从此提交新建分支", "category": "Hyper Git", "icon": "$(git-branch)" }, + { "command": "hyperGit.createTagFromCommit", "title": "从此提交新建标签", "category": "Hyper Git", "icon": "$(tag)" }, + { "command": "hyperGit.showContainingBranches", "title": "查看包含此提交的分支", "category": "Hyper Git" }, + { "command": "hyperGit.logFilterGrep", "title": "按 message 过滤(grep)", "category": "Hyper Git" }, + { "command": "hyperGit.logFilterMergeMode", "title": "合并提交过滤", "category": "Hyper Git" }, + { "command": "hyperGit.logFilterDate", "title": "按日期过滤", "category": "Hyper Git" }, + { "command": "hyperGit.logFilterRegex", "title": "按 message 正则过滤", "category": "Hyper Git" }, { "command": "hyperGit.rewordCommit", "title": "改写最新提交信息", "category": "Hyper Git" }, { "command": "hyperGit.showGraph", "title": "查看提交图(Graph)", "category": "Hyper Git", "icon": "$(git-commit)" }, { "command": "hyperGit.showConsole", "title": "打开 Console", "category": "Hyper Git" }, @@ -145,6 +160,12 @@ { "command": "hyperGit.dropCommit", "title": "删除提交(rebase)", "category": "Hyper Git" }, { "command": "hyperGit.fixupCommit", "title": "Fixup 到提交(autosquash)", "category": "Hyper Git" }, { "command": "hyperGit.cleanupBranches", "title": "清理已合并分支", "category": "Hyper Git" }, + { "command": "hyperGit.pushDialog", "title": "Push…", "category": "Hyper Git", "icon": "$(repo-push)" }, + { "command": "hyperGit.updateProject", "title": "Update Project…", "category": "Hyper Git", "icon": "$(sync)" }, + { "command": "hyperGit.mergeDialog", "title": "Merge…", "category": "Hyper Git", "icon": "$(git-merge)" }, + { "command": "hyperGit.resolveConflicts", "title": "解决冲突(3-way Merge Editor)…", "category": "Hyper Git", "icon": "$(git-merge)" }, + { "command": "hyperGit.acceptOurs", "title": "冲突文件:采用 Ours", "category": "Hyper Git" }, + { "command": "hyperGit.acceptTheirs", "title": "冲突文件:采用 Theirs", "category": "Hyper Git" }, { "command": "hyperGit.copyBranchRef", "title": "复制分支引用", "category": "Hyper Git" }, { "command": "hyperGit.threeWayDiff", "title": "3-way Diff 概览", "category": "Hyper Git" }, { "command": "hyperGit.inlineCommitHunk", "title": "行内提交 Hunk", "category": "Hyper Git" }, @@ -153,22 +174,52 @@ { "command": "hyperGit.unshelveWithMerge", "title": "Unshelve with Merge", "category": "Hyper Git" }, { "command": "hyperGit.deleteShelf", "title": "Delete Shelf", "category": "Hyper Git" }, { "command": "hyperGit.startRebase", "title": "Interactive Rebase…", "category": "Hyper Git" }, - { "command": "hyperGit.moveHunkToChangelist", "title": "Move Hunk to Changelist", "category": "Hyper Git" } + { "command": "hyperGit.moveHunkToChangelist", "title": "Move Hunk to Changelist", "category": "Hyper Git" }, + { "command": "hyperGit.stashKeepIndex", "title": "Stash(保留已暂存)", "category": "Hyper Git" }, + { "command": "hyperGit.stashClear", "title": "清空所有 Stash", "category": "Hyper Git" }, + { "command": "hyperGit.stashBranch", "title": "从 Stash 创建分支", "category": "Hyper Git" }, + { "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)" } ], "menus": { "view/title": [ { "command": "hyperGit.refresh", "when": "view == hyperGit.changes", "group": "navigation" }, { "command": "hyperGit.newChangelist", "when": "view == hyperGit.changes", "group": "navigation" }, + { "command": "hyperGit.commit", "when": "view == hyperGit.changes", "group": "navigation" }, + { "command": "hyperGit.commitAndPush", "when": "view == hyperGit.changes", "group": "navigation" }, + { "command": "hyperGit.fetch", "when": "view == hyperGit.changes", "group": "navigation" }, + { "command": "hyperGit.pull", "when": "view == hyperGit.changes", "group": "navigation" }, + { "command": "hyperGit.push", "when": "view == hyperGit.changes", "group": "navigation" }, + { "command": "hyperGit.pushDialog", "when": "view == hyperGit.changes", "group": "navigation" }, + { "command": "hyperGit.updateProject", "when": "view == hyperGit.changes", "group": "navigation" }, + { "command": "hyperGit.createPatch", "when": "view == hyperGit.changes" }, + { "command": "hyperGit.applyPatch", "when": "view == hyperGit.changes" }, { "command": "hyperGit.refreshLog", "when": "view == hyperGit.log", "group": "navigation" }, { "command": "hyperGit.showGraph", "when": "view == hyperGit.log", "group": "navigation" }, + { "command": "hyperGit.cherryPick", "when": "view == hyperGit.log", "group": "navigation" }, + { "command": "hyperGit.revertCommit", "when": "view == hyperGit.log", "group": "navigation" }, + { "command": "hyperGit.resetHead", "when": "view == hyperGit.log", "group": "navigation" }, { "command": "hyperGit.logFilterAuthor", "when": "view == hyperGit.log" }, { "command": "hyperGit.logFilterPath", "when": "view == hyperGit.log" }, { "command": "hyperGit.logClearFilter", "when": "view == hyperGit.log" }, + { "command": "hyperGit.logFilterGrep", "when": "view == hyperGit.log" }, + { "command": "hyperGit.logFilterMergeMode", "when": "view == hyperGit.log" }, + { "command": "hyperGit.logFilterDate", "when": "view == hyperGit.log" }, + { "command": "hyperGit.logFilterRegex", "when": "view == hyperGit.log" }, + { "command": "hyperGit.showReflog", "when": "view == hyperGit.log", "group": "navigation" }, { "command": "hyperGit.refreshBranches", "when": "view == hyperGit.branches", "group": "navigation" }, { "command": "hyperGit.branchCreate", "when": "view == hyperGit.branches", "group": "navigation" }, { "command": "hyperGit.pull", "when": "view == hyperGit.branches", "group": "navigation" }, { "command": "hyperGit.push", "when": "view == hyperGit.branches", "group": "navigation" }, + { "command": "hyperGit.fetch", "when": "view == hyperGit.branches", "group": "navigation" }, + { "command": "hyperGit.mergeDialog", "when": "view == hyperGit.branches", "group": "navigation" }, + { "command": "hyperGit.updateProject", "when": "view == hyperGit.branches", "group": "navigation" }, + { "command": "hyperGit.tagCreate", "when": "view == hyperGit.branches", "group": "navigation" }, { "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" } ], "view/item/context": [ @@ -187,20 +238,34 @@ { "command": "hyperGit.branchRename", "when": "view == hyperGit.branches && viewItem == hyperGit.branch", "group": "1_branch@5" }, { "command": "hyperGit.compareBranches", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/", "group": "1_branch@6" }, { "command": "hyperGit.copyBranchRef", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/", "group": "1_branch@7" }, + { "command": "hyperGit.toggleFavorite", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/", "group": "inline" }, + { "command": "hyperGit.toggleFavorite", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/", "group": "1_branch@0" }, + { "command": "hyperGit.checkoutAsNew", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/", "group": "1_branch@1.5" }, + { "command": "hyperGit.compareWithCurrent", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch|hyperGit.tag/", "group": "1_branch@8" }, + { "command": "hyperGit.tagCheckout", "when": "view == hyperGit.branches && viewItem == hyperGit.tag", "group": "1_tag@1" }, + { "command": "hyperGit.tagDelete", "when": "view == hyperGit.branches && viewItem == hyperGit.tag", "group": "1_tag@2" }, { "command": "hyperGit.cherryPick", "when": "view == hyperGit.log && viewItem == hyperGit.commit", "group": "1_commit@1" }, { "command": "hyperGit.revertCommit", "when": "view == hyperGit.log && viewItem == hyperGit.commit", "group": "1_commit@2" }, { "command": "hyperGit.dropCommit", "when": "view == hyperGit.log && viewItem == hyperGit.commit", "group": "1_commit@3" }, { "command": "hyperGit.fixupCommit", "when": "view == hyperGit.log && viewItem == hyperGit.commit", "group": "1_commit@4" }, + { "command": "hyperGit.createBranchFromCommit", "when": "view == hyperGit.log && viewItem == hyperGit.commit", "group": "1_commit@5" }, + { "command": "hyperGit.createTagFromCommit", "when": "view == hyperGit.log && viewItem == hyperGit.commit", "group": "1_commit@6" }, + { "command": "hyperGit.showContainingBranches", "when": "view == hyperGit.log && viewItem == hyperGit.commit", "group": "1_commit@7" }, + { "command": "hyperGit.resetToHere", "when": "view == hyperGit.log && viewItem == hyperGit.commit", "group": "1_commit@8" }, { "command": "hyperGit.ignorePath", "when": "view == hyperGit.changes && viewItem == hyperGit.fileChange", "group": "1_file@4" }, { "command": "hyperGit.partialStage", "when": "view == hyperGit.changes && viewItem == hyperGit.fileChange", "group": "1_file@5" }, { "command": "hyperGit.partialUnstage", "when": "view == hyperGit.changes && viewItem == hyperGit.fileChange", "group": "1_file@6" }, { "command": "hyperGit.stashApply", "when": "view == hyperGit.stash && viewItem == hyperGit.stash", "group": "1_stash@1" }, { "command": "hyperGit.stashPop", "when": "view == hyperGit.stash && viewItem == hyperGit.stash", "group": "1_stash@2" }, { "command": "hyperGit.stashDrop", "when": "view == hyperGit.stash && viewItem == hyperGit.stash", "group": "1_stash@3" }, + { "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" } ], + "editor/context": [ + { "command": "hyperGit.toggleBlameAnnotation", "when": "resourceScheme == file", "group": "hyperGit@1" } + ], "commandPalette": [ { "command": "hyperGit.openDiff", "when": "false" }, { "command": "hyperGit.setActiveChangelist", "when": "false" }, @@ -227,7 +292,17 @@ { "command": "hyperGit.unshelveSilently", "when": "false" }, { "command": "hyperGit.unshelveWithMerge", "when": "false" }, { "command": "hyperGit.deleteShelf", "when": "false" }, - { "command": "hyperGit.moveHunkToChangelist", "when": "false" } + { "command": "hyperGit.moveHunkToChangelist", "when": "false" }, + { "command": "hyperGit.toggleFavorite", "when": "false" }, + { "command": "hyperGit.checkoutAsNew", "when": "false" }, + { "command": "hyperGit.compareWithCurrent", "when": "false" }, + { "command": "hyperGit.tagDelete", "when": "false" }, + { "command": "hyperGit.tagCheckout", "when": "false" }, + { "command": "hyperGit.openCommitFileDiff", "when": "false" }, + { "command": "hyperGit.resetToHere", "when": "false" }, + { "command": "hyperGit.createBranchFromCommit", "when": "false" }, + { "command": "hyperGit.createTagFromCommit", "when": "false" }, + { "command": "hyperGit.showContainingBranches", "when": "false" } ] }, "configuration": { diff --git a/src/adapter/advanced-commands.ts b/src/adapter/advanced-commands.ts index 6832c13..c427dd0 100644 --- a/src/adapter/advanced-commands.ts +++ b/src/adapter/advanced-commands.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import type { BranchNode, BranchesTreeProvider } from './tree/branches-tree'; import type { GitRepositoryService } from './git-repository-service'; import type { LogNode } from './tree/log-tree'; +import { filterMergeable } from '../engine/ref/cleanup'; const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); @@ -103,10 +104,7 @@ export function registerAdvancedCommands(service: GitRepositoryService, branches let merged: string[] = []; try { const out = await service.execGit(['branch', '--merged', base]); - merged = out - .split('\n') - .map((s) => s.trim().replace(/^\*\s*/, '')) - .filter((s) => s && s !== base && s !== 'main' && s !== 'master' && s !== headName); + merged = filterMergeable(out, base, headName ? [headName] : []); } catch (e) { void vscode.window.showErrorMessage(`查询已合并分支失败:${errMsg(e)}`); return; @@ -141,7 +139,7 @@ export function registerAdvancedCommands(service: GitRepositoryService, branches if (node?.kind !== 'branch') { return; } - const ref = node.ref.name ?? ''; + const ref = node.ref.shortName; if (ref) { await vscode.env.clipboard.writeText(ref); void vscode.window.showInformationMessage(`已复制 ${ref}`); diff --git a/src/adapter/branch-favorites.ts b/src/adapter/branch-favorites.ts new file mode 100644 index 0000000..42367ba --- /dev/null +++ b/src/adapter/branch-favorites.ts @@ -0,0 +1,57 @@ +import * as vscode from 'vscode'; +import { toggleFavorite } from '../engine/ref/favorites'; + +/** + * BranchFavorites:分支收藏持久化(仿 {@link ChangelistRegistry})。 + * + * IDEA「Set Favorite」能力:把常用分支标星置顶。收藏名列表存于 workspaceState(按仓库根隔离), + * 重启后恢复。集合运算复用纯逻辑 {@link toggleFavorite},便于单测。 + */ +export class BranchFavorites implements vscode.Disposable { + private names: string[]; + private readonly storageKey: string; + private readonly _onDidChange = new vscode.EventEmitter(); + readonly onDidChange: vscode.Event = this._onDidChange.event; + + constructor(private readonly workspaceState: vscode.Memento, repoRoot: string) { + this.storageKey = `hyperGit.branchFavorites:${repoRoot}`; + this.names = this.load(); + } + + private load(): string[] { + const raw = this.workspaceState.get(this.storageKey); + if (!raw) { + return []; + } + try { + const arr = JSON.parse(raw) as unknown; + return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === 'string') : []; + } catch { + return []; + } + } + + private persist(): void { + void this.workspaceState.update(this.storageKey, JSON.stringify(this.names)); + this._onDidChange.fire(); + } + + /** 收藏名列表(插入顺序)。 */ + list(): readonly string[] { + return this.names; + } + + isFavorite(name: string): boolean { + return this.names.includes(name); + } + + /** 切换某分支(按 shortName)的收藏态。 */ + toggle(name: string): void { + this.names = toggleFavorite(this.names, name); + this.persist(); + } + + dispose(): void { + this._onDidChange.dispose(); + } +} diff --git a/src/adapter/conflict-ui.ts b/src/adapter/conflict-ui.ts new file mode 100644 index 0000000..1a9d7eb --- /dev/null +++ b/src/adapter/conflict-ui.ts @@ -0,0 +1,61 @@ +import * as fs from 'fs'; +import * as vscode from 'vscode'; +import { parseConflictState, type OngoingOperation } from '../engine/git-state/conflict-detector'; +import type { GitRepositoryService } from './git-repository-service'; + +/** ongoing 操作 → 对应的 `--abort` 子命令。 */ +const ABORT_ARGS: Record, string[]> = { + merge: ['merge', '--abort'], + rebase: ['rebase', '--abort'], + 'cherry-pick': ['cherry-pick', '--abort'], + revert: ['revert', '--abort'], +}; + +/** + * 操作失败后的冲突兜底引导。检测到冲突时弹出「解决冲突 / 中止操作」。 + * @returns true 表示检测到冲突并已引导(调用方无需再弹通用错误,但应刷新视图)。 + * + * Phase 3 自绘 3-way merge editor 就绪后,「解决冲突」将直接打开编辑器;当前引导用户手动处理。 + * 无 ongoing 标记的冲突(如 stash pop)仅提示手动解决(无「中止操作」按钮)。 + */ +export async function handleGitConflict(service: GitRepositoryService, opName: string): Promise { + const repo = service.repo; + if (!repo) { + return false; + } + try { + const status = await service.execGit(['status', '--porcelain']); + const gitDir = `${repo.rootUri.fsPath}/.git`; + let entries: string[] = []; + try { + entries = await fs.promises.readdir(gitDir); + } catch { + /* .git 非 dir(worktree/裸库)或不可读 → 仅靠 status 判冲突,ongoing 退化为 none */ + } + const state = parseConflictState(status, entries); + if (!state.hasConflicts) { + return false; + } + const op = state.ongoingOperation; + const canAbort = op !== 'none'; + const choice = await vscode.window.showWarningMessage( + `${opName} 遇到 ${state.conflictedPaths.length} 个冲突文件。`, + { modal: true, detail: canAbort ? `正在进行:${op}。可解决冲突后继续,或中止恢复工作区。` : '请手动解决冲突后提交。' }, + ...(canAbort ? ['解决冲突', '中止操作'] : ['知道了']), + ); + if (choice === '中止操作' && op !== 'none') { + try { + await service.execGit(ABORT_ARGS[op]); + void vscode.window.showInformationMessage(`已中止 ${op},工作区已恢复`); + } catch { + void vscode.window.showErrorMessage('中止操作失败,请手动处理'); + } + } else if (choice === '解决冲突') { + // 打开自绘 3-way merge editor(resolveConflicts 列出冲突文件供选择) + void vscode.commands.executeCommand('hyperGit.resolveConflicts'); + } + return true; + } catch { + return false; + } +} diff --git a/src/adapter/editor/blame-annotation.ts b/src/adapter/editor/blame-annotation.ts new file mode 100644 index 0000000..343c348 --- /dev/null +++ b/src/adapter/editor/blame-annotation.ts @@ -0,0 +1,94 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import type { GitRepositoryService } from '../git-repository-service'; +import { formatAnnotation, parseBlamePorcelain } from '../../engine/blame/blame-parser'; + +const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); + +/** + * 编辑器内 Blame 注解(IDEA Annotate 等价)。 + * + * Toggle:对当前文件执行 `git blame --line-porcelain`,解析每行作者/日期,用行内 + * before 装饰渲染在每行行首(gutter 风格)。再次 toggle 关闭。切换编辑器/文档变更时清理。 + */ +export class BlameAnnotationController implements vscode.Disposable { + private readonly decoration: vscode.TextEditorDecorationType; + private readonly annotated = new Set(); // 已注解的 document uri + private readonly disposables: vscode.Disposable[] = []; + + constructor(private readonly service: GitRepositoryService) { + this.decoration = vscode.window.createTextEditorDecorationType({ + before: { + margin: '0 1em 0 0', + color: new vscode.ThemeColor('editorCodeLens.foreground'), + }, + }); + // 文档变更后清除注解(行号失配) + this.disposables.push( + vscode.workspace.onDidChangeTextDocument((e) => { + if (this.annotated.has(e.document.uri.toString())) { + this.clear(e.document.uri); + } + }), + ); + } + + async toggle(): Promise { + const editor = vscode.window.activeTextEditor; + const repo = this.service.repo; + if (!editor || !repo) { + void vscode.window.showWarningMessage('请先打开一个文件'); + return; + } + const key = editor.document.uri.toString(); + if (this.annotated.has(key)) { + this.clear(editor.document.uri); + return; + } + const rel = path.relative(repo.rootUri.fsPath, editor.document.uri.fsPath).split(path.sep).join('/'); + if (rel.startsWith('..') || path.isAbsolute(rel)) { + void vscode.window.showWarningMessage('该文件不在当前仓库内'); + return; + } + let blame: BlameLineMap; + try { + const out = await this.service.execGit(['blame', '--line-porcelain', '--', rel]); + blame = new Map(parseBlamePorcelain(out).map((b) => [b.line, b])); + } catch (e) { + void vscode.window.showErrorMessage(`Blame 失败:${errMsg(e)}`); + return; + } + const options: vscode.DecorationOptions[] = []; + for (let line = 0; line < editor.document.lineCount; line++) { + const b = blame.get(line + 1); + if (!b) { + continue; + } + options.push({ + range: new vscode.Range(line, 0, line, 0), + renderOptions: { + before: { + contentText: formatAnnotation(b).padEnd(28).slice(0, 28), + fontStyle: 'italic', + }, + }, + hoverMessage: `${b.sha.slice(0, 7)} · ${b.author}\n${b.summary}`, + }); + } + editor.setDecorations(this.decoration, options); + this.annotated.add(key); + } + + private clear(uri: vscode.Uri): void { + const editor = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri.toString()); + editor?.setDecorations(this.decoration, []); + this.annotated.delete(uri.toString()); + } + + dispose(): void { + this.decoration.dispose(); + this.disposables.forEach((d) => d.dispose()); + } +} + +type BlameLineMap = Map; diff --git a/src/adapter/git-cli-commands.ts b/src/adapter/git-cli-commands.ts index c63c6d7..4bb6f9d 100644 --- a/src/adapter/git-cli-commands.ts +++ b/src/adapter/git-cli-commands.ts @@ -3,7 +3,8 @@ import * as path from 'path'; import * as vscode from 'vscode'; import type { BranchNode, BranchesTreeProvider } from './tree/branches-tree'; import type { ChangeItem, GitRepositoryService } from './git-repository-service'; -import type { LogNode } from './tree/log-tree'; +import type { LogNode, LogTreeProvider } from './tree/log-tree'; +import { handleGitConflict } from './conflict-ui'; const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); @@ -11,7 +12,7 @@ const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String( * 注册经 git CLI 补齐的操作(M5' batch 1):cherry-pick / revert / reset / branch rename / * ignore / compare branches / reword。均经 `service.execGit`(复用 vscode.git 的同一 git 二进制)。 */ -export function registerGitCliCommands(service: GitRepositoryService, branchesTree: BranchesTreeProvider): vscode.Disposable[] { +export function registerGitCliCommands(service: GitRepositoryService, branchesTree: BranchesTreeProvider, logTree: LogTreeProvider): vscode.Disposable[] { const subs: vscode.Disposable[] = []; subs.push( @@ -26,9 +27,13 @@ export function registerGitCliCommands(service: GitRepositoryService, branchesTr } try { await service.execGit(['cherry-pick', hash]); + branchesTree.refresh(); + logTree.refresh(); void vscode.window.showInformationMessage(`Cherry-pick ${hash.slice(0, 7)} 完成`); } catch (e) { - void vscode.window.showErrorMessage(`Cherry-pick 失败:${errMsg(e)}`); + if (!(await handleGitConflict(service, 'Cherry-pick'))) { + void vscode.window.showErrorMessage(`Cherry-pick 失败:${errMsg(e)}`); + } } }), ); @@ -45,9 +50,13 @@ export function registerGitCliCommands(service: GitRepositoryService, branchesTr } try { await service.execGit(['revert', '--no-edit', hash]); + branchesTree.refresh(); + logTree.refresh(); void vscode.window.showInformationMessage(`Revert ${hash.slice(0, 7)} 完成`); } catch (e) { - void vscode.window.showErrorMessage(`Revert 失败:${errMsg(e)}`); + if (!(await handleGitConflict(service, 'Revert'))) { + void vscode.window.showErrorMessage(`Revert 失败:${errMsg(e)}`); + } } }), ); @@ -58,13 +67,20 @@ export function registerGitCliCommands(service: GitRepositoryService, branchesTr if (!repo) { return; } + // 1) 选择目标 commit(默认最近 30 条;HEAD 在前)。修复此前固定 HEAD~0 导致 + // mixed/keep 等同 reset HEAD(仅取消暂存/丢工作区)而无法回退到历史提交的问题。 + const target = await pickResetTarget(service); + if (!target) { + return; + } + // 2) 选择 reset 模式 const items = [ - { label: 'soft', description: '保留工作区与暂存区改动' }, - { label: 'mixed', description: '保留工作区改动,取消暂存(默认)' }, - { label: 'hard', description: '⚠ 丢弃所有改动(不可撤销)' }, - { label: 'keep', description: '保留工作区已修改文件' }, + { label: 'soft', description: '仅移动 HEAD,保留暂存区与工作区改动' }, + { label: 'mixed', description: '移动 HEAD + 取消暂存(默认),保留工作区改动' }, + { label: 'hard', description: '⚠ 移动 HEAD + 丢弃暂存区与工作区所有改动(不可撤销)' }, + { label: 'keep', description: '移动 HEAD + 保留已修改文件(遇冲突中止)' }, ]; - const pick = await vscode.window.showQuickPick(items, { placeHolder: '选择 reset 模式(作用于 HEAD)' }); + const pick = await vscode.window.showQuickPick(items, { placeHolder: `选择 reset 模式(目标 ${target.slice(0, 7)})` }); if (!pick) { return; } @@ -75,9 +91,10 @@ export function registerGitCliCommands(service: GitRepositoryService, branchesTr } } try { - await service.execGit(['reset', `--${pick.label}`, 'HEAD~0']); + await service.execGit(['reset', `--${pick.label}`, target]); branchesTree.refresh(); - void vscode.window.showInformationMessage(`Reset (--${pick.label}) 完成`); + logTree.refresh(); + void vscode.window.showInformationMessage(`Reset (--${pick.label} ${target.slice(0, 7)}) 完成`); } catch (e) { void vscode.window.showErrorMessage(`Reset 失败:${errMsg(e)}`); } @@ -90,7 +107,7 @@ export function registerGitCliCommands(service: GitRepositoryService, branchesTr if (!repo || node?.kind !== 'branch') { return; } - const oldName = node.ref.name ?? ''; + const oldName = node.ref.shortName; const newName = await vscode.window.showInputBox({ prompt: `重命名分支「${oldName}」`, value: oldName }); if (!newName || !newName.trim() || newName === oldName) { return; @@ -133,7 +150,7 @@ export function registerGitCliCommands(service: GitRepositoryService, branchesTr return; } const refs = repo.state.refs.filter((r) => r.name && (r.type === 0 || r.type === 1)); - const base = node?.kind === 'branch' ? node.ref.name : await vscode.window.showQuickPick(refs.map((r) => r.name!), { placeHolder: '选择 base 分支' }); + const base = node?.kind === 'branch' ? node.ref.shortName : await vscode.window.showQuickPick(refs.map((r) => r.name!), { placeHolder: '选择 base 分支' }); if (!base) { return; } @@ -199,3 +216,19 @@ async function pickRelativePath(service: GitRepositoryService): Promise ({ label: c.relativePath })), { placeHolder: '选择要忽略的文件' }); return pick?.label; } + +/** 选择 reset 目标 commit(最近 30 条,HEAD 在前)。 */ +async function pickResetTarget(service: GitRepositoryService): Promise { + const repo = service.repo; + if (!repo) { + return undefined; + } + const commits = await repo.log({ maxEntries: 30 }); + const items = commits.map((c, i) => ({ + label: (c.message.split('\n', 1)[0] ?? c.hash).slice(0, 60), + description: `${c.hash.slice(0, 7)}${i === 0 ? ' · HEAD' : ''}`, + hash: c.hash, + })); + const pick = await vscode.window.showQuickPick(items, { placeHolder: '选择 reset 目标 commit' }); + return pick?.hash; +} diff --git a/src/adapter/history-commands.ts b/src/adapter/history-commands.ts index aed74cb..71fc757 100644 --- a/src/adapter/history-commands.ts +++ b/src/adapter/history-commands.ts @@ -2,14 +2,18 @@ import * as path from 'path'; import * as vscode from 'vscode'; import type { BranchNode } from './tree/branches-tree'; import type { BranchesTreeProvider } from './tree/branches-tree'; +import type { BranchFavorites } from './branch-favorites'; import type { ChangeItem, GitRepositoryService } from './git-repository-service'; import type { LogNode, LogTreeProvider } from './tree/log-tree'; +import { handleGitConflict } from './conflict-ui'; +import type { MergeMode } from '../engine/log/log-filter'; -/** 注册 Log/Branches/Blame/History 相关命令(M3)。 */ +/** 注册 Log/Branches/Blame/History/Tags 相关命令。 */ export function registerHistoryCommands( service: GitRepositoryService, logTree: LogTreeProvider, branchesTree: BranchesTreeProvider, + favorites: BranchFavorites, ): vscode.Disposable[] { const subs: vscode.Disposable[] = []; const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); @@ -82,7 +86,7 @@ export function registerHistoryCommands( return; } try { - await repo.checkout(node.ref.name ?? ''); + await repo.checkout(node.ref.shortName); branchesTree.refresh(); } catch (e) { void vscode.window.showErrorMessage(`检出失败:${errMsg(e)}`); @@ -96,11 +100,17 @@ export function registerHistoryCommands( if (!repo || node?.kind !== 'branch' || node.remote) { return; } - const name = node.ref.name ?? ''; - const choice = await vscode.window.showWarningMessage(`删除分支「${name}」?`, { modal: true }, '删除'); - if (choice === '删除') { + const name = node.ref.shortName; + // 查询是否已合并:已合并用安全删除(-d / force=false),未合并需二次确认强制删除(-D / force=true) + const merged = await isBranchMerged(service, name); + const detail = merged + ? `分支「${name}」已合并,可安全删除。` + : `分支「${name}」未合并,强制删除将丢失其独有提交!`; + const confirmText = merged ? '删除' : '强制删除'; + const choice = await vscode.window.showWarningMessage(detail, { modal: true }, confirmText); + if (choice === confirmText) { try { - await repo.deleteBranch(name, true); + await repo.deleteBranch(name, !merged); branchesTree.refresh(); } catch (e) { void vscode.window.showErrorMessage(`删除失败:${errMsg(e)}`); @@ -115,11 +125,18 @@ export function registerHistoryCommands( if (!repo || node?.kind !== 'branch') { return; } + const name = node.ref.shortName; + const ok = await vscode.window.showWarningMessage(`将「${name}」合并到当前分支?`, { modal: true }, '合并'); + if (ok !== '合并') { + return; + } try { - await repo.merge(node.ref.name ?? ''); + await repo.merge(name); branchesTree.refresh(); } catch (e) { - void vscode.window.showErrorMessage(`合并失败:${errMsg(e)}`); + if (!(await handleGitConflict(service, '合并'))) { + void vscode.window.showErrorMessage(`合并失败:${errMsg(e)}`); + } } }), ); @@ -130,11 +147,18 @@ export function registerHistoryCommands( if (!repo || node?.kind !== 'branch') { return; } + const name = node.ref.shortName; + const ok = await vscode.window.showWarningMessage(`将当前分支变基到「${name}」?(重写历史,可能冲突)`, { modal: true }, '变基'); + if (ok !== '变基') { + return; + } try { - await repo.rebase(node.ref.name ?? ''); + await repo.rebase(name); branchesTree.refresh(); } catch (e) { - void vscode.window.showErrorMessage(`变基失败:${errMsg(e)}`); + if (!(await handleGitConflict(service, '变基'))) { + void vscode.window.showErrorMessage(`变基失败:${errMsg(e)}`); + } } }), ); @@ -175,7 +199,9 @@ export function registerHistoryCommands( await repo.pull(); branchesTree.refresh(); } catch (e) { - void vscode.window.showErrorMessage(`Pull 失败:${errMsg(e)}`); + if (!(await handleGitConflict(service, 'Pull'))) { + void vscode.window.showErrorMessage(`Pull 失败:${errMsg(e)}`); + } } }), ); @@ -201,8 +227,15 @@ export function registerHistoryCommands( if (!repo) { return; } + const pick = await vscode.window.showQuickPick( + ['普通 Fetch', 'Fetch + Prune(清理已删除的远程分支)'], + { placeHolder: 'Fetch 模式' }, + ); + if (!pick) { + return; + } try { - await repo.fetch(); + await repo.fetch(pick.includes('Prune') ? { prune: true } : undefined); branchesTree.refresh(); } catch (e) { void vscode.window.showErrorMessage(`Fetch 失败:${errMsg(e)}`); @@ -210,5 +243,310 @@ export function registerHistoryCommands( }), ); + // —— IDEA 风格 Branches 高级操作(Phase 1)—— + + subs.push( + vscode.commands.registerCommand('hyperGit.toggleFavorite', async (node: BranchNode) => { + if (node?.kind !== 'branch' || node.ref.isTag) { + return; + } + favorites.toggle(node.ref.shortName); + // favorites.onDidChange 会触发 branchesTree.refresh() + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.checkoutAsNew', async (node: BranchNode) => { + const repo = service.repo; + if (!repo || node?.kind !== 'branch') { + return; + } + const source = node.ref.shortName; + const name = await vscode.window.showInputBox({ prompt: `从「${source}」新建并检出本地分支`, placeHolder: '新分支名' }); + if (!name || !name.trim()) { + return; + } + try { + await repo.createBranch(name.trim(), true, source); + branchesTree.refresh(); + } catch (e) { + void vscode.window.showErrorMessage(`新建分支失败:${errMsg(e)}`); + } + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.compareWithCurrent', async (node: BranchNode) => { + const repo = service.repo; + if (!repo || node?.kind !== 'branch') { + return; + } + const selected = node.ref.shortName; + try { + const out = await service.execGit(['diff', '--stat', `HEAD...${selected}`]); + const doc = await vscode.workspace.openTextDocument({ content: `$ git diff --stat HEAD...${selected}\n\n${out}`, language: 'plaintext' }); + await vscode.window.showTextDocument(doc, { preview: true }); + } catch (e) { + void vscode.window.showErrorMessage(`比较失败:${errMsg(e)}`); + } + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.tagCreate', async () => { + const repo = service.repo; + if (!repo) { + return; + } + const name = await vscode.window.showInputBox({ prompt: '标签名(如 v1.0.0)' }); + if (!name || !name.trim()) { + return; + } + const commits = await repo.log({ maxEntries: 20 }); + const items = [ + { label: 'HEAD', description: '当前提交', target: 'HEAD' }, + ...commits.map((c) => ({ + label: (c.message.split('\n', 1)[0] ?? c.hash).slice(0, 50), + description: c.hash.slice(0, 7), + target: c.hash, + })), + ]; + const pick = await vscode.window.showQuickPick(items, { placeHolder: '在哪个 commit 上打标签' }); + if (!pick) { + return; + } + try { + await service.execGit(['tag', name.trim(), pick.target]); + branchesTree.refresh(); + void vscode.window.showInformationMessage(`已创建标签 ${name.trim()} @ ${pick.target === 'HEAD' ? 'HEAD' : pick.target.slice(0, 7)}`); + } catch (e) { + void vscode.window.showErrorMessage(`创建标签失败:${errMsg(e)}`); + } + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.tagDelete', async (node: BranchNode) => { + if (node?.kind !== 'branch' || !node.ref.isTag) { + return; + } + const name = node.ref.shortName; + const ok = await vscode.window.showWarningMessage(`删除标签「${name}」?`, { modal: true }, '删除'); + if (ok !== '删除') { + return; + } + try { + await service.execGit(['tag', '-d', name]); + branchesTree.refresh(); + } catch (e) { + void vscode.window.showErrorMessage(`删除标签失败:${errMsg(e)}`); + } + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.tagCheckout', async (node: BranchNode) => { + const repo = service.repo; + if (!repo || node?.kind !== 'branch' || !node.ref.isTag) { + return; + } + const name = node.ref.shortName; + const ok = await vscode.window.showWarningMessage(`检出标签「${name}」(进入 detached HEAD)?`, { modal: true }, '检出'); + if (ok !== '检出') { + return; + } + try { + await repo.checkout(name); + branchesTree.refresh(); + } catch (e) { + void vscode.window.showErrorMessage(`检出标签失败:${errMsg(e)}`); + } + }), + ); + + // —— IDEA Log 增强(Phase 2):高级过滤 + 提交详情 diff + per-commit 操作 —— + + subs.push( + vscode.commands.registerCommand('hyperGit.openCommitFileDiff', async (hash: string, filePath: string, hasParent: boolean) => { + const repo = service.repo; + if (!repo) { + return; + } + const uri = vscode.Uri.joinPath(repo.rootUri, filePath); + const right = service.toGitUri(uri, hash); + try { + if (hasParent) { + const left = service.toGitUri(uri, `${hash}^`); + await vscode.commands.executeCommand('vscode.diff', left, right, `${filePath} · ${hash.slice(0, 7)} (commit diff)`, { preview: true }); + } else { + await vscode.commands.executeCommand('vscode.open', right); + } + } catch (e) { + void vscode.window.showErrorMessage(`打开 diff 失败:${errMsg(e)}`); + } + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.resetToHere', async (node: LogNode) => { + if (node?.kind !== 'commit') { + return; + } + const hash = node.commit.hash; + const items = [ + { label: 'soft', description: '仅移动 HEAD,保留暂存区与工作区改动' }, + { label: 'mixed', description: '移动 HEAD + 取消暂存(默认),保留工作区改动' }, + { label: 'hard', description: '⚠ 移动 HEAD + 丢弃暂存区与工作区所有改动(不可撤销)' }, + { label: 'keep', description: '移动 HEAD + 保留已修改文件(遇冲突中止)' }, + ]; + const pick = await vscode.window.showQuickPick(items, { placeHolder: `Reset 当前分支到 ${hash.slice(0, 7)} 的模式` }); + if (!pick) { + return; + } + if (pick.label === 'hard') { + const ok = await vscode.window.showWarningMessage(`hard reset 到 ${hash.slice(0, 7)} 将丢弃所有改动,确认?`, { modal: true }, '确认 hard reset'); + if (!ok) { + return; + } + } + try { + await service.execGit(['reset', `--${pick.label}`, hash]); + branchesTree.refresh(); + logTree.refresh(); + void vscode.window.showInformationMessage(`Reset (--${pick.label} ${hash.slice(0, 7)}) 完成`); + } catch (e) { + if (!(await handleGitConflict(service, 'Reset'))) { + void vscode.window.showErrorMessage(`Reset 失败:${errMsg(e)}`); + } + } + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.createBranchFromCommit', async (node: LogNode) => { + const repo = service.repo; + if (!repo || node?.kind !== 'commit') { + return; + } + const name = await vscode.window.showInputBox({ prompt: `从 ${node.commit.hash.slice(0, 7)} 新建并检出分支`, placeHolder: '新分支名' }); + if (!name || !name.trim()) { + return; + } + try { + await repo.createBranch(name.trim(), true, node.commit.hash); + branchesTree.refresh(); + void vscode.window.showInformationMessage(`已新建并检出 ${name.trim()} @ ${node.commit.hash.slice(0, 7)}`); + } catch (e) { + void vscode.window.showErrorMessage(`新建分支失败:${errMsg(e)}`); + } + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.createTagFromCommit', async (node: LogNode) => { + if (node?.kind !== 'commit') { + return; + } + const name = await vscode.window.showInputBox({ prompt: `在 ${node.commit.hash.slice(0, 7)} 上新建标签`, placeHolder: '如 v1.0.0' }); + if (!name || !name.trim()) { + return; + } + try { + await service.execGit(['tag', name.trim(), node.commit.hash]); + branchesTree.refresh(); + void vscode.window.showInformationMessage(`已创建标签 ${name.trim()} @ ${node.commit.hash.slice(0, 7)}`); + } catch (e) { + void vscode.window.showErrorMessage(`创建标签失败:${errMsg(e)}`); + } + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.showContainingBranches', async (node: LogNode) => { + if (node?.kind !== 'commit') { + return; + } + try { + const out = await service.execGit(['branch', '--contains', node.commit.hash]); + const doc = await vscode.workspace.openTextDocument({ + content: `$ git branch --contains ${node.commit.hash.slice(0, 7)}\n\n${out}`, + language: 'plaintext', + }); + await vscode.window.showTextDocument(doc, { preview: true }); + } catch (e) { + void vscode.window.showErrorMessage(`查询失败:${errMsg(e)}`); + } + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.logFilterGrep', async () => { + const grep = await vscode.window.showInputBox({ prompt: '按 message 文本/正则过滤(--grep)', placeHolder: '例如 fix|bug' }); + const f = logTree.getFilter(); + logTree.setFilter({ ...f, grep: grep && grep.trim() ? grep.trim() : undefined }); + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.logFilterMergeMode', async () => { + const pick = await vscode.window.showQuickPick( + [ + { label: '全部提交', mode: 'all' as MergeMode }, + { label: '仅合并提交(merge)', mode: 'merge-only' as MergeMode }, + { label: '仅非合并提交', mode: 'no-merge' as MergeMode }, + ], + { placeHolder: '合并提交过滤模式' }, + ); + if (!pick) { + return; + } + const f = logTree.getFilter(); + logTree.setFilter({ ...f, mergeMode: pick.mode }); + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.logFilterDate', async () => { + const now = Date.now(); + const pick = await vscode.window.showQuickPick( + [ + { label: '最近 7 天', days: 7 }, + { label: '最近 30 天', days: 30 }, + { label: '最近 90 天', days: 90 }, + { label: '清除日期过滤', days: 0 }, + ], + { placeHolder: '提交日期范围' }, + ); + if (!pick) { + return; + } + const f = logTree.getFilter(); + logTree.setFilter({ ...f, dateFrom: pick.days === 0 ? undefined : new Date(now - pick.days * 86_400_000) }); + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.logFilterRegex', async () => { + const re = await vscode.window.showInputBox({ prompt: '按 message 正则过滤(客户端)', placeHolder: '例如 ^feat:' }); + const f = logTree.getFilter(); + logTree.setFilter({ ...f, messageRegex: re && re.trim() ? re.trim() : undefined }); + }), + ); + return subs; } + +/** 查询本地分支是否已合并到当前 HEAD(或 main):经 `git branch --merged `。 */ +async function isBranchMerged(service: GitRepositoryService, branch: string): Promise { + const repo = service.repo; + if (!repo) { + return false; + } + const base = repo.state.HEAD?.name ?? 'main'; + try { + const out = await service.execGit(['branch', '--merged', base]); + return out.split('\n').some((line) => line.replace(/^\*\s*/, '').trim() === branch); + } catch { + return false; + } +} diff --git a/src/adapter/misc-commands.ts b/src/adapter/misc-commands.ts new file mode 100644 index 0000000..6b5f109 --- /dev/null +++ b/src/adapter/misc-commands.ts @@ -0,0 +1,123 @@ +import * as fs from 'fs'; +import * as vscode from 'vscode'; +import type { GitRepositoryService } from './git-repository-service'; +import type { LogTreeProvider } from './tree/log-tree'; +import type { BranchesTreeProvider } from './tree/branches-tree'; +import { handleGitConflict } from './conflict-ui'; + +const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); + +/** + * 杂项 git 操作(Phase 5):Create/Apply Patch、Reflog 视图。 + * + * - Create Patch:将工作区或已暂存改动导出为 .patch 文件(git diff [--cached])。 + * - Apply Patch:选择 .patch 文件应用到工作区(git apply)。 + * - Show Reflog:git reflog 渲染为只读文档(IDEA Git → Show Reflog 等价)。 + */ +export function registerMiscCommands( + service: GitRepositoryService, + branchesTree: BranchesTreeProvider, + logTree: LogTreeProvider, +): vscode.Disposable[] { + const subs: vscode.Disposable[] = []; + + subs.push( + vscode.commands.registerCommand('hyperGit.createPatch', async () => { + const repo = service.repo; + if (!repo) { + return; + } + const scope = await vscode.window.showQuickPick( + [ + { label: '未暂存改动', args: ['diff'] }, + { label: '已暂存改动', args: ['diff', '--cached'] }, + { label: '全部改动(HEAD↔工作区)', args: ['diff', 'HEAD'] }, + ], + { placeHolder: '导出哪部分改动为 patch' }, + ); + if (!scope) { + return; + } + let patch = ''; + try { + patch = await service.execGit(scope.args); + } catch (e) { + void vscode.window.showErrorMessage(`生成 patch 失败:${errMsg(e)}`); + return; + } + if (!patch.trim()) { + void vscode.window.showInformationMessage('无改动可导出'); + return; + } + const target = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.joinPath(repo.rootUri, 'changes.patch'), + filters: { Patch: ['patch', 'diff'] }, + }); + if (!target) { + return; + } + try { + await fs.promises.writeFile(target.fsPath, patch, 'utf8'); + void vscode.window.showInformationMessage(`已导出 patch:${target.fsPath}`); + } catch (e) { + void vscode.window.showErrorMessage(`写入 patch 失败:${errMsg(e)}`); + } + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.applyPatch', async () => { + const repo = service.repo; + if (!repo) { + return; + } + const sel = await vscode.window.showOpenDialog({ + canSelectMany: false, + filters: { Patch: ['patch', 'diff'], All: ['*'] }, + title: '选择要应用的 patch 文件', + }); + if (!sel?.[0]) { + return; + } + const mode = await vscode.window.showQuickPick( + [ + { label: '应用到工作区', args: [] as string[] }, + { label: '应用并暂存(--index)', args: ['--index'] }, + { label: '3-way 应用(冲突可解)', args: ['--3way'] }, + ], + { placeHolder: 'Apply 模式' }, + ); + if (!mode) { + return; + } + try { + await service.execGit(['apply', ...mode.args, sel[0].fsPath]); + branchesTree.refresh(); + logTree.refresh(); + void vscode.window.showInformationMessage('已应用 patch'); + } catch (e) { + if (!(await handleGitConflict(service, 'Apply Patch'))) { + void vscode.window.showErrorMessage(`应用 patch 失败:${errMsg(e)}`); + } + } + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.showReflog', async () => { + const repo = service.repo; + if (!repo) { + return; + } + try { + const out = await service.execGit(['reflog', '-n', '200', '--date=relative']); + const doc = await vscode.workspace.openTextDocument({ content: `# git reflog(最近 200 条)\n\n${out}`, language: 'plaintext' }); + await vscode.window.showTextDocument(doc, { preview: true }); + } catch (e) { + void vscode.window.showErrorMessage(`读取 reflog 失败:${errMsg(e)}`); + } + }), + ); + + return subs; +} diff --git a/src/adapter/remote-commands.ts b/src/adapter/remote-commands.ts new file mode 100644 index 0000000..0293191 --- /dev/null +++ b/src/adapter/remote-commands.ts @@ -0,0 +1,152 @@ +import * as vscode from 'vscode'; +import type { GitRepositoryService } from './git-repository-service'; +import type { BranchesTreeProvider } from './tree/branches-tree'; +import type { LogTreeProvider } from './tree/log-tree'; +import { handleGitConflict } from './conflict-ui'; + +const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); + +/** + * 注册远程同步对话框(Phase 3):Push 对话框 / Update Project / Merge 对话框。 + * + * vscode.git 稳定 API 的 pull/push 缺策略/force/tags 选项,故经 execGit 实现: + * - Push:force/force-with-lease/push tags(核心走 repo.push,tags 走 CLI)。 + * - Update Project:pull --rebase / --no-rebase(策略)。 + * - Merge:--ff-only / --no-ff / --squash + 自定义 message。 + */ +export function registerRemoteCommands( + service: GitRepositoryService, + branchesTree: BranchesTreeProvider, + logTree: LogTreeProvider, +): vscode.Disposable[] { + const subs: vscode.Disposable[] = []; + + subs.push( + vscode.commands.registerCommand('hyperGit.pushDialog', 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('未配置远程仓库(remote)'); + return; + } + // 待推送提交预览(best-effort,无 upstream 时忽略) + let previewCount = ''; + try { + const out = (await service.execGit(['log', '@{u}..HEAD', '--oneline'])).trim(); + if (out) { + previewCount = `(${out.split('\n').length} 个待推送提交)`; + } + } catch { + /* 无上游,忽略 */ + } + const remote = await vscode.window.showQuickPick(remotes, { placeHolder: `选择目标 remote ${previewCount}` }); + if (!remote) { + return; + } + const mode = await vscode.window.showQuickPick( + [ + { label: 'Normal', description: '普通推送', force: undefined as 0 | 1 | undefined }, + { label: 'Force-with-lease', description: '安全强推(推荐)', force: 1 as const }, + { label: 'Force', description: '⚠ 强制覆盖远程(危险)', force: 0 as const }, + ], + { placeHolder: '推送模式' }, + ); + if (!mode) { + return; + } + const tags = await vscode.window.showQuickPick(['否', '是'], { placeHolder: '同时推送 tags?' }); + if (!tags) { + return; + } + try { + await repo.push(remote, undefined, false, mode.force); + if (tags === '是') { + await service.execGit(['push', remote, '--tags']); + } + branchesTree.refresh(); + logTree.refresh(); + void vscode.window.showInformationMessage(`已推送到 ${remote} ${previewCount}`.trim()); + } catch (e) { + if (!(await handleGitConflict(service, 'Push'))) { + void vscode.window.showErrorMessage(`Push 失败:${errMsg(e)}`); + } + } + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.updateProject', async () => { + const repo = service.repo; + if (!repo) { + return; + } + const pick = await vscode.window.showQuickPick( + [ + { label: 'Merge 策略(默认)', description: 'pull --no-rebase', args: ['pull', '--no-rebase'] }, + { label: 'Rebase 策略', description: 'pull --rebase(线性历史)', args: ['pull', '--rebase'] }, + ], + { placeHolder: 'Update Project:拉取并整合远程变更' }, + ); + if (!pick) { + return; + } + try { + await service.execGit(pick.args); + branchesTree.refresh(); + logTree.refresh(); + void vscode.window.showInformationMessage('Update Project 完成'); + } catch (e) { + if (!(await handleGitConflict(service, 'Update'))) { + void vscode.window.showErrorMessage(`Update 失败:${errMsg(e)}`); + } + } + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.mergeDialog', async () => { + const repo = service.repo; + if (!repo) { + return; + } + const refs = repo.state.refs.filter((r) => r.name && (r.type === 0 || r.type === 1)); + const target = await vscode.window.showQuickPick(refs.map((r) => r.name!), { placeHolder: '合并哪个分支到当前分支' }); + if (!target) { + return; + } + const mode = await vscode.window.showQuickPick( + [ + { label: 'Fast-forward only', description: '仅当可快进时合并', args: ['--ff-only'] }, + { label: 'No fast-forward', description: '始终创建合并提交', args: ['--no-ff'] }, + { label: 'Squash', description: '压缩为单个提交(不产生合并提交)', args: ['--squash'] }, + ], + { placeHolder: `合并模式(${target} → 当前分支)` }, + ); + if (!mode) { + return; + } + const msgArgs: string[] = []; + if (mode.args[0] !== '--ff-only') { + const msg = await vscode.window.showInputBox({ prompt: '合并提交信息', value: `Merge ${target}` }); + if (msg && msg.trim()) { + msgArgs.push('-m', msg.trim()); + } + } + try { + await service.execGit(['merge', ...mode.args, ...msgArgs, target]); + branchesTree.refresh(); + logTree.refresh(); + void vscode.window.showInformationMessage(`已合并 ${target}`); + } catch (e) { + if (!(await handleGitConflict(service, '合并'))) { + void vscode.window.showErrorMessage(`合并失败:${errMsg(e)}`); + } + } + }), + ); + + return subs; +} diff --git a/src/adapter/shelf.ts b/src/adapter/shelf.ts index 7b5d3ae..0fb17b9 100644 --- a/src/adapter/shelf.ts +++ b/src/adapter/shelf.ts @@ -3,6 +3,7 @@ import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; import type { GitRepositoryService } from './git-repository-service'; +import { handleGitConflict } from './conflict-ui'; const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); @@ -214,7 +215,9 @@ export function registerShelfCommands(service: GitRepositoryService, shelfServic shelfTree.refresh(); void vscode.window.showInformationMessage(`已 unshelve(3-way)「${node.name}」`); } catch (e) { - void vscode.window.showErrorMessage(`Unshelve 失败:${errMsg(e)}`); + if (!(await handleGitConflict(service, 'Unshelve'))) { + void vscode.window.showErrorMessage(`Unshelve 失败:${errMsg(e)}`); + } } }), ); diff --git a/src/adapter/stash-commands.ts b/src/adapter/stash-commands.ts index 17ef521..8addbf3 100644 --- a/src/adapter/stash-commands.ts +++ b/src/adapter/stash-commands.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import type { GitRepositoryService } from './git-repository-service'; import type { StashNode, StashTreeProvider } from './tree/stash-tree'; +import { handleGitConflict } from './conflict-ui'; /** * 注册 Stash 相关命令(M4)。 @@ -55,7 +56,9 @@ export function registerStashCommands(service: GitRepositoryService, stashTree: await repo.popStash(index); stashTree.refresh(); } catch (e) { - void vscode.window.showErrorMessage(`Stash pop 失败:${errMsg(e)}`); + if (!(await handleGitConflict(service, 'Stash pop'))) { + void vscode.window.showErrorMessage(`Stash pop 失败:${errMsg(e)}`); + } } }), ); @@ -79,5 +82,95 @@ export function registerStashCommands(service: GitRepositoryService, stashTree: }), ); + // —— Stash 高级(Phase 4):keep-index / clear / unstash-as-branch —— + + subs.push( + vscode.commands.registerCommand('hyperGit.stashKeepIndex', async () => { + const repo = service.repo; + if (!repo) { + return; + } + const message = await vscode.window.showInputBox({ prompt: 'Stash 信息(保留已暂存改动在工作区)', placeHolder: 'WIP' }); + try { + const args = ['stash', 'push', '--keep-index']; + if (message && message.trim()) { + args.push('-m', message.trim()); + } + await service.execGit(args); + stashTree.refresh(); + void vscode.window.showInformationMessage('已 Stash(保留已暂存)'); + } catch (e) { + void vscode.window.showErrorMessage(`Stash 失败:${errMsg(e)}`); + } + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.stashClear', async () => { + const repo = service.repo; + if (!repo) { + return; + } + const ok = await vscode.window.showWarningMessage('清空所有 stash(不可撤销)?', { modal: true }, '清空'); + if (ok !== '清空') { + return; + } + try { + await service.execGit(['stash', 'clear']); + stashTree.refresh(); + void vscode.window.showInformationMessage('已清空所有 stash'); + } catch (e) { + void vscode.window.showErrorMessage(`清空失败:${errMsg(e)}`); + } + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.stashBranch', async (node?: StashNode) => { + const repo = service.repo; + if (!repo) { + return; + } + let index = node?.kind === 'stash' ? node.index : 0; + // 无节点时从 stash 列表选择 + if (!node) { + try { + const list = await service.execGit(['stash', 'list']); + const items = list + .split('\n') + .filter((l) => l.trim()) + .map((l) => { + const m = l.match(/^stash@\{(\d+)\}:\s*(.*)$/); + return m ? { label: `stash@{${m[1]}}`, description: m[2], index: Number(m[1]) } : null; + }) + .filter((x): x is { label: string; description: string; index: number } => x !== null); + if (items.length === 0) { + void vscode.window.showInformationMessage('无 stash'); + return; + } + const pick = await vscode.window.showQuickPick(items, { placeHolder: '选择要转为分支的 stash' }); + if (!pick) { + return; + } + index = pick.index; + } catch (e) { + void vscode.window.showErrorMessage(`读取 stash 列表失败:${errMsg(e)}`); + return; + } + } + const name = await vscode.window.showInputBox({ prompt: `从 stash@{${index}} 创建并检出新分支`, placeHolder: '新分支名' }); + if (!name || !name.trim()) { + return; + } + try { + await service.execGit(['stash', 'branch', name.trim(), `stash@{${index}}`]); + stashTree.refresh(); + void vscode.window.showInformationMessage(`已从 stash@{${index}} 创建分支 ${name.trim()}`); + } catch (e) { + void vscode.window.showErrorMessage(`创建分支失败:${errMsg(e)}`); + } + }), + ); + return subs; } diff --git a/src/adapter/tree/branches-tree.ts b/src/adapter/tree/branches-tree.ts index f60b182..7816d0e 100644 --- a/src/adapter/tree/branches-tree.ts +++ b/src/adapter/tree/branches-tree.ts @@ -1,71 +1,255 @@ import * as vscode from 'vscode'; import type { Ref } from '../../types/git'; import type { GitRepositoryService } from '../git-repository-service'; +import type { BranchFavorites } from '../branch-favorites'; +import { FOR_EACH_REF_FORMAT, type RawRef, parseForEachRef } from '../../engine/ref/for-each-ref'; + +export type BranchGroupId = 'favorites' | 'local' | 'remote' | 'tags'; export interface BranchGroupNode { readonly kind: 'group'; - readonly id: 'local' | 'remote'; + readonly id: BranchGroupId; readonly label: string; } export interface BranchRefNode { readonly kind: 'branch'; - readonly ref: Ref; + readonly ref: RawRef; readonly remote: boolean; } export type BranchNode = BranchGroupNode | BranchRefNode; -/** Branches 视图 TreeDataProvider:消费 `Repository.state.refs`(本地 + 远程分支)。 */ +/** + * Branches 视图 TreeDataProvider。 + * + * 数据源策略(解除「视图空白」根因):主路径读 `Repository.state.refs`(API,零成本); + * 为空(首帧未填充 / 仓库初始化竞态)时经 CLI 通道 `git for-each-ref` 兜底(含 ahead/behind track)。 + * 四段分组:Favorites(收藏置顶,仿 IDEA)/ Local / Remote / Tags。 + * 构造函数自订阅 service.onDidChange + favorites.onDidChange 做即时刷新。 + */ export class BranchesTreeProvider implements vscode.TreeDataProvider, vscode.Disposable { private readonly _onDidChange = new vscode.EventEmitter(); readonly onDidChangeTreeData = this._onDidChange.event; + private readonly disposables: vscode.Disposable[] = []; + private refsCache: RawRef[] | undefined; + private refsInFlight: Promise | undefined; - constructor(private readonly service: GitRepositoryService) {} + constructor( + private readonly service: GitRepositoryService, + private readonly favorites: BranchFavorites, + ) { + this.disposables.push(service.onDidChange(() => this.refresh())); + this.disposables.push(favorites.onDidChange(() => this.refresh())); + } refresh(): void { + this.refsCache = undefined; this._onDidChange.fire(undefined); } - getChildren(element?: BranchNode): BranchNode[] { + async getChildren(element?: BranchNode): Promise { const repo = this.service.repo; if (!repo) { return []; } if (!element) { - return [ + const refs = await this.loadRefs(); + const groups: BranchGroupNode[] = []; + if (this.favorites.list().length > 0) { + groups.push({ kind: 'group', id: 'favorites', label: 'Favorites' }); + } + groups.push( { kind: 'group', id: 'local', label: 'Local Branches' }, { kind: 'group', id: 'remote', label: 'Remote Branches' }, - ]; + ); + if (refs.some((r) => r.isTag)) { + groups.push({ kind: 'group', id: 'tags', label: 'Tags' }); + } + return groups; } if (element.kind === 'group') { - const type = element.id === 'remote' ? 1 : 0; // RefType: 0=Head, 1=RemoteHead - return repo.state.refs - .filter((r) => r.type === type && r.name) - .map((r): BranchRefNode => ({ kind: 'branch', ref: r, remote: element.id === 'remote' })); + const refs = await this.loadRefs(); + switch (element.id) { + case 'favorites': + return this.favoriteNodes(refs); + case 'local': + return this.sortLocal(refs.filter((r) => !r.isRemote && !r.isTag)).map((r) => this.toNode(r)); + case 'remote': + return refs + .filter((r) => r.isRemote) + .sort((a, b) => a.shortName.localeCompare(b.shortName)) + .map((r) => this.toNode(r)); + case 'tags': + return refs + .filter((r) => r.isTag) + .sort((a, b) => a.shortName.localeCompare(b.shortName)) + .map((r) => this.toNode(r)); + } } return []; } + private toNode(r: RawRef): BranchRefNode { + return { kind: 'branch', ref: r, remote: r.isRemote }; + } + + /** Favorites 段:按收藏插入顺序展示已收藏且仍存在的分支。 */ + private favoriteNodes(refs: RawRef[]): BranchNode[] { + const byName = new Map(refs.map((r) => [r.shortName, r] as const)); + const nodes: BranchNode[] = []; + for (const name of this.favorites.list()) { + const r = byName.get(name); + if (r) { + nodes.push(this.toNode(r)); + } + } + return nodes; + } + + /** Local 段排序:当前 HEAD → 收藏 → 字母序。 */ + private sortLocal(refs: RawRef[]): RawRef[] { + const favSet = new Set(this.favorites.list()); + return [...refs].sort((a, b) => { + const aActive = this.isActive(a) ? 0 : 1; + const bActive = this.isActive(b) ? 0 : 1; + if (aActive !== bActive) { + return aActive - bActive; + } + const aFav = favSet.has(a.shortName) ? 0 : 1; + const bFav = favSet.has(b.shortName) ? 0 : 1; + if (aFav !== bFav) { + return aFav - bFav; + } + return a.shortName.localeCompare(b.shortName); + }); + } + + /** + * 读取分支列表:优先 `repo.state.refs`(API,零成本);为空时 CLI 兜底 `git for-each-ref`。 + * 带 in-flight 去重缓存,避免多段并发展开重复 spawn CLI;refresh() 清缓存。 + */ + private loadRefs(): Promise { + if (this.refsCache) { + return Promise.resolve(this.refsCache); + } + if (this.refsInFlight) { + return this.refsInFlight; + } + this.refsInFlight = (async () => { + const apiRefs = this.service.repo?.state.refs ?? []; + const refs = apiRefs.length > 0 ? apiRefs.map(refToRaw) : await this.cliRefs(); + this.refsCache = refs; + this.refsInFlight = undefined; + return refs; + })(); + return this.refsInFlight; + } + + private async cliRefs(): Promise { + try { + const out = await this.service.execGit([ + 'for-each-ref', + `--format=${FOR_EACH_REF_FORMAT}`, + 'refs/heads', + 'refs/remotes', + 'refs/tags', + ]); + return parseForEachRef(out); + } catch { + return []; + } + } + getTreeItem(element: BranchNode): vscode.TreeItem { if (element.kind === 'group') { const item = new vscode.TreeItem(element.label, vscode.TreeItemCollapsibleState.Expanded); item.contextValue = 'hyperGit.branchGroup'; - item.iconPath = new vscode.ThemeIcon(element.id === 'remote' ? 'repo' : 'git-branch'); + const icon = + element.id === 'remote' ? 'repo' : element.id === 'tags' ? 'tag' : element.id === 'favorites' ? 'star-full' : 'git-branch'; + item.iconPath = new vscode.ThemeIcon(icon); return item; } const ref = element.ref; - const head = this.service.repo?.state.HEAD; - const active = !!head?.name && head.name === ref.name; - const item = new vscode.TreeItem(ref.name ?? '', vscode.TreeItemCollapsibleState.None); - item.description = active ? 'active' : (ref.commit?.slice(0, 7) ?? ''); - item.contextValue = element.remote ? 'hyperGit.remoteBranch' : 'hyperGit.branch'; - item.tooltip = `${ref.name ?? ''}${active ? ' (active)' : ''}`; - item.iconPath = new vscode.ThemeIcon(element.remote ? 'cloud' : 'git-branch', active ? new vscode.ThemeColor('gitDecoration.modifiedResourceForeground') : undefined); + const active = this.isActive(ref); + const fav = this.favorites.isFavorite(ref.shortName); + const isTag = ref.isTag; + const item = new vscode.TreeItem(ref.shortName, vscode.TreeItemCollapsibleState.None); + item.description = this.describe(ref, active, fav); + item.contextValue = isTag ? 'hyperGit.tag' : element.remote ? 'hyperGit.remoteBranch' : 'hyperGit.branch'; + item.tooltip = this.tooltip(ref, active, fav); + const icon = isTag ? 'tag' : element.remote ? 'cloud' : 'git-branch'; + item.iconPath = new vscode.ThemeIcon( + icon, + active ? new vscode.ThemeColor('gitDecoration.modifiedResourceForeground') : undefined, + ); return item; } + private describe(ref: RawRef, active: boolean, fav: boolean): string { + const parts: string[] = []; + if (active) { + parts.push('active'); + } else if (ref.ahead || ref.behind) { + if (ref.ahead) { + parts.push(`↑${ref.ahead}`); + } + if (ref.behind) { + parts.push(`↓${ref.behind}`); + } + } else if (ref.objectname) { + parts.push(ref.objectname); + } + if (fav) { + parts.push('★'); + } + return parts.join(' '); + } + + private tooltip(ref: RawRef, active: boolean, fav: boolean): string { + const lines = [ref.shortName + (active ? ' (active)' : '') + (fav ? ' ★' : '')]; + if (ref.upstream) { + const track: string[] = []; + if (ref.ahead) { + track.push(`ahead ${ref.ahead}`); + } + if (ref.behind) { + track.push(`behind ${ref.behind}`); + } + lines.push(`← ${ref.upstream}${track.length ? ` (${track.join(', ')})` : ''}`); + } + if (ref.isTag) { + lines.push(`tag @ ${ref.objectname}`); + } + return lines.join('\n'); + } + + /** 判定分支是否当前 HEAD:CLI 解析的 head 标记优先,API 路径回退与 state.HEAD.name 比较。 */ + private isActive(ref: RawRef): boolean { + if (ref.head) { + return true; + } + const headName = this.service.repo?.state.HEAD?.name; + return !!headName && headName === ref.shortName; + } + dispose(): void { + this.disposables.forEach((d) => d.dispose()); this._onDidChange.dispose(); } } + +/** vscode.git Ref → RawRef 归一(API 路径;head/ahead/behind 留空,由 isActive / 无 track 兜底)。 */ +function refToRaw(r: Ref): RawRef { + return { + refname: r.name ?? '', + shortName: r.name ?? '', + objectname: r.commit?.slice(0, 7) ?? '', + upstream: undefined, + ahead: undefined, + behind: undefined, + head: false, + isRemote: r.type === 1, + isTag: r.type === 2, + }; +} diff --git a/src/adapter/tree/log-tree.ts b/src/adapter/tree/log-tree.ts index d7e6829..3166c9e 100644 --- a/src/adapter/tree/log-tree.ts +++ b/src/adapter/tree/log-tree.ts @@ -1,22 +1,38 @@ import * as vscode from 'vscode'; import type { Commit } from '../../types/git'; import type { GitRepositoryService } from '../git-repository-service'; +import { applyClientFilters, safeRegex, type LogClientFilter, type MergeMode } from '../../engine/log/log-filter'; +import { parseNameStatus, statusLabel, type CommitFileChange } from '../../engine/log/commit-files'; export interface LogCommitNode { readonly kind: 'commit'; readonly commit: Commit; } -export type LogNode = LogCommitNode; +export interface LogFileNode { + readonly kind: 'file'; + readonly hash: string; + readonly change: CommitFileChange; + readonly hasParent: boolean; +} + +export type LogNode = LogCommitNode | LogFileNode; +/** Log 过滤器:author/path/grep 交 git log 服务端;mergeMode/dateFrom/dateTo/messageRegex 客户端。 */ export interface LogFilter { readonly author?: string; readonly path?: string; + readonly grep?: string; + readonly mergeMode?: MergeMode; + readonly dateFrom?: Date; + readonly dateTo?: Date; + /** message 正则模式串(运行时经 safeRegex 编译)。 */ + readonly messageRegex?: string; } /** - * Log 视图 TreeDataProvider:消费 `Repository.log()`,按 author/path 过滤。 - * 完整提交图(SVG 拓扑连线)作为后续增强(M3.x);当前以提交列表 + 过滤 + copy hash 提供核心浏览能力。 + * Log 视图 TreeDataProvider:消费 `Repository.log()`,支持多维过滤;commit 节点可展开显示 + * 该提交的变更文件(`git diff-tree --name-status`),单文件点击打开 diff(commit^ vs commit)。 */ export class LogTreeProvider implements vscode.TreeDataProvider, vscode.Disposable { private readonly _onDidChange = new vscode.EventEmitter(); @@ -44,31 +60,83 @@ export class LogTreeProvider implements vscode.TreeDataProvider, vscode } async getChildren(element?: LogNode): Promise { - if (element) { - return []; - } const repo = this.service.repo; if (!repo) { return []; } + if (element) { + return element.kind === 'commit' ? this.commitFiles(element.commit) : []; + } + try { + const commits = await repo.log({ + maxEntries: 200, + author: this.filter.author, + path: this.filter.path, + grep: this.filter.grep, + }); + const clientFilter: LogClientFilter = { + mergeMode: this.filter.mergeMode, + dateFrom: this.filter.dateFrom, + dateTo: this.filter.dateTo, + messageRegex: this.filter.messageRegex ? safeRegex(this.filter.messageRegex) : undefined, + }; + return applyClientFilters(commits, clientFilter).map((c): LogCommitNode => ({ kind: 'commit', commit: c })); + } catch { + return []; + } + } + + /** 展开 commit:经 diff-tree 取变更文件列表。 */ + private async commitFiles(commit: Commit): Promise { try { - const commits = await repo.log({ maxEntries: 200, author: this.filter.author, path: this.filter.path }); - return commits.map((c): LogCommitNode => ({ kind: 'commit', commit: c })); + const out = await this.service.execGit([ + 'diff-tree', + '--no-commit-id', + '--name-status', + '-r', + '--root', + commit.hash, + ]); + const hasParent = commit.parents.length > 0; + return parseNameStatus(out).map((change): LogFileNode => ({ kind: 'file', hash: commit.hash, change, hasParent })); } catch { return []; } } getTreeItem(node: LogNode): vscode.TreeItem { - const c = node.commit; + if (node.kind === 'file') { + return this.createFileItem(node); + } + return this.createCommitItem(node.commit); + } + + private createCommitItem(c: Commit): vscode.TreeItem { const subject = (c.message.split('\n', 1)[0] ?? c.message).slice(0, 80); const date = c.authorDate ? formatDate(c.authorDate) : ''; - const item = new vscode.TreeItem(subject, vscode.TreeItemCollapsibleState.None); + const isMerge = c.parents.length > 1; + const item = new vscode.TreeItem(subject, vscode.TreeItemCollapsibleState.Collapsed); item.id = c.hash; - item.description = `${c.authorName ?? '?'} · ${date} · ${c.hash.slice(0, 7)}`; + item.description = `${c.authorName ?? '?'} · ${date} · ${c.hash.slice(0, 7)}${isMerge ? ' · merge' : ''}`; item.tooltip = `${c.hash}\n${c.authorName ?? ''} <${c.authorEmail ?? ''}> · ${date}\n\n${c.message}`; item.contextValue = 'hyperGit.commit'; - item.iconPath = new vscode.ThemeIcon('git-commit'); + item.iconPath = new vscode.ThemeIcon(isMerge ? 'git-merge' : 'git-commit'); + return item; + } + + private createFileItem(node: LogFileNode): vscode.TreeItem { + const { change, hash, hasParent } = node; + const label = change.oldPath ? `${change.oldPath} → ${change.path}` : change.path; + const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.None); + item.description = statusLabel(change.status); + item.contextValue = 'hyperGit.commitFile'; + item.tooltip = `${statusLabel(change.status)} · ${change.path}\n@ ${hash.slice(0, 7)}`; + item.iconPath = new vscode.ThemeIcon('circle-filled', new vscode.ThemeColor(fileIconColor(change.status))); + item.command = { + command: 'hyperGit.openCommitFileDiff', + title: '打开 Diff', + arguments: [hash, change.path, hasParent], + }; return item; } @@ -82,3 +150,16 @@ function formatDate(d: Date): string { const day = String(d.getDate()).padStart(2, '0'); return `${d.getFullYear()}-${m}-${day}`; } + +function fileIconColor(status: string): string { + if (status.startsWith('A')) { + return 'gitDecoration.addedResourceForeground'; + } + if (status.startsWith('D')) { + return 'gitDecoration.deletedResourceForeground'; + } + if (status.startsWith('R') || status.startsWith('C')) { + return 'gitDecoration.renamedResourceForeground'; + } + return 'gitDecoration.modifiedResourceForeground'; +} diff --git a/src/adapter/webview/graph-webview.ts b/src/adapter/webview/graph-webview.ts index 3454212..23b1fb4 100644 --- a/src/adapter/webview/graph-webview.ts +++ b/src/adapter/webview/graph-webview.ts @@ -1,12 +1,22 @@ +import * as crypto from 'crypto'; import * as vscode from 'vscode'; import type { GitRepositoryService } from '../git-repository-service'; +import { classifyGraphChar, normalizeGraphWidth, parseGraphLog, type GraphRow } from '../../engine/log/graph-parser'; + +const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); + +/** 按列着色的 lane 调色板(类 IDEA 多色 lane)。 */ +const LANE_COLORS = ['#f85149', '#58a6ff', '#3fb950', '#d29922', '#bc8cff', '#ff7b72', '#56d4dd', '#ffa657']; +const CHAR_W = 10; +const ROW_H = 22; +const NODE_R = 4.5; /** - * Git 提交图(WebviewPanel)。 + * Git 提交图(WebviewPanel)—— 真实 SVG 拓扑渲染。 * - * 用 `git log --graph --oneline --decorate --all`(受控 CLI 通道)获取拓扑文本,在 Webview 内以 - * 等宽字体 + 语义着色渲染(graph 连线、refs、hash)——补齐 IDEA Log 提交图的可视化拓扑。 - * 完整像素级 lane-SVG 渲染作为后续增强(batch 2.x)。 + * 解析 `git log --graph --format=%x00%H%x00%d%x00%s`(git 已完成 lane 分配),按字符粒度渲染为 SVG: + * `*`→可点击圆点、`|`→竖线、`/ \`→斜线、`_`→横线,按列 floor(col/2) 多色着色(类 IDEA)。 + * 点节点 → host QuickPick 提供 per-commit 操作;订阅 service.onDidChange 实时刷新。 */ export class GraphWebview { private static readonly viewType = 'hyperGit.graph'; @@ -17,59 +27,199 @@ export class GraphWebview { void vscode.window.showWarningMessage('未找到 Git 仓库'); return; } - let graph = ''; - try { - graph = await service.execGit(['log', '--graph', '--oneline', '--decorate', '--all', '-n', '300']); - } catch (e) { - void vscode.window.showErrorMessage(`获取提交图失败:${e instanceof Error ? e.message : String(e)}`); + const initial = await GraphWebview.fetchGraph(service); + if (!initial) { return; } - const panel = vscode.window.createWebviewPanel(GraphWebview.viewType, 'Git Graph — Hyper Git', vscode.ViewColumn.Active, { - enableScripts: false, - retainContextWhenHidden: false, + enableScripts: true, + retainContextWhenHidden: true, + }); + panel.webview.html = GraphWebview.renderHtml(initial, repo.rootUri.fsPath); + panel.webview.onDidReceiveMessage((msg) => { + if (msg?.type === 'commit' && typeof msg.hash === 'string') { + void GraphWebview.handleCommitClick(service, msg.hash); + } }); - panel.webview.html = GraphWebview.renderHtml(graph, repo.rootUri.fsPath); + // 实时刷新(防抖) + let timer: ReturnType | undefined; + const sub = service.onDidChange(() => { + clearTimeout(timer); + timer = setTimeout(() => { + void GraphWebview.fetchGraph(service).then((g) => { + if (g && panel.visible) { + panel.webview.html = GraphWebview.renderHtml(g, repo.rootUri.fsPath); + } + }); + }, 400); + }); + panel.onDidDispose(() => { + clearTimeout(timer); + sub.dispose(); + }); + } + + private static async fetchGraph(service: GitRepositoryService): Promise { + try { + return await service.execGit(['log', '--graph', '-n', '300', '--all', '--format=%x00%H%x00%d%x00%s']); + } catch (e) { + void vscode.window.showErrorMessage(`获取提交图失败:${errMsg(e)}`); + return undefined; + } + } + + private static async handleCommitClick(service: GitRepositoryService, hash: string): Promise { + const repo = service.repo; + if (!repo) { + return; + } + const pick = await vscode.window.showQuickPick( + [ + { label: 'Cherry-Pick 此提交', op: 'cp' }, + { label: 'Revert 此提交', op: 'rv' }, + { label: 'Reset 当前分支到此提交…', op: 'rs' }, + { label: '从此提交新建分支…', op: 'nb' }, + { label: '从此提交新建标签…', op: 'nt' }, + { label: '查看包含此提交的分支', op: 'cb' }, + { label: '复制 Hash', op: 'copy' }, + ], + { placeHolder: `提交 ${hash.slice(0, 7)}` }, + ); + if (!pick) { + return; + } + try { + switch (pick.op) { + case 'cp': + await service.execGit(['cherry-pick', hash]); + break; + case 'rv': + await service.execGit(['revert', '--no-edit', hash]); + break; + case 'rs': + await vscode.commands.executeCommand('hyperGit.resetToHere', { kind: 'commit', commit: { hash, message: '', parents: [] } }); + return; + case 'nb': { + const name = await vscode.window.showInputBox({ prompt: `从 ${hash.slice(0, 7)} 新建并检出分支`, placeHolder: '新分支名' }); + if (name?.trim()) { + await repo.createBranch(name.trim(), true, hash); + } + break; + } + case 'nt': { + const name = await vscode.window.showInputBox({ prompt: '标签名', placeHolder: '如 v1.0.0' }); + if (name?.trim()) { + await service.execGit(['tag', name.trim(), hash]); + } + break; + } + case 'cb': { + const out = await service.execGit(['branch', '--contains', hash]); + const doc = await vscode.workspace.openTextDocument({ content: `$ git branch --contains ${hash.slice(0, 7)}\n\n${out}`, language: 'plaintext' }); + await vscode.window.showTextDocument(doc, { preview: true }); + return; + } + case 'copy': + await vscode.env.clipboard.writeText(hash); + void vscode.window.showInformationMessage(`已复制 ${hash.slice(0, 7)}`); + return; + } + void vscode.window.showInformationMessage(`已完成:${pick.label}`); + } catch (e) { + void vscode.window.showErrorMessage(`操作失败:${errMsg(e)}`); + } } - private static renderHtml(graph: string, repoRoot: string): string { - const lines = graph.split('\n').map(GraphWebview.renderLine).join('\n'); + private static renderHtml(graphOutput: string, repoRoot: string): string { + const nonce = crypto.randomBytes(16).toString('base64'); + const rows = parseGraphLog(graphOutput); + const padded = normalizeGraphWidth(rows); + const graphWidth = (padded[0]?.length ?? 0) * CHAR_W; + const height = rows.length * ROW_H + 8; + const body = GraphWebview.renderSvg(rows, padded, graphWidth, height); return ` - + -

Git 提交图(最近 300 条)

+

Git 提交图(最近 300 条 · 点击节点查看操作)

${escapeHtml(repoRoot)}
-
${lines}
+${body} + `; } - private static renderLine(line: string): string { - const m = line.match(/^([*|/\\_. ]+)(.*)$/); - if (!m) { - return escapeHtml(line); + private static renderSvg(rows: readonly GraphRow[], padded: readonly string[], graphWidth: number, height: number): string { + const parts: string[] = []; + parts.push(``); + for (let r = 0; r < rows.length; r++) { + const y = r * ROW_H; + const graph = padded[r] ?? ''; + for (let c = 0; c < graph.length; c++) { + const ch = graph[c]; + const kind = classifyGraphChar(ch); + if (kind === 'blank') { + continue; + } + const x = c * CHAR_W; + const cx = x + CHAR_W / 2; + const color = LANE_COLORS[Math.floor(c / 2) % LANE_COLORS.length]; + switch (kind) { + case 'node': { + const row = rows[r]; + const dataHash = row?.hash ? ` data-hash="${escapeHtml(row.hash)}"` : ''; + parts.push(``); + break; + } + case 'vert': + parts.push(``); + break; + case 'slash': + parts.push(``); + break; + case 'backslash': + parts.push(``); + break; + case 'underscore': + parts.push(``); + break; + } + } + // 文本(hash + refs + subject) + const row = rows[r]; + if (row?.hash) { + const tx = graphWidth + 8; + const ty = y + ROW_H * 0.68; + const shortHash = escapeHtml(row.hash.slice(0, 7)); + const refSpan = row.decorate ? `${escapeHtml(row.decorate)}` : ''; + const subj = row.subject ? `${escapeHtml(row.subject.slice(0, 80))}` : ''; + parts.push(`${shortHash}${refSpan}${subj}`); + } } - const graphPart = escapeHtml(m[1]); - let rest = escapeHtml(m[2]); - rest = rest.replace(/(\([^)]*\))/g, '$1'); - rest = rest.replace(/\b([0-9a-f]{7,40})\b/g, '$1'); - return `${graphPart}${rest}`; + parts.push(''); + return parts.join('\n'); } } function escapeHtml(s: string): string { - return s.replace(/&/g, '&').replace(//g, '>'); + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } diff --git a/src/adapter/webview/merge-editor.ts b/src/adapter/webview/merge-editor.ts new file mode 100644 index 0000000..d48c49a --- /dev/null +++ b/src/adapter/webview/merge-editor.ts @@ -0,0 +1,295 @@ +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import type { GitRepositoryService } from '../git-repository-service'; +import { diff3, type MergeHunk } from '../../engine/merge/diff3'; +import { parseConflictState } from '../../engine/git-state/conflict-detector'; + +const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); + +/** + * 自绘 3-way Merge Editor(WebviewPanel)—— 复刻 IDEA 冲突解决编辑器。 + * + * 数据:经 `git show :1/:2/:3:` 取 base/ours/theirs,diff3 产出 hunks。 + * UI:stable 段只读展示;conflict 段三栏(OURS | RESULT 可编辑 | THEIRS),Accept 按钮填 RESULT。 + * 保存:拼装全量结果写回工作区文件 + `git add` 标记已解决;残留冲突标记时二次确认。 + */ +export class MergeEditorWebview { + static async openForFile(service: GitRepositoryService, filePath: string): Promise { + const repo = service.repo; + if (!repo) { + void vscode.window.showWarningMessage('未找到 Git 仓库'); + return; + } + let base = ''; + let ours = ''; + let theirs = ''; + try { + // 并行取三阶段(冲突文件的 index stage 1/2/3) + [base, ours, theirs] = await Promise.all([ + service.execGit(['show', `:1:${filePath}`]), + service.execGit(['show', `:2:${filePath}`]), + service.execGit(['show', `:3:${filePath}`]), + ]); + } catch (e) { + void vscode.window.showErrorMessage(`读取冲突阶段失败(文件可能无冲突):${errMsg(e)}`); + return; + } + const hunks = diff3(splitLines(base), splitLines(ours), splitLines(theirs)); + const conflicts = hunks.filter((h) => h.kind === 'conflict').length; + if (conflicts === 0) { + // 无冲突:直接取合并结果写回 + add + const merged = hunks.flatMap((h) => (h.kind === 'stable' ? h.content : [])); + await MergeEditorWebview.saveResult(service, filePath, merged.join('\n') + '\n'); + void vscode.window.showInformationMessage(`「${filePath}」无冲突,已自动合并并标记已解决`); + return; + } + + const panel = vscode.window.createWebviewPanel( + 'hyperGit.mergeEditor', + `Merge — ${filePath} · Hyper Git`, + vscode.ViewColumn.Active, + { enableScripts: true, retainContextWhenHidden: true }, + ); + panel.webview.html = MergeEditorWebview.renderHtml(filePath, hunks, conflicts); + panel.webview.onDidReceiveMessage(async (msg) => { + if (msg?.type === 'save' && typeof msg.content === 'string') { + await MergeEditorWebview.saveResult(service, filePath, msg.content); + panel.dispose(); + } else if (msg?.type === 'cancel') { + panel.dispose(); + } + }); + } + + private static async saveResult(service: GitRepositoryService, filePath: string, content: string): Promise { + const repo = service.repo; + if (!repo) { + return; + } + if (/^<<<<<<< /m.test(content) || /^======= *$/m.test(content) || /^>>>>>>> /m.test(content)) { + const ok = await vscode.window.showWarningMessage( + '结果仍含冲突标记(<<<<<<< / ======= / >>>>>>>),强制保存将把带标记的内容标记为已解决。继续?', + { modal: true }, + '强制保存', + ); + if (ok !== '强制保存') { + return; + } + } + const abs = path.join(repo.rootUri.fsPath, filePath); + try { + await fs.promises.writeFile(abs, content, 'utf8'); + await service.execGit(['add', '--', filePath]); + void vscode.window.showInformationMessage(`「${filePath}」已保存并标记为已解决`); + } catch (e) { + void vscode.window.showErrorMessage(`保存失败:${errMsg(e)}`); + } + } + + private static renderHtml(filePath: string, hunks: readonly MergeHunk[], conflicts: number): string { + const nonce = crypto.randomBytes(16).toString('base64'); + const dataJson = escapeHtml(JSON.stringify(hunks)); + return ` + + + + + + + +
+ ${escapeHtml(filePath)} + ${conflicts} 个冲突 + + + +
+
+ + +`; + } +} + +function splitLines(s: string): string[] { + return s.length === 0 ? [] : s.replace(/\n$/, '').split('\n'); +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +/** + * 注册冲突解决命令(Phase 3): + * - resolveConflicts:列出冲突文件 → 选择 → 打开 3-way merge editor。 + * - acceptOurs / acceptTheirs:对单个文件快速采用 ours/theirs(git checkout --ours/--theirs + add)。 + */ +export function registerMergeCommands(service: GitRepositoryService): vscode.Disposable[] { + const subs: vscode.Disposable[] = []; + + subs.push( + vscode.commands.registerCommand('hyperGit.resolveConflicts', async () => { + const repo = service.repo; + if (!repo) { + return; + } + let paths: readonly string[] = []; + try { + const status = await service.execGit(['status', '--porcelain']); + paths = parseConflictState(status, []).conflictedPaths; + } catch { + /* ignore */ + } + if (paths.length === 0) { + void vscode.window.showInformationMessage('当前无冲突文件'); + return; + } + const pick = await vscode.window.showQuickPick( + paths.map((p) => ({ label: p, description: '冲突文件' })), + { placeHolder: '选择要解决的冲突文件', canPickMany: false }, + ); + if (!pick) { + return; + } + await MergeEditorWebview.openForFile(service, pick.label); + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.acceptOurs', async () => { + const repo = service.repo; + if (!repo) { + return; + } + const file = await pickConflicted(service); + if (!file) { + return; + } + try { + await service.execGit(['checkout', '--ours', '--', file]); + await service.execGit(['add', '--', file]); + void vscode.window.showInformationMessage(`「${file}」已采用 ours`); + } catch (e) { + void vscode.window.showErrorMessage(`失败:${errMsg(e)}`); + } + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.acceptTheirs', async () => { + const repo = service.repo; + if (!repo) { + return; + } + const file = await pickConflicted(service); + if (!file) { + return; + } + try { + await service.execGit(['checkout', '--theirs', '--', file]); + await service.execGit(['add', '--', file]); + void vscode.window.showInformationMessage(`「${file}」已采用 theirs`); + } catch (e) { + void vscode.window.showErrorMessage(`失败:${errMsg(e)}`); + } + }), + ); + + return subs; +} + +async function pickConflicted(service: GitRepositoryService): Promise { + let paths: readonly string[] = []; + try { + const status = await service.execGit(['status', '--porcelain']); + paths = parseConflictState(status, []).conflictedPaths; + } catch { + /* ignore */ + } + if (paths.length === 0) { + void vscode.window.showInformationMessage('当前无冲突文件'); + return undefined; + } + if (paths.length === 1) { + return paths[0]; + } + const pick = await vscode.window.showQuickPick(paths.map((p) => ({ label: p })), { placeHolder: '选择冲突文件' }); + return pick?.label; +} diff --git a/src/adapter/webview/rebase-webview.ts b/src/adapter/webview/rebase-webview.ts index e9aed38..70d19dd 100644 --- a/src/adapter/webview/rebase-webview.ts +++ b/src/adapter/webview/rebase-webview.ts @@ -4,6 +4,8 @@ import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; import type { GitRepositoryService } from '../git-repository-service'; +import { handleGitConflict } from '../conflict-ui'; +import { type RebaseTodoItem, isValidAction, serializeTodo } from '../../engine/rebase/todo'; interface RebaseCommit { readonly hash: string; @@ -13,11 +15,35 @@ interface RebaseCommit { const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); /** - * 交互式 Rebase Webview(IDEA Git Log → rebase 等价)。 + * reword 的 GIT_EDITOR helper 脚本(Node)。 * - * 展示 base..HEAD 的提交列表,每条可设 pick/squash/fixup/drop 动作。 - * 「Start Rebase」→ 构造 todo 序列 → 写临时文件 → `GIT_SEQUENCE_EDITOR=cp ` 非交互触发 rebase。 - * 重排序(drag/drop)作为后续增强;当前覆盖 squash/fixup/drop(最常见场景)。 + * git 对每个 reword 提交以 `node helper ` 调用本脚本;脚本从 HYPERGIT_REWORD_STATE + * 指向的 state 文件({counter, subjects})按调用顺序取出新 message 写入 msgfile,实现非交互 reword。 + * 用 process.execPath 运行(与扩展宿主同一 Node 二进制,规避 PATH 缺 node 的风险)。 + */ +const REWORD_EDITOR_JS = `const fs = require('fs'); +const target = process.argv[2]; +const stateFile = process.env.HYPERGIT_REWORD_STATE; +if (!target || !stateFile) { process.exit(0); } +let state; +try { state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); } catch (e) { process.exit(0); } +const idx = state.counter || 0; +const subjects = state.subjects || []; +if (idx < subjects.length) { + fs.writeFileSync(target, String(subjects[idx]) + '\\n'); + state.counter = idx + 1; + try { fs.writeFileSync(stateFile, JSON.stringify(state)); } catch (e) {} +} +`; + +/** + * 交互式 Rebase Webview(1:1 复刻 IDEA Git Log → Edit Rebase 编辑器)。 + * + * 展示 base..HEAD 的提交列表,每条可设 pick/reword/edit/squash/fixup/drop 动作; + * reword 支持行内编辑新 message;行可拖拽重排序。 + * 「Start Rebase」→ serializeTodo 构造 todo → 写临时文件 → `GIT_SEQUENCE_EDITOR=cp` 注入; + * reword 经 GIT_EDITOR=process.execPath + state 文件按序写入新 message(非交互)。 + * edit 或冲突会让 rebase 暂停 → 检测 rebase-merge 态并提示 continue/skip/abort。 */ export class RebaseWebview { static async open(service: GitRepositoryService): Promise { @@ -41,7 +67,7 @@ export class RebaseWebview { } const base = basePick.label; - // 获取 base..HEAD 提交(逆序 → 正序) + // 获取 base..HEAD 提交(逆序 → 正序:rebase 按时间正序回放) let rebaseCommits: RebaseCommit[] = []; try { const out = await service.execGit(['log', '--reverse', '--format=%h|%s', `${base}..HEAD`]); @@ -62,33 +88,90 @@ export class RebaseWebview { return; } - const panel = vscode.window.createWebviewPanel('hyperGit.rebase', 'Interactive Rebase — Hyper Git', vscode.ViewColumn.Active, { enableScripts: true }); + const panel = vscode.window.createWebviewPanel('hyperGit.rebase', 'Interactive Rebase — Hyper Git', vscode.ViewColumn.Active, { + enableScripts: true, + retainContextWhenHidden: true, + }); panel.webview.html = RebaseWebview.renderHtml(rebaseCommits); panel.webview.onDidReceiveMessage(async (msg) => { if (msg.type === 'rebase') { - await RebaseWebview.executeRebase(service, base, msg.actions as Array<{ hash: string; action: string; subject: string }>, panel); + await RebaseWebview.executeRebase( + service, + base, + msg.actions as Array<{ hash: string; action: string; subject: string }>, + panel, + ); } }); } - private static async executeRebase(service: GitRepositoryService, base: string, actions: Array<{ hash: string; action: string; subject: string }>, panel: vscode.WebviewPanel): Promise { - const todo = actions.map((a) => `${a.action} ${a.hash} ${a.subject}`).join('\n') + '\n'; - const tmpTodo = path.join(os.tmpdir(), `hg-rebase-todo-${crypto.randomBytes(4).toString('hex')}.txt`); + private static async executeRebase( + service: GitRepositoryService, + base: string, + actions: Array<{ hash: string; action: string; subject: string }>, + panel: vscode.WebviewPanel, + ): Promise { + // 校验并构造 todo(按 webview DOM 顺序,即用户重排后的顺序) + const todoItems: RebaseTodoItem[] = []; + const rewordSubjects: string[] = []; + for (const a of actions) { + if (!isValidAction(a.action)) { + void vscode.window.showErrorMessage(`非法动作:${a.action}`); + return; + } + todoItems.push({ action: a.action, hash: a.hash, subject: a.subject }); + if (a.action === 'reword') { + rewordSubjects.push(a.subject); + } + } + const todo = serializeTodo(todoItems); + + const tag = crypto.randomBytes(4).toString('hex'); + const tmpTodo = path.join(os.tmpdir(), `hg-rebase-todo-${tag}.txt`); fs.writeFileSync(tmpTodo, todo); + const env: NodeJS.ProcessEnv = { ...process.env, GIT_SEQUENCE_EDITOR: `cp ${tmpTodo}` }; + + // reword:写 Node editor helper + state 文件,按 todo 顺序写入新 message + let tmpEditor: string | undefined; + let tmpState: string | undefined; + if (rewordSubjects.length > 0) { + tmpEditor = path.join(os.tmpdir(), `hg-reword-editor-${tag}.js`); + tmpState = path.join(os.tmpdir(), `hg-reword-state-${tag}.json`); + fs.writeFileSync(tmpEditor, REWORD_EDITOR_JS); + fs.writeFileSync(tmpState, JSON.stringify({ counter: 0, subjects: rewordSubjects })); + env.GIT_EDITOR = `"${process.execPath}" "${tmpEditor}"`; + env.HYPERGIT_REWORD_STATE = tmpState; + } else { + env.GIT_EDITOR = ':'; + } + try { - await service.execGit(['rebase', '-i', base], { - env: { ...process.env, GIT_SEQUENCE_EDITOR: `cp ${tmpTodo}`, GIT_EDITOR: ':' }, - }); - void vscode.window.showInformationMessage('Rebase 完成'); - panel.dispose(); + await service.execGit(['rebase', '-i', base], { env }); + // rebase 可能因 edit / squash 暂停(exit 0 但 rebase-merge 仍在):检测并提示 + const gitDir = (await service.execGit(['rev-parse', '--absolute-git-dir'])).trim(); + if (fs.existsSync(path.join(gitDir, 'rebase-merge'))) { + void vscode.window.showWarningMessage( + 'Rebase 已暂停(遇到 edit 或需处理)。请在终端运行:git rebase --continue / --skip / --abort。', + ); + } else { + void vscode.window.showInformationMessage('Rebase 完成'); + panel.dispose(); + } } catch (e) { - void vscode.window.showErrorMessage(`Rebase 失败(可能需手动解冲突):${errMsg(e)}`); + if (!(await handleGitConflict(service, 'Rebase'))) { + void vscode.window.showErrorMessage(`Rebase 失败:${errMsg(e)}`); + } } finally { - try { - fs.unlinkSync(tmpTodo); - } catch { - /* ignore */ + for (const f of [tmpTodo, tmpEditor, tmpState]) { + if (!f) { + continue; + } + try { + fs.unlinkSync(f); + } catch { + /* ignore */ + } } } } @@ -97,15 +180,18 @@ export class RebaseWebview { const nonce = crypto.randomBytes(16).toString('base64'); const rows = commits .map( - (c, i) => ` - + + -${c.hash.slice(0, 7)} -${escapeHtml(c.subject)} +${escapeHtml(c.hash.slice(0, 7))} + `, ) .join('\n'); @@ -116,32 +202,87 @@ export class RebaseWebview {

Interactive Rebase

- +

拖拽 ⠿ 重排序 · 选 reword 可行内编辑 message · edit/squash 会暂停(终端 continue) · drop 删除提交

+
ActionHashSubject
${rows}
ActionHashSubject
+
+
`; diff --git a/src/engine/blame/blame-parser.ts b/src/engine/blame/blame-parser.ts new file mode 100644 index 0000000..09ccad1 --- /dev/null +++ b/src/engine/blame/blame-parser.ts @@ -0,0 +1,78 @@ +/** + * `git blame --line-porcelain` 解析器(纯逻辑,零 vscode 依赖)。 + * + * line-porcelain 格式:每行一个 header 块(首行 " [groupCount]"), + * 随后若干 "key value" 元数据(author / author-time / summary 等),以 "\t<原始行内容>" 结束。 + * 解析为每个最终行号 → BlameLine,供编辑器 gutter 注解(IDEA Annotate 等价)。 + */ + +export interface BlameLine { + readonly line: number; // 1-based 最终行号 + readonly sha: string; + readonly author: string; + /** author-time(Unix 秒)。 */ + readonly authorTime: number; + readonly summary: string; +} + +const HEADER_RE = /^([0-9a-f]{7,40})\s+(\d+)\s+(\d+)(?:\s+(\d+))?$/; + +/** 解析 `git blame --line-porcelain` 输出为 BlameLine[](按最终行号)。 */ +export function parseBlamePorcelain(output: string): BlameLine[] { + const lines = output.split('\n'); + const result: BlameLine[] = []; + // sha → 已知元数据缓存(porcelain 对同一 commit 后续块省略 author 等字段) + const metaCache = new Map(); + + let i = 0; + while (i < lines.length) { + const m = lines[i].match(HEADER_RE); + if (!m) { + i++; + continue; + } + const sha = m[1]; + const finalLine = Number(m[3]); + i++; + let author: string | undefined; + let authorTime: number | undefined; + let summary: string | undefined; + // 读取元数据直到内容行(以 \t 起始) + while (i < lines.length && !lines[i].startsWith('\t')) { + const sp = lines[i].indexOf(' '); + const key = sp < 0 ? lines[i] : lines[i].slice(0, sp); + const val = sp < 0 ? '' : lines[i].slice(sp + 1); + if (key === 'author') { + author = val; + } else if (key === 'author-time') { + authorTime = Number(val); + } else if (key === 'summary') { + summary = val; + } + i++; + } + // 跳过内容行(\t...) + if (i < lines.length && lines[i].startsWith('\t')) { + i++; + } + const cached = metaCache.get(sha); + const resolved = { + author: author ?? cached?.author ?? '', + authorTime: authorTime ?? cached?.authorTime ?? 0, + summary: summary ?? cached?.summary ?? '', + }; + metaCache.set(sha, resolved); + result.push({ line: finalLine, sha, ...resolved }); + } + return result; +} + +/** 格式化注解短文本:`作者 · YYYY-MM-DD`(uncommitted 行 sha 全 0 → 显示「未提交」)。 */ +export function formatAnnotation(b: BlameLine): string { + if (/^0+$/.test(b.sha)) { + return '未提交'; + } + const d = new Date(b.authorTime * 1000); + const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + return `${b.author} · ${date}`; +} diff --git a/src/engine/commit/trailer.ts b/src/engine/commit/trailer.ts new file mode 100644 index 0000000..fb75f2a --- /dev/null +++ b/src/engine/commit/trailer.ts @@ -0,0 +1,30 @@ +/** + * Commit trailer 构造(纯逻辑,零 vscode 依赖)。 + * + * 对齐 IDEA/Conventional Commits 的 Co-authored-by trailer 追加,以及 --author 参数值格式化。 + * trailer 与 message body 之间需空行分隔;重复 trailer 不重复追加。 + */ + +/** 追加 Co-authored-by trailer(若已存在则原样返回,避免重复)。 */ +export function appendCoAuthoredBy(message: string, name: string, email: string): string { + const trailer = `Co-authored-by: ${name} <${email}>`; + const trimmed = message.replace(/\s+$/, ''); + if (trimmed.length === 0) { + return trailer; + } + if (trimmed.includes(trailer)) { + return trimmed; + } + const lines = trimmed.split('\n'); + const lastLine = lines[lines.length - 1] ?? ''; + const lastTrailerLike = /^[A-Za-z0-9_-]+: .+/.test(lastLine); + // 仅当末行已是 trailer 格式「且」其前一行为空(即已存在 trailer 块)时,直接换行追加; + // 否则用空行分隔(避免把 Conventional Commits 单行 subject「feat: x」误判为 trailer)。 + const hasTrailerBlock = lastTrailerLike && lines.length >= 2 && lines[lines.length - 2] === ''; + return hasTrailerBlock ? `${trimmed}\n${trailer}` : `${trimmed}\n\n${trailer}`; +} + +/** 格式化 --author 参数值:`Name `。 */ +export function authorArg(name: string, email: string): string { + return `${name} <${email}>`; +} diff --git a/src/engine/git-state/conflict-detector.ts b/src/engine/git-state/conflict-detector.ts new file mode 100644 index 0000000..00f10c5 --- /dev/null +++ b/src/engine/git-state/conflict-detector.ts @@ -0,0 +1,61 @@ +/** + * Git 冲突状态检测(纯逻辑,零 vscode 依赖)。 + * + * 解析 `git status --porcelain` 的未合并(unmerged)条目,并结合 `.git` 目录下的标记文件 + * 判定正在进行(ongoing)的 git 操作类型。供 adapter 在 merge/rebase/cherry-pick/revert/stash pop + * 失败后做冲突兜底引导(解决冲突 / 中止操作),防止用户卡在半完成状态。 + */ + +/** 正在进行(且可能产生冲突)的 git 操作类型。 */ +export type OngoingOperation = 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'none'; + +export interface ConflictState { + readonly hasConflicts: boolean; + readonly conflictedPaths: readonly string[]; + readonly ongoingOperation: OngoingOperation; +} + +/** porcelain 中表示未合并(冲突)的 XY 状态码(git status 文档)。 */ +const UNMERGED_STATUS = new Set(['DD', 'AU', 'UD', 'AA', 'DU', 'UA', 'UU']); + +/** + * 解析冲突状态。 + * @param gitStatusPorcelain `git status --porcelain` 输出 + * @param gitDirEntries `.git` 目录下的条目名(用于判定 ongoing 操作: + * MERGE_HEAD / rebase-merge / rebase-apply / CHERRY_PICK_HEAD / REVERT_HEAD) + */ +export function parseConflictState(gitStatusPorcelain: string, gitDirEntries: readonly string[]): ConflictState { + const conflictedPaths: string[] = []; + for (const line of gitStatusPorcelain.split('\n')) { + if (line.length < 3) { + continue; + } + const xy = line.slice(0, 2); + if (!UNMERGED_STATUS.has(xy)) { + continue; + } + // porcelain v1 行格式:"XY path"(rename 为 "XY orig -> path");path 起始于第 4 列(index 3) + const rest = line.slice(3); + const raw = rest.includes(' -> ') ? rest.split(' -> ')[1] : rest; + const cleaned = raw.trim().replace(/^"|"$/g, ''); + if (cleaned) { + conflictedPaths.push(cleaned); + } + } + const entries = new Set(gitDirEntries); + let ongoingOperation: OngoingOperation = 'none'; + if (entries.has('MERGE_HEAD')) { + ongoingOperation = 'merge'; + } else if (entries.has('rebase-merge') || entries.has('rebase-apply')) { + ongoingOperation = 'rebase'; + } else if (entries.has('CHERRY_PICK_HEAD')) { + ongoingOperation = 'cherry-pick'; + } else if (entries.has('REVERT_HEAD')) { + ongoingOperation = 'revert'; + } + return { + hasConflicts: conflictedPaths.length > 0, + conflictedPaths, + ongoingOperation, + }; +} diff --git a/src/engine/log/commit-files.ts b/src/engine/log/commit-files.ts new file mode 100644 index 0000000..0b11d7a --- /dev/null +++ b/src/engine/log/commit-files.ts @@ -0,0 +1,58 @@ +/** + * 单个 commit 变更文件解析(纯逻辑,零 vscode 依赖)。 + * + * 解析 `git diff-tree --no-commit-id --name-status -r [--root] ` 输出为结构化变更列表, + * 供 Log 提交详情面板展开显示该 commit 改动的文件(IDEA Log → commit → changed files 等价)。 + * status 列含相似度时形如 R100/C90(rename/copy),路径列对 rename/copy 为 "old -> new"。 + */ + +export interface CommitFileChange { + /** 变更状态码:A/M/D/T/Rxx/Cxx/U 等。 */ + readonly status: string; + /** 变更后路径(rename/copy 为新路径)。 */ + readonly path: string; + /** rename/copy 的原路径。 */ + readonly oldPath?: string; +} + +/** 状态码语义(用于展示首字母)。 */ +export function statusLabel(status: string): string { + if (status.startsWith('R')) { + return 'R'; + } + if (status.startsWith('C')) { + return 'C'; + } + return status; +} + +/** + * 解析 `git diff-tree --name-status -r` 输出为 CommitFileChange[]。 + * 容错:跳过空行与无 tab 的非法行。 + */ +export function parseNameStatus(output: string): CommitFileChange[] { + const result: CommitFileChange[] = []; + for (const line of output.split('\n')) { + if (line.length === 0) { + continue; + } + const tabIdx = line.indexOf('\t'); + if (tabIdx < 0) { + continue; + } + const status = line.slice(0, tabIdx); + const rest = line.slice(tabIdx + 1); + if (status.length === 0 || rest.length === 0) { + continue; + } + if (status.startsWith('R') || status.startsWith('C')) { + const arrow = rest.split(' -> '); + if (arrow.length === 2) { + result.push({ status, oldPath: arrow[0], path: arrow[1] }); + continue; + } + } + result.push({ status, path: rest }); + } + return result; +} diff --git a/src/engine/log/graph-parser.ts b/src/engine/log/graph-parser.ts new file mode 100644 index 0000000..9690101 --- /dev/null +++ b/src/engine/log/graph-parser.ts @@ -0,0 +1,77 @@ +/** + * `git log --graph` 解析器(纯逻辑,零 vscode 依赖)。 + * + * 解析 `git log --graph --format=%x00%H%x00%d%x00%s` 输出:每行 = graph ASCII 前缀 + NUL 分隔的 + * [hash, decorate, subject]。graph 续行(分叉/合并的 `|\` 等连线,无 commit)不含 NUL。 + * git 已完成 lane 分配(`* | / \ _` 的列布局),解析器只做行级拆分,渲染层按字符粒度还原拓扑。 + */ + +export interface GraphRow { + /** graph ASCII(已去尾空白);续行可能为空串。 */ + readonly graph: string; + /** 完整 hash(仅 commit 行有)。 */ + readonly hash?: string; + /** refs 装饰(%d,如 " (HEAD -> main, origin/main)");已 trim。 */ + readonly decorate?: string; + readonly subject?: string; +} + +const NUL = '\x00'; + +/** + * 解析 `git log --graph --format=%x00%H%x00%d%x00%s` 输出为 GraphRow[]。 + * 容错:跳过空行;无 NUL 的行视为 graph 续行。 + */ +export function parseGraphLog(output: string): GraphRow[] { + const rows: GraphRow[] = []; + for (const line of output.split('\n')) { + if (line.length === 0) { + continue; + } + const firstNul = line.indexOf(NUL); + if (firstNul < 0) { + rows.push({ graph: line.replace(/\s+$/, '') }); + continue; + } + const graph = line.slice(0, firstNul).replace(/\s+$/, ''); + const data = line.slice(firstNul + 1).split(NUL); + rows.push({ + graph, + hash: data[0]?.trim() || undefined, + decorate: data[1]?.trim() || undefined, + subject: data[2] ?? undefined, + }); + } + return rows; +} + +/** 将所有行的 graph 右填充到最大长度(保证列对齐)。 */ +export function normalizeGraphWidth(rows: readonly GraphRow[]): string[] { + let max = 0; + for (const r of rows) { + if (r.graph.length > max) { + max = r.graph.length; + } + } + return rows.map((r) => r.graph.padEnd(max, ' ')); +} + +/** 字符 → 渲染类别。 */ +export type GraphCharKind = 'node' | 'vert' | 'slash' | 'backslash' | 'underscore' | 'blank'; + +export function classifyGraphChar(ch: string): GraphCharKind { + switch (ch) { + case '*': + return 'node'; + case '|': + return 'vert'; + case '/': + return 'slash'; + case '\\': + return 'backslash'; + case '_': + return 'underscore'; + default: + return 'blank'; + } +} diff --git a/src/engine/log/log-filter.ts b/src/engine/log/log-filter.ts new file mode 100644 index 0000000..0e0f0c5 --- /dev/null +++ b/src/engine/log/log-filter.ts @@ -0,0 +1,67 @@ +/** + * Log 客户端过滤器(纯逻辑,零 vscode 依赖)。 + * + * author / path / grep 交给 `git log`(repo.log)服务端过滤;以下维度 git log 稳定 API 不支持或 + * 表达不便,改在客户端对已取回的提交做过滤:合并模式(parents)/ 日期范围(authorDate)/ message 正则。 + * engine 不依赖 vscode,故以 {@link FilterableCommit} 抽象 commit 所需字段,由 adapter 映射。 + */ + +export type MergeMode = 'all' | 'merge-only' | 'no-merge'; + +/** 客户端可施加的过滤维度(均为可选,缺省即不过滤)。 */ +export interface LogClientFilter { + readonly mergeMode?: MergeMode; + /** 起始日期(含),按 authorDate 过滤。 */ + readonly dateFrom?: Date; + /** 截止日期(含),按 authorDate 过滤。 */ + readonly dateTo?: Date; + readonly messageRegex?: RegExp; +} + +/** 过滤所需的最小 commit 投影(adapter 由 vscode.git Commit 映射而来)。 */ +export interface FilterableCommit { + readonly message: string; + readonly authorDate?: Date; + readonly parents: readonly string[]; +} + +/** 安全构造 RegExp:空串或非法模式返回 undefined(避免过滤崩溃)。 */ +export function safeRegex(pattern: string): RegExp | undefined { + if (pattern.length === 0) { + return undefined; + } + try { + return new RegExp(pattern); + } catch { + return undefined; + } +} + +/** 对已取回的提交施加客户端过滤(不可变,返回新数组)。 */ +export function applyClientFilters(commits: readonly T[], filter: LogClientFilter): T[] { + let result: T[] = [...commits]; + if (filter.mergeMode === 'merge-only') { + result = result.filter((c) => c.parents.length > 1); + } else if (filter.mergeMode === 'no-merge') { + result = result.filter((c) => c.parents.length <= 1); + } + if (filter.dateFrom || filter.dateTo) { + result = result.filter((c) => { + if (!c.authorDate) { + return true; + } + if (filter.dateFrom && c.authorDate < filter.dateFrom) { + return false; + } + if (filter.dateTo && c.authorDate > filter.dateTo) { + return false; + } + return true; + }); + } + if (filter.messageRegex) { + const re = filter.messageRegex; + result = result.filter((c) => re.test(c.message)); + } + return result; +} diff --git a/src/engine/merge/diff3.ts b/src/engine/merge/diff3.ts new file mode 100644 index 0000000..5811754 --- /dev/null +++ b/src/engine/merge/diff3.ts @@ -0,0 +1,140 @@ +/** + * 三方合并(diff3)纯逻辑,零 vscode 依赖。 + * + * 对齐 IDEA 3-way merge editor:给定 base / ours / theirs 三份文本(按行),产出 MergeHunk[] + * (stable 一致段 / conflict 冲突段)。算法基于 LCS(最长公共子序列)求 base↔ours 与 base↔theirs + * 的匹配锚点,锚点间的区域按 diff3 规则判定为一致或冲突。 + */ + +export type MergeHunk = + | { readonly kind: 'stable'; readonly content: readonly string[] } + | { readonly kind: 'conflict'; readonly base: readonly string[]; readonly ours: readonly string[]; readonly theirs: readonly string[] }; + +export type ResolveChoice = 'ours' | 'theirs' | 'both' | 'base' | 'manual'; + +/** 两序列的 LCS 匹配对([aIdx, bIdx],单调递增)。 */ +function lcsMatches(a: readonly string[], b: readonly string[]): Array<[number, number]> { + const n = a.length; + const m = b.length; + const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + const matches: Array<[number, number]> = []; + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { + matches.push([i, j]); + i++; + j++; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + i++; + } else { + j++; + } + } + return matches; +} + +function eq(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) { + return false; + } + for (let k = 0; k < a.length; k++) { + if (a[k] !== b[k]) { + return false; + } + } + return true; +} + +/** + * 三方合并:返回 MergeHunk[]。冲突判定遵循 diff3:仅当 ours 与 theirs 对同一 base 区域做了不同改动时冲突。 + */ +export function diff3(base: readonly string[], ours: readonly string[], theirs: readonly string[]): MergeHunk[] { + const mo = new Map(); // baseIdx -> oursIdx + for (const [oi, bi] of lcsMatches(ours, base)) { + mo.set(bi, oi); + } + const mt = new Map(); // baseIdx -> theirsIdx + for (const [ti, bi] of lcsMatches(theirs, base)) { + mt.set(bi, ti); + } + + const hunks: MergeHunk[] = []; + const pushStable = (content: readonly string[]): void => { + if (content.length === 0) { + return; + } + const last = hunks[hunks.length - 1]; + if (last && last.kind === 'stable') { + // 合并相邻 stable 段,减少分段 + (hunks[hunks.length - 1] as { kind: 'stable'; content: string[] }).content.push(...content); + } else { + hunks.push({ kind: 'stable', content: [...content] }); + } + }; + const emitRegion = (baseR: readonly string[], oursR: readonly string[], theirsR: readonly string[]): void => { + if (baseR.length === 0 && oursR.length === 0 && theirsR.length === 0) { + return; + } + const oursSame = eq(oursR, baseR); + const theirsSame = eq(theirsR, baseR); + if (oursSame && theirsSame) { + pushStable(baseR); + } else if (oursSame) { + pushStable(theirsR); // theirs 改动,ours 未动 → 取 theirs + } else if (theirsSame) { + pushStable(oursR); // ours 改动,theirs 未动 → 取 ours + } else if (eq(oursR, theirsR)) { + pushStable(oursR); // 双方同样改动 → 取任一 + } else { + hunks.push({ kind: 'conflict', base: baseR, ours: oursR, theirs: theirsR }); + } + }; + + let po = -1; + let pb = -1; + let pt = -1; + for (let bi = 0; bi < base.length; bi++) { + if (!mo.has(bi) || !mt.has(bi)) { + continue; + } + const oi = mo.get(bi)!; + const ti = mt.get(bi)!; + emitRegion(base.slice(pb + 1, bi), ours.slice(po + 1, oi), theirs.slice(pt + 1, ti)); + pushStable([base[bi]]); + po = oi; + pb = bi; + pt = ti; + } + emitRegion(base.slice(pb + 1), ours.slice(po + 1), theirs.slice(pt + 1)); + return hunks; +} + +/** 解析单个 hunk 为最终行(stable 直接返回 content;conflict 按选择返回)。 */ +export function resolveHunk(hunk: MergeHunk, choice: ResolveChoice, manual?: readonly string[]): string[] { + if (hunk.kind === 'stable') { + return [...hunk.content]; + } + switch (choice) { + case 'ours': + return [...hunk.ours]; + case 'theirs': + return [...hunk.theirs]; + case 'both': + return [...hunk.ours, ...hunk.theirs]; + case 'base': + return [...hunk.base]; + case 'manual': + return manual ? [...manual] : [...hunk.ours]; + } +} + +/** 统计冲突数。 */ +export function conflictCount(hunks: readonly MergeHunk[]): number { + return hunks.reduce((n, h) => (h.kind === 'conflict' ? n + 1 : n), 0); +} diff --git a/src/engine/rebase/todo.ts b/src/engine/rebase/todo.ts new file mode 100644 index 0000000..b9c7fe9 --- /dev/null +++ b/src/engine/rebase/todo.ts @@ -0,0 +1,59 @@ +/** + * 交互式 rebase todo 序列构造(纯逻辑,零 vscode 依赖)。 + * + * 对齐 IDEA 交互式 rebase 编辑器:将 {action, hash, subject} 序列化为 git rebase -i 的 todo 文件内容, + * 供 GIT_SEQUENCE_EDITOR 注入;并支持解析(回填编辑器)与重排序(拖拽)。 + * action 限定 pick / reword / edit / squash / fixup / drop。 + */ + +export type RebaseAction = 'pick' | 'reword' | 'edit' | 'squash' | 'fixup' | 'drop'; + +export const REBASE_ACTIONS: readonly RebaseAction[] = ['pick', 'reword', 'edit', 'squash', 'fixup', 'drop']; + +export interface RebaseTodoItem { + readonly action: RebaseAction; + readonly hash: string; + readonly subject: string; +} + +/** 校验 action 合法(兼类型守卫)。 */ +export function isValidAction(action: string): action is RebaseAction { + return (REBASE_ACTIONS as readonly string[]).includes(action); +} + +/** 序列化 todo 列表为 git rebase -i 的 todo 文件内容(每行 "action hash subject",末尾换行)。 */ +export function serializeTodo(items: readonly RebaseTodoItem[]): string { + return items.map((i) => `${i.action} ${i.hash} ${i.subject}`).join('\n') + '\n'; +} + +/** 解析 todo 文件内容为 RebaseTodoItem[](容错:跳过注释行/空行/非法 action)。 */ +export function parseTodo(content: string): RebaseTodoItem[] { + const items: RebaseTodoItem[] = []; + for (const raw of content.split('\n')) { + const line = raw.trim(); + if (line.length === 0 || line.startsWith('#')) { + continue; + } + const m = line.match(/^(\w+)\s+(\S+)\s*(.*)$/); + if (!m) { + continue; + } + const [, action, hash, subject] = m; + if (!isValidAction(action)) { + continue; + } + items.push({ action, hash, subject }); + } + return items; +} + +/** 重排序:把 from 索引的项移动到 to 索引(不可变;越界或相同则原样返回副本)。 */ +export function reorderTodo(items: readonly RebaseTodoItem[], from: number, to: number): RebaseTodoItem[] { + if (from < 0 || from >= items.length || to < 0 || to >= items.length || from === to) { + return [...items]; + } + const next = [...items]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + return next; +} diff --git a/src/engine/ref/cleanup.ts b/src/engine/ref/cleanup.ts new file mode 100644 index 0000000..9e8bad3 --- /dev/null +++ b/src/engine/ref/cleanup.ts @@ -0,0 +1,37 @@ +/** + * 已合并分支清理的纯逻辑(零 vscode 依赖)。 + * + * 解析 `git branch --merged ` 输出并排除受保护分支(main/master/当前/指定 base), + * 输出可安全删除的分支名列表。从 advanced-commands.cleanupBranches 内联逻辑提取,便于单测。 + */ + +/** 默认受保护(永不清理)的分支名。 */ +export const PROTECTED_BRANCHES: readonly string[] = ['main', 'master']; + +/** 判定分支是否受保护(默认集 + 额外排除项,如当前 HEAD / 指定 base)。 */ +export function isProtectedBranch(name: string, extraExclude: readonly string[] = []): boolean { + return PROTECTED_BRANCHES.includes(name) || extraExclude.includes(name); +} + +/** + * 解析 `git branch --merged ` 输出为可安全删除的分支名列表。 + * @param mergedOutput `git branch --merged` 原始输出(当前分支前缀 `*`) + * @param base 比较基准分支名(排除自身) + * @param extraExclude 额外排除的分支名(如当前 HEAD 名) + */ +export function filterMergeable(mergedOutput: string, base: string, extraExclude: readonly string[] = []): string[] { + const seen = new Set(); + const result: string[] = []; + for (const raw of mergedOutput.split('\n')) { + const name = raw.trim().replace(/^\*\s*/, '').trim(); + if (name.length === 0 || seen.has(name)) { + continue; + } + seen.add(name); + if (isProtectedBranch(name, [...extraExclude, base])) { + continue; + } + result.push(name); + } + return result; +} diff --git a/src/engine/ref/favorites.ts b/src/engine/ref/favorites.ts new file mode 100644 index 0000000..a4c4acc --- /dev/null +++ b/src/engine/ref/favorites.ts @@ -0,0 +1,25 @@ +/** + * 分支收藏(Favorites)纯逻辑(零 vscode 依赖)。 + * + * IDEA 的「Set Favorite」能力:把常用分支标星置顶。本模块提供无状态集合运算, + * 持久化与事件由 adapter 层的 BranchFavorites(workspaceState)承载,便于单测。 + */ + +/** 切换某分支的收藏态,返回新的收藏名列表(不可变,保持插入顺序)。 */ +export function toggleFavorite(names: readonly string[], name: string): string[] { + if (name.length === 0) { + return [...names]; + } + const set = new Set(names); + if (set.has(name)) { + set.delete(name); + } else { + set.add(name); + } + return [...set]; +} + +/** 是否已收藏。 */ +export function isFavorite(names: readonly string[], name: string): boolean { + return names.includes(name); +} diff --git a/src/engine/ref/for-each-ref.ts b/src/engine/ref/for-each-ref.ts new file mode 100644 index 0000000..08eb26c --- /dev/null +++ b/src/engine/ref/for-each-ref.ts @@ -0,0 +1,102 @@ +/** + * `git for-each-ref` 输出解析器(纯逻辑,零 vscode 依赖)。 + * + * 目的:为 Branches 视图提供可靠的分支枚举底座。vscode.git 的 `Repository.state.refs` + * 在视图首帧渲染时可能尚未填充(异步),且其元素是 `Ref`(无 upstream / 无 HEAD 标记 / 无 ahead-behind)。 + * 故改用受控 CLI 通道执行 `git for-each-ref`,一次拿全字段(含 upstream / ahead-behind track / HEAD 标记), + * 供 adapter 渲染与 Phase 1 的 favorites / ahead-behind 展示复用。 + * + * 配套 CLI(字段以 NUL `%00` 分隔,规避短名含空格歧义;与 {@link parseForEachRef} 字段顺序严格对应): + * git for-each-ref --format= refs/heads refs/remotes refs/tags + */ + +/** for-each-ref 的 --format 值(与 parseForEachRef 的字段顺序严格对应,勿单独修改)。 */ +export const FOR_EACH_REF_FORMAT = + '%(refname)%00%(refname:short)%00%(objectname:short)%00%(upstream:short)%00%(upstream:track)%00%(HEAD)'; + +/** 一条 ref 的解析结果。 */ +export interface RawRef { + /** 完整 refname,如 refs/heads/main / refs/remotes/origin/main / refs/tags/v1.0。 */ + readonly refname: string; + /** 短名(%(refname:short)),如 main / origin/main / v1.0。 */ + readonly shortName: string; + /** 短 sha(%(objectname:short))。 */ + readonly objectname: string; + /** 上游短名(%(upstream:short)),仅本地分支有上游时非空。 */ + readonly upstream?: string; + /** 领先上游的提交数(来自 %(upstream:track) 的 "[ahead N]")。 */ + readonly ahead?: number; + /** 落后上游的提交数(来自 %(upstream:track) 的 "[behind M]")。 */ + readonly behind?: number; + /** 是否当前 HEAD(%(HEAD) 为 `*`)。 */ + readonly head: boolean; + readonly isRemote: boolean; + readonly isTag: boolean; +} + +/** 按本地分支 / 远程分支 / 标签分组。 */ +export interface GroupedRefs { + readonly local: readonly RawRef[]; + readonly remote: readonly RawRef[]; + readonly tags: readonly RawRef[]; +} + +const NUL = '\x00'; +const AHEAD_RE = /\bahead\s+(\d+)/; +const BEHIND_RE = /\bbehind\s+(\d+)/; + +/** + * 解析 `git for-each-ref`(NUL 分隔 6 字段)输出为 RawRef[]。 + * 容错:忽略空行与字段不足的行(避免解析异常中断整个视图)。 + */ +export function parseForEachRef(output: string): RawRef[] { + const refs: RawRef[] = []; + for (const line of output.split('\n')) { + if (line.length === 0) { + continue; + } + const parts = line.split(NUL); + if (parts.length < 6) { + continue; + } + const refname = parts[0]; + const shortName = parts[1]; + const objectname = parts[2]; + const upstream = parts[3]; + const track = parts[4]; + if (!refname) { + continue; + } + const aheadMatch = track.match(AHEAD_RE); + const behindMatch = track.match(BEHIND_RE); + refs.push({ + refname, + shortName, + objectname, + upstream: upstream.length > 0 ? upstream : undefined, + ahead: aheadMatch ? Number(aheadMatch[1]) : undefined, + behind: behindMatch ? Number(behindMatch[1]) : undefined, + head: parts[5].trim() === '*', + isRemote: refname.startsWith('refs/remotes/'), + isTag: refname.startsWith('refs/tags/'), + }); + } + return refs; +} + +/** 按本地 / 远程 / 标签分组。 */ +export function groupByKind(refs: readonly RawRef[]): GroupedRefs { + const local: RawRef[] = []; + const remote: RawRef[] = []; + const tags: RawRef[] = []; + for (const r of refs) { + if (r.isTag) { + tags.push(r); + } else if (r.isRemote) { + remote.push(r); + } else { + local.push(r); + } + } + return { local, remote, tags }; +} diff --git a/src/extension.ts b/src/extension.ts index 9de7d0f..80cdbaa 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,6 +6,7 @@ import { NullLlmProvider } from './agent/llm-provider'; import { NullPreCommitInspector } from './agent/pre-commit'; import { registerChangesCommands } from './adapter/commands'; import { ChangelistRegistry } from './adapter/changelist-registry'; +import { BranchFavorites } from './adapter/branch-favorites'; import { CommitService } from './adapter/commit/commit-service'; import { BranchesTreeProvider } from './adapter/tree/branches-tree'; import { ChangesTreeProvider, EmptyChangesProvider } from './adapter/tree/changes-tree'; @@ -15,13 +16,17 @@ import { registerStashCommands } from './adapter/stash-commands'; import { registerGitCliCommands } from './adapter/git-cli-commands'; 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 { CommitWebviewProvider } from './adapter/webview/commit-webview'; import { GraphWebview } from './adapter/webview/graph-webview'; import { showGitConsole } from './infra/git-console'; import { InlineCommitCodeLensProvider, registerInlineCommitCommand } from './adapter/editor/inline-commit-codelens'; +import { BlameAnnotationController } from './adapter/editor/blame-annotation'; import { ShelfService, ShelfTreeProvider, registerShelfCommands } from './adapter/shelf'; import { RebaseWebview } from './adapter/webview/rebase-webview'; +import { registerMergeCommands } from './adapter/webview/merge-editor'; +import { registerMiscCommands } from './adapter/misc-commands'; import { getGitApi } from './adapter/git-api'; import { GitRepositoryService } from './adapter/git-repository-service'; import { createLogger } from './infra/logger'; @@ -52,6 +57,7 @@ export async function activate(context: vscode.ExtensionContext): Promise const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? 'default'; const service = new GitRepositoryService(api); const registry = new ChangelistRegistry(context.workspaceState, service.repoRoot ?? workspaceRoot); + const favorites = new BranchFavorites(context.workspaceState, service.repoRoot ?? workspaceRoot); const tree = new ChangesTreeProvider(service, registry); // AI 接缝注入(Null 实现,M5 替换为真实 provider) @@ -64,9 +70,10 @@ export async function activate(context: vscode.ExtensionContext): Promise }); const commitView = new CommitWebviewProvider(service, registry, commit); const logTree = new LogTreeProvider(service); - const branchesTree = new BranchesTreeProvider(service); + const branchesTree = new BranchesTreeProvider(service, favorites); const stashTree = new StashTreeProvider(service); const inlineLens = new InlineCommitCodeLensProvider(service); + const blame = new BlameAnnotationController(service); const shelfService = new ShelfService(service, context.globalStorageUri.fsPath); const shelfTree = new ShelfTreeProvider(shelfService); const focusCommitView = (): void => { @@ -76,11 +83,13 @@ export async function activate(context: vscode.ExtensionContext): Promise context.subscriptions.push( service, registry, + favorites, commit, logTree, branchesTree, stashTree, shelfTree, + blame, vscode.window.registerTreeDataProvider('hyperGit.changes', tree), vscode.window.registerWebviewViewProvider(CommitWebviewProvider.viewType, commitView), vscode.window.registerTreeDataProvider('hyperGit.log', logTree), @@ -88,10 +97,14 @@ export async function activate(context: vscode.ExtensionContext): Promise vscode.window.registerTreeDataProvider('hyperGit.stash', stashTree), vscode.window.registerTreeDataProvider('hyperGit.shelf', shelfTree), ...registerChangesCommands(service, registry, tree), - ...registerHistoryCommands(service, logTree, branchesTree), - ...registerGitCliCommands(service, branchesTree), + ...registerHistoryCommands(service, logTree, branchesTree, favorites), + ...registerGitCliCommands(service, branchesTree, logTree), ...registerPartialCommands(service, registry), ...registerAdvancedCommands(service, branchesTree), + ...registerRemoteCommands(service, branchesTree, logTree), + ...registerMergeCommands(service), + ...registerMiscCommands(service, branchesTree, logTree), + vscode.commands.registerCommand('hyperGit.toggleBlameAnnotation', () => blame.toggle()), ...registerStashCommands(service, stashTree), ...registerShelfCommands(service, shelfService, shelfTree), vscode.commands.registerCommand('hyperGit.commit', focusCommitView), @@ -122,6 +135,14 @@ export async function activate(context: vscode.ExtensionContext): Promise registry.onDidChange(refreshAll), commit.onDidChange(refreshAll), ); + + // 首帧保险:若 repo 在 activate 前已就绪,GitRepositoryService 构造函数的 _onDidChange.fire() + // 早于任何订阅者挂载而被丢失,state.onDidChange 此后可能不再触发。主动刷新一次确保 + // Branches/Log 不停留在首帧空状态(getChildren 内已对未就绪数据做 CLI 兜底与空安全处理)。 + setTimeout(() => { + branchesTree.refresh(); + logTree.refresh(); + }, 500); } export function deactivate(): void { diff --git a/tests/unit/blame-parser.test.ts b/tests/unit/blame-parser.test.ts new file mode 100644 index 0000000..6b7ef1c --- /dev/null +++ b/tests/unit/blame-parser.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { parseBlamePorcelain, formatAnnotation } from '../../src/engine/blame/blame-parser'; + +// 模拟 git blame --line-porcelain(两行,第二行复用首个 commit 的元数据缓存) +const SAMPLE = [ + 'abc1234567 1 1 2', + 'author Alice', + 'author-mail ', + 'author-time 1700000000', + 'author-tz +0800', + 'summary feat: first', + 'filename f.txt', + '\tline one content', + 'abc1234567 2 2', + '\tline two content', + 'def8901234 3 3 1', + 'author Bob', + 'author-time 1710000000', + 'summary fix: third', + 'filename f.txt', + '\tline three content', +].join('\n'); + +describe('parseBlamePorcelain', () => { + it('解析每个最终行号 → BlameLine', () => { + const lines = parseBlamePorcelain(SAMPLE); + expect(lines).toHaveLength(3); + expect(lines.map((l) => l.line)).toEqual([1, 2, 3]); + }); + + it('首块解析 author/time/summary', () => { + const l = parseBlamePorcelain(SAMPLE)[0]; + expect(l.sha).toBe('abc1234567'); + expect(l.author).toBe('Alice'); + expect(l.authorTime).toBe(1700000000); + expect(l.summary).toBe('feat: first'); + }); + + it('同 commit 后续块复用元数据缓存(省略 author)', () => { + const l = parseBlamePorcelain(SAMPLE)[1]; + expect(l.sha).toBe('abc1234567'); + expect(l.author).toBe('Alice'); // 来自缓存 + expect(l.summary).toBe('feat: first'); + }); + + it('不同 commit 解析独立元数据', () => { + const l = parseBlamePorcelain(SAMPLE)[2]; + expect(l.sha).toBe('def8901234'); + expect(l.author).toBe('Bob'); + expect(l.summary).toBe('fix: third'); + }); + + it('空输入返回空', () => { + expect(parseBlamePorcelain('')).toEqual([]); + }); +}); + +describe('formatAnnotation', () => { + it('格式化 作者 · 日期', () => { + const s = formatAnnotation({ line: 1, sha: 'abc1234', author: 'Alice', authorTime: 1700000000, summary: 's' }); + expect(s).toMatch(/^Alice · \d{4}-\d{2}-\d{2}$/); + }); + + it('全 0 sha → 未提交', () => { + expect(formatAnnotation({ line: 1, sha: '0000000000', author: '', authorTime: 0, summary: '' })).toBe('未提交'); + }); +}); diff --git a/tests/unit/commit-trailer.test.ts b/tests/unit/commit-trailer.test.ts new file mode 100644 index 0000000..ae667df --- /dev/null +++ b/tests/unit/commit-trailer.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { appendCoAuthoredBy, authorArg } from '../../src/engine/commit/trailer'; + +describe('appendCoAuthoredBy', () => { + it('给单行 message 追加 trailer(带空行分隔)', () => { + expect(appendCoAuthoredBy('feat: x', 'Alice', 'a@x.com')).toBe('feat: x\n\nCo-authored-by: Alice '); + }); + + it('已有 body 时正确分隔', () => { + const msg = 'feat: x\n\nbody line'; + expect(appendCoAuthoredBy(msg, 'Bob', 'b@x.com')).toBe('feat: x\n\nbody line\n\nCo-authored-by: Bob '); + }); + + it('末行已是 trailer 时直接换行追加(不再加空行)', () => { + const msg = 'feat: x\n\nSigned-off-by: C '; + expect(appendCoAuthoredBy(msg, 'Alice', 'a@x.com')).toBe('feat: x\n\nSigned-off-by: C \nCo-authored-by: Alice '); + }); + + it('重复 trailer 不重复追加', () => { + const msg = 'feat: x\n\nCo-authored-by: Alice '; + expect(appendCoAuthoredBy(msg, 'Alice', 'a@x.com')).toBe(msg); + }); + + it('空 message 直接返回 trailer', () => { + expect(appendCoAuthoredBy('', 'Alice', 'a@x.com')).toBe('Co-authored-by: Alice '); + }); + + it('去除 message 尾部空白', () => { + expect(appendCoAuthoredBy('feat: x \n', 'Alice', 'a@x.com')).toBe('feat: x\n\nCo-authored-by: Alice '); + }); +}); + +describe('authorArg', () => { + it('格式化 Name ', () => { + expect(authorArg('Alice', 'a@x.com')).toBe('Alice '); + }); +}); diff --git a/tests/unit/conflict-detector.test.ts b/tests/unit/conflict-detector.test.ts new file mode 100644 index 0000000..a141e62 --- /dev/null +++ b/tests/unit/conflict-detector.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { parseConflictState } from '../../src/engine/git-state/conflict-detector'; + +describe('parseConflictState', () => { + it('无冲突时 hasConflicts=false', () => { + const s = parseConflictState(' M modified.txt\n?? untracked.txt\n', []); + expect(s.hasConflicts).toBe(false); + expect(s.conflictedPaths).toEqual([]); + expect(s.ongoingOperation).toBe('none'); + }); + + it('识别 UU/AA/DD 等未合并状态为冲突', () => { + const s = parseConflictState('UU both.txt\nAA added.txt\nDD deleted.txt\n M ok.txt\n', []); + expect(s.hasConflicts).toBe(true); + expect(s.conflictedPaths).toEqual(['both.txt', 'added.txt', 'deleted.txt']); + }); + + it('忽略非冲突状态(M / A / ? 等)', () => { + const s = parseConflictState(' M a.txt\nA b.txt\n?? c.txt\n', []); + expect(s.hasConflicts).toBe(false); + }); + + it('解析 rename 冲突的最终路径(orig -> new)', () => { + const s = parseConflictState('UU old.txt -> new.txt\n', []); + expect(s.conflictedPaths).toEqual(['new.txt']); + }); + + it('去除路径两侧引号(含空格文件名)', () => { + const s = parseConflictState('UU "my file.txt"\n', []); + expect(s.conflictedPaths).toEqual(['my file.txt']); + }); + + it('忽略空行与过短行', () => { + const s = parseConflictState('\nUU a.txt\n\nx\n', []); + expect(s.conflictedPaths).toEqual(['a.txt']); + }); + + it('MERGE_HEAD → ongoing=merge', () => { + expect(parseConflictState('UU a.txt', ['HEAD', 'MERGE_HEAD']).ongoingOperation).toBe('merge'); + }); + + it('rebase-merge / rebase-apply → ongoing=rebase', () => { + expect(parseConflictState('UU a.txt', ['rebase-merge']).ongoingOperation).toBe('rebase'); + expect(parseConflictState('UU a.txt', ['rebase-apply']).ongoingOperation).toBe('rebase'); + }); + + it('CHERRY_PICK_HEAD → ongoing=cherry-pick', () => { + expect(parseConflictState('UU a.txt', ['CHERRY_PICK_HEAD']).ongoingOperation).toBe('cherry-pick'); + }); + + it('REVERT_HEAD → ongoing=revert', () => { + expect(parseConflictState('UU a.txt', ['REVERT_HEAD']).ongoingOperation).toBe('revert'); + }); + + it('有冲突文件但无 ongoing 标记 → ongoing=none(如 stash pop 冲突)', () => { + expect(parseConflictState('UU a.txt', []).ongoingOperation).toBe('none'); + expect(parseConflictState('UU a.txt', []).hasConflicts).toBe(true); + }); +}); diff --git a/tests/unit/for-each-ref.test.ts b/tests/unit/for-each-ref.test.ts new file mode 100644 index 0000000..c1a155d --- /dev/null +++ b/tests/unit/for-each-ref.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { FOR_EACH_REF_FORMAT, parseForEachRef, groupByKind } from '../../src/engine/ref/for-each-ref'; + +const NUL = '\x00'; + +/** 构造一行 for-each-ref 输出:refname|shortName|sha|upstream|track|head(NUL 分隔)。 */ +function line(refname: string, shortName: string, sha: string, upstream: string, track: string, head: string): string { + return [refname, shortName, sha, upstream, track, head].join(NUL); +} + +const SAMPLE = [ + line('refs/heads/main', 'main', 'abc1234', 'origin/main', '[ahead 2]', '*'), + line('refs/heads/feature/x', 'feature/x', 'def5678', 'origin/feature/x', '[behind 3]', ' '), + line('refs/heads/behind-only', 'behind-only', '1111111', 'origin/behind-only', '[behind 1]', ' '), + line('refs/heads/diverged', 'diverged', '2222222', 'origin/diverged', '[ahead 5, behind 2]', ' '), + line('refs/heads/no-upstream', 'no-upstream', '3333333', '', '', ' '), + line('refs/remotes/origin/main', 'origin/main', 'abc1234', '', '', ' '), + line('refs/remotes/origin/feature/x', 'origin/feature/x', 'def5678', '', '', ''), + line('refs/tags/v1.0', 'v1.0', 'abc1234', '', '', ' '), +].join('\n'); + +describe('FOR_EACH_REF_FORMAT', () => { + it('格式与解析器字段顺序严格对应(6 个 %00 分隔字段,git 运行时解释为 NUL)', () => { + // 注意:%00 是字面文本(git 在运行时才解释为 NUL 字节),故按字面 %00 切分 + expect(FOR_EACH_REF_FORMAT.split('%00')).toHaveLength(6); + }); +}); + +describe('parseForEachRef', () => { + it('解析本地/远程/tag 三类 ref,正确判定 isRemote/isTag', () => { + const refs = parseForEachRef(SAMPLE); + expect(refs).toHaveLength(8); + expect(refs.filter((r) => !r.isRemote && !r.isTag)).toHaveLength(5); + expect(refs.filter((r) => r.isRemote)).toHaveLength(2); + expect(refs.filter((r) => r.isTag)).toHaveLength(1); + }); + + it('解析 objectname 短 sha', () => { + expect(parseForEachRef(SAMPLE).find((r) => r.shortName === 'main')?.objectname).toBe('abc1234'); + }); + + it('仅 `*` 行标记 head=true(空格/空均视为非 HEAD)', () => { + const refs = parseForEachRef(SAMPLE); + expect(refs.find((r) => r.shortName === 'main')?.head).toBe(true); + expect(refs.find((r) => r.shortName === 'feature/x')?.head).toBe(false); + expect(refs.find((r) => r.shortName === 'origin/feature/x')?.head).toBe(false); + }); + + it('upstream 仅本地分支有上游时为字符串,其余为 undefined', () => { + const refs = parseForEachRef(SAMPLE); + expect(refs.find((r) => r.shortName === 'main')?.upstream).toBe('origin/main'); + expect(refs.find((r) => r.shortName === 'no-upstream')?.upstream).toBeUndefined(); + expect(refs.find((r) => r.isRemote)?.upstream).toBeUndefined(); + }); + + it('解析 ahead([ahead N])', () => { + const refs = parseForEachRef(SAMPLE); + expect(refs.find((r) => r.shortName === 'main')?.ahead).toBe(2); + expect(refs.find((r) => r.shortName === 'main')?.behind).toBeUndefined(); + }); + + it('解析 behind([behind M])', () => { + const refs = parseForEachRef(SAMPLE); + expect(refs.find((r) => r.shortName === 'feature/x')?.behind).toBe(3); + expect(refs.find((r) => r.shortName === 'feature/x')?.ahead).toBeUndefined(); + }); + + it('解析同时 ahead+behind(diverged)', () => { + const diverged = parseForEachRef(SAMPLE).find((r) => r.shortName === 'diverged'); + expect(diverged?.ahead).toBe(5); + expect(diverged?.behind).toBe(2); + }); + + it('无 track 时 ahead/behind 均为 undefined', () => { + const noUp = parseForEachRef(SAMPLE).find((r) => r.shortName === 'no-upstream'); + expect(noUp?.ahead).toBeUndefined(); + expect(noUp?.behind).toBeUndefined(); + }); + + it('忽略空行', () => { + const out = `\n${line('refs/heads/a', 'a', '1111111', '', '', ' ')}\n\n`; + expect(parseForEachRef(out)).toHaveLength(1); + }); + + it('忽略字段不足的残行(< 6 字段)', () => { + const out = [line('refs/heads/a', 'a', '1111111', '', '', ' '), 'refs/heads/b\x00b'].join('\n'); + expect(parseForEachRef(out)).toHaveLength(1); + }); + + it('refname 为空时跳过', () => { + expect(parseForEachRef(line('', '', '1111111', '', '', ' '))).toHaveLength(0); + }); +}); + +describe('groupByKind', () => { + it('按本地/远程/tag 三组划分', () => { + const g = groupByKind(parseForEachRef(SAMPLE)); + expect(g.local).toHaveLength(5); + expect(g.remote).toHaveLength(2); + expect(g.tags).toHaveLength(1); + }); +}); diff --git a/tests/unit/log-commit-files.test.ts b/tests/unit/log-commit-files.test.ts new file mode 100644 index 0000000..af35fd9 --- /dev/null +++ b/tests/unit/log-commit-files.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { parseNameStatus, statusLabel } from '../../src/engine/log/commit-files'; + +describe('commit-files parseNameStatus', () => { + it('解析 A/M/D 普通变更', () => { + const out = 'A\tnew.txt\nM\tmod.txt\nD\tgone.txt'; + expect(parseNameStatus(out)).toEqual([ + { status: 'A', path: 'new.txt' }, + { status: 'M', path: 'mod.txt' }, + { status: 'D', path: 'gone.txt' }, + ]); + }); + + it('解析 rename(R100 old -> new)', () => { + const out = 'R100\told.txt -> new.txt'; + expect(parseNameStatus(out)).toEqual([{ status: 'R100', oldPath: 'old.txt', path: 'new.txt' }]); + }); + + it('解析 copy(C90)', () => { + const out = 'C90\tsrc.txt -> copy.txt'; + expect(parseNameStatus(out)).toEqual([{ status: 'C90', oldPath: 'src.txt', path: 'copy.txt' }]); + }); + + it('忽略空行与无 tab 的非法行', () => { + const out = '\nA\ta.txt\nINVALIDLINE\n\nM\tb.txt'; + expect(parseNameStatus(out)).toHaveLength(2); + }); + + it('状态或路径为空时跳过', () => { + const out = '\ta.txt\nM\t'; + expect(parseNameStatus(out)).toHaveLength(0); + }); + + it('含空格/中文路径正常解析', () => { + expect(parseNameStatus('M\tmy file 中文.txt')).toEqual([{ status: 'M', path: 'my file 中文.txt' }]); + }); +}); + +describe('statusLabel', () => { + it('R100 → R', () => { + expect(statusLabel('R100')).toBe('R'); + }); + it('C90 → C', () => { + expect(statusLabel('C90')).toBe('C'); + }); + it('A/M/D 原样', () => { + expect(statusLabel('A')).toBe('A'); + expect(statusLabel('M')).toBe('M'); + }); +}); diff --git a/tests/unit/log-filter.test.ts b/tests/unit/log-filter.test.ts new file mode 100644 index 0000000..e48ed06 --- /dev/null +++ b/tests/unit/log-filter.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { applyClientFilters, safeRegex, type FilterableCommit, type LogClientFilter } from '../../src/engine/log/log-filter'; + +const D = (iso: string): Date => new Date(iso); +const C = (message: string, parents: string[], authorDate?: Date): FilterableCommit => ({ message, parents, authorDate }); + +const COMMITS: FilterableCommit[] = [ + C('feat: a', ['p1'], D('2026-06-01')), + C('merge: branch', ['p1', 'p2'], D('2026-06-10')), + C('fix: bug [urgent]', ['p1'], D('2026-06-20')), + C('docs: readme', ['p1', 'p2', 'p3'], D('2026-07-01')), +]; + +describe('log-filter', () => { + it('merge-only 仅保留 parents>1', () => { + const f: LogClientFilter = { mergeMode: 'merge-only' }; + expect(applyClientFilters(COMMITS, f).map((c) => c.message)).toEqual(['merge: branch', 'docs: readme']); + }); + + it('no-merge 仅保留 parents<=1', () => { + const f: LogClientFilter = { mergeMode: 'no-merge' }; + expect(applyClientFilters(COMMITS, f).map((c) => c.message)).toEqual(['feat: a', 'fix: bug [urgent]']); + }); + + it('all 不过滤', () => { + expect(applyClientFilters(COMMITS, { mergeMode: 'all' })).toHaveLength(4); + expect(applyClientFilters(COMMITS, {})).toHaveLength(4); + }); + + it('dateFrom 截断早于该日期的提交', () => { + const f: LogClientFilter = { dateFrom: D('2026-06-15') }; + expect(applyClientFilters(COMMITS, f).map((c) => c.message)).toEqual(['fix: bug [urgent]', 'docs: readme']); + }); + + it('dateTo 截断晚于该日期的提交', () => { + const f: LogClientFilter = { dateTo: D('2026-06-15') }; + expect(applyClientFilters(COMMITS, f).map((c) => c.message)).toEqual(['feat: a', 'merge: branch']); + }); + + it('dateFrom+dateTo 区间', () => { + const f: LogClientFilter = { dateFrom: D('2026-06-05'), dateTo: D('2026-06-25') }; + expect(applyClientFilters(COMMITS, f).map((c) => c.message)).toEqual(['merge: branch', 'fix: bug [urgent]']); + }); + + it('无 authorDate 的提交在日期过滤时保留', () => { + const commits = [C('no-date', ['p1'])]; + expect(applyClientFilters(commits, { dateFrom: D('2026-01-01') })).toHaveLength(1); + }); + + it('messageRegex 按 message 过滤', () => { + const f: LogClientFilter = { messageRegex: /\[urgent\]/ }; + expect(applyClientFilters(COMMITS, f).map((c) => c.message)).toEqual(['fix: bug [urgent]']); + }); + + it('组合:no-merge + dateFrom + regex', () => { + const f: LogClientFilter = { mergeMode: 'no-merge', dateFrom: D('2026-06-15'), messageRegex: /bug/ }; + expect(applyClientFilters(COMMITS, f).map((c) => c.message)).toEqual(['fix: bug [urgent]']); + }); +}); + +describe('safeRegex', () => { + it('合法模式返回 RegExp', () => { + expect(safeRegex('feat')).toEqual(/feat/); + }); + it('空串返回 undefined', () => { + expect(safeRegex('')).toBeUndefined(); + }); + it('非法模式返回 undefined(不抛错)', () => { + expect(safeRegex('(')).toBeUndefined(); + }); +}); diff --git a/tests/unit/log-graph-parser.test.ts b/tests/unit/log-graph-parser.test.ts new file mode 100644 index 0000000..cbfc2ff --- /dev/null +++ b/tests/unit/log-graph-parser.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import { parseGraphLog, normalizeGraphWidth, classifyGraphChar } from '../../src/engine/log/graph-parser'; + +const NUL = '\x00'; + +describe('parseGraphLog', () => { + it('解析 commit 行(graph + hash + decorate + subject)', () => { + const out = `*${NUL}aaa1111${NUL} (HEAD -> main)${NUL}first commit`; + const rows = parseGraphLog(out); + expect(rows).toHaveLength(1); + expect(rows[0]).toEqual({ graph: '*', hash: 'aaa1111', decorate: '(HEAD -> main)', subject: 'first commit' }); + }); + + it('graph 续行(无 NUL)只含 graph', () => { + const out = ['*' + NUL + 'aaa' + NUL + '' + NUL + 'c1', '|\\', '| *' + NUL + 'bbb' + NUL + '' + NUL + 'c2'].join('\n'); + const rows = parseGraphLog(out); + expect(rows).toHaveLength(3); + expect(rows[1].graph).toBe('|\\'); + expect(rows[1].hash).toBeUndefined(); + expect(rows[2].hash).toBe('bbb'); + }); + + it('graph 去除尾部空白', () => { + const out = '* ' + NUL + 'aaa' + NUL + '' + NUL + 's'; + expect(parseGraphLog(out)[0].graph).toBe('*'); + }); + + it('decorate 为空时 trim 为 undefined', () => { + const out = '*' + NUL + 'aaa' + NUL + ' ' + NUL + 's'; + expect(parseGraphLog(out)[0].decorate).toBeUndefined(); + }); + + it('跳过空行', () => { + const out = '\n*' + NUL + 'aaa' + NUL + '' + NUL + 's\n\n'; + expect(parseGraphLog(out)).toHaveLength(1); + }); + + it('subject 含特殊字符(| / 等)原样保留', () => { + const out = '*' + NUL + 'aaa' + NUL + '' + NUL + 'fix: a | b / c'; + expect(parseGraphLog(out)[0].subject).toBe('fix: a | b / c'); + }); +}); + +describe('normalizeGraphWidth', () => { + it('右填充到最大 graph 长度,保证列对齐', () => { + const rows = parseGraphLog(['*' + NUL + 'a' + NUL + '' + NUL + 's', '| *' + NUL + 'b' + NUL + '' + NUL + 's'].join('\n')); + const padded = normalizeGraphWidth(rows); + expect(padded[0]).toBe('* '); // 长度 3(max=3) + expect(padded[1]).toBe('| *'); + expect(padded.every((g) => g.length === 3)).toBe(true); + }); +}); + +describe('classifyGraphChar', () => { + it('识别各类 graph 字符', () => { + expect(classifyGraphChar('*')).toBe('node'); + expect(classifyGraphChar('|')).toBe('vert'); + expect(classifyGraphChar('/')).toBe('slash'); + expect(classifyGraphChar('\\')).toBe('backslash'); + expect(classifyGraphChar('_')).toBe('underscore'); + expect(classifyGraphChar(' ')).toBe('blank'); + expect(classifyGraphChar('x')).toBe('blank'); + }); +}); diff --git a/tests/unit/merge-diff3.test.ts b/tests/unit/merge-diff3.test.ts new file mode 100644 index 0000000..e13f53b --- /dev/null +++ b/tests/unit/merge-diff3.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; +import { diff3, resolveHunk, conflictCount, type MergeHunk } from '../../src/engine/merge/diff3'; + +const stable = (content: string[]): Extract => ({ kind: 'stable', content }); +const conflict = (base: string[], ours: string[], theirs: string[]): Extract => ({ + kind: 'conflict', + base, + ours, + theirs, +}); + +describe('diff3', () => { + it('三方完全一致 → 单 stable', () => { + const b = ['a', 'b', 'c']; + expect(diff3(b, ['a', 'b', 'c'], ['a', 'b', 'c'])).toEqual([stable(['a', 'b', 'c'])]); + }); + + it('仅 ours 改动 → 取 ours(无冲突)', () => { + const b = ['a', 'b', 'c']; + expect(diff3(b, ['a', 'B', 'c'], ['a', 'b', 'c'])).toEqual([stable(['a', 'B', 'c'])]); + }); + + it('仅 theirs 改动 → 取 theirs(无冲突)', () => { + const b = ['a', 'b', 'c']; + expect(diff3(b, ['a', 'b', 'c'], ['a', 'T', 'c'])).toEqual([stable(['a', 'T', 'c'])]); + }); + + it('双方相同改动 → 取任一(无冲突)', () => { + const b = ['a', 'b', 'c']; + expect(diff3(b, ['a', 'X', 'c'], ['a', 'X', 'c'])).toEqual([stable(['a', 'X', 'c'])]); + }); + + it('双方不同改动同一行 → 冲突', () => { + const b = ['a', 'b', 'c']; + const h = diff3(b, ['a', 'O', 'c'], ['a', 'T', 'c']); + expect(h).toEqual([stable(['a']), conflict(['b'], ['O'], ['T']), stable(['c'])]); + }); + + it('ours 新增行 + theirs 改动别处 → 无冲突合并', () => { + const b = ['a', 'b']; + const h = diff3(b, ['a', 'X', 'b'], ['a', 'b', 'Y']); + // ours 在 a/b 间插 X(theirs 未动该处→取 ours);theirs 在末尾加 Y(ours 未动→取 theirs) + expect(h).toEqual([stable(['a', 'X', 'b', 'Y'])]); + }); + + it('双方删除同一段 → 一致(取空)', () => { + const b = ['a', 'b', 'c']; + const h = diff3(b, ['a', 'c'], ['a', 'c']); + expect(h).toEqual([stable(['a', 'c'])]); + }); + + it('多处冲突与一致段交替', () => { + const b = ['a', 'b', 'c', 'd']; + const h = diff3(b, ['a', 'O1', 'c', 'O2'], ['a', 'T1', 'c', 'T2']); + expect(h).toEqual([stable(['a']), conflict(['b'], ['O1'], ['T1']), stable(['c']), conflict(['d'], ['O2'], ['T2'])]); + }); +}); + +describe('resolveHunk', () => { + const c = conflict(['b'], ['O'], ['T']); + it('stable 返回 content', () => { + expect(resolveHunk(stable(['x']), 'ours')).toEqual(['x']); + }); + it('ours', () => { + expect(resolveHunk(c, 'ours')).toEqual(['O']); + }); + it('theirs', () => { + expect(resolveHunk(c, 'theirs')).toEqual(['T']); + }); + it('both(ours + theirs)', () => { + expect(resolveHunk(c, 'both')).toEqual(['O', 'T']); + }); + it('base', () => { + expect(resolveHunk(c, 'base')).toEqual(['b']); + }); + it('manual', () => { + expect(resolveHunk(c, 'manual', ['custom'])).toEqual(['custom']); + }); +}); + +describe('conflictCount', () => { + it('统计冲突段数', () => { + const h = diff3(['a', 'b', 'c', 'd'], ['a', 'O1', 'c', 'O2'], ['a', 'T1', 'c', 'T2']); + expect(conflictCount(h)).toBe(2); + }); + it('无冲突为 0', () => { + expect(conflictCount(diff3(['a'], ['a'], ['a']))).toBe(0); + }); +}); diff --git a/tests/unit/rebase-todo.test.ts b/tests/unit/rebase-todo.test.ts new file mode 100644 index 0000000..2990d7d --- /dev/null +++ b/tests/unit/rebase-todo.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { + serializeTodo, + parseTodo, + reorderTodo, + isValidAction, + REBASE_ACTIONS, + type RebaseTodoItem, +} from '../../src/engine/rebase/todo'; + +const ITEMS: RebaseTodoItem[] = [ + { action: 'pick', hash: 'aaa1111', subject: 'first' }, + { action: 'reword', hash: 'bbb2222', subject: 'second' }, + { action: 'drop', hash: 'ccc3333', subject: 'third' }, +]; + +describe('rebase todo', () => { + it('REBASE_ACTIONS 含全部 6 种合法动作', () => { + expect(REBASE_ACTIONS).toEqual(['pick', 'reword', 'edit', 'squash', 'fixup', 'drop']); + }); + + it('isValidAction 判定合法/非法', () => { + expect(isValidAction('pick')).toBe(true); + expect(isValidAction('squash')).toBe(true); + expect(isValidAction('merge')).toBe(false); + expect(isValidAction('')).toBe(false); + }); + + it('serializeTodo 产出 "action hash subject" 行(末尾换行)', () => { + expect(serializeTodo(ITEMS)).toBe('pick aaa1111 first\nreword bbb2222 second\ndrop ccc3333 third\n'); + }); + + it('serializeTodo 空列表产出单个换行', () => { + expect(serializeTodo([])).toBe('\n'); + }); + + it('parseTodo 跳过注释行/空行/非法 action', () => { + const content = [ + '# Rebase comment', + '', + 'pick aaa1111 first', + 'invalid zzz zzz', + 'reword bbb2222 second', + ].join('\n'); + const parsed = parseTodo(content); + expect(parsed).toHaveLength(2); + expect(parsed[0]).toEqual({ action: 'pick', hash: 'aaa1111', subject: 'first' }); + expect(parsed[1].action).toBe('reword'); + }); + + it('parseTodo 与 serializeTodo 可往返(合法项)', () => { + const text = serializeTodo(ITEMS); + expect(parseTodo(text)).toEqual(ITEMS); + }); + + it('reorderTodo 把 from 移到 to', () => { + const moved = reorderTodo(ITEMS, 2, 0); + expect(moved.map((i) => i.hash)).toEqual(['ccc3333', 'aaa1111', 'bbb2222']); + }); + + it('reorderTodo 越界/相同索引返回副本', () => { + expect(reorderTodo(ITEMS, 0, 0)).toEqual(ITEMS); + expect(reorderTodo(ITEMS, -1, 0)).toEqual(ITEMS); + expect(reorderTodo(ITEMS, 0, 99)).toEqual(ITEMS); + }); +}); diff --git a/tests/unit/ref-cleanup.test.ts b/tests/unit/ref-cleanup.test.ts new file mode 100644 index 0000000..3bde273 --- /dev/null +++ b/tests/unit/ref-cleanup.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { filterMergeable, isProtectedBranch, PROTECTED_BRANCHES } from '../../src/engine/ref/cleanup'; + +describe('cleanup', () => { + it('PROTECTED_BRANCHES 含 main/master', () => { + expect(PROTECTED_BRANCHES).toContain('main'); + expect(PROTECTED_BRANCHES).toContain('master'); + }); + + it('isProtectedBranch 判定默认集 + 额外排除', () => { + expect(isProtectedBranch('main')).toBe(true); + expect(isProtectedBranch('master')).toBe(true); + expect(isProtectedBranch('feature')).toBe(false); + expect(isProtectedBranch('feature', ['feature'])).toBe(true); + }); + + it('filterMergeable 解析 --merged 输出,去除 * 前缀与空行', () => { + const out = ' main\n* feature\n old-branch\n\n'; + // base=main → 排除 main(受保护+base),保留 feature / old-branch + expect(filterMergeable(out, 'main')).toEqual(['feature', 'old-branch']); + }); + + it('排除 base 与额外项(如当前 HEAD)', () => { + const out = ' main\n current\n stale\n'; + expect(filterMergeable(out, 'main', ['current'])).toEqual(['stale']); + }); + + it('去重重复行', () => { + const out = ' dup\n dup\n ok\n'; + expect(filterMergeable(out, 'main')).toEqual(['dup', 'ok']); + }); +}); diff --git a/tests/unit/ref-favorites.test.ts b/tests/unit/ref-favorites.test.ts new file mode 100644 index 0000000..bfe5d08 --- /dev/null +++ b/tests/unit/ref-favorites.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { toggleFavorite, isFavorite } from '../../src/engine/ref/favorites'; + +describe('favorites', () => { + it('toggleFavorite 添加未收藏的分支', () => { + expect(toggleFavorite([], 'main')).toEqual(['main']); + }); + + it('toggleFavorite 移除已收藏的分支', () => { + expect(toggleFavorite(['main', 'dev'], 'main')).toEqual(['dev']); + }); + + it('double-toggle 回到原状(幂等)', () => { + const a = toggleFavorite([], 'x'); + const b = toggleFavorite(a, 'x'); + expect(b).toEqual([]); + }); + + it('保持插入顺序,Set 去重', () => { + let names: string[] = []; + names = toggleFavorite(names, 'a'); + names = toggleFavorite(names, 'b'); + names = toggleFavorite(names, 'c'); + expect(names).toEqual(['a', 'b', 'c']); // 插入顺序 + names = toggleFavorite(names, 'a'); // 移除 a + expect(names).toEqual(['b', 'c']); + names = toggleFavorite(names, 'a'); // 重新加 a,置于末尾 + expect(names).toEqual(['b', 'c', 'a']); + }); + + it('空名不操作', () => { + expect(toggleFavorite(['main'], '')).toEqual(['main']); + }); + + it('isFavorite 正确判定', () => { + const names = ['main', 'dev']; + expect(isFavorite(names, 'main')).toBe(true); + expect(isFavorite(names, 'feature')).toBe(false); + }); +});