diff --git a/.agents/knowledge-map.md b/.agents/knowledge-map.md index 6ada661..00aff2a 100644 --- a/.agents/knowledge-map.md +++ b/.agents/knowledge-map.md @@ -16,6 +16,7 @@ ## 项目文档(docs/) - [文档中心](../docs/README.md) — 文档与调研资产总索引。 +- [实施状态总览](../docs/milestones/implementation-status.md) — M0-M5 交付记录 + API 限制 + M5 AI 设计 + 验证/发布(**实施看板**)。 - [工程实施方案](../docs/architecture/engineering-plan.md) — 路径 B 架构 + M0-M5 里程碑(**开发蓝图**)。 - [IDEA 功能复刻矩阵](../docs/requirements/idea-feature-matrix.md) — 56 功能点 / 8 组(**验收基线**)。 - [调研报告](../docs/research/README.md) — SCM 集成 / 工程蓝图 / 发布 CI / AI 接缝四路循证报告。 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..04f4c02 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d016e6..c86c033 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,10 +93,14 @@ jobs: with: name: vsix - name: 发布到 VS Code Marketplace - run: pnpm dlx @vscode/vsce publish --packagePath *.vsix + run: | + PRE_FLAG=$([[ "${GITHUB_REF_NAME}" == *rc* ]] && echo "--pre-release" || echo "") + pnpm dlx @vscode/vsce publish --packagePath *.vsix $PRE_FLAG env: VSCE_PAT: ${{ secrets.VSCE_PAT }} - name: 发布到 OpenVSX(覆盖 Cursor / Windsurf 等 AI IDE) - run: pnpm dlx ovsx publish *.vsix + run: | + PRE_FLAG=$([[ "${GITHUB_REF_NAME}" == *rc* ]] && echo "--pre-release" || echo "") + pnpm dlx ovsx publish *.vsix $PRE_FLAG env: OVSX_PAT: ${{ secrets.OVSX_PAT }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ba6a45..12b4571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ ## [Unreleased] +## [0.0.1-rc.1] - 2026-06-27 — 首发候选(Pre-release) + +首个公开预发布候选,整合 M0-M4 里程碑交付 + 两轮独立 code review 修复。 +版本号遵循 VS Code Marketplace 约定(仅 `major.minor.patch` = `0.0.1`),预发布语义由 `--pre-release` 标记 + git tag `v0.0.1-rc.1` 体现。 + +**核心能力**:多 changelist Changes 视图、Commit 提交窗口(Conventional Commits 校验 + Amend/sign-off/skipHooks + Checkin hook 链)、Log/Branches/Blame/Show History、Stash(create/apply/pop/drop)、Discard 改动、Pull/Push/Fetch。架构路径 B(消费 vscode.git 稳定 API),5 个 AI 接缝已预埋(M5 实现)。 + +**已知限制**(vscode.git 稳定 API 边界):cherry-pick / revert / reset / 分支重命名 / 行级 hunk 暂存 / 多 stash 列表枚举 / Author 覆盖暂不可用,详见 [实施状态 §3](./docs/milestones/implementation-status.md#3-api-限制汇总vscodegit-稳定-api-边界)。 + ### Added — M4 Stash/Shelf(0.5.0) - **Stash 视图**:`StashTreeProvider` 经 `Repository.log({ refNames: ['stash'] })` 枚举 stash(API 不暴露 `git stash list`,以此近似)。 diff --git a/README.md b/README.md index c7ad440..f5cd02e 100644 --- a/README.md +++ b/README.md @@ -15,18 +15,46 @@ IntelliJ IDEA 的统一 Git 工具窗口(顶部 `Commit / Shelf / Stash` 标 - **自绘** 独立视图容器承载 IDEA 风格 UI,与原生 Source Control 视图零冲突、平行共存。 - **AI**:现仅预留接口接缝(`ILlmProvider` / `IPreCommitInspector` 等,对齐 IDEA `CheckinHandler` 生命周期),实现延后至 M5。 -详见[工程实施方案](./.claude/plans)(决策循证基线)与[知识索引](./.agents/knowledge-map.md)。 +## 功能(v0.0.1-rc.1) + +- **多 changelist Changes 视图**:active 列表、新建/删除/重命名/移动,`workspaceState` 持久化(重启恢复);文件状态色复用 `gitDecoration.*` 主题色。 +- **Commit 提交窗口**:多行编辑器 + Conventional Commits 实时校验 + Amend / Signed-off-by / 跳过 Git hooks + Commit / Commit and Push;勾选集为提交权威范围;最近消息复用。 +- **Log 提交历史**:按作者/路径过滤、清除过滤、复制 commit hash、显示文件历史。 +- **Branches**:本地/远程分组、活动分支高亮;新建/检出/删除/合并/变基(rebase)。 +- **Stash**:create / apply / pop / drop(作用于 stash@{0} 最新)。 +- **其他**:Discard 改动、Pull / Push / Fetch、Blame、Diff(HEAD ↔ Working)。 + +## 安装 + +- **VS Code Marketplace**:搜索 `Hyper Git`(发布后)。 +- **OpenVSX**(Cursor / Windsurf / Gitpod / VSCodium):同 `Hyper Git`。 +- **手动**:从 [Releases](https://github.com/ThreeFish-AI/hyper-git/releases) 下载 `.vsix` → 命令面板 `Extensions: Install from VSIX`。 +- **要求**:VS Code ≥ 1.85.0 + 启用内置 Git 扩展(`vscode.git`,默认随附)。 + +## 已知限制 + +> 以下受 vscode.git 稳定 API 边界限制,暂不可用(详见 [实施状态 §3](./docs/milestones/implementation-status.md#3-api-限制汇总vscodegit-稳定-api-边界)): + +- 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 增强。 + +详见[工程实施方案](./docs/architecture/engineering-plan.md)、[实施状态总览](./docs/milestones/implementation-status.md)与[知识索引](./.agents/knowledge-map.md)。 ## 路线图 | 里程碑 | 主题 | 状态 | |---|---|---| -| **M0** | 脚手架 + CI | ✅ 进行中 | -| M1 | Git Adapter + Changes TreeView(多 changelist) | ⏳ | -| M2 | Commit 提交窗口(模板 / Amend / CC 校验 / hook 链) | ⏳ | -| M3 | Log 提交图 + Branches + Diff/Blame | ⏳ | -| M4 | Shelf + Partial/行级提交 + Stash UI | ⏳ | -| M5 | AI Agent(实现接缝) | ⏳ | +| M0 | 脚手架 + CI | ✅ | +| M1 | Git Adapter + Changes TreeView(多 changelist) | ✅ | +| M2 | Commit 提交窗口(模板 / Amend / CC 校验 / hook 链) | ✅ | +| M3 | Log + Branches + Diff/Blame | ✅ | +| M4 | Stash / Shelf MVP | ✅ | +| M5 | AI Agent(实现接缝) | ⏳ 留存设计,暂不实施 | + +> ⚠️ vscode.git 稳定 API 不含 cherry-pick / revert / reset / 分支重命名 / 行级 hunk 暂存;这些 IDEA 功能暂不可用(详见[实施状态 §3](./docs/milestones/implementation-status.md#3-api-限制汇总vscodegit-稳定-api-边界))。 ## 开发 diff --git a/docs/README.md b/docs/README.md index 30ccde1..4f7e7b0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,6 +3,7 @@ > 项目文档与调研资产索引。所有决策均循证(附 GitHub 源码路径 / 官方文档 URL)。 ## 工程方案与需求基线(高频引用) +- [实施状态总览(M0-M5)](./milestones/implementation-status.md) — 里程碑交付记录、P0/P1 达成矩阵、API 限制、M5 AI 设计、验证与发布状态(**实施看板**)。 - [工程实施方案](./architecture/engineering-plan.md) — 全链路调研结论 + 路径 B 架构 + M0-M5 里程碑路线图 + 风险与验证(**开发蓝图**)。 - [IDEA 功能复刻矩阵](./requirements/idea-feature-matrix.md) — 56 个原子功能点 / 8 组 + CheckinHandler 生命周期(**验收基线**)。 diff --git a/docs/milestones/implementation-status.md b/docs/milestones/implementation-status.md new file mode 100644 index 0000000..2c2a841 --- /dev/null +++ b/docs/milestones/implementation-status.md @@ -0,0 +1,103 @@ +# 实施状态总览(M0 → M5) + +> Hyper Git VS Code 扩展的里程碑实施记录、API 限制发现、功能达成矩阵与 M5 AI 设计(留存)。 +> 本文档随里程碑推进即时更新;调研与原始方案见 [工程实施方案](../architecture/engineering-plan.md) 与 [调研报告](../research/README.md)。 +> 最后更新:2026-06-27(M0-M4 落地完成,准备 v0.0.1-rc.1)。 + +--- + +## 0. 概览 + +- **架构**:路径 B —— 消费内置 `vscode.git` 稳定 `Repository` API + 自建 changelist registry + 独立视图容器自绘 IDEA 风格 UI,不接管原生 Source Control 视图。 +- **分层**:`engine/`(纯逻辑,零 vscode 依赖)/ `adapter/`(唯一接触 vscode API)/ `agent/`(AI 接缝)/ `shared/`(契约)/ `infra/`。 +- **质量基线**:TypeScript strict + ESLint9 + Vitest(engine 单测)+ @vscode/test-electron(adapter 集成)+ esbuild + 三平台 CI(Linux xvfb)。 +- **验证**:单测 45/45、集成 3/3(含真实 git 提交闭环 + amend)、`vsce package` 产 vsix。 + +## 1. 里程碑交付记录 + +| 里程碑 | 版本 | PR | 交付 | 验收 | +|---|---|---|---|---| +| M0 脚手架+CI | 0.1.0 | [#1](https://github.com/ThreeFish-AI/hyper-git/pull/1) | pnpm+esbuild+TS strict+ESLint9+Vitest+test-electron;正交分层骨架;CI 三平台矩阵+双市场发布;engine 纯逻辑 + AI 接缝预留 | check-types/lint/package/test 全绿 | +| 调研资产持久化 | — | [#2](https://github.com/ThreeFish-AI/hyper-git/pull/2) | docs/(IDEA 56 功能矩阵、工程方案、四路调研报告) | — | +| M1 Git Adapter+多 changelist | 0.2.0 | [#3](https://github.com/ThreeFish-AI/hyper-git/pull/3) | GitRepositoryService、ChangelistRegistry(CRUD+持久化)、Changes TreeView(状态色+diff) | 集成:真实仓库变更渲染 | +| 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 近似) | 命令注册集成 | + +## 2. P0/P1 功能达成矩阵(对照 [IDEA 功能矩阵](../requirements/idea-feature-matrix.md)) + +> 状态:✅ 已实现 · ⚠️ 部分/未验证 · 🔶 API 受限(无稳定 API) · ⏳ 未做(属后续里程碑) + +| 功能域(矩阵#) | 优先级 | 状态 | 说明 | +|---|---|---|---| +| Commit 窗口(#1-3,5,7-9,22) | P0 | ✅ | 窗口/模板/历史/Amend/Author 占位/signoff/skipHooks/Commit&Push/选择性勾选 | +| Conventional Commits(#4) | P0/P1 | ✅ | linter + webview 实时 + pipeline hook 阻断 | +| Author 覆盖(#6) | P0 | 🔶 | vscode.git 稳定 `commit()` 不支持 `--author`(API 限制,文档化) | +| 多 changelist(#15-20) | P0 | ✅ | active/新建/删除/重命名/移动 + workspaceState 持久化 | +| Commit 检查流水线(#10-12) | P1 | ⚠️ | CommitPipeline 责任链 + CC hook;CRLF/大文件预检(#12)未实现(可扩展 hook) | +| Diff(#35,37,38) | P0/P1 | ✅ | 本地↔HEAD diff、Blame、Show History(文件历史) | +| Rollback/Discard(#51) | P0 | ⚠️ | **当前无 discard/rollback 命令**(见 §4 缺口) | +| Branches(#45-48) | P1 | ✅/🔶 | create/checkout/delete/merge/rebase ✅;rename 🔶(API 无) | +| Stash(#29,30) | P1 | ✅ | create/apply/pop/drop(稳定 API);list 用 log refNames 近似 | +| Log 提交图(#39,40) | P2 | ⚠️ | Log TreeView(列表+过滤);**完整 SVG 拓扑图未做**(M3.x) | +| cherry-pick/revert from log(#41,42) | P2 | 🔶 | 稳定 API 无 cherry-pick/revert | +| Undo Commit/Reset(#43,52) | P2 | 🔶 | 稳定 API 无 reset | +| Partial/行级提交(#23-25) | P2 | 🔶 | `add` 仅整文件,无 hunk 暂存(API 限制) | +| Shelf 忠实(#27,28) | P2 | ⚠️ | MVP 用 stash 近似;忠实 patch Shelf 🔶 | + +## 3. API 限制汇总(vscode.git 稳定 API 边界) + +> 经逐行复核 `extensions/git/src/api/git.d.ts` 与 `api1.ts` 确认。以下 IDEA 功能**无稳定 API 对应**,文档化延后;未来可经 git CLI 兜底或 proposed API 评估。 + +- **cherry-pick / revert(commit-level)/ reset**:Repository 无对应方法。 +- **分支重命名**:无 `renameBranch`(仅 createBranch/deleteBranch/setBranchUpstream)。 +- **行级 / hunk 级暂存**:`add(paths)` 仅整文件;无 `git add -p` 暴露。 +- **`git stash list` 枚举**:无公开方法;M4 用 `log({ refNames: ['stash'] })` 近似。 +- **Author 覆盖**:`commit(message, opts)` 的 `CommitOptions` 无 `author` 字段。 +- **忠实 patch Shelf**:git 无原生 shelve;可用 `diffBetweenPatch` + `apply({threeWay})` 自建(复杂,延后)。 +- **提交图 History Provider / 多文件 diff 编辑器**:`scmHistoryProvider`/`scmMultiDiffSource` 仍为 proposed API,上架扩展禁用。 + +## 4. 已知缺口(待补) + +- **Rollback/Discard 命令**(P0 #51):M1/M2 范围内遗漏。vscode.git 有 `revert(paths)`(unstage)/ `clean(paths)`(discard untracked)/ `restore(paths, {staged})`,可实现「Discard Changes」。**建议补入 rc.1 后的小版本**。 +- **ChangelistRegistry CRUD 单测**:因 vscode 耦合 + 打包内联,未单测;逻辑简单 + grouper 已测,风险低。 +- **Log/Branches/Stash 命令的真实集成测试**:当前仅测命令注册;分支 checkout/merge 等的真实 git 验证可补。 +- **完整 Log SVG 拓扑图**:M3.x 增强。 +- **PNG 图标(128×128)**:发布 Marketplace 推荐;rc.1 处理。 + +## 5. M5 AI Agent 设计(留存,暂不实施) + +> 详见 [Track5 AI Agent 架构预留](../research/05-ai-agent-seams.md)。M2 已埋全部接缝 + Null 实现;M5 替换为真实实现。 + +**5 个 AI 接缝**(agent/,依赖 engine 不依赖 adapter): +- `ILlmProvider`:模型来源抽象(vscodeLM / byok-Ollama / openaiCompatible)。**最关键**——未来切换模型来源的命脉。 +- `ICommitMessageProvider`:AI 提交信息生成(staged diff + 团队规范 → 流式 CC 合规 message)。 +- `IPreCommitInspector`:提交前 AI 代码审查(对齐 IDEA `CheckinHandler.beforeCheckin`,可阻断)。 +- `IChangelistGrouper`:变更语义分组(回写 changelist)。 +- `IConflictResolver`:冲突解决(用户逐块确认)。 + +**Commit 流水线 hook 注入点**(M2 已建责任链,默认 Null): +`staged diff → [Hook A 提交信息生成] → message → [Hook B beforeCheckin 检查链] → [Hook C 分组] → commit → [Hook D checkinSuccessful] → push → [Hook E/F 冲突]` + +**落地机制(2026 现状)**: +- VS Code Language Model API(`vscode.lm`):底层模型访问;不支持 system message、需用户 consent、不可集成测试。 +- Chat Participant(`@hyper-git`)+ Language Model Tools(`languageModelTools` 暴露 git 能力给任意 Agent)。 +- BYOK Provider API(Ollama 本地 / OpenRouter / OpenAI 兼容)。 +- prompt-tsx(alpha)。 + +**差异化**(vs 内置 Copilot commit message):注入完整 commit 流程上下文(changelist/团队规范)+ 回写工作流 + Chat Tools 暴露。 + +**M5 启动前置**:`engines.vscode` 上调以支持 LM/Chat API;opt-in 配置开关;模型来源可切换。 + +## 6. 验证体系 + +- **单元测试(Vitest,< 1s)**:engine/ 纯逻辑(scm-mapping、changelist-grouper、commit-pipeline、conventional-linter、git-status-map、conventional-check)+ CommitService.executeCommit(mock Repository,7 分支)。共 45 项。 +- **集成测试(@vscode/test-electron)**:扩展激活 + 全部命令注册(M1-M4);真实 git 提交闭环(fixture 仓库 add+commit+git log 校验);amend 改写 HEAD。共 3 项。 +- **CI(GitHub Actions)**:lint→build→test 矩阵(ubuntu/mac/win + Linux xvfb)→package vsix→artifact;`tag v*` → 双市场发布(Marketplace + OpenVSX,需 secrets)。 + +## 7. 发布状态 + +- **当前版本**:0.5.0(preview,内部里程碑)。 +- **首发候选**:v0.0.1-rc.1(package 版本 `0.0.1` + `--pre-release` + git tag `v0.0.1-rc.1`)。Marketplace 仅支持 `major.minor.patch`,预发布语义由 `--pre-release` 标记 + tag 体现。 +- **发布前置**:publisher 账号(`threefish-ai`)、VSCE_PAT / OVSX_PAT secrets、PNG 图标。 diff --git a/eslint.config.mjs b/eslint.config.mjs index 1c9c6a0..12fc45d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,7 @@ export default tseslint.config( 'node_modules/**', '.vscode-test/**', '*.vsix', + '.temp/**', 'esbuild.js', 'tests/run-integration.js', 'tests/suite/**', diff --git a/media/icon.png b/media/icon.png new file mode 100644 index 0000000..73a23fa Binary files /dev/null and b/media/icon.png differ diff --git a/package.json b/package.json index ae67dc0..82d9681 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { "name": "hyper-git", "displayName": "Hyper Git", + "icon": "media/icon.png", "description": "在 VS Code 上完整复刻 IntelliJ IDEA 的 Git 工具窗口与 Commit 提交窗口,并为未来 AI Agent 自主代理预留架构接缝。", - "version": "0.5.0", + "version": "0.0.1", "publisher": "threefish-ai", "license": "MIT", "preview": true, @@ -118,7 +119,11 @@ { "command": "hyperGit.stashCreate", "title": "Stash 变更", "category": "Hyper Git", "icon": "$(archive)" }, { "command": "hyperGit.stashApply", "title": "应用 Stash", "category": "Hyper Git" }, { "command": "hyperGit.stashPop", "title": "Pop Stash", "category": "Hyper Git" }, - { "command": "hyperGit.stashDrop", "title": "删除 Stash", "category": "Hyper Git" } + { "command": "hyperGit.stashDrop", "title": "删除 Stash", "category": "Hyper Git" }, + { "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" } ], "menus": { "view/title": [ @@ -130,7 +135,12 @@ { "command": "hyperGit.logClearFilter", "when": "view == hyperGit.log" }, { "command": "hyperGit.refreshBranches", "when": "view == hyperGit.branches", "group": "navigation" }, { "command": "hyperGit.branchCreate", "when": "view == hyperGit.branches", "group": "navigation" }, - { "command": "hyperGit.stashCreate", "when": "view == hyperGit.stash", "group": "navigation" } + { "command": "hyperGit.pull", "when": "view == hyperGit.branches", "group": "navigation" }, + { "command": "hyperGit.push", "when": "view == hyperGit.branches", "group": "navigation" }, + { "command": "hyperGit.stashCreate", "when": "view == hyperGit.stash", "group": "navigation" }, + { "command": "hyperGit.stashApply", "when": "view == hyperGit.stash" }, + { "command": "hyperGit.stashPop", "when": "view == hyperGit.stash" }, + { "command": "hyperGit.stashDrop", "when": "view == hyperGit.stash" } ], "view/item/context": [ { "command": "hyperGit.setActiveChangelist", "when": "view == hyperGit.changes && viewItem == hyperGit.changelist", "group": "1_changelist@1" }, @@ -139,14 +149,12 @@ { "command": "hyperGit.moveChangelist", "when": "view == hyperGit.changes && viewItem == hyperGit.fileChange", "group": "inline" }, { "command": "hyperGit.moveChangelist", "when": "view == hyperGit.changes && viewItem == hyperGit.fileChange", "group": "1_file@1" }, { "command": "hyperGit.showHistory", "when": "view == hyperGit.changes && viewItem == hyperGit.fileChange", "group": "1_file@2" }, + { "command": "hyperGit.discardChanges", "when": "view == hyperGit.changes && viewItem == hyperGit.fileChange", "group": "1_file@3" }, { "command": "hyperGit.copyCommitHash", "when": "view == hyperGit.log && viewItem == hyperGit.commit", "group": "1_commit@1" }, { "command": "hyperGit.branchCheckout", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/", "group": "1_branch@1" }, { "command": "hyperGit.branchDelete", "when": "view == hyperGit.branches && viewItem == hyperGit.branch", "group": "1_branch@2" }, { "command": "hyperGit.mergeBranch", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/", "group": "1_branch@3" }, - { "command": "hyperGit.rebaseBranch", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/", "group": "1_branch@4" }, - { "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.rebaseBranch", "when": "view == hyperGit.branches && viewItem =~ /hyperGit.branch|hyperGit.remoteBranch/", "group": "1_branch@4" } ], "commandPalette": [ { "command": "hyperGit.openDiff", "when": "false" }, @@ -162,7 +170,8 @@ { "command": "hyperGit.rebaseBranch", "when": "false" }, { "command": "hyperGit.stashApply", "when": "false" }, { "command": "hyperGit.stashPop", "when": "false" }, - { "command": "hyperGit.stashDrop", "when": "false" } + { "command": "hyperGit.stashDrop", "when": "false" }, + { "command": "hyperGit.discardChanges", "when": "false" } ] }, "configuration": { diff --git a/src/adapter/commands.ts b/src/adapter/commands.ts index 5ce3bc0..434a613 100644 --- a/src/adapter/commands.ts +++ b/src/adapter/commands.ts @@ -1,5 +1,6 @@ import * as path from 'path'; import * as vscode from 'vscode'; +import { FileStatus } from '../engine/model'; import type { ChangelistRegistry } from './changelist-registry'; import type { ChangeItem, GitRepositoryService } from './git-repository-service'; import type { ChangesNode, ChangesTreeProvider } from './tree/changes-tree'; @@ -95,5 +96,33 @@ export function registerChangesCommands( }), ); + subs.push( + vscode.commands.registerCommand('hyperGit.discardChanges', async (change: ChangeItem) => { + const repo = service.repo; + if (!repo || !change) { + return; + } + const choice = await vscode.window.showWarningMessage( + `丢弃「${change.relativePath}」的改动?此操作不可撤销。`, + { modal: true }, + '丢弃', + ); + if (choice !== '丢弃') { + return; + } + try { + // 未跟踪文件用 clean(删除);已跟踪的改动用 restore(丢弃工作区改动) + if (change.status === FileStatus.Untracked) { + await repo.clean([change.uri.fsPath]); + } else { + await repo.restore([change.uri.fsPath]); + } + tree.refresh(); + } catch (e) { + void vscode.window.showErrorMessage(`丢弃失败:${e instanceof Error ? e.message : String(e)}`); + } + }), + ); + return subs; } diff --git a/src/adapter/history-commands.ts b/src/adapter/history-commands.ts index a9545e7..aed74cb 100644 --- a/src/adapter/history-commands.ts +++ b/src/adapter/history-commands.ts @@ -151,6 +151,10 @@ export function registerHistoryCommands( 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('该文件不在当前仓库内,无法 Blame'); + return; + } try { const blame = await repo.blame(rel); const doc = await vscode.workspace.openTextDocument({ content: blame, language: 'plaintext' }); @@ -161,5 +165,50 @@ export function registerHistoryCommands( }), ); + subs.push( + vscode.commands.registerCommand('hyperGit.pull', async () => { + const repo = service.repo; + if (!repo) { + return; + } + try { + await repo.pull(); + branchesTree.refresh(); + } catch (e) { + void vscode.window.showErrorMessage(`Pull 失败:${errMsg(e)}`); + } + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.push', async () => { + const repo = service.repo; + if (!repo) { + return; + } + try { + await repo.push(); + branchesTree.refresh(); + } catch (e) { + void vscode.window.showErrorMessage(`Push 失败:${errMsg(e)}`); + } + }), + ); + + subs.push( + vscode.commands.registerCommand('hyperGit.fetch', async () => { + const repo = service.repo; + if (!repo) { + return; + } + try { + await repo.fetch(); + branchesTree.refresh(); + } catch (e) { + void vscode.window.showErrorMessage(`Fetch 失败:${errMsg(e)}`); + } + }), + ); + return subs; } diff --git a/src/adapter/stash-commands.ts b/src/adapter/stash-commands.ts index 8f2aa6b..42dba78 100644 --- a/src/adapter/stash-commands.ts +++ b/src/adapter/stash-commands.ts @@ -1,12 +1,13 @@ import * as vscode from 'vscode'; import type { GitRepositoryService } from './git-repository-service'; -import type { StashNode, StashTreeProvider } from './tree/stash-tree'; +import type { StashTreeProvider } from './tree/stash-tree'; /** * 注册 Stash 相关命令(M4)。 * * 经 vscode.git 稳定 API:createStash / applyStash / popStash / dropStash。 - * 「Shelve Changes」以 stash 近似(MVP,见工程方案 §4 P2);忠实 patch Shelf 受 API 限制延后。 + * 因 API 无 stash 列表枚举(见 StashTreeProvider),apply/pop/drop 作用于 stash@{0}(最新)。 + * 「Shelve Changes」以 stash 近似(MVP,见工程方案 §4 P2)。 */ export function registerStashCommands(service: GitRepositoryService, stashTree: StashTreeProvider): vscode.Disposable[] { const subs: vscode.Disposable[] = []; @@ -29,13 +30,13 @@ export function registerStashCommands(service: GitRepositoryService, stashTree: ); subs.push( - vscode.commands.registerCommand('hyperGit.stashApply', async (node?: StashNode) => { + vscode.commands.registerCommand('hyperGit.stashApply', async () => { const repo = service.repo; if (!repo) { return; } try { - await repo.applyStash(node?.kind === 'stash' ? node.index : 0); + await repo.applyStash(0); stashTree.refresh(); } catch (e) { void vscode.window.showErrorMessage(`Stash 应用失败:${errMsg(e)}`); @@ -44,13 +45,13 @@ export function registerStashCommands(service: GitRepositoryService, stashTree: ); subs.push( - vscode.commands.registerCommand('hyperGit.stashPop', async (node?: StashNode) => { + vscode.commands.registerCommand('hyperGit.stashPop', async () => { const repo = service.repo; if (!repo) { return; } try { - await repo.popStash(node?.kind === 'stash' ? node.index : 0); + await repo.popStash(0); stashTree.refresh(); } catch (e) { void vscode.window.showErrorMessage(`Stash pop 失败:${errMsg(e)}`); @@ -59,15 +60,15 @@ export function registerStashCommands(service: GitRepositoryService, stashTree: ); subs.push( - vscode.commands.registerCommand('hyperGit.stashDrop', async (node?: StashNode) => { + vscode.commands.registerCommand('hyperGit.stashDrop', async () => { const repo = service.repo; - if (!repo || node?.kind !== 'stash') { + if (!repo) { return; } - const choice = await vscode.window.showWarningMessage(`删除 stash@{${node.index}}?`, { modal: true }, '删除'); + const choice = await vscode.window.showWarningMessage('删除最新的 stash(stash@{0})?', { modal: true }, '删除'); if (choice === '删除') { try { - await repo.dropStash(node.index); + await repo.dropStash(0); stashTree.refresh(); } catch (e) { void vscode.window.showErrorMessage(`Stash 删除失败:${errMsg(e)}`); diff --git a/src/adapter/tree/branches-tree.ts b/src/adapter/tree/branches-tree.ts index 251d4b3..f60b182 100644 --- a/src/adapter/tree/branches-tree.ts +++ b/src/adapter/tree/branches-tree.ts @@ -17,7 +17,7 @@ export interface BranchRefNode { export type BranchNode = BranchGroupNode | BranchRefNode; /** Branches 视图 TreeDataProvider:消费 `Repository.state.refs`(本地 + 远程分支)。 */ -export class BranchesTreeProvider implements vscode.TreeDataProvider { +export class BranchesTreeProvider implements vscode.TreeDataProvider, vscode.Disposable { private readonly _onDidChange = new vscode.EventEmitter(); readonly onDidChangeTreeData = this._onDidChange.event; @@ -64,4 +64,8 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider item.iconPath = new vscode.ThemeIcon(element.remote ? 'cloud' : 'git-branch', active ? new vscode.ThemeColor('gitDecoration.modifiedResourceForeground') : undefined); return item; } + + dispose(): void { + this._onDidChange.dispose(); + } } diff --git a/src/adapter/tree/log-tree.ts b/src/adapter/tree/log-tree.ts index 023d39a..d7e6829 100644 --- a/src/adapter/tree/log-tree.ts +++ b/src/adapter/tree/log-tree.ts @@ -18,7 +18,7 @@ export interface LogFilter { * Log 视图 TreeDataProvider:消费 `Repository.log()`,按 author/path 过滤。 * 完整提交图(SVG 拓扑连线)作为后续增强(M3.x);当前以提交列表 + 过滤 + copy hash 提供核心浏览能力。 */ -export class LogTreeProvider implements vscode.TreeDataProvider { +export class LogTreeProvider implements vscode.TreeDataProvider, vscode.Disposable { private readonly _onDidChange = new vscode.EventEmitter(); readonly onDidChangeTreeData = this._onDidChange.event; private filter: LogFilter = {}; @@ -71,6 +71,10 @@ export class LogTreeProvider implements vscode.TreeDataProvider { item.iconPath = new vscode.ThemeIcon('git-commit'); return item; } + + dispose(): void { + this._onDidChange.dispose(); + } } function formatDate(d: Date): string { diff --git a/src/adapter/tree/stash-tree.ts b/src/adapter/tree/stash-tree.ts index 4f2d47e..73631e2 100644 --- a/src/adapter/tree/stash-tree.ts +++ b/src/adapter/tree/stash-tree.ts @@ -1,56 +1,32 @@ import * as vscode from 'vscode'; -import type { Commit } from '../../types/git'; -import type { GitRepositoryService } from '../git-repository-service'; - -export interface StashEntryNode { - readonly kind: 'stash'; - readonly index: number; - readonly commit: Commit; -} - -export type StashNode = StashEntryNode; /** * Stash 视图 TreeDataProvider。 * - * vscode.git 稳定 API 不暴露 `git stash list`,故用 `Repository.log({ refNames: ['stash'] })` - * 枚举 stash 提交(stash@{0} 对应最新 = 列表首项,index 即 apply/pop/drop 的索引)。 - * 行级 partial commit / 忠实 patch Shelf 受 API 限制,文档化延后(见 CHANGELOG)。 + * ⚠️ vscode.git 稳定 API 不暴露 `git stash list`。曾尝试 `log({ refNames: ['stash'] })`, + * 但经实测其仅返回**最新一个 stash 的内部提交**(WIP commit / parent / index commit), + * 非 stash 栈列表(语义错误)——故弃用。 + * + * 现视图保持空(由 viewsWelcome 引导),stash 操作(create/apply/pop/drop)作用于 stash@{0}(最新)。 + * 多 stash 列表枚举为 API 限制,未来经 git CLI 兜底或 proposed API 评估(见 implementation-status §3)。 */ -export class StashTreeProvider implements vscode.TreeDataProvider { - private readonly _onDidChange = new vscode.EventEmitter(); +export class StashTreeProvider implements vscode.TreeDataProvider, vscode.Disposable { + private readonly _onDidChange = new vscode.EventEmitter(); readonly onDidChangeTreeData = this._onDidChange.event; - constructor(private readonly service: GitRepositoryService) {} - refresh(): void { this._onDidChange.fire(undefined); } - async getChildren(element?: StashNode): Promise { - if (element) { - return []; - } - const repo = this.service.repo; - if (!repo) { - return []; - } - try { - const commits = await repo.log({ refNames: ['stash'], maxEntries: 50 }); - return commits.map((c, i): StashEntryNode => ({ kind: 'stash', index: i, commit: c })); - } catch { - return []; - } + getTreeItem(): vscode.TreeItem { + return new vscode.TreeItem(''); + } + + getChildren(): never[] { + return []; } - getTreeItem(node: StashNode): vscode.TreeItem { - const subject = (node.commit.message.split('\n', 1)[0] ?? node.commit.message).slice(0, 60); - const item = new vscode.TreeItem(subject, vscode.TreeItemCollapsibleState.None); - item.id = `stash:${node.index}:${node.commit.hash}`; - item.description = `stash@{${node.index}} · ${node.commit.authorName ?? ''}`; - item.tooltip = `stash@{${node.index}}\n${node.commit.message}`; - item.contextValue = 'hyperGit.stash'; - item.iconPath = new vscode.ThemeIcon('archive'); - return item; + dispose(): void { + this._onDidChange.dispose(); } } diff --git a/src/extension.ts b/src/extension.ts index abaee9b..a878c6e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -57,7 +57,7 @@ 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 stashTree = new StashTreeProvider(service); + const stashTree = new StashTreeProvider(); const focusCommitView = (): void => { void vscode.commands.executeCommand('hyperGit.commit.focus'); }; @@ -66,6 +66,9 @@ export async function activate(context: vscode.ExtensionContext): Promise service, registry, commit, + logTree, + branchesTree, + stashTree, vscode.window.registerTreeDataProvider('hyperGit.changes', tree), vscode.window.registerWebviewViewProvider(CommitWebviewProvider.viewType, commitView), vscode.window.registerTreeDataProvider('hyperGit.log', logTree), @@ -78,12 +81,17 @@ export async function activate(context: vscode.ExtensionContext): Promise vscode.commands.registerCommand('hyperGit.commitAndPush', focusCommitView), ); + // git 状态变化频繁(add/checkout/diff 缓存失效均触发),防抖合并避免 log/stash 高频重拉。 + let refreshTimer: ReturnType | undefined; const refreshAll = (): void => { - tree.refresh(); - commitView.refresh(); - logTree.refresh(); - branchesTree.refresh(); - stashTree.refresh(); + clearTimeout(refreshTimer); + refreshTimer = setTimeout(() => { + tree.refresh(); + commitView.refresh(); + logTree.refresh(); + branchesTree.refresh(); + stashTree.refresh(); + }, 150); }; context.subscriptions.push( service.onDidChange(refreshAll), diff --git a/tests/suite/extension.test.js b/tests/suite/extension.test.js index e56ce06..3af7245 100644 --- a/tests/suite/extension.test.js +++ b/tests/suite/extension.test.js @@ -41,6 +41,10 @@ suite('扩展冒烟测试', function () { 'hyperGit.stashApply', 'hyperGit.stashPop', 'hyperGit.stashDrop', + 'hyperGit.discardChanges', + 'hyperGit.pull', + 'hyperGit.push', + 'hyperGit.fetch', ]) { assert.ok(commands.includes(cmd), `命令 ${cmd} 未注册`); } diff --git a/tests/unit/changelist-registry.test.ts b/tests/unit/changelist-registry.test.ts new file mode 100644 index 0000000..5b0d7e1 --- /dev/null +++ b/tests/unit/changelist-registry.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('vscode', () => ({ + EventEmitter: class { + get event() { + return () => ({ dispose: () => undefined }); + } + fire() {} + dispose() {} + }, +})); + +import type { Memento } from 'vscode'; +import { ChangelistRegistry } from '../../src/adapter/changelist-registry'; + +/** 内存 Memento(模拟 workspaceState 持久化往返)。 */ +class MemMemento { + private readonly map = new Map(); + get(key: string): T | undefined { + return this.map.get(key) as T | undefined; + } + update(key: string, value: unknown): Thenable { + this.map.set(key, value); + return Promise.resolve(); + } +} + +function makeRegistry(repoRoot = '/repo'): { reg: ChangelistRegistry; memento: MemMemento } { + const memento = new MemMemento(); + const reg = new ChangelistRegistry(memento as unknown as Memento, repoRoot); + return { reg, memento }; +} + +describe('ChangelistRegistry', () => { + it('初始仅含 default(active)', () => { + const { reg } = makeRegistry(); + expect(reg.listDefs().map((d) => d.id)).toEqual(['default']); + expect(reg.activeChangelistId).toBe('default'); + }); + + it('create 新增并持久化(重建恢复)', () => { + const { reg, memento } = makeRegistry(); + const id = reg.create('Feature X'); + expect(reg.listDefs().some((d) => d.id === id && d.name === 'Feature X')).toBe(true); + const reg2 = new ChangelistRegistry(memento as unknown as Memento, '/repo'); + expect(reg2.listDefs().some((d) => d.name === 'Feature X')).toBe(true); + }); + + it('setActive 切换活动列表', () => { + const { reg } = makeRegistry(); + const id = reg.create('fx'); + reg.setActive(id); + expect(reg.activeChangelistId).toBe(id); + }); + + it('rename:default 不可改名,其余可改', () => { + const { reg } = makeRegistry(); + reg.rename('default', 'NewName'); + expect(reg.getDef('default')?.name).toBe('Default'); + const id = reg.create('fx'); + reg.rename(id, 'renamed'); + expect(reg.getDef(id)?.name).toBe('renamed'); + }); + + it('remove:default 不可删;删除后文件重分配到 default,active 回退 default', () => { + const { reg } = makeRegistry(); + reg.remove('default'); + expect(reg.listDefs().length).toBe(1); + const id = reg.create('fx'); + reg.setActive(id); + reg.move('a.ts', id); + reg.remove(id); + expect(reg.listDefs().some((d) => d.id === id)).toBe(false); + expect(reg.activeChangelistId).toBe('default'); + }); + + it('move 分配后 getGroups 按归属分组', () => { + const { reg } = makeRegistry(); + const id = reg.create('fx'); + reg.move('a.ts', id); + const groups = reg.getGroups([{ relativePath: 'a.ts' }] as const, (i) => i.relativePath); + expect(groups.find((g) => g.id === id)?.items.length).toBe(1); + expect(groups.find((g) => g.id === 'default')?.items.length).toBe(0); + }); + + it('损坏 JSON 容错回退默认', () => { + const memento = new MemMemento(); + memento.update('hyperGit.changelists:/repo', '{ 损坏 JSON'); + const reg = new ChangelistRegistry(memento as unknown as Memento, '/repo'); + expect(reg.listDefs().map((d) => d.id)).toEqual(['default']); + expect(reg.activeChangelistId).toBe('default'); + }); +});